Hermes-agent
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Hermes plugins package
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Browser Use cloud browser plugin — bundled, auto-loaded.
|
||||
|
||||
Mirrors the ``plugins/web/<vendor>/`` layout: ``provider.py`` holds the
|
||||
provider class; ``__init__.py::register`` instantiates and registers it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plugins.browser.browser_use.provider import BrowserUseBrowserProvider
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the Browser Use provider with the plugin context."""
|
||||
ctx.register_browser_provider(BrowserUseBrowserProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: browser-browser-use
|
||||
version: 1.0.0
|
||||
description: "Browser Use (https://browser-use.com) cloud browser backend. Supports both direct BROWSER_USE_API_KEY and the managed Nous tool gateway. Also powers the 'Nous Subscription' UX flow that bills usage to a Nous subscription."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
provides_browser_providers:
|
||||
- browser-use
|
||||
@@ -0,0 +1,317 @@
|
||||
"""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
|
||||
|
||||
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 prefers_gateway
|
||||
|
||||
# Direct API key wins unless the user has explicitly opted into the
|
||||
# managed Nous gateway via ``tool_gateway.browser: gateway``.
|
||||
api_key = os.environ.get("BROWSER_USE_API_KEY")
|
||||
if api_key and not prefers_gateway("browser"):
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"base_url": _BASE_URL,
|
||||
"managed_mode": False,
|
||||
}
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
def _get_config(self) -> Dict[str, Any]:
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
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,
|
||||
"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) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "Browser Use",
|
||||
"badge": "paid",
|
||||
"tag": "Cloud browser with remote execution",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "BROWSER_USE_API_KEY",
|
||||
"prompt": "Browser Use API key",
|
||||
"url": "https://browser-use.com",
|
||||
},
|
||||
],
|
||||
"post_setup": "agent_browser",
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Browserbase cloud browser plugin — bundled, auto-loaded.
|
||||
|
||||
Mirrors the ``plugins/web/<vendor>/`` and ``plugins/image_gen/openai/``
|
||||
layout: ``provider.py`` holds the provider class; ``__init__.py::register``
|
||||
instantiates and registers it via the plugin context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plugins.browser.browserbase.provider import BrowserbaseBrowserProvider
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the Browserbase provider with the plugin context."""
|
||||
ctx.register_browser_provider(BrowserbaseBrowserProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: browser-browserbase
|
||||
version: 1.0.0
|
||||
description: "Browserbase (https://browserbase.com) cloud browser backend. Requires BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID. Supports stealth, proxies, and keep-alive sessions; auto-falls-back when paid features are unavailable."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
provides_browser_providers:
|
||||
- browserbase
|
||||
@@ -0,0 +1,297 @@
|
||||
"""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
|
||||
|
||||
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 = os.environ.get("BROWSERBASE_API_KEY")
|
||||
project_id = os.environ.get("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",
|
||||
},
|
||||
],
|
||||
"post_setup": "agent_browser",
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Firecrawl cloud browser plugin — bundled, auto-loaded.
|
||||
|
||||
Distinct from ``plugins/web/firecrawl/`` (the web search/extract/crawl
|
||||
plugin); both share the FIRECRAWL_API_KEY but speak to different endpoints
|
||||
(``/v2/browser`` here vs ``/v2/search`` / ``/v2/scrape`` / ``/v2/crawl``
|
||||
over there).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plugins.browser.firecrawl.provider import FirecrawlBrowserProvider
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the Firecrawl cloud-browser provider with the plugin context."""
|
||||
ctx.register_browser_provider(FirecrawlBrowserProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: browser-firecrawl
|
||||
version: 1.0.0
|
||||
description: "Firecrawl (https://firecrawl.dev) cloud browser backend. Requires FIRECRAWL_API_KEY. Distinct from the firecrawl WEB search/extract plugin — the two share an API key but operate on different endpoints."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
provides_browser_providers:
|
||||
- firecrawl
|
||||
@@ -0,0 +1,171 @@
|
||||
"""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
|
||||
|
||||
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(os.environ.get("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 = os.environ.get("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",
|
||||
},
|
||||
],
|
||||
"post_setup": "agent_browser",
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Context engine plugin discovery.
|
||||
|
||||
Scans ``plugins/context_engine/<name>/`` directories for context engine
|
||||
plugins. Each subdirectory must contain ``__init__.py`` with a class
|
||||
implementing the ContextEngine ABC.
|
||||
|
||||
Context engines are separate from the general plugin system — they live
|
||||
in the repo and are always available without user installation. Only ONE
|
||||
can be active at a time, selected via ``context.engine`` in config.yaml.
|
||||
The default engine is ``"compressor"`` (the built-in ContextCompressor).
|
||||
|
||||
Usage:
|
||||
from plugins.context_engine import discover_context_engines, load_context_engine
|
||||
|
||||
available = discover_context_engines() # [(name, desc, available), ...]
|
||||
engine = load_context_engine("lcm") # ContextEngine instance
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CONTEXT_ENGINE_PLUGINS_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def discover_context_engines() -> List[Tuple[str, str, bool]]:
|
||||
"""Scan plugins/context_engine/ for available engines.
|
||||
|
||||
Returns list of (name, description, is_available) tuples.
|
||||
Does NOT import the engines — just reads plugin.yaml for metadata
|
||||
and does a lightweight availability check.
|
||||
"""
|
||||
results = []
|
||||
if not _CONTEXT_ENGINE_PLUGINS_DIR.is_dir():
|
||||
return results
|
||||
|
||||
for child in sorted(_CONTEXT_ENGINE_PLUGINS_DIR.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith(("_", ".")):
|
||||
continue
|
||||
init_file = child / "__init__.py"
|
||||
if not init_file.exists():
|
||||
continue
|
||||
|
||||
# Read description from plugin.yaml if available
|
||||
desc = ""
|
||||
yaml_file = child / "plugin.yaml"
|
||||
if yaml_file.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_file, encoding="utf-8-sig") as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
desc = meta.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Quick availability check — try loading and calling is_available()
|
||||
available = True
|
||||
try:
|
||||
engine = _load_engine_from_dir(child)
|
||||
if engine is None:
|
||||
available = False
|
||||
elif hasattr(engine, "is_available"):
|
||||
available = engine.is_available()
|
||||
except Exception:
|
||||
available = False
|
||||
|
||||
results.append((child.name, desc, available))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_context_engine(name: str) -> Optional["ContextEngine"]:
|
||||
"""Load and return a ContextEngine instance by name.
|
||||
|
||||
Returns None if the engine is not found or fails to load.
|
||||
"""
|
||||
engine_dir = _CONTEXT_ENGINE_PLUGINS_DIR / name
|
||||
if not engine_dir.is_dir():
|
||||
logger.debug("Context engine '%s' not found in %s", name, _CONTEXT_ENGINE_PLUGINS_DIR)
|
||||
return None
|
||||
|
||||
try:
|
||||
engine = _load_engine_from_dir(engine_dir)
|
||||
if engine:
|
||||
return engine
|
||||
logger.warning("Context engine '%s' loaded but no engine instance found", name)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load context engine '%s': %s", name, e)
|
||||
return None
|
||||
|
||||
|
||||
def _load_engine_from_dir(engine_dir: Path) -> Optional["ContextEngine"]:
|
||||
"""Import an engine module and extract the ContextEngine instance.
|
||||
|
||||
The module must have either:
|
||||
- A register(ctx) function (plugin-style) — we simulate a ctx
|
||||
- A top-level class that extends ContextEngine — we instantiate it
|
||||
"""
|
||||
name = engine_dir.name
|
||||
module_name = f"plugins.context_engine.{name}"
|
||||
init_file = engine_dir / "__init__.py"
|
||||
|
||||
if not init_file.exists():
|
||||
return None
|
||||
|
||||
# Check if already loaded
|
||||
if module_name in sys.modules:
|
||||
mod = sys.modules[module_name]
|
||||
else:
|
||||
# Handle relative imports within the plugin
|
||||
# First ensure the parent packages are registered
|
||||
for parent in ("plugins", "plugins.context_engine"):
|
||||
if parent not in sys.modules:
|
||||
parent_path = Path(__file__).parent
|
||||
if parent == "plugins":
|
||||
parent_path = parent_path.parent
|
||||
parent_init = parent_path / "__init__.py"
|
||||
if parent_init.exists():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
parent, str(parent_init),
|
||||
submodule_search_locations=[str(parent_path)]
|
||||
)
|
||||
if spec:
|
||||
parent_mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[parent] = parent_mod
|
||||
try:
|
||||
spec.loader.exec_module(parent_mod)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Now load the engine module
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, str(init_file),
|
||||
submodule_search_locations=[str(engine_dir)]
|
||||
)
|
||||
if not spec:
|
||||
return None
|
||||
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
|
||||
# Register submodules so relative imports work
|
||||
for sub_file in engine_dir.glob("*.py"):
|
||||
if sub_file.name == "__init__.py":
|
||||
continue
|
||||
sub_name = sub_file.stem
|
||||
full_sub_name = f"{module_name}.{sub_name}"
|
||||
if full_sub_name not in sys.modules:
|
||||
sub_spec = importlib.util.spec_from_file_location(
|
||||
full_sub_name, str(sub_file)
|
||||
)
|
||||
if sub_spec:
|
||||
sub_mod = importlib.util.module_from_spec(sub_spec)
|
||||
sys.modules[full_sub_name] = sub_mod
|
||||
try:
|
||||
sub_spec.loader.exec_module(sub_mod)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load submodule %s: %s", full_sub_name, e)
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to exec_module %s: %s", module_name, e)
|
||||
sys.modules.pop(module_name, None)
|
||||
return None
|
||||
|
||||
# Try register(ctx) pattern first (how plugins are written)
|
||||
if hasattr(mod, "register"):
|
||||
collector = _EngineCollector(engine_name=name)
|
||||
try:
|
||||
mod.register(collector)
|
||||
if collector.engine:
|
||||
return collector.engine
|
||||
except Exception as e:
|
||||
logger.debug("register() failed for %s: %s", name, e)
|
||||
|
||||
# Fallback: find a ContextEngine subclass and instantiate it
|
||||
from agent.context_engine import ContextEngine
|
||||
for attr_name in dir(mod):
|
||||
attr = getattr(mod, attr_name, None)
|
||||
if (isinstance(attr, type) and issubclass(attr, ContextEngine)
|
||||
and attr is not ContextEngine):
|
||||
try:
|
||||
return attr()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class _EngineCollector:
|
||||
"""Fake plugin context that captures register_context_engine calls.
|
||||
|
||||
Plugin context engines using the standard ``register(ctx)`` pattern may
|
||||
also call ``ctx.register_command(...)`` to expose slash commands (e.g.
|
||||
``/lcm``). Forward those to the global plugin command registry so they
|
||||
behave identically to commands registered by normal plugins.
|
||||
"""
|
||||
|
||||
def __init__(self, engine_name: str = ""):
|
||||
self.engine = None
|
||||
self._engine_name = engine_name or "context_engine"
|
||||
self._registered_commands: list[str] = []
|
||||
|
||||
def register_context_engine(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
def register_command(
|
||||
self,
|
||||
name: str,
|
||||
handler,
|
||||
description: str = "",
|
||||
args_hint: str = "",
|
||||
) -> None:
|
||||
"""Forward to the global plugin command registry."""
|
||||
clean = (name or "").lower().strip().lstrip("/").replace(" ", "-")
|
||||
if not clean:
|
||||
logger.warning(
|
||||
"Context engine '%s' tried to register a command with an empty name.",
|
||||
self._engine_name,
|
||||
)
|
||||
return
|
||||
|
||||
# Reject conflicts with built-in commands.
|
||||
try:
|
||||
from hermes_cli.commands import resolve_command
|
||||
if resolve_command(clean) is not None:
|
||||
logger.warning(
|
||||
"Context engine '%s' tried to register command '/%s' which conflicts "
|
||||
"with a built-in command. Skipping.",
|
||||
self._engine_name, clean,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
manager = get_plugin_manager()
|
||||
if clean in manager._plugin_commands:
|
||||
# Don't clobber a regular plugin's command — same conflict
|
||||
# policy the plugin system uses for plugin-vs-plugin collisions.
|
||||
logger.warning(
|
||||
"Context engine '%s' tried to register command '/%s' which "
|
||||
"is already registered by a plugin. Skipping.",
|
||||
self._engine_name, clean,
|
||||
)
|
||||
return
|
||||
manager._plugin_commands[clean] = {
|
||||
"handler": handler,
|
||||
"description": description or "Context engine command",
|
||||
"plugin": f"context-engine:{self._engine_name}",
|
||||
"args_hint": (args_hint or "").strip(),
|
||||
}
|
||||
self._registered_commands.append(clean)
|
||||
logger.debug(
|
||||
"Context engine '%s' registered command: /%s",
|
||||
self._engine_name, clean,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Context engine '%s' could not register /%s: %s",
|
||||
self._engine_name, clean, exc,
|
||||
)
|
||||
|
||||
# No-op for other registration methods
|
||||
def register_tool(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_hook(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_cli_command(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def register_memory_provider(self, *args, **kwargs):
|
||||
pass
|
||||
@@ -0,0 +1,491 @@
|
||||
"""BasicAuthProvider — username/password dashboard auth (no OAuth IDP).
|
||||
|
||||
A self-hosted "just put a password on my dashboard" provider. It plugs
|
||||
into the same ``DashboardAuthProvider`` framework as the Nous OAuth
|
||||
provider, but authenticates with a username + password instead of an
|
||||
OAuth redirect: it sets ``supports_password = True`` and implements
|
||||
``complete_password_login``. The login page renders a credential form for
|
||||
it; everything downstream of login (session cookies, verify, refresh,
|
||||
ws-tickets, logout) is identical to the OAuth path because a password
|
||||
session is just a :class:`Session` with provider-minted opaque tokens.
|
||||
|
||||
This provider has **no external IDP and no database**. Credentials are
|
||||
configured up front; sessions are stateless HMAC-signed tokens this
|
||||
provider mints and verifies itself. That keeps it zero-infrastructure —
|
||||
appropriate for a single-box self-hosted dashboard.
|
||||
|
||||
Configuration surfaces (env wins over config.yaml when set non-empty),
|
||||
mirroring the Nous provider's precedence convention:
|
||||
|
||||
``config.yaml`` — canonical surface::
|
||||
|
||||
dashboard:
|
||||
basic_auth:
|
||||
username: admin # required
|
||||
# Provide EITHER a precomputed scrypt hash (preferred — no
|
||||
# plaintext at rest) ...
|
||||
password_hash: "scrypt$..." # see hash_password()
|
||||
# ... OR a plaintext password (hashed in-memory at load).
|
||||
password: "s3cret"
|
||||
secret: "<32+ random bytes, base64 or hex>" # optional; token-signing key
|
||||
session_ttl_seconds: 43200 # optional; access-token lifetime (default 12h)
|
||||
|
||||
Environment overrides::
|
||||
|
||||
HERMES_DASHBOARD_BASIC_AUTH_USERNAME
|
||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH # preferred
|
||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD # plaintext fallback
|
||||
HERMES_DASHBOARD_BASIC_AUTH_SECRET
|
||||
HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS
|
||||
|
||||
If ``secret`` is not configured, a random per-process secret is generated
|
||||
at startup. That's fine for a single-process dashboard, but means all
|
||||
sessions are invalidated on restart and sessions don't survive across
|
||||
multiple worker processes — set an explicit ``secret`` for stable
|
||||
multi-worker / restart-surviving sessions.
|
||||
|
||||
Password hashing uses stdlib :func:`hashlib.scrypt` (memory-hard, no
|
||||
third-party dependency). ``complete_password_login`` runs a constant-time
|
||||
comparison and always performs a hash even for an unknown username, so
|
||||
the endpoint is not a username-enumeration timing oracle.
|
||||
|
||||
Skip reasons:
|
||||
Like the Nous provider, this exposes a module-level ``LAST_SKIP_REASON``
|
||||
the gate's fail-closed branch can surface when the plugin loads but
|
||||
declines to register (no username/password configured).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider,
|
||||
InvalidCredentialsError,
|
||||
LoginStart,
|
||||
RefreshExpiredError,
|
||||
Session,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Access-token lifetime. The middleware transparently refreshes via the
|
||||
# refresh token (30-day) when the access token lapses, so this controls
|
||||
# how often a refresh round trip happens, not how long the user stays
|
||||
# logged in.
|
||||
_DEFAULT_TTL_SECONDS = 12 * 60 * 60 # 12h
|
||||
_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 # 30d
|
||||
|
||||
# scrypt parameters (RFC 7914 / stdlib hashlib.scrypt). n must be a power
|
||||
# of two; these are the widely-recommended interactive-login parameters
|
||||
# (~16 MiB, a few ms on commodity hardware).
|
||||
_SCRYPT_N = 2**14
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
_SCRYPT_DKLEN = 32
|
||||
_SCRYPT_SALT_BYTES = 16
|
||||
|
||||
# Length of the HMAC-SHA256 digest appended as a fixed-length suffix to
|
||||
# signed tokens (no separator — binary HMAC bytes can't be confused with
|
||||
# a delimiter).
|
||||
_SIG_LEN = hashlib.sha256().digest_size
|
||||
|
||||
|
||||
LAST_SKIP_REASON: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password hashing (stdlib scrypt)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Return a ``scrypt$n$r$p$<salt_b64>$<dk_b64>`` hash string.
|
||||
|
||||
Use this to precompute ``password_hash`` for config.yaml so plaintext
|
||||
never sits at rest. Exposed as a module function so operators can run
|
||||
``python -c "from plugins.dashboard_auth.basic import hash_password;
|
||||
print(hash_password('pw'))"``.
|
||||
"""
|
||||
salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
|
||||
dk = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=_SCRYPT_N,
|
||||
r=_SCRYPT_R,
|
||||
p=_SCRYPT_P,
|
||||
dklen=_SCRYPT_DKLEN,
|
||||
maxmem=0,
|
||||
)
|
||||
return (
|
||||
f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}$"
|
||||
f"{base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
|
||||
)
|
||||
|
||||
|
||||
def _verify_password(password: str, encoded: str) -> bool:
|
||||
"""Constant-time scrypt verify. False on any malformed hash string."""
|
||||
try:
|
||||
scheme, n_s, r_s, p_s, salt_b64, dk_b64 = encoded.split("$")
|
||||
if scheme != "scrypt":
|
||||
return False
|
||||
n, r, p = int(n_s), int(r_s), int(p_s)
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(dk_b64)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
try:
|
||||
actual = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=n,
|
||||
r=r,
|
||||
p=p,
|
||||
dklen=len(expected),
|
||||
maxmem=0,
|
||||
)
|
||||
except (ValueError, MemoryError):
|
||||
return False
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
# A fixed dummy hash used to spend ~equal time when the username is
|
||||
# unknown, so an attacker can't distinguish "no such user" (fast) from
|
||||
# "wrong password" (slow scrypt) by timing. Computed once at import.
|
||||
_DUMMY_HASH = hash_password("dummy-password-for-constant-time-verify")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token signing (stateless HMAC-signed blobs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sign(payload: dict, secret: bytes) -> str:
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
sig = hmac.new(secret, raw, hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(raw + sig).decode()
|
||||
|
||||
|
||||
def _unsign(token: str, secret: bytes) -> Optional[dict]:
|
||||
try:
|
||||
blob = base64.urlsafe_b64decode(token.encode())
|
||||
if len(blob) <= _SIG_LEN:
|
||||
return None
|
||||
raw, sig = blob[:-_SIG_LEN], blob[-_SIG_LEN:]
|
||||
expected = hmac.new(secret, raw, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BasicAuthProvider(DashboardAuthProvider):
|
||||
"""Username/password provider with stateless HMAC-signed sessions."""
|
||||
|
||||
name = "basic"
|
||||
display_name = "Username & Password"
|
||||
supports_password = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
username: str,
|
||||
password_hash: str,
|
||||
secret: bytes,
|
||||
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
|
||||
) -> None:
|
||||
if not username:
|
||||
raise ValueError("username must be non-empty")
|
||||
if not password_hash:
|
||||
raise ValueError("password_hash must be non-empty")
|
||||
if len(secret) < 16:
|
||||
raise ValueError("secret must be at least 16 bytes")
|
||||
self._username = username
|
||||
self._password_hash = password_hash
|
||||
self._secret = secret
|
||||
self._ttl = max(60, int(ttl_seconds))
|
||||
|
||||
# ---- OAuth methods: not used (pure-password provider) ------------------
|
||||
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
raise NotImplementedError(
|
||||
"BasicAuthProvider is password-only; there is no OAuth redirect "
|
||||
"flow. The login page POSTs to /auth/password-login instead."
|
||||
)
|
||||
|
||||
def complete_login(
|
||||
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
|
||||
) -> Session:
|
||||
raise NotImplementedError(
|
||||
"BasicAuthProvider is password-only; use complete_password_login."
|
||||
)
|
||||
|
||||
# ---- password login ----------------------------------------------------
|
||||
|
||||
def complete_password_login(
|
||||
self, *, username: str, password: str
|
||||
) -> Session:
|
||||
# Constant-time-ish: always run a scrypt verify (against the real
|
||||
# hash if the username matches, else a dummy hash) so an unknown
|
||||
# username and a wrong password take comparable time. Compare the
|
||||
# username with compare_digest too, to avoid a length/byte timing
|
||||
# leak on the username itself.
|
||||
username_ok = hmac.compare_digest(
|
||||
username.encode("utf-8"), self._username.encode("utf-8")
|
||||
)
|
||||
target_hash = self._password_hash if username_ok else _DUMMY_HASH
|
||||
password_ok = _verify_password(password, target_hash)
|
||||
if not (username_ok and password_ok):
|
||||
raise InvalidCredentialsError("invalid username or password")
|
||||
return self._mint_session(self._username)
|
||||
|
||||
# ---- session lifecycle -------------------------------------------------
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
payload = _unsign(access_token, self._secret)
|
||||
if (
|
||||
payload is None
|
||||
or payload.get("kind") != "access"
|
||||
or payload.get("exp", 0) <= int(time.time())
|
||||
):
|
||||
return None
|
||||
return self._session_from_payload(access_token, "", payload)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
if not refresh_token:
|
||||
raise RefreshExpiredError("no refresh token present in session")
|
||||
payload = _unsign(refresh_token, self._secret)
|
||||
if (
|
||||
payload is None
|
||||
or payload.get("kind") != "refresh"
|
||||
or payload.get("exp", 0) <= int(time.time())
|
||||
):
|
||||
raise RefreshExpiredError("refresh token expired or invalid")
|
||||
return self._mint_session(str(payload.get("sub", self._username)))
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
# Stateless tokens — nothing to revoke server-side. The session
|
||||
# expires within its TTL. Best-effort no-op, must not raise.
|
||||
_ = refresh_token
|
||||
return None
|
||||
|
||||
# ---- internals ---------------------------------------------------------
|
||||
|
||||
def _mint_session(self, user_id: str) -> Session:
|
||||
now = int(time.time())
|
||||
exp = now + self._ttl
|
||||
access_token = _sign(
|
||||
{"sub": user_id, "kind": "access", "exp": exp}, self._secret
|
||||
)
|
||||
refresh_token = _sign(
|
||||
{"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS},
|
||||
self._secret,
|
||||
)
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email="",
|
||||
display_name=user_id,
|
||||
org_id="",
|
||||
provider=self.name,
|
||||
expires_at=exp,
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
def _session_from_payload(
|
||||
self, access_token: str, refresh_token: str, payload: dict
|
||||
) -> Session:
|
||||
user_id = str(payload.get("sub", ""))
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email="",
|
||||
display_name=user_id,
|
||||
org_id="",
|
||||
provider=self.name,
|
||||
expires_at=int(payload["exp"]),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_config_basic_auth_section() -> dict:
|
||||
"""Return ``dashboard.basic_auth`` from config.yaml, or ``{}``.
|
||||
|
||||
Robust to load_config() raising, the keys being absent, or the value
|
||||
not being a dict — every shape falls through to ``{}``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
logger.debug(
|
||||
"dashboard-auth-basic: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg_get(cfg, "dashboard", "basic_auth", default=None)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def _resolve(env_name: str, cfg_section: dict, cfg_key: str) -> str:
|
||||
"""Env-wins-over-config resolution; empty env treated as unset."""
|
||||
env = os.environ.get(env_name, "").strip()
|
||||
if env:
|
||||
return env
|
||||
return str(cfg_section.get(cfg_key, "") or "").strip()
|
||||
|
||||
|
||||
def _resolve_secret(cfg_section: dict) -> bytes:
|
||||
"""Resolve the token-signing secret.
|
||||
|
||||
Accepts base64 or hex or raw text from config/env. When unset,
|
||||
generates a random per-process secret (sessions then don't survive a
|
||||
restart or span multiple workers — logged at INFO).
|
||||
"""
|
||||
raw = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_SECRET", cfg_section, "secret"
|
||||
)
|
||||
if not raw:
|
||||
logger.info(
|
||||
"dashboard-auth-basic: no 'secret' configured; generating a "
|
||||
"random per-process signing key. Sessions will not survive a "
|
||||
"restart or span multiple workers. Set dashboard.basic_auth."
|
||||
"secret (or HERMES_DASHBOARD_BASIC_AUTH_SECRET) for stable "
|
||||
"sessions."
|
||||
)
|
||||
return secrets.token_bytes(32)
|
||||
# Try base64, then hex, then fall back to the raw UTF-8 bytes.
|
||||
for decoder in (base64.b64decode, bytes.fromhex):
|
||||
try:
|
||||
decoded = decoder(raw)
|
||||
if len(decoded) >= 16:
|
||||
return decoded
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return raw.encode("utf-8")
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — registers BasicAuthProvider when credentials exist.
|
||||
|
||||
Loopback / ``--insecure`` operators and anyone using the OAuth
|
||||
provider leave ``dashboard.basic_auth`` unset, so this plugin is a
|
||||
no-op for them. When username + (password or password_hash) are
|
||||
configured, it registers a password provider that the login page
|
||||
renders as a credential form.
|
||||
"""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
|
||||
section = _load_config_basic_auth_section()
|
||||
username = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME", section, "username"
|
||||
)
|
||||
password_hash = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH", section, "password_hash"
|
||||
)
|
||||
plaintext = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", section, "password"
|
||||
)
|
||||
ttl_raw = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS", section, "session_ttl_seconds"
|
||||
)
|
||||
|
||||
if not username:
|
||||
LAST_SKIP_REASON = (
|
||||
"dashboard.basic_auth.username is not set (and "
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME is empty). Set a username "
|
||||
"and a password (or password_hash) under dashboard.basic_auth in "
|
||||
"config.yaml to enable username/password dashboard login, or use "
|
||||
"the OAuth provider, or pass --insecure to skip the auth gate."
|
||||
)
|
||||
logger.debug("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
if not password_hash and not plaintext:
|
||||
LAST_SKIP_REASON = (
|
||||
"dashboard.basic_auth.username is set but neither password_hash "
|
||||
"nor password is configured. Provide one of them (password_hash "
|
||||
"is preferred — compute it with "
|
||||
"plugins.dashboard_auth.basic.hash_password)."
|
||||
)
|
||||
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
# Precedence (env-wins convention): a password supplied via the
|
||||
# HERMES_DASHBOARD_BASIC_AUTH_PASSWORD env var overrides a config.yaml
|
||||
# password_hash, so an operator can rotate the password by setting an
|
||||
# env var without editing config. A password_hash (precomputed) wins
|
||||
# over a config-only plaintext password at the same tier — it's the
|
||||
# preferred at-rest form. Concretely:
|
||||
# * env password set → hash it (overrides any config hash)
|
||||
# * else config password_hash set → use it
|
||||
# * else config plaintext password → hash it in-memory
|
||||
plaintext_from_env = os.environ.get(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", ""
|
||||
).strip()
|
||||
if plaintext_from_env:
|
||||
password_hash = hash_password(plaintext_from_env)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: hashed env-supplied password in-memory "
|
||||
"(overrides any config password_hash)."
|
||||
)
|
||||
elif not password_hash:
|
||||
# config-only plaintext password.
|
||||
password_hash = hash_password(plaintext)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: hashed plaintext password in-memory. "
|
||||
"For production, precompute dashboard.basic_auth.password_hash "
|
||||
"and remove the plaintext password from config."
|
||||
)
|
||||
|
||||
secret = _resolve_secret(section)
|
||||
|
||||
try:
|
||||
ttl = int(ttl_raw) if ttl_raw else _DEFAULT_TTL_SECONDS
|
||||
except ValueError:
|
||||
ttl = _DEFAULT_TTL_SECONDS
|
||||
|
||||
try:
|
||||
provider = BasicAuthProvider(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
secret=secret,
|
||||
ttl_seconds=ttl,
|
||||
)
|
||||
except ValueError as exc:
|
||||
LAST_SKIP_REASON = f"BasicAuthProvider construction failed: {exc}"
|
||||
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: registered password provider (username=%s)",
|
||||
username,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
name: basic
|
||||
version: 1.0.0
|
||||
description: "Dashboard auth provider — username/password (no OAuth IDP). A self-hosted 'just put a password on my dashboard' provider. Activates when dashboard.basic_auth.username plus a password (or password_hash) are configured via config.yaml (canonical surface) or the HERMES_DASHBOARD_BASIC_AUTH_* env vars. Sessions are stateless HMAC-signed tokens minted by the provider; password hashing uses stdlib scrypt (no third-party dependency). Set dashboard.basic_auth.secret for restart-surviving / multi-worker sessions."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- HERMES_DASHBOARD_BASIC_AUTH_USERNAME
|
||||
@@ -0,0 +1,667 @@
|
||||
"""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,
|
||||
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,
|
||||
)
|
||||
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 jwt.PyJWKClientError as exc:
|
||||
raise ProviderError(f"JWKS lookup failed: {exc}") from exc
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise ProviderError(f"JWKS lookup failed: {exc!r}") from exc
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
access_token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
# Contract C2: aud is the bare client_id.
|
||||
audience=self._client_id,
|
||||
# Contract: issuer is the Portal base URL.
|
||||
issuer=self._portal_url,
|
||||
options={"require": ["exp", "iat", "aud", "iss", "sub"]},
|
||||
)
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
# verify_session() catches this and returns None per protocol.
|
||||
raise InvalidCodeError(f"access token expired: {exc}") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
# Surface the actual claim values that failed verification so
|
||||
# operators don't have to dig into the JWT to debug config drift
|
||||
# between HERMES_DASHBOARD_PORTAL_URL / HERMES_DASHBOARD_OAUTH_CLIENT_ID
|
||||
# and what Portal is actually emitting. Decoding without verification
|
||||
# is safe here: we've already failed to verify, and we never trust
|
||||
# these values — they're surfaced for diagnostics only.
|
||||
details = ""
|
||||
try:
|
||||
unverified = jwt.decode(
|
||||
access_token,
|
||||
options={"verify_signature": False, "verify_exp": False},
|
||||
)
|
||||
details = (
|
||||
f" [token iss={unverified.get('iss')!r} "
|
||||
f"aud={unverified.get('aud')!r}; "
|
||||
f"expected iss={self._portal_url!r} "
|
||||
f"aud={self._client_id!r}]"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise ProviderError(
|
||||
f"access token verification failed: {exc}{details}"
|
||||
) from exc
|
||||
|
||||
self._check_agent_instance_id(claims)
|
||||
self._check_contract_version(claims)
|
||||
return claims
|
||||
|
||||
def _check_agent_instance_id(self, claims: Dict[str, Any]) -> None:
|
||||
"""Contract C9: cross-check agent_instance_id against our config."""
|
||||
token_instance_id = claims.get("agent_instance_id")
|
||||
if token_instance_id is None:
|
||||
# Tolerated — the claim is documented as "should" not "must".
|
||||
# Our audience check on the bare client_id already binds the
|
||||
# token to this instance; agent_instance_id is defense-in-depth.
|
||||
return
|
||||
if token_instance_id != self._agent_instance_id:
|
||||
raise ProviderError(
|
||||
f"agent_instance_id mismatch: token={token_instance_id!r} "
|
||||
f"vs configured={self._agent_instance_id!r}"
|
||||
)
|
||||
|
||||
def _check_contract_version(self, claims: Dict[str, Any]) -> None:
|
||||
"""Contract C11 — tolerant treatment per OQ-C2."""
|
||||
contract_version = claims.get("oauth_contract_version")
|
||||
if contract_version is None:
|
||||
logger.warning(
|
||||
"Nous Portal token missing oauth_contract_version claim "
|
||||
"(contract says it should be %d); proceeding anyway.",
|
||||
_EXPECTED_CONTRACT_VERSION,
|
||||
)
|
||||
return
|
||||
if contract_version != _EXPECTED_CONTRACT_VERSION:
|
||||
raise ProviderError(
|
||||
f"unsupported oauth_contract_version={contract_version!r}, "
|
||||
f"expected {_EXPECTED_CONTRACT_VERSION}"
|
||||
)
|
||||
|
||||
def _session_from_claims(
|
||||
self,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
claims: Dict[str, Any],
|
||||
) -> Session:
|
||||
# Contract C4: no email / display_name in tokens. AuthWidget will
|
||||
# show user_id (truncated). Session fields kept for forward-compat.
|
||||
user_id = str(claims.get("sub", ""))
|
||||
if not user_id:
|
||||
raise ProviderError("token missing 'sub' (user_id) claim")
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email="",
|
||||
display_name="",
|
||||
org_id=str(claims.get("org_id") or ""),
|
||||
provider=self.name,
|
||||
expires_at=int(claims["exp"]),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_config_oauth_section() -> dict:
|
||||
"""Return the ``dashboard.oauth`` block from ``config.yaml`` if it
|
||||
exists and is a dict; otherwise an empty dict.
|
||||
|
||||
Robust to (a) load_config() raising (malformed YAML, IO error,
|
||||
config.yaml absent — common in fresh installs), (b) the
|
||||
``dashboard`` key being absent or non-dict, and (c) the ``oauth``
|
||||
sub-key being present but not a dict (user typo). Each shape falls
|
||||
through to ``{}`` so register() can rely on `.get(...)` access.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
logger.debug(
|
||||
"dashboard-auth-nous: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg_get(cfg, "dashboard", "oauth", default=None)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def _resolve_client_id() -> str:
|
||||
"""Resolve the OAuth client_id with env-overrides-config precedence.
|
||||
|
||||
Order:
|
||||
1. ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` env var (when non-empty
|
||||
after strip — empty values are treated as unset so a
|
||||
provisioned-but-not-populated Fly secret can't shadow a valid
|
||||
config.yaml entry).
|
||||
2. ``dashboard.oauth.client_id`` in ``config.yaml``.
|
||||
3. Empty string — signals "no client_id configured" to the caller.
|
||||
"""
|
||||
env = os.environ.get("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "").strip()
|
||||
if env:
|
||||
return env
|
||||
cfg_value = _load_config_oauth_section().get("client_id", "")
|
||||
return str(cfg_value).strip()
|
||||
|
||||
|
||||
def _resolve_portal_url() -> str:
|
||||
"""Resolve the Portal URL with env-overrides-config precedence.
|
||||
|
||||
Order:
|
||||
1. ``HERMES_DASHBOARD_PORTAL_URL`` env var (non-empty after strip).
|
||||
2. ``dashboard.oauth.portal_url`` in ``config.yaml``.
|
||||
3. :data:`_DEFAULT_PORTAL_URL` (production Portal).
|
||||
"""
|
||||
env = os.environ.get("HERMES_DASHBOARD_PORTAL_URL", "").strip()
|
||||
if env:
|
||||
return env
|
||||
cfg_value = str(
|
||||
_load_config_oauth_section().get("portal_url", "")
|
||||
).strip()
|
||||
return cfg_value or _DEFAULT_PORTAL_URL
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — called by the plugin loader at startup.
|
||||
|
||||
Registers ``NousDashboardAuthProvider`` only when a client_id is
|
||||
configured (either via ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` env var
|
||||
or via ``dashboard.oauth.client_id`` in ``config.yaml``). The env
|
||||
var wins when set non-empty — Fly.io's platform-secret injection
|
||||
pushes the per-deploy value through this path.
|
||||
|
||||
When skipping, writes a short human-readable reason to the module-
|
||||
level :data:`LAST_SKIP_REASON` so the dashboard's fail-closed branch
|
||||
can surface "Set HERMES_DASHBOARD_OAUTH_CLIENT_ID …" instead of the
|
||||
bare "no providers registered" the gate would otherwise emit. The
|
||||
reason mentions BOTH configuration surfaces so operators don't
|
||||
guess wrong about which one to populate.
|
||||
|
||||
Operator-owned dashboards (loopback / ``--insecure``) leave both
|
||||
surfaces unset, so this plugin is a no-op for them. The gate-
|
||||
engagement layer (``hermes_cli.web_server.should_require_auth`` +
|
||||
the fail-closed check in ``start_server``) handles the "public bind
|
||||
with zero providers" case independently.
|
||||
"""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
|
||||
client_id = _resolve_client_id()
|
||||
portal_url = _resolve_portal_url()
|
||||
|
||||
if not client_id:
|
||||
LAST_SKIP_REASON = (
|
||||
"HERMES_DASHBOARD_OAUTH_CLIENT_ID is not set (and "
|
||||
"dashboard.oauth.client_id in config.yaml is empty). The "
|
||||
"Nous Portal provisions this env var (shape "
|
||||
"'agent:{instance_id}') when it deploys a Hermes Agent "
|
||||
"instance — set it to your provisioned client id (either "
|
||||
"as an env var or under dashboard.oauth.client_id in "
|
||||
"config.yaml), or pass --insecure to skip the OAuth gate "
|
||||
"entirely."
|
||||
)
|
||||
logger.debug("dashboard-auth-nous: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
if not client_id.startswith("agent:"):
|
||||
LAST_SKIP_REASON = (
|
||||
f"HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id!r} doesn't match "
|
||||
f"the contract shape 'agent:{{instance_id}}'. The Nous Portal "
|
||||
f"provisions this value at deploy time; check your Fly app's "
|
||||
f"secrets or override with the value from the Portal admin UI."
|
||||
)
|
||||
logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
try:
|
||||
provider = NousDashboardAuthProvider(
|
||||
client_id=client_id, portal_url=portal_url
|
||||
)
|
||||
except ValueError as exc:
|
||||
LAST_SKIP_REASON = f"NousDashboardAuthProvider construction failed: {exc}"
|
||||
logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
logger.info(
|
||||
"dashboard-auth-nous: registered provider (client_id=%s, portal=%s)",
|
||||
client_id,
|
||||
portal_url,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
name: nous
|
||||
version: 1.0.0
|
||||
description: "Dashboard auth provider — OAuth 2.0 (authorization-code + PKCE) against Nous Portal. Auto-activates when a client_id is configured via either dashboard.oauth.client_id in config.yaml (canonical surface) or HERMES_DASHBOARD_OAUTH_CLIENT_ID env var (operator override; Portal injects this at Fly.io provisioning). dashboard.oauth.portal_url / HERMES_DASHBOARD_PORTAL_URL are optional and default to https://portal.nousresearch.com."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- HERMES_DASHBOARD_OAUTH_CLIENT_ID
|
||||
@@ -0,0 +1,736 @@
|
||||
"""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.)
|
||||
|
||||
Public PKCE clients only. Confidential clients (with a ``client_secret``) are
|
||||
not yet supported — see the ``# TODO(confidential-client)`` seam in
|
||||
``complete_login`` / ``refresh_session``. Self-hosters configuring a CLI/SPA
|
||||
client almost always register a public + PKCE client, which is the smaller,
|
||||
simpler surface.
|
||||
|
||||
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
|
||||
|
||||
# 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"
|
||||
|
||||
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,
|
||||
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,
|
||||
) -> 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
|
||||
|
||||
# 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,
|
||||
}
|
||||
# TODO(confidential-client): when client_secret support lands, add it
|
||||
# here (and switch to HTTP Basic auth if the IDP's
|
||||
# token_endpoint_auth_methods_supported prefers client_secret_basic).
|
||||
return self._exchange(
|
||||
disco["token_endpoint"], data, bad_request_exc=InvalidCodeError
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
# TODO(confidential-client): add client_secret here when supported.
|
||||
return self._exchange(
|
||||
disco["token_endpoint"],
|
||||
data,
|
||||
bad_request_exc=RefreshExpiredError,
|
||||
previous_refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
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
|
||||
try:
|
||||
httpx.post(
|
||||
endpoint,
|
||||
data={
|
||||
"token": refresh_token,
|
||||
"token_type_hint": "refresh_token",
|
||||
"client_id": self._client_id,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
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 _exchange(
|
||||
self,
|
||||
token_endpoint: str,
|
||||
data: Dict[str, str],
|
||||
*,
|
||||
bad_request_exc: type[Exception],
|
||||
previous_refresh_token: str = "",
|
||||
) -> 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.
|
||||
"""
|
||||
try:
|
||||
response = httpx.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers={"Accept": "application/json"},
|
||||
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:
|
||||
response = httpx.get(
|
||||
url,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=_DISCOVERY_TIMEOUT_SEC,
|
||||
)
|
||||
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()
|
||||
|
||||
return {
|
||||
"issuer": advertised_issuer or self._issuer,
|
||||
"authorization_endpoint": authorization_endpoint,
|
||||
"token_endpoint": token_endpoint,
|
||||
"jwks_uri": jwks_uri,
|
||||
"revocation_endpoint": revocation_endpoint,
|
||||
}
|
||||
|
||||
# ---- 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,
|
||||
)
|
||||
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 jwt.PyJWKClientError as exc:
|
||||
raise ProviderError(f"JWKS lookup failed: {exc}") from exc
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise ProviderError(f"JWKS lookup failed: {exc!r}") 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. 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 parsed.scheme == "http" and parsed.hostname not in (
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
):
|
||||
raise ProviderError(
|
||||
"redirect_uri may only use http:// for localhost/127.0.0.1, "
|
||||
f"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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
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)",
|
||||
issuer,
|
||||
client_id,
|
||||
scopes,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
name: self-hosted
|
||||
version: 1.0.0
|
||||
description: "Dashboard auth provider — generic self-hosted OpenID Connect (authorization-code + PKCE, public client). Works against any conformant OIDC identity provider (Authentik, Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …) via OIDC discovery. Auto-activates when an issuer + client_id are configured, either under dashboard.oauth.self_hosted.{issuer,client_id} in config.yaml (canonical surface) or via the HERMES_DASHBOARD_OIDC_ISSUER + HERMES_DASHBOARD_OIDC_CLIENT_ID env vars (operator override / secret injection). Scopes default to 'openid profile email'. Verifies the OIDC ID token (RS256/ES256) against the discovered jwks_uri."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- HERMES_DASHBOARD_OIDC_ISSUER
|
||||
- HERMES_DASHBOARD_OIDC_CLIENT_ID
|
||||
@@ -0,0 +1,51 @@
|
||||
# disk-cleanup
|
||||
|
||||
Auto-tracks and cleans up ephemeral files created during Hermes Agent
|
||||
sessions — test scripts, temp outputs, cron logs, stale chrome profiles.
|
||||
Scoped strictly to `$HERMES_HOME` and `/tmp/hermes-*`.
|
||||
|
||||
Originally contributed by [@LVT382009](https://github.com/LVT382009) as a
|
||||
skill in PR #12212. Ported to the plugin system so the behaviour runs
|
||||
automatically via `post_tool_call` and `on_session_end` hooks — the agent
|
||||
never needs to remember to call a tool.
|
||||
|
||||
## How it works
|
||||
|
||||
| Hook | Behaviour |
|
||||
|---|---|
|
||||
| `post_tool_call` | When `write_file` / `terminal` / `patch` creates a file matching `test_*`, `tmp_*`, or `*.test.*` inside `HERMES_HOME`, track it silently as `test` / `temp` / `cron-output`. |
|
||||
| `on_session_end` | If any test files were auto-tracked during this turn, run `quick` cleanup (no prompts). |
|
||||
|
||||
Deletion rules (same as the original PR):
|
||||
|
||||
| Category | Threshold | Confirmation |
|
||||
|---|---|---|
|
||||
| `test` | every session end | Never |
|
||||
| `temp` | >7 days since tracked | Never |
|
||||
| `cron-output` | >14 days since tracked | Never |
|
||||
| empty dirs under HERMES_HOME | always | Never |
|
||||
| `research` | >30 days, beyond 10 newest | Always (deep only) |
|
||||
| `chrome-profile` | >14 days since tracked | Always (deep only) |
|
||||
| files >500 MB | never auto | Always (deep only) |
|
||||
|
||||
## Slash command
|
||||
|
||||
```
|
||||
/disk-cleanup status # breakdown + top-10 largest
|
||||
/disk-cleanup dry-run # preview without deleting
|
||||
/disk-cleanup quick # run safe cleanup now
|
||||
/disk-cleanup deep # quick + list items needing prompt
|
||||
/disk-cleanup track <path> <category> # manual tracking
|
||||
/disk-cleanup forget <path> # stop tracking
|
||||
```
|
||||
|
||||
## Safety
|
||||
|
||||
- `is_safe_path()` rejects anything outside `HERMES_HOME` or `/tmp/hermes-*`
|
||||
- Windows mounts (`/mnt/c` etc.) are rejected
|
||||
- The state directory `$HERMES_HOME/disk-cleanup/` is itself excluded
|
||||
- `$HERMES_HOME/logs/`, `memories/`, `sessions/`, `skills/`, `plugins/`,
|
||||
and config files are never tracked
|
||||
- Backup/restore is scoped to `tracked.json` — the plugin never touches
|
||||
agent logs
|
||||
- Atomic writes: `.tmp` → backup → rename
|
||||
@@ -0,0 +1,316 @@
|
||||
"""disk-cleanup plugin — auto-cleanup of ephemeral Hermes session files.
|
||||
|
||||
Wires three behaviours:
|
||||
|
||||
1. ``post_tool_call`` hook — inspects ``write_file`` and ``terminal``
|
||||
tool results for newly-created paths matching test/temp patterns
|
||||
under ``HERMES_HOME`` and tracks them silently. Zero agent
|
||||
compliance required.
|
||||
|
||||
2. ``on_session_end`` hook — when any test files were auto-tracked
|
||||
during the just-finished turn, runs :func:`disk_cleanup.quick` and
|
||||
logs a single line to ``$HERMES_HOME/disk-cleanup/cleanup.log``.
|
||||
|
||||
3. ``/disk-cleanup`` slash command — manual ``status``, ``dry-run``,
|
||||
``quick``, ``deep``, ``track``, ``forget``.
|
||||
|
||||
Replaces PR #12212's skill-plus-script design: the agent no longer
|
||||
needs to remember to run commands.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from . import disk_cleanup as dg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Per-task set of "test files newly tracked this turn". Keyed by task_id
|
||||
# (or session_id as fallback) so on_session_end can decide whether to run
|
||||
# cleanup. Guarded by a lock — post_tool_call can fire concurrently on
|
||||
# parallel tool calls.
|
||||
_recent_test_tracks: Dict[str, Set[str]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
# Tool-call result shapes we can parse
|
||||
_WRITE_FILE_PATH_KEY = "path"
|
||||
_TERMINAL_PATH_REGEX = re.compile(r"(?:^|\s)(/[^\s'\"`]+|\~/[^\s'\"`]+)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _tracker_key(task_id: str, session_id: str) -> str:
|
||||
return task_id or session_id or "default"
|
||||
|
||||
|
||||
def _record_track(task_id: str, session_id: str, path: Path, category: str) -> None:
|
||||
"""Record that we tracked *path* as *category* during this turn."""
|
||||
if category != "test":
|
||||
return
|
||||
key = _tracker_key(task_id, session_id)
|
||||
with _lock:
|
||||
_recent_test_tracks.setdefault(key, set()).add(str(path))
|
||||
|
||||
|
||||
def _drain(task_id: str, session_id: str) -> Set[str]:
|
||||
"""Pop the set of test paths tracked during this turn."""
|
||||
key = _tracker_key(task_id, session_id)
|
||||
with _lock:
|
||||
return _recent_test_tracks.pop(key, set())
|
||||
|
||||
|
||||
def _attempt_track(path_str: str, task_id: str, session_id: str) -> None:
|
||||
"""Best-effort auto-track. Never raises."""
|
||||
try:
|
||||
p = Path(path_str).expanduser()
|
||||
except Exception:
|
||||
return
|
||||
if not p.exists():
|
||||
return
|
||||
category = dg.guess_category(p)
|
||||
if category is None:
|
||||
return
|
||||
newly = dg.track(str(p), category, silent=True)
|
||||
if newly:
|
||||
_record_track(task_id, session_id, p, category)
|
||||
|
||||
|
||||
def _extract_paths_from_write_file(args: Dict[str, Any]) -> Set[str]:
|
||||
path = args.get(_WRITE_FILE_PATH_KEY)
|
||||
return {path} if isinstance(path, str) and path else set()
|
||||
|
||||
|
||||
def _extract_paths_from_patch(args: Dict[str, Any]) -> Set[str]:
|
||||
# The patch tool creates new files via the `mode="patch"` path too, but
|
||||
# most of its use is editing existing files — we only care about new
|
||||
# ephemeral creations, so treat patch conservatively and only pick up
|
||||
# the single-file `path` arg. Track-then-cleanup is idempotent, so
|
||||
# re-tracking an already-tracked file is a no-op (dedup in track()).
|
||||
path = args.get("path")
|
||||
return {path} if isinstance(path, str) and path else set()
|
||||
|
||||
|
||||
def _extract_paths_from_terminal(args: Dict[str, Any], result: str) -> Set[str]:
|
||||
"""Best-effort: pull candidate filesystem paths from a terminal command
|
||||
and its output, then let ``guess_category`` / ``is_safe_path`` filter.
|
||||
"""
|
||||
paths: Set[str] = set()
|
||||
cmd = args.get("command") or ""
|
||||
if isinstance(cmd, str) and cmd:
|
||||
# Tokenise the command — catches `touch /tmp/hermes-x/test_foo.py`
|
||||
try:
|
||||
for tok in shlex.split(cmd, posix=True):
|
||||
if tok.startswith(("/", "~")):
|
||||
paths.add(tok)
|
||||
except ValueError:
|
||||
pass
|
||||
# Only scan the result text if it's a reasonable size (avoid 50KB dumps).
|
||||
if isinstance(result, str) and len(result) < 4096:
|
||||
for match in _TERMINAL_PATH_REGEX.findall(result):
|
||||
paths.add(match)
|
||||
return paths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _on_post_tool_call(
|
||||
tool_name: str = "",
|
||||
args: Optional[Dict[str, Any]] = None,
|
||||
result: Any = None,
|
||||
task_id: str = "",
|
||||
session_id: str = "",
|
||||
tool_call_id: str = "",
|
||||
**_: Any,
|
||||
) -> None:
|
||||
"""Auto-track ephemeral files created by recent tool calls."""
|
||||
if not isinstance(args, dict):
|
||||
return
|
||||
|
||||
candidates: Set[str] = set()
|
||||
if tool_name == "write_file":
|
||||
candidates = _extract_paths_from_write_file(args)
|
||||
elif tool_name == "patch":
|
||||
candidates = _extract_paths_from_patch(args)
|
||||
elif tool_name == "terminal":
|
||||
candidates = _extract_paths_from_terminal(args, result if isinstance(result, str) else "")
|
||||
else:
|
||||
return
|
||||
|
||||
for path_str in candidates:
|
||||
_attempt_track(path_str, task_id, session_id)
|
||||
|
||||
|
||||
def _on_session_end(
|
||||
session_id: str = "",
|
||||
completed: bool = True,
|
||||
interrupted: bool = False,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
"""Run quick cleanup if any test files were tracked during this turn."""
|
||||
# Drain both task-level and session-level buckets. In practice only one
|
||||
# is populated per turn; the other is empty.
|
||||
drained_session = _drain("", session_id)
|
||||
# Also drain any task-scoped buckets that happen to exist. This is a
|
||||
# cheap sweep: if an agent spawned subagents (each with their own
|
||||
# task_id) they'll have recorded into separate buckets; we want to
|
||||
# cleanup them all at session end.
|
||||
with _lock:
|
||||
task_buckets = list(_recent_test_tracks.keys())
|
||||
for key in task_buckets:
|
||||
if key and key != session_id:
|
||||
_recent_test_tracks.pop(key, None)
|
||||
|
||||
if not drained_session and not task_buckets:
|
||||
return
|
||||
|
||||
try:
|
||||
summary = dg.quick()
|
||||
except Exception as exc:
|
||||
logger.debug("disk-cleanup quick cleanup failed: %s", exc)
|
||||
return
|
||||
|
||||
if summary["deleted"] or summary["empty_dirs"]:
|
||||
dg._log(
|
||||
f"AUTO_QUICK (session_end): deleted={summary['deleted']} "
|
||||
f"dirs={summary['empty_dirs']} freed={dg.fmt_size(summary['freed'])}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slash command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HELP_TEXT = """\
|
||||
/disk-cleanup — ephemeral-file cleanup
|
||||
|
||||
Subcommands:
|
||||
status Per-category breakdown + top-10 largest
|
||||
dry-run Preview what quick/deep would delete
|
||||
quick Run safe cleanup now (no prompts)
|
||||
deep Run quick, then list items that need prompts
|
||||
track <path> <category> Manually add a path to tracking
|
||||
forget <path> Stop tracking a path (does not delete)
|
||||
|
||||
Categories: temp | test | research | download | chrome-profile | cron-output | other
|
||||
|
||||
All operations are scoped to HERMES_HOME and /tmp/hermes-*.
|
||||
Test files are auto-tracked on write_file / terminal and auto-cleaned at session end.
|
||||
"""
|
||||
|
||||
|
||||
def _fmt_summary(summary: Dict[str, Any]) -> str:
|
||||
base = (
|
||||
f"[disk-cleanup] Cleaned {summary['deleted']} files + "
|
||||
f"{summary['empty_dirs']} empty dirs, freed {dg.fmt_size(summary['freed'])}."
|
||||
)
|
||||
if summary.get("errors"):
|
||||
base += f"\n {len(summary['errors'])} error(s); see cleanup.log."
|
||||
return base
|
||||
|
||||
|
||||
def _handle_slash(raw_args: str) -> Optional[str]:
|
||||
argv = raw_args.strip().split()
|
||||
if not argv or argv[0] in {"help", "-h", "--help"}:
|
||||
return _HELP_TEXT
|
||||
|
||||
sub = argv[0]
|
||||
|
||||
if sub == "status":
|
||||
return dg.format_status(dg.status())
|
||||
|
||||
if sub == "dry-run":
|
||||
auto, prompt = dg.dry_run()
|
||||
auto_size = sum(i["size"] for i in auto)
|
||||
prompt_size = sum(i["size"] for i in prompt)
|
||||
lines = [
|
||||
"Dry-run preview (nothing deleted):",
|
||||
f" Auto-delete : {len(auto)} files ({dg.fmt_size(auto_size)})",
|
||||
]
|
||||
for item in auto:
|
||||
lines.append(f" [{item['category']}] {item['path']}")
|
||||
lines.append(
|
||||
f" Needs prompt: {len(prompt)} files ({dg.fmt_size(prompt_size)})"
|
||||
)
|
||||
for item in prompt:
|
||||
lines.append(f" [{item['category']}] {item['path']}")
|
||||
lines.append(
|
||||
f"\n Total potential: {dg.fmt_size(auto_size + prompt_size)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
if sub == "quick":
|
||||
return _fmt_summary(dg.quick())
|
||||
|
||||
if sub == "deep":
|
||||
# In-session deep can't prompt the user interactively — show what
|
||||
# quick cleaned plus the items that WOULD need confirmation.
|
||||
quick_summary = dg.quick()
|
||||
_auto, prompt_items = dg.dry_run()
|
||||
lines = [_fmt_summary(quick_summary)]
|
||||
if prompt_items:
|
||||
size = sum(i["size"] for i in prompt_items)
|
||||
lines.append(
|
||||
f"\n{len(prompt_items)} item(s) need confirmation "
|
||||
f"({dg.fmt_size(size)}):"
|
||||
)
|
||||
for item in prompt_items:
|
||||
lines.append(f" [{item['category']}] {item['path']}")
|
||||
lines.append(
|
||||
"\nRun `/disk-cleanup forget <path>` to skip, or delete "
|
||||
"manually via terminal."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
if sub == "track":
|
||||
if len(argv) < 3:
|
||||
return "Usage: /disk-cleanup track <path> <category>"
|
||||
path_arg = argv[1]
|
||||
category = argv[2]
|
||||
if category not in dg.ALLOWED_CATEGORIES:
|
||||
return (
|
||||
f"Unknown category '{category}'. "
|
||||
f"Allowed: {sorted(dg.ALLOWED_CATEGORIES)}"
|
||||
)
|
||||
if dg.track(path_arg, category, silent=True):
|
||||
return f"Tracked {path_arg} as '{category}'."
|
||||
return (
|
||||
f"Not tracked (already present, missing, or outside HERMES_HOME): "
|
||||
f"{path_arg}"
|
||||
)
|
||||
|
||||
if sub == "forget":
|
||||
if len(argv) < 2:
|
||||
return "Usage: /disk-cleanup forget <path>"
|
||||
n = dg.forget(argv[1])
|
||||
return (
|
||||
f"Removed {n} tracking entr{'y' if n == 1 else 'ies'} for {argv[1]}."
|
||||
if n else f"Not found in tracking: {argv[1]}"
|
||||
)
|
||||
|
||||
return f"Unknown subcommand: {sub}\n\n{_HELP_TEXT}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register(ctx) -> None:
|
||||
ctx.register_hook("post_tool_call", _on_post_tool_call)
|
||||
ctx.register_hook("on_session_end", _on_session_end)
|
||||
ctx.register_command(
|
||||
"disk-cleanup",
|
||||
handler=_handle_slash,
|
||||
description="Track and clean up ephemeral Hermes session files.",
|
||||
)
|
||||
Executable
+560
@@ -0,0 +1,560 @@
|
||||
"""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())
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
bak = tf.with_suffix(".json.bak")
|
||||
if bak.exists():
|
||||
try:
|
||||
data = json.loads(bak.read_text())
|
||||
_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))
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
# 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 only matches the directory itself and known control-plane files
|
||||
(``jobs.json``, ``.tick.lock``) — it does NOT blanket-protect
|
||||
everything under ``cron/`` because ``cron/output/`` is disposable.
|
||||
"""
|
||||
# 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 / "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
|
||||
|
||||
# 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 leave HERMES_HOME itself and
|
||||
# a short list of well-known top-level state dirs alone — a fresh install
|
||||
# has these empty, and deleting them would surprise the user).
|
||||
hermes_home = get_hermes_home()
|
||||
_PROTECTED_TOP_LEVEL = {
|
||||
"logs", "memories", "sessions", "cron", "cronjobs",
|
||||
"cache", "skills", "plugins", "disk-cleanup", "optional-skills",
|
||||
"hermes-agent", "backups", "profiles", ".worktrees",
|
||||
}
|
||||
empty_removed = 0
|
||||
try:
|
||||
for dirpath in sorted(hermes_home.rglob("*"), reverse=True):
|
||||
if not dirpath.is_dir() or dirpath == hermes_home:
|
||||
continue
|
||||
try:
|
||||
rel_parts = dirpath.relative_to(hermes_home).parts
|
||||
except ValueError:
|
||||
continue
|
||||
# Skip the well-known top-level state dirs themselves.
|
||||
if len(rel_parts) == 1 and rel_parts[0] in _PROTECTED_TOP_LEVEL:
|
||||
continue
|
||||
try:
|
||||
if not any(dirpath.iterdir()):
|
||||
dirpath.rmdir()
|
||||
empty_removed += 1
|
||||
_log(f"DELETED: {dirpath} (empty dir)")
|
||||
except OSError:
|
||||
pass
|
||||
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",
|
||||
}:
|
||||
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) >= 2 and rel.parts[1] == "output":
|
||||
return "cron-output"
|
||||
return None
|
||||
if top == "cache":
|
||||
return "temp"
|
||||
except ValueError:
|
||||
# Path isn't under HERMES_HOME (e.g. /tmp/hermes-*) — fall through.
|
||||
pass
|
||||
|
||||
name = path.name
|
||||
if name.startswith(_TEST_PATTERNS):
|
||||
return "test"
|
||||
if any(name.endswith(sfx) for sfx in _TEST_SUFFIXES):
|
||||
return "test"
|
||||
return None
|
||||
@@ -0,0 +1,7 @@
|
||||
name: disk-cleanup
|
||||
version: 2.0.0
|
||||
description: "Auto-track and clean up ephemeral files (test scripts, temp outputs, cron logs) created during Hermes sessions. Runs via plugin hooks — no agent action required."
|
||||
author: "@LVT382009 (original), NousResearch (plugin port)"
|
||||
hooks:
|
||||
- post_tool_call
|
||||
- on_session_end
|
||||
@@ -0,0 +1,131 @@
|
||||
# google_meet plugin
|
||||
|
||||
Let the hermes agent join a Google Meet call, transcribe it, optionally speak
|
||||
in it, and do the followup work afterwards.
|
||||
|
||||
## What ships
|
||||
|
||||
| Version | What | Status |
|
||||
|---|---|---|
|
||||
| v1 | Transcribe-only: Playwright joins Meet, scrapes captions to transcript file | ✓ ships by default |
|
||||
| v2 | Realtime duplex audio: bot speaks in-call via OpenAI Realtime + BlackHole/PulseAudio null-sink | ✓ opt in with `mode='realtime'` |
|
||||
| v3 | Remote node host: run the bot on a different machine than the gateway | ✓ opt in with `node='<name>'` |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─ gateway (Linux box, where hermes runs) ────────────────────────────┐
|
||||
│ │
|
||||
│ agent → meet_join(url, mode='realtime', node='my-mac') │
|
||||
│ │ │
|
||||
│ └─ NodeClient ─── ws ────┐ │
|
||||
│ │ │
|
||||
└──────────────────────────────────┼───────────────────────────────────┘
|
||||
│ wss (token auth)
|
||||
▼
|
||||
┌─ node host (user's Mac, signed-in Chrome lives here) ───────────────┐
|
||||
│ │
|
||||
│ NodeServer (from `hermes meet node run`) │
|
||||
│ │ │
|
||||
│ ├─ start_bot → process_manager.start() → spawns meet_bot │
|
||||
│ │ │
|
||||
│ └─ meet_bot (Playwright) │
|
||||
│ ├─ Chromium → meet.google.com │
|
||||
│ ├─ caption scraper → transcript.txt │
|
||||
│ └─ (realtime mode only) RealtimeSpeaker thread │
|
||||
│ ↓ │
|
||||
│ OpenAI Realtime WS → speaker.pcm │
|
||||
│ ↓ │
|
||||
│ paplay → null-sink ← Chrome fake mic │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Without v3: the whole right column runs on the gateway machine.
|
||||
Without v2: the "realtime" path is skipped; transcribe runs alone.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `plugin.yaml` | manifest |
|
||||
| `__init__.py` | `register(ctx)` — registers 5 tools + `on_session_end` hook + `hermes meet` CLI |
|
||||
| `meet_bot.py` | Playwright bot subprocess (standalone, `python -m plugins.google_meet.meet_bot`) |
|
||||
| `process_manager.py` | local bot lifecycle + `enqueue_say` |
|
||||
| `tools.py` | agent-facing tools + node-routing helper |
|
||||
| `cli.py` | `hermes meet setup / auth / join / status / transcript / say / stop / node ...` |
|
||||
| `audio_bridge.py` | v2: PulseAudio null-sink (Linux) + BlackHole probe (macOS) |
|
||||
| `realtime/openai_client.py` | v2: `RealtimeSession` + `RealtimeSpeaker` (file-queue → OpenAI Realtime WS → PCM) |
|
||||
| `node/protocol.py` | v3: message envelope + validation |
|
||||
| `node/registry.py` | v3: `$HERMES_HOME/workspace/meetings/nodes.json` |
|
||||
| `node/server.py` | v3: `NodeServer` (runs on host machine) |
|
||||
| `node/client.py` | v3: `NodeClient` (used by tool handlers + CLI on gateway) |
|
||||
| `node/cli.py` | v3: `hermes meet node {run,list,approve,remove,status,ping}` |
|
||||
| `SKILL.md` | agent usage guide |
|
||||
|
||||
## Local quick start
|
||||
|
||||
```bash
|
||||
hermes plugins enable google_meet
|
||||
hermes meet install # pip + Chromium
|
||||
hermes meet setup # preflight
|
||||
hermes meet auth # optional
|
||||
hermes meet join https://meet.google.com/abc-defg-hij # transcribe
|
||||
```
|
||||
|
||||
## Realtime mode
|
||||
|
||||
Linux (preferred, most automated):
|
||||
```bash
|
||||
hermes meet install --realtime # installs pulseaudio-utils
|
||||
echo 'OPENAI_API_KEY=sk-...' >> ~/.hermes/.env
|
||||
hermes meet join https://meet.google.com/abc-defg-hij --mode realtime
|
||||
# then from the agent or CLI:
|
||||
hermes meet say "Good morning everyone, I'm the note-taker bot."
|
||||
```
|
||||
|
||||
macOS:
|
||||
```bash
|
||||
hermes meet install --realtime # runs: brew install blackhole-2ch ffmpeg
|
||||
# then — manually! — open System Settings → Sound → Input → BlackHole 2ch
|
||||
echo 'OPENAI_API_KEY=sk-...' >> ~/.hermes/.env
|
||||
hermes meet join https://meet.google.com/abc-defg-hij --mode realtime
|
||||
```
|
||||
|
||||
On macOS, hermes will **not** switch your system audio input automatically — the
|
||||
user has to do it. This is deliberate: switching default input on a whim would
|
||||
be a surprising side effect.
|
||||
|
||||
## Remote node host
|
||||
|
||||
On the node machine (e.g. user's Mac with a signed-in Chrome):
|
||||
```bash
|
||||
pip install playwright websockets
|
||||
python -m playwright install chromium
|
||||
hermes plugins enable google_meet
|
||||
hermes meet node run --display-name my-mac --host 0.0.0.0 --port 18789
|
||||
# prints the bearer token on first run; copy it
|
||||
```
|
||||
|
||||
On the gateway:
|
||||
```bash
|
||||
hermes meet node approve my-mac ws://<mac-ip>:18789 <token>
|
||||
hermes meet node ping my-mac
|
||||
# now any meet_* tool call accepts node='my-mac' (or 'auto')
|
||||
```
|
||||
|
||||
## Safety
|
||||
|
||||
- URL gate: only `https://meet.google.com/abc-defg-hij`, `/new`, `/lookup/<id>`.
|
||||
- No calendar scanning, no auto-dial, no auto-consent announcement.
|
||||
- Node server uses bearer-token auth; no key exchange, no TLS termination
|
||||
built in — run it on a LAN or behind a reverse proxy you trust.
|
||||
- One active meeting per (gateway, node) pair. A second `meet_join` leaves the first.
|
||||
- `meet_say` refuses unless the active meeting was started with `mode='realtime'`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Calendar scanning** — deliberately not implemented. Join URLs must be explicit.
|
||||
- **Multi-tenant node sharing** — a node serves one gateway at a time.
|
||||
- **Windows** — audio bridging isn't tested; `register()` no-ops on Windows.
|
||||
- **System audio input switching on macOS** — user responsibility, not the bot's.
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: google_meet
|
||||
description: Join a Google Meet call, transcribe live captions, optionally speak in realtime, and do the followup work afterwards. Use when the user asks the agent to sit in on a meeting, take notes, summarize, respond in-call, or action items from it.
|
||||
version: 0.2.0
|
||||
platforms:
|
||||
- linux
|
||||
- macos
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [meetings, google-meet, transcription, realtime-voice]
|
||||
---
|
||||
|
||||
# google_meet
|
||||
|
||||
## When to use
|
||||
|
||||
The user says any of:
|
||||
|
||||
- "join my Meet at <url>"
|
||||
- "take notes on this meeting"
|
||||
- "summarize the meeting and send followups"
|
||||
- "sit in on my standup"
|
||||
- "be a bot in this call and speak up when X"
|
||||
|
||||
## Two modes
|
||||
|
||||
| Mode | What the bot does |
|
||||
|---|---|
|
||||
| `transcribe` (default) | Joins, enables captions, scrapes a transcript. Listen-only. |
|
||||
| `realtime` | Same as transcribe PLUS speaks into the meeting via OpenAI Realtime. The agent calls `meet_say(text)` and the bot's voice comes out of the call. |
|
||||
|
||||
Pick `realtime` only when the user actually wants the agent to speak. It costs real money (OpenAI Realtime is pay-per-audio-minute) and requires a virtual audio device set up on the machine running the bot.
|
||||
|
||||
## Two locations
|
||||
|
||||
| Location | When |
|
||||
|---|---|
|
||||
| Local (default) | Gateway machine runs the Playwright bot directly. |
|
||||
| Remote node (`node="<name>"`) | Bot runs on a different machine that has a signed-in Chrome and (for realtime) a configured audio bridge. Useful when the gateway runs on a headless Linux box but the user's real signed-in Chrome lives on their Mac. |
|
||||
|
||||
## Prerequisites the user must handle once
|
||||
|
||||
Easiest path — run the built-in installer:
|
||||
|
||||
```bash
|
||||
hermes plugins enable google_meet
|
||||
hermes meet install # pip deps + Chromium (transcribe only)
|
||||
hermes meet install --realtime # + pulseaudio-utils / brew blackhole+ffmpeg
|
||||
hermes meet auth # optional; skips guest-lobby wait
|
||||
hermes meet setup # preflight checks
|
||||
```
|
||||
|
||||
`hermes meet install --realtime` prompts before running `sudo apt-get` (Linux)
|
||||
or `brew install` (macOS). Pass `--yes` to skip the prompt. It will NOT touch
|
||||
your macOS default-input setting — you have to select BlackHole 2ch in
|
||||
System Settings yourself before starting a realtime meeting.
|
||||
|
||||
Or do it manually:
|
||||
```bash
|
||||
pip install playwright websockets && python -m playwright install chromium
|
||||
|
||||
# For realtime mode, additionally:
|
||||
# Linux: sudo apt install pulseaudio-utils
|
||||
# macOS: brew install blackhole-2ch ffmpeg
|
||||
# → System Settings → Sound → Input → BlackHole 2ch
|
||||
# Then set OPENAI_API_KEY or HERMES_MEET_REALTIME_KEY in ~/.hermes/.env
|
||||
```
|
||||
|
||||
For a remote node:
|
||||
```bash
|
||||
# on the user's Mac (where Chrome is signed in):
|
||||
pip install playwright websockets && python -m playwright install chromium
|
||||
hermes plugins enable google_meet
|
||||
hermes meet node run --display-name my-mac # persistent server
|
||||
# copy the printed token
|
||||
|
||||
# on the gateway:
|
||||
hermes meet node approve my-mac ws://<mac-ip>:18789 <token>
|
||||
hermes meet node ping my-mac # confirm reachable
|
||||
```
|
||||
|
||||
Run `hermes meet setup` to preflight local prereqs.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Join** — call `meet_join(url=..., mode=..., node=...)`. Returns immediately.
|
||||
2. **Announce yourself** — no auto-consent. Say (in whatever channel the user is watching): "A Hermes agent bot is in this call taking notes."
|
||||
3. **Poll** — `meet_status()` for liveness, `meet_transcript(last=20)` for recent captions. Don't re-read the whole transcript every turn.
|
||||
4. **Speak (realtime only)** — `meet_say(text="...")` queues text for TTS. The speech lags by ~2s. Don't spam it.
|
||||
5. **Leave** — `meet_leave()` when done, or set `duration="30m"` on `meet_join` for auto-leave.
|
||||
6. **Follow up** — read `meet_transcript()` in full, summarize, and use regular tools to send the recap, file issues, schedule followups.
|
||||
|
||||
## Tool reference
|
||||
|
||||
| Tool | Parameters | Use |
|
||||
|---|---|---|
|
||||
| `meet_join` | `url`, `mode?`, `guest_name?`, `duration?`, `headed?`, `node?` | Start bot |
|
||||
| `meet_status` | `node?` | Liveness + progress |
|
||||
| `meet_transcript` | `last?`, `node?` | Read captions |
|
||||
| `meet_leave` | `node?` | Close bot |
|
||||
| `meet_say` | `text`, `node?` | Speak in realtime meeting |
|
||||
|
||||
`node?` on all tools: pass a registered node name (or `"auto"` for the sole node) to operate a remote bot instead of a local one. Omit for local.
|
||||
|
||||
## Important limits
|
||||
|
||||
- Captions are only as good as Google Meet's live captions. English-biased, lossy on overlapping speakers.
|
||||
- Guest mode sits in the lobby until a host admits. Warn the user; `hermes meet auth` avoids this.
|
||||
- **Lobby timeout**: if the host doesn't admit the bot within 5 minutes (configurable via `HERMES_MEET_LOBBY_TIMEOUT` env), the bot leaves and `meet_status` reports `leaveReason: "lobby_timeout"`.
|
||||
- **One active meeting per install per location.** A second `meet_join` leaves the first.
|
||||
- **Windows not supported.**
|
||||
- Realtime mode needs a virtual audio device. If the audio bridge setup fails, the bot falls back to transcribe mode and flags it in `meet_status().error`.
|
||||
- `meet_say` requires `mode='realtime'` on the originating `meet_join`. Calling it against a transcribe-mode meeting returns a clear error.
|
||||
- **Barge-in is best-effort.** When a caption arrives attributed to a real participant while the bot is generating audio, the bot sends `response.cancel` to OpenAI Realtime. Captions take ~500ms to show up, so the bot will talk over the first second or so of a human interruption.
|
||||
|
||||
## Status dict reference
|
||||
|
||||
`meet_status()` returns (subset shown, there are more):
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `inCall` | Past the lobby. False while waiting for admission. |
|
||||
| `lobbyWaiting` | Clicked "Ask to join", waiting on host. |
|
||||
| `joinAttemptedAt` / `joinedAt` | Timestamps for lobby-click and actual admission. |
|
||||
| `captioning` | Caption observer is installed. |
|
||||
| `transcriptLines` / `lastCaptionAt` | Transcript progress. |
|
||||
| `realtime` / `realtimeReady` | Realtime mode provisioned / WS connected. |
|
||||
| `realtimeDevice` | Audio device name the bot is feeding (e.g. `hermes_meet_src`). |
|
||||
| `audioBytesOut` / `lastAudioOutAt` | How much PCM the OpenAI session has produced. |
|
||||
| `lastBargeInAt` | Timestamp of the most recent `response.cancel` sent. |
|
||||
| `leaveReason` | `duration_expired`, `lobby_timeout`, `denied`, `page_closed`, or null. |
|
||||
| `error` | Last error (soft — bot may still be running). |
|
||||
|
||||
## Transcript location
|
||||
|
||||
Local:
|
||||
```
|
||||
$HERMES_HOME/workspace/meetings/<meeting-id>/transcript.txt
|
||||
```
|
||||
|
||||
Remote node: transcript lives on the node host's disk. Use `meet_transcript(node=...)` to read it over RPC.
|
||||
|
||||
## Safety
|
||||
|
||||
- URL regex: only `https://meet.google.com/...` URLs pass.
|
||||
- No calendar scanning. No auto-dial.
|
||||
- Remote nodes use bearer-token auth; tokens are generated on the node (32 hex chars, persisted in `$HERMES_HOME/workspace/meetings/node_token.json`) and must be copied to the gateway via `hermes meet node approve`.
|
||||
- `meet_say` text is rate-limited by the OpenAI Realtime session; spam-protection is the bot's problem, not yours, but still — don't queue hundreds of lines.
|
||||
@@ -0,0 +1,103 @@
|
||||
"""google_meet plugin — let the agent join a Meet call, transcribe it, follow up.
|
||||
|
||||
v1: transcribe-only. Spawns a headless Chromium via Playwright, joins the Meet
|
||||
URL, enables live captions, scrapes them into a transcript file. The agent then
|
||||
has the transcript in its workspace and can do whatever followup work it needs
|
||||
using its regular tools.
|
||||
|
||||
v2 (not in this PR): realtime duplex audio so the agent can speak in the
|
||||
meeting, via OpenAI Realtime / Gemini Live + BlackHole / PulseAudio null-sink.
|
||||
``meet_say`` exists as a stub today so the tool surface is stable.
|
||||
|
||||
Explicit-by-design: only joins ``https://meet.google.com/`` URLs explicitly
|
||||
passed in. No calendar scanning, no auto-dial, no consent announcement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import platform
|
||||
|
||||
from plugins.google_meet import process_manager as pm
|
||||
from plugins.google_meet.cli import register_cli as _register_meet_cli
|
||||
from plugins.google_meet.cli import meet_command as _meet_command
|
||||
from plugins.google_meet.tools import (
|
||||
MEET_JOIN_SCHEMA,
|
||||
MEET_LEAVE_SCHEMA,
|
||||
MEET_SAY_SCHEMA,
|
||||
MEET_STATUS_SCHEMA,
|
||||
MEET_TRANSCRIPT_SCHEMA,
|
||||
check_meet_requirements,
|
||||
handle_meet_join,
|
||||
handle_meet_leave,
|
||||
handle_meet_say,
|
||||
handle_meet_status,
|
||||
handle_meet_transcript,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_TOOLS = (
|
||||
("meet_join", MEET_JOIN_SCHEMA, handle_meet_join, "📞"),
|
||||
("meet_status", MEET_STATUS_SCHEMA, handle_meet_status, "🟢"),
|
||||
("meet_transcript", MEET_TRANSCRIPT_SCHEMA, handle_meet_transcript, "📝"),
|
||||
("meet_leave", MEET_LEAVE_SCHEMA, handle_meet_leave, "👋"),
|
||||
("meet_say", MEET_SAY_SCHEMA, handle_meet_say, "🗣️"),
|
||||
)
|
||||
|
||||
|
||||
def _on_session_end(**kwargs) -> None:
|
||||
"""Best-effort cleanup — if a meet bot is still running when the session
|
||||
ends, leave the call so we don't orphan a headless Chromium.
|
||||
|
||||
No-ops when nothing is active. Swallows all exceptions — session end must
|
||||
not fail because the bot cleanup hit an edge case.
|
||||
"""
|
||||
try:
|
||||
status = pm.status()
|
||||
if status.get("ok") and status.get("alive"):
|
||||
pm.stop(reason="session ended")
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.debug("google_meet on_session_end cleanup failed: %s", e)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register tools, CLI, and lifecycle hooks.
|
||||
|
||||
Called once by the plugin loader when the plugin is enabled via
|
||||
``plugins.enabled`` in config.yaml.
|
||||
"""
|
||||
# Windows is not supported in v1 — audio routing for v2 doesn't have a
|
||||
# tested path there and guest-join Chromium is flakier. Refuse to register
|
||||
# rather than half-working.
|
||||
system = platform.system().lower()
|
||||
if system not in {"linux", "darwin"}:
|
||||
logger.info(
|
||||
"google_meet plugin: platform=%s not supported (linux/macos only)",
|
||||
system,
|
||||
)
|
||||
return
|
||||
|
||||
for name, schema, handler, emoji in _TOOLS:
|
||||
ctx.register_tool(
|
||||
name=name,
|
||||
toolset="google_meet",
|
||||
schema=schema,
|
||||
handler=handler,
|
||||
check_fn=check_meet_requirements,
|
||||
emoji=emoji,
|
||||
)
|
||||
|
||||
ctx.register_cli_command(
|
||||
name="meet",
|
||||
help="Google Meet bot (join, transcribe, follow up)",
|
||||
setup_fn=_register_meet_cli,
|
||||
handler_fn=_meet_command,
|
||||
description=(
|
||||
"Let the hermes agent join a Google Meet call and scrape live "
|
||||
"captions into a transcript. See: hermes meet setup"
|
||||
),
|
||||
)
|
||||
|
||||
ctx.register_hook("on_session_end", _on_session_end)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Virtual audio bridge for feeding generated speech into Chrome's mic.
|
||||
|
||||
v2 module. Provisions a platform-specific virtual audio device so the
|
||||
Meet bot's Chromium instance can be pointed at an input source we
|
||||
control. The OpenAI Realtime client writes PCM bytes into this device;
|
||||
Chrome reads them as if they were coming from a microphone.
|
||||
|
||||
Linux (primary): uses pactl (PulseAudio) to create a null-sink plus a
|
||||
virtual source whose master is the null-sink's monitor. Callers set
|
||||
PULSE_SOURCE=<source_name> in Chrome's env and pass the fake-mic flag.
|
||||
|
||||
macOS: requires BlackHole 2ch to be installed. This module only
|
||||
verifies its presence and returns the device name; routing OS default
|
||||
input is left to the user (or a future switchaudio-osx integration) to
|
||||
avoid surprising the user's system audio state.
|
||||
|
||||
Windows: not supported in v2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
|
||||
_BLACKHOLE_DEVICE = "BlackHole 2ch"
|
||||
|
||||
|
||||
class AudioBridge:
|
||||
"""Manages a virtual audio device for Chrome fake-mic input.
|
||||
|
||||
Call ``setup()`` once before launching the Meet bot and
|
||||
``teardown()`` when the session ends. ``teardown()`` is idempotent.
|
||||
"""
|
||||
|
||||
def __init__(self, name_prefix: str = "hermes_meet") -> None:
|
||||
self._name_prefix = name_prefix
|
||||
self._platform: Optional[str] = None
|
||||
self._device_name: Optional[str] = None
|
||||
self._write_target: Optional[str] = None
|
||||
self._module_ids: list[int] = []
|
||||
self._torn_down = False
|
||||
|
||||
# ── public properties ─────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def device_name(self) -> str:
|
||||
if not self._device_name:
|
||||
raise RuntimeError("AudioBridge not set up yet")
|
||||
return self._device_name
|
||||
|
||||
@property
|
||||
def write_target(self) -> str:
|
||||
if not self._write_target:
|
||||
raise RuntimeError("AudioBridge not set up yet")
|
||||
return self._write_target
|
||||
|
||||
# ── lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
def setup(self) -> dict:
|
||||
"""Provision the virtual audio device.
|
||||
|
||||
Returns a dict describing the device. Raises RuntimeError on
|
||||
unsupported platforms or when required system tools are missing.
|
||||
"""
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
return self._setup_linux()
|
||||
if system == "Darwin":
|
||||
return self._setup_darwin()
|
||||
if system == "Windows":
|
||||
raise RuntimeError("windows not supported in v2")
|
||||
raise RuntimeError(f"unsupported platform: {system}")
|
||||
|
||||
def teardown(self) -> None:
|
||||
"""Release the virtual audio device. Idempotent."""
|
||||
if self._torn_down:
|
||||
return
|
||||
# Only Linux needs explicit unloading.
|
||||
if self._platform == "linux" and self._module_ids:
|
||||
# Unload in reverse order (virtual-source before null-sink).
|
||||
for mod_id in reversed(self._module_ids):
|
||||
try:
|
||||
subprocess.run(
|
||||
["pactl", "unload-module", str(mod_id)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
# Best-effort teardown — never raise from here.
|
||||
pass
|
||||
self._module_ids = []
|
||||
self._torn_down = True
|
||||
|
||||
# ── platform impls ────────────────────────────────────────────────────
|
||||
|
||||
def _setup_linux(self) -> dict:
|
||||
sink_name = f"{self._name_prefix}_sink"
|
||||
src_name = f"{self._name_prefix}_src"
|
||||
|
||||
try:
|
||||
sink_out = subprocess.run(
|
||||
[
|
||||
"pactl",
|
||||
"load-module",
|
||||
"module-null-sink",
|
||||
f"sink_name={sink_name}",
|
||||
f"sink_properties=device.description=HermesMeetSink",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
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,
|
||||
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,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"system_profiler not found (macOS-only command)"
|
||||
) from exc
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(
|
||||
f"system_profiler failed: {exc.output}"
|
||||
) from exc
|
||||
|
||||
if "BlackHole" not in out:
|
||||
raise RuntimeError(
|
||||
"BlackHole virtual audio device not installed. "
|
||||
"Install via: brew install blackhole-2ch"
|
||||
)
|
||||
|
||||
self._platform = "darwin"
|
||||
self._device_name = _BLACKHOLE_DEVICE
|
||||
self._write_target = _BLACKHOLE_DEVICE
|
||||
self._module_ids = []
|
||||
self._torn_down = False
|
||||
|
||||
return {
|
||||
"platform": "darwin",
|
||||
"device_name": _BLACKHOLE_DEVICE,
|
||||
"sample_rate": 48000,
|
||||
"channels": 2,
|
||||
"module_ids": [],
|
||||
"write_target": _BLACKHOLE_DEVICE,
|
||||
}
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_module_id(stdout: str) -> int:
|
||||
"""pactl load-module prints the new module ID to stdout."""
|
||||
text = (stdout or "").strip()
|
||||
if not text:
|
||||
raise RuntimeError("pactl load-module returned empty stdout")
|
||||
# Take the last whitespace-separated token on the first non-empty line.
|
||||
first = text.splitlines()[0].strip()
|
||||
token = first.split()[-1]
|
||||
try:
|
||||
return int(token)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(
|
||||
f"could not parse pactl module id from: {stdout!r}"
|
||||
) from exc
|
||||
|
||||
|
||||
def chrome_fake_audio_flags(bridge_info: dict) -> list[str]:
|
||||
"""Return Chrome flags for using the fake audio input.
|
||||
|
||||
The PulseAudio source is selected via the ``PULSE_SOURCE`` env var,
|
||||
which callers must set in Chrome's environment before launch:
|
||||
|
||||
env["PULSE_SOURCE"] = bridge_info["device_name"]
|
||||
|
||||
On macOS the caller must ensure the system default audio input is
|
||||
set to the returned BlackHole device (we do not flip that switch).
|
||||
"""
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
# Chromium on Linux picks up the PulseAudio source selected via
|
||||
# PULSE_SOURCE env var; the fake-ui flag skips the permission
|
||||
# prompt so the bot can pick "use my mic" without user input.
|
||||
return ["--use-fake-ui-for-media-stream"]
|
||||
if system == "Darwin":
|
||||
return ["--use-fake-ui-for-media-stream"]
|
||||
if system == "Windows":
|
||||
raise RuntimeError("windows not supported in v2")
|
||||
raise RuntimeError(f"unsupported platform: {system}")
|
||||
@@ -0,0 +1,477 @@
|
||||
"""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:
|
||||
res = _sp.run(
|
||||
[sys.executable, "-m", "pip", "install", "--upgrade", *pip_pkgs],
|
||||
check=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)
|
||||
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(f"opening Chromium — sign in to Google, then return here and press Enter.")
|
||||
print(f"saving storage state to: {path}")
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=False)
|
||||
context = browser.new_context()
|
||||
page = context.new_page()
|
||||
page.goto("https://accounts.google.com/", wait_until="domcontentloaded")
|
||||
try:
|
||||
input("press Enter after you've signed in ... ")
|
||||
except EOFError:
|
||||
pass
|
||||
context.storage_state(path=str(path))
|
||||
browser.close()
|
||||
except Exception as e:
|
||||
print(f"auth failed: {e}")
|
||||
return 1
|
||||
print("saved. you can now run: hermes meet join <url>")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_join(
|
||||
url: str,
|
||||
*,
|
||||
guest_name: str,
|
||||
duration: Optional[str],
|
||||
headed: bool,
|
||||
mode: str = "transcribe",
|
||||
node: Optional[str] = None,
|
||||
) -> int:
|
||||
if not _is_safe_meet_url(url):
|
||||
print(f"refusing: not a meet.google.com URL: {url}")
|
||||
return 2
|
||||
if node:
|
||||
# Remote: go through NodeClient.
|
||||
try:
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
except ImportError as e:
|
||||
print(f"node module unavailable: {e}")
|
||||
return 1
|
||||
reg = NodeRegistry()
|
||||
entry = reg.resolve(node if node != "auto" else None)
|
||||
if entry is None:
|
||||
print(f"no registered node matches {node!r}")
|
||||
return 1
|
||||
client = NodeClient(url=entry["url"], token=entry["token"])
|
||||
try:
|
||||
res = client.start_bot(
|
||||
url=url, guest_name=guest_name, duration=duration,
|
||||
headed=headed, mode=mode,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"remote start_bot failed: {e}")
|
||||
return 1
|
||||
print(json.dumps({"node": entry.get("name"), **res}, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
auth = _auth_state_path()
|
||||
res = pm.start(
|
||||
url=url,
|
||||
headed=headed,
|
||||
guest_name=guest_name,
|
||||
duration=duration,
|
||||
auth_state=str(auth) if auth.is_file() else None,
|
||||
mode=mode,
|
||||
)
|
||||
print(json.dumps(res, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
|
||||
def _cmd_say(text: str, node: Optional[str] = None) -> int:
|
||||
if not (text or "").strip():
|
||||
print("refusing: empty text")
|
||||
return 2
|
||||
if node:
|
||||
try:
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
except ImportError as e:
|
||||
print(f"node module unavailable: {e}")
|
||||
return 1
|
||||
reg = NodeRegistry()
|
||||
entry = reg.resolve(node if node != "auto" else None)
|
||||
if entry is None:
|
||||
print(f"no registered node matches {node!r}")
|
||||
return 1
|
||||
client = NodeClient(url=entry["url"], token=entry["token"])
|
||||
try:
|
||||
res = client.say(text)
|
||||
except Exception as e:
|
||||
print(f"remote say failed: {e}")
|
||||
return 1
|
||||
print(json.dumps({"node": entry.get("name"), **res}, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
res = pm.enqueue_say(text)
|
||||
print(json.dumps(res, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
|
||||
def _cmd_status() -> int:
|
||||
res = pm.status()
|
||||
print(json.dumps(res, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
|
||||
def _cmd_transcript(last: Optional[int]) -> int:
|
||||
res = pm.transcript(last=last)
|
||||
if not res.get("ok"):
|
||||
print(json.dumps(res, indent=2))
|
||||
return 1
|
||||
for ln in res.get("lines", []):
|
||||
print(ln)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_stop() -> int:
|
||||
res = pm.stop(reason="hermes meet stop")
|
||||
print(json.dumps(res, indent=2))
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
parser = argparse.ArgumentParser(prog="hermes meet")
|
||||
register_cli(parser)
|
||||
ns = parser.parse_args()
|
||||
sys.exit(meet_command(ns))
|
||||
@@ -0,0 +1,858 @@
|
||||
"""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,
|
||||
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", "")
|
||||
realtime_api_key = os.environ.get("HERMES_MEET_REALTIME_KEY") or os.environ.get("OPENAI_API_KEY", "")
|
||||
|
||||
if not url or not _is_safe_meet_url(url):
|
||||
sys.stderr.write(
|
||||
"google_meet bot: refusing to launch — HERMES_MEET_URL must be a "
|
||||
"meet.google.com URL. got: %r\n" % url
|
||||
)
|
||||
return 2
|
||||
if not out_dir_env:
|
||||
sys.stderr.write("google_meet bot: HERMES_MEET_OUT_DIR is required\n")
|
||||
return 2
|
||||
|
||||
out_dir = Path(out_dir_env)
|
||||
meeting_id = _meeting_id_from_url(url)
|
||||
state = _BotState(out_dir=out_dir, meeting_id=meeting_id, url=url)
|
||||
|
||||
# SIGTERM → exit cleanly so the parent ``meet_leave`` gets a finalized
|
||||
# transcript. We set a flag instead of raising so the Playwright context
|
||||
# teardown runs in the finally block below.
|
||||
stop_flag = {"stop": False}
|
||||
|
||||
def _on_signal(_sig, _frame):
|
||||
stop_flag["stop"] = True
|
||||
|
||||
signal.signal(signal.SIGTERM, _on_signal)
|
||||
signal.signal(signal.SIGINT, _on_signal)
|
||||
|
||||
# v2 realtime: provision virtual audio device + start speaker thread.
|
||||
# We track these in a dict so the finally block can tear them down
|
||||
# regardless of how we exit. If anything in the realtime setup fails we
|
||||
# fall back to transcribe mode with a status flag.
|
||||
rt = {
|
||||
"enabled": mode == "realtime",
|
||||
"bridge": None, # AudioBridge | None
|
||||
"bridge_info": None, # dict | None
|
||||
"session": None, # RealtimeSession | None
|
||||
"speaker_thread": None, # threading.Thread | None
|
||||
"speaker_stop": None, # callable | None
|
||||
}
|
||||
if rt["enabled"]:
|
||||
if not realtime_api_key:
|
||||
state.set(error="realtime mode requested but no API key in HERMES_MEET_REALTIME_KEY/OPENAI_API_KEY — falling back to transcribe")
|
||||
rt["enabled"] = False
|
||||
else:
|
||||
try:
|
||||
from plugins.google_meet.audio_bridge import AudioBridge
|
||||
bridge = AudioBridge()
|
||||
rt["bridge_info"] = bridge.setup()
|
||||
rt["bridge"] = bridge
|
||||
state.set(realtime=True, realtime_device=rt["bridge_info"].get("device_name"))
|
||||
except Exception as e:
|
||||
state.set(error=f"audio bridge setup failed: {e} — falling back to transcribe")
|
||||
rt["enabled"] = False
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError as e:
|
||||
state.set(error=f"playwright not installed: {e}", exited=True)
|
||||
sys.stderr.write(
|
||||
"google_meet bot: playwright is not installed. Run "
|
||||
"`pip install playwright && python -m playwright install chromium`\n"
|
||||
)
|
||||
if rt["bridge"]:
|
||||
rt["bridge"].teardown()
|
||||
return 3
|
||||
|
||||
# Chrome env: if realtime is live on Linux, point PULSE_SOURCE at the
|
||||
# virtual source so Chrome's fake mic reads the audio we generate.
|
||||
chrome_env = os.environ.copy()
|
||||
chrome_args = [
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
]
|
||||
if not rt["enabled"]:
|
||||
# v1-style fake device (silence) — we don't care about mic content
|
||||
# when we're not speaking.
|
||||
chrome_args.insert(1, "--use-fake-device-for-media-stream")
|
||||
elif rt["bridge_info"] and rt["bridge_info"].get("platform") == "linux":
|
||||
chrome_env["PULSE_SOURCE"] = rt["bridge_info"].get("device_name", "")
|
||||
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
# Playwright's launch() doesn't take env; we set PULSE_SOURCE
|
||||
# via the process env before launch so the child Chrome inherits it.
|
||||
for k, v in chrome_env.items():
|
||||
os.environ[k] = v
|
||||
browser = pw.chromium.launch(
|
||||
headless=not headed,
|
||||
args=chrome_args,
|
||||
)
|
||||
context_args = {
|
||||
"viewport": {"width": 1280, "height": 800},
|
||||
"user_agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
),
|
||||
"permissions": ["microphone", "camera"],
|
||||
}
|
||||
if auth_state and Path(auth_state).is_file():
|
||||
context_args["storage_state"] = auth_state
|
||||
context = browser.new_context(**context_args)
|
||||
page = context.new_page()
|
||||
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=30_000)
|
||||
except Exception as e:
|
||||
state.set(error=f"navigate failed: {e}", exited=True)
|
||||
return 4
|
||||
|
||||
# Guest-mode: Meet shows a name field before "Ask to join". When
|
||||
# we're authed, we instead see "Join now".
|
||||
_try_guest_name(page, guest_name)
|
||||
_click_join(page, state)
|
||||
|
||||
# Install caption observer and attempt to enable captions.
|
||||
try:
|
||||
page.evaluate(_enable_captions_js())
|
||||
state.set(captions_enabled_attempted=True)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
page.evaluate(_CAPTION_OBSERVER_JS)
|
||||
except Exception as e:
|
||||
state.set(error=f"caption observer install failed: {e}")
|
||||
|
||||
# Note: in_call=False until admission is confirmed (we detect
|
||||
# either the Leave button or the caption region, signalling we
|
||||
# made it past the lobby).
|
||||
state.set(captioning=True, join_attempted_at=time.time())
|
||||
|
||||
# v2 realtime: start the speaker thread reading from the
|
||||
# plugin-side say queue. The thread reads JSONL lines written by
|
||||
# meet_say, calls OpenAI Realtime, and streams the audio PCM to
|
||||
# the virtual sink that Chrome's fake-mic is pointed at.
|
||||
if rt["enabled"]:
|
||||
_start_realtime_speaker(
|
||||
rt=rt,
|
||||
out_dir=out_dir,
|
||||
bridge_info=rt["bridge_info"],
|
||||
api_key=realtime_api_key,
|
||||
model=realtime_model,
|
||||
voice=realtime_voice,
|
||||
instructions=realtime_instructions,
|
||||
stop_flag=stop_flag,
|
||||
state=state,
|
||||
)
|
||||
if rt["session"] is not None:
|
||||
state.set(realtime_ready=True)
|
||||
|
||||
# Admission + drain loop. Runs until SIGTERM, duration expiry,
|
||||
# or the page detects "You were removed / you left the
|
||||
# meeting". Responsible for:
|
||||
# * detecting admission (Leave button visible → in_call=True)
|
||||
# * timing out stuck-in-lobby (default 5 minutes)
|
||||
# * draining scraped captions into the transcript
|
||||
# * triggering realtime barge-in when a human speaks while
|
||||
# the bot is generating audio
|
||||
# * periodically flushing realtime counters into status.json
|
||||
deadline = (time.time() + duration_s) if duration_s else None
|
||||
lobby_deadline = time.time() + float(
|
||||
os.environ.get("HERMES_MEET_LOBBY_TIMEOUT", "300")
|
||||
)
|
||||
last_admission_check = 0.0
|
||||
while not stop_flag["stop"]:
|
||||
now = time.time()
|
||||
if deadline and now > deadline:
|
||||
state.set(leave_reason="duration_expired")
|
||||
break
|
||||
|
||||
# Admission detection every ~3s until admitted.
|
||||
if not state.in_call and (now - last_admission_check) > 3.0:
|
||||
last_admission_check = now
|
||||
admitted = _detect_admission(page)
|
||||
if admitted:
|
||||
state.set(
|
||||
in_call=True,
|
||||
lobby_waiting=False,
|
||||
joined_at=now,
|
||||
)
|
||||
elif now > lobby_deadline:
|
||||
state.set(
|
||||
error=(
|
||||
"lobby timeout — host never admitted the bot "
|
||||
f"within {int(lobby_deadline - state.join_attempted_at) if state.join_attempted_at else 0}s"
|
||||
),
|
||||
leave_reason="lobby_timeout",
|
||||
)
|
||||
break
|
||||
elif _detect_denied(page):
|
||||
state.set(
|
||||
error="host denied admission",
|
||||
leave_reason="denied",
|
||||
)
|
||||
break
|
||||
|
||||
try:
|
||||
queued = page.evaluate("window.__hermesMeetDrain && window.__hermesMeetDrain()")
|
||||
if isinstance(queued, list):
|
||||
for entry in queued:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
speaker = str(entry.get("speaker", ""))
|
||||
text = str(entry.get("text", ""))
|
||||
state.record_caption(speaker=speaker, text=text)
|
||||
# Barge-in: if the bot is currently generating
|
||||
# audio AND a real human just spoke, cancel the
|
||||
# in-flight response so we don't talk over them.
|
||||
if rt["enabled"] and rt["session"] is not None:
|
||||
if _looks_like_human_speaker(speaker, guest_name):
|
||||
try:
|
||||
cancelled = rt["session"].cancel_response()
|
||||
if cancelled:
|
||||
state.set(last_barge_in_at=now)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# Meet reloaded or we got booted — try to detect and
|
||||
# exit gracefully rather than spinning.
|
||||
if page.is_closed():
|
||||
state.set(leave_reason="page_closed")
|
||||
break
|
||||
|
||||
# Fold the realtime session's byte/timestamp counters into
|
||||
# the status file so meet_status can surface them.
|
||||
if rt["session"] is not None:
|
||||
state.set(
|
||||
audio_bytes_out=getattr(rt["session"], "audio_bytes_out", 0),
|
||||
last_audio_out_at=getattr(rt["session"], "last_audio_out_at", None),
|
||||
)
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
# Try to leave cleanly — click "Leave call" button if present.
|
||||
try:
|
||||
page.evaluate(
|
||||
"() => { const b = document.querySelector('button[aria-label*=\"eave call\"]');"
|
||||
" if (b) b.click(); }"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context.close()
|
||||
browser.close()
|
||||
# v2: teardown PCM pump, speaker thread, and audio bridge.
|
||||
if rt.get("pcm_pump"):
|
||||
try:
|
||||
rt["pcm_pump"].terminate()
|
||||
rt["pcm_pump"].wait(timeout=3)
|
||||
except Exception:
|
||||
pass
|
||||
if rt["speaker_stop"]:
|
||||
try:
|
||||
rt["speaker_stop"]()
|
||||
except Exception:
|
||||
pass
|
||||
if rt["speaker_thread"] is not None:
|
||||
try:
|
||||
rt["speaker_thread"].join(timeout=5.0)
|
||||
except Exception:
|
||||
pass
|
||||
if rt["session"]:
|
||||
try:
|
||||
rt["session"].close()
|
||||
except Exception:
|
||||
pass
|
||||
if rt["bridge"]:
|
||||
try:
|
||||
rt["bridge"].teardown()
|
||||
except Exception:
|
||||
pass
|
||||
state.set(in_call=False, captioning=False, exited=True)
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
state.set(error=f"unhandled: {e}", exited=True)
|
||||
return 1
|
||||
|
||||
|
||||
def _try_guest_name(page, guest_name: str) -> None:
|
||||
"""If Meet is showing a guest-name input, type *guest_name* into it."""
|
||||
try:
|
||||
# Meet's guest name input has placeholder "Your name".
|
||||
locator = page.locator('input[aria-label*="name" i]').first
|
||||
if locator.count() and locator.is_visible():
|
||||
locator.fill(guest_name, timeout=2_000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _detect_admission(page) -> bool:
|
||||
"""True if we're clearly past the lobby and in the call itself.
|
||||
|
||||
Uses a JS-side probe because Meet's DOM structure varies by client
|
||||
version. We check several high-signal indicators and declare admission
|
||||
on the first hit:
|
||||
|
||||
1. Leave-call button is present (``aria-label`` contains "eave call").
|
||||
2. Caption region has appeared (we installed the observer and it attached).
|
||||
3. The participant list container is visible.
|
||||
|
||||
Conservative by default — returns False on any error.
|
||||
"""
|
||||
probe = r"""
|
||||
(() => {
|
||||
const leave = document.querySelector('button[aria-label*="eave call" i]');
|
||||
if (leave) return true;
|
||||
if (window.__hermesMeetInstalled) {
|
||||
const caps = document.querySelector(
|
||||
'[role="region"][aria-label*="aption" i], ' +
|
||||
'div[jsname="YSxPC"], div[jsname="tgaKEf"]'
|
||||
);
|
||||
if (caps) return true;
|
||||
}
|
||||
const parts = document.querySelector('[aria-label*="articipants" i]');
|
||||
if (parts) return true;
|
||||
return false;
|
||||
})();
|
||||
"""
|
||||
try:
|
||||
return bool(page.evaluate(probe))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _detect_denied(page) -> bool:
|
||||
"""True when Meet is showing a 'you were denied' / 'no one admitted' page."""
|
||||
probe = r"""
|
||||
(() => {
|
||||
const text = document.body ? document.body.innerText || '' : '';
|
||||
// English only — matches what shows up when the host denies or
|
||||
// removes a guest.
|
||||
if (/You can't join this video call/i.test(text)) return true;
|
||||
if (/You were removed from the meeting/i.test(text)) return true;
|
||||
if (/No one responded to your request to join/i.test(text)) return true;
|
||||
return false;
|
||||
})();
|
||||
"""
|
||||
try:
|
||||
return bool(page.evaluate(probe))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _looks_like_human_speaker(speaker: str, bot_guest_name: str) -> bool:
|
||||
"""Whether a caption line's speaker is probably a human, not our bot echo.
|
||||
|
||||
Meet attributes captions to the speaker's display name. When Chrome is
|
||||
reading our fake mic, Meet still attributes captions to *our* bot name
|
||||
(because the bot is the one "speaking"). We don't want those to trigger
|
||||
barge-in. Anything else — real participant names — does.
|
||||
|
||||
Conservative: unknown / blank speakers (common when caption scraping
|
||||
falls back to raw text) do NOT trigger barge-in, because we can't tell
|
||||
whether it was a human or us.
|
||||
"""
|
||||
if not speaker or not speaker.strip():
|
||||
return False
|
||||
spk = speaker.strip().lower()
|
||||
if spk in {"unknown", "you", bot_guest_name.strip().lower()}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _click_join(page, state: _BotState) -> None:
|
||||
"""Click 'Join now' or 'Ask to join' if either button is visible.
|
||||
|
||||
Flags ``lobby_waiting`` when we hit the "waiting for host to admit you"
|
||||
state so the agent can surface that in status.
|
||||
"""
|
||||
for label in ("Join now", "Ask to join"):
|
||||
try:
|
||||
btn = page.get_by_role("button", name=label, exact=False).first
|
||||
if btn.count() and btn.is_visible():
|
||||
btn.click(timeout=3_000)
|
||||
if label == "Ask to join":
|
||||
state.set(lobby_waiting=True)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _parse_duration(raw: str) -> Optional[float]:
|
||||
"""Parse ``30m`` / ``2h`` / ``90`` (seconds) → float seconds, or None."""
|
||||
if not raw:
|
||||
return None
|
||||
raw = raw.strip().lower()
|
||||
try:
|
||||
if raw.endswith("h"):
|
||||
return float(raw[:-1]) * 3600
|
||||
if raw.endswith("m"):
|
||||
return float(raw[:-1]) * 60
|
||||
if raw.endswith("s"):
|
||||
return float(raw[:-1])
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover — subprocess entry point
|
||||
sys.exit(run_bot())
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Remote 'node host' primitive for the google_meet plugin.
|
||||
|
||||
Lets the Meet bot (Playwright + Chrome) run on a different machine than
|
||||
the hermes-agent gateway. The gateway speaks a small JSON-over-WebSocket
|
||||
RPC protocol to the remote node; the node wraps the existing
|
||||
``plugins.google_meet.process_manager`` API.
|
||||
|
||||
Topology
|
||||
--------
|
||||
gateway (Linux) ── ws://mac.local:18789 ──▶ node server (Mac)
|
||||
└─ process_manager
|
||||
└─ meet_bot (Playwright)
|
||||
|
||||
Why: Google sign-in + Chrome profile live on the user's laptop. Running
|
||||
the bot there reuses that profile without shipping credentials to the
|
||||
server.
|
||||
|
||||
Public surface
|
||||
--------------
|
||||
NodeClient — gateway-side RPC client (short-lived sync WS per call)
|
||||
NodeServer — long-running server that hosts the bot
|
||||
NodeRegistry — local JSON registry of approved nodes (name → url+token)
|
||||
protocol — message envelope helpers (make_request, encode, decode, ...)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from plugins.google_meet.node import protocol
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
from plugins.google_meet.node.protocol import (
|
||||
VALID_REQUEST_TYPES,
|
||||
decode,
|
||||
encode,
|
||||
make_error,
|
||||
make_request,
|
||||
make_response,
|
||||
validate_request,
|
||||
)
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
|
||||
__all__ = [
|
||||
"NodeClient",
|
||||
"NodeServer",
|
||||
"NodeRegistry",
|
||||
"protocol",
|
||||
"make_request",
|
||||
"make_response",
|
||||
"make_error",
|
||||
"encode",
|
||||
"decode",
|
||||
"validate_request",
|
||||
"VALID_REQUEST_TYPES",
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""`hermes meet node ...` subcommand tree.
|
||||
|
||||
Wired into the existing ``hermes meet`` parser by the plugin's top-level
|
||||
CLI. This module only defines the subparsers and their dispatch — it
|
||||
does not mutate the existing cli.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node.server import NodeServer
|
||||
|
||||
|
||||
def register_cli(subparser: argparse.ArgumentParser) -> None:
|
||||
"""Add ``run / list / approve / remove / status / ping`` subparsers.
|
||||
|
||||
*subparser* is the ``hermes meet node`` argparse object — typically
|
||||
the result of ``meet_parser.add_parser('node', ...)``.
|
||||
"""
|
||||
sp = subparser.add_subparsers(dest="node_cmd", required=True)
|
||||
|
||||
run = sp.add_parser("run", help="Start a node server on this machine.")
|
||||
run.add_argument("--host", default="0.0.0.0")
|
||||
run.add_argument("--port", type=int, default=18789)
|
||||
run.add_argument("--display-name", default="hermes-meet-node")
|
||||
run.set_defaults(func=node_command)
|
||||
|
||||
lst = sp.add_parser("list", help="List approved remote nodes.")
|
||||
lst.set_defaults(func=node_command)
|
||||
|
||||
app = sp.add_parser("approve", help="Register a remote node on the gateway.")
|
||||
app.add_argument("name")
|
||||
app.add_argument("url")
|
||||
app.add_argument("token")
|
||||
app.set_defaults(func=node_command)
|
||||
|
||||
rm = sp.add_parser("remove", help="Forget a registered node.")
|
||||
rm.add_argument("name")
|
||||
rm.set_defaults(func=node_command)
|
||||
|
||||
st = sp.add_parser("status", help="Ping a registered node.")
|
||||
st.add_argument("name")
|
||||
st.set_defaults(func=node_command)
|
||||
|
||||
pg = sp.add_parser("ping", help="Alias for status.")
|
||||
pg.add_argument("name")
|
||||
pg.set_defaults(func=node_command)
|
||||
|
||||
|
||||
def node_command(args: argparse.Namespace) -> int:
|
||||
"""Dispatch for ``hermes meet node ...``.
|
||||
|
||||
Returns a process exit code. Side-effects print to stdout/stderr.
|
||||
"""
|
||||
cmd = getattr(args, "node_cmd", None)
|
||||
|
||||
if cmd == "run":
|
||||
server = NodeServer(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
display_name=args.display_name,
|
||||
)
|
||||
token = server.ensure_token()
|
||||
print(f"[meet-node] display_name={server.display_name}")
|
||||
print(f"[meet-node] listening on ws://{args.host}:{args.port}")
|
||||
print(f"[meet-node] token (copy to gateway): {token}")
|
||||
print(f"[meet-node] approve with:")
|
||||
print(f" hermes meet node approve <name> ws://<host>:{args.port} {token}")
|
||||
try:
|
||||
asyncio.run(server.serve())
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(f"[meet-node] error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
reg = NodeRegistry()
|
||||
|
||||
if cmd == "list":
|
||||
nodes = reg.list_all()
|
||||
if not nodes:
|
||||
print("no nodes registered")
|
||||
return 0
|
||||
for n in nodes:
|
||||
print(f"{n['name']}\t{n['url']}\ttoken={n['token'][:6]}…")
|
||||
return 0
|
||||
|
||||
if cmd == "approve":
|
||||
reg.add(args.name, args.url, args.token)
|
||||
print(f"approved node {args.name!r} at {args.url}")
|
||||
return 0
|
||||
|
||||
if cmd == "remove":
|
||||
ok = reg.remove(args.name)
|
||||
print(f"removed {args.name!r}" if ok else f"no such node: {args.name!r}")
|
||||
return 0 if ok else 1
|
||||
|
||||
if cmd in {"status", "ping"}:
|
||||
entry = reg.get(args.name)
|
||||
if entry is None:
|
||||
print(f"no such node: {args.name!r}", file=sys.stderr)
|
||||
return 1
|
||||
client = NodeClient(entry["url"], entry["token"])
|
||||
try:
|
||||
result = client.ping()
|
||||
except Exception as exc: # noqa: BLE001 — surface any connection error
|
||||
print(json.dumps({"ok": False, "error": str(exc)}))
|
||||
return 1
|
||||
print(json.dumps({"ok": True, "node": args.name, **_coerce_dict(result)}))
|
||||
return 0
|
||||
|
||||
print(f"unknown node command: {cmd!r}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def _coerce_dict(value: Any) -> dict:
|
||||
return value if isinstance(value, dict) else {"result": value}
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Gateway-side RPC client for a remote meet node.
|
||||
|
||||
Each call opens a short-lived synchronous WebSocket to the node, sends
|
||||
exactly one request, reads exactly one response, and closes. This keeps
|
||||
the client trivial to use from non-async tool handlers and avoids
|
||||
maintaining persistent connection state across agent turns.
|
||||
|
||||
The ``websockets`` package is an optional dep — we import it lazily so
|
||||
plugin load doesn't require it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from plugins.google_meet.node import protocol as _proto
|
||||
|
||||
|
||||
class NodeClient:
|
||||
"""Thin synchronous WS client matching the server's request surface."""
|
||||
|
||||
def __init__(self, url: str, token: str, timeout: float = 10.0) -> None:
|
||||
if not isinstance(url, str) or not url:
|
||||
raise ValueError("url must be a non-empty string")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string")
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.timeout = float(timeout)
|
||||
|
||||
# ----- core RPC -----------------------------------------------------
|
||||
|
||||
def _rpc(self, type: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Send one request, return the response payload dict.
|
||||
|
||||
Raises RuntimeError when the server sends an ``error`` envelope
|
||||
or the response id doesn't match.
|
||||
"""
|
||||
try:
|
||||
from websockets.sync.client import connect # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"NodeClient requires the 'websockets' package. "
|
||||
"Install it with: pip install websockets"
|
||||
) from exc
|
||||
|
||||
req = _proto.make_request(type, self.token, payload)
|
||||
raw_out = _proto.encode(req)
|
||||
|
||||
with connect(self.url, open_timeout=self.timeout,
|
||||
close_timeout=self.timeout) as ws:
|
||||
ws.send(raw_out)
|
||||
raw_in = ws.recv(timeout=self.timeout)
|
||||
|
||||
if isinstance(raw_in, (bytes, bytearray)):
|
||||
raw_in = raw_in.decode("utf-8")
|
||||
resp = _proto.decode(raw_in)
|
||||
|
||||
if resp.get("type") == "error":
|
||||
raise RuntimeError(f"node error: {resp.get('error', '<unknown>')}")
|
||||
if resp.get("id") != req["id"]:
|
||||
raise RuntimeError(
|
||||
f"response id mismatch: sent {req['id']}, got {resp.get('id')!r}"
|
||||
)
|
||||
payload_out = resp.get("payload")
|
||||
if not isinstance(payload_out, dict):
|
||||
# Ping returns {"type": "pong", "payload": {...}} — still a dict.
|
||||
raise RuntimeError("response missing payload dict")
|
||||
return payload_out
|
||||
|
||||
# ----- convenience methods -----------------------------------------
|
||||
|
||||
def start_bot(
|
||||
self,
|
||||
url: str,
|
||||
guest_name: str = "Hermes Agent",
|
||||
duration: Optional[str] = None,
|
||||
headed: bool = False,
|
||||
mode: str = "transcribe",
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"url": url,
|
||||
"guest_name": guest_name,
|
||||
"headed": bool(headed),
|
||||
"mode": mode,
|
||||
}
|
||||
if duration is not None:
|
||||
payload["duration"] = duration
|
||||
return self._rpc("start_bot", payload)
|
||||
|
||||
def stop(self) -> Dict[str, Any]:
|
||||
return self._rpc("stop", {})
|
||||
|
||||
def status(self) -> Dict[str, Any]:
|
||||
return self._rpc("status", {})
|
||||
|
||||
def transcript(self, last: Optional[int] = None) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {}
|
||||
if last is not None:
|
||||
payload["last"] = int(last)
|
||||
return self._rpc("transcript", payload)
|
||||
|
||||
def say(self, text: str) -> Dict[str, Any]:
|
||||
return self._rpc("say", {"text": str(text)})
|
||||
|
||||
def ping(self) -> Dict[str, Any]:
|
||||
return self._rpc("ping", {})
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Wire protocol for gateway ↔ node RPC.
|
||||
|
||||
Everything is a JSON object with the same envelope shape:
|
||||
|
||||
Request: {"type": <str>, "id": <str>, "token": <str>, "payload": <dict>}
|
||||
Response: {"type": "<req-type>_res", "id": <req-id>, "payload": <dict>}
|
||||
Error: {"type": "error", "id": <req-id>, "error": <str>}
|
||||
|
||||
Requests must carry the shared bearer token (set up via
|
||||
``hermes meet node approve`` on the gateway and read off disk on the
|
||||
server). Mismatched tokens are rejected before dispatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
|
||||
VALID_REQUEST_TYPES = frozenset({
|
||||
"start_bot",
|
||||
"stop",
|
||||
"status",
|
||||
"transcript",
|
||||
"say",
|
||||
"ping",
|
||||
})
|
||||
|
||||
|
||||
def make_request(
|
||||
type: str,
|
||||
token: str,
|
||||
payload: Dict[str, Any],
|
||||
req_id: str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Construct a request envelope.
|
||||
|
||||
``req_id`` is auto-generated (uuid4 hex) when not supplied so callers
|
||||
can correlate async responses.
|
||||
"""
|
||||
if not isinstance(type, str) or not type:
|
||||
raise ValueError("type must be a non-empty string")
|
||||
if type not in VALID_REQUEST_TYPES:
|
||||
raise ValueError(f"unknown request type: {type!r}")
|
||||
if not isinstance(token, str):
|
||||
raise ValueError("token must be a string")
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload must be a dict")
|
||||
return {
|
||||
"type": type,
|
||||
"id": req_id or uuid.uuid4().hex,
|
||||
"token": token,
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
|
||||
def make_response(req_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build a success response. The caller supplies the *request* type;
|
||||
we suffix it with ``_res`` so clients can assert they got the right
|
||||
reply.
|
||||
|
||||
For simplicity we don't require the type here — clients usually just
|
||||
key off ``id``. But we still emit a generic ``*_res`` envelope.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload must be a dict")
|
||||
return {"type": "response", "id": req_id, "payload": payload}
|
||||
|
||||
|
||||
def make_error(req_id: str, error: str) -> Dict[str, Any]:
|
||||
return {"type": "error", "id": req_id, "error": str(error)}
|
||||
|
||||
|
||||
def encode(msg: Dict[str, Any]) -> str:
|
||||
"""Serialize a message envelope to a JSON string."""
|
||||
return json.dumps(msg, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def decode(raw: str) -> Dict[str, Any]:
|
||||
"""Parse a JSON envelope, raising ValueError on anything malformed.
|
||||
|
||||
Minimal type validation: must be an object, must contain ``type`` and
|
||||
``id``. Heavier validation (token match, payload shape) happens in
|
||||
:func:`validate_request` on the server side.
|
||||
"""
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"malformed JSON: {exc}") from exc
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("envelope must be a JSON object")
|
||||
if "type" not in obj or not isinstance(obj["type"], str):
|
||||
raise ValueError("envelope missing string 'type'")
|
||||
if "id" not in obj or not isinstance(obj["id"], str):
|
||||
raise ValueError("envelope missing string 'id'")
|
||||
return obj
|
||||
|
||||
|
||||
def validate_request(msg: Dict[str, Any], expected_token: str) -> Tuple[bool, str]:
|
||||
"""Check a decoded request against the server's shared token.
|
||||
|
||||
Returns ``(True, "")`` when the envelope is acceptable or
|
||||
``(False, <reason>)`` otherwise. Reason strings are safe to surface
|
||||
back to the client in an error envelope.
|
||||
"""
|
||||
if not isinstance(msg, dict):
|
||||
return False, "envelope must be a dict"
|
||||
t = msg.get("type")
|
||||
if not isinstance(t, str) or not t:
|
||||
return False, "missing or non-string 'type'"
|
||||
if t not in VALID_REQUEST_TYPES:
|
||||
return False, f"unknown request type: {t!r}"
|
||||
if not isinstance(msg.get("id"), str) or not msg.get("id"):
|
||||
return False, "missing or non-string 'id'"
|
||||
token = msg.get("token")
|
||||
if not isinstance(token, str) or not token:
|
||||
return False, "missing token"
|
||||
if token != expected_token:
|
||||
return False, "token mismatch"
|
||||
payload = msg.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
return False, "payload must be a dict"
|
||||
return True, ""
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Local JSON registry of approved remote meet nodes.
|
||||
|
||||
Lives at ``$HERMES_HOME/workspace/meetings/nodes.json``. The gateway
|
||||
consults it to resolve a ``chrome_node`` name to a ``(url, token)`` pair
|
||||
before opening a WebSocket to the remote bot host.
|
||||
|
||||
Schema
|
||||
------
|
||||
{
|
||||
"nodes": {
|
||||
"<name>": {
|
||||
"url": "ws://host:port",
|
||||
"token": "...",
|
||||
"added_at": <epoch_float>
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
def _default_path() -> Path:
|
||||
return Path(get_hermes_home()) / "workspace" / "meetings" / "nodes.json"
|
||||
|
||||
|
||||
class NodeRegistry:
|
||||
"""Simple file-backed registry. Not concurrent-safe across processes
|
||||
— single writer assumed (the gateway CLI)."""
|
||||
|
||||
def __init__(self, path: Optional[Path] = None) -> None:
|
||||
self.path = Path(path) if path is not None else _default_path()
|
||||
|
||||
# ----- storage ------------------------------------------------------
|
||||
|
||||
def _load(self) -> Dict[str, Any]:
|
||||
if not self.path.is_file():
|
||||
return {"nodes": {}}
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {"nodes": {}}
|
||||
if not isinstance(data, dict) or not isinstance(data.get("nodes"), dict):
|
||||
return {"nodes": {}}
|
||||
return data
|
||||
|
||||
def _save(self, data: Dict[str, Any]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
tmp.replace(self.path)
|
||||
|
||||
# ----- public API ---------------------------------------------------
|
||||
|
||||
def get(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
data = self._load()
|
||||
entry = data["nodes"].get(name)
|
||||
if entry is None:
|
||||
return None
|
||||
return {"name": name, **entry}
|
||||
|
||||
def add(self, name: str, url: str, token: str) -> None:
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ValueError("node name must be a non-empty string")
|
||||
if not isinstance(url, str) or not url:
|
||||
raise ValueError("url must be a non-empty string")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ValueError("token must be a non-empty string")
|
||||
data = self._load()
|
||||
data["nodes"][name] = {
|
||||
"url": url,
|
||||
"token": token,
|
||||
"added_at": time.time(),
|
||||
}
|
||||
self._save(data)
|
||||
|
||||
def remove(self, name: str) -> bool:
|
||||
data = self._load()
|
||||
if name in data["nodes"]:
|
||||
del data["nodes"][name]
|
||||
self._save(data)
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_all(self) -> List[Dict[str, Any]]:
|
||||
data = self._load()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for name, entry in sorted(data["nodes"].items()):
|
||||
out.append({"name": name, **entry})
|
||||
return out
|
||||
|
||||
def resolve(self, chrome_node: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve a node name to its entry.
|
||||
|
||||
If ``chrome_node`` is provided, return that named node (or None).
|
||||
If ``chrome_node`` is None, return the sole registered node when
|
||||
exactly one is registered; otherwise return None (ambiguous or
|
||||
empty).
|
||||
"""
|
||||
if chrome_node:
|
||||
return self.get(chrome_node)
|
||||
nodes = self.list_all()
|
||||
if len(nodes) == 1:
|
||||
return nodes[0]
|
||||
return None
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Remote node server.
|
||||
|
||||
Runs on the machine that will host the Meet bot (typically the user's
|
||||
Mac laptop with a signed-in Chrome). Exposes a WebSocket endpoint that
|
||||
accepts signed RPC requests and dispatches them to the existing
|
||||
``plugins.google_meet.process_manager`` module.
|
||||
|
||||
Launched by ``hermes meet node run``.
|
||||
|
||||
Token handling
|
||||
--------------
|
||||
On first boot we mint 32 hex chars of entropy and persist them at
|
||||
``$HERMES_HOME/workspace/meetings/node_token.json``. Subsequent boots
|
||||
reuse the same token so previously-approved gateways don't need to be
|
||||
re-paired. The operator copies this token out-of-band to the gateway
|
||||
via ``hermes meet node approve <name> <url> <token>``.
|
||||
|
||||
Dependencies
|
||||
------------
|
||||
``websockets`` is an optional dep. We import it lazily inside
|
||||
:meth:`serve` so installing the plugin doesn't require it unless you
|
||||
actually host a node.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from plugins.google_meet.node import protocol as _proto
|
||||
|
||||
|
||||
def _default_token_path() -> Path:
|
||||
return Path(get_hermes_home()) / "workspace" / "meetings" / "node_token.json"
|
||||
|
||||
|
||||
class NodeServer:
|
||||
"""WebSocket server that executes meet bot RPCs locally."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 18789,
|
||||
token_path: Optional[Path] = None,
|
||||
display_name: str = "hermes-meet-node",
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.display_name = display_name
|
||||
self.token_path = Path(token_path) if token_path is not None else _default_token_path()
|
||||
self._token: Optional[str] = None
|
||||
|
||||
# ----- token management --------------------------------------------
|
||||
|
||||
def ensure_token(self) -> str:
|
||||
"""Return the persisted shared secret, generating one on first use."""
|
||||
if self._token:
|
||||
return self._token
|
||||
if self.token_path.is_file():
|
||||
try:
|
||||
data = json.loads(self.token_path.read_text(encoding="utf-8"))
|
||||
tok = data.get("token")
|
||||
if isinstance(tok, str) and tok:
|
||||
self._token = tok
|
||||
return tok
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
tok = secrets.token_hex(16) # 32 hex chars
|
||||
self.token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.token_path.with_suffix(".json.tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"token": tok, "generated_at": time.time()}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Restrict to owner-read-write only — the token grants full RPC
|
||||
# access to the meet bot (start, transcribe, speak in meetings).
|
||||
try:
|
||||
tmp.chmod(0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
# Best-effort on non-POSIX filesystems; mode is set on POSIX.
|
||||
pass
|
||||
tmp.replace(self.token_path)
|
||||
self._token = tok
|
||||
return tok
|
||||
|
||||
def get_token(self) -> str:
|
||||
"""Alias for :meth:`ensure_token`; does not mutate on subsequent calls."""
|
||||
return self.ensure_token()
|
||||
|
||||
# ----- dispatch -----------------------------------------------------
|
||||
|
||||
async def _handle_request(self, msg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Validate + dispatch a single decoded request envelope.
|
||||
|
||||
Always returns a response envelope (success or error); never
|
||||
raises. Errors from inside the process_manager are wrapped into
|
||||
the response payload's ``ok``/``error`` keys (which pm already
|
||||
does) rather than being re-encoded as error envelopes — the
|
||||
envelope-level error channel is reserved for auth / protocol
|
||||
failures.
|
||||
"""
|
||||
expected = self.ensure_token()
|
||||
ok, reason = _proto.validate_request(msg, expected)
|
||||
if not ok:
|
||||
return _proto.make_error(str(msg.get("id") or ""), reason)
|
||||
|
||||
req_id = msg["id"]
|
||||
t = msg["type"]
|
||||
payload = msg["payload"]
|
||||
|
||||
# Import lazily so test mocks can monkeypatch freely.
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
try:
|
||||
if t == "ping":
|
||||
return {"type": "pong", "id": req_id,
|
||||
"payload": {"display_name": self.display_name,
|
||||
"ts": time.time()}}
|
||||
if t == "start_bot":
|
||||
# Whitelist kwargs we pass through to pm.start.
|
||||
kwargs = {
|
||||
k: payload[k]
|
||||
for k in ("url", "guest_name", "duration", "headed",
|
||||
"auth_state", "session_id", "out_dir")
|
||||
if k in payload
|
||||
}
|
||||
if "url" not in kwargs:
|
||||
return _proto.make_error(req_id, "missing 'url' in payload")
|
||||
result = pm.start(**kwargs)
|
||||
return _proto.make_response(req_id, result)
|
||||
if t == "stop":
|
||||
reason_arg = payload.get("reason", "requested")
|
||||
result = pm.stop(reason=reason_arg)
|
||||
return _proto.make_response(req_id, result)
|
||||
if t == "status":
|
||||
return _proto.make_response(req_id, pm.status())
|
||||
if t == "transcript":
|
||||
last = payload.get("last")
|
||||
result = pm.transcript(last=last)
|
||||
return _proto.make_response(req_id, result)
|
||||
if t == "say":
|
||||
# v2 wiring: enqueue into say_queue.jsonl inside the
|
||||
# active meeting's out_dir when present. The bot-side
|
||||
# consumer is v3+ (for v1 this is a stub returning ok).
|
||||
text = payload.get("text", "")
|
||||
active = pm._read_active() # type: ignore[attr-defined]
|
||||
enqueued = False
|
||||
if active and active.get("out_dir"):
|
||||
queue = Path(active["out_dir"]) / "say_queue.jsonl"
|
||||
try:
|
||||
queue.parent.mkdir(parents=True, exist_ok=True)
|
||||
with queue.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps({"text": text, "ts": time.time()}) + "\n")
|
||||
enqueued = True
|
||||
except OSError:
|
||||
enqueued = False
|
||||
return _proto.make_response(
|
||||
req_id,
|
||||
{"ok": True, "enqueued": enqueued, "text": text},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — surface any pm crash to client
|
||||
return _proto.make_error(req_id, f"{type(exc).__name__}: {exc}")
|
||||
|
||||
return _proto.make_error(req_id, f"unhandled type: {t!r}")
|
||||
|
||||
# ----- server loop --------------------------------------------------
|
||||
|
||||
async def serve(self) -> None:
|
||||
"""Run the WebSocket server until cancelled.
|
||||
|
||||
Blocks forever. Callers typically wrap this in ``asyncio.run``.
|
||||
"""
|
||||
try:
|
||||
import websockets # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"NodeServer.serve requires the 'websockets' package. "
|
||||
"Install it with: pip install websockets"
|
||||
) from exc
|
||||
|
||||
self.ensure_token()
|
||||
|
||||
async def _handler(ws):
|
||||
async for raw in ws:
|
||||
try:
|
||||
msg = _proto.decode(raw if isinstance(raw, str) else raw.decode("utf-8"))
|
||||
except ValueError as exc:
|
||||
await ws.send(_proto.encode(_proto.make_error("", f"decode: {exc}")))
|
||||
continue
|
||||
reply = await self._handle_request(msg)
|
||||
await ws.send(_proto.encode(reply))
|
||||
|
||||
async with websockets.serve(_handler, self.host, self.port):
|
||||
# Run until cancelled.
|
||||
import asyncio
|
||||
await asyncio.Future()
|
||||
@@ -0,0 +1,16 @@
|
||||
name: google_meet
|
||||
version: 0.2.0
|
||||
description: "Join a Google Meet call, transcribe live captions, speak in realtime, and follow up afterwards. v1 transcribe-only is the default; v2 realtime duplex audio via OpenAI Realtime + BlackHole/PulseAudio ships with mode='realtime'; v3 remote node host lets the bot run on a different machine than the gateway (gateway on Linux, Chrome+signed-in profile on the user's Mac). Explicit-by-design: only joins meet.google.com URLs passed in \u2014 no calendar scanning, no auto-dial."
|
||||
author: NousResearch
|
||||
kind: standalone
|
||||
platforms:
|
||||
- linux
|
||||
- macos
|
||||
provides_tools:
|
||||
- meet_join
|
||||
- meet_leave
|
||||
- meet_status
|
||||
- meet_transcript
|
||||
- meet_say
|
||||
hooks:
|
||||
- on_session_end
|
||||
@@ -0,0 +1,323 @@
|
||||
"""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
|
||||
if realtime_api_key:
|
||||
env["HERMES_MEET_REALTIME_KEY"] = realtime_api_key
|
||||
|
||||
log_path = out / "bot.log"
|
||||
# Detach: stdin=devnull, stdout/stderr → log file, new session so parent
|
||||
# signals don't propagate.
|
||||
log_fh = open(log_path, "ab", buffering=0)
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "plugins.google_meet.meet_bot"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_fh,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
close_fds=True,
|
||||
)
|
||||
finally:
|
||||
# The subprocess now owns the log fd; we can close ours.
|
||||
log_fh.close()
|
||||
|
||||
record = {
|
||||
"pid": proc.pid,
|
||||
"meeting_id": meeting_id,
|
||||
"out_dir": str(out),
|
||||
"url": url,
|
||||
"started_at": time.time(),
|
||||
"session_id": session_id,
|
||||
"log_path": str(log_path),
|
||||
"mode": mode,
|
||||
}
|
||||
_write_active(record)
|
||||
return {"ok": True, **record}
|
||||
|
||||
|
||||
def status() -> Dict[str, Any]:
|
||||
"""Return the current meeting state, or ``{"ok": False, "reason": ...}``."""
|
||||
active = _read_active()
|
||||
if not active:
|
||||
return {"ok": False, "reason": "no active meeting"}
|
||||
|
||||
pid = int(active.get("pid", 0))
|
||||
alive = _pid_alive(pid) if pid else False
|
||||
|
||||
status_path = Path(active.get("out_dir", "")) / "status.json"
|
||||
bot_status: Dict[str, Any] = {}
|
||||
if status_path.is_file():
|
||||
try:
|
||||
bot_status = json.loads(status_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"alive": alive,
|
||||
"pid": pid,
|
||||
"meetingId": active.get("meeting_id"),
|
||||
"url": active.get("url"),
|
||||
"startedAt": active.get("started_at"),
|
||||
"outDir": active.get("out_dir"),
|
||||
**bot_status,
|
||||
}
|
||||
|
||||
|
||||
def transcript(last: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Read the current transcript file. Returns ok=False if none exists."""
|
||||
active = _read_active()
|
||||
if not active:
|
||||
return {"ok": False, "reason": "no active meeting"}
|
||||
|
||||
tp = Path(active.get("out_dir", "")) / "transcript.txt"
|
||||
if not tp.is_file():
|
||||
return {
|
||||
"ok": True,
|
||||
"meetingId": active.get("meeting_id"),
|
||||
"lines": [],
|
||||
"total": 0,
|
||||
"path": str(tp),
|
||||
}
|
||||
text = tp.read_text(encoding="utf-8", errors="replace")
|
||||
all_lines = [ln for ln in text.splitlines() if ln.strip()]
|
||||
lines = all_lines[-last:] if last else all_lines
|
||||
return {
|
||||
"ok": True,
|
||||
"meetingId": active.get("meeting_id"),
|
||||
"lines": lines,
|
||||
"total": len(all_lines),
|
||||
"path": str(tp),
|
||||
}
|
||||
|
||||
|
||||
def enqueue_say(text: str) -> Dict[str, Any]:
|
||||
"""Append a ``say`` request to the active bot's JSONL queue.
|
||||
|
||||
Returns ``{"ok": False, "reason": ...}`` when no meeting is active or
|
||||
the active bot is in transcribe-only mode. Otherwise writes a line to
|
||||
``<out_dir>/say_queue.jsonl`` that the bot's realtime speaker thread
|
||||
will consume.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return {"ok": False, "reason": "text is required"}
|
||||
|
||||
active = _read_active()
|
||||
if not active:
|
||||
return {"ok": False, "reason": "no active meeting"}
|
||||
if active.get("mode") != "realtime":
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": (
|
||||
"active meeting is in transcribe mode — pass mode='realtime' "
|
||||
"to meet_join to enable agent speech"
|
||||
),
|
||||
}
|
||||
|
||||
out_dir = Path(active.get("out_dir", ""))
|
||||
if not out_dir.is_dir():
|
||||
return {"ok": False, "reason": f"out_dir missing: {out_dir}"}
|
||||
|
||||
queue_path = out_dir / "say_queue.jsonl"
|
||||
entry = {"id": uuid.uuid4().hex[:12], "text": text}
|
||||
with queue_path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
return {
|
||||
"ok": True,
|
||||
"meetingId": active.get("meeting_id"),
|
||||
"enqueued_id": entry["id"],
|
||||
"queue_path": str(queue_path),
|
||||
}
|
||||
|
||||
|
||||
def stop(*, reason: str = "requested") -> Dict[str, Any]:
|
||||
"""Signal the active bot to leave cleanly, then clear the active pointer.
|
||||
|
||||
Sends SIGTERM and waits up to 10s for the bot to exit. Falls back to
|
||||
SIGKILL if the bot doesn't respond.
|
||||
"""
|
||||
active = _read_active()
|
||||
if not active:
|
||||
return {"ok": False, "reason": "no active meeting"}
|
||||
|
||||
pid = int(active.get("pid", 0))
|
||||
out_dir = active.get("out_dir")
|
||||
transcript_path = Path(out_dir) / "transcript.txt" if out_dir else None
|
||||
|
||||
if pid and _pid_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
for _ in range(20):
|
||||
if not _pid_alive(pid):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
if _pid_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only plugin (google_meet registers no-op on Windows; see __init__.py)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
_clear_active()
|
||||
return {
|
||||
"ok": True,
|
||||
"reason": reason,
|
||||
"meetingId": active.get("meeting_id"),
|
||||
"transcriptPath": str(transcript_path) if transcript_path else None,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Realtime speech subpackage for the google_meet plugin (v2).
|
||||
|
||||
Provides a thin OpenAI Realtime API client and a file-queue speaker
|
||||
wrapper so the Meet bot can play synthesized speech through the
|
||||
virtual audio bridge.
|
||||
"""
|
||||
|
||||
from .openai_client import RealtimeSession, RealtimeSpeaker # noqa: F401
|
||||
|
||||
__all__ = ["RealtimeSession", "RealtimeSpeaker"]
|
||||
@@ -0,0 +1,332 @@
|
||||
"""OpenAI Realtime API WebSocket client + file-queue speaker.
|
||||
|
||||
This module is the "output" side of the v2 voice bridge: it takes text,
|
||||
sends it to the OpenAI Realtime API, receives audio deltas back, and
|
||||
appends the PCM bytes to a file. A separate consumer (the audio
|
||||
bridge) streams that file into Chrome's fake microphone.
|
||||
|
||||
Designed for simplicity: a single synchronous WebSocket connection per
|
||||
speaker, per session. The ``websockets`` package is imported lazily so
|
||||
that importing this module never fails just because the optional dep
|
||||
is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
REALTIME_URL = "wss://api.openai.com/v1/realtime"
|
||||
|
||||
|
||||
def _require_websockets():
|
||||
"""Import ``websockets.sync.client.connect`` or raise with hint."""
|
||||
try:
|
||||
from websockets.sync.client import connect as _connect # type: ignore
|
||||
except ImportError as exc: # pragma: no cover - exercised via test
|
||||
raise RuntimeError(
|
||||
"websockets package is required for OpenAI Realtime; "
|
||||
"install with: pip install websockets"
|
||||
) from exc
|
||||
return _connect
|
||||
|
||||
|
||||
class RealtimeSession:
|
||||
"""Minimal sync client for the OpenAI Realtime WebSocket API.
|
||||
|
||||
Usage:
|
||||
sess = RealtimeSession(api_key=..., audio_sink_path=Path("out.pcm"))
|
||||
sess.connect()
|
||||
sess.speak("Hello team.")
|
||||
sess.close()
|
||||
|
||||
Thread safety: ``speak`` and ``cancel_response`` may be called from
|
||||
different threads; a lock serializes WebSocket writes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
model: str = "gpt-realtime",
|
||||
voice: str = "alloy",
|
||||
instructions: str = "",
|
||||
audio_sink_path: Optional[Path] = None,
|
||||
sample_rate: int = 24000,
|
||||
) -> None:
|
||||
import threading as _threading
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.voice = voice
|
||||
self.instructions = instructions
|
||||
self.audio_sink_path = Path(audio_sink_path) if audio_sink_path else None
|
||||
self.sample_rate = sample_rate
|
||||
self._ws: Any = None
|
||||
self._send_lock = _threading.Lock()
|
||||
self._last_response_id: Optional[str] = None
|
||||
# Public counters for status reporting.
|
||||
self.audio_bytes_out: int = 0
|
||||
self.last_audio_out_at: Optional[float] = None
|
||||
|
||||
# ── lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Open WS and send session.update with voice+instructions."""
|
||||
connect = _require_websockets()
|
||||
url = f"{REALTIME_URL}?model={self.model}"
|
||||
headers = [
|
||||
("Authorization", f"Bearer {self.api_key}"),
|
||||
("OpenAI-Beta", "realtime=v1"),
|
||||
]
|
||||
# websockets.sync.client.connect accepts either additional_headers=
|
||||
# (newer) or extra_headers= depending on version; try the newer
|
||||
# name first and fall back.
|
||||
try:
|
||||
self._ws = connect(url, additional_headers=headers)
|
||||
except TypeError:
|
||||
self._ws = connect(url, extra_headers=headers)
|
||||
|
||||
self._send_json(
|
||||
{
|
||||
"type": "session.update",
|
||||
"session": {
|
||||
"voice": self.voice,
|
||||
"instructions": self.instructions,
|
||||
"modalities": ["audio", "text"],
|
||||
"output_audio_format": "pcm16",
|
||||
"input_audio_format": "pcm16",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._ws is not None:
|
||||
try:
|
||||
self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._ws = None
|
||||
|
||||
# ── speaking ──────────────────────────────────────────────────────────
|
||||
|
||||
def speak(self, text: str, timeout: float = 30.0) -> dict:
|
||||
"""Send ``text`` and accumulate the audio response.
|
||||
|
||||
Audio deltas are base64-decoded and appended to
|
||||
``audio_sink_path`` (opened 'ab' and closed per call, so a
|
||||
separate streaming reader can consume whatever is there).
|
||||
"""
|
||||
if self._ws is None:
|
||||
raise RuntimeError("RealtimeSession.connect() must be called first")
|
||||
|
||||
start = time.monotonic()
|
||||
|
||||
self._send_json(
|
||||
{
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": text}],
|
||||
},
|
||||
}
|
||||
)
|
||||
self._send_json(
|
||||
{
|
||||
"type": "response.create",
|
||||
"response": {"modalities": ["audio"]},
|
||||
}
|
||||
)
|
||||
|
||||
bytes_written = 0
|
||||
sink_fp = None
|
||||
if self.audio_sink_path is not None:
|
||||
self.audio_sink_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
sink_fp = open(self.audio_sink_path, "ab")
|
||||
|
||||
try:
|
||||
while True:
|
||||
remaining = timeout - (time.monotonic() - start)
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(
|
||||
f"realtime response did not complete within {timeout}s"
|
||||
)
|
||||
raw = self._recv(timeout=remaining)
|
||||
if raw is None:
|
||||
# Connection closed by peer.
|
||||
break
|
||||
try:
|
||||
frame = json.loads(raw) if isinstance(raw, (str, bytes, bytearray)) else raw
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not isinstance(frame, dict):
|
||||
continue
|
||||
ftype = frame.get("type")
|
||||
if ftype == "response.audio.delta":
|
||||
b64 = frame.get("delta") or frame.get("audio") or ""
|
||||
if b64 and sink_fp is not None:
|
||||
try:
|
||||
chunk = base64.b64decode(b64)
|
||||
except (ValueError, TypeError):
|
||||
chunk = b""
|
||||
if chunk:
|
||||
sink_fp.write(chunk)
|
||||
sink_fp.flush()
|
||||
bytes_written += len(chunk)
|
||||
self.audio_bytes_out += len(chunk)
|
||||
self.last_audio_out_at = time.time()
|
||||
elif ftype == "response.created":
|
||||
rid = (frame.get("response") or {}).get("id")
|
||||
if rid:
|
||||
self._last_response_id = rid
|
||||
elif ftype in {"response.done", "response.completed", "response.cancelled"}:
|
||||
break
|
||||
elif ftype == "error":
|
||||
err = frame.get("error") or frame
|
||||
raise RuntimeError(f"realtime error: {err}")
|
||||
# All other frames (response.created, response.output_item.*,
|
||||
# response.audio_transcript.delta, rate_limits.updated, ...)
|
||||
# are ignored for v2.
|
||||
finally:
|
||||
if sink_fp is not None:
|
||||
sink_fp.close()
|
||||
|
||||
duration_ms = (time.monotonic() - start) * 1000.0
|
||||
return {
|
||||
"ok": True,
|
||||
"bytes_written": bytes_written,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
|
||||
# ── ws plumbing ───────────────────────────────────────────────────────
|
||||
|
||||
def cancel_response(self) -> bool:
|
||||
"""Interrupt the in-flight response (barge-in).
|
||||
|
||||
Sends ``response.cancel`` on the current WebSocket so the model
|
||||
stops generating audio immediately. Safe to call at any time;
|
||||
returns True if a cancel was actually sent, False when there's
|
||||
nothing to cancel or the socket isn't open.
|
||||
"""
|
||||
if self._ws is None:
|
||||
return False
|
||||
try:
|
||||
self._send_json({"type": "response.cancel"})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _send_json(self, payload: dict) -> None:
|
||||
assert self._ws is not None
|
||||
with self._send_lock:
|
||||
self._ws.send(json.dumps(payload))
|
||||
|
||||
def _recv(self, timeout: Optional[float] = None):
|
||||
assert self._ws is not None
|
||||
try:
|
||||
if timeout is None:
|
||||
return self._ws.recv()
|
||||
return self._ws.recv(timeout=timeout)
|
||||
except TypeError:
|
||||
# Older websockets may not accept timeout kwarg.
|
||||
return self._ws.recv()
|
||||
|
||||
|
||||
class RealtimeSpeaker:
|
||||
"""File-based JSONL queue wrapper around :class:`RealtimeSession`.
|
||||
|
||||
Each line in ``queue_path`` is a JSON object of the form
|
||||
``{"id": "<uuid>", "text": "..."}``. Processed lines are appended
|
||||
to ``processed_path`` (if set) and then removed from the queue;
|
||||
if ``processed_path`` is ``None``, processed lines are simply
|
||||
dropped.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: RealtimeSession,
|
||||
queue_path: Path,
|
||||
processed_path: Optional[Path] = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.queue_path = Path(queue_path)
|
||||
self.processed_path = Path(processed_path) if processed_path else None
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _read_queue(self) -> list[dict]:
|
||||
if not self.queue_path.exists():
|
||||
return []
|
||||
out: list[dict] = []
|
||||
for line in self.queue_path.read_text().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("")
|
||||
return
|
||||
self.queue_path.write_text(
|
||||
"\n".join(json.dumps(e) for e in remaining) + "\n"
|
||||
)
|
||||
|
||||
def _append_processed(self, entry: dict, result: dict) -> None:
|
||||
if self.processed_path is None:
|
||||
return
|
||||
self.processed_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
record = {"id": entry.get("id"), "text": entry.get("text", ""), "result": result}
|
||||
with open(self.processed_path, "a", encoding="utf-8") as fp:
|
||||
fp.write(json.dumps(record) + "\n")
|
||||
|
||||
# ── main loop ────────────────────────────────────────────────────────
|
||||
|
||||
def run_until_stopped(
|
||||
self,
|
||||
stop_fn: Callable[[], bool],
|
||||
poll_interval: float = 0.5,
|
||||
) -> None:
|
||||
while not stop_fn():
|
||||
entries = self._read_queue()
|
||||
if not entries:
|
||||
time.sleep(poll_interval)
|
||||
continue
|
||||
# Process one at a time; re-check the queue file after each
|
||||
# speak() call because new entries may have arrived.
|
||||
head = entries[0]
|
||||
text = (head.get("text") or "").strip()
|
||||
if text:
|
||||
try:
|
||||
result = self.session.speak(text)
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "error": str(exc)}
|
||||
else:
|
||||
result = {"ok": True, "bytes_written": 0, "duration_ms": 0.0}
|
||||
self._append_processed(head, result)
|
||||
|
||||
# Re-read the queue from disk in case it was appended to
|
||||
# while we were speaking, then drop the head.
|
||||
latest = self._read_queue()
|
||||
if latest and latest[0].get("id") == head.get("id"):
|
||||
self._rewrite_queue(latest[1:])
|
||||
else:
|
||||
# Fallback: drop-by-id anywhere in the queue.
|
||||
self._rewrite_queue(
|
||||
[e for e in latest if e.get("id") != head.get("id")]
|
||||
)
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Agent-facing tools for the google_meet plugin.
|
||||
|
||||
Tools:
|
||||
meet_join — join a Google Meet URL (spawns Playwright bot locally
|
||||
OR on a remote node host via node=<name>)
|
||||
meet_status — report bot liveness + transcript progress
|
||||
meet_transcript — read the current transcript (optional last-N)
|
||||
meet_leave — signal the bot to leave cleanly
|
||||
meet_say — (v2) speak text through the realtime audio bridge.
|
||||
Requires the active meeting to have been joined with
|
||||
mode='realtime'.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from plugins.google_meet import process_manager as pm
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_meet_requirements() -> bool:
|
||||
"""Return True when the plugin can actually run LOCALLY.
|
||||
|
||||
Gates on:
|
||||
* Python ``playwright`` package importable
|
||||
* the plugin being on a supported platform (Linux or macOS)
|
||||
|
||||
Note: remote-node operation (``node=<name>``) only needs the
|
||||
``websockets`` dep on the gateway side — Chromium lives on the node.
|
||||
But the plugin-level gate keeps the v1 semantics; individual tool
|
||||
handlers relax the requirement when a node is addressed.
|
||||
"""
|
||||
import platform as _p
|
||||
if _p.system().lower() not in {"linux", "darwin"}:
|
||||
return False
|
||||
try:
|
||||
import playwright # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node client helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_node_client(node: Optional[str]):
|
||||
"""Return (NodeClient, node_name) for *node*, or (None, None) to run local.
|
||||
|
||||
Raises RuntimeError with a readable message if the node is named but
|
||||
unresolvable, so the handler can surface a clear error to the agent.
|
||||
"""
|
||||
if node is None or node == "":
|
||||
return None, None
|
||||
from plugins.google_meet.node.registry import NodeRegistry
|
||||
from plugins.google_meet.node.client import NodeClient
|
||||
|
||||
reg = NodeRegistry()
|
||||
entry = reg.resolve(node if node != "auto" else None)
|
||||
if entry is None:
|
||||
raise RuntimeError(
|
||||
f"no registered meet node matches {node!r} — "
|
||||
"run `hermes meet node approve <name> <url> <token>` first"
|
||||
)
|
||||
client = NodeClient(url=entry["url"], token=entry["token"])
|
||||
return client, entry.get("name")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEET_JOIN_SCHEMA: Dict[str, Any] = {
|
||||
"name": "meet_join",
|
||||
"description": (
|
||||
"Join a Google Meet call and start scraping live captions into a "
|
||||
"transcript file. Only meet.google.com URLs are accepted; no calendar "
|
||||
"scanning, no auto-dial. Spawns a headless Chromium subprocess that "
|
||||
"runs in parallel with the agent loop — returns immediately. Poll "
|
||||
"with meet_status and read captions with meet_transcript. Reminder "
|
||||
"to the agent: you should announce yourself in the meeting (there is "
|
||||
"no automatic consent announcement)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Full https://meet.google.com/... URL. Required."
|
||||
),
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["transcribe", "realtime"],
|
||||
"description": (
|
||||
"transcribe (default): listen-only, scrape captions. "
|
||||
"realtime: also enable agent speech via meet_say "
|
||||
"(requires OpenAI Realtime key + platform audio bridge)."
|
||||
),
|
||||
},
|
||||
"guest_name": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Display name to use when joining as guest. Defaults to "
|
||||
"'Hermes Agent'."
|
||||
),
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional max duration before auto-leave (e.g. '30m', "
|
||||
"'2h', '90s'). Omit to stay until meet_leave is called."
|
||||
),
|
||||
},
|
||||
"headed": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Run Chromium headed instead of headless (debug only). "
|
||||
"Default false."
|
||||
),
|
||||
},
|
||||
"node": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Name of a registered remote node to run the bot on "
|
||||
"(useful when the gateway runs on a headless Linux box "
|
||||
"but the user's Chrome with a signed-in Google profile "
|
||||
"lives on their Mac). Pass 'auto' to use the single "
|
||||
"registered node. Default: run locally. Nodes are "
|
||||
"approved via `hermes meet node approve`."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
MEET_STATUS_SCHEMA: Dict[str, Any] = {
|
||||
"name": "meet_status",
|
||||
"description": (
|
||||
"Report the current Meet session state — whether the bot is alive, "
|
||||
"has joined, is sitting in the lobby, number of transcript lines "
|
||||
"captured, and last-caption timestamp."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
MEET_TRANSCRIPT_SCHEMA: Dict[str, Any] = {
|
||||
"name": "meet_transcript",
|
||||
"description": (
|
||||
"Read the scraped transcript for the active Meet session. Returns "
|
||||
"full transcript unless 'last' is set, in which case returns the last "
|
||||
"N lines only."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"last": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Optional: return only the last N caption lines. Useful "
|
||||
"for polling during a meeting without re-reading the "
|
||||
"whole transcript."
|
||||
),
|
||||
"minimum": 1,
|
||||
},
|
||||
"node": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
MEET_LEAVE_SCHEMA: Dict[str, Any] = {
|
||||
"name": "meet_leave",
|
||||
"description": (
|
||||
"Leave the active Meet call cleanly, stop caption scraping, and "
|
||||
"finalize the transcript file. Safe to call when no meeting is "
|
||||
"active — returns ok=false with a reason."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
MEET_SAY_SCHEMA: Dict[str, Any] = {
|
||||
"name": "meet_say",
|
||||
"description": (
|
||||
"Speak text into the active Meet call. Requires the active meeting "
|
||||
"to have been joined with mode='realtime'. The text is queued to "
|
||||
"the bot's OpenAI Realtime session; the generated audio is streamed "
|
||||
"into Chrome's fake microphone via a virtual audio device "
|
||||
"(PulseAudio null-sink on Linux, BlackHole on macOS). Returns "
|
||||
"immediately — the actual speech lags by a couple of seconds."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Text to speak."},
|
||||
"node": {"type": "string"},
|
||||
},
|
||||
"required": ["text"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _json(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
|
||||
def _err(msg: str, **extra) -> str:
|
||||
return _json({"success": False, "error": msg, **extra})
|
||||
|
||||
|
||||
def handle_meet_join(args: Dict[str, Any], **_kw) -> str:
|
||||
url = (args.get("url") or "").strip()
|
||||
if not url:
|
||||
return _err("url is required")
|
||||
mode = (args.get("mode") or "transcribe").strip().lower()
|
||||
if mode not in {"transcribe", "realtime"}:
|
||||
return _err(f"mode must be 'transcribe' or 'realtime' (got {mode!r})")
|
||||
|
||||
node = args.get("node")
|
||||
try:
|
||||
client, node_name = _resolve_node_client(node)
|
||||
except RuntimeError as e:
|
||||
return _err(str(e))
|
||||
|
||||
if client is not None:
|
||||
# Remote path — delegate to the node host.
|
||||
try:
|
||||
res = client.start_bot(
|
||||
url=url,
|
||||
guest_name=str(args.get("guest_name") or "Hermes Agent"),
|
||||
duration=str(args.get("duration")) if args.get("duration") else None,
|
||||
headed=bool(args.get("headed", False)),
|
||||
mode=mode,
|
||||
)
|
||||
return _json({"success": bool(res.get("ok")), "node": node_name, **res})
|
||||
except Exception as e:
|
||||
return _err(f"remote node start_bot failed: {e}", node=node_name)
|
||||
|
||||
# Local path — same as v1, with v2 params.
|
||||
if not check_meet_requirements():
|
||||
return _err(
|
||||
"google_meet plugin prerequisites missing — install with "
|
||||
"`pip install playwright && python -m playwright install "
|
||||
"chromium`. Plugin is supported on Linux and macOS only."
|
||||
)
|
||||
res = pm.start(
|
||||
url=url,
|
||||
headed=bool(args.get("headed", False)),
|
||||
guest_name=str(args.get("guest_name") or "Hermes Agent"),
|
||||
duration=str(args.get("duration")) if args.get("duration") else None,
|
||||
mode=mode,
|
||||
)
|
||||
return _json({"success": bool(res.get("ok")), **res})
|
||||
|
||||
|
||||
def handle_meet_status(args: Dict[str, Any], **_kw) -> str:
|
||||
try:
|
||||
client, node_name = _resolve_node_client(args.get("node"))
|
||||
except RuntimeError as e:
|
||||
return _err(str(e))
|
||||
if client is not None:
|
||||
try:
|
||||
res = client.status()
|
||||
return _json({"success": bool(res.get("ok")), "node": node_name, **res})
|
||||
except Exception as e:
|
||||
return _err(f"remote node status failed: {e}", node=node_name)
|
||||
res = pm.status()
|
||||
return _json({"success": bool(res.get("ok")), **res})
|
||||
|
||||
|
||||
def handle_meet_transcript(args: Dict[str, Any], **_kw) -> str:
|
||||
last = args.get("last")
|
||||
try:
|
||||
last_i = int(last) if last is not None else None
|
||||
if last_i is not None and last_i < 1:
|
||||
last_i = None
|
||||
except (TypeError, ValueError):
|
||||
last_i = None
|
||||
try:
|
||||
client, node_name = _resolve_node_client(args.get("node"))
|
||||
except RuntimeError as e:
|
||||
return _err(str(e))
|
||||
if client is not None:
|
||||
try:
|
||||
res = client.transcript(last=last_i)
|
||||
return _json({"success": bool(res.get("ok")), "node": node_name, **res})
|
||||
except Exception as e:
|
||||
return _err(f"remote node transcript failed: {e}", node=node_name)
|
||||
res = pm.transcript(last=last_i)
|
||||
return _json({"success": bool(res.get("ok")), **res})
|
||||
|
||||
|
||||
def handle_meet_leave(args: Dict[str, Any], **_kw) -> str:
|
||||
try:
|
||||
client, node_name = _resolve_node_client(args.get("node"))
|
||||
except RuntimeError as e:
|
||||
return _err(str(e))
|
||||
if client is not None:
|
||||
try:
|
||||
res = client.stop()
|
||||
return _json({"success": bool(res.get("ok")), "node": node_name, **res})
|
||||
except Exception as e:
|
||||
return _err(f"remote node stop failed: {e}", node=node_name)
|
||||
res = pm.stop(reason="agent called meet_leave")
|
||||
return _json({"success": bool(res.get("ok")), **res})
|
||||
|
||||
|
||||
def handle_meet_say(args: Dict[str, Any], **_kw) -> str:
|
||||
text = (args.get("text") or "").strip()
|
||||
if not text:
|
||||
return _err("text is required")
|
||||
try:
|
||||
client, node_name = _resolve_node_client(args.get("node"))
|
||||
except RuntimeError as e:
|
||||
return _err(str(e))
|
||||
if client is not None:
|
||||
try:
|
||||
res = client.say(text)
|
||||
return _json({"success": bool(res.get("ok")), "node": node_name, **res})
|
||||
except Exception as e:
|
||||
return _err(f"remote node say failed: {e}", node=node_name)
|
||||
res = pm.enqueue_say(text)
|
||||
return _json({"success": bool(res.get("ok")), **res})
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Hermes Achievements contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Hermes Achievements
|
||||
|
||||
> **Bundled with Hermes Agent.** Originally authored by [@PCinkusz](https://github.com/PCinkusz) at https://github.com/PCinkusz/hermes-achievements — vendored into `plugins/hermes-achievements/` so it ships with the dashboard out-of-the-box and stays in lockstep with Hermes feature changes. Upstream repo remains the staging ground for new badges and UI iteration.
|
||||
>
|
||||
> When Hermes is installed via `pip install hermes-agent` or cloned from source, this plugin auto-registers as a dashboard tab on first `hermes dashboard` launch. No separate install step. See [Built-in Plugins → hermes-achievements](../../website/docs/user-guide/features/built-in-plugins.md) in the main docs.
|
||||
|
||||
Achievement system for the Hermes Dashboard: collectible, tiered badges generated from real local Hermes session history.
|
||||
|
||||

|
||||
|
||||
The screenshots use temporary demo tier data to show the full visual range. The plugin itself reads real local Hermes session history by default.
|
||||
|
||||
> **Update notice (2026-04-29):** If you installed this plugin before today, update to the latest version. The achievements scan path was refactored for much faster warm loads (snapshot cache + incremental checkpoint scan).
|
||||
>
|
||||
> **Share cards (2026-05-04, vendored in hermes-agent v0.4.0):** Unlocked achievement cards now have a "Share" button that renders a 1200×630 PNG share card (client-side canvas, no backend, no network) with Download + Copy-to-clipboard actions. Fits X/Twitter, Discord, LinkedIn, Bluesky link-preview dimensions.
|
||||
|
||||
## What it does
|
||||
|
||||
Hermes Achievements scans local Hermes sessions and unlocks badges based on real agent behavior:
|
||||
|
||||
- autonomous tool chains
|
||||
- debugging and recovery patterns
|
||||
- vibe-coding file edits
|
||||
- Hermes-native skills, memory, cron, and plugin usage
|
||||
- web research and browser automation
|
||||
- model/provider workflows
|
||||
- lifestyle patterns such as weekend or night sessions
|
||||
|
||||
Achievements have three visible states:
|
||||
|
||||
- **Unlocked** — earned at least one tier
|
||||
- **Discovered** — known achievement, progress visible, not earned yet
|
||||
- **Secret** — hidden until Hermes detects the first related signal
|
||||
|
||||
Most achievements level through:
|
||||
|
||||
```text
|
||||
Copper → Silver → Gold → Diamond → Olympian
|
||||
```
|
||||
|
||||
Each card has a collapsible **What counts** section showing the exact tracked metric or requirement once the user wants details.
|
||||
|
||||
Version `0.2.x` expands the catalog to 60+ achievements, including model/provider badges such as **Five-Model Flight**, **Provider Polyglot**, **Claude Confidant**, **Gemini Cartographer**, and **Open Weights Pilgrim**.
|
||||
|
||||
## Examples
|
||||
|
||||
- Let Him Cook
|
||||
- Toolchain Maxxer
|
||||
- Red Text Connoisseur
|
||||
- Port 3000 Is Taken
|
||||
- This Was Supposed To Be Quick
|
||||
- One More Small Change
|
||||
- Skillsmith
|
||||
- Memory Keeper
|
||||
- Context Dragon
|
||||
- Plugin Goblin
|
||||
- Rabbit Hole Certified
|
||||
|
||||
## Install
|
||||
|
||||
Clone into your Hermes plugins directory:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PCinkusz/hermes-achievements ~/.hermes/plugins/hermes-achievements
|
||||
```
|
||||
|
||||
For local development, keep the repo elsewhere and symlink it:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/PCinkusz/hermes-achievements ~/hermes-achievements
|
||||
ln -s ~/hermes-achievements ~/.hermes/plugins/hermes-achievements
|
||||
```
|
||||
|
||||
Then rescan dashboard plugins:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
|
||||
```
|
||||
|
||||
If backend API routes 404, restart `hermes dashboard`; plugin APIs are mounted at dashboard startup.
|
||||
|
||||
## Updating
|
||||
|
||||
If you installed with git:
|
||||
|
||||
```bash
|
||||
cd ~/.hermes/plugins/hermes-achievements
|
||||
git pull --ff-only
|
||||
curl http://127.0.0.1:9119/api/dashboard/plugins/rescan
|
||||
```
|
||||
|
||||
If the update changes backend routes or `plugin_api.py`, restart `hermes dashboard` after pulling.
|
||||
|
||||
As of 2026-04-29, updating is strongly recommended because scan performance changed significantly:
|
||||
- removed duplicate `/overview` scan path
|
||||
- added cached `/achievements` snapshot
|
||||
- added incremental checkpoint reuse for unchanged sessions
|
||||
|
||||
Achievement unlock state is stored locally in `state.json` and is not overwritten by git updates. New achievements are evaluated from your existing Hermes session history. Achievement IDs are stable and should not be renamed casually because they are the unlock-state keys.
|
||||
|
||||
Releases are tagged in git, for example:
|
||||
|
||||
```bash
|
||||
git fetch --tags
|
||||
git checkout v0.2.0
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
```text
|
||||
dashboard/
|
||||
├── manifest.json
|
||||
├── plugin_api.py
|
||||
└── dist/
|
||||
├── index.js
|
||||
└── style.css
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Routes are mounted under:
|
||||
|
||||
```text
|
||||
/api/plugins/hermes-achievements/
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
|
||||
```text
|
||||
GET /achievements
|
||||
GET /scan-status
|
||||
GET /recent-unlocks
|
||||
GET /sessions/{session_id}/badges
|
||||
POST /rescan
|
||||
POST /reset-state
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Run checks:
|
||||
|
||||
```bash
|
||||
node --check dashboard/dist/index.js
|
||||
python3 -m py_compile dashboard/plugin_api.py
|
||||
python3 -m unittest tests/test_achievement_engine.py -v
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+726
File diff suppressed because one or more lines are too long
@@ -0,0 +1,146 @@
|
||||
/* hermes-achievements dashboard styles
|
||||
* Originally authored by @PCinkusz — https://github.com/PCinkusz/hermes-achievements (MIT).
|
||||
* Bundled into hermes-agent. The in-progress scan banner rules at the bottom
|
||||
* (.ha-scan-banner*) are a small addition layered on top of the original bundle.
|
||||
*/
|
||||
.ha-page { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.ha-hero { position: relative; overflow: hidden; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; border: 1px solid var(--color-border); background: radial-gradient(circle at 12% 0, rgba(103,232,249,.13), transparent 30%), linear-gradient(135deg, color-mix(in srgb, var(--color-card) 88%, transparent), color-mix(in srgb, var(--color-primary) 10%, transparent)); padding: 1.25rem; }
|
||||
.ha-hero:before { content: ""; position: absolute; inset: auto -10% -80% -10%; height: 180%; pointer-events: none; background: radial-gradient(circle, rgba(242,201,76,.12), transparent 55%); }
|
||||
.ha-hero h1 { position: relative; margin: 0; font-size: clamp(2rem, 4vw, 4.2rem); line-height: .9; letter-spacing: -0.06em; }
|
||||
.ha-hero p { position: relative; max-width: 52rem; margin: .65rem 0 0; color: var(--color-muted-foreground); }
|
||||
.ha-kicker { position: relative; color: var(--color-muted-foreground); text-transform: uppercase; letter-spacing: .18em; font-size: .72rem; font-family: var(--font-mono, ui-monospace, monospace); }
|
||||
.ha-refresh { position: relative; white-space: nowrap; }
|
||||
.ha-stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .75rem; }
|
||||
.ha-stat-content { padding: 1rem !important; }
|
||||
.ha-stat-label { color: var(--color-muted-foreground); font-size: .75rem; text-transform: uppercase; letter-spacing: .12em; }
|
||||
.ha-stat-value { margin-top: .35rem; font-size: 1.4rem; font-weight: 750; letter-spacing: -0.035em; }
|
||||
.ha-stat-hint { margin-top: .2rem; color: var(--color-muted-foreground); font-size: .75rem; }
|
||||
.ha-toolbar { display: flex; justify-content: space-between; gap: .75rem; align-items: center; flex-wrap: wrap; }
|
||||
.ha-pills { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||
.ha-pills button { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 72%, transparent); color: var(--color-muted-foreground); padding: .35rem .6rem; font-size: .78rem; cursor: pointer; }
|
||||
.ha-pills button.active, .ha-pills button:hover { color: var(--color-foreground); border-color: var(--ha-tier, var(--color-ring)); background: color-mix(in srgb, var(--color-primary) 16%, var(--color-card)); }
|
||||
.ha-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: .9rem; }
|
||||
.ha-card { --ha-tier: var(--color-border); position: relative; overflow: hidden; min-height: 214px; border: 1px solid color-mix(in srgb, var(--ha-tier) 46%, var(--color-border)); background: radial-gradient(circle at 2.6rem 2.2rem, color-mix(in srgb, var(--ha-tier) 16%, transparent), transparent 34%), linear-gradient(180deg, rgba(255,255,255,.04), transparent), color-mix(in srgb, var(--color-card) 92%, #000); transition: transform .16s ease, border-color .16s ease, opacity .16s ease, box-shadow .16s ease; }
|
||||
.ha-card:hover { transform: translateY(-2px); 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
@@ -0,0 +1,157 @@
|
||||
# Hermes Achievements Performance Implementation Plan
|
||||
|
||||
Status: Ready for execution after hackathon review window
|
||||
Constraint: Plugin remains frozen until judging is complete
|
||||
Decision: `/overview` and top-banner slots are out of scope and will be removed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Baseline & Safety (no behavior change)
|
||||
|
||||
### Task 0.1: Add perf benchmark script (local)
|
||||
Objective: Repro baseline before/after.
|
||||
|
||||
Acceptance:
|
||||
- Can print endpoint timings for `/achievements` (3 runs each, cold + warm).
|
||||
|
||||
### Task 0.2: Define acceptance thresholds
|
||||
Objective: Lock success criteria now.
|
||||
|
||||
Acceptance:
|
||||
- Documented SLOs:
|
||||
- `/achievements` p95 < 1s (cached)
|
||||
- max active scan jobs = 1
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Remove unused overview/slot surface (highest certainty)
|
||||
|
||||
### Task 1.1: Remove `/overview` backend route
|
||||
Objective: Eliminate duplicate heavy endpoint path.
|
||||
|
||||
Acceptance:
|
||||
- `plugin_api.py` no longer exposes `/overview`.
|
||||
|
||||
### Task 1.2: Remove slot registration and SummarySlot frontend code
|
||||
Objective: Remove cross-tab banner fetch behavior.
|
||||
|
||||
Acceptance:
|
||||
- No `registerSlot(..."sessions:top"...)` or `registerSlot(..."analytics:top"...)`.
|
||||
- No frontend call to `api("/overview")`.
|
||||
|
||||
### Task 1.3: Update plugin manifest
|
||||
Objective: Reflect final UI scope.
|
||||
|
||||
Acceptance:
|
||||
- `manifest.json` removes `slots` declarations.
|
||||
- Tab registration remains intact.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Shared snapshot persistence + single-flight for `/achievements`
|
||||
|
||||
### Task 2.1: Introduce snapshot store abstraction + on-disk persistence
|
||||
Objective: Single source of truth for Achievements data that survives process restarts.
|
||||
|
||||
Acceptance:
|
||||
- One structure contains dataset consumed by `/achievements`.
|
||||
- Repeated requests do not recompute when cache is fresh.
|
||||
- Snapshot persisted at `~/.hermes/plugins/hermes-achievements/scan_snapshot.json`.
|
||||
|
||||
### Task 2.2: Single-flight scan coordinator
|
||||
Objective: Prevent concurrent recomputes.
|
||||
|
||||
Acceptance:
|
||||
- Simultaneous requests result in one compute run.
|
||||
|
||||
### Task 2.3: Refactor `/achievements` to read snapshot
|
||||
Objective: Remove direct repeated compute from request path.
|
||||
|
||||
Acceptance:
|
||||
- `/achievements` does not run independent full recompute per request when cache is valid.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Stale-While-Revalidate
|
||||
|
||||
### Task 3.1: TTL state (`FRESH`/`STALE`)
|
||||
Objective: Serve immediately when stale, refresh in background.
|
||||
|
||||
Acceptance:
|
||||
- Cached response returned quickly even when expired.
|
||||
- Refresh is asynchronous.
|
||||
|
||||
### Task 3.2: Add `scan-status` endpoint (optional)
|
||||
Objective: Let UI/ops inspect scan state.
|
||||
|
||||
Acceptance:
|
||||
- Returns state, last success time, last duration, last error.
|
||||
|
||||
### Task 3.3: Add metadata fields to `/achievements`
|
||||
Objective: Improve transparency.
|
||||
|
||||
Acceptance:
|
||||
- Response includes `generated_at`, `is_stale`, maybe `scan_id`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Incremental Scanning (optional but recommended)
|
||||
|
||||
### Task 4.1: Add per-session checkpoint file
|
||||
Objective: Track session-level changes, not just global scan time.
|
||||
|
||||
Acceptance:
|
||||
- Checkpoint persisted at `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json`.
|
||||
- For each session: `session_id`, fingerprint (`updated_at`/message_count/hash), and cached contribution.
|
||||
|
||||
### Task 4.2: Incremental aggregation
|
||||
Objective: Recompute only changed/new sessions and reuse unchanged contributions.
|
||||
|
||||
Acceptance:
|
||||
- Typical refresh time drops materially below full scan.
|
||||
- Aggregate rebuild uses: subtract old contribution + add new contribution for changed sessions.
|
||||
|
||||
### Task 4.3: Full rebuild fallback
|
||||
Objective: Preserve correctness.
|
||||
|
||||
Acceptance:
|
||||
- Manual full rescan always possible.
|
||||
- Schema/version changes invalidate checkpoint and force full rebuild.
|
||||
|
||||
---
|
||||
|
||||
## Test Plan
|
||||
|
||||
1. Unit tests
|
||||
- Snapshot lifecycle transitions
|
||||
- Dedupe logic under parallel requests
|
||||
- `/achievements` response compatibility
|
||||
|
||||
2. Integration tests
|
||||
- Opening Achievements repeatedly causes <=1 heavy scan while in-flight
|
||||
- `/achievements` warm-cache load is fast
|
||||
- manual rescan updates snapshot and timestamps
|
||||
|
||||
3. Manual benchmarks
|
||||
- Compare pre/post `/achievements` timings with same history dataset
|
||||
|
||||
---
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
1. Release internal branch with Phase 1 (remove overview/slots).
|
||||
2. Validate no UI regression in Achievements tab.
|
||||
3. Add Phase 2 snapshot/dedupe.
|
||||
4. Add Phase 3 stale-while-revalidate + status metadata.
|
||||
5. Optional: incremental scanner.
|
||||
|
||||
Rollback: keep old compute path behind temporary feature flag for one release window.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- Achievements tab remains fully functional (counts, latest, tiers, cards, filters).
|
||||
- No `/overview` endpoint or slot calls remain.
|
||||
- Repeated Achievements loads feel immediate after warm cache.
|
||||
- Metrics/unlocks remain unchanged versus baseline.
|
||||
@@ -0,0 +1,219 @@
|
||||
# Hermes Achievements Implementation Spec (Detailed)
|
||||
|
||||
This document is implementation-facing detail to execute the performance refactor later.
|
||||
|
||||
Decision scope: keep only Achievements tab flow; remove `/overview` + top-banner slot integration.
|
||||
|
||||
---
|
||||
|
||||
## A) Current Behavior Summary
|
||||
|
||||
- `evaluate_all()` performs:
|
||||
- full `scan_sessions()`
|
||||
- `SessionDB.list_sessions_rich(...)`
|
||||
- `db.get_messages(session_id)` for each session
|
||||
- text/tool regex analysis + aggregation + evaluation
|
||||
- `/overview` and `/achievements` both currently call `evaluate_all()` directly.
|
||||
- slot calls (`sessions:top`, `analytics:top`) currently invoke `/overview`.
|
||||
|
||||
Consequence: repeated full recomputes and contention.
|
||||
|
||||
---
|
||||
|
||||
## B) De-scope/Removal Changes
|
||||
|
||||
1. Remove backend route:
|
||||
- `GET /overview`
|
||||
|
||||
2. Remove frontend slot usage:
|
||||
- `SummarySlot` component
|
||||
- `registerSlot("sessions:top")`
|
||||
- `registerSlot("analytics:top")`
|
||||
|
||||
3. Remove manifest slot declarations:
|
||||
- `"slots": ["sessions:top", "analytics:top"]`
|
||||
|
||||
4. Keep:
|
||||
- tab route/page for Achievements
|
||||
- `/achievements` endpoint and full tab rendering
|
||||
|
||||
---
|
||||
|
||||
## C) Target Internal Interfaces
|
||||
|
||||
### 1) `SnapshotStore`
|
||||
Responsibilities:
|
||||
- hold latest computed snapshot in memory
|
||||
- persist/load snapshot from disk
|
||||
- expose age and staleness checks
|
||||
|
||||
Storage path:
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_snapshot.json`
|
||||
|
||||
Methods (conceptual):
|
||||
- `get()` -> snapshot | null
|
||||
- `set(snapshot)`
|
||||
- `is_stale(ttl_seconds)`
|
||||
|
||||
### 2) `ScanCoordinator`
|
||||
Responsibilities:
|
||||
- single-flight guard for compute jobs
|
||||
- track scan status
|
||||
|
||||
Methods:
|
||||
- `run_if_needed(force: bool = false)`
|
||||
- `get_status()`
|
||||
|
||||
State fields:
|
||||
- `state`: `idle|running|failed`
|
||||
- `started_at`, `finished_at`
|
||||
- `last_error`
|
||||
- `run_count`
|
||||
|
||||
### 3) `build_snapshot()`
|
||||
Responsibilities:
|
||||
- execute current compute logic once
|
||||
- on first run, perform full scan and materialize per-session contributions
|
||||
- on subsequent runs, process only changed/new sessions via checkpoint fingerprints
|
||||
- produce shape consumed by `/achievements`
|
||||
|
||||
Output:
|
||||
- `achievements`
|
||||
- count fields
|
||||
- optional `scan_meta`
|
||||
|
||||
---
|
||||
|
||||
## D) Endpoint Behavior Matrix (No `/overview`)
|
||||
|
||||
| Endpoint | Cache fresh | Cache stale | No cache | Force rescan |
|
||||
|---|---|---|---|---|
|
||||
| `/achievements` | return cached | return stale + trigger bg refresh | blocking bootstrap scan | n/a |
|
||||
| `/rescan` | trigger refresh | trigger refresh | trigger refresh | yes |
|
||||
| `/scan-status` | status only | status only | status only | status only |
|
||||
|
||||
Notes:
|
||||
- At most one scan run active.
|
||||
- Other callers either await same run or receive stale snapshot according to policy.
|
||||
|
||||
---
|
||||
|
||||
## E) Data Shape (Proposed)
|
||||
|
||||
```json
|
||||
{
|
||||
"generated_at": 0,
|
||||
"is_stale": false,
|
||||
"scan_meta": {
|
||||
"duration_ms": 0,
|
||||
"sessions_scanned": 0,
|
||||
"messages_scanned": 0,
|
||||
"mode": "full",
|
||||
"error": null
|
||||
},
|
||||
"achievements": [],
|
||||
"unlocked_count": 0,
|
||||
"discovered_count": 0,
|
||||
"secret_count": 0,
|
||||
"total_count": 0,
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
Compatibility guidance:
|
||||
- Keep existing `/achievements` keys.
|
||||
- Add metadata keys without breaking old callers.
|
||||
|
||||
Checkpoint file (new):
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json`
|
||||
|
||||
Suggested checkpoint shape:
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"generated_at": 0,
|
||||
"sessions": {
|
||||
"<session_id>": {
|
||||
"fingerprint": {
|
||||
"updated_at": 0,
|
||||
"message_count": 0,
|
||||
"hash": "optional"
|
||||
},
|
||||
"contribution": {
|
||||
"metrics": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- fingerprint mismatch => recompute that session contribution only.
|
||||
- unchanged fingerprint => reuse stored contribution.
|
||||
|
||||
---
|
||||
|
||||
## F) Concurrency Contract
|
||||
|
||||
- Any request path that needs fresh data must pass through single-flight coordinator.
|
||||
- If a scan is running:
|
||||
- do not start second scan
|
||||
- either await in-flight run (bounded) or serve stale snapshot immediately
|
||||
- lock scope must include scan start/finish state transitions.
|
||||
|
||||
---
|
||||
|
||||
## G) Error Handling Contract
|
||||
|
||||
- If refresh fails and prior snapshot exists:
|
||||
- return prior snapshot with `is_stale=true` and error metadata
|
||||
- If refresh fails and no prior snapshot:
|
||||
- return explicit error response (current behavior equivalent)
|
||||
- `scan-status` should always return last known state/error.
|
||||
|
||||
---
|
||||
|
||||
## H) Frontend Integration Contract
|
||||
|
||||
- Achievements page:
|
||||
- one fetch on mount to `/achievements`
|
||||
- optional background refresh indicator if stale
|
||||
- no top-banner slot integration
|
||||
- avoid duplicate in-flight calls during fast navigation by cancellation/debounce.
|
||||
|
||||
---
|
||||
|
||||
## I) Validation Checklist
|
||||
|
||||
- [ ] `/overview` route removed
|
||||
- [ ] manifest has no `sessions:top`/`analytics:top` slots
|
||||
- [ ] frontend has no `api("/overview")` calls
|
||||
- [ ] repeated Achievements navigation does not create multiple heavy scans
|
||||
- [ ] average warm load times meet SLOs
|
||||
- [ ] unlock totals match pre-refactor baseline for same history
|
||||
- [ ] no schema regression in `/achievements` response
|
||||
|
||||
---
|
||||
|
||||
## J) Suggested File Placement for Future Work
|
||||
|
||||
- backend changes: `dashboard/plugin_api.py`
|
||||
- optional extraction:
|
||||
- `dashboard/perf_snapshot.py`
|
||||
- `dashboard/perf_scan_coordinator.py`
|
||||
- frontend request hygiene: `dashboard/dist/index.js` (or source if available)
|
||||
- plugin metadata: `dashboard/manifest.json`
|
||||
- persisted runtime files:
|
||||
- `~/.hermes/plugins/hermes-achievements/state.json` (existing unlock state)
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_snapshot.json` (new)
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json` (new)
|
||||
|
||||
---
|
||||
|
||||
## K) Post-Implementation Reporting Template
|
||||
|
||||
Record:
|
||||
- dataset size (sessions/messages/tool calls)
|
||||
- pre/post `/achievements` timings (cold/warm)
|
||||
- whether single-flight dedupe triggered under repeated tab open
|
||||
- any behavioral diffs in unlock counts
|
||||
@@ -0,0 +1,174 @@
|
||||
# Hermes Achievements Performance Spec (Post-Hackathon)
|
||||
|
||||
Status: Draft (no code changes yet)
|
||||
Owner: hermes-achievements plugin
|
||||
Scope: `dashboard/plugin_api.py` + `dashboard/dist/index.js` request behavior
|
||||
Decision: **Drop `/overview` and top-banner slots**; keep only Achievements tab data path.
|
||||
|
||||
---
|
||||
|
||||
## 1) Problem Statement
|
||||
|
||||
Current plugin endpoints `/achievements` and `/overview` both execute a full history recomputation (`evaluate_all()`), which performs a full SessionDB scan each request.
|
||||
|
||||
Observed on this machine/repo:
|
||||
- ~83 sessions
|
||||
- ~7,125 messages
|
||||
- ~3,623 tool calls
|
||||
- `evaluate_all()` ~13–16s per call
|
||||
- `/achievements` ~13–15s per call
|
||||
- `/overview` ~12–15s per call
|
||||
- Overlap between endpoints increases perceived wait.
|
||||
|
||||
Given current product direction, `/overview` and cross-tab top-banner slots are not needed.
|
||||
|
||||
---
|
||||
|
||||
## 2) Goals
|
||||
|
||||
- Keep achievement correctness unchanged.
|
||||
- Keep all Achievements-tab UX/data (unlocked/discovered/secrets/highest/latest/cards).
|
||||
- Remove unused summary path (`/overview`) and slot wiring.
|
||||
- Make Achievements tab faster by avoiding duplicate endpoint pathways.
|
||||
- Ensure at most one heavy scan can run at a time.
|
||||
|
||||
Non-goals (phase 1):
|
||||
- Rewriting achievement rules.
|
||||
- Changing badge semantics/states.
|
||||
|
||||
---
|
||||
|
||||
## 3) Endpoint Semantics (Target)
|
||||
|
||||
### `GET /api/plugins/hermes-achievements/achievements`
|
||||
Single source endpoint for Achievements UI.
|
||||
Returns full payload used by the tab:
|
||||
- `achievements`
|
||||
- `unlocked_count`
|
||||
- `discovered_count`
|
||||
- `secret_count`
|
||||
- `total_count`
|
||||
- `error`
|
||||
|
||||
### `POST /api/plugins/hermes-achievements/rescan` (optional)
|
||||
Manual refresh trigger.
|
||||
Prefer async trigger + immediate status response.
|
||||
|
||||
### `GET /api/plugins/hermes-achievements/scan-status` (optional new)
|
||||
Reports scan state for UX/ops.
|
||||
|
||||
### Removed
|
||||
- `GET /api/plugins/hermes-achievements/overview`
|
||||
|
||||
---
|
||||
|
||||
## 4) UI Scope (Target)
|
||||
|
||||
Keep:
|
||||
- Achievements page/tab (`/achievements` in plugin tab manifest)
|
||||
- All existing Achievements tab stats/cards/filters
|
||||
|
||||
Remove:
|
||||
- Top-banner summary slot components using `sessions:top` and `analytics:top`
|
||||
- Any frontend call path to `/overview`
|
||||
|
||||
---
|
||||
|
||||
## 5) Runtime State Machine (for `/achievements`)
|
||||
|
||||
- `FRESH`: cached snapshot age <= TTL
|
||||
- `STALE`: snapshot exists but expired
|
||||
- `SCANNING`: background recompute running
|
||||
- `FAILED`: last recompute failed, last good snapshot still served
|
||||
|
||||
Rules:
|
||||
1. FRESH -> serve immediately.
|
||||
2. STALE + not scanning -> serve stale snapshot immediately and launch background refresh.
|
||||
3. SCANNING -> do not start another scan; join single-flight in-flight job.
|
||||
4. No snapshot yet -> allow one blocking bootstrap scan.
|
||||
|
||||
---
|
||||
|
||||
## 6) Caching & Invalidation
|
||||
|
||||
### Phase 1
|
||||
- In-memory cache + persisted snapshot file.
|
||||
- TTL: 60–180 seconds (configurable).
|
||||
- Single-flight dedupe for scan requests.
|
||||
- Persist plugin data under:
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_snapshot.json`
|
||||
|
||||
### Phase 2
|
||||
- Incremental scan checkpoints with per-session fingerprints.
|
||||
- Persist checkpoint data under:
|
||||
- `~/.hermes/plugins/hermes-achievements/scan_checkpoint.json`
|
||||
- Checkpoint stores, per session:
|
||||
- `session_id`
|
||||
- fingerprint (`updated_at`, message_count, or hash)
|
||||
- cached per-session contribution used for aggregate recomposition
|
||||
- Scan policy:
|
||||
- First run: full scan and materialize snapshot + checkpoint.
|
||||
- Next runs: process only new/changed sessions, reuse unchanged contributions.
|
||||
- Full rebuild only on:
|
||||
- schema/version change
|
||||
- checkpoint corruption
|
||||
- explicit full rescan
|
||||
|
||||
---
|
||||
|
||||
## 7) Frontend Contract
|
||||
|
||||
- Achievements tab requests `/achievements` once on mount.
|
||||
- No slot-based summary fetches.
|
||||
- If response says `is_stale=true`, UI may display “Updating in background”.
|
||||
- Avoid duplicate mount-triggered calls and cancel stale requests on navigation.
|
||||
|
||||
---
|
||||
|
||||
## 8) SLO Targets
|
||||
|
||||
- `/achievements` p95 < 1s (cached)
|
||||
- Max concurrent heavy scans: 1
|
||||
- Background refresh should not block UI
|
||||
|
||||
---
|
||||
|
||||
## 9) Observability Requirements
|
||||
|
||||
Track:
|
||||
- scan count
|
||||
- scan duration avg/p95
|
||||
- dedupe hit count (joined in-flight scans)
|
||||
- stale-served count
|
||||
- failures + last error
|
||||
|
||||
Expose minimal diagnostics in `/scan-status`.
|
||||
|
||||
---
|
||||
|
||||
## 10) Backward Compatibility
|
||||
|
||||
- Keep `/achievements` response shape backward-compatible.
|
||||
- Removing `/overview` is acceptable because slot UI is intentionally removed.
|
||||
- If temporary compatibility is needed, `/overview` can return static deprecation response for one release.
|
||||
|
||||
---
|
||||
|
||||
## 11) Risks
|
||||
|
||||
- Stale data confusion -> mitigate with `generated_at` and explicit refresh status.
|
||||
- Cache invalidation bugs -> start with conservative TTL + manual rescan.
|
||||
- Concurrency bugs -> protect scan section with lock/single-flight guard.
|
||||
- Session mutation edge cases -> use per-session fingerprint invalidation (not global timestamp only).
|
||||
|
||||
---
|
||||
|
||||
## 12) Persistence Files (Explicit)
|
||||
|
||||
Plugin state directory:
|
||||
- `~/.hermes/plugins/hermes-achievements/`
|
||||
|
||||
Files:
|
||||
- `state.json` (existing): unlock tracking
|
||||
- `scan_snapshot.json` (new): latest materialized achievements payload
|
||||
- `scan_checkpoint.json` (new): per-session fingerprints + contributions for incremental refresh
|
||||
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,156 @@
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,182 @@
|
||||
"""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, etc.",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "FAL_KEY",
|
||||
"prompt": "FAL API key",
|
||||
"url": "https://fal.ai/dashboard/keys",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate an image via the legacy FAL pipeline.
|
||||
|
||||
Forwards prompt + aspect_ratio (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",
|
||||
)
|
||||
if key in kwargs and kwargs[key] is not None
|
||||
}
|
||||
|
||||
try:
|
||||
raw = _it.image_generate_tool(
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
**passthrough,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — never raise out of generate
|
||||
logger.warning("FAL image_generate_tool raised: %s", exc, exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"image": None,
|
||||
"error": f"FAL image generation failed: {exc}",
|
||||
"error_type": type(exc).__name__,
|
||||
"provider": "fal",
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": aspect,
|
||||
}
|
||||
|
||||
try:
|
||||
response = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception: # noqa: BLE001
|
||||
response = {"success": False, "image": None, "error": "Invalid JSON from FAL pipeline"}
|
||||
|
||||
if not isinstance(response, dict):
|
||||
response = {
|
||||
"success": False,
|
||||
"image": None,
|
||||
"error": "FAL pipeline returned a non-dict response",
|
||||
"error_type": "provider_contract",
|
||||
}
|
||||
|
||||
# Stamp provider/prompt/aspect_ratio so downstream consumers see
|
||||
# the uniform shape declared in ``agent.image_gen_provider``.
|
||||
response.setdefault("provider", "fal")
|
||||
response.setdefault("prompt", prompt)
|
||||
response.setdefault("aspect_ratio", aspect)
|
||||
# Annotate model best-effort — the legacy pipeline resolves it
|
||||
# internally, so query it after the fact for the response shape.
|
||||
if "model" not in response:
|
||||
try:
|
||||
model_id, _meta = _it._resolve_fal_model()
|
||||
response["model"] = model_id
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — wire ``FalImageGenProvider`` into the registry."""
|
||||
ctx.register_image_gen_provider(FalImageGenProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: fal
|
||||
version: 1.0.0
|
||||
description: "FAL.ai image generation backend (flux-2-klein, flux-2-pro, nano-banana, gpt-image-1.5, recraft-v3, etc.)."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- FAL_KEY
|
||||
@@ -0,0 +1,548 @@
|
||||
"""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
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
error_response,
|
||||
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",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
# 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"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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() -> Tuple[str, Dict[str, Any]]:
|
||||
"""Decide which model to use and return ``(model_id, meta)``."""
|
||||
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_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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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:
|
||||
return bool(os.environ.get("KREA_API_KEY"))
|
||||
|
||||
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). Strong style transfer + moodboards.",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "KREA_API_KEY",
|
||||
"prompt": "Krea API key",
|
||||
"url": "https://www.krea.ai/settings/api-tokens",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# generate()
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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)
|
||||
krea_ar = _ASPECT_MAP.get(aspect, "1:1")
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
api_key = os.environ.get("KREA_API_KEY")
|
||||
if not api_key:
|
||||
return error_response(
|
||||
error=(
|
||||
"KREA_API_KEY not set. Run `hermes tools` → Image "
|
||||
"Generation → Krea to configure, or get a key at "
|
||||
"https://www.krea.ai/settings/api-tokens."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="krea",
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
model_id, meta = _resolve_model()
|
||||
creativity = _resolve_creativity(kwargs.get("creativity"))
|
||||
|
||||
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
|
||||
|
||||
image_style_references = kwargs.get("image_style_references")
|
||||
if isinstance(image_style_references, list) and image_style_references:
|
||||
# Krea caps at 10 refs per request.
|
||||
payload["image_style_references"] = image_style_references[:10]
|
||||
|
||||
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 {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Hermes-Agent/1.0 (krea-image-gen)",
|
||||
}
|
||||
|
||||
# 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)
|
||||
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.
|
||||
job_url = f"{BASE_URL}/jobs/{job_id}"
|
||||
poll_headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"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.
|
||||
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():
|
||||
image_url = candidate.strip()
|
||||
break
|
||||
if image_url is None:
|
||||
single = result.get("url")
|
||||
if isinstance(single, str) and single.strip():
|
||||
image_url = single.strip()
|
||||
|
||||
if 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,
|
||||
)
|
||||
|
||||
# Materialise locally — Krea result URLs may expire, mirroring
|
||||
# what we do for xAI / OpenAI URL responses (#26942).
|
||||
try:
|
||||
saved_path = save_url_image(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.",
|
||||
image_url,
|
||||
exc,
|
||||
)
|
||||
image_ref = 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,
|
||||
}
|
||||
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",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — wire ``KreaImageGenProvider`` into the registry."""
|
||||
ctx.register_image_gen_provider(KreaImageGenProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: krea
|
||||
version: 1.0.0
|
||||
description: "Krea image generation backend (Krea 2 Large + Krea 2 Medium foundation models)."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- KREA_API_KEY
|
||||
@@ -0,0 +1,442 @@
|
||||
"""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/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
error_response,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
success_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 requests by "
|
||||
"using the image_generation tool when provided."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 _build_responses_payload(*, prompt: str, size: str, quality: str) -> Dict[str, Any]:
|
||||
"""Build the Codex Responses request body for an image_generation call."""
|
||||
return {
|
||||
"model": _CODEX_CHAT_MODEL,
|
||||
"store": False,
|
||||
"instructions": _CODEX_INSTRUCTIONS,
|
||||
"input": [{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": prompt}],
|
||||
}],
|
||||
"tools": [{
|
||||
"type": "image_generation",
|
||||
"model": API_MODEL,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"output_format": "png",
|
||||
"background": "opaque",
|
||||
"partial_images": 1,
|
||||
}],
|
||||
"tool_choice": {
|
||||
"type": "allowed_tools",
|
||||
"mode": "required",
|
||||
"tools": [{"type": "image_generation"}],
|
||||
},
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
|
||||
def _extract_image_b64(value: Any) -> Optional[str]:
|
||||
"""Return the newest image b64 embedded in a Responses event payload."""
|
||||
found: Optional[str] = None
|
||||
if isinstance(value, dict):
|
||||
if value.get("type") == "image_generation_call":
|
||||
result = value.get("result")
|
||||
if isinstance(result, str) and result:
|
||||
found = result
|
||||
partial = value.get("partial_image_b64")
|
||||
if isinstance(partial, str) and partial:
|
||||
found = partial
|
||||
for child in value.values():
|
||||
nested = _extract_image_b64(child)
|
||||
if nested:
|
||||
found = nested
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
nested = _extract_image_b64(child)
|
||||
if nested:
|
||||
found = nested
|
||||
return found
|
||||
|
||||
|
||||
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) -> Optional[str]:
|
||||
"""Stream a Codex Responses image_generation call and return the b64 image."""
|
||||
import httpx
|
||||
from agent.auxiliary_client 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)
|
||||
timeout = httpx.Timeout(300.0, connect=30.0, read=300.0, write=30.0, pool=30.0)
|
||||
|
||||
image_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()
|
||||
body = exc.response.text[:500]
|
||||
raise RuntimeError(
|
||||
f"Codex Responses API returned HTTP {exc.response.status_code}: {body}"
|
||||
) from exc
|
||||
for event in _iter_sse_json(response):
|
||||
found = _extract_image_b64(event)
|
||||
if found:
|
||||
image_b64 = found
|
||||
|
||||
return image_b64
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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",
|
||||
"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 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 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:
|
||||
b64 = _collect_image_b64(
|
||||
token,
|
||||
prompt=prompt,
|
||||
size=size,
|
||||
quality=meta["quality"],
|
||||
)
|
||||
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 b64:
|
||||
return error_response(
|
||||
error="Codex response contained no image_generation_call result",
|
||||
error_type="empty_response",
|
||||
provider="openai-codex",
|
||||
model=tier_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
try:
|
||||
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",
|
||||
extra={"size": size, "quality": meta["quality"]},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — register the Codex-backed image-gen provider."""
|
||||
ctx.register_image_gen_provider(OpenAICodexImageGenProvider())
|
||||
@@ -0,0 +1,5 @@
|
||||
name: openai-codex
|
||||
version: 1.0.0
|
||||
description: "OpenAI image generation backed by ChatGPT/Codex OAuth (gpt-image-2 via the Responses image_generation tool). Saves generated images to $HERMES_HOME/cache/images/."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
@@ -0,0 +1,316 @@
|
||||
"""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.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__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenAIImageGenProvider(ImageGenProvider):
|
||||
"""OpenAI ``images.generate`` backend — gpt-image-2 at low/medium/high."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "openai"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "OpenAI"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
if not os.environ.get("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",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "OPENAI_API_KEY",
|
||||
"prompt": "OpenAI API key",
|
||||
"url": "https://platform.openai.com/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 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,
|
||||
)
|
||||
|
||||
if not os.environ.get("OPENAI_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"])
|
||||
|
||||
# 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:
|
||||
client = openai.OpenAI()
|
||||
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",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — wire ``OpenAIImageGenProvider`` into the registry."""
|
||||
ctx.register_image_gen_provider(OpenAIImageGenProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: openai
|
||||
version: 1.0.0
|
||||
description: "OpenAI image generation backend (gpt-image-2). Saves generated images to $HERMES_HOME/cache/images/."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- OPENAI_API_KEY
|
||||
@@ -0,0 +1,334 @@
|
||||
"""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 typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from agent.image_gen_provider import (
|
||||
DEFAULT_ASPECT_RATIO,
|
||||
ImageGenProvider,
|
||||
error_response,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
from tools.xai_http import hermes_xai_user_agent, resolve_xai_http_credentials
|
||||
|
||||
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-quality": {
|
||||
"display": "Grok Imagine Image (Quality)",
|
||||
"speed": "~10-20s",
|
||||
"strengths": "Higher fidelity / detail; slower than the standard model.",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_MODEL = "grok-imagine-image"
|
||||
|
||||
# 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() -> Tuple[str, Dict[str, Any]]:
|
||||
"""Decide which model to use and return ``(model_id, meta)``."""
|
||||
env_override = os.environ.get("XAI_IMAGE_MODEL")
|
||||
if env_override and env_override in _MODELS:
|
||||
return env_override, _MODELS[env_override]
|
||||
|
||||
cfg = _load_xai_config()
|
||||
candidate = cfg.get("model") if isinstance(cfg.get("model"), str) else None
|
||||
if candidate and candidate in _MODELS:
|
||||
return candidate, _MODELS[candidate]
|
||||
|
||||
return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 _MODELS.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.
|
||||
return {
|
||||
"name": "xAI Grok Imagine (image)",
|
||||
"badge": "paid",
|
||||
"tag": "grok-imagine-image — text-to-image; uses xAI Grok OAuth or XAI_API_KEY",
|
||||
"env_vars": [],
|
||||
"post_setup": "xai_grok",
|
||||
}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate an image using xAI's grok-imagine-image."""
|
||||
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()
|
||||
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
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model_id,
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": xai_ar,
|
||||
"resolution": xai_res,
|
||||
}
|
||||
|
||||
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("/")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{base_url}/images/generations",
|
||||
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 or data[0].url
|
||||
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")
|
||||
|
||||
if 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] = {
|
||||
"resolution": xai_res,
|
||||
}
|
||||
|
||||
return success_response(
|
||||
image=image_ref,
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
provider="xai",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register(ctx: Any) -> None:
|
||||
"""Register this provider with the image gen registry."""
|
||||
ctx.register_image_gen_provider(XAIImageGenProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: xai
|
||||
version: 1.0.0
|
||||
description: "xAI image generation backend (grok-imagine-image). Text-to-image."
|
||||
author: Julien Talbot
|
||||
kind: backend
|
||||
requires_env:
|
||||
- XAI_API_KEY
|
||||
+3875
File diff suppressed because it is too large
Load Diff
+1583
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "kanban",
|
||||
"label": "Kanban",
|
||||
"description": "Multi-agent collaboration board — drag-drop cards across columns, read comment threads, see which profile is running what",
|
||||
"icon": "Package",
|
||||
"version": "1.0.0",
|
||||
"tab": {
|
||||
"path": "/kanban",
|
||||
"position": "after:skills"
|
||||
},
|
||||
"entry": "dist/index.js",
|
||||
"css": "dist/style.css",
|
||||
"api": "plugin_api.py"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
# DEPRECATED — the kanban dispatcher now runs inside the gateway by
|
||||
# default (config key: kanban.dispatch_in_gateway, default true). To
|
||||
# migrate:
|
||||
#
|
||||
# systemctl --user disable --now hermes-kanban-dispatcher.service
|
||||
# # then make sure a gateway is running; e.g. a systemd user unit
|
||||
# # for `hermes gateway start`. The gateway hosts the dispatcher.
|
||||
#
|
||||
# This unit is kept for users who truly cannot run the gateway (host
|
||||
# policy forbids long-lived services, etc.). It now invokes the
|
||||
# standalone dispatcher via the explicit --force flag, so nobody
|
||||
# accidentally keeps two dispatchers racing against the same
|
||||
# kanban.db. Running this unit AND a gateway with
|
||||
# dispatch_in_gateway=true is NOT supported.
|
||||
|
||||
[Unit]
|
||||
Description=Hermes Kanban dispatcher (DEPRECATED standalone daemon — prefer gateway-embedded dispatch)
|
||||
Documentation=https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/env hermes kanban daemon --force --interval 60 --pidfile %t/hermes-kanban-dispatcher.pid
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# Log to the journal via stdout/stderr; the dispatcher also writes per-task
|
||||
# worker output to $HERMES_HOME/kanban/logs/<task>.log.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Memory provider plugin discovery.
|
||||
|
||||
Scans two directories for memory provider plugins:
|
||||
|
||||
1. Bundled providers: ``plugins/memory/<name>/`` (shipped with hermes-agent)
|
||||
2. User-installed providers: ``$HERMES_HOME/plugins/<name>/``
|
||||
|
||||
Each subdirectory must contain ``__init__.py`` with a class implementing
|
||||
the MemoryProvider ABC. On name collisions, bundled providers take
|
||||
precedence.
|
||||
|
||||
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.util
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MEMORY_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_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 _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")[: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 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/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>/)
|
||||
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_memory_provider_dir(child):
|
||||
continue # skip non-memory 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 = _MEMORY_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_memory_provider_dir(user):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def discover_memory_providers() -> List[Tuple[str, str, bool]]:
|
||||
"""Scan bundled and user-installed directories for available providers.
|
||||
|
||||
Returns list of (name, description, is_available) tuples.
|
||||
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_memory_provider(name: str) -> Optional["MemoryProvider"]:
|
||||
"""Load and return a MemoryProvider instance by name.
|
||||
|
||||
Checks both bundled (``plugins/memory/<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("Memory 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("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_dir(provider_dir: Path) -> 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()
|
||||
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 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:
|
||||
"""Fake plugin context that captures register_memory_provider calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.provider = None
|
||||
|
||||
def register_memory_provider(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_cli_command(self, *args, **kwargs):
|
||||
pass # CLI registration happens via discover_plugin_cli_commands()
|
||||
|
||||
|
||||
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 discover_plugin_cli_commands() -> List[dict]:
|
||||
"""Return CLI commands for the **active** memory plugin only.
|
||||
|
||||
Only one memory provider can be active at a time (set via
|
||||
``memory.provider`` in config.yaml). This function reads that
|
||||
value and only loads CLI registration for the matching plugin.
|
||||
If no provider is active, no commands are registered.
|
||||
|
||||
Looks for a ``register_cli(subparser)`` function in the active
|
||||
plugin's ``cli.py``. Returns a list of at most one dict with
|
||||
keys: ``name``, ``help``, ``description``, ``setup_fn``,
|
||||
``handler_fn``.
|
||||
|
||||
This is a lightweight scan — it only imports ``cli.py``, not the
|
||||
full plugin module. Safe to call during argparse setup before
|
||||
any provider is loaded.
|
||||
"""
|
||||
results: List[dict] = []
|
||||
if not _MEMORY_PLUGINS_DIR.is_dir():
|
||||
return results
|
||||
|
||||
active_provider = _get_active_memory_provider()
|
||||
if not active_provider:
|
||||
return results
|
||||
|
||||
# Only look at the active provider's directory
|
||||
plugin_dir = find_provider_dir(active_provider)
|
||||
if not plugin_dir:
|
||||
return results
|
||||
|
||||
cli_file = plugin_dir / "cli.py"
|
||||
if not cli_file.exists():
|
||||
return results
|
||||
|
||||
_is_bundled = _MEMORY_PLUGINS_DIR in plugin_dir.parents or plugin_dir.parent == _MEMORY_PLUGINS_DIR
|
||||
module_name = f"plugins.memory.{active_provider}.cli" if _is_bundled else f"{_USER_NAMESPACE}.{active_provider}.cli"
|
||||
try:
|
||||
# Import the CLI module (lightweight — no SDK needed)
|
||||
if module_name in sys.modules:
|
||||
cli_mod = sys.modules[module_name]
|
||||
else:
|
||||
if not _is_bundled:
|
||||
# cli.py imports as _hermes_user_memory.<name>.cli, usually
|
||||
# before the provider itself is loaded. Register its parent
|
||||
# packages so relative imports inside cli.py
|
||||
# ("from . import config") resolve without executing the
|
||||
# plugin's __init__.py. The package shell has no __file__,
|
||||
# so _load_provider_from_dir() will still load the real
|
||||
# module later instead of reusing the shell.
|
||||
_register_synthetic_package(_USER_NAMESPACE, [])
|
||||
_register_synthetic_package(
|
||||
f"{_USER_NAMESPACE}.{active_provider}", [str(plugin_dir)]
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, str(cli_file)
|
||||
)
|
||||
if not spec or not spec.loader:
|
||||
return results
|
||||
cli_mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = cli_mod
|
||||
spec.loader.exec_module(cli_mod)
|
||||
|
||||
register_cli = getattr(cli_mod, "register_cli", None)
|
||||
if not callable(register_cli):
|
||||
return results
|
||||
|
||||
# Read metadata from plugin.yaml if available
|
||||
help_text = f"Manage {active_provider} memory plugin"
|
||||
description = ""
|
||||
yaml_file = plugin_dir / "plugin.yaml"
|
||||
if yaml_file.exists():
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_file, encoding="utf-8-sig") as f:
|
||||
meta = yaml.safe_load(f) or {}
|
||||
desc = meta.get("description", "")
|
||||
if desc:
|
||||
help_text = desc
|
||||
description = desc
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
handler_fn = getattr(cli_mod, f"{active_provider}_command", None) or \
|
||||
getattr(cli_mod, "honcho_command", None)
|
||||
|
||||
results.append({
|
||||
"name": active_provider,
|
||||
"help": help_text,
|
||||
"description": description,
|
||||
"setup_fn": register_cli,
|
||||
"handler_fn": handler_fn,
|
||||
"plugin": active_provider,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("Failed to scan CLI for memory plugin '%s': %s", active_provider, e)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,41 @@
|
||||
# ByteRover Memory Provider
|
||||
|
||||
Persistent memory via the `brv` CLI — hierarchical knowledge tree with tiered retrieval (fuzzy text → LLM-driven search).
|
||||
|
||||
## Requirements
|
||||
|
||||
Install the ByteRover CLI:
|
||||
```bash
|
||||
curl -fsSL https://byterover.dev/install.sh | sh
|
||||
# or
|
||||
npm install -g byterover-cli
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
hermes memory setup # select "byterover"
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
hermes config set memory.provider byterover
|
||||
# Optional cloud sync:
|
||||
echo "BRV_API_KEY=your-key" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
| Env Var | Required | Description |
|
||||
|---------|----------|-------------|
|
||||
| `BRV_API_KEY` | No | Cloud sync key (optional, local-first by default) |
|
||||
|
||||
Working directory: `$HERMES_HOME/byterover/` (profile-scoped).
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `brv_query` | Search the knowledge tree |
|
||||
| `brv_curate` | Store facts, decisions, patterns |
|
||||
| `brv_status` | CLI version, tree stats, sync state |
|
||||
@@ -0,0 +1,384 @@
|
||||
"""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)
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,
|
||||
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):
|
||||
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",
|
||||
},
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
# 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 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 messages:
|
||||
return ""
|
||||
|
||||
# Build a summary of messages about to be compressed
|
||||
parts = []
|
||||
for msg in messages[-10:]: # last 10 messages
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content.strip() and role in {"user", "assistant"}:
|
||||
parts.append(f"{role}: {content[:500]}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
combined = "\n".join(parts)
|
||||
|
||||
def _flush():
|
||||
try:
|
||||
_run_brv(
|
||||
["curate", "--", f"[Pre-compression context]\n{combined}"],
|
||||
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
|
||||
)
|
||||
logger.info("ByteRover pre-compression flush: %d messages", len(parts))
|
||||
except Exception as e:
|
||||
logger.debug("ByteRover pre-compression flush failed: %s", e)
|
||||
|
||||
t = threading.Thread(target=_flush, daemon=True, name="brv-flush")
|
||||
t.start()
|
||||
return ""
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [QUERY_SCHEMA, CURATE_SCHEMA, STATUS_SCHEMA]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
if tool_name == "brv_query":
|
||||
return self._tool_query(args)
|
||||
elif tool_name == "brv_curate":
|
||||
return self._tool_curate(args)
|
||||
elif tool_name == "brv_status":
|
||||
return self._tool_status()
|
||||
return tool_error(f"Unknown tool: {tool_name}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self._sync_thread and self._sync_thread.is_alive():
|
||||
self._sync_thread.join(timeout=10.0)
|
||||
|
||||
# -- Tool implementations ------------------------------------------------
|
||||
|
||||
def _tool_query(self, args: dict) -> str:
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return tool_error("query is required")
|
||||
|
||||
result = _run_brv(
|
||||
["query", "--", query.strip()[:5000]],
|
||||
timeout=_QUERY_TIMEOUT, cwd=self._cwd,
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
return tool_error(result.get("error", "Query failed"))
|
||||
|
||||
output = result.get("output", "").strip()
|
||||
if not output or len(output) < _MIN_OUTPUT_LEN:
|
||||
return json.dumps({"result": "No relevant memories found."})
|
||||
|
||||
# Truncate very long results
|
||||
if len(output) > 8000:
|
||||
output = output[:8000] + "\n\n[... truncated]"
|
||||
|
||||
return json.dumps({"result": output})
|
||||
|
||||
def _tool_curate(self, args: dict) -> str:
|
||||
content = args.get("content", "")
|
||||
if not content:
|
||||
return tool_error("content is required")
|
||||
|
||||
result = _run_brv(
|
||||
["curate", "--", content],
|
||||
timeout=_CURATE_TIMEOUT, cwd=self._cwd,
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
return tool_error(result.get("error", "Curate failed"))
|
||||
|
||||
return json.dumps({"result": "Memory curated successfully."})
|
||||
|
||||
def _tool_status(self) -> str:
|
||||
result = _run_brv(["status"], timeout=15, cwd=self._cwd)
|
||||
if not result["success"]:
|
||||
return tool_error(result.get("error", "Status check failed"))
|
||||
return json.dumps({"status": result.get("output", "")})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register ByteRover as a memory provider plugin."""
|
||||
ctx.register_memory_provider(ByteRoverMemoryProvider())
|
||||
@@ -0,0 +1,9 @@
|
||||
name: byterover
|
||||
version: 1.0.0
|
||||
description: "ByteRover — persistent knowledge tree with tiered retrieval via the brv CLI."
|
||||
external_dependencies:
|
||||
- name: brv
|
||||
install: "curl -fsSL https://byterover.dev/install.sh | sh"
|
||||
check: "brv --version"
|
||||
hooks:
|
||||
- on_pre_compress
|
||||
@@ -0,0 +1,147 @@
|
||||
# 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 will install dependencies automatically via `uv` and walk you through configuration.
|
||||
|
||||
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 |
|
||||
|
||||
> **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` | — | Optional `metadata.source` attached to retained memories |
|
||||
| `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.4.22`. The plugin auto-upgrades on session start if an older version is detected.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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.4.22"
|
||||
requires_env: []
|
||||
hooks:
|
||||
- on_session_end
|
||||
@@ -0,0 +1,36 @@
|
||||
# Holographic Memory Provider
|
||||
|
||||
Local SQLite fact store with FTS5 search, trust scoring, entity resolution, and HRR-based compositional retrieval.
|
||||
|
||||
## Requirements
|
||||
|
||||
None — uses SQLite (always available). NumPy optional for HRR algebra.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
hermes memory setup # select "holographic"
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
hermes config set memory.provider holographic
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
Config in `config.yaml` under `plugins.hermes-memory-store`:
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `db_path` | `$HERMES_HOME/memory_store.db` | SQLite database path |
|
||||
| `auto_extract` | `false` | Auto-extract facts at session end |
|
||||
| `default_trust` | `0.5` | Default trust score for new facts |
|
||||
| `hrr_dim` | `1024` | HRR vector dimensions |
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `fact_store` | 9 actions: add, search, probe, related, reason, contradict, update, remove, list |
|
||||
| `fact_feedback` | Rate facts as helpful/unhelpful (trains trust scores) |
|
||||
@@ -0,0 +1,408 @@
|
||||
"""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 .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:
|
||||
from hermes_constants import get_hermes_home
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
try:
|
||||
import yaml
|
||||
with open(config_path, encoding="utf-8-sig") as f:
|
||||
all_config = yaml.safe_load(f) or {}
|
||||
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
|
||||
existing = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8-sig") as f:
|
||||
existing = yaml.safe_load(f) or {}
|
||||
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:
|
||||
if not 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:
|
||||
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:
|
||||
_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
|
||||
content = msg.get("content", "")
|
||||
if not isinstance(content, str) or len(content) < 10:
|
||||
continue
|
||||
|
||||
for pattern in _PREF_PATTERNS:
|
||||
if pattern.search(content):
|
||||
try:
|
||||
self._store.add_fact(content[:400], category="user_pref")
|
||||
extracted += 1
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
for pattern in _DECISION_PATTERNS:
|
||||
if pattern.search(content):
|
||||
try:
|
||||
self._store.add_fact(content[:400], category="project")
|
||||
extracted += 1
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
if extracted:
|
||||
logger.info("Auto-extracted %d facts from conversation", extracted)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the holographic memory provider with the plugin system."""
|
||||
config = _load_plugin_config()
|
||||
provider = HolographicMemoryProvider(config=config)
|
||||
ctx.register_memory_provider(provider)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""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
|
||||
|
||||
|
||||
def _require_numpy() -> None:
|
||||
if not _HAS_NUMPY:
|
||||
raise RuntimeError("numpy is required for holographic operations")
|
||||
|
||||
|
||||
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") -> bytes:
|
||||
"""Serialize phase vector to bytes. float64 tobytes — 8 KB at dim=1024."""
|
||||
_require_numpy()
|
||||
return phases.tobytes()
|
||||
|
||||
|
||||
def bytes_to_phases(data: bytes) -> "np.ndarray":
|
||||
"""Deserialize bytes back to phase vector. Inverse of phases_to_bytes.
|
||||
|
||||
The .copy() call is required because frombuffer returns a read-only view
|
||||
backed by the bytes object; callers expect a mutable array.
|
||||
"""
|
||||
_require_numpy()
|
||||
return np.frombuffer(data, dtype=np.float64).copy()
|
||||
|
||||
|
||||
def snr_estimate(dim: int, n_items: int) -> float:
|
||||
"""Signal-to-noise ratio estimate for holographic storage.
|
||||
|
||||
SNR = sqrt(dim / n_items) when n_items > 0, else inf.
|
||||
|
||||
The SNR falls below 2.0 when n_items > dim / 4, meaning retrieval
|
||||
errors become likely. Logs a warning when this threshold is crossed.
|
||||
"""
|
||||
_require_numpy()
|
||||
|
||||
if n_items <= 0:
|
||||
return float("inf")
|
||||
|
||||
snr = math.sqrt(dim / n_items)
|
||||
|
||||
if snr < 2.0:
|
||||
logger.warning(
|
||||
"HRR storage near capacity: SNR=%.2f (dim=%d, n_items=%d). "
|
||||
"Retrieval accuracy may degrade. Consider increasing dim or reducing stored items.",
|
||||
snr,
|
||||
dim,
|
||||
n_items,
|
||||
)
|
||||
|
||||
return snr
|
||||
@@ -0,0 +1,5 @@
|
||||
name: holographic
|
||||
version: 0.1.0
|
||||
description: "Holographic memory — local SQLite fact store with FTS5 search, trust scoring, and HRR-based compositional retrieval."
|
||||
hooks:
|
||||
- on_session_end
|
||||
@@ -0,0 +1,593 @@
|
||||
"""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)
|
||||
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"])
|
||||
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"])
|
||||
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)
|
||||
|
||||
scored = []
|
||||
for row in rows:
|
||||
fact = dict(row)
|
||||
fact_vec = hrr.bytes_to_phases(fact.pop("hrr_vector"))
|
||||
# Unbind probe key from fact to see if entity is structurally present
|
||||
residual = hrr.unbind(fact_vec, probe_key)
|
||||
# Compare residual against content signal
|
||||
role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim)
|
||||
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
|
||||
scored = []
|
||||
for row in rows:
|
||||
fact = dict(row)
|
||||
fact_vec = hrr.bytes_to_phases(fact.pop("hrr_vector"))
|
||||
|
||||
# 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
|
||||
role_entity = hrr.encode_atom("__hrr_role_entity__", self.hrr_dim)
|
||||
role_content = hrr.encode_atom("__hrr_role_content__", self.hrr_dim)
|
||||
|
||||
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"))
|
||||
|
||||
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"])
|
||||
v2 = hrr.bytes_to_phases(f2["hrr_vector"])
|
||||
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"))
|
||||
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 ?"]
|
||||
params.append(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
|
||||
|
||||
@staticmethod
|
||||
def _jaccard_similarity(set_a: set, set_b: set) -> float:
|
||||
"""Jaccard similarity coefficient: |A ∩ B| / |A ∪ B|."""
|
||||
if not set_a or not set_b:
|
||||
return 0.0
|
||||
intersection = len(set_a & set_b)
|
||||
union = len(set_a | set_b)
|
||||
return intersection / union if union > 0 else 0.0
|
||||
|
||||
def _temporal_decay(self, timestamp_str: str | None) -> float:
|
||||
"""Exponential decay: 0.5^(age_days / half_life_days).
|
||||
|
||||
Returns 1.0 if decay is disabled or timestamp is missing.
|
||||
"""
|
||||
if not self.half_life or not timestamp_str:
|
||||
return 1.0
|
||||
|
||||
try:
|
||||
if isinstance(timestamp_str, str):
|
||||
# Parse ISO format timestamp from SQLite
|
||||
ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||
else:
|
||||
ts = timestamp_str
|
||||
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
|
||||
age_days = (datetime.now(timezone.utc) - ts).total_seconds() / 86400
|
||||
if age_days < 0:
|
||||
return 1.0
|
||||
|
||||
return math.pow(0.5, age_days / self.half_life)
|
||||
except (ValueError, TypeError):
|
||||
return 1.0
|
||||
@@ -0,0 +1,578 @@
|
||||
"""
|
||||
SQLite-backed fact store with entity resolution and trust scoring.
|
||||
Single-user Hermes memory store plugin.
|
||||
"""
|
||||
|
||||
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."""
|
||||
|
||||
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
|
||||
self._conn: sqlite3.Connection = sqlite3.connect(
|
||||
str(self.db_path),
|
||||
check_same_thread=False,
|
||||
timeout=10.0,
|
||||
)
|
||||
self._lock = threading.RLock()
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._init_db()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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 []
|
||||
|
||||
params: list = [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"]) 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)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the database connection."""
|
||||
self._conn.close()
|
||||
|
||||
def __enter__(self) -> "MemoryStore":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_: object) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,368 @@
|
||||
# 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`
|
||||
- Honcho 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
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
### Cold Start vs Warm Session Prompts
|
||||
|
||||
Dialectic pass 0 automatically selects its prompt based on session state:
|
||||
|
||||
- **Cold** (no base context cached): "Who is this person? What are their preferences, goals, and working style? Focus on facts that would help an AI assistant be immediately useful."
|
||||
- **Warm** (base context exists): "Given what's been discussed in this session so far, what context about this user is most relevant to the current conversation? Prioritize active context over biographical facts."
|
||||
|
||||
Not configurable — determined automatically.
|
||||
|
||||
### Dialectic Depth (Multi-Pass Reasoning)
|
||||
|
||||
`dialecticDepth` (1–3, clamped) controls how many `.chat()` calls fire per dialectic cycle:
|
||||
|
||||
| Depth | Passes | Behavior |
|
||||
|-------|--------|----------|
|
||||
| 1 | single `.chat()` | Base query only (cold or warm prompt) |
|
||||
| 2 | audit + synthesis | Pass 0 result is self-audited; pass 1 does targeted synthesis. Conditional bail-out if pass 0 returns strong signal (>300 chars or structured with bullets/sections >100 chars) |
|
||||
| 3 | audit + synthesis + reconciliation | Pass 2 reconciles contradictions across prior passes into a final synthesis |
|
||||
|
||||
### Proportional Reasoning Levels
|
||||
|
||||
When `dialecticDepthLevels` is not set, each pass uses a proportional level relative to `dialecticReasoningLevel` (the "base"):
|
||||
|
||||
| Depth | Pass levels |
|
||||
|-------|-------------|
|
||||
| 1 | [base] |
|
||||
| 2 | [minimal, base] |
|
||||
| 3 | [minimal, base, low] |
|
||||
|
||||
Override with `dialecticDepthLevels`: an explicit array of reasoning level strings per pass.
|
||||
|
||||
### Three Orthogonal Dialectic Knobs
|
||||
|
||||
| Knob | Controls | Type |
|
||||
|------|----------|------|
|
||||
| `dialecticCadence` | How often — minimum turns between dialectic firings | int |
|
||||
| `dialecticDepth` | How many — passes per firing (1–3) | int |
|
||||
| `dialecticReasoningLevel` | How hard — reasoning ceiling per `.chat()` call | string |
|
||||
|
||||
### Input Sanitization
|
||||
|
||||
`run_conversation` strips leaked `<memory-context>` blocks from user input before processing. When `saveMessages` persists a turn that included injected context, the block can reappear in subsequent turns via message history. The sanitizer removes `<memory-context>` blocks plus associated system notes.
|
||||
|
||||
## Tools
|
||||
|
||||
Five bidirectional tools. All accept an optional `peer` parameter (`"user"` or `"ai"`, default `"user"`).
|
||||
|
||||
| Tool | LLM call? | Description |
|
||||
|------|-----------|-------------|
|
||||
| `honcho_profile` | No | Peer card — key facts snapshot |
|
||||
| `honcho_search` | No | Semantic search over stored context (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 a persistent fact/conclusion about the user |
|
||||
|
||||
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 |
|
||||
| `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. Also accepted as `pinPeerName` |
|
||||
| `pinPeerName` | bool | `false` | Alias for `pinUserPeer`; same effect |
|
||||
| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "eri"}`). 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_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape |
|
||||
|
||||
**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: ""`.
|
||||
|
||||
**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these):
|
||||
|
||||
- **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc.
|
||||
- **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans.
|
||||
- **Hybrid** — `pinUserPeer: false`, `userPeerAliases` mapping the operator's runtime IDs to `peerName`. Multi-user gateway where YOU are routed but others stay distinct.
|
||||
|
||||
**Migrating single → multi.** 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, use the **hybrid** shape — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The setup wizard offers this path automatically when it detects a single → multi transition.
|
||||
|
||||
### 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"` (shared pool). 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 |
|
||||
|
||||
### 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: `eri-hermes-agent`.
|
||||
|
||||
#### What each strategy produces
|
||||
|
||||
- **`per-directory`** — basename of `$PWD`. Opening hermes in `~/code/myapp` and `~/code/other` gives two separate sessions. Same directory = same session across runs.
|
||||
- **`per-repo`** — git root directory name. All subdirectories within a repo share one session. Falls back to `per-directory` if not inside a git repo.
|
||||
- **`per-session`** — Hermes session ID (timestamp + hex). Every `hermes` invocation starts a fresh Honcho session. Falls back to `per-directory` if no session ID is available.
|
||||
- **`global`** — workspace name. One session for everything. Memory accumulates across all directories and runs.
|
||||
|
||||
### Multi-Profile Pattern
|
||||
|
||||
Multiple Hermes profiles can share one workspace while maintaining separate AI identities. Config resolution is **host block > root > env var > default** — host blocks inherit from root, so shared settings only need to be declared once:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "***",
|
||||
"workspace": "hermes",
|
||||
"peerName": "yourname",
|
||||
"hosts": {
|
||||
"hermes": {
|
||||
"aiPeer": "hermes",
|
||||
"recallMode": "hybrid",
|
||||
"sessionStrategy": "per-directory"
|
||||
},
|
||||
"hermes_coder": {
|
||||
"aiPeer": "coder",
|
||||
"recallMode": "tools",
|
||||
"sessionStrategy": "per-repo"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both profiles see the same user (`yourname`) in the same shared environment (`hermes`), but each AI peer builds its own observations, conclusions, and behavior patterns. The coder's memory stays code-oriented; the main agent's stays broad.
|
||||
|
||||
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>` (e.g. `hermes -p coder` -> host key `hermes_coder`). Older `hermes.<profile>` host blocks are still read for compatibility and are migrated when the CLI writes profile-scoped Honcho config.
|
||||
|
||||
### Dialectic & Reasoning
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `dialecticDepth` | int | `1` | Passes per dialectic cycle (1–3, clamped). 1=single query, 2=audit+synthesis, 3=audit+synthesis+reconciliation |
|
||||
| `dialecticDepthLevels` | array | — | Optional array of reasoning level strings per pass. Overrides proportional defaults. Example: `["minimal", "low", "medium"]` |
|
||||
| `dialecticReasoningLevel` | string | `"low"` | Base reasoning level for `.chat()`: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` |
|
||||
| `dialecticDynamic` | bool | `true` | When `true`, model can override reasoning level per-call via `honcho_reasoning` tool. When `false`, always uses `dialecticReasoningLevel` |
|
||||
| `dialecticMaxChars` | int | `600` | Max chars of dialectic result injected into system prompt |
|
||||
| `dialecticMaxInputChars` | int | `10000` | Max chars for dialectic query input to `.chat()`. Honcho cloud limit: 10k |
|
||||
|
||||
### 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 context on the first user message only, skip from turn 2 onward) |
|
||||
| `reasoningLevelCap` | string | — | Hard cap on reasoning level: `"minimal"`, `"low"`, `"medium"`, `"high"` |
|
||||
|
||||
### 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 |
|
||||
|
||||
## 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
@@ -0,0 +1,882 @@
|
||||
"""Honcho client initialization and configuration.
|
||||
|
||||
Resolution order for config file:
|
||||
1. $HERMES_HOME/honcho.json (instance-local, enables isolated Hermes instances)
|
||||
2. ~/.honcho/config.json (global, shared across all Honcho-enabled apps)
|
||||
3. Environment variables (HONCHO_API_KEY, HONCHO_ENVIRONMENT)
|
||||
|
||||
Resolution order for host-specific settings:
|
||||
1. Explicit host block fields (always win)
|
||||
2. Flat/global fields from config root
|
||||
3. Defaults (host name as workspace/peer)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.profiles import _get_default_hermes_home
|
||||
from plugins.plugin_utils import SingletonSlot
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from honcho import Honcho
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HOST = "hermes"
|
||||
|
||||
|
||||
def profile_host_key(profile: str | None) -> str:
|
||||
"""Return the safe Honcho host key for a Hermes profile."""
|
||||
if not profile or profile in {"default", "custom"}:
|
||||
return HOST
|
||||
sanitized = "".join(c if c.isalnum() or c in "_-" else "_" for c in profile).strip("_")
|
||||
return f"{HOST}_{sanitized or 'profile'}"
|
||||
|
||||
|
||||
def _host_block(raw: dict, host: str) -> dict:
|
||||
"""Return host config, accepting legacy dot-form profile host keys."""
|
||||
hosts = raw.get("hosts") or {}
|
||||
block = hosts.get(host, {})
|
||||
if block or not host.startswith(f"{HOST}_"):
|
||||
return block
|
||||
legacy = f"{HOST}.{host[len(HOST) + 1:]}"
|
||||
return hosts.get(legacy, {})
|
||||
|
||||
|
||||
def resolve_active_host() -> str:
|
||||
"""Derive the Honcho host key from the active Hermes profile.
|
||||
|
||||
Resolution order:
|
||||
1. HERMES_HONCHO_HOST env var (explicit override)
|
||||
2. Active profile name via profiles system -> ``hermes.<profile>``
|
||||
3. Fallback: ``"hermes"`` (default profile)
|
||||
"""
|
||||
explicit = os.environ.get("HERMES_HONCHO_HOST", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
profile = get_active_profile_name()
|
||||
return profile_host_key(profile)
|
||||
except Exception:
|
||||
pass
|
||||
return HOST
|
||||
|
||||
|
||||
def resolve_global_config_path() -> Path:
|
||||
"""Return the shared Honcho config path for the current HOME."""
|
||||
return Path.home() / ".honcho" / "config.json"
|
||||
|
||||
|
||||
def resolve_config_path() -> Path:
|
||||
"""Return the active Honcho config path.
|
||||
|
||||
Resolution order:
|
||||
1. $HERMES_HOME/honcho.json (profile-local, if it exists)
|
||||
2. ~/.hermes/honcho.json (default profile — shared host blocks live here)
|
||||
3. ~/.honcho/config.json (global, cross-app interop)
|
||||
|
||||
Returns the global path if none exist (for first-time setup writes).
|
||||
"""
|
||||
local_path = get_hermes_home() / "honcho.json"
|
||||
if local_path.exists():
|
||||
return local_path
|
||||
|
||||
# Default profile's config — host blocks accumulate here via setup/clone
|
||||
default_path = _get_default_hermes_home() / "honcho.json"
|
||||
if default_path != local_path and default_path.exists():
|
||||
return default_path
|
||||
|
||||
return resolve_global_config_path()
|
||||
|
||||
|
||||
_RECALL_MODE_ALIASES = {"auto": "hybrid"}
|
||||
_VALID_RECALL_MODES = {"hybrid", "context", "tools"}
|
||||
|
||||
|
||||
def _normalize_recall_mode(val: str) -> str:
|
||||
"""Normalize legacy recall mode values (e.g. 'auto' → 'hybrid')."""
|
||||
val = _RECALL_MODE_ALIASES.get(val, val)
|
||||
return val if val in _VALID_RECALL_MODES else "hybrid"
|
||||
|
||||
|
||||
def _resolve_bool(*vals, default: bool) -> bool:
|
||||
"""Resolve a bool config field: first non-None wins, else default.
|
||||
|
||||
Variadic to support aliased keys (e.g. ``pinUserPeer`` shadowing
|
||||
``pinPeerName`` for backwards compatibility). Pass values in
|
||||
precedence order: caller's preferred alias first, then fallback
|
||||
aliases, in (host, root) interleaving as needed.
|
||||
"""
|
||||
for val in vals:
|
||||
if val is not None:
|
||||
return bool(val)
|
||||
return default
|
||||
|
||||
|
||||
def _parse_context_tokens(host_val, root_val) -> int | None:
|
||||
"""Parse contextTokens: host wins, then root, then None (uncapped)."""
|
||||
for val in (host_val, root_val):
|
||||
if val is not None:
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_int_config(host_val, root_val, default: int) -> int:
|
||||
"""Parse an integer config: host wins, then root, then default."""
|
||||
for val in (host_val, root_val):
|
||||
if val is not None:
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def _parse_string_map(host_obj: dict, root_obj: dict, key: str) -> dict[str, str]:
|
||||
"""Parse a string-to-string map with host-level whole-map override."""
|
||||
source = host_obj[key] if key in host_obj else root_obj.get(key)
|
||||
if not isinstance(source, dict):
|
||||
return {}
|
||||
|
||||
result: dict[str, str] = {}
|
||||
for raw_key, raw_value in source.items():
|
||||
alias_key = str(raw_key).strip()
|
||||
alias_value = str(raw_value).strip() if raw_value is not None else ""
|
||||
if alias_key and alias_value:
|
||||
result[alias_key] = alias_value
|
||||
return result
|
||||
|
||||
|
||||
def _parse_optional_string(
|
||||
host_obj: dict, root_obj: dict, key: str, default: str = ""
|
||||
) -> str:
|
||||
"""Parse a string field where host-level empty string can override root."""
|
||||
if key in host_obj:
|
||||
value = host_obj.get(key)
|
||||
else:
|
||||
value = root_obj.get(key, default)
|
||||
if value is None:
|
||||
return default
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _parse_dialectic_depth(host_val, root_val) -> int:
|
||||
"""Parse dialecticDepth: host wins, then root, then 1. Clamped to 1-3."""
|
||||
for val in (host_val, root_val):
|
||||
if val is not None:
|
||||
try:
|
||||
return max(1, min(int(val), 3))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
_VALID_REASONING_LEVELS = ("minimal", "low", "medium", "high", "max")
|
||||
|
||||
|
||||
def _parse_dialectic_depth_levels(host_val, root_val, depth: int) -> list[str] | None:
|
||||
"""Parse dialecticDepthLevels: optional array of reasoning levels per pass.
|
||||
|
||||
Returns None when not configured (use proportional defaults).
|
||||
When configured, validates each level and truncates/pads to match depth.
|
||||
"""
|
||||
for val in (host_val, root_val):
|
||||
if val is not None and isinstance(val, list):
|
||||
levels = [
|
||||
lvl if lvl in _VALID_REASONING_LEVELS else "low"
|
||||
for lvl in val[:depth]
|
||||
]
|
||||
# Pad with "low" if array is shorter than depth
|
||||
while len(levels) < depth:
|
||||
levels.append("low")
|
||||
return levels
|
||||
return None
|
||||
|
||||
|
||||
# Default HTTP timeout (seconds) applied when no explicit timeout is
|
||||
# configured via HonchoClientConfig.timeout, honcho.timeout / requestTimeout,
|
||||
# or HONCHO_TIMEOUT. Honcho calls happen on the post-response path of
|
||||
# run_conversation; without a cap the agent can block indefinitely when
|
||||
# the Honcho backend is unreachable, preventing the gateway from
|
||||
# delivering the already-generated response.
|
||||
_DEFAULT_HTTP_TIMEOUT = 30.0
|
||||
|
||||
|
||||
def _resolve_optional_float(*values: Any) -> float | None:
|
||||
"""Return the first non-empty value coerced to a positive float."""
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if parsed > 0:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
_VALID_OBSERVATION_MODES = {"unified", "directional"}
|
||||
_OBSERVATION_MODE_ALIASES = {"shared": "unified", "separate": "directional", "cross": "directional"}
|
||||
|
||||
|
||||
def _normalize_observation_mode(val: str) -> str:
|
||||
"""Normalize observation mode values."""
|
||||
val = _OBSERVATION_MODE_ALIASES.get(val, val)
|
||||
return val if val in _VALID_OBSERVATION_MODES else "directional"
|
||||
|
||||
|
||||
# Observation presets — granular booleans derived from legacy string mode.
|
||||
# Explicit per-peer config always wins over presets.
|
||||
_OBSERVATION_PRESETS = {
|
||||
"directional": {
|
||||
"user_observe_me": True, "user_observe_others": True,
|
||||
"ai_observe_me": True, "ai_observe_others": True,
|
||||
},
|
||||
"unified": {
|
||||
"user_observe_me": True, "user_observe_others": False,
|
||||
"ai_observe_me": False, "ai_observe_others": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_observation(
|
||||
mode: str,
|
||||
observation_obj: dict | None,
|
||||
) -> dict:
|
||||
"""Resolve per-peer observation booleans.
|
||||
|
||||
Config forms:
|
||||
String shorthand: ``"observationMode": "directional"``
|
||||
Granular object: ``"observation": {"user": {"observeMe": true, "observeOthers": true},
|
||||
"ai": {"observeMe": true, "observeOthers": false}}``
|
||||
|
||||
Granular fields override preset defaults.
|
||||
"""
|
||||
preset = _OBSERVATION_PRESETS.get(mode, _OBSERVATION_PRESETS["directional"])
|
||||
if not observation_obj or not isinstance(observation_obj, dict):
|
||||
return dict(preset)
|
||||
|
||||
user_block = observation_obj.get("user") or {}
|
||||
ai_block = observation_obj.get("ai") or {}
|
||||
|
||||
return {
|
||||
"user_observe_me": user_block.get("observeMe", preset["user_observe_me"]),
|
||||
"user_observe_others": user_block.get("observeOthers", preset["user_observe_others"]),
|
||||
"ai_observe_me": ai_block.get("observeMe", preset["ai_observe_me"]),
|
||||
"ai_observe_others": ai_block.get("observeOthers", preset["ai_observe_others"]),
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class HonchoClientConfig:
|
||||
"""Configuration for Honcho client, resolved for a specific host."""
|
||||
|
||||
host: str = HOST
|
||||
workspace_id: str = "hermes"
|
||||
api_key: str | None = None
|
||||
environment: str = "production"
|
||||
# Optional base URL for self-hosted Honcho (overrides environment mapping)
|
||||
base_url: str | None = None
|
||||
# Optional request timeout in seconds for Honcho SDK HTTP calls
|
||||
timeout: float | None = None
|
||||
# Identity
|
||||
peer_name: str | None = None
|
||||
ai_peer: str = "hermes"
|
||||
# When True, ``peer_name`` wins over any gateway-supplied runtime
|
||||
# identity (Telegram UID, Discord ID, …) when resolving the user peer.
|
||||
# This keeps memory unified across platforms for single-user deployments
|
||||
# where Honcho's one peer-name is an unambiguous identity — otherwise
|
||||
# each platform would fork memory into its own peer (#14984). Default
|
||||
# ``False`` preserves existing multi-user behaviour.
|
||||
pin_peer_name: bool = False
|
||||
# Map gateway runtime user IDs to stable Honcho user peers. Host-level
|
||||
# config replaces the root map as a whole so profiles can intentionally
|
||||
# own their identity mappings.
|
||||
user_peer_aliases: dict[str, str] = field(default_factory=dict)
|
||||
# Optional prefix for unknown gateway runtime user IDs, e.g. "telegram_".
|
||||
runtime_peer_prefix: str = ""
|
||||
# Toggles
|
||||
enabled: bool = False
|
||||
save_messages: bool = True
|
||||
# Write frequency: "async" (background thread), "turn" (sync per turn),
|
||||
# "session" (flush on session end), or int (every N turns)
|
||||
write_frequency: str | int = "async"
|
||||
# Prefetch budget (None = no cap; set to an integer to bound auto-injected context)
|
||||
context_tokens: int | None = None
|
||||
# Dialectic (peer.chat) settings
|
||||
# reasoning_level: "minimal" | "low" | "medium" | "high" | "max"
|
||||
dialectic_reasoning_level: str = "low"
|
||||
# When true, the model can override reasoning_level per-call via the
|
||||
# honcho_reasoning tool param (agentic). When false, always uses
|
||||
# dialecticReasoningLevel and ignores model-provided overrides.
|
||||
dialectic_dynamic: bool = True
|
||||
# Max chars of dialectic result to inject into Hermes system prompt
|
||||
dialectic_max_chars: int = 600
|
||||
# Dialectic depth: how many .chat() calls per dialectic cycle (1-3).
|
||||
# Depth 1: single call. Depth 2: self-audit + targeted synthesis.
|
||||
# Depth 3: self-audit + synthesis + reconciliation.
|
||||
dialectic_depth: int = 1
|
||||
# Optional per-pass reasoning level override. Array of reasoning levels
|
||||
# matching dialectic_depth length. When None, uses proportional defaults
|
||||
# derived from dialectic_reasoning_level.
|
||||
dialectic_depth_levels: list[str] | None = None
|
||||
# When true, the auto-injected dialectic scales reasoning level up on
|
||||
# longer queries. See HonchoMemoryProvider for thresholds.
|
||||
reasoning_heuristic: bool = True
|
||||
# Ceiling for the heuristic-selected reasoning level.
|
||||
reasoning_level_cap: str = "high"
|
||||
# Honcho API limits — configurable for self-hosted instances
|
||||
# Max chars per message sent via add_messages() (Honcho cloud: 25000)
|
||||
message_max_chars: int = 25000
|
||||
# Max chars for dialectic query input to peer.chat() (Honcho cloud: 10000)
|
||||
dialectic_max_input_chars: int = 10000
|
||||
# Recall mode: how memory retrieval works when Honcho is active.
|
||||
# "hybrid" — auto-injected context + Honcho tools available (model decides)
|
||||
# "context" — auto-injected context only, Honcho tools removed
|
||||
# "tools" — Honcho tools only, no auto-injected context
|
||||
recall_mode: str = "hybrid"
|
||||
# Eager init in tools mode — when true, initializes session during
|
||||
# initialize() instead of deferring to first tool call
|
||||
init_on_session_start: bool = False
|
||||
# Observation mode: legacy string shorthand ("directional" or "unified").
|
||||
# Kept for backward compat; granular per-peer booleans below are preferred.
|
||||
observation_mode: str = "directional"
|
||||
# Per-peer observation booleans — maps 1:1 to Honcho's SessionPeerConfig.
|
||||
# Resolved from "observation" object in config, falling back to observation_mode preset.
|
||||
user_observe_me: bool = True
|
||||
user_observe_others: bool = True
|
||||
ai_observe_me: bool = True
|
||||
ai_observe_others: bool = True
|
||||
# Session resolution
|
||||
session_strategy: str = "per-directory"
|
||||
session_peer_prefix: bool = False
|
||||
sessions: dict[str, str] = field(default_factory=dict)
|
||||
# Raw global config for anything else consumers need
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
# True when Honcho was explicitly configured for this host (hosts.hermes
|
||||
# block exists or enabled was set explicitly), vs auto-enabled from a
|
||||
# stray HONCHO_API_KEY env var.
|
||||
explicitly_configured: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_env(
|
||||
cls,
|
||||
workspace_id: str = "hermes",
|
||||
host: str | None = None,
|
||||
) -> HonchoClientConfig:
|
||||
"""Create config from environment variables (fallback)."""
|
||||
resolved_host = host or resolve_active_host()
|
||||
api_key = os.environ.get("HONCHO_API_KEY")
|
||||
base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None
|
||||
timeout = _resolve_optional_float(os.environ.get("HONCHO_TIMEOUT"))
|
||||
return cls(
|
||||
host=resolved_host,
|
||||
workspace_id=workspace_id,
|
||||
api_key=api_key,
|
||||
environment=os.environ.get("HONCHO_ENVIRONMENT", "production"),
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
ai_peer=resolved_host,
|
||||
enabled=bool(api_key or base_url),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_global_config(
|
||||
cls,
|
||||
host: str | None = None,
|
||||
config_path: Path | None = None,
|
||||
) -> HonchoClientConfig:
|
||||
"""Create config from the resolved Honcho config path.
|
||||
|
||||
Resolution: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars.
|
||||
When host is None, derives it from the active Hermes profile.
|
||||
"""
|
||||
resolved_host = host or resolve_active_host()
|
||||
path = config_path or resolve_config_path()
|
||||
if not path.exists():
|
||||
logger.debug("No global Honcho config at %s, falling back to env", path)
|
||||
return cls.from_env(host=resolved_host)
|
||||
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to read %s: %s, falling back to env", path, e)
|
||||
return cls.from_env(host=resolved_host)
|
||||
|
||||
host_block = _host_block(raw, resolved_host)
|
||||
# A hosts.hermes block or explicit enabled flag means the user
|
||||
# intentionally configured Honcho for this host.
|
||||
_explicitly_configured = bool(host_block) or raw.get("enabled") is True
|
||||
|
||||
# Explicit host block fields win, then flat/global, then defaults
|
||||
workspace = (
|
||||
host_block.get("workspace")
|
||||
or raw.get("workspace")
|
||||
or resolved_host
|
||||
)
|
||||
ai_peer = (
|
||||
host_block.get("aiPeer")
|
||||
or raw.get("aiPeer")
|
||||
or resolved_host
|
||||
)
|
||||
api_key = (
|
||||
host_block.get("apiKey")
|
||||
or raw.get("apiKey")
|
||||
or os.environ.get("HONCHO_API_KEY")
|
||||
)
|
||||
|
||||
environment = (
|
||||
host_block.get("environment")
|
||||
or raw.get("environment", "production")
|
||||
)
|
||||
|
||||
base_url = (
|
||||
raw.get("baseUrl")
|
||||
or raw.get("base_url")
|
||||
or os.environ.get("HONCHO_BASE_URL", "").strip()
|
||||
or None
|
||||
)
|
||||
timeout = _resolve_optional_float(
|
||||
raw.get("timeout"),
|
||||
raw.get("requestTimeout"),
|
||||
os.environ.get("HONCHO_TIMEOUT"),
|
||||
)
|
||||
|
||||
# Auto-enable when API key or base_url is present (unless explicitly disabled)
|
||||
# Host-level enabled wins, then root-level, then auto-enable if key/url exists.
|
||||
host_enabled = host_block.get("enabled")
|
||||
root_enabled = raw.get("enabled")
|
||||
if host_enabled is not None:
|
||||
enabled = host_enabled
|
||||
elif root_enabled is not None:
|
||||
enabled = root_enabled
|
||||
else:
|
||||
# Not explicitly set anywhere -> auto-enable if API key or base_url exists
|
||||
enabled = bool(api_key or base_url)
|
||||
|
||||
# write_frequency: accept int or string
|
||||
raw_wf = (
|
||||
host_block.get("writeFrequency")
|
||||
or raw.get("writeFrequency")
|
||||
or "async"
|
||||
)
|
||||
try:
|
||||
write_frequency: str | int = int(raw_wf)
|
||||
except (TypeError, ValueError):
|
||||
write_frequency = str(raw_wf)
|
||||
|
||||
# saveMessages: host wins (None-aware since False is valid)
|
||||
host_save = host_block.get("saveMessages")
|
||||
save_messages = host_save if host_save is not None else raw.get("saveMessages", True)
|
||||
|
||||
# sessionStrategy / sessionPeerPrefix: host first, root fallback
|
||||
session_strategy = (
|
||||
host_block.get("sessionStrategy")
|
||||
or raw.get("sessionStrategy", "per-directory")
|
||||
)
|
||||
host_prefix = host_block.get("sessionPeerPrefix")
|
||||
session_peer_prefix = (
|
||||
host_prefix if host_prefix is not None
|
||||
else raw.get("sessionPeerPrefix", False)
|
||||
)
|
||||
|
||||
return cls(
|
||||
host=resolved_host,
|
||||
workspace_id=workspace,
|
||||
api_key=api_key,
|
||||
environment=environment,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
peer_name=host_block.get("peerName") or raw.get("peerName"),
|
||||
ai_peer=ai_peer,
|
||||
pin_peer_name=_resolve_bool(
|
||||
# ``pinUserPeer`` is the clearer name (the resolver pins
|
||||
# the user-side peer to ``peerName``, ignoring runtime
|
||||
# identity). ``pinPeerName`` is the original key from
|
||||
# #14984 and stays accepted for backward compatibility.
|
||||
# Host-level keys win over root-level; among same-level
|
||||
# keys, ``pinUserPeer`` wins over ``pinPeerName``.
|
||||
host_block.get("pinUserPeer"),
|
||||
host_block.get("pinPeerName"),
|
||||
raw.get("pinUserPeer"),
|
||||
raw.get("pinPeerName"),
|
||||
default=False,
|
||||
),
|
||||
user_peer_aliases=_parse_string_map(
|
||||
host_block,
|
||||
raw,
|
||||
"userPeerAliases",
|
||||
),
|
||||
runtime_peer_prefix=_parse_optional_string(
|
||||
host_block,
|
||||
raw,
|
||||
"runtimePeerPrefix",
|
||||
),
|
||||
enabled=enabled,
|
||||
save_messages=save_messages,
|
||||
write_frequency=write_frequency,
|
||||
context_tokens=_parse_context_tokens(
|
||||
host_block.get("contextTokens"),
|
||||
raw.get("contextTokens"),
|
||||
),
|
||||
dialectic_reasoning_level=(
|
||||
host_block.get("dialecticReasoningLevel")
|
||||
or raw.get("dialecticReasoningLevel")
|
||||
or "low"
|
||||
),
|
||||
dialectic_dynamic=_resolve_bool(
|
||||
host_block.get("dialecticDynamic"),
|
||||
raw.get("dialecticDynamic"),
|
||||
default=True,
|
||||
),
|
||||
dialectic_max_chars=_parse_int_config(
|
||||
host_block.get("dialecticMaxChars"),
|
||||
raw.get("dialecticMaxChars"),
|
||||
default=600,
|
||||
),
|
||||
dialectic_depth=_parse_dialectic_depth(
|
||||
host_block.get("dialecticDepth"),
|
||||
raw.get("dialecticDepth"),
|
||||
),
|
||||
dialectic_depth_levels=_parse_dialectic_depth_levels(
|
||||
host_block.get("dialecticDepthLevels"),
|
||||
raw.get("dialecticDepthLevels"),
|
||||
depth=_parse_dialectic_depth(host_block.get("dialecticDepth"), raw.get("dialecticDepth")),
|
||||
),
|
||||
reasoning_heuristic=_resolve_bool(
|
||||
host_block.get("reasoningHeuristic"),
|
||||
raw.get("reasoningHeuristic"),
|
||||
default=True,
|
||||
),
|
||||
reasoning_level_cap=(
|
||||
host_block.get("reasoningLevelCap")
|
||||
or raw.get("reasoningLevelCap")
|
||||
or "high"
|
||||
),
|
||||
message_max_chars=_parse_int_config(
|
||||
host_block.get("messageMaxChars"),
|
||||
raw.get("messageMaxChars"),
|
||||
default=25000,
|
||||
),
|
||||
dialectic_max_input_chars=_parse_int_config(
|
||||
host_block.get("dialecticMaxInputChars"),
|
||||
raw.get("dialecticMaxInputChars"),
|
||||
default=10000,
|
||||
),
|
||||
recall_mode=_normalize_recall_mode(
|
||||
host_block.get("recallMode")
|
||||
or raw.get("recallMode")
|
||||
or "hybrid"
|
||||
),
|
||||
init_on_session_start=_resolve_bool(
|
||||
host_block.get("initOnSessionStart"),
|
||||
raw.get("initOnSessionStart"),
|
||||
default=False,
|
||||
),
|
||||
# Migration guard: existing configs without an explicit
|
||||
# observationMode keep the old "unified" default so users
|
||||
# aren't silently switched to full bidirectional observation.
|
||||
# New installations (no host block, no credentials) get
|
||||
# "directional" (all observations on) as the new default.
|
||||
observation_mode=_normalize_observation_mode(
|
||||
host_block.get("observationMode")
|
||||
or raw.get("observationMode")
|
||||
or ("unified" if _explicitly_configured else "directional")
|
||||
),
|
||||
**_resolve_observation(
|
||||
_normalize_observation_mode(
|
||||
host_block.get("observationMode")
|
||||
or raw.get("observationMode")
|
||||
or ("unified" if _explicitly_configured else "directional")
|
||||
),
|
||||
host_block.get("observation") or raw.get("observation"),
|
||||
),
|
||||
session_strategy=session_strategy,
|
||||
session_peer_prefix=session_peer_prefix,
|
||||
sessions=raw.get("sessions", {}),
|
||||
raw=raw,
|
||||
explicitly_configured=_explicitly_configured,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _git_repo_name(cwd: str) -> str | None:
|
||||
"""Return the git repo root directory name, or None if not in a repo."""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
root = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True, text=True, cwd=cwd, timeout=5,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if root.returncode == 0:
|
||||
return Path(root.stdout.strip()).name
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return None
|
||||
|
||||
# Honcho enforces a 100-char limit on session IDs. Long gateway session keys
|
||||
# (Matrix "!room:server" + thread event IDs, Telegram supergroup reply
|
||||
# chains, Slack thread IDs with long workspace prefixes) can overflow this
|
||||
# limit after sanitization; the Honcho API then rejects every call for that
|
||||
# session with "session_id too long". See issue #13868.
|
||||
_HONCHO_SESSION_ID_MAX_LEN = 100
|
||||
_HONCHO_SESSION_ID_HASH_LEN = 8
|
||||
|
||||
@classmethod
|
||||
def _enforce_session_id_limit(cls, sanitized: str, original: str) -> str:
|
||||
"""Truncate a sanitized session ID to Honcho's 100-char limit.
|
||||
|
||||
The common case (short keys) short-circuits with no modification.
|
||||
For over-limit keys, keep a prefix of the sanitized ID and append a
|
||||
deterministic ``-<sha256 prefix>`` suffix so two distinct long keys
|
||||
that share a leading segment don't collide onto the same truncated ID.
|
||||
The hash is taken over the *original* pre-sanitization key, so two
|
||||
inputs that sanitize to the same string still collide intentionally
|
||||
(same logical session), but two inputs that only share a prefix do not.
|
||||
"""
|
||||
max_len = cls._HONCHO_SESSION_ID_MAX_LEN
|
||||
if len(sanitized) <= max_len:
|
||||
return sanitized
|
||||
|
||||
hash_len = cls._HONCHO_SESSION_ID_HASH_LEN
|
||||
digest = hashlib.sha256(original.encode("utf-8")).hexdigest()[:hash_len]
|
||||
# max_len - hash_len - 1 (for the '-' separator) chars of the sanitized
|
||||
# prefix, then '-<hash>'. Strip any trailing hyphen from the prefix so
|
||||
# the result doesn't double up on separators.
|
||||
prefix_len = max_len - hash_len - 1
|
||||
prefix = sanitized[:prefix_len].rstrip("-")
|
||||
return f"{prefix}-{digest}"
|
||||
|
||||
def resolve_session_name(
|
||||
self,
|
||||
cwd: str | None = None,
|
||||
session_title: str | None = None,
|
||||
session_id: str | None = None,
|
||||
gateway_session_key: str | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve Honcho session name.
|
||||
|
||||
Resolution order:
|
||||
1. Manual directory override from sessions map
|
||||
2. Hermes session title (from /title command)
|
||||
3. Gateway session key (stable per-chat identifier from gateway platforms)
|
||||
4. per-session strategy — Hermes session_id ({timestamp}_{hex})
|
||||
5. per-repo strategy — git repo root directory name
|
||||
6. per-directory strategy — directory basename
|
||||
7. global strategy — workspace name
|
||||
"""
|
||||
import re
|
||||
|
||||
if not cwd:
|
||||
cwd = os.getcwd()
|
||||
|
||||
# Manual override always wins
|
||||
manual = self.sessions.get(cwd)
|
||||
if manual:
|
||||
return manual
|
||||
|
||||
# /title mid-session remap
|
||||
if session_title:
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', session_title).strip('-')
|
||||
if sanitized:
|
||||
if self.session_peer_prefix and self.peer_name:
|
||||
return f"{self.peer_name}-{sanitized}"
|
||||
return sanitized
|
||||
|
||||
# Gateway session key: stable per-chat identifier passed by the gateway
|
||||
# (e.g. "agent:main:telegram:dm:8439114563"). Sanitize colons to hyphens
|
||||
# for Honcho session ID compatibility. This takes priority over strategy-
|
||||
# based resolution because gateway platforms need per-chat isolation that
|
||||
# cwd-based strategies cannot provide.
|
||||
if gateway_session_key:
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', gateway_session_key).strip('-')
|
||||
if sanitized:
|
||||
return self._enforce_session_id_limit(sanitized, gateway_session_key)
|
||||
|
||||
# per-session: inherit Hermes session_id (new Honcho session each run)
|
||||
if self.session_strategy == "per-session" and session_id:
|
||||
if self.session_peer_prefix and self.peer_name:
|
||||
return f"{self.peer_name}-{session_id}"
|
||||
return session_id
|
||||
|
||||
# per-repo: one Honcho session per git repository
|
||||
if self.session_strategy == "per-repo":
|
||||
base = self._git_repo_name(cwd) or Path(cwd).name
|
||||
if self.session_peer_prefix and self.peer_name:
|
||||
return f"{self.peer_name}-{base}"
|
||||
return base
|
||||
|
||||
# per-directory: one Honcho session per working directory (default)
|
||||
if self.session_strategy in {"per-directory", "per-session"}:
|
||||
base = Path(cwd).name
|
||||
if self.session_peer_prefix and self.peer_name:
|
||||
return f"{self.peer_name}-{base}"
|
||||
return base
|
||||
|
||||
# global: single session across all directories
|
||||
return self.workspace_id
|
||||
|
||||
|
||||
_honcho_client_slot: SingletonSlot = SingletonSlot()
|
||||
|
||||
|
||||
def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
"""Get or create the Honcho client singleton.
|
||||
|
||||
When no config is provided, attempts to load ~/.honcho/config.json
|
||||
first, falling back to environment variables.
|
||||
|
||||
Thread-safe: the client is built exactly once even under concurrent
|
||||
first calls (double-checked locking via ``SingletonSlot``), so racing
|
||||
threads can't each construct a client and leak the loser's connection.
|
||||
"""
|
||||
cached = _honcho_client_slot.peek()
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if config is None:
|
||||
config = HonchoClientConfig.from_global_config()
|
||||
|
||||
if not config.api_key and not config.base_url:
|
||||
raise ValueError(
|
||||
"Honcho API key not found. "
|
||||
"Get your API key at https://app.honcho.dev, "
|
||||
"then run 'hermes honcho setup' or set HONCHO_API_KEY. "
|
||||
"For local instances, set HONCHO_BASE_URL instead."
|
||||
)
|
||||
|
||||
# Everything below is the expensive part the issue flags: lazy SDK
|
||||
# install, config resolution, and client construction. Run it inside the
|
||||
# slot's factory so it executes exactly once even when several threads
|
||||
# race the first call — the slot's double-checked lock serializes them and
|
||||
# the losers get the winner's client instead of building their own.
|
||||
def _build() -> "Honcho":
|
||||
# Lazy-install the honcho SDK on demand. ensure() honors
|
||||
# security.allow_lazy_installs (default true). On failure we surface
|
||||
# the original ImportError-shape message so existing callers still get
|
||||
# the "go run hermes honcho setup" hint they used to.
|
||||
try:
|
||||
from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure
|
||||
_lazy_ensure("memory.honcho", prompt=False)
|
||||
except ImportError:
|
||||
# lazy_deps module missing — fall through to the raw import below.
|
||||
pass
|
||||
except Exception:
|
||||
# FeatureUnavailable or unexpected error. Don't crash here; let the
|
||||
# actual import attempt produce the canonical error message.
|
||||
pass
|
||||
|
||||
try:
|
||||
from honcho import Honcho
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"honcho-ai is required for Honcho integration. "
|
||||
"Install it with: pip install honcho-ai "
|
||||
"(or run `hermes honcho setup` to configure)."
|
||||
)
|
||||
|
||||
# Allow config.yaml honcho.base_url to override the SDK's environment
|
||||
# mapping, enabling remote self-hosted Honcho deployments without
|
||||
# requiring the server to live on localhost.
|
||||
resolved_base_url = config.base_url
|
||||
resolved_timeout = config.timeout
|
||||
if not resolved_base_url or resolved_timeout is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
hermes_cfg = load_config()
|
||||
honcho_cfg = hermes_cfg.get("honcho", {})
|
||||
if isinstance(honcho_cfg, dict):
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = honcho_cfg.get("base_url", "").strip() or None
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _resolve_optional_float(
|
||||
honcho_cfg.get("timeout"),
|
||||
honcho_cfg.get("request_timeout"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to the default so an unconfigured install cannot hang
|
||||
# indefinitely on a stalled Honcho request.
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _DEFAULT_HTTP_TIMEOUT
|
||||
|
||||
if resolved_base_url:
|
||||
logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", resolved_base_url, config.workspace_id)
|
||||
else:
|
||||
logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id)
|
||||
|
||||
# Local Honcho instances don't require an API key, but the SDK
|
||||
# expects a non-empty string. Use a placeholder for local URLs.
|
||||
# For local: only use config.api_key if the host block explicitly
|
||||
# sets apiKey (meaning the user wants local auth). Otherwise skip
|
||||
# the stored key -- it's likely a cloud key that would break local.
|
||||
_is_local = resolved_base_url and (
|
||||
"localhost" in resolved_base_url
|
||||
or "127.0.0.1" in resolved_base_url
|
||||
or "::1" in resolved_base_url
|
||||
)
|
||||
if _is_local:
|
||||
# Check if the host block has its own apiKey (explicit local auth).
|
||||
# Auth-skipping is loopback-only: a stored key is likely a cloud key
|
||||
# that would break a no-auth local server, so we substitute the SDK's
|
||||
# required-non-empty placeholder unless the host block opts in.
|
||||
_raw = config.raw or {}
|
||||
_host_block = (_raw.get("hosts") or {}).get(config.host, {})
|
||||
_host_has_key = bool(_host_block.get("apiKey"))
|
||||
effective_api_key = config.api_key if _host_has_key else "local"
|
||||
else:
|
||||
effective_api_key = config.api_key
|
||||
|
||||
# The Honcho SDK's route builders (e.g. routes.workspaces()) already
|
||||
# include the version prefix (e.g. "/v3/workspaces"). When a user-supplied
|
||||
# base_url already ends in a version segment (e.g.
|
||||
# "http://localhost:38000/v3", "https://honcho.my.ts.net/v3"), concatenating
|
||||
# the two produces "/v3/v3/workspaces" → 404 on every call. This is a pure
|
||||
# routing concern independent of host, so strip a trailing version segment
|
||||
# from ANY base_url — loopback, LAN, custom domain, or cloud alike. The
|
||||
# SDK then appends its own versioned paths correctly.
|
||||
if resolved_base_url:
|
||||
import re as _re
|
||||
resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/")
|
||||
|
||||
kwargs: dict = {
|
||||
"workspace_id": config.workspace_id,
|
||||
"api_key": effective_api_key,
|
||||
"environment": config.environment,
|
||||
}
|
||||
if resolved_base_url:
|
||||
kwargs["base_url"] = resolved_base_url
|
||||
if resolved_timeout is not None:
|
||||
kwargs["timeout"] = resolved_timeout
|
||||
|
||||
return Honcho(**kwargs)
|
||||
|
||||
return _honcho_client_slot.get(_build)
|
||||
|
||||
|
||||
def reset_honcho_client() -> None:
|
||||
"""Reset the Honcho client singleton (useful for testing)."""
|
||||
_honcho_client_slot.reset()
|
||||
@@ -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
@@ -0,0 +1,38 @@
|
||||
# Mem0 Memory Provider
|
||||
|
||||
Server-side LLM fact extraction with semantic search, reranking, and automatic deduplication.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `pip install mem0ai`
|
||||
- Mem0 API key from [app.mem0.ai](https://app.mem0.ai)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
hermes memory setup # select "mem0"
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
hermes config set memory.provider mem0
|
||||
echo "MEM0_API_KEY=your-key" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
Config file: `$HERMES_HOME/mem0.json`
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `user_id` | `hermes-user` | User identifier on Mem0 |
|
||||
| `agent_id` | `hermes` | Agent identifier |
|
||||
| `rerank` | `true` | Enable reranking for recall |
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `mem0_profile` | All stored memories about the user |
|
||||
| `mem0_search` | Semantic search with optional reranking |
|
||||
| `mem0_conclude` | Store a fact verbatim (no LLM extraction) |
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Mem0 memory plugin — MemoryProvider interface.
|
||||
|
||||
Server-side LLM fact extraction, semantic search with reranking, and
|
||||
automatic deduplication via the Mem0 Platform API.
|
||||
|
||||
Original PR #2933 by kartik-mem0, adapted to MemoryProvider ABC.
|
||||
|
||||
Config via environment variables:
|
||||
MEM0_API_KEY — Mem0 Platform API key (required)
|
||||
MEM0_USER_ID — User identifier (default: hermes-user)
|
||||
MEM0_AGENT_ID — Agent identifier (default: hermes)
|
||||
|
||||
Or via $HERMES_HOME/mem0.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Circuit breaker: after this many consecutive failures, pause API calls
|
||||
# for _BREAKER_COOLDOWN_SECS to avoid hammering a down server.
|
||||
_BREAKER_THRESHOLD = 5
|
||||
_BREAKER_COOLDOWN_SECS = 120
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load config from env vars, with $HERMES_HOME/mem0.json overrides.
|
||||
|
||||
Environment variables provide defaults; mem0.json (if present) overrides
|
||||
individual keys. This avoids a silent failure when the JSON file exists
|
||||
but is missing fields like ``api_key`` that the user set in ``.env``.
|
||||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
config = {
|
||||
"api_key": os.environ.get("MEM0_API_KEY", ""),
|
||||
"user_id": os.environ.get("MEM0_USER_ID", "hermes-user"),
|
||||
"agent_id": os.environ.get("MEM0_AGENT_ID", "hermes"),
|
||||
"rerank": True,
|
||||
"keyword_search": False,
|
||||
}
|
||||
|
||||
config_path = get_hermes_home() / "mem0.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
file_cfg = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
config.update({k: v for k, v in file_cfg.items()
|
||||
if v is not None and v != ""})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROFILE_SCHEMA = {
|
||||
"name": "mem0_profile",
|
||||
"description": (
|
||||
"Retrieve all stored memories about the user — preferences, facts, "
|
||||
"project context. Fast, no reranking. Use at conversation start."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
}
|
||||
|
||||
SEARCH_SCHEMA = {
|
||||
"name": "mem0_search",
|
||||
"description": (
|
||||
"Search memories by meaning. Returns relevant facts ranked by similarity. "
|
||||
"Set rerank=true for higher accuracy on important queries."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "What to search for."},
|
||||
"rerank": {"type": "boolean", "description": "Enable reranking for precision (default: false)."},
|
||||
"top_k": {"type": "integer", "description": "Max results (default: 10, max: 50)."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
CONCLUDE_SCHEMA = {
|
||||
"name": "mem0_conclude",
|
||||
"description": (
|
||||
"Store a durable fact about the user. Stored verbatim (no LLM extraction). "
|
||||
"Use for explicit preferences, corrections, or decisions."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"conclusion": {"type": "string", "description": "The fact to store."},
|
||||
},
|
||||
"required": ["conclusion"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryProvider implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Mem0MemoryProvider(MemoryProvider):
|
||||
"""Mem0 Platform memory with server-side extraction and semantic search."""
|
||||
|
||||
def __init__(self):
|
||||
self._config = None
|
||||
self._client = None
|
||||
self._client_lock = threading.Lock()
|
||||
self._api_key = ""
|
||||
self._user_id = "hermes-user"
|
||||
self._agent_id = "hermes"
|
||||
self._rerank = True
|
||||
self._prefetch_result = ""
|
||||
self._prefetch_lock = threading.Lock()
|
||||
self._prefetch_thread = None
|
||||
self._sync_thread = None
|
||||
# Circuit breaker state
|
||||
self._consecutive_failures = 0
|
||||
self._breaker_open_until = 0.0
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "mem0"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
cfg = _load_config()
|
||||
return bool(cfg.get("api_key"))
|
||||
|
||||
def save_config(self, values, hermes_home):
|
||||
"""Write config to $HERMES_HOME/mem0.json."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
config_path = Path(hermes_home) / "mem0.json"
|
||||
existing = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
existing = json.loads(config_path.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
existing.update(values)
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(config_path, existing, mode=0o600)
|
||||
|
||||
def get_config_schema(self):
|
||||
return [
|
||||
{"key": "api_key", "description": "Mem0 Platform API key", "secret": True, "required": True, "env_var": "MEM0_API_KEY", "url": "https://app.mem0.ai"},
|
||||
{"key": "user_id", "description": "User identifier", "default": "hermes-user"},
|
||||
{"key": "agent_id", "description": "Agent identifier", "default": "hermes"},
|
||||
{"key": "rerank", "description": "Enable reranking for recall", "default": "true", "choices": ["true", "false"]},
|
||||
]
|
||||
|
||||
def _get_client(self):
|
||||
"""Thread-safe client accessor with lazy initialization."""
|
||||
with self._client_lock:
|
||||
if self._client is not None:
|
||||
return self._client
|
||||
try:
|
||||
from mem0 import MemoryClient
|
||||
self._client = MemoryClient(api_key=self._api_key)
|
||||
return self._client
|
||||
except ImportError:
|
||||
raise RuntimeError("mem0 package not installed. Run: pip install mem0ai")
|
||||
|
||||
def _is_breaker_open(self) -> bool:
|
||||
"""Return True if the circuit breaker is tripped (too many failures)."""
|
||||
if self._consecutive_failures < _BREAKER_THRESHOLD:
|
||||
return False
|
||||
if time.monotonic() >= self._breaker_open_until:
|
||||
# Cooldown expired — reset and allow a retry
|
||||
self._consecutive_failures = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
def _record_success(self):
|
||||
self._consecutive_failures = 0
|
||||
|
||||
def _record_failure(self):
|
||||
self._consecutive_failures += 1
|
||||
if self._consecutive_failures >= _BREAKER_THRESHOLD:
|
||||
self._breaker_open_until = time.monotonic() + _BREAKER_COOLDOWN_SECS
|
||||
logger.warning(
|
||||
"Mem0 circuit breaker tripped after %d consecutive failures. "
|
||||
"Pausing API calls for %ds.",
|
||||
self._consecutive_failures, _BREAKER_COOLDOWN_SECS,
|
||||
)
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
self._config = _load_config()
|
||||
self._api_key = self._config.get("api_key", "")
|
||||
# Prefer gateway-provided user_id for per-user memory scoping;
|
||||
# fall back to config/env default for CLI (single-user) sessions.
|
||||
self._user_id = kwargs.get("user_id") or self._config.get("user_id", "hermes-user")
|
||||
self._agent_id = self._config.get("agent_id", "hermes")
|
||||
self._rerank = self._config.get("rerank", True)
|
||||
|
||||
def _read_filters(self) -> Dict[str, Any]:
|
||||
"""Filters for search/get_all — scoped to user only for cross-session recall."""
|
||||
return {"user_id": self._user_id}
|
||||
|
||||
def _write_filters(self) -> Dict[str, Any]:
|
||||
"""Filters for add — scoped to user + agent for attribution."""
|
||||
return {"user_id": self._user_id, "agent_id": self._agent_id}
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_results(response: Any) -> list:
|
||||
"""Normalize Mem0 API response — v2 wraps results in {"results": [...]}."""
|
||||
if isinstance(response, dict):
|
||||
return response.get("results", [])
|
||||
if isinstance(response, list):
|
||||
return response
|
||||
return []
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
return (
|
||||
"# Mem0 Memory\n"
|
||||
f"Active. User: {self._user_id}.\n"
|
||||
"Use mem0_search to find memories, mem0_conclude to store facts, "
|
||||
"mem0_profile for a full overview."
|
||||
)
|
||||
|
||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||
if self._prefetch_thread and self._prefetch_thread.is_alive():
|
||||
self._prefetch_thread.join(timeout=3.0)
|
||||
with self._prefetch_lock:
|
||||
result = self._prefetch_result
|
||||
self._prefetch_result = ""
|
||||
if not result:
|
||||
return ""
|
||||
return f"## Mem0 Memory\n{result}"
|
||||
|
||||
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
||||
if self._is_breaker_open():
|
||||
return
|
||||
|
||||
def _run():
|
||||
try:
|
||||
client = self._get_client()
|
||||
results = self._unwrap_results(client.search(
|
||||
query=query,
|
||||
filters=self._read_filters(),
|
||||
rerank=self._rerank,
|
||||
top_k=5,
|
||||
))
|
||||
if results:
|
||||
lines = [r.get("memory", "") for r in results if r.get("memory")]
|
||||
with self._prefetch_lock:
|
||||
self._prefetch_result = "\n".join(f"- {l}" for l in lines)
|
||||
self._record_success()
|
||||
except Exception as e:
|
||||
self._record_failure()
|
||||
logger.debug("Mem0 prefetch failed: %s", e)
|
||||
|
||||
self._prefetch_thread = threading.Thread(target=_run, daemon=True, name="mem0-prefetch")
|
||||
self._prefetch_thread.start()
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
"""Send the turn to Mem0 for server-side fact extraction (non-blocking)."""
|
||||
if self._is_breaker_open():
|
||||
return
|
||||
|
||||
def _sync():
|
||||
try:
|
||||
client = self._get_client()
|
||||
messages = [
|
||||
{"role": "user", "content": user_content},
|
||||
{"role": "assistant", "content": assistant_content},
|
||||
]
|
||||
client.add(messages, **self._write_filters())
|
||||
self._record_success()
|
||||
except Exception as e:
|
||||
self._record_failure()
|
||||
logger.warning("Mem0 sync failed: %s", e)
|
||||
|
||||
# Wait for any previous sync before starting a new one
|
||||
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="mem0-sync")
|
||||
self._sync_thread.start()
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [PROFILE_SCHEMA, SEARCH_SCHEMA, CONCLUDE_SCHEMA]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
if self._is_breaker_open():
|
||||
return json.dumps({
|
||||
"error": "Mem0 API temporarily unavailable (multiple consecutive failures). Will retry automatically."
|
||||
})
|
||||
|
||||
try:
|
||||
client = self._get_client()
|
||||
except Exception as e:
|
||||
return tool_error(str(e))
|
||||
|
||||
if tool_name == "mem0_profile":
|
||||
try:
|
||||
memories = self._unwrap_results(client.get_all(filters=self._read_filters()))
|
||||
self._record_success()
|
||||
if not memories:
|
||||
return json.dumps({"result": "No memories stored yet."})
|
||||
lines = [m.get("memory", "") for m in memories if m.get("memory")]
|
||||
return json.dumps({"result": "\n".join(lines), "count": len(lines)})
|
||||
except Exception as e:
|
||||
self._record_failure()
|
||||
return tool_error(f"Failed to fetch profile: {e}")
|
||||
|
||||
elif tool_name == "mem0_search":
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return tool_error("Missing required parameter: query")
|
||||
rerank = args.get("rerank", False)
|
||||
top_k = min(int(args.get("top_k", 10)), 50)
|
||||
try:
|
||||
results = self._unwrap_results(client.search(
|
||||
query=query,
|
||||
filters=self._read_filters(),
|
||||
rerank=rerank,
|
||||
top_k=top_k,
|
||||
))
|
||||
self._record_success()
|
||||
if not results:
|
||||
return json.dumps({"result": "No relevant memories found."})
|
||||
items = [{"memory": r.get("memory", ""), "score": r.get("score", 0)} for r in results]
|
||||
return json.dumps({"results": items, "count": len(items)})
|
||||
except Exception as e:
|
||||
self._record_failure()
|
||||
return tool_error(f"Search failed: {e}")
|
||||
|
||||
elif tool_name == "mem0_conclude":
|
||||
conclusion = args.get("conclusion", "")
|
||||
if not conclusion:
|
||||
return tool_error("Missing required parameter: conclusion")
|
||||
try:
|
||||
client.add(
|
||||
[{"role": "user", "content": conclusion}],
|
||||
**self._write_filters(),
|
||||
infer=False,
|
||||
)
|
||||
self._record_success()
|
||||
return json.dumps({"result": "Fact stored."})
|
||||
except Exception as e:
|
||||
self._record_failure()
|
||||
return tool_error(f"Failed to store: {e}")
|
||||
|
||||
return tool_error(f"Unknown tool: {tool_name}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
for t in (self._prefetch_thread, self._sync_thread):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
with self._client_lock:
|
||||
self._client = None
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register Mem0 as a memory provider plugin."""
|
||||
ctx.register_memory_provider(Mem0MemoryProvider())
|
||||
@@ -0,0 +1,5 @@
|
||||
name: mem0
|
||||
version: 1.0.0
|
||||
description: "Mem0 — server-side LLM fact extraction with semantic search, reranking, and automatic deduplication."
|
||||
pip_dependencies:
|
||||
- mem0ai
|
||||
@@ -0,0 +1,40 @@
|
||||
# OpenViking Memory Provider
|
||||
|
||||
Context database by Volcengine (ByteDance) with filesystem-style knowledge hierarchy, tiered retrieval, and automatic memory extraction.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `pip install openviking`
|
||||
- OpenViking server running (`openviking-server`)
|
||||
- Embedding + VLM model configured in `~/.openviking/ov.conf`
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
hermes memory setup # select "openviking"
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
hermes config set memory.provider openviking
|
||||
echo "OPENVIKING_ENDPOINT=http://localhost:1933" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
All config via environment variables in `.env`:
|
||||
|
||||
| Env Var | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `OPENVIKING_ENDPOINT` | `http://127.0.0.1:1933` | Server URL |
|
||||
| `OPENVIKING_API_KEY` | (none) | API key (optional) |
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `viking_search` | Semantic search with fast/deep/auto modes |
|
||||
| `viking_read` | Read content at a viking:// URI (abstract/overview/full) |
|
||||
| `viking_browse` | Filesystem-style navigation (list/tree/stat) |
|
||||
| `viking_remember` | Store a fact for extraction on session commit |
|
||||
| `viking_add_resource` | Ingest URLs/docs into the knowledge base |
|
||||
@@ -0,0 +1,978 @@
|
||||
"""OpenViking memory plugin — full bidirectional MemoryProvider interface.
|
||||
|
||||
Context database by Volcengine (ByteDance) that organizes agent knowledge
|
||||
into a filesystem hierarchy (viking:// URIs) with tiered context loading,
|
||||
automatic memory extraction, and session management.
|
||||
|
||||
Original PR #3369 by Mibayy, rewritten to use the full OpenViking session
|
||||
lifecycle instead of read-only search endpoints.
|
||||
|
||||
Config via environment variables (profile-scoped via each profile's .env):
|
||||
OPENVIKING_ENDPOINT — Server URL (default: http://127.0.0.1:1933)
|
||||
OPENVIKING_API_KEY — API key (required for authenticated servers)
|
||||
OPENVIKING_ACCOUNT — Tenant account (default: default)
|
||||
OPENVIKING_USER — Tenant user (default: default)
|
||||
OPENVIKING_AGENT — Tenant agent (default: hermes)
|
||||
|
||||
Capabilities:
|
||||
- Automatic memory extraction on session commit (6 categories)
|
||||
- Tiered context: L0 (~100 tokens), L1 (~2k), L2 (full)
|
||||
- Semantic search with hierarchical directory retrieval
|
||||
- Filesystem-style browsing via viking:// URIs
|
||||
- Resource ingestion (URLs, docs, code)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import url2pathname
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_ENDPOINT = "http://127.0.0.1:1933"
|
||||
_TIMEOUT = 30.0
|
||||
_REMOTE_RESOURCE_PREFIXES = ("http://", "https://", "git@", "ssh://", "git://")
|
||||
|
||||
# Maps the viking_remember `category` enum to a viking:// subdirectory.
|
||||
# Keep in sync with REMEMBER_SCHEMA.parameters.properties.category.enum.
|
||||
_CATEGORY_SUBDIR_MAP = {
|
||||
"preference": "preferences",
|
||||
"entity": "entities",
|
||||
"event": "events",
|
||||
"case": "cases",
|
||||
"pattern": "patterns",
|
||||
}
|
||||
_DEFAULT_MEMORY_SUBDIR = "preferences"
|
||||
|
||||
# Maps the built-in memory tool's `target` ("user" vs "memory") to a subdir
|
||||
# for on_memory_write mirroring. User profile facts → preferences; agent
|
||||
# notes / observations → patterns. Anything unknown falls back to the default.
|
||||
_MEMORY_WRITE_TARGET_SUBDIR_MAP = {
|
||||
"user": "preferences",
|
||||
"memory": "patterns",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process-level atexit safety net — ensures pending sessions are committed
|
||||
# even if shutdown_memory_provider is never called (e.g. gateway crash,
|
||||
# SIGKILL, or exception in the session expiry watcher preventing shutdown).
|
||||
# ---------------------------------------------------------------------------
|
||||
_last_active_provider: Optional["OpenVikingMemoryProvider"] = None
|
||||
|
||||
|
||||
def _atexit_commit_sessions():
|
||||
"""Fire on_session_end for the last active provider on process exit."""
|
||||
global _last_active_provider
|
||||
provider = _last_active_provider
|
||||
if provider is None:
|
||||
return
|
||||
_last_active_provider = None
|
||||
try:
|
||||
provider.on_session_end([])
|
||||
except Exception:
|
||||
pass # best-effort at shutdown time
|
||||
|
||||
|
||||
atexit.register(_atexit_commit_sessions)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helper — uses httpx to avoid requiring the openviking SDK
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_httpx():
|
||||
"""Lazy import httpx."""
|
||||
try:
|
||||
import httpx
|
||||
return httpx
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
class _VikingClient:
|
||||
"""Thin HTTP client for the OpenViking REST API."""
|
||||
|
||||
def __init__(self, endpoint: str, api_key: str = "",
|
||||
account: str = "", user: str = "", agent: str = ""):
|
||||
self._endpoint = endpoint.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._account = account or os.environ.get("OPENVIKING_ACCOUNT", "default")
|
||||
self._user = user or os.environ.get("OPENVIKING_USER", "default")
|
||||
self._agent = agent or os.environ.get("OPENVIKING_AGENT", "hermes")
|
||||
self._httpx = _get_httpx()
|
||||
if self._httpx is None:
|
||||
raise ImportError("httpx is required for OpenViking: pip install httpx")
|
||||
|
||||
def _headers(self) -> dict:
|
||||
# Always send tenant headers when account/user are configured.
|
||||
# OpenViking 0.3.x requires X-OpenViking-Account and X-OpenViking-User
|
||||
# for ROOT API key requests to tenant-scoped APIs — omitting them
|
||||
# causes INVALID_ARGUMENT errors even when account="default".
|
||||
# User-level keys can omit them (server derives tenancy from the key),
|
||||
# but ROOT keys must always include them explicitly.
|
||||
h = {
|
||||
"Content-Type": "application/json",
|
||||
"X-OpenViking-Agent": self._agent,
|
||||
}
|
||||
if self._account:
|
||||
h["X-OpenViking-Account"] = self._account
|
||||
if self._user:
|
||||
h["X-OpenViking-User"] = self._user
|
||||
if self._api_key:
|
||||
h["X-API-Key"] = self._api_key
|
||||
h["Authorization"] = "Bearer " + self._api_key
|
||||
return h
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self._endpoint}{path}"
|
||||
|
||||
def _multipart_headers(self) -> dict:
|
||||
headers = self._headers()
|
||||
headers.pop("Content-Type", None)
|
||||
return headers
|
||||
|
||||
def _parse_response(self, resp) -> dict:
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = None
|
||||
|
||||
if resp.status_code >= 400:
|
||||
if isinstance(data, dict):
|
||||
error = data.get("error")
|
||||
if isinstance(error, dict):
|
||||
code = error.get("code", "HTTP_ERROR")
|
||||
message = error.get("message", resp.text)
|
||||
raise RuntimeError(f"{code}: {message}")
|
||||
if data.get("status") == "error":
|
||||
raise RuntimeError(str(data))
|
||||
resp.raise_for_status()
|
||||
|
||||
if isinstance(data, dict) and data.get("status") == "error":
|
||||
error = data.get("error")
|
||||
if isinstance(error, dict):
|
||||
code = error.get("code", "OPENVIKING_ERROR")
|
||||
message = error.get("message", "")
|
||||
raise RuntimeError(f"{code}: {message}")
|
||||
raise RuntimeError(str(data))
|
||||
|
||||
if data is None:
|
||||
return {}
|
||||
return data
|
||||
|
||||
def get(self, path: str, **kwargs) -> dict:
|
||||
resp = self._httpx.get(
|
||||
self._url(path), headers=self._headers(), timeout=_TIMEOUT, **kwargs
|
||||
)
|
||||
return self._parse_response(resp)
|
||||
|
||||
def post(self, path: str, payload: dict = None, **kwargs) -> dict:
|
||||
resp = self._httpx.post(
|
||||
self._url(path), json=payload or {}, headers=self._headers(),
|
||||
timeout=_TIMEOUT, **kwargs
|
||||
)
|
||||
return self._parse_response(resp)
|
||||
|
||||
def upload_temp_file(self, file_path: Path) -> str:
|
||||
mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
||||
with file_path.open("rb") as f:
|
||||
resp = self._httpx.post(
|
||||
self._url("/api/v1/resources/temp_upload"),
|
||||
files={"file": (file_path.name, f, mime_type)},
|
||||
headers=self._multipart_headers(),
|
||||
timeout=_TIMEOUT,
|
||||
)
|
||||
data = self._parse_response(resp)
|
||||
result = data.get("result", {})
|
||||
temp_file_id = result.get("temp_file_id", "")
|
||||
if not temp_file_id:
|
||||
raise RuntimeError("OpenViking temp upload did not return temp_file_id")
|
||||
return temp_file_id
|
||||
|
||||
def health(self) -> bool:
|
||||
try:
|
||||
resp = self._httpx.get(
|
||||
self._url("/health"), headers=self._headers(), timeout=3.0
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEARCH_SCHEMA = {
|
||||
"name": "viking_search",
|
||||
"description": (
|
||||
"Semantic search over the OpenViking knowledge base. "
|
||||
"Returns ranked results with viking:// URIs for deeper reading. "
|
||||
"Use mode='deep' for complex queries that need reasoning across "
|
||||
"multiple sources, 'fast' for simple lookups."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query."},
|
||||
"mode": {
|
||||
"type": "string", "enum": ["auto", "fast", "deep"],
|
||||
"description": "Search depth (default: auto).",
|
||||
},
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "Viking URI prefix to scope search (e.g. 'viking://resources/docs/').",
|
||||
},
|
||||
"limit": {"type": "integer", "description": "Max results (default: 10)."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
READ_SCHEMA = {
|
||||
"name": "viking_read",
|
||||
"description": (
|
||||
"Read content at a viking:// URI. Three detail levels:\n"
|
||||
" abstract — ~100 token summary (L0)\n"
|
||||
" overview — ~2k token key points (L1)\n"
|
||||
" full — complete content (L2)\n"
|
||||
"Start with abstract/overview, only use full when you need details."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uri": {"type": "string", "description": "viking:// URI to read."},
|
||||
"level": {
|
||||
"type": "string", "enum": ["abstract", "overview", "full"],
|
||||
"description": "Detail level (default: overview).",
|
||||
},
|
||||
},
|
||||
"required": ["uri"],
|
||||
},
|
||||
}
|
||||
|
||||
BROWSE_SCHEMA = {
|
||||
"name": "viking_browse",
|
||||
"description": (
|
||||
"Browse the OpenViking knowledge store like a filesystem.\n"
|
||||
" list — show directory contents\n"
|
||||
" tree — show hierarchy\n"
|
||||
" stat — show metadata for a URI"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string", "enum": ["tree", "list", "stat"],
|
||||
"description": "Browse action.",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Viking URI path (default: viking://). Examples: 'viking://resources/', 'viking://user/memories/'.",
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
}
|
||||
|
||||
REMEMBER_SCHEMA = {
|
||||
"name": "viking_remember",
|
||||
"description": (
|
||||
"Explicitly store a fact or memory in the OpenViking knowledge base. "
|
||||
"Use for important information the agent should remember long-term. "
|
||||
"The system automatically categorizes and indexes the memory."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "The information to remember."},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["preference", "entity", "event", "case", "pattern"],
|
||||
"description": "Memory category (default: auto-detected).",
|
||||
},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
}
|
||||
|
||||
ADD_RESOURCE_SCHEMA = {
|
||||
"name": "viking_add_resource",
|
||||
"description": (
|
||||
"Add a remote URL or local file/directory to the OpenViking knowledge base. "
|
||||
"Remote resources must be public http(s), git, or ssh URLs. "
|
||||
"Local files are uploaded first using OpenViking temp_upload. "
|
||||
"The system automatically parses, indexes, and generates summaries."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "Remote URL or local file/directory path to add."},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Why this resource is relevant (improves search).",
|
||||
},
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Optional target viking:// URI for the resource.",
|
||||
},
|
||||
"parent": {
|
||||
"type": "string",
|
||||
"description": "Optional parent viking:// URI. Cannot be used with to.",
|
||||
},
|
||||
"instruction": {
|
||||
"type": "string",
|
||||
"description": "Optional processing instruction for semantic extraction.",
|
||||
},
|
||||
"wait": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to wait for processing to complete.",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"description": "Timeout in seconds when wait is true.",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _zip_directory(dir_path: Path) -> Path:
|
||||
"""Create a temporary zip file containing a directory tree."""
|
||||
root = dir_path.resolve()
|
||||
zip_path = Path(tempfile.gettempdir()) / f"openviking_upload_{uuid.uuid4().hex}.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for file_path in dir_path.rglob("*"):
|
||||
if file_path.is_symlink():
|
||||
continue
|
||||
if file_path.is_file():
|
||||
try:
|
||||
file_path.resolve().relative_to(root)
|
||||
except ValueError:
|
||||
continue
|
||||
arcname = str(file_path.relative_to(dir_path)).replace("\\", "/")
|
||||
zipf.write(file_path, arcname=arcname)
|
||||
return zip_path
|
||||
|
||||
|
||||
def _is_windows_absolute_path(value: str) -> bool:
|
||||
return (
|
||||
len(value) >= 3
|
||||
and value[0].isalpha()
|
||||
and value[1] == ":"
|
||||
and value[2] in {"/", "\\"}
|
||||
)
|
||||
|
||||
|
||||
def _is_remote_resource_source(value: str) -> bool:
|
||||
return value.startswith(_REMOTE_RESOURCE_PREFIXES)
|
||||
|
||||
|
||||
def _is_local_path_reference(value: str) -> bool:
|
||||
if not value or "\n" in value or "\r" in value:
|
||||
return False
|
||||
if _is_remote_resource_source(value):
|
||||
return False
|
||||
if _is_windows_absolute_path(value):
|
||||
return True
|
||||
return (
|
||||
value.startswith(("/", "./", "../", "~/", ".\\", "..\\", "~\\"))
|
||||
or "/" in value
|
||||
or "\\" in value
|
||||
)
|
||||
|
||||
|
||||
def _path_from_file_uri(uri: str) -> Path | str:
|
||||
parsed = urlparse(uri)
|
||||
if parsed.netloc not in {"", "localhost"}:
|
||||
return f"Unsupported non-local file URI: {uri}"
|
||||
return Path(url2pathname(parsed.path)).expanduser()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryProvider implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OpenVikingMemoryProvider(MemoryProvider):
|
||||
"""Full bidirectional memory via OpenViking context database."""
|
||||
|
||||
def __init__(self):
|
||||
self._client: Optional[_VikingClient] = None
|
||||
self._endpoint = ""
|
||||
self._api_key = ""
|
||||
self._session_id = ""
|
||||
self._turn_count = 0
|
||||
self._sync_thread: Optional[threading.Thread] = None
|
||||
self._prefetch_result = ""
|
||||
self._prefetch_lock = threading.Lock()
|
||||
self._prefetch_thread: Optional[threading.Thread] = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "openviking"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if OpenViking endpoint is configured. No network calls."""
|
||||
return bool(os.environ.get("OPENVIKING_ENDPOINT"))
|
||||
|
||||
def get_config_schema(self):
|
||||
return [
|
||||
{
|
||||
"key": "endpoint",
|
||||
"description": "OpenViking server URL",
|
||||
"required": True,
|
||||
"default": _DEFAULT_ENDPOINT,
|
||||
"env_var": "OPENVIKING_ENDPOINT",
|
||||
},
|
||||
{
|
||||
"key": "api_key",
|
||||
"description": "OpenViking API key (leave blank for local dev mode)",
|
||||
"secret": True,
|
||||
"env_var": "OPENVIKING_API_KEY",
|
||||
},
|
||||
{
|
||||
"key": "account",
|
||||
"description": "OpenViking tenant account ID ([default], used when local mode, OPENVIKING_API_KEY is empty)",
|
||||
"default": "default",
|
||||
"env_var": "OPENVIKING_ACCOUNT",
|
||||
},
|
||||
{
|
||||
"key": "user",
|
||||
"description": "OpenViking user ID within the account ([default], used when local mode, OPENVIKING_API_KEY is empty)",
|
||||
"default": "default",
|
||||
"env_var": "OPENVIKING_USER",
|
||||
},
|
||||
{
|
||||
"key": "agent",
|
||||
"description": "OpenViking agent ID within the account ([hermes], useful in multi-agent mode)",
|
||||
"default": "hermes",
|
||||
"env_var": "OPENVIKING_AGENT",
|
||||
},
|
||||
]
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
self._endpoint = os.environ.get("OPENVIKING_ENDPOINT", _DEFAULT_ENDPOINT)
|
||||
self._api_key = os.environ.get("OPENVIKING_API_KEY", "")
|
||||
self._account = os.environ.get("OPENVIKING_ACCOUNT", "default")
|
||||
self._user = os.environ.get("OPENVIKING_USER", "default")
|
||||
self._agent = os.environ.get("OPENVIKING_AGENT", "hermes")
|
||||
self._session_id = session_id
|
||||
self._turn_count = 0
|
||||
|
||||
try:
|
||||
self._client = _VikingClient(
|
||||
self._endpoint, self._api_key,
|
||||
account=self._account, user=self._user, agent=self._agent,
|
||||
)
|
||||
if not self._client.health():
|
||||
logger.warning("OpenViking server at %s is not reachable", self._endpoint)
|
||||
self._client = None
|
||||
except ImportError:
|
||||
logger.warning("httpx not installed — OpenViking plugin disabled")
|
||||
self._client = None
|
||||
|
||||
# Register as the last active provider for atexit safety net
|
||||
global _last_active_provider
|
||||
_last_active_provider = self
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
if not self._client:
|
||||
return ""
|
||||
# Provide brief info about the knowledge base
|
||||
try:
|
||||
# Check what's in the knowledge base via a root listing
|
||||
resp = self._client.get("/api/v1/fs/ls", params={"uri": "viking://"})
|
||||
result = resp.get("result", [])
|
||||
children = len(result) if isinstance(result, list) else 0
|
||||
if children == 0:
|
||||
return ""
|
||||
return (
|
||||
"# OpenViking Knowledge Base\n"
|
||||
f"Active. Endpoint: {self._endpoint}\n"
|
||||
"Use viking_search to find information, viking_read for details "
|
||||
"(abstract/overview/full), viking_browse to explore.\n"
|
||||
"Use viking_remember to store facts, viking_add_resource to index URLs/docs."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("OpenViking system_prompt_block failed: %s", e)
|
||||
return (
|
||||
"# OpenViking Knowledge Base\n"
|
||||
f"Active. Endpoint: {self._endpoint}\n"
|
||||
"Use viking_search, viking_read, viking_browse, "
|
||||
"viking_remember, viking_add_resource."
|
||||
)
|
||||
|
||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||
"""Return prefetched results from the background thread."""
|
||||
if self._prefetch_thread and self._prefetch_thread.is_alive():
|
||||
self._prefetch_thread.join(timeout=3.0)
|
||||
with self._prefetch_lock:
|
||||
result = self._prefetch_result
|
||||
self._prefetch_result = ""
|
||||
if not result:
|
||||
return ""
|
||||
return f"## OpenViking Context\n{result}"
|
||||
|
||||
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
||||
"""Fire a background search to pre-load relevant context."""
|
||||
if not self._client or not query:
|
||||
return
|
||||
|
||||
def _run():
|
||||
try:
|
||||
client = _VikingClient(
|
||||
self._endpoint, self._api_key,
|
||||
account=self._account, user=self._user, agent=self._agent,
|
||||
)
|
||||
resp = client.post("/api/v1/search/find", {
|
||||
"query": query,
|
||||
"top_k": 5,
|
||||
})
|
||||
result = resp.get("result", {})
|
||||
parts = []
|
||||
for ctx_type in ("memories", "resources"):
|
||||
items = result.get(ctx_type, [])
|
||||
for item in items[:3]:
|
||||
uri = item.get("uri", "")
|
||||
abstract = item.get("abstract", "")
|
||||
score = item.get("score", 0)
|
||||
if abstract:
|
||||
parts.append(f"- [{score:.2f}] {abstract} ({uri})")
|
||||
if parts:
|
||||
with self._prefetch_lock:
|
||||
self._prefetch_result = "\n".join(parts)
|
||||
except Exception as e:
|
||||
logger.debug("OpenViking prefetch failed: %s", e)
|
||||
|
||||
self._prefetch_thread = threading.Thread(
|
||||
target=_run, daemon=True, name="openviking-prefetch"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
"""Record the conversation turn in OpenViking's session (non-blocking)."""
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
self._turn_count += 1
|
||||
|
||||
def _sync():
|
||||
try:
|
||||
client = _VikingClient(
|
||||
self._endpoint, self._api_key,
|
||||
account=self._account, user=self._user, agent=self._agent,
|
||||
)
|
||||
sid = self._session_id
|
||||
|
||||
# Add user message
|
||||
client.post(f"/api/v1/sessions/{sid}/messages", {
|
||||
"role": "user",
|
||||
"content": user_content[:4000], # trim very long messages
|
||||
})
|
||||
# Add assistant message
|
||||
client.post(f"/api/v1/sessions/{sid}/messages", {
|
||||
"role": "assistant",
|
||||
"content": assistant_content[:4000],
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("OpenViking sync_turn failed: %s", e)
|
||||
|
||||
# Wait for any previous sync to finish before starting a new one
|
||||
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="openviking-sync"
|
||||
)
|
||||
self._sync_thread.start()
|
||||
|
||||
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
|
||||
"""Commit the session to trigger memory extraction.
|
||||
|
||||
OpenViking automatically extracts 6 categories of memories:
|
||||
profile, preferences, entities, events, cases, and patterns.
|
||||
"""
|
||||
if not self._client:
|
||||
return
|
||||
|
||||
# Wait for any pending sync to finish first — do this before the
|
||||
# turn_count check so the last turn's messages are flushed even if
|
||||
# the count hasn't been incremented yet.
|
||||
if self._sync_thread and self._sync_thread.is_alive():
|
||||
self._sync_thread.join(timeout=10.0)
|
||||
|
||||
if self._turn_count == 0:
|
||||
return
|
||||
|
||||
try:
|
||||
self._client.post(f"/api/v1/sessions/{self._session_id}/commit")
|
||||
logger.info("OpenViking session %s committed (%d turns)", self._session_id, self._turn_count)
|
||||
except Exception as e:
|
||||
logger.warning("OpenViking session commit failed: %s", e)
|
||||
|
||||
def _build_memory_uri(self, subdir: str) -> str:
|
||||
"""Build a viking:// memory URI under the configured user/agent/subdir."""
|
||||
slug = uuid.uuid4().hex[:12]
|
||||
return f"viking://user/{self._user}/agent/{self._agent}/memories/{subdir}/mem_{slug}.md"
|
||||
|
||||
def on_memory_write(
|
||||
self,
|
||||
action: str,
|
||||
target: str,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Mirror built-in memory writes to OpenViking via content/write."""
|
||||
if not self._client or action != "add" or not content:
|
||||
return
|
||||
|
||||
subdir = _MEMORY_WRITE_TARGET_SUBDIR_MAP.get(target, _DEFAULT_MEMORY_SUBDIR)
|
||||
uri = self._build_memory_uri(subdir)
|
||||
|
||||
def _write():
|
||||
try:
|
||||
client = _VikingClient(
|
||||
self._endpoint, self._api_key,
|
||||
account=self._account, user=self._user, agent=self._agent,
|
||||
)
|
||||
client.post("/api/v1/content/write", {
|
||||
"uri": uri,
|
||||
"content": content,
|
||||
"mode": "create",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("OpenViking memory mirror failed: %s", e)
|
||||
|
||||
t = threading.Thread(target=_write, daemon=True, name="openviking-memwrite")
|
||||
t.start()
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [SEARCH_SCHEMA, READ_SCHEMA, BROWSE_SCHEMA, REMEMBER_SCHEMA, ADD_RESOURCE_SCHEMA]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
if not self._client:
|
||||
return tool_error("OpenViking server not connected")
|
||||
|
||||
try:
|
||||
if tool_name == "viking_search":
|
||||
return self._tool_search(args)
|
||||
elif tool_name == "viking_read":
|
||||
return self._tool_read(args)
|
||||
elif tool_name == "viking_browse":
|
||||
return self._tool_browse(args)
|
||||
elif tool_name == "viking_remember":
|
||||
return self._tool_remember(args)
|
||||
elif tool_name == "viking_add_resource":
|
||||
return self._tool_add_resource(args)
|
||||
return tool_error(f"Unknown tool: {tool_name}")
|
||||
except Exception as e:
|
||||
return tool_error(str(e))
|
||||
|
||||
def shutdown(self) -> None:
|
||||
# Wait for background threads to finish
|
||||
for t in (self._sync_thread, self._prefetch_thread):
|
||||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
# Clear atexit reference so it doesn't double-commit
|
||||
global _last_active_provider
|
||||
if _last_active_provider is self:
|
||||
_last_active_provider = None
|
||||
|
||||
# -- Tool implementations ------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_result(resp: Any) -> Any:
|
||||
"""Return OpenViking payload body regardless of wrapped/unwrapped shape."""
|
||||
if isinstance(resp, dict) and "result" in resp:
|
||||
return resp.get("result")
|
||||
return resp
|
||||
|
||||
@staticmethod
|
||||
def _normalize_summary_uri(uri: str) -> str:
|
||||
"""Map pseudo summary files to their parent directory URI for L0/L1 reads."""
|
||||
if not uri:
|
||||
return uri
|
||||
for suffix in ("/.abstract.md", "/.overview.md", "/.read.md", "/.full.md"):
|
||||
if uri.endswith(suffix):
|
||||
return uri[: -len(suffix)] or "viking://"
|
||||
return uri
|
||||
|
||||
def _is_directory_uri(self, uri: str) -> bool | None:
|
||||
"""Probe fs/stat to decide if a URI is a directory.
|
||||
|
||||
Returns True/False when the server answers cleanly, and None when the
|
||||
probe itself fails (network error, unexpected shape). Callers should
|
||||
treat None as "unknown" and fall back to the exception-based path.
|
||||
"""
|
||||
try:
|
||||
resp = self._client.get("/api/v1/fs/stat", params={"uri": uri})
|
||||
except Exception:
|
||||
return None
|
||||
result = self._unwrap_result(resp)
|
||||
if isinstance(result, dict):
|
||||
if "isDir" in result:
|
||||
return bool(result.get("isDir"))
|
||||
if "is_dir" in result:
|
||||
return bool(result.get("is_dir"))
|
||||
if result.get("type") == "dir":
|
||||
return True
|
||||
if result.get("type") == "file":
|
||||
return False
|
||||
return None
|
||||
|
||||
def _tool_search(self, args: dict) -> str:
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return tool_error("query is required")
|
||||
|
||||
payload: Dict[str, Any] = {"query": query}
|
||||
mode = args.get("mode", "auto")
|
||||
if mode != "auto":
|
||||
payload["mode"] = mode
|
||||
if args.get("scope"):
|
||||
payload["target_uri"] = args["scope"]
|
||||
if args.get("limit"):
|
||||
payload["top_k"] = args["limit"]
|
||||
|
||||
resp = self._client.post("/api/v1/search/find", payload)
|
||||
result = resp.get("result", {})
|
||||
|
||||
# Format results for the model — keep it concise
|
||||
scored_entries = []
|
||||
for ctx_type in ("memories", "resources", "skills"):
|
||||
items = result.get(ctx_type, [])
|
||||
for item in items:
|
||||
raw_score = item.get("score")
|
||||
sort_score = raw_score if raw_score is not None else 0.0
|
||||
entry = {
|
||||
"uri": item.get("uri", ""),
|
||||
"type": ctx_type.rstrip("s"),
|
||||
"score": round(raw_score, 3) if raw_score is not None else 0.0,
|
||||
"abstract": item.get("abstract", ""),
|
||||
}
|
||||
if item.get("relations"):
|
||||
entry["related"] = [r.get("uri") for r in item["relations"][:3]]
|
||||
scored_entries.append((sort_score, entry))
|
||||
|
||||
scored_entries.sort(key=lambda x: x[0], reverse=True)
|
||||
formatted = [entry for _, entry in scored_entries]
|
||||
|
||||
return json.dumps({
|
||||
"results": formatted,
|
||||
"total": result.get("total", len(formatted)),
|
||||
}, ensure_ascii=False)
|
||||
|
||||
def _tool_read(self, args: dict) -> str:
|
||||
uri = args.get("uri", "")
|
||||
if not uri:
|
||||
return tool_error("uri is required")
|
||||
|
||||
level = args.get("level", "overview")
|
||||
|
||||
summary_level = level in {"abstract", "overview"}
|
||||
# OpenViking expects directory URIs for pseudo summary files
|
||||
# (e.g. viking://user/hermes/.overview.md).
|
||||
resolved_uri = self._normalize_summary_uri(uri) if summary_level else uri
|
||||
used_fallback = False
|
||||
|
||||
# abstract/overview endpoints are directory-only on OpenViking
|
||||
# (v0.3.x returns 500/412 for file URIs). When the caller asks for a
|
||||
# summary level on a non-pseudo URI, probe fs/stat first and route
|
||||
# file URIs straight to /content/read instead of eating a failing
|
||||
# round-trip. The pseudo-URI path already points at a directory, so
|
||||
# skip the probe there.
|
||||
if summary_level and resolved_uri == uri:
|
||||
is_dir = self._is_directory_uri(uri)
|
||||
if is_dir is False:
|
||||
resolved_uri = uri
|
||||
used_fallback = True
|
||||
|
||||
# Map our level names to OpenViking GET endpoints.
|
||||
endpoint = "/api/v1/content/read"
|
||||
if not used_fallback:
|
||||
if level == "abstract":
|
||||
endpoint = "/api/v1/content/abstract"
|
||||
elif level == "overview":
|
||||
endpoint = "/api/v1/content/overview"
|
||||
|
||||
try:
|
||||
resp = self._client.get(endpoint, params={"uri": resolved_uri})
|
||||
except Exception:
|
||||
# OpenViking may return HTTP 500 for abstract/overview reads on normal
|
||||
# file URIs (mem_*.md). For those, gracefully fallback to full read.
|
||||
if not summary_level or resolved_uri != uri or used_fallback:
|
||||
raise
|
||||
resp = self._client.get("/api/v1/content/read", params={"uri": uri})
|
||||
used_fallback = True
|
||||
|
||||
result = self._unwrap_result(resp)
|
||||
# Content endpoints may return either plain strings or objects.
|
||||
if isinstance(result, str):
|
||||
content = result
|
||||
elif isinstance(result, dict):
|
||||
content = result.get("content", "") or result.get("text", "")
|
||||
else:
|
||||
content = ""
|
||||
|
||||
# Truncate long content to avoid flooding context.
|
||||
max_len = 8000
|
||||
if level == "overview":
|
||||
max_len = 4000
|
||||
elif level == "abstract":
|
||||
max_len = 1200
|
||||
|
||||
if len(content) > max_len:
|
||||
content = content[:max_len] + "\n\n[... truncated, use a more specific URI or full level]"
|
||||
|
||||
payload = {
|
||||
"uri": uri,
|
||||
"resolved_uri": resolved_uri,
|
||||
"level": level,
|
||||
"content": content,
|
||||
}
|
||||
if used_fallback:
|
||||
payload["fallback"] = "content/read"
|
||||
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
def _tool_browse(self, args: dict) -> str:
|
||||
action = args.get("action", "list")
|
||||
path = args.get("path", "viking://")
|
||||
|
||||
# Map action to the correct fs endpoint (all GET with uri= param)
|
||||
endpoint_map = {"tree": "/api/v1/fs/tree", "list": "/api/v1/fs/ls", "stat": "/api/v1/fs/stat"}
|
||||
endpoint = endpoint_map.get(action, "/api/v1/fs/ls")
|
||||
resp = self._client.get(endpoint, params={"uri": path})
|
||||
result = self._unwrap_result(resp)
|
||||
|
||||
# Format list/tree results for readability
|
||||
if action in {"list", "tree"}:
|
||||
raw_entries = result
|
||||
if isinstance(result, dict):
|
||||
raw_entries = result.get("entries") or result.get("items") or result.get("children") or []
|
||||
|
||||
if isinstance(raw_entries, list):
|
||||
entries = []
|
||||
for e in raw_entries[:50]: # cap at 50 entries
|
||||
uri = e.get("uri", "")
|
||||
name = e.get("rel_path") or e.get("name") or (uri.rsplit("/", 1)[-1] if uri else "")
|
||||
is_dir = bool(e.get("isDir") or e.get("is_dir") or e.get("type") == "dir")
|
||||
entries.append({
|
||||
"name": name,
|
||||
"uri": uri,
|
||||
"type": "dir" if is_dir else "file",
|
||||
"abstract": e.get("abstract", ""),
|
||||
})
|
||||
return json.dumps({"path": path, "entries": entries}, ensure_ascii=False)
|
||||
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
def _tool_remember(self, args: dict) -> str:
|
||||
content = args.get("content", "")
|
||||
if not content:
|
||||
return tool_error("content is required")
|
||||
|
||||
category = args.get("category", "")
|
||||
subdir = _CATEGORY_SUBDIR_MAP.get(category, _DEFAULT_MEMORY_SUBDIR)
|
||||
uri = self._build_memory_uri(subdir)
|
||||
|
||||
# Write directly via content/write API.
|
||||
# This creates the file, stores the content, and queues vector indexing
|
||||
# in a single call — no dependency on session commit / VLM extraction.
|
||||
try:
|
||||
result = self._client.post("/api/v1/content/write", {
|
||||
"uri": uri,
|
||||
"content": content,
|
||||
"mode": "create",
|
||||
})
|
||||
written = result.get("result", {}).get("written_bytes", 0)
|
||||
return json.dumps({
|
||||
"status": "stored",
|
||||
"message": f"Memory stored ({written}b) and queued for vector indexing.",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("OpenViking content/write failed: %s", e)
|
||||
return tool_error(f"Failed to store memory: {e}")
|
||||
|
||||
def _tool_add_resource(self, args: dict) -> str:
|
||||
url = args.get("url", "")
|
||||
if not url:
|
||||
return tool_error("url is required")
|
||||
|
||||
if args.get("to") and args.get("parent"):
|
||||
return tool_error("Cannot specify both 'to' and 'parent'")
|
||||
|
||||
payload: Dict[str, Any] = {}
|
||||
for key in ("reason", "to", "parent", "instruction", "wait", "timeout"):
|
||||
if key in args and args[key] not in {None, ""}:
|
||||
payload[key] = args[key]
|
||||
|
||||
parsed_url = urlparse(url)
|
||||
if _is_remote_resource_source(url):
|
||||
source_path = None
|
||||
elif parsed_url.scheme == "file":
|
||||
source_path = _path_from_file_uri(url)
|
||||
if isinstance(source_path, str):
|
||||
return tool_error(source_path)
|
||||
elif parsed_url.scheme and not _is_windows_absolute_path(url):
|
||||
source_path = None
|
||||
else:
|
||||
source_path = Path(url).expanduser()
|
||||
|
||||
cleanup_path: Optional[Path] = None
|
||||
try:
|
||||
if source_path is not None:
|
||||
if source_path.exists():
|
||||
if source_path.is_dir():
|
||||
payload["source_name"] = source_path.name
|
||||
cleanup_path = _zip_directory(source_path)
|
||||
upload_path = cleanup_path
|
||||
elif source_path.is_file():
|
||||
payload["source_name"] = source_path.name
|
||||
upload_path = source_path
|
||||
else:
|
||||
return tool_error(f"Unsupported local resource path: {url}")
|
||||
payload["temp_file_id"] = self._client.upload_temp_file(upload_path)
|
||||
elif _is_local_path_reference(url):
|
||||
return tool_error(f"Local resource path does not exist: {url}")
|
||||
else:
|
||||
payload["path"] = url
|
||||
else:
|
||||
payload["path"] = url
|
||||
|
||||
resp = self._client.post("/api/v1/resources", payload)
|
||||
result = resp.get("result", {})
|
||||
finally:
|
||||
if cleanup_path:
|
||||
cleanup_path.unlink(missing_ok=True)
|
||||
|
||||
return json.dumps({
|
||||
"status": "added",
|
||||
"root_uri": result.get("root_uri", ""),
|
||||
"message": "Resource queued for processing. Use viking_search after a moment to find it.",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register OpenViking as a memory provider plugin."""
|
||||
ctx.register_memory_provider(OpenVikingMemoryProvider())
|
||||
@@ -0,0 +1,9 @@
|
||||
name: openviking
|
||||
version: 2.0.0
|
||||
description: "OpenViking context database — session-managed memory with automatic extraction, tiered retrieval, and filesystem-style knowledge browsing."
|
||||
pip_dependencies:
|
||||
- httpx
|
||||
requires_env:
|
||||
- OPENVIKING_ENDPOINT
|
||||
hooks:
|
||||
- on_session_end
|
||||
@@ -0,0 +1,40 @@
|
||||
# RetainDB Memory Provider
|
||||
|
||||
Cloud memory API with hybrid search (Vector + BM25 + Reranking) and 7 memory types.
|
||||
|
||||
## Requirements
|
||||
|
||||
- RetainDB account ($20/month) from [retaindb.com](https://www.retaindb.com)
|
||||
- `pip install requests`
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
hermes memory setup # select "retaindb"
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
hermes config set memory.provider retaindb
|
||||
echo "RETAINDB_API_KEY=your-key" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
All config via environment variables in `.env`:
|
||||
|
||||
| Env Var | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `RETAINDB_API_KEY` | (required) | API key |
|
||||
| `RETAINDB_BASE_URL` | `https://api.retaindb.com` | API endpoint |
|
||||
| `RETAINDB_PROJECT` | auto (profile-scoped) | Project identifier |
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `retaindb_profile` | User's stable profile |
|
||||
| `retaindb_search` | Semantic search |
|
||||
| `retaindb_context` | Task-relevant context |
|
||||
| `retaindb_remember` | Store a fact with type + importance |
|
||||
| `retaindb_forget` | Delete a memory by ID |
|
||||
@@ -0,0 +1,766 @@
|
||||
"""RetainDB memory plugin — MemoryProvider interface.
|
||||
|
||||
Cross-session memory via RetainDB cloud API.
|
||||
|
||||
Features:
|
||||
- Correct API routes for all operations
|
||||
- Durable SQLite write-behind queue (crash-safe, async ingest)
|
||||
- Semantic search + user profile retrieval
|
||||
- Context query with deduplication overlay
|
||||
- Dialectic synthesis (LLM-powered user understanding, prefetched each turn)
|
||||
- Agent self-model (persona + instructions from SOUL.md, prefetched each turn)
|
||||
- Shared file store tools (upload, list, read, ingest, delete)
|
||||
- Explicit memory tools (profile, search, context, remember, forget)
|
||||
|
||||
Config (env vars or hermes config.yaml under retaindb:):
|
||||
RETAINDB_API_KEY — API key (required)
|
||||
RETAINDB_BASE_URL — API endpoint (default: https://api.retaindb.com)
|
||||
RETAINDB_PROJECT — Project identifier (optional — defaults to "default")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from urllib.parse import quote
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_BASE_URL = "https://api.retaindb.com"
|
||||
_ASYNC_SHUTDOWN = object()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROFILE_SCHEMA = {
|
||||
"name": "retaindb_profile",
|
||||
"description": "Get the user's stable profile — preferences, facts, and patterns recalled from long-term memory.",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
}
|
||||
|
||||
SEARCH_SCHEMA = {
|
||||
"name": "retaindb_search",
|
||||
"description": "Semantic search across stored memories. Returns ranked results with relevance scores.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "What to search for."},
|
||||
"top_k": {"type": "integer", "description": "Max results (default: 8, max: 20)."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
CONTEXT_SCHEMA = {
|
||||
"name": "retaindb_context",
|
||||
"description": "Synthesized context block — what matters most for the current task, pulled from long-term memory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Current task or question."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
REMEMBER_SCHEMA = {
|
||||
"name": "retaindb_remember",
|
||||
"description": "Persist an explicit fact, preference, or decision to long-term memory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "The fact to remember."},
|
||||
"memory_type": {
|
||||
"type": "string",
|
||||
"enum": ["factual", "preference", "goal", "instruction", "event", "opinion"],
|
||||
"description": "Category (default: factual).",
|
||||
},
|
||||
"importance": {"type": "number", "description": "Importance 0-1 (default: 0.7)."},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
}
|
||||
|
||||
FORGET_SCHEMA = {
|
||||
"name": "retaindb_forget",
|
||||
"description": "Delete a specific memory by ID.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {"type": "string", "description": "Memory ID to delete."},
|
||||
},
|
||||
"required": ["memory_id"],
|
||||
},
|
||||
}
|
||||
|
||||
FILE_UPLOAD_SCHEMA = {
|
||||
"name": "retaindb_upload_file",
|
||||
"description": "Upload a file to the shared RetainDB file store. Returns an rdb:// URI any agent can reference.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"local_path": {"type": "string", "description": "Local file path to upload."},
|
||||
"remote_path": {"type": "string", "description": "Destination path, e.g. /reports/q1.pdf"},
|
||||
"scope": {"type": "string", "enum": ["USER", "PROJECT", "ORG"], "description": "Access scope (default: PROJECT)."},
|
||||
"ingest": {"type": "boolean", "description": "Also extract memories from file after upload (default: false)."},
|
||||
},
|
||||
"required": ["local_path"],
|
||||
},
|
||||
}
|
||||
|
||||
FILE_LIST_SCHEMA = {
|
||||
"name": "retaindb_list_files",
|
||||
"description": "List files in the shared file store.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prefix": {"type": "string", "description": "Path prefix to filter by, e.g. /reports/"},
|
||||
"limit": {"type": "integer", "description": "Max results (default: 50)."},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
FILE_READ_SCHEMA = {
|
||||
"name": "retaindb_read_file",
|
||||
"description": "Read the text content of a stored file by its file ID.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_id": {"type": "string", "description": "File ID returned from upload or list."},
|
||||
},
|
||||
"required": ["file_id"],
|
||||
},
|
||||
}
|
||||
|
||||
FILE_INGEST_SCHEMA = {
|
||||
"name": "retaindb_ingest_file",
|
||||
"description": "Chunk, embed, and extract memories from a stored file. Makes its contents searchable.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_id": {"type": "string", "description": "File ID to ingest."},
|
||||
},
|
||||
"required": ["file_id"],
|
||||
},
|
||||
}
|
||||
|
||||
FILE_DELETE_SCHEMA = {
|
||||
"name": "retaindb_delete_file",
|
||||
"description": "Delete a stored file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_id": {"type": "string", "description": "File ID to delete."},
|
||||
},
|
||||
"required": ["file_id"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _Client:
|
||||
def __init__(self, api_key: str, base_url: str, project: str):
|
||||
self.api_key = api_key
|
||||
self.base_url = re.sub(r"/+$", "", base_url)
|
||||
self.project = project
|
||||
|
||||
def _headers(self, path: str) -> dict:
|
||||
token = self.api_key.replace("Bearer ", "").strip()
|
||||
h = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"x-sdk-runtime": "hermes-plugin",
|
||||
}
|
||||
if path.startswith(("/v1/memory", "/v1/context")):
|
||||
h["X-API-Key"] = token
|
||||
return h
|
||||
|
||||
def request(self, method: str, path: str, *, params=None, json_body=None, timeout: float = 8.0) -> Any:
|
||||
import requests
|
||||
url = f"{self.base_url}{path}"
|
||||
resp = requests.request(
|
||||
method.upper(), url,
|
||||
params=params,
|
||||
json=json_body if method.upper() not in {"GET", "DELETE"} else None,
|
||||
headers=self._headers(path),
|
||||
timeout=timeout,
|
||||
)
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
payload = resp.text
|
||||
if not resp.ok:
|
||||
msg = ""
|
||||
if isinstance(payload, dict):
|
||||
msg = str(payload.get("message") or payload.get("error") or "")
|
||||
raise RuntimeError(f"RetainDB {method} {path} failed ({resp.status_code}): {msg or payload}")
|
||||
return payload
|
||||
|
||||
# ── Memory ────────────────────────────────────────────────────────────────
|
||||
|
||||
def query_context(self, user_id: str, session_id: str, query: str, max_tokens: int = 1200) -> dict:
|
||||
return self.request("POST", "/v1/context/query", json_body={
|
||||
"project": self.project,
|
||||
"query": query,
|
||||
"user_id": user_id,
|
||||
"session_id": session_id,
|
||||
"include_memories": True,
|
||||
"max_tokens": max_tokens,
|
||||
})
|
||||
|
||||
def search(self, user_id: str, session_id: str, query: str, top_k: int = 8) -> dict:
|
||||
return self.request("POST", "/v1/memory/search", json_body={
|
||||
"project": self.project,
|
||||
"query": query,
|
||||
"user_id": user_id,
|
||||
"session_id": session_id,
|
||||
"top_k": top_k,
|
||||
"include_pending": True,
|
||||
})
|
||||
|
||||
def get_profile(self, user_id: str) -> dict:
|
||||
try:
|
||||
return self.request("GET", f"/v1/memory/profile/{quote(user_id, safe='')}", params={"project": self.project, "include_pending": "true"})
|
||||
except Exception:
|
||||
return self.request("GET", "/v1/memories", params={"project": self.project, "user_id": user_id, "limit": "200"})
|
||||
|
||||
def add_memory(self, user_id: str, session_id: str, content: str, memory_type: str = "factual", importance: float = 0.7) -> dict:
|
||||
try:
|
||||
return self.request("POST", "/v1/memory", json_body={
|
||||
"project": self.project, "content": content, "memory_type": memory_type,
|
||||
"user_id": user_id, "session_id": session_id, "importance": importance, "write_mode": "sync",
|
||||
}, timeout=5.0)
|
||||
except Exception:
|
||||
return self.request("POST", "/v1/memories", json_body={
|
||||
"project": self.project, "content": content, "memory_type": memory_type,
|
||||
"user_id": user_id, "session_id": session_id, "importance": importance,
|
||||
}, timeout=5.0)
|
||||
|
||||
def delete_memory(self, memory_id: str) -> dict:
|
||||
try:
|
||||
return self.request("DELETE", f"/v1/memory/{quote(memory_id, safe='')}", timeout=5.0)
|
||||
except Exception:
|
||||
return self.request("DELETE", f"/v1/memories/{quote(memory_id, safe='')}", timeout=5.0)
|
||||
|
||||
def ingest_session(self, user_id: str, session_id: str, messages: list, timeout: float = 15.0) -> dict:
|
||||
return self.request("POST", "/v1/memory/ingest/session", json_body={
|
||||
"project": self.project, "session_id": session_id, "user_id": user_id,
|
||||
"messages": messages, "write_mode": "sync",
|
||||
}, timeout=timeout)
|
||||
|
||||
def ask_user(self, user_id: str, query: str, reasoning_level: str = "low") -> dict:
|
||||
return self.request("POST", f"/v1/memory/profile/{quote(user_id, safe='')}/ask", json_body={
|
||||
"project": self.project, "query": query, "reasoning_level": reasoning_level,
|
||||
}, timeout=8.0)
|
||||
|
||||
def get_agent_model(self, agent_id: str) -> dict:
|
||||
return self.request("GET", f"/v1/memory/agent/{quote(agent_id, safe='')}/model", params={"project": self.project}, timeout=4.0)
|
||||
|
||||
def seed_agent_identity(self, agent_id: str, content: str, source: str = "soul_md") -> dict:
|
||||
return self.request("POST", f"/v1/memory/agent/{quote(agent_id, safe='')}/seed", json_body={
|
||||
"project": self.project, "content": content, "source": source,
|
||||
}, timeout=20.0)
|
||||
|
||||
# ── Files ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def upload_file(self, data: bytes, filename: str, remote_path: str, mime_type: str, scope: str, project_id: str | None) -> dict:
|
||||
import io
|
||||
import requests
|
||||
url = f"{self.base_url}/v1/files"
|
||||
token = self.api_key.replace("Bearer ", "").strip()
|
||||
headers = {"Authorization": f"Bearer {token}", "x-sdk-runtime": "hermes-plugin"}
|
||||
fields = {"path": remote_path, "scope": scope.upper()}
|
||||
if project_id:
|
||||
fields["project_id"] = project_id
|
||||
resp = requests.post(url, files={"file": (filename, io.BytesIO(data), mime_type)}, data=fields, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def list_files(self, prefix: str | None = None, limit: int = 50) -> dict:
|
||||
params: dict = {"limit": limit}
|
||||
if prefix:
|
||||
params["prefix"] = prefix
|
||||
return self.request("GET", "/v1/files", params=params)
|
||||
|
||||
def get_file(self, file_id: str) -> dict:
|
||||
return self.request("GET", f"/v1/files/{quote(file_id, safe='')}")
|
||||
|
||||
def read_file_content(self, file_id: str) -> bytes:
|
||||
import requests
|
||||
token = self.api_key.replace("Bearer ", "").strip()
|
||||
url = f"{self.base_url}/v1/files/{quote(file_id, safe='')}/content"
|
||||
resp = requests.get(url, headers={"Authorization": f"Bearer {token}", "x-sdk-runtime": "hermes-plugin"}, timeout=30, allow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
def ingest_file(self, file_id: str, user_id: str | None = None, agent_id: str | None = None) -> dict:
|
||||
body: dict = {}
|
||||
if user_id:
|
||||
body["user_id"] = user_id
|
||||
if agent_id:
|
||||
body["agent_id"] = agent_id
|
||||
return self.request("POST", f"/v1/files/{quote(file_id, safe='')}/ingest", json_body=body, timeout=60.0)
|
||||
|
||||
def delete_file(self, file_id: str) -> dict:
|
||||
return self.request("DELETE", f"/v1/files/{quote(file_id, safe='')}", timeout=5.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Durable write-behind queue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _WriteQueue:
|
||||
"""SQLite-backed async write queue. Survives crashes — pending rows replay on startup."""
|
||||
|
||||
def __init__(self, client: _Client, db_path: Path):
|
||||
self._client = client
|
||||
self._db_path = db_path
|
||||
self._q: queue.Queue = queue.Queue()
|
||||
self._thread = threading.Thread(target=self._loop, name="retaindb-writer", daemon=True)
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Thread-local connection cache — one connection per thread, reused.
|
||||
self._local = threading.local()
|
||||
self._init_db()
|
||||
self._thread.start()
|
||||
# Replay any rows left from a previous crash
|
||||
for row_id, user_id, session_id, msgs_json in self._pending_rows():
|
||||
self._q.put((row_id, user_id, session_id, json.loads(msgs_json)))
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
"""Return a cached connection for the current thread."""
|
||||
conn = getattr(self._local, "conn", None)
|
||||
if conn is None:
|
||||
conn = sqlite3.connect(str(self._db_path), timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
self._local.conn = conn
|
||||
return conn
|
||||
|
||||
def _init_db(self) -> None:
|
||||
conn = self._get_conn()
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS pending (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT, session_id TEXT, messages_json TEXT,
|
||||
created_at TEXT, last_error TEXT
|
||||
)""")
|
||||
conn.commit()
|
||||
|
||||
def _pending_rows(self) -> list:
|
||||
conn = self._get_conn()
|
||||
return conn.execute("SELECT id, user_id, session_id, messages_json FROM pending ORDER BY id ASC LIMIT 200").fetchall()
|
||||
|
||||
def enqueue(self, user_id: str, session_id: str, messages: list) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = self._get_conn()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pending (user_id, session_id, messages_json, created_at) VALUES (?,?,?,?)",
|
||||
(user_id, session_id, json.dumps(messages, ensure_ascii=False), now),
|
||||
)
|
||||
row_id = cur.lastrowid
|
||||
conn.commit()
|
||||
self._q.put((row_id, user_id, session_id, messages))
|
||||
|
||||
def _flush_row(self, row_id: int, user_id: str, session_id: str, messages: list) -> None:
|
||||
try:
|
||||
self._client.ingest_session(user_id, session_id, messages)
|
||||
conn = self._get_conn()
|
||||
conn.execute("DELETE FROM pending WHERE id = ?", (row_id,))
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("RetainDB ingest failed (will retry): %s", exc)
|
||||
conn = self._get_conn()
|
||||
conn.execute("UPDATE pending SET last_error = ? WHERE id = ?", (str(exc), row_id))
|
||||
conn.commit()
|
||||
time.sleep(2)
|
||||
|
||||
def _loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
item = self._q.get(timeout=5)
|
||||
if item is _ASYNC_SHUTDOWN:
|
||||
break
|
||||
self._flush_row(*item)
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.error("RetainDB writer error: %s", exc)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._q.put(_ASYNC_SHUTDOWN)
|
||||
self._thread.join(timeout=10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Overlay formatter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_overlay(profile: dict, query_result: dict, local_entries: list[str] | None = None) -> str:
|
||||
def _compact(s: str) -> str:
|
||||
return re.sub(r"\s+", " ", str(s or "")).strip()[:320]
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return re.sub(r"[^a-z0-9 ]", "", _compact(s).lower())
|
||||
|
||||
seen: list[str] = [_norm(e) for e in (local_entries or []) if _norm(e)]
|
||||
profile_items: list[str] = []
|
||||
for m in list((profile or {}).get("memories") or [])[:5]:
|
||||
c = _compact((m or {}).get("content") or "")
|
||||
n = _norm(c)
|
||||
if c and n not in seen:
|
||||
seen.append(n)
|
||||
profile_items.append(c)
|
||||
|
||||
query_items: list[str] = []
|
||||
for r in list((query_result or {}).get("results") or [])[:5]:
|
||||
c = _compact((r or {}).get("content") or "")
|
||||
n = _norm(c)
|
||||
if c and n not in seen:
|
||||
seen.append(n)
|
||||
query_items.append(c)
|
||||
|
||||
if not profile_items and not query_items:
|
||||
return ""
|
||||
|
||||
lines = ["[RetainDB Context]", "Profile:"]
|
||||
lines += [f"- {i}" for i in profile_items] or ["- None"]
|
||||
lines.append("Relevant memories:")
|
||||
lines += [f"- {i}" for i in query_items] or ["- None"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main plugin class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RetainDBMemoryProvider(MemoryProvider):
|
||||
"""RetainDB cloud memory — durable queue, semantic search, dialectic synthesis, shared files."""
|
||||
|
||||
def __init__(self):
|
||||
self._client: _Client | None = None
|
||||
self._queue: _WriteQueue | None = None
|
||||
self._user_id = "default"
|
||||
self._session_id = ""
|
||||
self._agent_id = "hermes"
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Prefetch caches
|
||||
self._context_result = ""
|
||||
self._dialectic_result = ""
|
||||
self._agent_model: dict = {}
|
||||
|
||||
# Prefetch thread tracking — prevents accumulation on rapid calls
|
||||
self._prefetch_threads: list[threading.Thread] = []
|
||||
|
||||
# ── Core identity ──────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "retaindb"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(os.environ.get("RETAINDB_API_KEY"))
|
||||
|
||||
def get_config_schema(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{"key": "api_key", "description": "RetainDB API key", "secret": True, "required": True, "env_var": "RETAINDB_API_KEY", "url": "https://retaindb.com"},
|
||||
{"key": "base_url", "description": "API endpoint", "default": _DEFAULT_BASE_URL},
|
||||
{"key": "project", "description": "Project identifier (optional — uses 'default' project if not set)", "default": ""},
|
||||
]
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
api_key = os.environ.get("RETAINDB_API_KEY", "")
|
||||
base_url = re.sub(r"/+$", "", os.environ.get("RETAINDB_BASE_URL", _DEFAULT_BASE_URL))
|
||||
|
||||
# Project resolution: RETAINDB_PROJECT > hermes-<profile> > "default"
|
||||
# If unset, the API auto-creates and uses the "default" project — no config required.
|
||||
explicit = os.environ.get("RETAINDB_PROJECT")
|
||||
if explicit:
|
||||
project = explicit
|
||||
else:
|
||||
hermes_home = str(kwargs.get("hermes_home", ""))
|
||||
profile_name = os.path.basename(hermes_home) if hermes_home else ""
|
||||
project = f"hermes-{profile_name}" if (profile_name and profile_name not in {"", ".hermes"}) else "default"
|
||||
|
||||
self._client = _Client(api_key, base_url, project)
|
||||
self._session_id = session_id
|
||||
self._user_id = kwargs.get("user_id", "default") or "default"
|
||||
self._agent_id = kwargs.get("agent_id", "hermes") or "hermes"
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
hermes_home_path = get_hermes_home()
|
||||
db_path = hermes_home_path / "retaindb_queue.db"
|
||||
self._queue = _WriteQueue(self._client, db_path)
|
||||
|
||||
# Seed agent identity from SOUL.md in background
|
||||
soul_path = hermes_home_path / "SOUL.md"
|
||||
if soul_path.exists():
|
||||
soul_content = soul_path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
if soul_content:
|
||||
threading.Thread(
|
||||
target=self._seed_soul,
|
||||
args=(soul_content,),
|
||||
name="retaindb-soul-seed",
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _seed_soul(self, content: str) -> None:
|
||||
try:
|
||||
self._client.seed_agent_identity(self._agent_id, content, source="soul_md")
|
||||
except Exception as exc:
|
||||
logger.debug("RetainDB soul seed failed: %s", exc)
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
project = self._client.project if self._client else "retaindb"
|
||||
return (
|
||||
"# RetainDB Memory\n"
|
||||
f"Active. Project: {project}.\n"
|
||||
"Use retaindb_search to find memories, retaindb_remember to store facts, "
|
||||
"retaindb_profile for a user overview, retaindb_context for current-task context."
|
||||
)
|
||||
|
||||
# ── Background prefetch (fires at turn-end, consumed next turn-start) ──
|
||||
|
||||
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
||||
"""Fire context + dialectic + agent model prefetches in background."""
|
||||
if not self._client:
|
||||
return
|
||||
# Wait for any still-running prefetch threads before spawning new ones.
|
||||
# Prevents thread accumulation if turns fire faster than prefetches complete.
|
||||
for t in self._prefetch_threads:
|
||||
t.join(timeout=2.0)
|
||||
threads = [
|
||||
threading.Thread(target=self._prefetch_context, args=(query,), name="retaindb-ctx", daemon=True),
|
||||
threading.Thread(target=self._prefetch_dialectic, args=(query,), name="retaindb-dialectic", daemon=True),
|
||||
threading.Thread(target=self._prefetch_agent_model, name="retaindb-agent-model", daemon=True),
|
||||
]
|
||||
self._prefetch_threads = threads
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
def _prefetch_context(self, query: str) -> None:
|
||||
try:
|
||||
query_result = self._client.query_context(self._user_id, self._session_id, query)
|
||||
profile = self._client.get_profile(self._user_id)
|
||||
overlay = _build_overlay(profile, query_result)
|
||||
with self._lock:
|
||||
self._context_result = overlay
|
||||
except Exception as exc:
|
||||
logger.debug("RetainDB context prefetch failed: %s", exc)
|
||||
|
||||
def _prefetch_dialectic(self, query: str) -> None:
|
||||
try:
|
||||
result = self._client.ask_user(self._user_id, query, reasoning_level=self._reasoning_level(query))
|
||||
answer = str(result.get("answer") or "")
|
||||
if answer:
|
||||
with self._lock:
|
||||
self._dialectic_result = answer
|
||||
except Exception as exc:
|
||||
logger.debug("RetainDB dialectic prefetch failed: %s", exc)
|
||||
|
||||
def _prefetch_agent_model(self) -> None:
|
||||
try:
|
||||
model = self._client.get_agent_model(self._agent_id)
|
||||
if model.get("memory_count", 0) > 0:
|
||||
with self._lock:
|
||||
self._agent_model = model
|
||||
except Exception as exc:
|
||||
logger.debug("RetainDB agent model prefetch failed: %s", exc)
|
||||
|
||||
@staticmethod
|
||||
def _reasoning_level(query: str) -> str:
|
||||
n = len(query)
|
||||
if n < 120:
|
||||
return "low"
|
||||
if n < 400:
|
||||
return "medium"
|
||||
return "high"
|
||||
|
||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||
"""Consume prefetched results and return them as a context block."""
|
||||
with self._lock:
|
||||
context = self._context_result
|
||||
dialectic = self._dialectic_result
|
||||
agent_model = self._agent_model
|
||||
self._context_result = ""
|
||||
self._dialectic_result = ""
|
||||
self._agent_model = {}
|
||||
|
||||
parts: list[str] = []
|
||||
if context:
|
||||
parts.append(context)
|
||||
if dialectic:
|
||||
parts.append(f"[RetainDB User Synthesis]\n{dialectic}")
|
||||
if agent_model and agent_model.get("memory_count", 0) > 0:
|
||||
model_lines: list[str] = []
|
||||
if agent_model.get("persona"):
|
||||
model_lines.append(f"Persona: {agent_model['persona']}")
|
||||
if agent_model.get("persistent_instructions"):
|
||||
model_lines.append("Instructions:\n" + "\n".join(f"- {i}" for i in agent_model["persistent_instructions"]))
|
||||
if agent_model.get("working_style"):
|
||||
model_lines.append(f"Working style: {agent_model['working_style']}")
|
||||
if model_lines:
|
||||
parts.append("[RetainDB Agent Self-Model]\n" + "\n".join(model_lines))
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
# ── Turn sync ──────────────────────────────────────────────────────────
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
"""Queue turn for async ingest. Returns immediately."""
|
||||
if not self._queue or not user_content:
|
||||
return
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self._queue.enqueue(
|
||||
self._user_id,
|
||||
session_id or self._session_id,
|
||||
[
|
||||
{"role": "user", "content": user_content, "timestamp": now},
|
||||
{"role": "assistant", "content": assistant_content, "timestamp": now},
|
||||
],
|
||||
)
|
||||
|
||||
# ── Tools ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
PROFILE_SCHEMA, SEARCH_SCHEMA, CONTEXT_SCHEMA,
|
||||
REMEMBER_SCHEMA, FORGET_SCHEMA,
|
||||
FILE_UPLOAD_SCHEMA, FILE_LIST_SCHEMA, FILE_READ_SCHEMA,
|
||||
FILE_INGEST_SCHEMA, FILE_DELETE_SCHEMA,
|
||||
]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
if not self._client:
|
||||
return tool_error("RetainDB not initialized")
|
||||
try:
|
||||
return json.dumps(self._dispatch(tool_name, args))
|
||||
except Exception as exc:
|
||||
return tool_error(str(exc))
|
||||
|
||||
def _dispatch(self, tool_name: str, args: dict) -> Any:
|
||||
c = self._client
|
||||
|
||||
if tool_name == "retaindb_profile":
|
||||
return c.get_profile(self._user_id)
|
||||
|
||||
if tool_name == "retaindb_search":
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return {"error": "query is required"}
|
||||
return c.search(self._user_id, self._session_id, query, top_k=min(int(args.get("top_k", 8)), 20))
|
||||
|
||||
if tool_name == "retaindb_context":
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return {"error": "query is required"}
|
||||
query_result = c.query_context(self._user_id, self._session_id, query)
|
||||
profile = c.get_profile(self._user_id)
|
||||
overlay = _build_overlay(profile, query_result)
|
||||
return {"context": overlay, "raw": query_result}
|
||||
|
||||
if tool_name == "retaindb_remember":
|
||||
content = args.get("content", "")
|
||||
if not content:
|
||||
return {"error": "content is required"}
|
||||
return c.add_memory(
|
||||
self._user_id, self._session_id, content,
|
||||
memory_type=args.get("memory_type", "factual"),
|
||||
importance=float(args.get("importance", 0.7)),
|
||||
)
|
||||
|
||||
if tool_name == "retaindb_forget":
|
||||
memory_id = args.get("memory_id", "")
|
||||
if not memory_id:
|
||||
return {"error": "memory_id is required"}
|
||||
return c.delete_memory(memory_id)
|
||||
|
||||
# ── File tools ──────────────────────────────────────────────────────
|
||||
|
||||
if tool_name == "retaindb_upload_file":
|
||||
local_path = args.get("local_path", "")
|
||||
if not local_path:
|
||||
return {"error": "local_path is required"}
|
||||
path_obj = Path(local_path)
|
||||
if not path_obj.exists():
|
||||
return {"error": f"File not found: {local_path}"}
|
||||
data = path_obj.read_bytes()
|
||||
import mimetypes
|
||||
mime = mimetypes.guess_type(path_obj.name)[0] or "application/octet-stream"
|
||||
remote_path = args.get("remote_path") or f"/{path_obj.name}"
|
||||
result = c.upload_file(data, path_obj.name, remote_path, mime, args.get("scope", "PROJECT"), None)
|
||||
if args.get("ingest") and result.get("file", {}).get("id"):
|
||||
ingest = c.ingest_file(result["file"]["id"], user_id=self._user_id, agent_id=self._agent_id)
|
||||
result["ingest"] = ingest
|
||||
return result
|
||||
|
||||
if tool_name == "retaindb_list_files":
|
||||
return c.list_files(prefix=args.get("prefix"), limit=int(args.get("limit", 50)))
|
||||
|
||||
if tool_name == "retaindb_read_file":
|
||||
file_id = args.get("file_id", "")
|
||||
if not file_id:
|
||||
return {"error": "file_id is required"}
|
||||
meta = c.get_file(file_id)
|
||||
file_info = meta.get("file") or {}
|
||||
mime = (file_info.get("mime_type") or "").lower()
|
||||
raw = c.read_file_content(file_id)
|
||||
if not (mime.startswith("text/") or any(file_info.get("name", "").endswith(e) for e in (".txt", ".md", ".json", ".csv", ".yaml", ".yml", ".xml", ".html"))):
|
||||
return {"file_id": file_id, "rdb_uri": file_info.get("rdb_uri"), "name": file_info.get("name"), "content": None, "note": "Binary file — use retaindb_ingest_file to extract text into memory."}
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
return {"file_id": file_id, "rdb_uri": file_info.get("rdb_uri"), "name": file_info.get("name"), "content": text[:32000], "truncated": len(text) > 32000}
|
||||
|
||||
if tool_name == "retaindb_ingest_file":
|
||||
file_id = args.get("file_id", "")
|
||||
if not file_id:
|
||||
return {"error": "file_id is required"}
|
||||
return c.ingest_file(file_id, user_id=self._user_id, agent_id=self._agent_id)
|
||||
|
||||
if tool_name == "retaindb_delete_file":
|
||||
file_id = args.get("file_id", "")
|
||||
if not file_id:
|
||||
return {"error": "file_id is required"}
|
||||
return c.delete_file(file_id)
|
||||
|
||||
return {"error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
# ── Optional hooks ─────────────────────────────────────────────────────
|
||||
|
||||
def on_memory_write(self, action: str, target: str, content: str) -> None:
|
||||
"""Mirror built-in memory writes to RetainDB."""
|
||||
if action != "add" or not content or not self._client:
|
||||
return
|
||||
try:
|
||||
memory_type = "preference" if target == "user" else "factual"
|
||||
self._client.add_memory(self._user_id, self._session_id, content, memory_type=memory_type)
|
||||
except Exception as exc:
|
||||
logger.debug("RetainDB memory mirror failed: %s", exc)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
for t in self._prefetch_threads:
|
||||
t.join(timeout=3.0)
|
||||
if self._queue:
|
||||
self._queue.shutdown()
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register RetainDB as a memory provider plugin."""
|
||||
ctx.register_memory_provider(RetainDBMemoryProvider())
|
||||
@@ -0,0 +1,7 @@
|
||||
name: retaindb
|
||||
version: 1.0.0
|
||||
description: "RetainDB — cloud memory API with hybrid search and 7 memory types."
|
||||
pip_dependencies:
|
||||
- requests
|
||||
requires_env:
|
||||
- RETAINDB_API_KEY
|
||||
@@ -0,0 +1,111 @@
|
||||
# Supermemory Memory Provider
|
||||
|
||||
Semantic long-term memory with profile recall, semantic search, explicit memory tools, and full-session conversation ingest (one ingest per session) for richer profiles.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `pip install supermemory`
|
||||
- Supermemory API key from [supermemory.ai](https://supermemory.ai)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
hermes memory setup # select "supermemory"
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
hermes config set memory.provider supermemory
|
||||
echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
Config file: `$HERMES_HOME/supermemory.json`
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `container_tag` | `hermes` | Container tag used for search and writes. Supports `{identity}` template for profile-scoped tags (e.g. `hermes-{identity}` → `hermes-coder`). |
|
||||
| `auto_recall` | `true` | Inject relevant memory context before turns |
|
||||
| `auto_capture` | `true` | Store cleaned user-assistant turns after each response |
|
||||
| `max_recall_results` | `10` | Max recalled items to format into context |
|
||||
| `profile_frequency` | `50` | Include profile facts on first turn and every N turns |
|
||||
| `capture_mode` | `all` | Skip tiny or trivial turns by default |
|
||||
| `search_mode` | `hybrid` | Search mode: `hybrid` (profile + memories), `memories` (memories only), `documents` (documents only) |
|
||||
| `entity_context` | built-in default | Extraction guidance passed to Supermemory |
|
||||
| `api_timeout` | `5.0` | Timeout for SDK and ingest requests |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SUPERMEMORY_API_KEY` | API key (required) |
|
||||
| `SUPERMEMORY_CONTAINER_TAG` | Override container tag (takes priority over config file) |
|
||||
|
||||
## Tools
|
||||
|
||||
Kebab-case names are registered for the agent; snake_case aliases remain supported.
|
||||
|
||||
| Tool | Alias | Description |
|
||||
|------|-------|-------------|
|
||||
| `supermemory-save` | `supermemory_store` | Store an explicit memory |
|
||||
| `supermemory-search` | `supermemory_search` | Search memories by semantic similarity |
|
||||
| `supermemory-forget` | `supermemory_forget` | Forget a memory by ID or best-match query |
|
||||
| `supermemory-profile` | `supermemory_profile` | Retrieve persistent profile and recent context |
|
||||
|
||||
## Source attribution
|
||||
|
||||
All Supermemory API calls send `x-sm-source: hermes`, and document writes stamp
|
||||
`metadata.sm_source: hermes`. This is a **functional routing key, not telemetry**:
|
||||
it groups Hermes-written memories into a dedicated "Hermes" Space in the
|
||||
Supermemory app, so you can filter, browse, and bulk-manage them per source agent
|
||||
(alongside Codex, Claude Code, etc.) from the Supermemory UI.
|
||||
|
||||
## Behavior
|
||||
|
||||
When enabled, Hermes can:
|
||||
|
||||
- prefetch relevant memory context before each turn
|
||||
- buffer the full conversation and ingest it as **one session** at session end (or on `/reset`, branch, compression, or shutdown)
|
||||
- ingest the full session to the conversations endpoint for richer profile/graph updates
|
||||
- expose explicit tools for search, store, forget, and profile access
|
||||
|
||||
The session is written once via the conversations endpoint, which drives Supermemory's entity extraction and profile building while keeping a clean, retrievable full transcript.
|
||||
|
||||
## Profile-Scoped Containers
|
||||
|
||||
Use `{identity}` in the `container_tag` to scope memories per Hermes profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"container_tag": "hermes-{identity}"
|
||||
}
|
||||
```
|
||||
|
||||
For a profile named `coder`, this resolves to `hermes-coder`. The default profile resolves to `hermes-default`. Without `{identity}`, all profiles share the same container.
|
||||
|
||||
## Multi-Container Mode
|
||||
|
||||
For advanced setups (e.g. OpenClaw-style multi-workspace), you can enable custom container tags so the agent can read/write across multiple named containers:
|
||||
|
||||
```json
|
||||
{
|
||||
"container_tag": "hermes",
|
||||
"enable_custom_container_tags": true,
|
||||
"custom_containers": ["project-alpha", "project-beta", "shared-knowledge"],
|
||||
"custom_container_instructions": "Use project-alpha for coding tasks, project-beta for research, and shared-knowledge for team-wide facts."
|
||||
}
|
||||
```
|
||||
|
||||
When enabled:
|
||||
- `supermemory-search`, `supermemory-save`, `supermemory-forget`, and `supermemory-profile` accept an optional `container_tag` parameter
|
||||
- The tag must be in the whitelist: primary container + `custom_containers`
|
||||
- Automatic operations (turn sync, prefetch, memory write mirroring, session ingest) always use the **primary** container only
|
||||
- Custom container instructions are injected into the system prompt
|
||||
|
||||
## Support
|
||||
|
||||
- [Supermemory Discord](https://supermemory.link/discord)
|
||||
- [support@supermemory.com](mailto:support@supermemory.com)
|
||||
- [supermemory.ai](https://supermemory.ai)
|
||||
@@ -0,0 +1,897 @@
|
||||
"""Supermemory memory plugin using the MemoryProvider interface.
|
||||
|
||||
Provides semantic long-term memory with profile recall, semantic search,
|
||||
explicit memory tools, cleaned turn capture, and session-end conversation ingest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
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__)
|
||||
|
||||
_DEFAULT_CONTAINER_TAG = "hermes"
|
||||
_DEFAULT_MAX_RECALL_RESULTS = 10
|
||||
_DEFAULT_PROFILE_FREQUENCY = 50
|
||||
_DEFAULT_CAPTURE_MODE = "all"
|
||||
_DEFAULT_SEARCH_MODE = "hybrid"
|
||||
_VALID_SEARCH_MODES = ("hybrid", "memories", "documents")
|
||||
_DEFAULT_API_TIMEOUT = 5.0
|
||||
_MIN_CAPTURE_LENGTH = 10
|
||||
_MAX_ENTITY_CONTEXT_LENGTH = 1500
|
||||
_CONVERSATIONS_URL = "https://api.supermemory.ai/v4/conversations"
|
||||
_TRIVIAL_RE = re.compile(
|
||||
r"^(ok|okay|thanks|thank you|got it|sure|yes|no|yep|nope|k|ty|thx|np)\.?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONTEXT_STRIP_RE = re.compile(
|
||||
r"<supermemory-context>[\s\S]*?</supermemory-context>\s*", re.DOTALL
|
||||
)
|
||||
_CONTAINERS_STRIP_RE = re.compile(
|
||||
r"<supermemory-containers>[\s\S]*?</supermemory-containers>\s*", re.DOTALL
|
||||
)
|
||||
_DEFAULT_ENTITY_CONTEXT = (
|
||||
"User-assistant conversation. Format: [role: user]...[user:end] and "
|
||||
"[role: assistant]...[assistant:end].\n\n"
|
||||
"Only extract things useful in future conversations. Most messages are not worth remembering.\n\n"
|
||||
"Remember lasting personal facts, preferences, routines, tools, ongoing projects, working context, "
|
||||
"and explicit requests to remember something.\n\n"
|
||||
"Do not remember temporary intents, one-time tasks, assistant actions, implementation details, or in-progress status.\n\n"
|
||||
"When in doubt, store less."
|
||||
)
|
||||
|
||||
|
||||
def _default_config() -> dict:
|
||||
return {
|
||||
"container_tag": _DEFAULT_CONTAINER_TAG,
|
||||
"auto_recall": True,
|
||||
"auto_capture": True,
|
||||
"max_recall_results": _DEFAULT_MAX_RECALL_RESULTS,
|
||||
"profile_frequency": _DEFAULT_PROFILE_FREQUENCY,
|
||||
"capture_mode": _DEFAULT_CAPTURE_MODE,
|
||||
"search_mode": _DEFAULT_SEARCH_MODE,
|
||||
"entity_context": _DEFAULT_ENTITY_CONTEXT,
|
||||
"api_timeout": _DEFAULT_API_TIMEOUT,
|
||||
"enable_custom_container_tags": False,
|
||||
"custom_containers": [],
|
||||
"custom_container_instructions": "",
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_tag(raw: str) -> str:
|
||||
tag = re.sub(r"[^a-zA-Z0-9_]", "_", raw or "")
|
||||
tag = re.sub(r"_+", "_", tag)
|
||||
return tag.strip("_") or _DEFAULT_CONTAINER_TAG
|
||||
|
||||
|
||||
def _clamp_entity_context(text: str) -> str:
|
||||
if not text:
|
||||
return _DEFAULT_ENTITY_CONTEXT
|
||||
text = text.strip()
|
||||
return text[:_MAX_ENTITY_CONTEXT_LENGTH]
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "y", "on"}:
|
||||
return True
|
||||
if lowered in {"false", "0", "no", "n", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _load_supermemory_config(hermes_home: str) -> dict:
|
||||
config = _default_config()
|
||||
config_path = Path(hermes_home) / "supermemory.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
config.update({k: v for k, v in raw.items() if v is not None})
|
||||
except Exception:
|
||||
logger.debug("Failed to parse %s", config_path, exc_info=True)
|
||||
|
||||
# Keep raw container_tag — template variables like {identity} are resolved
|
||||
# in initialize(), and _sanitize_tag runs AFTER resolution.
|
||||
raw_tag = str(config.get("container_tag", _DEFAULT_CONTAINER_TAG)).strip()
|
||||
config["container_tag"] = raw_tag if raw_tag else _DEFAULT_CONTAINER_TAG
|
||||
config["auto_recall"] = _as_bool(config.get("auto_recall"), True)
|
||||
config["auto_capture"] = _as_bool(config.get("auto_capture"), True)
|
||||
try:
|
||||
config["max_recall_results"] = max(1, min(20, int(config.get("max_recall_results", _DEFAULT_MAX_RECALL_RESULTS))))
|
||||
except Exception:
|
||||
config["max_recall_results"] = _DEFAULT_MAX_RECALL_RESULTS
|
||||
try:
|
||||
config["profile_frequency"] = max(1, min(500, int(config.get("profile_frequency", _DEFAULT_PROFILE_FREQUENCY))))
|
||||
except Exception:
|
||||
config["profile_frequency"] = _DEFAULT_PROFILE_FREQUENCY
|
||||
config["capture_mode"] = "everything" if config.get("capture_mode") == "everything" else "all"
|
||||
raw_search_mode = str(config.get("search_mode", _DEFAULT_SEARCH_MODE)).strip().lower()
|
||||
config["search_mode"] = raw_search_mode if raw_search_mode in _VALID_SEARCH_MODES else _DEFAULT_SEARCH_MODE
|
||||
config["entity_context"] = _clamp_entity_context(str(config.get("entity_context", _DEFAULT_ENTITY_CONTEXT)))
|
||||
try:
|
||||
config["api_timeout"] = max(0.5, min(15.0, float(config.get("api_timeout", _DEFAULT_API_TIMEOUT))))
|
||||
except Exception:
|
||||
config["api_timeout"] = _DEFAULT_API_TIMEOUT
|
||||
|
||||
# Multi-container support
|
||||
config["enable_custom_container_tags"] = _as_bool(config.get("enable_custom_container_tags"), False)
|
||||
raw_containers = config.get("custom_containers", [])
|
||||
if isinstance(raw_containers, list):
|
||||
config["custom_containers"] = [_sanitize_tag(str(t)) for t in raw_containers if t]
|
||||
else:
|
||||
config["custom_containers"] = []
|
||||
config["custom_container_instructions"] = str(config.get("custom_container_instructions", "")).strip()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _save_supermemory_config(values: dict, hermes_home: str) -> None:
|
||||
config_path = Path(hermes_home) / "supermemory.json"
|
||||
existing = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
existing = raw
|
||||
except Exception:
|
||||
existing = {}
|
||||
existing.update(values)
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(config_path, existing, mode=0o600, sort_keys=True)
|
||||
|
||||
|
||||
def _detect_category(text: str) -> str:
|
||||
lowered = text.lower()
|
||||
if re.search(r"prefer|like|love|hate|want", lowered):
|
||||
return "preference"
|
||||
if re.search(r"decided|will use|going with", lowered):
|
||||
return "decision"
|
||||
if re.search(r"\bis\b|\bare\b|\bhas\b|\bhave\b", lowered):
|
||||
return "fact"
|
||||
return "other"
|
||||
|
||||
|
||||
def _format_relative_time(iso_timestamp: str) -> str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
seconds = (now - dt).total_seconds()
|
||||
if seconds < 1800:
|
||||
return "just now"
|
||||
if seconds < 3600:
|
||||
return f"{int(seconds / 60)}m ago"
|
||||
if seconds < 86400:
|
||||
return f"{int(seconds / 3600)}h ago"
|
||||
if seconds < 604800:
|
||||
return f"{int(seconds / 86400)}d ago"
|
||||
if dt.year == now.year:
|
||||
return dt.strftime("%d %b")
|
||||
return dt.strftime("%d %b %Y")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _deduplicate_recall(static_facts: list, dynamic_facts: list, search_results: list) -> tuple[list, list, list]:
|
||||
seen = set()
|
||||
out_static, out_dynamic, out_search = [], [], []
|
||||
for fact in static_facts or []:
|
||||
if fact and fact not in seen:
|
||||
seen.add(fact)
|
||||
out_static.append(fact)
|
||||
for fact in dynamic_facts or []:
|
||||
if fact and fact not in seen:
|
||||
seen.add(fact)
|
||||
out_dynamic.append(fact)
|
||||
for item in search_results or []:
|
||||
memory = item.get("memory", "")
|
||||
if memory and memory not in seen:
|
||||
seen.add(memory)
|
||||
out_search.append(item)
|
||||
return out_static, out_dynamic, out_search
|
||||
|
||||
|
||||
def _format_prefetch_context(static_facts: list, dynamic_facts: list, search_results: list, max_results: int) -> str:
|
||||
statics, dynamics, search = _deduplicate_recall(static_facts, dynamic_facts, search_results)
|
||||
statics = statics[:max_results]
|
||||
dynamics = dynamics[:max_results]
|
||||
search = search[:max_results]
|
||||
if not statics and not dynamics and not search:
|
||||
return ""
|
||||
|
||||
sections = []
|
||||
if statics:
|
||||
sections.append("## User Profile (Persistent)\n" + "\n".join(f"- {item}" for item in statics))
|
||||
if dynamics:
|
||||
sections.append("## Recent Context\n" + "\n".join(f"- {item}" for item in dynamics))
|
||||
if search:
|
||||
lines = []
|
||||
for item in search:
|
||||
memory = item.get("memory", "")
|
||||
if not memory:
|
||||
continue
|
||||
similarity = item.get("similarity")
|
||||
updated = item.get("updated_at") or item.get("updatedAt") or ""
|
||||
prefix_bits = []
|
||||
rel = _format_relative_time(updated)
|
||||
if rel:
|
||||
prefix_bits.append(f"[{rel}]")
|
||||
if similarity is not None:
|
||||
try:
|
||||
prefix_bits.append(f"[{round(float(similarity) * 100)}%]")
|
||||
except Exception:
|
||||
pass
|
||||
prefix = " ".join(prefix_bits)
|
||||
lines.append(f"- {prefix} {memory}".strip())
|
||||
if lines:
|
||||
sections.append("## Relevant Memories\n" + "\n".join(lines))
|
||||
if not sections:
|
||||
return ""
|
||||
|
||||
intro = (
|
||||
"The following is background context from long-term memory. Use it silently when relevant. "
|
||||
"Do not force memories into the conversation."
|
||||
)
|
||||
body = "\n\n".join(sections)
|
||||
return f"<supermemory-context>\n{intro}\n\n{body}\n</supermemory-context>"
|
||||
|
||||
|
||||
def _clean_text_for_capture(text: str) -> str:
|
||||
text = _CONTEXT_STRIP_RE.sub("", text or "")
|
||||
text = _CONTAINERS_STRIP_RE.sub("", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _is_trivial_message(text: str) -> bool:
|
||||
return bool(_TRIVIAL_RE.match((text or "").strip()))
|
||||
|
||||
|
||||
class _SupermemoryClient:
|
||||
def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid"):
|
||||
from supermemory import Supermemory
|
||||
|
||||
self._api_key = api_key
|
||||
self._container_tag = container_tag
|
||||
self._search_mode = search_mode if search_mode in _VALID_SEARCH_MODES else _DEFAULT_SEARCH_MODE
|
||||
self._timeout = timeout
|
||||
self._client = Supermemory(
|
||||
api_key=api_key,
|
||||
timeout=timeout,
|
||||
max_retries=0,
|
||||
default_headers={"x-sm-source": "hermes"},
|
||||
)
|
||||
|
||||
def _merge_metadata(self, metadata: Optional[dict]) -> dict:
|
||||
# sm_source routes Hermes writes into the "Hermes" Space in the Supermemory
|
||||
# app so the user can filter / bulk-manage them per source agent. This is a
|
||||
# functional routing key for the user, not vendor telemetry.
|
||||
merged = {"sm_source": "hermes", **(metadata or {})}
|
||||
legacy_source = merged.pop("source", None)
|
||||
if legacy_source and "type" not in merged:
|
||||
merged["type"] = str(legacy_source)
|
||||
return merged
|
||||
|
||||
def add_memory(self, content: str, metadata: Optional[dict] = None, *,
|
||||
entity_context: str = "", container_tag: Optional[str] = None,
|
||||
custom_id: Optional[str] = None) -> dict:
|
||||
tag = container_tag or self._container_tag
|
||||
kwargs: dict[str, Any] = {
|
||||
"content": content.strip(),
|
||||
"container_tags": [tag],
|
||||
}
|
||||
if metadata:
|
||||
kwargs["metadata"] = self._merge_metadata(metadata)
|
||||
if entity_context:
|
||||
kwargs["entity_context"] = _clamp_entity_context(entity_context)
|
||||
if custom_id:
|
||||
kwargs["custom_id"] = custom_id
|
||||
result = self._client.documents.add(**kwargs)
|
||||
return {"id": getattr(result, "id", "")}
|
||||
|
||||
def search_memories(self, query: str, *, limit: int = 5,
|
||||
container_tag: Optional[str] = None,
|
||||
search_mode: Optional[str] = None) -> list[dict]:
|
||||
tag = container_tag or self._container_tag
|
||||
mode = search_mode or self._search_mode
|
||||
kwargs: dict[str, Any] = {"q": query, "container_tag": tag, "limit": limit}
|
||||
if mode in _VALID_SEARCH_MODES:
|
||||
kwargs["search_mode"] = mode
|
||||
response = self._client.search.memories(**kwargs)
|
||||
results = []
|
||||
for item in (getattr(response, "results", None) or []):
|
||||
results.append({
|
||||
"id": getattr(item, "id", ""),
|
||||
"memory": getattr(item, "memory", "") or "",
|
||||
"similarity": getattr(item, "similarity", None),
|
||||
"updated_at": getattr(item, "updated_at", None) or getattr(item, "updatedAt", None),
|
||||
"metadata": getattr(item, "metadata", None),
|
||||
})
|
||||
return results
|
||||
|
||||
def get_profile(self, query: Optional[str] = None, *,
|
||||
container_tag: Optional[str] = None) -> dict:
|
||||
tag = container_tag or self._container_tag
|
||||
kwargs: dict[str, Any] = {"container_tag": tag}
|
||||
if query:
|
||||
kwargs["q"] = query
|
||||
response = self._client.profile(**kwargs)
|
||||
profile_data = getattr(response, "profile", None)
|
||||
search_data = getattr(response, "search_results", None) or getattr(response, "searchResults", None)
|
||||
static = getattr(profile_data, "static", []) or [] if profile_data else []
|
||||
dynamic = getattr(profile_data, "dynamic", []) or [] if profile_data else []
|
||||
raw_results = getattr(search_data, "results", None) or search_data or []
|
||||
search_results = []
|
||||
if isinstance(raw_results, list):
|
||||
for item in raw_results:
|
||||
if isinstance(item, dict):
|
||||
search_results.append(item)
|
||||
else:
|
||||
search_results.append({
|
||||
"memory": getattr(item, "memory", ""),
|
||||
"updated_at": getattr(item, "updated_at", None) or getattr(item, "updatedAt", None),
|
||||
"similarity": getattr(item, "similarity", None),
|
||||
})
|
||||
return {"static": static, "dynamic": dynamic, "search_results": search_results}
|
||||
|
||||
def forget_memory(self, memory_id: str, *, container_tag: Optional[str] = None) -> None:
|
||||
tag = container_tag or self._container_tag
|
||||
self._client.memories.forget(container_tag=tag, id=memory_id)
|
||||
|
||||
def forget_by_query(self, query: str, *, container_tag: Optional[str] = None) -> dict:
|
||||
results = self.search_memories(query, limit=5, container_tag=container_tag)
|
||||
if not results:
|
||||
return {"success": False, "message": "No matching memory found to forget."}
|
||||
target = results[0]
|
||||
memory_id = target.get("id", "")
|
||||
if not memory_id:
|
||||
return {"success": False, "message": "Best matching memory has no id."}
|
||||
self.forget_memory(memory_id, container_tag=container_tag)
|
||||
preview = (target.get("memory") or "")[:100]
|
||||
return {"success": True, "message": f'Forgot: "{preview}"', "id": memory_id}
|
||||
|
||||
def ingest_conversation(self, session_id: str, messages: list[dict], metadata: dict | None = None) -> None:
|
||||
payload: dict = {
|
||||
"conversationId": session_id,
|
||||
"messages": messages,
|
||||
"containerTags": [self._container_tag],
|
||||
}
|
||||
if metadata:
|
||||
payload["metadata"] = self._merge_metadata(metadata)
|
||||
|
||||
req = urllib.request.Request(
|
||||
_CONVERSATIONS_URL,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"x-sm-source": "hermes",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=self._timeout + 3):
|
||||
return
|
||||
|
||||
|
||||
STORE_SCHEMA = {
|
||||
"name": "supermemory_store",
|
||||
"description": "Store an explicit memory for future recall.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "The memory content to store."},
|
||||
"metadata": {"type": "object", "description": "Optional metadata attached to the memory."},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
}
|
||||
|
||||
SEARCH_SCHEMA = {
|
||||
"name": "supermemory_search",
|
||||
"description": "Search long-term memory by semantic similarity.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "What to search for."},
|
||||
"limit": {"type": "integer", "description": "Maximum results to return, 1 to 20."},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
FORGET_SCHEMA = {
|
||||
"name": "supermemory_forget",
|
||||
"description": "Forget a memory by exact id or by best-match query.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Exact memory id to delete."},
|
||||
"query": {"type": "string", "description": "Query used to find the memory to forget."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
PROFILE_SCHEMA = {
|
||||
"name": "supermemory_profile",
|
||||
"description": "Retrieve persistent profile facts and recent memory context.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Optional query to focus the profile response."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class SupermemoryMemoryProvider(MemoryProvider):
|
||||
def __init__(self):
|
||||
self._config = _default_config()
|
||||
self._api_key = ""
|
||||
self._client: Optional[_SupermemoryClient] = None
|
||||
self._container_tag = _DEFAULT_CONTAINER_TAG
|
||||
self._session_id = ""
|
||||
self._turn_count = 0
|
||||
self._prefetch_result = ""
|
||||
self._prefetch_lock = threading.Lock()
|
||||
self._prefetch_thread: Optional[threading.Thread] = None
|
||||
self._sync_thread: Optional[threading.Thread] = None
|
||||
self._write_thread: Optional[threading.Thread] = None
|
||||
self._auto_recall = True
|
||||
self._auto_capture = True
|
||||
self._max_recall_results = _DEFAULT_MAX_RECALL_RESULTS
|
||||
self._profile_frequency = _DEFAULT_PROFILE_FREQUENCY
|
||||
self._capture_mode = _DEFAULT_CAPTURE_MODE
|
||||
self._search_mode = _DEFAULT_SEARCH_MODE
|
||||
self._entity_context = _DEFAULT_ENTITY_CONTEXT
|
||||
self._api_timeout = _DEFAULT_API_TIMEOUT
|
||||
self._hermes_home = ""
|
||||
self._write_enabled = True
|
||||
self._active = False
|
||||
# Multi-container support
|
||||
self._enable_custom_containers = False
|
||||
self._custom_containers: List[str] = []
|
||||
self._custom_container_instructions = ""
|
||||
self._allowed_containers: List[str] = []
|
||||
self._session_turns: List[Dict[str, str]] = []
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "supermemory"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
api_key = os.environ.get("SUPERMEMORY_API_KEY", "")
|
||||
if not api_key:
|
||||
return False
|
||||
try:
|
||||
__import__("supermemory")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_config_schema(self):
|
||||
# Only prompt for the API key during `hermes memory setup`.
|
||||
# All other options are documented for $HERMES_HOME/supermemory.json
|
||||
# or the SUPERMEMORY_CONTAINER_TAG env var.
|
||||
return [
|
||||
{"key": "api_key", "description": "Supermemory API key", "secret": True, "required": True, "env_var": "SUPERMEMORY_API_KEY", "url": "https://supermemory.ai"},
|
||||
]
|
||||
|
||||
def save_config(self, values, hermes_home):
|
||||
sanitized = dict(values or {})
|
||||
if "container_tag" in sanitized:
|
||||
sanitized["container_tag"] = _sanitize_tag(str(sanitized["container_tag"]))
|
||||
if "entity_context" in sanitized:
|
||||
sanitized["entity_context"] = _clamp_entity_context(str(sanitized["entity_context"]))
|
||||
_save_supermemory_config(sanitized, hermes_home)
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
from hermes_constants import get_hermes_home
|
||||
self._hermes_home = kwargs.get("hermes_home") or str(get_hermes_home())
|
||||
self._session_id = session_id
|
||||
self._turn_count = 0
|
||||
self._config = _load_supermemory_config(self._hermes_home)
|
||||
self._api_key = os.environ.get("SUPERMEMORY_API_KEY", "")
|
||||
|
||||
# Resolve container tag: env var > config > default.
|
||||
# Supports {identity} template for profile-scoped containers.
|
||||
env_tag = os.environ.get("SUPERMEMORY_CONTAINER_TAG", "").strip()
|
||||
raw_tag = env_tag or self._config["container_tag"]
|
||||
identity = kwargs.get("agent_identity", "default")
|
||||
self._container_tag = _sanitize_tag(raw_tag.replace("{identity}", identity))
|
||||
|
||||
self._auto_recall = self._config["auto_recall"]
|
||||
self._auto_capture = self._config["auto_capture"]
|
||||
self._max_recall_results = self._config["max_recall_results"]
|
||||
self._profile_frequency = self._config["profile_frequency"]
|
||||
self._capture_mode = self._config["capture_mode"]
|
||||
self._search_mode = self._config["search_mode"]
|
||||
self._entity_context = self._config["entity_context"]
|
||||
self._api_timeout = self._config["api_timeout"]
|
||||
self._enable_custom_containers = self._config["enable_custom_container_tags"]
|
||||
self._custom_containers = self._config["custom_containers"]
|
||||
self._custom_container_instructions = self._config["custom_container_instructions"]
|
||||
self._allowed_containers = [self._container_tag] + list(self._custom_containers)
|
||||
|
||||
self._session_turns = []
|
||||
|
||||
agent_context = kwargs.get("agent_context", "")
|
||||
self._write_enabled = agent_context not in {"cron", "flush", "subagent"}
|
||||
self._active = bool(self._api_key)
|
||||
self._client = None
|
||||
if self._active:
|
||||
try:
|
||||
self._client = _SupermemoryClient(
|
||||
api_key=self._api_key,
|
||||
timeout=self._api_timeout,
|
||||
container_tag=self._container_tag,
|
||||
search_mode=self._search_mode,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Supermemory initialization failed", exc_info=True)
|
||||
self._active = False
|
||||
self._client = None
|
||||
|
||||
def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None:
|
||||
self._turn_count = max(turn_number, 0)
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
if not self._active:
|
||||
return ""
|
||||
lines = [
|
||||
"# Supermemory",
|
||||
f"Active. Container: {self._container_tag}.",
|
||||
"Use supermemory-search, supermemory-save, supermemory-forget, and supermemory-profile (aliases: supermemory_search, supermemory_store, supermemory_forget, supermemory_profile).",
|
||||
]
|
||||
if self._enable_custom_containers and self._custom_containers:
|
||||
tags_str = ", ".join(self._allowed_containers)
|
||||
lines.append(f"\nMulti-container mode enabled. Available containers: {tags_str}.")
|
||||
lines.append("Pass an optional container_tag to supermemory_search, supermemory_store, supermemory_forget, and supermemory_profile to target a specific container.")
|
||||
if self._custom_container_instructions:
|
||||
lines.append(f"\n{self._custom_container_instructions}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||
if not self._active or not self._auto_recall or not self._client or not query.strip():
|
||||
return ""
|
||||
try:
|
||||
profile = self._client.get_profile(query=query[:200])
|
||||
include_profile = self._turn_count <= 1 or (self._turn_count % self._profile_frequency == 0)
|
||||
context = _format_prefetch_context(
|
||||
static_facts=profile["static"] if include_profile else [],
|
||||
dynamic_facts=profile["dynamic"] if include_profile else [],
|
||||
search_results=profile["search_results"],
|
||||
max_results=self._max_recall_results,
|
||||
)
|
||||
return context
|
||||
except Exception:
|
||||
logger.debug("Supermemory prefetch failed", exc_info=True)
|
||||
return ""
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
if not self._active or not self._auto_capture or not self._write_enabled or not self._client:
|
||||
return
|
||||
|
||||
clean_user = _clean_text_for_capture(user_content)
|
||||
clean_assistant = _clean_text_for_capture(assistant_content)
|
||||
if not clean_user and not clean_assistant:
|
||||
return
|
||||
|
||||
# Buffer every turn for the single full-session document written at end/switch/shutdown
|
||||
self._session_turns.append({"user": clean_user, "assistant": clean_assistant})
|
||||
|
||||
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
|
||||
if not self._active or not self._write_enabled or not self._client or not self._session_id:
|
||||
return
|
||||
cleaned = []
|
||||
for message in messages or []:
|
||||
role = message.get("role")
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
content = _clean_text_for_capture(str(message.get("content", "")))
|
||||
if content:
|
||||
cleaned.append({"role": role, "content": content})
|
||||
if not cleaned:
|
||||
return
|
||||
if len(cleaned) == 1 and len(cleaned[0].get("content", "")) < 20:
|
||||
return
|
||||
try:
|
||||
self._client.ingest_conversation(
|
||||
self._session_id,
|
||||
cleaned,
|
||||
metadata={
|
||||
"type": "full_session",
|
||||
"session_id": self._session_id,
|
||||
"message_count": len(cleaned),
|
||||
},
|
||||
)
|
||||
except urllib.error.HTTPError:
|
||||
logger.warning("Supermemory session ingest failed", exc_info=True)
|
||||
except Exception:
|
||||
logger.warning("Supermemory session ingest failed", exc_info=True)
|
||||
|
||||
# Clear buffer so shutdown() doesn't duplicate on normal exit
|
||||
self._session_turns = []
|
||||
|
||||
def on_session_switch(
|
||||
self,
|
||||
new_session_id: str,
|
||||
*,
|
||||
parent_session_id: str = "",
|
||||
reset: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Flush any buffered turns from the old session as one document, then reset for the new session."""
|
||||
if not self._active or not self._write_enabled or not self._client:
|
||||
self._session_id = str(new_session_id or "").strip() or self._session_id
|
||||
self._session_turns = []
|
||||
return
|
||||
|
||||
old_session_id = self._session_id
|
||||
old_turns = list(self._session_turns)
|
||||
|
||||
# Flush previous session via conversations ingest (with metadata)
|
||||
if old_turns and old_session_id:
|
||||
messages: list[dict] = []
|
||||
for turn in old_turns:
|
||||
if turn.get("user"):
|
||||
messages.append({"role": "user", "content": turn["user"]})
|
||||
if turn.get("assistant"):
|
||||
messages.append({"role": "assistant", "content": turn["assistant"]})
|
||||
|
||||
try:
|
||||
self._client.ingest_conversation(
|
||||
old_session_id,
|
||||
messages,
|
||||
metadata={
|
||||
"type": "full_session",
|
||||
"session_id": old_session_id,
|
||||
"message_count": len(old_turns) * 2,
|
||||
"partial": not reset,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Supermemory session-switch ingest failed", exc_info=True)
|
||||
|
||||
# Reset for new session
|
||||
self._session_id = str(new_session_id or "").strip() or old_session_id
|
||||
self._session_turns = []
|
||||
self._turn_count = 0
|
||||
|
||||
def on_memory_write(self, action: str, target: str, content: str) -> None:
|
||||
if not self._active or not self._write_enabled or not self._client:
|
||||
return
|
||||
if action != "add" or not (content or "").strip():
|
||||
return
|
||||
|
||||
def _run():
|
||||
try:
|
||||
self._client.add_memory(
|
||||
content.strip(),
|
||||
metadata={"target": target, "type": "explicit_memory"},
|
||||
entity_context=self._entity_context,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Supermemory on_memory_write failed", exc_info=True)
|
||||
|
||||
if self._write_thread and self._write_thread.is_alive():
|
||||
self._write_thread.join(timeout=2.0)
|
||||
self._write_thread = None
|
||||
self._write_thread = threading.Thread(target=_run, daemon=False, name="supermemory-memory-write")
|
||||
self._write_thread.start()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
# Emergency fallback (crashes only). Buffer is cleared on normal on_session_end().
|
||||
if self._active and self._write_enabled and self._client and self._session_turns and self._session_id:
|
||||
logger.warning("Supermemory: Saving session via shutdown (session=%s, turns=%d)", self._session_id, len(self._session_turns))
|
||||
|
||||
messages: list[dict] = []
|
||||
for turn in self._session_turns:
|
||||
if turn.get("user"):
|
||||
messages.append({"role": "user", "content": turn["user"]})
|
||||
if turn.get("assistant"):
|
||||
messages.append({"role": "assistant", "content": turn["assistant"]})
|
||||
|
||||
try:
|
||||
self._client.ingest_conversation(
|
||||
self._session_id,
|
||||
messages,
|
||||
metadata={
|
||||
"type": "full_session",
|
||||
"session_id": self._session_id,
|
||||
"message_count": len(self._session_turns) * 2,
|
||||
"partial": True,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Supermemory shutdown ingest failed", exc_info=True)
|
||||
|
||||
for attr_name in ("_prefetch_thread", "_sync_thread", "_write_thread"):
|
||||
thread = getattr(self, attr_name, None)
|
||||
if thread and thread.is_alive():
|
||||
thread.join(timeout=5.0)
|
||||
setattr(self, attr_name, None)
|
||||
|
||||
def _resolve_tool_container_tag(self, args: dict) -> Optional[str]:
|
||||
"""Validate and resolve container_tag from tool call args.
|
||||
|
||||
Returns None (use primary) if multi-container is disabled or no tag provided.
|
||||
Returns the validated tag if it's in the allowed list.
|
||||
Raises ValueError if the tag is not whitelisted.
|
||||
"""
|
||||
if not self._enable_custom_containers:
|
||||
return None
|
||||
tag = str(args.get("container_tag") or "").strip()
|
||||
if not tag:
|
||||
return None
|
||||
sanitized = _sanitize_tag(tag)
|
||||
if sanitized not in self._allowed_containers:
|
||||
raise ValueError(
|
||||
f"Container tag '{sanitized}' is not allowed. "
|
||||
f"Allowed: {', '.join(self._allowed_containers)}"
|
||||
)
|
||||
return sanitized
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
def with_kebab_aliases(schemas: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
aliases = {
|
||||
"supermemory_store": "supermemory-save",
|
||||
"supermemory_search": "supermemory-search",
|
||||
"supermemory_forget": "supermemory-forget",
|
||||
"supermemory_profile": "supermemory-profile",
|
||||
}
|
||||
expanded = list(schemas)
|
||||
for schema in schemas:
|
||||
kebab = aliases.get(schema.get("name", ""))
|
||||
if not kebab:
|
||||
continue
|
||||
copy = json.loads(json.dumps(schema))
|
||||
copy["name"] = kebab
|
||||
expanded.append(copy)
|
||||
return expanded
|
||||
|
||||
if not self._enable_custom_containers:
|
||||
return with_kebab_aliases([STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA])
|
||||
|
||||
# When multi-container is enabled, add optional container_tag to relevant tools
|
||||
container_param = {
|
||||
"type": "string",
|
||||
"description": f"Optional container tag. Allowed: {', '.join(self._allowed_containers)}. Defaults to primary ({self._container_tag}).",
|
||||
}
|
||||
schemas = []
|
||||
for base in [STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA]:
|
||||
schema = json.loads(json.dumps(base)) # deep copy
|
||||
schema["parameters"]["properties"]["container_tag"] = container_param
|
||||
schemas.append(schema)
|
||||
return with_kebab_aliases(schemas)
|
||||
|
||||
def _tool_store(self, args: dict) -> str:
|
||||
content = str(args.get("content") or "").strip()
|
||||
if not content:
|
||||
return tool_error("content is required")
|
||||
try:
|
||||
tag = self._resolve_tool_container_tag(args)
|
||||
except ValueError as exc:
|
||||
return tool_error(str(exc))
|
||||
metadata = args.get("metadata") or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
metadata.setdefault("type", _detect_category(content))
|
||||
metadata.pop("source", None)
|
||||
try:
|
||||
result = self._client.add_memory(content, metadata=metadata, entity_context=self._entity_context, container_tag=tag)
|
||||
preview = content[:80] + ("..." if len(content) > 80 else "")
|
||||
resp: dict[str, Any] = {"saved": True, "id": result.get("id", ""), "preview": preview}
|
||||
if tag:
|
||||
resp["container_tag"] = tag
|
||||
return json.dumps(resp)
|
||||
except Exception as exc:
|
||||
return tool_error(f"Failed to store memory: {exc}")
|
||||
|
||||
def _tool_search(self, args: dict) -> str:
|
||||
query = str(args.get("query") or "").strip()
|
||||
if not query:
|
||||
return tool_error("query is required")
|
||||
try:
|
||||
tag = self._resolve_tool_container_tag(args)
|
||||
except ValueError as exc:
|
||||
return tool_error(str(exc))
|
||||
try:
|
||||
limit = max(1, min(20, int(args.get("limit", 5) or 5)))
|
||||
except Exception:
|
||||
limit = 5
|
||||
try:
|
||||
results = self._client.search_memories(query, limit=limit, container_tag=tag)
|
||||
formatted = []
|
||||
for item in results:
|
||||
entry: dict[str, Any] = {"id": item.get("id", ""), "content": item.get("memory", "")}
|
||||
if item.get("similarity") is not None:
|
||||
try:
|
||||
entry["similarity"] = round(float(item["similarity"]) * 100)
|
||||
except Exception:
|
||||
pass
|
||||
formatted.append(entry)
|
||||
resp: dict[str, Any] = {"results": formatted, "count": len(formatted)}
|
||||
if tag:
|
||||
resp["container_tag"] = tag
|
||||
return json.dumps(resp)
|
||||
except Exception as exc:
|
||||
return tool_error(f"Search failed: {exc}")
|
||||
|
||||
def _tool_forget(self, args: dict) -> str:
|
||||
memory_id = str(args.get("id") or "").strip()
|
||||
query = str(args.get("query") or "").strip()
|
||||
if not memory_id and not query:
|
||||
return tool_error("Provide either id or query")
|
||||
try:
|
||||
tag = self._resolve_tool_container_tag(args)
|
||||
except ValueError as exc:
|
||||
return tool_error(str(exc))
|
||||
try:
|
||||
if memory_id:
|
||||
self._client.forget_memory(memory_id, container_tag=tag)
|
||||
return json.dumps({"forgotten": True, "id": memory_id})
|
||||
return json.dumps(self._client.forget_by_query(query, container_tag=tag))
|
||||
except Exception as exc:
|
||||
return tool_error(f"Forget failed: {exc}")
|
||||
|
||||
def _tool_profile(self, args: dict) -> str:
|
||||
query = str(args.get("query") or "").strip() or None
|
||||
try:
|
||||
tag = self._resolve_tool_container_tag(args)
|
||||
except ValueError as exc:
|
||||
return tool_error(str(exc))
|
||||
try:
|
||||
profile = self._client.get_profile(query=query, container_tag=tag)
|
||||
sections = []
|
||||
if profile["static"]:
|
||||
sections.append("## User Profile (Persistent)\n" + "\n".join(f"- {item}" for item in profile["static"]))
|
||||
if profile["dynamic"]:
|
||||
sections.append("## Recent Context\n" + "\n".join(f"- {item}" for item in profile["dynamic"]))
|
||||
resp: dict[str, Any] = {
|
||||
"profile": "\n\n".join(sections),
|
||||
"static_count": len(profile["static"]),
|
||||
"dynamic_count": len(profile["dynamic"]),
|
||||
}
|
||||
if tag:
|
||||
resp["container_tag"] = tag
|
||||
return json.dumps(resp)
|
||||
except Exception as exc:
|
||||
return tool_error(f"Profile failed: {exc}")
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
|
||||
if not self._active or not self._client:
|
||||
return tool_error("Supermemory is not configured")
|
||||
aliases = {
|
||||
"supermemory-save": "supermemory_store",
|
||||
"supermemory-search": "supermemory_search",
|
||||
"supermemory-forget": "supermemory_forget",
|
||||
"supermemory-profile": "supermemory_profile",
|
||||
}
|
||||
tool_name = aliases.get(tool_name, tool_name)
|
||||
if tool_name == "supermemory_store":
|
||||
return self._tool_store(args)
|
||||
if tool_name == "supermemory_search":
|
||||
return self._tool_search(args)
|
||||
if tool_name == "supermemory_forget":
|
||||
return self._tool_forget(args)
|
||||
if tool_name == "supermemory_profile":
|
||||
return self._tool_profile(args)
|
||||
return tool_error(f"Unknown tool: {tool_name}")
|
||||
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_memory_provider(SupermemoryMemoryProvider())
|
||||
@@ -0,0 +1,5 @@
|
||||
name: supermemory
|
||||
version: 1.0.1
|
||||
description: "Supermemory semantic long-term memory with profile recall, semantic search, explicit memory tools, and session ingest."
|
||||
pip_dependencies:
|
||||
- supermemory
|
||||
@@ -0,0 +1,70 @@
|
||||
# Model Provider Plugins
|
||||
|
||||
Each subdirectory is a self-contained provider profile plugin. The
|
||||
directory layout mirrors `plugins/platforms/`:
|
||||
|
||||
```
|
||||
plugins/model-providers/
|
||||
├── openrouter/
|
||||
│ ├── __init__.py # registers the ProviderProfile
|
||||
│ └── plugin.yaml # manifest: name, kind, version, description
|
||||
├── anthropic/
|
||||
│ ├── __init__.py
|
||||
│ └── plugin.yaml
|
||||
└── ...
|
||||
```
|
||||
|
||||
## How discovery works
|
||||
|
||||
`providers/__init__.py._discover_providers()` scans this directory (and
|
||||
`$HERMES_HOME/plugins/model-providers/`) the first time anything calls
|
||||
`get_provider_profile()` or `list_providers()`. Each `__init__.py` is
|
||||
imported and expected to call `providers.register_provider(profile)`.
|
||||
|
||||
User plugins at `$HERMES_HOME/plugins/model-providers/<name>/` override
|
||||
bundled plugins of the same name — last-writer-wins in
|
||||
`register_provider()`. Drop a file there to replace a built-in.
|
||||
|
||||
## Adding a new provider
|
||||
|
||||
1. Create `plugins/model-providers/<your_provider>/__init__.py`:
|
||||
|
||||
```python
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
my_provider = ProviderProfile(
|
||||
name="your-provider",
|
||||
aliases=("alias1", "alias2"),
|
||||
display_name="Your Provider",
|
||||
description="One-line description shown in the setup picker",
|
||||
signup_url="https://your-provider.example.com/keys",
|
||||
env_vars=("YOUR_PROVIDER_API_KEY", "YOUR_PROVIDER_BASE_URL"),
|
||||
base_url="https://api.your-provider.example.com/v1",
|
||||
default_aux_model="your-cheap-model",
|
||||
)
|
||||
|
||||
register_provider(my_provider)
|
||||
```
|
||||
|
||||
2. Create `plugins/model-providers/<your_provider>/plugin.yaml`:
|
||||
|
||||
```yaml
|
||||
name: your-provider-profile
|
||||
kind: model-provider
|
||||
version: 1.0.0
|
||||
description: Short sentence about the provider
|
||||
author: Your Name
|
||||
```
|
||||
|
||||
Nothing else needs to change. `auth.py`, `config.py`, `models.py`,
|
||||
`doctor.py`, `model_metadata.py`, `runtime_provider.py`, and the
|
||||
chat_completions transport all auto-wire from the registry.
|
||||
|
||||
## Non-trivial profiles
|
||||
|
||||
Override the `ProviderProfile` hooks in a subclass for per-provider
|
||||
quirks — see `plugins/model-providers/openrouter/__init__.py` for
|
||||
`build_extra_body` and `build_api_kwargs_extras` examples, and
|
||||
`plugins/model-providers/gemini/__init__.py` for `thinking_config`
|
||||
translation.
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Alibaba Cloud Coding Plan provider profile.
|
||||
|
||||
Separate from the standard `alibaba` profile because it hits a different
|
||||
endpoint (coding-intl.dashscope.aliyuncs.com) with a dedicated API key tier.
|
||||
"""
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
alibaba_coding_plan = ProviderProfile(
|
||||
name="alibaba-coding-plan",
|
||||
aliases=("alibaba_coding", "alibaba-coding", "dashscope-coding"),
|
||||
display_name="Alibaba Cloud (Coding Plan)",
|
||||
description="Alibaba Cloud Coding Plan (Dedicated coding tier)",
|
||||
signup_url="https://help.aliyun.com/zh/model-studio/",
|
||||
env_vars=("ALIBABA_CODING_PLAN_API_KEY", "DASHSCOPE_API_KEY", "ALIBABA_CODING_PLAN_BASE_URL"),
|
||||
base_url="https://coding-intl.dashscope.aliyuncs.com/v1",
|
||||
auth_type="api_key",
|
||||
)
|
||||
|
||||
register_provider(alibaba_coding_plan)
|
||||
@@ -0,0 +1,5 @@
|
||||
name: alibaba-coding-plan-provider
|
||||
kind: model-provider
|
||||
version: 1.0.0
|
||||
description: Alibaba Cloud Coding Plan
|
||||
author: Nous Research
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Alibaba Cloud DashScope provider profile."""
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
alibaba = ProviderProfile(
|
||||
name="alibaba",
|
||||
aliases=("dashscope", "alibaba-cloud", "qwen-dashscope"),
|
||||
env_vars=("DASHSCOPE_API_KEY",),
|
||||
base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
)
|
||||
|
||||
register_provider(alibaba)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user