Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
854206e59e | ||
|
|
dd83173621 | ||
|
|
c65c1ddf21 | ||
|
|
1970bcf5a5 | ||
|
|
832ecde4b0 | ||
|
|
be184aa5fa | ||
|
|
63b7b6d5bd | ||
|
|
123f8d0fed | ||
|
|
a24c6e191f | ||
|
|
7206eed319 | ||
|
|
1619c0e503 | ||
|
|
e27c819de3 | ||
|
|
1c78f6627a | ||
|
|
8ef2ae6502 | ||
|
|
0146cb2bd2 | ||
|
|
da7d09c3b6 | ||
|
|
af8d43dbbb | ||
|
|
27fc6c1086 | ||
|
|
45806629c5 | ||
|
|
4093201c47 | ||
|
|
9f610aa8f3 | ||
|
|
e1c5e741ad |
@@ -69,4 +69,3 @@ mini-swe-agent/
|
||||
.nix-stamps/
|
||||
result
|
||||
website/static/api/skills-index.json
|
||||
models-dev-upstream/
|
||||
|
||||
+2
-6
@@ -30,22 +30,18 @@ WORKDIR /opt/hermes
|
||||
# unless the lockfiles themselves change.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY web/package.json web/package-lock.json web/
|
||||
COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/
|
||||
COPY ui-tui/packages/hermes-ink/package.json ui-tui/packages/hermes-ink/package-lock.json ui-tui/packages/hermes-ink/
|
||||
|
||||
RUN npm install --prefer-offline --no-audit && \
|
||||
npx playwright install --with-deps chromium --only-shell && \
|
||||
(cd web && npm install --prefer-offline --no-audit) && \
|
||||
(cd ui-tui && npm install --prefer-offline --no-audit) && \
|
||||
npm cache clean --force
|
||||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
COPY --chown=hermes:hermes . .
|
||||
|
||||
# Build browser dashboard and terminal UI assets.
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
# Build web dashboard (Vite outputs to hermes_cli/web_dist/)
|
||||
RUN cd web && npm run build
|
||||
|
||||
# ---------- Permissions ----------
|
||||
# Make install dir world-readable so any HERMES_UID can read it at runtime.
|
||||
|
||||
@@ -202,33 +202,19 @@ def _forbids_sampling_params(model: str) -> bool:
|
||||
|
||||
|
||||
# Beta headers for enhanced features (sent with ALL auth types).
|
||||
# As of Opus 4.7 (2026-04-16), the first two are GA on Claude 4.6+ — the
|
||||
# As of Opus 4.7 (2026-04-16), both of these are GA on Claude 4.6+ — the
|
||||
# beta headers are still accepted (harmless no-op) but not required. Kept
|
||||
# here so older Claude (4.5, 4.1) + third-party Anthropic-compat endpoints
|
||||
# that still gate on the headers continue to get the enhanced features.
|
||||
#
|
||||
# ``context-1m-2025-08-07`` unlocks the 1M context window on Claude Opus 4.6/4.7
|
||||
# and Sonnet 4.6 when served via AWS Bedrock or Azure AI Foundry. 1M is GA on
|
||||
# native Anthropic (api.anthropic.com) for Opus 4.6+, but Bedrock/Azure still
|
||||
# gate it behind this beta header as of 2026-04 — without it Bedrock caps Opus
|
||||
# at 200K even though model_metadata.py advertises 1M. The header is a harmless
|
||||
# no-op on endpoints where 1M is GA.
|
||||
#
|
||||
# Migration guide: remove these if you no longer support ≤4.5 models or once
|
||||
# Bedrock/Azure promote 1M to GA.
|
||||
# Migration guide: remove these if you no longer support ≤4.5 models.
|
||||
_COMMON_BETAS = [
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
"context-1m-2025-08-07",
|
||||
]
|
||||
# MiniMax's Anthropic-compatible endpoints fail tool-use requests when
|
||||
# the fine-grained tool streaming beta is present. Omit it so tool calls
|
||||
# fall back to the provider's default response path.
|
||||
_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"
|
||||
# 1M context beta — see comment on _COMMON_BETAS above. Stripped for
|
||||
# Bearer-auth (MiniMax) endpoints since they host their own models and
|
||||
# unknown Anthropic beta headers risk request rejection.
|
||||
_CONTEXT_1M_BETA = "context-1m-2025-08-07"
|
||||
|
||||
# Fast mode beta — enables the ``speed: "fast"`` request parameter for
|
||||
# significantly higher output token throughput on Opus 4.6 (~2.5x).
|
||||
@@ -371,14 +357,9 @@ def _common_betas_for_base_url(base_url: str | None) -> list[str]:
|
||||
that include Anthropic's ``fine-grained-tool-streaming`` beta — every
|
||||
tool-use message triggers a connection error. Strip that beta for
|
||||
Bearer-auth endpoints while keeping all other betas intact.
|
||||
|
||||
The ``context-1m-2025-08-07`` beta is also stripped for Bearer-auth
|
||||
endpoints — MiniMax hosts its own models, not Claude, so the header is
|
||||
irrelevant at best and risks request rejection at worst.
|
||||
"""
|
||||
if _requires_bearer_auth(base_url):
|
||||
_stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA}
|
||||
return [b for b in _COMMON_BETAS if b not in _stripped]
|
||||
return [b for b in _COMMON_BETAS if b != _TOOL_STREAMING_BETA]
|
||||
return _COMMON_BETAS
|
||||
|
||||
|
||||
@@ -475,13 +456,6 @@ def build_anthropic_bedrock_client(region: str):
|
||||
Claude feature parity: prompt caching, thinking budgets, adaptive
|
||||
thinking, fast mode — features not available via the Converse API.
|
||||
|
||||
Attaches the common Anthropic beta headers as client-level defaults so
|
||||
that Bedrock-hosted Claude models get the same enhanced features as
|
||||
native Anthropic. The ``context-1m-2025-08-07`` beta in particular
|
||||
unlocks the 1M context window for Opus 4.6/4.7 on Bedrock — without
|
||||
it, Bedrock caps these models at 200K even though the Anthropic API
|
||||
serves them with 1M natively.
|
||||
|
||||
Auth uses the boto3 default credential chain (IAM roles, SSO, env vars).
|
||||
"""
|
||||
if _anthropic_sdk is None:
|
||||
@@ -499,7 +473,6 @@ def build_anthropic_bedrock_client(region: str):
|
||||
return _anthropic_sdk.AnthropicBedrock(
|
||||
aws_region=region,
|
||||
timeout=Timeout(timeout=900.0, connect=10.0),
|
||||
default_headers={"anthropic-beta": ",".join(_COMMON_BETAS)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
+33
-77
@@ -82,8 +82,6 @@ _PROVIDER_ALIASES = {
|
||||
"moonshot": "kimi-coding",
|
||||
"kimi-cn": "kimi-coding-cn",
|
||||
"moonshot-cn": "kimi-coding-cn",
|
||||
"gmi-cloud": "gmi",
|
||||
"gmicloud": "gmi",
|
||||
"minimax-china": "minimax-cn",
|
||||
"minimax_cn": "minimax-cn",
|
||||
"claude": "anthropic",
|
||||
@@ -157,7 +155,6 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
|
||||
"kimi-coding": "kimi-k2-turbo-preview",
|
||||
"stepfun": "step-3.5-flash",
|
||||
"kimi-coding-cn": "kimi-k2-turbo-preview",
|
||||
"gmi": "google/gemini-3.1-flash-lite-preview",
|
||||
"minimax": "MiniMax-M2.7",
|
||||
"minimax-cn": "MiniMax-M2.7",
|
||||
"anthropic": "claude-haiku-4-5-20251001",
|
||||
@@ -1620,14 +1617,8 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
# below — never look up auth env vars ad-hoc.
|
||||
|
||||
|
||||
def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
||||
"""Convert a sync client to its async counterpart, preserving Codex routing.
|
||||
|
||||
When ``is_vision=True`` and the underlying base URL is Copilot, the
|
||||
resulting async client carries the ``Copilot-Vision-Request: true``
|
||||
header so the request is routed to Copilot's vision-capable
|
||||
infrastructure (otherwise vision payloads silently time out).
|
||||
"""
|
||||
def _to_async_client(sync_client, model: str):
|
||||
"""Convert a sync client to its async counterpart, preserving Codex routing."""
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
if isinstance(sync_client, CodexAuxiliaryClient):
|
||||
@@ -1656,11 +1647,9 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
|
||||
if base_url_host_matches(sync_base_url, "openrouter.ai"):
|
||||
async_kwargs["default_headers"] = dict(_OR_HEADERS)
|
||||
elif base_url_host_matches(sync_base_url, "api.githubcopilot.com"):
|
||||
from hermes_cli.copilot_auth import copilot_request_headers
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
|
||||
async_kwargs["default_headers"] = copilot_request_headers(
|
||||
is_agent_turn=True, is_vision=is_vision
|
||||
)
|
||||
async_kwargs["default_headers"] = copilot_default_headers()
|
||||
elif base_url_host_matches(sync_base_url, "api.kimi.com"):
|
||||
async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
|
||||
return AsyncOpenAI(**async_kwargs), model
|
||||
@@ -1687,7 +1676,6 @@ def resolve_provider_client(
|
||||
explicit_api_key: str = None,
|
||||
api_mode: str = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Central router: given a provider name and optional model, return a
|
||||
configured client with the correct auth, base URL, and API format.
|
||||
@@ -1771,7 +1759,7 @@ def resolve_provider_client(
|
||||
"auxiliary provider (using %r instead)", model, resolved)
|
||||
model = None
|
||||
final_model = model or resolved
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
# ── OpenRouter ───────────────────────────────────────────────────
|
||||
@@ -1784,7 +1772,7 @@ def resolve_provider_client(
|
||||
)
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or default, provider)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
# ── Nous Portal (OAuth) ──────────────────────────────────────────
|
||||
@@ -1801,7 +1789,7 @@ def resolve_provider_client(
|
||||
"but Nous Portal not configured (run: hermes auth)")
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or default, provider)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
# ── OpenAI Codex (OAuth → Responses API) ─────────────────────────
|
||||
@@ -1828,7 +1816,7 @@ def resolve_provider_client(
|
||||
"but no Codex OAuth token found (run: hermes model)")
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or default, provider)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
# ── Custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY) ───────────
|
||||
@@ -1857,13 +1845,11 @@ def resolve_provider_client(
|
||||
if base_url_host_matches(custom_base, "api.kimi.com"):
|
||||
extra["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
|
||||
elif base_url_host_matches(custom_base, "api.githubcopilot.com"):
|
||||
from hermes_cli.copilot_auth import copilot_request_headers
|
||||
extra["default_headers"] = copilot_request_headers(
|
||||
is_agent_turn=True, is_vision=is_vision
|
||||
)
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
extra["default_headers"] = copilot_default_headers()
|
||||
client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra)
|
||||
client = _wrap_if_needed(client, final_model, custom_base)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
# Try custom first, then codex, then API-key providers
|
||||
for try_fn in (_try_custom_endpoint, _try_codex,
|
||||
@@ -1873,7 +1859,7 @@ def resolve_provider_client(
|
||||
final_model = _normalize_resolved_model(model or default, provider)
|
||||
_cbase = str(getattr(client, "base_url", "") or "")
|
||||
client = _wrap_if_needed(client, final_model, _cbase)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
logger.warning("resolve_provider_client: custom/main requested "
|
||||
"but no endpoint credentials found")
|
||||
@@ -1918,7 +1904,7 @@ def resolve_provider_client(
|
||||
provider,
|
||||
)
|
||||
client = OpenAI(api_key=custom_key, base_url=_clean_base2, **_extra2)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
sync_anthropic = AnthropicAuxiliaryClient(
|
||||
real_client, final_model, custom_key, custom_base, is_oauth=False,
|
||||
@@ -1937,7 +1923,7 @@ def resolve_provider_client(
|
||||
client = CodexAuxiliaryClient(client, final_model)
|
||||
else:
|
||||
client = _wrap_if_needed(client, final_model, custom_base)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
logger.warning(
|
||||
"resolve_provider_client: named custom provider %r has no base_url",
|
||||
@@ -1969,7 +1955,7 @@ def resolve_provider_client(
|
||||
logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found")
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or default_model, provider)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model))
|
||||
return (_to_async_client(client, final_model) if async_mode else (client, final_model))
|
||||
|
||||
creds = resolve_api_key_provider_credentials(provider)
|
||||
api_key = str(creds.get("api_key", "")).strip()
|
||||
@@ -1995,7 +1981,7 @@ def resolve_provider_client(
|
||||
if is_native_gemini_base_url(base_url):
|
||||
client = GeminiNativeClient(api_key=api_key, base_url=base_url)
|
||||
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
# Provider-specific headers
|
||||
@@ -2003,11 +1989,9 @@ def resolve_provider_client(
|
||||
if base_url_host_matches(base_url, "api.kimi.com"):
|
||||
headers["User-Agent"] = "claude-code/0.1.0"
|
||||
elif base_url_host_matches(base_url, "api.githubcopilot.com"):
|
||||
from hermes_cli.copilot_auth import copilot_request_headers
|
||||
from hermes_cli.models import copilot_default_headers
|
||||
|
||||
headers.update(copilot_request_headers(
|
||||
is_agent_turn=True, is_vision=is_vision
|
||||
))
|
||||
headers.update(copilot_default_headers())
|
||||
client = OpenAI(api_key=api_key, base_url=base_url,
|
||||
**({"default_headers": headers} if headers else {}))
|
||||
|
||||
@@ -2033,7 +2017,7 @@ def resolve_provider_client(
|
||||
client = _wrap_if_needed(client, final_model, base_url)
|
||||
|
||||
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
if pconfig.auth_type == "external_process":
|
||||
@@ -2065,7 +2049,7 @@ def resolve_provider_client(
|
||||
args=args,
|
||||
)
|
||||
logger.debug("resolve_provider_client: %s (%s)", provider, final_model)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
logger.warning("resolve_provider_client: external-process provider %s not "
|
||||
"directly supported", provider)
|
||||
@@ -2101,7 +2085,7 @@ def resolve_provider_client(
|
||||
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
|
||||
)
|
||||
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
|
||||
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
elif pconfig.auth_type in ("oauth_device_code", "oauth_external"):
|
||||
@@ -2176,13 +2160,8 @@ def _normalize_vision_provider(provider: Optional[str]) -> str:
|
||||
return _normalize_aux_provider(provider)
|
||||
|
||||
|
||||
def _resolve_strict_vision_backend(
|
||||
provider: str,
|
||||
model: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
def _resolve_strict_vision_backend(provider: str) -> Tuple[Optional[Any], Optional[str]]:
|
||||
provider = _normalize_vision_provider(provider)
|
||||
if provider == "copilot":
|
||||
return resolve_provider_client("copilot", model, is_vision=True)
|
||||
if provider == "openrouter":
|
||||
return _try_openrouter()
|
||||
if provider == "nous":
|
||||
@@ -2250,7 +2229,7 @@ def resolve_vision_provider_client(
|
||||
return resolved_provider, None, None
|
||||
final_model = resolved_model or default_model
|
||||
if async_mode:
|
||||
async_client, async_model = _to_async_client(sync_client, final_model, is_vision=True)
|
||||
async_client, async_model = _to_async_client(sync_client, final_model)
|
||||
return resolved_provider, async_client, async_model
|
||||
return resolved_provider, sync_client, final_model
|
||||
|
||||
@@ -2282,11 +2261,8 @@ def resolve_vision_provider_client(
|
||||
main_provider = _read_main_provider()
|
||||
main_model = _read_main_model()
|
||||
if main_provider and main_provider not in ("auto", ""):
|
||||
vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model)
|
||||
if main_provider == "nous":
|
||||
sync_client, default_model = _resolve_strict_vision_backend(
|
||||
main_provider, vision_model
|
||||
)
|
||||
sync_client, default_model = _resolve_strict_vision_backend(main_provider)
|
||||
if sync_client is not None:
|
||||
logger.info(
|
||||
"Vision auto-detect: using main provider %s (%s)",
|
||||
@@ -2294,10 +2270,10 @@ def resolve_vision_provider_client(
|
||||
)
|
||||
return _finalize(main_provider, sync_client, default_model)
|
||||
else:
|
||||
vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model)
|
||||
rpc_client, rpc_model = resolve_provider_client(
|
||||
main_provider, vision_model,
|
||||
api_mode=resolved_api_mode,
|
||||
is_vision=True)
|
||||
api_mode=resolved_api_mode)
|
||||
if rpc_client is not None:
|
||||
logger.info(
|
||||
"Vision auto-detect: using main provider %s (%s)",
|
||||
@@ -2319,14 +2295,11 @@ def resolve_vision_provider_client(
|
||||
return None, None, None
|
||||
|
||||
if requested in _VISION_AUTO_PROVIDER_ORDER:
|
||||
sync_client, default_model = _resolve_strict_vision_backend(
|
||||
requested, resolved_model
|
||||
)
|
||||
sync_client, default_model = _resolve_strict_vision_backend(requested)
|
||||
return _finalize(requested, sync_client, default_model)
|
||||
|
||||
client, final_model = _get_cached_client(requested, resolved_model, async_mode,
|
||||
api_mode=resolved_api_mode,
|
||||
is_vision=True)
|
||||
api_mode=resolved_api_mode)
|
||||
if client is None:
|
||||
return requested, None, None
|
||||
return requested, client, final_model
|
||||
@@ -2390,11 +2363,10 @@ def _client_cache_key(
|
||||
api_key: Optional[str] = None,
|
||||
api_mode: Optional[str] = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
) -> tuple:
|
||||
runtime = _normalize_main_runtime(main_runtime)
|
||||
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
|
||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision)
|
||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key)
|
||||
|
||||
|
||||
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
|
||||
@@ -2420,7 +2392,6 @@ def _refresh_nous_auxiliary_client(
|
||||
api_key: Optional[str] = None,
|
||||
api_mode: Optional[str] = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Refresh Nous runtime creds, rebuild the client, and replace the cache entry."""
|
||||
runtime = _resolve_nous_runtime_api(force_refresh=True)
|
||||
@@ -2438,7 +2409,7 @@ def _refresh_nous_auxiliary_client(
|
||||
current_loop = _aio.get_event_loop()
|
||||
except RuntimeError:
|
||||
pass
|
||||
client, final_model = _to_async_client(sync_client, final_model or "", is_vision=is_vision)
|
||||
client, final_model = _to_async_client(sync_client, final_model or "")
|
||||
else:
|
||||
client = sync_client
|
||||
|
||||
@@ -2449,7 +2420,6 @@ def _refresh_nous_auxiliary_client(
|
||||
api_key=api_key,
|
||||
api_mode=api_mode,
|
||||
main_runtime=main_runtime,
|
||||
is_vision=is_vision,
|
||||
)
|
||||
_store_cached_client(cache_key, client, final_model, bound_loop=current_loop)
|
||||
return client, final_model
|
||||
@@ -2561,19 +2531,12 @@ def _is_openrouter_client(client: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _cached_client_accepts_slash_models(client: Any, cached_default: Optional[str]) -> bool:
|
||||
"""Best-effort check for cached clients that accept ``vendor/model`` IDs."""
|
||||
if _is_openrouter_client(client):
|
||||
return True
|
||||
return bool(cached_default and "/" in cached_default)
|
||||
|
||||
|
||||
def _compat_model(client: Any, model: Optional[str], cached_default: Optional[str]) -> Optional[str]:
|
||||
"""Keep slash-bearing model IDs only for cached clients that support them.
|
||||
"""Drop OpenRouter-format model slugs (with '/') for non-OpenRouter clients.
|
||||
|
||||
Mirrors the guard in resolve_provider_client() which is skipped on cache hits.
|
||||
"""
|
||||
if model and "/" in model and not _cached_client_accepts_slash_models(client, cached_default):
|
||||
if model and "/" in model and not _is_openrouter_client(client):
|
||||
return cached_default
|
||||
return model or cached_default
|
||||
|
||||
@@ -2586,7 +2549,6 @@ def _get_cached_client(
|
||||
api_key: str = None,
|
||||
api_mode: str = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Get or create a cached client for the given provider.
|
||||
|
||||
@@ -2623,7 +2585,6 @@ def _get_cached_client(
|
||||
api_key=api_key,
|
||||
api_mode=api_mode,
|
||||
main_runtime=main_runtime,
|
||||
is_vision=is_vision,
|
||||
)
|
||||
with _client_cache_lock:
|
||||
if cache_key in _client_cache:
|
||||
@@ -2655,7 +2616,6 @@ def _get_cached_client(
|
||||
explicit_api_key=api_key,
|
||||
api_mode=api_mode,
|
||||
main_runtime=runtime,
|
||||
is_vision=is_vision,
|
||||
)
|
||||
if client is not None:
|
||||
# For async clients, remember which loop they were created on so we
|
||||
@@ -3119,7 +3079,6 @@ def call_llm(
|
||||
api_key=resolved_api_key,
|
||||
api_mode=resolved_api_mode,
|
||||
main_runtime=main_runtime,
|
||||
is_vision=(task == "vision"),
|
||||
)
|
||||
if refreshed_client is not None:
|
||||
logger.info("Auxiliary %s: refreshed Nous runtime credentials after 401, retrying",
|
||||
@@ -3410,7 +3369,6 @@ async def async_call_llm(
|
||||
base_url=resolved_base_url,
|
||||
api_key=resolved_api_key,
|
||||
api_mode=resolved_api_mode,
|
||||
is_vision=(task == "vision"),
|
||||
)
|
||||
if refreshed_client is not None:
|
||||
logger.info("Auxiliary %s (async): refreshed Nous runtime credentials after 401, retrying",
|
||||
@@ -3479,9 +3437,7 @@ async def async_call_llm(
|
||||
extra_body=effective_extra_body,
|
||||
base_url=str(getattr(fb_client, "base_url", "") or ""))
|
||||
# Convert sync fallback client to async
|
||||
async_fb, async_fb_model = _to_async_client(
|
||||
fb_client, fb_model or "", is_vision=(task == "vision")
|
||||
)
|
||||
async_fb, async_fb_model = _to_async_client(fb_client, fb_model or "")
|
||||
if async_fb_model and async_fb_model != fb_kwargs.get("model"):
|
||||
fb_kwargs["model"] = async_fb_model
|
||||
return _validate_llm_response(
|
||||
|
||||
+5
-113
@@ -61,52 +61,9 @@ _PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]"
|
||||
|
||||
# Chars per token rough estimate
|
||||
_CHARS_PER_TOKEN = 4
|
||||
# Flat token cost per attached image part. Real cost varies by provider and
|
||||
# dimensions (Anthropic ≈ width×height/750, GPT-4o up to ~1700 for
|
||||
# high-detail 2048×2048, Gemini 258/tile), but 1600 is a realistic ceiling
|
||||
# that keeps compression budgeting honest for multi-image conversations.
|
||||
# Matches Claude Code's IMAGE_TOKEN_ESTIMATE constant.
|
||||
_IMAGE_TOKEN_ESTIMATE = 1600
|
||||
# Same figure expressed in the char-budget currency the rest of the
|
||||
# compressor speaks in. Used when accumulating message "content length"
|
||||
# for tail-cut decisions.
|
||||
_IMAGE_CHAR_EQUIVALENT = _IMAGE_TOKEN_ESTIMATE * _CHARS_PER_TOKEN
|
||||
_SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
|
||||
|
||||
|
||||
def _content_length_for_budget(raw_content: Any) -> int:
|
||||
"""Return the effective char-length of a message's content for token budgeting.
|
||||
|
||||
Plain strings: ``len(content)``. Multimodal lists: sum of text-part
|
||||
``len(text)`` plus a flat ``_IMAGE_CHAR_EQUIVALENT`` per image part
|
||||
(``image_url`` / ``input_image`` / Anthropic-style ``image``). This
|
||||
keeps the compressor from treating a turn with 5 attached images as
|
||||
near-zero tokens just because the text part is empty.
|
||||
"""
|
||||
if isinstance(raw_content, str):
|
||||
return len(raw_content)
|
||||
if not isinstance(raw_content, list):
|
||||
return len(str(raw_content or ""))
|
||||
|
||||
total = 0
|
||||
for p in raw_content:
|
||||
if isinstance(p, str):
|
||||
total += len(p)
|
||||
continue
|
||||
if not isinstance(p, dict):
|
||||
total += len(str(p))
|
||||
continue
|
||||
ptype = p.get("type")
|
||||
if ptype in {"image_url", "input_image", "image"}:
|
||||
total += _IMAGE_CHAR_EQUIVALENT
|
||||
else:
|
||||
# text / input_text / tool_result-with-text / anything else with
|
||||
# a text field. Ignore the raw base64 payload inside image_url
|
||||
# dicts — dimensions don't matter, only whether it's an image.
|
||||
total += len(p.get("text", "") or "")
|
||||
return total
|
||||
|
||||
|
||||
def _content_text_for_contains(content: Any) -> str:
|
||||
"""Return a best-effort text view of message content.
|
||||
|
||||
@@ -338,10 +295,6 @@ class ContextCompressor(ContextEngine):
|
||||
self._context_probe_persistable = False
|
||||
self._previous_summary = None
|
||||
self._last_summary_error = None
|
||||
self._last_summary_dropped_count = 0
|
||||
self._last_summary_fallback_used = False
|
||||
self._last_aux_model_failure_error = None
|
||||
self._last_aux_model_failure_model = None
|
||||
self._last_compression_savings_pct = 100.0
|
||||
self._ineffective_compression_count = 0
|
||||
|
||||
@@ -445,17 +398,6 @@ class ContextCompressor(ContextEngine):
|
||||
self._ineffective_compression_count: int = 0
|
||||
self._summary_failure_cooldown_until: float = 0.0
|
||||
self._last_summary_error: Optional[str] = None
|
||||
# When summary generation fails and a static fallback is inserted,
|
||||
# record how many turns were unrecoverably dropped so callers
|
||||
# (gateway hygiene, /compress) can surface a visible warning.
|
||||
self._last_summary_dropped_count: int = 0
|
||||
self._last_summary_fallback_used: bool = False
|
||||
# When a user-configured summary model fails and we recover by
|
||||
# retrying on the main model, record the failure so gateway /
|
||||
# CLI callers can still warn the user even though compression
|
||||
# succeeded. Silent recovery would hide the broken config.
|
||||
self._last_aux_model_failure_error: Optional[str] = None
|
||||
self._last_aux_model_failure_model: Optional[str] = None
|
||||
|
||||
def update_from_response(self, usage: Dict[str, Any]):
|
||||
"""Update tracked token usage from API response."""
|
||||
@@ -542,7 +484,7 @@ class ContextCompressor(ContextEngine):
|
||||
for i in range(len(result) - 1, -1, -1):
|
||||
msg = result[i]
|
||||
raw_content = msg.get("content") or ""
|
||||
content_len = _content_length_for_budget(raw_content)
|
||||
content_len = sum(len(p.get("text", "")) for p in raw_content) if isinstance(raw_content, list) else len(raw_content)
|
||||
msg_tokens = content_len // _CHARS_PER_TOKEN + 10
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict):
|
||||
@@ -915,50 +857,10 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
"Falling back to main model '%s' for compression.",
|
||||
self.summary_model, e, self.model,
|
||||
)
|
||||
# Record the aux-model failure so callers can warn the user
|
||||
# even if the retry-on-main succeeds — a misconfigured aux
|
||||
# model is something the user needs to fix.
|
||||
_err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(_err_text) > 220:
|
||||
_err_text = _err_text[:217].rstrip() + "..."
|
||||
self._last_aux_model_failure_error = _err_text
|
||||
self._last_aux_model_failure_model = self.summary_model
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0 # no cooldown
|
||||
return self._generate_summary(turns_to_summarize, focus_topic=focus_topic) # retry immediately
|
||||
|
||||
# Unknown-error best-effort retry on main model. Losing N turns of
|
||||
# context is almost always worse than one extra summary attempt, so
|
||||
# if we haven't already fallen back and the summary model differs
|
||||
# from the main model, try once more on main before entering
|
||||
# cooldown. Errors that DID match _is_model_not_found above are
|
||||
# already handled by the fast-path retry; this branch catches
|
||||
# everything else (400s, provider-specific "no route" strings,
|
||||
# aggregator rejections, etc.) where auto-retry is still safer
|
||||
# than dropping the turns.
|
||||
if (
|
||||
self.summary_model
|
||||
and self.summary_model != self.model
|
||||
and not getattr(self, "_summary_model_fallen_back", False)
|
||||
):
|
||||
self._summary_model_fallen_back = True
|
||||
logging.warning(
|
||||
"Summary model '%s' failed (%s). "
|
||||
"Retrying on main model '%s' before giving up.",
|
||||
self.summary_model, e, self.model,
|
||||
)
|
||||
# Record the aux-model failure (see 404 branch above) — user
|
||||
# should know their configured model is broken even if main
|
||||
# recovers the call.
|
||||
_err_text = str(e).strip() or e.__class__.__name__
|
||||
if len(_err_text) > 220:
|
||||
_err_text = _err_text[:217].rstrip() + "..."
|
||||
self._last_aux_model_failure_error = _err_text
|
||||
self._last_aux_model_failure_model = self.summary_model
|
||||
self.summary_model = "" # empty = use main model
|
||||
self._summary_failure_cooldown_until = 0.0
|
||||
return self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
|
||||
|
||||
# Transient errors (timeout, rate limit, network) — shorter cooldown
|
||||
_transient_cooldown = 60
|
||||
self._summary_failure_cooldown_until = time.monotonic() + _transient_cooldown
|
||||
@@ -1180,9 +1082,8 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
|
||||
for i in range(n - 1, head_end - 1, -1):
|
||||
msg = messages[i]
|
||||
raw_content = msg.get("content") or ""
|
||||
content_len = _content_length_for_budget(raw_content)
|
||||
msg_tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/metadata
|
||||
content = msg.get("content") or ""
|
||||
msg_tokens = len(content) // _CHARS_PER_TOKEN + 10 # +10 for role/metadata
|
||||
# Include tool call arguments in estimate
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict):
|
||||
@@ -1251,13 +1152,6 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
related to this topic and be more aggressive about compressing
|
||||
everything else. Inspired by Claude Code's ``/compact``.
|
||||
"""
|
||||
# Reset per-call summary failure state — callers inspect these fields
|
||||
# after compress() returns to decide whether to surface a warning.
|
||||
self._last_summary_dropped_count = 0
|
||||
self._last_summary_fallback_used = False
|
||||
self._last_summary_error = None
|
||||
self._last_aux_model_failure_error = None
|
||||
self._last_aux_model_failure_model = None
|
||||
n_messages = len(messages)
|
||||
# Only need head + 3 tail messages minimum (token budget decides the real tail size)
|
||||
_min_for_compress = self.protect_first_n + 3 + 1
|
||||
@@ -1336,13 +1230,11 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
if not self.quiet_mode:
|
||||
logger.warning("Summary generation failed — inserting static fallback context marker")
|
||||
n_dropped = compress_end - compress_start
|
||||
self._last_summary_dropped_count = n_dropped
|
||||
self._last_summary_fallback_used = True
|
||||
summary = (
|
||||
f"{SUMMARY_PREFIX}\n"
|
||||
f"Summary generation was unavailable. {n_dropped} message(s) were "
|
||||
f"Summary generation was unavailable. {n_dropped} conversation turns were "
|
||||
f"removed to free context space but could not be summarized. The removed "
|
||||
f"messages contained earlier work in this session. Continue based on the "
|
||||
f"turns contained earlier work in this session. Continue based on the "
|
||||
f"recent messages below and the current state of any files or resources."
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_cli.config import get_env_value
|
||||
import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import (
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
@@ -1274,8 +1273,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
def _is_source_suppressed(_p, _s): # type: ignore[misc]
|
||||
return False
|
||||
if provider == "openrouter":
|
||||
# Check both os.environ and ~/.hermes/.env file
|
||||
token = (get_env_value("OPENROUTER_API_KEY") or "").strip()
|
||||
token = os.getenv("OPENROUTER_API_KEY", "").strip()
|
||||
if token:
|
||||
source = "env:OPENROUTER_API_KEY"
|
||||
if _is_source_suppressed(provider, source):
|
||||
@@ -1301,7 +1299,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
|
||||
env_url = ""
|
||||
if pconfig.base_url_env_var:
|
||||
env_url = (get_env_value(pconfig.base_url_env_var) or "").strip().rstrip("/")
|
||||
env_url = os.getenv(pconfig.base_url_env_var, "").strip().rstrip("/")
|
||||
|
||||
env_vars = list(pconfig.api_key_env_vars)
|
||||
if provider == "anthropic":
|
||||
@@ -1312,8 +1310,7 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
]
|
||||
|
||||
for env_var in env_vars:
|
||||
# Check both os.environ and ~/.hermes/.env file
|
||||
token = (get_env_value(env_var) or "").strip()
|
||||
token = os.getenv(env_var, "").strip()
|
||||
if not token:
|
||||
continue
|
||||
source = f"env:{env_var}"
|
||||
|
||||
@@ -42,7 +42,6 @@ class FailoverReason(enum.Enum):
|
||||
# Context / payload
|
||||
context_overflow = "context_overflow" # Context too large — compress, not failover
|
||||
payload_too_large = "payload_too_large" # 413 — compress payload
|
||||
image_too_large = "image_too_large" # Native image part exceeds provider's per-image limit — shrink and retry
|
||||
|
||||
# Model
|
||||
model_not_found = "model_not_found" # 404 or invalid model — fallback to different model
|
||||
@@ -148,20 +147,6 @@ _PAYLOAD_TOO_LARGE_PATTERNS = [
|
||||
"error code: 413",
|
||||
]
|
||||
|
||||
# Image-size patterns. Matched against 400 bodies (not 413) because most
|
||||
# providers return a 400 with a specific image-too-big message before the
|
||||
# whole request hits the 413 size limit. Anthropic's wording is the most
|
||||
# important here (hard 5 MB per image, returned as
|
||||
# "messages.N.content.K.image.source.base64: image exceeds 5 MB maximum").
|
||||
_IMAGE_TOO_LARGE_PATTERNS = [
|
||||
"image exceeds", # Anthropic: "image exceeds 5 MB maximum"
|
||||
"image too large", # generic
|
||||
"image_too_large", # error_code variant
|
||||
"image size exceeds", # variant
|
||||
# "request_too_large" on a request known to contain an image → image is
|
||||
# the likely culprit; we still try the shrink path before giving up.
|
||||
]
|
||||
|
||||
# Context overflow patterns
|
||||
_CONTEXT_OVERFLOW_PATTERNS = [
|
||||
"context length",
|
||||
@@ -686,15 +671,6 @@ def _classify_400(
|
||||
) -> ClassifiedError:
|
||||
"""Classify 400 Bad Request — context overflow, format error, or generic."""
|
||||
|
||||
# Image-too-large from 400 (Anthropic's 5 MB per-image check fires this way).
|
||||
# Must be checked BEFORE context_overflow because messages can trip both
|
||||
# patterns ("exceeds" + "image") and image-shrink is a cheaper recovery.
|
||||
if any(p in error_msg for p in _IMAGE_TOO_LARGE_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.image_too_large,
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
# Context overflow from 400
|
||||
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
||||
return result_fn(
|
||||
@@ -822,13 +798,6 @@ def _classify_by_message(
|
||||
should_compress=True,
|
||||
)
|
||||
|
||||
# Image-too-large patterns (from message text when no status_code)
|
||||
if any(p in error_msg for p in _IMAGE_TOO_LARGE_PATTERNS):
|
||||
return result_fn(
|
||||
FailoverReason.image_too_large,
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
# Usage-limit patterns need the same disambiguation as 402: some providers
|
||||
# surface "usage limit" errors without an HTTP status code. A transient
|
||||
# signal ("try again", "resets at", …) means it's a periodic quota, not
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Routing helpers for inbound user-attached images.
|
||||
|
||||
Two modes:
|
||||
|
||||
native — attach images as OpenAI-style ``image_url`` content parts on the
|
||||
user turn. Provider adapters (Anthropic, Gemini, Bedrock, Codex,
|
||||
OpenAI chat.completions) already translate these into their
|
||||
vendor-specific multimodal formats.
|
||||
|
||||
text — run ``vision_analyze`` on each image up-front and prepend the
|
||||
description to the user's text. The model never sees the pixels;
|
||||
it only sees a lossy text summary. This is the pre-existing
|
||||
behaviour and still the right choice for non-vision models.
|
||||
|
||||
The decision is made once per message turn by :func:`decide_image_input_mode`.
|
||||
It reads ``agent.image_input_mode`` from config.yaml (``auto`` | ``native``
|
||||
| ``text``, default ``auto``) and the active model's capability metadata.
|
||||
|
||||
In ``auto`` mode:
|
||||
- If the user has explicitly configured ``auxiliary.vision.provider``
|
||||
(i.e. not ``auto`` and not empty), we assume they want the text pipeline
|
||||
regardless of the main model — they've opted in to a specific vision
|
||||
backend for a reason (cost, quality, local-only, etc.).
|
||||
- Otherwise, if the active model reports ``supports_vision=True`` in its
|
||||
models.dev metadata, we attach natively.
|
||||
- Otherwise (non-vision model, no explicit override), we fall back to text.
|
||||
|
||||
This keeps ``vision_analyze`` surfaced as a tool in every session — skills
|
||||
and agent flows that chain it (browser screenshots, deeper inspection of
|
||||
URL-referenced images, style-gating loops) keep working. The routing only
|
||||
affects *how user-attached images on the current turn* are presented to the
|
||||
main model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_VALID_MODES = frozenset({"auto", "native", "text"})
|
||||
|
||||
|
||||
def _coerce_mode(raw: Any) -> str:
|
||||
"""Normalize a config value into one of the valid modes."""
|
||||
if not isinstance(raw, str):
|
||||
return "auto"
|
||||
val = raw.strip().lower()
|
||||
if val in _VALID_MODES:
|
||||
return val
|
||||
return "auto"
|
||||
|
||||
|
||||
def _explicit_aux_vision_override(cfg: Optional[Dict[str, Any]]) -> bool:
|
||||
"""True when the user configured a specific auxiliary vision backend.
|
||||
|
||||
An explicit override means the user *wants* the text pipeline (they're
|
||||
paying for a dedicated vision model), so we don't silently bypass it.
|
||||
"""
|
||||
if not isinstance(cfg, dict):
|
||||
return False
|
||||
aux = cfg.get("auxiliary") or {}
|
||||
if not isinstance(aux, dict):
|
||||
return False
|
||||
vision = aux.get("vision") or {}
|
||||
if not isinstance(vision, dict):
|
||||
return False
|
||||
|
||||
provider = str(vision.get("provider") or "").strip().lower()
|
||||
model = str(vision.get("model") or "").strip()
|
||||
base_url = str(vision.get("base_url") or "").strip()
|
||||
|
||||
# "auto" / "" / blank = not explicit
|
||||
if provider in ("", "auto") and not model and not base_url:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _lookup_supports_vision(provider: str, model: str) -> Optional[bool]:
|
||||
"""Return True/False if we can resolve caps, None if unknown."""
|
||||
if not provider or not model:
|
||||
return None
|
||||
try:
|
||||
from agent.models_dev import get_model_capabilities
|
||||
caps = get_model_capabilities(provider, model)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("image_routing: caps lookup failed for %s:%s — %s", provider, model, exc)
|
||||
return None
|
||||
if caps is None:
|
||||
return None
|
||||
return bool(caps.supports_vision)
|
||||
|
||||
|
||||
def decide_image_input_mode(
|
||||
provider: str,
|
||||
model: str,
|
||||
cfg: Optional[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""Return ``"native"`` or ``"text"`` for the given turn.
|
||||
|
||||
Args:
|
||||
provider: active inference provider ID (e.g. ``"anthropic"``, ``"openrouter"``).
|
||||
model: active model slug as it would be sent to the provider.
|
||||
cfg: loaded config.yaml dict, or None. When None, behaves as auto.
|
||||
"""
|
||||
mode_cfg = "auto"
|
||||
if isinstance(cfg, dict):
|
||||
agent_cfg = cfg.get("agent") or {}
|
||||
if isinstance(agent_cfg, dict):
|
||||
mode_cfg = _coerce_mode(agent_cfg.get("image_input_mode"))
|
||||
|
||||
if mode_cfg == "native":
|
||||
return "native"
|
||||
if mode_cfg == "text":
|
||||
return "text"
|
||||
|
||||
# auto
|
||||
if _explicit_aux_vision_override(cfg):
|
||||
return "text"
|
||||
|
||||
supports = _lookup_supports_vision(provider, model)
|
||||
if supports is True:
|
||||
return "native"
|
||||
return "text"
|
||||
|
||||
|
||||
# Image size handling is REACTIVE rather than proactive: we attempt native
|
||||
# attachment at full size regardless of provider, and rely on
|
||||
# ``run_agent._try_shrink_image_parts_in_messages`` to shrink + retry if
|
||||
# the provider rejects the request (e.g. Anthropic's hard 5 MB per-image
|
||||
# ceiling returned as HTTP 400 "image exceeds 5 MB maximum").
|
||||
#
|
||||
# Why reactive: our knowledge of provider ceilings is partial and evolving
|
||||
# (OpenAI accepts 49 MB+, Anthropic 5 MB, Gemini 100 MB, others unknown).
|
||||
# A proactive per-provider table would be stale the moment a provider raises
|
||||
# or lowers its limit, and silently degrading quality for users on providers
|
||||
# that would have accepted the full image is the worse failure mode.
|
||||
# The shrink-on-reject path loses 1 API call + maybe 1s of Pillow work when
|
||||
# it fires, which is cheaper than permanent quality loss.
|
||||
|
||||
|
||||
def _guess_mime(path: Path) -> str:
|
||||
mime, _ = mimetypes.guess_type(str(path))
|
||||
if mime and mime.startswith("image/"):
|
||||
return mime
|
||||
# mimetypes on some Linux distros mis-maps .jpg; default to jpeg when
|
||||
# the suffix looks imagey.
|
||||
suffix = path.suffix.lower()
|
||||
return {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
}.get(suffix, "image/jpeg")
|
||||
|
||||
|
||||
def _file_to_data_url(path: Path) -> Optional[str]:
|
||||
"""Encode a local image as a base64 data URL at its native size.
|
||||
|
||||
Size limits are NOT enforced here — the agent retry loop
|
||||
(``run_agent._try_shrink_image_parts_in_messages``) shrinks on the
|
||||
provider's first rejection. Keeping this simple means providers that
|
||||
accept large images (OpenAI 49 MB+, Gemini 100 MB) don't pay a silent
|
||||
quality tax just because one other provider is stricter.
|
||||
|
||||
Returns None only if the file can't be read (missing, permission
|
||||
denied, etc.); the caller reports those paths in ``skipped``.
|
||||
"""
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except Exception as exc:
|
||||
logger.warning("image_routing: failed to read %s — %s", path, exc)
|
||||
return None
|
||||
mime = _guess_mime(path)
|
||||
b64 = base64.b64encode(raw).decode("ascii")
|
||||
return f"data:{mime};base64,{b64}"
|
||||
|
||||
|
||||
def build_native_content_parts(
|
||||
user_text: str,
|
||||
image_paths: List[str],
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
"""Build an OpenAI-style ``content`` list for a user turn.
|
||||
|
||||
Shape:
|
||||
[{"type": "text", "text": "..."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}},
|
||||
...]
|
||||
|
||||
Images are attached at their native size. If a provider rejects the
|
||||
request because an image is too large (e.g. Anthropic's 5 MB per-image
|
||||
ceiling), the agent's retry loop transparently shrinks and retries
|
||||
once — see ``run_agent._try_shrink_image_parts_in_messages``.
|
||||
|
||||
Returns (content_parts, skipped_paths). Skipped paths are files that
|
||||
couldn't be read from disk.
|
||||
"""
|
||||
parts: List[Dict[str, Any]] = []
|
||||
skipped: List[str] = []
|
||||
|
||||
text = (user_text or "").strip()
|
||||
if text:
|
||||
parts.append({"type": "text", "text": text})
|
||||
|
||||
for raw_path in image_paths:
|
||||
p = Path(raw_path)
|
||||
if not p.exists() or not p.is_file():
|
||||
skipped.append(str(raw_path))
|
||||
continue
|
||||
data_url = _file_to_data_url(p)
|
||||
if not data_url:
|
||||
skipped.append(str(raw_path))
|
||||
continue
|
||||
parts.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": data_url},
|
||||
})
|
||||
|
||||
# If the text was empty, add a neutral prompt so the turn isn't just images.
|
||||
if not text and any(p.get("type") == "image_url" for p in parts):
|
||||
parts.insert(0, {"type": "text", "text": "What do you see in this image?"})
|
||||
|
||||
return parts, skipped
|
||||
|
||||
|
||||
__all__ = [
|
||||
"decide_image_input_mode",
|
||||
"build_native_content_parts",
|
||||
]
|
||||
+5
-114
@@ -63,124 +63,15 @@ def sanitize_context(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
class StreamingContextScrubber:
|
||||
"""Stateful scrubber for streaming text that may contain split memory-context spans.
|
||||
|
||||
The one-shot ``sanitize_context`` regex cannot survive chunk boundaries:
|
||||
a ``<memory-context>`` opened in one delta and closed in a later delta
|
||||
leaks its payload to the UI because the non-greedy block regex needs
|
||||
both tags in one string. This scrubber runs a small state machine
|
||||
across deltas, holding back partial-tag tails and discarding
|
||||
everything inside a span (including the system-note line).
|
||||
|
||||
Usage::
|
||||
|
||||
scrubber = StreamingContextScrubber()
|
||||
for delta in stream:
|
||||
visible = scrubber.feed(delta)
|
||||
if visible:
|
||||
emit(visible)
|
||||
trailing = scrubber.flush() # at end of stream
|
||||
if trailing:
|
||||
emit(trailing)
|
||||
|
||||
The scrubber is re-entrant per agent instance. Callers building new
|
||||
top-level responses (new turn) should create a fresh scrubber or call
|
||||
``reset()``.
|
||||
"""
|
||||
|
||||
_OPEN_TAG = "<memory-context>"
|
||||
_CLOSE_TAG = "</memory-context>"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._in_span: bool = False
|
||||
self._buf: str = ""
|
||||
|
||||
def reset(self) -> None:
|
||||
self._in_span = False
|
||||
self._buf = ""
|
||||
|
||||
def feed(self, text: str) -> str:
|
||||
"""Return the visible portion of ``text`` after scrubbing.
|
||||
|
||||
Any trailing fragment that could be the start of an open/close tag
|
||||
is held back in the internal buffer and surfaced on the next
|
||||
``feed()`` call or discarded/emitted by ``flush()``.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
buf = self._buf + text
|
||||
self._buf = ""
|
||||
out: list[str] = []
|
||||
|
||||
while buf:
|
||||
if self._in_span:
|
||||
idx = buf.lower().find(self._CLOSE_TAG)
|
||||
if idx == -1:
|
||||
# Hold back a potential partial close tag; drop the rest
|
||||
held = self._max_partial_suffix(buf, self._CLOSE_TAG)
|
||||
self._buf = buf[-held:] if held else ""
|
||||
return "".join(out)
|
||||
# Found close — skip span content + tag, continue
|
||||
buf = buf[idx + len(self._CLOSE_TAG):]
|
||||
self._in_span = False
|
||||
else:
|
||||
idx = buf.lower().find(self._OPEN_TAG)
|
||||
if idx == -1:
|
||||
# No open tag — hold back a potential partial open tag
|
||||
held = self._max_partial_suffix(buf, self._OPEN_TAG)
|
||||
if held:
|
||||
out.append(buf[:-held])
|
||||
self._buf = buf[-held:]
|
||||
else:
|
||||
out.append(buf)
|
||||
return "".join(out)
|
||||
# Emit text before the tag, enter span
|
||||
if idx > 0:
|
||||
out.append(buf[:idx])
|
||||
buf = buf[idx + len(self._OPEN_TAG):]
|
||||
self._in_span = True
|
||||
|
||||
return "".join(out)
|
||||
|
||||
def flush(self) -> str:
|
||||
"""Emit any held-back buffer at end-of-stream.
|
||||
|
||||
If we're still inside an unterminated span the remaining content is
|
||||
discarded (safer: leaking partial memory context is worse than a
|
||||
truncated answer). Otherwise the held-back partial-tag tail is
|
||||
emitted verbatim (it turned out not to be a real tag).
|
||||
"""
|
||||
if self._in_span:
|
||||
self._buf = ""
|
||||
self._in_span = False
|
||||
return ""
|
||||
tail = self._buf
|
||||
self._buf = ""
|
||||
return tail
|
||||
|
||||
@staticmethod
|
||||
def _max_partial_suffix(buf: str, tag: str) -> int:
|
||||
"""Return the length of the longest buf-suffix that is a tag-prefix.
|
||||
|
||||
Case-insensitive. Returns 0 if no suffix could start the tag.
|
||||
"""
|
||||
tag_lower = tag.lower()
|
||||
buf_lower = buf.lower()
|
||||
max_check = min(len(buf_lower), len(tag_lower) - 1)
|
||||
for i in range(max_check, 0, -1):
|
||||
if tag_lower.startswith(buf_lower[-i:]):
|
||||
return i
|
||||
return 0
|
||||
|
||||
|
||||
def build_memory_context_block(raw_context: str) -> str:
|
||||
"""Wrap prefetched memory in a fenced block with system note."""
|
||||
"""Wrap prefetched memory in a fenced block with system note.
|
||||
|
||||
The fence prevents the model from treating recalled context as user
|
||||
discourse. Injected at API-call time only — never persisted.
|
||||
"""
|
||||
if not raw_context or not raw_context.strip():
|
||||
return ""
|
||||
clean = sanitize_context(raw_context)
|
||||
if clean != raw_context:
|
||||
logger.warning("memory provider returned pre-wrapped context; stripped")
|
||||
return (
|
||||
"<memory-context>\n"
|
||||
"[System note: The following is recalled memory context, "
|
||||
|
||||
+16
-35
@@ -51,7 +51,6 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"qwen-oauth",
|
||||
"xiaomi",
|
||||
"arcee",
|
||||
"gmi",
|
||||
"custom", "local",
|
||||
# Common aliases
|
||||
"google", "google-gemini", "google-ai-studio",
|
||||
@@ -61,7 +60,6 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"stepfun", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
|
||||
"mimo", "xiaomi-mimo",
|
||||
"arcee-ai", "arceeai",
|
||||
"gmi-cloud", "gmicloud",
|
||||
"xai", "x-ai", "x.ai", "grok",
|
||||
"nvidia", "nim", "nvidia-nim", "nemotron",
|
||||
"qwen-portal",
|
||||
@@ -309,7 +307,6 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
||||
"integrate.api.nvidia.com": "nvidia",
|
||||
"api.xiaomimimo.com": "xiaomi",
|
||||
"xiaomimimo.com": "xiaomi",
|
||||
"api.gmi-serving.com": "gmi",
|
||||
"ollama.com": "ollama-cloud",
|
||||
}
|
||||
|
||||
@@ -705,29 +702,6 @@ def fetch_endpoint_model_metadata(
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_endpoint_context_length(
|
||||
model: str,
|
||||
base_url: str,
|
||||
api_key: str = "",
|
||||
) -> Optional[int]:
|
||||
"""Resolve context length from an endpoint's live ``/models`` metadata."""
|
||||
endpoint_metadata = fetch_endpoint_model_metadata(base_url, api_key=api_key)
|
||||
matched = endpoint_metadata.get(model)
|
||||
if not matched:
|
||||
if len(endpoint_metadata) == 1:
|
||||
matched = next(iter(endpoint_metadata.values()))
|
||||
else:
|
||||
for key, entry in endpoint_metadata.items():
|
||||
if model in key or key in model:
|
||||
matched = entry
|
||||
break
|
||||
if matched:
|
||||
context_length = matched.get("context_length")
|
||||
if isinstance(context_length, int):
|
||||
return context_length
|
||||
return None
|
||||
|
||||
|
||||
def _get_context_cache_path() -> Path:
|
||||
"""Return path to the persistent context length cache file."""
|
||||
from hermes_constants import get_hermes_home
|
||||
@@ -1321,9 +1295,22 @@ def get_model_context_length(
|
||||
# returns 128k) instead of the model's full context (400k). models.dev
|
||||
# has the correct per-provider values and is checked at step 5+.
|
||||
if _is_custom_endpoint(base_url) and not _is_known_provider_base_url(base_url):
|
||||
context_length = _resolve_endpoint_context_length(model, base_url, api_key=api_key)
|
||||
if context_length is not None:
|
||||
return context_length
|
||||
endpoint_metadata = fetch_endpoint_model_metadata(base_url, api_key=api_key)
|
||||
matched = endpoint_metadata.get(model)
|
||||
if not matched:
|
||||
# Single-model servers: if only one model is loaded, use it
|
||||
if len(endpoint_metadata) == 1:
|
||||
matched = next(iter(endpoint_metadata.values()))
|
||||
else:
|
||||
# Fuzzy match: substring in either direction
|
||||
for key, entry in endpoint_metadata.items():
|
||||
if model in key or key in model:
|
||||
matched = entry
|
||||
break
|
||||
if matched:
|
||||
context_length = matched.get("context_length")
|
||||
if isinstance(context_length, int):
|
||||
return context_length
|
||||
if not _is_known_provider_base_url(base_url):
|
||||
# 3. Try querying local server directly
|
||||
if is_local_endpoint(base_url):
|
||||
@@ -1387,12 +1374,6 @@ def get_model_context_length(
|
||||
if base_url:
|
||||
save_context_length(model, base_url, codex_ctx)
|
||||
return codex_ctx
|
||||
if effective_provider == "gmi" and base_url:
|
||||
# GMI exposes authoritative context_length via /models, but it is not
|
||||
# in models.dev yet. Preserve that higher-fidelity endpoint lookup.
|
||||
ctx = _resolve_endpoint_context_length(model, base_url, api_key=api_key)
|
||||
if ctx is not None:
|
||||
return ctx
|
||||
if effective_provider:
|
||||
from agent.models_dev import lookup_models_dev_context
|
||||
ctx = lookup_models_dev_context(effective_provider, model)
|
||||
|
||||
+5
-52
@@ -25,7 +25,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
BUSY_INPUT_FLAG = "busy_input_prompt"
|
||||
TOOL_PROGRESS_FLAG = "tool_progress_prompt"
|
||||
OPENCLAW_RESIDUE_FLAG = "openclaw_residue_cleanup"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -44,18 +43,10 @@ def busy_input_hint_gateway(mode: str) -> str:
|
||||
"Send `/busy interrupt` to make new messages stop the current task "
|
||||
"immediately, or `/busy status` to check. This notice won't appear again."
|
||||
)
|
||||
if mode == "steer":
|
||||
return (
|
||||
"💡 First-time tip — I steered your message into the current run; "
|
||||
"it will arrive after the next tool call instead of interrupting. "
|
||||
"Send `/busy interrupt` or `/busy queue` to change this, or "
|
||||
"`/busy status` to check. This notice won't appear again."
|
||||
)
|
||||
return (
|
||||
"💡 First-time tip — I just interrupted my current task to answer you. "
|
||||
"Send `/busy queue` to queue follow-ups for after the current task instead, "
|
||||
"`/busy steer` to inject them mid-run without interrupting, or "
|
||||
"`/busy status` to check. This notice won't appear again."
|
||||
"or `/busy status` to check. This notice won't appear again."
|
||||
)
|
||||
|
||||
|
||||
@@ -64,19 +55,13 @@ def busy_input_hint_cli(mode: str) -> str:
|
||||
if mode == "queue":
|
||||
return (
|
||||
"(tip) Your message was queued for the next turn. "
|
||||
"Use /busy interrupt to make Enter stop the current run instead, "
|
||||
"or /busy steer to inject mid-run. This tip only shows once."
|
||||
)
|
||||
if mode == "steer":
|
||||
return (
|
||||
"(tip) Your message was steered into the current run; it arrives "
|
||||
"after the next tool call. Use /busy interrupt or /busy queue to "
|
||||
"change this. This tip only shows once."
|
||||
"Use /busy interrupt to make Enter stop the current run instead. "
|
||||
"This tip only shows once."
|
||||
)
|
||||
return (
|
||||
"(tip) Your message interrupted the current run. "
|
||||
"Use /busy queue to queue messages for the next turn instead, "
|
||||
"or /busy steer to inject mid-run. This tip only shows once."
|
||||
"Use /busy queue to queue messages for the next turn instead. "
|
||||
"This tip only shows once."
|
||||
)
|
||||
|
||||
|
||||
@@ -95,35 +80,6 @@ def tool_progress_hint_cli() -> str:
|
||||
)
|
||||
|
||||
|
||||
def openclaw_residue_hint_cli() -> str:
|
||||
"""Banner shown the first time Hermes starts and finds ``~/.openclaw/``.
|
||||
|
||||
OpenClaw-era config, memory, and skill paths in ``~/.openclaw/`` will
|
||||
otherwise attract the agent (memory entries like ``~/.openclaw/config.yaml``
|
||||
get carried forward and the agent dutifully reads them). ``hermes claw
|
||||
cleanup`` renames the directory so the agent stops finding it.
|
||||
"""
|
||||
return (
|
||||
"Heads up — an OpenClaw workspace was detected at ~/.openclaw/.\n"
|
||||
"After migrating, the agent can still get confused and read that "
|
||||
"directory's config/memory instead of Hermes's.\n"
|
||||
"Run `hermes claw cleanup` to archive it (rename → .openclaw.pre-migration). "
|
||||
"This tip only shows once; rerun it any time with `hermes claw cleanup`."
|
||||
)
|
||||
|
||||
|
||||
def detect_openclaw_residue(home: Optional[Path] = None) -> bool:
|
||||
"""Return True if an OpenClaw workspace directory is present in ``$HOME``.
|
||||
|
||||
Pure filesystem check — no side effects. ``home`` override exists for tests.
|
||||
"""
|
||||
base = home or Path.home()
|
||||
try:
|
||||
return (base / ".openclaw").is_dir()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# State read / write
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -179,13 +135,10 @@ def mark_seen(config_path: Path, flag: str) -> bool:
|
||||
__all__ = [
|
||||
"BUSY_INPUT_FLAG",
|
||||
"TOOL_PROGRESS_FLAG",
|
||||
"OPENCLAW_RESIDUE_FLAG",
|
||||
"busy_input_hint_gateway",
|
||||
"busy_input_hint_cli",
|
||||
"tool_progress_hint_gateway",
|
||||
"tool_progress_hint_cli",
|
||||
"openclaw_residue_hint_cli",
|
||||
"detect_openclaw_residue",
|
||||
"is_seen",
|
||||
"mark_seen",
|
||||
]
|
||||
|
||||
+58
-34
@@ -141,12 +141,6 @@ DEFAULT_AGENT_IDENTITY = (
|
||||
"Be targeted and efficient in your exploration and investigations."
|
||||
)
|
||||
|
||||
HERMES_AGENT_HELP_GUIDANCE = (
|
||||
"If the user asks about configuring, setting up, or using Hermes Agent "
|
||||
"itself, load the `hermes-agent` skill with skill_view(name='hermes-agent') "
|
||||
"before answering. Docs: https://hermes-agent.nousresearch.com/docs"
|
||||
)
|
||||
|
||||
MEMORY_GUIDANCE = (
|
||||
"You have persistent memory across sessions. Save durable facts using the memory "
|
||||
"tool: user preferences, environment details, tool quirks, and stable conventions. "
|
||||
@@ -182,6 +176,64 @@ SKILLS_GUIDANCE = (
|
||||
"Skills that aren't maintained become liabilities."
|
||||
)
|
||||
|
||||
KANBAN_GUIDANCE = (
|
||||
"# You are a Kanban worker\n"
|
||||
"You were spawned by the Hermes Kanban dispatcher to execute ONE task from "
|
||||
"the shared board at `~/.hermes/kanban.db`. Your task id is in "
|
||||
"`$HERMES_KANBAN_TASK`; your workspace is `$HERMES_KANBAN_WORKSPACE`. "
|
||||
"The `kanban_*` tools in your schema are your primary coordination surface — "
|
||||
"they write directly to the shared SQLite DB and work regardless of terminal "
|
||||
"backend (local/docker/modal/ssh).\n"
|
||||
"\n"
|
||||
"## Lifecycle\n"
|
||||
"\n"
|
||||
"1. **Orient.** Call `kanban_show()` first (no args — it defaults to your "
|
||||
"task). The response includes title, body, parent-task handoffs (summary + "
|
||||
"metadata), any prior attempts on this task if you're a retry, the full "
|
||||
"comment thread, and a pre-formatted `worker_context` you can treat as "
|
||||
"ground truth.\n"
|
||||
"2. **Work inside the workspace.** `cd $HERMES_KANBAN_WORKSPACE` before "
|
||||
"any file operations. The workspace is yours for this run. Don't modify "
|
||||
"files outside it unless the task explicitly asks.\n"
|
||||
"3. **Heartbeat on long operations.** Call `kanban_heartbeat(note=...)` "
|
||||
"every few minutes during long subprocesses (training, encoding, crawling). "
|
||||
"Skip heartbeats for short tasks.\n"
|
||||
"4. **Block on genuine ambiguity.** If you need a human decision you cannot "
|
||||
"infer (missing credentials, UX choice, paywalled source, peer output you "
|
||||
"need first), call `kanban_block(reason=\"...\")` and stop. Don't guess. "
|
||||
"The user will unblock with context and the dispatcher will respawn you.\n"
|
||||
"5. **Complete with structured handoff.** Call `kanban_complete(summary=..., "
|
||||
"metadata=...)`. `summary` is 1–3 human-readable sentences naming concrete "
|
||||
"artifacts. `metadata` is machine-readable facts "
|
||||
"(`{changed_files: [...], tests_run: N, decisions: [...]}`). Downstream "
|
||||
"workers read both via their own `kanban_show`. Never put secrets / "
|
||||
"tokens / raw PII in either field — run rows are durable forever.\n"
|
||||
"6. **If follow-up work appears, create it; don't do it.** Use "
|
||||
"`kanban_create(title=..., assignee=<right-profile>, parents=[your-task-id])` "
|
||||
"to spawn a child task for the appropriate specialist profile instead of "
|
||||
"scope-creeping into the next thing.\n"
|
||||
"\n"
|
||||
"## Orchestrator mode\n"
|
||||
"\n"
|
||||
"If your task is itself a decomposition task (e.g. a planner profile given "
|
||||
"a high-level goal), use `kanban_create` to fan out into child tasks — one "
|
||||
"per specialist, each with an explicit `assignee` and `parents=[...]` to "
|
||||
"express dependencies. Then `kanban_complete` your own task with a summary "
|
||||
"of the decomposition. Do NOT execute the work yourself; your job is "
|
||||
"routing, not implementation.\n"
|
||||
"\n"
|
||||
"## Do NOT\n"
|
||||
"\n"
|
||||
"- Do not shell out to `hermes kanban <verb>` for board operations. Use "
|
||||
"the `kanban_*` tools — they work across all terminal backends.\n"
|
||||
"- Do not complete a task you didn't actually finish. Block it.\n"
|
||||
"- Do not assign follow-up work to yourself. Assign it to the right "
|
||||
"specialist profile.\n"
|
||||
"- Do not call `delegate_task` as a board substitute. `delegate_task` is "
|
||||
"for short reasoning subtasks inside your own run; board tasks are for "
|
||||
"cross-agent handoffs that outlive one API loop."
|
||||
)
|
||||
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE = (
|
||||
"# Tool-use enforcement\n"
|
||||
"You MUST use your tools to take action — do not describe what you would do "
|
||||
@@ -428,29 +480,6 @@ PLATFORM_HINTS = {
|
||||
"your response. Images are sent as native photos, and other files arrive as downloadable "
|
||||
"documents."
|
||||
),
|
||||
"yuanbao": (
|
||||
"You are on Yuanbao (腾讯元宝), a Chinese AI assistant platform. "
|
||||
"Markdown formatting is supported (code blocks, tables, bold/italic). "
|
||||
"You CAN send media files natively — to deliver a file to the user, include "
|
||||
"MEDIA:/absolute/path/to/file in your response. The file will be sent as a native "
|
||||
"Yuanbao attachment: images (.jpg, .png, .webp, .gif) are sent as photos, "
|
||||
"and other files (.pdf, .docx, .txt, .zip, etc.) arrive as downloadable documents "
|
||||
"(max 50 MB). You can also include image URLs in markdown format  and "
|
||||
"they will be downloaded and sent as native photos. "
|
||||
"Do NOT tell the user you lack file-sending capability — use MEDIA: syntax "
|
||||
"whenever a file delivery is appropriate.\n\n"
|
||||
"Stickers (贴纸 / 表情包 / TIM face): Yuanbao has a built-in sticker catalogue. "
|
||||
"When the user sends a sticker (you see '[emoji: 名称]' in their message) or asks "
|
||||
"you to send/reply-with a 贴纸/表情/表情包, you MUST use the sticker tools:\n"
|
||||
" 1. Call yb_search_sticker with a Chinese keyword (e.g. '666', '比心', '吃瓜', "
|
||||
" '捂脸', '合十') to discover matching sticker_ids.\n"
|
||||
" 2. Call yb_send_sticker with the chosen sticker_id or name — this sends a real "
|
||||
" TIMFaceElem that renders as a native sticker in the chat.\n"
|
||||
"DO NOT draw sticker-like PNGs with execute_code/Pillow/matplotlib and then send "
|
||||
"them via MEDIA: or send_image_file. That produces a fake low-quality 'sticker' "
|
||||
"image and is the WRONG path. Bare Unicode emoji in text is also not a substitute "
|
||||
"— when a sticker is the right response, use yb_send_sticker."
|
||||
),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -854,11 +883,6 @@ def build_skills_system_prompt(
|
||||
"Skills also encode the user's preferred approach, conventions, and quality standards "
|
||||
"for tasks like code review, planning, and testing — load them even for tasks you "
|
||||
"already know how to do, because the skill defines how it should be done here.\n"
|
||||
"Whenever the user asks you to configure, set up, install, enable, disable, modify, "
|
||||
"or troubleshoot Hermes Agent itself — its CLI, config, models, providers, tools, "
|
||||
"skills, voice, gateway, plugins, or any feature — load the `hermes-agent` skill "
|
||||
"first. It has the actual commands (e.g. `hermes config set …`, `hermes tools`, "
|
||||
"`hermes setup`) so you don't have to guess or invent workarounds.\n"
|
||||
"If a skill has issues, fix it with skill_manage(action='patch').\n"
|
||||
"After difficult/iterative tasks, offer to save as a skill. "
|
||||
"If a skill you loaded was missing steps, had wrong commands, or needed "
|
||||
|
||||
+3
-7
@@ -56,12 +56,8 @@ _SENSITIVE_BODY_KEYS = frozenset({
|
||||
})
|
||||
|
||||
# Snapshot at import time so runtime env mutations (e.g. LLM-generated
|
||||
# `export HERMES_REDACT_SECRETS=true`) cannot enable/disable redaction
|
||||
# mid-session. OFF by default — user must opt in via
|
||||
# `security.redact_secrets: true` in config.yaml (bridged to this env var
|
||||
# in hermes_cli/main.py and gateway/run.py) or `HERMES_REDACT_SECRETS=true`
|
||||
# in ~/.hermes/.env.
|
||||
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("1", "true", "yes", "on")
|
||||
# `export HERMES_REDACT_SECRETS=false`) cannot disable redaction mid-session.
|
||||
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() not in ("0", "false", "no", "off")
|
||||
|
||||
# Known API key prefixes -- match the prefix + contiguous token chars
|
||||
_PREFIX_PATTERNS = [
|
||||
@@ -261,7 +257,7 @@ def redact_sensitive_text(text: str) -> str:
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
||||
Disabled when security.redact_secrets is false in config.yaml.
|
||||
"""
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
@@ -754,11 +754,7 @@ def _resolve_effective_accept(
|
||||
if env in ("1", "true", "yes", "on"):
|
||||
return True
|
||||
cfg_val = cfg.get("hooks_auto_accept", False)
|
||||
if isinstance(cfg_val, bool):
|
||||
return cfg_val
|
||||
if isinstance(cfg_val, str):
|
||||
return cfg_val.strip().lower() in ("1", "true", "yes", "on")
|
||||
return False
|
||||
return bool(cfg_val)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -329,7 +329,7 @@ def build_skill_invocation_message(
|
||||
|
||||
loaded_skill, skill_dir, skill_name = loaded
|
||||
activation_note = (
|
||||
f'[IMPORTANT: The user has invoked the "{skill_name}" skill, indicating they want '
|
||||
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want '
|
||||
"you to follow its instructions. The full skill content is loaded below.]"
|
||||
)
|
||||
return _build_skill_message(
|
||||
@@ -368,7 +368,7 @@ def build_preloaded_skills_prompt(
|
||||
|
||||
loaded_skill, skill_dir, skill_name = loaded
|
||||
activation_note = (
|
||||
f'[IMPORTANT: The user launched this CLI session with the "{skill_name}" skill '
|
||||
f'[SYSTEM: The user launched this CLI session with the "{skill_name}" skill '
|
||||
"preloaded. Treat its instructions as active guidance for the duration of this "
|
||||
"session unless the user overrides them.]"
|
||||
)
|
||||
|
||||
@@ -6,18 +6,12 @@ adds latency to the user-facing reply.
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
from typing import Optional
|
||||
|
||||
from agent.auxiliary_client import call_llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Callback signature: (task_name, exception) -> None. Used to surface
|
||||
# auxiliary failures to the user through AIAgent._emit_auxiliary_failure
|
||||
# so silent-drops (e.g. OpenRouter 402 exhausting the fallback chain)
|
||||
# become visible instead of piling up as NULL session titles.
|
||||
FailureCallback = Callable[[str, BaseException], None]
|
||||
|
||||
_TITLE_PROMPT = (
|
||||
"Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
|
||||
"following exchange. The title should capture the main topic or intent. "
|
||||
@@ -25,21 +19,11 @@ _TITLE_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
def generate_title(
|
||||
user_message: str,
|
||||
assistant_response: str,
|
||||
timeout: float = 30.0,
|
||||
failure_callback: Optional[FailureCallback] = None,
|
||||
) -> Optional[str]:
|
||||
def generate_title(user_message: str, assistant_response: str, timeout: float = 30.0) -> Optional[str]:
|
||||
"""Generate a session title from the first exchange.
|
||||
|
||||
Uses the auxiliary LLM client (cheapest/fastest available model).
|
||||
Returns the title string or None on failure.
|
||||
|
||||
``failure_callback`` is invoked with ``(task, exception)`` when the
|
||||
auxiliary call raises — the caller typically wires this to
|
||||
``AIAgent._emit_auxiliary_failure`` so the user sees a warning instead
|
||||
of silently accumulating untitled sessions.
|
||||
"""
|
||||
# Truncate long messages to keep the request small
|
||||
user_snippet = user_message[:500] if user_message else ""
|
||||
@@ -68,15 +52,7 @@ def generate_title(
|
||||
title = title[:77] + "..."
|
||||
return title if title else None
|
||||
except Exception as e:
|
||||
# Log at WARNING so this shows up in agent.log without debug mode.
|
||||
# Full detail at debug level for operators who need the stack.
|
||||
logger.warning("Title generation failed: %s", e)
|
||||
logger.debug("Title generation traceback", exc_info=True)
|
||||
if failure_callback is not None:
|
||||
try:
|
||||
failure_callback("title generation", e)
|
||||
except Exception:
|
||||
logger.debug("Title generation failure_callback raised", exc_info=True)
|
||||
logger.debug("Title generation failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -85,7 +61,6 @@ def auto_title_session(
|
||||
session_id: str,
|
||||
user_message: str,
|
||||
assistant_response: str,
|
||||
failure_callback: Optional[FailureCallback] = None,
|
||||
) -> None:
|
||||
"""Generate and set a session title if one doesn't already exist.
|
||||
|
||||
@@ -106,9 +81,7 @@ def auto_title_session(
|
||||
except Exception:
|
||||
return
|
||||
|
||||
title = generate_title(
|
||||
user_message, assistant_response, failure_callback=failure_callback
|
||||
)
|
||||
title = generate_title(user_message, assistant_response)
|
||||
if not title:
|
||||
return
|
||||
|
||||
@@ -125,7 +98,6 @@ def maybe_auto_title(
|
||||
user_message: str,
|
||||
assistant_response: str,
|
||||
conversation_history: list,
|
||||
failure_callback: Optional[FailureCallback] = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget title generation after the first exchange.
|
||||
|
||||
@@ -147,7 +119,6 @@ def maybe_auto_title(
|
||||
thread = threading.Thread(
|
||||
target=auto_title_session,
|
||||
args=(session_db, session_id, user_message, assistant_response),
|
||||
kwargs={"failure_callback": failure_callback},
|
||||
daemon=True,
|
||||
name="auto-title",
|
||||
)
|
||||
|
||||
@@ -606,7 +606,6 @@ platform_toolsets:
|
||||
signal: [hermes-signal]
|
||||
homeassistant: [hermes-homeassistant]
|
||||
qqbot: [hermes-qqbot]
|
||||
yuanbao: [hermes-yuanbao]
|
||||
|
||||
# =============================================================================
|
||||
# Gateway Platform Settings
|
||||
@@ -848,12 +847,8 @@ display:
|
||||
# What Enter does when Hermes is already busy (CLI and gateway platforms).
|
||||
# interrupt: Interrupt the current run and redirect Hermes (default)
|
||||
# queue: Queue your message for the next turn
|
||||
# steer: Inject your message mid-run via /steer, arriving at the agent
|
||||
# after the next tool call — no interrupt, no role violation.
|
||||
# Falls back to 'queue' if the agent isn't running yet or if
|
||||
# images are attached (steer only carries text).
|
||||
# Ctrl+C (or /stop in gateway) always interrupts regardless of this setting.
|
||||
# Toggle at runtime with /busy <interrupt|queue|steer>.
|
||||
# Toggle at runtime with /busy_input_mode <interrupt|queue>.
|
||||
busy_input_mode: interrupt
|
||||
|
||||
# Background process notifications (gateway/messaging only).
|
||||
|
||||
@@ -15,7 +15,6 @@ Usage:
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import json
|
||||
@@ -759,17 +758,9 @@ def _run_cleanup():
|
||||
pass
|
||||
try:
|
||||
if _active_agent_ref and hasattr(_active_agent_ref, 'shutdown_memory_provider'):
|
||||
# Forward the agent's own transcript so memory providers'
|
||||
# ``on_session_end`` hooks see the real conversation instead of
|
||||
# an empty list (#15165). ``_session_messages`` is set on
|
||||
# ``AIAgent.__init__`` and refreshed every turn via
|
||||
# ``_persist_session``. Fall back to no-arg on test stubs /
|
||||
# partially-initialised agents where the attribute is missing.
|
||||
_session_msgs = getattr(_active_agent_ref, '_session_messages', None)
|
||||
if isinstance(_session_msgs, list):
|
||||
_active_agent_ref.shutdown_memory_provider(_session_msgs)
|
||||
else:
|
||||
_active_agent_ref.shutdown_memory_provider()
|
||||
_active_agent_ref.shutdown_memory_provider(
|
||||
getattr(_active_agent_ref, 'conversation_history', None) or []
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -983,7 +974,6 @@ def _run_state_db_auto_maintenance(session_db) -> None:
|
||||
return
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
from hermes_constants import get_hermes_home as _get_hermes_home
|
||||
cfg = (_load_full_config().get("sessions") or {})
|
||||
if not cfg.get("auto_prune", False):
|
||||
return
|
||||
@@ -991,35 +981,11 @@ def _run_state_db_auto_maintenance(session_db) -> None:
|
||||
retention_days=int(cfg.get("retention_days", 90)),
|
||||
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
|
||||
vacuum=bool(cfg.get("vacuum_after_prune", True)),
|
||||
sessions_dir=_get_hermes_home() / "sessions",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("state.db auto-maintenance skipped: %s", exc)
|
||||
|
||||
|
||||
def _run_checkpoint_auto_maintenance() -> None:
|
||||
"""Call ``checkpoint_manager.maybe_auto_prune_checkpoints`` using current config.
|
||||
|
||||
Reads the ``checkpoints:`` section from config.yaml via
|
||||
:func:`hermes_cli.config.load_config`. Honours ``auto_prune`` /
|
||||
``retention_days`` / ``delete_orphans`` / ``min_interval_hours``.
|
||||
Never raises — maintenance must never block interactive startup.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
cfg = (_load_full_config().get("checkpoints") or {})
|
||||
if not cfg.get("auto_prune", False):
|
||||
return
|
||||
from tools.checkpoint_manager import maybe_auto_prune_checkpoints
|
||||
maybe_auto_prune_checkpoints(
|
||||
retention_days=int(cfg.get("retention_days", 7)),
|
||||
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
|
||||
delete_orphans=bool(cfg.get("delete_orphans", True)),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("checkpoint auto-maintenance skipped: %s", exc)
|
||||
|
||||
|
||||
def _prune_stale_worktrees(repo_root: str, max_age_hours: int = 24) -> None:
|
||||
"""Remove stale worktrees and orphaned branches on startup.
|
||||
|
||||
@@ -1412,7 +1378,7 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
|
||||
|
||||
|
||||
def _format_process_notification(evt: dict) -> "str | None":
|
||||
"""Format a process notification event into a [IMPORTANT: ...] message.
|
||||
"""Format a process notification event into a [SYSTEM: ...] message.
|
||||
|
||||
Handles both completion events (notify_on_complete) and watch pattern
|
||||
match events from the unified completion_queue.
|
||||
@@ -1422,14 +1388,14 @@ def _format_process_notification(evt: dict) -> "str | None":
|
||||
_cmd = evt.get("command", "unknown")
|
||||
|
||||
if evt_type == "watch_disabled":
|
||||
return f"[IMPORTANT: {evt.get('message', '')}]"
|
||||
return f"[SYSTEM: {evt.get('message', '')}]"
|
||||
|
||||
if evt_type == "watch_match":
|
||||
_pat = evt.get("pattern", "?")
|
||||
_out = evt.get("output", "")
|
||||
_sup = evt.get("suppressed", 0)
|
||||
text = (
|
||||
f"[IMPORTANT: Background process {_sid} matched "
|
||||
f"[SYSTEM: Background process {_sid} matched "
|
||||
f"watch pattern \"{_pat}\".\n"
|
||||
f"Command: {_cmd}\n"
|
||||
f"Matched output:\n{_out}"
|
||||
@@ -1443,7 +1409,7 @@ def _format_process_notification(evt: dict) -> "str | None":
|
||||
_exit = evt.get("exit_code", "?")
|
||||
_out = evt.get("output", "")
|
||||
return (
|
||||
f"[IMPORTANT: Background process {_sid} completed "
|
||||
f"[SYSTEM: Background process {_sid} completed "
|
||||
f"(exit code {_exit}).\n"
|
||||
f"Command: {_cmd}\n"
|
||||
f"Output:\n{_out}]"
|
||||
@@ -1556,60 +1522,6 @@ def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool:
|
||||
return not pasted_text.strip()
|
||||
|
||||
|
||||
def _strip_leaked_bracketed_paste_wrappers(text: str) -> str:
|
||||
"""Strip leaked bracketed-paste wrapper markers from user-visible text.
|
||||
|
||||
Defensive normalization for cases where terminal/prompt_toolkit parsing
|
||||
fails and bracketed-paste markers end up in the buffer as literal text.
|
||||
|
||||
We strip canonical wrappers unconditionally and also handle degraded
|
||||
visible forms like ``[200~`` / ``[201~`` and ``00~`` / ``01~`` when they
|
||||
look like wrapper boundaries, not arbitrary user content.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
text = (
|
||||
text.replace("\x1b[200~", "")
|
||||
.replace("\x1b[201~", "")
|
||||
.replace("^[[200~", "")
|
||||
.replace("^[[201~", "")
|
||||
)
|
||||
text = re.sub(r"(^|[\s\n>:\]\)])\[200~", r"\1", text)
|
||||
text = re.sub(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])", "", text)
|
||||
text = re.sub(r"(^|[\s\n>:\]\)])00~", r"\1", text)
|
||||
text = re.sub(r"01~(?=$|[\s\n<\[\(\):;.,!?])", "", text)
|
||||
return text
|
||||
|
||||
|
||||
# Cursor Position Report (CPR / DSR) response, format ``ESC[<row>;<col>R``.
|
||||
# prompt_toolkit's _on_resize() + renderer send ``ESC[6n`` queries to the
|
||||
# terminal; under resize storms or tab switches the terminal's reply can
|
||||
# race past the input parser and end up in the input buffer as literal
|
||||
# text (see issue #14692). Also matches the visible-form ``^[[<row>;<col>R``
|
||||
# that appears when the ESC byte was stripped by a prior filter.
|
||||
_DSR_CPR_ESC_RE = re.compile(r"\x1b\[\d+;\d+R")
|
||||
_DSR_CPR_VISIBLE_RE = re.compile(r"\^\[\[\d+;\d+R")
|
||||
|
||||
|
||||
def _strip_leaked_terminal_responses(text: str) -> str:
|
||||
"""Strip leaked terminal control-response sequences from user input.
|
||||
|
||||
Covers Cursor Position Report (CPR / DSR) responses — ``ESC[<row>;<col>R``
|
||||
and the visible ``^[[<row>;<col>R`` form. These are replies the terminal
|
||||
sends back to queries prompt_toolkit makes during ``_on_resize`` /
|
||||
``_request_absolute_cursor_position``. When the input parser drops one
|
||||
(resize storms, multiplexer focus changes, slow PTYs) the response
|
||||
lands in the input buffer as literal text and corrupts what the user
|
||||
typed.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
text = _DSR_CPR_ESC_RE.sub("", text)
|
||||
text = _DSR_CPR_VISIBLE_RE.sub("", text)
|
||||
return text
|
||||
|
||||
|
||||
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
|
||||
"""Collect local image attachments for single-query CLI flows."""
|
||||
message = query or ""
|
||||
@@ -1936,16 +1848,9 @@ class HermesCLI:
|
||||
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
|
||||
# show_reasoning: display model thinking/reasoning before the response
|
||||
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
|
||||
# busy_input_mode: "interrupt" (Enter interrupts current run),
|
||||
# "queue" (Enter queues for next turn), or "steer" (Enter injects
|
||||
# mid-run via /steer, arriving after the next tool call).
|
||||
_bim = str(CLI_CONFIG["display"].get("busy_input_mode", "interrupt")).strip().lower()
|
||||
if _bim == "queue":
|
||||
self.busy_input_mode = "queue"
|
||||
elif _bim == "steer":
|
||||
self.busy_input_mode = "steer"
|
||||
else:
|
||||
self.busy_input_mode = "interrupt"
|
||||
# busy_input_mode: "interrupt" (Enter interrupts current run) or "queue" (Enter queues for next turn)
|
||||
_bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt")
|
||||
self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt"
|
||||
|
||||
self.verbose = verbose if verbose is not None else (self.tool_progress_mode == "verbose")
|
||||
|
||||
@@ -2140,11 +2045,6 @@ class HermesCLI:
|
||||
# Never blocks startup on failure.
|
||||
_run_state_db_auto_maintenance(self._session_db)
|
||||
|
||||
# Opportunistic shadow-repo cleanup — deletes orphan/stale
|
||||
# checkpoint repos under ~/.hermes/checkpoints/. Opt-in via
|
||||
# checkpoints.auto_prune, idempotent via .last_prune marker.
|
||||
_run_checkpoint_auto_maintenance()
|
||||
|
||||
# Deferred title: stored in memory until the session is created in the DB
|
||||
self._pending_title: Optional[str] = None
|
||||
|
||||
@@ -2218,42 +2118,6 @@ class HermesCLI:
|
||||
self._last_invalidate = now
|
||||
self._app.invalidate()
|
||||
|
||||
def _force_full_redraw(self) -> None:
|
||||
"""Force a clean full-screen repaint of the prompt_toolkit UI.
|
||||
|
||||
Used to recover from terminal buffer drift caused by external
|
||||
redraws we can't detect — e.g. macOS cmux / tmux tab switches,
|
||||
``clear`` issued from a subshell, or SSH window restores. These
|
||||
wipe or repaint the terminal without firing SIGWINCH, so
|
||||
prompt_toolkit's tracked ``_cursor_pos`` no longer matches reality
|
||||
and the next incremental redraw stacks on top of stale content
|
||||
(ghost status bars, duplicated prompts).
|
||||
|
||||
Bound to Ctrl+L and exposed as the ``/redraw`` slash command,
|
||||
matching the standard terminal-UX convention (bash, zsh, fish,
|
||||
vim, htop).
|
||||
"""
|
||||
app = getattr(self, "_app", None)
|
||||
if not app:
|
||||
return
|
||||
try:
|
||||
renderer = app.renderer
|
||||
out = renderer.output
|
||||
out.reset_attributes()
|
||||
out.erase_screen()
|
||||
out.cursor_goto(0, 0)
|
||||
out.flush()
|
||||
# Drop prompt_toolkit's cached screen + cursor state so the
|
||||
# next _redraw() starts from a known (0, 0) origin and
|
||||
# re-renders every cell rather than diffing against stale.
|
||||
renderer.reset(leave_alternate_screen=False)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
app.invalidate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _status_bar_context_style(self, percent_used: Optional[int]) -> str:
|
||||
if percent_used is None:
|
||||
return "class:status-bar-dim"
|
||||
@@ -5051,12 +4915,6 @@ class HermesCLI:
|
||||
if self.agent:
|
||||
self.agent.session_id = new_session_id
|
||||
self.agent.session_start = now
|
||||
# Redirect the JSON session log to the new branch session file so
|
||||
# messages written after branching land in the correct file.
|
||||
if hasattr(self.agent, "session_log_file") and hasattr(self.agent, "logs_dir"):
|
||||
self.agent.session_log_file = (
|
||||
self.agent.logs_dir / f"session_{new_session_id}.json"
|
||||
)
|
||||
self.agent.reset_session_state()
|
||||
if hasattr(self.agent, "_last_flushed_db_idx"):
|
||||
self.agent._last_flushed_db_idx = len(self.conversation_history)
|
||||
@@ -5078,37 +4936,22 @@ class HermesCLI:
|
||||
_cprint(f" Branch session: {new_session_id}")
|
||||
|
||||
def save_conversation(self):
|
||||
"""Save the current conversation to a JSON snapshot under ~/.hermes/sessions/saved/.
|
||||
|
||||
The snapshot is a convenience export for sharing or off-line inspection;
|
||||
every message is already persisted incrementally to the SQLite session
|
||||
DB, so the live session remains resumable via ``hermes --resume <id>``
|
||||
regardless of whether the user ever runs ``/save``.
|
||||
"""
|
||||
"""Save the current conversation to a file."""
|
||||
if not self.conversation_history:
|
||||
print("(;_;) No conversation to save.")
|
||||
return
|
||||
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
saved_dir = get_hermes_home() / "sessions" / "saved"
|
||||
filename = f"hermes_conversation_{timestamp}.json"
|
||||
|
||||
try:
|
||||
saved_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
print(f"(x_x) Failed to create save directory {saved_dir}: {e}")
|
||||
return
|
||||
path = saved_dir / f"hermes_conversation_{timestamp}.json"
|
||||
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
with open(filename, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"model": self.model,
|
||||
"session_id": self.session_id,
|
||||
"session_start": self.session_start.isoformat(),
|
||||
"messages": self.conversation_history,
|
||||
}, f, indent=2, ensure_ascii=False)
|
||||
print(f"(^_^)v Conversation snapshot saved to: {path}")
|
||||
if self.session_id:
|
||||
print(f" Resume the live session with: hermes --resume {self.session_id}")
|
||||
print(f"(^_^)v Conversation saved to: {filename}")
|
||||
except Exception as e:
|
||||
print(f"(x_x) Failed to save: {e}")
|
||||
|
||||
@@ -5975,7 +5818,28 @@ class HermesCLI:
|
||||
|
||||
print(f"(._.) Unknown cron command: {subcommand}")
|
||||
print(" Available: list, add, edit, pause, resume, run, remove")
|
||||
|
||||
|
||||
def _handle_kanban_command(self, cmd: str):
|
||||
"""Handle the /kanban command — delegate to the shared kanban CLI.
|
||||
|
||||
The string form passed here is the user's full ``/kanban ...``
|
||||
including the leading slash; we strip it and hand the remainder
|
||||
to ``kanban.run_slash`` which returns a single formatted string.
|
||||
"""
|
||||
from hermes_cli.kanban import run_slash
|
||||
|
||||
rest = cmd.strip()
|
||||
if rest.startswith("/"):
|
||||
rest = rest.lstrip("/")
|
||||
if rest.startswith("kanban"):
|
||||
rest = rest[len("kanban"):].lstrip()
|
||||
try:
|
||||
output = run_slash(rest)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
output = f"(._.) kanban error: {exc}"
|
||||
if output:
|
||||
print(output)
|
||||
|
||||
def _handle_skills_command(self, cmd: str):
|
||||
"""Handle /skills slash command — delegates to hermes_cli.skills_hub."""
|
||||
from hermes_cli.skills_hub import handle_skills_slash
|
||||
@@ -6000,7 +5864,6 @@ class HermesCLI:
|
||||
platform_status = {
|
||||
Platform.TELEGRAM: ("Telegram", "TELEGRAM_BOT_TOKEN"),
|
||||
Platform.DISCORD: ("Discord", "DISCORD_BOT_TOKEN"),
|
||||
Platform.SLACK: ("Slack", "SLACK_BOT_TOKEN"),
|
||||
Platform.WHATSAPP: ("WhatsApp", "WHATSAPP_ENABLED"),
|
||||
}
|
||||
|
||||
@@ -6071,12 +5934,6 @@ class HermesCLI:
|
||||
self.show_toolsets()
|
||||
elif canonical == "config":
|
||||
self.show_config()
|
||||
elif canonical == "redraw":
|
||||
# Manual recovery for terminal buffer drift from multiplexer
|
||||
# tab switches, subshell ``clear``, SSH window restores, etc.
|
||||
# See issue #8688 (cmux). Ctrl+L is bound to the same helper.
|
||||
self._force_full_redraw()
|
||||
_cprint(f" {_DIM}✓ UI redrawn{_RST}")
|
||||
elif canonical == "clear":
|
||||
self.new_session(silent=True)
|
||||
# Clear terminal screen. Inside the TUI, Rich's console.clear()
|
||||
@@ -6219,6 +6076,8 @@ class HermesCLI:
|
||||
self.save_conversation()
|
||||
elif canonical == "cron":
|
||||
self._handle_cron_command(cmd_original)
|
||||
elif canonical == "kanban":
|
||||
self._handle_kanban_command(cmd_original)
|
||||
elif canonical == "skills":
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._handle_skills_command(cmd_original)
|
||||
@@ -6471,12 +6330,6 @@ class HermesCLI:
|
||||
turn_route = self._resolve_turn_agent_config(prompt)
|
||||
|
||||
def run_background():
|
||||
set_sudo_password_callback(self._sudo_password_callback)
|
||||
set_approval_callback(self._approval_callback)
|
||||
try:
|
||||
set_secret_capture_callback(self._secret_capture_callback)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
bg_agent = AIAgent(
|
||||
model=turn_route["model"],
|
||||
@@ -6574,12 +6427,6 @@ class HermesCLI:
|
||||
print()
|
||||
_cprint(f" ❌ Background task #{task_num} failed: {e}")
|
||||
finally:
|
||||
try:
|
||||
set_sudo_password_callback(None)
|
||||
set_approval_callback(None)
|
||||
set_secret_capture_callback(None)
|
||||
except Exception:
|
||||
pass
|
||||
self._background_tasks.pop(task_id, None)
|
||||
# Clear spinner only if no foreground agent owns it
|
||||
if not self._agent_running:
|
||||
@@ -6974,36 +6821,24 @@ class HermesCLI:
|
||||
/busy Show current busy input mode
|
||||
/busy status Show current busy input mode
|
||||
/busy queue Queue input for the next turn instead of interrupting
|
||||
/busy steer Inject Enter mid-run via /steer (after next tool call)
|
||||
/busy interrupt Interrupt the current run on Enter (default)
|
||||
"""
|
||||
parts = cmd.strip().split(maxsplit=1)
|
||||
if len(parts) < 2 or parts[1].strip().lower() == "status":
|
||||
_cprint(f" {_ACCENT}Busy input mode: {self.busy_input_mode}{_RST}")
|
||||
if self.busy_input_mode == "queue":
|
||||
_behavior = "queues for next turn"
|
||||
elif self.busy_input_mode == "steer":
|
||||
_behavior = "steers into current run (after next tool call)"
|
||||
else:
|
||||
_behavior = "interrupts current run"
|
||||
_cprint(f" {_DIM}Enter while busy: {_behavior}{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}")
|
||||
_cprint(f" {_DIM}Enter while busy: {'queues for next turn' if self.busy_input_mode == 'queue' else 'interrupts current run'}{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /busy [queue|interrupt|status]{_RST}")
|
||||
return
|
||||
|
||||
arg = parts[1].strip().lower()
|
||||
if arg not in {"queue", "interrupt", "steer"}:
|
||||
if arg not in {"queue", "interrupt"}:
|
||||
_cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /busy [queue|interrupt|status]{_RST}")
|
||||
return
|
||||
|
||||
self.busy_input_mode = arg
|
||||
if save_config_value("display.busy_input_mode", arg):
|
||||
if arg == "queue":
|
||||
behavior = "Enter will queue follow-up input while Hermes is busy."
|
||||
elif arg == "steer":
|
||||
behavior = "Enter will steer your message into the current run (after the next tool call)."
|
||||
else:
|
||||
behavior = "Enter will interrupt the current run while Hermes is busy."
|
||||
behavior = "Enter will queue follow-up input while Hermes is busy." if arg == "queue" else "Enter will interrupt the current run while Hermes is busy."
|
||||
_cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (saved to config){_RST}")
|
||||
_cprint(f" {_DIM}{behavior}{_RST}")
|
||||
else:
|
||||
@@ -7405,7 +7240,7 @@ class HermesCLI:
|
||||
change_detail = ". ".join(change_parts) + ". " if change_parts else ""
|
||||
self.conversation_history.append({
|
||||
"role": "user",
|
||||
"content": f"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
|
||||
"content": f"[SYSTEM: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]",
|
||||
})
|
||||
|
||||
# Persist session immediately so the session log reflects the
|
||||
@@ -8442,62 +8277,13 @@ class HermesCLI:
|
||||
):
|
||||
return None
|
||||
|
||||
# Route image attachments based on the active model's vision capability.
|
||||
# "native" → pass pixels as OpenAI-style content parts (adapters
|
||||
# translate for Anthropic/Gemini/Bedrock).
|
||||
# "text" → pre-analyze each image with vision_analyze and prepend the
|
||||
# description as text — works with non-vision models.
|
||||
# See agent/image_routing.py for the decision table.
|
||||
# Pre-process images through the vision tool (Gemini Flash) so the
|
||||
# main model receives text descriptions instead of raw base64 image
|
||||
# content — works with any model, not just vision-capable ones.
|
||||
if images:
|
||||
try:
|
||||
from agent.image_routing import (
|
||||
build_native_content_parts,
|
||||
decide_image_input_mode,
|
||||
)
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
_img_mode = decide_image_input_mode(
|
||||
(self.provider or "").strip(),
|
||||
(self.model or "").strip(),
|
||||
load_config(),
|
||||
)
|
||||
except Exception as _img_exc:
|
||||
logging.debug("image_routing decision failed, defaulting to text: %s", _img_exc)
|
||||
_img_mode = "text"
|
||||
|
||||
if _img_mode == "native":
|
||||
try:
|
||||
_text_for_parts = message if isinstance(message, str) else ""
|
||||
_img_str_paths = [str(p) for p in images]
|
||||
_parts, _skipped = build_native_content_parts(
|
||||
_text_for_parts,
|
||||
_img_str_paths,
|
||||
)
|
||||
if _skipped:
|
||||
_cprint(
|
||||
f" {_DIM}⚠ skipped {len(_skipped)} unreadable image path(s){_RST}"
|
||||
)
|
||||
if any(p.get("type") == "image_url" for p in _parts):
|
||||
_img_names = ", ".join(Path(p).name for p in _img_str_paths)
|
||||
_cprint(
|
||||
f" {_DIM}📎 attaching {len(images)} image(s) natively "
|
||||
f"(model supports vision): {_img_names}{_RST}"
|
||||
)
|
||||
message = _parts
|
||||
else:
|
||||
# All images unreadable — fall back to text enrichment.
|
||||
message = self._preprocess_images_with_vision(
|
||||
message if isinstance(message, str) else "", images
|
||||
)
|
||||
except Exception as _img_exc:
|
||||
logging.warning("native image attach failed, falling back to text: %s", _img_exc)
|
||||
message = self._preprocess_images_with_vision(
|
||||
message if isinstance(message, str) else "", images
|
||||
)
|
||||
else:
|
||||
message = self._preprocess_images_with_vision(
|
||||
message if isinstance(message, str) else "", images
|
||||
)
|
||||
message = self._preprocess_images_with_vision(
|
||||
message if isinstance(message, str) else "", images
|
||||
)
|
||||
|
||||
# Expand @ context references (e.g. @file:main.py, @diff, @folder:src/)
|
||||
if isinstance(message, str) and "@" in message:
|
||||
@@ -8800,20 +8586,12 @@ class HermesCLI:
|
||||
if response and result and not result.get("failed") and not result.get("partial"):
|
||||
try:
|
||||
from agent.title_generator import maybe_auto_title
|
||||
# Route title-generation failures through the agent's
|
||||
# user-visible warning channel so a depleted auxiliary
|
||||
# provider doesn't silently leave sessions untitled
|
||||
# (issue #15775).
|
||||
_title_failure_cb = getattr(
|
||||
self.agent, "_emit_auxiliary_failure", None
|
||||
) if self.agent else None
|
||||
maybe_auto_title(
|
||||
self._session_db,
|
||||
self.session_id,
|
||||
message,
|
||||
response,
|
||||
self.conversation_history,
|
||||
failure_callback=_title_failure_cb,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -9236,30 +9014,6 @@ class HermesCLI:
|
||||
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
|
||||
_welcome_color = "#FFF8DC"
|
||||
self._console_print(f"[{_welcome_color}]{_welcome_text}[/]")
|
||||
# First-time OpenClaw-residue banner — fires once if ~/.openclaw/ exists
|
||||
# after an OpenClaw→Hermes migration (especially migrations done by
|
||||
# OpenClaw's own tool, which doesn't archive the source directory).
|
||||
try:
|
||||
from agent.onboarding import (
|
||||
OPENCLAW_RESIDUE_FLAG,
|
||||
detect_openclaw_residue,
|
||||
is_seen,
|
||||
mark_seen,
|
||||
openclaw_residue_hint_cli,
|
||||
)
|
||||
if not is_seen(self.config, OPENCLAW_RESIDUE_FLAG) and detect_openclaw_residue():
|
||||
try:
|
||||
_resid_color = _welcome_skin.get_color("banner_dim", "#B8860B")
|
||||
except Exception:
|
||||
_resid_color = "#B8860B"
|
||||
self._console_print(f"[{_resid_color}]{openclaw_residue_hint_cli()}[/]")
|
||||
try:
|
||||
from hermes_cli.config import get_config_path as _get_cfg_path_resid
|
||||
mark_seen(_get_cfg_path_resid(), OPENCLAW_RESIDUE_FLAG)
|
||||
except Exception:
|
||||
pass # best-effort — banner will fire again next session
|
||||
except Exception:
|
||||
pass # banner is non-critical — never break startup
|
||||
# Show a random tip to help users discover features
|
||||
try:
|
||||
from hermes_cli.tips import get_random_tip
|
||||
@@ -9461,34 +9215,12 @@ class HermesCLI:
|
||||
# Bundle text + images as a tuple when images are present
|
||||
payload = (text, images) if images else text
|
||||
if self._agent_running and not (text and _looks_like_slash_command(text)):
|
||||
_effective_mode = self.busy_input_mode
|
||||
if _effective_mode == "steer":
|
||||
# Route Enter through /steer — inject mid-run after the
|
||||
# next tool call. Images can't ride along (steer only
|
||||
# appends text), so fall back to queue when images are
|
||||
# attached. If the agent lacks steer() or rejects the
|
||||
# payload, also fall back to queue so nothing is lost.
|
||||
if images or not text:
|
||||
_effective_mode = "queue"
|
||||
else:
|
||||
accepted = False
|
||||
try:
|
||||
if self.agent is not None and hasattr(self.agent, "steer"):
|
||||
accepted = bool(self.agent.steer(text))
|
||||
except Exception as exc:
|
||||
_cprint(f" {_DIM}Steer failed ({exc}) — queued for next turn.{_RST}")
|
||||
accepted = False
|
||||
if accepted:
|
||||
preview = text[:80] + ("..." if len(text) > 80 else "")
|
||||
_cprint(f" {_ACCENT}⏩ Steered: '{preview}'{_RST}")
|
||||
else:
|
||||
_effective_mode = "queue"
|
||||
if _effective_mode == "queue":
|
||||
if self.busy_input_mode == "queue":
|
||||
# Queue for the next turn instead of interrupting
|
||||
self._pending_input.put(payload)
|
||||
preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]"
|
||||
_cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}")
|
||||
elif _effective_mode == "interrupt":
|
||||
else:
|
||||
self._interrupt_queue.put(payload)
|
||||
# Debug: log to file when message enters interrupt queue
|
||||
try:
|
||||
@@ -9691,17 +9423,6 @@ class HermesCLI:
|
||||
"""Down arrow: browse history when on last line, else move cursor down."""
|
||||
event.app.current_buffer.auto_down(count=event.arg)
|
||||
|
||||
@kb.add('c-l')
|
||||
def handle_ctrl_l(event):
|
||||
"""Ctrl+L: force a clean full-screen repaint.
|
||||
|
||||
Recovers the UI after external terminal buffer drift — tmux /
|
||||
cmux tab switches, ``clear`` from a subshell, SSH window
|
||||
restores, etc. — that prompt_toolkit can't detect on its own.
|
||||
Matches the universal bash/zsh/fish/vim/htop convention.
|
||||
"""
|
||||
self._force_full_redraw()
|
||||
|
||||
@kb.add('c-c')
|
||||
def handle_ctrl_c(event):
|
||||
"""Handle Ctrl+C - cancel interactive prompts, interrupt agent, or exit.
|
||||
@@ -9929,18 +9650,10 @@ class HermesCLI:
|
||||
placeholder while preserving any existing user text in the
|
||||
buffer.
|
||||
"""
|
||||
# Diagnostic canary: measure how long the paste handler blocks
|
||||
# the prompt_toolkit event loop. If this exceeds ~500ms we log
|
||||
# it so recurring "CLI freezes on paste" reports (issue #16263,
|
||||
# macOS Tahoe 26 + iTerm2/Ghostty) arrive with data attached.
|
||||
_paste_handler_start = time.perf_counter()
|
||||
_paste_raw_size = len(event.data or "")
|
||||
pasted_text = event.data or ""
|
||||
# Normalise line endings — Windows \r\n and old Mac \r both become \n
|
||||
# so the 5-line collapse threshold and display are consistent.
|
||||
pasted_text = pasted_text.replace('\r\n', '\n').replace('\r', '\n')
|
||||
pasted_text = _strip_leaked_bracketed_paste_wrappers(pasted_text)
|
||||
pasted_text = _strip_leaked_terminal_responses(pasted_text)
|
||||
if _should_auto_attach_clipboard_image_on_paste(pasted_text) and self._try_attach_clipboard_image():
|
||||
event.app.invalidate()
|
||||
if pasted_text:
|
||||
@@ -9963,17 +9676,6 @@ class HermesCLI:
|
||||
buf.insert_text(prefix + placeholder)
|
||||
else:
|
||||
buf.insert_text(pasted_text)
|
||||
_paste_handler_elapsed_ms = (time.perf_counter() - _paste_handler_start) * 1000.0
|
||||
if _paste_handler_elapsed_ms > 500.0:
|
||||
logger.warning(
|
||||
"Slow bracketed-paste handler: %.1fms to process %d bytes "
|
||||
"(%d lines) on %s. If the input becomes unresponsive after "
|
||||
"this, attach this log line to the bug report.",
|
||||
_paste_handler_elapsed_ms,
|
||||
_paste_raw_size,
|
||||
pasted_text.count('\n') + 1 if pasted_text else 0,
|
||||
sys.platform,
|
||||
)
|
||||
|
||||
@kb.add('c-v')
|
||||
def handle_ctrl_v(event):
|
||||
@@ -10093,16 +9795,7 @@ class HermesCLI:
|
||||
still batch newlines. Alt+Enter only adds 1 newline per
|
||||
event so it never triggers this.
|
||||
"""
|
||||
text = _strip_leaked_bracketed_paste_wrappers(buf.text)
|
||||
text = _strip_leaked_terminal_responses(text)
|
||||
if text != buf.text:
|
||||
cursor = min(buf.cursor_position, len(text))
|
||||
_paste_just_collapsed[0] = True
|
||||
buf.text = text
|
||||
buf.cursor_position = cursor
|
||||
_prev_text_len[0] = len(text)
|
||||
_prev_newline_count[0] = text.count('\n')
|
||||
return
|
||||
text = buf.text
|
||||
chars_added = len(text) - _prev_text_len[0]
|
||||
_prev_text_len[0] = len(text)
|
||||
if _paste_just_collapsed[0] or self._skip_paste_collapse:
|
||||
@@ -10171,7 +9864,7 @@ class HermesCLI:
|
||||
status = cli_ref._command_status or "Processing command..."
|
||||
return f"{frame} {status}"
|
||||
if cli_ref._agent_running:
|
||||
return "msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel"
|
||||
return "type a message + Enter to interrupt, Ctrl+C to cancel"
|
||||
if cli_ref._voice_mode:
|
||||
return "type or Ctrl+B to record"
|
||||
return ""
|
||||
@@ -10759,30 +10452,36 @@ class HermesCLI:
|
||||
# only cursor_up()s by the stored layout height, missing the extra
|
||||
# rows created by reflow — leaving ghost duplicates visible.
|
||||
#
|
||||
# It's not just column-shrink: widening, row-shrinking, and
|
||||
# multiplexer-driven SIGWINCH-less redraws (cmux / tmux tab switch)
|
||||
# all produce the same class of drift, where the renderer's tracked
|
||||
# _cursor_pos.y no longer matches terminal reality. The only reliable
|
||||
# recovery is a full screen-clear (\x1b[2J\x1b[H) before the next
|
||||
# redraw, so we force one on every resize rather than trying to
|
||||
# compute the exact drift.
|
||||
# Fix: before the standard erase, inflate _cursor_pos.y so the
|
||||
# cursor moves up far enough to cover the reflowed ghost content.
|
||||
_original_on_resize = app._on_resize
|
||||
|
||||
def _resize_clear_ghosts():
|
||||
from prompt_toolkit.data_structures import Point as _Pt
|
||||
renderer = app.renderer
|
||||
try:
|
||||
out = renderer.output
|
||||
# Reset attributes, erase the entire screen, and home the
|
||||
# cursor. This overwrites any reflowed status-bar rows or
|
||||
# stale content the terminal kept from the prior layout.
|
||||
out.reset_attributes()
|
||||
out.erase_screen()
|
||||
out.cursor_goto(0, 0)
|
||||
out.flush()
|
||||
# Tell the renderer its tracked position is fresh so its
|
||||
# own erase() inside _on_resize doesn't cursor_up() past
|
||||
# the top of the screen.
|
||||
renderer.reset(leave_alternate_screen=False)
|
||||
old_size = renderer._last_size
|
||||
new_size = renderer.output.get_size()
|
||||
if (
|
||||
old_size
|
||||
and new_size.columns < old_size.columns
|
||||
and new_size.columns > 0
|
||||
):
|
||||
reflow_factor = (
|
||||
(old_size.columns + new_size.columns - 1)
|
||||
// new_size.columns
|
||||
)
|
||||
last_h = (
|
||||
renderer._last_screen.height
|
||||
if renderer._last_screen
|
||||
else 0
|
||||
)
|
||||
extra = last_h * (reflow_factor - 1)
|
||||
if extra > 0:
|
||||
renderer._cursor_pos = _Pt(
|
||||
x=renderer._cursor_pos.x,
|
||||
y=renderer._cursor_pos.y + extra,
|
||||
)
|
||||
except Exception:
|
||||
pass # never break resize handling
|
||||
_original_on_resize()
|
||||
@@ -10790,6 +10489,7 @@ class HermesCLI:
|
||||
app._on_resize = _resize_clear_ghosts
|
||||
|
||||
def spinner_loop():
|
||||
last_idle_refresh = 0.0
|
||||
while not self._should_exit:
|
||||
if not self._app:
|
||||
time.sleep(0.1)
|
||||
@@ -10798,11 +10498,10 @@ class HermesCLI:
|
||||
self._invalidate(min_interval=0.1)
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
# Do not repaint the idle prompt every second. In non-full-screen
|
||||
# prompt_toolkit mode, background redraws can fight tmux/Ghostty/cmux
|
||||
# viewport restoration after focus changes and visually move the
|
||||
# command input area. Keep idle stable; input/agent events still
|
||||
# invalidate explicitly when the UI actually changes.
|
||||
now = time.monotonic()
|
||||
if now - last_idle_refresh >= 1.0:
|
||||
last_idle_refresh = now
|
||||
self._invalidate(min_interval=1.0)
|
||||
time.sleep(0.2)
|
||||
|
||||
spinner_thread = threading.Thread(target=spinner_loop, daemon=True)
|
||||
@@ -10844,10 +10543,6 @@ class HermesCLI:
|
||||
submit_images = []
|
||||
if isinstance(user_input, tuple):
|
||||
user_input, submit_images = user_input
|
||||
|
||||
if isinstance(user_input, str):
|
||||
user_input = _strip_leaked_bracketed_paste_wrappers(user_input)
|
||||
user_input = _strip_leaked_terminal_responses(user_input)
|
||||
|
||||
# Check for commands — but detect dragged/pasted file paths first.
|
||||
# See _detect_file_drop() for details.
|
||||
|
||||
+3
-31
@@ -311,12 +311,6 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None
|
||||
|
||||
elif schedule["kind"] == "cron":
|
||||
if not HAS_CRONITER:
|
||||
logger.warning(
|
||||
"Cannot compute next run for cron schedule %r: 'croniter' "
|
||||
"is not installed. Install the 'cron' extra (pip install "
|
||||
"'hermes-agent[cron]') to re-enable recurring cron jobs.",
|
||||
schedule.get("expr"),
|
||||
)
|
||||
return None
|
||||
cron = croniter(schedule["expr"], now)
|
||||
next_run = cron.get_next(datetime)
|
||||
@@ -704,32 +698,10 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,
|
||||
# Compute next run
|
||||
job["next_run_at"] = compute_next_run(job["schedule"], now)
|
||||
|
||||
# If no next run, decide whether this is terminal completion
|
||||
# (one-shot) or a transient failure (recurring schedule couldn't
|
||||
# compute — e.g. 'croniter' missing from the runtime env).
|
||||
# Recurring jobs must NEVER be silently disabled: that turns a
|
||||
# missing runtime dep into "job completed" and the user's
|
||||
# schedule quietly goes off. See issue #16265.
|
||||
# If no next run (one-shot completed), disable
|
||||
if job["next_run_at"] is None:
|
||||
kind = job.get("schedule", {}).get("kind")
|
||||
if kind in ("cron", "interval"):
|
||||
job["state"] = "error"
|
||||
if not job.get("last_error"):
|
||||
job["last_error"] = (
|
||||
"Failed to compute next run for recurring "
|
||||
"schedule (is the 'croniter' package "
|
||||
"installed in the gateway's Python env?)"
|
||||
)
|
||||
logger.error(
|
||||
"Job '%s' (%s) could not compute next_run_at; "
|
||||
"leaving enabled and marking state=error so the "
|
||||
"job is not silently disabled.",
|
||||
job.get("name", job["id"]),
|
||||
kind,
|
||||
)
|
||||
else:
|
||||
job["enabled"] = False
|
||||
job["state"] = "completed"
|
||||
job["enabled"] = False
|
||||
job["state"] = "completed"
|
||||
elif job.get("state") != "paused":
|
||||
job["state"] = "scheduled"
|
||||
|
||||
|
||||
+4
-36
@@ -77,7 +77,7 @@ _KNOWN_DELIVERY_PLATFORMS = frozenset({
|
||||
"telegram", "discord", "slack", "whatsapp", "signal",
|
||||
"matrix", "mattermost", "homeassistant", "dingtalk", "feishu",
|
||||
"wecom", "wecom_callback", "weixin", "sms", "email", "webhook", "bluebubbles",
|
||||
"qqbot", "yuanbao",
|
||||
"qqbot",
|
||||
})
|
||||
|
||||
# Platforms that support a configured cron/notification home target, mapped to
|
||||
@@ -337,7 +337,6 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
"sms": Platform.SMS,
|
||||
"bluebubbles": Platform.BLUEBUBBLES,
|
||||
"qqbot": Platform.QQBOT,
|
||||
"yuanbao": Platform.YUANBAO,
|
||||
}
|
||||
|
||||
# Optionally wrap the content with a header/footer so the user knows this
|
||||
@@ -716,7 +715,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
# Always prepend cron execution guidance so the agent knows how
|
||||
# delivery works and can suppress delivery when appropriate.
|
||||
cron_hint = (
|
||||
"[IMPORTANT: You are running as a scheduled cron job. "
|
||||
"[SYSTEM: You are running as a scheduled cron job. "
|
||||
"DELIVERY: Your final response will be automatically delivered "
|
||||
"to the user — do NOT use send_message or try to deliver "
|
||||
"the output yourself. Just produce your report/output as your "
|
||||
@@ -752,7 +751,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
parts.append("")
|
||||
parts.extend(
|
||||
[
|
||||
f'[IMPORTANT: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
|
||||
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
|
||||
"",
|
||||
content,
|
||||
]
|
||||
@@ -760,7 +759,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
|
||||
if skipped:
|
||||
notice = (
|
||||
f"[IMPORTANT: The following skill(s) were listed for this job but could not be found "
|
||||
f"[SYSTEM: The following skill(s) were listed for this job but could not be found "
|
||||
f"and were skipped: {', '.join(skipped)}. "
|
||||
f"Start your response with a brief notice so the user is aware, e.g.: "
|
||||
f"'⚠️ Skill(s) not found and skipped: {', '.join(skipped)}']"
|
||||
@@ -822,8 +821,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
logger.info("Running job '%s' (ID: %s)", job_name, job_id)
|
||||
logger.info("Prompt: %s", prompt[:100])
|
||||
|
||||
agent = None
|
||||
|
||||
# Mark this as a cron session so the approval system can apply cron_mode.
|
||||
# This env var is process-wide and persists for the lifetime of the
|
||||
# scheduler process — every job this process runs is a cron job.
|
||||
@@ -1172,24 +1169,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
_session_db.close()
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Job '%s': failed to close SQLite session store: %s", job_id, e)
|
||||
# Release subprocesses, terminal sandboxes, browser daemons, and the
|
||||
# main OpenAI/httpx client held by this ephemeral cron agent. Without
|
||||
# this, a gateway that ticks cron every N minutes leaks fds per job
|
||||
# until it hits EMFILE (#10200 / "too many open files").
|
||||
try:
|
||||
if agent is not None:
|
||||
agent.close()
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Job '%s': failed to close agent resources: %s", job_id, e)
|
||||
# Each cron run spins up a short-lived worker thread whose event loop
|
||||
# dies as soon as the ``ThreadPoolExecutor`` shuts down. Any async
|
||||
# httpx clients cached under that loop are now unusable — reap them
|
||||
# so their transports don't accumulate in the process-global cache.
|
||||
try:
|
||||
from agent.auxiliary_client import cleanup_stale_async_clients
|
||||
cleanup_stale_async_clients()
|
||||
except Exception as e:
|
||||
logger.debug("Job '%s': failed to reap stale auxiliary clients: %s", job_id, e)
|
||||
|
||||
|
||||
def tick(verbose: bool = True, adapters=None, loop=None) -> int:
|
||||
@@ -1329,17 +1308,6 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
|
||||
_futures.append(_tick_pool.submit(_ctx.run, _process_job, job))
|
||||
_results.extend(f.result() for f in _futures)
|
||||
|
||||
# Best-effort sweep of MCP stdio subprocesses that survived their
|
||||
# session teardown during this tick. Runs AFTER every job has
|
||||
# finished so active sessions (including live user chats) are
|
||||
# never touched — only PIDs explicitly detected as orphans in
|
||||
# tools.mcp_tool._run_stdio's finally block are reaped.
|
||||
try:
|
||||
from tools.mcp_tool import _kill_orphaned_mcp_children
|
||||
_kill_orphaned_mcp_children()
|
||||
except Exception as _e:
|
||||
logger.debug("Post-tick MCP orphan cleanup failed: %s", _e)
|
||||
|
||||
return sum(_results)
|
||||
finally:
|
||||
if fcntl:
|
||||
|
||||
Binary file not shown.
@@ -36,7 +36,6 @@
|
||||
|
||||
imports = [
|
||||
./nix/packages.nix
|
||||
./nix/overlays.nix
|
||||
./nix/nixosModules.nix
|
||||
./nix/checks.nix
|
||||
./nix/devShell.nix
|
||||
|
||||
@@ -57,7 +57,7 @@ def _session_entry_name(origin: Dict[str, Any]) -> str:
|
||||
# Build / refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Build a channel directory from connected platform adapters and session data.
|
||||
|
||||
@@ -72,7 +72,7 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
if platform == Platform.DISCORD:
|
||||
platforms["discord"] = _build_discord(adapter)
|
||||
elif platform == Platform.SLACK:
|
||||
platforms["slack"] = await _build_slack(adapter)
|
||||
platforms["slack"] = _build_slack(adapter)
|
||||
except Exception as e:
|
||||
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
|
||||
|
||||
@@ -136,66 +136,21 @@ def _build_discord(adapter) -> List[Dict[str, str]]:
|
||||
return channels
|
||||
|
||||
|
||||
async def _build_slack(adapter) -> List[Dict[str, Any]]:
|
||||
"""List Slack channels the bot has joined across all workspaces.
|
||||
|
||||
Uses ``users.conversations`` against each workspace's web client. Pulls
|
||||
public + private channels the bot is a member of, then merges in DMs
|
||||
discovered from session history (IMs aren't useful to enumerate
|
||||
proactively).
|
||||
"""
|
||||
team_clients = getattr(adapter, "_team_clients", None) or {}
|
||||
if not team_clients:
|
||||
def _build_slack(adapter) -> List[Dict[str, str]]:
|
||||
"""List Slack channels the bot has joined."""
|
||||
# Slack adapter may expose a web client
|
||||
client = getattr(adapter, "_app", None) or getattr(adapter, "_client", None)
|
||||
if not client:
|
||||
return _build_from_sessions("slack")
|
||||
|
||||
channels: List[Dict[str, Any]] = []
|
||||
seen_ids: set = set()
|
||||
try:
|
||||
from tools.send_message_tool import _send_slack # noqa: F401
|
||||
# Use the Slack Web API directly if available
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for team_id, client in team_clients.items():
|
||||
try:
|
||||
cursor: Optional[str] = None
|
||||
for _page in range(20): # safety cap on pagination
|
||||
response = await client.users_conversations(
|
||||
types="public_channel,private_channel",
|
||||
exclude_archived=True,
|
||||
limit=200,
|
||||
cursor=cursor,
|
||||
)
|
||||
if not response.get("ok"):
|
||||
logger.warning(
|
||||
"Channel directory: users.conversations not ok for team %s: %s",
|
||||
team_id,
|
||||
response.get("error", "unknown"),
|
||||
)
|
||||
break
|
||||
for ch in response.get("channels", []):
|
||||
cid = ch.get("id")
|
||||
name = ch.get("name")
|
||||
if not cid or not name or cid in seen_ids:
|
||||
continue
|
||||
seen_ids.add(cid)
|
||||
channels.append({
|
||||
"id": cid,
|
||||
"name": name,
|
||||
"type": "private" if ch.get("is_private") else "channel",
|
||||
})
|
||||
cursor = (response.get("response_metadata") or {}).get("next_cursor")
|
||||
if not cursor:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Channel directory: failed to list Slack channels for team %s: %s",
|
||||
team_id, e,
|
||||
)
|
||||
continue
|
||||
|
||||
# Merge in DM/group entries discovered from session history.
|
||||
for entry in _build_from_sessions("slack"):
|
||||
if entry.get("id") not in seen_ids:
|
||||
channels.append(entry)
|
||||
seen_ids.add(entry.get("id"))
|
||||
|
||||
return channels
|
||||
# Fallback to session data
|
||||
return _build_from_sessions("slack")
|
||||
|
||||
|
||||
def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
|
||||
@@ -268,14 +223,6 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
|
||||
if not channels:
|
||||
return None
|
||||
|
||||
# 0. Exact ID match — case-sensitive, no normalization. Lets callers pass
|
||||
# raw platform IDs (e.g. Slack "C0B0QV5434G") even when the format guard
|
||||
# in _parse_target_ref hasn't recognized them as explicit.
|
||||
raw = name.strip()
|
||||
for ch in channels:
|
||||
if ch.get("id") == raw:
|
||||
return ch["id"]
|
||||
|
||||
query = _normalize_channel_query(name)
|
||||
|
||||
# 1. Exact name match, including the display labels shown by send_message(action="list")
|
||||
|
||||
+3
-84
@@ -67,7 +67,6 @@ class Platform(Enum):
|
||||
WEIXIN = "weixin"
|
||||
BLUEBUBBLES = "bluebubbles"
|
||||
QQBOT = "qqbot"
|
||||
YUANBAO = "yuanbao"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -196,14 +195,6 @@ class StreamingConfig:
|
||||
edit_interval: float = 1.0 # Seconds between message edits (Telegram rate-limits at ~1/s)
|
||||
buffer_threshold: int = 40 # Chars before forcing an edit
|
||||
cursor: str = " ▉" # Cursor shown during streaming
|
||||
# Ported from openclaw/openclaw#72038. When >0, the final edit for
|
||||
# a long-running streamed response is delivered as a fresh message
|
||||
# if the original preview has been visible for at least this many
|
||||
# seconds, so the platform's visible timestamp reflects completion
|
||||
# time instead of the preview creation time. Currently applied to
|
||||
# Telegram only (other platforms ignore the setting). Default 60s
|
||||
# matches the OpenClaw rollout. Set to 0 to disable.
|
||||
fresh_final_after_seconds: float = 60.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -212,7 +203,6 @@ class StreamingConfig:
|
||||
"edit_interval": self.edit_interval,
|
||||
"buffer_threshold": self.buffer_threshold,
|
||||
"cursor": self.cursor,
|
||||
"fresh_final_after_seconds": self.fresh_final_after_seconds,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -225,9 +215,6 @@ class StreamingConfig:
|
||||
edit_interval=float(data.get("edit_interval", 1.0)),
|
||||
buffer_threshold=int(data.get("buffer_threshold", 40)),
|
||||
cursor=data.get("cursor", " ▉"),
|
||||
fresh_final_after_seconds=float(
|
||||
data.get("fresh_final_after_seconds", 60.0)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -327,9 +314,6 @@ class GatewayConfig:
|
||||
# QQBot uses extra dict for app credentials
|
||||
elif platform == Platform.QQBOT and config.extra.get("app_id") and config.extra.get("client_secret"):
|
||||
connected.append(platform)
|
||||
# Yuanbao uses extra dict for app credentials
|
||||
elif platform == Platform.YUANBAO and config.extra.get("app_id") and config.extra.get("app_secret"):
|
||||
connected.append(platform)
|
||||
# DingTalk uses client_id/client_secret from config.extra or env vars
|
||||
elif platform == Platform.DINGTALK and (
|
||||
config.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID")
|
||||
@@ -566,8 +550,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
existing = {}
|
||||
# Deep-merge extra dicts so gateway.json defaults survive
|
||||
merged_extra = {**existing.get("extra", {}), **plat_block.get("extra", {})}
|
||||
if plat_name == Platform.SLACK.value and "enabled" in plat_block:
|
||||
merged_extra["_enabled_explicit"] = True
|
||||
merged = {**existing, **plat_block}
|
||||
if merged_extra:
|
||||
merged["extra"] = merged_extra
|
||||
@@ -588,8 +570,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
)
|
||||
if "reply_prefix" in platform_cfg:
|
||||
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
|
||||
if "reply_in_thread" in platform_cfg:
|
||||
bridged["reply_in_thread"] = platform_cfg["reply_in_thread"]
|
||||
if "require_mention" in platform_cfg:
|
||||
bridged["require_mention"] = platform_cfg["require_mention"]
|
||||
if "free_response_channels" in platform_cfg:
|
||||
@@ -604,7 +584,7 @@ def load_gateway_config() -> GatewayConfig:
|
||||
bridged["group_policy"] = platform_cfg["group_policy"]
|
||||
if "group_allow_from" in platform_cfg:
|
||||
bridged["group_allow_from"] = platform_cfg["group_allow_from"]
|
||||
if plat in (Platform.DISCORD, Platform.SLACK) and "channel_skill_bindings" in platform_cfg:
|
||||
if plat == Platform.DISCORD and "channel_skill_bindings" in platform_cfg:
|
||||
bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"]
|
||||
if "channel_prompts" in platform_cfg:
|
||||
channel_prompts = platform_cfg["channel_prompts"]
|
||||
@@ -612,21 +592,16 @@ def load_gateway_config() -> GatewayConfig:
|
||||
bridged["channel_prompts"] = {str(k): v for k, v in channel_prompts.items()}
|
||||
else:
|
||||
bridged["channel_prompts"] = channel_prompts
|
||||
enabled_was_explicit = "enabled" in platform_cfg
|
||||
if not bridged and not enabled_was_explicit:
|
||||
if not bridged:
|
||||
continue
|
||||
plat_data = platforms_data.setdefault(plat.value, {})
|
||||
if not isinstance(plat_data, dict):
|
||||
plat_data = {}
|
||||
platforms_data[plat.value] = plat_data
|
||||
if enabled_was_explicit:
|
||||
plat_data["enabled"] = platform_cfg["enabled"]
|
||||
extra = plat_data.setdefault("extra", {})
|
||||
if not isinstance(extra, dict):
|
||||
extra = {}
|
||||
plat_data["extra"] = extra
|
||||
if plat == Platform.SLACK and enabled_was_explicit:
|
||||
extra["_enabled_explicit"] = True
|
||||
extra.update(bridged)
|
||||
|
||||
# Slack settings → env vars (env vars take precedence)
|
||||
@@ -634,8 +609,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(slack_cfg, dict):
|
||||
if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"):
|
||||
os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower()
|
||||
if "strict_mention" in slack_cfg and not os.getenv("SLACK_STRICT_MENTION"):
|
||||
os.environ["SLACK_STRICT_MENTION"] = str(slack_cfg["strict_mention"]).lower()
|
||||
if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"):
|
||||
os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower()
|
||||
frc = slack_cfg.get("free_response_channels")
|
||||
@@ -945,20 +918,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
slack_token = os.getenv("SLACK_BOT_TOKEN")
|
||||
if slack_token:
|
||||
if Platform.SLACK not in config.platforms:
|
||||
# No yaml config for Slack — env-only setup, enable it
|
||||
config.platforms[Platform.SLACK] = PlatformConfig()
|
||||
config.platforms[Platform.SLACK].enabled = True
|
||||
else:
|
||||
slack_config = config.platforms[Platform.SLACK]
|
||||
enabled_was_explicit = bool(slack_config.extra.pop("_enabled_explicit", False))
|
||||
if not slack_config.enabled and not enabled_was_explicit:
|
||||
# Top-level Slack settings such as channel prompts should not
|
||||
# turn an env-token setup into a disabled platform. Only an
|
||||
# explicit slack.enabled/platforms.slack.enabled false should.
|
||||
slack_config.enabled = True
|
||||
# If yaml config exists, respect its enabled flag (don't override
|
||||
# explicit enabled: false). Token is still stored so skills that
|
||||
# send Slack messages can use it without activating the gateway adapter.
|
||||
config.platforms[Platform.SLACK].enabled = True
|
||||
config.platforms[Platform.SLACK].token = slack_token
|
||||
slack_home = os.getenv("SLACK_HOME_CHANNEL")
|
||||
if slack_home and Platform.SLACK in config.platforms:
|
||||
@@ -1315,48 +1276,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
name=os.getenv("QQBOT_HOME_CHANNEL_NAME") or os.getenv(qq_home_name_env, "Home"),
|
||||
)
|
||||
|
||||
# Yuanbao — YUANBAO_APP_ID preferred
|
||||
yuanbao_app_id = os.getenv("YUANBAO_APP_ID") or os.getenv("YUANBAO_APP_KEY")
|
||||
yuanbao_app_secret = os.getenv("YUANBAO_APP_SECRET")
|
||||
if yuanbao_app_id and yuanbao_app_secret:
|
||||
if Platform.YUANBAO not in config.platforms:
|
||||
config.platforms[Platform.YUANBAO] = PlatformConfig()
|
||||
config.platforms[Platform.YUANBAO].enabled = True
|
||||
extra = config.platforms[Platform.YUANBAO].extra
|
||||
extra["app_id"] = yuanbao_app_id
|
||||
extra["app_secret"] = yuanbao_app_secret
|
||||
yuanbao_bot_id = os.getenv("YUANBAO_BOT_ID")
|
||||
if yuanbao_bot_id:
|
||||
extra["bot_id"] = yuanbao_bot_id
|
||||
yuanbao_ws_url = os.getenv("YUANBAO_WS_URL")
|
||||
if yuanbao_ws_url:
|
||||
extra["ws_url"] = yuanbao_ws_url
|
||||
yuanbao_api_domain = os.getenv("YUANBAO_API_DOMAIN")
|
||||
if yuanbao_api_domain:
|
||||
extra["api_domain"] = yuanbao_api_domain
|
||||
yuanbao_route_env = os.getenv("YUANBAO_ROUTE_ENV")
|
||||
if yuanbao_route_env:
|
||||
extra["route_env"] = yuanbao_route_env
|
||||
yuanbao_home = os.getenv("YUANBAO_HOME_CHANNEL")
|
||||
if yuanbao_home:
|
||||
config.platforms[Platform.YUANBAO].home_channel = HomeChannel(
|
||||
platform=Platform.YUANBAO,
|
||||
chat_id=yuanbao_home,
|
||||
name=os.getenv("YUANBAO_HOME_CHANNEL_NAME", "Home"),
|
||||
)
|
||||
yuanbao_dm_policy = os.getenv("YUANBAO_DM_POLICY")
|
||||
if yuanbao_dm_policy:
|
||||
extra["dm_policy"] = yuanbao_dm_policy.strip().lower()
|
||||
yuanbao_dm_allow_from = os.getenv("YUANBAO_DM_ALLOW_FROM")
|
||||
if yuanbao_dm_allow_from:
|
||||
extra["dm_allow_from"] = yuanbao_dm_allow_from
|
||||
yuanbao_group_policy = os.getenv("YUANBAO_GROUP_POLICY")
|
||||
if yuanbao_group_policy:
|
||||
extra["group_policy"] = yuanbao_group_policy.strip().lower()
|
||||
yuanbao_group_allow_from = os.getenv("YUANBAO_GROUP_ALLOW_FROM")
|
||||
if yuanbao_group_allow_from:
|
||||
extra["group_allow_from"] = yuanbao_group_allow_from
|
||||
|
||||
# Session settings
|
||||
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
||||
if idle_minutes:
|
||||
|
||||
@@ -79,9 +79,7 @@ _PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"discord": _TIER_HIGH,
|
||||
|
||||
# Tier 2 — edit support, often customer/workspace channels
|
||||
# Slack: tool_progress off by default — Bolt posts cannot be edited like CLI;
|
||||
# "new"/"all" spam permanent lines in channels (hermes-agent#14663).
|
||||
"slack": {**_TIER_MEDIUM, "tool_progress": "off"},
|
||||
"slack": _TIER_MEDIUM,
|
||||
"mattermost": _TIER_MEDIUM,
|
||||
"matrix": _TIER_MEDIUM,
|
||||
"feishu": _TIER_MEDIUM,
|
||||
|
||||
+16
-3
@@ -21,6 +21,7 @@ Errors in hooks are caught and logged but never block the main pipeline.
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
@@ -103,16 +104,28 @@ class HookRegistry:
|
||||
print(f"[hooks] Skipping {hook_name}: no events declared", flush=True)
|
||||
continue
|
||||
|
||||
# Dynamically load the handler module
|
||||
# Dynamically load the handler module.
|
||||
# Register in sys.modules BEFORE exec_module so Pydantic /
|
||||
# dataclasses / typing introspection can resolve forward
|
||||
# references (triggered by `from __future__ import annotations`
|
||||
# in the handler). Without this, a handler that declares a
|
||||
# Pydantic BaseModel for webhook/event payloads fails at first
|
||||
# dispatch with "TypeAdapter ... is not fully defined".
|
||||
module_name = f"hermes_hook_{hook_name}"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"hermes_hook_{hook_name}", handler_path
|
||||
module_name, handler_path
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
print(f"[hooks] Skipping {hook_name}: could not load handler.py", flush=True)
|
||||
continue
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
sys.modules[module_name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception:
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
|
||||
handle_fn = getattr(module, "handle", None)
|
||||
if handle_fn is None:
|
||||
|
||||
+11
-57
@@ -28,7 +28,6 @@ def mirror_to_session(
|
||||
message_text: str,
|
||||
source_label: str = "cli",
|
||||
thread_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Append a delivery-mirror message to the target session's transcript.
|
||||
@@ -40,20 +39,9 @@ def mirror_to_session(
|
||||
All errors are caught -- this is never fatal.
|
||||
"""
|
||||
try:
|
||||
session_id = _find_session_id(
|
||||
platform,
|
||||
str(chat_id),
|
||||
thread_id=thread_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
session_id = _find_session_id(platform, str(chat_id), thread_id=thread_id)
|
||||
if not session_id:
|
||||
logger.debug(
|
||||
"Mirror: no session found for %s:%s:%s:%s",
|
||||
platform,
|
||||
chat_id,
|
||||
thread_id,
|
||||
user_id,
|
||||
)
|
||||
logger.debug("Mirror: no session found for %s:%s:%s", platform, chat_id, thread_id)
|
||||
return False
|
||||
|
||||
mirror_msg = {
|
||||
@@ -71,33 +59,17 @@ def mirror_to_session(
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Mirror failed for %s:%s:%s:%s: %s",
|
||||
platform,
|
||||
chat_id,
|
||||
thread_id,
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
logger.debug("Mirror failed for %s:%s:%s: %s", platform, chat_id, thread_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def _find_session_id(
|
||||
platform: str,
|
||||
chat_id: str,
|
||||
thread_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
def _find_session_id(platform: str, chat_id: str, thread_id: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Find the active session_id for a platform + chat_id pair.
|
||||
|
||||
Scans sessions.json entries and matches where origin.chat_id == chat_id
|
||||
on the right platform. DM session keys don't embed the chat_id
|
||||
(e.g. "agent:main:telegram:dm"), so we check the origin dict.
|
||||
|
||||
When *user_id* is provided, prefer exact sender matches. If multiple
|
||||
same-chat candidates exist and none matches the user, return None instead
|
||||
of guessing and contaminating another participant's session.
|
||||
"""
|
||||
if not _SESSIONS_INDEX.exists():
|
||||
return None
|
||||
@@ -109,7 +81,8 @@ def _find_session_id(
|
||||
return None
|
||||
|
||||
platform_lower = platform.lower()
|
||||
candidates = []
|
||||
best_match = None
|
||||
best_updated = ""
|
||||
|
||||
for _key, entry in data.items():
|
||||
origin = entry.get("origin") or {}
|
||||
@@ -123,31 +96,12 @@ def _find_session_id(
|
||||
origin_thread_id = origin.get("thread_id")
|
||||
if thread_id is not None and str(origin_thread_id or "") != str(thread_id):
|
||||
continue
|
||||
candidates.append(entry)
|
||||
updated = entry.get("updated_at", "")
|
||||
if updated > best_updated:
|
||||
best_updated = updated
|
||||
best_match = entry.get("session_id")
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
if user_id:
|
||||
exact_user_matches = [
|
||||
entry for entry in candidates
|
||||
if str((entry.get("origin") or {}).get("user_id") or "") == str(user_id)
|
||||
]
|
||||
if exact_user_matches:
|
||||
candidates = exact_user_matches
|
||||
elif len(candidates) > 1:
|
||||
return None
|
||||
elif len(candidates) > 1:
|
||||
distinct_user_ids = {
|
||||
str((entry.get("origin") or {}).get("user_id") or "").strip()
|
||||
for entry in candidates
|
||||
if str((entry.get("origin") or {}).get("user_id") or "").strip()
|
||||
}
|
||||
if len(distinct_user_ids) > 1:
|
||||
return None
|
||||
|
||||
best_entry = max(candidates, key=lambda entry: entry.get("updated_at", ""))
|
||||
return best_entry.get("session_id")
|
||||
return best_match
|
||||
|
||||
|
||||
def _append_to_jsonl(session_id: str, message: dict) -> None:
|
||||
|
||||
@@ -10,12 +10,10 @@ Each adapter handles:
|
||||
|
||||
from .base import BasePlatformAdapter, MessageEvent, SendResult
|
||||
from .qqbot import QQAdapter
|
||||
from .yuanbao import YuanbaoAdapter
|
||||
|
||||
__all__ = [
|
||||
"BasePlatformAdapter",
|
||||
"MessageEvent",
|
||||
"SendResult",
|
||||
"QQAdapter",
|
||||
"YuanbaoAdapter",
|
||||
]
|
||||
|
||||
+12
-166
@@ -307,14 +307,9 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
||||
"""Build kwargs for standalone ``aiohttp.ClientSession`` with proxy.
|
||||
|
||||
Returns ``(session_kwargs, request_kwargs)`` where:
|
||||
- With aiohttp-socks → ``({"connector": ProxyConnector(...)}, {})``
|
||||
for *all* proxy schemes (SOCKS **and** HTTP/HTTPS).
|
||||
- HTTP without aiohttp-socks → ``({}, {"proxy": url})``.
|
||||
- None → ``({}, {})``.
|
||||
|
||||
Prefer the connector path: it works transparently with libraries
|
||||
(like mautrix) that call ``session.request()`` without forwarding
|
||||
per-request ``proxy=`` kwargs.
|
||||
- SOCKS → ``({"connector": ProxyConnector(...)}, {})``
|
||||
- HTTP → ``({}, {"proxy": url})``
|
||||
- None → ``({}, {})``
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -325,53 +320,20 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
|
||||
"""
|
||||
if not proxy_url:
|
||||
return {}, {}
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}, {}
|
||||
except ImportError:
|
||||
if proxy_url.lower().startswith("socks"):
|
||||
connector = ProxyConnector.from_url(proxy_url, rdns=True)
|
||||
return {"connector": connector}, {}
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
|
||||
"Run: pip install aiohttp-socks",
|
||||
proxy_url,
|
||||
)
|
||||
return {}, {}
|
||||
return {}, {"proxy": proxy_url}
|
||||
|
||||
|
||||
def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool:
|
||||
"""Return True when ``hostname`` matches a ``NO_PROXY`` entry.
|
||||
|
||||
Supports comma- or whitespace-separated entries with optional leading dots
|
||||
and ``*.`` wildcards, which match both the apex domain and subdomains.
|
||||
"""
|
||||
raw = no_proxy_value
|
||||
if raw is None:
|
||||
raw = os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or ""
|
||||
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return False
|
||||
|
||||
lower_hostname = hostname.lower()
|
||||
for entry in re.split(r"[\s,]+", raw):
|
||||
normalized = entry.strip().lower()
|
||||
if not normalized:
|
||||
continue
|
||||
if normalized == "*":
|
||||
return True
|
||||
|
||||
if normalized.startswith("*."):
|
||||
normalized = normalized[2:]
|
||||
elif normalized.startswith("."):
|
||||
normalized = normalized[1:]
|
||||
|
||||
if lower_hostname == normalized or lower_hostname.endswith(f".{normalized}"):
|
||||
return True
|
||||
|
||||
return False
|
||||
return {}, {"proxy": proxy_url}
|
||||
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -731,15 +693,7 @@ SUPPORTED_DOCUMENT_TYPES = {
|
||||
".pdf": "application/pdf",
|
||||
".md": "text/markdown",
|
||||
".txt": "text/plain",
|
||||
".csv": "text/csv",
|
||||
".log": "text/plain",
|
||||
".json": "application/json",
|
||||
".xml": "application/xml",
|
||||
".yaml": "application/yaml",
|
||||
".yml": "application/yaml",
|
||||
".toml": "application/toml",
|
||||
".ini": "text/plain",
|
||||
".cfg": "text/plain",
|
||||
".zip": "application/zip",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
@@ -1028,61 +982,6 @@ def resolve_channel_prompt(
|
||||
return None
|
||||
|
||||
|
||||
def resolve_channel_skills(
|
||||
config_extra: dict,
|
||||
channel_id: str,
|
||||
parent_id: str | None = None,
|
||||
) -> list[str] | None:
|
||||
"""Resolve auto-loaded skill(s) for a channel/thread from platform config.
|
||||
|
||||
Looks up ``channel_skill_bindings`` in the adapter's ``config.extra`` dict.
|
||||
|
||||
Config format::
|
||||
|
||||
channel_skill_bindings:
|
||||
- id: "C0123" # Slack channel ID or Discord channel/forum ID
|
||||
skills: ["skill-a", "skill-b"]
|
||||
- id: "D0ABCDE"
|
||||
skill: "solo-skill" # single string also accepted
|
||||
|
||||
Prefers an exact match on *channel_id*; falls back to *parent_id*
|
||||
(useful for forum threads / Slack threads inheriting the parent channel's
|
||||
binding).
|
||||
|
||||
Returns a deduplicated list of skill names (order preserved), or None if
|
||||
no match is found.
|
||||
"""
|
||||
bindings = config_extra.get("channel_skill_bindings") or []
|
||||
if not isinstance(bindings, list) or not bindings:
|
||||
return None
|
||||
ids_to_check: set[str] = set()
|
||||
if channel_id:
|
||||
ids_to_check.add(str(channel_id))
|
||||
if parent_id:
|
||||
ids_to_check.add(str(parent_id))
|
||||
if not ids_to_check:
|
||||
return None
|
||||
for entry in bindings:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_id = str(entry.get("id", ""))
|
||||
if entry_id in ids_to_check:
|
||||
skills = entry.get("skills") or entry.get("skill")
|
||||
if isinstance(skills, str):
|
||||
s = skills.strip()
|
||||
return [s] if s else None
|
||||
if isinstance(skills, list) and skills:
|
||||
seen: list[str] = []
|
||||
for name in skills:
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
nm = name.strip()
|
||||
if nm and nm not in seen:
|
||||
seen.append(nm)
|
||||
return seen or None
|
||||
return None
|
||||
|
||||
|
||||
class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
Base class for platform adapters.
|
||||
@@ -1359,27 +1258,6 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
return SendResult(success=False, error="Not supported")
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a previously sent message. Optional — platforms that don't
|
||||
support deletion return ``False`` and callers fall back to leaving
|
||||
the message in place.
|
||||
|
||||
Used by the stream consumer's fresh-final cleanup path (see
|
||||
openclaw/openclaw#72038) to remove long-lived preview messages
|
||||
after sending the completed reply as a fresh message so the
|
||||
platform's visible timestamp reflects completion time.
|
||||
|
||||
Returns ``True`` on successful deletion, ``False`` otherwise.
|
||||
Subclasses should override for platforms with a deletion API
|
||||
(e.g. Telegram ``deleteMessage``).
|
||||
"""
|
||||
return False
|
||||
|
||||
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
||||
"""
|
||||
Send a typing indicator.
|
||||
@@ -1707,41 +1585,13 @@ class BasePlatformAdapter(ABC):
|
||||
the agent is waiting for dangerous-command approval). This is critical
|
||||
for Slack's Assistant API where ``assistant_threads_setStatus`` disables
|
||||
the compose box — pausing lets the user type ``/approve`` or ``/deny``.
|
||||
|
||||
Each ``send_typing`` call is bounded by a ~1.5s timeout so a slow
|
||||
network round-trip can't stall the refresh cadence. Telegram- and
|
||||
Discord-side typing expire after ~5s; if any individual send_typing
|
||||
takes longer than the refresh interval, the bubble would die and
|
||||
stay dead until that call returns. Abandoning the slow call lets
|
||||
the next tick fire a fresh send_typing on schedule — as long as
|
||||
one of them succeeds within the 5s platform-side window, the bubble
|
||||
stays visible across provider stalls / upstream API timeouts.
|
||||
"""
|
||||
# Bound each send_typing round-trip so the refresh cadence isn't
|
||||
# gated on network health. Must stay below ``interval`` so a slow
|
||||
# call gets abandoned before the next scheduled tick.
|
||||
_send_typing_timeout = max(0.25, min(1.5, interval - 0.25))
|
||||
try:
|
||||
while True:
|
||||
if stop_event is not None and stop_event.is_set():
|
||||
return
|
||||
if chat_id not in self._typing_paused:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.send_typing(chat_id, metadata=metadata),
|
||||
timeout=_send_typing_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Slow network — abandon this tick, keep the loop
|
||||
# on schedule so the next send_typing fires fresh.
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as typing_err:
|
||||
logger.debug(
|
||||
"[%s] send_typing error (non-fatal): %s",
|
||||
self.name, typing_err,
|
||||
)
|
||||
await self.send_typing(chat_id, metadata=metadata)
|
||||
if stop_event is None:
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
@@ -2432,15 +2282,11 @@ class BasePlatformAdapter(ABC):
|
||||
# Send the text portion
|
||||
if text_content:
|
||||
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
|
||||
# Build send metadata: thread_id + mention target for platforms that need it
|
||||
send_metadata = dict(_thread_metadata) if _thread_metadata else {}
|
||||
if event.source.user_id:
|
||||
send_metadata["mention_user_id"] = event.source.user_id
|
||||
result = await self._send_with_retry(
|
||||
chat_id=event.source.chat_id,
|
||||
content=text_content,
|
||||
reply_to=event.message_id,
|
||||
metadata=send_metadata,
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
_record_delivery(result)
|
||||
|
||||
|
||||
@@ -2679,8 +2679,21 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
skills: ["skill-a", "skill-b"]
|
||||
Also checks parent_id so forum threads inherit the forum's bindings.
|
||||
"""
|
||||
from gateway.platforms.base import resolve_channel_skills
|
||||
return resolve_channel_skills(self.config.extra, channel_id, parent_id)
|
||||
bindings = self.config.extra.get("channel_skill_bindings", [])
|
||||
if not bindings:
|
||||
return None
|
||||
ids_to_check = {channel_id}
|
||||
if parent_id:
|
||||
ids_to_check.add(parent_id)
|
||||
for entry in bindings:
|
||||
entry_id = str(entry.get("id", ""))
|
||||
if entry_id in ids_to_check:
|
||||
skills = entry.get("skills") or entry.get("skill")
|
||||
if isinstance(skills, str):
|
||||
return [skills]
|
||||
if isinstance(skills, list) and skills:
|
||||
return list(dict.fromkeys(skills)) # dedup, preserve order
|
||||
return None
|
||||
|
||||
def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None) -> str | None:
|
||||
"""Resolve a Discord per-channel prompt, preferring the exact channel over its parent."""
|
||||
@@ -3294,7 +3307,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
chat_topic = self._get_effective_topic(message.channel, is_thread=is_thread)
|
||||
|
||||
# Build source
|
||||
guild = getattr(message, "guild", None)
|
||||
source = self.build_source(
|
||||
chat_id=str(effective_channel.id),
|
||||
chat_name=chat_name,
|
||||
@@ -3304,7 +3316,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
thread_id=thread_id,
|
||||
chat_topic=chat_topic,
|
||||
is_bot=getattr(message.author, "bot", False),
|
||||
guild_id=str(guild.id) if guild else None,
|
||||
guild_id=str(message.guild.id) if message.guild else None,
|
||||
parent_chat_id=parent_channel_id,
|
||||
message_id=str(message.id),
|
||||
)
|
||||
|
||||
@@ -28,7 +28,6 @@ from email.header import decode_header
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.base import MIMEBase
|
||||
from email.utils import formatdate
|
||||
from email import encoders
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -505,7 +504,6 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
msg["In-Reply-To"] = original_msg_id
|
||||
msg["References"] = original_msg_id
|
||||
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
|
||||
msg["Message-ID"] = msg_id
|
||||
|
||||
@@ -588,7 +586,6 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
msg["In-Reply-To"] = original_msg_id
|
||||
msg["References"] = original_msg_id
|
||||
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
|
||||
msg["Message-ID"] = msg_id
|
||||
|
||||
|
||||
@@ -57,15 +57,6 @@ class MessageDeduplicator:
|
||||
if len(self._seen) > self._max_size:
|
||||
cutoff = now - self._ttl
|
||||
self._seen = {k: v for k, v in self._seen.items() if v > cutoff}
|
||||
if len(self._seen) > self._max_size:
|
||||
# TTL pruning alone does not cap the cache when every entry is
|
||||
# still fresh. Keep the newest entries so the helper's
|
||||
# max_size bound is enforced under sustained traffic.
|
||||
newest = sorted(
|
||||
self._seen.items(),
|
||||
key=lambda item: item[1],
|
||||
)[-self._max_size:]
|
||||
self._seen = dict(newest)
|
||||
return False
|
||||
|
||||
def clear(self):
|
||||
|
||||
+45
-511
@@ -11,7 +11,6 @@ Environment variables:
|
||||
MATRIX_PASSWORD Password (alternative to access token)
|
||||
MATRIX_ENCRYPTION Set "true" to enable E2EE
|
||||
MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts
|
||||
MATRIX_PROXY HTTP(S) or SOCKS proxy URL for Matrix traffic
|
||||
MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server)
|
||||
MATRIX_HOME_ROOM Room ID for cron/notification delivery
|
||||
MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions
|
||||
@@ -19,7 +18,6 @@ Environment variables:
|
||||
MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true)
|
||||
MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement
|
||||
MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true)
|
||||
MATRIX_DM_AUTO_THREAD Auto-create threads for DM messages (default: false)
|
||||
MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation
|
||||
MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false)
|
||||
"""
|
||||
@@ -32,8 +30,6 @@ import mimetypes
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from html import escape as _html_escape
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Set
|
||||
@@ -99,25 +95,11 @@ from gateway.platforms.base import (
|
||||
MessageType,
|
||||
ProcessingOutcome,
|
||||
SendResult,
|
||||
resolve_proxy_url,
|
||||
proxy_kwargs_for_aiohttp,
|
||||
)
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MatrixApprovalPrompt:
|
||||
"""Tracks a pending Matrix reaction-based exec approval prompt."""
|
||||
|
||||
def __init__(self, session_key: str, chat_id: str, message_id: str, resolved: bool = False):
|
||||
self.session_key = session_key
|
||||
self.chat_id = chat_id
|
||||
self.message_id = message_id
|
||||
self.resolved = resolved
|
||||
self.bot_reaction_events: dict[str, str] = {} # emoji -> event_id
|
||||
|
||||
# Matrix message size limit (4000 chars practical, spec has no hard limit
|
||||
# but clients render poorly above this).
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
@@ -132,85 +114,11 @@ _CRYPTO_DB_PATH = _STORE_DIR / "crypto.db"
|
||||
# Grace period: ignore messages older than this many seconds before startup.
|
||||
_STARTUP_GRACE_SECONDS = 5
|
||||
|
||||
_OUTBOUND_MENTION_RE = re.compile(
|
||||
r"(?<![\w/])(@[0-9A-Za-z._=/-]+:[0-9A-Za-z.-]+(?::\d+)?)"
|
||||
)
|
||||
|
||||
_E2EE_INSTALL_HINT = (
|
||||
"Install with: pip install 'mautrix[encryption]' (requires libolm C library)"
|
||||
)
|
||||
|
||||
_MATRIX_IMAGE_FILENAME_EXTS = frozenset({
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
".avif",
|
||||
})
|
||||
|
||||
|
||||
def _looks_like_matrix_image_filename(text: str) -> bool:
|
||||
"""Return True when Matrix image body text is probably just a transport filename.
|
||||
|
||||
Matrix ``m.image`` events commonly populate ``content.body`` with the uploaded
|
||||
filename when the user did not add a caption. Treating that raw filename as
|
||||
user-authored text confuses downstream vision enrichment.
|
||||
"""
|
||||
candidate = str(text or "").strip()
|
||||
if not candidate or "\n" in candidate or candidate.endswith("/"):
|
||||
return False
|
||||
|
||||
name = Path(candidate).name
|
||||
if not name or name != candidate:
|
||||
return False
|
||||
|
||||
suffix = Path(name).suffix.lower()
|
||||
if not suffix:
|
||||
return False
|
||||
|
||||
guessed_type, _ = mimetypes.guess_type(name)
|
||||
if guessed_type and guessed_type.startswith("image/"):
|
||||
return True
|
||||
return suffix in _MATRIX_IMAGE_FILENAME_EXTS
|
||||
|
||||
|
||||
def _create_matrix_session(proxy_url: str | None):
|
||||
"""Create an ``aiohttp.ClientSession`` whose proxy applies to *all* requests.
|
||||
|
||||
mautrix's ``HTTPAPI._send()`` calls ``session.request()`` without forwarding
|
||||
per-request ``proxy=`` kwargs. For HTTP(S) proxies we use aiohttp's native
|
||||
``proxy=`` session parameter which sets a default for every request. For SOCKS
|
||||
we use ``aiohttp_socks.ProxyConnector`` (connector-level).
|
||||
When no proxy is configured we enable ``trust_env`` so standard env vars
|
||||
(``HTTP_PROXY`` / ``HTTPS_PROXY``) are honoured automatically.
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
if not proxy_url:
|
||||
return aiohttp.ClientSession(trust_env=True)
|
||||
|
||||
if proxy_url.split("://")[0].lower().startswith("socks"):
|
||||
try:
|
||||
from aiohttp_socks import ProxyConnector
|
||||
|
||||
return aiohttp.ClientSession(
|
||||
connector=ProxyConnector.from_url(proxy_url, rdns=True),
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
|
||||
"Run: pip install aiohttp-socks",
|
||||
proxy_url,
|
||||
)
|
||||
return aiohttp.ClientSession(trust_env=True)
|
||||
|
||||
return aiohttp.ClientSession(proxy=proxy_url)
|
||||
|
||||
|
||||
def _check_e2ee_deps() -> bool:
|
||||
"""Return True if mautrix E2EE dependencies (python-olm) are available."""
|
||||
@@ -352,9 +260,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
self._dm_auto_thread: bool = os.getenv(
|
||||
"MATRIX_DM_AUTO_THREAD", "false"
|
||||
).lower() in ("true", "1", "yes")
|
||||
self._dm_mention_threads: bool = os.getenv(
|
||||
"MATRIX_DM_MENTION_THREADS", "false"
|
||||
).lower() in ("true", "1", "yes")
|
||||
@@ -365,11 +270,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
).lower() not in ("false", "0", "no")
|
||||
self._pending_reactions: dict[tuple[str, str], str] = {}
|
||||
|
||||
# Proxy support — resolve once at init, reuse for all HTTP traffic.
|
||||
self._proxy_url: str | None = resolve_proxy_url(platform_env_var="MATRIX_PROXY")
|
||||
if self._proxy_url:
|
||||
logger.info("Matrix: proxy configured — %s", self._proxy_url)
|
||||
|
||||
# Text batching: merge rapid successive messages (Telegram-style).
|
||||
# Matrix clients split long messages around 4000 chars.
|
||||
self._text_batch_delay_seconds = float(
|
||||
@@ -381,18 +281,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
# Matrix reaction-based dangerous command approvals.
|
||||
self._approval_reaction_map = {
|
||||
"✅": "once",
|
||||
"❎": "deny",
|
||||
}
|
||||
self._approval_prompts_by_event: Dict[str, _MatrixApprovalPrompt] = {}
|
||||
self._approval_prompt_by_session: Dict[str, str] = {}
|
||||
allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "")
|
||||
self._allowed_user_ids: Set[str] = {
|
||||
u.strip() for u in allowed_users_raw.split(",") if u.strip()
|
||||
}
|
||||
|
||||
def _is_duplicate_event(self, event_id) -> bool:
|
||||
"""Return True if this event was already processed. Tracks the ID otherwise."""
|
||||
if not event_id:
|
||||
@@ -438,7 +326,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error("Matrix: post-upload key verification failed: %s", exc, exc_info=True)
|
||||
logger.error("Matrix: post-upload key verification failed: %s", exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -454,7 +342,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.error(
|
||||
"Matrix: cannot verify device keys on server: %s — refusing E2EE",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -469,7 +356,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
await olm.share_keys()
|
||||
except Exception as exc:
|
||||
logger.error("Matrix: failed to re-upload device keys: %s", exc, exc_info=True)
|
||||
logger.error("Matrix: failed to re-upload device keys: %s", exc)
|
||||
return False
|
||||
return await self._reverify_keys_after_upload(client, local_ed25519)
|
||||
|
||||
@@ -509,7 +396,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"Try generating a new access token to get a fresh device.",
|
||||
client.device_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
return await self._reverify_keys_after_upload(client, local_ed25519)
|
||||
@@ -534,11 +420,9 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create the HTTP API layer.
|
||||
client_session = _create_matrix_session(self._proxy_url)
|
||||
api = HTTPAPI(
|
||||
base_url=self._homeserver,
|
||||
token=self._access_token or "",
|
||||
client_session=client_session,
|
||||
)
|
||||
|
||||
# Create the client.
|
||||
@@ -581,7 +465,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.error(
|
||||
"Matrix: whoami failed — check MATRIX_ACCESS_TOKEN and MATRIX_HOMESERVER: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await api.session.close()
|
||||
return False
|
||||
@@ -724,44 +607,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
logger.warning(
|
||||
"Matrix: recovery key verification failed: %s", exc
|
||||
)
|
||||
else:
|
||||
# No recovery key — bootstrap cross-signing if the bot
|
||||
# has none yet. Without this, Element shows "Encrypted
|
||||
# by a device not verified by its owner" on every
|
||||
# message from this bot, indefinitely. mautrix's
|
||||
# generate_recovery_key does the full flow: generates
|
||||
# MSK/SSK/USK, uploads private keys to SSSS, publishes
|
||||
# public keys to the homeserver, and signs the current
|
||||
# device with the new SSK. Some homeservers require UIA
|
||||
# for /keys/device_signing/upload — those will need an
|
||||
# alternate path; Continuwuity and Synapse-with-shared-
|
||||
# secret accept the unauthenticated upload.
|
||||
try:
|
||||
own_xsign = await olm.get_own_cross_signing_public_keys()
|
||||
except Exception as exc:
|
||||
own_xsign = None
|
||||
logger.warning(
|
||||
"Matrix: cross-signing key lookup failed: %s", exc
|
||||
)
|
||||
if own_xsign is None:
|
||||
try:
|
||||
new_recovery_key = await olm.generate_recovery_key()
|
||||
logger.warning(
|
||||
"Matrix: bootstrapped cross-signing for %s. "
|
||||
"SAVE THIS RECOVERY KEY — set "
|
||||
"MATRIX_RECOVERY_KEY for future restarts so "
|
||||
"the bot can re-sign its device after key "
|
||||
"rotation: %s",
|
||||
client.mxid,
|
||||
new_recovery_key,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Matrix: cross-signing bootstrap failed "
|
||||
"(non-fatal — Element will show 'not "
|
||||
"verified by its owner'): %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
client.crypto = olm
|
||||
logger.info(
|
||||
@@ -819,7 +664,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: initial sync event dispatch error: %s", exc)
|
||||
await self._join_pending_invites(sync_data)
|
||||
else:
|
||||
logger.warning(
|
||||
"Matrix: initial sync returned unexpected type %s",
|
||||
@@ -879,32 +723,21 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if not content:
|
||||
return SendResult(success=True)
|
||||
|
||||
mention_user_id = (metadata or {}).get("mention_user_id")
|
||||
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH)
|
||||
|
||||
last_event_id = None
|
||||
for i, chunk in enumerate(chunks):
|
||||
msg_content = self._build_text_message_content(chunk)
|
||||
for chunk in chunks:
|
||||
msg_content: Dict[str, Any] = {
|
||||
"msgtype": "m.text",
|
||||
"body": chunk,
|
||||
}
|
||||
|
||||
# Append @mention pill to the last chunk for push notifications
|
||||
# in muted rooms (mention-only mode).
|
||||
if mention_user_id and i == len(chunks) - 1:
|
||||
mention_html = (
|
||||
f'<a href="https://matrix.to/#/{mention_user_id}">'
|
||||
f"{mention_user_id}</a>"
|
||||
)
|
||||
msg_content["body"] = chunk + f" @{mention_user_id}"
|
||||
base_html = msg_content.get("formatted_body", chunk)
|
||||
# Convert markdown to HTML for rich rendering.
|
||||
html = self._markdown_to_html(chunk)
|
||||
if html and html != chunk:
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = base_html + " " + mention_html
|
||||
# m.mentions for MSC3952 push reliability.
|
||||
existing_mentions = msg_content.get("m.mentions", {}).get("user_ids", [])
|
||||
if mention_user_id not in existing_mentions:
|
||||
msg_content["m.mentions"] = {
|
||||
"user_ids": existing_mentions + [mention_user_id]
|
||||
}
|
||||
msg_content["formatted_body"] = html
|
||||
|
||||
# Reply-to support.
|
||||
if reply_to:
|
||||
@@ -1011,21 +844,25 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"""Edit an existing message (via m.replace)."""
|
||||
|
||||
formatted = self.format_message(content)
|
||||
new_content = self._build_text_message_content(formatted)
|
||||
msg_content: Dict[str, Any] = {
|
||||
"msgtype": "m.text",
|
||||
"body": f"* {formatted}",
|
||||
"m.new_content": new_content,
|
||||
"m.new_content": {
|
||||
"msgtype": "m.text",
|
||||
"body": formatted,
|
||||
},
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": message_id,
|
||||
},
|
||||
}
|
||||
if "m.mentions" in new_content:
|
||||
msg_content["m.mentions"] = new_content["m.mentions"]
|
||||
if "formatted_body" in new_content:
|
||||
|
||||
html = self._markdown_to_html(formatted)
|
||||
if html and html != formatted:
|
||||
msg_content["m.new_content"]["format"] = "org.matrix.custom.html"
|
||||
msg_content["m.new_content"]["formatted_body"] = html
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = f'* {new_content["formatted_body"]}'
|
||||
msg_content["m.relates_to"] = {
|
||||
"rel_type": "m.replace",
|
||||
"event_id": message_id,
|
||||
}
|
||||
msg_content["formatted_body"] = f"* {html}"
|
||||
|
||||
try:
|
||||
event_id = await self._client.send_message_event(
|
||||
@@ -1058,12 +895,10 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Try aiohttp first (always available), fall back to httpx
|
||||
try:
|
||||
import aiohttp as _aiohttp
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url)
|
||||
async with _aiohttp.ClientSession(**_sess_kw) as http:
|
||||
|
||||
async with _aiohttp.ClientSession(trust_env=True) as http:
|
||||
async with http.get(
|
||||
image_url,
|
||||
timeout=_aiohttp.ClientTimeout(total=30),
|
||||
**_req_kw,
|
||||
image_url, timeout=_aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.read()
|
||||
@@ -1073,10 +908,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
)
|
||||
except ImportError:
|
||||
import httpx
|
||||
_httpx_kw: dict = {}
|
||||
if self._proxy_url:
|
||||
_httpx_kw["proxy"] = self._proxy_url
|
||||
async with httpx.AsyncClient(**_httpx_kw) as http:
|
||||
|
||||
async with httpx.AsyncClient() as http:
|
||||
resp = await http.get(image_url, follow_redirects=True, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
@@ -1151,56 +984,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
chat_id, video_path, "m.video", caption, reply_to, metadata=metadata
|
||||
)
|
||||
|
||||
async def send_exec_approval(
|
||||
self,
|
||||
chat_id: str,
|
||||
command: str,
|
||||
session_key: str,
|
||||
description: str = "dangerous command",
|
||||
metadata: Optional[dict] = None,
|
||||
) -> SendResult:
|
||||
"""Send a reaction-based exec approval prompt for Matrix."""
|
||||
if not self._client:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
cmd_preview = command[:2000] + "..." if len(command) > 2000 else command
|
||||
text = (
|
||||
"⚠️ **Dangerous command requires approval**\n"
|
||||
f"```\n{cmd_preview}\n```\n"
|
||||
f"Reason: {description}\n\n"
|
||||
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
|
||||
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
|
||||
"You can also click the reaction to approve:\n"
|
||||
"✅ = /approve\n"
|
||||
"❎ = /deny"
|
||||
)
|
||||
|
||||
result = await self.send(chat_id, text, metadata=metadata)
|
||||
if not result.success or not result.message_id:
|
||||
return result
|
||||
|
||||
prompt = _MatrixApprovalPrompt(
|
||||
session_key=session_key,
|
||||
chat_id=chat_id,
|
||||
message_id=result.message_id,
|
||||
)
|
||||
old_event = self._approval_prompt_by_session.get(session_key)
|
||||
if old_event:
|
||||
self._approval_prompts_by_event.pop(old_event, None)
|
||||
self._approval_prompts_by_event[result.message_id] = prompt
|
||||
self._approval_prompt_by_session[session_key] = result.message_id
|
||||
|
||||
for emoji in ("✅", "❎"):
|
||||
try:
|
||||
reaction_result = await self._send_reaction(chat_id, result.message_id, emoji)
|
||||
# Save the bot's reaction event_id for later cleanup
|
||||
if reaction_result:
|
||||
prompt.bot_reaction_events[emoji] = str(reaction_result)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: failed to add approval reaction %s: %s", emoji, exc)
|
||||
|
||||
return result
|
||||
|
||||
def format_message(self, content: str) -> str:
|
||||
"""Pass-through — Matrix supports standard Markdown natively."""
|
||||
# Strip image markdown; media is uploaded separately.
|
||||
@@ -1332,15 +1115,9 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
next_batch = await client.sync_store.get_next_batch()
|
||||
while not self._closing:
|
||||
try:
|
||||
# Wrap in asyncio.wait_for to guard against TCP-level hangs
|
||||
# that the Matrix long-poll timeout cannot catch. Long-poll
|
||||
# is 30s, so 45s gives 15s slack for network drain.
|
||||
sync_data = await asyncio.wait_for(
|
||||
client.sync(
|
||||
since=next_batch,
|
||||
timeout=30000,
|
||||
),
|
||||
timeout=45.0,
|
||||
sync_data = await client.sync(
|
||||
since=next_batch,
|
||||
timeout=30000,
|
||||
)
|
||||
|
||||
# nio returns SyncError objects (not exceptions) for auth
|
||||
@@ -1376,7 +1153,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
await asyncio.gather(*tasks)
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: sync event dispatch error: %s", exc)
|
||||
await self._join_pending_invites(sync_data)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
@@ -1402,92 +1178,13 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Event callbacks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _is_self_sender(self, sender: str) -> bool:
|
||||
"""Return True if the sender refers to the bot's own account.
|
||||
|
||||
Matrix user IDs are byte-compared after trimming whitespace and
|
||||
lowercasing — some homeservers normalize the localpart case
|
||||
differently at different API surfaces, and the reply-loop tail
|
||||
of the "hall of mirrors" bug (#15763) has been observed with the
|
||||
bot's own account bypassing a case-sensitive equality check.
|
||||
|
||||
When ``self._user_id`` is empty (whoami hasn't resolved yet, or
|
||||
login failed), we cannot prove a sender is NOT us, so we return
|
||||
True defensively — an unidentified bot dropping its own events
|
||||
is always preferable to falling into an echo loop.
|
||||
"""
|
||||
own = (self._user_id or "").strip().lower()
|
||||
if not own:
|
||||
return True
|
||||
return sender.strip().lower() == own
|
||||
|
||||
@staticmethod
|
||||
def _is_system_or_bridge_sender(sender: str) -> bool:
|
||||
"""Return True if the sender looks like a system / bridge / appservice
|
||||
identity rather than a real user.
|
||||
|
||||
Appservice namespaces on Matrix conventionally prefix bot / puppet
|
||||
user IDs with an underscore (e.g. ``@_telegram_12345:server``,
|
||||
``@_discord_999:server``, ``@_slack_...:server``). Server-notices
|
||||
bots and bridge-controller bots on many homeservers use the same
|
||||
pattern.
|
||||
|
||||
We treat these as system identities for pairing purposes: they
|
||||
should never be offered a pairing code, because an operator
|
||||
approving the code would hand the bridge itself permanent
|
||||
authorization — and every outbound message relayed by the bridge
|
||||
would then loop back into the agent as an "authorized user
|
||||
message", which is the root of issue #15763.
|
||||
|
||||
Matches:
|
||||
``@_something:server`` — appservice namespace convention
|
||||
``@:server`` — malformed / empty localpart
|
||||
``:server`` — malformed, no leading ``@``
|
||||
"""
|
||||
s = (sender or "").strip()
|
||||
if not s:
|
||||
return True
|
||||
# Localpart is everything between leading '@' and ':'
|
||||
if s.startswith("@"):
|
||||
s = s[1:]
|
||||
if ":" in s:
|
||||
localpart, _, _ = s.partition(":")
|
||||
else:
|
||||
localpart = s
|
||||
if not localpart:
|
||||
return True
|
||||
return localpart.startswith("_")
|
||||
|
||||
async def _on_room_message(self, event: Any) -> None:
|
||||
"""Handle incoming room message events (text, media)."""
|
||||
room_id = str(getattr(event, "room_id", ""))
|
||||
sender = str(getattr(event, "sender", ""))
|
||||
|
||||
# Diagnostic: confirm the callback is firing at all when DEBUG is on.
|
||||
# Helps users troubleshoot silent inbound issues like #5819, #7914, #12614.
|
||||
logger.debug(
|
||||
"Matrix: callback fired — event %s from %s in %s",
|
||||
getattr(event, "event_id", "?"),
|
||||
sender,
|
||||
room_id,
|
||||
)
|
||||
|
||||
# Ignore own messages (case-insensitive; also drops when our own
|
||||
# user_id hasn't been resolved yet — see _is_self_sender docstring
|
||||
# and issue #15763).
|
||||
if self._is_self_sender(sender):
|
||||
return
|
||||
|
||||
# Ignore appservice / bridge / system identities so they never
|
||||
# trigger the pairing flow. Once a bridge user is paired, every
|
||||
# outbound message it relays would loop back as an authorized
|
||||
# user message (the "hall of mirrors" in #15763).
|
||||
if self._is_system_or_bridge_sender(sender):
|
||||
logger.debug(
|
||||
"Matrix: ignoring system/bridge sender %s in %s",
|
||||
sender,
|
||||
room_id,
|
||||
)
|
||||
# Ignore own messages.
|
||||
if sender == self._user_id:
|
||||
return
|
||||
|
||||
# Deduplicate by event ID.
|
||||
@@ -1583,12 +1280,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
in_bot_thread = bool(thread_id and thread_id in self._threads)
|
||||
if self._require_mention and not is_free_room and not in_bot_thread:
|
||||
if not is_mentioned:
|
||||
logger.debug(
|
||||
"Matrix: ignoring message %s in %s — no @mention "
|
||||
"(set MATRIX_REQUIRE_MENTION=false to disable)",
|
||||
event_id,
|
||||
room_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# DM mention-thread.
|
||||
@@ -1601,7 +1292,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
body = self._strip_mention(body)
|
||||
|
||||
# Auto-thread.
|
||||
if not thread_id and ((not is_dm and self._auto_thread) or (is_dm and self._dm_auto_thread)):
|
||||
if not is_dm and not thread_id and self._auto_thread:
|
||||
thread_id = event_id
|
||||
self._threads.mark(thread_id)
|
||||
|
||||
@@ -1843,9 +1534,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
return
|
||||
body, is_dm, chat_type, thread_id, display_name, source = ctx
|
||||
|
||||
if msgtype == "m.image" and _looks_like_matrix_image_filename(body):
|
||||
body = ""
|
||||
|
||||
allow_http_fallback = bool(http_url) and not is_encrypted_media
|
||||
media_urls = (
|
||||
[cached_path]
|
||||
@@ -1875,35 +1563,13 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"Matrix: invited to %s — joining",
|
||||
room_id,
|
||||
)
|
||||
await self._join_room_by_id(room_id)
|
||||
|
||||
async def _join_room_by_id(self, room_id: str) -> bool:
|
||||
"""Join a room by ID and refresh local caches on success."""
|
||||
if not room_id:
|
||||
return False
|
||||
if room_id in self._joined_rooms:
|
||||
return True
|
||||
try:
|
||||
await self._client.join_room(RoomID(room_id))
|
||||
self._joined_rooms.add(room_id)
|
||||
logger.info("Matrix: joined %s", room_id)
|
||||
await self._refresh_dm_cache()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Matrix: error joining %s: %s", room_id, exc)
|
||||
return False
|
||||
|
||||
async def _join_pending_invites(self, sync_data: Dict[str, Any]) -> None:
|
||||
"""Join rooms still present in rooms.invite after sync processing."""
|
||||
rooms = sync_data.get("rooms", {}) if isinstance(sync_data, dict) else {}
|
||||
invites = rooms.get("invite", {})
|
||||
if not isinstance(invites, dict):
|
||||
return
|
||||
for room_id in invites:
|
||||
if room_id in self._joined_rooms:
|
||||
continue
|
||||
logger.info("Matrix: reconciling pending invite for %s", room_id)
|
||||
await self._join_room_by_id(str(room_id))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reactions (send, receive, processing lifecycle)
|
||||
@@ -1988,7 +1654,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
async def _on_reaction(self, event: Any) -> None:
|
||||
"""Handle incoming reaction events."""
|
||||
sender = str(getattr(event, "sender", ""))
|
||||
if self._is_self_sender(sender):
|
||||
if sender == self._user_id:
|
||||
return
|
||||
event_id = str(getattr(event, "event_id", ""))
|
||||
if self._is_duplicate_event(event_id):
|
||||
@@ -2018,51 +1684,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
room_id,
|
||||
)
|
||||
|
||||
# Check if this reaction resolves a pending approval prompt.
|
||||
prompt = self._approval_prompts_by_event.get(reacts_to)
|
||||
if prompt and not prompt.resolved:
|
||||
if room_id != prompt.chat_id:
|
||||
return
|
||||
if self._allowed_user_ids and sender not in self._allowed_user_ids:
|
||||
logger.info(
|
||||
"Matrix: ignoring approval reaction from unauthorized user %s on %s",
|
||||
sender, reacts_to,
|
||||
)
|
||||
return
|
||||
choice = self._approval_reaction_map.get(key)
|
||||
if not choice:
|
||||
return
|
||||
try:
|
||||
from tools.approval import resolve_gateway_approval
|
||||
|
||||
count = resolve_gateway_approval(prompt.session_key, choice)
|
||||
if count:
|
||||
prompt.resolved = True
|
||||
self._approval_prompts_by_event.pop(reacts_to, None)
|
||||
self._approval_prompt_by_session.pop(prompt.session_key, None)
|
||||
logger.info(
|
||||
"Matrix reaction resolved %d approval(s) for session %s "
|
||||
"(choice=%s, user=%s)",
|
||||
count, prompt.session_key, choice, sender,
|
||||
)
|
||||
# Redact bot's seed reactions, leaving only the user's
|
||||
await self._redact_bot_approval_reactions(room_id, prompt)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to resolve gateway approval from Matrix reaction: %s", exc)
|
||||
|
||||
async def _redact_bot_approval_reactions(
|
||||
self,
|
||||
room_id: str,
|
||||
prompt: "_MatrixApprovalPrompt",
|
||||
) -> None:
|
||||
"""Redact the bot's seed ✅/❎ reactions, leaving only the user's reaction."""
|
||||
for emoji, evt_id in prompt.bot_reaction_events.items():
|
||||
try:
|
||||
await self.redact_message(room_id, evt_id, "approval resolved")
|
||||
logger.debug("Matrix: redacted bot reaction %s (%s)", emoji, evt_id)
|
||||
except Exception as exc:
|
||||
logger.debug("Matrix: failed to redact bot reaction %s: %s", emoji, exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text message aggregation (handles Matrix client-side splits)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -2288,7 +1909,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if not self._client or not text:
|
||||
return SendResult(success=False, error="No client or empty text")
|
||||
|
||||
msg_content = self._build_text_message_content(text, msgtype=msgtype)
|
||||
msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
|
||||
html = self._markdown_to_html(text)
|
||||
if html and html != text:
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = html
|
||||
|
||||
try:
|
||||
event_id = await self._client.send_message_event(
|
||||
@@ -2351,77 +1976,6 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
# Mention detection helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_text_message_content(self, text: str, msgtype: str = "m.text") -> Dict[str, Any]:
|
||||
"""Build Matrix text content with HTML and outbound mention metadata."""
|
||||
msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
|
||||
mention_user_ids = self._extract_outbound_mentions(text)
|
||||
if mention_user_ids:
|
||||
msg_content["m.mentions"] = {"user_ids": mention_user_ids}
|
||||
|
||||
html_source = self._inject_outbound_mention_links(text)
|
||||
html = self._markdown_to_html(html_source)
|
||||
if html and html != text:
|
||||
msg_content["format"] = "org.matrix.custom.html"
|
||||
msg_content["formatted_body"] = html
|
||||
|
||||
return msg_content
|
||||
|
||||
def _extract_outbound_mentions(self, text: str) -> list[str]:
|
||||
"""Return unique Matrix user IDs mentioned in outbound text."""
|
||||
protected, _ = self._protect_outbound_mention_regions(text)
|
||||
seen: Set[str] = set()
|
||||
mentions: list[str] = []
|
||||
for match in _OUTBOUND_MENTION_RE.finditer(protected):
|
||||
user_id = match.group(1)
|
||||
if user_id not in seen:
|
||||
seen.add(user_id)
|
||||
mentions.append(user_id)
|
||||
return mentions
|
||||
|
||||
def _inject_outbound_mention_links(self, text: str) -> str:
|
||||
"""Wrap outbound Matrix mentions in markdown links outside code spans."""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
protected, placeholders = self._protect_outbound_mention_regions(text)
|
||||
|
||||
linked = _OUTBOUND_MENTION_RE.sub(
|
||||
lambda match: f"[{match.group(1)}](https://matrix.to/#/{match.group(1)})",
|
||||
protected,
|
||||
)
|
||||
|
||||
for idx, original in enumerate(placeholders):
|
||||
linked = linked.replace(f"\x00MENTION_PROTECTED{idx}\x00", original)
|
||||
|
||||
return linked
|
||||
|
||||
def _protect_outbound_mention_regions(self, text: str) -> tuple[str, list[str]]:
|
||||
"""Protect markdown regions where outbound mentions should stay literal."""
|
||||
placeholders: list[str] = []
|
||||
|
||||
def _protect(fragment: str) -> str:
|
||||
idx = len(placeholders)
|
||||
placeholders.append(fragment)
|
||||
return f"\x00MENTION_PROTECTED{idx}\x00"
|
||||
|
||||
protected = re.sub(
|
||||
r"```[\s\S]*?```",
|
||||
lambda match: _protect(match.group(0)),
|
||||
text or "",
|
||||
)
|
||||
protected = re.sub(
|
||||
r"`[^`\n]+`",
|
||||
lambda match: _protect(match.group(0)),
|
||||
protected,
|
||||
)
|
||||
protected = re.sub(
|
||||
r"\[[^\]]+\]\([^)]+\)",
|
||||
lambda match: _protect(match.group(0)),
|
||||
protected,
|
||||
)
|
||||
|
||||
return protected, placeholders
|
||||
|
||||
def _is_bot_mentioned(
|
||||
self,
|
||||
body: str,
|
||||
@@ -2456,33 +2010,13 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
return False
|
||||
|
||||
def _strip_mention(self, body: str) -> str:
|
||||
"""Remove explicit bot mentions from message body.
|
||||
"""Strip the bot's full MXID (``@user:server``) from *body*.
|
||||
|
||||
Important: only strip explicit mention tokens (``@user:server`` or
|
||||
``@localpart``). Do NOT strip bare words matching the bot localpart,
|
||||
otherwise normal phrases like "Hermes Agent" become "Agent".
|
||||
The bare localpart is intentionally *not* stripped — it would
|
||||
mangle file paths like ``/home/hermes/media/file.png``.
|
||||
"""
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
# Strip explicit full MXID mentions.
|
||||
if self._user_id:
|
||||
body = body.replace(self._user_id, "")
|
||||
|
||||
# Strip explicit @localpart mentions only (not bare localpart words).
|
||||
if self._user_id and ":" in self._user_id:
|
||||
localpart = self._user_id.split(":")[0].lstrip("@")
|
||||
if localpart:
|
||||
body = re.sub(
|
||||
r'(?<![\w])@' + re.escape(localpart) + r'\b',
|
||||
'',
|
||||
body,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Normalize spacing after mention removal.
|
||||
body = re.sub(r'[ \t]{2,}', ' ', body)
|
||||
body = re.sub(r'\s+([,.;:!?])', r'\1', body)
|
||||
return body.strip()
|
||||
|
||||
async def _get_display_name(self, room_id: str, user_id: str) -> str:
|
||||
|
||||
+70
-753
File diff suppressed because it is too large
Load Diff
@@ -1209,31 +1209,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def delete_message(self, chat_id: str, message_id: str) -> bool:
|
||||
"""Delete a previously sent Telegram message.
|
||||
|
||||
Used by the stream consumer's fresh-final cleanup path (ported
|
||||
from openclaw/openclaw#72038) to remove long-lived preview
|
||||
messages after sending the completed reply as a fresh message.
|
||||
Telegram's Bot API ``deleteMessage`` works for bot-posted
|
||||
messages in the last 48 hours. Failures are non-fatal — the
|
||||
caller leaves the preview in place and logs at debug level.
|
||||
"""
|
||||
if not self._bot:
|
||||
return False
|
||||
try:
|
||||
await self._bot.delete_message(
|
||||
chat_id=int(chat_id),
|
||||
message_id=int(message_id),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"[%s] Failed to delete Telegram message %s: %s",
|
||||
self.name, message_id, e,
|
||||
)
|
||||
return False
|
||||
|
||||
async def send_update_prompt(
|
||||
self, chat_id: str, prompt: str, default: str = "",
|
||||
session_key: str = "",
|
||||
@@ -2353,26 +2328,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
user = getattr(entity, "user", None)
|
||||
if user and getattr(user, "id", None) == bot_id:
|
||||
return True
|
||||
elif entity_type == "bot_command" and expected:
|
||||
# Telegram's official group-disambiguation form for slash
|
||||
# commands (``/cmd@botname``) is emitted as a single
|
||||
# ``bot_command`` entity covering the whole span — there
|
||||
# is no accompanying ``mention`` entity. Treat it as a
|
||||
# direct address to this bot when the ``@botname`` suffix
|
||||
# matches. This is the form Telegram's own command menu
|
||||
# autocomplete produces in groups, so dropping it at the
|
||||
# mention gate would break /new, /reset, /help, ... for
|
||||
# every group that has ``require_mention`` enabled (#15415).
|
||||
offset = int(getattr(entity, "offset", -1))
|
||||
length = int(getattr(entity, "length", 0))
|
||||
if offset < 0 or length <= 0:
|
||||
continue
|
||||
command_text = source_text[offset:offset + length]
|
||||
at_index = command_text.find("@")
|
||||
if at_index < 0:
|
||||
continue
|
||||
if command_text[at_index:].strip().lower() == expected:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _message_matches_mention_patterns(self, message: Message) -> bool:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,647 +0,0 @@
|
||||
"""
|
||||
yuanbao_media.py — 元宝平台媒体处理模块
|
||||
|
||||
提供 COS 上传、文件下载、TIM 媒体消息构建等功能。
|
||||
移植自 TypeScript 版 media.ts(yuanbao-openclaw-plugin),
|
||||
使用 httpx 替代 cos-nodejs-sdk-v5,避免引入额外 SDK 依赖。
|
||||
|
||||
COS 上传流程:
|
||||
1. 调用 genUploadInfo 获取临时凭证(tmpSecretId/tmpSecretKey/sessionToken)
|
||||
2. 用临时凭证通过 HMAC-SHA1 签名构建 Authorization 头
|
||||
3. HTTP PUT 上传到 COS
|
||||
|
||||
TIM 消息体构建:
|
||||
- buildImageMsgBody() → TIMImageElem
|
||||
- buildFileMsgBody() → TIMFileElem
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional, Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============ 常量 ============
|
||||
|
||||
UPLOAD_INFO_PATH = "/api/resource/genUploadInfo"
|
||||
DEFAULT_API_DOMAIN = "yuanbao.tencent.com"
|
||||
DEFAULT_MAX_SIZE_MB = 50
|
||||
|
||||
# COS 加速域名后缀(优先使用全球加速)
|
||||
COS_USE_ACCELERATE = True
|
||||
|
||||
# ============ 类型映射 ============
|
||||
|
||||
# MIME → image_format 数字(TIM 协议字段)
|
||||
_MIME_TO_IMAGE_FORMAT: dict[str, int] = {
|
||||
"image/jpeg": 1,
|
||||
"image/jpg": 1,
|
||||
"image/gif": 2,
|
||||
"image/png": 3,
|
||||
"image/bmp": 4,
|
||||
"image/webp": 255,
|
||||
"image/heic": 255,
|
||||
"image/tiff": 255,
|
||||
}
|
||||
|
||||
# 文件扩展名 → MIME
|
||||
_EXT_TO_MIME: dict[str, str] = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".heic": "image/heic",
|
||||
".tiff": "image/tiff",
|
||||
".ico": "image/x-icon",
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".txt": "text/plain",
|
||||
".zip": "application/zip",
|
||||
".tar": "application/x-tar",
|
||||
".gz": "application/gzip",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".webm": "video/webm",
|
||||
}
|
||||
|
||||
|
||||
# ============ 工具函数 ============
|
||||
|
||||
def guess_mime_type(filename: str) -> str:
|
||||
"""根据文件扩展名猜测 MIME 类型。"""
|
||||
ext = os.path.splitext(filename)[-1].lower()
|
||||
return _EXT_TO_MIME.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
def is_image(filename: str, mime_type: str = "") -> bool:
|
||||
"""判断是否为图片类型。"""
|
||||
if mime_type.startswith("image/"):
|
||||
return True
|
||||
ext = os.path.splitext(filename)[-1].lower()
|
||||
return ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".tiff", ".ico"}
|
||||
|
||||
|
||||
def get_image_format(mime_type: str) -> int:
|
||||
"""获取 TIM 图片格式编号。"""
|
||||
return _MIME_TO_IMAGE_FORMAT.get(mime_type.lower(), 255)
|
||||
|
||||
|
||||
def md5_hex(data: bytes) -> str:
|
||||
"""计算 MD5 十六进制摘要。"""
|
||||
return hashlib.md5(data).hexdigest()
|
||||
|
||||
|
||||
def generate_file_id() -> str:
|
||||
"""生成随机文件 ID(32 位 hex)。"""
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
|
||||
# ============ 图片尺寸解析(纯 Python,无需 Pillow) ============
|
||||
|
||||
def parse_image_size(data: bytes) -> Optional[dict[str, int]]:
|
||||
"""
|
||||
解析图片宽高(支持 JPEG/PNG/GIF/WebP),无需第三方依赖。
|
||||
返回 {"width": w, "height": h} 或 None(无法识别)。
|
||||
"""
|
||||
return (
|
||||
_parse_png_size(data)
|
||||
or _parse_jpeg_size(data)
|
||||
or _parse_gif_size(data)
|
||||
or _parse_webp_size(data)
|
||||
)
|
||||
|
||||
|
||||
def _parse_png_size(buf: bytes) -> Optional[dict[str, int]]:
|
||||
if len(buf) < 24:
|
||||
return None
|
||||
if buf[:4] != b"\x89PNG":
|
||||
return None
|
||||
w = struct.unpack(">I", buf[16:20])[0]
|
||||
h = struct.unpack(">I", buf[20:24])[0]
|
||||
return {"width": w, "height": h}
|
||||
|
||||
|
||||
def _parse_jpeg_size(buf: bytes) -> Optional[dict[str, int]]:
|
||||
if len(buf) < 4 or buf[0] != 0xFF or buf[1] != 0xD8:
|
||||
return None
|
||||
i = 2
|
||||
while i < len(buf) - 9:
|
||||
if buf[i] != 0xFF:
|
||||
i += 1
|
||||
continue
|
||||
marker = buf[i + 1]
|
||||
if marker in (0xC0, 0xC2):
|
||||
h = struct.unpack(">H", buf[i + 5: i + 7])[0]
|
||||
w = struct.unpack(">H", buf[i + 7: i + 9])[0]
|
||||
return {"width": w, "height": h}
|
||||
if i + 3 < len(buf):
|
||||
i += 2 + struct.unpack(">H", buf[i + 2: i + 4])[0]
|
||||
else:
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _parse_gif_size(buf: bytes) -> Optional[dict[str, int]]:
|
||||
if len(buf) < 10:
|
||||
return None
|
||||
sig = buf[:6].decode("ascii", errors="replace")
|
||||
if sig not in ("GIF87a", "GIF89a"):
|
||||
return None
|
||||
w = struct.unpack("<H", buf[6:8])[0]
|
||||
h = struct.unpack("<H", buf[8:10])[0]
|
||||
return {"width": w, "height": h}
|
||||
|
||||
|
||||
def _parse_webp_size(buf: bytes) -> Optional[dict[str, int]]:
|
||||
if len(buf) < 16:
|
||||
return None
|
||||
if buf[:4] != b"RIFF" or buf[8:12] != b"WEBP":
|
||||
return None
|
||||
chunk = buf[12:16].decode("ascii", errors="replace")
|
||||
if chunk == "VP8 ":
|
||||
if len(buf) >= 30 and buf[23] == 0x9D and buf[24] == 0x01 and buf[25] == 0x2A:
|
||||
w = struct.unpack("<H", buf[26:28])[0] & 0x3FFF
|
||||
h = struct.unpack("<H", buf[28:30])[0] & 0x3FFF
|
||||
return {"width": w, "height": h}
|
||||
elif chunk == "VP8L":
|
||||
if len(buf) >= 25 and buf[20] == 0x2F:
|
||||
bits = struct.unpack("<I", buf[21:25])[0]
|
||||
w = (bits & 0x3FFF) + 1
|
||||
h = ((bits >> 14) & 0x3FFF) + 1
|
||||
return {"width": w, "height": h}
|
||||
elif chunk == "VP8X":
|
||||
if len(buf) >= 30:
|
||||
w = (buf[24] | (buf[25] << 8) | (buf[26] << 16)) + 1
|
||||
h = (buf[27] | (buf[28] << 8) | (buf[29] << 16)) + 1
|
||||
return {"width": w, "height": h}
|
||||
return None
|
||||
|
||||
|
||||
# ============ URL 下载 ============
|
||||
|
||||
async def download_url(
|
||||
url: str,
|
||||
max_size_mb: int = DEFAULT_MAX_SIZE_MB,
|
||||
) -> tuple[bytes, str]:
|
||||
"""
|
||||
下载 URL 内容,返回 (bytes, content_type)。
|
||||
|
||||
Args:
|
||||
url: HTTP(S) URL
|
||||
max_size_mb: 最大允许大小(MB),超过则抛出异常
|
||||
|
||||
Returns:
|
||||
(data_bytes, content_type_string)
|
||||
|
||||
Raises:
|
||||
ValueError: 内容超过大小限制
|
||||
httpx.HTTPError: 网络/HTTP 错误
|
||||
"""
|
||||
max_bytes = max_size_mb * 1024 * 1024
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
# 先 HEAD 检查大小
|
||||
try:
|
||||
head = await client.head(url)
|
||||
content_length = int(head.headers.get("content-length", 0) or 0)
|
||||
if content_length > 0 and content_length > max_bytes:
|
||||
raise ValueError(
|
||||
f"文件过大: {content_length / 1024 / 1024:.1f} MB > {max_size_mb} MB"
|
||||
)
|
||||
except httpx.HTTPStatusError:
|
||||
pass # 部分服务器不支持 HEAD,忽略
|
||||
|
||||
# GET 下载(流式读取,防止超限)
|
||||
async with client.stream("GET", url) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
content_type = resp.headers.get("content-type", "").split(";")[0].strip()
|
||||
|
||||
chunks: list[bytes] = []
|
||||
downloaded = 0
|
||||
async for chunk in resp.aiter_bytes(65536):
|
||||
downloaded += len(chunk)
|
||||
if downloaded > max_bytes:
|
||||
raise ValueError(
|
||||
f"文件过大: 已超过 {max_size_mb} MB 限制"
|
||||
)
|
||||
chunks.append(chunk)
|
||||
|
||||
data = b"".join(chunks)
|
||||
return data, content_type
|
||||
|
||||
|
||||
# ============ COS 鉴权(HMAC-SHA1) ============
|
||||
|
||||
def _cos_sign(
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict[str, str],
|
||||
headers: dict[str, str],
|
||||
secret_id: str,
|
||||
secret_key: str,
|
||||
start_time: Optional[int] = None,
|
||||
expire_seconds: int = 3600,
|
||||
) -> str:
|
||||
"""
|
||||
构建 COS 请求签名(q-sign-algorithm=sha1 方案)。
|
||||
参考:https://cloud.tencent.com/document/product/436/7778
|
||||
|
||||
Args:
|
||||
method: HTTP 方法(小写,如 "put")
|
||||
path: URL 路径(URL encode 后的小写)
|
||||
params: URL 查询参数 dict(用于签名)
|
||||
headers: 参与签名的请求头 dict(key 需小写)
|
||||
secret_id: 临时 SecretId(tmpSecretId)
|
||||
secret_key: 临时 SecretKey(tmpSecretKey)
|
||||
start_time: 签名起始 Unix 时间戳(默认 now)
|
||||
expire_seconds: 签名有效期(秒,默认 3600)
|
||||
|
||||
Returns:
|
||||
Authorization header 值(完整字符串)
|
||||
"""
|
||||
now = int(time.time())
|
||||
q_sign_time = f"{start_time or now};{(start_time or now) + expire_seconds}"
|
||||
|
||||
# Step 1: SignKey = HMAC-SHA1(SecretKey, q-sign-time)
|
||||
sign_key = hmac.new(
|
||||
secret_key.encode("utf-8"),
|
||||
q_sign_time.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).hexdigest()
|
||||
|
||||
# Step 2: HttpString
|
||||
# 参数和头部需按字典序排列,key 小写
|
||||
sorted_params = sorted((k.lower(), urllib.parse.quote(str(v), safe="") ) for k, v in params.items())
|
||||
sorted_headers = sorted((k.lower(), urllib.parse.quote(str(v), safe="") ) for k, v in headers.items())
|
||||
|
||||
url_param_list = ";".join(k for k, _ in sorted_params)
|
||||
url_params = "&".join(f"{k}={v}" for k, v in sorted_params)
|
||||
header_list = ";".join(k for k, _ in sorted_headers)
|
||||
header_str = "&".join(f"{k}={v}" for k, v in sorted_headers)
|
||||
|
||||
http_string = "\n".join([
|
||||
method.lower(),
|
||||
path,
|
||||
url_params,
|
||||
header_str,
|
||||
"",
|
||||
])
|
||||
|
||||
# Step 3: StringToSign = sha1 hash of HttpString
|
||||
sha1_of_http = hashlib.sha1(http_string.encode("utf-8")).hexdigest()
|
||||
string_to_sign = "\n".join([
|
||||
"sha1",
|
||||
q_sign_time,
|
||||
sha1_of_http,
|
||||
"",
|
||||
])
|
||||
|
||||
# Step 4: Signature = HMAC-SHA1(SignKey, StringToSign)
|
||||
signature = hmac.new(
|
||||
sign_key.encode("utf-8"),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).hexdigest()
|
||||
|
||||
return (
|
||||
f"q-sign-algorithm=sha1"
|
||||
f"&q-ak={secret_id}"
|
||||
f"&q-sign-time={q_sign_time}"
|
||||
f"&q-key-time={q_sign_time}"
|
||||
f"&q-header-list={header_list}"
|
||||
f"&q-url-param-list={url_param_list}"
|
||||
f"&q-signature={signature}"
|
||||
)
|
||||
|
||||
|
||||
# ============ 主要公开 API ============
|
||||
|
||||
async def get_cos_credentials(
|
||||
app_key: str,
|
||||
api_domain: str,
|
||||
token: str,
|
||||
filename: str = "file",
|
||||
file_id: Optional[str] = None,
|
||||
bot_id: str = "",
|
||||
route_env: str = "",
|
||||
) -> dict:
|
||||
"""
|
||||
调用 genUploadInfo 接口获取 COS 临时密钥及上传配置。
|
||||
|
||||
Args:
|
||||
app_key: 应用 Key(用于 X-ID 头)
|
||||
api_domain: API 域名(如 https://bot.yuanbao.tencent.com)
|
||||
token: 当前有效的签票 token(X-Token 头)
|
||||
filename: 待上传的文件名(含扩展名)
|
||||
file_id: 客户端生成的唯一文件 ID(不传则自动生成)
|
||||
bot_id: Bot 账号 ID(用于 X-ID 头)
|
||||
|
||||
Returns:
|
||||
COS 上传配置 dict,包含以下字段:
|
||||
bucketName (str) — COS Bucket 名称
|
||||
region (str) — COS 地域
|
||||
location (str) — 上传 Key(对象路径)
|
||||
encryptTmpSecretId (str) — 临时 SecretId
|
||||
encryptTmpSecretKey(str) — 临时 SecretKey
|
||||
encryptToken (str) — SessionToken
|
||||
startTime (int) — 凭证起始时间戳(Unix)
|
||||
expiredTime (int) — 凭证过期时间戳(Unix)
|
||||
resourceUrl (str) — 上传后的公网访问 URL
|
||||
resourceID (str) — 资源 ID(可选)
|
||||
|
||||
Raises:
|
||||
RuntimeError: 接口返回非 0 code 或字段缺失
|
||||
"""
|
||||
if file_id is None:
|
||||
file_id = generate_file_id()
|
||||
|
||||
upload_url = f"{api_domain.rstrip('/')}{UPLOAD_INFO_PATH}"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Token": token,
|
||||
"X-ID": bot_id or app_key,
|
||||
"X-Source": "web",
|
||||
}
|
||||
if route_env:
|
||||
headers["X-Route-Env"] = route_env
|
||||
body = {
|
||||
"fileName": filename,
|
||||
"fileId": file_id,
|
||||
"docFrom": "localDoc",
|
||||
"docOpenId": "",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(upload_url, json=body, headers=headers)
|
||||
resp.raise_for_status()
|
||||
result: dict[str, Any] = resp.json()
|
||||
|
||||
code = result.get("code")
|
||||
if code != 0 and code is not None:
|
||||
raise RuntimeError(
|
||||
f"genUploadInfo 失败: code={code}, msg={result.get('msg', '')}"
|
||||
)
|
||||
|
||||
data = result.get("data") or result
|
||||
required_fields = ["bucketName", "location"]
|
||||
missing = [f for f in required_fields if not data.get(f)]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
f"genUploadInfo 返回字段不完整: 缺少字段 {missing}"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def upload_to_cos(
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
credentials: dict,
|
||||
bucket: str,
|
||||
region: str,
|
||||
) -> dict:
|
||||
"""
|
||||
通过 httpx PUT 请求将文件上传到 COS。
|
||||
使用临时凭证(tmpSecretId/tmpSecretKey/sessionToken)构建 HMAC-SHA1 签名。
|
||||
|
||||
Args:
|
||||
file_bytes: 文件二进制内容
|
||||
filename: 文件名(用于辅助计算 MIME、UUID)
|
||||
content_type: MIME 类型(如 "image/jpeg")
|
||||
credentials: get_cos_credentials() 返回的 dict,包含:
|
||||
encryptTmpSecretId → tmpSecretId
|
||||
encryptTmpSecretKey → tmpSecretKey
|
||||
encryptToken → sessionToken
|
||||
location → COS key(对象路径)
|
||||
resourceUrl → 上传后公网 URL
|
||||
startTime → 凭证起始时间(Unix)
|
||||
expiredTime → 凭证过期时间(Unix)
|
||||
bucket: COS Bucket 名称(如 chatbot-1234567890)
|
||||
region: COS 地域(如 ap-guangzhou)
|
||||
|
||||
Returns:
|
||||
上传结果 dict,包含:
|
||||
url (str) — COS 公网访问 URL
|
||||
uuid (str) — 文件内容 MD5
|
||||
size (int) — 文件大小(字节)
|
||||
width (int, optional) — 图片宽度(仅图片)
|
||||
height (int, optional) — 图片高度(仅图片)
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: COS 返回非 2xx 状态
|
||||
RuntimeError: credentials 字段缺失
|
||||
"""
|
||||
secret_id: str = credentials.get("encryptTmpSecretId", "")
|
||||
secret_key: str = credentials.get("encryptTmpSecretKey", "")
|
||||
session_token: str = credentials.get("encryptToken", "")
|
||||
cos_key: str = credentials.get("location", "")
|
||||
resource_url: str = credentials.get("resourceUrl", "")
|
||||
start_time: Optional[int] = credentials.get("startTime")
|
||||
expired_time: Optional[int] = credentials.get("expiredTime")
|
||||
|
||||
if not secret_id or not secret_key or not cos_key:
|
||||
raise RuntimeError(
|
||||
f"COS credentials 不完整: secretId={bool(secret_id)}, "
|
||||
f"secretKey={bool(secret_key)}, location={bool(cos_key)}"
|
||||
)
|
||||
|
||||
# 构建 COS 上传 URL(优先使用全球加速域名)
|
||||
if COS_USE_ACCELERATE:
|
||||
cos_host = f"{bucket}.cos.accelerate.myqcloud.com"
|
||||
else:
|
||||
cos_host = f"{bucket}.cos.{region}.myqcloud.com"
|
||||
|
||||
# URL encode cos_key(保留 /)
|
||||
encoded_key = urllib.parse.quote(cos_key, safe="/")
|
||||
cos_url = f"https://{cos_host}/{encoded_key.lstrip('/')}"
|
||||
|
||||
# 确定 Content-Type
|
||||
if not content_type or content_type == "application/octet-stream":
|
||||
if is_image(filename):
|
||||
content_type = guess_mime_type(filename)
|
||||
else:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
# 计算文件 MD5 + size
|
||||
file_uuid = md5_hex(file_bytes)
|
||||
file_size = len(file_bytes)
|
||||
|
||||
# 参与签名的请求头
|
||||
sign_headers = {
|
||||
"host": cos_host,
|
||||
"content-type": content_type,
|
||||
"x-cos-security-token": session_token,
|
||||
}
|
||||
|
||||
# 计算签名有效期
|
||||
now = int(time.time())
|
||||
sign_start = start_time if start_time else now
|
||||
sign_expire = (expired_time - now) if expired_time and expired_time > now else 3600
|
||||
|
||||
authorization = _cos_sign(
|
||||
method="put",
|
||||
path=f"/{encoded_key.lstrip('/')}",
|
||||
params={},
|
||||
headers=sign_headers,
|
||||
secret_id=secret_id,
|
||||
secret_key=secret_key,
|
||||
start_time=sign_start,
|
||||
expire_seconds=sign_expire,
|
||||
)
|
||||
|
||||
put_headers = {
|
||||
"Authorization": authorization,
|
||||
"Content-Type": content_type,
|
||||
"x-cos-security-token": session_token,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"COS PUT: bucket=%s region=%s key=%s size=%d mime=%s",
|
||||
bucket, region, cos_key, file_size, content_type,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.put(
|
||||
cos_url,
|
||||
content=file_bytes,
|
||||
headers=put_headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
# 解析图片尺寸(仅图片类型)
|
||||
result: dict[str, Any] = {
|
||||
"url": resource_url or cos_url,
|
||||
"uuid": file_uuid,
|
||||
"size": file_size,
|
||||
}
|
||||
|
||||
if content_type.startswith("image/"):
|
||||
size_info = parse_image_size(file_bytes)
|
||||
if size_info:
|
||||
result["width"] = size_info["width"]
|
||||
result["height"] = size_info["height"]
|
||||
|
||||
logger.info(
|
||||
"COS 上传成功: url=%s size=%d",
|
||||
result["url"], file_size,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ============ TIM 媒体消息构建 ============
|
||||
|
||||
def build_image_msg_body(
|
||||
url: str,
|
||||
uuid: Optional[str] = None,
|
||||
filename: Optional[str] = None,
|
||||
size: int = 0,
|
||||
width: int = 0,
|
||||
height: int = 0,
|
||||
mime_type: str = "",
|
||||
) -> list[dict]:
|
||||
"""
|
||||
构建腾讯 IM TIMImageElem 消息体。
|
||||
参考:https://cloud.tencent.com/document/product/269/2720
|
||||
|
||||
Args:
|
||||
url: 图片公网访问 URL(COS resourceUrl)
|
||||
uuid: 文件 UUID(MD5 或其他唯一标识)
|
||||
filename: 文件名(uuid 为空时作为备用)
|
||||
size: 文件大小(字节)
|
||||
width: 图片宽度(像素)
|
||||
height: 图片高度(像素)
|
||||
mime_type: MIME 类型(用于确定 image_format)
|
||||
|
||||
Returns:
|
||||
TIMImageElem 消息体列表(适合直接放入 msg_body)
|
||||
"""
|
||||
_uuid = uuid or filename or _basename_from_url(url) or "image"
|
||||
image_format = get_image_format(mime_type) if mime_type else 255
|
||||
|
||||
return [
|
||||
{
|
||||
"msg_type": "TIMImageElem",
|
||||
"msg_content": {
|
||||
"uuid": _uuid,
|
||||
"image_format": image_format,
|
||||
"image_info_array": [
|
||||
{
|
||||
"type": 1, # 1 = 原图
|
||||
"size": size,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"url": url,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def build_file_msg_body(
|
||||
url: str,
|
||||
filename: str,
|
||||
uuid: Optional[str] = None,
|
||||
size: int = 0,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
构建腾讯 IM TIMFileElem 消息体。
|
||||
参考:https://cloud.tencent.com/document/product/269/2720
|
||||
|
||||
Args:
|
||||
url: 文件公网访问 URL(COS resourceUrl)
|
||||
filename: 文件名(含扩展名)
|
||||
uuid: 文件 UUID(MD5 或其他唯一标识,不传则使用 filename)
|
||||
size: 文件大小(字节)
|
||||
|
||||
Returns:
|
||||
TIMFileElem 消息体列表(适合直接放入 msg_body)
|
||||
"""
|
||||
_uuid = uuid or filename
|
||||
|
||||
return [
|
||||
{
|
||||
"msg_type": "TIMFileElem",
|
||||
"msg_content": {
|
||||
"uuid": _uuid,
|
||||
"file_name": filename,
|
||||
"file_size": size,
|
||||
"url": url,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ============ 内部工具 ============
|
||||
|
||||
def _basename_from_url(url: str) -> str:
|
||||
"""从 URL 提取文件名。"""
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
return os.path.basename(parsed.path)
|
||||
except Exception:
|
||||
return ""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,558 +0,0 @@
|
||||
"""
|
||||
Yuanbao sticker (TIMFaceElem) support.
|
||||
|
||||
Ported from yuanbao-openclaw-plugin/src/sticker/.
|
||||
|
||||
TIMFaceElem wire format:
|
||||
{
|
||||
"msg_type": "TIMFaceElem",
|
||||
"msg_content": {
|
||||
"index": 0, # always 0 per Yuanbao convention
|
||||
"data": "<json>", # serialised sticker metadata
|
||||
}
|
||||
}
|
||||
|
||||
The `data` field carries a JSON string with the sticker's metadata so the
|
||||
receiver can look up the correct asset in the emoji pack.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sticker catalogue – ported from builtin-stickers.json
|
||||
# Key : canonical name (Chinese)
|
||||
# Value : {sticker_id, package_id, name, description, width, height, formats}
|
||||
# ---------------------------------------------------------------------------
|
||||
STICKER_MAP: dict[str, dict] = {
|
||||
"六六六": {
|
||||
"sticker_id": "278", "package_id": "1003", "name": "六六六",
|
||||
"description": "666 厉害 牛 棒 绝了 好强 awesome",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"我想开了": {
|
||||
"sticker_id": "262", "package_id": "1003", "name": "我想开了",
|
||||
"description": "想开 佛系 释怀 顿悟 看淡了 无所谓",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"害羞": {
|
||||
"sticker_id": "130", "package_id": "1003", "name": "害羞",
|
||||
"description": "腼腆 不好意思 脸红 娇羞 羞涩 捂脸",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"比心": {
|
||||
"sticker_id": "252", "package_id": "1003", "name": "比心",
|
||||
"description": "笔芯 爱你 爱心手势 love heart 喜欢你",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"委屈": {
|
||||
"sticker_id": "125", "package_id": "1003", "name": "委屈",
|
||||
"description": "难过 想哭 可怜巴巴 瘪嘴 受伤 被欺负",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"亲亲": {
|
||||
"sticker_id": "146", "package_id": "1003", "name": "亲亲",
|
||||
"description": "么么 mua 亲一下 kiss 飞吻 啵",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"酷": {
|
||||
"sticker_id": "131", "package_id": "1003", "name": "酷",
|
||||
"description": "帅 墨镜 cool 高冷 有型 swagger",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"睡": {
|
||||
"sticker_id": "145", "package_id": "1003", "name": "睡",
|
||||
"description": "睡觉 困 zzZ 打盹 躺平 休眠 sleepy",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"发呆": {
|
||||
"sticker_id": "152", "package_id": "1003", "name": "发呆",
|
||||
"description": "懵 愣住 放空 呆滞 出神 脑子空白",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"可怜": {
|
||||
"sticker_id": "157", "package_id": "1003", "name": "可怜",
|
||||
"description": "卖萌 求饶 委屈巴巴 弱小 拜托 眼巴巴",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"摊手": {
|
||||
"sticker_id": "200", "package_id": "1003", "name": "摊手",
|
||||
"description": "无奈 没办法 耸肩 随便 那咋整 whatever",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"头大": {
|
||||
"sticker_id": "213", "package_id": "1003", "name": "头大",
|
||||
"description": "头疼 烦恼 郁闷 难搞 崩溃 一团乱",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"吓": {
|
||||
"sticker_id": "256", "package_id": "1003", "name": "吓",
|
||||
"description": "害怕 惊恐 震惊 吓一跳 恐怖 怂",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"吐血": {
|
||||
"sticker_id": "203", "package_id": "1003", "name": "吐血",
|
||||
"description": "无语 崩溃 被雷 内伤 一口老血 屮",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"哼": {
|
||||
"sticker_id": "185", "package_id": "1003", "name": "哼",
|
||||
"description": "傲娇 生气 不满 撇嘴 不理 赌气",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"嘿嘿": {
|
||||
"sticker_id": "220", "package_id": "1003", "name": "嘿嘿",
|
||||
"description": "坏笑 猥琐笑 偷笑 憨笑 得意 你懂的",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"头秃": {
|
||||
"sticker_id": "218", "package_id": "1003", "name": "头秃",
|
||||
"description": "程序员 加班 焦虑 没头发 秃了 肝爆",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"暗中观察": {
|
||||
"sticker_id": "221", "package_id": "1003", "name": "暗中观察",
|
||||
"description": "窥屏 潜水 偷偷看 角落 围观 屏住呼吸",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"我酸了": {
|
||||
"sticker_id": "224", "package_id": "1003", "name": "我酸了",
|
||||
"description": "嫉妒 柠檬精 羡慕 吃柠檬 眼红 恰柠檬",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"打call": {
|
||||
"sticker_id": "246", "package_id": "1003", "name": "打call",
|
||||
"description": "应援 加油 支持 喝彩 助威 call",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"庆祝": {
|
||||
"sticker_id": "251", "package_id": "1003", "name": "庆祝",
|
||||
"description": "祝贺 开心 耶 party 胜利 干杯",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"奋斗": {
|
||||
"sticker_id": "151", "package_id": "1003", "name": "奋斗",
|
||||
"description": "努力 加油 拼搏 冲 干劲 卷起来",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"惊讶": {
|
||||
"sticker_id": "143", "package_id": "1003", "name": "惊讶",
|
||||
"description": "震惊 哇 不敢相信 OMG 居然 这么离谱",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"疑问": {
|
||||
"sticker_id": "144", "package_id": "1003", "name": "疑问",
|
||||
"description": "问号 不懂 啥 为什么 啥情况 懵逼问",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"仔细分析": {
|
||||
"sticker_id": "248", "package_id": "1003", "name": "仔细分析",
|
||||
"description": "思考 推敲 认真 研究 琢磨 让我想想",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"撅嘴": {
|
||||
"sticker_id": "184", "package_id": "1003", "name": "撅嘴",
|
||||
"description": "嘟嘴 卖萌 不高兴 撒娇 嘴翘",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"泪奔": {
|
||||
"sticker_id": "199", "package_id": "1003", "name": "泪奔",
|
||||
"description": "大哭 伤心 破防 感动哭 泪流满面 呜呜",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"尊嘟假嘟": {
|
||||
"sticker_id": "276", "package_id": "1003", "name": "尊嘟假嘟",
|
||||
"description": "真的假的 真假 可爱问 你骗我 是不是",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"略略略": {
|
||||
"sticker_id": "113", "package_id": "1003", "name": "略略略",
|
||||
"description": "调皮 吐舌 不服 略 气死你 鬼脸",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"困": {
|
||||
"sticker_id": "180", "package_id": "1003", "name": "困",
|
||||
"description": "想睡 倦 打哈欠 睁不开眼 好困啊 sleepy",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"折磨": {
|
||||
"sticker_id": "181", "package_id": "1003", "name": "折磨",
|
||||
"description": "难受 痛苦 煎熬 蚌埠住了 受不了 要命",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"抠鼻": {
|
||||
"sticker_id": "182", "package_id": "1003", "name": "抠鼻",
|
||||
"description": "不屑 无聊 淡定 无所谓 鄙视 挖鼻",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"鼓掌": {
|
||||
"sticker_id": "183", "package_id": "1003", "name": "鼓掌",
|
||||
"description": "拍手 叫好 赞同 666 喝彩 掌声",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"斜眼笑": {
|
||||
"sticker_id": "204", "package_id": "1003", "name": "斜眼笑",
|
||||
"description": "滑稽 坏笑 doge 意味深长 阴阳怪气 嘿嘿嘿",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"辣眼睛": {
|
||||
"sticker_id": "216", "package_id": "1003", "name": "辣眼睛",
|
||||
"description": "看不下去 cringe 毁三观 太丑了 瞎了",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"哦哟": {
|
||||
"sticker_id": "217", "package_id": "1003", "name": "哦哟",
|
||||
"description": "惊讶 起哄 哇哦 有戏 不简单 哟",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"吃瓜": {
|
||||
"sticker_id": "222", "package_id": "1003", "name": "吃瓜",
|
||||
"description": "围观 看戏 八卦 路人 看热闹 板凳",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"狗头": {
|
||||
"sticker_id": "225", "package_id": "1003", "name": "狗头",
|
||||
"description": "doge 保命 开玩笑 滑稽 反讽 懂的都懂",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"敬礼": {
|
||||
"sticker_id": "227", "package_id": "1003", "name": "敬礼",
|
||||
"description": "salute 尊重 收到 遵命 致敬 报告",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"哦": {
|
||||
"sticker_id": "231", "package_id": "1003", "name": "哦",
|
||||
"description": "知道了 明白 敷衍 嗯 这样啊 收到",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"拿到红包": {
|
||||
"sticker_id": "236", "package_id": "1003", "name": "拿到红包",
|
||||
"description": "红包 谢谢老板 发财 开心 抢到了 欧气",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"牛吖": {
|
||||
"sticker_id": "239", "package_id": "1003", "name": "牛吖",
|
||||
"description": "牛 厉害 强 666 佩服 大佬",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"贴贴": {
|
||||
"sticker_id": "272", "package_id": "1003", "name": "贴贴",
|
||||
"description": "抱抱 亲昵 蹭蹭 亲密 靠靠 撒娇贴",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"爱心": {
|
||||
"sticker_id": "138", "package_id": "1003", "name": "爱心",
|
||||
"description": "心 love 喜欢你 红心 示爱 么么哒",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"晚安": {
|
||||
"sticker_id": "170", "package_id": "1003", "name": "晚安",
|
||||
"description": "好梦 睡了 night 早点休息 安啦 moon",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"太阳": {
|
||||
"sticker_id": "176", "package_id": "1003", "name": "太阳",
|
||||
"description": "晴天 早上好 阳光 morning 好天气 日",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"柠檬": {
|
||||
"sticker_id": "266", "package_id": "1003", "name": "柠檬",
|
||||
"description": "酸 嫉妒 柠檬精 羡慕 我酸 恰柠檬",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"大冤种": {
|
||||
"sticker_id": "267", "package_id": "1003", "name": "大冤种",
|
||||
"description": "倒霉 吃亏 自嘲 好心没好报 背锅 工具人",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"吐了": {
|
||||
"sticker_id": "132", "package_id": "1003", "name": "吐了",
|
||||
"description": "恶心 yue 受不了 嫌弃 想吐 生理不适",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"怒": {
|
||||
"sticker_id": "134", "package_id": "1003", "name": "怒",
|
||||
"description": "生气 愤怒 火大 暴躁 气炸 怼",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"玫瑰": {
|
||||
"sticker_id": "165", "package_id": "1003", "name": "玫瑰",
|
||||
"description": "花 示爱 表白 浪漫 送你花 情人节",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"凋谢": {
|
||||
"sticker_id": "119", "package_id": "1003", "name": "凋谢",
|
||||
"description": "花谢 失恋 难过 枯萎 心碎 凉了",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"点赞": {
|
||||
"sticker_id": "159", "package_id": "1003", "name": "点赞",
|
||||
"description": "赞 认同 好棒 good like 大拇指 顶",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"握手": {
|
||||
"sticker_id": "164", "package_id": "1003", "name": "握手",
|
||||
"description": "合作 你好 商务 hello deal 成交 友好",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"抱拳": {
|
||||
"sticker_id": "163", "package_id": "1003", "name": "抱拳",
|
||||
"description": "谢谢 失敬 江湖 承让 拜托 有礼",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"ok": {
|
||||
"sticker_id": "169", "package_id": "1003", "name": "ok",
|
||||
"description": "好的 收到 没问题 okay 行 可以 懂了",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"拳头": {
|
||||
"sticker_id": "174", "package_id": "1003", "name": "拳头",
|
||||
"description": "加油 干 冲 fight 力量 击拳 硬气",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"鞭炮": {
|
||||
"sticker_id": "191", "package_id": "1003", "name": "鞭炮",
|
||||
"description": "过年 喜庆 爆竹 春节 噼里啪啦 红",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
"烟花": {
|
||||
"sticker_id": "258", "package_id": "1003", "name": "烟花",
|
||||
"description": "庆典 漂亮 新年 嘭 绽放 节日快乐",
|
||||
"width": 128, "height": 128, "formats": "png",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_sticker_by_name(name: str) -> Optional[dict]:
|
||||
"""
|
||||
按名称查找贴纸,支持模糊匹配。
|
||||
|
||||
匹配优先级:
|
||||
1. 完全相等(name)
|
||||
2. name 包含查询词(前缀/子串)
|
||||
3. description 包含查询词(同义词搜索)
|
||||
4. 通用模糊评分(与 sticker-search 同算法),命中即返回得分最高的一条
|
||||
|
||||
返回 sticker dict,找不到返回 None。
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
|
||||
query = name.strip()
|
||||
|
||||
if query in STICKER_MAP:
|
||||
return STICKER_MAP[query]
|
||||
|
||||
for key, sticker in STICKER_MAP.items():
|
||||
if query in key or key in query:
|
||||
return sticker
|
||||
|
||||
for sticker in STICKER_MAP.values():
|
||||
desc = sticker.get("description", "")
|
||||
if query in desc:
|
||||
return sticker
|
||||
|
||||
matches = search_stickers(query, limit=1)
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def get_random_sticker(category: str = None) -> dict:
|
||||
"""
|
||||
随机返回一个贴纸。
|
||||
|
||||
若指定 category,则在 description 中含有该关键词的贴纸里随机选取;
|
||||
category 为 None 时从全表随机。
|
||||
"""
|
||||
if category:
|
||||
candidates = [
|
||||
s for s in STICKER_MAP.values()
|
||||
if category in s.get("description", "") or category in s.get("name", "")
|
||||
]
|
||||
if candidates:
|
||||
return random.choice(candidates)
|
||||
return random.choice(list(STICKER_MAP.values()))
|
||||
|
||||
|
||||
def get_sticker_by_id(sticker_id: str) -> Optional[dict]:
|
||||
"""按 sticker_id 精确查找贴纸。"""
|
||||
if not sticker_id:
|
||||
return None
|
||||
sid = str(sticker_id).strip()
|
||||
for sticker in STICKER_MAP.values():
|
||||
if sticker.get("sticker_id") == sid:
|
||||
return sticker
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模糊搜索(对齐 chatbot-web yuanbao-openclaw-plugin/sticker-cache.ts.searchStickers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PUNCT_RE = re.compile(r"[\s\u3000\-_·.,,。!!??\"“”'‘’、/\\]+")
|
||||
|
||||
|
||||
def _normalize_text(raw: str) -> str:
|
||||
return unicodedata.normalize("NFKC", str(raw or "")).strip().lower()
|
||||
|
||||
|
||||
def _compact_text(raw: str) -> str:
|
||||
return _PUNCT_RE.sub("", _normalize_text(raw))
|
||||
|
||||
|
||||
def _multiset_char_hit_ratio(needle: str, haystack: str) -> float:
|
||||
if not needle:
|
||||
return 0.0
|
||||
bag: dict[str, int] = {}
|
||||
for ch in haystack:
|
||||
bag[ch] = bag.get(ch, 0) + 1
|
||||
hits = 0
|
||||
for ch in needle:
|
||||
n = bag.get(ch, 0)
|
||||
if n > 0:
|
||||
hits += 1
|
||||
bag[ch] = n - 1
|
||||
return hits / len(needle)
|
||||
|
||||
|
||||
def _bigram_jaccard(a: str, b: str) -> float:
|
||||
if len(a) < 2 or len(b) < 2:
|
||||
return 0.0
|
||||
A = {a[i:i + 2] for i in range(len(a) - 1)}
|
||||
B = {b[i:i + 2] for i in range(len(b) - 1)}
|
||||
inter = len(A & B)
|
||||
union = len(A) + len(B) - inter
|
||||
return inter / union if union else 0.0
|
||||
|
||||
|
||||
def _longest_subsequence_ratio(needle: str, haystack: str) -> float:
|
||||
if not needle:
|
||||
return 0.0
|
||||
j = 0
|
||||
for ch in haystack:
|
||||
if j >= len(needle):
|
||||
break
|
||||
if ch == needle[j]:
|
||||
j += 1
|
||||
return j / len(needle)
|
||||
|
||||
|
||||
def _score_field(haystack: str, query: str) -> float:
|
||||
hay = _normalize_text(haystack)
|
||||
q = _normalize_text(query)
|
||||
if not hay or not q:
|
||||
return 0.0
|
||||
hay_c = _compact_text(haystack)
|
||||
q_c = _compact_text(query)
|
||||
best = 0.0
|
||||
if hay == q:
|
||||
best = max(best, 100.0)
|
||||
if q in hay:
|
||||
best = max(best, 92 + min(6, len(q)))
|
||||
if len(q) >= 2 and hay.startswith(q):
|
||||
best = max(best, 88.0)
|
||||
if q_c and q_c in hay_c:
|
||||
best = max(best, 86.0)
|
||||
best = max(best, _multiset_char_hit_ratio(q_c, hay_c) * 62)
|
||||
best = max(best, _bigram_jaccard(q_c, hay_c) * 58)
|
||||
best = max(best, _longest_subsequence_ratio(q_c, hay_c) * 52)
|
||||
if len(q) == 1 and q in hay:
|
||||
best = max(best, 68.0)
|
||||
return best
|
||||
|
||||
|
||||
def search_stickers(query: str, limit: int = 10) -> list[dict]:
|
||||
"""
|
||||
在内置贴纸表中按模糊匹配排序返回前 N 条结果。
|
||||
|
||||
评分综合 name/description 字段的子串、字符多重集覆盖、bigram Jaccard、子序列比例。
|
||||
name 权重略高于 description(×0.88)。空 query 时按字典顺序返回前 N 条。
|
||||
"""
|
||||
safe_limit = max(1, min(500, int(limit) if limit else 10))
|
||||
if not query or not _normalize_text(query):
|
||||
return list(STICKER_MAP.values())[:safe_limit]
|
||||
|
||||
scored: list[tuple[float, dict]] = []
|
||||
for sticker in STICKER_MAP.values():
|
||||
name_s = _score_field(sticker.get("name", ""), query)
|
||||
desc_s = _score_field(sticker.get("description", ""), query) * 0.88
|
||||
sid = str(sticker.get("sticker_id", "")).strip()
|
||||
q_norm = _normalize_text(query)
|
||||
id_s = 0.0
|
||||
if sid and q_norm:
|
||||
sid_norm = _normalize_text(sid)
|
||||
if sid_norm == q_norm:
|
||||
id_s = 100.0
|
||||
elif q_norm in sid_norm:
|
||||
id_s = 84.0
|
||||
scored.append((max(name_s, desc_s, id_s), sticker))
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
top = scored[0][0] if scored else 0
|
||||
if top <= 0:
|
||||
return [s for _, s in scored[:safe_limit]]
|
||||
|
||||
if top >= 22:
|
||||
floor = 18.0
|
||||
elif top >= 12:
|
||||
floor = max(10.0, top * 0.5)
|
||||
else:
|
||||
floor = max(6.0, top * 0.35)
|
||||
|
||||
filtered = [pair for pair in scored if pair[0] >= floor]
|
||||
out = filtered if filtered else scored
|
||||
return [s for _, s in out[:safe_limit]]
|
||||
|
||||
|
||||
def build_face_msg_body(
|
||||
face_index: int,
|
||||
face_type: int = 1,
|
||||
data: Optional[str] = None,
|
||||
) -> list:
|
||||
"""
|
||||
构造 TIMFaceElem 消息体。
|
||||
|
||||
Yuanbao 约定:
|
||||
- index 固定传 0(服务端通过 data 字段识别具体表情)
|
||||
- data 为 JSON 字符串,包含 sticker_id / package_id 等字段
|
||||
|
||||
Args:
|
||||
face_index: 保留字段,暂时不影响 wire format(Yuanbao 固定 index=0)。
|
||||
当 face_index > 0 时视为旧版 QQ 表情 ID,直接放入 index。
|
||||
face_type: 保留字段(兼容旧接口,当前未使用)。
|
||||
data: 已序列化的 JSON 字符串;为 None 时仅传 index。
|
||||
|
||||
Returns:
|
||||
符合 Yuanbao TIM 协议的 msg_body list,如::
|
||||
|
||||
[{"msg_type": "TIMFaceElem", "msg_content": {"index": 0, "data": "..."}}]
|
||||
"""
|
||||
msg_content: dict = {"index": face_index}
|
||||
if data is not None:
|
||||
msg_content["data"] = data
|
||||
return [{"msg_type": "TIMFaceElem", "msg_content": msg_content}]
|
||||
|
||||
|
||||
def build_sticker_msg_body(sticker: dict) -> list:
|
||||
"""
|
||||
从 STICKER_MAP 中的 sticker dict 直接构造 TIMFaceElem 消息体。
|
||||
|
||||
这是 send_sticker() 的内部辅助,确保 data 字段与原始 JS 插件一致。
|
||||
"""
|
||||
data_payload = json.dumps(
|
||||
{
|
||||
"sticker_id": sticker["sticker_id"],
|
||||
"package_id": sticker["package_id"],
|
||||
"width": sticker.get("width", 128),
|
||||
"height": sticker.get("height", 128),
|
||||
"formats": sticker.get("formats", "png"),
|
||||
"name": sticker["name"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return build_face_msg_body(face_index=0, data=data_payload)
|
||||
+371
-584
File diff suppressed because it is too large
Load Diff
+2
-11
@@ -310,9 +310,8 @@ def build_session_context_prompt(
|
||||
"**Platform notes:** You are running inside Slack. "
|
||||
"You do NOT have access to Slack-specific APIs — you cannot search "
|
||||
"channel history, pin/unpin messages, manage channels, or list users. "
|
||||
"Do not promise to perform these actions. The gateway may inline the "
|
||||
"current message's Slack block/attachment payload when available, but "
|
||||
"you still cannot call Slack APIs yourself."
|
||||
"Do not promise to perform these actions. If the user asks, explain "
|
||||
"that you can only read messages sent directly to you and respond."
|
||||
)
|
||||
elif context.source.platform == Platform.DISCORD:
|
||||
# Inject the Discord IDs block only when the agent actually has
|
||||
@@ -354,14 +353,6 @@ def build_session_context_prompt(
|
||||
"If the user needs a detailed answer, give the short version first "
|
||||
"and offer to elaborate."
|
||||
)
|
||||
elif context.source.platform == Platform.YUANBAO:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"**Platform notes:** You are running inside Yuanbao. "
|
||||
"You CAN send private (DM) messages via the send_message tool. "
|
||||
"Use target='yuanbao:direct:<account_id>' for DM "
|
||||
"and target='yuanbao:group:<group_code>' for group chat."
|
||||
)
|
||||
|
||||
# Connected platforms
|
||||
platforms_list = ["local (files on this machine)"]
|
||||
|
||||
@@ -44,14 +44,6 @@ class StreamConsumerConfig:
|
||||
buffer_threshold: int = 40
|
||||
cursor: str = " ▉"
|
||||
buffer_only: bool = False
|
||||
# When >0, the final edit for a streamed response is delivered as a
|
||||
# fresh message if the original preview has been visible for at least
|
||||
# this many seconds. This makes the platform's visible timestamp
|
||||
# reflect completion time instead of first-token time for long-running
|
||||
# responses (e.g. reasoning models that stream slowly). Ported from
|
||||
# openclaw/openclaw#72038. Default 0 = always edit in place (legacy
|
||||
# behavior). The gateway enables this selectively per-platform.
|
||||
fresh_final_after_seconds: float = 0.0
|
||||
|
||||
|
||||
class GatewayStreamConsumer:
|
||||
@@ -99,12 +91,6 @@ class GatewayStreamConsumer:
|
||||
self._queue: queue.Queue = queue.Queue()
|
||||
self._accumulated = ""
|
||||
self._message_id: Optional[str] = None
|
||||
# Wall-clock timestamp (time.monotonic) when ``_message_id`` was
|
||||
# first assigned from a successful first-send. Used by the
|
||||
# fresh-final logic to detect long-lived previews whose edit
|
||||
# timestamps would be stale by completion time. Ported from
|
||||
# openclaw/openclaw#72038.
|
||||
self._message_created_ts: Optional[float] = None
|
||||
self._already_sent = False
|
||||
self._edit_supported = True # Disabled when progressive edits are no longer usable
|
||||
self._last_edit_time = 0.0
|
||||
@@ -150,7 +136,6 @@ class GatewayStreamConsumer:
|
||||
if preserve_no_edit and self._message_id == "__no_edit__":
|
||||
return
|
||||
self._message_id = None
|
||||
self._message_created_ts = None
|
||||
self._accumulated = ""
|
||||
self._last_sent_text = ""
|
||||
self._fallback_final_send = False
|
||||
@@ -749,81 +734,6 @@ class GatewayStreamConsumer:
|
||||
logger.error("Commentary send error: %s", e)
|
||||
return False
|
||||
|
||||
def _should_send_fresh_final(self) -> bool:
|
||||
"""Return True when a long-lived preview should be replaced with a
|
||||
fresh final message instead of an edit.
|
||||
|
||||
Conditions:
|
||||
- Fresh-final is enabled (``fresh_final_after_seconds > 0``).
|
||||
- We have a real preview message id (not the ``__no_edit__`` sentinel
|
||||
and not ``None``).
|
||||
- The preview has been visible for at least the configured threshold.
|
||||
|
||||
Ported from openclaw/openclaw#72038.
|
||||
"""
|
||||
threshold = getattr(self.cfg, "fresh_final_after_seconds", 0.0) or 0.0
|
||||
if threshold <= 0:
|
||||
return False
|
||||
if not self._message_id or self._message_id == "__no_edit__":
|
||||
return False
|
||||
if self._message_created_ts is None:
|
||||
return False
|
||||
age = time.monotonic() - self._message_created_ts
|
||||
return age >= threshold
|
||||
|
||||
async def _try_fresh_final(self, text: str) -> bool:
|
||||
"""Send ``text`` as a brand-new message (best-effort delete the old
|
||||
preview) so the platform's visible timestamp reflects completion
|
||||
time. Returns True on successful delivery, False on any failure so
|
||||
the caller falls back to the normal edit path.
|
||||
|
||||
Ported from openclaw/openclaw#72038.
|
||||
"""
|
||||
old_message_id = self._message_id
|
||||
try:
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
metadata=self.metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Fresh-final send failed, falling back to edit: %s", e)
|
||||
return False
|
||||
if not getattr(result, "success", False):
|
||||
return False
|
||||
# Successful fresh send — try to delete the stale preview so the
|
||||
# user doesn't see the old edit-stuck message underneath. Cleanup
|
||||
# is best-effort; platforms that don't implement ``delete_message``
|
||||
# just leave the preview behind (still an acceptable outcome —
|
||||
# the visible final timestamp is the important part).
|
||||
if old_message_id and old_message_id != "__no_edit__":
|
||||
delete_fn = getattr(self.adapter, "delete_message", None)
|
||||
if delete_fn is not None:
|
||||
try:
|
||||
await delete_fn(self.chat_id, old_message_id)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Fresh-final preview cleanup failed (%s): %s",
|
||||
old_message_id, e,
|
||||
)
|
||||
# Adopt the new message id as the current message so subsequent
|
||||
# callers (e.g. overflow split loops, finalize retries) see a
|
||||
# consistent state.
|
||||
new_message_id = getattr(result, "message_id", None)
|
||||
if new_message_id:
|
||||
self._message_id = new_message_id
|
||||
self._message_created_ts = time.monotonic()
|
||||
else:
|
||||
# Send succeeded but platform didn't return an id — treat the
|
||||
# delivery as final-only and fall back to "__no_edit__" so we
|
||||
# don't try to edit something we can't address.
|
||||
self._message_id = "__no_edit__"
|
||||
self._message_created_ts = None
|
||||
self._already_sent = True
|
||||
self._last_sent_text = text
|
||||
self._final_response_sent = True
|
||||
return True
|
||||
|
||||
async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
|
||||
"""Send or edit the streaming message.
|
||||
|
||||
@@ -876,22 +786,6 @@ class GatewayStreamConsumer:
|
||||
finalize and self._adapter_requires_finalize
|
||||
):
|
||||
return True
|
||||
# Fresh-final for long-lived previews: when finalizing
|
||||
# the last edit in a streaming sequence, if the
|
||||
# original preview has been visible for at least
|
||||
# ``fresh_final_after_seconds``, send the completed
|
||||
# reply as a fresh message so the platform's visible
|
||||
# timestamp reflects completion time instead of the
|
||||
# preview creation time. Best-effort cleanup of the
|
||||
# old preview follows. Ported from
|
||||
# openclaw/openclaw#72038. Gated by config so the
|
||||
# legacy edit-in-place path stays the default.
|
||||
if (
|
||||
finalize
|
||||
and self._should_send_fresh_final()
|
||||
and await self._try_fresh_final(text)
|
||||
):
|
||||
return True
|
||||
# Edit existing message
|
||||
result = await self.adapter.edit_message(
|
||||
chat_id=self.chat_id,
|
||||
@@ -958,10 +852,6 @@ class GatewayStreamConsumer:
|
||||
if result.success:
|
||||
if result.message_id:
|
||||
self._message_id = result.message_id
|
||||
# Track when the preview first became visible to
|
||||
# the user so fresh-final logic can detect stale
|
||||
# preview timestamps on long-running responses.
|
||||
self._message_created_ts = time.monotonic()
|
||||
else:
|
||||
self._edit_supported = False
|
||||
self._already_sent = True
|
||||
|
||||
@@ -31,17 +31,8 @@ Hermes' own session keys.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# WhatsApp JIDs are numeric (or plus-prefixed numeric) with optional
|
||||
# ``@``, ``.`` and ``:`` separators. ``\w`` is pinned to ASCII so
|
||||
# full-width digits / Unicode word chars can't sneak through.
|
||||
_SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9@.+\-]+$")
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
@@ -90,16 +81,6 @@ def expand_whatsapp_aliases(identifier: str) -> Set[str]:
|
||||
current = queue.pop(0)
|
||||
if not current or current in resolved:
|
||||
continue
|
||||
# Defense-in-depth: reject identifiers that could sneak path
|
||||
# separators / traversal segments into the ``lid-mapping-{current}``
|
||||
# filename below. The hardcoded ``lid-mapping-`` prefix already
|
||||
# prevents escape via pathlib's component split (an attacker can't
|
||||
# create ``lid-mapping-..`` as a real directory in session_dir), but
|
||||
# this keeps the identifier space to the characters WhatsApp JIDs
|
||||
# actually use and avoids depending on that filesystem-layout
|
||||
# invariant.
|
||||
if not _SAFE_IDENTIFIER_RE.match(current):
|
||||
continue
|
||||
|
||||
resolved.add(current)
|
||||
for suffix in ("", "_reverse"):
|
||||
@@ -110,8 +91,7 @@ def expand_whatsapp_aliases(identifier: str) -> Set[str]:
|
||||
mapped = normalize_whatsapp_identifier(
|
||||
json.loads(mapping_path.read_text(encoding="utf-8"))
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.debug("whatsapp_identity: failed to read %s: %s", mapping_path, exc)
|
||||
except Exception:
|
||||
continue
|
||||
if mapped and mapped not in resolved:
|
||||
queue.append(mapped)
|
||||
|
||||
+1
-26
@@ -224,14 +224,6 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
api_key_env_vars=("ARCEEAI_API_KEY",),
|
||||
base_url_env_var="ARCEE_BASE_URL",
|
||||
),
|
||||
"gmi": ProviderConfig(
|
||||
id="gmi",
|
||||
name="GMI Cloud",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://api.gmi-serving.com/v1",
|
||||
api_key_env_vars=("GMI_API_KEY",),
|
||||
base_url_env_var="GMI_BASE_URL",
|
||||
),
|
||||
"minimax": ProviderConfig(
|
||||
id="minimax",
|
||||
name="MiniMax",
|
||||
@@ -475,27 +467,11 @@ def _resolve_api_key_provider_secret(
|
||||
pass
|
||||
return "", ""
|
||||
|
||||
from hermes_cli.config import get_env_value
|
||||
for env_var in pconfig.api_key_env_vars:
|
||||
# Check both os.environ and ~/.hermes/.env file
|
||||
val = (get_env_value(env_var) or "").strip()
|
||||
val = os.getenv(env_var, "").strip()
|
||||
if has_usable_secret(val):
|
||||
return val, env_var
|
||||
|
||||
# Fallback: try credential pool (e.g. zai key stored via auth.json)
|
||||
try:
|
||||
from agent.credential_pool import load_pool
|
||||
pool = load_pool(provider_id)
|
||||
if pool and pool.has_credentials():
|
||||
entry = pool.peek()
|
||||
if entry:
|
||||
key = getattr(entry, "access_token", "") or getattr(entry, "runtime_api_key", "")
|
||||
key = str(key).strip()
|
||||
if has_usable_secret(key):
|
||||
return key, f"credential_pool:{provider_id}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "", ""
|
||||
|
||||
|
||||
@@ -1128,7 +1104,6 @@ def resolve_provider(
|
||||
"kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn",
|
||||
"step": "stepfun", "stepfun-coding-plan": "stepfun",
|
||||
"arcee-ai": "arcee", "arceeai": "arcee",
|
||||
"gmi-cloud": "gmi", "gmicloud": "gmi",
|
||||
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
||||
"alibaba_coding": "alibaba-coding-plan", "alibaba-coding": "alibaba-coding-plan",
|
||||
"alibaba_coding_plan": "alibaba-coding-plan",
|
||||
|
||||
+1
-177
@@ -36,23 +36,12 @@ _EXCLUDED_DIRS = {
|
||||
"__pycache__", # bytecode caches — regenerated on import
|
||||
".git", # nested git dirs (profiles shouldn't have these, but safety)
|
||||
"node_modules", # js deps if website/ somehow leaks in
|
||||
"backups", # prior auto-backups — don't nest backups exponentially
|
||||
"checkpoints", # session-local trajectory caches — regenerated per-session,
|
||||
# session-hash-keyed so they don't port to another machine anyway
|
||||
}
|
||||
|
||||
# File-name suffixes to skip
|
||||
_EXCLUDED_SUFFIXES = (
|
||||
".pyc",
|
||||
".pyo",
|
||||
# SQLite sidecar files — the backup takes a consistent snapshot of ``*.db``
|
||||
# via ``sqlite3.backup()``, so shipping the live WAL / shared-memory /
|
||||
# rollback-journal alongside would pair a fresh snapshot with stale sidecar
|
||||
# state and produce a torn restore on the next open. They're transient and
|
||||
# regenerated on first connection anyway.
|
||||
".db-wal",
|
||||
".db-shm",
|
||||
".db-journal",
|
||||
)
|
||||
|
||||
# File names to skip (runtime state that's meaningless on another machine)
|
||||
@@ -465,12 +454,6 @@ def run_import(args) -> None:
|
||||
# Critical state files to include in quick snapshots (relative to HERMES_HOME).
|
||||
# Everything else is either regeneratable (logs, cache) or managed separately
|
||||
# (skills, repo, sessions/).
|
||||
#
|
||||
# Entries may be individual files OR directories. Directories are captured
|
||||
# recursively; missing entries are silently skipped. Pairing data lives in
|
||||
# platform-specific JSON blobs outside state.db, so it's listed here explicitly
|
||||
# — `hermes update` snapshots this set before pulling so approved-user lists
|
||||
# are recoverable if anything goes wrong (issue #15733).
|
||||
_QUICK_STATE_FILES = (
|
||||
"state.db",
|
||||
"config.yaml",
|
||||
@@ -480,10 +463,6 @@ _QUICK_STATE_FILES = (
|
||||
"gateway_state.json",
|
||||
"channel_directory.json",
|
||||
"processes.json",
|
||||
# Pairing stores (generic + per-platform JSONs outside state.db)
|
||||
"pairing", # legacy location (gateway/pairing.py)
|
||||
"platforms/pairing", # new location (gateway/pairing.py)
|
||||
"feishu_comment_pairing.json", # Feishu comment subscription pairings
|
||||
)
|
||||
|
||||
_QUICK_SNAPSHOTS_DIR = "state-snapshots"
|
||||
@@ -519,27 +498,7 @@ def create_quick_snapshot(
|
||||
|
||||
for rel in _QUICK_STATE_FILES:
|
||||
src = home / rel
|
||||
if not src.exists():
|
||||
continue
|
||||
|
||||
if src.is_dir():
|
||||
# Walk the directory and record each file individually in the
|
||||
# manifest so restore can treat them uniformly. Empty dirs are
|
||||
# skipped (nothing to snapshot).
|
||||
for sub in src.rglob("*"):
|
||||
if not sub.is_file():
|
||||
continue
|
||||
sub_rel = sub.relative_to(home).as_posix()
|
||||
dst = snap_dir / sub_rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(sub, dst)
|
||||
manifest[sub_rel] = dst.stat().st_size
|
||||
except (OSError, PermissionError) as exc:
|
||||
logger.warning("Could not snapshot %s: %s", sub_rel, exc)
|
||||
continue
|
||||
|
||||
if not src.is_file():
|
||||
if not src.exists() or not src.is_file():
|
||||
continue
|
||||
|
||||
dst = snap_dir / rel
|
||||
@@ -694,138 +653,3 @@ def run_quick_backup(args) -> None:
|
||||
print(f" Restore with: /snapshot restore {snap_id}")
|
||||
else:
|
||||
print("No state files found to snapshot.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-update auto-backup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PRE_UPDATE_BACKUPS_DIR = "backups"
|
||||
_PRE_UPDATE_PREFIX = "pre-update-"
|
||||
_PRE_UPDATE_DEFAULT_KEEP = 5
|
||||
|
||||
|
||||
def _pre_update_backup_dir(hermes_home: Optional[Path] = None) -> Path:
|
||||
home = hermes_home or get_hermes_home()
|
||||
return home / _PRE_UPDATE_BACKUPS_DIR
|
||||
|
||||
|
||||
def _prune_pre_update_backups(backup_dir: Path, keep: int) -> int:
|
||||
"""Remove oldest pre-update backups beyond the keep limit.
|
||||
|
||||
Returns the number of files deleted. Only touches files matching
|
||||
``pre-update-*.zip`` so hand-made zips dropped in the same directory
|
||||
are never touched.
|
||||
"""
|
||||
if keep < 0:
|
||||
keep = 0
|
||||
if not backup_dir.exists():
|
||||
return 0
|
||||
|
||||
backups = sorted(
|
||||
(p for p in backup_dir.iterdir()
|
||||
if p.is_file() and p.name.startswith(_PRE_UPDATE_PREFIX) and p.suffix.lower() == ".zip"),
|
||||
key=lambda p: p.name,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
deleted = 0
|
||||
for p in backups[keep:]:
|
||||
try:
|
||||
p.unlink()
|
||||
deleted += 1
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to prune backup %s: %s", p.name, exc)
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
def create_pre_update_backup(
|
||||
hermes_home: Optional[Path] = None,
|
||||
keep: int = _PRE_UPDATE_DEFAULT_KEEP,
|
||||
) -> Optional[Path]:
|
||||
"""Create a full zip backup of HERMES_HOME under ``backups/``.
|
||||
|
||||
Mirrors :func:`run_backup` (same exclusion rules, same SQLite safe-copy)
|
||||
but writes to ``<HERMES_HOME>/backups/pre-update-<timestamp>.zip`` and
|
||||
auto-prunes old pre-update backups.
|
||||
|
||||
Returns the path to the created zip, or ``None`` if no files were
|
||||
found or the backup could not be created. Never raises — the caller
|
||||
(``hermes update``) should continue even if the backup fails.
|
||||
"""
|
||||
hermes_root = hermes_home or get_default_hermes_root()
|
||||
if not hermes_root.is_dir():
|
||||
return None
|
||||
|
||||
backup_dir = _pre_update_backup_dir(hermes_root)
|
||||
try:
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not create pre-update backup dir %s: %s", backup_dir, exc)
|
||||
return None
|
||||
|
||||
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
out_path = backup_dir / f"{_PRE_UPDATE_PREFIX}{stamp}.zip"
|
||||
|
||||
# Collect files (same logic as run_backup, minus the chatty progress prints)
|
||||
files_to_add: list[tuple[Path, Path]] = []
|
||||
try:
|
||||
for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
|
||||
dp = Path(dirpath)
|
||||
# Prune excluded directories in-place so os.walk doesn't descend
|
||||
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
|
||||
|
||||
for fname in filenames:
|
||||
fpath = dp / fname
|
||||
try:
|
||||
rel = fpath.relative_to(hermes_root)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if _should_exclude(rel):
|
||||
continue
|
||||
|
||||
# Skip the output zip itself if it already exists
|
||||
try:
|
||||
if fpath.resolve() == out_path.resolve():
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
files_to_add.append((fpath, rel))
|
||||
except OSError as exc:
|
||||
logger.warning("Pre-update backup: walk failed: %s", exc)
|
||||
return None
|
||||
|
||||
if not files_to_add:
|
||||
return None
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
||||
for abs_path, rel_path in files_to_add:
|
||||
try:
|
||||
if abs_path.suffix == ".db":
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
tmp_db = Path(tmp.name)
|
||||
try:
|
||||
if _safe_copy_db(abs_path, tmp_db):
|
||||
zf.write(tmp_db, arcname=str(rel_path))
|
||||
finally:
|
||||
tmp_db.unlink(missing_ok=True)
|
||||
else:
|
||||
zf.write(abs_path, arcname=str(rel_path))
|
||||
except (PermissionError, OSError, ValueError) as exc:
|
||||
logger.debug("Skipping %s in pre-update backup: %s", rel_path, exc)
|
||||
continue
|
||||
except OSError as exc:
|
||||
logger.warning("Pre-update backup: zip write failed: %s", exc)
|
||||
# Best-effort cleanup of partial file
|
||||
try:
|
||||
out_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
_prune_pre_update_backups(backup_dir, keep=keep)
|
||||
return out_path
|
||||
|
||||
+7
-112
@@ -62,8 +62,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
aliases=("reset",)),
|
||||
CommandDef("clear", "Clear screen and start a new session", "Session",
|
||||
cli_only=True),
|
||||
CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session",
|
||||
cli_only=True),
|
||||
CommandDef("history", "Show conversation history", "Session",
|
||||
cli_only=True),
|
||||
CommandDef("save", "Save the current conversation", "Session",
|
||||
@@ -128,8 +126,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("voice", "Toggle voice mode", "Configuration",
|
||||
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
|
||||
CommandDef("busy", "Control what Enter does while Hermes is working", "Configuration",
|
||||
cli_only=True, args_hint="[queue|steer|interrupt|status]",
|
||||
subcommands=("queue", "steer", "interrupt", "status")),
|
||||
cli_only=True, args_hint="[queue|interrupt|status]",
|
||||
subcommands=("queue", "interrupt", "status")),
|
||||
|
||||
# Tools & Skills
|
||||
CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills",
|
||||
@@ -142,6 +140,11 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)",
|
||||
"Tools & Skills", args_hint="[subcommand]",
|
||||
subcommands=("list", "ls", "show", "create", "assign", "link", "unlink",
|
||||
"claim", "comment", "complete", "block", "unblock", "archive",
|
||||
"tail", "dispatch", "context", "init", "gc")),
|
||||
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills",
|
||||
cli_only=True),
|
||||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||||
@@ -808,114 +811,6 @@ def discord_skill_commands_by_category(
|
||||
return trimmed_categories, uncategorized, hidden
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slack native slash commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Slack slash command name constraints: lowercase a-z, 0-9, hyphens,
|
||||
# underscores. Max 32 chars. Slack app manifest accepts up to 50 slash
|
||||
# commands per app.
|
||||
_SLACK_MAX_SLASH_COMMANDS = 50
|
||||
_SLACK_NAME_LIMIT = 32
|
||||
_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]")
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
"""Convert a command name to a valid Slack slash command name.
|
||||
|
||||
Slack allows lowercase a-z, digits, hyphens, and underscores. Max 32
|
||||
chars. Uppercase is lowercased; invalid chars are stripped.
|
||||
"""
|
||||
name = raw.lower()
|
||||
name = _SLACK_INVALID_CHARS.sub("", name)
|
||||
name = name.strip("-_")
|
||||
return name[:_SLACK_NAME_LIMIT]
|
||||
|
||||
|
||||
def slack_native_slashes() -> list[tuple[str, str, str]]:
|
||||
"""Return (slash_name, description, usage_hint) triples for Slack.
|
||||
|
||||
Every gateway-available command in ``COMMAND_REGISTRY`` is surfaced as
|
||||
a standalone Slack slash command (e.g. ``/btw``, ``/stop``, ``/model``),
|
||||
matching Discord's and Telegram's model where every command is a
|
||||
first-class slash and not a ``/hermes <verb>`` subcommand.
|
||||
|
||||
Both canonical names and aliases are included so users can type any
|
||||
documented form (e.g. ``/background``, ``/bg``, and ``/btw`` all work).
|
||||
Plugin-registered slash commands are included too.
|
||||
|
||||
Results are clamped to Slack's 50-command limit with duplicate-name
|
||||
avoidance. ``/hermes`` is always reserved as the first entry so the
|
||||
legacy ``/hermes <subcommand>`` form keeps working for anything that
|
||||
gets dropped by the clamp or for free-form questions.
|
||||
"""
|
||||
overrides = _resolve_config_gates()
|
||||
entries: list[tuple[str, str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
# Reserve /hermes as the catch-all top-level command.
|
||||
entries.append(("hermes", "Talk to Hermes or run a subcommand", "[subcommand] [args]"))
|
||||
seen.add("hermes")
|
||||
|
||||
def _add(name: str, desc: str, hint: str) -> None:
|
||||
slack_name = _sanitize_slack_name(name)
|
||||
if not slack_name or slack_name in seen:
|
||||
return
|
||||
if len(entries) >= _SLACK_MAX_SLASH_COMMANDS:
|
||||
return
|
||||
# Slack description cap is 2000 chars; keep it short.
|
||||
entries.append((slack_name, desc[:140], hint[:100]))
|
||||
seen.add(slack_name)
|
||||
|
||||
# First pass: canonical names (so they win slots if we hit the cap).
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not _is_gateway_available(cmd, overrides):
|
||||
continue
|
||||
_add(cmd.name, cmd.description, cmd.args_hint or "")
|
||||
|
||||
# Second pass: aliases.
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not _is_gateway_available(cmd, overrides):
|
||||
continue
|
||||
for alias in cmd.aliases:
|
||||
# Skip aliases that only differ from canonical by case/punctuation
|
||||
# normalization (already covered by _add dedup).
|
||||
_add(alias, f"Alias for /{cmd.name} — {cmd.description}", cmd.args_hint or "")
|
||||
|
||||
# Third pass: plugin commands.
|
||||
for name, description, args_hint in _iter_plugin_command_entries():
|
||||
_add(name, description, args_hint or "")
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def slack_app_manifest(request_url: str = "https://hermes-agent.local/slack/commands") -> dict[str, Any]:
|
||||
"""Generate a Slack app manifest with all gateway commands as slashes.
|
||||
|
||||
``request_url`` is required by Slack's manifest schema for every slash
|
||||
command, but in Socket Mode (which we use) Slack ignores it and routes
|
||||
the command event through the WebSocket. A placeholder URL is fine.
|
||||
|
||||
The returned dict is the ``features.slash_commands`` portion only —
|
||||
callers compose it into a full manifest (or merge into an existing
|
||||
one). Keeping it narrow avoids coupling us to the rest of the manifest
|
||||
schema (display_information, oauth_config, settings, etc.) which users
|
||||
set up once in the Slack UI and rarely change.
|
||||
"""
|
||||
slashes = []
|
||||
for name, desc, usage in slack_native_slashes():
|
||||
entry = {
|
||||
"command": f"/{name}",
|
||||
"description": desc or f"Run /{name}",
|
||||
"should_escape": False,
|
||||
"url": request_url,
|
||||
}
|
||||
if usage:
|
||||
entry["usage_hint"] = usage
|
||||
slashes.append(entry)
|
||||
return {"features": {"slash_commands": slashes}}
|
||||
|
||||
|
||||
def slack_subcommand_map() -> dict[str, str]:
|
||||
"""Return subcommand -> /command mapping for Slack /hermes handler.
|
||||
|
||||
|
||||
+9
-149
@@ -56,18 +56,8 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
|
||||
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD",
|
||||
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD",
|
||||
"MATRIX_RECOVERY_KEY",
|
||||
# Langfuse observability plugin — optional tuning keys + standard SDK vars
|
||||
"HERMES_LANGFUSE_ENABLED", # backward-compat env var (new: plugins.langfuse.enabled in config.yaml)
|
||||
"HERMES_LANGFUSE_ENV",
|
||||
"HERMES_LANGFUSE_RELEASE",
|
||||
"HERMES_LANGFUSE_SAMPLE_RATE",
|
||||
"HERMES_LANGFUSE_MAX_CHARS",
|
||||
"HERMES_LANGFUSE_DEBUG",
|
||||
"LANGFUSE_PUBLIC_KEY",
|
||||
"LANGFUSE_SECRET_KEY",
|
||||
"LANGFUSE_BASE_URL",
|
||||
})
|
||||
import yaml
|
||||
|
||||
@@ -399,20 +389,6 @@ DEFAULT_CONFIG = {
|
||||
# (60+ tool iterations with tiny output) before users assume the
|
||||
# bot is dead and /restart.
|
||||
"gateway_notify_interval": 180,
|
||||
# How user-attached images are presented to the main model on each turn.
|
||||
# "auto" — attach natively when the active model reports
|
||||
# supports_vision=True AND the user hasn't explicitly
|
||||
# configured auxiliary.vision.provider. Otherwise fall
|
||||
# back to text (vision_analyze pre-analysis).
|
||||
# "native" — always attach natively; non-vision models will either
|
||||
# error at the provider or get a last-chance text fallback
|
||||
# (see run_agent._prepare_messages_for_api).
|
||||
# "text" — always pre-analyze with vision_analyze and prepend the
|
||||
# description as text; the main model never sees pixels.
|
||||
# Affects gateway platforms, the TUI, and CLI /attach. vision_analyze
|
||||
# remains available as a tool regardless of this setting — the routing
|
||||
# only controls how inbound user images are presented.
|
||||
"image_input_mode": "auto",
|
||||
},
|
||||
|
||||
"terminal": {
|
||||
@@ -489,7 +465,6 @@ DEFAULT_CONFIG = {
|
||||
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
|
||||
"record_sessions": False, # Auto-record browser sessions as WebM videos
|
||||
"allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
|
||||
"auto_local_for_private_urls": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud
|
||||
"cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome
|
||||
# CDP supervisor — dialog + frame detection via a persistent WebSocket.
|
||||
# Active only when a CDP-capable backend is attached (Browserbase or
|
||||
@@ -511,19 +486,6 @@ DEFAULT_CONFIG = {
|
||||
"checkpoints": {
|
||||
"enabled": True,
|
||||
"max_snapshots": 50, # Max checkpoints to keep per directory
|
||||
# Auto-maintenance: shadow repos accumulate forever under
|
||||
# ~/.hermes/checkpoints/ (one per cd'd working directory). Field
|
||||
# reports put the typical offender at 1000+ repos / ~12 GB. When
|
||||
# auto_prune is on, hermes sweeps at startup (at most once per
|
||||
# min_interval_hours) and deletes:
|
||||
# * orphan repos: HERMES_WORKDIR no longer exists on disk
|
||||
# * stale repos: newest mtime older than retention_days
|
||||
# Opt-in so users who rely on /rollback against long-ago sessions
|
||||
# never lose data silently.
|
||||
"auto_prune": False,
|
||||
"retention_days": 7,
|
||||
"delete_orphans": True,
|
||||
"min_interval_hours": 24,
|
||||
},
|
||||
|
||||
# Maximum characters returned by a single read_file call. Reads that
|
||||
@@ -664,7 +626,7 @@ DEFAULT_CONFIG = {
|
||||
"compact": False,
|
||||
"personality": "kawaii",
|
||||
"resume_display": "full",
|
||||
"busy_input_mode": "interrupt", # interrupt | queue | steer
|
||||
"busy_input_mode": "interrupt",
|
||||
"bell_on_complete": False,
|
||||
"show_reasoning": False,
|
||||
"streaming": False,
|
||||
@@ -952,7 +914,7 @@ DEFAULT_CONFIG = {
|
||||
# Pre-exec security scanning via tirith
|
||||
"security": {
|
||||
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
|
||||
"redact_secrets": False,
|
||||
"redact_secrets": True,
|
||||
"tirith_enabled": True,
|
||||
"tirith_path": "tirith",
|
||||
"tirith_timeout": 5,
|
||||
@@ -1061,20 +1023,6 @@ DEFAULT_CONFIG = {
|
||||
"seen": {},
|
||||
},
|
||||
|
||||
# ``hermes update`` behaviour.
|
||||
"updates": {
|
||||
# Run a full ``hermes backup``-style zip of HERMES_HOME before every
|
||||
# ``hermes update``. Backups land in ``<HERMES_HOME>/backups/`` and
|
||||
# can be restored with ``hermes import <path>``. Off by default —
|
||||
# on large HERMES_HOME directories the zip can add minutes to every
|
||||
# update. Set to true to re-enable, or pass ``--backup`` to opt in
|
||||
# for a single update run.
|
||||
"pre_update_backup": False,
|
||||
# How many pre-update backup zips to retain. Older ones are pruned
|
||||
# automatically after each successful backup.
|
||||
"backup_keep": 5,
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 22,
|
||||
}
|
||||
@@ -1264,22 +1212,6 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"GMI_API_KEY": {
|
||||
"description": "GMI Cloud API key",
|
||||
"prompt": "GMI Cloud API key",
|
||||
"url": "https://www.gmicloud.ai/",
|
||||
"password": True,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"GMI_BASE_URL": {
|
||||
"description": "GMI Cloud base URL override",
|
||||
"prompt": "GMI Cloud base URL (leave empty for default)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"MINIMAX_API_KEY": {
|
||||
"description": "MiniMax API key (international)",
|
||||
"prompt": "MiniMax API key",
|
||||
@@ -1649,44 +1581,6 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "tool",
|
||||
},
|
||||
|
||||
# ── Bundled skills (opt-in: only needed if the user uses that skill) ──
|
||||
# These use category="skill" (distinct from "tool") so the sandbox
|
||||
# env blocklist in tools/environments/local.py does NOT rewrite them —
|
||||
# skills legitimately need these passed through to curl via
|
||||
# tools/env_passthrough.py when the user's skill calls out.
|
||||
"NOTION_API_KEY": {
|
||||
"description": "Notion integration token (used by the `notion` skill)",
|
||||
"prompt": "Notion API key",
|
||||
"url": "https://www.notion.so/my-integrations",
|
||||
"password": True,
|
||||
"category": "skill",
|
||||
"advanced": True,
|
||||
},
|
||||
"LINEAR_API_KEY": {
|
||||
"description": "Linear personal API key (used by the `linear` skill)",
|
||||
"prompt": "Linear API key",
|
||||
"url": "https://linear.app/settings/api",
|
||||
"password": True,
|
||||
"category": "skill",
|
||||
"advanced": True,
|
||||
},
|
||||
"AIRTABLE_API_KEY": {
|
||||
"description": "Airtable personal access token (used by the `airtable` skill)",
|
||||
"prompt": "Airtable API key",
|
||||
"url": "https://airtable.com/create/tokens",
|
||||
"password": True,
|
||||
"category": "skill",
|
||||
"advanced": True,
|
||||
},
|
||||
"TENOR_API_KEY": {
|
||||
"description": "Tenor API key for GIF search (used by the `gif-search` skill)",
|
||||
"prompt": "Tenor API key",
|
||||
"url": "https://developers.google.com/tenor/guides/quickstart",
|
||||
"password": True,
|
||||
"category": "skill",
|
||||
"advanced": True,
|
||||
},
|
||||
|
||||
# ── Honcho ──
|
||||
"HONCHO_API_KEY": {
|
||||
"description": "Honcho API key for AI-native persistent memory",
|
||||
@@ -1702,30 +1596,6 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "tool",
|
||||
},
|
||||
|
||||
# ── Langfuse observability ──
|
||||
"HERMES_LANGFUSE_PUBLIC_KEY": {
|
||||
"description": "Langfuse project public key (pk-lf-...)",
|
||||
"prompt": "Langfuse public key",
|
||||
"url": "https://cloud.langfuse.com",
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"HERMES_LANGFUSE_SECRET_KEY": {
|
||||
"description": "Langfuse project secret key (sk-lf-...)",
|
||||
"prompt": "Langfuse secret key",
|
||||
"url": "https://cloud.langfuse.com",
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"HERMES_LANGFUSE_BASE_URL": {
|
||||
"description": "Langfuse server URL (default: https://cloud.langfuse.com)",
|
||||
"prompt": "Langfuse server URL (leave empty for cloud.langfuse.com)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
"advanced": True,
|
||||
},
|
||||
|
||||
# ── Messaging platforms ──
|
||||
"TELEGRAM_BOT_TOKEN": {
|
||||
"description": "Telegram bot token from @BotFather",
|
||||
@@ -1873,14 +1743,6 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"MATRIX_DM_AUTO_THREAD": {
|
||||
"description": "Auto-create threads for DM messages in Matrix (default: false)",
|
||||
"prompt": "Auto-create threads in DMs (true/false)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "messaging",
|
||||
"advanced": True,
|
||||
},
|
||||
"MATRIX_DEVICE_ID": {
|
||||
"description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)",
|
||||
"prompt": "Matrix device ID (stable across restarts)",
|
||||
@@ -3395,16 +3257,14 @@ def load_config() -> Dict[str, Any]:
|
||||
|
||||
_SECURITY_COMMENT = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# Secret redaction is OFF by default — tool output (terminal stdout,
|
||||
# read_file results, web content) passes through unmodified. Set
|
||||
# redact_secrets to true to mask strings that look like API keys, tokens,
|
||||
# and passwords before they enter the model context and logs.
|
||||
# API keys, tokens, and passwords are redacted from tool output by default.
|
||||
# Set to false to see full values (useful for debugging auth issues).
|
||||
# tirith pre-exec scanning is enabled by default when the tirith binary
|
||||
# is available. Configure via security.tirith_* keys or env vars
|
||||
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
|
||||
#
|
||||
# security:
|
||||
# redact_secrets: true
|
||||
# redact_secrets: false
|
||||
# tirith_enabled: true
|
||||
# tirith_path: "tirith"
|
||||
# tirith_timeout: 5
|
||||
@@ -3437,11 +3297,11 @@ _FALLBACK_COMMENT = """
|
||||
|
||||
_COMMENTED_SECTIONS = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# Secret redaction is OFF by default. Set to true to mask strings that
|
||||
# look like API keys, tokens, and passwords in tool output and logs.
|
||||
# API keys, tokens, and passwords are redacted from tool output by default.
|
||||
# Set to false to see full values (useful for debugging auth issues).
|
||||
#
|
||||
# security:
|
||||
# redact_secrets: true
|
||||
# redact_secrets: false
|
||||
|
||||
# ── Fallback Model ────────────────────────────────────────────────────
|
||||
# Automatic provider failover when primary is unavailable.
|
||||
|
||||
+5
-11
@@ -45,13 +45,8 @@ def _pending_file() -> Path:
|
||||
Each entry: ``{"url": "...", "expire_at": <unix_ts>}``. Scheduled
|
||||
DELETEs used to be handled by spawning a detached Python process per
|
||||
paste that slept for 6 hours; those accumulated forever if the user
|
||||
ran ``hermes debug share`` repeatedly.
|
||||
|
||||
Deletion is now driven by the gateway's cron ticker
|
||||
(``gateway/run.py::_start_cron_ticker``) which calls
|
||||
``_sweep_expired_pastes`` once per hour. ``hermes debug share`` also
|
||||
runs an opportunistic sweep on entry as a fallback for CLI-only users
|
||||
who never start the gateway.
|
||||
ran ``hermes debug share`` repeatedly. We now persist the schedule
|
||||
to disk and sweep expired entries on the next debug invocation.
|
||||
"""
|
||||
return get_hermes_home() / "pastes" / "pending.json"
|
||||
|
||||
@@ -228,10 +223,9 @@ def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SEC
|
||||
interpreters that never exited until the sleep completed.
|
||||
|
||||
The replacement is stateless: we append to ``~/.hermes/pastes/pending.json``
|
||||
and the gateway's cron ticker sweeps expired entries once per hour.
|
||||
``hermes debug share`` also runs an opportunistic sweep as a fallback
|
||||
for CLI-only users. If neither runs again, paste.rs's own retention
|
||||
policy handles cleanup.
|
||||
and rely on opportunistic sweeps (``_sweep_expired_pastes``) called from
|
||||
every ``hermes debug`` invocation. If the user never runs ``hermes debug``
|
||||
again, paste.rs's own retention policy handles cleanup.
|
||||
"""
|
||||
_record_pending(urls, delay_seconds=delay_seconds)
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ _PROVIDER_ENV_HINTS = (
|
||||
"Z_AI_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"KIMI_CN_API_KEY",
|
||||
"GMI_API_KEY",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_CN_API_KEY",
|
||||
"KILOCODE_API_KEY",
|
||||
@@ -938,7 +937,6 @@ def run_doctor(args):
|
||||
("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True),
|
||||
("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
|
||||
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
|
||||
("GMI Cloud", ("GMI_API_KEY",), "https://api.gmi-serving.com/v1/models", "GMI_BASE_URL", True),
|
||||
("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True),
|
||||
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
|
||||
("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
|
||||
|
||||
@@ -2724,24 +2724,6 @@ _PLATFORMS = [
|
||||
"help": "OpenID to deliver cron results and notifications to."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "yuanbao",
|
||||
"label": "Yuanbao",
|
||||
"emoji": "💎",
|
||||
"token_var": "YUANBAO_APP_ID",
|
||||
"setup_instructions": [
|
||||
"1. Download the Yuanbao app from https://yuanbao.tencent.com/",
|
||||
"2. In the app, go to PAI → My Bot and create a new bot",
|
||||
"3. After the bot is created, copy the App ID and App Secret",
|
||||
"4. Enter them below and Hermes will connect automatically over WebSocket",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "YUANBAO_APP_ID", "prompt": "App ID", "password": False,
|
||||
"help": "The App ID from your Yuanbao IM Bot credentials."},
|
||||
{"name": "YUANBAO_APP_SECRET", "prompt": "App Secret", "password": True,
|
||||
"help": "The App Secret (used for HMAC signing) from your Yuanbao IM Bot."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -3126,12 +3108,6 @@ def _setup_wecom():
|
||||
print_success("💬 WeCom configured!")
|
||||
|
||||
|
||||
def _setup_yuanbao():
|
||||
"""Configure Yuanbao via the standard platform setup."""
|
||||
yuanbao_platform = next(p for p in _PLATFORMS if p["key"] == "yuanbao")
|
||||
_setup_standard_platform(yuanbao_platform)
|
||||
|
||||
|
||||
def _is_service_installed() -> bool:
|
||||
"""Check if the gateway is installed as a system service."""
|
||||
if supports_systemd_services():
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+39
-520
@@ -44,7 +44,6 @@ Usage:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -596,22 +595,17 @@ def _session_browse_picker(sessions: list) -> Optional[str]:
|
||||
|
||||
|
||||
def _resolve_last_session(source: str = "cli") -> Optional[str]:
|
||||
"""Look up the most recently-used session ID for a source."""
|
||||
db = None
|
||||
"""Look up the most recent session ID for a source."""
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
sessions = db.search_sessions(source=source, limit=1)
|
||||
return sessions[0]["id"] if sessions else None
|
||||
db.close()
|
||||
if sessions:
|
||||
return sessions[0]["id"]
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if db is not None:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -766,20 +760,9 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _read_tui_active_session_file(path: Optional[str]) -> Optional[str]:
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
sid = str(data.get("session_id") or "").strip()
|
||||
return sid or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Optional[str] = None) -> None:
|
||||
def _print_tui_exit_summary(session_id: Optional[str]) -> None:
|
||||
"""Print a shell-visible epilogue after TUI exits."""
|
||||
target = _read_tui_active_session_file(active_session_file) or session_id or _resolve_last_session(source="tui")
|
||||
target = session_id or _resolve_last_session(source="tui")
|
||||
if not target:
|
||||
return
|
||||
|
||||
@@ -829,29 +812,8 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti
|
||||
)
|
||||
|
||||
|
||||
_NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert"})
|
||||
|
||||
|
||||
def _tui_need_npm_install(root: Path) -> bool:
|
||||
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
||||
|
||||
Compares ``package-lock.json`` against ``node_modules/.package-lock.json``
|
||||
(npm's hidden lockfile) by **content**, not mtime: git checkouts and npm
|
||||
rewrites can bump the root lockfile's timestamp even when installed deps
|
||||
already match, which used to trigger a spurious "Installing TUI
|
||||
dependencies" on every launch.
|
||||
|
||||
For each entry in the root lock's ``packages`` map:
|
||||
- missing from hidden lock → reinstall (unless the entry is marked
|
||||
``optional`` or ``peer``, which npm may intentionally skip per platform)
|
||||
- present but with differing fields (excluding npm-written runtime
|
||||
annotations like ``ideallyInert``) → reinstall
|
||||
|
||||
Extra entries that exist only in the hidden lock are ignored — stale
|
||||
transitives left over from a removed dependency don't break runtime and
|
||||
we'd rather not force a reinstall for them. Falls back to mtime
|
||||
comparison if either lockfile is unparseable.
|
||||
"""
|
||||
"""True when @hermes/ink is missing or node_modules is behind package-lock.json (post-pull)."""
|
||||
ink = root / "node_modules" / "@hermes" / "ink" / "package.json"
|
||||
if not ink.is_file():
|
||||
return True
|
||||
@@ -861,35 +823,7 @@ def _tui_need_npm_install(root: Path) -> bool:
|
||||
marker = root / "node_modules" / ".package-lock.json"
|
||||
if not marker.is_file():
|
||||
return True
|
||||
|
||||
# Compare lockfile contents, not mtimes: git checkouts and npm rewrites
|
||||
# can bump the root lockfile timestamp even when installed deps already
|
||||
# match. Fall back to mtime when either file is unparseable.
|
||||
try:
|
||||
wanted = json.loads(lock.read_text(encoding="utf-8")).get("packages") or {}
|
||||
installed = json.loads(marker.read_text(encoding="utf-8")).get("packages") or {}
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return lock.stat().st_mtime > marker.stat().st_mtime
|
||||
|
||||
def comparable(pkg: dict) -> dict:
|
||||
return {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS}
|
||||
|
||||
for name, pkg in wanted.items():
|
||||
if not name:
|
||||
continue
|
||||
|
||||
if not isinstance(pkg, dict):
|
||||
continue
|
||||
|
||||
if name not in installed:
|
||||
if pkg.get("optional") or pkg.get("peer"):
|
||||
continue
|
||||
return True
|
||||
|
||||
if isinstance(installed[name], dict) and comparable(pkg) != comparable(installed[name]):
|
||||
return True
|
||||
|
||||
return False
|
||||
return lock.stat().st_mtime > marker.stat().st_mtime
|
||||
|
||||
|
||||
def _find_bundled_tui(tui_dir: Path) -> Optional[Path]:
|
||||
@@ -1103,20 +1037,12 @@ def _launch_tui(
|
||||
"""Replace current process with the TUI."""
|
||||
tui_dir = PROJECT_ROOT / "ui-tui"
|
||||
|
||||
import tempfile
|
||||
|
||||
env = os.environ.copy()
|
||||
active_session_fd, active_session_file = tempfile.mkstemp(
|
||||
prefix="hermes-tui-active-session-", suffix=".json"
|
||||
)
|
||||
os.close(active_session_fd)
|
||||
env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file
|
||||
env["HERMES_PYTHON_SRC_ROOT"] = os.environ.get(
|
||||
"HERMES_PYTHON_SRC_ROOT", str(PROJECT_ROOT)
|
||||
)
|
||||
env.setdefault("HERMES_PYTHON", sys.executable)
|
||||
env.setdefault("HERMES_CWD", os.getcwd())
|
||||
env.setdefault("NODE_ENV", "development" if tui_dev else "production")
|
||||
if model:
|
||||
env["HERMES_MODEL"] = model
|
||||
env["HERMES_INFERENCE_MODEL"] = model
|
||||
@@ -1138,20 +1064,13 @@ def _launch_tui(
|
||||
env["HERMES_TUI_RESUME"] = resume_session_id
|
||||
|
||||
argv, cwd = _make_tui_argv(tui_dir, tui_dev)
|
||||
code: Optional[int] = None
|
||||
try:
|
||||
try:
|
||||
code = subprocess.call(argv, cwd=str(cwd), env=env)
|
||||
except KeyboardInterrupt:
|
||||
code = 130
|
||||
code = subprocess.call(argv, cwd=str(cwd), env=env)
|
||||
except KeyboardInterrupt:
|
||||
code = 130
|
||||
|
||||
if code in (0, 130):
|
||||
_print_tui_exit_summary(resume_session_id, active_session_file)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(active_session_file)
|
||||
except OSError:
|
||||
pass
|
||||
if code in (0, 130):
|
||||
_print_tui_exit_summary(resume_session_id)
|
||||
|
||||
sys.exit(code)
|
||||
|
||||
@@ -1817,7 +1736,6 @@ def select_provider_and_model(args=None):
|
||||
"huggingface",
|
||||
"xiaomi",
|
||||
"arcee",
|
||||
"gmi",
|
||||
"nvidia",
|
||||
"ollama-cloud",
|
||||
):
|
||||
@@ -3413,26 +3331,7 @@ def _model_flow_named_custom(config, provider_info):
|
||||
provider_entry = providers_cfg.get(provider_key)
|
||||
if isinstance(provider_entry, dict):
|
||||
provider_entry["default_model"] = model_name
|
||||
# Only persist an inline api_key when the user originally had
|
||||
# one (either a literal secret or a ``${VAR}`` template). When
|
||||
# the entry relies on ``key_env``, do not synthesize a
|
||||
# ``${key_env}`` api_key — the runtime already resolves the
|
||||
# key from ``key_env`` directly, and writing the resolved
|
||||
# secret (or even a synthesized template) would silently
|
||||
# downgrade credential hygiene on entries that intentionally
|
||||
# keep plaintext out of ``config.yaml``. See issue #15803.
|
||||
original_api_key_ref = str(
|
||||
provider_info.get("api_key_ref", "") or ""
|
||||
).strip()
|
||||
original_api_key = str(
|
||||
provider_info.get("api_key", "") or ""
|
||||
).strip()
|
||||
had_inline_api_key = bool(original_api_key_ref or original_api_key)
|
||||
if (
|
||||
had_inline_api_key
|
||||
and config_api_key
|
||||
and not str(provider_entry.get("api_key", "") or "").strip()
|
||||
):
|
||||
if config_api_key and not str(provider_entry.get("api_key", "") or "").strip():
|
||||
provider_entry["api_key"] = config_api_key
|
||||
if key_env and not str(provider_entry.get("key_env", "") or "").strip():
|
||||
provider_entry["key_env"] = key_env
|
||||
@@ -4513,14 +4412,8 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
|
||||
# During setup, force a live refresh so the picker reflects newly
|
||||
# released models (e.g. deepseek v4 flash, kimi k2.6) the moment
|
||||
# the user enters their key — not an hour later when the disk
|
||||
# cache TTL expires.
|
||||
model_list = fetch_ollama_cloud_models(
|
||||
api_key=api_key_for_probe,
|
||||
base_url=effective_base,
|
||||
force_refresh=True,
|
||||
api_key=api_key_for_probe, base_url=effective_base
|
||||
)
|
||||
if model_list:
|
||||
print(f" Found {len(model_list)} model(s) from Ollama Cloud")
|
||||
@@ -4887,35 +4780,11 @@ def cmd_webhook(args):
|
||||
webhook_command(args)
|
||||
|
||||
|
||||
def cmd_slack(args):
|
||||
"""Slack integration helpers.
|
||||
def cmd_kanban(args):
|
||||
"""Multi-profile collaboration board."""
|
||||
from hermes_cli.kanban import kanban_command
|
||||
|
||||
Dispatches ``hermes slack <subcommand>``. Currently supports:
|
||||
manifest — print or write a Slack app manifest with every gateway
|
||||
command registered as a first-class slash.
|
||||
"""
|
||||
sub = getattr(args, "slack_command", None)
|
||||
if sub in (None, ""):
|
||||
# No subcommand — print usage hint.
|
||||
print(
|
||||
"usage: hermes slack <subcommand>\n"
|
||||
"\n"
|
||||
"subcommands:\n"
|
||||
" manifest Generate a Slack app manifest with every gateway\n"
|
||||
" command registered as a native slash\n"
|
||||
"\n"
|
||||
"Run `hermes slack manifest -h` for details.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if sub == "manifest":
|
||||
from hermes_cli.slack_cli import slack_manifest_command
|
||||
|
||||
return slack_manifest_command(args)
|
||||
|
||||
print(f"Unknown slack subcommand: {sub}", file=sys.stderr)
|
||||
return 1
|
||||
return kanban_command(args)
|
||||
|
||||
|
||||
def cmd_hooks(args):
|
||||
@@ -5091,83 +4960,6 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
|
||||
return default
|
||||
|
||||
|
||||
def _web_ui_build_needed(web_dir: Path) -> bool:
|
||||
"""Return True if the web UI dist is missing or stale.
|
||||
|
||||
Mirrors the staleness logic used by ``_tui_build_needed()`` for the TUI.
|
||||
The Vite build outputs to ``hermes_cli/web_dist/`` (per vite.config.ts
|
||||
outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``. Uses the Vite
|
||||
manifest as the sentinel because it is written last and therefore has the
|
||||
newest mtime of any build output.
|
||||
"""
|
||||
dist_dir = web_dir.parent / "hermes_cli" / "web_dist"
|
||||
sentinel = dist_dir / ".vite" / "manifest.json"
|
||||
if not sentinel.exists():
|
||||
sentinel = dist_dir / "index.html"
|
||||
if not sentinel.exists():
|
||||
return True
|
||||
dist_mtime = sentinel.stat().st_mtime
|
||||
skip = frozenset({"node_modules", "dist"})
|
||||
for dirpath, dirnames, filenames in os.walk(web_dir, topdown=True):
|
||||
dirnames[:] = [d for d in dirnames if d not in skip]
|
||||
for fn in filenames:
|
||||
if fn.endswith((".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".vue")):
|
||||
if os.path.getmtime(os.path.join(dirpath, fn)) > dist_mtime:
|
||||
return True
|
||||
for meta in (
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"vite.config.ts",
|
||||
"vite.config.js",
|
||||
):
|
||||
mp = web_dir / meta
|
||||
if mp.exists() and mp.stat().st_mtime > dist_mtime:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _run_npm_install_deterministic(
|
||||
npm: str,
|
||||
cwd: Path,
|
||||
*,
|
||||
extra_args: tuple[str, ...] = (),
|
||||
capture_output: bool = True,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a deterministic npm install that does not mutate ``package-lock.json``.
|
||||
|
||||
Prefers ``npm ci`` (strict, lockfile-preserving) when a lockfile is present;
|
||||
falls back to ``npm install`` only if ``npm ci`` fails (e.g. lockfile out of
|
||||
sync on a WIP checkout). Without this, ``npm install`` on npm ≥ 10 silently
|
||||
rewrites committed lockfiles (stripping ``"peer": true`` etc.), which leaves
|
||||
the working tree dirty and causes the next ``hermes update`` to stash the
|
||||
lockfile — repeatedly.
|
||||
"""
|
||||
lockfile = cwd / "package-lock.json"
|
||||
if lockfile.exists():
|
||||
ci_cmd = [npm, "ci", *extra_args]
|
||||
ci_result = subprocess.run(
|
||||
ci_cmd,
|
||||
cwd=cwd,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if ci_result.returncode == 0:
|
||||
return ci_result
|
||||
# Fall through to `npm install` — lockfile may be out of sync on a
|
||||
# WIP fork/branch, or `npm ci` may not be available on very old npm.
|
||||
install_cmd = [npm, "install", *extra_args]
|
||||
return subprocess.run(
|
||||
install_cmd,
|
||||
cwd=cwd,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
"""Build the web UI frontend if npm is available.
|
||||
|
||||
@@ -5181,9 +4973,6 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
if not (web_dir / "package.json").exists():
|
||||
return True
|
||||
|
||||
if not _web_ui_build_needed(web_dir):
|
||||
return True
|
||||
|
||||
npm = shutil.which("npm")
|
||||
if not npm:
|
||||
if fatal:
|
||||
@@ -5191,7 +4980,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
print("Install Node.js, then run: cd web && npm install && npm run build")
|
||||
return not fatal
|
||||
print("→ Building web UI...")
|
||||
r1 = _run_npm_install_deterministic(npm, web_dir, extra_args=("--silent",))
|
||||
r1 = subprocess.run([npm, "install", "--silent"], cwd=web_dir, capture_output=True)
|
||||
if r1.returncode != 0:
|
||||
print(
|
||||
f" {'✗' if fatal else '⚠'} Web UI npm install failed"
|
||||
@@ -5902,10 +5691,12 @@ def _update_node_dependencies() -> None:
|
||||
if not (path / "package.json").exists():
|
||||
continue
|
||||
|
||||
result = _run_npm_install_deterministic(
|
||||
npm,
|
||||
path,
|
||||
extra_args=("--silent", "--no-fund", "--no-audit", "--progress=false"),
|
||||
result = subprocess.run(
|
||||
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
||||
cwd=path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f" ✓ {label}")
|
||||
@@ -6141,178 +5932,6 @@ def _cmd_update_check():
|
||||
print(f" Run '{recommended_update_command()}' to install.")
|
||||
|
||||
|
||||
def _ensure_fhs_path_guard() -> None:
|
||||
"""Ensure /usr/local/bin is on PATH for RHEL-family root non-login shells.
|
||||
|
||||
Mirrors the post-symlink probe added to ``scripts/install.sh`` so that
|
||||
existing FHS-layout root installs on RHEL/CentOS/Rocky/Alma 8+ get
|
||||
repaired on ``hermes update`` without requiring a reinstall. The
|
||||
installer's assumption that ``/usr/local/bin`` is on PATH for every
|
||||
standard shell breaks on those distros in non-login interactive shells
|
||||
(su, sudo -s, tmux panes, some web terminals): /etc/bashrc doesn't
|
||||
add /usr/local/bin and /root/.bash_profile doesn't either. Symptom:
|
||||
``hermes`` prints ``command not found`` even though the symlink lives
|
||||
at /usr/local/bin/hermes.
|
||||
|
||||
Silent no-op on: non-Linux, non-root, non-FHS installs, and any system
|
||||
where ``bash -i -c 'command -v hermes'`` already resolves. Idempotent.
|
||||
"""
|
||||
if sys.platform != "linux":
|
||||
return
|
||||
try:
|
||||
if os.geteuid() != 0:
|
||||
return
|
||||
except AttributeError:
|
||||
return
|
||||
# Only act when this is actually an FHS-layout install (command link at
|
||||
# /usr/local/bin/hermes, code at /usr/local/lib/hermes-agent).
|
||||
fhs_link = Path("/usr/local/bin/hermes")
|
||||
if not fhs_link.is_symlink() and not fhs_link.exists():
|
||||
return
|
||||
|
||||
# Probe a fresh non-login interactive bash the way the user will use it.
|
||||
# ``bash -i -c`` sources ~/.bashrc but NOT ~/.bash_profile or /etc/profile,
|
||||
# which is the exact scenario where RHEL root loses /usr/local/bin.
|
||||
home = os.environ.get("HOME") or "/root"
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
["env", "-i",
|
||||
f"HOME={home}",
|
||||
f"TERM={os.environ.get('TERM', 'dumb')}",
|
||||
"bash", "-i", "-c", "command -v hermes"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return # no bash or probe hung — don't block update on this
|
||||
if probe.returncode == 0:
|
||||
return # already on PATH, nothing to do
|
||||
|
||||
path_line = 'export PATH="/usr/local/bin:$PATH"'
|
||||
path_comment = (
|
||||
"# Hermes Agent — ensure /usr/local/bin is on PATH "
|
||||
"(RHEL non-login shells)"
|
||||
)
|
||||
wrote_any = False
|
||||
for candidate in (".bashrc", ".bash_profile"):
|
||||
cfg = Path(home) / candidate
|
||||
if not cfg.is_file():
|
||||
continue
|
||||
try:
|
||||
existing = cfg.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
# Idempotency: skip if any uncommented PATH= line already references
|
||||
# /usr/local/bin. Mirrors the grep pattern used by install.sh.
|
||||
already_guarded = any(
|
||||
"/usr/local/bin" in line
|
||||
and "PATH" in line
|
||||
and not line.lstrip().startswith("#")
|
||||
for line in existing.splitlines()
|
||||
)
|
||||
if already_guarded:
|
||||
continue
|
||||
try:
|
||||
with cfg.open("a", encoding="utf-8") as f:
|
||||
f.write("\n" + path_comment + "\n" + path_line + "\n")
|
||||
except OSError as e:
|
||||
print(f" ⚠ Could not update {cfg}: {e}")
|
||||
continue
|
||||
print(f" ✓ Added /usr/local/bin to PATH in {cfg}")
|
||||
wrote_any = True
|
||||
if wrote_any:
|
||||
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")
|
||||
|
||||
|
||||
def _run_pre_update_backup(args) -> None:
|
||||
"""Create a full zip backup of HERMES_HOME before running the update.
|
||||
|
||||
Gated on ``updates.pre_update_backup`` in config (default false). Off
|
||||
by default because the zip can add minutes to every update on large
|
||||
HERMES_HOME directories. The ``--backup`` flag on ``hermes update``
|
||||
opts in for a single run; ``--no-backup`` forces it off when config
|
||||
has it enabled. Never raises — a backup failure should not block the
|
||||
update itself.
|
||||
"""
|
||||
# CLI flags win over config. --no-backup beats --backup if both are set.
|
||||
if getattr(args, "no_backup", False):
|
||||
print("◆ Pre-update backup: skipped (--no-backup)")
|
||||
print()
|
||||
return
|
||||
|
||||
force_backup = bool(getattr(args, "backup", False))
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).debug("Could not load config for pre-update backup: %s", exc)
|
||||
cfg = {}
|
||||
|
||||
updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {}
|
||||
enabled = updates_cfg.get("pre_update_backup", False)
|
||||
keep = updates_cfg.get("backup_keep", 5)
|
||||
|
||||
if not enabled and not force_backup:
|
||||
# Silent by default — the backup is off, most users don't need to
|
||||
# hear about it on every update. They can opt in via --backup
|
||||
# or by flipping the config knob.
|
||||
return
|
||||
|
||||
try:
|
||||
from hermes_cli.backup import create_pre_update_backup
|
||||
except Exception as exc:
|
||||
print(f"⚠ Pre-update backup: could not load backup module ({exc}); continuing update.")
|
||||
print()
|
||||
return
|
||||
|
||||
print("◆ Creating pre-update backup...")
|
||||
t0 = _time.monotonic()
|
||||
try:
|
||||
out_path = create_pre_update_backup(keep=int(keep))
|
||||
except Exception as exc: # defensive — helper already swallows, but just in case
|
||||
print(f" ⚠ Backup failed: {exc}")
|
||||
print(" Continuing with update.")
|
||||
print()
|
||||
return
|
||||
|
||||
elapsed = _time.monotonic() - t0
|
||||
|
||||
if out_path is None:
|
||||
print(" ⚠ Backup skipped (no files found or write failed); continuing update.")
|
||||
print()
|
||||
return
|
||||
|
||||
try:
|
||||
size_bytes = out_path.stat().st_size
|
||||
except OSError:
|
||||
size_bytes = 0
|
||||
|
||||
# Human-readable size
|
||||
size_str = f"{size_bytes} B"
|
||||
for unit in ("KB", "MB", "GB"):
|
||||
if size_bytes < 1024:
|
||||
break
|
||||
size_bytes /= 1024
|
||||
size_str = f"{size_bytes:.1f} {unit}"
|
||||
|
||||
# Render path using display_hermes_home so the user sees ~/.hermes/...
|
||||
try:
|
||||
from hermes_constants import get_hermes_home, display_hermes_home
|
||||
home = get_hermes_home()
|
||||
try:
|
||||
display_path = f"{display_hermes_home()}/{out_path.relative_to(home)}"
|
||||
except ValueError:
|
||||
display_path = str(out_path)
|
||||
except Exception:
|
||||
display_path = str(out_path)
|
||||
|
||||
print(f" Saved: {display_path} ({size_str}, {elapsed:.1f}s)")
|
||||
print(f" Restore: hermes import {out_path}")
|
||||
print(f" Disable: omit --backup (backups are off by default)")
|
||||
print(f" set updates.pre_update_backup: false in config.yaml")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_update(args):
|
||||
"""Update Hermes Agent to the latest version.
|
||||
|
||||
@@ -6355,10 +5974,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
print("⚕ Updating Hermes Agent...")
|
||||
print()
|
||||
|
||||
# Pre-update backup — runs before any git/file mutation so users can
|
||||
# always roll back to the exact state they had before this update.
|
||||
_run_pre_update_backup(args)
|
||||
|
||||
# Try git-based update first, fall back to ZIP download on Windows
|
||||
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
|
||||
use_zip_update = False
|
||||
@@ -6508,22 +6123,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
|
||||
print(f"→ Found {commit_count} new commit(s)")
|
||||
|
||||
# Snapshot critical state (state.db, config, pairing JSONs, etc.)
|
||||
# before pulling so a user can recover if something goes wrong.
|
||||
# Issue #15733 reported missing pairing data after an update; even
|
||||
# though `git pull` can't touch $HERMES_HOME, this is cheap
|
||||
# belt-and-suspenders insurance and gives the user something to
|
||||
# restore from via `/snapshot list` / `/snapshot restore <id>`.
|
||||
try:
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
snap_id = create_quick_snapshot(label="pre-update")
|
||||
if snap_id:
|
||||
print(f" ✓ Pre-update snapshot: {snap_id}")
|
||||
except Exception as exc:
|
||||
# Never let a snapshot failure block an update.
|
||||
logger.debug("Pre-update snapshot failed: %s", exc)
|
||||
|
||||
print("→ Pulling updates...")
|
||||
update_succeeded = False
|
||||
try:
|
||||
@@ -6776,13 +6375,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
|
||||
# Repair RHEL-family root installs where /usr/local/bin isn't on PATH
|
||||
# for non-login interactive shells. No-op on every other platform.
|
||||
try:
|
||||
_ensure_fhs_path_guard()
|
||||
except Exception as e:
|
||||
logger.debug("FHS PATH guard check failed: %s", e)
|
||||
|
||||
# Write exit code *before* the gateway restart attempt.
|
||||
# When running as ``hermes update --gateway`` (spawned by the gateway's
|
||||
# /update command), this process lives inside the gateway's systemd
|
||||
@@ -7832,7 +7424,6 @@ For more help on a command:
|
||||
"kilocode",
|
||||
"xiaomi",
|
||||
"arcee",
|
||||
"gmi",
|
||||
"nvidia",
|
||||
],
|
||||
default=None,
|
||||
@@ -8214,54 +7805,6 @@ For more help on a command:
|
||||
)
|
||||
whatsapp_parser.set_defaults(func=cmd_whatsapp)
|
||||
|
||||
# =========================================================================
|
||||
# slack command
|
||||
# =========================================================================
|
||||
slack_parser = subparsers.add_parser(
|
||||
"slack",
|
||||
help="Slack integration helpers (manifest generation, etc.)",
|
||||
description="Slack integration helpers for Hermes.",
|
||||
)
|
||||
slack_sub = slack_parser.add_subparsers(dest="slack_command")
|
||||
slack_manifest = slack_sub.add_parser(
|
||||
"manifest",
|
||||
help="Print or write a Slack app manifest with every gateway command "
|
||||
"registered as a native slash (/btw, /stop, /model, ...)",
|
||||
description=(
|
||||
"Generate a Slack app manifest that registers every gateway "
|
||||
"command in COMMAND_REGISTRY as a first-class Slack slash "
|
||||
"command (matching Discord and Telegram parity). Paste the "
|
||||
"output into Slack app config → Features → App Manifest → "
|
||||
"Edit, then Save. Reinstall the app if Slack prompts for it."
|
||||
),
|
||||
)
|
||||
slack_manifest.add_argument(
|
||||
"--write",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help="Write manifest to a file instead of stdout. With no PATH "
|
||||
"writes to $HERMES_HOME/slack-manifest.json.",
|
||||
)
|
||||
slack_manifest.add_argument(
|
||||
"--name",
|
||||
default=None,
|
||||
help='Bot display name (default: "Hermes")',
|
||||
)
|
||||
slack_manifest.add_argument(
|
||||
"--description",
|
||||
default=None,
|
||||
help="Bot description shown in Slack's app directory.",
|
||||
)
|
||||
slack_manifest.add_argument(
|
||||
"--slashes-only",
|
||||
action="store_true",
|
||||
help="Emit only the features.slash_commands array (for merging "
|
||||
"into an existing manifest manually).",
|
||||
)
|
||||
slack_parser.set_defaults(func=cmd_slack)
|
||||
|
||||
# =========================================================================
|
||||
# login command
|
||||
# =========================================================================
|
||||
@@ -8580,6 +8123,13 @@ For more help on a command:
|
||||
|
||||
webhook_parser.set_defaults(func=cmd_webhook)
|
||||
|
||||
# =========================================================================
|
||||
# kanban command — multi-profile collaboration board
|
||||
# =========================================================================
|
||||
from hermes_cli.kanban import build_parser as _build_kanban_parser
|
||||
kanban_parser = _build_kanban_parser(subparsers)
|
||||
kanban_parser.set_defaults(func=cmd_kanban)
|
||||
|
||||
# =========================================================================
|
||||
# hooks command — shell-hook inspection and management
|
||||
# =========================================================================
|
||||
@@ -8893,17 +8443,11 @@ Examples:
|
||||
|
||||
skills_install = skills_subparsers.add_parser("install", help="Install a skill")
|
||||
skills_install.add_argument(
|
||||
"identifier",
|
||||
help="Skill identifier (e.g. openai/skills/skill-creator) or a direct HTTP(S) URL to a SKILL.md file",
|
||||
"identifier", help="Skill identifier (e.g. openai/skills/skill-creator)"
|
||||
)
|
||||
skills_install.add_argument(
|
||||
"--category", default="", help="Category folder to install into"
|
||||
)
|
||||
skills_install.add_argument(
|
||||
"--name",
|
||||
default="",
|
||||
help="Override the skill name (useful when installing from a URL whose SKILL.md has no `name:` frontmatter)",
|
||||
)
|
||||
skills_install.add_argument(
|
||||
"--force", action="store_true", help="Install despite blocked scan verdict"
|
||||
)
|
||||
@@ -8923,12 +8467,6 @@ Examples:
|
||||
skills_list.add_argument(
|
||||
"--source", default="all", choices=["all", "hub", "builtin", "local"]
|
||||
)
|
||||
skills_list.add_argument(
|
||||
"--enabled-only",
|
||||
action="store_true",
|
||||
help="Hide disabled skills. Use with -p <profile> to see exactly "
|
||||
"which skills will load for that profile.",
|
||||
)
|
||||
|
||||
skills_check = skills_subparsers.add_parser(
|
||||
"check", help="Check installed hub skills for updates"
|
||||
@@ -9082,11 +8620,7 @@ Examples:
|
||||
)
|
||||
plugins_remove.add_argument("name", help="Plugin directory name to remove")
|
||||
|
||||
plugins_list = plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins")
|
||||
plugins_list.add_argument(
|
||||
"--available", action="store_true",
|
||||
help="Also show official optional plugins that are not yet installed",
|
||||
)
|
||||
plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins")
|
||||
|
||||
plugins_enable = plugins_subparsers.add_parser(
|
||||
"enable", help="Enable a disabled plugin"
|
||||
@@ -9439,7 +8973,7 @@ Examples:
|
||||
"--source", help="Filter by source (cli, telegram, discord, etc.)"
|
||||
)
|
||||
sessions_browse.add_argument(
|
||||
"--limit", type=int, default=500, help="Max sessions to load (default: 500)"
|
||||
"--limit", type=int, default=50, help="Max sessions to load (default: 50)"
|
||||
)
|
||||
|
||||
def _confirm_prompt(prompt: str) -> bool:
|
||||
@@ -9536,8 +9070,7 @@ Examples:
|
||||
):
|
||||
print("Cancelled.")
|
||||
return
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
if db.delete_session(resolved_session_id, sessions_dir=sessions_dir):
|
||||
if db.delete_session(resolved_session_id):
|
||||
print(f"Deleted session '{resolved_session_id}'.")
|
||||
else:
|
||||
print(f"Session '{args.session_id}' not found.")
|
||||
@@ -9551,9 +9084,7 @@ Examples:
|
||||
):
|
||||
print("Cancelled.")
|
||||
return
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
count = db.prune_sessions(older_than_days=days, source=args.source,
|
||||
sessions_dir=sessions_dir)
|
||||
count = db.prune_sessions(older_than_days=days, source=args.source)
|
||||
print(f"Pruned {count} session(s).")
|
||||
|
||||
elif action == "rename":
|
||||
@@ -9571,7 +9102,7 @@ Examples:
|
||||
print(f"Error: {e}")
|
||||
|
||||
elif action == "browse":
|
||||
limit = getattr(args, "limit", 500) or 500
|
||||
limit = getattr(args, "limit", 50) or 50
|
||||
source = getattr(args, "source", None)
|
||||
_browse_exclude = None if source else ["tool"]
|
||||
sessions = db.list_sessions_rich(
|
||||
@@ -9757,18 +9288,6 @@ Examples:
|
||||
default=False,
|
||||
help="Check whether an update is available without installing anything",
|
||||
)
|
||||
update_parser.add_argument(
|
||||
"--no-backup",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip the pre-update backup for this run (overrides updates.pre_update_backup)",
|
||||
)
|
||||
update_parser.add_argument(
|
||||
"--backup",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force a pre-update backup for this run (off by default; overrides updates.pre_update_backup)",
|
||||
)
|
||||
update_parser.set_defaults(func=cmd_update)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
+5
-70
@@ -33,6 +33,8 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
|
||||
# (model_id, display description shown in menus)
|
||||
OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("moonshotai/kimi-k2.6", "recommended"),
|
||||
("deepseek/deepseek-v4-pro", ""),
|
||||
("deepseek/deepseek-v4-flash", ""),
|
||||
("anthropic/claude-opus-4.7", ""),
|
||||
("anthropic/claude-opus-4.6", ""),
|
||||
("anthropic/claude-sonnet-4.6", ""),
|
||||
@@ -109,6 +111,8 @@ def _codex_curated_models() -> list[str]:
|
||||
_PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"nous": [
|
||||
"moonshotai/kimi-k2.6",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
"xiaomi/mimo-v2.5",
|
||||
"anthropic/claude-opus-4.7",
|
||||
@@ -278,14 +282,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"trinity-large-preview",
|
||||
"trinity-mini",
|
||||
],
|
||||
"gmi": [
|
||||
"zai-org/GLM-5.1-FP8",
|
||||
"deepseek-ai/DeepSeek-V3.2",
|
||||
"moonshotai/Kimi-K2.5",
|
||||
"google/gemini-3.1-flash-lite-preview",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"openai/gpt-5.4",
|
||||
],
|
||||
"opencode-zen": [
|
||||
"kimi-k2.5",
|
||||
"gpt-5.4-pro",
|
||||
@@ -717,6 +713,7 @@ class ProviderEntry(NamedTuple):
|
||||
label: str
|
||||
tui_desc: str # detailed description for `hermes model` TUI
|
||||
|
||||
|
||||
CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("nous", "Nous Portal", "Nous Portal (Nous Research subscription)"),
|
||||
ProviderEntry("openrouter", "OpenRouter", "OpenRouter (100+ models, pay-per-use)"),
|
||||
@@ -742,7 +739,6 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"),
|
||||
ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (cloud-hosted open models — ollama.com)"),
|
||||
ProviderEntry("arcee", "Arcee AI", "Arcee AI (Trinity models — direct API)"),
|
||||
ProviderEntry("gmi", "GMI Cloud", "GMI Cloud (multi-model direct API)"),
|
||||
ProviderEntry("kilocode", "Kilo Code", "Kilo Code (Kilo Gateway API)"),
|
||||
ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"),
|
||||
ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"),
|
||||
@@ -777,8 +773,6 @@ _PROVIDER_ALIASES = {
|
||||
"stepfun-coding-plan": "stepfun",
|
||||
"arcee-ai": "arcee",
|
||||
"arceeai": "arcee",
|
||||
"gmi-cloud": "gmi",
|
||||
"gmicloud": "gmi",
|
||||
"minimax-china": "minimax-cn",
|
||||
"minimax_cn": "minimax-cn",
|
||||
"claude": "anthropic",
|
||||
@@ -1859,19 +1853,6 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
return live
|
||||
except Exception:
|
||||
pass
|
||||
if normalized == "gmi":
|
||||
try:
|
||||
from hermes_cli.auth import resolve_api_key_provider_credentials
|
||||
|
||||
creds = resolve_api_key_provider_credentials("gmi")
|
||||
api_key = str(creds.get("api_key") or "").strip()
|
||||
base_url = str(creds.get("base_url") or "").strip()
|
||||
if api_key and base_url:
|
||||
live = fetch_api_models(api_key, base_url)
|
||||
if live:
|
||||
return live
|
||||
except Exception:
|
||||
pass
|
||||
if normalized == "custom":
|
||||
base_url = _get_custom_base_url()
|
||||
if base_url:
|
||||
@@ -2249,52 +2230,6 @@ def copilot_model_api_mode(
|
||||
return "chat_completions"
|
||||
|
||||
|
||||
# Azure Foundry model families that require the Responses API. Azure
|
||||
# rejects /chat/completions against these deployments with
|
||||
# ``400 "The requested operation is unsupported."`` — the same payload Bob
|
||||
# Dobolina hit in April 2026 on ``gpt-5.3-codex`` while ``gpt-4o-pure`` on
|
||||
# the same endpoint worked fine. Keep the patterns broad enough to cover
|
||||
# vendor-renamed deployments (e.g. ``gpt-5.3-codex``, ``gpt-5-codex``,
|
||||
# ``gpt-5.4``, ``o1-preview``) but tight enough to leave GPT-4 / 3.5 / Llama /
|
||||
# Mistral / Grok deployments on chat completions.
|
||||
_AZURE_FOUNDRY_RESPONSES_PREFIXES = (
|
||||
"codex", # codex-*, codex-mini
|
||||
"gpt-5", # gpt-5, gpt-5.x, gpt-5-codex, gpt-5.x-codex
|
||||
"o1", # o1, o1-preview, o1-mini
|
||||
"o3", # o3, o3-mini
|
||||
"o4", # o4, o4-mini
|
||||
)
|
||||
|
||||
|
||||
def azure_foundry_model_api_mode(model_name: Optional[str]) -> Optional[str]:
|
||||
"""Infer Azure Foundry api_mode from a deployment/model name.
|
||||
|
||||
Returns ``"codex_responses"`` when the model name matches a family that
|
||||
only accepts the Responses API on Azure Foundry (GPT-5.x, codex, o1/o3/o4
|
||||
reasoning models). Returns ``None`` otherwise — the caller should fall
|
||||
back to the configured/default api_mode (typically ``chat_completions``)
|
||||
so GPT-4o, GPT-4 Turbo, Llama, Mistral, etc. keep working.
|
||||
|
||||
Intentionally does NOT return ``anthropic_messages``; Anthropic-style
|
||||
Azure endpoints are disambiguated by URL (``/anthropic`` suffix) in
|
||||
``runtime_provider._detect_api_mode_for_url`` and by the user setting
|
||||
``model.api_mode: anthropic_messages`` explicitly.
|
||||
"""
|
||||
raw = str(model_name or "").strip().lower()
|
||||
if not raw:
|
||||
return None
|
||||
# Strip any vendor/ prefix a user may have copied from OpenRouter / Copilot.
|
||||
if "/" in raw:
|
||||
raw = raw.rsplit("/", 1)[-1]
|
||||
# gpt-5-mini speaks chat completions on Copilot but Azure Foundry deploys
|
||||
# the full gpt-5 family uniformly on Responses API — don't carve an
|
||||
# exception here.
|
||||
for prefix in _AZURE_FOUNDRY_RESPONSES_PREFIXES:
|
||||
if raw.startswith(prefix):
|
||||
return "codex_responses"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_opencode_model_id(provider_id: Optional[str], model_id: Optional[str]) -> str:
|
||||
"""Normalize OpenCode config IDs to the bare model slug used in API requests."""
|
||||
provider = normalize_provider(provider_id)
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import Dict, Iterable, Optional, Set
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
from hermes_cli.config import get_env_value, load_config
|
||||
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||
from utils import is_truthy_value
|
||||
from tools.tool_backend_helpers import (
|
||||
fal_key_is_configured,
|
||||
has_direct_modal_credentials,
|
||||
@@ -26,13 +25,6 @@ _DEFAULT_PLATFORM_TOOLSETS = {
|
||||
}
|
||||
|
||||
|
||||
def _uses_gateway(section: object) -> bool:
|
||||
"""Return True when a config section explicitly opts into the gateway."""
|
||||
if not isinstance(section, dict):
|
||||
return False
|
||||
return is_truthy_value(section.get("use_gateway"), default=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousFeatureState:
|
||||
key: str
|
||||
@@ -270,11 +262,11 @@ def get_nous_subscription_features(
|
||||
# use_gateway flags — when True, the user explicitly opted into the
|
||||
# Tool Gateway via `hermes model`, so direct credentials should NOT
|
||||
# prevent gateway routing.
|
||||
web_use_gateway = _uses_gateway(web_cfg)
|
||||
tts_use_gateway = _uses_gateway(tts_cfg)
|
||||
browser_use_gateway = _uses_gateway(browser_cfg)
|
||||
web_use_gateway = bool(web_cfg.get("use_gateway"))
|
||||
tts_use_gateway = bool(tts_cfg.get("use_gateway"))
|
||||
browser_use_gateway = bool(browser_cfg.get("use_gateway"))
|
||||
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
|
||||
image_use_gateway = _uses_gateway(image_gen_cfg)
|
||||
image_use_gateway = bool(image_gen_cfg.get("use_gateway"))
|
||||
|
||||
direct_exa = bool(get_env_value("EXA_API_KEY"))
|
||||
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
|
||||
@@ -609,10 +601,10 @@ def get_gateway_eligible_tools(
|
||||
# no direct keys exist — we only skip the prompt for tools where
|
||||
# use_gateway was explicitly set.
|
||||
opted_in = {
|
||||
"web": _uses_gateway(config.get("web")),
|
||||
"image_gen": _uses_gateway(config.get("image_gen")),
|
||||
"tts": _uses_gateway(config.get("tts")),
|
||||
"browser": _uses_gateway(config.get("browser")),
|
||||
"web": bool((config.get("web") if isinstance(config.get("web"), dict) else {}).get("use_gateway")),
|
||||
"image_gen": bool((config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}).get("use_gateway")),
|
||||
"tts": bool((config.get("tts") if isinstance(config.get("tts"), dict) else {}).get("use_gateway")),
|
||||
"browser": bool((config.get("browser") if isinstance(config.get("browser"), dict) else {}).get("use_gateway")),
|
||||
}
|
||||
|
||||
unconfigured: list[str] = []
|
||||
|
||||
@@ -36,7 +36,6 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
|
||||
("wecom_callback", PlatformInfo(label="💬 WeCom Callback", default_toolset="hermes-wecom-callback")),
|
||||
("weixin", PlatformInfo(label="💬 Weixin", default_toolset="hermes-weixin")),
|
||||
("qqbot", PlatformInfo(label="💬 QQBot", default_toolset="hermes-qqbot")),
|
||||
("yuanbao", PlatformInfo(label="🤖 Yuanbao", default_toolset="hermes-yuanbao")),
|
||||
("webhook", PlatformInfo(label="🔗 Webhook", default_toolset="hermes-webhook")),
|
||||
("api_server", PlatformInfo(label="🌐 API Server", default_toolset="hermes-api-server")),
|
||||
("cron", PlatformInfo(label="⏰ Cron", default_toolset="hermes-cron")),
|
||||
|
||||
@@ -79,20 +79,6 @@ VALID_HOOKS: Set[str] = {
|
||||
# {"action": "allow"} / None -> normal dispatch
|
||||
# Kwargs: event: MessageEvent, gateway: GatewayRunner, session_store.
|
||||
"pre_gateway_dispatch",
|
||||
# Approval lifecycle hooks. Fired by tools/approval.py when a dangerous
|
||||
# command needs user approval -- fires BOTH for CLI-interactive prompts
|
||||
# and for gateway/ACP approvals (Telegram, Discord, Slack, TUI, etc.).
|
||||
# Observers only: return values are ignored. Plugins cannot veto or
|
||||
# pre-answer an approval from these hooks (use pre_tool_call to block
|
||||
# a tool before it reaches approval).
|
||||
#
|
||||
# Kwargs for pre_approval_request:
|
||||
# command: str, description: str, pattern_key: str, pattern_keys: list[str],
|
||||
# session_key: str, surface: "cli" | "gateway"
|
||||
# Kwargs for post_approval_response: same as above plus
|
||||
# choice: "once" | "session" | "always" | "deny" | "timeout"
|
||||
"pre_approval_request",
|
||||
"post_approval_response",
|
||||
}
|
||||
|
||||
ENTRY_POINTS_GROUP = "hermes_agent.plugins"
|
||||
|
||||
+10
-168
@@ -1,13 +1,7 @@
|
||||
"""``hermes plugins`` CLI subcommand — install, update, remove, and list plugins.
|
||||
|
||||
Plugins can be installed from:
|
||||
- Official optional plugins shipped with the repo: ``official/<category>/<name>``
|
||||
- Git repositories (full URL or ``owner/repo`` GitHub shorthand)
|
||||
|
||||
Official plugins live in ``optional-plugins/`` inside the Hermes repo and are
|
||||
copied into ``~/.hermes/plugins/`` on install — no git clone needed, no network
|
||||
required. They are NOT auto-discovered from ``optional-plugins/``; only installed
|
||||
copies in ``~/.hermes/plugins/`` are loaded by Hermes.
|
||||
Plugins are installed from Git repositories into ``~/.hermes/plugins/``.
|
||||
Supports full URLs and ``owner/repo`` shorthand (resolves to GitHub).
|
||||
|
||||
After install, if the plugin ships an ``after-install.md`` file it is
|
||||
rendered with Rich Markdown. Otherwise a default confirmation is shown.
|
||||
@@ -101,80 +95,10 @@ def _resolve_git_url(identifier: str) -> str:
|
||||
|
||||
raise ValueError(
|
||||
f"Invalid plugin identifier: '{identifier}'. "
|
||||
"Use 'official/<category>/<name>', a Git URL, or owner/repo shorthand."
|
||||
"Use a Git URL or owner/repo shorthand."
|
||||
)
|
||||
|
||||
|
||||
def _optional_plugins_dir() -> Path:
|
||||
"""Return the optional-plugins/ directory shipped with the Hermes repo."""
|
||||
return Path(__file__).resolve().parent.parent / "optional-plugins"
|
||||
|
||||
|
||||
def _resolve_official_plugin(identifier: str) -> Optional[Path]:
|
||||
"""If *identifier* is 'official/<category>/<name>', return its source path.
|
||||
|
||||
Returns ``None`` when the identifier is not in official format or the
|
||||
plugin directory does not exist.
|
||||
"""
|
||||
# Accept 'official/category/name' or just 'category/name' when the
|
||||
# category/name path exists under optional-plugins/.
|
||||
parts = identifier.strip("/").split("/")
|
||||
|
||||
# Strip leading 'official' prefix if present
|
||||
if parts and parts[0] == "official":
|
||||
parts = parts[1:]
|
||||
|
||||
if len(parts) < 1:
|
||||
return None
|
||||
|
||||
base = _optional_plugins_dir()
|
||||
# Try category/name (2 parts) or bare name (1 part)
|
||||
for nparts in (2, 1):
|
||||
if len(parts) < nparts:
|
||||
continue
|
||||
candidate = base.joinpath(*parts[-nparts:])
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
base_resolved = base.resolve()
|
||||
resolved.relative_to(base_resolved) # traversal guard
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if resolved.is_dir() and (
|
||||
(resolved / "plugin.yaml").exists() or (resolved / "__init__.py").exists()
|
||||
):
|
||||
return resolved
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _list_official_plugins() -> list[tuple[str, str]]:
|
||||
"""Return [(identifier, description), ...] for all official optional plugins."""
|
||||
base = _optional_plugins_dir()
|
||||
if not base.is_dir():
|
||||
return []
|
||||
|
||||
results = []
|
||||
for category_dir in sorted(base.iterdir()):
|
||||
if not category_dir.is_dir() or category_dir.name.startswith("."):
|
||||
continue
|
||||
for plugin_dir in sorted(category_dir.iterdir()):
|
||||
if not plugin_dir.is_dir() or plugin_dir.name.startswith("."):
|
||||
continue
|
||||
manifest_file = plugin_dir / "plugin.yaml"
|
||||
desc = ""
|
||||
if manifest_file.exists():
|
||||
try:
|
||||
import yaml
|
||||
data = yaml.safe_load(manifest_file.read_text()) or {}
|
||||
desc = data.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
identifier = f"official/{category_dir.name}/{plugin_dir.name}"
|
||||
results.append((identifier, desc))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _repo_name_from_url(url: str) -> str:
|
||||
"""Extract the repo name from a Git URL for the plugin directory name."""
|
||||
# Strip trailing .git and slashes
|
||||
@@ -372,61 +296,7 @@ def cmd_install(
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
# ── Official optional plugins (no network, copied from optional-plugins/) ──
|
||||
official_src = _resolve_official_plugin(identifier)
|
||||
if official_src is not None:
|
||||
manifest = _read_manifest(official_src)
|
||||
plugin_name = manifest.get("name") or official_src.name
|
||||
target = _sanitize_plugin_name(plugin_name, plugins_dir)
|
||||
|
||||
if target.exists():
|
||||
if not force:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Plugin '{plugin_name}' already exists at {target}.\n"
|
||||
f"Use [bold]--force[/bold] to reinstall, or "
|
||||
f"[bold]hermes plugins update {plugin_name}[/bold] to update."
|
||||
)
|
||||
sys.exit(1)
|
||||
console.print(f"[dim] Removing existing {plugin_name}...[/dim]")
|
||||
shutil.rmtree(target)
|
||||
|
||||
console.print(f"[dim]Installing {plugin_name} from official optional plugins...[/dim]")
|
||||
shutil.copytree(str(official_src), str(target))
|
||||
|
||||
_copy_example_files(target, console)
|
||||
_prompt_plugin_env_vars(manifest, console)
|
||||
_display_after_install(target, identifier)
|
||||
|
||||
installed_name = manifest.get("name") or target.name
|
||||
should_enable = enable
|
||||
if should_enable is None:
|
||||
if sys.stdin.isatty() and sys.stdout.isatty():
|
||||
try:
|
||||
answer = input(" Enable now? [y/N] ").strip().lower()
|
||||
should_enable = answer in ("y", "yes")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
should_enable = False
|
||||
else:
|
||||
should_enable = False
|
||||
|
||||
if should_enable:
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
enabled.add(installed_name)
|
||||
disabled.discard(installed_name)
|
||||
_save_enabled_set(enabled)
|
||||
_save_disabled_set(disabled)
|
||||
console.print(f" [green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.")
|
||||
else:
|
||||
console.print(
|
||||
f" [dim]Plugin installed but not enabled. "
|
||||
f"Run [bold]hermes plugins enable {installed_name}[/bold] to activate.[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
# ── Git URL / owner/repo install ──────────────────────────────────────────
|
||||
try:
|
||||
git_url = _resolve_git_url(identifier)
|
||||
except ValueError as e:
|
||||
@@ -440,6 +310,8 @@ def cmd_install(
|
||||
"Consider using https:// or git@ for production installs."
|
||||
)
|
||||
|
||||
plugins_dir = _plugins_dir()
|
||||
|
||||
# Clone into a temp directory first so we can read plugin.yaml for the name
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_target = Path(tmp) / "plugin"
|
||||
@@ -824,21 +696,16 @@ def _discover_all_plugins() -> list:
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def cmd_list(available: bool = False) -> None:
|
||||
"""List all plugins (bundled + user) with enabled/disabled state.
|
||||
|
||||
When *available* is True, also show official optional plugins that are
|
||||
not yet installed.
|
||||
"""
|
||||
def cmd_list() -> None:
|
||||
"""List all plugins (bundled + user) with enabled/disabled state."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
entries = _discover_all_plugins()
|
||||
if not entries and not available:
|
||||
if not entries:
|
||||
console.print("[dim]No plugins installed.[/dim]")
|
||||
console.print("[dim]Install with:[/dim] hermes plugins install official/<category>/<name>")
|
||||
console.print("[dim]Browse available:[/dim] hermes plugins list --available")
|
||||
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
|
||||
return
|
||||
|
||||
enabled = _get_enabled_set()
|
||||
@@ -867,31 +734,6 @@ def cmd_list(available: bool = False) -> None:
|
||||
console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>")
|
||||
console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]")
|
||||
|
||||
if available:
|
||||
official = _list_official_plugins()
|
||||
if official:
|
||||
installed_names = {name for name, *_ in entries}
|
||||
def _is_installed(ident: str) -> bool:
|
||||
dirname = ident.rsplit("/", 1)[-1]
|
||||
# Check both the directory name (langfuse-tracing) and
|
||||
# common underscore variant (langfuse_tracing) since the
|
||||
# installed plugin uses the manifest name, not the dir name.
|
||||
return (dirname in installed_names
|
||||
or dirname.replace("-", "_") in installed_names)
|
||||
not_installed = [(ident, desc) for ident, desc in official
|
||||
if not _is_installed(ident)]
|
||||
if not_installed:
|
||||
console.print()
|
||||
avail_table = Table(title="Official optional plugins (not installed)", show_lines=False)
|
||||
avail_table.add_column("Identifier", style="bold")
|
||||
avail_table.add_column("Description")
|
||||
for ident, desc in not_installed:
|
||||
avail_table.add_row(ident, desc)
|
||||
console.print(avail_table)
|
||||
console.print("[dim]Install:[/dim] hermes plugins install official/<category>/<name>")
|
||||
else:
|
||||
console.print("[dim]All official optional plugins are already installed.[/dim]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider plugin discovery helpers
|
||||
@@ -1428,7 +1270,7 @@ def plugins_command(args) -> None:
|
||||
elif action == "disable":
|
||||
cmd_disable(args.name)
|
||||
elif action in ("list", "ls"):
|
||||
cmd_list(available=getattr(args, "available", False))
|
||||
cmd_list()
|
||||
elif action is None:
|
||||
cmd_toggle()
|
||||
else:
|
||||
|
||||
@@ -163,12 +163,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
base_url_override="https://api.arcee.ai/api/v1",
|
||||
base_url_env_var="ARCEE_BASE_URL",
|
||||
),
|
||||
"gmi": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
extra_env_vars=("GMI_API_KEY",),
|
||||
base_url_override="https://api.gmi-serving.com/v1",
|
||||
base_url_env_var="GMI_BASE_URL",
|
||||
),
|
||||
"ollama-cloud": HermesOverlay(
|
||||
transport="openai_chat",
|
||||
base_url_env_var="OLLAMA_BASE_URL",
|
||||
@@ -303,10 +297,6 @@ ALIASES: Dict[str, str] = {
|
||||
"arcee-ai": "arcee",
|
||||
"arceeai": "arcee",
|
||||
|
||||
# gmi
|
||||
"gmi-cloud": "gmi",
|
||||
"gmicloud": "gmi",
|
||||
|
||||
# Local server aliases → virtual "local" concept (resolved via user config)
|
||||
"lmstudio": "lmstudio",
|
||||
"lm-studio": "lmstudio",
|
||||
@@ -329,7 +319,6 @@ _LABEL_OVERRIDES: Dict[str, str] = {
|
||||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"stepfun": "StepFun Step Plan",
|
||||
"xiaomi": "Xiaomi MiMo",
|
||||
"gmi": "GMI Cloud",
|
||||
"local": "Local endpoint",
|
||||
"bedrock": "AWS Bedrock",
|
||||
"ollama-cloud": "Ollama Cloud",
|
||||
|
||||
@@ -231,19 +231,6 @@ def _resolve_runtime_from_pool_entry(
|
||||
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
|
||||
if configured_mode:
|
||||
api_mode = configured_mode
|
||||
# Model-family inference for GPT-5.x / codex / o1-o4: Azure rejects
|
||||
# /chat/completions on these with 400 "operation unsupported" — see
|
||||
# azure_foundry_model_api_mode() for rationale. Skip when the user
|
||||
# explicitly picked anthropic_messages (Anthropic-style endpoint).
|
||||
if effective_model and api_mode != "anthropic_messages":
|
||||
try:
|
||||
from hermes_cli.models import azure_foundry_model_api_mode
|
||||
|
||||
inferred = azure_foundry_model_api_mode(effective_model)
|
||||
except Exception:
|
||||
inferred = None
|
||||
if inferred:
|
||||
api_mode = inferred
|
||||
# For Anthropic-style endpoints, strip /v1 suffix
|
||||
if api_mode == "anthropic_messages":
|
||||
base_url = re.sub(r"/v1/?$", "", base_url)
|
||||
@@ -621,7 +608,6 @@ def _resolve_azure_foundry_runtime(
|
||||
model_cfg: Dict[str, Any],
|
||||
explicit_api_key: Optional[str] = None,
|
||||
explicit_base_url: Optional[str] = None,
|
||||
target_model: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve an Azure Foundry runtime entry.
|
||||
|
||||
@@ -642,22 +628,6 @@ def _resolve_azure_foundry_runtime(
|
||||
cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/")
|
||||
cfg_api_mode = _parse_api_mode(model_cfg.get("api_mode")) or "chat_completions"
|
||||
|
||||
# Model-family inference: Azure Foundry deploys GPT-5.x / codex / o1-o4
|
||||
# reasoning models as Responses-API-only. Calling /chat/completions
|
||||
# against them returns 400 "The requested operation is unsupported."
|
||||
# Upgrade api_mode when the model name matches, unless the user has
|
||||
# explicitly chosen anthropic_messages (Anthropic-style endpoint).
|
||||
effective_model = str(target_model or model_cfg.get("default") or "").strip()
|
||||
if effective_model and cfg_api_mode != "anthropic_messages":
|
||||
try:
|
||||
from hermes_cli.models import azure_foundry_model_api_mode
|
||||
|
||||
inferred = azure_foundry_model_api_mode(effective_model)
|
||||
except Exception:
|
||||
inferred = None
|
||||
if inferred:
|
||||
cfg_api_mode = inferred
|
||||
|
||||
env_base_url = os.getenv("AZURE_FOUNDRY_BASE_URL", "").strip().rstrip("/")
|
||||
base_url = explicit_base_url_clean or cfg_base_url or env_base_url
|
||||
if not base_url:
|
||||
@@ -894,7 +864,6 @@ def resolve_runtime_provider(
|
||||
model_cfg=_get_model_config(),
|
||||
explicit_api_key=explicit_api_key,
|
||||
explicit_base_url=explicit_base_url,
|
||||
target_model=target_model,
|
||||
)
|
||||
return azure_runtime
|
||||
|
||||
|
||||
+14
-69
@@ -1856,32 +1856,27 @@ def _setup_slack():
|
||||
if existing:
|
||||
print_info("Slack: already configured")
|
||||
if not prompt_yes_no("Reconfigure Slack?", False):
|
||||
# Even without reconfiguring, offer to refresh the manifest so
|
||||
# new commands (e.g. /btw, /stop, ...) get registered in Slack.
|
||||
if prompt_yes_no(
|
||||
"Regenerate the Slack app manifest with the latest command "
|
||||
"list? (recommended after `hermes update`)",
|
||||
True,
|
||||
):
|
||||
_write_slack_manifest_and_instruct()
|
||||
return
|
||||
|
||||
print_info("Steps to create a Slack app:")
|
||||
print_info(" 1. Go to https://api.slack.com/apps → Create New App")
|
||||
print_info(" Pick 'From an app manifest' — we'll generate one for you below.")
|
||||
print_info(" 1. Go to https://api.slack.com/apps → Create New App (from scratch)")
|
||||
print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable")
|
||||
print_info(" • Create an App-Level Token with 'connections:write' scope")
|
||||
print_info(" 3. Install to Workspace: Settings → Install App")
|
||||
print_info(" 4. After installing, invite the bot to channels: /invite @YourBot")
|
||||
print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions")
|
||||
print_info(" Required scopes: chat:write, app_mentions:read,")
|
||||
print_info(" channels:history, channels:read, im:history,")
|
||||
print_info(" im:read, im:write, users:read, files:read, files:write")
|
||||
print_info(" Optional for private channels: groups:history")
|
||||
print_info(" 4. Subscribe to Events: Features → Event Subscriptions → Enable")
|
||||
print_info(" Required events: message.im, message.channels, app_mention")
|
||||
print_info(" Optional for private channels: message.groups")
|
||||
print_warning(" ⚠ Without message.channels the bot will ONLY work in DMs,")
|
||||
print_warning(" not public channels.")
|
||||
print_info(" 5. Install to Workspace: Settings → Install App")
|
||||
print_info(" 6. Reinstall the app after any scope or event changes")
|
||||
print_info(" 7. After installing, invite the bot to channels: /invite @YourBot")
|
||||
print()
|
||||
print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/")
|
||||
print()
|
||||
|
||||
# Generate and write manifest up-front so the user can paste it into
|
||||
# the "Create from manifest" flow instead of clicking through scopes /
|
||||
# events / slash commands one at a time.
|
||||
_write_slack_manifest_and_instruct()
|
||||
|
||||
print()
|
||||
bot_token = prompt("Slack Bot Token (xoxb-...)", password=True)
|
||||
if not bot_token:
|
||||
@@ -1907,49 +1902,6 @@ def _setup_slack():
|
||||
print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.")
|
||||
|
||||
|
||||
def _write_slack_manifest_and_instruct():
|
||||
"""Generate the Slack manifest, write it under HERMES_HOME, and print
|
||||
paste-into-Slack instructions.
|
||||
|
||||
Exposed as its own helper so both the initial setup flow and the
|
||||
"reconfigure? → no" branch can refresh the manifest without the user
|
||||
re-entering tokens. Failures are non-fatal — if the manifest write
|
||||
fails for any reason, we print a warning and skip rather than abort
|
||||
the whole Slack setup.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.slack_cli import _build_full_manifest
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
manifest = _build_full_manifest(
|
||||
bot_name="Hermes",
|
||||
bot_description="Your Hermes agent on Slack",
|
||||
)
|
||||
target = Path(get_hermes_home()) / "slack-manifest.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
import json as _json
|
||||
target.write_text(
|
||||
_json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print_success(f"Slack app manifest written to: {target}")
|
||||
print_info(
|
||||
" Paste it into https://api.slack.com/apps → your app → Features "
|
||||
"→ App Manifest → Edit, then Save. Slack will prompt to "
|
||||
"reinstall if scopes or slash commands changed."
|
||||
)
|
||||
print_info(
|
||||
" Re-run `hermes slack manifest --write` anytime to refresh after "
|
||||
"Hermes adds new commands."
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - best-effort UX helper
|
||||
print_warning(f"Couldn't write Slack manifest: {exc}")
|
||||
print_info(
|
||||
" You can generate it manually later with: "
|
||||
"hermes slack manifest --write"
|
||||
)
|
||||
|
||||
|
||||
def _setup_matrix():
|
||||
"""Configure Matrix credentials."""
|
||||
print_header("Matrix")
|
||||
@@ -2133,12 +2085,6 @@ def _setup_feishu():
|
||||
_gateway_setup_feishu()
|
||||
|
||||
|
||||
def _setup_yuanbao():
|
||||
"""Configure Yuanbao via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_yuanbao as _gateway_setup_yuanbao
|
||||
_gateway_setup_yuanbao()
|
||||
|
||||
|
||||
def _setup_wecom():
|
||||
"""Configure WeCom (Enterprise WeChat) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_wecom as _gateway_setup_wecom
|
||||
@@ -2283,7 +2229,6 @@ _GATEWAY_PLATFORMS = [
|
||||
("WhatsApp", "WHATSAPP_ENABLED", _setup_whatsapp),
|
||||
("DingTalk", "DINGTALK_CLIENT_ID", _setup_dingtalk),
|
||||
("Feishu / Lark", "FEISHU_APP_ID", _setup_feishu),
|
||||
("Yuanbao", "YUANBAO_APP_ID", _setup_yuanbao),
|
||||
("WeCom (Enterprise WeChat)", "WECOM_BOT_ID", _setup_wecom),
|
||||
("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback),
|
||||
("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin),
|
||||
|
||||
+20
-230
@@ -11,10 +11,9 @@ handler are thin wrappers that parse args and delegate.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
@@ -142,103 +141,6 @@ def _derive_category_from_install_path(install_path: str) -> str:
|
||||
return "" if parent == "." else parent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive name/category resolution for URL-installed skills
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
|
||||
_VALID_CATEGORY_RE = re.compile(r"^[a-z][a-z0-9_/-]*$")
|
||||
|
||||
|
||||
def _is_valid_installed_skill_name(name: str) -> bool:
|
||||
"""Accept identifier-shaped names, reject empty / sentinel-y values."""
|
||||
if not isinstance(name, str):
|
||||
return False
|
||||
candidate = name.strip().lower()
|
||||
if not candidate or candidate in {"skill", "readme", "index", "unnamed-skill"}:
|
||||
return False
|
||||
return bool(_VALID_NAME_RE.match(candidate))
|
||||
|
||||
|
||||
def _existing_categories() -> List[str]:
|
||||
"""Return sorted subdirectory names under ``~/.hermes/skills/`` that look
|
||||
like category buckets (contain at least one ``SKILL.md`` somewhere below).
|
||||
|
||||
Used to suggest reusable categories when interactively installing from a
|
||||
URL. Hidden dirs (``.hub``, ``.trash``) are skipped.
|
||||
"""
|
||||
from tools.skills_hub import SKILLS_DIR
|
||||
out: List[str] = []
|
||||
try:
|
||||
for entry in SKILLS_DIR.iterdir():
|
||||
if not entry.is_dir() or entry.name.startswith("."):
|
||||
continue
|
||||
# Only count as a category if it contains skills, not if it IS a skill.
|
||||
# Heuristic: if ``<entry>/SKILL.md`` exists, it's a skill at the
|
||||
# top level (no category); otherwise treat as a category bucket.
|
||||
if (entry / "SKILL.md").exists():
|
||||
continue
|
||||
# Has at least one nested SKILL.md?
|
||||
try:
|
||||
if any(entry.rglob("SKILL.md")):
|
||||
out.append(entry.name)
|
||||
except OSError:
|
||||
continue
|
||||
except (FileNotFoundError, OSError):
|
||||
return []
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def _prompt_for_skill_name(c: Console, url: str, default: str = "") -> Optional[str]:
|
||||
"""Prompt interactively for a skill name. Returns None on cancel/EOF."""
|
||||
c.print()
|
||||
c.print(
|
||||
f"[yellow]The SKILL.md at {url} doesn't declare a `name:` in its "
|
||||
f"frontmatter,[/]\n[yellow]and the URL path doesn't produce a valid "
|
||||
f"identifier either.[/]"
|
||||
)
|
||||
default_hint = f" [{default}]" if default else ""
|
||||
c.print(
|
||||
f"[bold]Enter a skill name{default_hint}:[/] "
|
||||
f"[dim](lowercase letters, digits, hyphens, underscores; starts with a letter)[/]"
|
||||
)
|
||||
try:
|
||||
answer = input("Name: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
if not answer and default:
|
||||
answer = default
|
||||
if not _is_valid_installed_skill_name(answer):
|
||||
c.print(f"[bold red]Invalid name:[/] {answer!r}. Aborting install.\n")
|
||||
return None
|
||||
return answer
|
||||
|
||||
|
||||
def _prompt_for_category(c: Console, existing: List[str]) -> str:
|
||||
"""Prompt interactively for a category. Empty/None input means flat install."""
|
||||
c.print()
|
||||
if existing:
|
||||
c.print(
|
||||
"[bold]Pick a category[/] "
|
||||
"[dim](reuse an existing bucket, type a new one, or press Enter to install flat)[/]"
|
||||
)
|
||||
c.print(f"[dim]Existing: {', '.join(existing)}[/]")
|
||||
else:
|
||||
c.print(
|
||||
"[bold]Category[/] [dim](optional — press Enter to install flat at ~/.hermes/skills/<name>/)[/]"
|
||||
)
|
||||
try:
|
||||
answer = input("Category: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return ""
|
||||
if not answer:
|
||||
return ""
|
||||
if not _VALID_CATEGORY_RE.match(answer):
|
||||
c.print(f"[dim]Invalid category {answer!r} — installing flat.[/]")
|
||||
return ""
|
||||
return answer
|
||||
|
||||
|
||||
def do_search(query: str, source: str = "all", limit: int = 10,
|
||||
console: Optional[Console] = None) -> None:
|
||||
"""Search registries and display results as a Rich table."""
|
||||
@@ -407,17 +309,8 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
|
||||
|
||||
def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
console: Optional[Console] = None, skip_confirm: bool = False,
|
||||
invalidate_cache: bool = True,
|
||||
name_override: str = "") -> None:
|
||||
"""Fetch, quarantine, scan, confirm, and install a skill.
|
||||
|
||||
``name_override`` lets non-interactive callers (slash commands, gateway,
|
||||
scripts) supply a skill name when the upstream SKILL.md lacks a valid
|
||||
``name:`` frontmatter field. On interactive TTY surfaces, a missing name
|
||||
triggers a prompt instead; ``skip_confirm=True`` means "non-interactive"
|
||||
(so pair it with ``name_override`` when installing from a URL that has
|
||||
no frontmatter).
|
||||
"""
|
||||
invalidate_cache: bool = True) -> None:
|
||||
"""Fetch, quarantine, scan, confirm, and install a skill."""
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth, create_source_router, ensure_hub_dirs,
|
||||
quarantine_bundle, install_from_quarantine, HubLockFile,
|
||||
@@ -461,58 +354,6 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
c.print()
|
||||
return
|
||||
|
||||
# URL-sourced skills may arrive with an empty name when SKILL.md has no
|
||||
# ``name:`` in frontmatter AND the URL path doesn't yield a valid
|
||||
# identifier. Resolve by (1) --name override, (2) interactive prompt on
|
||||
# a TTY, (3) refuse with an actionable error on non-interactive surfaces.
|
||||
bundle_meta = getattr(bundle, "metadata", {}) or {}
|
||||
if bundle.source == "url" and (not bundle.name or bundle_meta.get("awaiting_name")):
|
||||
if name_override and _is_valid_installed_skill_name(name_override):
|
||||
bundle.name = name_override.strip()
|
||||
bundle_meta["awaiting_name"] = False
|
||||
elif name_override:
|
||||
c.print(
|
||||
f"[bold red]Invalid --name:[/] {name_override!r}. "
|
||||
"Must be a lowercase identifier (letters, digits, hyphens, "
|
||||
"underscores; starts with a letter).\n"
|
||||
)
|
||||
return
|
||||
elif skip_confirm:
|
||||
# Non-interactive surface (slash command / TUI / gateway). Can't
|
||||
# prompt — emit an actionable error.
|
||||
url = bundle_meta.get("url") or identifier
|
||||
c.print(
|
||||
f"[bold red]Cannot install from URL:[/] {url}\n"
|
||||
"[yellow]The SKILL.md has no `name:` in its frontmatter, "
|
||||
"and the URL path doesn't produce a valid identifier.[/]\n\n"
|
||||
"Retry with an explicit name:\n"
|
||||
f" [bold]/skills install {url} --name <your-name>[/]\n"
|
||||
f" [bold]hermes skills install {url} --name <your-name>[/]\n\n"
|
||||
"[dim]Or ask the SKILL.md's author to add a `name:` field to "
|
||||
"its YAML frontmatter.[/]\n"
|
||||
)
|
||||
return
|
||||
else:
|
||||
# Interactive TTY — prompt.
|
||||
url = bundle_meta.get("url") or identifier
|
||||
chosen = _prompt_for_skill_name(c, url)
|
||||
if not chosen:
|
||||
c.print("[dim]Installation cancelled.[/]\n")
|
||||
return
|
||||
bundle.name = chosen
|
||||
bundle_meta["awaiting_name"] = False
|
||||
# Keep SkillMeta in sync so downstream "already installed" checks,
|
||||
# audit logs, and display all see the final name.
|
||||
if meta is not None:
|
||||
meta.name = bundle.name
|
||||
meta.path = bundle.name
|
||||
|
||||
# URL-sourced skills: offer to pick a category interactively when the
|
||||
# caller didn't specify one (TTY only — non-interactive installs fall
|
||||
# through to flat install, matching all other sources).
|
||||
if bundle.source == "url" and not category and not skip_confirm:
|
||||
category = _prompt_for_category(c, _existing_categories())
|
||||
|
||||
# Auto-detect category for official skills (e.g. "official/autonomous-ai-agents/blackbox")
|
||||
if bundle.source == "official" and not category:
|
||||
id_parts = bundle.identifier.split("/") # ["official", "category", "skill"]
|
||||
@@ -758,24 +599,11 @@ def inspect_skill(identifier: str) -> Optional[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def do_list(source_filter: str = "all",
|
||||
enabled_only: bool = False,
|
||||
console: Optional[Console] = None) -> None:
|
||||
"""List installed skills, distinguishing hub, builtin, and local skills.
|
||||
|
||||
Args:
|
||||
source_filter: ``all`` | ``hub`` | ``builtin`` | ``local``.
|
||||
enabled_only: If True, hide disabled skills from the output.
|
||||
|
||||
Enabled/disabled state is resolved against the currently active profile's
|
||||
config — ``hermes -p <profile> skills list`` reads that profile's
|
||||
``skills.disabled`` list because ``-p`` swaps ``HERMES_HOME`` at process
|
||||
start. No explicit profile flag needed here.
|
||||
"""
|
||||
def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None:
|
||||
"""List installed skills, distinguishing hub, builtin, and local skills."""
|
||||
from tools.skills_hub import HubLockFile, ensure_hub_dirs
|
||||
from tools.skills_sync import _read_manifest
|
||||
from tools.skills_tool import _find_all_skills
|
||||
from agent.skill_utils import get_disabled_skill_names
|
||||
|
||||
c = console or _console
|
||||
ensure_hub_dirs()
|
||||
@@ -783,26 +611,17 @@ def do_list(source_filter: str = "all",
|
||||
hub_installed = {e["name"]: e for e in lock.list_installed()}
|
||||
builtin_names = set(_read_manifest())
|
||||
|
||||
# Pull ALL skills (including disabled ones) so we can annotate status.
|
||||
all_skills = _find_all_skills(skip_disabled=True)
|
||||
disabled_names = get_disabled_skill_names()
|
||||
all_skills = _find_all_skills()
|
||||
|
||||
title = "Installed Skills"
|
||||
if enabled_only:
|
||||
title += " (enabled only)"
|
||||
|
||||
table = Table(title=title)
|
||||
table = Table(title="Installed Skills")
|
||||
table.add_column("Name", style="bold cyan")
|
||||
table.add_column("Category", style="dim")
|
||||
table.add_column("Source", style="dim")
|
||||
table.add_column("Trust", style="dim")
|
||||
table.add_column("Status", style="dim")
|
||||
|
||||
hub_count = 0
|
||||
builtin_count = 0
|
||||
local_count = 0
|
||||
enabled_count = 0
|
||||
disabled_count = 0
|
||||
|
||||
for skill in sorted(all_skills, key=lambda s: (s.get("category") or "", s["name"])):
|
||||
name = skill["name"]
|
||||
@@ -813,48 +632,29 @@ def do_list(source_filter: str = "all",
|
||||
source_type = "hub"
|
||||
source_display = hub_entry.get("source", "hub")
|
||||
trust = hub_entry.get("trust_level", "community")
|
||||
hub_count += 1
|
||||
elif name in builtin_names:
|
||||
source_type = "builtin"
|
||||
source_display = "builtin"
|
||||
trust = "builtin"
|
||||
builtin_count += 1
|
||||
else:
|
||||
source_type = "local"
|
||||
source_display = "local"
|
||||
trust = "local"
|
||||
local_count += 1
|
||||
|
||||
if source_filter != "all" and source_filter != source_type:
|
||||
continue
|
||||
|
||||
is_enabled = name not in disabled_names
|
||||
if enabled_only and not is_enabled:
|
||||
continue
|
||||
|
||||
if source_type == "hub":
|
||||
hub_count += 1
|
||||
elif source_type == "builtin":
|
||||
builtin_count += 1
|
||||
else:
|
||||
local_count += 1
|
||||
|
||||
if is_enabled:
|
||||
enabled_count += 1
|
||||
status_cell = "[bold green]enabled[/]"
|
||||
else:
|
||||
disabled_count += 1
|
||||
status_cell = "[dim red]disabled[/]"
|
||||
|
||||
trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow", "local": "dim"}.get(trust, "dim")
|
||||
trust_label = "official" if source_display == "official" else trust
|
||||
table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]", status_cell)
|
||||
table.add_row(name, category, source_display, f"[{trust_style}]{trust_label}[/]")
|
||||
|
||||
c.print(table)
|
||||
summary = f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local"
|
||||
if enabled_only:
|
||||
summary += f" — {enabled_count} enabled shown"
|
||||
else:
|
||||
summary += f" — {enabled_count} enabled, {disabled_count} disabled"
|
||||
summary += "[/]\n"
|
||||
c.print(summary)
|
||||
c.print(
|
||||
f"[dim]{hub_count} hub-installed, {builtin_count} builtin, {local_count} local[/]\n"
|
||||
)
|
||||
|
||||
|
||||
def do_check(name: Optional[str] = None, console: Optional[Console] = None) -> None:
|
||||
@@ -1323,15 +1123,11 @@ def skills_command(args) -> None:
|
||||
do_search(args.query, source=args.source, limit=args.limit)
|
||||
elif action == "install":
|
||||
do_install(args.identifier, category=args.category, force=args.force,
|
||||
skip_confirm=getattr(args, "yes", False),
|
||||
name_override=getattr(args, "name", "") or "")
|
||||
skip_confirm=getattr(args, "yes", False))
|
||||
elif action == "inspect":
|
||||
do_inspect(args.identifier)
|
||||
elif action == "list":
|
||||
do_list(
|
||||
source_filter=args.source,
|
||||
enabled_only=getattr(args, "enabled_only", False),
|
||||
)
|
||||
do_list(source_filter=args.source)
|
||||
elif action == "check":
|
||||
do_check(name=getattr(args, "name", None))
|
||||
elif action == "update":
|
||||
@@ -1381,7 +1177,6 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
/skills search kubernetes
|
||||
/skills install openai/skills/skill-creator
|
||||
/skills install openai/skills/skill-creator --force
|
||||
/skills install https://example.com/path/SKILL.md
|
||||
/skills inspect openai/skills/skill-creator
|
||||
/skills list
|
||||
/skills list --source hub
|
||||
@@ -1458,11 +1253,10 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
|
||||
elif action == "install":
|
||||
if not args:
|
||||
c.print("[bold red]Usage:[/] /skills install <identifier-or-url> [--name <name>] [--category <cat>] [--force] [--now]\n")
|
||||
c.print("[bold red]Usage:[/] /skills install <identifier> [--category <cat>] [--force] [--now]\n")
|
||||
return
|
||||
identifier = args[0]
|
||||
category = ""
|
||||
name_override = ""
|
||||
# Slash commands run inside prompt_toolkit where input() hangs.
|
||||
# Always skip confirmation — the user typing the command is implicit consent.
|
||||
skip_confirm = True
|
||||
@@ -1473,11 +1267,9 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
for i, a in enumerate(args):
|
||||
if a == "--category" and i + 1 < len(args):
|
||||
category = args[i + 1]
|
||||
elif a == "--name" and i + 1 < len(args):
|
||||
name_override = args[i + 1]
|
||||
do_install(identifier, category=category, force=force,
|
||||
skip_confirm=skip_confirm, invalidate_cache=invalidate_cache,
|
||||
name_override=name_override, console=c)
|
||||
console=c)
|
||||
|
||||
elif action == "inspect":
|
||||
if not args:
|
||||
@@ -1487,12 +1279,11 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None:
|
||||
|
||||
elif action == "list":
|
||||
source_filter = "all"
|
||||
enabled_only = "--enabled-only" in args or "--enabled" in args
|
||||
if "--source" in args:
|
||||
idx = args.index("--source")
|
||||
if idx + 1 < len(args):
|
||||
source_filter = args[idx + 1]
|
||||
do_list(source_filter=source_filter, enabled_only=enabled_only, console=c)
|
||||
do_list(source_filter=source_filter, console=c)
|
||||
|
||||
elif action == "check":
|
||||
name = args[0] if args else None
|
||||
@@ -1580,8 +1371,7 @@ def _print_skills_help(console: Console) -> None:
|
||||
" [cyan]search[/] <query> Search registries for skills\n"
|
||||
" [cyan]install[/] <identifier> Install a skill (with security scan)\n"
|
||||
" [cyan]inspect[/] <identifier> Preview a skill without installing\n"
|
||||
" [cyan]list[/] [--source hub|builtin|local] [--enabled-only]\n"
|
||||
" List installed skills; --enabled-only filters to the active profile's live set\n"
|
||||
" [cyan]list[/] [--source hub|builtin|local] List installed skills\n"
|
||||
" [cyan]check[/] [name] Check hub skills for upstream updates\n"
|
||||
" [cyan]update[/] [name] Update hub skills with upstream changes\n"
|
||||
" [cyan]audit[/] [name] Re-scan hub skills for security\n"
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"""``hermes slack ...`` CLI subcommands.
|
||||
|
||||
Today only ``hermes slack manifest`` is implemented — it generates the
|
||||
Slack app manifest JSON for registering every gateway command as a native
|
||||
Slack slash (``/btw``, ``/stop``, ``/model``, …) so users get the same
|
||||
first-class slash UX Discord and Telegram already have.
|
||||
|
||||
Typical workflow::
|
||||
|
||||
$ hermes slack manifest > slack-manifest.json
|
||||
# or:
|
||||
$ hermes slack manifest --write
|
||||
|
||||
Then paste the printed JSON into the Slack app config (Features → App
|
||||
Manifest → Edit) and click Save. Slack diffs the manifest and prompts
|
||||
for reinstall when scopes/commands change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _build_full_manifest(bot_name: str, bot_description: str) -> dict:
|
||||
"""Build a full Slack manifest merging display info + our slash list.
|
||||
|
||||
The slash-command list is always generated from ``COMMAND_REGISTRY`` so
|
||||
it stays in sync with the rest of Hermes. Other manifest sections
|
||||
(display info, OAuth scopes, socket mode) are set to sensible defaults
|
||||
for a Hermes deployment — users can tweak them in the Slack UI after
|
||||
pasting.
|
||||
"""
|
||||
from hermes_cli.commands import slack_app_manifest
|
||||
|
||||
partial = slack_app_manifest()
|
||||
slashes = partial["features"]["slash_commands"]
|
||||
|
||||
return {
|
||||
"_metadata": {
|
||||
"major_version": 1,
|
||||
"minor_version": 1,
|
||||
},
|
||||
"display_information": {
|
||||
"name": bot_name[:35],
|
||||
"description": (bot_description or "Your Hermes agent on Slack")[:140],
|
||||
"background_color": "#1a1a2e",
|
||||
},
|
||||
"features": {
|
||||
"bot_user": {
|
||||
"display_name": bot_name[:80],
|
||||
"always_online": True,
|
||||
},
|
||||
"slash_commands": slashes,
|
||||
"assistant_view": {
|
||||
"assistant_description": "Chat with Hermes in threads and DMs.",
|
||||
},
|
||||
},
|
||||
"oauth_config": {
|
||||
"scopes": {
|
||||
"bot": [
|
||||
"app_mentions:read",
|
||||
"assistant:write",
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"chat:write",
|
||||
"commands",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"groups:history",
|
||||
"im:history",
|
||||
"im:read",
|
||||
"im:write",
|
||||
"users:read",
|
||||
],
|
||||
},
|
||||
},
|
||||
"settings": {
|
||||
"event_subscriptions": {
|
||||
"bot_events": [
|
||||
"app_mention",
|
||||
"assistant_thread_context_changed",
|
||||
"assistant_thread_started",
|
||||
"message.channels",
|
||||
"message.groups",
|
||||
"message.im",
|
||||
],
|
||||
},
|
||||
"interactivity": {
|
||||
"is_enabled": True,
|
||||
},
|
||||
"org_deploy_enabled": False,
|
||||
"socket_mode_enabled": True,
|
||||
"token_rotation_enabled": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def slack_manifest_command(args) -> int:
|
||||
"""Print or write a Slack app manifest JSON.
|
||||
|
||||
Flags (all parsed in ``hermes_cli/main.py``):
|
||||
--write [PATH] Write to file instead of stdout (default path:
|
||||
``$HERMES_HOME/slack-manifest.json``)
|
||||
--name NAME Override the bot display name (default: "Hermes")
|
||||
--description DESC Override the bot description
|
||||
--slashes-only Emit only the ``features.slash_commands`` array (for
|
||||
merging into an existing manifest manually)
|
||||
"""
|
||||
name = getattr(args, "name", None) or "Hermes"
|
||||
description = getattr(args, "description", None) or "Your Hermes agent on Slack"
|
||||
|
||||
if getattr(args, "slashes_only", False):
|
||||
from hermes_cli.commands import slack_app_manifest
|
||||
|
||||
manifest = slack_app_manifest()["features"]["slash_commands"]
|
||||
else:
|
||||
manifest = _build_full_manifest(name, description)
|
||||
|
||||
payload = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
write_target = getattr(args, "write", None)
|
||||
if write_target is not None:
|
||||
if isinstance(write_target, bool) and write_target:
|
||||
# --write with no value → default location
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
target = Path(get_hermes_home()) / "slack-manifest.json"
|
||||
except Exception:
|
||||
target = Path.home() / ".hermes" / "slack-manifest.json"
|
||||
else:
|
||||
target = Path(write_target).expanduser()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(payload, encoding="utf-8")
|
||||
print(f"Slack manifest written to: {target}", file=sys.stderr)
|
||||
print(
|
||||
"\nNext steps:\n"
|
||||
" 1. Open https://api.slack.com/apps and pick your Hermes app\n"
|
||||
" (or create a new one: Create New App → From an app manifest).\n"
|
||||
f" 2. Features → App Manifest → paste the contents of\n"
|
||||
f" {target}\n"
|
||||
" 3. Save; Slack will prompt to reinstall the app if scopes or\n"
|
||||
" slash commands changed.\n"
|
||||
" 4. Make sure Socket Mode is enabled and you have a bot token\n"
|
||||
" (xoxb-...) and app token (xapp-...) configured via\n"
|
||||
" `hermes setup`.\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(payload)
|
||||
return 0
|
||||
@@ -326,8 +326,7 @@ def show_status(args):
|
||||
"WeCom Callback": ("WECOM_CALLBACK_CORP_ID", None),
|
||||
"Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"),
|
||||
"BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"),
|
||||
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
|
||||
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"),
|
||||
"QQBot": ("QQ_APP_ID", "QQBOT_HOME_CHANNEL"),
|
||||
}
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
|
||||
@@ -20,10 +20,10 @@ def get_provider_request_timeout(
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
except Exception:
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
config = load_config()
|
||||
providers = config.get("providers", {}) if isinstance(config, dict) else {}
|
||||
provider_config = (
|
||||
providers.get(provider_id, {}) if isinstance(providers, dict) else {}
|
||||
@@ -49,10 +49,10 @@ def get_provider_stale_timeout(
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
config = load_config()
|
||||
except Exception:
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
config = load_config()
|
||||
providers = config.get("providers", {}) if isinstance(config, dict) else {}
|
||||
provider_config = (
|
||||
providers.get(provider_id, {}) if isinstance(providers, dict) else {}
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ TIPS = [
|
||||
"Set display.streaming: true to see tokens appear in real time as the model generates.",
|
||||
"Set display.show_reasoning: true to watch the model's chain-of-thought reasoning.",
|
||||
"Set display.compact: true to reduce whitespace in output for denser information.",
|
||||
"Set display.busy_input_mode: queue to queue messages instead of interrupting the agent, or steer to inject them mid-run via /steer.",
|
||||
"Set display.busy_input_mode: queue to queue messages instead of interrupting the agent.",
|
||||
"Set display.resume_display: minimal to skip the full conversation recap on session resume.",
|
||||
"Set compression.threshold: 0.50 to control when auto-compression fires (default: 50% of context).",
|
||||
"Set agent.max_turns: 200 to let the agent take more tool-calling steps per turn.",
|
||||
|
||||
@@ -11,7 +11,6 @@ the `platform_toolsets` key.
|
||||
|
||||
import json as _json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
@@ -26,7 +25,7 @@ from hermes_cli.nous_subscription import (
|
||||
get_nous_subscription_features,
|
||||
)
|
||||
from tools.tool_backend_helpers import fal_key_is_configured, managed_nous_tools_enabled
|
||||
from utils import base_url_hostname, is_truthy_value
|
||||
from utils import base_url_hostname
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,7 +70,6 @@ CONFIGURABLE_TOOLSETS = [
|
||||
("spotify", "🎵 Spotify", "playback, search, playlists, library"),
|
||||
("discord", "💬 Discord (read/participate)", "fetch messages, search members, create thread"),
|
||||
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
|
||||
("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"),
|
||||
]
|
||||
|
||||
# Toolsets that are OFF by default for new installs.
|
||||
@@ -425,31 +423,6 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
],
|
||||
},
|
||||
"langfuse": {
|
||||
"name": "Langfuse Observability",
|
||||
"icon": "📊",
|
||||
"providers": [
|
||||
{
|
||||
"name": "Langfuse Cloud",
|
||||
"tag": "Hosted Langfuse (cloud.langfuse.com)",
|
||||
"env_vars": [
|
||||
{"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)", "url": "https://cloud.langfuse.com"},
|
||||
{"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)", "url": "https://cloud.langfuse.com"},
|
||||
],
|
||||
"post_setup": "langfuse",
|
||||
},
|
||||
{
|
||||
"name": "Langfuse Self-Hosted",
|
||||
"tag": "Self-hosted Langfuse instance",
|
||||
"env_vars": [
|
||||
{"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)"},
|
||||
{"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)"},
|
||||
{"key": "HERMES_LANGFUSE_BASE_URL", "prompt": "Langfuse server URL (e.g. http://localhost:3000)", "default": "http://localhost:3000"},
|
||||
],
|
||||
"post_setup": "langfuse",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES.
|
||||
@@ -592,31 +565,6 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_info(" git submodule update --init --recursive")
|
||||
_print_info(' uv pip install -e "./tinker-atropos"')
|
||||
|
||||
elif post_setup_key == "langfuse":
|
||||
# Install the langfuse SDK.
|
||||
try:
|
||||
__import__("langfuse")
|
||||
_print_success(" langfuse SDK already installed")
|
||||
except ImportError:
|
||||
import subprocess
|
||||
_print_info(" Installing langfuse SDK...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "langfuse", "--quiet"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" langfuse SDK installed")
|
||||
else:
|
||||
_print_warning(" langfuse SDK install failed — run manually: pip install langfuse")
|
||||
# Install and enable the official optional plugin into ~/.hermes/plugins/.
|
||||
try:
|
||||
from hermes_cli.plugins_cmd import cmd_install as _plugins_install
|
||||
_plugins_install("official/observability/langfuse", enable=True)
|
||||
except SystemExit:
|
||||
pass # cmd_install prints its own errors and calls sys.exit
|
||||
_print_info(" Restart Hermes for tracing to take effect.")
|
||||
_print_info(" Verify: hermes plugins list")
|
||||
|
||||
|
||||
# ─── Platform / Toolset Helpers ───────────────────────────────────────────────
|
||||
|
||||
@@ -728,15 +676,6 @@ def _get_platform_tools(
|
||||
# their own platform (e.g. `discord` + `discord` should stay OFF).
|
||||
if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS:
|
||||
default_off.remove(platform)
|
||||
# Home Assistant is already runtime-gated by its check_fn (requires
|
||||
# HASS_TOKEN to register any tools). When a user has configured
|
||||
# HASS_TOKEN, they've explicitly opted in — don't also strip it via
|
||||
# _DEFAULT_OFF_TOOLSETS, which would silently drop HA from platforms
|
||||
# (e.g. cron) that run through _get_platform_tools without an
|
||||
# explicit saved toolset list. Without this, Norbert's HA cron jobs
|
||||
# regressed after #14798 made cron honor per-platform tool config.
|
||||
if "homeassistant" in default_off and os.getenv("HASS_TOKEN"):
|
||||
default_off.remove("homeassistant")
|
||||
enabled_toolsets -= default_off
|
||||
|
||||
# Recover non-configurable platform toolsets (e.g. discord, feishu_doc,
|
||||
@@ -1238,7 +1177,7 @@ def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
configured_provider = image_cfg.get("provider")
|
||||
if configured_provider not in (None, "", "fal"):
|
||||
return False
|
||||
if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False):
|
||||
if image_cfg.get("use_gateway") is False:
|
||||
return False
|
||||
return feature.managed_by_nous
|
||||
if provider.get("tts_provider"):
|
||||
@@ -1270,7 +1209,7 @@ def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
return (
|
||||
provider["imagegen_backend"] == "fal"
|
||||
and configured_provider in (None, "", "fal")
|
||||
and not is_truthy_value(image_cfg.get("use_gateway"), default=False)
|
||||
and not image_cfg.get("use_gateway")
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
+23
-11
@@ -287,7 +287,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"display.busy_input_mode": {
|
||||
"type": "select",
|
||||
"description": "Input behavior while agent is running",
|
||||
"options": ["interrupt", "queue", "steer"],
|
||||
"options": ["interrupt", "queue"],
|
||||
},
|
||||
"memory.provider": {
|
||||
"type": "select",
|
||||
@@ -2327,14 +2327,16 @@ def _resolve_chat_argv(
|
||||
from hermes_cli.main import PROJECT_ROOT, _make_tui_argv
|
||||
|
||||
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
|
||||
env = os.environ.copy()
|
||||
env.setdefault("NODE_ENV", "production")
|
||||
env: Optional[dict] = None
|
||||
|
||||
if resume:
|
||||
env["HERMES_TUI_RESUME"] = resume
|
||||
if resume or sidecar_url:
|
||||
env = os.environ.copy()
|
||||
|
||||
if sidecar_url:
|
||||
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
|
||||
if resume:
|
||||
env["HERMES_TUI_RESUME"] = resume
|
||||
|
||||
if sidecar_url:
|
||||
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
|
||||
|
||||
return list(argv), str(cwd) if cwd else None, env
|
||||
|
||||
@@ -3101,13 +3103,23 @@ def _mount_plugin_api_routes():
|
||||
_log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name)
|
||||
continue
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"hermes_dashboard_plugin_{plugin['name']}", api_path,
|
||||
)
|
||||
module_name = f"hermes_dashboard_plugin_{plugin['name']}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, api_path)
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
# Register in sys.modules BEFORE exec_module so pydantic/FastAPI
|
||||
# can resolve forward references (e.g. models defined in a file
|
||||
# that uses `from __future__ import annotations`). Without this,
|
||||
# TypeAdapter lazy-build fails at first request with
|
||||
# "is not fully defined" because the module namespace isn't
|
||||
# reachable by name for string-annotation resolution.
|
||||
sys.modules[module_name] = mod
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception:
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
router = getattr(mod, "router", None)
|
||||
if router is None:
|
||||
_log.warning("Plugin %s api file has no 'router' attribute", plugin["name"])
|
||||
|
||||
+4
-3
@@ -195,6 +195,10 @@ def setup_logging(
|
||||
The ``logs/`` directory where files are written.
|
||||
"""
|
||||
global _logging_initialized
|
||||
if _logging_initialized and not force:
|
||||
home = hermes_home or get_hermes_home()
|
||||
return home / "logs"
|
||||
|
||||
home = hermes_home or get_hermes_home()
|
||||
log_dir = home / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -244,9 +248,6 @@ def setup_logging(
|
||||
log_filter=_ComponentFilter(COMPONENT_PREFIXES["gateway"]),
|
||||
)
|
||||
|
||||
if _logging_initialized and not force:
|
||||
return log_dir
|
||||
|
||||
# Ensure root logger level is low enough for the handlers to fire.
|
||||
if root.level == logging.NOTSET or root.level > level:
|
||||
root.setLevel(level)
|
||||
|
||||
+161
-443
@@ -22,8 +22,6 @@ import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from agent.memory_manager import sanitize_context
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Any, Callable, Dict, List, Optional, TypeVar
|
||||
|
||||
@@ -33,7 +31,7 @@ T = TypeVar("T")
|
||||
|
||||
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
||||
|
||||
SCHEMA_VERSION = 10
|
||||
SCHEMA_VERSION = 9
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
@@ -121,32 +119,6 @@ CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
|
||||
END;
|
||||
"""
|
||||
|
||||
# Trigram FTS5 table for CJK substring search. The default unicode61
|
||||
# tokenizer splits CJK characters into individual tokens, breaking phrase
|
||||
# matching. The trigram tokenizer creates overlapping 3-byte sequences so
|
||||
# substring queries work natively for any script (CJK, Thai, etc.).
|
||||
FTS_TRIGRAM_SQL = """
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
|
||||
content,
|
||||
content=messages,
|
||||
content_rowid=id,
|
||||
tokenize='trigram'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_insert AFTER INSERT ON messages BEGIN
|
||||
INSERT INTO messages_fts_trigram(rowid, content) VALUES (new.id, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_delete AFTER DELETE ON messages BEGIN
|
||||
INSERT INTO messages_fts_trigram(messages_fts_trigram, rowid, content) VALUES('delete', old.id, old.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_update AFTER UPDATE ON messages BEGIN
|
||||
INSERT INTO messages_fts_trigram(messages_fts_trigram, rowid, content) VALUES('delete', old.id, old.content);
|
||||
INSERT INTO messages_fts_trigram(rowid, content) VALUES (new.id, new.content);
|
||||
END;
|
||||
"""
|
||||
|
||||
|
||||
class SessionDB:
|
||||
"""
|
||||
@@ -285,156 +257,118 @@ class SessionDB:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@staticmethod
|
||||
def _parse_schema_columns(schema_sql: str) -> Dict[str, Dict[str, str]]:
|
||||
"""Extract expected columns per table from SCHEMA_SQL.
|
||||
|
||||
Uses an in-memory SQLite database to parse the SQL — SQLite itself
|
||||
handles all syntax (DEFAULT expressions with commas, inline
|
||||
REFERENCES, CHECK constraints, etc.) so there are zero regex
|
||||
edge cases. The in-memory DB is opened, the schema DDL is
|
||||
executed, and PRAGMA table_info extracts the column metadata.
|
||||
|
||||
Adding a column to SCHEMA_SQL is all that's needed; the
|
||||
reconciliation loop picks it up automatically.
|
||||
"""
|
||||
ref = sqlite3.connect(":memory:")
|
||||
try:
|
||||
ref.executescript(schema_sql)
|
||||
table_columns: Dict[str, Dict[str, str]] = {}
|
||||
for (tbl,) in ref.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall():
|
||||
cols: Dict[str, str] = {}
|
||||
for row in ref.execute(
|
||||
f'PRAGMA table_info("{tbl}")'
|
||||
).fetchall():
|
||||
# row: (cid, name, type, notnull, dflt_value, pk)
|
||||
col_name = row[1]
|
||||
col_type = row[2] or ""
|
||||
notnull = row[3]
|
||||
default = row[4]
|
||||
pk = row[5]
|
||||
# Reconstruct the type expression for ALTER TABLE ADD COLUMN
|
||||
parts = [col_type] if col_type else []
|
||||
if notnull and not pk:
|
||||
parts.append("NOT NULL")
|
||||
if default is not None:
|
||||
parts.append(f"DEFAULT {default}")
|
||||
cols[col_name] = " ".join(parts)
|
||||
table_columns[tbl] = cols
|
||||
return table_columns
|
||||
finally:
|
||||
ref.close()
|
||||
|
||||
def _reconcile_columns(self, cursor: sqlite3.Cursor) -> None:
|
||||
"""Ensure live tables have every column declared in SCHEMA_SQL.
|
||||
|
||||
Follows the Beets/sqlite-utils pattern: the CREATE TABLE definition
|
||||
in SCHEMA_SQL is the single source of truth for the desired schema.
|
||||
On every startup this method diffs the live columns (via PRAGMA
|
||||
table_info) against the declared columns, and ADDs any that are
|
||||
missing.
|
||||
|
||||
This makes column additions a declarative operation — just add
|
||||
the column to SCHEMA_SQL and it appears on the next startup.
|
||||
Version-gated migration blocks are no longer needed for ADD COLUMN.
|
||||
"""
|
||||
expected = self._parse_schema_columns(SCHEMA_SQL)
|
||||
for table_name, declared_cols in expected.items():
|
||||
# Get current columns from the live table
|
||||
try:
|
||||
rows = cursor.execute(
|
||||
f'PRAGMA table_info("{table_name}")'
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
continue # Table doesn't exist yet (shouldn't happen after executescript)
|
||||
live_cols = set()
|
||||
for row in rows:
|
||||
# PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)
|
||||
name = row[1] if isinstance(row, (tuple, list)) else row["name"]
|
||||
live_cols.add(name)
|
||||
|
||||
for col_name, col_type in declared_cols.items():
|
||||
if col_name not in live_cols:
|
||||
safe_name = col_name.replace('"', '""')
|
||||
try:
|
||||
cursor.execute(
|
||||
f'ALTER TABLE "{table_name}" ADD COLUMN "{safe_name}" {col_type}'
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
# Expected: "duplicate column name" from a race or
|
||||
# re-run. Unexpected: "Cannot add a NOT NULL column
|
||||
# with default value NULL" from a schema mistake.
|
||||
# Log at DEBUG so it's visible in agent.log.
|
||||
logger.debug(
|
||||
"reconcile %s.%s: %s", table_name, col_name, exc,
|
||||
)
|
||||
|
||||
def _init_schema(self):
|
||||
"""Create tables and FTS if they don't exist, reconcile columns.
|
||||
|
||||
Schema management follows the declarative reconciliation pattern
|
||||
(Beets, sqlite-utils): SCHEMA_SQL is the single source of truth.
|
||||
On existing databases, _reconcile_columns() diffs live columns
|
||||
against SCHEMA_SQL and ADDs any missing ones. This eliminates
|
||||
the version-gated migration chain for column additions, making
|
||||
it impossible for reordered or inserted migrations to skip columns.
|
||||
|
||||
The schema_version table is retained for future data migrations
|
||||
(transforming existing rows) which cannot be handled declaratively.
|
||||
"""
|
||||
"""Create tables and FTS if they don't exist, run migrations."""
|
||||
cursor = self._conn.cursor()
|
||||
|
||||
cursor.executescript(SCHEMA_SQL)
|
||||
|
||||
# ── Declarative column reconciliation ──────────────────────────
|
||||
# Diff live tables against SCHEMA_SQL and ADD any missing columns.
|
||||
# This is idempotent and self-healing: even if a version-gated
|
||||
# migration was skipped (e.g. due to version renumbering), the
|
||||
# column gets created here.
|
||||
self._reconcile_columns(cursor)
|
||||
|
||||
# ── Schema version bookkeeping ─────────────────────────────────
|
||||
# Bump to current so future data migrations (if any) can gate on
|
||||
# version. No version-gated column additions remain.
|
||||
# Check schema version and run migrations
|
||||
cursor.execute("SELECT version FROM schema_version LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
cursor.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (?)",
|
||||
(SCHEMA_VERSION,),
|
||||
)
|
||||
cursor.execute("INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,))
|
||||
else:
|
||||
current_version = row["version"] if isinstance(row, sqlite3.Row) else row[0]
|
||||
# Data migrations that can't be expressed declaratively (row
|
||||
# backfills, index changes tied to a specific version step) stay
|
||||
# in a version-gated chain. Column additions are handled by
|
||||
# _reconcile_columns() above and no longer need entries here.
|
||||
if current_version < 10:
|
||||
# v10: trigram FTS5 table for CJK/substring search. The
|
||||
# virtual table + triggers are created unconditionally via
|
||||
# FTS_TRIGRAM_SQL below, but existing rows need a one-time
|
||||
# backfill into the FTS index.
|
||||
if current_version < 2:
|
||||
# v2: add finish_reason column to messages
|
||||
try:
|
||||
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
|
||||
_fts_trigram_exists = True
|
||||
cursor.execute("ALTER TABLE messages ADD COLUMN finish_reason TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
_fts_trigram_exists = False
|
||||
if not _fts_trigram_exists:
|
||||
cursor.executescript(FTS_TRIGRAM_SQL)
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 2")
|
||||
if current_version < 3:
|
||||
# v3: add title column to sessions
|
||||
try:
|
||||
cursor.execute("ALTER TABLE sessions ADD COLUMN title TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 3")
|
||||
if current_version < 4:
|
||||
# v4: add unique index on title (NULLs allowed, only non-NULL must be unique)
|
||||
try:
|
||||
cursor.execute(
|
||||
"INSERT INTO messages_fts_trigram(rowid, content) "
|
||||
"SELECT id, content FROM messages WHERE content IS NOT NULL"
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
||||
"ON sessions(title) WHERE title IS NOT NULL"
|
||||
)
|
||||
if current_version < SCHEMA_VERSION:
|
||||
cursor.execute(
|
||||
"UPDATE schema_version SET version = ?",
|
||||
(SCHEMA_VERSION,),
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Index already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 4")
|
||||
if current_version < 5:
|
||||
new_columns = [
|
||||
("cache_read_tokens", "INTEGER DEFAULT 0"),
|
||||
("cache_write_tokens", "INTEGER DEFAULT 0"),
|
||||
("reasoning_tokens", "INTEGER DEFAULT 0"),
|
||||
("billing_provider", "TEXT"),
|
||||
("billing_base_url", "TEXT"),
|
||||
("billing_mode", "TEXT"),
|
||||
("estimated_cost_usd", "REAL"),
|
||||
("actual_cost_usd", "REAL"),
|
||||
("cost_status", "TEXT"),
|
||||
("cost_source", "TEXT"),
|
||||
("pricing_version", "TEXT"),
|
||||
]
|
||||
for name, column_type in new_columns:
|
||||
try:
|
||||
# name and column_type come from the hardcoded tuple above,
|
||||
# not user input. Double-quote identifier escaping is applied
|
||||
# as defense-in-depth; SQLite DDL cannot be parameterized.
|
||||
safe_name = name.replace('"', '""')
|
||||
cursor.execute(f'ALTER TABLE sessions ADD COLUMN "{safe_name}" {column_type}')
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
cursor.execute("UPDATE schema_version SET version = 5")
|
||||
if current_version < 6:
|
||||
# v6: add reasoning columns to messages table — preserves assistant
|
||||
# reasoning text and structured reasoning_details across gateway
|
||||
# session turns. Without these, reasoning chains are lost on
|
||||
# session reload, breaking multi-turn reasoning continuity for
|
||||
# providers that replay reasoning (OpenRouter, OpenAI, Nous).
|
||||
for col_name, col_type in [
|
||||
("reasoning", "TEXT"),
|
||||
("reasoning_details", "TEXT"),
|
||||
("codex_reasoning_items", "TEXT"),
|
||||
]:
|
||||
try:
|
||||
safe = col_name.replace('"', '""')
|
||||
cursor.execute(
|
||||
f'ALTER TABLE messages ADD COLUMN "{safe}" {col_type}'
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 6")
|
||||
if current_version < 7:
|
||||
# v7: preserve provider-native reasoning_content separately from
|
||||
# normalized reasoning text. Kimi/Moonshot replay can require
|
||||
# this field on assistant tool-call messages when thinking is on.
|
||||
try:
|
||||
cursor.execute('ALTER TABLE messages ADD COLUMN "reasoning_content" TEXT')
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 7")
|
||||
if current_version < 8:
|
||||
# v8: add api_call_count column to sessions — tracks the number
|
||||
# of individual LLM API calls made within a session (as opposed
|
||||
# to the session count itself).
|
||||
try:
|
||||
cursor.execute(
|
||||
'ALTER TABLE sessions ADD COLUMN "api_call_count" INTEGER DEFAULT 0'
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 8")
|
||||
if current_version < 9:
|
||||
# v9: preserve replayable Codex assistant message ids/phases so
|
||||
# follow-up turns can rebuild Responses API message items instead
|
||||
# of flattening everything to plain assistant text.
|
||||
try:
|
||||
cursor.execute('ALTER TABLE messages ADD COLUMN "codex_message_items" TEXT')
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute("UPDATE schema_version SET version = 9")
|
||||
|
||||
# Unique title index — always ensure it exists
|
||||
# Unique title index — always ensure it exists (safe to run after migrations
|
||||
# since the title column is guaranteed to exist at this point)
|
||||
try:
|
||||
cursor.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
||||
@@ -449,12 +383,6 @@ class SessionDB:
|
||||
except sqlite3.OperationalError:
|
||||
cursor.executescript(FTS_SQL)
|
||||
|
||||
# Trigram FTS5 for CJK/substring search
|
||||
try:
|
||||
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
|
||||
except sqlite3.OperationalError:
|
||||
cursor.executescript(FTS_TRIGRAM_SQL)
|
||||
|
||||
self._conn.commit()
|
||||
|
||||
# =========================================================================
|
||||
@@ -904,18 +832,7 @@ class SessionDB:
|
||||
params = []
|
||||
|
||||
if not include_children:
|
||||
# Show root sessions and branch sessions (whose parent ended with
|
||||
# end_reason='branched' before the child was created), while still
|
||||
# hiding sub-agent runs and compression continuations (which also
|
||||
# carry a parent_session_id but were spawned while the parent was
|
||||
# still live — i.e., started_at < parent.ended_at).
|
||||
where_clauses.append(
|
||||
"(s.parent_session_id IS NULL"
|
||||
" OR EXISTS (SELECT 1 FROM sessions p"
|
||||
" WHERE p.id = s.parent_session_id"
|
||||
" AND p.end_reason = 'branched'"
|
||||
" AND s.started_at >= p.ended_at))"
|
||||
)
|
||||
where_clauses.append("s.parent_session_id IS NULL")
|
||||
|
||||
if source:
|
||||
where_clauses.append("s.source = ?")
|
||||
@@ -1204,33 +1121,23 @@ class SessionDB:
|
||||
current = child_id
|
||||
return session_id
|
||||
|
||||
def get_messages_as_conversation(
|
||||
self, session_id: str, include_ancestors: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Load messages in the OpenAI conversation format (role + content dicts).
|
||||
Used by the gateway to restore conversation history.
|
||||
"""
|
||||
session_ids = [session_id]
|
||||
if include_ancestors:
|
||||
session_ids = self._session_lineage_root_to_tip(session_id)
|
||||
|
||||
with self._lock:
|
||||
placeholders = ",".join("?" for _ in session_ids)
|
||||
rows = self._conn.execute(
|
||||
cursor = self._conn.execute(
|
||||
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
|
||||
"reasoning, reasoning_content, reasoning_details, codex_reasoning_items, "
|
||||
"codex_message_items "
|
||||
f"FROM messages WHERE session_id IN ({placeholders}) ORDER BY timestamp, id",
|
||||
tuple(session_ids),
|
||||
).fetchall()
|
||||
|
||||
"FROM messages WHERE session_id = ? ORDER BY timestamp, id",
|
||||
(session_id,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
messages = []
|
||||
for row in rows:
|
||||
content = row["content"]
|
||||
if row["role"] in {"user", "assistant"} and isinstance(content, str):
|
||||
content = sanitize_context(content).strip()
|
||||
msg = {"role": row["role"], "content": content}
|
||||
msg = {"role": row["role"], "content": row["content"]}
|
||||
if row["tool_call_id"]:
|
||||
msg["tool_call_id"] = row["tool_call_id"]
|
||||
if row["tool_name"]:
|
||||
@@ -1267,47 +1174,9 @@ class SessionDB:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Failed to deserialize codex_message_items, falling back to None")
|
||||
msg["codex_message_items"] = None
|
||||
if include_ancestors and self._is_duplicate_replayed_user_message(messages, msg):
|
||||
continue
|
||||
messages.append(msg)
|
||||
return messages
|
||||
|
||||
def _session_lineage_root_to_tip(self, session_id: str) -> List[str]:
|
||||
if not session_id:
|
||||
return [session_id]
|
||||
|
||||
chain = []
|
||||
current = session_id
|
||||
seen = set()
|
||||
with self._lock:
|
||||
for _ in range(100):
|
||||
if not current or current in seen:
|
||||
break
|
||||
seen.add(current)
|
||||
chain.append(current)
|
||||
row = self._conn.execute(
|
||||
"SELECT parent_session_id FROM sessions WHERE id = ?",
|
||||
(current,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
break
|
||||
current = row["parent_session_id"] if hasattr(row, "keys") else row[0]
|
||||
return list(reversed(chain)) or [session_id]
|
||||
|
||||
@staticmethod
|
||||
def _is_duplicate_replayed_user_message(messages: List[Dict[str, Any]], msg: Dict[str, Any]) -> bool:
|
||||
if msg.get("role") != "user":
|
||||
return False
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or not content:
|
||||
return False
|
||||
for prev in reversed(messages):
|
||||
if prev.get("role") == "user" and prev.get("content") == content:
|
||||
return True
|
||||
if prev.get("role") == "assistant" and (prev.get("content") or prev.get("tool_calls")):
|
||||
return False
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Search
|
||||
# =========================================================================
|
||||
@@ -1366,16 +1235,6 @@ class SessionDB:
|
||||
return sanitized.strip()
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _is_cjk_codepoint(cp: int) -> bool:
|
||||
return (0x4E00 <= cp <= 0x9FFF or # CJK Unified Ideographs
|
||||
0x3400 <= cp <= 0x4DBF or # CJK Extension A
|
||||
0x20000 <= cp <= 0x2A6DF or # CJK Extension B
|
||||
0x3000 <= cp <= 0x303F or # CJK Symbols
|
||||
0x3040 <= cp <= 0x309F or # Hiragana
|
||||
0x30A0 <= cp <= 0x30FF or # Katakana
|
||||
0xAC00 <= cp <= 0xD7AF) # Hangul Syllables
|
||||
|
||||
@staticmethod
|
||||
def _contains_cjk(text: str) -> bool:
|
||||
"""Check if text contains CJK (Chinese, Japanese, Korean) characters."""
|
||||
@@ -1391,11 +1250,6 @@ class SessionDB:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _count_cjk(cls, text: str) -> int:
|
||||
"""Count CJK characters in text."""
|
||||
return sum(1 for ch in text if cls._is_cjk_codepoint(ord(ch)))
|
||||
|
||||
def search_messages(
|
||||
self,
|
||||
query: str,
|
||||
@@ -1466,113 +1320,52 @@ class SessionDB:
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
|
||||
# CJK queries bypass the unicode61 FTS5 table. The default tokenizer
|
||||
# splits CJK characters into individual tokens, so "大别山项目" becomes
|
||||
# "大 AND 别 AND 山 AND 项 AND 目" — producing false positives and
|
||||
# missing exact phrase matches.
|
||||
#
|
||||
# For queries with 3+ CJK characters, we use the trigram FTS5 table
|
||||
# (indexed substring matching with ranking and snippets). For shorter
|
||||
# CJK queries (1-2 chars), trigram can't match (it needs ≥9 UTF-8
|
||||
# bytes = 3 CJK chars), so we fall back to LIKE.
|
||||
is_cjk = self._contains_cjk(query)
|
||||
if is_cjk:
|
||||
raw_query = query.strip('"').strip()
|
||||
cjk_count = self._count_cjk(raw_query)
|
||||
|
||||
if cjk_count >= 3:
|
||||
# Trigram FTS5 path — quote each non-operator token to handle
|
||||
# FTS5 special chars (%, *, etc.) while preserving boolean
|
||||
# operators (AND, OR, NOT) for multi-term queries.
|
||||
tokens = raw_query.split()
|
||||
parts = []
|
||||
for tok in tokens:
|
||||
if tok.upper() in ("AND", "OR", "NOT"):
|
||||
parts.append(tok)
|
||||
else:
|
||||
parts.append('"' + tok.replace('"', '""') + '"')
|
||||
trigram_query = " ".join(parts)
|
||||
tri_where = ["messages_fts_trigram MATCH ?"]
|
||||
tri_params: list = [trigram_query]
|
||||
if source_filter is not None:
|
||||
tri_where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
|
||||
tri_params.extend(source_filter)
|
||||
if exclude_sources is not None:
|
||||
tri_where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
|
||||
tri_params.extend(exclude_sources)
|
||||
if role_filter:
|
||||
tri_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
|
||||
tri_params.extend(role_filter)
|
||||
tri_sql = f"""
|
||||
SELECT
|
||||
m.id,
|
||||
m.session_id,
|
||||
m.role,
|
||||
snippet(messages_fts_trigram, 0, '>>>', '<<<', '...', 40) AS snippet,
|
||||
m.content,
|
||||
m.timestamp,
|
||||
m.tool_name,
|
||||
s.source,
|
||||
s.model,
|
||||
s.started_at AS session_started
|
||||
FROM messages_fts_trigram
|
||||
JOIN messages m ON m.id = messages_fts_trigram.rowid
|
||||
JOIN sessions s ON s.id = m.session_id
|
||||
WHERE {' AND '.join(tri_where)}
|
||||
ORDER BY rank
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
tri_params.extend([limit, offset])
|
||||
with self._lock:
|
||||
try:
|
||||
tri_cursor = self._conn.execute(tri_sql, tri_params)
|
||||
except sqlite3.OperationalError:
|
||||
matches = []
|
||||
else:
|
||||
matches = [dict(row) for row in tri_cursor.fetchall()]
|
||||
else:
|
||||
# Short CJK query (1-2 chars) — trigram needs ≥3 CJK chars.
|
||||
# Fall back to LIKE substring search.
|
||||
escaped = raw_query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
like_where = ["m.content LIKE ? ESCAPE '\\'"]
|
||||
like_params: list = [f"%{escaped}%"]
|
||||
if source_filter is not None:
|
||||
like_where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
|
||||
like_params.extend(source_filter)
|
||||
if exclude_sources is not None:
|
||||
like_where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
|
||||
like_params.extend(exclude_sources)
|
||||
if role_filter:
|
||||
like_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
|
||||
like_params.extend(role_filter)
|
||||
like_sql = f"""
|
||||
SELECT m.id, m.session_id, m.role,
|
||||
substr(m.content,
|
||||
max(1, instr(m.content, ?) - 40),
|
||||
120) AS snippet,
|
||||
m.content, m.timestamp, m.tool_name,
|
||||
s.source, s.model, s.started_at AS session_started
|
||||
FROM messages m
|
||||
JOIN sessions s ON s.id = m.session_id
|
||||
WHERE {' AND '.join(like_where)}
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
like_params.extend([limit, offset])
|
||||
# instr() parameter goes first in the bound list
|
||||
like_params = [raw_query] + like_params
|
||||
with self._lock:
|
||||
like_cursor = self._conn.execute(like_sql, like_params)
|
||||
matches = [dict(row) for row in like_cursor.fetchall()]
|
||||
else:
|
||||
with self._lock:
|
||||
try:
|
||||
cursor = self._conn.execute(sql, params)
|
||||
except sqlite3.OperationalError:
|
||||
# FTS5 query syntax error despite sanitization — return empty
|
||||
with self._lock:
|
||||
try:
|
||||
cursor = self._conn.execute(sql, params)
|
||||
except sqlite3.OperationalError:
|
||||
# FTS5 query syntax error despite sanitization — return empty
|
||||
# unless query contains CJK (fall back to LIKE below)
|
||||
if not self._contains_cjk(query):
|
||||
return []
|
||||
else:
|
||||
matches = [dict(row) for row in cursor.fetchall()]
|
||||
matches = []
|
||||
else:
|
||||
matches = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# LIKE fallback for CJK queries: FTS5 default tokenizer splits CJK
|
||||
# characters individually, causing multi-character queries to fail.
|
||||
if not matches and self._contains_cjk(query):
|
||||
raw_query = query.strip('"').strip()
|
||||
like_where = ["m.content LIKE ?"]
|
||||
like_params: list = [f"%{raw_query}%"]
|
||||
if source_filter is not None:
|
||||
like_where.append(f"s.source IN ({','.join('?' for _ in source_filter)})")
|
||||
like_params.extend(source_filter)
|
||||
if exclude_sources is not None:
|
||||
like_where.append(f"s.source NOT IN ({','.join('?' for _ in exclude_sources)})")
|
||||
like_params.extend(exclude_sources)
|
||||
if role_filter:
|
||||
like_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})")
|
||||
like_params.extend(role_filter)
|
||||
like_sql = f"""
|
||||
SELECT m.id, m.session_id, m.role,
|
||||
substr(m.content,
|
||||
max(1, instr(m.content, ?) - 40),
|
||||
120) AS snippet,
|
||||
m.content, m.timestamp, m.tool_name,
|
||||
s.source, s.model, s.started_at AS session_started
|
||||
FROM messages m
|
||||
JOIN sessions s ON s.id = m.session_id
|
||||
WHERE {' AND '.join(like_where)}
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
like_params.extend([limit, offset])
|
||||
# instr() parameter goes first in the bound list
|
||||
like_params = [raw_query] + like_params
|
||||
with self._lock:
|
||||
like_cursor = self._conn.execute(like_sql, like_params)
|
||||
matches = [dict(row) for row in like_cursor.fetchall()]
|
||||
|
||||
# Add surrounding context (1 message before + after each match).
|
||||
# Done outside the lock so we don't hold it across N sequential queries.
|
||||
@@ -1632,32 +1425,16 @@ class SessionDB:
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions, optionally filtered by source.
|
||||
|
||||
Returns rows enriched with a computed ``last_active`` column (latest
|
||||
message timestamp for the session, falling back to ``started_at``),
|
||||
ordered by most-recently-used first.
|
||||
"""
|
||||
select_with_last_active = (
|
||||
"SELECT s.*, COALESCE(m.last_active, s.started_at) AS last_active "
|
||||
"FROM sessions s "
|
||||
"LEFT JOIN ("
|
||||
"SELECT session_id, MAX(timestamp) AS last_active "
|
||||
"FROM messages GROUP BY session_id"
|
||||
") m ON m.session_id = s.id "
|
||||
)
|
||||
"""List sessions, optionally filtered by source."""
|
||||
with self._lock:
|
||||
if source:
|
||||
cursor = self._conn.execute(
|
||||
f"{select_with_last_active}"
|
||||
"WHERE s.source = ? "
|
||||
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
|
||||
"SELECT * FROM sessions WHERE source = ? ORDER BY started_at DESC LIMIT ? OFFSET ?",
|
||||
(source, limit, offset),
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute(
|
||||
f"{select_with_last_active}"
|
||||
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
|
||||
"SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
@@ -1724,45 +1501,12 @@ class SessionDB:
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
@staticmethod
|
||||
def _remove_session_files(sessions_dir: Optional[Path], session_id: str) -> None:
|
||||
"""Remove on-disk transcript files for a session.
|
||||
|
||||
Cleans up ``{session_id}.json``, ``{session_id}.jsonl``, and any
|
||||
``request_dump_{session_id}_*.json`` files left by the gateway.
|
||||
Silently skips files that don't exist and swallows OSError so a
|
||||
filesystem hiccup never blocks a DB operation.
|
||||
"""
|
||||
if sessions_dir is None:
|
||||
return
|
||||
for suffix in (".json", ".jsonl"):
|
||||
p = sessions_dir / f"{session_id}{suffix}"
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
# request_dump files use session_id as a prefix component
|
||||
try:
|
||||
for p in sessions_dir.glob(f"request_dump_{session_id}_*.json"):
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def delete_session(
|
||||
self,
|
||||
session_id: str,
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> bool:
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all its messages.
|
||||
|
||||
Child sessions are orphaned (parent_session_id set to NULL) rather
|
||||
than cascade-deleted, so they remain accessible independently.
|
||||
When *sessions_dir* is provided, also removes on-disk transcript
|
||||
files (``.json`` / ``.jsonl`` / ``request_dump_*``) for the deleted
|
||||
session. Returns True if the session was found and deleted.
|
||||
Returns True if the session was found and deleted.
|
||||
"""
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
@@ -1779,29 +1523,16 @@ class SessionDB:
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
||||
return True
|
||||
return self._execute_write(_do)
|
||||
|
||||
deleted = self._execute_write(_do)
|
||||
if deleted:
|
||||
self._remove_session_files(sessions_dir, session_id)
|
||||
return deleted
|
||||
|
||||
def prune_sessions(
|
||||
self,
|
||||
older_than_days: int = 90,
|
||||
source: str = None,
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> int:
|
||||
def prune_sessions(self, older_than_days: int = 90, source: str = None) -> int:
|
||||
"""Delete sessions older than N days. Returns count of deleted sessions.
|
||||
|
||||
Only prunes ended sessions (not active ones). Child sessions outside
|
||||
the prune window are orphaned (parent_session_id set to NULL) rather
|
||||
than cascade-deleted. When *sessions_dir* is provided, also removes
|
||||
on-disk transcript files (``.json`` / ``.jsonl`` /
|
||||
``request_dump_*``) for every pruned session, outside the DB
|
||||
transaction.
|
||||
than cascade-deleted.
|
||||
"""
|
||||
cutoff = time.time() - (older_than_days * 86400)
|
||||
removed_ids: list[str] = []
|
||||
|
||||
def _do(conn):
|
||||
if source:
|
||||
@@ -1831,14 +1562,9 @@ class SessionDB:
|
||||
for sid in session_ids:
|
||||
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
||||
removed_ids.append(sid)
|
||||
return len(session_ids)
|
||||
|
||||
count = self._execute_write(_do)
|
||||
# Clean up on-disk files outside the DB transaction
|
||||
for sid in removed_ids:
|
||||
self._remove_session_files(sessions_dir, sid)
|
||||
return count
|
||||
return self._execute_write(_do)
|
||||
|
||||
# ── Meta key/value (for scheduler bookkeeping) ──
|
||||
|
||||
@@ -1892,7 +1618,6 @@ class SessionDB:
|
||||
retention_days: int = 90,
|
||||
min_interval_hours: int = 24,
|
||||
vacuum: bool = True,
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Idempotent auto-maintenance: prune old sessions + optional VACUUM.
|
||||
|
||||
@@ -1900,10 +1625,6 @@ class SessionDB:
|
||||
within ``min_interval_hours`` no-op. Designed to be called once at
|
||||
startup from long-lived entrypoints (CLI, gateway, cron scheduler).
|
||||
|
||||
When *sessions_dir* is provided, on-disk transcript files
|
||||
(``.json`` / ``.jsonl`` / ``request_dump_*``) for pruned sessions
|
||||
are removed as part of the same sweep (issue #3015).
|
||||
|
||||
Never raises. On any failure, logs a warning and returns a dict
|
||||
with ``"error"`` set.
|
||||
|
||||
@@ -1927,10 +1648,7 @@ class SessionDB:
|
||||
except (TypeError, ValueError):
|
||||
pass # corrupt meta; treat as no prior run
|
||||
|
||||
pruned = self.prune_sessions(
|
||||
older_than_days=retention_days,
|
||||
sessions_dir=sessions_dir,
|
||||
)
|
||||
pruned = self.prune_sessions(older_than_days=retention_days)
|
||||
result["pruned"] = pruned
|
||||
|
||||
# Only VACUUM if we actually freed rows — VACUUM on a tight DB
|
||||
|
||||
+3
-30
@@ -7,7 +7,9 @@
|
||||
perSystem = { pkgs, system, lib, ... }:
|
||||
let
|
||||
hermes-agent = inputs.self.packages.${system}.default;
|
||||
hermesVenv = hermes-agent.hermesVenv;
|
||||
hermesVenv = pkgs.callPackage ./python.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
};
|
||||
|
||||
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
|
||||
|
||||
@@ -191,35 +193,6 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify extraPythonPackages PYTHONPATH injection
|
||||
extra-python-packages = let
|
||||
testPkg = pkgs.python312Packages.pyfiglet;
|
||||
hermesWithExtra = hermes-agent.override {
|
||||
extraPythonPackages = [ testPkg ];
|
||||
};
|
||||
in pkgs.runCommand "hermes-extra-python-packages" { } ''
|
||||
set -e
|
||||
echo "=== Checking extraPythonPackages PYTHONPATH injection ==="
|
||||
|
||||
grep -q "PYTHONPATH" ${hermesWithExtra}/bin/hermes || \
|
||||
(echo "FAIL: PYTHONPATH not in wrapper"; exit 1)
|
||||
echo "PASS: PYTHONPATH present in wrapper"
|
||||
|
||||
grep -q "${testPkg}" ${hermesWithExtra}/bin/hermes || \
|
||||
(echo "FAIL: test package path not in PYTHONPATH"; exit 1)
|
||||
echo "PASS: test package path found in wrapper"
|
||||
|
||||
echo "=== Checking base package has no PYTHONPATH ==="
|
||||
if grep -q "PYTHONPATH" ${hermes-agent}/bin/hermes; then
|
||||
echo "FAIL: base package should not have PYTHONPATH"; exit 1
|
||||
fi
|
||||
echo "PASS: base package clean"
|
||||
|
||||
echo "=== All extraPythonPackages checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# ── Config merge + round-trip test ────────────────────────────────
|
||||
# Tests the merge script (Nix activation behavior) across 7
|
||||
# scenarios, then verifies Python's load_config() reads correctly.
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
# nix/hermes-agent.nix — Overridable Hermes Agent package
|
||||
#
|
||||
# callPackage auto-wires nixpkgs args; flake inputs are passed explicitly.
|
||||
# Users override via: pkgs.hermes-agent.override { extraPythonPackages = [...]; }
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
makeWrapper,
|
||||
callPackage,
|
||||
python312,
|
||||
nodejs_22,
|
||||
ripgrep,
|
||||
git,
|
||||
openssh,
|
||||
ffmpeg,
|
||||
tirith,
|
||||
# Flake inputs — passed explicitly by packages.nix and overlays.nix
|
||||
uv2nix,
|
||||
pyproject-nix,
|
||||
pyproject-build-systems,
|
||||
npm-lockfile-fix,
|
||||
# Overridable parameters
|
||||
extraPythonPackages ? [ ],
|
||||
}:
|
||||
let
|
||||
hermesVenv = callPackage ./python.nix {
|
||||
inherit uv2nix pyproject-nix pyproject-build-systems;
|
||||
};
|
||||
|
||||
hermesNpmLib = callPackage ./lib.nix {
|
||||
inherit npm-lockfile-fix;
|
||||
};
|
||||
|
||||
hermesTui = callPackage ./tui.nix {
|
||||
inherit hermesNpmLib;
|
||||
};
|
||||
|
||||
hermesWeb = callPackage ./web.nix {
|
||||
inherit hermesNpmLib;
|
||||
};
|
||||
|
||||
bundledSkills = lib.cleanSourceWith {
|
||||
src = ../skills;
|
||||
filter = path: _type: !(lib.hasInfix "/index-cache/" path);
|
||||
};
|
||||
|
||||
runtimeDeps = [
|
||||
nodejs_22
|
||||
ripgrep
|
||||
git
|
||||
openssh
|
||||
ffmpeg
|
||||
tirith
|
||||
];
|
||||
|
||||
runtimePath = lib.makeBinPath runtimeDeps;
|
||||
|
||||
sitePackagesPath = python312.sitePackages;
|
||||
|
||||
# Walk propagatedBuildInputs to include transitive Python deps in PYTHONPATH.
|
||||
# Without this, a plugin listing e.g. requests as a dep would fail at runtime
|
||||
# if requests isn't already in the sealed uv2nix venv.
|
||||
allExtraPythonPackages = python312.pkgs.requiredPythonModules extraPythonPackages;
|
||||
|
||||
pythonPath = lib.makeSearchPath sitePackagesPath allExtraPythonPackages;
|
||||
|
||||
pyprojectHash = builtins.hashString "sha256" (builtins.readFile ../pyproject.toml);
|
||||
uvLockHash =
|
||||
if builtins.pathExists ../uv.lock then
|
||||
builtins.hashString "sha256" (builtins.readFile ../uv.lock)
|
||||
else
|
||||
"none";
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "hermes-agent";
|
||||
version = (builtins.fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
|
||||
dontUnpack = true;
|
||||
dontBuild = true;
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/share/hermes-agent $out/bin
|
||||
cp -r ${bundledSkills} $out/share/hermes-agent/skills
|
||||
cp -r ${hermesWeb} $out/share/hermes-agent/web_dist
|
||||
|
||||
mkdir -p $out/ui-tui
|
||||
cp -r ${hermesTui}/lib/hermes-tui/* $out/ui-tui/
|
||||
|
||||
${lib.concatMapStringsSep "\n"
|
||||
(name: ''
|
||||
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
|
||||
--suffix PATH : "${runtimePath}" \
|
||||
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \
|
||||
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
|
||||
--set HERMES_TUI_DIR $out/ui-tui \
|
||||
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
|
||||
--set HERMES_NODE ${nodejs_22}/bin/node \
|
||||
${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''}
|
||||
'')
|
||||
[
|
||||
"hermes"
|
||||
"hermes-agent"
|
||||
"hermes-acp"
|
||||
]
|
||||
}
|
||||
|
||||
${lib.optionalString (extraPythonPackages != [ ]) ''
|
||||
echo "=== Checking for plugin/core package collisions ==="
|
||||
${hermesVenv}/bin/python3 -c "
|
||||
import pathlib, sys, re
|
||||
|
||||
def canonical(name):
|
||||
return re.sub(r'[-_.]+', '-', name).lower()
|
||||
|
||||
# Collect core venv package names
|
||||
core = set()
|
||||
venv_sp = pathlib.Path('${hermesVenv}/${sitePackagesPath}')
|
||||
for di in venv_sp.glob('*.dist-info'):
|
||||
meta = di / 'METADATA'
|
||||
if meta.exists():
|
||||
for line in meta.read_text().splitlines():
|
||||
if line.startswith('Name:'):
|
||||
core.add(canonical(line.split(':', 1)[1].strip()))
|
||||
break
|
||||
|
||||
# Check each extra package for collisions
|
||||
extras_dirs = [${lib.concatMapStringsSep ", " (p: "'${toString p}'") allExtraPythonPackages}]
|
||||
for edir in extras_dirs:
|
||||
sp = pathlib.Path(edir) / '${sitePackagesPath}'
|
||||
if not sp.exists():
|
||||
continue
|
||||
for di in sp.glob('*.dist-info'):
|
||||
meta = di / 'METADATA'
|
||||
if not meta.exists():
|
||||
continue
|
||||
for line in meta.read_text().splitlines():
|
||||
if line.startswith('Name:'):
|
||||
pkg = canonical(line.split(':', 1)[1].strip())
|
||||
if pkg in core:
|
||||
print(f'ERROR: plugin package \"{pkg}\" collides with a package in hermes sealed venv', file=sys.stderr)
|
||||
print(f' from: {di}', file=sys.stderr)
|
||||
print(f' Remove this dependency from extraPythonPackages.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
break
|
||||
|
||||
print('No collisions found.')
|
||||
"
|
||||
echo "=== No collisions ==="
|
||||
''}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit hermesTui hermesWeb hermesNpmLib hermesVenv;
|
||||
|
||||
devShellHook = ''
|
||||
STAMP=".nix-stamps/hermes-agent"
|
||||
STAMP_VALUE="${pyprojectHash}:${uvLockHash}"
|
||||
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
|
||||
echo "hermes-agent: installing Python dependencies..."
|
||||
uv venv .venv --python ${python312}/bin/python3 2>/dev/null || true
|
||||
source .venv/bin/activate
|
||||
uv pip install -e ".[all]"
|
||||
[ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true
|
||||
[ -d tinker-atropos ] && uv pip install -e ./tinker-atropos 2>/dev/null || true
|
||||
mkdir -p .nix-stamps
|
||||
echo "$STAMP_VALUE" > "$STAMP"
|
||||
else
|
||||
source .venv/bin/activate
|
||||
export HERMES_PYTHON=${hermesVenv}/bin/python3
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
meta = with lib; {
|
||||
description = "AI agent with advanced tool-calling capabilities";
|
||||
homepage = "https://github.com/NousResearch/hermes-agent";
|
||||
mainProgram = "hermes";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
}
|
||||
+6
-81
@@ -28,8 +28,6 @@
|
||||
|
||||
let
|
||||
cfg = config.services.hermes-agent;
|
||||
effectivePackage = if cfg.extraPythonPackages == [ ] then cfg.package
|
||||
else cfg.package.override { inherit (cfg) extraPythonPackages; };
|
||||
hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
|
||||
# Deep-merge config type (from 0xrsydn/nix-hermes-agent)
|
||||
@@ -458,52 +456,6 @@
|
||||
description = "Extra packages available on PATH.";
|
||||
};
|
||||
|
||||
extraPlugins = mkOption {
|
||||
type = types.listOf types.package;
|
||||
default = [ ];
|
||||
description = ''
|
||||
Directory-based plugin packages to symlink into the hermes plugins
|
||||
directory. Each package should contain a plugin.yaml and __init__.py
|
||||
at its root. Hermes discovers these automatically on startup.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
[
|
||||
(pkgs.fetchFromGitHub {
|
||||
owner = "stephenschoettler";
|
||||
repo = "hermes-lcm";
|
||||
name = "hermes-lcm";
|
||||
rev = "v0.7.0";
|
||||
hash = "sha256-...";
|
||||
})
|
||||
]
|
||||
'';
|
||||
};
|
||||
|
||||
extraPythonPackages = mkOption {
|
||||
type = types.listOf types.package;
|
||||
default = [ ];
|
||||
description = ''
|
||||
Python packages to add to PYTHONPATH for entry-point plugin discovery.
|
||||
These are pip-packaged plugins that register via the
|
||||
hermes_agent.plugins entry-point group. Each package must be built
|
||||
with the same Python interpreter as hermes (python312).
|
||||
'';
|
||||
example = literalExpression ''
|
||||
[
|
||||
(pkgs.python312Packages.buildPythonPackage {
|
||||
pname = "rtk-hermes";
|
||||
version = "1.0.0";
|
||||
src = pkgs.fetchFromGitHub {
|
||||
owner = "ogallotti";
|
||||
repo = "rtk-hermes";
|
||||
rev = "main";
|
||||
hash = "sha256-...";
|
||||
};
|
||||
})
|
||||
]
|
||||
'';
|
||||
};
|
||||
|
||||
restart = mkOption {
|
||||
type = types.str;
|
||||
default = "always";
|
||||
@@ -618,7 +570,7 @@
|
||||
# so interactive shells share state (sessions, skills, cron) with the
|
||||
# gateway service instead of creating a separate ~/.hermes/.
|
||||
(lib.mkIf cfg.addToSystemPackages {
|
||||
environment.systemPackages = [ effectivePackage ];
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
environment.variables.HERMES_HOME = "${cfg.stateDir}/.hermes";
|
||||
})
|
||||
|
||||
@@ -629,16 +581,6 @@
|
||||
});
|
||||
})
|
||||
|
||||
# ── Assertions ─────────────────────────────────────────────────────
|
||||
{
|
||||
assertions = let
|
||||
names = map lib.getName cfg.extraPlugins;
|
||||
in [{
|
||||
assertion = (lib.length names) == (lib.length (lib.unique names));
|
||||
message = "services.hermes-agent.extraPlugins: duplicate plugin names detected: ${toString names}. If using fetchFromGitHub, set name = \"plugin-name\" to disambiguate.";
|
||||
}];
|
||||
}
|
||||
|
||||
# ── Warnings ──────────────────────────────────────────────────────
|
||||
(lib.mkIf (cfg.container.enable && !cfg.addToSystemPackages && cfg.container.hostUsers != []) {
|
||||
warnings = [
|
||||
@@ -660,7 +602,6 @@
|
||||
"d ${cfg.stateDir}/.hermes/sessions 2770 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.hermes/logs 2770 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.hermes/memories 2770 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.hermes/plugins 2770 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.workingDirectory} 2770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
@@ -682,7 +623,7 @@
|
||||
find ${cfg.stateDir}/.hermes -maxdepth 1 \
|
||||
\( -name "*.db" -o -name "*.db-wal" -o -name "*.db-shm" -o -name "SOUL.md" \) \
|
||||
-exec chmod g+rw {} + 2>/dev/null || true
|
||||
for _subdir in cron sessions logs memories plugins; do
|
||||
for _subdir in cron sessions logs memories; do
|
||||
mkdir -p "${cfg.stateDir}/.hermes/$_subdir"
|
||||
chown ${cfg.user}:${cfg.group} "${cfg.stateDir}/.hermes/$_subdir"
|
||||
chmod 2770 "${cfg.stateDir}/.hermes/$_subdir"
|
||||
@@ -791,22 +732,6 @@ HERMES_NIX_ENV_EOF
|
||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _value: ''
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0640 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name}
|
||||
'') cfg.documents)}
|
||||
|
||||
# ── Declarative plugins ─────────────────────────────────────────
|
||||
# Remove stale managed symlinks (plugins removed from config)
|
||||
find ${cfg.stateDir}/.hermes/plugins -maxdepth 1 -type l -name 'nix-managed-*' -delete 2>/dev/null || true
|
||||
|
||||
${lib.concatStringsSep "\n" (map (plugin:
|
||||
let
|
||||
name = lib.getName plugin;
|
||||
in ''
|
||||
if [ ! -f "${plugin}/plugin.yaml" ]; then
|
||||
echo "ERROR: extraPlugins entry '${plugin}' has no plugin.yaml" >&2
|
||||
exit 1
|
||||
fi
|
||||
ln -sfn ${plugin} ${cfg.stateDir}/.hermes/plugins/nix-managed-${name}
|
||||
chown -h ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/plugins/nix-managed-${name}
|
||||
'') cfg.extraPlugins)}
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -837,7 +762,7 @@ HERMES_NIX_ENV_EOF
|
||||
# reads them at Python startup — no systemd EnvironmentFile needed.
|
||||
|
||||
ExecStart = lib.concatStringsSep " " ([
|
||||
"${effectivePackage}/bin/hermes"
|
||||
"${cfg.package}/bin/hermes"
|
||||
"gateway"
|
||||
] ++ cfg.extraArgs);
|
||||
|
||||
@@ -860,7 +785,7 @@ HERMES_NIX_ENV_EOF
|
||||
};
|
||||
|
||||
path = [
|
||||
effectivePackage
|
||||
cfg.package
|
||||
pkgs.bash
|
||||
pkgs.coreutils
|
||||
pkgs.git
|
||||
@@ -885,11 +810,11 @@ HERMES_NIX_ENV_EOF
|
||||
|
||||
preStart = ''
|
||||
# Stable symlinks — container references these, not store paths directly
|
||||
ln -sfn ${effectivePackage} ${cfg.stateDir}/current-package
|
||||
ln -sfn ${cfg.package} ${cfg.stateDir}/current-package
|
||||
ln -sfn ${containerEntrypoint} ${cfg.stateDir}/current-entrypoint
|
||||
|
||||
# GC roots so nix-collect-garbage doesn't remove store paths in use
|
||||
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${effectivePackage} 2>/dev/null || true
|
||||
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${cfg.package} 2>/dev/null || true
|
||||
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root-entrypoint --indirect -r ${containerEntrypoint} 2>/dev/null || true
|
||||
|
||||
# Check if container needs (re)creation
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# nix/overlays.nix — Expose pkgs.hermes-agent for external NixOS configs
|
||||
{ inputs, ... }:
|
||||
{
|
||||
flake.overlays.default = final: _: {
|
||||
hermes-agent = final.callPackage ./hermes-agent.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
npm-lockfile-fix = inputs.npm-lockfile-fix.packages.${final.stdenv.hostPlatform.system}.default;
|
||||
};
|
||||
};
|
||||
}
|
||||
+107
-6
@@ -4,19 +4,120 @@
|
||||
perSystem =
|
||||
{ pkgs, inputs', ... }:
|
||||
let
|
||||
hermesAgent = pkgs.callPackage ./hermes-agent.nix {
|
||||
hermesVenv = pkgs.callPackage ./python.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
};
|
||||
|
||||
hermesNpmLib = pkgs.callPackage ./lib.nix {
|
||||
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
|
||||
};
|
||||
|
||||
hermesTui = pkgs.callPackage ./tui.nix {
|
||||
inherit hermesNpmLib;
|
||||
};
|
||||
|
||||
# Import bundled skills, excluding runtime caches
|
||||
bundledSkills = pkgs.lib.cleanSourceWith {
|
||||
src = ../skills;
|
||||
filter = path: _type: !(pkgs.lib.hasInfix "/index-cache/" path);
|
||||
};
|
||||
|
||||
hermesWeb = pkgs.callPackage ./web.nix {
|
||||
inherit hermesNpmLib;
|
||||
};
|
||||
|
||||
runtimeDeps = with pkgs; [
|
||||
nodejs_22
|
||||
ripgrep
|
||||
git
|
||||
openssh
|
||||
ffmpeg
|
||||
tirith
|
||||
];
|
||||
|
||||
runtimePath = pkgs.lib.makeBinPath runtimeDeps;
|
||||
|
||||
# Lockfile hashes for dev shell stamps
|
||||
pyprojectHash = builtins.hashString "sha256" (builtins.readFile ../pyproject.toml);
|
||||
uvLockHash =
|
||||
if builtins.pathExists ../uv.lock then
|
||||
builtins.hashString "sha256" (builtins.readFile ../uv.lock)
|
||||
else
|
||||
"none";
|
||||
in
|
||||
{
|
||||
packages = {
|
||||
default = hermesAgent;
|
||||
tui = hermesAgent.hermesTui;
|
||||
web = hermesAgent.hermesWeb;
|
||||
default = pkgs.stdenv.mkDerivation {
|
||||
pname = "hermes-agent";
|
||||
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
|
||||
fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles {
|
||||
packages = [ hermesAgent.hermesTui hermesAgent.hermesWeb ];
|
||||
dontUnpack = true;
|
||||
dontBuild = true;
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/share/hermes-agent $out/bin
|
||||
cp -r ${bundledSkills} $out/share/hermes-agent/skills
|
||||
cp -r ${hermesWeb} $out/share/hermes-agent/web_dist
|
||||
|
||||
# copy pre-built TUI (same layout as dev: ui-tui/dist/ + node_modules/)
|
||||
mkdir -p $out/ui-tui
|
||||
cp -r ${hermesTui}/lib/hermes-tui/* $out/ui-tui/
|
||||
|
||||
${pkgs.lib.concatMapStringsSep "\n"
|
||||
(name: ''
|
||||
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
|
||||
--suffix PATH : "${runtimePath}" \
|
||||
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \
|
||||
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
|
||||
--set HERMES_TUI_DIR $out/ui-tui \
|
||||
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
|
||||
--set HERMES_NODE ${pkgs.nodejs_22}/bin/node
|
||||
'')
|
||||
[
|
||||
"hermes"
|
||||
"hermes-agent"
|
||||
"hermes-acp"
|
||||
]
|
||||
}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru.devShellHook = ''
|
||||
STAMP=".nix-stamps/hermes-agent"
|
||||
STAMP_VALUE="${pyprojectHash}:${uvLockHash}"
|
||||
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
|
||||
echo "hermes-agent: installing Python dependencies..."
|
||||
uv venv .venv --python ${pkgs.python312}/bin/python3 2>/dev/null || true
|
||||
source .venv/bin/activate
|
||||
uv pip install -e ".[all]"
|
||||
[ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true
|
||||
[ -d tinker-atropos ] && uv pip install -e ./tinker-atropos 2>/dev/null || true
|
||||
mkdir -p .nix-stamps
|
||||
echo "$STAMP_VALUE" > "$STAMP"
|
||||
else
|
||||
source .venv/bin/activate
|
||||
export HERMES_PYTHON=${hermesVenv}/bin/python3
|
||||
fi
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "AI agent with advanced tool-calling capabilities";
|
||||
homepage = "https://github.com/NousResearch/hermes-agent";
|
||||
mainProgram = "hermes";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
};
|
||||
|
||||
tui = hermesTui;
|
||||
web = hermesWeb;
|
||||
|
||||
fix-lockfiles = hermesNpmLib.mkFixLockfiles {
|
||||
packages = [ hermesTui hermesWeb ];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
+1
-2
@@ -7,7 +7,6 @@
|
||||
pyproject-nix,
|
||||
pyproject-build-systems,
|
||||
stdenv,
|
||||
dependency-groups ? [ "all" ],
|
||||
}:
|
||||
let
|
||||
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./..; };
|
||||
@@ -97,5 +96,5 @@ let
|
||||
]);
|
||||
in
|
||||
pythonSet.mkVirtualEnv "hermes-agent-env" {
|
||||
hermes-agent = dependency-groups;
|
||||
hermes-agent = [ "all" ];
|
||||
}
|
||||
|
||||
+1
-2
@@ -4,7 +4,7 @@ let
|
||||
src = ../ui-tui;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-Chz+NW9NXqboXHOa6PKwf5bhAkkcFtKNhvKWwg2XSPc=";
|
||||
hash = "sha256-RU4qSHgJPMyfRSEJDzkG4+MReDZDc6QbTD2wisa5QE0=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; };
|
||||
@@ -17,7 +17,6 @@ pkgs.buildNpmPackage (npm // {
|
||||
inherit src npmDeps version;
|
||||
|
||||
doCheck = false;
|
||||
npmFlags = [ "--legacy-peer-deps" ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
@@ -1,875 +0,0 @@
|
||||
"""langfuse — Hermes plugin for Langfuse observability.
|
||||
|
||||
Traces Hermes conversations, LLM calls, and tool usage to Langfuse.
|
||||
Enable via ``hermes tools`` or by setting HERMES_LANGFUSE_ENABLED=true
|
||||
and the required credentials in ~/.hermes/.env.
|
||||
|
||||
Required env vars (set via ``hermes tools`` or ~/.hermes/.env):
|
||||
HERMES_LANGFUSE_ENABLED - set to "true" to activate tracing
|
||||
HERMES_LANGFUSE_PUBLIC_KEY - Langfuse project public key (pk-lf-...)
|
||||
HERMES_LANGFUSE_SECRET_KEY - Langfuse project secret key (sk-lf-...)
|
||||
HERMES_LANGFUSE_BASE_URL - Langfuse server URL (default: https://cloud.langfuse.com)
|
||||
|
||||
Optional env vars:
|
||||
HERMES_LANGFUSE_ENV - environment tag (e.g. "production", "local")
|
||||
HERMES_LANGFUSE_RELEASE - release/version tag
|
||||
HERMES_LANGFUSE_SAMPLE_RATE - sampling rate 0.0–1.0 (default: 1.0)
|
||||
HERMES_LANGFUSE_MAX_CHARS - max chars per field (default: 12000)
|
||||
HERMES_LANGFUSE_DEBUG - set to "true" for verbose logging
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from langfuse import Langfuse, propagate_attributes
|
||||
except Exception: # pragma: no cover - fail-open when optional dep is missing
|
||||
Langfuse = None
|
||||
propagate_attributes = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TraceState:
|
||||
trace_id: str
|
||||
root_ctx: Any
|
||||
root_span: Any
|
||||
generations: Dict[str, Any] = field(default_factory=dict)
|
||||
tools: Dict[str, Any] = field(default_factory=dict)
|
||||
turn_tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
last_updated_at: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
_STATE_LOCK = threading.Lock()
|
||||
_TRACE_STATE: Dict[str, TraceState] = {}
|
||||
_LANGFUSE_CLIENT = None
|
||||
_READ_FILE_LINE_RE = re.compile(r"^\s*(\d+)\|(.*)$")
|
||||
_READ_FILE_HEAD_LINES = 25
|
||||
_READ_FILE_TAIL_LINES = 15
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return os.environ.get(name, default).strip()
|
||||
|
||||
|
||||
def _env_bool(*names: str) -> bool:
|
||||
for name in names:
|
||||
value = _env(name).lower()
|
||||
if value:
|
||||
return value in {"1", "true", "yes", "on"}
|
||||
return False
|
||||
|
||||
|
||||
def _debug_enabled() -> bool:
|
||||
return _env_bool("HERMES_LANGFUSE_DEBUG")
|
||||
|
||||
|
||||
def _debug(message: str) -> None:
|
||||
if _debug_enabled():
|
||||
logger.info("Langfuse tracing: %s", message)
|
||||
|
||||
|
||||
def _is_enabled() -> bool:
|
||||
if Langfuse is None:
|
||||
return False
|
||||
# Primary activation path: config.yaml plugins.langfuse.enabled
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
_cfg = load_config()
|
||||
_plugin_cfg = _cfg.get("plugins", {})
|
||||
if isinstance(_plugin_cfg, dict):
|
||||
_lt_cfg = _plugin_cfg.get("langfuse", {})
|
||||
if isinstance(_lt_cfg, dict) and "enabled" in _lt_cfg:
|
||||
if not _lt_cfg["enabled"]:
|
||||
return False
|
||||
# Explicit enabled=true in config — skip env-var check below
|
||||
public_key = _env("HERMES_LANGFUSE_PUBLIC_KEY") or _env("LANGFUSE_PUBLIC_KEY")
|
||||
secret_key = _env("HERMES_LANGFUSE_SECRET_KEY") or _env("LANGFUSE_SECRET_KEY")
|
||||
return bool(public_key and secret_key)
|
||||
except Exception:
|
||||
pass
|
||||
# Backward-compat path: HERMES_LANGFUSE_ENABLED env var (legacy .env installs)
|
||||
if not _env_bool("HERMES_LANGFUSE_ENABLED"):
|
||||
return False
|
||||
public_key = _env("HERMES_LANGFUSE_PUBLIC_KEY") or _env("LANGFUSE_PUBLIC_KEY")
|
||||
secret_key = _env("HERMES_LANGFUSE_SECRET_KEY") or _env("LANGFUSE_SECRET_KEY")
|
||||
return bool(public_key and secret_key)
|
||||
|
||||
|
||||
def _get_langfuse() -> Optional[Langfuse]:
|
||||
global _LANGFUSE_CLIENT
|
||||
if not _is_enabled():
|
||||
return None
|
||||
if _LANGFUSE_CLIENT is not None:
|
||||
return _LANGFUSE_CLIENT
|
||||
|
||||
public_key = _env("HERMES_LANGFUSE_PUBLIC_KEY") or _env("LANGFUSE_PUBLIC_KEY")
|
||||
secret_key = _env("HERMES_LANGFUSE_SECRET_KEY") or _env("LANGFUSE_SECRET_KEY")
|
||||
base_url = _env("HERMES_LANGFUSE_BASE_URL") or _env("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com"
|
||||
environment = _env("HERMES_LANGFUSE_ENV") or _env("LANGFUSE_ENV")
|
||||
release = _env("HERMES_LANGFUSE_RELEASE") or _env("LANGFUSE_RELEASE")
|
||||
sample_rate = _env("HERMES_LANGFUSE_SAMPLE_RATE")
|
||||
|
||||
kwargs: Dict[str, Any] = {
|
||||
"public_key": public_key,
|
||||
"secret_key": secret_key,
|
||||
"base_url": base_url,
|
||||
}
|
||||
if environment:
|
||||
kwargs["environment"] = environment
|
||||
if release:
|
||||
kwargs["release"] = release
|
||||
if sample_rate:
|
||||
try:
|
||||
kwargs["sample_rate"] = float(sample_rate)
|
||||
except ValueError:
|
||||
logger.warning("Invalid HERMES_LANGFUSE_SAMPLE_RATE=%r", sample_rate)
|
||||
|
||||
try:
|
||||
_LANGFUSE_CLIENT = Langfuse(**kwargs)
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
logger.warning("Could not initialize Langfuse client: %s", exc)
|
||||
return None
|
||||
|
||||
return _LANGFUSE_CLIENT
|
||||
|
||||
|
||||
def _trace_key(task_id: str, session_id: str) -> str:
|
||||
if task_id:
|
||||
return task_id
|
||||
if session_id:
|
||||
return f"session:{session_id}"
|
||||
return f"thread:{threading.get_ident()}"
|
||||
|
||||
|
||||
def _truncate_text(value: str, max_chars: int) -> str:
|
||||
if len(value) <= max_chars:
|
||||
return value
|
||||
return value[:max_chars] + f"... [truncated {len(value) - max_chars} chars]"
|
||||
|
||||
|
||||
def _maybe_parse_json_string(value: str) -> Any:
|
||||
stripped = value.strip()
|
||||
if len(stripped) < 2 or stripped[0] not in "{[" or stripped[-1] not in "}]":
|
||||
if len(stripped) < 2 or stripped[0] not in "{[":
|
||||
return value
|
||||
try:
|
||||
parsed, idx = json.JSONDecoder().raw_decode(stripped)
|
||||
except Exception:
|
||||
return value
|
||||
if not isinstance(parsed, (dict, list)):
|
||||
return value
|
||||
|
||||
trailing = stripped[idx:].strip()
|
||||
if not trailing:
|
||||
return parsed
|
||||
|
||||
hint_key = "_hint" if trailing.startswith("[Hint:") else "_trailing_text"
|
||||
if isinstance(parsed, dict):
|
||||
merged = dict(parsed)
|
||||
key = hint_key if hint_key not in merged else "_trailing_text"
|
||||
merged[key] = trailing
|
||||
return merged
|
||||
|
||||
return {"data": parsed, hint_key: trailing}
|
||||
|
||||
|
||||
def _looks_like_read_file_payload(value: Any) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
content = value.get("content")
|
||||
return (
|
||||
isinstance(content, str)
|
||||
and "total_lines" in value
|
||||
and "file_size" in value
|
||||
and "is_binary" in value
|
||||
and "is_image" in value
|
||||
and not value.get("error")
|
||||
)
|
||||
|
||||
|
||||
def _parse_read_file_lines(content: str) -> list[dict[str, Any]]:
|
||||
if not isinstance(content, str) or not content:
|
||||
return []
|
||||
|
||||
lines = []
|
||||
for raw_line in content.splitlines():
|
||||
match = _READ_FILE_LINE_RE.match(raw_line)
|
||||
if not match:
|
||||
return []
|
||||
lines.append({
|
||||
"line": int(match.group(1)),
|
||||
"text": match.group(2),
|
||||
})
|
||||
return lines
|
||||
|
||||
|
||||
def _build_read_file_preview(lines: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
if len(lines) <= (_READ_FILE_HEAD_LINES + _READ_FILE_TAIL_LINES):
|
||||
return {"lines": lines}
|
||||
|
||||
return {
|
||||
"head": lines[:_READ_FILE_HEAD_LINES],
|
||||
"tail": lines[-_READ_FILE_TAIL_LINES:],
|
||||
"omitted_line_count": len(lines) - _READ_FILE_HEAD_LINES - _READ_FILE_TAIL_LINES,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_read_file_payload(value: dict[str, Any], *, args: Any = None) -> dict[str, Any]:
|
||||
normalized: dict[str, Any] = {}
|
||||
if isinstance(args, dict):
|
||||
path = args.get("path")
|
||||
offset = args.get("offset")
|
||||
limit = args.get("limit")
|
||||
if isinstance(path, str) and path:
|
||||
normalized["path"] = path
|
||||
if isinstance(offset, int):
|
||||
normalized["offset"] = offset
|
||||
if isinstance(limit, int):
|
||||
normalized["limit"] = limit
|
||||
|
||||
lines = _parse_read_file_lines(value.get("content", ""))
|
||||
if lines:
|
||||
normalized["returned_lines"] = {
|
||||
"start": lines[0]["line"],
|
||||
"end": lines[-1]["line"],
|
||||
"count": len(lines),
|
||||
}
|
||||
normalized["content_preview"] = _build_read_file_preview(lines)
|
||||
elif value.get("content"):
|
||||
normalized["content_preview"] = {
|
||||
"text": value.get("content", ""),
|
||||
}
|
||||
|
||||
for key in (
|
||||
"total_lines",
|
||||
"file_size",
|
||||
"truncated",
|
||||
"is_binary",
|
||||
"is_image",
|
||||
"hint",
|
||||
"_warning",
|
||||
"mime_type",
|
||||
"dimensions",
|
||||
"similar_files",
|
||||
"error",
|
||||
):
|
||||
if key in value:
|
||||
normalized[key] = value[key]
|
||||
|
||||
base64_content = value.get("base64_content")
|
||||
if isinstance(base64_content, str) and base64_content:
|
||||
normalized["base64_content"] = {
|
||||
"omitted": True,
|
||||
"length": len(base64_content),
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_payload(value: Any, *, tool_name: str = "", args: Any = None) -> Any:
|
||||
if _looks_like_read_file_payload(value):
|
||||
return _normalize_read_file_payload(
|
||||
value,
|
||||
args=args if tool_name == "read_file" else None,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _safe_value(value: Any, *, max_chars: Optional[int] = None, depth: int = 0,
|
||||
parse_json_strings: bool = False) -> Any:
|
||||
max_chars = max_chars if max_chars is not None else int(_env("HERMES_LANGFUSE_MAX_CHARS", "12000") or "12000")
|
||||
if depth > 4:
|
||||
return "<max-depth>"
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
return {"type": "bytes", "len": len(value)}
|
||||
if isinstance(value, str):
|
||||
if parse_json_strings:
|
||||
parsed = _maybe_parse_json_string(value)
|
||||
if parsed is not value:
|
||||
return _safe_value(parsed, max_chars=max_chars, depth=depth, parse_json_strings=True)
|
||||
return _truncate_text(value, max_chars)
|
||||
if isinstance(value, dict):
|
||||
normalized = _normalize_payload(value)
|
||||
if normalized is not value:
|
||||
return _safe_value(normalized, max_chars=max_chars, depth=depth, parse_json_strings=parse_json_strings)
|
||||
return {
|
||||
str(k): _safe_value(v, max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
|
||||
for k, v in list(value.items())[:50]
|
||||
}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [
|
||||
_safe_value(v, max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
|
||||
for v in list(value)[:50]
|
||||
]
|
||||
if hasattr(value, "__dict__"):
|
||||
return _safe_value(vars(value), max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
|
||||
return _truncate_text(repr(value), max_chars)
|
||||
|
||||
|
||||
def _extract_last_user_message(messages: Any) -> Any:
|
||||
if not isinstance(messages, list):
|
||||
return None
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, dict) and message.get("role") == "user":
|
||||
return {
|
||||
"role": "user",
|
||||
"content": _safe_value(message.get("content")),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_messages(messages: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(messages, list):
|
||||
return []
|
||||
serialized = []
|
||||
for message in messages[-12:]:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
role = message.get("role")
|
||||
item = {
|
||||
"role": role,
|
||||
"content": _safe_value(
|
||||
message.get("content"),
|
||||
parse_json_strings=(role == "tool"),
|
||||
),
|
||||
}
|
||||
if role == "tool" and message.get("tool_call_id"):
|
||||
item["tool_call_id"] = message.get("tool_call_id")
|
||||
if message.get("tool_calls"):
|
||||
item["tool_calls"] = _safe_value(message.get("tool_calls"), parse_json_strings=True)
|
||||
serialized.append(item)
|
||||
return serialized
|
||||
|
||||
|
||||
def _serialize_tool_calls(tool_calls: Any) -> list[dict[str, Any]]:
|
||||
if not tool_calls:
|
||||
return []
|
||||
serialized = []
|
||||
for tool_call in tool_calls:
|
||||
fn = getattr(tool_call, "function", None)
|
||||
name = getattr(fn, "name", None) if fn else None
|
||||
arguments = getattr(fn, "arguments", None) if fn else None
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = json.loads(arguments)
|
||||
except Exception:
|
||||
pass
|
||||
serialized.append({
|
||||
"id": getattr(tool_call, "id", None),
|
||||
"name": name,
|
||||
"arguments": _safe_value(arguments, parse_json_strings=True),
|
||||
})
|
||||
return serialized
|
||||
|
||||
|
||||
def _serialize_assistant_message(message: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"content": _safe_value(getattr(message, "content", None)),
|
||||
"reasoning": _safe_value(getattr(message, "reasoning", None)),
|
||||
"tool_calls": _serialize_tool_calls(getattr(message, "tool_calls", None)),
|
||||
}
|
||||
|
||||
|
||||
def _usage_and_cost(response: Any, *, provider: str, api_mode: str, model: str, base_url: str) -> tuple[dict[str, int], dict[str, float]]:
|
||||
usage_details: Dict[str, int] = {}
|
||||
cost_details: Dict[str, float] = {}
|
||||
raw_usage = getattr(response, "usage", None)
|
||||
if not raw_usage:
|
||||
return usage_details, cost_details
|
||||
|
||||
try:
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
|
||||
canonical = normalize_usage(raw_usage, provider=provider, api_mode=api_mode)
|
||||
# Langfuse usage_details keys follow a naming convention:
|
||||
# - Dashboard sums all keys containing "input" as input total
|
||||
# - Dashboard sums all keys containing "output" as output total
|
||||
# - If no "total" key, Langfuse derives it from all usage types
|
||||
# Use Anthropic-style key names so cache tokens roll into the
|
||||
# dashboard input total automatically.
|
||||
# Ref: https://langfuse.com/docs/model-usage-and-cost
|
||||
usage_details = {
|
||||
"input": canonical.input_tokens,
|
||||
"output": canonical.output_tokens,
|
||||
}
|
||||
if canonical.cache_read_tokens:
|
||||
usage_details["cache_read_input_tokens"] = canonical.cache_read_tokens
|
||||
if canonical.cache_write_tokens:
|
||||
usage_details["cache_creation_input_tokens"] = canonical.cache_write_tokens
|
||||
if canonical.reasoning_tokens:
|
||||
usage_details["reasoning_tokens"] = canonical.reasoning_tokens
|
||||
cost = estimate_usage_cost(
|
||||
model,
|
||||
canonical,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
api_key="",
|
||||
)
|
||||
if cost.amount_usd is not None:
|
||||
# Langfuse cost_details keys must match usage_details keys.
|
||||
# Provide per-type breakdown so dashboard can show cost by type.
|
||||
try:
|
||||
from agent.usage_pricing import get_pricing_entry
|
||||
from decimal import Decimal
|
||||
_ONE_M = Decimal("1000000")
|
||||
entry = get_pricing_entry(model, provider=provider, base_url=base_url)
|
||||
if entry:
|
||||
if entry.input_cost_per_million is not None and canonical.input_tokens:
|
||||
cost_details["input"] = float(Decimal(canonical.input_tokens) * entry.input_cost_per_million / _ONE_M)
|
||||
if entry.output_cost_per_million is not None and canonical.output_tokens:
|
||||
cost_details["output"] = float(Decimal(canonical.output_tokens) * entry.output_cost_per_million / _ONE_M)
|
||||
if entry.cache_read_cost_per_million is not None and canonical.cache_read_tokens:
|
||||
cost_details["cache_read_input_tokens"] = float(Decimal(canonical.cache_read_tokens) * entry.cache_read_cost_per_million / _ONE_M)
|
||||
if entry.cache_write_cost_per_million is not None and canonical.cache_write_tokens:
|
||||
cost_details["cache_creation_input_tokens"] = float(Decimal(canonical.cache_write_tokens) * entry.cache_write_cost_per_million / _ONE_M)
|
||||
else:
|
||||
cost_details["total"] = float(cost.amount_usd)
|
||||
except Exception:
|
||||
cost_details["total"] = float(cost.amount_usd)
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
_debug(f"usage normalization failed: {exc}")
|
||||
|
||||
return usage_details, cost_details
|
||||
|
||||
|
||||
def _start_root_trace(task_key: str, *, task_id: str, session_id: str, platform: str, provider: str, model: str,
|
||||
api_mode: str, messages: Any, client: Langfuse) -> TraceState:
|
||||
trace_id = client.create_trace_id(seed=f"{session_id or 'sessionless'}::{task_id or task_key}")
|
||||
trace_input = _extract_last_user_message(messages)
|
||||
metadata = {
|
||||
"source": "hermes",
|
||||
"task_id": task_id,
|
||||
"platform": platform,
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"api_mode": api_mode,
|
||||
}
|
||||
|
||||
# session_id must be passed in trace_context for Langfuse session grouping.
|
||||
trace_ctx: Dict[str, Any] = {"trace_id": trace_id}
|
||||
if session_id:
|
||||
trace_ctx["session_id"] = session_id
|
||||
|
||||
if propagate_attributes is not None:
|
||||
try:
|
||||
with propagate_attributes(
|
||||
session_id=session_id or task_key,
|
||||
trace_name="Hermes turn",
|
||||
tags=["hermes", "langfuse"],
|
||||
):
|
||||
root_ctx = client.start_as_current_observation(
|
||||
trace_context=trace_ctx,
|
||||
name="Hermes turn",
|
||||
as_type="chain",
|
||||
input=trace_input,
|
||||
metadata=metadata,
|
||||
end_on_exit=False,
|
||||
)
|
||||
root_span = root_ctx.__enter__()
|
||||
except Exception:
|
||||
root_ctx = client.start_as_current_observation(
|
||||
trace_context=trace_ctx,
|
||||
name="Hermes turn",
|
||||
as_type="chain",
|
||||
input=trace_input,
|
||||
metadata=metadata,
|
||||
end_on_exit=False,
|
||||
)
|
||||
root_span = root_ctx.__enter__()
|
||||
else:
|
||||
root_ctx = client.start_as_current_observation(
|
||||
trace_context=trace_ctx,
|
||||
name="Hermes turn",
|
||||
as_type="chain",
|
||||
input=trace_input,
|
||||
metadata=metadata,
|
||||
end_on_exit=False,
|
||||
)
|
||||
root_span = root_ctx.__enter__()
|
||||
|
||||
try:
|
||||
root_span.set_trace_io(input=trace_input)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_debug(f"started trace {trace_id} for {task_key}")
|
||||
return TraceState(trace_id=trace_id, root_ctx=root_ctx, root_span=root_span)
|
||||
|
||||
|
||||
def _start_child_observation(state: TraceState, *, client: Langfuse, name: str, as_type: str,
|
||||
input_value: Any, metadata: Optional[dict] = None,
|
||||
model: Optional[str] = None, model_parameters: Optional[dict] = None) -> Any:
|
||||
return state.root_span.start_observation(
|
||||
name=name,
|
||||
as_type=as_type,
|
||||
input=input_value,
|
||||
metadata=metadata or {},
|
||||
model=model,
|
||||
model_parameters=model_parameters,
|
||||
)
|
||||
|
||||
|
||||
def _end_observation(observation: Any, *, output: Any = None, metadata: Optional[dict] = None,
|
||||
usage_details: Optional[dict] = None, cost_details: Optional[dict] = None) -> None:
|
||||
if observation is None:
|
||||
return
|
||||
try:
|
||||
update_kwargs: Dict[str, Any] = {}
|
||||
if output is not None:
|
||||
update_kwargs["output"] = output
|
||||
if metadata:
|
||||
update_kwargs["metadata"] = metadata
|
||||
if usage_details:
|
||||
update_kwargs["usage_details"] = usage_details
|
||||
if cost_details:
|
||||
update_kwargs["cost_details"] = cost_details
|
||||
if update_kwargs:
|
||||
observation.update(**update_kwargs)
|
||||
observation.end()
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
_debug(f"end observation failed: {exc}")
|
||||
|
||||
|
||||
def _merge_trace_output(output: Any, state: TraceState) -> Any:
|
||||
if not state.turn_tool_calls:
|
||||
return output
|
||||
|
||||
merged = dict(output) if isinstance(output, dict) else {"content": output}
|
||||
merged["tool_calls"] = list(state.turn_tool_calls)
|
||||
return merged
|
||||
|
||||
|
||||
def _finish_trace(task_key: str, *, output: Any = None) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.pop(task_key, None)
|
||||
if state is None:
|
||||
return
|
||||
|
||||
try:
|
||||
for observation in state.generations.values():
|
||||
_end_observation(observation)
|
||||
for observation in state.tools.values():
|
||||
_end_observation(observation)
|
||||
final_output = _merge_trace_output(output, state)
|
||||
if final_output is not None:
|
||||
state.root_span.set_trace_io(output=final_output)
|
||||
state.root_span.update(output=final_output)
|
||||
state.root_span.end()
|
||||
except Exception as exc: # pragma: no cover - fail-open
|
||||
_debug(f"finish trace failed: {exc}")
|
||||
finally:
|
||||
try:
|
||||
client.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _assistant_has_tool_calls(message: Any) -> bool:
|
||||
return bool(getattr(message, "tool_calls", None))
|
||||
|
||||
|
||||
def _request_key(api_call_count: Any) -> str:
|
||||
return str(api_call_count or 0)
|
||||
|
||||
|
||||
def on_pre_llm_call(*, task_id: str = "", session_id: str = "", platform: str = "", model: str = "",
|
||||
provider: str = "", base_url: str = "", api_mode: str = "",
|
||||
api_call_count: int = 0, messages: Any = None, turn_type: str = "user",
|
||||
conversation_history: Any = None, user_message: Any = None, **_: Any) -> None:
|
||||
# Older Hermes branches used pre_llm_call for request-scoped tracing and
|
||||
# passed the actual API messages. Current Hermes also has a turn-scoped
|
||||
# pre_llm_call used for context injection; tracing that hook creates an
|
||||
# extra orphan/root trace before the real request trace. Only trace the
|
||||
# legacy request-shaped call here.
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
# messages is a list only for legacy Hermes branches that fired
|
||||
# pre_llm_call with API messages directly. Current Hermes fires
|
||||
# pre_llm_call for context injection (conversation_history/user_message,
|
||||
# no messages list) — tracing that would create orphan traces.
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
state = _start_root_trace(
|
||||
task_key,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
platform=platform,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_mode=api_mode,
|
||||
messages=messages,
|
||||
client=client,
|
||||
)
|
||||
_TRACE_STATE[task_key] = state
|
||||
state.last_updated_at = time.time()
|
||||
|
||||
|
||||
def on_pre_llm_request(
|
||||
*,
|
||||
task_id: str = "",
|
||||
session_id: str = "",
|
||||
platform: str = "",
|
||||
model: str = "",
|
||||
provider: str = "",
|
||||
base_url: str = "",
|
||||
api_mode: str = "",
|
||||
api_call_count: int = 0,
|
||||
messages: Any = None,
|
||||
turn_type: str = "user",
|
||||
message_count: int = 0,
|
||||
tool_count: int = 0,
|
||||
approx_input_tokens: int = 0,
|
||||
request_char_count: int = 0,
|
||||
max_tokens: Any = None,
|
||||
**_: Any,
|
||||
) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
req_key = _request_key(api_call_count)
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
state = _start_root_trace(
|
||||
task_key,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
platform=platform,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_mode=api_mode,
|
||||
messages=messages,
|
||||
client=client,
|
||||
)
|
||||
_TRACE_STATE[task_key] = state
|
||||
state.last_updated_at = time.time()
|
||||
previous = state.generations.pop(req_key, None)
|
||||
if previous is not None:
|
||||
_end_observation(previous)
|
||||
state.generations[req_key] = _start_child_observation(
|
||||
state,
|
||||
client=client,
|
||||
name=f"LLM call {api_call_count}",
|
||||
as_type="generation",
|
||||
input_value=_serialize_messages(messages),
|
||||
metadata={
|
||||
"provider": provider,
|
||||
"platform": platform,
|
||||
"api_mode": api_mode,
|
||||
"base_url": base_url,
|
||||
},
|
||||
model=model,
|
||||
model_parameters={"api_mode": api_mode, "provider": provider},
|
||||
)
|
||||
|
||||
|
||||
def on_post_llm_call(*, task_id: str = "", session_id: str = "", provider: str = "", base_url: str = "",
|
||||
api_mode: str = "", model: str = "", api_call_count: int = 0,
|
||||
assistant_message: Any = None, response: Any = None,
|
||||
api_duration: float = 0.0, finish_reason: str = "",
|
||||
usage: Any = None, assistant_content_chars: int = 0,
|
||||
assistant_tool_call_count: int = 0, assistant_response: Any = None,
|
||||
**_: Any) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
req_key = _request_key(api_call_count)
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
generation = state.generations.pop(req_key, None) if state else None
|
||||
if state is None or generation is None:
|
||||
return
|
||||
|
||||
# Handle both call patterns:
|
||||
# 1. post_api_request: passes usage (dict), assistant_content_chars, assistant_tool_call_count
|
||||
# 2. post_llm_call: passes assistant_message (object), response (object), assistant_response (str)
|
||||
if assistant_message is not None:
|
||||
output = _serialize_assistant_message(assistant_message)
|
||||
elif assistant_response is not None:
|
||||
# post_llm_call passes assistant_response as a plain string
|
||||
output = {"content": _safe_value(assistant_response), "reasoning": None, "tool_calls": []}
|
||||
else:
|
||||
# post_api_request path — reconstruct from summary kwargs
|
||||
output = {
|
||||
"content": f"[{assistant_content_chars} chars]" if assistant_content_chars else None,
|
||||
"reasoning": None,
|
||||
"tool_calls": [{"id": f"tc_{i}"} for i in range(assistant_tool_call_count)] if assistant_tool_call_count else [],
|
||||
}
|
||||
|
||||
if output.get("tool_calls"):
|
||||
state.turn_tool_calls.extend(output["tool_calls"])
|
||||
|
||||
# Extract usage: prefer response object, fall back to usage dict from post_api_request
|
||||
if response is not None:
|
||||
usage_details, cost_details = _usage_and_cost(
|
||||
response,
|
||||
provider=provider,
|
||||
api_mode=api_mode,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
)
|
||||
elif isinstance(usage, dict) and usage:
|
||||
# post_api_request passes a pre-built CanonicalUsage summary dict.
|
||||
# Use Langfuse-convention key names: "input", "output", and
|
||||
# "cache_read_input_tokens" / "cache_creation_input_tokens" so the
|
||||
# dashboard sums cache tokens into the input total automatically.
|
||||
_input = usage.get("input_tokens", 0)
|
||||
_output = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0)
|
||||
_cache_read = usage.get("cache_read_tokens", 0)
|
||||
_cache_write = usage.get("cache_write_tokens", 0)
|
||||
_reasoning = usage.get("reasoning_tokens", 0)
|
||||
usage_details = {
|
||||
"input": _input,
|
||||
"output": _output,
|
||||
}
|
||||
if _cache_read:
|
||||
usage_details["cache_read_input_tokens"] = _cache_read
|
||||
if _cache_write:
|
||||
usage_details["cache_creation_input_tokens"] = _cache_write
|
||||
if _reasoning:
|
||||
usage_details["reasoning_tokens"] = _reasoning
|
||||
cost_details = {}
|
||||
# Estimate per-type cost from the summary if possible
|
||||
try:
|
||||
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost, get_pricing_entry
|
||||
from decimal import Decimal
|
||||
_ONE_M = Decimal("1000000")
|
||||
_cu = CanonicalUsage(
|
||||
input_tokens=_input,
|
||||
output_tokens=_output,
|
||||
cache_read_tokens=_cache_read,
|
||||
cache_write_tokens=_cache_write,
|
||||
reasoning_tokens=_reasoning,
|
||||
)
|
||||
entry = get_pricing_entry(model, provider=provider, base_url=base_url)
|
||||
if entry:
|
||||
if entry.input_cost_per_million is not None and _input:
|
||||
cost_details["input"] = float(Decimal(_input) * entry.input_cost_per_million / _ONE_M)
|
||||
if entry.output_cost_per_million is not None and _output:
|
||||
cost_details["output"] = float(Decimal(_output) * entry.output_cost_per_million / _ONE_M)
|
||||
if entry.cache_read_cost_per_million is not None and _cache_read:
|
||||
cost_details["cache_read_input_tokens"] = float(Decimal(_cache_read) * entry.cache_read_cost_per_million / _ONE_M)
|
||||
if entry.cache_write_cost_per_million is not None and _cache_write:
|
||||
cost_details["cache_creation_input_tokens"] = float(Decimal(_cache_write) * entry.cache_write_cost_per_million / _ONE_M)
|
||||
else:
|
||||
_cost = estimate_usage_cost(model, _cu, provider=provider, base_url=base_url, api_key="")
|
||||
if _cost.amount_usd is not None:
|
||||
cost_details["total"] = float(_cost.amount_usd)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
usage_details, cost_details = {}, {}
|
||||
|
||||
tool_count = len(output.get("tool_calls", [])) or assistant_tool_call_count
|
||||
gen_metadata: Dict[str, Any] = {"tool_call_count": tool_count}
|
||||
if api_duration and api_duration > 0:
|
||||
gen_metadata["api_duration_s"] = round(api_duration, 3)
|
||||
if finish_reason:
|
||||
gen_metadata["finish_reason"] = finish_reason
|
||||
_end_observation(
|
||||
generation,
|
||||
output=output,
|
||||
usage_details=usage_details,
|
||||
cost_details=cost_details,
|
||||
metadata=gen_metadata,
|
||||
)
|
||||
|
||||
has_tools = _assistant_has_tool_calls(assistant_message) if assistant_message else (assistant_tool_call_count > 0)
|
||||
has_content = bool(output.get("content"))
|
||||
if not has_tools and has_content:
|
||||
_finish_trace(task_key, output=output)
|
||||
|
||||
|
||||
def on_pre_tool_call(*, tool_name: str = "", args: Any = None, task_id: str = "",
|
||||
session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
|
||||
client = _get_langfuse()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
tool_key = tool_call_id or f"{tool_name}:{time.time_ns()}"
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
return
|
||||
state.tools[tool_key] = _start_child_observation(
|
||||
state,
|
||||
client=client,
|
||||
name=f"Tool: {tool_name}",
|
||||
as_type="tool",
|
||||
input_value=_safe_value(args),
|
||||
metadata={"tool_name": tool_name, "tool_call_id": tool_call_id},
|
||||
)
|
||||
|
||||
|
||||
def on_post_tool_call(*, tool_name: str = "", args: Any = None, result: Any = None,
|
||||
task_id: str = "", session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
|
||||
task_key = _trace_key(task_id, session_id)
|
||||
tool_key = tool_call_id or ""
|
||||
observation = None
|
||||
|
||||
with _STATE_LOCK:
|
||||
state = _TRACE_STATE.get(task_key)
|
||||
if state is None:
|
||||
return
|
||||
if tool_key:
|
||||
observation = state.tools.pop(tool_key, None)
|
||||
elif state.tools:
|
||||
_, observation = state.tools.popitem()
|
||||
|
||||
if observation is None:
|
||||
return
|
||||
|
||||
if isinstance(result, str):
|
||||
result_value = _maybe_parse_json_string(result)
|
||||
else:
|
||||
result_value = result
|
||||
result_value = _normalize_payload(result_value, tool_name=tool_name, args=args)
|
||||
|
||||
_end_observation(
|
||||
observation,
|
||||
output=_safe_value(result_value, parse_json_strings=True),
|
||||
metadata={"tool_name": tool_name, "args": _safe_value(args, parse_json_strings=True)},
|
||||
)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
# Register for both hook name variants so the plugin works across
|
||||
# Hermes versions. pre_api_request / post_api_request fire per API
|
||||
# call (preferred); pre_llm_call / post_llm_call fire once per turn.
|
||||
ctx.register_hook("pre_api_request", on_pre_llm_request)
|
||||
ctx.register_hook("post_api_request", on_post_llm_call)
|
||||
ctx.register_hook("pre_llm_call", on_pre_llm_call)
|
||||
ctx.register_hook("post_llm_call", on_post_llm_call)
|
||||
ctx.register_hook("pre_tool_call", on_pre_tool_call)
|
||||
ctx.register_hook("post_tool_call", on_post_tool_call)
|
||||
@@ -1,38 +0,0 @@
|
||||
# After installing langfuse
|
||||
|
||||
Langfuse tracing is now installed and enabled for your Hermes profile.
|
||||
|
||||
## Required credentials
|
||||
|
||||
Set these in `~/.hermes/.env` (or via `hermes tools` → Langfuse Observability):
|
||||
|
||||
```bash
|
||||
HERMES_LANGFUSE_PUBLIC_KEY=pk-lf-...
|
||||
HERMES_LANGFUSE_SECRET_KEY=sk-lf-...
|
||||
HERMES_LANGFUSE_BASE_URL=https://cloud.langfuse.com # or your self-hosted URL
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
hermes plugins list # langfuse should appear as enabled
|
||||
hermes chat -q "hello" # then check Langfuse for a "Hermes turn" trace
|
||||
```
|
||||
|
||||
## Optional settings
|
||||
|
||||
```bash
|
||||
HERMES_LANGFUSE_ENV=production # environment tag
|
||||
HERMES_LANGFUSE_RELEASE=v1.0.0 # release tag
|
||||
HERMES_LANGFUSE_SAMPLE_RATE=0.5 # sample 50% of traces
|
||||
HERMES_LANGFUSE_MAX_CHARS=12000 # max chars per field (default: 12000)
|
||||
HERMES_LANGFUSE_DEBUG=true # verbose plugin logging
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
The `langfuse` Python SDK is required. Install it into your Hermes venv:
|
||||
|
||||
```bash
|
||||
pip install langfuse
|
||||
```
|
||||
@@ -1,14 +0,0 @@
|
||||
name: langfuse
|
||||
version: "1.0.0"
|
||||
description: "Optional Langfuse observability for Hermes — traces conversations, LLM calls, and tool usage. Install via: hermes plugins install official/observability/langfuse"
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- HERMES_LANGFUSE_PUBLIC_KEY
|
||||
- HERMES_LANGFUSE_SECRET_KEY
|
||||
hooks:
|
||||
- pre_api_request
|
||||
- post_api_request
|
||||
- pre_llm_call
|
||||
- post_llm_call
|
||||
- pre_tool_call
|
||||
- post_tool_call
|
||||
+2
-18
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: touchdesigner-mcp
|
||||
description: "Control a running TouchDesigner instance via twozero MCP — create operators, set parameters, wire connections, execute Python, build real-time visuals. 36 native tools."
|
||||
version: 1.1.0
|
||||
version: 1.0.0
|
||||
author: kshitijk4poor
|
||||
license: MIT
|
||||
metadata:
|
||||
@@ -204,9 +204,8 @@ win.par.winopen.pulse()
|
||||
| `td_input_clear` | Stop input automation |
|
||||
| `td_op_screen_rect` | Get screen coords of a node |
|
||||
| `td_click_screen_point` | Click a point in a screenshot |
|
||||
| `td_screen_point_to_global` | Convert screenshot pixel to absolute screen coords |
|
||||
|
||||
The table above covers the 32 tools used in typical creative workflows. The remaining 4 tools (`td_project_quit`, `td_test_session`, `td_dev_log`, `td_clear_dev_log`) are admin/dev-mode utilities — see `references/mcp-tools.md` for the full 36-tool reference with complete parameter schemas.
|
||||
See `references/mcp-tools.md` for full parameter schemas.
|
||||
|
||||
## Key Implementation Rules
|
||||
|
||||
@@ -333,21 +332,6 @@ See `references/network-patterns.md` for complete build scripts + shader code.
|
||||
| `references/mcp-tools.md` | Full twozero MCP tool parameter schemas |
|
||||
| `references/python-api.md` | TD Python: op(), scripting, extensions |
|
||||
| `references/troubleshooting.md` | Connection diagnostics, debugging |
|
||||
| `references/glsl.md` | GLSL uniforms, built-in functions, shader templates |
|
||||
| `references/postfx.md` | Post-FX: bloom, CRT, chromatic aberration, feedback glow |
|
||||
| `references/layout-compositor.md` | HUD layout patterns, panel grids, BSP-style layouts |
|
||||
| `references/operator-tips.md` | Wireframe rendering, feedback TOP setup |
|
||||
| `references/geometry-comp.md` | Geometry COMP: instancing, POP vs SOP, morphing |
|
||||
| `references/audio-reactive.md` | Audio band extraction, beat detection, envelope following |
|
||||
| `references/animation.md` | LFOs, timers, keyframes, easing, expression-driven motion |
|
||||
| `references/midi-osc.md` | MIDI/OSC controllers, TouchOSC, multi-machine sync |
|
||||
| `references/particles.md` | POPs and legacy particleSOP — emission, forces, collisions |
|
||||
| `references/projection-mapping.md` | Multi-window output, corner pin, mesh warp, edge blending |
|
||||
| `references/external-data.md` | HTTP, WebSocket, MQTT, Serial, TCP, webserverDAT |
|
||||
| `references/panel-ui.md` | Custom params, panel COMPs, button/slider/field, panelExecuteDAT |
|
||||
| `references/replicator.md` | replicatorCOMP — data-driven cloning, layouts, callbacks |
|
||||
| `references/dat-scripting.md` | Execute DAT family — chop/dat/parameter/panel/op/executeDAT |
|
||||
| `references/3d-scene.md` | Lighting rigs, shadows, IBL/cubemaps, multi-camera, PBR |
|
||||
| `scripts/setup.sh` | Automated setup script |
|
||||
|
||||
---
|
||||
+25
-221
@@ -143,20 +143,20 @@ Creating nodes with the same names you just destroyed in the SAME script causes
|
||||
```python
|
||||
# td_execute_python:
|
||||
for c in list(root.children):
|
||||
if c.valid and c.name.startswith('my_'):
|
||||
if c.valid and c.name.startswith('promo_'):
|
||||
c.destroy()
|
||||
# ... then create my_audio, my_shader etc. in same script → CRASHES
|
||||
# ... then create promo_audio, promo_shader etc. in same script → CRASHES
|
||||
```
|
||||
|
||||
**CORRECT (two separate calls):**
|
||||
```python
|
||||
# Call 1: td_execute_python — clean only
|
||||
for c in list(root.children):
|
||||
if c.valid and c.name.startswith('my_'):
|
||||
if c.valid and c.name.startswith('promo_'):
|
||||
c.destroy()
|
||||
|
||||
# Call 2: td_execute_python — build (separate MCP call)
|
||||
audio = root.create(audiofileinCHOP, 'my_audio')
|
||||
audio = root.create(audiofileinCHOP, 'promo_audio')
|
||||
# ... rest of build
|
||||
```
|
||||
|
||||
@@ -361,13 +361,21 @@ win.par.winopen.pulse()
|
||||
|
||||
`out.sample(x, y)` returns pixels from a single cook snapshot. Compare samples with 2+ second delays, or use screencapture on the display window.
|
||||
|
||||
### 32. Audio-reactive GLSL: TD-side pipeline
|
||||
### 32. Audio-reactive GLSL: dual-layer sync pipeline
|
||||
|
||||
For audio-synced visuals: AudioFileIn → AudioSpectrum(timeslice=True, fftsize='256') → Math(gain=5) → choptoTOP(par.chop=math, layout='rowscropped') → GLSL input. The shader samples `sTD2DInputs[1]` at different x positions for bass/mid/hi. Record the TD output with MovieFileOut.
|
||||
For audio-synced visuals, use BOTH layers for maximum effect:
|
||||
|
||||
**Layer 1 (TD-side, real-time):** AudioFileIn → AudioSpectrum(timeslice=True, fftsize='256') → Math(gain=5) → choptoTOP(par.chop=math, layout='rowscropped') → GLSL input. The shader samples `sTD2DInputs[1]` at different x positions for bass/mid/hi. Record the TD output with MovieFileOut.
|
||||
|
||||
**Layer 2 (Python-side, post-hoc):** scipy FFT on the SAME audio file → per-frame features (rms, bass, mid, hi, beat detection) → drive ASCII brightness, chromatic aberration, beat flashes during the render pass.
|
||||
|
||||
Both layers locked to the same audio file = visuals genuinely sync to the beat at two independent stages.
|
||||
|
||||
**Key gotcha:** AudioFileIn must be cued (`par.cue=True` → `par.cuepulse.pulse()`) then uncued (`par.cue=False`, `par.play=True`) before recording starts. Otherwise the spectrum is silent for the first few seconds.
|
||||
|
||||
### 33. twozero MCP: prefer native tools
|
||||
### 33. twozero MCP: benchmark and prefer native tools
|
||||
|
||||
Benchmarked April 2026: twozero MCP with 36 native tools. The old curl/REST method (port 9981) had zero native tools.
|
||||
|
||||
**Always prefer native MCP tools over td_execute_python:**
|
||||
- `td_create_operator` over `root.create()` scripts (handles viewport positioning)
|
||||
@@ -417,16 +425,13 @@ TD can show `fps:0` in `td_get_perf` while ops still cook and `TOP.save()` still
|
||||
|
||||
**a) Project is paused (playbar stopped).** TD's playbar can be toggled with spacebar. The `root` at `/` has no `.playbar` attribute (it's on the perform COMP). The easiest fix is sending a spacebar keypress via `td_input_execute`, though this tool can sometimes error. As a workaround, `TOP.save()` always works regardless of play state — use it to verify rendering is actually happening before spending time debugging FPS.
|
||||
|
||||
**b) Audio device CHOP blocking the main thread (MOST COMMON).** An `audiodeviceoutCHOP` with `active=True` can consume 300-400ms/s (2000%+ of frame budget), stalling the cook loop at FPS=0. **`volume=0` is NOT sufficient** — the audio driver still blocks. Fix: `par.active = False`. This completely stops the CHOP from interacting with the audio driver. If you need audio monitoring, enable it only during short playback checks, then disable before recording.
|
||||
|
||||
Verified April 2026: disabling `audiodeviceoutCHOP` (`active=False`) restored FPS from 0 to 60 instantly, recovering from 2348% budget usage to 0.1%.
|
||||
**b) Audio device CHOP blocking the main thread.** An `audiooutCHOP` with an active audio device can consume 300-400ms/s (2000%+ of frame budget), stalling the cook loop at FPS=0. Fix: keep the CHOP active but set `volume=0` to prevent the audio driver from blocking. Disabling it entirely (`active=False`) may also work but can prevent downstream audio processing CHOPs from cooking.
|
||||
|
||||
Diagnostic sequence when FPS=0:
|
||||
1. `td_get_perf` — check if any op has extreme CPU/s (audiodeviceoutCHOP is the usual suspect)
|
||||
2. If audiodeviceoutCHOP shows >100ms/s: set `par.active = False` immediately
|
||||
3. `TOP.save()` on the output — if it produces a valid image, the pipeline works, just not at real-time rate
|
||||
4. Check for other blocking CHOPs (audiodevin, etc.)
|
||||
5. Toggle play state (spacebar, or check if absTime.seconds is advancing)
|
||||
1. `td_get_perf` — check if any op has extreme CPU/s
|
||||
2. `TOP.save()` on the output — if it produces a valid image, the pipeline works, just not at real-time rate
|
||||
3. Check for blocking CHOPs (audioout, audiodevin, etc.)
|
||||
4. Toggle play state (spacebar, or check if absTime.seconds is advancing)
|
||||
|
||||
### 39. Recording while FPS=0 produces empty or near-empty files
|
||||
|
||||
@@ -479,20 +484,9 @@ If `td_write_dat` fails, fall back to `td_execute_python`:
|
||||
op("/project1/shader_code").text = shader_string
|
||||
```
|
||||
|
||||
### 42. td_execute_python DOES return print() output — use it for debugging
|
||||
### 42. td_execute_python does NOT return stdout or print() output
|
||||
|
||||
`print()` statements in `td_execute_python` scripts appear in the MCP response text. This is the correct way to read values back from scripts. The response format is: printed output first, then `[fps X.X/X] [N err/N warn]` on a separate line.
|
||||
|
||||
However, the `result` variable (if you set one) does NOT appear verbatim — use `print()` for anything you need to read back:
|
||||
```python
|
||||
# CORRECT — appears in response:
|
||||
print('value:', some_value)
|
||||
|
||||
# WRONG — not reliably in response:
|
||||
result = some_value
|
||||
```
|
||||
|
||||
For structured data, use dedicated inspection tools (`td_get_operator_info`, `td_read_chop`) which return clean JSON.
|
||||
Despite what earlier versions of pitfall #33 stated, `print()` and `debug()` output from `td_execute_python` scripts does NOT appear in the MCP response. The response is always just `(ok)` + FPS/error summary. To read values back, use dedicated inspection tools (`td_get_operator_info`, `td_read_dat`, `td_read_chop`) instead of trying to print from within a script.
|
||||
|
||||
### 43. td_get_operator_info JSON is appended with `[fps X.X/X]` — breaks json.loads()
|
||||
|
||||
@@ -502,203 +496,13 @@ clean = response_text.rsplit('[fps', 1)[0]
|
||||
data = json.loads(clean)
|
||||
```
|
||||
|
||||
### 44. td_get_screenshot is unreliable — returns `{"status": "pending"}` and may never deliver
|
||||
### 44. td_get_screenshot is asynchronous — returns `{"status": "pending"}`
|
||||
|
||||
Screenshots don't complete instantly. The tool returns `{"status": "pending", "requestId": "..."}` and the actual file may appear later — or may NEVER appear at all. In testing (April 2026), screenshots stayed "pending" indefinitely with no file written to disk, even though the shader was cooking at 8-30fps.
|
||||
Screenshots don't complete instantly. The tool returns `{"status": "pending", "requestId": "..."}` and the actual file appears later. Wait a few seconds before checking for the file. There is no callback or completion notification — poll the filesystem.
|
||||
|
||||
**Do NOT rely on `td_get_screenshot` for frame capture.** For reliable frame capture, use MovieFileOut recording + ffmpeg frame extraction:
|
||||
```bash
|
||||
# Record in TD first, then extract frames:
|
||||
ffmpeg -y -i /tmp/td_output.mov -t 25 -vf 'fps=24' /tmp/td_frames/frame_%06d.png
|
||||
```
|
||||
|
||||
If you need a quick visual check, `td_get_screenshot` is worth trying (it sometimes works), but always have the recording fallback. There is no callback or completion notification — if the file doesn't appear after 5-10 seconds, it's not coming.
|
||||
|
||||
### 45. Heavy shaders cook below record FPS — many duplicate frames in output
|
||||
|
||||
A raymarched GLSL shader may only cook at 8-15fps even though MovieFileOut records at 60fps. The recording still works (TD writes the last-cooked frame each time), but the resulting file has many duplicate frames. When extracting frames for post-processing, use a lower fps filter to avoid redundant frames:
|
||||
```bash
|
||||
# Extract at 24fps from a 60fps recording of an 8fps shader:
|
||||
ffmpeg -y -i /tmp/td_output.mov -t 25 -vf 'fps=24' /tmp/td_frames/frame_%06d.png
|
||||
```
|
||||
Check actual cook FPS with `td_get_perf` before committing to a long recording. If FPS < 15, the output will be a slideshow regardless of the recording codec.
|
||||
|
||||
### 46. Recording duration is manual — no auto-stop at audio end
|
||||
### 45. Recording duration is manual — no auto-stop at audio end
|
||||
|
||||
MovieFileOut records until `par.record = False` is set. If audio ends before you stop recording, the file keeps growing with repeated frames. Always stop recording promptly after the audio duration. For precision: set a timer on the agent side matching the audio length, then send `par.record = False`. Trim excess with ffmpeg as a safety net:
|
||||
```bash
|
||||
ffmpeg -i raw.mov -t 25 -c copy trimmed.mov
|
||||
```
|
||||
|
||||
### 47. AudioFileIn par.index stays at 0 in sequential mode — not a reliable progress indicator
|
||||
|
||||
When `audiofileinCHOP` is in `playmode=2` (sequential), `par.index.eval()` returns 0.0 even while audio IS actively playing and the spectrum IS receiving data. Do NOT use `par.index` to check playback progress in sequential mode.
|
||||
|
||||
**How to verify audio is actually playing:**
|
||||
- Read the spectrum CHOP values via `td_read_chop` — if values are non-zero and CHANGE between reads 1-2s apart, audio is flowing
|
||||
- Read the audio CHOP itself: non-zero waveform samples confirm the file is loaded and playing
|
||||
- `par.play.eval()` returning True is necessary but NOT sufficient — it can be True with no audio flowing if cue is stuck
|
||||
|
||||
### 48. GLSL shader whiteout — clamp audio spectrum values in the shader
|
||||
|
||||
Raw spectrum values multiplied by Math CHOP gain can produce very large numbers (5-20+) that blow out the shader's lighting, producing flat white/grey. The shader MUST clamp audio inputs:
|
||||
|
||||
```glsl
|
||||
float bass = texture(sTD2DInputs[1], vec2(0.05, 0.25)).r;
|
||||
bass = clamp(bass, 0.0, 3.0); // prevent whiteout
|
||||
mids = clamp(mids, 0.0, 3.0);
|
||||
hi = clamp(hi, 0.0, 3.0);
|
||||
```
|
||||
|
||||
Discovered when gain=10 produced ~0.13 (too dark) during quiet passages but gain=50 produced ~9.4 (total whiteout). Fix: keep gain=10, use `highfreqboost=3.0` on AudioSpectrum, clamp in shader.
|
||||
|
||||
### 49. Non-Commercial TD records at 1280x1280 (square) — always crop in post
|
||||
|
||||
Even with `resolutionw=1280, resolutionh=720` on the GLSL TOP, Non-Commercial TD may output 1280x1280 to MovieFileOut. Always check dimensions with ffprobe and crop during extraction:
|
||||
|
||||
```bash
|
||||
# Center-crop from 1280x1280 to 1280x720:
|
||||
ffmpeg -y -i /tmp/td_output.mov -t 25 -r 24 -vf "crop=1280:720:0:280" /tmp/frames/frame_%06d.png
|
||||
```
|
||||
|
||||
Large ProRes files (1-2GB) at 1280x1280 decode at ~3fps, so 25s of footage takes ~3 minutes to extract.
|
||||
|
||||
## Advanced Patterns (pitfalls 51+)
|
||||
|
||||
### 51. Connection syntax: use `outputConnectors`/`inputConnectors`, NOT `outputs`/`inputs`
|
||||
|
||||
```python
|
||||
# CORRECT
|
||||
src.outputConnectors[0].connect(dst.inputConnectors[0])
|
||||
# WRONG — raises IndexError or AttributeError
|
||||
src.outputs[0].connect(dst.inputs[0])
|
||||
```
|
||||
|
||||
For feedback TOP, BOTH are required:
|
||||
```python
|
||||
fb.par.top = target.path
|
||||
target.outputConnectors[0].connect(fb.inputConnectors[0])
|
||||
```
|
||||
|
||||
### 52. moviefileoutTOP `par.input` doesn't resolve via Python in TD 2025.32460
|
||||
|
||||
Setting `moviefileoutTOP.par.input` programmatically does NOT work. All forms fail silently with "Not enough sources specified."
|
||||
|
||||
**Workaround — frame capture + ffmpeg:**
|
||||
```python
|
||||
out = op('/project1/out')
|
||||
for i in range(300):
|
||||
delay = i * 5
|
||||
run(f"op('/project1/out').save('/tmp/frames/f_{i:04d}.png')", delayFrames=delay)
|
||||
# Then: ffmpeg -y -framerate 30 -i /tmp/frames/f_%04d.png -c:v prores -pix_fmt yuv420p /tmp/output.mov
|
||||
```
|
||||
|
||||
### 53. Batch frame capture — use `me.fetch`/`me.store` for state across calls
|
||||
|
||||
```python
|
||||
start = me.fetch('cap_frame', 0)
|
||||
for i in range(60):
|
||||
frame = start + i
|
||||
op('/project1/out').save(f'/tmp/frames/frame_{str(frame).zfill(4)}.png')
|
||||
me.store('cap_frame', start + 60)
|
||||
```
|
||||
Call 5 times for 300 frames. Each picks up where the last left off.
|
||||
|
||||
### 54. GLSL TOP pixel shader requirements in TD 2025
|
||||
|
||||
```glsl
|
||||
// REQUIRED — declare output
|
||||
layout(location = 0) out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
vec3 col = vec3(1.0, 0.0, 0.0);
|
||||
fragColor = TDOutputSwizzle(vec4(col, 1.0));
|
||||
}
|
||||
```
|
||||
**Built-in uniforms available:** `uTDOutputInfo.res` (vec4), `uTDTimeInfo.seconds`, `sTD2DInputs[N]`.
|
||||
**Auto-created DATs:** `name_pixel`, `name_vertex`, `name_compute` textDATs with example code.
|
||||
|
||||
### 55. TOP.save() doesn't advance time — identical frames in tight loops
|
||||
|
||||
`.save()` captures the current cooked frame without advancing TD's timeline:
|
||||
```python
|
||||
# WRONG — all frames identical
|
||||
for i in range(300):
|
||||
op('/project1/out').save(f'frames/f_{i:04d}.png')
|
||||
|
||||
# CORRECT — use run() with delayFrames
|
||||
for i in range(300):
|
||||
delay = i * 5
|
||||
run(f"op('/project1/out').save('frames/f_{i:04d}.png')", delayFrames=delay)
|
||||
```
|
||||
**NEVER use `time.sleep()` in TD** — it blocks the main thread and freezes the UI.
|
||||
|
||||
### 56. Feedback loop masks input changes — force switch during capture
|
||||
|
||||
With feedback TOP opacity 0.7+, the buffer dominates output. Switching input produces nearly identical frames.
|
||||
|
||||
**Fix — force switch index per capture:**
|
||||
```python
|
||||
for i in range(300):
|
||||
idx = (i // 8) % num_inputs
|
||||
delay = i * 5
|
||||
run(f"op('/project1/vswitch').par.index={idx}; op('/project1/out').save('f_{i:04d}.png')", delayFrames=delay)
|
||||
```
|
||||
|
||||
### 57. Large td_execute_python scripts fail — split into incremental calls
|
||||
|
||||
10+ operator creations in one script cause timing issues. Split into 2-4 calls of 2-4 operators each. Within one call, `create()` handles work immediately. Across calls, `op('name')` may return `None` if the previous call hasn't committed.
|
||||
|
||||
### 58. MCP instance reconnection after project.load()
|
||||
|
||||
`project.load(path)` changes the PID. After loading, call `td_list_instances()` and use the new `target_instance`. For TOX files: import as child comp instead (doesn't disconnect).
|
||||
|
||||
### 59. TOX reverse-engineering workflow
|
||||
|
||||
```python
|
||||
comp = root.loadTox(r'/path/to/file.tox')
|
||||
comp.name = '_study_comp'
|
||||
for child in comp.children:
|
||||
print(f'{child.name} ({child.OPType})')
|
||||
# Use td_get_operators_info, td_read_dat, check custom params
|
||||
```
|
||||
|
||||
### 60. sliderCOMP naming — TD appends suffix
|
||||
|
||||
TD auto-renames: `slider_brightness` → `slider_brightness1`. Always check names after creation.
|
||||
|
||||
### 61. create() requires full operator type suffix
|
||||
|
||||
```python
|
||||
# CORRECT
|
||||
proj.create('audiofileinCHOP', 'audio_in')
|
||||
proj.create('glslTOP', 'render')
|
||||
|
||||
# WRONG — raises "Unknown operator type"
|
||||
proj.create('audiofilein', 'audio_in')
|
||||
proj.create('glsl', 'render')
|
||||
```
|
||||
|
||||
### 62. Reparenting COMPs — use copyOPs, not connect()
|
||||
|
||||
Moving COMPs with `inputCOMPConnectors[0].connect()` fails. Use copy + destroy:
|
||||
```python
|
||||
copied = target.copyOPs([source]) # preserves internal wiring
|
||||
source.destroy()
|
||||
# Re-wire external connections manually after the move
|
||||
```
|
||||
|
||||
### 63. Slider wiring — expressionCHOP with op() expressions crashes TD
|
||||
|
||||
```python
|
||||
# CRASHES TD — don't do this
|
||||
echop = root.create(expressionCHOP, 'slider_ctrl')
|
||||
echop.par.chan0expr = 'op("/project1/controls/slider_brightness1").par.value0'
|
||||
|
||||
# WORKING — parameterCHOP as bridge
|
||||
pchop = root.create(parameterCHOP, 'slider_vals')
|
||||
pchop.par.ops = '/project1/controls'
|
||||
pchop.par.parameters = 'value0'
|
||||
pchop.par.custom = True
|
||||
pchop.par.builtin = False
|
||||
```
|
||||
@@ -380,10 +380,6 @@ def backup_existing(path: Path, backup_root: Path) -> Optional[Path]:
|
||||
# Replace OpenClaw brand names with Hermes in migrated text so that
|
||||
# memory entries, user profiles, SOUL.md, and workspace instructions
|
||||
# read as self-referential to the new agent identity.
|
||||
#
|
||||
# Case-preserving: ``OpenClaw`` → ``Hermes`` (prose), but lowercase matches
|
||||
# like ``openclaw`` → ``hermes`` (so filesystem paths like ``~/.openclaw``
|
||||
# become ``~/.hermes`` — the real Hermes home — not the broken ``~/.Hermes``).
|
||||
_REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [
|
||||
(re.compile(r'\bOpen[\s-]?Claw\b', re.IGNORECASE), 'Hermes'),
|
||||
(re.compile(r'\bClawdBot\b', re.IGNORECASE), 'Hermes'),
|
||||
@@ -391,31 +387,10 @@ _REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [
|
||||
]
|
||||
|
||||
|
||||
def _case_preserving_replacement(replacement: str):
|
||||
"""Return a re.sub replacement fn that lowercases the result when the
|
||||
matched text was all-lowercase.
|
||||
|
||||
Keeps ``OpenClaw`` → ``Hermes`` but maps ``openclaw`` → ``hermes`` so a
|
||||
filesystem path like ``~/.openclaw/config.yaml`` rewrites to
|
||||
``~/.hermes/config.yaml`` (the real Hermes home) instead of the broken
|
||||
``~/.Hermes/config.yaml``.
|
||||
"""
|
||||
def _sub(match: "re.Match[str]") -> str:
|
||||
matched = match.group(0)
|
||||
if matched and matched.islower():
|
||||
return replacement.lower()
|
||||
return replacement
|
||||
return _sub
|
||||
|
||||
|
||||
def rebrand_text(text: str) -> str:
|
||||
"""Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes.
|
||||
|
||||
Preserves case so filesystem-path matches (lowercase) don't become
|
||||
capitalized directory names that don't exist.
|
||||
"""
|
||||
"""Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes."""
|
||||
for pattern, replacement in _REBRAND_PATTERNS:
|
||||
text = pattern.sub(_case_preserving_replacement(replacement), text)
|
||||
text = pattern.sub(replacement, text)
|
||||
return text
|
||||
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,148 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,103 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,244 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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}")
|
||||
@@ -1,478 +0,0 @@
|
||||
"""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 os
|
||||
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))
|
||||
@@ -1,852 +0,0 @@
|
||||
"""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 realtime speaker + audio bridge.
|
||||
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())
|
||||
@@ -1,54 +0,0 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -1,125 +0,0 @@
|
||||
"""`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}
|
||||
@@ -1,107 +0,0 @@
|
||||
"""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", {})
|
||||
@@ -1,124 +0,0 @@
|
||||
"""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, ""
|
||||
@@ -1,112 +0,0 @@
|
||||
"""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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user