From a657397769ab69b3bc72afca38161e04ee36aff7 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 18 Jun 2026 13:08:21 +1000 Subject: [PATCH 001/470] test(cron): characterize in-process + desktop ticker contract before provider refactor --- tests/cron/test_scheduler_provider.py | 83 +++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/cron/test_scheduler_provider.py diff --git a/tests/cron/test_scheduler_provider.py b/tests/cron/test_scheduler_provider.py new file mode 100644 index 00000000000..1e94347dfa8 --- /dev/null +++ b/tests/cron/test_scheduler_provider.py @@ -0,0 +1,83 @@ +"""Characterization tests for the cron trigger before/after the provider refactor. + +These lock the CURRENT in-process-ticker contract (Phase 0 of the pluggable +CronScheduler plan, .hermes/plans/cron-scheduler-provider-interface.md). They +must pass unchanged on `main` now, and after every subsequent phase of the +refactor — they are the regression harness that proves the built-in firing +behavior is byte-for-byte preserved when the ticker is moved behind the +CronScheduler provider interface. + +No production code is exercised beyond the two ticker entry points: + - gateway/run.py::_start_cron_ticker (production gateway ticker) + - hermes_cli/web_server.py::_start_desktop_cron_ticker (desktop fallback) + +Both call `cron.scheduler.tick(...)` on a loop and exit when their stop_event +is set. We patch `cron.scheduler.tick` (both tickers import it locally as +`cron_tick`, so the module-attribute patch is observed) and assert the loop +drives it and stops promptly. +""" +import threading +import time +from unittest.mock import patch + + +def test_ticker_calls_tick_at_least_once_then_stops(): + """The gateway in-process ticker loop calls cron.scheduler.tick repeatedly + and exits promptly once the stop_event is set.""" + from gateway.run import _start_cron_ticker + + calls = [] + stop = threading.Event() + + def fake_tick(*args, **kwargs): + calls.append(kwargs) + return 0 + + with patch("cron.scheduler.tick", side_effect=fake_tick): + # interval=0 keeps the loop tight; stop after a brief beat. + t = threading.Thread( + target=_start_cron_ticker, + args=(stop,), + kwargs={"interval": 0}, + daemon=True, + ) + t.start() + time.sleep(0.2) + stop.set() + t.join(timeout=5) + + assert not t.is_alive(), "ticker did not exit after stop_event was set" + assert len(calls) >= 1, "ticker never called tick()" + # Contract: the ticker invokes tick with sync=False (fire-and-forget from + # the background thread, never the synchronous CLI path). + assert calls[0].get("sync") is False + + +def test_desktop_ticker_calls_tick_then_stops(): + """The desktop dashboard ticker loop calls cron.scheduler.tick and exits + once the stop_event is set. Desktop has no live adapters, so it ticks with + no adapters/loop.""" + from hermes_cli.web_server import _start_desktop_cron_ticker + + calls = [] + stop = threading.Event() + + def fake_tick(*args, **kwargs): + calls.append(kwargs) + return 0 + + with patch("cron.scheduler.tick", side_effect=fake_tick): + t = threading.Thread( + target=_start_desktop_cron_ticker, + args=(stop,), + kwargs={"interval": 0}, + daemon=True, + ) + t.start() + time.sleep(0.2) + stop.set() + t.join(timeout=5) + + assert not t.is_alive(), "desktop ticker did not exit after stop_event was set" + assert len(calls) >= 1, "desktop ticker never called tick()" + assert calls[0].get("sync") is False From e6ff41ca9516cbca6470a56b1ab98939dbdb935a Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 18 Jun 2026 13:58:43 +1000 Subject: [PATCH 002/470] feat(cron): CronScheduler ABC + InProcessCronScheduler (provider #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the pluggable cron-scheduler refactor (Axis B — the trigger). No call-site changes; this phase only makes the abstraction exist + tested in isolation. Task 1.1: cron/scheduler_provider.py — the EXPERIMENTAL CronScheduler ABC. Required surface is name + start; is_available()/stop() carry safe defaults. is_available has a no-network invariant. Docstring marks it experimental until the Chronos provider (Phase 4) validates the shape. Task 1.2: InProcessCronScheduler wraps the historical 60s ticker loop, calling cron.scheduler.tick(sync=False) exactly as the raw ticker does. Uses stop_event.wait(interval) for responsive stop (both raw tickers already do). Tests: ABC-is-abstract, default-is_available, the InProcess loop drives tick and stops, stop() no-op, and test_abc_growth_stays_additive (the forward-compat guard: required abstractmethods must stay exactly {name, start}, so the three Phase-4 hooks land as NON-abstract additions). tick() internals in cron/scheduler.py are byte-unchanged (only new file added). Phase 0 characterization tests still green. Full tests/cron/: 445 passed. --- cron/scheduler_provider.py | 98 +++++++++++++++++++++++++++ tests/cron/test_scheduler_provider.py | 78 +++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 cron/scheduler_provider.py diff --git a/cron/scheduler_provider.py b/cron/scheduler_provider.py new file mode 100644 index 00000000000..329cf4ae8a6 --- /dev/null +++ b/cron/scheduler_provider.py @@ -0,0 +1,98 @@ +"""CronScheduler provider interface (Axis B — the trigger). + +⚠️ EXPERIMENTAL — this interface is validated by exactly ONE consumer (the +built-in) until an external provider (Chronos, Phase 4) shakes it out. Until +then the module path, method signatures, and start() kwargs MAY change without +a deprecation cycle. Once a second provider validates the shape it becomes +stable. Any growth MUST be additive (new optional method with a default), never +a changed signature on start() or a new abstractmethod. + +A CronScheduler decides *when* a due job fires. It does NOT decide what firing +means: execution + delivery stay in cron.scheduler.run_job / _deliver_result, +shared by all providers. Providers must never reimplement agent construction or +delivery. + +The built-in InProcessCronScheduler runs the historical 60s daemon-thread +ticker. Alternative providers (e.g. Chronos, a NAS-mediated managed-cron +provider for scale-to-zero deployments) live under plugins/cron// and are +selected via the `cron.provider` config key (empty = built-in). +""" +from __future__ import annotations + +import threading +from abc import ABC, abstractmethod +from typing import Any + + +class CronScheduler(ABC): + """Axis-B trigger provider. Decides WHEN a due cron job fires. + + Required surface is intentionally minimal: ``name`` + ``start``. ``stop`` + and ``is_available`` carry safe defaults. The three Phase-4 hooks + (``on_jobs_changed`` / ``fire_due`` / ``reconcile``) are added later as + NON-abstract methods so the built-in keeps satisfying the ABC without + overriding them — see ``test_abc_growth_stays_additive``. + """ + + @property + @abstractmethod + def name(self) -> str: + """Short identifier, e.g. 'builtin', 'chronos'.""" + + def is_available(self) -> bool: + """Whether this provider can run in the current environment. + + MUST NOT make network calls. The built-in is always available; an + external provider checks for configured endpoint/credentials. When a + named provider returns False, the resolver falls back to the built-in. + """ + return True + + @abstractmethod + def start( + self, + stop_event: threading.Event, + *, + adapters: Any = None, + loop: Any = None, + interval: int = 60, + ) -> None: + """Begin firing due jobs. + + For the built-in this BLOCKS in the 60s loop until stop_event is set + (it is run inside a daemon thread by the caller, exactly as today). + An external provider may register a schedule/webhook and return + immediately; in that case it must still honor stop_event for teardown. + """ + + def stop(self) -> None: + """Optional eager teardown hook. Default no-op; setting the stop_event + is the primary stop signal. Override for providers holding external + resources (queue consumers, HTTP servers).""" + return None + + +class InProcessCronScheduler(CronScheduler): + """Default provider: the historical in-process 60s ticker. + + ``start()`` blocks in the tick loop until ``stop_event`` is set, identical + to the pre-refactor ``_start_cron_ticker`` core loop. The caller runs it in + a daemon thread. + """ + + @property + def name(self) -> str: + return "builtin" + + def start(self, stop_event, *, adapters=None, loop=None, interval=60): + import logging + from cron.scheduler import tick as cron_tick + + logger = logging.getLogger("cron.scheduler_provider") + logger.info("In-process cron scheduler started (interval=%ds)", interval) + while not stop_event.is_set(): + try: + cron_tick(verbose=False, adapters=adapters, loop=loop, sync=False) + except Exception as e: + logger.debug("Cron tick error: %s", e) + stop_event.wait(interval) diff --git a/tests/cron/test_scheduler_provider.py b/tests/cron/test_scheduler_provider.py index 1e94347dfa8..74b3891122c 100644 --- a/tests/cron/test_scheduler_provider.py +++ b/tests/cron/test_scheduler_provider.py @@ -81,3 +81,81 @@ def test_desktop_ticker_calls_tick_then_stops(): assert not t.is_alive(), "desktop ticker did not exit after stop_event was set" assert len(calls) >= 1, "desktop ticker never called tick()" assert calls[0].get("sync") is False + + +# ── Phase 1: CronScheduler ABC + InProcessCronScheduler ────────────────────── + + +def test_cronscheduler_is_abstract(): + """name + start are abstract — the bare ABC can't be instantiated.""" + import pytest + from cron.scheduler_provider import CronScheduler + + with pytest.raises(TypeError): + CronScheduler() + + +def test_cronscheduler_default_is_available_true(): + """is_available defaults to True (no-network) for a minimal subclass.""" + from cron.scheduler_provider import CronScheduler + + class Dummy(CronScheduler): + @property + def name(self): + return "dummy" + + def start(self, stop_event, **kw): + pass + + assert Dummy().is_available() is True + + +def test_abc_growth_stays_additive(): + """Forward-compat guard: the ABC's REQUIRED surface is exactly name+start. + + Any optional hook added later for the external provider + (on_jobs_changed/fire_due/reconcile) must be NON-abstract (carry a default), + so the built-in keeps satisfying the ABC without overriding them. This test + fails loudly if someone makes a future hook abstract (a breaking change that + would force every provider — including the built-in — to implement it). + """ + from cron.scheduler_provider import CronScheduler + + abstract = set(getattr(CronScheduler, "__abstractmethods__", set())) + assert abstract == {"name", "start"}, ( + f"CronScheduler abstractmethods changed to {abstract}; growth must be " + "additive (optional methods with defaults), not new abstract methods." + ) + + +def test_inprocess_provider_ticks_and_stops(): + """The built-in provider drives cron.scheduler.tick(sync=False) on a loop + and exits promptly when stop_event is set — same contract as the raw + ticker characterized above.""" + from cron.scheduler_provider import InProcessCronScheduler + + calls = [] + stop = threading.Event() + prov = InProcessCronScheduler() + assert prov.name == "builtin" + + with patch("cron.scheduler.tick", side_effect=lambda *a, **k: calls.append(k) or 0): + t = threading.Thread( + target=prov.start, args=(stop,), kwargs={"interval": 0}, daemon=True + ) + t.start() + time.sleep(0.2) + stop.set() + t.join(timeout=5) + + assert not t.is_alive(), "provider did not exit after stop_event was set" + assert len(calls) >= 1, "provider never called tick()" + assert calls[0].get("sync") is False + + +def test_inprocess_provider_stop_is_noop(): + """The default stop() hook is a safe no-op (the stop_event is the real + stop signal for the built-in).""" + from cron.scheduler_provider import InProcessCronScheduler + + assert InProcessCronScheduler().stop() is None From ae8fa11097e181ee61a2f5feba0c77f1d3d1d69d Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 18 Jun 2026 14:09:36 +1000 Subject: [PATCH 003/470] feat(cron): cron.provider config + plugins/cron discovery + resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the pluggable cron-scheduler refactor. Still no call-site changes; this wires up provider SELECTION with a hard safety net. Task 2.1: cron.provider config key (hermes_cli/config.py), empty = built-in. Additive key — deep-merge picks it up into existing configs with no version bump (verified: load_config() yields the key on a pre-existing config.yaml). Task 2.2: plugins/cron/__init__.py — discovery machinery cloned near-verbatim from plugins/memory/__init__.py, retargeted at CronScheduler / register_cron_scheduler. Bundled (plugins/cron//) + user (/plugins//) dirs, bundled wins collisions. The built-in is NOT discovered here — it's core, so the fallback can't be removed. Task 2.3: resolve_cron_scheduler() in cron/scheduler_provider.py — reads cron.provider and ALWAYS degrades to built-in (missing / unavailable / load error / typo all fall back with a warning). cron can never be left without a trigger. Deviation from plan: the plan's resolver snippet used cfg_get("cron.provider") (dotted-string form). The real cfg_get signature is cfg_get(cfg, *keys, default=) — corrected to cfg_get(load_config(), "cron", "provider", default=""), matching plugins/memory/__init__.py:349. Tests monkeypatch load_config (not cfg_get) so the real traversal runs. Tests: default key empty, discovery returns list, unknown load returns None, and the four resolver paths (empty→builtin, no-section→builtin, unknown→builtin, unavailable→builtin, available→used). Full tests/cron/: 453 passed; config suite green (additive key, no migration break). --- cron/scheduler_provider.py | 40 +++ hermes_cli/config.py | 8 + plugins/cron/__init__.py | 344 ++++++++++++++++++++++++++ tests/cron/test_scheduler_provider.py | 103 ++++++++ 4 files changed, 495 insertions(+) create mode 100644 plugins/cron/__init__.py diff --git a/cron/scheduler_provider.py b/cron/scheduler_provider.py index 329cf4ae8a6..45243e7749c 100644 --- a/cron/scheduler_provider.py +++ b/cron/scheduler_provider.py @@ -72,6 +72,46 @@ class CronScheduler(ABC): return None +def resolve_cron_scheduler() -> "CronScheduler": + """Return the active cron scheduler provider. + + Reads ``cron.provider`` from config. Empty/absent → built-in. A named + provider that is missing, fails to load, or reports ``is_available() == + False`` falls back to the built-in with a warning — cron must never be left + without a trigger. + """ + import logging + + logger = logging.getLogger("cron.scheduler_provider") + + name = "" + try: + from hermes_cli.config import cfg_get, load_config + name = (cfg_get(load_config(), "cron", "provider", default="") or "").strip() + except Exception: + pass + + if not name or name in ("builtin", "in-process", "inprocess"): + return InProcessCronScheduler() + + try: + from plugins.cron import load_cron_scheduler + provider = load_cron_scheduler(name) + if provider is None: + logger.warning("cron.provider '%s' not found; using built-in ticker", name) + return InProcessCronScheduler() + if not provider.is_available(): + logger.warning("cron.provider '%s' not available; using built-in ticker", name) + return InProcessCronScheduler() + logger.info("Using cron scheduler provider: %s", provider.name) + return provider + except Exception as e: + logger.warning( + "Failed to load cron.provider '%s' (%s); using built-in ticker", name, e + ) + return InProcessCronScheduler() + + class InProcessCronScheduler(CronScheduler): """Default provider: the historical in-process 60s ticker. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 356839f9903..d53393ac432 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2124,6 +2124,14 @@ DEFAULT_CONFIG = { }, "cron": { + # Active cron SCHEDULER provider (Axis B — the trigger that decides + # WHEN a due job fires). Empty string = the built-in in-process 60s + # ticker (default). Name an installed provider (plugins/cron// or + # $HERMES_HOME/plugins//) to relocate the trigger — e.g. "chronos", + # the NAS-mediated managed-cron provider for scale-to-zero deployments. + # An unknown or unavailable provider falls back to the built-in, so cron + # never loses its trigger. + "provider": "", # Wrap delivered cron responses with a header (task name) and footer # ("The agent cannot see this message"). Set to false for clean output. "wrap_response": True, diff --git a/plugins/cron/__init__.py b/plugins/cron/__init__.py new file mode 100644 index 00000000000..fbf1ac2eb08 --- /dev/null +++ b/plugins/cron/__init__.py @@ -0,0 +1,344 @@ +"""Cron scheduler provider plugin discovery. + +Scans two directories for cron scheduler provider plugins: + +1. Bundled providers: ``plugins/cron//`` (shipped with hermes-agent) +2. User-installed providers: ``$HERMES_HOME/plugins//`` + +Each subdirectory must contain ``__init__.py`` with a class implementing the +``CronScheduler`` ABC (``cron/scheduler_provider.py``). On name collisions, +bundled providers take precedence. + +This is a near-verbatim clone of ``plugins/memory/__init__.py`` — the same +discovery/loader machinery, retargeted at ``CronScheduler``. The built-in +``InProcessCronScheduler`` is NOT discovered here: it is core (lives in +``cron/scheduler_provider.py``) so the fallback can never be accidentally +removed. Only NON-default providers (e.g. "chronos") live under this directory. + +Only ONE provider can be active at a time, selected via ``cron.provider`` in +config.yaml (empty = built-in). See ``cron.scheduler_provider.resolve_cron_scheduler``. + +Usage: + from plugins.cron import discover_cron_schedulers, load_cron_scheduler + + available = discover_cron_schedulers() # [(name, desc, available), ...] + provider = load_cron_scheduler("chronos") # CronScheduler instance +""" + +from __future__ import annotations + +import importlib +import importlib.machinery +import importlib.util +import logging +import sys +from pathlib import Path +from typing import List, Optional, Tuple + +logger = logging.getLogger(__name__) + +_CRON_PLUGINS_DIR = Path(__file__).parent + +# Synthetic parent package for user-installed providers, so they don't +# collide with bundled providers in sys.modules. +_USER_NAMESPACE = "_hermes_user_cron" + + +def _register_synthetic_package(name: str, search_locations: List[str]) -> None: + """Register an empty package shell in sys.modules. + + User-installed providers import as ``_hermes_user_cron.``, a dotted + name whose parents exist nowhere on disk. Unless those parents are present + in ``sys.modules``, any relative import inside the plugin + (``from . import config``) fails with + ``ModuleNotFoundError: No module named '_hermes_user_cron'`` — the same + reason the loader already registers ``plugins`` and ``plugins.cron`` for + bundled providers. + """ + if name in sys.modules: + return + spec = importlib.machinery.ModuleSpec(name, None, is_package=True) + spec.submodule_search_locations = search_locations + sys.modules[name] = importlib.util.module_from_spec(spec) + + +# --------------------------------------------------------------------------- +# Directory helpers +# --------------------------------------------------------------------------- + +def _get_user_plugins_dir() -> Optional[Path]: + """Return ``$HERMES_HOME/plugins/`` or None if unavailable.""" + try: + from hermes_constants import get_hermes_home + d = get_hermes_home() / "plugins" + return d if d.is_dir() else None + except Exception: + return None + + +def _is_cron_provider_dir(path: Path) -> bool: + """Heuristic: does *path* look like a cron scheduler provider plugin? + + Checks for ``register_cron_scheduler`` or ``CronScheduler`` in the + ``__init__.py`` source. Cheap text scan — no import needed. + """ + init_file = path / "__init__.py" + if not init_file.exists(): + return False + try: + source = init_file.read_text(errors="replace")[:8192] + return "register_cron_scheduler" in source or "CronScheduler" in source + except Exception: + return False + + +def _iter_provider_dirs() -> List[Tuple[str, Path]]: + """Yield ``(name, path)`` for all discovered provider directories. + + Scans bundled first, then user-installed. Bundled takes precedence on + name collisions (first-seen wins via ``seen`` set). + """ + seen: set = set() + dirs: List[Tuple[str, Path]] = [] + + # 1. Bundled providers (plugins/cron//) + if _CRON_PLUGINS_DIR.is_dir(): + for child in sorted(_CRON_PLUGINS_DIR.iterdir()): + if not child.is_dir() or child.name.startswith(("_", ".")): + continue + if not (child / "__init__.py").exists(): + continue + seen.add(child.name) + dirs.append((child.name, child)) + + # 2. User-installed providers ($HERMES_HOME/plugins//) + user_dir = _get_user_plugins_dir() + if user_dir: + for child in sorted(user_dir.iterdir()): + if not child.is_dir() or child.name.startswith(("_", ".")): + continue + if child.name in seen: + continue # bundled takes precedence + if not _is_cron_provider_dir(child): + continue # skip non-cron plugins + dirs.append((child.name, child)) + + return dirs + + +def find_provider_dir(name: str) -> Optional[Path]: + """Resolve a provider name to its directory. + + Checks bundled first, then user-installed. + """ + # Bundled + bundled = _CRON_PLUGINS_DIR / name + if bundled.is_dir() and (bundled / "__init__.py").exists(): + return bundled + # User-installed + user_dir = _get_user_plugins_dir() + if user_dir: + user = user_dir / name + if user.is_dir() and _is_cron_provider_dir(user): + return user + return None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def discover_cron_schedulers() -> List[Tuple[str, str, bool]]: + """Scan bundled and user-installed directories for available providers. + + Returns list of (name, description, is_available) tuples. May be empty — + the built-in is core, not discovered here, so a fresh checkout with no + bundled non-default provider returns []. Bundled providers take precedence + on name collisions. + """ + results = [] + + for name, child in _iter_provider_dirs(): + # Read description from plugin.yaml if available + desc = "" + yaml_file = child / "plugin.yaml" + if yaml_file.exists(): + try: + import yaml + with open(yaml_file, encoding="utf-8-sig") as f: + meta = yaml.safe_load(f) or {} + desc = meta.get("description", "") + except Exception: + pass + + # Quick availability check — try loading and calling is_available() + available = True + try: + provider = _load_provider_from_dir(child) + if provider: + available = provider.is_available() + else: + available = False + except Exception: + available = False + + results.append((name, desc, available)) + + return results + + +def load_cron_scheduler(name: str) -> Optional["CronScheduler"]: # noqa: F821 + """Load and return a CronScheduler instance by name. + + Checks both bundled (``plugins/cron//``) and user-installed + (``$HERMES_HOME/plugins//``) directories. Bundled takes precedence + on name collisions. + + Returns None if the provider is not found or fails to load. + """ + provider_dir = find_provider_dir(name) + if not provider_dir: + logger.debug("Cron provider '%s' not found in bundled or user plugins", name) + return None + + try: + provider = _load_provider_from_dir(provider_dir) + if provider: + return provider + logger.warning("Cron provider '%s' loaded but no provider instance found", name) + return None + except Exception as e: + logger.warning("Failed to load cron provider '%s': %s", name, e) + return None + + +def _load_provider_from_dir(provider_dir: Path) -> Optional["CronScheduler"]: # noqa: F821 + """Import a provider module and extract the CronScheduler instance. + + The module must have either: + - A register(ctx) function (plugin-style) — we simulate a ctx + - A top-level class that extends CronScheduler — we instantiate it + """ + name = provider_dir.name + # Use a separate namespace for user-installed plugins so they don't + # collide with bundled providers in sys.modules. + _is_bundled = _CRON_PLUGINS_DIR in provider_dir.parents or provider_dir.parent == _CRON_PLUGINS_DIR + module_name = f"plugins.cron.{name}" if _is_bundled else f"{_USER_NAMESPACE}.{name}" + init_file = provider_dir / "__init__.py" + + if not init_file.exists(): + return None + + # Check if already loaded. A synthetic package shell has no __file__; + # only reuse modules that were actually loaded from disk. + cached = sys.modules.get(module_name) + if cached is not None and getattr(cached, "__file__", None): + mod = cached + else: + # Ensure the parent packages are registered (for relative imports) + for parent in ("plugins", "plugins.cron"): + if parent not in sys.modules: + parent_path = Path(__file__).parent + if parent == "plugins": + parent_path = parent_path.parent + parent_init = parent_path / "__init__.py" + if parent_init.exists(): + spec = importlib.util.spec_from_file_location( + parent, str(parent_init), + submodule_search_locations=[str(parent_path)] + ) + if spec: + parent_mod = importlib.util.module_from_spec(spec) + sys.modules[parent] = parent_mod + try: + spec.loader.exec_module(parent_mod) + except Exception: + pass + + # User-installed plugins need their synthetic parent registered the + # same way, or relative imports inside the plugin cannot resolve. + if not _is_bundled: + _register_synthetic_package(_USER_NAMESPACE, []) + + # Now load the provider module + spec = importlib.util.spec_from_file_location( + module_name, str(init_file), + submodule_search_locations=[str(provider_dir)] + ) + if not spec: + return None + + mod = importlib.util.module_from_spec(spec) + sys.modules[module_name] = mod + + # Register submodules so relative imports work + # e.g., "from ._nas_client import NasCronClient" in the chronos plugin + for sub_file in provider_dir.glob("*.py"): + if sub_file.name == "__init__.py": + continue + sub_name = sub_file.stem + full_sub_name = f"{module_name}.{sub_name}" + if full_sub_name not in sys.modules: + sub_spec = importlib.util.spec_from_file_location( + full_sub_name, str(sub_file) + ) + if sub_spec: + sub_mod = importlib.util.module_from_spec(sub_spec) + sys.modules[full_sub_name] = sub_mod + try: + sub_spec.loader.exec_module(sub_mod) + except Exception as e: + logger.debug("Failed to load submodule %s: %s", full_sub_name, e) + + try: + spec.loader.exec_module(mod) + except Exception as e: + logger.debug("Failed to exec_module %s: %s", module_name, e) + sys.modules.pop(module_name, None) + return None + + # Try register(ctx) pattern first (how our plugins are written) + if hasattr(mod, "register"): + collector = _ProviderCollector() + try: + mod.register(collector) + if collector.provider: + return collector.provider + except Exception as e: + logger.debug("register() failed for %s: %s", name, e) + + # Fallback: find a CronScheduler subclass and instantiate it + from cron.scheduler_provider import CronScheduler + for attr_name in dir(mod): + attr = getattr(mod, attr_name, None) + if (isinstance(attr, type) and issubclass(attr, CronScheduler) + and attr is not CronScheduler): + try: + return attr() + except Exception: + pass + + return None + + +class _ProviderCollector: + """Fake plugin context that captures register_cron_scheduler calls.""" + + def __init__(self): + self.provider = None + + def register_cron_scheduler(self, provider): + self.provider = provider + + # No-op for other registration methods + def register_tool(self, *args, **kwargs): + pass + + def register_hook(self, *args, **kwargs): + pass + + def register_memory_provider(self, *args, **kwargs): + pass + + def register_cli_command(self, *args, **kwargs): + pass diff --git a/tests/cron/test_scheduler_provider.py b/tests/cron/test_scheduler_provider.py index 74b3891122c..8fdbb305a0f 100644 --- a/tests/cron/test_scheduler_provider.py +++ b/tests/cron/test_scheduler_provider.py @@ -159,3 +159,106 @@ def test_inprocess_provider_stop_is_noop(): from cron.scheduler_provider import InProcessCronScheduler assert InProcessCronScheduler().stop() is None + + +# ── Phase 2: config key, discovery, resolver ───────────────────────────────── + + +def test_default_config_cron_provider_is_empty(): + """The new cron.provider key defaults to empty (= built-in).""" + from hermes_cli.config import DEFAULT_CONFIG + + assert DEFAULT_CONFIG["cron"]["provider"] == "" + + +def test_discover_cron_schedulers_returns_list(): + """Discovery returns a list. May be empty — the built-in is core, not + discovered, and no bundled non-default provider ships yet.""" + from plugins.cron import discover_cron_schedulers + + result = discover_cron_schedulers() + assert isinstance(result, list) + + +def test_load_unknown_cron_scheduler_returns_none(): + from plugins.cron import load_cron_scheduler + + assert load_cron_scheduler("does-not-exist-xyz") is None + + +def test_resolve_defaults_to_builtin(monkeypatch): + """Empty cron.provider → built-in.""" + import hermes_cli.config as cfg + from cron import scheduler_provider as sp + + monkeypatch.setattr(cfg, "load_config", lambda: {"cron": {"provider": ""}}) + prov = sp.resolve_cron_scheduler() + assert prov.name == "builtin" + + +def test_resolve_no_cron_section_falls_back_to_builtin(monkeypatch): + """Config with no cron section at all → built-in (cfg_get returns default).""" + import hermes_cli.config as cfg + from cron import scheduler_provider as sp + + monkeypatch.setattr(cfg, "load_config", lambda: {}) + prov = sp.resolve_cron_scheduler() + assert prov.name == "builtin" + + +def test_resolve_unknown_provider_falls_back_to_builtin(monkeypatch): + """A named provider that doesn't exist → built-in (cron never dies).""" + import hermes_cli.config as cfg + from cron import scheduler_provider as sp + + monkeypatch.setattr(cfg, "load_config", lambda: {"cron": {"provider": "nope-not-real"}}) + prov = sp.resolve_cron_scheduler() + assert prov.name == "builtin" + + +def test_resolve_unavailable_provider_falls_back(monkeypatch): + """A provider that loads but reports is_available()==False → built-in.""" + import hermes_cli.config as cfg + import plugins.cron as pc + from cron import scheduler_provider as sp + from cron.scheduler_provider import CronScheduler + + class Unavailable(CronScheduler): + @property + def name(self): + return "unavailable" + + def is_available(self): + return False + + def start(self, stop_event, **kw): + pass + + monkeypatch.setattr(cfg, "load_config", lambda: {"cron": {"provider": "unavailable"}}) + monkeypatch.setattr(pc, "load_cron_scheduler", lambda n: Unavailable()) + prov = sp.resolve_cron_scheduler() + assert prov.name == "builtin" + + +def test_resolve_available_provider_is_used(monkeypatch): + """A provider that loads and is available is returned (not the fallback).""" + import hermes_cli.config as cfg + import plugins.cron as pc + from cron import scheduler_provider as sp + from cron.scheduler_provider import CronScheduler + + class Fake(CronScheduler): + @property + def name(self): + return "fake" + + def is_available(self): + return True + + def start(self, stop_event, **kw): + pass + + monkeypatch.setattr(cfg, "load_config", lambda: {"cron": {"provider": "fake"}}) + monkeypatch.setattr(pc, "load_cron_scheduler", lambda n: Fake()) + prov = sp.resolve_cron_scheduler() + assert prov.name == "fake" From abbd8646eb511833500377799f5853d8d4eda5a2 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 18 Jun 2026 14:14:53 +1000 Subject: [PATCH 004/470] feat(gateway,desktop): start cron via resolved CronScheduler provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 — rebind both ticker call sites to resolve_cron_scheduler(). Default (built-in) path is byte-identical; Phase 0 characterization tests + the full gateway suite (6919) stay green. Task 3.1: split gateway/run.py _start_cron_ticker into: - _start_gateway_housekeeping() — the gateway-only chores (channel-dir refresh, image/doc cache cleanup, paste sweep, curator poll), now on their own loop/thread, independent of which cron provider is active. - _start_cron_ticker() — kept as a DEPRECATED shim that runs only the built-in InProcessCronScheduler().start(), preserving the symbol for hermes_cli/debug.py and the Phase 0 characterization test. Task 3.2: start_gateway() resolves the provider and runs provider.start() in the 'cron-scheduler' thread, plus a second 'gateway-housekeeping' thread; teardown sets the shared cron_stop, calls provider.stop(), joins both. Task 3.3: desktop _start_desktop_cron_ticker() swapped its inline tick loop for resolve_cron_scheduler().start() (no adapters/loop — desktop has none). The provider owns ONLY the cron tick (so an external scale-to-zero provider with no 60s loop fits); gateway housekeeping is decoupled from the cron trigger. Both threads share cron_stop. Verified: full tests/cron/ (453) + full tests/gateway/ (6919) green. Manual gateway smoke (Task 3.4) is operator-run, pending. --- gateway/run.py | 87 +++++++++++++++++++++++++++------------- hermes_cli/web_server.py | 25 +++++------- 2 files changed, 70 insertions(+), 42 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 4b41cfc6aec..2f5900e92f5 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -16454,21 +16454,20 @@ def _run_planned_stop_watcher( stop_event.wait(poll_interval) -def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60): - """ - Background thread that ticks the cron scheduler at a regular interval. - - Runs inside the gateway process so cronjobs fire automatically without - needing a separate `hermes cron daemon` or system cron entry. +def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60): + """Background thread for gateway-only periodic chores (NOT cron). - When ``adapters`` and ``loop`` are provided, passes them through to the - cron delivery path so live adapters can be used for E2EE rooms. + Split out of the historical ``_start_cron_ticker`` so the cron *trigger* + can live behind the ``CronScheduler`` provider (built-in or external) while + these gateway-specific chores keep running independently of which provider + fires cron. An external scale-to-zero provider has no 60s loop at all, but + this housekeeping still wants its hourly cadence — so it owns its own loop. - Also refreshes the channel directory every 5 minutes and prunes the - image/audio/document cache + expired ``hermes debug share`` pastes - once per hour. + Refreshes the channel directory every 5 minutes and prunes the + image/audio/document cache + expired ``hermes debug share`` pastes once per + hour, and polls the curator hourly (its inner gate enforces the real + weekly cadence). """ - from cron.scheduler import tick as cron_tick from gateway.platforms.base import cleanup_image_cache, cleanup_document_cache from hermes_cli.debug import _sweep_expired_pastes @@ -16477,14 +16476,9 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in PASTE_SWEEP_EVERY = 60 # ticks — once per hour CURATOR_EVERY = 60 # ticks — poll hourly (inner gate handles the real cadence) - logger.info("Cron ticker started (interval=%ds)", interval) + logger.info("Gateway housekeeping started (interval=%ds)", interval) tick_count = 0 while not stop_event.is_set(): - try: - cron_tick(verbose=False, adapters=adapters, loop=loop, sync=False) - except Exception as e: - logger.debug("Cron tick error: %s", e) - tick_count += 1 if tick_count % CHANNEL_DIR_EVERY == 0 and adapters: @@ -16492,9 +16486,9 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in from gateway.channel_directory import build_channel_directory if loop is not None: # build_channel_directory is async (Slack web calls), and - # this ticker runs in a background thread. Schedule onto - # the gateway event loop and wait briefly for completion - # so refresh failures are still logged via the except. + # this runs in a background thread. Schedule onto the + # gateway event loop and wait briefly for completion so + # refresh failures are still logged via the except. fut = safe_schedule_threadsafe( build_channel_directory(adapters), loop, logger=logger, @@ -16530,7 +16524,7 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in except Exception as e: logger.debug("Paste sweep error: %s", e) - # Curator — piggy-back on the existing cron ticker so long-running + # Curator — piggy-back on the housekeeping loop so long-running # gateways get weekly skill maintenance without needing restarts. # maybe_run_curator() is internally gated by config.interval_hours # (7 days by default), so CURATOR_EVERY is just the poll rate — the @@ -16546,7 +16540,22 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in logger.debug("Curator tick error: %s", e) stop_event.wait(timeout=interval) - logger.info("Cron ticker stopped") + logger.info("Gateway housekeeping stopped") + + +def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, interval: int = 60): + """DEPRECATED shim — preserved for backward compatibility. + + The cron trigger now lives behind the ``CronScheduler`` provider + (``cron.scheduler_provider``); the gateway resolves a provider and runs its + ``start()`` directly (see ``start_gateway``). This shim runs ONLY the + built-in in-process tick loop, exactly as before, for any external caller + or test that still references this symbol (e.g. hermes_cli/debug.py). It no + longer runs gateway housekeeping — that moved to + ``_start_gateway_housekeeping``. + """ + from cron.scheduler_provider import InProcessCronScheduler + InProcessCronScheduler().start(stop_event, adapters=adapters, loop=loop, interval=interval) async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = False, verbosity: Optional[int] = 0) -> bool: @@ -16942,17 +16951,34 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = logger.error("Gateway exiting cleanly: %s", runner.exit_reason) return True - # Start background cron ticker so scheduled jobs fire automatically. - # Pass the event loop so cron delivery can use live adapters (E2EE support). + # Start the background cron scheduler via the resolved provider so + # scheduled jobs fire automatically. The built-in provider is the + # historical in-process 60s ticker; an external provider (e.g. chronos) + # may arm a schedule and return. Pass the event loop so cron delivery can + # use live adapters (E2EE support). + from cron.scheduler_provider import resolve_cron_scheduler cron_stop = threading.Event() + cron_provider = resolve_cron_scheduler() cron_thread = threading.Thread( - target=_start_cron_ticker, + target=cron_provider.start, args=(cron_stop,), kwargs={"adapters": runner.adapters, "loop": asyncio.get_running_loop()}, daemon=True, - name="cron-ticker", + name="cron-scheduler", ) cron_thread.start() + + # Gateway-only periodic housekeeping (channel dir, cache cleanup, paste + # sweep, curator) — runs independently of which cron provider is active. + # Shares cron_stop as the shutdown signal. + housekeeping_thread = threading.Thread( + target=_start_gateway_housekeeping, + args=(cron_stop,), + kwargs={"adapters": runner.adapters, "loop": asyncio.get_running_loop()}, + daemon=True, + name="gateway-housekeeping", + ) + housekeeping_thread.start() # Wait for shutdown await runner.wait_for_shutdown() @@ -16962,9 +16988,14 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = logger.error("Gateway exiting with failure: %s", runner.exit_reason) return False - # Stop cron ticker cleanly + # Stop cron scheduler + housekeeping cleanly cron_stop.set() + try: + cron_provider.stop() + except Exception as e: + logger.debug("Cron provider stop() error: %s", e) cron_thread.join(timeout=5) + housekeeping_thread.join(timeout=5) # Stop the planned-stop watcher (daemon=True so this is belt-and-suspenders). _planned_stop_watcher_stop.set() diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 70f39162cf8..768084eba36 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -113,23 +113,20 @@ def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60 The scheduler tick loop normally lives in ``hermes gateway run`` — but the desktop app spawns a ``hermes dashboard`` backend, not a gateway, so a cron - a user creates in the app would never fire. We run a minimal ticker here - (no live adapters; delivery falls back to the per-platform send path). + a user creates in the app would never fire. We run the resolved cron + scheduler provider here (no live adapters; delivery falls back to the + per-platform send path). - Cross-process safe: ``cron.scheduler.tick`` takes the ``cron/.tick.lock`` - file lock, so this never double-fires alongside a real gateway on the same - HERMES_HOME — whichever process grabs the lock first wins the tick. + Cross-process safe: the built-in provider's ``cron.scheduler.tick`` takes + the ``cron/.tick.lock`` file lock, so this never double-fires alongside a + real gateway on the same HERMES_HOME — whichever process grabs the lock + first wins the tick. """ - from cron.scheduler import tick as cron_tick + from cron.scheduler_provider import resolve_cron_scheduler - _log.info("Desktop cron ticker started (interval=%ds)", interval) - # Tick once up front (catches jobs due at launch), then on the interval. - while not stop_event.is_set(): - try: - cron_tick(verbose=False, sync=False) - except Exception as e: - _log.debug("Desktop cron tick error: %s", e) - stop_event.wait(interval) + provider = resolve_cron_scheduler() + _log.info("Desktop cron scheduler started (provider=%s, interval=%ds)", provider.name, interval) + provider.start(stop_event, interval=interval) @asynccontextmanager From bfb6e0bb33e61cef064ab5b41f91716bc02a474b Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 18 Jun 2026 14:18:31 +1000 Subject: [PATCH 005/470] docs(cron): document CronScheduler provider + cron.provider key Phase 3.5. cron-internals.md gateway-integration section now describes the pluggable trigger (resolve_cron_scheduler, built-in default, plugins/cron discovery, the never-without-a-trigger fallback, and the trigger-vs-execution split). cli-commands.md notes cron.provider near the hermes cron entry. --- .../docs/developer-guide/cron-internals.md | 25 ++++++++++++++++++- website/docs/reference/cli-commands.md | 7 ++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/website/docs/developer-guide/cron-internals.md b/website/docs/developer-guide/cron-internals.md index bad59645dbc..c895d339b09 100644 --- a/website/docs/developer-guide/cron-internals.md +++ b/website/docs/developer-guide/cron-internals.md @@ -102,7 +102,30 @@ tick() ### Gateway Integration -In gateway mode, the scheduler runs in a dedicated background thread (`_start_cron_ticker` in `gateway/run.py`) that calls `scheduler.tick()` every 60 seconds alongside message handling. +In gateway mode, the cron **trigger** (the part that decides *when* a due job +fires — "Axis B") is selected through a pluggable `CronScheduler` provider. The +gateway calls `resolve_cron_scheduler()` (`cron/scheduler_provider.py`) and runs +the resolved provider's `start()` in a dedicated background thread, alongside a +separate gateway-housekeeping thread. + +The active provider is chosen by the `cron.provider` config key: + +- **empty (default)** → the built-in `InProcessCronScheduler`, which runs the + historical in-process loop calling `scheduler.tick()` every 60 seconds. This + is byte-identical to the pre-provider behavior. +- **a named provider** (e.g. `chronos`, a managed-cron provider for + scale-to-zero deployments) → discovered from `plugins/cron//` or + `$HERMES_HOME/plugins//`. + +If a named provider is missing, fails to load, or reports `is_available() == +False`, the resolver falls back to the built-in with a warning — **cron is +never left without a trigger.** The built-in provider lives in core +(`cron/scheduler_provider.py`), not in `plugins/`, so the fallback can't be +accidentally removed. + +What "firing" *means* (job execution + delivery) is unchanged and shared by all +providers — it stays in `scheduler.run_job()` / `scheduler._deliver_result()`. +A provider only controls the trigger, never execution. In CLI mode, cron jobs only fire when `hermes cron` commands are run or during active CLI sessions. diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 3071ac0e5fc..f0fe67d4349 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -533,6 +533,13 @@ hermes cron | `status` | Check whether the cron scheduler is running. | | `tick` | Run due jobs once and exit. | +The cron **trigger** is pluggable via the `cron.provider` config key. Empty +(the default) uses the built-in in-process ticker. A named provider (e.g. +`chronos`, a managed-cron provider for scale-to-zero deployments) is discovered +from `plugins/cron//` or `$HERMES_HOME/plugins//`; an unknown or +unavailable provider falls back to the built-in, so cron is never left without +a trigger. See the [cron internals](../developer-guide/cron-internals.md#gateway-integration) doc. + ## `hermes kanban` ```bash From 58b19a4f6988f2fda2cddb5c620628afce750a36 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 18 Jun 2026 14:26:29 +1000 Subject: [PATCH 006/470] refactor(cron): extract run_one_job shared firing helper from tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4A. Factor tick's per-job closure (_process_job: execute → save → deliver → mark) into a module-level run_one_job(job, *, adapters, loop, verbose) so the external Chronos provider's fire_due (Phase 4D) reuses the IDENTICAL body — no duplicated correctness. tick's _process_job is now a thin wrapper calling run_one_job; the pool/in-flight-guard/contextvars dispatch logic is unchanged. run_one_job fires ONE given job; it does NOT decide due-ness, claim, or compute next_run (tick advances next_run_at under the file lock; an external provider claims via the store CAS in Phase 4C). Pure refactor, no behavior change. TDD: test_run_one_job.py characterizes the sequence through tick() first (test_tick_process_job_sequence, passed pre-extraction), then unit-tests the helper directly: success sequence, [SILENT]→skip delivery, empty-response soft failure (#8585), failed-job-still-delivers, exception→mark-failed. Verified: tests/cron/ 459 passed (was 453 + 6 new); tick behavior unchanged. --- cron/scheduler.py | 105 +++++++++++++++++------------ tests/cron/test_run_one_job.py | 119 +++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 42 deletions(-) create mode 100644 tests/cron/test_run_one_job.py diff --git a/cron/scheduler.py b/cron/scheduler.py index 35906996619..9bab59456ea 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -1967,6 +1967,64 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: logger.debug("Job '%s': failed to reap stale auxiliary clients: %s", job_id, e) +def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) -> bool: + """Run ONE due job end-to-end: execute → save output → deliver → mark. + + This is the shared firing body extracted from ``tick``'s per-job closure so + that BOTH the built-in ticker and an external provider's ``fire_due`` (e.g. + Chronos) run the identical sequence — no duplicated correctness. + + It does NOT decide whether the job is due, claim it, or compute the next + run — those are the caller's concern (``tick`` advances ``next_run_at`` + under the file lock before dispatch; an external provider claims via the + store CAS). This function only fires the given job once. + + Returns True if the job was processed (even if the job itself failed — + failure is recorded via ``mark_job_run``), False only if processing raised. + """ + try: + success, output, final_response, error = run_job(job) + + output_file = save_job_output(job["id"], output) + if verbose: + logger.info("Output saved to: %s", output_file) + + # Deliver the final response to the origin/target chat. + # If the agent responded with [SILENT], skip delivery (but + # output is already saved above). Failed jobs always deliver. + deliver_content = final_response if success else f"⚠️ Cron job '{job.get('name', job['id'])}' failed:\n{error}" + # Treat whitespace-only final responses the same as empty + # responses: do not deliver a blank message, and let the + # empty-response guard below mark the run as a soft failure. + should_deliver = bool(deliver_content.strip()) + if should_deliver and success and SILENT_MARKER in deliver_content.strip().upper(): + logger.info("Job '%s': agent returned %s — skipping delivery", job["id"], SILENT_MARKER) + should_deliver = False + + delivery_error = None + if should_deliver: + try: + delivery_error = _deliver_result(job, deliver_content, adapters=adapters, loop=loop) + except Exception as de: + delivery_error = str(de) + logger.error("Delivery failed for job %s: %s", job["id"], de) + + # Treat empty final_response as a soft failure so last_status + # is not "ok" — the agent ran but produced nothing useful. + # (issue #8585) + if success and not final_response.strip(): + success = False + error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)" + + mark_job_run(job["id"], success, error, delivery_error=delivery_error) + return True + + except Exception as e: + logger.error("Error processing job %s: %s", job['id'], e) + mark_job_run(job["id"], False, str(e)) + return False + + def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> int: """ Check and run all due jobs. @@ -2045,48 +2103,11 @@ def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> i ) def _process_job(job: dict) -> bool: - """Run one due job end-to-end: execute, save, deliver, mark.""" - try: - success, output, final_response, error = run_job(job) - - output_file = save_job_output(job["id"], output) - if verbose: - logger.info("Output saved to: %s", output_file) - - # Deliver the final response to the origin/target chat. - # If the agent responded with [SILENT], skip delivery (but - # output is already saved above). Failed jobs always deliver. - deliver_content = final_response if success else f"⚠️ Cron job '{job.get('name', job['id'])}' failed:\n{error}" - # Treat whitespace-only final responses the same as empty - # responses: do not deliver a blank message, and let the - # empty-response guard below mark the run as a soft failure. - should_deliver = bool(deliver_content.strip()) - if should_deliver and success and SILENT_MARKER in deliver_content.strip().upper(): - logger.info("Job '%s': agent returned %s — skipping delivery", job["id"], SILENT_MARKER) - should_deliver = False - - delivery_error = None - if should_deliver: - try: - delivery_error = _deliver_result(job, deliver_content, adapters=adapters, loop=loop) - except Exception as de: - delivery_error = str(de) - logger.error("Delivery failed for job %s: %s", job["id"], de) - - # Treat empty final_response as a soft failure so last_status - # is not "ok" — the agent ran but produced nothing useful. - # (issue #8585) - if success and not final_response.strip(): - success = False - error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)" - - mark_job_run(job["id"], success, error, delivery_error=delivery_error) - return True - - except Exception as e: - logger.error("Error processing job %s: %s", job['id'], e) - mark_job_run(job["id"], False, str(e)) - return False + """Run one due job end-to-end. Thin wrapper around the shared + module-level ``run_one_job`` so ``tick`` and external providers + (Chronos ``fire_due``) use the identical execute→save→deliver→mark + body.""" + return run_one_job(job, adapters=adapters, loop=loop, verbose=verbose) # Partition due jobs: those with a per-job workdir mutate # os.environ["TERMINAL_CWD"] inside run_job, which is process-global — diff --git a/tests/cron/test_run_one_job.py b/tests/cron/test_run_one_job.py new file mode 100644 index 00000000000..7da6b1c14f4 --- /dev/null +++ b/tests/cron/test_run_one_job.py @@ -0,0 +1,119 @@ +"""Characterization + unit tests for the `run_one_job` shared helper (Phase 4A). + +`tick`'s per-job body (`_process_job`) is the execute → save → deliver → mark +sequence that fires ONE due job. Phase 4A extracts it into a module-level +`run_one_job(job, *, adapters=None, loop=None, verbose=False)` so the external +Chronos provider's `fire_due` can reuse the IDENTICAL body — no duplicated +correctness. + +The first test characterizes the sequence as driven through `tick()` (proving +the extraction didn't change `tick`'s behavior); the rest unit-test the +extracted helper directly. +""" +import cron.scheduler as s + + +def _patch_pipeline(monkeypatch, *, success=True, output="out", final="final response", + error=None, silent_marker_in=None): + """Patch the job pipeline primitives and record the call order.""" + calls = [] + + def fake_run_job(job): + calls.append(("run_job", job["id"])) + fr = final if silent_marker_in is None else silent_marker_in + return (success, output, fr, error) + + def fake_save(jid, out): + calls.append(("save", jid)) + return f"/tmp/{jid}.txt" + + def fake_deliver(job, content, adapters=None, loop=None): + calls.append(("deliver", job["id"])) + return None + + def fake_mark(jid, ok, err=None, delivery_error=None): + calls.append(("mark", jid, ok)) + + monkeypatch.setattr(s, "run_job", fake_run_job) + monkeypatch.setattr(s, "save_job_output", fake_save) + monkeypatch.setattr(s, "_deliver_result", fake_deliver) + monkeypatch.setattr(s, "mark_job_run", fake_mark) + return calls + + +def test_tick_process_job_sequence(monkeypatch): + """Characterization: a single due job driven through tick() runs the + sequence run_job → save → deliver → mark, in that order.""" + calls = _patch_pipeline(monkeypatch) + monkeypatch.setattr(s, "get_due_jobs", lambda: [{"id": "j1", "name": "t"}]) + monkeypatch.setattr(s, "advance_next_run", lambda jid: True) + + s.tick(verbose=False, sync=True) + + assert [c[0] for c in calls] == ["run_job", "save", "deliver", "mark"] + assert calls[-1] == ("mark", "j1", True) + + +def test_run_one_job_success_sequence(monkeypatch): + """The extracted helper runs the same execute→save→deliver→mark sequence + for a successful job.""" + calls = _patch_pipeline(monkeypatch) + + ok = s.run_one_job({"id": "j2", "name": "t"}) + + assert ok is True + assert [c[0] for c in calls] == ["run_job", "save", "deliver", "mark"] + assert calls[-1] == ("mark", "j2", True) + + +def test_run_one_job_silent_skips_delivery(monkeypatch): + """A [SILENT] final response saves output + marks the run but does NOT + deliver.""" + calls = _patch_pipeline(monkeypatch, silent_marker_in="[SILENT]") + + s.run_one_job({"id": "j3", "name": "t"}) + + kinds = [c[0] for c in calls] + assert "run_job" in kinds and "save" in kinds and "mark" in kinds + assert "deliver" not in kinds + + +def test_run_one_job_empty_response_is_soft_failure(monkeypatch): + """An empty final response marks the run as NOT ok (issue #8585).""" + calls = _patch_pipeline(monkeypatch, final=" ") + + s.run_one_job({"id": "j4", "name": "t"}) + + mark = [c for c in calls if c[0] == "mark"][0] + assert mark == ("mark", "j4", False) + + +def test_run_one_job_failed_job_delivers_error(monkeypatch): + """A failed job still delivers (the error notice) and marks not-ok.""" + calls = _patch_pipeline(monkeypatch, success=False, final="", error="boom") + + s.run_one_job({"id": "j5", "name": "t"}) + + kinds = [c[0] for c in calls] + assert "deliver" in kinds # failures always deliver + mark = [c for c in calls if c[0] == "mark"][0] + assert mark == ("mark", "j5", False) + + +def test_run_one_job_exception_marks_failure(monkeypatch): + """If run_job raises, the helper marks the run failed and returns False + rather than propagating.""" + def boom(job): + raise RuntimeError("kaboom") + + monkeypatch.setattr(s, "run_job", boom) + marks = [] + monkeypatch.setattr( + s, "mark_job_run", + lambda jid, ok, err=None, delivery_error=None: marks.append((jid, ok)), + ) + + ok = s.run_one_job({"id": "j6", "name": "t"}) + + assert ok is False + assert marks == [("j6", False)] From 6ff5fd373b6695b1ed7b7e0f63fde6a8430d16e6 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 18 Jun 2026 14:30:31 +1000 Subject: [PATCH 007/470] feat(cron): additive CronScheduler hooks (on_jobs_changed/fire_due/reconcile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4B. Three NON-abstract hooks on the CronScheduler ABC, all with built-in-safe defaults so the built-in inherits them without overriding and test_abc_growth_stays_additive stays green (required surface still {name, start}): - on_jobs_changed(): post-mutation reconcile hook. Built-in no-op. - fire_due(job_id): claim the job via the store CAS (claim_job_for_fire, Phase 4C) then run it through the shared run_one_job (Phase 4A). Returns False if the claim is lost or the job vanished (repeat-N exhausted between arm and fire). The inbound webhook (Phase 4E) routes here. - reconcile(): converge the external registry toward jobs.json. Built-in no-op. fire_due imports claim_job_for_fire/get_job/run_one_job INSIDE the method, so this commits cleanly before Phase 4C lands claim_job_for_fire (import-time is unaffected; tests monkeypatch it with raising=False). Tests: required-surface-unchanged guard, built-in inherits no-op defaults, and fire_due's three paths (claim+run, lost-claim→no-run, missing-job→no-run). tests/cron/ green (20 in test_scheduler_provider.py). --- cron/scheduler_provider.py | 39 +++++++++++++++ tests/cron/test_scheduler_provider.py | 70 +++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/cron/scheduler_provider.py b/cron/scheduler_provider.py index 45243e7749c..50bca6b892b 100644 --- a/cron/scheduler_provider.py +++ b/cron/scheduler_provider.py @@ -71,6 +71,45 @@ class CronScheduler(ABC): resources (queue consumers, HTTP servers).""" return None + # --- Optional hooks for external providers (added Phase 4). -------------- + # All default-safe so the built-in inherits working behavior without + # overriding. Keep these NON-abstract — see test_abc_growth_stays_additive. + + def on_jobs_changed(self) -> None: + """Called after a successful store mutation (create/update/remove/ + pause/resume). External providers reconcile their registry here (e.g. + Chronos re-provisions/cancels the affected one-shot via NAS). + Built-in: no-op (it re-reads jobs.json on every tick).""" + return None + + def fire_due(self, job_id: str, *, adapters: Any = None, loop: Any = None) -> bool: + """Run a single job NOW via the shared orchestrator. Called by the + inbound fire webhook when an external scheduler signals a job is due. + + The default claims the job with a store-level compare-and-set + (multi-machine at-most-once), then runs it via the shared + ``run_one_job`` body. Built-in never calls this (it has its own tick + loop); an external provider routes its inbound fire here. + + Returns True if THIS caller claimed and ran the job, False if the claim + was lost (another machine/retry won it) or the job no longer exists. + """ + from cron.jobs import claim_job_for_fire, get_job + from cron.scheduler import run_one_job + + if not claim_job_for_fire(job_id): + return False # another machine already claimed this fire + job = get_job(job_id) + if job is None: + return False # job removed (e.g. repeat-N exhausted) between arm and fire + return run_one_job(job, adapters=adapters, loop=loop) + + def reconcile(self) -> None: + """Converge the external registry toward jobs.json (the desired state): + arm missing one-shots, cancel orphaned ones, re-arm changed times. + Built-in: no-op.""" + return None + def resolve_cron_scheduler() -> "CronScheduler": """Return the active cron scheduler provider. diff --git a/tests/cron/test_scheduler_provider.py b/tests/cron/test_scheduler_provider.py index 8fdbb305a0f..2b2e159e2a3 100644 --- a/tests/cron/test_scheduler_provider.py +++ b/tests/cron/test_scheduler_provider.py @@ -262,3 +262,73 @@ def test_resolve_available_provider_is_used(monkeypatch): monkeypatch.setattr(pc, "load_cron_scheduler", lambda n: Fake()) prov = sp.resolve_cron_scheduler() assert prov.name == "fake" + + +# ── Phase 4B: additive hooks (on_jobs_changed / fire_due / reconcile) ──────── + + +def test_hooks_did_not_change_required_surface(): + """The additive hooks must NOT become abstractmethods — the Phase-1 guard + still holds (required surface is exactly name + start).""" + from cron.scheduler_provider import CronScheduler + + assert set(CronScheduler.__abstractmethods__) == {"name", "start"} + + +def test_builtin_inherits_hook_defaults(): + """The built-in inherits no-op defaults for the new hooks (it never needs + to override them).""" + from cron.scheduler_provider import InProcessCronScheduler + + p = InProcessCronScheduler() + assert p.on_jobs_changed() is None + assert p.reconcile() is None + # built-in does not override fire_due; it simply isn't called for built-in. + assert hasattr(p, "fire_due") + + +def test_fire_due_default_claims_then_runs(monkeypatch): + """The default fire_due claims via the store CAS, fetches the job, and runs + it through the shared run_one_job body.""" + import cron.jobs as jobs + import cron.scheduler as sched + from cron.scheduler_provider import InProcessCronScheduler + + ran = [] + monkeypatch.setattr(jobs, "claim_job_for_fire", lambda jid: True, raising=False) + monkeypatch.setattr(jobs, "get_job", lambda jid: {"id": jid, "name": "t"}) + monkeypatch.setattr(sched, "run_one_job", lambda job, **kw: ran.append(job["id"]) or True) + + assert InProcessCronScheduler().fire_due("j1") is True + assert ran == ["j1"] + + +def test_fire_due_lost_claim_does_not_run(monkeypatch): + """If the CAS claim is lost (another machine/retry won), fire_due returns + False and never runs the job.""" + import cron.jobs as jobs + import cron.scheduler as sched + from cron.scheduler_provider import InProcessCronScheduler + + ran = [] + monkeypatch.setattr(jobs, "claim_job_for_fire", lambda jid: False, raising=False) + monkeypatch.setattr(sched, "run_one_job", lambda job, **kw: ran.append(job["id"]) or True) + + assert InProcessCronScheduler().fire_due("j1") is False + assert ran == [] + + +def test_fire_due_missing_job_does_not_run(monkeypatch): + """If the job vanished between arm and fire (e.g. repeat-N exhausted), + fire_due returns False without running.""" + import cron.jobs as jobs + import cron.scheduler as sched + from cron.scheduler_provider import InProcessCronScheduler + + ran = [] + monkeypatch.setattr(jobs, "claim_job_for_fire", lambda jid: True, raising=False) + monkeypatch.setattr(jobs, "get_job", lambda jid: None) + monkeypatch.setattr(sched, "run_one_job", lambda job, **kw: ran.append(job["id"]) or True) + + assert InProcessCronScheduler().fire_due("gone") is False + assert ran == [] From b01eee0c77e182f1c6f9d101c5851fbe4b5efae3 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 18 Jun 2026 14:34:34 +1000 Subject: [PATCH 008/470] feat(cron): store-level CAS claim for multi-machine at-most-once fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4C. claim_job_for_fire(job_id, *, claim_ttl_seconds=300) in cron/jobs.py: under the existing _jobs_lock() file lock, claim a job for a single external fire so that across N gateway replicas exactly ONE wins. Single-machine deployments always win (unaffected). Semantics: - missing / disabled / paused job → False. - a fresh fire_claim (younger than claim_ttl_seconds) already present → False (someone else holds it). Stale claim (crashed winner) → overwrite, so a job is never wedged forever. - on win: stamp fire_claim={at, by:_machine_id()}; for recurring (cron/interval) advance next_run_at (mirrors advance_next_run's at-most-once bump so a stale re-delivery can't re-fire); one-shots keep next_run_at but the fresh claim blocks a duplicate retry for the same fire. - mark_job_run now clears fire_claim on completion so a re-armed recurring job is claimable again next fire. _machine_id() (HERMES_MACHINE_ID env, else hostname:pid) is attribution-only; correctness is the file lock + fresh-claim check, not the id. This is consumed by CronScheduler.fire_due (Phase 4B). tick is untouched — it still uses advance_next_run, so the built-in single-machine path is unaffected. Tests (real store, temp HERMES_HOME): claim-once-then-block + next_run advance, one-shot no-double-claim, unknown→False, paused→False, stale-claim reclaimable, mark_job_run clears the claim (recurring re-claimable). tests/cron/ 470 passed. --- cron/jobs.py | 68 ++++++++++++++++++++++ tests/cron/test_claim_job_for_fire.py | 84 +++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/cron/test_claim_job_for_fire.py diff --git a/cron/jobs.py b/cron/jobs.py index 178bd0fad81..2f44608d649 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -976,6 +976,9 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None, job["last_error"] = error if not success else None # Track delivery failures separately — cleared on successful delivery job["last_delivery_error"] = delivery_error + # Clear any external-fire claim so a re-armed recurring job can + # be claimed again on its next fire (Phase 4C CAS). + job["fire_claim"] = None # Increment completed count if job.get("repeat"): @@ -1057,6 +1060,71 @@ def advance_next_run(job_id: str) -> bool: return False +def _machine_id() -> str: + """Stable-ish identifier for claim attribution/debugging (NOT correctness). + + Uses ``HERMES_MACHINE_ID`` if set, else hostname + pid. The CAS correctness + comes from the file lock + the fresh-claim check, not from this value. + """ + explicit = os.getenv("HERMES_MACHINE_ID", "").strip() + if explicit: + return explicit + try: + import socket + host = socket.gethostname() + except Exception: + host = "unknown" + return f"{host}:{os.getpid()}" + + +def claim_job_for_fire(job_id: str, *, claim_ttl_seconds: int = 300) -> bool: + """Atomically claim a job for a single external 'fire' (multi-machine + at-most-once). Returns True iff THIS caller won the claim. + + Used by the external-provider fire path (``CronScheduler.fire_due``) when an + external scheduler (Chronos) signals a job is due across N gateway replicas: + exactly one wins. Single-machine deployments always win. + + Under the file lock: reject if the job is missing/disabled/paused. If a + fresh claim (younger than ``claim_ttl_seconds``) already exists, lose. + Otherwise stamp a ``fire_claim`` and, for recurring jobs, advance + ``next_run_at`` (mirrors ``advance_next_run``'s at-most-once bump so a stale + re-delivery for the old time can't re-fire). One-shots keep ``next_run_at`` + but the fresh ``fire_claim`` blocks a duplicate retry for the same fire. + ``mark_job_run`` clears the claim on completion so a re-armed recurring job + is claimable again next fire. + + The stale-claim TTL means a machine that crashed after claiming but before + completing doesn't wedge the job forever — after the TTL another fire can + reclaim it. + """ + with _jobs_lock(): + jobs = load_jobs() + for job in jobs: + if job["id"] != job_id: + continue + if not job.get("enabled", True) or job.get("state") == "paused": + return False + now = _hermes_now() + existing = job.get("fire_claim") + if existing: + try: + claimed_at = _ensure_aware(datetime.fromisoformat(existing["at"])) + if (now - claimed_at).total_seconds() < claim_ttl_seconds: + return False # someone holds a fresh claim + except Exception: + pass # malformed claim → overwrite + job["fire_claim"] = {"at": now.isoformat(), "by": _machine_id()} + kind = job.get("schedule", {}).get("kind") + if kind in {"cron", "interval"}: + nxt = compute_next_run(job["schedule"], now.isoformat()) + if nxt: + job["next_run_at"] = nxt + save_jobs(jobs) + return True + return False + + def get_due_jobs() -> List[Dict[str, Any]]: """Get all jobs that are due to run now. diff --git a/tests/cron/test_claim_job_for_fire.py b/tests/cron/test_claim_job_for_fire.py new file mode 100644 index 00000000000..abbe969eb04 --- /dev/null +++ b/tests/cron/test_claim_job_for_fire.py @@ -0,0 +1,84 @@ +"""Tests for the store-level CAS fire claim (Phase 4C). + +`claim_job_for_fire` gives multi-machine at-most-once semantics when an external +scheduler (Chronos) fires a job: across N gateway replicas, exactly ONE wins the +claim for a given fire. Single-machine deployments always win (unaffected). + +These exercise the real store against a temp HERMES_HOME (no mocks) per the +E2E-over-mocks discipline for file-touching code. +""" +import pytest + + +@pytest.fixture +def temp_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME so jobs.json doesn't touch the real store.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + # cron.jobs caches no home at import; get_hermes_home() reads the env live. + yield tmp_path + + +def test_claim_succeeds_once_then_blocks(temp_home): + """First claim for a fire wins; a second claim for the same fire loses, and + next_run_at is advanced (a re-delivery for the old time can't re-fire).""" + from cron.jobs import create_job, claim_job_for_fire, get_job + + job = create_job(prompt="x", schedule="every 5m", name="t") + jid = job["id"] + before = get_job(jid)["next_run_at"] + + assert claim_job_for_fire(jid) is True + assert claim_job_for_fire(jid) is False + assert get_job(jid)["next_run_at"] != before + + +def test_claim_oneshot_cannot_be_double_claimed(temp_home): + """A one-shot can't be double-claimed (the fresh claim blocks the retry).""" + from cron.jobs import create_job, claim_job_for_fire + + job = create_job(prompt="x", schedule="30m", name="o") + assert claim_job_for_fire(job["id"]) is True + assert claim_job_for_fire(job["id"]) is False + + +def test_claim_unknown_job_returns_false(temp_home): + from cron.jobs import claim_job_for_fire + + assert claim_job_for_fire("nope-does-not-exist") is False + + +def test_claim_paused_job_returns_false(temp_home): + """A paused job can't be claimed.""" + from cron.jobs import create_job, claim_job_for_fire, pause_job + + job = create_job(prompt="x", schedule="every 5m", name="p") + pause_job(job["id"]) + assert claim_job_for_fire(job["id"]) is False + + +def test_stale_claim_is_reclaimable(temp_home, monkeypatch): + """A claim older than the TTL is overwritten — the fire isn't stuck forever + if the winning machine crashed before mark_job_run cleared the claim.""" + from cron.jobs import create_job, claim_job_for_fire + + job = create_job(prompt="x", schedule="every 5m", name="s") + jid = job["id"] + assert claim_job_for_fire(jid) is True + # With a 0s TTL, the existing claim is always considered stale. + assert claim_job_for_fire(jid, claim_ttl_seconds=0) is True + + +def test_mark_job_run_clears_claim(temp_home): + """After a recurring job completes, its claim is cleared so the next fire + can be claimed again.""" + from cron.jobs import create_job, claim_job_for_fire, mark_job_run, get_job + + job = create_job(prompt="x", schedule="every 5m", name="c") + jid = job["id"] + assert claim_job_for_fire(jid) is True + assert get_job(jid).get("fire_claim") is not None + + mark_job_run(jid, success=True) + assert get_job(jid).get("fire_claim") is None + # …and the re-armed recurring job is claimable again. + assert claim_job_for_fire(jid) is True From 4c8bbe6416966fccc8663be0c4049121d2af5f07 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 18 Jun 2026 14:40:56 +1000 Subject: [PATCH 009/470] feat(cron): Chronos NAS-mediated managed-cron provider (scale-to-zero) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4D. The first non-default CronScheduler: plugins/cron/chronos/. Inert unless cron.provider=chronos; resolve_cron_scheduler falls back to the built-in if unavailable, so cron never loses its trigger. Files: - chronos/__init__.py — ChronosCronScheduler + register(ctx). * is_available(): config-only, NO network (portal_url + callback_url + a stored Nous access token via get_provider_auth_state). Returns False → resolver falls back to built-in. * start(): reconcile() then RETURN — no blocking loop, no 60s wake (DQ-1: this is what makes scale-to-zero real; the machine wakes only on a NAS→agent fire). * _arm_one_shot(job): POST NAS provision {job_id, fire_at, agent_callback_url, dedup_key=job_id:fire_at}. Agent owns the time → sub-minute fires survive (no scheduler 1-minute floor). * reconcile(): converge NAS arms toward jobs.json — arm missing/changed-time, cancel orphaned, skip paused. Cold process rebuilds from jobs.json + idempotent dedup_key. * on_jobs_changed(): reconcile (re-arm/cancel the affected one-shot). * fire_due(): ABC default (CAS claim + run_one_job) THEN re-arm the next one-shot. Job gone (one-shot done / repeat-N exhausted) → no re-arm. - chronos/_nas_client.py — thin HTTP wrapper for provision/cancel/list using the agent's existing refresh-aware Nous token (resolve_nous_access_token). Names no scheduler vendor; holds no scheduler creds. - chronos/plugin.yaml — discovery metadata. INVARIANT: zero "qstash"/"upstash" hits in plugins/cron, gateway, hermes_cli, website/docs — the external scheduler is a NAS-internal detail, never named agent-side. Tests (13, all NAS mocked, zero network): is_available off-without-config + on-with-config + makes-no-network; arm payload incl. sub-minute + noop without next_run; reconcile arms-all / cancels-orphan / skips-paused / skips-already- armed; fire_due re-arms next / no re-arm when job gone / no re-arm when claim lost. --- plugins/cron/chronos/__init__.py | 241 ++++++++++++++++++++++++++++ plugins/cron/chronos/_nas_client.py | 123 ++++++++++++++ plugins/cron/chronos/plugin.yaml | 9 ++ tests/plugins/test_chronos_cron.py | 203 +++++++++++++++++++++++ 4 files changed, 576 insertions(+) create mode 100644 plugins/cron/chronos/__init__.py create mode 100644 plugins/cron/chronos/_nas_client.py create mode 100644 plugins/cron/chronos/plugin.yaml create mode 100644 tests/plugins/test_chronos_cron.py diff --git a/plugins/cron/chronos/__init__.py b/plugins/cron/chronos/__init__.py new file mode 100644 index 00000000000..1ec5a457763 --- /dev/null +++ b/plugins/cron/chronos/__init__.py @@ -0,0 +1,241 @@ +"""Chronos — NAS-mediated managed cron provider (scale-to-zero). + +Chronos (the Greek god of time, alongside Hermes) is the first non-default +``CronScheduler``. It lets a hosted gateway scale to zero while idle and still +fire cron jobs: instead of a 60s in-process ticker, it asks NAS to arm exactly +one external one-shot per job at that job's real next-fire time. NAS calls the +agent back at fire time over an authenticated webhook (``/api/cron/fire``); the +agent runs the job via the shared ``run_one_job`` body and re-arms the next +one-shot. + +The external scheduler NAS uses is an internal NAS implementation detail — +Chronos names no vendor, holds no scheduler credentials, and speaks only to +NAS's ``agent-cron`` endpoints with the agent's existing Nous token. + +Design constraints (see the plan's DQ-1): + - start() arms all enabled jobs and RETURNS; it never blocks and never spawns + a periodic wake. Between fires the machine is truly at zero. + - reconcile runs only on a warm process (start / on_jobs_changed / piggybacked + on a fire), never as a periodic wake of a sleeping machine. + +Inert unless ``cron.provider: chronos``. ``resolve_cron_scheduler`` falls back +to the built-in if Chronos is unavailable, so cron never loses its trigger. + +Wire contract: ``docs/chronos-managed-cron-contract.md``. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Dict, Optional + +from cron.scheduler_provider import CronScheduler + +logger = logging.getLogger("cron.chronos") + + +def _cfg(*keys: str, default: Any = "") -> Any: + """Read a cron.chronos.* config value (no network).""" + try: + from hermes_cli.config import cfg_get, load_config + return cfg_get(load_config(), *keys, default=default) + except Exception: + return default + + +class ChronosCronScheduler(CronScheduler): + """NAS-mediated external cron provider.""" + + def __init__(self) -> None: + # In-memory map of job_id → fire_at we've asked NAS to arm. Best-effort + # cache; reconcile rebuilds desired state from jobs.json, so a cold + # process simply re-arms (idempotent via dedup_key). + self._armed: Dict[str, str] = {} + self._lock = threading.Lock() + self._client = None # lazily constructed (no network in is_available) + + # -- identity / availability ----------------------------------------- + + @property + def name(self) -> str: + return "chronos" + + def is_available(self) -> bool: + """Config presence only — NO network. + + Chronos needs a portal base URL, the agent's own publicly-reachable + callback URL (for NAS→agent fires), and a usable Nous token (the agent + is logged into the portal). If any is missing, resolve_cron_scheduler + falls back to the built-in ticker. + """ + if not (_cfg("cron", "chronos", "portal_url") and _cfg("cron", "chronos", "callback_url")): + return False + return self._have_nous_token() + + def _have_nous_token(self) -> bool: + """True if the agent has a Nous Portal login (no network call). + + Checks the stored auth state for a Nous access token — does NOT refresh + or hit the network (is_available must stay offline). The actual + refresh-aware token is resolved lazily at provision time. + """ + try: + from hermes_cli.auth import get_provider_auth_state + state = get_provider_auth_state("nous") or {} + return bool(state.get("access_token")) + except Exception: + return False + + # -- client ----------------------------------------------------------- + + def _get_client(self): + if self._client is None: + from ._nas_client import NasCronClient + self._client = NasCronClient(_cfg("cron", "chronos", "portal_url")) + return self._client + + def _callback_url(self) -> str: + return str(_cfg("cron", "chronos", "callback_url") or "") + + # -- lifecycle -------------------------------------------------------- + + def start(self, stop_event, *, adapters=None, loop=None, interval=60): + """Arm all enabled jobs via NAS, then RETURN immediately. + + Does NOT block and does NOT spawn a 60s wake (DQ-1) — that is the whole + point of scale-to-zero. The machine wakes only on a NAS→agent fire. + """ + try: + self.reconcile() + except Exception as e: + logger.warning("Chronos start() reconcile failed: %s", e) + # Intentionally return — no loop, no periodic wake. + + def stop(self) -> None: + return None + + def on_jobs_changed(self) -> None: + """A job was created/updated/removed/paused/resumed — reconcile the NAS + registry so the affected one-shot is (re-)armed or cancelled.""" + try: + self.reconcile() + except Exception as e: + logger.debug("Chronos on_jobs_changed reconcile failed: %s", e) + + # -- arming ----------------------------------------------------------- + + def _arm_one_shot(self, job: Dict[str, Any]) -> None: + """Ask NAS to arm exactly one one-shot at the job's next_run_at. + + The agent computes the time; NAS+its scheduler are the dumb executor. + Idempotent per (job_id, fire_at) via dedup_key, so re-arming the same + fire is a no-op NAS-side. + """ + job_id = job["id"] + fire_at = job.get("next_run_at") + if not fire_at: + return + dedup_key = f"{job_id}:{fire_at}" + self._get_client().provision( + job_id=job_id, + fire_at=fire_at, + agent_callback_url=self._callback_url(), + dedup_key=dedup_key, + ) + with self._lock: + self._armed[job_id] = fire_at + + def _cancel(self, job_id: str) -> None: + try: + self._get_client().cancel(job_id=job_id) + finally: + with self._lock: + self._armed.pop(job_id, None) + + def _list_armed(self) -> Dict[str, str]: + """Observed armed one-shots: job_id → fire_at. + + Prefer the in-memory map (warm process); on a cold/empty map, ask NAS + (best-effort). If NAS list fails, return what we have — reconcile then + re-arms desired jobs idempotently. + """ + with self._lock: + if self._armed: + return dict(self._armed) + try: + observed = { + item["job_id"]: item.get("fire_at", "") + for item in self._get_client().list_armed() + if item.get("job_id") + } + with self._lock: + self._armed.update(observed) + return observed + except Exception as e: + logger.debug("Chronos _list_armed failed (will re-arm idempotently): %s", e) + return {} + + # -- reconcile -------------------------------------------------------- + + def reconcile(self) -> None: + """Converge the NAS-armed one-shots toward jobs.json (desired state): + arm missing / re-arm changed-time, cancel orphaned.""" + from cron.jobs import load_jobs + + desired: Dict[str, str] = { + j["id"]: j["next_run_at"] + for j in load_jobs() + if j.get("enabled") and j.get("next_run_at") and j.get("state") != "paused" + } + observed = self._list_armed() + + # Arm missing or changed-time. + for job_id, fire_at in desired.items(): + if observed.get(job_id) != fire_at: + # Re-fetch the full job dict to arm (need the whole record). + from cron.jobs import get_job + job = get_job(job_id) + if job: + try: + self._arm_one_shot(job) + except Exception as e: + logger.warning("Chronos failed to arm job %s: %s", job_id, e) + + # Cancel orphans (armed but no longer desired). + for job_id in list(observed.keys()): + if job_id not in desired: + try: + self._cancel(job_id) + except Exception as e: + logger.warning("Chronos failed to cancel orphan %s: %s", job_id, e) + + # -- fire ------------------------------------------------------------- + + def fire_due(self, job_id: str, *, adapters: Any = None, loop: Any = None) -> bool: + """Run the due job (claim + run_one_job via the ABC default), then + re-arm the NEXT one-shot through NAS. + + Re-arm happens AFTER the run so next_run_at reflects the completed fire. + If the job is gone (one-shot completed / repeat-N exhausted), get_job + returns None → nothing to re-arm (the schedule naturally stops). + """ + ran = super().fire_due(job_id, adapters=adapters, loop=loop) + if ran: + from cron.jobs import get_job + job = get_job(job_id) + if job and job.get("enabled") and job.get("next_run_at"): + try: + self._arm_one_shot(job) + except Exception as e: + logger.warning("Chronos failed to re-arm job %s after fire: %s", job_id, e) + return ran + + +def register(ctx) -> None: + """Plugin entrypoint — register the Chronos provider with the loader. + + Mirrors the memory-plugin shape; plugins/cron discovery calls this and + collects the provider via register_cron_scheduler. + """ + ctx.register_cron_scheduler(ChronosCronScheduler()) diff --git a/plugins/cron/chronos/_nas_client.py b/plugins/cron/chronos/_nas_client.py new file mode 100644 index 00000000000..04382adc8ea --- /dev/null +++ b/plugins/cron/chronos/_nas_client.py @@ -0,0 +1,123 @@ +"""Thin HTTP client for the agent → NAS ``agent-cron`` endpoints (Chronos). + +The Chronos provider speaks ONLY to NAS — it names no scheduler vendor and +holds no scheduler credentials. NAS owns the external scheduler (an internal +implementation detail) and that scheduler's account; the agent just asks NAS to +"arm a one-shot at time T" / "cancel" / "list", authenticated with the agent's +existing Nous Portal access token (the same token it already uses to call the +portal — no new secret). + +Wire contract: ``docs/chronos-managed-cron-contract.md``. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +logger = logging.getLogger("cron.chronos") + +# Endpoint paths under the portal base URL. +_PROVISION_PATH = "/api/agent-cron/provision" +_CANCEL_PATH = "/api/agent-cron/cancel" +_LIST_PATH = "/api/agent-cron/list" + + +class NasCronClientError(RuntimeError): + """Raised when a NAS agent-cron call fails (non-2xx or transport error).""" + + +class NasCronClient: + """Minimal client for the agent→NAS provision/cancel/list endpoints. + + Uses the agent's refresh-aware Nous access token for auth. No scheduler + vendor, no scheduler creds — NAS hides all of that behind these three calls. + """ + + def __init__(self, portal_url: str, *, timeout_seconds: float = 15.0) -> None: + self.portal_url = portal_url.rstrip("/") + self.timeout_seconds = timeout_seconds + + # -- auth ------------------------------------------------------------- + + def _access_token(self) -> str: + """The agent's existing Nous Portal access token (refresh-aware).""" + from hermes_cli.auth import resolve_nous_access_token + return resolve_nous_access_token() + + def _headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self._access_token()}", + "Content-Type": "application/json", + } + + # -- HTTP ------------------------------------------------------------- + + def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]: + import requests # lazy: agent already depends on requests + + url = f"{self.portal_url}{path}" + try: + resp = requests.post( + url, json=body, headers=self._headers(), timeout=self.timeout_seconds + ) + except Exception as e: + raise NasCronClientError(f"POST {path} failed: {e}") from e + if resp.status_code // 100 != 2: + raise NasCronClientError( + f"POST {path} returned {resp.status_code}: {resp.text[:200]}" + ) + try: + return resp.json() if resp.content else {} + except Exception: + return {} + + def _get(self, path: str, params: Dict[str, Any]) -> Dict[str, Any]: + import requests + + url = f"{self.portal_url}{path}" + try: + resp = requests.get( + url, params=params, headers=self._headers(), timeout=self.timeout_seconds + ) + except Exception as e: + raise NasCronClientError(f"GET {path} failed: {e}") from e + if resp.status_code // 100 != 2: + raise NasCronClientError( + f"GET {path} returned {resp.status_code}: {resp.text[:200]}" + ) + try: + return resp.json() if resp.content else {} + except Exception: + return {} + + # -- endpoints -------------------------------------------------------- + + def provision(self, *, job_id: str, fire_at: str, agent_callback_url: str, + dedup_key: str) -> Dict[str, Any]: + """Ask NAS to arm a one-shot for ``job_id`` at ``fire_at`` (ISO 8601). + + ``dedup_key`` (``{job_id}:{fire_at}``) makes re-arming the same fire + idempotent NAS-side. Returns the NAS response (e.g. ``{schedule_id}``). + """ + return self._post(_PROVISION_PATH, { + "job_id": job_id, + "fire_at": fire_at, + "agent_callback_url": agent_callback_url, + "dedup_key": dedup_key, + }) + + def cancel(self, *, job_id: str) -> Dict[str, Any]: + """Ask NAS to cancel any armed one-shot for ``job_id``.""" + return self._post(_CANCEL_PATH, {"job_id": job_id}) + + def list_armed(self) -> List[Dict[str, Any]]: + """List the one-shots NAS currently has armed for this agent. + + Returns a list of ``{job_id, fire_at, schedule_id}``. Best-effort: used + by reconcile to find orphaned arms on a cold process; on error the + caller falls back to idempotent re-arm of all desired jobs. + """ + data = self._get(_LIST_PATH, {}) + items = data.get("armed") if isinstance(data, dict) else None + return items if isinstance(items, list) else [] diff --git a/plugins/cron/chronos/plugin.yaml b/plugins/cron/chronos/plugin.yaml new file mode 100644 index 00000000000..aad48b35655 --- /dev/null +++ b/plugins/cron/chronos/plugin.yaml @@ -0,0 +1,9 @@ +name: chronos +description: >- + Chronos — NAS-mediated managed cron provider for scale-to-zero hosted agents. + Delegates the "wake me at time T" trigger to Nous infrastructure so an idle + gateway can scale to zero and still fire cron jobs. The agent computes each + job's next-fire time and asks NAS to arm a one-shot; NAS calls the agent back + at fire time over an authenticated webhook. Inert unless cron.provider=chronos. +version: 1.0.0 +author: Nous Research diff --git a/tests/plugins/test_chronos_cron.py b/tests/plugins/test_chronos_cron.py new file mode 100644 index 00000000000..36b32f7a501 --- /dev/null +++ b/tests/plugins/test_chronos_cron.py @@ -0,0 +1,203 @@ +"""Unit tests for the Chronos NAS-mediated cron provider (Phase 4D). + +All NAS calls are mocked — ZERO live network. These prove: + - is_available is config-only (no network), false without config. + - one-shot arming sends the right provision payload (incl. sub-minute fires — + the agent owns the time, so there's no 1-minute floor). + - reconcile arms missing, cancels orphaned, skips paused. + - fire_due re-arms the next one-shot after a successful run, and repeat-N + (job gone) stops re-arming. +""" + +import pytest + + +@pytest.fixture +def temp_home(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + yield tmp_path + + +@pytest.fixture +def chronos(monkeypatch): + """A ChronosCronScheduler with a fake NAS client capturing calls.""" + from plugins.cron.chronos import ChronosCronScheduler + + class FakeClient: + def __init__(self): + self.provisions = [] + self.cancels = [] + self._armed = [] + + def provision(self, *, job_id, fire_at, agent_callback_url, dedup_key): + self.provisions.append({ + "job_id": job_id, "fire_at": fire_at, + "agent_callback_url": agent_callback_url, "dedup_key": dedup_key, + }) + return {"schedule_id": f"sched-{job_id}"} + + def cancel(self, *, job_id): + self.cancels.append(job_id) + return {} + + def list_armed(self): + return list(self._armed) + + prov = ChronosCronScheduler() + fake = FakeClient() + prov._client = fake + # callback_url is read via _cfg; patch the module helper to avoid config. + monkeypatch.setattr("plugins.cron.chronos._cfg", + lambda *k, default="": "https://agent.example/" if k[-1] == "callback_url" else "https://portal.test") + return prov, fake + + +# -- is_available ------------------------------------------------------------- + +def test_is_available_false_without_config(temp_home, monkeypatch): + from plugins.cron.chronos import ChronosCronScheduler + + monkeypatch.setattr("plugins.cron.chronos._cfg", lambda *k, default="": "") + assert ChronosCronScheduler().is_available() is False + + +def test_is_available_true_with_config_and_token(temp_home, monkeypatch): + import plugins.cron.chronos as mod + from plugins.cron.chronos import ChronosCronScheduler + + monkeypatch.setattr(mod, "_cfg", lambda *k, default="": "https://x" ) + monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", + lambda pid: {"access_token": "tok"}) + assert ChronosCronScheduler().is_available() is True + + +def test_is_available_makes_no_network(temp_home, monkeypatch): + """is_available must not construct the NAS client / hit network.""" + import plugins.cron.chronos as mod + from plugins.cron.chronos import ChronosCronScheduler + + monkeypatch.setattr(mod, "_cfg", lambda *k, default="": "https://x") + monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", + lambda pid: {"access_token": "tok"}) + p = ChronosCronScheduler() + + def explode(): + raise AssertionError("is_available must not build the NAS client") + + monkeypatch.setattr(p, "_get_client", explode) + assert p.is_available() is True # did not call _get_client + + +# -- arming ------------------------------------------------------------------- + +def test_arm_one_shot_sends_provision(chronos): + prov, fake = chronos + prov._arm_one_shot({"id": "j1", "next_run_at": "2026-06-18T12:00:00+00:00"}) + + assert len(fake.provisions) == 1 + p = fake.provisions[0] + assert p["job_id"] == "j1" + assert p["fire_at"] == "2026-06-18T12:00:00+00:00" + assert p["dedup_key"] == "j1:2026-06-18T12:00:00+00:00" + assert p["agent_callback_url"] == "https://agent.example/" + + +def test_arm_one_shot_preserves_sub_minute_fire(chronos): + """Sub-minute fire times survive — the agent owns the time, so there's no + 1-minute scheduler floor.""" + prov, fake = chronos + prov._arm_one_shot({"id": "j2", "next_run_at": "2026-06-18T12:00:30+00:00"}) + assert fake.provisions[0]["fire_at"] == "2026-06-18T12:00:30+00:00" + + +def test_arm_one_shot_noop_without_next_run(chronos): + prov, fake = chronos + prov._arm_one_shot({"id": "j3", "next_run_at": None}) + assert fake.provisions == [] + + +# -- reconcile ---------------------------------------------------------------- + +def test_reconcile_arms_all_enabled(temp_home, chronos, monkeypatch): + prov, fake = chronos + jobs = [ + {"id": "a", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "scheduled"}, + {"id": "b", "enabled": True, "next_run_at": "2026-06-18T12:05:00+00:00", "state": "scheduled"}, + ] + monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs) + monkeypatch.setattr("cron.jobs.get_job", lambda jid: next(j for j in jobs if j["id"] == jid)) + + prov.reconcile() + assert {p["job_id"] for p in fake.provisions} == {"a", "b"} + assert fake.cancels == [] + + +def test_reconcile_cancels_orphan_arms_desired(temp_home, chronos, monkeypatch): + prov, fake = chronos + # NAS already has a stale arm for deleted job "gone". + prov._armed = {"gone": "2026-06-18T11:00:00+00:00"} + jobs = [{"id": "a", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "scheduled"}] + monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs) + monkeypatch.setattr("cron.jobs.get_job", lambda jid: next((j for j in jobs if j["id"] == jid), None)) + + prov.reconcile() + assert [p["job_id"] for p in fake.provisions] == ["a"] + assert fake.cancels == ["gone"] + + +def test_reconcile_skips_paused(temp_home, chronos, monkeypatch): + prov, fake = chronos + jobs = [{"id": "p", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "paused"}] + monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs) + monkeypatch.setattr("cron.jobs.get_job", lambda jid: next((j for j in jobs if j["id"] == jid), None)) + + prov.reconcile() + assert fake.provisions == [] + + +def test_reconcile_skips_already_armed_same_time(temp_home, chronos, monkeypatch): + prov, fake = chronos + prov._armed = {"a": "2026-06-18T12:00:00+00:00"} + jobs = [{"id": "a", "enabled": True, "next_run_at": "2026-06-18T12:00:00+00:00", "state": "scheduled"}] + monkeypatch.setattr("cron.jobs.load_jobs", lambda: jobs) + monkeypatch.setattr("cron.jobs.get_job", lambda jid: jobs[0]) + + prov.reconcile() + assert fake.provisions == [] # already armed at the same time → no re-arm + + +# -- fire_due re-arm ---------------------------------------------------------- + +def test_fire_due_rearms_next_oneshot(chronos, monkeypatch): + prov, fake = chronos + # super().fire_due runs the job; stub the ABC default to "ran". + monkeypatch.setattr("cron.scheduler_provider.CronScheduler.fire_due", + lambda self, jid, **kw: True) + monkeypatch.setattr("cron.jobs.get_job", + lambda jid: {"id": jid, "enabled": True, "next_run_at": "2026-06-18T12:05:00+00:00"}) + + assert prov.fire_due("j1") is True + assert [p["job_id"] for p in fake.provisions] == ["j1"] + assert fake.provisions[0]["fire_at"] == "2026-06-18T12:05:00+00:00" + + +def test_fire_due_no_rearm_when_job_gone(chronos, monkeypatch): + """repeat-N exhausted / one-shot completed → mark_job_run deleted the job → + get_job None → no re-arm (the schedule stops cleanly).""" + prov, fake = chronos + monkeypatch.setattr("cron.scheduler_provider.CronScheduler.fire_due", + lambda self, jid, **kw: True) + monkeypatch.setattr("cron.jobs.get_job", lambda jid: None) + + assert prov.fire_due("j1") is True + assert fake.provisions == [] + + +def test_fire_due_no_rearm_when_claim_lost(chronos, monkeypatch): + """If the run didn't happen (claim lost), don't re-arm.""" + prov, fake = chronos + monkeypatch.setattr("cron.scheduler_provider.CronScheduler.fire_due", + lambda self, jid, **kw: False) + + assert prov.fire_due("j1") is False + assert fake.provisions == [] From 3fc7b624d860aca1004155cbe8a09a083bbef30a Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 18 Jun 2026 14:46:33 +1000 Subject: [PATCH 010/470] feat(cron,gateway): NAS-JWT fire verifier + /api/cron/fire webhook (Chronos) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4E (E.1 + E.2). The inbound side of Chronos: NAS POSTs the agent when a one-shot fires; the agent verifies a NAS-minted JWT and runs the job. E.1 — plugins/cron/chronos/verify.py: - verify_nas_fire_token(token, expected_audience, jwks_or_key, issuer): verifies signature against the NAS JWKS (RS/ES family; symmetric rejected), aud == this agent, exp/nbf, iss, and purpose == "cron_fire" (so a general agent JWT can't be replayed against the fire endpoint). Returns claims or None; never raises. Crypto delegated to PyJWT[crypto] (already a declared dep) — no hand-rolled JWT, no new dependency. No key configured → refuse (never unsigned-decode a security boundary). - get_fire_verifier(): pluggable indirection so the DQ-4 escape hatch (direct per-job cron-key) can swap in with no handler change. E.2 — gateway/platforms/api_server.py: - POST /api/cron/fire (registered only when _CRON_AVAILABLE). Authenticated by the NAS-JWT via get_fire_verifier() — NOT API_SERVER_KEY (NAS holds no API key; this is the only inbound that triggers remote job execution, so it gets its own purpose-scoped check). Verifier args come from cron.chronos.* config. 401 on bad/missing/forged token. 400 on missing job_id. On success: 202 + fire_due runs in the background (so a long agent turn never trips NAS's HTTP timeout); the store CAS claim inside fire_due de-dupes a scheduler retry. Tests: - test_chronos_verify (11): REAL RS256 signing — valid→claims, wrong-aud, missing/wrong purpose, expired, wrong-iss, tampered-signature (attacker key), no-key-refuse, empty-token, JWKS-URL key resolution, get_fire_verifier. - test_cron_fire_webhook (5): valid→202+fire, invalid→401+no-fire, missing token→401, missing job_id→400, and fire path does NOT require API_SERVER_KEY. api_server regression suites (214) green. E.3 (NAS endpoints) is a separate cross-repo PR; the wire contract lands next (docs/chronos-managed-cron-contract.md). --- gateway/platforms/api_server.py | 63 ++++++++ plugins/cron/chronos/verify.py | 103 ++++++++++++++ tests/gateway/test_cron_fire_webhook.py | 152 ++++++++++++++++++++ tests/plugins/test_chronos_verify.py | 182 ++++++++++++++++++++++++ 4 files changed, 500 insertions(+) create mode 100644 plugins/cron/chronos/verify.py create mode 100644 tests/gateway/test_cron_fire_webhook.py create mode 100644 tests/plugins/test_chronos_verify.py diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index da86952a09d..c657f4b4c6d 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -3342,6 +3342,64 @@ class APIServerAdapter(BasePlatformAdapter): except Exception as e: return web.json_response({"error": str(e)}, status=500) + async def _handle_cron_fire(self, request: "web.Request") -> "web.Response": + """POST /api/cron/fire — Chronos managed-cron fire webhook (NAS → agent). + + Authenticated by a NAS-minted JWT (verified via the pluggable + fire-verifier), NOT API_SERVER_KEY — NAS holds no API server key, and + this is the only inbound that can trigger remote job execution, so it + gets its own purpose-scoped token check. + + Returns 202 + runs the job in the background so a long agent turn never + trips NAS's HTTP timeout. The store CAS claim inside fire_due guards + against double-fire on a NAS/scheduler retry. + """ + from hermes_cli.config import cfg_get, load_config + from plugins.cron.chronos.verify import get_fire_verifier + + auth = request.headers.get("Authorization", "") + token = auth[7:].strip() if auth.startswith("Bearer ") else "" + + cfg = load_config() + claims = get_fire_verifier()( + token=token, + expected_audience=cfg_get(cfg, "cron", "chronos", "expected_audience", default=""), + jwks_or_key=cfg_get(cfg, "cron", "chronos", "nas_jwks_url", default="") or None, + issuer=cfg_get(cfg, "cron", "chronos", "portal_url", default="") or None, + ) + if claims is None: + logger.warning( + "cron fire: rejected invalid token: %s", + self._request_audit_log_suffix(request), + ) + return web.json_response({"error": "invalid fire token"}, status=401) + + try: + body = await request.json() + except Exception: + body = {} + job_id = (body or {}).get("job_id") + if not job_id: + return web.json_response({"error": "missing job_id"}, status=400) + + from cron.scheduler_provider import resolve_cron_scheduler + provider = resolve_cron_scheduler() + + loop = asyncio.get_running_loop() + # Fire in the background (202 immediately). fire_due claims via the + # store CAS, so a retry while this is in flight is de-duped. + task = asyncio.create_task( + asyncio.to_thread(provider.fire_due, job_id, adapters=None, loop=loop) + ) + try: + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + except (TypeError, AttributeError): + pass + + return web.json_response({"status": "accepted", "job_id": job_id}, status=202) + + # ------------------------------------------------------------------ # Output extraction helper # ------------------------------------------------------------------ @@ -4196,6 +4254,11 @@ class APIServerAdapter(BasePlatformAdapter): self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job) self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job) self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job) + + # Chronos managed-cron fire webhook (NAS → agent). Authenticated by a + # NAS-minted JWT (NOT API_SERVER_KEY), so it has its own auth path. + if _CRON_AVAILABLE: + self._app.router.add_post("/api/cron/fire", self._handle_cron_fire) # Structured event streaming self._app.router.add_post("/v1/runs", self._handle_runs) self._app.router.add_get("/v1/runs/{run_id}", self._handle_get_run) diff --git a/plugins/cron/chronos/verify.py b/plugins/cron/chronos/verify.py new file mode 100644 index 00000000000..99c8db93e4b --- /dev/null +++ b/plugins/cron/chronos/verify.py @@ -0,0 +1,103 @@ +"""Inbound cron-fire token verification for Chronos (Phase 4E.1). + +When NAS relays an external scheduler fire to the agent, it POSTs +``/api/cron/fire`` with a short-lived NAS-minted JWT. This module verifies that +JWT before any job runs — the security boundary for remotely-triggered job +execution. + +We verify a NAS-minted JWT (the trust path the agent already has) rather than +let an external scheduler call the agent directly: the scheduler signs with +NAS's keys, which the agent doesn't (and shouldn't) hold. See the plan's DQ-4. + +The verifier is pluggable (``get_fire_verifier``) so the escape-hatch mode +(direct per-job cron-key) can swap in later with no handler change. + +Crypto is delegated to PyJWT (already a declared dependency) — we do NOT +hand-roll JWT verification. +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, Dict, Optional + +logger = logging.getLogger("cron.chronos.verify") + +# The purpose claim that scopes a token to the fire endpoint. A general agent +# JWT (without this claim) must NOT be replayable against /api/cron/fire. +_FIRE_PURPOSE = "cron_fire" + + +def verify_nas_fire_token( + *, + token: str, + expected_audience: str, + jwks_or_key: Optional[str] = None, + issuer: Optional[str] = None, + leeway_seconds: int = 30, +) -> Optional[Dict[str, Any]]: + """Verify a NAS-minted cron-fire JWT. Return decoded claims, or None. + + Checks (all must pass): + - signature against the NAS JWKS (``jwks_or_key`` is a JWKS URL) — RS256 + family; symmetric secrets are rejected (NAS signs asymmetrically). + - ``aud`` == ``expected_audience`` (this agent: ``agent:{instance_id}``). + - ``exp`` / ``nbf`` within ``leeway_seconds``. + - ``iss`` == ``issuer`` when an issuer is configured. + - ``purpose`` == ``"cron_fire"`` — so a general agent JWT can't be + replayed against the fire endpoint. + + Returns None (never raises) on any failure, so the handler can answer 401 + without leaking which check failed. + """ + if not token or not expected_audience: + return None + if not jwks_or_key: + # No verification key configured → cannot verify → refuse. We never + # fall back to unsigned decode for a security boundary. + logger.warning("cron fire: no JWKS/key configured; refusing token") + return None + + try: + import jwt + from jwt import PyJWKClient + + # Resolve the signing key from the JWKS endpoint by the token's kid. + signing_key = None + if jwks_or_key.startswith("http://") or jwks_or_key.startswith("https://"): + jwk_client = PyJWKClient(jwks_or_key) + signing_key = jwk_client.get_signing_key_from_jwt(token).key + else: + # A PEM public key passed inline (test / pinned-key deployments). + signing_key = jwks_or_key + + options = {"require": ["exp", "aud"]} + decode_kwargs: Dict[str, Any] = dict( + algorithms=["RS256", "RS384", "RS512", "ES256", "ES384"], + audience=expected_audience, + leeway=leeway_seconds, + options=options, + ) + if issuer: + decode_kwargs["issuer"] = issuer + + claims = jwt.decode(token, signing_key, **decode_kwargs) + except Exception as e: + logger.warning("cron fire: token verification failed: %s", e) + return None + + if claims.get("purpose") != _FIRE_PURPOSE: + logger.warning("cron fire: token missing/!=%s purpose claim", _FIRE_PURPOSE) + return None + + return claims + + +def get_fire_verifier() -> Callable[..., Optional[Dict[str, Any]]]: + """Return the active inbound-fire verifier. + + Default = the NAS-JWT verifier. The DQ-4 escape hatch (direct per-job + cron-key) would return a cron-key verifier here instead, selected by config + — so the webhook handler never changes when the auth mode is swapped. + """ + return verify_nas_fire_token diff --git a/tests/gateway/test_cron_fire_webhook.py b/tests/gateway/test_cron_fire_webhook.py new file mode 100644 index 00000000000..e4aef243526 --- /dev/null +++ b/tests/gateway/test_cron_fire_webhook.py @@ -0,0 +1,152 @@ +"""Tests for the Chronos cron-fire webhook (POST /api/cron/fire) — Phase 4E.2. + +The webhook authenticates a NAS-minted JWT via the pluggable fire-verifier +(NOT API_SERVER_KEY), then runs the job via the resolved provider's fire_due in +the background, returning 202. These tests monkeypatch the verifier and +resolve_cron_scheduler — the verifier itself is tested with real crypto in +test_chronos_verify.py. +""" + +import asyncio + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from gateway.config import PlatformConfig +from gateway.platforms.api_server import APIServerAdapter, cors_middleware + +_MOD = "gateway.platforms.api_server" + + +def _make_adapter() -> APIServerAdapter: + return APIServerAdapter(PlatformConfig(enabled=True, extra={"key": "sk-secret"})) + + +def _create_app(adapter: APIServerAdapter) -> web.Application: + app = web.Application(middlewares=[cors_middleware]) + app["api_server_adapter"] = adapter + app.router.add_post("/api/cron/fire", adapter._handle_cron_fire) + return app + + +@pytest.fixture +def adapter(): + return _make_adapter() + + +class _SpyProvider: + """Records fire_due calls; stands in for the resolved provider.""" + + def __init__(self): + self.fired = [] + + def fire_due(self, job_id, *, adapters=None, loop=None): + self.fired.append(job_id) + return True + + +@pytest.mark.asyncio +async def test_valid_token_accepts_and_fires(adapter, monkeypatch): + """Valid NAS-JWT + {job_id} → 202 and fire_due invoked with that id.""" + spy = _SpyProvider() + monkeypatch.setattr("cron.scheduler_provider.resolve_cron_scheduler", lambda: spy) + # verifier returns claims (valid token) + monkeypatch.setattr( + "plugins.cron.chronos.verify.get_fire_verifier", + lambda: (lambda **kw: {"purpose": "cron_fire", "aud": "agent:x"}), + ) + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post("/api/cron/fire", + headers={"Authorization": "Bearer good"}, + json={"job_id": "abc123"}) + assert resp.status == 202 + data = await resp.json() + assert data["job_id"] == "abc123" + + # fire runs in a background thread/task — give it a beat to land. + for _ in range(50): + if spy.fired: + break + await asyncio.sleep(0.01) + assert spy.fired == ["abc123"] + + +@pytest.mark.asyncio +async def test_invalid_token_401_and_no_fire(adapter, monkeypatch): + """Bad/forged token → 401, fire_due NOT invoked.""" + spy = _SpyProvider() + monkeypatch.setattr("cron.scheduler_provider.resolve_cron_scheduler", lambda: spy) + monkeypatch.setattr( + "plugins.cron.chronos.verify.get_fire_verifier", + lambda: (lambda **kw: None), # verification fails + ) + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post("/api/cron/fire", + headers={"Authorization": "Bearer forged"}, + json={"job_id": "abc123"}) + assert resp.status == 401 + + await asyncio.sleep(0.05) + assert spy.fired == [] + + +@pytest.mark.asyncio +async def test_missing_token_401(adapter, monkeypatch): + """No Authorization header → verifier gets empty token → 401.""" + spy = _SpyProvider() + monkeypatch.setattr("cron.scheduler_provider.resolve_cron_scheduler", lambda: spy) + # Real verifier: empty token returns None. + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post("/api/cron/fire", json={"job_id": "abc123"}) + assert resp.status == 401 + assert spy.fired == [] + + +@pytest.mark.asyncio +async def test_missing_job_id_400(adapter, monkeypatch): + """Valid token but no job_id → 400, no fire.""" + spy = _SpyProvider() + monkeypatch.setattr("cron.scheduler_provider.resolve_cron_scheduler", lambda: spy) + monkeypatch.setattr( + "plugins.cron.chronos.verify.get_fire_verifier", + lambda: (lambda **kw: {"purpose": "cron_fire"}), + ) + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + resp = await cli.post("/api/cron/fire", + headers={"Authorization": "Bearer good"}, + json={}) + assert resp.status == 400 + assert spy.fired == [] + + +@pytest.mark.asyncio +async def test_fire_does_not_require_api_server_key(adapter, monkeypatch): + """The fire endpoint must NOT gate on API_SERVER_KEY — auth is the NAS-JWT. + A request with NO API key header but a valid fire token still succeeds.""" + spy = _SpyProvider() + monkeypatch.setattr("cron.scheduler_provider.resolve_cron_scheduler", lambda: spy) + monkeypatch.setattr( + "plugins.cron.chronos.verify.get_fire_verifier", + lambda: (lambda **kw: {"purpose": "cron_fire"}), + ) + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + # Bearer is the FIRE token, not the API_SERVER_KEY "sk-secret". + resp = await cli.post("/api/cron/fire", + headers={"Authorization": "Bearer nas-jwt"}, + json={"job_id": "j9"}) + assert resp.status == 202 + for _ in range(50): + if spy.fired: + break + await asyncio.sleep(0.01) + assert spy.fired == ["j9"] diff --git a/tests/plugins/test_chronos_verify.py b/tests/plugins/test_chronos_verify.py new file mode 100644 index 00000000000..1d9259f4eee --- /dev/null +++ b/tests/plugins/test_chronos_verify.py @@ -0,0 +1,182 @@ +"""Tests for the Chronos inbound cron-fire JWT verifier (Phase 4E.1). + +These exercise REAL RS256 signing/verification (PyJWT[crypto] is a declared +dependency) against an inline PEM public key — no mocking of the crypto, since +this is a security boundary. The JWKS-URL path is covered separately by mocking +PyJWKClient's key resolution. +""" + +import time + +import pytest + + +@pytest.fixture(scope="module") +def rsa_keys(): + """An RS256 keypair: (private_pem, public_pem).""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + priv = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + pub = key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + return priv, pub + + +def _mint(priv, claims): + import jwt + return jwt.encode(claims, priv, algorithm="RS256") + + +AUD = "agent:inst-123" +ISS = "https://portal.nousresearch.com" + + +def _base_claims(**over): + now = int(time.time()) + c = { + "aud": AUD, + "iss": ISS, + "purpose": "cron_fire", + "iat": now, + "nbf": now - 5, + "exp": now + 300, + } + c.update(over) + return c + + +def test_valid_token_returns_claims(rsa_keys): + from plugins.cron.chronos.verify import verify_nas_fire_token + + priv, pub = rsa_keys + token = _mint(priv, _base_claims()) + claims = verify_nas_fire_token(token=token, expected_audience=AUD, + jwks_or_key=pub, issuer=ISS) + assert claims is not None + assert claims["purpose"] == "cron_fire" + assert claims["aud"] == AUD + + +def test_wrong_audience_rejected(rsa_keys): + from plugins.cron.chronos.verify import verify_nas_fire_token + + priv, pub = rsa_keys + token = _mint(priv, _base_claims(aud="agent:someone-else")) + assert verify_nas_fire_token(token=token, expected_audience=AUD, + jwks_or_key=pub, issuer=ISS) is None + + +def test_missing_purpose_rejected(rsa_keys): + """A general agent JWT (no purpose=cron_fire) can't fire jobs.""" + from plugins.cron.chronos.verify import verify_nas_fire_token + + priv, pub = rsa_keys + claims = _base_claims() + del claims["purpose"] + token = _mint(priv, claims) + assert verify_nas_fire_token(token=token, expected_audience=AUD, + jwks_or_key=pub, issuer=ISS) is None + + +def test_wrong_purpose_rejected(rsa_keys): + from plugins.cron.chronos.verify import verify_nas_fire_token + + priv, pub = rsa_keys + token = _mint(priv, _base_claims(purpose="inference")) + assert verify_nas_fire_token(token=token, expected_audience=AUD, + jwks_or_key=pub, issuer=ISS) is None + + +def test_expired_token_rejected(rsa_keys): + from plugins.cron.chronos.verify import verify_nas_fire_token + + priv, pub = rsa_keys + now = int(time.time()) + token = _mint(priv, _base_claims(iat=now - 1000, nbf=now - 1000, exp=now - 600)) + assert verify_nas_fire_token(token=token, expected_audience=AUD, + jwks_or_key=pub, issuer=ISS) is None + + +def test_wrong_issuer_rejected(rsa_keys): + from plugins.cron.chronos.verify import verify_nas_fire_token + + priv, pub = rsa_keys + token = _mint(priv, _base_claims(iss="https://evil.example")) + assert verify_nas_fire_token(token=token, expected_audience=AUD, + jwks_or_key=pub, issuer=ISS) is None + + +def test_tampered_signature_rejected(rsa_keys): + """A token signed by a DIFFERENT key must fail signature verification.""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from plugins.cron.chronos.verify import verify_nas_fire_token + + _, pub = rsa_keys + attacker = rsa.generate_private_key(public_exponent=65537, key_size=2048) + attacker_priv = attacker.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + token = _mint(attacker_priv, _base_claims()) + # Verified against the REAL public key → signature mismatch → None. + assert verify_nas_fire_token(token=token, expected_audience=AUD, + jwks_or_key=pub, issuer=ISS) is None + + +def test_no_key_configured_refuses(rsa_keys): + """No JWKS/key configured → refuse (never fall back to unsigned decode).""" + from plugins.cron.chronos.verify import verify_nas_fire_token + + priv, _ = rsa_keys + token = _mint(priv, _base_claims()) + assert verify_nas_fire_token(token=token, expected_audience=AUD, + jwks_or_key=None) is None + + +def test_empty_token_refused(rsa_keys): + from plugins.cron.chronos.verify import verify_nas_fire_token + + _, pub = rsa_keys + assert verify_nas_fire_token(token="", expected_audience=AUD, jwks_or_key=pub) is None + + +def test_jwks_url_path_resolves_key(rsa_keys, monkeypatch): + """The JWKS-URL branch resolves the signing key via PyJWKClient.""" + from plugins.cron.chronos.verify import verify_nas_fire_token + + priv, pub = rsa_keys + token = _mint(priv, _base_claims()) + + class FakeKey: + key = pub + + class FakeJWKClient: + def __init__(self, url): + assert url == "https://portal.nousresearch.com/.well-known/jwks.json" + + def get_signing_key_from_jwt(self, tok): + return FakeKey() + + monkeypatch.setattr("jwt.PyJWKClient", FakeJWKClient) + claims = verify_nas_fire_token( + token=token, expected_audience=AUD, + jwks_or_key="https://portal.nousresearch.com/.well-known/jwks.json", + issuer=ISS, + ) + assert claims is not None and claims["purpose"] == "cron_fire" + + +def test_get_fire_verifier_returns_nas_verifier(): + from plugins.cron.chronos.verify import get_fire_verifier, verify_nas_fire_token + + assert get_fire_verifier() is verify_nas_fire_token From b75757d4aa85e893d6e202c82a7c3392a57dee2e Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 18 Jun 2026 15:11:32 +1000 Subject: [PATCH 011/470] =?UTF-8?q?feat(cron):=20wire=20on=5Fjobs=5Fchange?= =?UTF-8?q?d,=20cron.chronos=20config,=20docs=20+=20agent=E2=86=94NAS=20co?= =?UTF-8?q?ntract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4F (F.1 + F.2 + F.3, agent side). F.4 is the operator-run live smoke (needs a NAS deployment); recorded in the PR, not code. F.1 — on_jobs_changed wiring: - cron/scheduler.py: _notify_provider_jobs_changed() — resolve the active provider, call on_jobs_changed(), swallow errors. Lives in scheduler.py (not jobs.py) so the store stays free of provider imports (no import cycle). - Wired at the consumer surfaces AFTER a successful mutation: the cronjob model tool (tools/cronjob_tools.py, create/update/remove/pause/resume) — which the `hermes cron` CLI also routes through — and the REST handlers (gateway/platforms/api_server.py, same five). Built-in's no-op default = zero behavior change on the default path. Sleeping-agent direct jobs.json writes (no tool/CLI/REST) are covered by reconcile-on-wake in start(). F.2 — config: cron.chronos.{portal_url,callback_url,expected_audience, nas_jwks_url}. All non-secret; the agent holds no scheduler creds and the outbound provision call reuses the existing Nous token (no token key). Additive deep-merge key, no version literal. F.3 — docs: - docs/chronos-managed-cron-contract.md: authoritative agent↔NAS wire contract (the three agent-cron endpoints + inbound /api/cron/fire + the 3-hop trust model + at-most-once/re-arm semantics). This is what the NAS-side agent builds against. - cron-internals.md: "Managed cron (Chronos) for scale-to-zero" section. - cli-commands.md: cron.provider accepts chronos + the cron.chronos.* keys. - User docs name no scheduler vendor (QStash is a NAS-internal detail). INVARIANT re-verified: zero qstash/upstash hits across plugins/cron, gateway, hermes_cli, tools, website/docs (the one remaining repo hit is an unrelated Context7 MCP comment in tools/mcp_tool.py). Tests: test_jobs_changed_notify (5) — notify calls provider hook, swallows errors, built-in harmless, tool create/remove notify. Full cron + chronos + webhook + config + api_server_jobs suites green (504 in the cron+chronos+webhook run). --- cron/scheduler.py | 18 ++ docs/chronos-managed-cron-contract.md | 192 ++++++++++++++++++ gateway/platforms/api_server.py | 15 ++ hermes_cli/config.py | 19 ++ tests/cron/test_jobs_changed_notify.py | 101 +++++++++ tools/cronjob_tools.py | 15 ++ .../docs/developer-guide/cron-internals.md | 42 ++++ website/docs/reference/cli-commands.md | 12 +- 8 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 docs/chronos-managed-cron-contract.md create mode 100644 tests/cron/test_jobs_changed_notify.py diff --git a/cron/scheduler.py b/cron/scheduler.py index 9bab59456ea..4f7940db0b1 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2025,6 +2025,24 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) - return False +def _notify_provider_jobs_changed() -> None: + """Best-effort: tell the active scheduler provider the job set changed. + + Called by the consumer surfaces (model tool / CLI / REST) AFTER a + successful store mutation (create/update/remove/pause/resume) so an external + provider (Chronos) can re-provision/cancel the affected one-shot via NAS. + No-op for the built-in (it re-reads jobs.json each tick), so the default + path is unchanged. Lives here (not in cron/jobs.py) to keep the store free + of provider imports — avoids an import cycle and keeps jobs.py low-coupling. + Never raises into the caller. + """ + try: + from cron.scheduler_provider import resolve_cron_scheduler + resolve_cron_scheduler().on_jobs_changed() + except Exception as e: + logger.debug("on_jobs_changed notify failed: %s", e) + + def tick(verbose: bool = True, adapters=None, loop=None, sync: bool = True) -> int: """ Check and run all due jobs. diff --git a/docs/chronos-managed-cron-contract.md b/docs/chronos-managed-cron-contract.md new file mode 100644 index 00000000000..0848d5eb939 --- /dev/null +++ b/docs/chronos-managed-cron-contract.md @@ -0,0 +1,192 @@ +# Chronos managed-cron — agent ↔ NAS wire contract + +**Status:** authoritative wire spec for the Chronos cron provider. +**Audience:** the NAS-side implementer of the `agent-cron` endpoints +(`nous-account-service`) and anyone debugging the managed-cron path. + +Chronos lets a hosted Hermes gateway **scale to zero** while idle and still +fire cron jobs. Instead of an in-process 60-second ticker, the agent asks NAS +to arm exactly **one external one-shot per job at that job's real next-fire +time**. NAS calls the agent back at fire time over an authenticated webhook; +the agent runs the job and re-arms the next one-shot. Between fires the agent +process can be fully stopped — it wakes only on a genuine fire. + +The external scheduler NAS uses to implement the one-shots is an **internal NAS +implementation detail**. The agent never talks to it, never holds its +credentials, and never names it. The agent only knows the three NAS endpoints +below. + +``` +create/update/pause/resume/remove a cron job (agent side) + │ + ▼ +ChronosCronScheduler.reconcile() ── agent computes next_run_at + │ POST {portal}/api/agent-cron/provision (auth: agent's Nous access token) + ▼ +NAS arms a one-shot for fire_at ── NAS owns the scheduler + its creds + │ + ⏰ at fire_at + ▼ +scheduler → POST {portal}/api/agent-cron/relay (auth: scheduler signature, NAS-verified) + │ + ▼ +NAS mints a short-lived agent-audience JWT (purpose=cron_fire) + │ POST {agent_callback_url}/api/cron/fire (auth: that JWT) + ▼ +agent verifies the NAS JWT → store CAS claim → run_one_job → re-arm next one-shot +``` + +## Trust model (read this first) + +| Hop | Who calls whom | Auth mechanism | Verified by | +|---|---|---|---| +| 1 | agent → NAS (`provision`/`cancel`/`list`) | the agent's existing **Nous Portal access token** (Bearer) | NAS (its normal agent-token path) | +| 2 | scheduler → NAS (`relay`) | the scheduler's request **signature** | NAS (the signature path it already has) | +| 3 | NAS → agent (`/api/cron/fire`) | a **short-lived NAS-minted JWT** (`aud=agent:{instance_id}`, `purpose=cron_fire`) | agent (PyJWT against NAS JWKS) | + +Why NAS-mediated rather than scheduler→agent direct: the scheduler signs with +**NAS's** keys, which the agent does not (and should not) hold. The agent can +only verify a **NAS-minted** token — a trust path it already has. This keeps +all scheduler credentials inside NAS. (Full rationale: the plan's DQ-4.) + +No new secret is introduced on the agent: hop 1 reuses the token the agent +already uses for the portal, and hop 3 reuses the NAS-JWT verification the agent +already performs. + +--- + +## Endpoint 1 — `POST /api/agent-cron/provision` (agent → NAS) + +Arm (or re-arm, idempotently) exactly one one-shot for a job. + +- **Auth:** `Authorization: Bearer `. NAS validates via + its normal agent-token path and scopes the row to the calling agent/org. +- **Request body:** + ```json + { + "job_id": "ab12cd34", + "fire_at": "2026-06-18T12:34:56+00:00", + "agent_callback_url": "https://agent-xyz.fly.dev", + "dedup_key": "ab12cd34:2026-06-18T12:34:56+00:00" + } + ``` + - `fire_at` — ISO 8601, **agent-computed**. May be sub-minute in the future; + NAS must honor second-granularity (the agent owns the time, so there is no + 1-minute scheduler floor). + - `agent_callback_url` — the agent's own publicly-reachable base URL. NAS + POSTs `{agent_callback_url}/api/cron/fire` at fire time. + - `dedup_key` — `"{job_id}:{fire_at}"`. NAS **upserts by `(agent_id, job_id)`** + so re-arming the same fire is idempotent (no duplicate one-shots). A new + `fire_at` for the same `job_id` replaces the prior arm. +- **Action:** arm one one-shot to fire at `fire_at`, destined for the NAS + **relay** route (Endpoint 3) — NOT the agent directly, so NAS stays in the + loop to mint the agent JWT. Persist `(agent_id, job_id, schedule_id, + agent_callback_url)`. +- **Response:** `200 {"schedule_id": ""}`. + +## Endpoint 2 — `POST /api/agent-cron/cancel` (agent → NAS) + +- **Auth:** same as Endpoint 1. +- **Body:** `{"job_id": "ab12cd34"}`. +- **Action:** cancel the armed one-shot for `(agent_id, job_id)` and delete the + row. Idempotent — cancelling an unknown job is a 200 no-op. +- **Response:** `200 {"ok": true}`. + +## Endpoint 3 — `POST /api/agent-cron/relay` (scheduler → NAS, the fire relay) + +- **Auth:** the scheduler's request **signature**, verified by NAS with the + signature path it already has. This is the trust boundary for the fire — a + forged relay call must be rejected here. +- **Action:** + 1. Look up `(agent_id, job_id) → agent_callback_url` from the persisted row. + 2. Mint a **short-lived** JWT: `aud = "agent:{instance_id}"`, + `iss = {portal_url}`, `purpose = "cron_fire"`, small `exp` (≈60–120s), + signed with NAS's normal asymmetric signing key (published via JWKS). + 3. `POST {agent_callback_url}/api/cron/fire` with + `Authorization: Bearer ` and body `{"job_id": "...", "fire_at": "..."}`. + 4. Treat a non-2xx agent response as a **retryable** failure (let the + scheduler retry the relay). The agent's store CAS de-dupes a double fire, + so retries are safe. +- **Response to the scheduler:** 2xx once the agent POST is accepted (202), so + the scheduler does not retry a delivered fire. + +--- + +## Inbound `POST /api/cron/fire` (NAS → agent) — agent side, already implemented + +This is the agent endpoint NAS calls in Endpoint 3 step 3. Implemented on the +`APIServerAdapter` (`gateway/platforms/api_server.py`); the verifier is +`plugins/cron/chronos/verify.py`. + +- **Auth:** `Authorization: Bearer `. The agent verifies: + - signature against the NAS JWKS (`cron.chronos.nas_jwks_url`), + - `aud` == `cron.chronos.expected_audience` (this agent's + `agent:{instance_id}`), + - `iss` == `cron.chronos.portal_url`, + - `exp` / `nbf` (30s leeway), + - `purpose == "cron_fire"` — a general agent JWT (no/other purpose) is + rejected so it can't be replayed against this endpoint. +- **Body:** `{"job_id": "ab12cd34", "fire_at": "..."}` (only `job_id` is used). +- **Behavior:** + - invalid/missing/forged/expired/wrong-aud/wrong-purpose token → **401**, no + execution. + - missing `job_id` → **400**. + - valid → **202 `{"status": "accepted", "job_id": "..."}`** immediately, and + the job runs in the background. 202-before-run means a long agent turn never + trips the relay's HTTP timeout. +- **At-most-once:** the agent claims the job with a store-level compare-and-set + (`claim_job_for_fire`) before running. A relay/scheduler retry that arrives + while the first fire is in flight (or after it completed) loses the claim and + does not double-run. + +--- + +## At-most-once & re-arm semantics + +- **Recurring (cron/interval):** on fire, the agent advances `next_run_at` + (under its store lock) as part of the claim, runs the job, then re-provisions + a one-shot for the new `next_run_at`. A duplicate relay for the old `fire_at` + finds the claim taken / time advanced and is dropped. +- **One-shot (`30m`, `+90s`, etc.):** fires once; `mark_job_run` marks it + completed. No re-arm. +- **`repeat.times = N`:** `mark_job_run` deletes the job at the limit, so + `get_job` returns `None` after the final fire → the agent does **not** re-arm + → the schedule stops cleanly with no orphaned one-shot. +- **Multi-replica agents:** the store CAS makes the fire at-most-once across N + gateway replicas sharing one `HERMES_HOME` — exactly one replica runs each + fire. + +## Reconcile (self-healing) + +The agent reconciles desired (`jobs.json`) vs armed on: +- `start()` (gateway boot / wake), +- every successful job mutation (`on_jobs_changed`), +- piggybacked after each fire (re-arm). + +Reconcile arms missing/changed-time jobs and cancels orphans. A missed +provision (transient NAS error) self-heals on the next reconcile. There is **no +periodic wake** of a sleeping agent — that would negate scale-to-zero. + +## Config (agent side) + +All non-secret (`cron.chronos.*` in `config.yaml`); the agent holds no scheduler +credentials. For hosted agents NAS sets these at provision time: + +| key | meaning | +|---|---| +| `cron.provider` | `"chronos"` to activate (empty = built-in ticker) | +| `cron.chronos.portal_url` | NAS base URL (also the expected JWT `iss`) | +| `cron.chronos.callback_url` | the agent's own public base URL for NAS→agent fires | +| `cron.chronos.expected_audience` | this agent's JWT `aud` (`agent:{instance_id}`) | +| `cron.chronos.nas_jwks_url` | NAS JWKS for verifying the fire JWT | + +If `callback_url` / `portal_url` is blank or the agent has no Nous login, +`is_available()` returns False and the resolver falls back to the built-in +in-process ticker — cron never loses its trigger. + +## Escape hatch (not default) + +The inbound `/api/cron/fire` verifier is pluggable (`get_fire_verifier()`). If +relay volume through NAS ever saturates, a direct scheduler→agent mode with a +per-job NAS-minted cron-key can replace the NAS-JWT verifier with **no change to +the webhook handler**. NAS-mediated (this contract) is the default. diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index c657f4b4c6d..f7e1ba42f85 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -717,6 +717,16 @@ except ImportError: _cron_resume = None _cron_trigger = None + +def _notify_cron_provider_jobs_changed() -> None: + """Tell the active cron scheduler provider the job set changed after a REST + mutation (no-op for the built-in). Best-effort — never breaks the handler.""" + try: + from cron.scheduler import _notify_provider_jobs_changed + _notify_provider_jobs_changed() + except Exception: + pass + # Defense-in-depth: mirror the agent-facing cronjob tool, which scans the # user-supplied prompt for exfiltration/injection payloads at create/update # time (tools/cronjob_tools.py). The REST cron endpoints are authenticated @@ -3206,6 +3216,7 @@ class APIServerAdapter(BasePlatformAdapter): kwargs["repeat"] = repeat job = _cron_create(**kwargs) + _notify_cron_provider_jobs_changed() return web.json_response({"job": job}) except Exception as e: return web.json_response({"error": str(e)}, status=500) @@ -3262,6 +3273,7 @@ class APIServerAdapter(BasePlatformAdapter): job = _cron_update(job_id, sanitized) if not job: return web.json_response({"error": "Job not found"}, status=404) + _notify_cron_provider_jobs_changed() return web.json_response({"job": job}) except Exception as e: return web.json_response({"error": str(e)}, status=500) @@ -3281,6 +3293,7 @@ class APIServerAdapter(BasePlatformAdapter): success = _cron_remove(job_id) if not success: return web.json_response({"error": "Job not found"}, status=404) + _notify_cron_provider_jobs_changed() return web.json_response({"ok": True}) except Exception as e: return web.json_response({"error": str(e)}, status=500) @@ -3300,6 +3313,7 @@ class APIServerAdapter(BasePlatformAdapter): job = _cron_pause(job_id) if not job: return web.json_response({"error": "Job not found"}, status=404) + _notify_cron_provider_jobs_changed() return web.json_response({"job": job}) except Exception as e: return web.json_response({"error": str(e)}, status=500) @@ -3319,6 +3333,7 @@ class APIServerAdapter(BasePlatformAdapter): job = _cron_resume(job_id) if not job: return web.json_response({"error": "Job not found"}, status=404) + _notify_cron_provider_jobs_changed() return web.json_response({"job": job}) except Exception as e: return web.json_response({"error": str(e)}, status=500) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d53393ac432..79f56be5d2e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2132,6 +2132,25 @@ DEFAULT_CONFIG = { # An unknown or unavailable provider falls back to the built-in, so cron # never loses its trigger. "provider": "", + # Chronos (NAS-mediated managed cron) settings. Only consulted when + # provider == "chronos". All non-secret (URLs + the JWT audience): the + # agent holds NO external-scheduler credentials. For hosted agents, NAS + # sets these at provision time. The outbound provision call reuses the + # agent's existing Nous Portal token — there is no token key here. + "chronos": { + # NAS / portal base URL the agent calls to arm/cancel one-shots + # and that mints the inbound fire JWT (used as the expected issuer). + "portal_url": "https://portal.nousresearch.com", + # The agent's OWN publicly-reachable base URL for NAS→agent fires + # (NAS POSTs {callback_url}/api/cron/fire). Empty → Chronos is + # unavailable and the resolver falls back to the built-in ticker. + "callback_url": "", + # This agent's expected JWT audience (e.g. "agent:{instance_id}"). + "expected_audience": "", + # NAS JWKS URL for verifying the inbound fire JWT's signature. + # Empty → the fire endpoint refuses all tokens (no unsigned decode). + "nas_jwks_url": "", + }, # Wrap delivered cron responses with a header (task name) and footer # ("The agent cannot see this message"). Set to false for clean output. "wrap_response": True, diff --git a/tests/cron/test_jobs_changed_notify.py b/tests/cron/test_jobs_changed_notify.py new file mode 100644 index 00000000000..eed875186b4 --- /dev/null +++ b/tests/cron/test_jobs_changed_notify.py @@ -0,0 +1,101 @@ +"""Tests for on_jobs_changed wiring (Phase 4F.1). + +After a store mutation via the consumer surfaces (model tool / CLI / REST), the +active scheduler provider's on_jobs_changed() must be invoked so an external +provider (Chronos) re-provisions/cancels. The built-in's no-op default means +the default path is unchanged. +""" + +import pytest + + +@pytest.fixture +def temp_home(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + yield tmp_path + + +def test_notify_helper_calls_provider_on_jobs_changed(monkeypatch): + """cron.scheduler._notify_provider_jobs_changed resolves the provider and + calls on_jobs_changed exactly once.""" + import cron.scheduler_provider as sp + import cron.scheduler as sched + + calls = [] + + class Spy(sp.CronScheduler): + @property + def name(self): + return "spy" + + def start(self, stop_event, **kw): + pass + + def on_jobs_changed(self): + calls.append(1) + + monkeypatch.setattr(sp, "resolve_cron_scheduler", lambda: Spy()) + sched._notify_provider_jobs_changed() + assert calls == [1] + + +def test_notify_helper_swallows_provider_errors(monkeypatch): + """A provider that raises in on_jobs_changed must not propagate into the + caller (best-effort notify).""" + import cron.scheduler_provider as sp + import cron.scheduler as sched + + class Boom(sp.CronScheduler): + @property + def name(self): + return "boom" + + def start(self, stop_event, **kw): + pass + + def on_jobs_changed(self): + raise RuntimeError("kaboom") + + monkeypatch.setattr(sp, "resolve_cron_scheduler", lambda: Boom()) + sched._notify_provider_jobs_changed() # must not raise + + +def test_builtin_notify_is_harmless(monkeypatch): + """With the built-in provider (default), notify is a no-op and never + raises.""" + import cron.scheduler as sched + # default resolution → built-in; just assert it doesn't blow up. + sched._notify_provider_jobs_changed() + + +def test_tool_create_notifies_provider(temp_home, monkeypatch): + """Creating a job via the cronjob tool path invokes on_jobs_changed.""" + import cron.scheduler as sched + calls = [] + monkeypatch.setattr(sched, "_notify_provider_jobs_changed", + lambda: calls.append("changed")) + + from tools.cronjob_tools import cronjob + import json + + out = json.loads(cronjob(action="create", prompt="echo hi", schedule="every 5m", name="w")) + assert out["success"] is True + assert calls == ["changed"] + + +def test_tool_remove_notifies_provider(temp_home, monkeypatch): + """Removing a job via the tool path invokes on_jobs_changed.""" + import json + from tools.cronjob_tools import cronjob + + created = json.loads(cronjob(action="create", prompt="x", schedule="every 5m", name="r")) + jid = created["job_id"] + + import cron.scheduler as sched + calls = [] + monkeypatch.setattr(sched, "_notify_provider_jobs_changed", + lambda: calls.append("changed")) + + out = json.loads(cronjob(action="remove", job_id=jid)) + assert out["success"] is True + assert calls == ["changed"] diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 7ec31b806c4..0bd62b2fc37 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -33,6 +33,16 @@ from cron.jobs import ( ) +def _notify_provider_jobs_changed_safe() -> None: + """Tell the active cron scheduler provider the job set changed (no-op for + the built-in). Best-effort — never lets a provider error break the tool.""" + try: + from cron.scheduler import _notify_provider_jobs_changed + _notify_provider_jobs_changed() + except Exception: + pass + + # --------------------------------------------------------------------------- # Cron prompt scanning # --------------------------------------------------------------------------- @@ -549,6 +559,7 @@ def cronjob( workdir=_normalize_optional_job_value(workdir), no_agent=_no_agent, ) + _notify_provider_jobs_changed_safe() return json.dumps( { "success": True, @@ -604,6 +615,7 @@ def cronjob( removed = remove_job(job_id) if not removed: return tool_error(f"Failed to remove job '{job_id}'", success=False) + _notify_provider_jobs_changed_safe() return json.dumps( { "success": True, @@ -619,10 +631,12 @@ def cronjob( if normalized == "pause": updated = pause_job(job_id, reason=reason) + _notify_provider_jobs_changed_safe() return json.dumps({"success": True, "job": _format_job(updated)}, indent=2) if normalized == "resume": updated = resume_job(job_id) + _notify_provider_jobs_changed_safe() return json.dumps({"success": True, "job": _format_job(updated)}, indent=2) if normalized in {"run", "run_now", "trigger"}: @@ -711,6 +725,7 @@ def cronjob( if not updates: return tool_error("No updates provided.", success=False) updated = update_job(job_id, updates) + _notify_provider_jobs_changed_safe() return json.dumps({"success": True, "job": _format_job(updated)}, indent=2) return tool_error(f"Unknown cron action '{action}'", success=False) diff --git a/website/docs/developer-guide/cron-internals.md b/website/docs/developer-guide/cron-internals.md index c895d339b09..386302554d7 100644 --- a/website/docs/developer-guide/cron-internals.md +++ b/website/docs/developer-guide/cron-internals.md @@ -129,6 +129,48 @@ A provider only controls the trigger, never execution. In CLI mode, cron jobs only fire when `hermes cron` commands are run or during active CLI sessions. +### Managed cron (Chronos) for scale-to-zero + +Hosted gateways can run the **Chronos** provider (`cron.provider: chronos`) +instead of the built-in ticker. Chronos lets an idle gateway **scale to zero** +and still fire cron jobs: rather than a 60-second in-process loop (which would +keep the process awake), it asks Nous infrastructure to arm exactly **one +managed one-shot per job at that job's real next-fire time**. At fire time Nous +calls the gateway back over an authenticated webhook (`POST /api/cron/fire`); +the gateway runs the job through the same `run_one_job` path as the built-in, +then re-arms the next one-shot. Between fires the process can be fully stopped — +it wakes only on a genuine fire, never on a periodic timer. + +The flow (the managed scheduler is provided by Nous; the agent holds no +scheduler credentials): + +``` +create/update a cron job + → Chronos asks Nous to arm a one-shot at the job's next_run_at + (authenticated with the agent's existing Nous token) + → at fire time Nous calls the gateway: POST {callback_url}/api/cron/fire + (authenticated with a short-lived, purpose-scoped Nous-minted JWT) + → the gateway verifies the token, claims the job (store compare-and-set so + multi-replica deployments fire at-most-once), runs it, and re-arms the next + one-shot +``` + +Config (all non-secret; on hosted agents Nous sets these at provision time): + +| key | meaning | +|---|---| +| `cron.provider` | `chronos` to activate (empty = built-in ticker) | +| `cron.chronos.portal_url` | Nous base URL (arming + the fire-token issuer) | +| `cron.chronos.callback_url` | the gateway's own public base URL for inbound fires | +| `cron.chronos.expected_audience` | this agent's fire-token audience | +| `cron.chronos.nas_jwks_url` | key set for verifying the inbound fire token | + +If Chronos is misconfigured or the agent isn't logged into Nous, +`resolve_cron_scheduler()` falls back to the built-in ticker (logged warning) — +cron never loses its trigger. Recurring jobs re-arm after each fire; `repeat`-N +jobs stop cleanly when the count is exhausted (no orphaned one-shot). The full +agent↔Nous wire contract lives in `docs/chronos-managed-cron-contract.md`. + ### Fresh Session Isolation Each cron job runs in a completely fresh agent session: diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index f0fe67d4349..0cf004f1a0c 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -534,11 +534,13 @@ hermes cron | `tick` | Run due jobs once and exit. | The cron **trigger** is pluggable via the `cron.provider` config key. Empty -(the default) uses the built-in in-process ticker. A named provider (e.g. -`chronos`, a managed-cron provider for scale-to-zero deployments) is discovered -from `plugins/cron//` or `$HERMES_HOME/plugins//`; an unknown or -unavailable provider falls back to the built-in, so cron is never left without -a trigger. See the [cron internals](../developer-guide/cron-internals.md#gateway-integration) doc. +(the default) uses the built-in in-process ticker. Set it to `chronos` (the +NAS-managed provider for scale-to-zero hosted gateways) — configured via the +`cron.chronos.*` keys (`portal_url`, `callback_url`, `expected_audience`, +`nas_jwks_url`) — or name a custom provider under `plugins/cron//` or +`$HERMES_HOME/plugins//`. An unknown or unavailable provider falls back to +the built-in, so cron is never left without a trigger. See the +[cron internals](../developer-guide/cron-internals.md#gateway-integration) doc. ## `hermes kanban` From 6752da9a7735add1aff6ebc632c7e83fc4005a48 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:32:18 +0530 Subject: [PATCH 012/470] fix(dashboard): clean up upload temp file on client disconnect + pin python-multipart (NS-501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #47663 (streaming multipart upload), fixing two issues that landed with it. 1. Temp file leaked on client disconnect. The streaming upload endpoint's except chain caught only HTTPException / PermissionError / OSError — all Exception subclasses. asyncio.CancelledError, raised when a browser aborts a large upload mid-stream (the exact NS-501 scenario), is a BaseException, so it bypassed every except clause and reached a finally that only closed the file handle and never unlinked the temp file. Every aborted large upload orphaned a partial `.{name}.*.upload` file (up to ~100 MB) in the target directory. Cleanup now lives in finally, keyed on a `renamed` success flag, so the temp file is removed on every non-success exit including BaseException paths. Added test_stream_upload_cleans_temp_on_cancellation, which fails on the pre-fix code (leaks the temp file) and passes with the fix. 2. python-multipart pinned to ==0.0.27 instead of ==0.0.20. The package was already resolved at 0.0.27 transitively (via daytona) before #47663; the explicit ==0.0.20 pin in the [web] extra and the tool.dashboard lazy-install set downgraded it. Bumped both to ==0.0.27 and regenerated with `uv lock`, keeping the lockfile coherent. The base dependency stays >=0.0.9,<1. --- hermes_cli/web_server.py | 12 ++++-- pyproject.toml | 2 +- tests/hermes_cli/test_web_server_files.py | 52 +++++++++++++++++++++++ tools/lazy_deps.py | 2 +- uv.lock | 8 ++-- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ed619979bfb..ad82d9fdfef 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1529,6 +1529,7 @@ async def upload_managed_file_stream( ) tmp_path = Path(tmp_name) total = 0 + renamed = False try: with os.fdopen(tmp_fd, "wb") as out: while True: @@ -1540,16 +1541,21 @@ async def upload_managed_file_stream( raise HTTPException(status_code=413, detail="File is too large") out.write(chunk) os.replace(tmp_path, target) + renamed = True except HTTPException: - tmp_path.unlink(missing_ok=True) raise except PermissionError: - tmp_path.unlink(missing_ok=True) raise HTTPException(status_code=403, detail="File is not writable") except OSError as exc: - tmp_path.unlink(missing_ok=True) raise HTTPException(status_code=500, detail=f"Could not write file: {exc}") finally: + # Clean up the temp file on every non-success exit, including + # BaseException paths the `except` clauses above don't catch — most + # importantly asyncio.CancelledError when a browser aborts a large + # upload mid-stream (the exact NS-501 scenario). os.replace clears + # tmp_path on success, so only unlink when the rename didn't happen. + if not renamed: + tmp_path.unlink(missing_ok=True) await file.close() return { diff --git a/pyproject.toml b/pyproject.toml index 6e371126dd2..cab849dc755 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -258,7 +258,7 @@ youtube = [ # `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean. # starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette # transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above. -web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1", "python-multipart==0.0.20"] +web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1", "python-multipart==0.0.27"] all = [ # Policy (2026-05-12): `[all]` includes only extras that genuinely # CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every diff --git a/tests/hermes_cli/test_web_server_files.py b/tests/hermes_cli/test_web_server_files.py index 46ba18b1355..b295f0ab998 100644 --- a/tests/hermes_cli/test_web_server_files.py +++ b/tests/hermes_cli/test_web_server_files.py @@ -436,3 +436,55 @@ def test_stream_upload_large_file_under_cap_succeeds(forced_files_client, monkey assert created.status_code == 200 assert file_path.stat().st_size == len(payload) assert file_path.read_bytes() == payload + + +def test_stream_upload_cleans_temp_on_cancellation(forced_files_client): + """A client disconnect mid-stream (asyncio.CancelledError) must not leak a temp file. + + CancelledError is a BaseException, not an Exception, so it bypasses the + endpoint's ``except`` clauses entirely. The cleanup therefore lives in a + ``finally`` keyed on a success flag — without it, every aborted large + upload (the exact NS-501 scenario) would orphan a partial ``.upload`` temp + file in the target directory. We invoke the endpoint coroutine directly so + the BaseException propagates instead of being swallowed by the test client. + """ + import asyncio + + _client, root = forced_files_client + target = root / "out" / "aborted.bin" + target.parent.mkdir(parents=True, exist_ok=True) + + class _AbortingUpload: + """UploadFile stand-in that yields one chunk then aborts like a dropped client.""" + + filename = "aborted.bin" + + def __init__(self): + self._calls = 0 + + async def read(self, _size): + self._calls += 1 + if self._calls == 1: + return b"partial chunk before the client vanished" + raise asyncio.CancelledError() + + async def close(self): + return None + + request = SimpleNamespace() + + with pytest.raises(asyncio.CancelledError): + asyncio.run( + web_server.upload_managed_file_stream( + request=request, + file=_AbortingUpload(), + path=str(target), + overwrite=True, + ) + ) + + # No partial data was promoted into place ... + assert not target.exists() + # ... and no .upload temp file was left behind. + leftovers = [p.name for p in target.parent.iterdir() if ".upload" in p.name] + assert leftovers == [], f"temp upload files leaked on cancellation: {leftovers}" diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 98bacbf42a0..4e2159a1a02 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -178,7 +178,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { "fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1", # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web] - "python-multipart==0.0.20", # FastAPI UploadFile/Form for streaming uploads (NS-501) + "python-multipart==0.0.27", # FastAPI UploadFile/Form for streaming uploads (NS-501) ), # Vision image-resize recovery (Pillow). Pillow is now a CORE dependency # (pyproject `dependencies`), so this entry is a belt-and-suspenders fallback diff --git a/uv.lock b/uv.lock index fc340bdbe89..095b7563311 100644 --- a/uv.lock +++ b/uv.lock @@ -1713,7 +1713,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "python-multipart", specifier = ">=0.0.9,<1" }, - { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.20" }, + { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.27" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" }, @@ -3317,11 +3317,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.27" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, ] [[package]] From b892ee2bcf1b65f3010c7229f4d61e574ada54ad Mon Sep 17 00:00:00 2001 From: xxxigm Date: Tue, 16 Jun 2026 21:20:14 +0700 Subject: [PATCH 013/470] fix(agent): summarize non-retryable API errors so raw HTML never leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a non-retryable client error aborts the turn (e.g. a Codex/Cloudflare HTTP 403 "managed challenge" page), the conversation loop returned the failure dict with `error: str(api_error)` — the entire ~60KB HTML page. Downstream consumers deliver that field verbatim: a cron job dumped a Cloudflare challenge page to Discord, where it was split into ~31 messages. The sibling "max retries exhausted" path already collapses such bodies via `_summarize_api_error` (which extracts the / status from HTML error pages). This makes the non-retryable path consistent: compute the summary once and use it for both the status emit and the returned `error`. --- agent/conversation_loop.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index ef69ac68329..163a508a8cd 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -3197,15 +3197,22 @@ def run_conversation( # Terminal — flush buffered context so the user sees # what was tried before the abort. agent._flush_status_buffer() + # Summarize once: Cloudflare/proxy HTML challenge pages and + # other raw provider bodies must be collapsed to a short + # one-liner here, otherwise the full page leaks into the + # returned ``error`` field and downstream consumers deliver + # it verbatim (e.g. a cron failure notification dumped a + # ~60KB Cloudflare challenge page as 31 Discord messages). + _nonretryable_summary = agent._summarize_api_error(api_error) if classified.reason == FailoverReason.content_policy_blocked: agent._emit_status( f"❌ Provider safety filter blocked this request: " - f"{agent._summarize_api_error(api_error)}" + f"{_nonretryable_summary}" ) else: agent._emit_status( f"❌ Non-retryable error (HTTP {status_code}): " - f"{agent._summarize_api_error(api_error)}" + f"{_nonretryable_summary}" ) agent._vprint(f"{agent.log_prefix}❌ Non-retryable client error (HTTP {status_code}). Aborting.", force=True) agent._vprint(f"{agent.log_prefix} 🔌 Provider: {_provider} Model: {_model}", force=True) @@ -3309,7 +3316,7 @@ def run_conversation( "api_calls": api_call_count, "completed": False, "failed": True, - "error": str(api_error), + "error": _nonretryable_summary, } if retry_count >= max_retries: From f18f31ebf6dda993ade9f9de222fcf7fdfe8952e Mon Sep 17 00:00:00 2001 From: xxxigm <tuancanhnguyen706@gmail.com> Date: Thu, 18 Jun 2026 14:55:38 +0700 Subject: [PATCH 014/470] test(agent): cover non-retryable error HTML summarization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks the contract that a non-retryable failure (a Cloudflare 403 "managed challenge" page) returns a short, HTML-free `error` field — guarding the field path where the raw page was dumped to Discord as ~31 messages. The test drives the standard chat-completions path with a concrete model so the turn actually reaches `client.chat.completions.create`, where the mocked 403 is raised. It asserts the create call happened (guarding against a vacuous pass — an empty model on the Codex Responses path would otherwise abort on a validation ValueError before any API call) and that the summarized error includes "403" while excluding <html> / _cf_chl_opt. The non-retryable abort path is provider-agnostic; a Cloudflare managed-challenge 403 can surface on any provider behind Cloudflare. --- .../test_nonretryable_error_html_summary.py | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 tests/run_agent/test_nonretryable_error_html_summary.py diff --git a/tests/run_agent/test_nonretryable_error_html_summary.py b/tests/run_agent/test_nonretryable_error_html_summary.py new file mode 100644 index 00000000000..db765b124f3 --- /dev/null +++ b/tests/run_agent/test_nonretryable_error_html_summary.py @@ -0,0 +1,130 @@ +"""Regression: non-retryable API failures must not leak raw HTML pages. + +A scheduled cron job fell back to the Codex (``chatgpt.com``) provider, which +returned a Cloudflare *challenge* page (HTTP 403) instead of a normal API +response. The conversation loop classified this as a non-retryable client +error and returned the failure dict — but the ``error`` field carried +``str(api_error)``, i.e. the entire ~60 KB Cloudflare HTML page. The cron +scheduler then delivered that verbatim to Discord, where it was split into +~31 messages (the reporter's "31 part discord message which is cloudflares +challenge page"). + +The sibling "max retries exhausted" path already summarized the error via +``_summarize_api_error`` (which collapses HTML pages to a one-liner); the +non-retryable path did not. These tests lock the contract: whichever +terminal path is taken, ``result['error']`` is a short, HTML-free summary. +""" + +from unittest.mock import MagicMock, patch + +import run_agent +from run_agent import AIAgent + + +# A representative Cloudflare "managed challenge" body, matching the shape the +# Codex backend returned in the field report (no <title>, large inline +# ``_cf_chl_opt`` script). Padded so length-based assertions are meaningful. +_CLOUDFLARE_CHALLENGE_HTML = ( + "<!DOCTYPE html>\n<html>\n <head>\n" + ' <meta http-equiv="refresh" content="360"></head>\n' + " <body>\n <div class=\"data\"><noscript>" + "Enable JavaScript and cookies to continue</noscript>" + "<script>(function(){window._cf_chl_opt = {cRay: 'a0ca002c4f91769c'," + "cZone: 'chatgpt.com', cType: 'managed', " + + ("md: '" + "x" * 4000 + "',") + + "};})();</script></div>\n </body>\n</html>\n" +) + + +def _make_403_html_error() -> Exception: + """An exception mimicking a Codex 403 whose body is a Cloudflare page.""" + err = Exception(_CLOUDFLARE_CHALLENGE_HTML) + err.status_code = 403 + return err + + +def _make_agent() -> AIAgent: + # Drive the standard chat-completions path with a concrete model so the + # turn actually reaches ``client.chat.completions.create`` — that is where + # the mocked 403 is raised. The non-retryable abort being exercised lives + # in the shared conversation loop and is provider-agnostic; a Cloudflare + # "managed challenge" 403 can surface on any provider sitting behind + # Cloudflare (it was first reported on the Codex backend). Pinning + # ``api_mode`` + ``model`` here avoids the earlier abort the previous + # revision hit: an empty model on the Codex Responses path raised a + # validation ``ValueError`` *before* any API call, so the test passed + # without ever touching the 403 summarization path. + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + a = AIAgent( + api_key="test-key-1234567890", + base_url="https://api.openai.com/v1", + provider="openai", + api_mode="chat_completions", + model="gpt-5.5", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + a.client = MagicMock() + a._cached_system_prompt = "You are helpful." + a._use_prompt_caching = False + a.tool_delay = 0 + a.compression_enabled = False + a.save_trajectories = False + return a + + +def test_summarize_collapses_cloudflare_challenge_page(): + """``_summarize_api_error`` must never echo the raw HTML body.""" + summary = AIAgent._summarize_api_error(_make_403_html_error()) + + assert "<html" not in summary.lower() + assert "<!doctype" not in summary.lower() + assert "_cf_chl_opt" not in summary + # A one-liner, not a multi-kilobyte page. + assert len(summary) < 200 + # Still informative: the HTTP status survives. + assert "403" in summary + + +def test_non_retryable_failure_error_is_summarized_not_raw_html(): + """The terminal non-retryable dict must carry a short, HTML-free error. + + This is the exact field path: a 403 Cloudflare challenge with no fallback + configured aborts as a non-retryable client error. Before the fix the + returned ``error`` was the full ~60 KB page. + + The mocked 403 is the *only* failure the turn can hit — the agent reaches + ``client.chat.completions.create`` (asserted below), so the test cannot + pass vacuously by aborting on some earlier, unrelated error. + """ + agent = _make_agent() + agent.client.chat.completions.create.side_effect = _make_403_html_error() + + with ( + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("daily briefing please") + + # Guard against a vacuous pass: the mocked 403 must actually be the + # failure that aborted the turn. (The previous revision never reached + # this call and still "passed".) + assert agent.client.chat.completions.create.called + assert result.get("failed") is True + error = result.get("error") or "" + # The whole point of the fix: no raw HTML / Cloudflare markup leaks. + assert "<html" not in error.lower() + assert "<!doctype" not in error.lower() + assert "_cf_chl_opt" not in error + # Still informative: the summarized 403 status survives into the field + # delivered downstream. + assert "403" in error + # The original page was tens of kilobytes; a summary is short. + assert len(error) < 500 + assert len(error) < len(_CLOUDFLARE_CHALLENGE_HTML) From d0622cafabfbf0acfe8649e4f0390d20d0bc11d6 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:46:47 +0530 Subject: [PATCH 015/470] refactor(agent): reuse hoisted summary in content-policy branch The non-retryable abort path now computes _nonretryable_summary once and reuses it at the emit sites and the returned error field. The content-policy-blocked return branch still recomputed the identical value into a separate _summary local, half-honoring the 'summarize once' intent. _summarize_api_error is a pure staticmethod and api_error is never reassigned in this block, so _summary was provably byte-identical to _nonretryable_summary. Reuse the hoisted value and drop the redundant call. Behavior-preserving. --- agent/conversation_loop.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 163a508a8cd..0ccc9649428 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -3297,18 +3297,17 @@ def run_conversation( else: agent._persist_session(messages, conversation_history) if classified.reason == FailoverReason.content_policy_blocked: - _summary = agent._summarize_api_error(api_error) _policy_response = ( "⚠️ The model provider's safety filter blocked this request " "(not a Hermes/gateway failure).\n\n" - f"Provider message: {_summary}\n\n" + f"Provider message: {_nonretryable_summary}\n\n" f"{_CONTENT_POLICY_RECOVERY_HINT}" ) return _content_policy_blocked_result( messages, api_call_count, final_response=_policy_response, - error_detail=_summary, + error_detail=_nonretryable_summary, ) return { "final_response": None, From d573e7c9e1639d7c98c02f3face6f599464f8758 Mon Sep 17 00:00:00 2001 From: emozilla <emozilla@nousresearch.com> Date: Thu, 18 Jun 2026 16:00:26 -0400 Subject: [PATCH 016/470] fix(dashboard): use DS Button prefix/size API instead of inline icons @nous-research/ui@0.18.2 Button is grid-based: size=xs is an aspect-square icon-only box, and icons belong in prefix/suffix. The dashboard used shadcn-style size=xs + inline <Icon/> text children, which forced text buttons into broken tall squares (Configure, Run setup, Select, Save keys) and split icon/label across grid columns elsewhere (Schedule it, Prune/Delete actions). Move leading icons to prefix and size text buttons as sm/default. For the post-setup spinner, drive the spin from a button-level [&_svg]:animate-spin selector since the prefix slot clones the icon and overwrites its className. - ToolsetConfigDrawer: Select, Save keys, Run setup - SkillsPage: New skill, Configure - AutomationBlueprints: Schedule it - SessionsPage: Prune old sessions, Delete empty, Delete selected --- web/src/components/AutomationBlueprints.tsx | 7 +++-- web/src/components/ToolsetConfigDrawer.tsx | 32 ++++++++++++--------- web/src/pages/SessionsPage.tsx | 7 ++--- web/src/pages/SkillsPage.tsx | 7 ++--- 4 files changed, 30 insertions(+), 23 deletions(-) diff --git a/web/src/components/AutomationBlueprints.tsx b/web/src/components/AutomationBlueprints.tsx index 10d1270fa05..209c75e0682 100644 --- a/web/src/components/AutomationBlueprints.tsx +++ b/web/src/components/AutomationBlueprints.tsx @@ -149,8 +149,11 @@ function BlueprintCard({ </p> ) : null} <div className="flex items-center gap-2"> - <Button onClick={() => void submit()} disabled={submitting}> - {submitting ? <Spinner className="h-4 w-4" /> : <Clock className="h-4 w-4" />} + <Button + onClick={() => void submit()} + disabled={submitting} + prefix={submitting ? <Spinner /> : <Clock />} + > Schedule it </Button> </div> diff --git a/web/src/components/ToolsetConfigDrawer.tsx b/web/src/components/ToolsetConfigDrawer.tsx index 792393c9285..a042a780ad5 100644 --- a/web/src/components/ToolsetConfigDrawer.tsx +++ b/web/src/components/ToolsetConfigDrawer.tsx @@ -309,7 +309,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr </Badge> ) : ( <Button - size="xs" + size="sm" outlined onClick={() => void handleSelectProvider(provider)} disabled={selecting !== null} @@ -376,7 +376,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr </div> ))} <Button - size="xs" + size="sm" onClick={() => void handleSaveKeys(provider)} disabled={savingProvider !== null} > @@ -401,22 +401,28 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr . Runs on this host — may take a few minutes. </p> <Button - size="xs" + size="sm" outlined + className={cn( + postSetupRunning && + postSetupKey === provider.post_setup && + "[&_svg]:animate-spin", + )} onClick={() => void handleRunPostSetup(provider)} disabled={postSetupRunning} + prefix={ + postSetupRunning && + postSetupKey === provider.post_setup ? ( + <Loader2 /> + ) : ( + <Terminal /> + ) + } > {postSetupRunning && - postSetupKey === provider.post_setup ? ( - <> - <Loader2 className="h-3 w-3 animate-spin mr-1" /> - Installing… - </> - ) : ( - <> - <Terminal className="h-3 w-3 mr-1" /> Run setup - </> - )} + postSetupKey === provider.post_setup + ? "Installing…" + : "Run setup"} </Button> </div> )} diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index c48d2453876..2d70c399af2 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -794,10 +794,9 @@ export default function SessionsPage() { <Button outlined size="sm" - className="gap-1.5" onClick={() => setPruneOpen(true)} + prefix={<Archive />} > - <Archive className="h-3.5 w-3.5" /> Prune old sessions </Button>, ); @@ -1491,8 +1490,8 @@ export default function SessionsPage() { onClick={() => setDeleteEmptyOpen(true)} aria-label={t.sessions.deleteEmpty} title={t.sessions.deleteEmpty} + prefix={<Eraser />} > - <Eraser className="h-3.5 w-3.5" /> <span className="font-mondwest normal-case text-xs"> {t.sessions.deleteEmpty} ({emptyCount}) </span> @@ -1565,8 +1564,8 @@ export default function SessionsPage() { "{count}", String(selectedIds.size), )} + prefix={<Trash2 />} > - <Trash2 className="h-3.5 w-3.5" /> <span className="font-mondwest normal-case text-xs"> {t.sessions.deleteSelected.replace( "{count}", diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index e8f764d8e86..cb6beef22fa 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -493,9 +493,8 @@ export default function SkillsPage() { .replace("{s}", activeSkills.length !== 1 ? "s" : "")} </Badge> <Button - size="xs" + size="sm" outlined - className="uppercase" onClick={openCreateEditor} prefix={<Plus />} > @@ -594,11 +593,11 @@ export default function SkillsPage() { )} <div className="mt-3"> <Button - size="xs" + size="sm" outlined onClick={() => setConfigToolset(ts)} + prefix={<Wrench />} > - <Wrench className="h-3 w-3 mr-1" /> Configure </Button> </div> From d2c53ff5583eca0e5f4009a3fcc28c5da8b17fce Mon Sep 17 00:00:00 2001 From: Ben Barclay <ben@nousresearch.com> Date: Fri, 19 Jun 2026 09:33:15 +1000 Subject: [PATCH 017/470] feat(relay): WS-only inbound on the gateway adapter (Phase 3) (#48294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector now delivers inbound (messages + interrupts) over the gateway's OUTBOUND /relay WebSocket, not a signed HTTP POST to an inbound endpoint. The gateway needs no inbound HTTP port — which is what makes hosted gateways (no public IP) able to receive inbound at all. - gateway/relay/adapter.py: connect() wires set_interrupt_inbound_handler( self.on_interrupt) so connector->gateway interrupt_inbound frames bridge into the existing per-session interrupt path (the inbound message handler was already wired). Removed _maybe_start_inbound_receiver() + the _inbound_runner lifecycle — there is no HTTP receiver anymore. - gateway/relay/inbound_receiver.py: deleted (the signed-HTTP InboundDelivery receiver). - gateway/relay/__init__.py: removed relay_inbound_config() (dead with the receiver gone). The delivery key is still set in-process by self-provision for forward-compat but is no longer consumed for inbound. - docs/relay-connector-contract.md: §3 rewritten — inbound is the WS back-channel routed cross-instance via the connector's relay bus; §5 interrupt + §6 auth table updated; the old signed-HTTP-POST + per-tenant-delivery-key-signing path is documented as superseded. gatewayEndpoint noted as passthrough-plane only. Tests: stub_connector grows set_interrupt_inbound_handler + push_interrupt; new test_relay_interrupt case proves connect() wires BOTH inbound handlers and an interrupt_inbound frame over the WS cancels the right session. Removed the HTTP-receiver test; updated the crypto-shedding scan + self-provision delivery-key assertion. 88 relay tests pass. EXPERIMENTAL. Pairs with gateway-gateway (relay bus + WsGatewayDelivery) and the NAS GATEWAY_RELAY_URL stamp. The cross-repo E2E (connector repo) proves the full multi-instance path against this production adapter code. --- docs/relay-connector-contract.md | 89 +++++--- gateway/relay/__init__.py | 41 +--- gateway/relay/adapter.py | 52 +---- gateway/relay/inbound_receiver.py | 204 ------------------ tests/gateway/relay/stub_connector.py | 12 ++ tests/gateway/relay/test_inbound_receiver.py | 150 ------------- tests/gateway/relay/test_relay_interrupt.py | 20 ++ .../gateway/relay/test_relay_sheds_crypto.py | 18 +- tests/gateway/relay/test_self_provision.py | 7 +- 9 files changed, 117 insertions(+), 476 deletions(-) delete mode 100644 gateway/relay/inbound_receiver.py delete mode 100644 tests/gateway/relay/test_inbound_receiver.py diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index 39c86a5f839..54fff9406cc 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -62,33 +62,55 @@ live platform adapter's capability methods. The connector normalizes each platform wire event into a `MessageEvent` (`gateway/platforms/base.py`) and delivers it to the gateway. **Inbound is -delivered over a signed HTTP POST, not the outbound `/relay` WebSocket** (see -the transport note below). The gateway keys the session via `build_session_key()` +delivered over the gateway's OUTBOUND `/relay` WebSocket** (see the transport +note below) — the connector pushes an `inbound` frame down the socket the +gateway already dialed. The gateway keys the session via `build_session_key()` from the embedded `SessionSource` — so populating the right discriminators is the single highest-correctness responsibility of the connector. -### Inbound transport (signed HTTP POST, not the outbound WS) +### Inbound transport (WS back-channel, not HTTP) The gateway dials **out** to the connector's `/relay` WebSocket for the -handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound, -however, is delivered the other way: the connector **POSTs** the normalized -event to the gateway's inbound endpoint (`HttpGatewayDelivery` on the connector; -`gateway/relay/inbound_receiver.py` on the gateway). The reason is -multi-instance: the connector instance that owns a platform's socket (and thus -produces inbound events) is generally **not** the instance a given gateway -dialed its outbound WS into, so inbound must target a tenant **endpoint** (which -may load-balance across gateway instances) rather than ride one gateway's -outbound socket. Each delivery is HMAC-signed with the per-tenant **delivery -key** (§6.1); the gateway verifies the signature over the exact raw bytes before -accepting the event. Two POST targets: +handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound rides +the **same socket** in the other direction: the connector pushes an `inbound` +frame (and `interrupt_inbound` for §5) down the gateway's outbound WS. There is +**no gateway-side inbound HTTP endpoint** — a gateway need not (and, when hosted, +cannot) expose any inbound port; everything flows over the connection it +initiated. + +**Multi-instance routing.** The connector instance that owns a platform's socket +(and thus produces inbound events) is generally **not** the instance the gateway +dialed its outbound WS into. The producing instance therefore publishes the +event on the connector's internal **relay bus** (Redis pub/sub; `RelayBus` in +`src/core/relayBus.ts`) keyed by tenant. Every connector instance subscribes and +routes each message to its **local** sessions for that tenant +(`RelayServer.routeBusMessage`); the single instance that actually holds the +gateway's socket delivers it, and instances with no local session for the tenant +no-op. Cross-instance delivery is thus an in-cluster Redis hop, not a public +HTTP call. + +Frames (connector → gateway, over the WS): + +- `{"type":"inbound", "event": <MessageEvent>, "bufferId"?}` +- `{"type":"interrupt_inbound", "session_key", "chat_id"}` (§5) + +**Trust.** The WS upgrade is authenticated with the gateway's per-gateway secret +(§6.1), so the channel is trusted end to end — inbound frames are not separately +HMAC-signed (the authenticated socket subsumes the per-delivery origin proof the +old HTTP path needed). The relay-bus hop is inside the connector trust domain +(same as the lease/buffer/capability stores). + +> Earlier drafts of this contract delivered inbound over a signed **HTTP POST** +> to a `gatewayEndpoint` (`HttpGatewayDelivery` + a gateway-side +> `inbound_receiver`), HMAC-signed with a per-tenant delivery key. That required +> every gateway to expose a reachable inbound URL — impossible for hosted +> gateways, which have no public IP. The WS back-channel above replaces it; the +> per-tenant delivery key is retained at provision for forward-compat but is no +> longer used for inbound. `gatewayEndpoint` remains only for the **passthrough +> plane** (Class-2/3 webhooks like Discord interactions / Twilio), which is a +> separate synchronous-forward path and out of scope for this section. -- `POST {gatewayEndpoint}` → `{"type":"message", "event": <MessageEvent>}` -- `POST {gatewayEndpoint}/interrupt` → `{"type":"interrupt", "session_key", "reason"?}` (§5) -> An earlier draft of this contract delivered inbound over the WS `inbound` -> frame. That only works single-instance and predates the multi-instance -> socket-ownership + channel-auth model; the signed-HTTP path above is the -> shipped design. ### SessionSource fields (the wire surface) @@ -178,13 +200,15 @@ gateway holds zero capability material). Source of truth: mid-turn `/stop` over the outbound WS. The connector MUST forward it to the gateway instance running that `session_key` (the routing invariant). - **Connector → gateway:** an inbound interrupt for a `session_key` is delivered - as a **signed HTTP POST** to `{gatewayEndpoint}/interrupt` (§3 transport note), - and bridged by the adapter's `on_interrupt(session_key, chat_id)` into the - existing per-session interrupt mechanism, cancelling exactly that turn + as an `interrupt_inbound` frame down the gateway's outbound WS (§3 transport + note) — routed cross-instance via the relay bus to whichever instance holds + the socket — and bridged by the adapter's `on_interrupt(session_key, chat_id)` + into the existing per-session interrupt mechanism, cancelling exactly that turn (siblings untouched). -The gateway→connector `/stop` rides the outbound WS; the connector→gateway -interrupt rides the same signed-HTTP inbound path as a normalized event. +Both directions ride the gateway's outbound WS: the gateway→connector `/stop` +egresses over it, and the connector→gateway interrupt rides the same `inbound` +back-channel as a normalized event. --- @@ -231,20 +255,21 @@ only in transport. See `docs/capability-trust-boundary.md` (connector repo: A2 makes the connector the sole holder of platform secrets while the gateway may be **customer-managed and internet-exposed**, so the connector⇄gateway channel -is itself authenticated. The gateway holds two enrollment-issued credentials -(`hermes gateway enroll` → connector `/relay/enroll`): a **per-gateway secret** -and a **per-tenant delivery key**. Both are HMAC-SHA256 schemes with a -multi-secret rotation verify list (gateway side: `gateway/relay/auth.py`; -connector side: `src/core/relayAuthToken.ts` + `src/core/deliverySigning.ts`). +is itself authenticated. The gateway holds an enrollment- or provision-issued +**per-gateway secret** (`hermes gateway enroll` → connector `/relay/enroll`, or +managed self-provision → `/relay/provision`) that authenticates its outbound WS +upgrade. It is an HMAC-SHA256 scheme with a multi-secret rotation verify list +(gateway side: `gateway/relay/auth.py`; connector side: +`src/core/relayAuthToken.ts`). | Leg | Credential | Mechanism | |-----|-----------|-----------| | Gateway → connector WS upgrade | per-gateway secret | An `Authorization` bearer header on the `/relay` upgrade. The token is `base64url(payload:exp:sig)` where `payload = gatewayId` and `sig = HMAC(payload:exp, secret)`. Connector verifies and rejects the upgrade (**close 4401**) on mismatch/absence/revocation. The authenticated tenant comes from the connector's store, never the `hello` frame. | -| Connector → gateway inbound POST | per-tenant delivery key | Two headers: `x-relay-timestamp` (unix seconds) and `x-relay-signature` (hex `HMAC(ts.rawBody, deliveryKey)`). Gateway verifies over the **exact raw bytes** within a ±300s replay window before accepting the event; rejects **401** otherwise. | +| Connector → gateway inbound (`inbound` / `interrupt_inbound` frames) | — (rides the authenticated WS) | Inbound is pushed down the gateway's already-authenticated outbound socket (§3), so no per-message signature is needed. A **per-tenant delivery key** is still issued at enroll/provision and retained for forward-compat, but is no longer used to sign inbound. | This is the **channel** authenticator — distinct from platform crypto, which the relay path still sheds entirely (§6). The gateway holds zero platform secrets; -these two keys authenticate only the connector link. Full threat model + +the per-gateway secret authenticates only the connector link. Full threat model + enrollment/rotation/kill-switch design: `docs/connector-gateway-auth-design.md` (connector repo). diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py index 421fe0ac240..a0bd4f526ef 100644 --- a/gateway/relay/__init__.py +++ b/gateway/relay/__init__.py @@ -79,40 +79,6 @@ def relay_connection_auth() -> tuple[Optional[str], Optional[str]]: return (gateway_id or None, secret or None) -def relay_inbound_config() -> tuple[Optional[str], Optional[str], int]: - """Resolve (delivery_key, bind_host, bind_port) for the inbound receiver. - - The connector delivers normalized inbound events to this gateway over a - SIGNED HTTP POST (not the outbound WS), verified with the per-tenant delivery - key issued at enrollment (``GATEWAY_RELAY_DELIVERY_KEY``). The receiver only - starts when a delivery key AND a bind port are configured — a gateway with no - public inbound URL (e.g. a purely outbound dev run) simply doesn't run it. - - Env first (Docker), then ``gateway.relay_delivery_key`` / - ``gateway.relay_inbound_host`` / ``gateway.relay_inbound_port`` in config.yaml. - Port 0 (default/unset) -> receiver disabled. - """ - key = os.environ.get("GATEWAY_RELAY_DELIVERY_KEY", "").strip() - host = os.environ.get("GATEWAY_RELAY_INBOUND_HOST", "").strip() - port_raw = os.environ.get("GATEWAY_RELAY_INBOUND_PORT", "").strip() - if not (key and port_raw): - try: - from gateway.run import _load_gateway_config # late import to avoid cycle - - cfg = (_load_gateway_config().get("gateway") or {}) - key = key or str(cfg.get("relay_delivery_key", "") or "").strip() - host = host or str(cfg.get("relay_inbound_host", "") or "").strip() - if not port_raw: - port_raw = str(cfg.get("relay_inbound_port", "") or "").strip() - except Exception: # noqa: BLE001 - config absence/parse must never crash registration - pass - try: - port = int(port_raw) if port_raw else 0 - except ValueError: - port = 0 - return (key or None, host or "0.0.0.0", port) - - def relay_endpoint() -> Optional[str]: """The gateway's own PUBLIC inbound URL, asserted to the connector at provision. @@ -318,8 +284,11 @@ def self_provision_if_managed() -> bool: logger.warning("relay self-provision failed (%s); gateway will boot without relay auth", exc) return False - # Set creds in-process so register_relay_adapter() + relay_inbound_config() - # read them from os.environ. Never logged. + # Set creds in-process so register_relay_adapter() reads them from os.environ + # (the per-gateway secret authenticates the outbound WS upgrade). The delivery + # key is still issued by the connector and persisted for forward-compat, but + # inbound now rides the WS (no HTTP receiver), so it is not consumed here. + # Never logged. os.environ["GATEWAY_RELAY_ID"] = str(result.get("gatewayId") or gateway_id) os.environ["GATEWAY_RELAY_SECRET"] = str(result.get("secret") or "") os.environ["GATEWAY_RELAY_DELIVERY_KEY"] = str(result.get("deliveryKey") or "") diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index b64f7abc517..fc4e5f40ee7 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -58,10 +58,6 @@ class RelayAdapter(BasePlatformAdapter): # Capability surface read by stream_consumer (getattr(..., 4096)). self.MAX_MESSAGE_LENGTH = descriptor.max_message_length self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain") - # Inbound delivery receiver (signed connector→gateway HTTP POSTs). Built - # lazily in connect() when a delivery key + bind port are configured; a - # purely-outbound dev gateway runs without it. See inbound_receiver.py. - self._inbound_runner: Any = None # ── capability surface (from descriptor) ───────────────────────────── @property @@ -80,6 +76,12 @@ class RelayAdapter(BasePlatformAdapter): if self._transport is None: raise RuntimeError("RelayAdapter has no transport configured") self._transport.set_inbound_handler(self._on_inbound) + # Inbound interrupts (connector -> owning gateway) arrive as + # interrupt_inbound frames over the SAME outbound WS; bridge them to the + # adapter's interrupt path. WS-only: there is no inbound HTTP receiver. + set_interrupt = getattr(self._transport, "set_interrupt_inbound_handler", None) + if callable(set_interrupt): + set_interrupt(self.on_interrupt) ok = await self._transport.connect() if not ok: return False @@ -92,40 +94,12 @@ class RelayAdapter(BasePlatformAdapter): logger.warning("relay handshake failed: %s", exc) return False self._apply_descriptor(descriptor) - # Start the signed inbound-delivery receiver if configured (the connector - # POSTs normalized events to it over HTTP, verified with the tenant - # delivery key). Non-fatal: a receiver bind failure must not fail the - # outbound connection — the gateway can still send. - await self._maybe_start_inbound_receiver() + # Inbound (messages + interrupts) is delivered over the outbound WS via + # the connector's relay bus — there is NO inbound HTTP endpoint (hosted + # gateways have no public IP). The transport's reader already dispatches + # `inbound` / `interrupt_inbound` frames to the handlers wired above. return True - async def _maybe_start_inbound_receiver(self) -> None: - """Start the inbound HTTP receiver when a delivery key + port are set.""" - from gateway.relay import relay_inbound_config - - delivery_key, host, port = relay_inbound_config() - if not (delivery_key and port): - return # no inbound URL configured -> outbound-only gateway - try: - from aiohttp import web - - from gateway.relay.inbound_receiver import InboundDeliveryReceiver - - receiver = InboundDeliveryReceiver( - delivery_key_verify_list=lambda: [delivery_key], - on_message=self._on_inbound, - on_interrupt=self.on_interrupt, - ) - runner = web.AppRunner(receiver.build_app(), access_log=None) - await runner.setup() - site = web.TCPSite(runner, host, port) - await site.start() - self._inbound_runner = runner - logger.info("relay inbound receiver listening on http://%s:%s", host, port) - except Exception as exc: # noqa: BLE001 - inbound bind failure must not kill outbound - logger.warning("relay inbound receiver failed to start: %s", exc) - self._inbound_runner = None - def _apply_descriptor(self, descriptor: CapabilityDescriptor) -> None: """Adopt a (re)negotiated descriptor into the live capability surface.""" self.descriptor = descriptor @@ -148,12 +122,6 @@ class RelayAdapter(BasePlatformAdapter): await self.interrupt_session_activity(session_key, chat_id) async def disconnect(self) -> None: - if self._inbound_runner is not None: - try: - await self._inbound_runner.cleanup() - except Exception: # noqa: BLE001 - best-effort teardown - pass - self._inbound_runner = None if self._transport is not None: await self._transport.disconnect() diff --git a/gateway/relay/inbound_receiver.py b/gateway/relay/inbound_receiver.py deleted file mode 100644 index 733fe38c2c6..00000000000 --- a/gateway/relay/inbound_receiver.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Gateway-side inbound delivery receiver. EXPERIMENTAL. - -The connector delivers normalized inbound events to a tenant's gateway over a -**signed HTTP POST** (connector ``src/relay/httpGatewayDelivery.ts``), NOT over -the gateway's outbound ``/relay`` WebSocket: the connector instance that owns a -platform socket is generally not the instance a given gateway dialed out to, so -inbound is delivered to a tenant ENDPOINT (which may load-balance across gateway -instances). Each delivery is HMAC-signed with the per-tenant **delivery key** -(``gateway/relay/auth.py``); this receiver verifies the signature over the EXACT -raw request bytes before accepting the event. - -Two routes (mirroring the connector's two POST targets): - POST {base} {"type":"message", "event": <MessageEvent>, ...} - POST {base}/interrupt {"type":"interrupt","session_key": ..., "reason"?} - -The receiver: - 1. reads the RAW body bytes (never a reparsed/re-serialized form — the HMAC is - over the literal bytes the connector signed), - 2. verifies ``x-relay-signature`` / ``x-relay-timestamp`` against the delivery - key verify list (primary + secondary during rotation), within the replay - window — rejects 401 on any failure, - 3. parses the JSON and dispatches: a ``message`` to the inbound handler (the - RelayAdapter's ``handle_message`` via the transport's normal path), an - ``interrupt`` to the interrupt handler. - -EXPERIMENTAL: the transport protocol may change without a deprecation cycle -until ≥2 Class-1 platforms validate it. See docs/relay-connector-contract.md. -""" - -from __future__ import annotations - -import json -import logging -from typing import Any, Awaitable, Callable, Optional, Sequence - -from gateway.platforms.base import MessageEvent -from gateway.relay.auth import ( - DELIVERY_SIG_HEADER, - DELIVERY_TS_HEADER, - verify_delivery_signature, -) - -logger = logging.getLogger(__name__) - -# Callbacks the receiver dispatches verified deliveries to. -InboundMessageHandler = Callable[[MessageEvent], Awaitable[None]] -InboundInterruptHandler = Callable[[str, str], Awaitable[None]] - -try: # lazy/optional dep — mirrors the other HTTP-receiving adapters - from aiohttp import web -except ImportError: # pragma: no cover - exercised only when the extra is absent - web = None # type: ignore[assignment] - -AIOHTTP_AVAILABLE = web is not None - - -def _event_from_wire(raw: dict) -> MessageEvent: - """Rebuild a MessageEvent from the connector's normalized inbound payload. - - Identical mapping to the WS transport's ``_event_from_wire`` (the wire shape - is the same; only the transport differs). Kept here so the HTTP receiver has - no import dependency on the WS transport module. - """ - from gateway.config import Platform - from gateway.platforms.base import MessageType - from gateway.session import SessionSource - - src = raw.get("source", {}) or {} - platform = src.get("platform", "relay") - try: - platform_enum = Platform(platform) - except ValueError: - platform_enum = Platform.RELAY - - source = SessionSource( - platform=platform_enum, - chat_id=src.get("chat_id", ""), - chat_type=src.get("chat_type", "dm"), - chat_name=src.get("chat_name"), - user_id=src.get("user_id"), - user_name=src.get("user_name"), - thread_id=src.get("thread_id"), - chat_topic=src.get("chat_topic"), - user_id_alt=src.get("user_id_alt"), - chat_id_alt=src.get("chat_id_alt"), - guild_id=src.get("guild_id"), - parent_chat_id=src.get("parent_chat_id"), - message_id=src.get("message_id"), - ) - try: - msg_type = MessageType(raw.get("message_type", "text")) - except ValueError: - msg_type = MessageType.TEXT - - return MessageEvent( - text=raw.get("text", ""), - message_type=msg_type, - source=source, - message_id=raw.get("message_id"), - reply_to_message_id=raw.get("reply_to_message_id"), - media_urls=raw.get("media_urls") or [], - ) - - -class InboundDeliveryReceiver: - """Verifies + dispatches signed connector→gateway inbound deliveries. - - Transport-agnostic core: ``handle_raw`` takes the raw body bytes + headers + - which route was hit and returns ``(status, body)``. The aiohttp wiring - (``build_app`` / ``serve``) is a thin shell so the verify+dispatch logic is - unit-testable without a live socket. - """ - - def __init__( - self, - *, - delivery_key_verify_list: Callable[[], Sequence[str]], - on_message: InboundMessageHandler, - on_interrupt: Optional[InboundInterruptHandler] = None, - max_skew_seconds: int = 300, - ) -> None: - # A callable (not a static list) so a rotated delivery key is picked up - # without rebuilding the receiver — mirrors the connector's verify list. - self._verify_list = delivery_key_verify_list - self._on_message = on_message - self._on_interrupt = on_interrupt - self._max_skew_seconds = max_skew_seconds - - async def handle_raw( - self, *, raw_body: bytes, timestamp: Optional[str], signature: Optional[str], is_interrupt: bool - ) -> tuple[int, dict]: - """Verify the signature over ``raw_body`` and dispatch. Returns (status, json). - - 401 on a missing/invalid/expired signature (never dispatches unverified). - 400 on malformed JSON. 200 on a verified, dispatched delivery. - """ - verify_keys = list(self._verify_list() or []) - if not verify_keys: - # No delivery key provisioned -> we cannot verify -> reject. A gateway - # that hasn't enrolled must not accept inbound (fail closed). - logger.warning("relay inbound: no delivery key configured; rejecting") - return 401, {"error": "no delivery key configured"} - - # Verify over the EXACT raw bytes the connector signed. Decode to text - # with the same UTF-8 the connector's JSON.stringify produced; a single - # differing byte breaks the HMAC (raw-body-preservation discipline). - body_text = raw_body.decode("utf-8", errors="strict") - if not verify_delivery_signature( - body_text, timestamp, signature, verify_keys, self._max_skew_seconds - ): - return 401, {"error": "invalid delivery signature"} - - try: - payload = json.loads(body_text) - except json.JSONDecodeError: - return 400, {"error": "invalid JSON body"} - - if is_interrupt or payload.get("type") == "interrupt": - session_key = str(payload.get("session_key", "")) - chat_id = str(payload.get("chat_id", "") or payload.get("reason", "") or "") - if self._on_interrupt is not None and session_key: - await self._on_interrupt(session_key, chat_id) - return 200, {"ok": True} - - # Default: a normalized inbound message event. - event_raw = payload.get("event") - if not isinstance(event_raw, dict): - return 400, {"error": "missing event"} - event = _event_from_wire(event_raw) - await self._on_message(event) - return 200, {"ok": True} - - # ── aiohttp wiring (thin shell over handle_raw) ────────────────────── - def build_app(self) -> Any: - """Build an aiohttp Application exposing the delivery + interrupt routes.""" - if not AIOHTTP_AVAILABLE: - raise RuntimeError( - "InboundDeliveryReceiver requires the 'aiohttp' package " - "(install the messaging extra)." - ) - - async def _deliver(request: Any) -> Any: - return await self._respond(request, is_interrupt=False) - - async def _interrupt(request: Any) -> Any: - return await self._respond(request, is_interrupt=True) - - app = web.Application() - app.router.add_get("/healthz", lambda _: web.Response(text="ok")) - app.router.add_post("/", _deliver) - app.router.add_post("/interrupt", _interrupt) - return app - - async def _respond(self, request: Any, *, is_interrupt: bool) -> Any: - # Read the RAW bytes — do NOT use request.json() (it reparses and we'd - # verify over a re-serialized form, breaking the HMAC). - raw_body = await request.read() - status, body = await self.handle_raw( - raw_body=raw_body, - timestamp=request.headers.get(DELIVERY_TS_HEADER), - signature=request.headers.get(DELIVERY_SIG_HEADER), - is_interrupt=is_interrupt, - ) - return web.json_response(body, status=status) diff --git a/tests/gateway/relay/stub_connector.py b/tests/gateway/relay/stub_connector.py index 60e79a81a1b..11a97cae53a 100644 --- a/tests/gateway/relay/stub_connector.py +++ b/tests/gateway/relay/stub_connector.py @@ -26,6 +26,7 @@ class StubConnector: def __init__(self, descriptor: CapabilityDescriptor) -> None: self._descriptor = descriptor self._inbound: Optional[InboundHandler] = None + self._interrupt_inbound: Optional[Any] = None self.connected = False self.sent: List[Dict[str, Any]] = [] self.interrupts: List[Dict[str, Any]] = [] @@ -51,6 +52,11 @@ class StubConnector: def set_inbound_handler(self, handler: InboundHandler) -> None: self._inbound = handler + def set_interrupt_inbound_handler(self, handler: Any) -> None: + """Mirror the real WS transport: the adapter registers its interrupt + bridge here so connector→gateway interrupt_inbound frames route to it.""" + self._interrupt_inbound = handler + async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]: self.sent.append(action) if action.get("op") == "send": @@ -73,3 +79,9 @@ class StubConnector: if self._inbound is None: raise RuntimeError("no inbound handler registered (call adapter.connect first)") await self._inbound(event) + + async def push_interrupt(self, session_key: str, chat_id: str) -> None: + """Simulate the connector delivering an interrupt_inbound over the WS.""" + if self._interrupt_inbound is None: + raise RuntimeError("no interrupt_inbound handler registered (call adapter.connect first)") + await self._interrupt_inbound(session_key, chat_id) diff --git a/tests/gateway/relay/test_inbound_receiver.py b/tests/gateway/relay/test_inbound_receiver.py deleted file mode 100644 index 076fc3c9528..00000000000 --- a/tests/gateway/relay/test_inbound_receiver.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Unit tests for gateway/relay/inbound_receiver.py. - -Covers the verify-then-dispatch core (handle_raw): a correctly-signed message -delivery is verified + dispatched; an interrupt delivery routes to the interrupt -handler; unsigned/tampered/expired/no-key deliveries are rejected 401; malformed -JSON is 400. Signatures are produced with the SAME auth primitives the connector -uses (gateway/relay/auth.py sign), so this exercises the real verify path. -""" - -from __future__ import annotations - -import json -import time - -import pytest - -from gateway.relay.auth import sign -from gateway.relay.inbound_receiver import InboundDeliveryReceiver - -_KEY = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" - - -def _signed(body_obj: dict, key: str = _KEY, ts: int | None = None) -> tuple[bytes, str, str]: - """Serialize compactly (as the connector's JSON.stringify does), sign it.""" - body = json.dumps(body_obj, separators=(",", ":")) - raw = body.encode("utf-8") - t = ts if ts is not None else int(time.time()) - return raw, str(t), sign(f"{t}.{body}", key) - - -def _receiver(**kw): - received: list = [] - interrupts: list = [] - - async def on_message(ev): - received.append(ev) - - async def on_interrupt(sk, chat): - interrupts.append((sk, chat)) - - r = InboundDeliveryReceiver( - delivery_key_verify_list=lambda: [_KEY], - on_message=on_message, - on_interrupt=on_interrupt, - **kw, - ) - return r, received, interrupts - - -@pytest.mark.asyncio -async def test_valid_message_delivery_dispatched(): - r, received, _ = _receiver() - raw, ts, sig = _signed( - { - "type": "message", - "event": { - "text": "hello", - "message_type": "text", - "source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "guild_id": "guildA"}, - }, - } - ) - status, body = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False) - assert status == 200 and body == {"ok": True} - assert len(received) == 1 - assert received[0].text == "hello" - assert received[0].source.guild_id == "guildA" - - -@pytest.mark.asyncio -async def test_valid_interrupt_delivery_routes_to_interrupt_handler(): - r, _, interrupts = _receiver() - raw, ts, sig = _signed({"type": "interrupt", "session_key": "agent:main:discord:group:c:u", "reason": "stop"}) - status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=True) - assert status == 200 - assert interrupts and interrupts[0][0] == "agent:main:discord:group:c:u" - - -@pytest.mark.asyncio -async def test_tampered_body_rejected_401(): - r, received, _ = _receiver() - raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}) - status, _ = await r.handle_raw(raw_body=raw + b" ", timestamp=ts, signature=sig, is_interrupt=False) - assert status == 401 - assert received == [] - - -@pytest.mark.asyncio -async def test_unsigned_rejected_401(): - r, _, _ = _receiver() - raw, _, _ = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}) - status, _ = await r.handle_raw(raw_body=raw, timestamp=None, signature=None, is_interrupt=False) - assert status == 401 - - -@pytest.mark.asyncio -async def test_expired_timestamp_rejected_401(): - r, _, _ = _receiver(max_skew_seconds=300) - raw, _, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, ts=1) - # ts=1 (1970) is far outside the 300s window vs now. - status, _ = await r.handle_raw(raw_body=raw, timestamp="1", signature=sig, is_interrupt=False) - assert status == 401 - - -@pytest.mark.asyncio -async def test_wrong_key_rejected_401(): - r, _, _ = _receiver() - other = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100" - raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, key=other) - status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False) - assert status == 401 - - -@pytest.mark.asyncio -async def test_no_delivery_key_fails_closed_401(): - async def on_message(ev): - pass - - r = InboundDeliveryReceiver(delivery_key_verify_list=lambda: [], on_message=on_message) - raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}) - status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False) - assert status == 401 - - -@pytest.mark.asyncio -async def test_rotation_secondary_key_accepted(): - new = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - received: list = [] - - async def on_message(ev): - received.append(ev) - - # Connector still signs with the OLD key (secondary); verify list has both. - r = InboundDeliveryReceiver( - delivery_key_verify_list=lambda: [new, _KEY], on_message=on_message - ) - raw, ts, sig = _signed({"type": "message", "event": {"text": "x", "source": {"chat_id": "c"}}}, key=_KEY) - status, _ = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False) - assert status == 200 and len(received) == 1 - - -@pytest.mark.asyncio -async def test_malformed_json_after_valid_signature_is_400(): - r, _, _ = _receiver() - # Sign a non-JSON body so the signature passes but json.loads fails. - raw = b"not json at all" - ts = str(int(time.time())) - sig = sign(f"{ts}.{raw.decode()}", _KEY) - status, body = await r.handle_raw(raw_body=raw, timestamp=ts, signature=sig, is_interrupt=False) - assert status == 400 diff --git a/tests/gateway/relay/test_relay_interrupt.py b/tests/gateway/relay/test_relay_interrupt.py index 49b6d8607ed..10f34308cf8 100644 --- a/tests/gateway/relay/test_relay_interrupt.py +++ b/tests/gateway/relay/test_relay_interrupt.py @@ -67,3 +67,23 @@ async def test_outbound_interrupt_reaches_connector(adapter): assert stub.interrupts == [ {"session_key": "agent:main:discord:group:chanA:userX", "reason": "stop"} ] + + +@pytest.mark.asyncio +async def test_connect_wires_inbound_interrupt_over_ws(adapter): + """WS-only inbound: connect() registers BOTH the inbound message handler AND + the interrupt_inbound handler on the transport, so a connector-delivered + interrupt_inbound frame (no HTTP receiver) reaches the right session.""" + await adapter.connect() + stub = adapter._transport + # Both connector->gateway handlers are wired post-connect. + assert stub._inbound is not None + assert stub._interrupt_inbound is not None + + key = "agent:main:discord:group:chanA:userX" + ev = asyncio.Event() + adapter._active_sessions[key] = ev + + # Simulate the connector pushing an interrupt_inbound frame down the WS. + await stub.push_interrupt(key, chat_id="chanA") + assert ev.is_set() is True, "interrupt delivered over the WS must cancel the target turn" diff --git a/tests/gateway/relay/test_relay_sheds_crypto.py b/tests/gateway/relay/test_relay_sheds_crypto.py index f2e0810af4a..4af7d7368ba 100644 --- a/tests/gateway/relay/test_relay_sheds_crypto.py +++ b/tests/gateway/relay/test_relay_sheds_crypto.py @@ -48,16 +48,14 @@ def _relay_py_files() -> list[Path]: # ``auth.py`` is the connector⇄gateway CHANNEL authenticator (the gateway's WS -# upgrade bearer + inbound-delivery signature verification). ``inbound_receiver.py`` -# is the signed-inbound-delivery receiver that USES that channel auth to verify -# connector→gateway POSTs. Both are net-new, intended, and the whole point of -# authenticating an untrusted/disposable gateway — they are NOT platform crypto. -# They use HMAC over the connector's per-gateway / per-tenant secrets (NOT any -# platform's signing secret), so they are exempt from the platform-crypto symbol -# scan below. The module-import ban (platform-crypto modules) still applies to -# every file including these — they import only stdlib hmac/hashlib and each -# other, never a platform-crypto module, so they stay clean there. -_CHANNEL_AUTH_FILES = {"auth.py", "inbound_receiver.py"} +# upgrade bearer). It is net-new, intended, and the whole point of +# authenticating an untrusted/disposable gateway — it is NOT platform crypto. +# It uses HMAC over the connector's per-gateway secret (NOT any platform's +# signing secret), so it is exempt from the platform-crypto symbol scan below. +# The module-import ban (platform-crypto modules) still applies to every file +# including this one — it imports only stdlib hmac/hashlib, never a +# platform-crypto module, so it stays clean there. +_CHANNEL_AUTH_FILES = {"auth.py"} def test_relay_package_imports_no_platform_crypto(): diff --git a/tests/gateway/relay/test_self_provision.py b/tests/gateway/relay/test_self_provision.py index 4b4a6070e7e..7a379eb5c3b 100644 --- a/tests/gateway/relay/test_self_provision.py +++ b/tests/gateway/relay/test_self_provision.py @@ -8,6 +8,8 @@ TRIGGER logic, in-process env wiring, and fail-soft boot behaviour. from __future__ import annotations +import os + import pytest import gateway.relay as relay @@ -126,8 +128,9 @@ def test_provisions_and_sets_env_in_process(monkeypatch): # Creds landed in os.environ (in-process), so register_relay_adapter() reads them. gid, secret = relay.relay_connection_auth() assert gid and secret == "a" * 64 - key, _host, _port = relay.relay_inbound_config() - assert key == "b" * 64 + # The delivery key is persisted in-process too (issued by the connector, + # kept for forward-compat; inbound rides the WS so it isn't consumed). + assert os.environ["GATEWAY_RELAY_DELIVERY_KEY"] == "b" * 64 def test_outbound_only_when_no_endpoint(monkeypatch): From 36851fa576eb4079f0397010f418cafa15a4ab26 Mon Sep 17 00:00:00 2001 From: Evo <r2668940489@gmail.com> Date: Fri, 19 Jun 2026 08:52:16 +0800 Subject: [PATCH 018/470] fix(docker): support WebUI installs from read-only sources (#48541) --- .dockerignore | 3 - setup.py | 59 +++++++++++++++ tests/test_docker_webui_install_surface.py | 87 ++++++++++++++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 tests/test_docker_webui_install_surface.py diff --git a/.dockerignore b/.dockerignore index f6fbbc9f137..a5b50068f02 100644 --- a/.dockerignore +++ b/.dockerignore @@ -102,6 +102,3 @@ acp_registry/ .gitattributes .hadolint.yaml .mailmap - -# Top-level LICENSE (not matched by *.md); not needed inside the container -LICENSE diff --git a/setup.py b/setup.py index 8487f76e86f..6e3e8c4272e 100644 --- a/setup.py +++ b/setup.py @@ -2,13 +2,68 @@ from __future__ import annotations from collections import defaultdict from pathlib import Path +import tempfile from setuptools import setup +from setuptools.command.build import build as _build +from setuptools.command.egg_info import egg_info as _egg_info REPO_ROOT = Path(__file__).parent.resolve() +def _source_tree_is_writable() -> bool: + probe = REPO_ROOT / ".setuptools-write-probe" + try: + with probe.open("w", encoding="utf-8") as handle: + handle.write("") + probe.unlink() + except OSError: + try: + probe.unlink(missing_ok=True) + except OSError: + pass + return False + return True + + +def _temporary_build_dir(kind: str) -> str: + return tempfile.mkdtemp(prefix=f"hermes-agent-{kind}-") + + +def _would_write_under_source(path_value: str | None) -> bool: + if path_value is None: + return True + path = Path(path_value) + if not path.is_absolute(): + path = REPO_ROOT / path + try: + path.resolve().relative_to(REPO_ROOT) + except ValueError: + return False + return True + + +class ReadOnlySourceBuild(_build): + def finalize_options(self) -> None: + if ( + not _source_tree_is_writable() + and _would_write_under_source(self.build_base) + ): + self.build_base = _temporary_build_dir("build") + super().finalize_options() + + +class ReadOnlySourceEggInfo(_egg_info): + def finalize_options(self) -> None: + if ( + not _source_tree_is_writable() + and _would_write_under_source(self.egg_base) + ): + self.egg_base = _temporary_build_dir("egg-info") + super().finalize_options() + + def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]: root = REPO_ROOT / root_name grouped: defaultdict[str, list[str]] = defaultdict(list) @@ -21,6 +76,10 @@ def _data_file_tree(root_name: str) -> list[tuple[str, list[str]]]: setup( + cmdclass={ + "build": ReadOnlySourceBuild, + "egg_info": ReadOnlySourceEggInfo, + }, data_files=[ *_data_file_tree("skills"), *_data_file_tree("optional-skills"), diff --git a/tests/test_docker_webui_install_surface.py b/tests/test_docker_webui_install_surface.py new file mode 100644 index 00000000000..413bfdaf071 --- /dev/null +++ b/tests/test_docker_webui_install_surface.py @@ -0,0 +1,87 @@ +"""Guards for the multi-container Hermes WebUI install surface.""" + +from __future__ import annotations + +from pathlib import Path +import runpy + +from setuptools import Distribution +import setuptools + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _is_under(path: str, root: Path) -> bool: + try: + Path(path).resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def test_docker_context_includes_license_file() -> None: + """PEP 639 license-files metadata must resolve inside the Docker image.""" + dockerignore = (REPO_ROOT / ".dockerignore").read_text(encoding="utf-8") + active_lines = [ + line.strip() + for line in dockerignore.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + assert "LICENSE" not in active_lines + + +def test_setup_uses_temporary_outputs_when_source_tree_is_read_only( + monkeypatch, +) -> None: + """WebUI installs from read-only /opt/hermes must not write build metadata.""" + captured: dict[str, object] = {} + + def capture_setup(**kwargs: object) -> None: + captured.update(kwargs) + + monkeypatch.setattr(setuptools, "setup", capture_setup) + namespace = runpy.run_path(str(REPO_ROOT / "setup.py")) + + cmdclass = captured["cmdclass"] + monkeypatch.setitem( + cmdclass["build"].finalize_options.__globals__, + "_source_tree_is_writable", + lambda: False, + ) + monkeypatch.setitem( + cmdclass["egg_info"].finalize_options.__globals__, + "_source_tree_is_writable", + lambda: False, + ) + + build_cmd = cmdclass["build"](Distribution()) + build_cmd.initialize_options() + build_cmd.finalize_options() + assert not _is_under(build_cmd.build_base, REPO_ROOT) + assert Path(build_cmd.build_base).name.startswith("hermes-agent-build") + + source_relative_build = cmdclass["build"](Distribution()) + source_relative_build.initialize_options() + source_relative_build.build_base = "nested/build" + source_relative_build.finalize_options() + assert not _is_under(source_relative_build.build_base, REPO_ROOT) + assert Path(source_relative_build.build_base).name.startswith("hermes-agent-build") + + egg_info_cmd = cmdclass["egg_info"](Distribution()) + egg_info_cmd.initialize_options() + egg_info_cmd.finalize_options() + assert egg_info_cmd.egg_base is not None + assert not _is_under(egg_info_cmd.egg_base, REPO_ROOT) + assert Path(egg_info_cmd.egg_base).name.startswith("hermes-agent-egg-info") + + source_relative_egg_info = cmdclass["egg_info"](Distribution()) + source_relative_egg_info.initialize_options() + source_relative_egg_info.egg_base = "." + source_relative_egg_info.finalize_options() + assert source_relative_egg_info.egg_base is not None + assert not _is_under(source_relative_egg_info.egg_base, REPO_ROOT) + assert Path(source_relative_egg_info.egg_base).name.startswith( + "hermes-agent-egg-info" + ) From 2c6e266e8829f9aaff1be4666afdbb05ca15fc6d Mon Sep 17 00:00:00 2001 From: Ben Barclay <ben@nousresearch.com> Date: Fri, 19 Jun 2026 11:01:24 +1000 Subject: [PATCH 019/470] fix(relay): trigger self-provision on relay-config + NAS token, not is_managed() (#48724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit self_provision_if_managed() gated on is_managed(), but is_managed() means "NixOS/package-manager-managed" (it keys on HERMES_MANAGED or a ~/.hermes/.managed marker) — NOT "NAS-hosted". A NAS-provisioned Fly agent sets NEITHER, so the gate was always False and relay self-provision SILENTLY no-oped on exactly the hosted agents it was built for. Caught live: a staging agent with GATEWAY_RELAY_URL correctly stamped logged "No messaging platforms enabled" and never dialed the connector; HERMES_MANAGED was unset on the machine. The unit tests had mocked is_managed()->True, so they passed while the real trigger never fired (mocked- trigger blind spot). Fix: drop the is_managed() gate and rename self_provision_if_managed -> self_provision_relay. The real trigger is now "relay_url() set + no pinned secret + a resolvable NAS token", which is both NAS-independent and self-guarding: - NAS-hosted agent: GATEWAY_RELAY_URL + no pinned secret + bootstrapped NAS token -> self-provisions. - Self-hosted + `hermes gateway enroll`: pinned GATEWAY_RELAY_SECRET -> skipped (existing secret-present guard). - Self-hosted, unenrolled, no NAS identity: resolve_nous_access_token() fails -> graceful no-op (existing fail-soft path). Security: unchanged trust model. The connector still derives tenant from the validated NAS token; this only broadens WHEN the provision attempt fires, and every broadened case is still guarded by token-resolution + pinned-secret-skip. Tests: replaced the (wrong) "skips when not managed" test with a regression test proving a NAS host where is_managed()==False STILL provisions; renamed all call sites; added a "no NAS token -> non-fatal skip" test for the self-hosted branch. 88 relay tests pass. Relay-adapter lane. EXPERIMENTAL. --- gateway/relay/__init__.py | 42 +++++++++------- gateway/run.py | 13 ++--- tests/gateway/relay/test_self_provision.py | 56 +++++++++++++++------- 3 files changed, 70 insertions(+), 41 deletions(-) diff --git a/gateway/relay/__init__.py b/gateway/relay/__init__.py index a0bd4f526ef..4b3fdda8a8d 100644 --- a/gateway/relay/__init__.py +++ b/gateway/relay/__init__.py @@ -204,21 +204,33 @@ def _post_provision( return payload -def self_provision_if_managed() -> bool: - """Managed-boot self-provision: mint relay creds in-process, no human, no disk. +def self_provision_relay() -> bool: + """Boot-time relay self-provision: mint relay creds in-process, no human, no disk. - Fires only on a MANAGED boot (``is_managed()``) with relay configured - (``relay_url()`` set) and NO per-gateway secret already present. In that case - the runtime resolves the agent's own Nous access token (the same + Fires when relay is configured (``relay_url()`` set) and NO per-gateway secret + is already present, AND the agent can resolve its own Nous access token. In + that case the runtime resolves the agent's own Nous access token (the same ``resolve_nous_access_token()`` the enroll CLI / dashboard register use), POSTs ``/relay/provision`` asserting its own endpoint + route keys, and sets ``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` / ``GATEWAY_RELAY_DELIVERY_KEY`` into ``os.environ`` so the subsequent ``register_relay_adapter()`` picks them - up. The creds live ONLY in process memory — never written to ``~/.hermes/.env`` - (``save_env_value`` refuses under managed anyway, and keeping the secret off - any volume is the stronger posture). + up. The creds live ONLY in process memory — never written to ``~/.hermes/.env``. - Stateless: process-env creds don't survive a restart, so a managed container + The trigger is deliberately NOT ``is_managed()``: that means + "package-manager/NixOS-managed" and is False on a NAS-hosted Fly agent (which + sets neither ``HERMES_MANAGED`` nor a ``.managed`` marker), so gating on it + blocked the exact hosted case this is for. The real signal is "you pointed me + at a connector and didn't pin a secret" — which is both NAS-independent and + self-guarding: + + - A NAS-hosted agent: has ``GATEWAY_RELAY_URL``, no pinned secret, and a + bootstrapped NAS token -> self-provisions. + - A self-hosted operator who ran ``hermes gateway enroll``: has a PINNED + ``GATEWAY_RELAY_SECRET`` -> skipped (the secret-present guard below). + - A self-hosted box with a relay URL but no NAS identity: + ``resolve_nous_access_token()`` fails -> graceful no-op. + + Stateless: process-env creds don't survive a restart, so a hosted container re-provisions every boot; the connector's rotation window covers a still- connected prior instance. An explicitly-pinned ``GATEWAY_RELAY_SECRET`` (env or config) is RESPECTED — self-provision skips so an operator pin isn't @@ -233,18 +245,12 @@ def self_provision_if_managed() -> bool: logger = logging.getLogger("gateway.relay") - try: - from hermes_cli.config import is_managed - except Exception: # noqa: BLE001 - return False - - if not is_managed(): - return False dial_url = relay_url() if not dial_url: return False - # Respect an already-present (pinned/stamped) secret — don't stomp it. + # Respect an already-present (pinned/stamped) secret — don't stomp it. This + # is also what makes a self-hosted, enrolled gateway skip self-provision. existing_id, existing_secret = relay_connection_auth() if existing_id and existing_secret: logger.info("relay self-provision skipped: GATEWAY_RELAY_SECRET already set") @@ -255,6 +261,8 @@ def self_provision_if_managed() -> bool: access_token = resolve_nous_access_token() except Exception as exc: # noqa: BLE001 - boot must survive a token failure + # No resolvable NAS identity (e.g. a self-hosted box that hasn't enrolled) + # -> nothing to provision with; skip quietly and let the gateway boot. logger.warning("relay self-provision skipped: could not resolve Nous token (%s)", exc) return False diff --git a/gateway/run.py b/gateway/run.py index 8f139341793..e24afd035e7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5119,14 +5119,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew from gateway.relay import ( register_relay_adapter, relay_url, - self_provision_if_managed, + self_provision_relay, ) - # Managed boot: self-provision relay creds in-process (resolve the - # agent's NAS token -> POST /relay/provision -> set GATEWAY_RELAY_* in - # os.environ) BEFORE registration reads them. No-op when not managed, - # relay unconfigured, or a secret is already pinned. Never raises. - self_provision_if_managed() + # Boot-time relay self-provision: resolve the agent's NAS token -> + # POST /relay/provision -> set GATEWAY_RELAY_* in os.environ BEFORE + # registration reads them. No-op when relay is unconfigured, a secret + # is already pinned, or no NAS token resolves (self-hosted, unenrolled). + # Never raises. + self_provision_relay() if register_relay_adapter(): logger.info("relay adapter registered (connector at %s)", relay_url()) diff --git a/tests/gateway/relay/test_self_provision.py b/tests/gateway/relay/test_self_provision.py index 7a379eb5c3b..c5af66f94ef 100644 --- a/tests/gateway/relay/test_self_provision.py +++ b/tests/gateway/relay/test_self_provision.py @@ -1,9 +1,13 @@ -"""Unit tests for managed-boot relay self-provisioning. +"""Unit tests for boot-time relay self-provisioning. -Covers gateway.relay.self_provision_if_managed() + the relay_endpoint() / +Covers gateway.relay.self_provision_relay() + the relay_endpoint() / relay_route_keys() config readers. The connector HTTP POST is monkeypatched (the cross-repo E2E exercises the real /relay/provision); these prove the TRIGGER logic, in-process env wiring, and fail-soft boot behaviour. + +The trigger is deliberately NOT is_managed() (that means NixOS/package-manager- +managed, which is False on a NAS-hosted Fly agent). The real gate is +"relay_url set + no pinned secret + a resolvable NAS token". """ from __future__ import annotations @@ -48,8 +52,13 @@ def _stub_post(captured: dict): return _fake -def _arm(monkeypatch, *, managed=True, url="wss://connector.example/relay", token="nas-token"): - monkeypatch.setattr("hermes_cli.config.is_managed", lambda: managed) +def _arm(monkeypatch, *, url="wss://connector.example/relay", token="nas-token"): + """Arm the real trigger: a relay URL + a resolvable NAS token. + + Note there is intentionally no `managed` knob — self-provision no longer + consults is_managed(). A test that wants the "no NAS identity" branch + monkeypatches resolve_nous_access_token to raise instead. + """ monkeypatch.setattr(relay, "relay_url", lambda: url) monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: token) @@ -82,29 +91,37 @@ def test_provision_url_maps_ws_to_http(): # ─────────────────────────── trigger logic ─────────────────────────── -def test_skips_when_not_managed(monkeypatch): - _arm(monkeypatch, managed=False) - called = {"n": 0} - monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {}) - assert relay.self_provision_if_managed() is False - assert called["n"] == 0 +def test_provisions_on_nas_host_that_is_NOT_is_managed(monkeypatch): + """Regression: a NAS-hosted Fly agent sets neither HERMES_MANAGED nor a + .managed marker, so is_managed() is False. Self-provision must STILL fire — + the old is_managed() gate silently no-oped exactly this case in staging. + """ + # Force is_managed() False to model a real hosted agent; it must be irrelevant. + monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False) + _arm(monkeypatch) + captured: dict = {} + monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) + + assert relay.self_provision_relay() is True + assert relay.relay_connection_auth()[1] == "a" * 64 def test_skips_when_relay_not_configured(monkeypatch): _arm(monkeypatch, url=None) called = {"n": 0} monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {}) - assert relay.self_provision_if_managed() is False + assert relay.self_provision_relay() is False assert called["n"] == 0 def test_skips_when_secret_already_pinned(monkeypatch): + """A self-hosted, enrolled gateway has a pinned secret -> never self-provisions.""" _arm(monkeypatch) monkeypatch.setenv("GATEWAY_RELAY_ID", "gw-pinned") monkeypatch.setenv("GATEWAY_RELAY_SECRET", "deadbeef") called = {"n": 0} monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {}) - assert relay.self_provision_if_managed() is False + assert relay.self_provision_relay() is False assert called["n"] == 0 # The pinned secret is untouched. assert relay.relay_connection_auth() == ("gw-pinned", "deadbeef") @@ -119,7 +136,7 @@ def test_provisions_and_sets_env_in_process(monkeypatch): captured: dict = {} monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) - assert relay.self_provision_if_managed() is True + assert relay.self_provision_relay() is True # The connector POST carried the gateway-asserted endpoint + route keys. assert captured["provision_url"] == "https://connector.example/relay/provision" assert captured["access_token"] == "nas-token" @@ -138,7 +155,7 @@ def test_outbound_only_when_no_endpoint(monkeypatch): captured: dict = {} monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) - assert relay.self_provision_if_managed() is True + assert relay.self_provision_relay() is True assert captured["gateway_endpoint"] is None assert captured["route_keys"] == [] assert relay.relay_connection_auth()[1] == "a" * 64 @@ -146,15 +163,18 @@ def test_outbound_only_when_no_endpoint(monkeypatch): # ─────────────────────────── fail-soft ─────────────────────────── -def test_token_failure_is_non_fatal(monkeypatch): - _arm(monkeypatch) +def test_no_nas_token_is_non_fatal(monkeypatch): + """A self-hosted box with a relay URL but no resolvable NAS identity skips + quietly (this is the branch that replaces the old is_managed() gate for the + non-NAS case).""" + monkeypatch.setattr(relay, "relay_url", lambda: "wss://connector.example/relay") def _boom(): raise RuntimeError("no token") monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", _boom) # Must not raise; returns False; no creds set. - assert relay.self_provision_if_managed() is False + assert relay.self_provision_relay() is False assert relay.relay_connection_auth() == (None, None) @@ -165,5 +185,5 @@ def test_connector_failure_is_non_fatal(monkeypatch): raise RuntimeError("connector returned HTTP 503") monkeypatch.setattr(relay, "_post_provision", _boom) - assert relay.self_provision_if_managed() is False + assert relay.self_provision_relay() is False assert relay.relay_connection_auth() == (None, None) From 0403f41f9cc4b3e51d9e58c889bbd669aeabdb48 Mon Sep 17 00:00:00 2001 From: liuhao1024 <sunsky.lau@gmail.com> Date: Tue, 16 Jun 2026 12:13:39 +0800 Subject: [PATCH 020/470] fix(agent): handle missing trigram tokenizer without disabling FTS5 _is_fts5_unavailable_error only matched 'no such module: fts5', but SQLite builds that ship FTS5 without the optional trigram tokenizer raise 'no such tokenizer: trigram' instead. This caused SessionDB init to crash on those builds. Additionally, the trigram failure path called _warn_fts5_unavailable which set _fts_enabled = False, globally disabling full-text search even though the base FTS5 table was created successfully. Fix: - Extend _is_fts5_unavailable_error to also match 'no such tokenizer' - Add _is_tokenizer_unavailable_error to distinguish tokenizer-specific failures from whole-module absence - Only call _warn_fts5_unavailable for module-level failures; skip it for tokenizer-specific failures so base FTS5 remains usable Fixes #47002 --- hermes_state.py | 26 +++++++++++++++--- tests/test_hermes_state.py | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 19c6a269b99..f54fbbd6af5 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -772,7 +772,18 @@ class SessionDB: @staticmethod def _is_fts5_unavailable_error(exc: sqlite3.OperationalError) -> bool: err = str(exc).lower() - return "no such module" in err and "fts5" in err + if "no such module" in err and "fts5" in err: + return True + # SQLite builds that have FTS5 but lack the optional trigram tokenizer + # raise "no such tokenizer: trigram" instead of "no such module". + if "no such tokenizer" in err: + return True + return False + + @staticmethod + def _is_tokenizer_unavailable_error(exc: sqlite3.OperationalError) -> bool: + """Check if the error is about a specific tokenizer (not the whole FTS5 module).""" + return "no such tokenizer" in str(exc).lower() def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None: self._fts_enabled = False @@ -844,7 +855,9 @@ class SessionDB: return True except sqlite3.OperationalError as exc: if self._is_fts5_unavailable_error(exc): - self._warn_fts5_unavailable(exc) + # Only disable FTS entirely when the whole module is missing. + if not self._is_tokenizer_unavailable_error(exc): + self._warn_fts5_unavailable(exc) return None if "no such table" in str(exc).lower(): return False @@ -868,7 +881,11 @@ class SessionDB: except sqlite3.OperationalError as exc: if not self._is_fts5_unavailable_error(exc): raise - self._warn_fts5_unavailable(exc) + # Only disable FTS entirely when the whole FTS5 module is missing. + # A missing specific tokenizer (e.g. trigram) means only that + # particular table cannot be created — the base FTS5 table is fine. + if not self._is_tokenizer_unavailable_error(exc): + self._warn_fts5_unavailable(exc) return False def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T: @@ -1166,7 +1183,8 @@ class SessionDB: except sqlite3.OperationalError as exc: if not self._is_fts5_unavailable_error(exc): raise - self._warn_fts5_unavailable(exc) + if not self._is_tokenizer_unavailable_error(exc): + self._warn_fts5_unavailable(exc) fts5_available = False fts_migrations_complete = False break diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 3644308401f..4bdc12d4642 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -50,6 +50,20 @@ class _NoFtsExistingTableConnection(sqlite3.Connection): return super().cursor(factory or _NoFtsExistingTableCursor) +class _NoTrigramCursor(sqlite3.Cursor): + """Simulate a SQLite build with FTS5 but without the trigram tokenizer.""" + + def executescript(self, sql_script): + if "tokenize='trigram'" in sql_script: + raise sqlite3.OperationalError("no such tokenizer: trigram") + return super().executescript(sql_script) + + +class _NoTrigramConnection(sqlite3.Connection): + def cursor(self, factory=None): + return super().cursor(factory or _NoTrigramCursor) + + @pytest.fixture() def db(tmp_path): """Create a SessionDB with a temp database file.""" @@ -330,6 +344,46 @@ class TestSessionLifecycle: finally: restored.close() + def test_is_fts5_unavailable_error_catches_trigram_tokenizer(self): + """Unit test: _is_fts5_unavailable_error matches 'no such tokenizer'.""" + fts5_err = sqlite3.OperationalError("no such module: fts5") + trigram_err = sqlite3.OperationalError("no such tokenizer: trigram") + unrelated_err = sqlite3.OperationalError("no such table: foo") + + assert SessionDB._is_fts5_unavailable_error(fts5_err) is True + assert SessionDB._is_fts5_unavailable_error(trigram_err) is True + assert SessionDB._is_fts5_unavailable_error(unrelated_err) is False + + def test_db_initializes_without_trigram_tokenizer(self, tmp_path, monkeypatch): + """SessionDB must not crash when FTS5 exists but trigram tokenizer is missing.""" + real_connect = sqlite3.connect + + def connect_without_trigram(*args, **kwargs): + kwargs["factory"] = _NoTrigramConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram) + + db = SessionDB(db_path=tmp_path / "state.db") + try: + # Base FTS5 should still work (trigram is optional). + assert db._fts_enabled is True + assert db._fts_table_exists("messages_fts") is True + # Trigram table should NOT have been created. + assert db._fts_table_exists("messages_fts_trigram") is False + + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="user", content="hello without trigram") + + messages = db.get_messages("s1") + assert len(messages) == 1 + assert messages[0]["content"] == "hello without trigram" + + # FTS5 keyword search should still work. + assert len(db.search_messages("hello")) == 1 + finally: + db.close() + # ========================================================================= # Message storage From c10aa5dc9c69e8e2cc03178be4b189844df29965 Mon Sep 17 00:00:00 2001 From: liuhao1024 <sunsky.lau@gmail.com> Date: Tue, 16 Jun 2026 12:47:07 +0800 Subject: [PATCH 021/470] fix(agent): address review feedback on trigram tokenizer fallback - Scope 'no such tokenizer' matcher to trigram specifically (#779) - Decouple base FTS and trigram backfill in v11 migration (#1195) - CJK search falls back to LIKE when trigram unavailable (#3384/#3430) - Add _trigram_available tracking across init, migration, and startup - Add regression tests for migration backfill and CJK LIKE fallback - Add _is_trigram_unavailable_error and _warn_trigram_unavailable helpers --- hermes_state.py | 76 ++++++++++++++++++++++++---------- tests/test_hermes_state.py | 84 +++++++++++++++++++++++++++++++++++++- 2 files changed, 138 insertions(+), 22 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index f54fbbd6af5..99cb24748e6 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -684,6 +684,7 @@ class SessionDB: self._lock = threading.Lock() self._write_count = 0 self._fts_enabled = False + self._trigram_available = False self._fts_unavailable_warned = False self._conn = None try: @@ -776,14 +777,29 @@ class SessionDB: return True # SQLite builds that have FTS5 but lack the optional trigram tokenizer # raise "no such tokenizer: trigram" instead of "no such module". - if "no such tokenizer" in err: + # Scope to trigram specifically to avoid masking unrelated tokenizer errors. + if "no such tokenizer: trigram" in err: return True return False @staticmethod - def _is_tokenizer_unavailable_error(exc: sqlite3.OperationalError) -> bool: - """Check if the error is about a specific tokenizer (not the whole FTS5 module).""" - return "no such tokenizer" in str(exc).lower() + def _is_trigram_unavailable_error(exc: sqlite3.OperationalError) -> bool: + """True when only the trigram tokenizer is missing (FTS5 itself works).""" + return "no such tokenizer: trigram" in str(exc).lower() + + def _warn_trigram_unavailable(self, exc: sqlite3.OperationalError) -> None: + """Log once that the trigram tokenizer is missing; base FTS5 stays enabled.""" + if getattr(self, "_trigram_unavailable_warned", False): + return + self._trigram_unavailable_warned = True + logger.info( + "SQLite trigram tokenizer unavailable for %s " + "(requires SQLite >= 3.34, this build is %s); " + "CJK/substring search will fall back to LIKE: %s", + self.db_path, + sqlite3.sqlite_version, + exc, + ) def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None: self._fts_enabled = False @@ -856,7 +872,10 @@ class SessionDB: except sqlite3.OperationalError as exc: if self._is_fts5_unavailable_error(exc): # Only disable FTS entirely when the whole module is missing. - if not self._is_tokenizer_unavailable_error(exc): + # A missing trigram tokenizer only affects trigram searches. + if self._is_trigram_unavailable_error(exc): + self._warn_trigram_unavailable(exc) + else: self._warn_fts5_unavailable(exc) return None if "no such table" in str(exc).lower(): @@ -884,7 +903,9 @@ class SessionDB: # Only disable FTS entirely when the whole FTS5 module is missing. # A missing specific tokenizer (e.g. trigram) means only that # particular table cannot be created — the base FTS5 table is fine. - if not self._is_tokenizer_unavailable_error(exc): + if self._is_trigram_unavailable_error(exc): + self._warn_trigram_unavailable(exc) + else: self._warn_fts5_unavailable(exc) return False @@ -1183,22 +1204,23 @@ class SessionDB: except sqlite3.OperationalError as exc: if not self._is_fts5_unavailable_error(exc): raise - if not self._is_tokenizer_unavailable_error(exc): + if self._is_trigram_unavailable_error(exc): + self._warn_trigram_unavailable(exc) + else: self._warn_fts5_unavailable(exc) - fts5_available = False - fts_migrations_complete = False + fts5_available = False + fts_migrations_complete = False break if fts5_available: # Recreate virtual tables + triggers with the new inline-mode # schema that indexes content || tool_name || tool_calls. - if ( - self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL) - and self._ensure_fts_schema( - cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL - ) - ): - # Backfill both indexes from every existing messages row. + # Handle base and trigram independently — a missing + # trigram tokenizer should not prevent base FTS backfill. + base_fts_ok = self._ensure_fts_schema( + cursor, "messages_fts", FTS_SQL + ) + if base_fts_ok: cursor.execute( "INSERT INTO messages_fts(rowid, content) " "SELECT id, " @@ -1207,6 +1229,10 @@ class SessionDB: "COALESCE(tool_calls, '') " "FROM messages" ) + trigram_ok = self._ensure_fts_schema( + cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL + ) + if trigram_ok: cursor.execute( "INSERT INTO messages_fts_trigram(rowid, content) " "SELECT id, " @@ -1215,8 +1241,12 @@ class SessionDB: "COALESCE(tool_calls, '') " "FROM messages" ) - else: + if not base_fts_ok: fts_migrations_complete = False + # Track trigram availability for CJK LIKE fallback. + self._trigram_available = trigram_ok + else: + fts_migrations_complete = False else: fts_migrations_complete = False if current_version < 12: @@ -1286,6 +1316,7 @@ class SessionDB: trigram_enabled = self._ensure_fts_schema( cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL ) + self._trigram_available = trigram_enabled if trigram_enabled and triggers_need_repair: self._rebuild_fts_indexes(cursor) @@ -3422,7 +3453,8 @@ class SessionDB: self._count_cjk(t) < 3 for t in _tokens_for_check ) - if cjk_count >= 3 and not _any_short_cjk: + _trigram_succeeded = False + if cjk_count >= 3 and not _any_short_cjk and self._trigram_available: # 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. @@ -3471,11 +3503,13 @@ class SessionDB: try: tri_cursor = self._conn.execute(tri_sql, tri_params) except sqlite3.OperationalError: - matches = [] + # Trigram query failed at runtime — fall through to LIKE. + pass else: matches = [dict(row) for row in tri_cursor.fetchall()] - else: - # Short / mixed CJK query: trigram cannot match tokens with + _trigram_succeeded = True + if not _trigram_succeeded: + # Short / mixed CJK query, trigram unavailable, or trigram # <3 CJK chars. Fall back to LIKE substring search. # For multi-token OR queries (e.g. "广西 OR 桂林 OR 漓江"), # build one LIKE condition per non-operator token so each term diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 4bdc12d4642..0baf3226401 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -345,15 +345,28 @@ class TestSessionLifecycle: restored.close() def test_is_fts5_unavailable_error_catches_trigram_tokenizer(self): - """Unit test: _is_fts5_unavailable_error matches 'no such tokenizer'.""" + """Unit test: _is_fts5_unavailable_error matches 'no such tokenizer: trigram'.""" fts5_err = sqlite3.OperationalError("no such module: fts5") trigram_err = sqlite3.OperationalError("no such tokenizer: trigram") + generic_tokenizer_err = sqlite3.OperationalError("no such tokenizer: foo") unrelated_err = sqlite3.OperationalError("no such table: foo") assert SessionDB._is_fts5_unavailable_error(fts5_err) is True assert SessionDB._is_fts5_unavailable_error(trigram_err) is True + # Generic tokenizer errors should NOT match — only trigram. + assert SessionDB._is_fts5_unavailable_error(generic_tokenizer_err) is False assert SessionDB._is_fts5_unavailable_error(unrelated_err) is False + def test_is_trigram_unavailable_error(self): + """Unit test: _is_trigram_unavailable_error is scoped to trigram.""" + trigram_err = sqlite3.OperationalError("no such tokenizer: trigram") + generic_err = sqlite3.OperationalError("no such tokenizer: foo") + fts5_err = sqlite3.OperationalError("no such module: fts5") + + assert SessionDB._is_trigram_unavailable_error(trigram_err) is True + assert SessionDB._is_trigram_unavailable_error(generic_err) is False + assert SessionDB._is_trigram_unavailable_error(fts5_err) is False + def test_db_initializes_without_trigram_tokenizer(self, tmp_path, monkeypatch): """SessionDB must not crash when FTS5 exists but trigram tokenizer is missing.""" real_connect = sqlite3.connect @@ -384,6 +397,75 @@ class TestSessionLifecycle: finally: db.close() + def test_v11_migration_backfills_base_fts_when_trigram_unavailable( + self, tmp_path, monkeypatch + ): + """Regression: v11 migration must backfill base FTS even when trigram is unavailable.""" + real_connect = sqlite3.connect + db_path = tmp_path / "state.db" + + # Phase 1: create a DB at schema v10 with messages. + db = SessionDB(db_path=db_path) + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="user", content="legacy message alpha") + db.append_message("s1", role="assistant", content="legacy reply beta") + # Force schema version to v10 so migration runs on next open. + db._conn.execute( + "UPDATE schema_version SET version = 10" + ) + db._conn.commit() + db.close() + + # Phase 2: reopen with trigram disabled — migration should still + # backfill base FTS and make existing messages searchable. + def connect_without_trigram(*args, **kwargs): + kwargs["factory"] = _NoTrigramConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram) + migrated_db = SessionDB(db_path=db_path) + try: + assert migrated_db._fts_enabled is True + assert migrated_db._trigram_available is False + assert migrated_db._fts_table_exists("messages_fts") is True + assert migrated_db._fts_table_exists("messages_fts_trigram") is False + + # Existing messages must be searchable via base FTS. + results = migrated_db.search_messages("legacy message") + assert len(results) == 1 + # snippet has FTS5 highlight markers (>>>...<<<); check raw content via get_messages + msgs = migrated_db.get_messages("s1") + assert any("legacy message" in m["content"] for m in msgs) + finally: + migrated_db.close() + + def test_cjk_search_falls_back_to_like_when_trigram_unavailable( + self, tmp_path, monkeypatch + ): + """Regression: long CJK queries must fall back to LIKE when trigram is missing.""" + real_connect = sqlite3.connect + db_path = tmp_path / "state.db" + + def connect_without_trigram(*args, **kwargs): + kwargs["factory"] = _NoTrigramConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram) + db = SessionDB(db_path=db_path) + try: + db.create_session(session_id="s1", source="cli") + db.append_message("s1", role="user", content="大别山项目计划书") + db.append_message("s1", role="user", content="长江大桥设计方案") + + # 3+ CJK chars would normally use trigram, but it's unavailable. + # Must fall back to LIKE and still return results. + results = db.search_messages("大别山") + assert len(results) == 1 + # Note: search_messages strips 'content' from results; use 'snippet'. + assert "大别山" in results[0]["snippet"] + finally: + db.close() + # ========================================================================= # Message storage From 9ae98e07a7ee7929f8ec3902c545c42d66f10268 Mon Sep 17 00:00:00 2001 From: channkim <chanyoung.kim@nota.ai> Date: Tue, 16 Jun 2026 14:06:26 +0900 Subject: [PATCH 022/470] fix(agent): rebuild base fts without trigram --- hermes_state.py | 19 ++++++++++++++----- tests/test_hermes_state.py | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 99cb24748e6..36e5c91fe8a 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -845,9 +845,12 @@ class SessionDB: return int(row[0] if not isinstance(row, sqlite3.Row) else row[0]) @staticmethod - def _rebuild_fts_indexes(cursor: sqlite3.Cursor) -> None: - for table_name in ("messages_fts", "messages_fts_trigram"): - cursor.execute(f"DELETE FROM {table_name}") + def _rebuild_fts_indexes( + cursor: sqlite3.Cursor, + *, + include_trigram: bool = True, + ) -> None: + cursor.execute("DELETE FROM messages_fts") cursor.execute( "INSERT INTO messages_fts(rowid, content) " "SELECT id, " @@ -856,6 +859,9 @@ class SessionDB: "COALESCE(tool_calls, '') " "FROM messages" ) + if not include_trigram: + return + cursor.execute("DELETE FROM messages_fts_trigram") cursor.execute( "INSERT INTO messages_fts_trigram(rowid, content) " "SELECT id, " @@ -1317,8 +1323,11 @@ class SessionDB: cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL ) self._trigram_available = trigram_enabled - if trigram_enabled and triggers_need_repair: - self._rebuild_fts_indexes(cursor) + if triggers_need_repair: + self._rebuild_fts_indexes( + cursor, + include_trigram=trigram_enabled, + ) self._conn.commit() diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 0baf3226401..e4650ed5dc7 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -344,6 +344,45 @@ class TestSessionLifecycle: finally: restored.close() + def test_base_fts_rebuilds_after_trigger_repair_without_trigram( + self, tmp_path, monkeypatch + ): + """Trigger repair must rebuild base FTS even when trigram is unavailable.""" + db_path = tmp_path / "state.db" + seeded = SessionDB(db_path=db_path) + try: + seeded.create_session(session_id="s1", source="cli") + seeded.append_message("s1", role="user", content="already indexed") + for trigger in ( + "messages_fts_insert", + "messages_fts_delete", + "messages_fts_update", + "messages_fts_trigram_insert", + "messages_fts_trigram_delete", + "messages_fts_trigram_update", + ): + seeded._conn.execute(f"DROP TRIGGER IF EXISTS {trigger}") + seeded._conn.commit() + seeded.append_message("s1", role="assistant", content="repair only base needle") + finally: + seeded.close() + + real_connect = sqlite3.connect + + def connect_without_trigram(*args, **kwargs): + kwargs["factory"] = _NoTrigramConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_trigram) + restored = SessionDB(db_path=db_path) + try: + assert restored._fts_enabled is True + assert restored._trigram_available is False + assert restored._fts_table_exists("messages_fts") is True + assert len(restored.search_messages("needle")) == 1 + finally: + restored.close() + def test_is_fts5_unavailable_error_catches_trigram_tokenizer(self): """Unit test: _is_fts5_unavailable_error matches 'no such tokenizer: trigram'.""" fts5_err = sqlite3.OperationalError("no such module: fts5") From 1d2e359678692204af91bb39677264cda8b9545d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:37:48 -0700 Subject: [PATCH 023/470] fix(cli): surface a visible warning when the session store is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When SessionDB init fails, the CLI/Desktop previously continued live with only a buried log line. The chat looks healthy, but the transcript is never written to state.db — so resume later shows a truncated or empty session and the user only discovers the loss after the fact (#41386). Emit a prominent stderr banner at startup when the store is unavailable, making it explicit that the conversation will not be saved and cannot be resumed, with a pointer to fix the store. Also set _session_db_unavailable so downstream code can detect the degraded state. --- cli.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cli.py b/cli.py index b1c9a4bc8ef..4e4ddb015c0 100644 --- a/cli.py +++ b/cli.py @@ -3503,11 +3503,36 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._last_turn_finished_at: Optional[float] = None # time.time() when the last agent loop finished # Initialize SQLite session store early so /title works before first message self._session_db = None + self._session_db_unavailable = False try: from hermes_state import SessionDB self._session_db = SessionDB() except Exception as e: + # #41386: a failed session store means the transcript is NOT + # persisted to state.db — the live chat looks healthy but resume + # later shows a truncated/empty session. A buried log line is not + # enough; surface it prominently so the user knows persistence is + # off for this run and can fix the store before relying on resume. + self._session_db_unavailable = True logger.warning("Failed to initialize SessionDB — session will NOT be indexed for search: %s", e) + try: + # Console is imported at module scope; do NOT re-import it here. + # A function-local `import` would make `Console` a local name for + # the whole __init__ body and break the earlier `self.console = + # Console()` with UnboundLocalError. + Console(stderr=True).print( + "[bold yellow]⚠ Session store unavailable[/bold yellow] — " + "this conversation will [bold]NOT be saved[/bold] to disk and " + "cannot be resumed later. Searching past sessions is also disabled.\n" + f" Reason: {e}\n" + " Fix the state.db store (e.g. `hermes update` to rebuild the venv) to restore persistence." + ) + except Exception: + # Never let the warning path itself break startup. + print( + "WARNING: Session store unavailable — this conversation will NOT be " + f"saved to disk and cannot be resumed later. Reason: {e}" + ) # Opportunistic state.db maintenance — runs at most once per # min_interval_hours, tracked via state_meta in state.db itself so From 62c71ebd8f5a57857357c1325dd08d66ca14926f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:38:53 -0700 Subject: [PATCH 024/470] chore(release): map chanyoung.kim@nota.ai -> channkim for #47049 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 6f56a14154d..b2f5f7d8ddc 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -102,6 +102,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "chanyoung.kim@nota.ai": "channkim", "stevenn.damatoo@gmail.com": "x1erra", "evansrory@gmail.com": "zimigit2020", "237263164+ft-ioxcs@users.noreply.github.com": "ft-ioxcs", From e48554a3e0d5bec74e619070c3fd3f03cac52716 Mon Sep 17 00:00:00 2001 From: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:55:50 -0700 Subject: [PATCH 025/470] feat(cli): lock hermes worktrees so concurrent processes can't clobber them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git worktree lock at creation and unlock before removal. A locked worktree refuses 'git worktree remove' (and prune), so a second hermes process or a stray cleanup can't silently delete an in-use isolated worktree. Fail-soft on both paths — a lock/unlock error never blocks the session or cleanup. Salvaged from #47029 (Issue #46303). Unlock moved to the actual-removal path so a preserved (unpushed-commits) worktree stays locked while in use. --- cli.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cli.py b/cli.py index 4e4ddb015c0..f6a9393d34a 100644 --- a/cli.py +++ b/cli.py @@ -1340,6 +1340,17 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: except Exception as e: logger.debug("Error copying .worktreeinclude entries: %s", e) + # Lock the worktree so other processes (and `git worktree remove`) can see + # it is actively in use. Fail-soft: a lock failure never blocks the session. + try: + subprocess.run( + ["git", "worktree", "lock", "--reason", f"hermes pid={os.getpid()}", str(wt_path)], + capture_output=True, text=True, timeout=10, cwd=repo_root, + ) + logger.debug("Worktree locked: %s (pid=%s)", wt_path, os.getpid()) + except Exception as e: + logger.debug("git worktree lock failed (non-fatal): %s", e) + info = { "path": str(wt_path), "branch": branch_name, @@ -1415,6 +1426,16 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None: # Remove worktree (even if working tree is dirty — uncommitted # changes without unpushed commits are just artifacts) + # Unlock first so `git worktree remove` isn't blocked by the lock we + # placed at creation time. Fail-soft — never block cleanup. + try: + subprocess.run( + ["git", "worktree", "unlock", wt_path], + capture_output=True, text=True, timeout=10, cwd=repo_root, + ) + except Exception as e: + logger.debug("git worktree unlock failed (non-fatal): %s", e) + try: subprocess.run( ["git", "worktree", "remove", wt_path, "--force"], From 8568988b0157dc744f0e0cfa46f7bd770d98aa89 Mon Sep 17 00:00:00 2001 From: teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:56:25 -0700 Subject: [PATCH 026/470] chore: add JoaoMarcos44 to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index b2f5f7d8ddc..cee08fab0af 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -46,6 +46,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "victor@rocketfueldev.com": "victor-kyriazakos", + "87440198+JoaoMarcos44@users.noreply.github.com": "JoaoMarcos44", "286497132+srojk34@users.noreply.github.com": "srojk34", "59806492+sitkarev@users.noreply.github.com": "sitkarev", "zheng@omegasys.eu": "omegazheng", From d06104a9ee163e6369d3870f092de875b2f2ab0c Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:50:52 +0530 Subject: [PATCH 027/470] fix(dashboard): resolve chat TUI argv off event loop (#48561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): resolve chat TUI argv off event loop Dashboard chat now resolves its TUI launch command off the FastAPI/WebSocket event loop. The resolver can run `npm install` / `npm run build` through `_make_tui_argv()`, and doing that synchronously in `/api/pty` can block proxy keepalives and other dashboard WebSocket work long enough for reverse-proxy deployments to drop the chat connection. This keeps the current TUI build policy intact: normal production launches still run the correctness-first `npm run build` path, while `HERMES_TUI_DIR` remains the prebuilt/no-build path for distros and containers. The change only moves the potentially slow resolver work to a worker thread for the dashboard chat path, serialized by an `asyncio.Lock` so concurrent chat tabs preserve one-build-at-a-time behavior. `SystemExit` (node/npm missing) and the profile `HTTPException` path still propagate cleanly through `asyncio.to_thread()`. Salvaged from #26124 — rebased onto current main. The async wrapper now threads the `profile` parameter that `_resolve_chat_argv` gained on main since the PR was opened, so cross-profile chat is preserved. Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> * chore: add 0xdany to AUTHOR_MAP * fix(dashboard): bind chat-argv lock to app.state; cover error propagation Self-review hardening on top of the salvaged fix: - Move `_chat_argv_lock` from a module-level `asyncio.Lock()` onto `app.state` (initialised in `_lifespan`, lazy fallback via `_get_chat_argv_lock`), mirroring `event_lock`. A module-level `asyncio.Lock()` binds to whatever event loop is active at import time, which is the exact pattern `_get_event_state`'s docstring warns against (breaks across TestClient instances / uvicorn reloads). This keeps the lock on the running loop. - Add two tests exercising the real `_resolve_chat_argv_async` → `asyncio.to_thread` → lock → re-raise chain: `SystemExit` (node/npm missing) and `HTTPException` (invalid profile) both propagate out of the worker thread and are caught by `pty_ws`'s existing handlers. The prior tests mocked `asyncio.to_thread` away and never covered this path. * test(dashboard): dedupe pty error-propagation tests; assert close code simplify-code cleanup pass on the salvage stack: - Extract the shared scaffolding of the two pty_ws error-propagation tests into `_assert_pty_propagates`, keeping the two tests as distinct contracts for the `except SystemExit` and `except HTTPException` arms. - Assert the stable WebSocket close code (1011) instead of relying solely on the user-facing "Chat unavailable" notice wording — a behavior contract per the AGENTS.md "behavior contracts over snapshots" rule, robust to notice rewording. The detail substring ("unknown profile") is still checked for the HTTPException case since proving the detail survives the thread hop is the point of that test. No production-code change; the helper exercises the same real _resolve_chat_argv_async -> asyncio.to_thread -> lock -> re-raise chain. --------- Co-authored-by: draihan <draihan@student.ubc.ca> --- hermes_cli/web_server.py | 48 ++++++++++++- scripts/release.py | 1 + tests/hermes_cli/test_web_server.py | 102 ++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 9a6f28a68b5..fb96f0f4b49 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -147,6 +147,11 @@ def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60 async def _lifespan(app: "FastAPI"): app.state.event_channels = {} # dict[str, set] app.state.event_lock = asyncio.Lock() + # Serializes chat-argv resolution so concurrent /api/pty connections + # don't trigger overlapping ``npm install`` / ``npm run build`` work. + # On app.state (not a module global) so the Lock binds to the running + # event loop during lifespan startup — see _get_event_state's docstring. + app.state.chat_argv_lock = asyncio.Lock() # Desktop-spawned backends (HERMES_DESKTOP=1) fire cron jobs themselves, # since the app has no gateway running the scheduler. Server `hermes @@ -187,6 +192,20 @@ def _get_event_state(app: "FastAPI"): return app.state.event_channels, app.state.event_lock +def _get_chat_argv_lock(app: "FastAPI") -> asyncio.Lock: + """Return the chat-argv resolution lock from app.state. + + Mirrors :func:`_get_event_state`: prefers the lifespan-initialised Lock + (created on the correct event loop) but lazily initialises it for + non-``with`` TestClient usages. + """ + try: + return app.state.chat_argv_lock + except AttributeError: + app.state.chat_argv_lock = asyncio.Lock() + return app.state.chat_argv_lock + + app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan) # --------------------------------------------------------------------------- @@ -10745,7 +10764,8 @@ def _ws_auth_ok(ws: "WebSocket") -> bool: # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id # the chat tab generates on mount; entries auto-evict when the last subscriber # drops AND the publisher has disconnected. -# (State is initialised in _lifespan on app startup — see above.) +# (Channel state and the chat-argv lock are initialised in _lifespan on app +# startup — see _get_event_state / _get_chat_argv_lock above.) def _resolve_chat_argv( @@ -10862,6 +10882,30 @@ def _build_gateway_ws_url() -> Optional[str]: return f"ws://{netloc}/api/ws?{qs}" +async def _resolve_chat_argv_async( + resume: Optional[str] = None, + sidecar_url: Optional[str] = None, + profile: Optional[str] = None, +) -> tuple[list[str], Optional[str], Optional[dict]]: + """Resolve chat argv without blocking the dashboard event loop. + + ``_resolve_chat_argv`` may run ``npm install`` / ``npm run build`` through + ``_make_tui_argv``. Keep that synchronous work off the WebSocket event + loop so reverse proxies and existing dashboard connections can continue + to exchange keepalives while the TUI launch command is prepared. The + async lock preserves the previous one-build-at-a-time behavior when + multiple browser tabs connect at once without occupying worker threads + while queued connections wait. + """ + async with _get_chat_argv_lock(app): + return await asyncio.to_thread( + _resolve_chat_argv, + resume=resume, + sidecar_url=sidecar_url, + profile=profile, + ) + + def _build_sidecar_url(channel: str) -> Optional[str]: """ws:// URL the PTY child should publish events to, or None when unbound. @@ -10992,7 +11036,7 @@ async def pty_ws(ws: WebSocket) -> None: sidecar_url = _build_sidecar_url(channel) if channel else None try: - argv, cwd, env = _resolve_chat_argv( + argv, cwd, env = await _resolve_chat_argv_async( resume=resume, sidecar_url=sidecar_url, profile=profile ) except HTTPException as exc: diff --git a/scripts/release.py b/scripts/release.py index cee08fab0af..6c5d33ec3a1 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -208,6 +208,7 @@ AUTHOR_MAP = { "me@promplate.dev": "CNSeniorious000", "yichengqiao21@gmail.com": "YarrowQiao", "erhanyasarx@gmail.com": "erhnysr", + "draihan@student.ubc.ca": "0xdany", # PR #26124 salvage (chat argv off event loop) "30366221+WorldWriter@users.noreply.github.com": "WorldWriter", "dafeng@DafengdeMacBook-Pro.local": "WorldWriter", "schepers.zander1@gmail.com": "Strontvod", diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index f03265ee678..e0ad77dfc8a 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1,5 +1,6 @@ """Tests for hermes_cli.web_server and related config utilities.""" +import asyncio import os import json import shutil @@ -5132,6 +5133,107 @@ class TestPtyWebSocket: pass assert exc.value.code == 4401 + def test_resolve_chat_argv_async_uses_worker_thread(self, monkeypatch): + captured: dict = {} + + def fake_resolve(resume=None, sidecar_url=None, profile=None): + captured["resume"] = resume + captured["sidecar_url"] = sidecar_url + captured["profile"] = profile + return (["node", "dist/entry.js"], "/tmp/ui-tui", {"NODE_ENV": "production"}) + + async def fake_to_thread(fn, *args, **kwargs): + captured["thread_fn"] = fn + captured["thread_args"] = args + captured["thread_kwargs"] = kwargs + return fn(*args, **kwargs) + + monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", fake_resolve) + monkeypatch.setattr(self.ws_module.asyncio, "to_thread", fake_to_thread) + + argv, cwd, env = asyncio.run( + self.ws_module._resolve_chat_argv_async( + resume="sess-42", + sidecar_url="ws://127.0.0.1:9119/api/pub?channel=abc", + profile="worker", + ) + ) + + assert callable(captured["thread_fn"]) + assert captured["thread_args"] == () + assert captured["thread_kwargs"] == { + "resume": "sess-42", + "sidecar_url": "ws://127.0.0.1:9119/api/pub?channel=abc", + "profile": "worker", + } + assert argv == ["node", "dist/entry.js"] + assert cwd == "/tmp/ui-tui" + assert env == {"NODE_ENV": "production"} + assert captured["resume"] == "sess-42" + assert captured["sidecar_url"] == "ws://127.0.0.1:9119/api/pub?channel=abc" + assert captured["profile"] == "worker" + + def test_pty_ws_resolves_argv_through_async_wrapper(self, monkeypatch): + captured: dict = {} + + async def fake_resolve_async(resume=None, sidecar_url=None, profile=None): + captured["resume"] = resume + captured["sidecar_url"] = sidecar_url + captured["profile"] = profile + return (["/bin/sh", "-c", "printf async-resolve-ok"], None, None) + + monkeypatch.setattr(self.ws_module, "_resolve_chat_argv_async", fake_resolve_async) + + with self.client.websocket_connect(self._url(resume="sess-99")) as conn: + try: + conn.receive_bytes() + except Exception: + pass + + assert captured["resume"] == "sess-99" + + def _assert_pty_propagates(self, monkeypatch, raising_resolver, *, profile=None, expect_detail=None): + """Drive /api/pty with a resolver that raises, and assert the error + propagates through the real _resolve_chat_argv_async -> asyncio.to_thread + -> lock -> re-raise chain into pty_ws's handler: the "Chat unavailable" + notice is sent and the socket closes with code 1011 (the stable + contract — we assert the close code, not the exact notice wording).""" + from starlette.websockets import WebSocketDisconnect + + # Patch the REAL resolver so the whole wrapper/to_thread/lock chain runs. + monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", raising_resolver) + + url = self._url(profile=profile) if profile else self._url() + with self.client.websocket_connect(url) as conn: + notice = conn.receive_text() + with pytest.raises(WebSocketDisconnect) as exc: + conn.receive_text() + assert "Chat unavailable" in notice + assert exc.value.code == 1011 + if expect_detail is not None: + assert expect_detail in notice + + def test_pty_ws_propagates_systemexit_through_async_wrapper(self, monkeypatch): + """SystemExit from _make_tui_argv (node/npm missing) propagates through + the async wrapper and is caught by pty_ws's ``except SystemExit``.""" + + def boom(resume=None, sidecar_url=None, profile=None): + raise SystemExit("node not found") + + self._assert_pty_propagates(monkeypatch, boom) + + def test_pty_ws_propagates_httpexception_through_async_wrapper(self, monkeypatch): + """An invalid-profile HTTPException raised inside the threaded resolver + propagates through the wrapper and hits pty_ws's ``except HTTPException``.""" + from fastapi import HTTPException + + def bad_profile(resume=None, sidecar_url=None, profile=None): + raise HTTPException(status_code=404, detail="unknown profile") + + self._assert_pty_propagates( + monkeypatch, bad_profile, profile="ghost", expect_detail="unknown profile" + ) + def test_streams_child_stdout_to_client(self, monkeypatch): monkeypatch.setattr( self.ws_module, From c34840e22e086387e0a1e0d72a50a4c7988b4f81 Mon Sep 17 00:00:00 2001 From: Ben <ben@nousresearch.com> Date: Fri, 19 Jun 2026 12:43:30 +1000 Subject: [PATCH 028/470] fix(cron): serve /api/cron/fire on the dashboard app (hosted-agent surface) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-test finding: the Chronos fire webhook was only on the APIServerAdapter (aiohttp), but hosted agents expose `hermes dashboard` (the FastAPI web_server app on :9119) as their public URL — NOT the api_server adapter. So NAS's relay callback to {callback_url}/api/cron/fire could never reach the verifier on a hosted agent (the exact target environment). Two layers were wrong: 1. Wrong server: /api/cron/fire didn't exist on the dashboard app. Added cron_fire_webhook there, alongside the existing /api/cron/* dashboard routes. It resolves the job's profile (_find_cron_job_profile) and runs fire_due via the resolved provider under the cron-profile retarget lock (_fire_cron_job_for_profile, mirroring _call_cron_for_profile) so the CAS claim + run_one_job operate on the right profile's jobs.json. Runs with no live adapters (delivery falls back to the per-platform send path, like the desktop cron path). 202 + background so a long turn never trips NAS's timeout; the store CAS de-dupes a NAS retry. job-not-found -> 200 "gone". 2. Auth gate: the dashboard auth middleware 401s any non-cookie request before the handler runs. Added /api/cron/fire to the shared PUBLIC_API_PATHS so the NAS bearer-JWT callback reaches the verifier — the JWT (purpose=cron_fire), not the cookie, is the real gate. One shared frozenset feeds both the loopback and OAuth middlewares, so no drift. Kept the APIServerAdapter route too (valid self-host api_server surface). Contract doc updated to name the dashboard app as the hosted-agent callback surface. Tests: test_cron_fire_dashboard (6) — route registered on the dashboard app, in PUBLIC_API_PATHS, 401 on bad token WITH the cookie gate engaged (proves it's reachable past the gate + JWT is the gate), 400 missing job_id, 200 gone for unknown job, 202 + fire_due invoked for the resolved profile on a valid token. Full hermes_cli + cron + chronos + webhook suites green (7637). Why the original tests missed it: the api_server webhook test built an APIServerAdapter client directly and never asserted which server the hosted public URL exposes — green-but-wrong-integration. The new test pins the route to the dashboard app. --- docs/chronos-managed-cron-contract.md | 8 +- hermes_cli/dashboard_auth/public_paths.py | 6 + hermes_cli/web_server.py | 87 ++++++++++++ tests/hermes_cli/test_cron_fire_dashboard.py | 142 +++++++++++++++++++ 4 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_cron_fire_dashboard.py diff --git a/docs/chronos-managed-cron-contract.md b/docs/chronos-managed-cron-contract.md index 0848d5eb939..64937a9c994 100644 --- a/docs/chronos-managed-cron-contract.md +++ b/docs/chronos-managed-cron-contract.md @@ -114,8 +114,12 @@ Arm (or re-arm, idempotently) exactly one one-shot for a job. ## Inbound `POST /api/cron/fire` (NAS → agent) — agent side, already implemented -This is the agent endpoint NAS calls in Endpoint 3 step 3. Implemented on the -`APIServerAdapter` (`gateway/platforms/api_server.py`); the verifier is +This is the agent endpoint NAS calls in Endpoint 3 step 3. Served by the +**dashboard app** (`hermes_cli/web_server.py`) — the agent's always-reachable +public HTTP surface on hosted deployments (the gateway may be idle/scaled down); +it is in `PUBLIC_API_PATHS` so the dashboard cookie gate lets the bearer-JWT +callback through to the verifier. (Also registered on the optional +`APIServerAdapter` for self-host API-server deployments.) The verifier is `plugins/cron/chronos/verify.py`. - **Auth:** `Authorization: Bearer <NAS-minted JWT>`. The agent verifies: diff --git a/hermes_cli/dashboard_auth/public_paths.py b/hermes_cli/dashboard_auth/public_paths.py index 2699e15c979..349937cffa0 100644 --- a/hermes_cli/dashboard_auth/public_paths.py +++ b/hermes_cli/dashboard_auth/public_paths.py @@ -46,4 +46,10 @@ PUBLIC_API_PATHS: frozenset[str] = frozenset({ # Read-only theme + plugin manifests for the dashboard skin engine. "/api/dashboard/themes", "/api/dashboard/plugins", + # Chronos managed-cron fire webhook (NAS -> agent). NOT cookie-gated: it + # carries its own short-lived NAS-minted JWT (purpose=cron_fire), which the + # handler verifies as the real auth. Must bypass the dashboard auth gate so + # the NAS relay's bearer-only callback reaches the verifier instead of a + # 401 no_cookie. The JWT — not this allowlist — is the security boundary. + "/api/cron/fire", }) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a338ebfc131..c3095dd727e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -7310,6 +7310,93 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None): return {"ok": True} +def _fire_cron_job_for_profile(profile: str, job_id: str) -> bool: + """Run ONE due cron job end-to-end for ``profile`` via the resolved + scheduler provider's ``fire_due`` (store CAS claim + ``run_one_job``). + + Retargets the ``cron.jobs`` module globals to the profile's cron dir under + the shared lock — same mechanism as ``_call_cron_for_profile`` — so the + claim and the run operate on the right profile's ``jobs.json``. Runs with + no live adapters; delivery falls back to the per-platform send path (the + dashboard process has no gateway adapter handles, exactly like the desktop + cron path above). + """ + _profile_name, home = _cron_profile_home(profile) + with _CRON_PROFILE_LOCK: + from cron import jobs as cron_jobs + from cron.scheduler_provider import resolve_cron_scheduler + + old_cron_dir = cron_jobs.CRON_DIR + old_jobs_file = cron_jobs.JOBS_FILE + old_output_dir = cron_jobs.OUTPUT_DIR + cron_jobs.CRON_DIR = home / "cron" + cron_jobs.JOBS_FILE = cron_jobs.CRON_DIR / "jobs.json" + cron_jobs.OUTPUT_DIR = cron_jobs.CRON_DIR / "output" + try: + provider = resolve_cron_scheduler() + return bool(provider.fire_due(job_id, adapters=None, loop=None)) + finally: + cron_jobs.CRON_DIR = old_cron_dir + cron_jobs.JOBS_FILE = old_jobs_file + cron_jobs.OUTPUT_DIR = old_output_dir + + +@app.post("/api/cron/fire") +async def cron_fire_webhook(request: Request): + """Chronos managed-cron fire webhook (NAS -> agent). + + Authenticated by a short-lived NAS-minted JWT (verified by the pluggable + Chronos fire-verifier), NOT the dashboard session cookie — so this path is + in ``PUBLIC_API_PATHS`` to bypass the dashboard auth gate, and the JWT is + the real gate. This is the inbound half of scale-to-zero managed cron: NAS + POSTs here at fire time, the agent verifies, claims the job (store CAS, so + at-most-once across replicas / on a NAS retry), runs it, and re-arms the + next one-shot. + + Lives on the dashboard app (not the api_server adapter) because the + dashboard is the agent's always-reachable public HTTP surface on hosted + deployments; the gateway may be idle/scaled down. + + Returns 202 immediately and runs the job in the background so a long agent + turn never trips NAS's HTTP timeout. + """ + from plugins.cron.chronos.verify import get_fire_verifier + + auth = request.headers.get("Authorization", "") + token = auth[7:].strip() if auth.startswith("Bearer ") else "" + + cfg = load_config() + claims = get_fire_verifier()( + token=token, + expected_audience=cfg_get(cfg, "cron", "chronos", "expected_audience", default=""), + jwks_or_key=cfg_get(cfg, "cron", "chronos", "nas_jwks_url", default="") or None, + issuer=cfg_get(cfg, "cron", "chronos", "portal_url", default="") or None, + ) + if claims is None: + return JSONResponse({"error": "invalid fire token"}, status_code=401) + + try: + body = await request.json() + except Exception: + body = {} + job_id = (body or {}).get("job_id") if isinstance(body, dict) else None + if not job_id: + return JSONResponse({"error": "missing job_id"}, status_code=400) + + profile = _find_cron_job_profile(job_id) + if not profile: + # Job is gone (cancelled / completed) — nothing to fire. 200 so NAS + # does not retry a fire that is intentionally absent. + return JSONResponse({"status": "gone", "job_id": job_id}, status_code=200) + + # Run in the background; the store CAS claim inside fire_due de-dupes a + # NAS/scheduler retry that arrives while this is in flight. + asyncio.create_task( + asyncio.to_thread(_fire_cron_job_for_profile, profile, job_id) + ) + return JSONResponse({"status": "accepted", "job_id": job_id}, status_code=202) + + # --------------------------------------------------------------------------- # Automation Blueprints — parameterized automation blueprints. The dashboard renders the # slot schema as a form; submitting instantiates a real cron job via the same diff --git a/tests/hermes_cli/test_cron_fire_dashboard.py b/tests/hermes_cli/test_cron_fire_dashboard.py new file mode 100644 index 00000000000..44d6f07c270 --- /dev/null +++ b/tests/hermes_cli/test_cron_fire_dashboard.py @@ -0,0 +1,142 @@ +"""Tests for the Chronos cron-fire webhook ON THE DASHBOARD APP (web_server). + +Regression guard for the relocation bug: the fire webhook MUST live on the +dashboard FastAPI app (`hermes_cli.web_server.app`) — the agent's public HTTP +surface on hosted deployments — not only on the aiohttp APIServerAdapter (which +hosted agents don't expose). It must: + - be a registered route on the dashboard app, + - be in PUBLIC_API_PATHS so the dashboard cookie gate doesn't 401 it before + the JWT verifier runs, + - reject a bad/missing NAS-JWT with 401 (the JWT is the real gate), + - 400 on missing job_id, + - on a valid token, resolve the job's profile and run fire_due in the + background, returning 202. +""" + +import pytest +from starlette.testclient import TestClient + +from hermes_cli import web_server +from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS + + +def _client(auth_required: bool): + prev_auth = getattr(web_server.app.state, "auth_required", None) + prev_host = getattr(web_server.app.state, "bound_host", None) + web_server.app.state.auth_required = auth_required + web_server.app.state.bound_host = None + client = TestClient(web_server.app) + return client, prev_auth, prev_host + + +def _restore(prev_auth, prev_host): + if prev_auth is None: + if hasattr(web_server.app.state, "auth_required"): + delattr(web_server.app.state, "auth_required") + else: + web_server.app.state.auth_required = prev_auth + if prev_host is None: + if hasattr(web_server.app.state, "bound_host"): + delattr(web_server.app.state, "bound_host") + else: + web_server.app.state.bound_host = prev_host + + +def test_route_registered_on_dashboard_app(): + """The fire webhook is served by the dashboard app (the hosted-agent public + surface), not only the aiohttp adapter.""" + paths = {r.path for r in web_server.app.routes if hasattr(r, "path")} + assert "/api/cron/fire" in paths + + +def test_fire_path_is_public(): + """Must bypass the dashboard cookie gate so the NAS bearer-JWT callback + reaches the verifier (the JWT is the real auth).""" + assert "/api/cron/fire" in PUBLIC_API_PATHS + + +def test_bad_token_401(monkeypatch): + """Invalid NAS-JWT -> 401, even with the dashboard auth gate ENGAGED + (proves the route is reachable past the cookie gate and the verifier is the + gate). fire_due must NOT run.""" + fired = [] + monkeypatch.setattr( + "plugins.cron.chronos.verify.get_fire_verifier", + lambda: (lambda **kw: None), # verification fails + ) + monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default") + monkeypatch.setattr(web_server, "_fire_cron_job_for_profile", + lambda p, j: fired.append((p, j))) + + client, pa, ph = _client(auth_required=True) + try: + resp = client.post("/api/cron/fire", + headers={"Authorization": "Bearer forged"}, + json={"job_id": "abc"}) + assert resp.status_code == 401 + assert fired == [] + finally: + _restore(pa, ph) + client.close() + + +def test_missing_job_id_400(monkeypatch): + monkeypatch.setattr( + "plugins.cron.chronos.verify.get_fire_verifier", + lambda: (lambda **kw: {"purpose": "cron_fire"}), + ) + client, pa, ph = _client(auth_required=False) + try: + resp = client.post("/api/cron/fire", + headers={"Authorization": "Bearer good"}, + json={}) + assert resp.status_code == 400 + finally: + _restore(pa, ph) + client.close() + + +def test_unknown_job_200_gone(monkeypatch): + """Valid token but the job isn't found in any profile -> 200 'gone' + (NAS shouldn't retry a fire for a cancelled/completed job).""" + monkeypatch.setattr( + "plugins.cron.chronos.verify.get_fire_verifier", + lambda: (lambda **kw: {"purpose": "cron_fire"}), + ) + monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: None) + client, pa, ph = _client(auth_required=False) + try: + resp = client.post("/api/cron/fire", + headers={"Authorization": "Bearer good"}, + json={"job_id": "ghost"}) + assert resp.status_code == 200 + assert resp.json().get("status") == "gone" + finally: + _restore(pa, ph) + client.close() + + +def test_valid_token_accepts_and_fires(monkeypatch): + """Valid token + known job -> 202 and fire_due invoked for the resolved + profile.""" + fired = [] + monkeypatch.setattr( + "plugins.cron.chronos.verify.get_fire_verifier", + lambda: (lambda **kw: {"purpose": "cron_fire", "aud": "agent:x"}), + ) + monkeypatch.setattr(web_server, "_find_cron_job_profile", lambda jid: "default") + monkeypatch.setattr(web_server, "_fire_cron_job_for_profile", + lambda p, j: fired.append((p, j)) or True) + + client, pa, ph = _client(auth_required=False) + try: + resp = client.post("/api/cron/fire", + headers={"Authorization": "Bearer good"}, + json={"job_id": "j1"}) + assert resp.status_code == 202 + assert resp.json()["job_id"] == "j1" + finally: + _restore(pa, ph) + client.close() + # background task ran the fire for the resolved profile + assert fired == [("default", "j1")] From 620fd59b8e6f235ec2822897f2627bad7df6d071 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:37:41 -0700 Subject: [PATCH 029/470] feat(model-picker): add Refresh Models control to bust stale model cache (#48691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop model picker had no way to force a fresh model fetch: model.options went through the 1h-cached provider_models_cache.json, and there was no flag to bust it. When a provider's cached list expired and its next live fetch failed, the picker fell back to the curated static list — silently dropping live-only models (e.g. OpenCode Zen's free tier like deepseek-v4-flash-free) the user had been using. - Thread refresh through model.options (RPC + REST /api/model/options) -> build_models_payload -> list_authenticated_providers, which calls clear_provider_models_cache() up front when set so every row re-fetches live. - Add a 'Refresh Models' control to the desktop picker (5-locale i18n, spinning sync icon). Normal opens leave refresh=false to stay snappy on the cache. Verified: stale cache hides deepseek-v4-flash-free -> refresh busts it -> live re-fetch surfaces it. refresh=false never touches the cache. --- .../src/app/shell/model-menu-panel.tsx | 48 ++++++++++++++++++- apps/desktop/src/hermes.ts | 4 +- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + hermes_cli/inventory.py | 6 +++ hermes_cli/model_switch.py | 21 +++++++- hermes_cli/web_server.py | 7 ++- tests/hermes_cli/test_inventory.py | 37 ++++++++++++++ tui_gateway/server.py | 1 + 12 files changed, 124 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index c3d20ebd878..577d98f1495 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { createContext, useContext, useMemo, useState } from 'react' import { Codicon } from '@/components/ui/codicon' @@ -62,6 +62,8 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model const copy = t.shell.modelMenu const closeMenu = useContext(ModelMenuCloseContext) const [search, setSearch] = useState('') + const [refreshing, setRefreshing] = useState(false) + const queryClient = useQueryClient() // Reactive session state is read from the stores here (not drilled in), so // toggling effort/fast/model re-renders this panel in place without forcing // the parent to rebuild the menu content (which would close the dropdown). @@ -110,6 +112,38 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model // next session.create (see selectModel). The default lives in Settings → Model. const switchTo = (model: string, provider: string) => onSelectModel({ model, provider }) + // Explicit "Refresh Models": re-fetch the catalog with refresh:true so the + // backend busts its 1h provider-model disk cache and re-pulls each provider's + // live list. Fixes live-only models (e.g. OpenCode Zen free tier) vanishing + // when the cache expires and falls back to the curated static list. + const refreshModels = async () => { + if (refreshing) { + return + } + + setRefreshing(true) + + try { + const queryKey = ['model-options', activeSessionId || 'global'] + + const next = + gateway && activeSessionId + ? await gateway.request<ModelOptionsResponse>('model.options', { + session_id: activeSessionId, + refresh: true + }) + : await getGlobalModelOptions({ refresh: true }) + + queryClient.setQueryData<ModelOptionsResponse>(queryKey, next) + } catch { + // Network/backend hiccup — fall back to a plain invalidate so the next + // open re-fetches (still cached, but no worse than before). + void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + } finally { + setRefreshing(false) + } + } + // Selecting a model row restores that model's remembered preset onto the // session (effort/fast), gated by capability. Unset → Hermes defaults. const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => { @@ -268,6 +302,18 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model <DropdownMenuSeparator className="mx-0" /> + <DropdownMenuItem + className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')} + disabled={refreshing} + onSelect={event => { + event.preventDefault() + void refreshModels() + }} + > + <Codicon className={cn('mr-1.5', refreshing && 'animate-spin')} name="sync" size="0.75rem" /> + {copy.refreshModels} + </DropdownMenuItem> + <DropdownMenuItem className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')} onSelect={() => setModelVisibilityOpen(true)} diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 3b200a598f4..197e24611ab 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -660,10 +660,10 @@ export function getUsageAnalytics(days = 30): Promise<AnalyticsResponse> { }) } -export function getGlobalModelOptions(): Promise<ModelOptionsResponse> { +export function getGlobalModelOptions(opts?: { refresh?: boolean }): Promise<ModelOptionsResponse> { return window.hermesDesktop.api<ModelOptionsResponse>({ ...profileScoped(), - path: '/api/model/options' + path: opts?.refresh ? '/api/model/options?refresh=1' : '/api/model/options' }) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3c1a7ec3879..d27741c44db 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1532,6 +1532,7 @@ export const en: Translations = { search: 'Search models', noModels: 'No models found', editModels: 'Edit Models…', + refreshModels: 'Refresh Models', fast: 'Fast', medium: 'Med' }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 904e4b25c53..194452ed407 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1662,6 +1662,7 @@ export const ja = defineLocale({ search: 'モデルを検索', noModels: 'モデルが見つかりません', editModels: 'モデルを編集…', + refreshModels: 'モデルを更新', fast: '高速', medium: '中' }, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index dcf1028fb4b..94489e5de9e 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1174,6 +1174,7 @@ export interface Translations { search: string noModels: string editModels: string + refreshModels: string fast: string medium: string } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 8f208aff341..de329631098 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1606,6 +1606,7 @@ export const zhHant = defineLocale({ search: '搜尋模型', noModels: '找不到模型', editModels: '編輯模型…', + refreshModels: '重新整理模型', fast: '快速', medium: '中' }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f368d3585ca..ac8c5c0b958 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1712,6 +1712,7 @@ export const zh: Translations = { search: '搜索模型', noModels: '未找到模型', editModels: '编辑模型…', + refreshModels: '刷新模型', fast: '快速', medium: '中' }, diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 7584dd887e0..7f0d3d220e6 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -117,6 +117,7 @@ def build_models_payload( pricing: bool = False, capabilities: bool = False, force_fresh_nous_tier: bool = False, + refresh: bool = False, max_models: int | None = None, ) -> dict: """Build the ``{providers, model, provider}`` shape every consumer @@ -144,6 +145,10 @@ def build_models_payload( selecting Portal-recommended Nous models and applying tier gating. Keep this false for UI picker opens; explicit auth/model flows can opt in when they need freshly-purchased credits to show up immediately. + - ``refresh``: bust the per-provider model-id disk cache so every row + re-fetches its live catalog. Set only for an explicit user-triggered + "refresh models" action; normal picker opens leave it false to stay + snappy on the 1h cache. """ from hermes_cli.model_switch import list_authenticated_providers @@ -155,6 +160,7 @@ def build_models_payload( custom_providers=ctx.custom_providers, force_fresh_nous_tier=force_fresh_nous_tier, max_models=max_models, + refresh=refresh, ) # --- Deduplicate: remove models from aggregators that overlap with diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index eae987fbbdf..2ed5b14790c 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1207,6 +1207,7 @@ def list_authenticated_providers( force_fresh_nous_tier: bool = False, max_models: int | None = None, current_model: str = "", + refresh: bool = False, ) -> List[dict]: """Detect which providers have credentials and list their curated models. @@ -1227,6 +1228,12 @@ def list_authenticated_providers( ``force_fresh_nous_tier`` bypasses the short Nous tier cache for explicit account-sensitive flows. UI picker opens should leave it false so they do not block on fresh Portal/account checks every time. + + ``refresh`` busts the per-provider model-id disk cache + (``provider_models_cache.json``) up front so every row re-fetches its + live catalog. Use for an explicit user-triggered "refresh models" action + (e.g. the desktop picker's refresh control); leave false for normal picker + opens so they stay snappy on the 1h cache. """ import os from agent.models_dev import ( @@ -1238,9 +1245,21 @@ def list_authenticated_providers( from hermes_cli.models import ( OPENROUTER_MODELS, _PROVIDER_MODELS, _MODELS_DEV_PREFERRED, _merge_with_models_dev, cached_provider_model_ids, - get_curated_nous_model_ids, + clear_provider_models_cache, get_curated_nous_model_ids, ) + # Explicit refresh: drop every provider's cached model-id list so the + # cached_provider_model_ids() calls below all re-fetch live. Without this + # a stale 1h cache can fall back to the curated static list when its live + # fetch later fails, silently dropping live-only models (e.g. OpenCode + # Zen's free tier) the user had seen before. + if refresh: + try: + clear_provider_models_cache() + except Exception: + pass + + results: List[dict] = [] seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545) seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index fb96f0f4b49..b2544ce9d77 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3479,7 +3479,7 @@ _AUX_TASK_SLOTS: Tuple[str, ...] = ( @app.get("/api/model/options") -def get_model_options(profile: Optional[str] = None): +def get_model_options(profile: Optional[str] = None, refresh: bool = False): """Return authenticated providers + their curated model lists. REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the @@ -3490,6 +3490,10 @@ def get_model_options(profile: Optional[str] = None): ``profile`` scopes the picker context (current model/provider, custom providers from config, per-profile .env auth state) so the Models page reads the SAME profile /api/model/set writes. + + ``refresh`` busts the per-provider model-id disk cache so every row + re-fetches its live catalog — used by the picker's explicit "Refresh + Models" control. Normal opens leave it false to stay on the 1h cache. """ try: from hermes_cli.inventory import build_models_payload, load_picker_context @@ -3510,6 +3514,7 @@ def get_model_options(profile: Optional[str] = None): canonical_order=True, pricing=True, capabilities=True, + refresh=bool(refresh), ) except HTTPException: raise diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index c7d761515b1..2eff7bd460d 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -688,3 +688,40 @@ def test_build_models_payload_no_max_models_returns_full_list(): assert kilo_row["total_models"] == 100 assert len(kilo_row["models"]) == 100 + +# ─── refresh flag (cache-bust) ───────────────────────────────────────── + + +def test_build_models_payload_forwards_refresh_flag(): + """build_models_payload must forward refresh= to list_authenticated_providers. + + The desktop picker's "Refresh Models" control passes refresh=True; the + flag has to reach list_authenticated_providers so the per-provider + model-id cache gets busted. Default opens pass refresh=False. + """ + captured: dict = {} + + def _capture(*args, **kwargs): + captured["refresh"] = kwargs.get("refresh") + return [] + + with patch("hermes_cli.model_switch.list_authenticated_providers", side_effect=_capture): + build_models_payload(_empty_ctx()) + assert captured["refresh"] is False + + with patch("hermes_cli.model_switch.list_authenticated_providers", side_effect=_capture): + build_models_payload(_empty_ctx(), refresh=True) + assert captured["refresh"] is True + + +def test_list_authenticated_providers_refresh_busts_cache(): + """refresh=True clears the provider-model disk cache exactly once; + refresh=False leaves it untouched (so normal picker opens stay snappy).""" + from hermes_cli import model_switch + + with patch("hermes_cli.models.clear_provider_models_cache") as clear: + model_switch.list_authenticated_providers(refresh=False) + assert clear.call_count == 0 + model_switch.list_authenticated_providers(refresh=True) + assert clear.call_count == 1 + diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 294e543c230..1b92831df3d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9517,6 +9517,7 @@ def _(rid, params: dict) -> dict: canonical_order=True, pricing=True, capabilities=True, + refresh=bool(params.get("refresh")), ) return _ok(rid, payload) except Exception as e: From e4452ffb8a4986343a7b256c3f7469a73fc9fc54 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:49:14 -0600 Subject: [PATCH 030/470] fix(agent): summarize structured provider error messages --- run_agent.py | 30 +++++++++++++++++++ .../test_codex_xai_oauth_recovery.py | 29 ++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/run_agent.py b/run_agent.py index 331ff2c66ab..65b95483e54 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1840,6 +1840,35 @@ class AIAgent: return detail return f"{detail}{hint}" + @staticmethod + def _coerce_api_error_detail(value: Any) -> str: + """Return a display-safe string for structured provider error fields.""" + if isinstance(value, str): + return value + if isinstance(value, dict): + for key in ("message", "detail", "error", "code", "type"): + nested = value.get(key) + if isinstance(nested, str) and nested.strip(): + return nested + for key in ("message", "detail", "error", "code", "type"): + if key in value: + nested_detail = AIAgent._coerce_api_error_detail(value[key]) + if nested_detail: + return nested_detail + try: + return json.dumps(value, ensure_ascii=False, sort_keys=True) + except TypeError: + return str(value) + if isinstance(value, (list, tuple)): + parts = [ + AIAgent._coerce_api_error_detail(item) + for item in value + ] + return "; ".join(part for part in parts if part) + if value is None: + return "" + return str(value) + @staticmethod def _summarize_api_error(error: Exception) -> str: """Extract a human-readable one-liner from an API error. @@ -1879,6 +1908,7 @@ class AIAgent: if msg: status_code = getattr(error, "status_code", None) prefix = f"HTTP {status_code}: " if status_code else "" + msg = AIAgent._coerce_api_error_detail(msg) return AIAgent._decorate_xai_entitlement_error(f"{prefix}{msg[:300]}") # Fallback: truncate the raw string but give more room than 200 chars diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py index 8a2ce564193..2bc31686e75 100644 --- a/tests/run_agent/test_codex_xai_oauth_recovery.py +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -252,6 +252,35 @@ def test_summarize_api_error_decorates_xai_body_message(): assert "X Premium+ does NOT include" in summary +def test_summarize_api_error_handles_nested_provider_message(): + """HF router may put a structured object in error.message.""" + from run_agent import AIAgent + + class _NestedProviderErr(Exception): + status_code = 400 + body = { + "error": { + "message": { + "type": "Bad Request", + "code": "context_length_exceeded", + "message": ( + "This model's maximum context length is 262144 tokens. " + "Please reduce the length of the messages." + ), + "param": None, + }, + "type": "invalid_request_error", + "param": None, + "code": None, + } + } + + summary = AIAgent._summarize_api_error(_NestedProviderErr("400")) + assert "HTTP 400" in summary + assert "maximum context length is 262144 tokens" in summary + assert "context_length_exceeded" not in summary + + def test_summarize_api_error_idempotent_for_entitlement_hint(): """Decorating twice must not double up the hint.""" from run_agent import AIAgent From cfb55de5ea49ef60268bf5a6924e25c1701943ec Mon Sep 17 00:00:00 2001 From: colinwren-stripe <92538686+colinwren-stripe@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:43:15 -0400 Subject: [PATCH 031/470] Update Stripe Projects skill docs (#48673) Committed-By-Agent: codex Committed-By-Agent: codex Committed-By-Agent: codex Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> --- optional-skills/payments/stripe-projects/SKILL.md | 4 ++-- .../skills/optional/payments/payments-stripe-projects.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/optional-skills/payments/stripe-projects/SKILL.md b/optional-skills/payments/stripe-projects/SKILL.md index d1b30d89875..90eeb700a3c 100644 --- a/optional-skills/payments/stripe-projects/SKILL.md +++ b/optional-skills/payments/stripe-projects/SKILL.md @@ -26,13 +26,13 @@ Trigger phrases: - "manage my stack credentials", "rotate this key", "upgrade my plan" - "what providers can I add?" -If the user already has the service set up manually and just wants to use it, this skill is not the right entry point. +If the user already has a provider account, this skill can still connect it with `stripe projects link <provider>`. If the user wants to use an existing provider resource, such as an existing database or Vercel project, check provider support first; many providers currently support provisioning new resources but not importing existing ones. ## Prerequisites - Stripe CLI installed (Homebrew on macOS, package manager on Linux, or download from https://docs.stripe.com/stripe-cli/install) - Stripe Projects plugin installed -- A Stripe account, logged in via `stripe login` +- A Stripe account. If the user doesn't have one yet, the CLI can guide them through sign-in or account creation in the browser during setup. ## Install diff --git a/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md b/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md index 5ee426361a2..74e60876bf5 100644 --- a/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md +++ b/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md @@ -44,13 +44,13 @@ Trigger phrases: - "manage my stack credentials", "rotate this key", "upgrade my plan" - "what providers can I add?" -If the user already has the service set up manually and just wants to use it, this skill is not the right entry point. +If the user already has a provider account, this skill can still connect it with `stripe projects link <provider>`. If the user wants to use an existing provider resource, such as an existing database or Vercel project, check provider support first; many providers currently support provisioning new resources but not importing existing ones. ## Prerequisites - Stripe CLI installed (Homebrew on macOS, package manager on Linux, or download from https://docs.stripe.com/stripe-cli/install) - Stripe Projects plugin installed -- A Stripe account, logged in via `stripe login` +- A Stripe account. If the user doesn't have one yet, the CLI can guide them through sign-in or account creation in the browser during setup. ## Install From c02192ff6ace129fc9bcc2f8907eabd6eb3f0f1d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:13:07 -0700 Subject: [PATCH 032/470] feat(image-gen): add image-to-image / editing to image_generate (#48705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(image-gen): add image-to-image / editing to image_generate Brings image generation to parity with video generation: the unified image_generate tool now edits/transforms a source image (image-to-image) when given image_url / reference_image_urls, routing to each backend's edit endpoint, exactly as video_generate routes to image-to-video. - ImageGenProvider ABC: generate() gains keyword-only image_url + reference_image_urls; new capabilities() declares modalities + max_reference_images (defaults to text-only, backward compatible). success_response gains a modality field; adds normalize_reference_images. - image_generate tool: schema exposes image_url + reference_image_urls; dynamic schema reflects the active model's actual edit capability so the agent knows when image_url is honored. Handler + plugin dispatch forward the new inputs; legacy/text-only providers get a clear modality_unsupported error instead of silently dropping the source image. - In-tree FAL: 7 models gain edit endpoints (flux-2-klein, flux-2-pro, nano-banana-pro, gpt-image-1.5, gpt-image-2, ideogram/v3, qwen-image) with per-model edit_supports whitelists + reference caps; routes to the /edit endpoint and skips the upscaler for edits. - Plugins: openai (images.edit, 16 refs), xai (/v1/images/edits via grok-imagine-image-quality, JSON body per xAI docs), krea (image_style_references, 10 refs). openai-codex stays text-only and rejects edits with an actionable error. - Tests: 15 new (payload, routing, dispatch forwarding, dynamic schema, capabilities); updated 2 change-detector/lambda tests for the new schema. - Docs: image-generation feature page, image-gen provider plugin guide, tools reference. * fix(image-gen): preserve legacy passthrough in fal/krea plugin tests Two existing plugin tests asserted pre-image-to-image behavior: - fal: forward image_url/reference_image_urls only when supplied, so a text-to-image delegation stays byte-identical (no None kwargs). - krea: keep dict-shaped image_style_references refs verbatim (the unified string refs go through normalize_reference_images; legacy non-string ref objects pass through unchanged) — fixes KeyError when callers pass the richer Krea ref-object shape. * fix(image-gen): clearer not-capable message for text-to-image-only models When a text-to-image-only model (incl. gpt-image-2 on the Codex OAuth path, which can't do editing through the Responses image_generation tool) gets a source image, say 'this model is not capable of image-to-image / editing — provide a text-only prompt' rather than sending the user shopping for other backends. Applies to the openai-codex guard, the in-tree FAL no-edit-endpoint error, and the dynamic tool-schema text-only line. --- agent/image_gen_provider.py | 79 +++- plugins/image_gen/fal/__init__.py | 41 +- plugins/image_gen/krea/__init__.py | 69 ++- plugins/image_gen/openai-codex/__init__.py | 28 +- plugins/image_gen/openai/__init__.py | 146 +++++- plugins/image_gen/xai/__init__.py | 104 ++++- tests/tools/test_image_generation.py | 13 +- .../tools/test_image_generation_artifacts.py | 2 +- .../test_image_generation_image_to_image.py | 349 ++++++++++++++ tools/image_generation_tool.py | 426 ++++++++++++++++-- .../image-gen-provider-plugin.md | 38 +- website/docs/reference/tools-reference.md | 2 +- .../user-guide/features/image-generation.md | 48 +- 13 files changed, 1239 insertions(+), 106 deletions(-) create mode 100644 tests/tools/test_image_generation_image_to_image.py diff --git a/agent/image_gen_provider.py b/agent/image_gen_provider.py index a7f1b8c31ff..a3eeb1e4c8c 100644 --- a/agent/image_gen_provider.py +++ b/agent/image_gen_provider.py @@ -11,6 +11,18 @@ Providers live in ``<repo>/plugins/image_gen/<name>/`` (built-in, auto-loaded as ``kind: backend``) or ``~/.hermes/plugins/image_gen/<name>/`` (user, opt-in via ``plugins.enabled``). +Unified surface +--------------- +One tool — ``image_generate`` — covers **text-to-image** and +**image-to-image / image editing**. The router is the presence of +``image_url`` (and/or ``reference_image_urls``): if any source image is +provided, the provider routes to its image-to-image / edit endpoint; if +omitted, the provider routes to text-to-image. Users pick one **model** +(e.g. nano-banana-pro, gpt-image-2, grok-imagine-image); the provider +handles which underlying endpoint to hit. This mirrors the ``video_gen`` +provider design (``agent/video_gen_provider.py``) so the two surfaces +stay learnable together. + Response shape -------------- All providers return a dict that :func:`success_response` / :func:`error_response` @@ -21,6 +33,7 @@ produce. The tool wrapper JSON-serializes it. Keys: model str provider-specific model identifier prompt str echoed prompt aspect_ratio str "landscape" | "square" | "portrait" + modality str "text" | "image" (which mode was used) provider str provider name (for diagnostics) error str only when success=False error_type str only when success=False @@ -127,19 +140,51 @@ class ImageGenProvider(abc.ABC): return models[0].get("id") return None + def capabilities(self) -> Dict[str, Any]: + """Return what this provider supports. + + Returned dict (all keys optional):: + + { + "modalities": ["text", "image"], # which inputs the backend accepts + "max_reference_images": 9, # cap for reference_image_urls + } + + ``modalities`` declares whether the active backend/model supports + text-to-image (``"text"``), image-to-image / editing (``"image"``), + or both. The tool layer surfaces this in the dynamic schema so the + model knows when ``image_url`` is honored. Used by ``hermes tools`` + for the picker too. Default: text-only (backward compatible — a + provider that doesn't override this advertises text-to-image only). + """ + return { + "modalities": ["text"], + "max_reference_images": 0, + } + @abc.abstractmethod def generate( self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, + *, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: - """Generate an image. + """Generate an image from a text prompt, or edit/transform a source image. + + Routing: if ``image_url`` (or any ``reference_image_urls``) is + provided, the provider should route to its image-to-image / edit + endpoint; otherwise text-to-image. ``image_url`` is the primary + source image to edit; ``reference_image_urls`` are additional + style/composition references (provider clamps to its declared + ``max_reference_images``). Implementations should return the dict from :func:`success_response` or :func:`error_response`. ``kwargs`` may contain forward-compat - parameters future versions of the schema will expose — implementations - should ignore unknown keys. + parameters future versions of the schema will expose — + implementations MUST ignore unknown keys (no TypeError). """ @@ -162,6 +207,26 @@ def resolve_aspect_ratio(value: Optional[str]) -> str: return DEFAULT_ASPECT_RATIO +def normalize_reference_images(value: Any) -> Optional[List[str]]: + """Coerce a reference-image argument into a clean list of URL/path strings. + + Accepts a single string or a list; strips blanks and whitespace. Returns + ``None`` when nothing usable remains so providers can treat "no refs" as a + single sentinel. + """ + if value is None: + return None + if isinstance(value, str): + value = [value] + if not isinstance(value, (list, tuple)): + return None + out: List[str] = [] + for item in value: + if isinstance(item, str) and item.strip(): + out.append(item.strip()) + return out or None + + def _images_cache_dir() -> Path: """Return ``$HERMES_HOME/cache/images/``, creating parents as needed.""" from hermes_constants import get_hermes_home @@ -280,13 +345,16 @@ def success_response( prompt: str, aspect_ratio: str, provider: str, + modality: str = "text", extra: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Build a uniform success response dict. ``image`` may be an HTTP URL or an absolute filesystem path (for b64 - providers like OpenAI). Callers that need to pass through additional - backend-specific fields can supply ``extra``. + providers like OpenAI). ``modality`` is ``"text"`` (text-to-image) or + ``"image"`` (image-to-image / editing) — indicates which endpoint was + actually hit, useful for diagnostics. Callers that need to pass through + additional backend-specific fields can supply ``extra``. """ payload: Dict[str, Any] = { "success": True, @@ -294,6 +362,7 @@ def success_response( "model": model, "prompt": prompt, "aspect_ratio": aspect_ratio, + "modality": modality, "provider": provider, } if extra: diff --git a/plugins/image_gen/fal/__init__.py b/plugins/image_gen/fal/__init__.py index 21b88f37f34..3e7777c7149 100644 --- a/plugins/image_gen/fal/__init__.py +++ b/plugins/image_gen/fal/__init__.py @@ -87,7 +87,7 @@ class FalImageGenProvider(ImageGenProvider): return { "name": "FAL.ai", "badge": "paid", - "tag": "Pick from flux-2-klein, flux-2-pro, gpt-image, nano-banana, etc.", + "tag": "Pick from flux-2-klein, flux-2-pro, gpt-image, nano-banana, etc. — text-to-image & image editing", "env_vars": [ { "key": "FAL_KEY", @@ -97,18 +97,40 @@ class FalImageGenProvider(ImageGenProvider): ], } + def capabilities(self) -> Dict[str, Any]: + # Whether image-to-image is available depends on the currently- + # selected FAL model (each model entry declares an edit_endpoint or + # not). Report the active model's actual surface so the dynamic tool + # schema is accurate. + import tools.image_generation_tool as _it + + try: + _model_id, meta = _it._resolve_fal_model() + except Exception: # noqa: BLE001 + return {"modalities": ["text"], "max_reference_images": 0} + if meta.get("edit_endpoint"): + return { + "modalities": ["text", "image"], + "max_reference_images": int(meta.get("max_reference_images") or 1), + } + return {"modalities": ["text"], "max_reference_images": 0} + def generate( self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, + *, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: - """Generate an image via the legacy FAL pipeline. + """Generate or edit an image via the legacy FAL pipeline. - Forwards prompt + aspect_ratio (and any forward-compat extras - the schema supports) into :func:`tools.image_generation_tool.image_generate_tool`, - then reshapes its JSON-string response into the provider-ABC - dict format consumed by ``_dispatch_to_plugin_provider``. + Forwards prompt + aspect_ratio + image_url/reference_image_urls (and + any forward-compat extras the schema supports) into + :func:`tools.image_generation_tool.image_generate_tool`, then reshapes + its JSON-string response into the provider-ABC dict format consumed by + ``_dispatch_to_plugin_provider``. """ import tools.image_generation_tool as _it @@ -124,6 +146,13 @@ class FalImageGenProvider(ImageGenProvider): ) if key in kwargs and kwargs[key] is not None } + # Only forward the image-to-image inputs when actually supplied, so a + # plain text-to-image call delegates exactly as it did before (no + # noisy None kwargs). + if image_url is not None: + passthrough["image_url"] = image_url + if reference_image_urls is not None: + passthrough["reference_image_urls"] = reference_image_urls try: raw = _it.image_generate_tool( diff --git a/plugins/image_gen/krea/__init__.py b/plugins/image_gen/krea/__init__.py index 552f2ae71fe..a897302175b 100644 --- a/plugins/image_gen/krea/__init__.py +++ b/plugins/image_gen/krea/__init__.py @@ -33,6 +33,7 @@ from agent.image_gen_provider import ( DEFAULT_ASPECT_RATIO, ImageGenProvider, error_response, + normalize_reference_images, resolve_aspect_ratio, save_url_image, success_response, @@ -191,7 +192,7 @@ class KreaImageGenProvider(ImageGenProvider): return { "name": "Krea", "badge": "paid", - "tag": "Krea 2 foundation model — Medium ($0.03) + Large ($0.06). Strong style transfer + moodboards.", + "tag": "Krea 2 foundation model — Medium ($0.03) + Large ($0.06). Style transfer, moodboards, reference-guided generation.", "env_vars": [ { "key": "KREA_API_KEY", @@ -201,6 +202,11 @@ class KreaImageGenProvider(ImageGenProvider): ], } + def capabilities(self) -> Dict[str, Any]: + # Krea supports reference-guided generation (image-to-image style + # transfer) via image_style_references — up to 10 refs. + return {"modalities": ["text", "image"], "max_reference_images": 10} + # ------------------------------------------------------------------ # generate() # ------------------------------------------------------------------ @@ -209,12 +215,48 @@ class KreaImageGenProvider(ImageGenProvider): self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, + *, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: prompt = (prompt or "").strip() aspect = resolve_aspect_ratio(aspect_ratio) krea_ar = _ASPECT_MAP.get(aspect, "1:1") + # Collect reference images for reference-guided generation (image-to- + # image style transfer). Sources, in order: + # 1. unified image_url (primary source) + reference_image_urls (strings) + # 2. legacy image_style_references kwarg — may be plain URL strings OR + # Krea's richer ref objects (e.g. {"url": ..., "strength": ...}), + # which are passed through verbatim for backward compatibility. + style_refs: List[Any] = [] + if isinstance(image_url, str) and image_url.strip(): + style_refs.append(image_url.strip()) + for ref in (normalize_reference_images(reference_image_urls) or []): + style_refs.append(ref) + legacy_refs = kwargs.get("image_style_references") + if isinstance(legacy_refs, list): + for ref in legacy_refs: + if isinstance(ref, str): + if ref.strip(): + style_refs.append(ref.strip()) + elif ref: + # Non-string ref object (dict, etc.) — pass through as-is. + style_refs.append(ref) + # Dedupe string entries while preserving order (dict refs aren't + # hashable, so they're kept verbatim); Krea caps at 10. + seen: set = set() + deduped: List[Any] = [] + for r in style_refs: + if isinstance(r, str): + if r in seen: + continue + seen.add(r) + deduped.append(r) + style_refs = deduped[:10] + modality = "image" if style_refs else "text" + if not prompt: return error_response( error="Prompt is required and must be a non-empty string", @@ -256,10 +298,10 @@ class KreaImageGenProvider(ImageGenProvider): if isinstance(styles, list) and styles: payload["styles"] = styles - image_style_references = kwargs.get("image_style_references") - if isinstance(image_style_references, list) and image_style_references: - # Krea caps at 10 refs per request. - payload["image_style_references"] = image_style_references[:10] + if style_refs: + # Reference-guided generation (image-to-image style transfer). + # Krea caps at 10 refs per request (already clamped above). + payload["image_style_references"] = style_refs moodboards = kwargs.get("moodboards") if isinstance(moodboards, list) and moodboards: @@ -483,19 +525,19 @@ class KreaImageGenProvider(ImageGenProvider): # Per Krea's job-lifecycle docs the completed payload exposes # ``result.urls`` (an array). Fall back to a single ``url`` field # for forward/backward compatibility. - image_url: Optional[str] = None + result_image_url: Optional[str] = None urls = result.get("urls") if isinstance(urls, list) and urls: for candidate in urls: if isinstance(candidate, str) and candidate.strip(): - image_url = candidate.strip() + result_image_url = candidate.strip() break - if image_url is None: + if result_image_url is None: single = result.get("url") if isinstance(single, str) and single.strip(): - image_url = single.strip() + result_image_url = single.strip() - if image_url is None: + if result_image_url is None: return error_response( error="Krea result contained no image URL", error_type="empty_response", @@ -508,14 +550,14 @@ class KreaImageGenProvider(ImageGenProvider): # Materialise locally — Krea result URLs may expire, mirroring # what we do for xAI / OpenAI URL responses (#26942). try: - saved_path = save_url_image(image_url, prefix=f"krea_{model_id}") + saved_path = save_url_image(result_image_url, prefix=f"krea_{model_id}") except Exception as exc: # noqa: BLE001 logger.warning( "Krea image URL %s could not be cached (%s); falling back to bare URL.", - image_url, + result_image_url, exc, ) - image_ref = image_url + image_ref = result_image_url else: image_ref = str(saved_path) @@ -534,6 +576,7 @@ class KreaImageGenProvider(ImageGenProvider): prompt=prompt, aspect_ratio=aspect, provider="krea", + modality=modality, extra=extra, ) diff --git a/plugins/image_gen/openai-codex/__init__.py b/plugins/image_gen/openai-codex/__init__.py index 6fde2d60bbb..0bd61267db1 100644 --- a/plugins/image_gen/openai-codex/__init__.py +++ b/plugins/image_gen/openai-codex/__init__.py @@ -319,7 +319,7 @@ class OpenAICodexImageGenProvider(ImageGenProvider): return { "name": "OpenAI (Codex auth)", "badge": "free", - "tag": "gpt-image-2 via ChatGPT/Codex OAuth — no API key required", + "tag": "gpt-image-2 via ChatGPT/Codex OAuth — no API key required (text-to-image only)", "env_vars": [], "post_setup_hint": ( "Sign in with `hermes auth codex` (or `hermes setup` → Codex) " @@ -327,15 +327,41 @@ class OpenAICodexImageGenProvider(ImageGenProvider): ), } + def capabilities(self) -> Dict[str, Any]: + # The Codex Responses image_generation tool path is text-to-image + # only here. Image-to-image / editing via Codex OAuth is not wired — + # users who need editing should use the `openai` (API key), `fal`, or + # `xai` backends. Declaring text-only keeps the dynamic tool schema + # honest so the model doesn't attempt an unsupported edit. + return {"modalities": ["text"], "max_reference_images": 0} + def generate( self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, + *, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: prompt = (prompt or "").strip() aspect = resolve_aspect_ratio(aspect_ratio) + # Image-to-image / editing is not supported on the Codex OAuth path. + # Surface a clear, actionable error instead of silently ignoring the + # source image and producing an unrelated picture. + if (isinstance(image_url, str) and image_url.strip()) or reference_image_urls: + return error_response( + error=( + "This model is not capable of image-to-image / editing. " + "Please provide a text-only prompt (drop image_url and " + "reference_image_urls)." + ), + error_type="modality_unsupported", + provider="openai-codex", + aspect_ratio=aspect, + ) + if not prompt: return error_response( error="Prompt is required and must be a non-empty string", diff --git a/plugins/image_gen/openai/__init__.py b/plugins/image_gen/openai/__init__.py index 448f5bc45af..e214271bcd9 100644 --- a/plugins/image_gen/openai/__init__.py +++ b/plugins/image_gen/openai/__init__.py @@ -31,6 +31,7 @@ from agent.image_gen_provider import ( DEFAULT_ASPECT_RATIO, ImageGenProvider, error_response, + normalize_reference_images, resolve_aspect_ratio, save_b64_image, save_url_image, @@ -117,13 +118,48 @@ def _resolve_model() -> Tuple[str, Dict[str, Any]]: return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL] +# --------------------------------------------------------------------------- +# Source-image loading (for image-to-image / edit) +# --------------------------------------------------------------------------- + + +def _load_image_bytes(ref: str) -> Tuple[bytes, str]: + """Load image bytes from a URL or local file path. + + Returns ``(data, filename)``. Raises on any network / IO error so the + caller can surface a clean error_response. + """ + ref = ref.strip() + lower = ref.lower() + if lower.startswith(("http://", "https://")): + import requests + + resp = requests.get(ref, timeout=60) + resp.raise_for_status() + name = ref.split("?", 1)[0].rsplit("/", 1)[-1] or "image.png" + return resp.content, name + if lower.startswith("data:"): + import base64 + + header, _, b64 = ref.partition(",") + ext = "png" + if "image/" in header: + ext = header.split("image/", 1)[1].split(";", 1)[0] or "png" + return base64.b64decode(b64), f"image.{ext}" + # Local file path. + with open(ref, "rb") as fh: + data = fh.read() + name = os.path.basename(ref) or "image.png" + return data, name + + # --------------------------------------------------------------------------- # Provider # --------------------------------------------------------------------------- class OpenAIImageGenProvider(ImageGenProvider): - """OpenAI ``images.generate`` backend — gpt-image-2 at low/medium/high.""" + """OpenAI ``images.generate`` / ``images.edit`` backend — gpt-image-2.""" @property def name(self) -> str: @@ -161,7 +197,7 @@ class OpenAIImageGenProvider(ImageGenProvider): return { "name": "OpenAI", "badge": "paid", - "tag": "gpt-image-2 at low/medium/high quality tiers", + "tag": "gpt-image-2 at low/medium/high quality tiers — text-to-image & image editing", "env_vars": [ { "key": "OPENAI_API_KEY", @@ -171,10 +207,18 @@ class OpenAIImageGenProvider(ImageGenProvider): ], } + def capabilities(self) -> Dict[str, Any]: + # gpt-image-2 supports editing via images.edit() with up to 16 source + # images. + return {"modalities": ["text", "image"], "max_reference_images": 16} + def generate( self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, + *, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: prompt = (prompt or "").strip() @@ -213,29 +257,82 @@ class OpenAIImageGenProvider(ImageGenProvider): tier_id, meta = _resolve_model() size = _SIZES.get(aspect, _SIZES["square"]) - # gpt-image-2 returns b64_json unconditionally and REJECTS - # ``response_format`` as an unknown parameter. Don't send it. - payload: Dict[str, Any] = { - "model": API_MODEL, - "prompt": prompt, - "size": size, - "n": 1, - "quality": meta["quality"], - } + # Collect source images (primary + references) for image-to-image. + sources: List[str] = [] + if isinstance(image_url, str) and image_url.strip(): + sources.append(image_url.strip()) + for ref in (normalize_reference_images(reference_image_urls) or []): + sources.append(ref) + sources = sources[:16] # gpt-image-2 edit caps at 16 images + is_edit = bool(sources) + modality = "image" if is_edit else "text" - try: - client = openai.OpenAI() - response = client.images.generate(**payload) - except Exception as exc: - logger.debug("OpenAI image generation failed", exc_info=True) - return error_response( - error=f"OpenAI image generation failed: {exc}", - error_type="api_error", - provider="openai", - model=tier_id, - prompt=prompt, - aspect_ratio=aspect, - ) + client = openai.OpenAI() + + if is_edit: + # images.edit() expects file-like objects. Download/read each + # source into a named BytesIO so the SDK sends correct multipart. + import io + + try: + files = [] + for ref in sources: + data, fname = _load_image_bytes(ref) + bio = io.BytesIO(data) + bio.name = fname + files.append(bio) + except Exception as exc: + return error_response( + error=f"Could not load source image for editing: {exc}", + error_type="io_error", + provider="openai", + model=tier_id, + prompt=prompt, + aspect_ratio=aspect, + ) + + try: + response = client.images.edit( + model=API_MODEL, + image=files if len(files) > 1 else files[0], + prompt=prompt, + size=size, # type: ignore[arg-type] # _SIZES values are valid gpt-image sizes + quality=meta["quality"], + n=1, + ) + except Exception as exc: + logger.debug("OpenAI image edit failed", exc_info=True) + return error_response( + error=f"OpenAI image editing failed: {exc}", + error_type="api_error", + provider="openai", + model=tier_id, + prompt=prompt, + aspect_ratio=aspect, + ) + else: + # gpt-image-2 returns b64_json unconditionally and REJECTS + # ``response_format`` as an unknown parameter. Don't send it. + payload: Dict[str, Any] = { + "model": API_MODEL, + "prompt": prompt, + "size": size, + "n": 1, + "quality": meta["quality"], + } + + try: + response = client.images.generate(**payload) + except Exception as exc: + logger.debug("OpenAI image generation failed", exc_info=True) + return error_response( + error=f"OpenAI image generation failed: {exc}", + error_type="api_error", + provider="openai", + model=tier_id, + prompt=prompt, + aspect_ratio=aspect, + ) data = getattr(response, "data", None) or [] if not data: @@ -302,6 +399,7 @@ class OpenAIImageGenProvider(ImageGenProvider): prompt=prompt, aspect_ratio=aspect, provider="openai", + modality=modality, extra=extra, ) diff --git a/plugins/image_gen/xai/__init__.py b/plugins/image_gen/xai/__init__.py index a8982393f7e..f487d90ada6 100644 --- a/plugins/image_gen/xai/__init__.py +++ b/plugins/image_gen/xai/__init__.py @@ -27,6 +27,7 @@ from agent.image_gen_provider import ( DEFAULT_ASPECT_RATIO, ImageGenProvider, error_response, + normalize_reference_images, resolve_aspect_ratio, save_b64_image, save_url_image, @@ -114,6 +115,31 @@ def _resolve_resolution() -> str: return DEFAULT_RESOLUTION +def _xai_image_field(source: str) -> Dict[str, str]: + """Build the xAI ``image`` field for an edit request. + + xAI's ``/v1/images/edits`` accepts ``{"url": <ref>, "type": "image_url"}`` + where ``<ref>`` is a public URL or a base64 data URI. Public URLs and + existing data URIs pass through unchanged; local file paths are read and + encoded into a ``data:`` URI. + """ + source = source.strip() + lower = source.lower() + if lower.startswith(("http://", "https://", "data:")): + return {"url": source, "type": "image_url"} + # Local file path → base64 data URI. + import base64 + import os as _os + + with open(source, "rb") as fh: + raw = fh.read() + ext = (_os.path.splitext(source)[1].lstrip(".") or "png").lower() + if ext == "jpg": + ext = "jpeg" + b64 = base64.b64encode(raw).decode("utf-8") + return {"url": f"data:image/{ext};base64,{b64}", "type": "image_url"} + + # --------------------------------------------------------------------------- # Provider # --------------------------------------------------------------------------- @@ -153,18 +179,34 @@ class XAIImageGenProvider(ImageGenProvider): return { "name": "xAI Grok Imagine (image)", "badge": "paid", - "tag": "grok-imagine-image — text-to-image; uses xAI Grok OAuth or XAI_API_KEY", + "tag": "grok-imagine-image — text-to-image & image editing; uses xAI Grok OAuth or XAI_API_KEY", "env_vars": [], "post_setup": "xai_grok", } + def capabilities(self) -> Dict[str, Any]: + # xAI's /v1/images/edits supports image editing via grok-imagine-image + # -quality. Single primary source image (multi-image editing exists as + # a separate capability but we keep the primary edit surface here). + return {"modalities": ["text", "image"], "max_reference_images": 1} + def generate( self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, + *, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: - """Generate an image using xAI's grok-imagine-image.""" + """Generate an image (text-to-image) or edit a source image (image-to-image). + + Routing: when ``image_url`` is provided, POST to ``/v1/images/edits`` + with the source image; otherwise POST to ``/v1/images/generations``. + Per xAI docs, editing uses the ``grok-imagine-image-quality`` model and + a JSON body (the OpenAI SDK's multipart ``images.edit()`` is NOT + supported by xAI). + """ creds = resolve_xai_http_credentials() api_key = str(creds.get("api_key") or "").strip() provider_name = str(creds.get("provider") or "xai").strip() or "xai" @@ -182,12 +224,17 @@ class XAIImageGenProvider(ImageGenProvider): resolution = _resolve_resolution() xai_res = resolution if resolution in _XAI_RESOLUTIONS else DEFAULT_RESOLUTION - payload: Dict[str, Any] = { - "model": model_id, - "prompt": prompt, - "aspect_ratio": xai_ar, - "resolution": xai_res, - } + # Pick the primary source image: explicit image_url wins, else the + # first reference image. + source_image = None + if isinstance(image_url, str) and image_url.strip(): + source_image = image_url.strip() + else: + refs = normalize_reference_images(reference_image_urls) + if refs: + source_image = refs[0] + is_edit = bool(source_image) + modality = "image" if is_edit else "text" headers = { "Authorization": f"Bearer {api_key}", @@ -197,9 +244,41 @@ class XAIImageGenProvider(ImageGenProvider): base_url = str(creds.get("base_url") or "https://api.x.ai/v1").strip().rstrip("/") + if is_edit: + # Editing requires the quality model per xAI docs. The source + # image may be a public URL or a base64 data URI; local file paths + # are converted to a data URI here. + edit_model = "grok-imagine-image-quality" + try: + image_field = _xai_image_field(source_image) + except Exception as exc: + return error_response( + error=f"Could not load source image for editing: {exc}", + error_type="io_error", + provider=provider_name, + model=edit_model, + prompt=prompt, + aspect_ratio=aspect, + ) + payload: Dict[str, Any] = { + "model": edit_model, + "prompt": prompt, + "image": image_field, + } + endpoint_url = f"{base_url}/images/edits" + model_id = edit_model + else: + payload = { + "model": model_id, + "prompt": prompt, + "aspect_ratio": xai_ar, + "resolution": xai_res, + } + endpoint_url = f"{base_url}/images/generations" + try: response = requests.post( - f"{base_url}/images/generations", + endpoint_url, headers=headers, json=payload, timeout=120, @@ -310,9 +389,9 @@ class XAIImageGenProvider(ImageGenProvider): aspect_ratio=aspect, ) - extra: Dict[str, Any] = { - "resolution": xai_res, - } + extra: Dict[str, Any] = {} + if not is_edit: + extra["resolution"] = xai_res return success_response( image=image_ref, @@ -320,6 +399,7 @@ class XAIImageGenProvider(ImageGenProvider): prompt=prompt, aspect_ratio=aspect, provider="xai", + modality=modality, extra=extra, ) diff --git a/tests/tools/test_image_generation.py b/tests/tools/test_image_generation.py index b24e6bc1fcc..df7d3a34abb 100644 --- a/tests/tools/test_image_generation.py +++ b/tests/tools/test_image_generation.py @@ -363,11 +363,16 @@ class TestAspectRatioNormalization: class TestRegistryIntegration: - def test_schema_exposes_only_prompt_and_aspect_ratio_to_agent(self, image_tool): - """The agent-facing schema must stay tight — model selection is a - user-level config choice, not an agent-level arg.""" + def test_schema_exposes_expected_agent_params(self, image_tool): + """The agent-facing schema exposes the unified text+image surface: + prompt (required), aspect_ratio, and the image-to-image inputs + image_url + reference_image_urls. Model selection stays a user-level + config choice, never an agent-level arg.""" props = image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["properties"] - assert set(props.keys()) == {"prompt", "aspect_ratio"} + assert set(props.keys()) == { + "prompt", "aspect_ratio", "image_url", "reference_image_urls", + } + assert image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["required"] == ["prompt"] def test_aspect_ratio_enum_is_three_values(self, image_tool): enum = image_tool.IMAGE_GENERATE_SCHEMA["parameters"]["properties"]["aspect_ratio"]["enum"] diff --git a/tests/tools/test_image_generation_artifacts.py b/tests/tools/test_image_generation_artifacts.py index 2a1ce111353..ea4fd37d01c 100644 --- a/tests/tools/test_image_generation_artifacts.py +++ b/tests/tools/test_image_generation_artifacts.py @@ -110,7 +110,7 @@ def test_handle_image_generate_postprocesses_plugin_result(monkeypatch, tmp_path monkeypatch.setattr( image_generation_tool, "_dispatch_to_plugin_provider", - lambda prompt, aspect_ratio: json.dumps({"success": True, "image": str(image_path)}), + lambda prompt, aspect_ratio, **kw: json.dumps({"success": True, "image": str(image_path)}), ) result = json.loads( diff --git a/tests/tools/test_image_generation_image_to_image.py b/tests/tools/test_image_generation_image_to_image.py new file mode 100644 index 00000000000..4e9d457a49f --- /dev/null +++ b/tests/tools/test_image_generation_image_to_image.py @@ -0,0 +1,349 @@ +"""Tests for the image-to-image / editing surface of ``image_generate``. + +Mirrors the video-gen image-to-video tests: the unified ``image_generate`` +tool routes to a provider's edit endpoint when ``image_url`` / +``reference_image_urls`` is supplied, otherwise to text-to-image. Coverage: + +- In-tree FAL edit payload construction (``_build_fal_edit_payload``) +- In-tree FAL routing (text vs edit endpoint) via ``image_generate_tool`` +- Plugin dispatch forwards image_url / reference_image_urls to ``generate()`` +- ``capabilities()`` honesty drives the dynamic tool-schema description +- Models without an edit endpoint reject image inputs with a clear error +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +import pytest +import yaml + +from agent import image_gen_registry +from agent.image_gen_provider import ImageGenProvider + + +@pytest.fixture(autouse=True) +def _reset_registry(): + image_gen_registry._reset_for_tests() + yield + image_gen_registry._reset_for_tests() + + +@pytest.fixture +def cfg_home(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + return tmp_path + + +def _write_cfg(home, cfg: dict): + (home / "config.yaml").write_text(yaml.safe_dump(cfg)) + + +# --------------------------------------------------------------------------- +# In-tree FAL edit payload + routing +# --------------------------------------------------------------------------- + + +class TestFalEditPayload: + def test_edit_payload_includes_image_urls(self): + from tools.image_generation_tool import _build_fal_edit_payload + + payload = _build_fal_edit_payload( + "fal-ai/nano-banana-pro", "make it night", ["https://x/y.png"], + "landscape", + ) + assert payload["prompt"] == "make it night" + assert payload["image_urls"] == ["https://x/y.png"] + # nano-banana edit advertises aspect_ratio in edit_supports + assert payload.get("aspect_ratio") == "16:9" + + def test_edit_payload_strips_keys_outside_edit_supports(self): + from tools.image_generation_tool import _build_fal_edit_payload + + # gpt-image-2 edit does NOT advertise image_size (auto-inferred), so + # it must be stripped even though the text-to-image path sets it. + payload = _build_fal_edit_payload( + "fal-ai/gpt-image-2", "swap bg", ["https://x/y.png"], "square", + ) + assert "image_size" not in payload + assert payload["image_urls"] == ["https://x/y.png"] + assert payload["quality"] == "medium" + + def test_text_only_model_has_no_edit_endpoint(self): + from tools.image_generation_tool import FAL_MODELS + + # z-image/turbo is a pure text-to-image model — no edit endpoint. + assert "edit_endpoint" not in FAL_MODELS["fal-ai/z-image/turbo"] + # while nano-banana-pro is edit-capable + assert FAL_MODELS["fal-ai/nano-banana-pro"].get("edit_endpoint") + + +class TestFalRouting: + def _patch_submit(self, monkeypatch, image_tool, capture: dict): + class _Handler: + def get(self_inner): + return {"images": [{"url": "https://out/img.png", "width": 1, "height": 1}]} + + def fake_submit(endpoint, arguments): + capture["endpoint"] = endpoint + capture["arguments"] = arguments + return _Handler() + + monkeypatch.setattr(image_tool, "_submit_fal_request", fake_submit) + monkeypatch.setattr(image_tool, "fal_key_is_configured", lambda: True) + monkeypatch.setattr(image_tool, "_resolve_managed_fal_gateway", lambda: None) + + def test_text_to_image_uses_base_endpoint(self, cfg_home, monkeypatch): + import tools.image_generation_tool as image_tool + + _write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/nano-banana-pro"}}) + capture: dict = {} + self._patch_submit(monkeypatch, image_tool, capture) + + raw = image_tool.image_generate_tool(prompt="a cat", aspect_ratio="square") + out = json.loads(raw) + assert out["success"] is True + assert out["modality"] == "text" + assert capture["endpoint"] == "fal-ai/nano-banana-pro" + assert "image_urls" not in capture["arguments"] + + def test_image_to_image_routes_to_edit_endpoint(self, cfg_home, monkeypatch): + import tools.image_generation_tool as image_tool + + _write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/nano-banana-pro"}}) + capture: dict = {} + self._patch_submit(monkeypatch, image_tool, capture) + + raw = image_tool.image_generate_tool( + prompt="make it night", + aspect_ratio="square", + image_url="https://in/src.png", + ) + out = json.loads(raw) + assert out["success"] is True + assert out["modality"] == "image" + assert capture["endpoint"] == "fal-ai/nano-banana-pro/edit" + assert capture["arguments"]["image_urls"] == ["https://in/src.png"] + + def test_reference_images_clamped_to_model_cap(self, cfg_home, monkeypatch): + import tools.image_generation_tool as image_tool + + # nano-banana-pro caps at 2 reference images. + _write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/nano-banana-pro"}}) + capture: dict = {} + self._patch_submit(monkeypatch, image_tool, capture) + + raw = image_tool.image_generate_tool( + prompt="blend", + image_url="https://in/a.png", + reference_image_urls=["https://in/b.png", "https://in/c.png", "https://in/d.png"], + ) + out = json.loads(raw) + assert out["success"] is True + assert capture["arguments"]["image_urls"] == ["https://in/a.png", "https://in/b.png"] + + def test_text_only_model_rejects_image_url(self, cfg_home, monkeypatch): + import tools.image_generation_tool as image_tool + + _write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/z-image/turbo"}}) + capture: dict = {} + self._patch_submit(monkeypatch, image_tool, capture) + + raw = image_tool.image_generate_tool( + prompt="edit this", image_url="https://in/src.png", + ) + out = json.loads(raw) + assert out["success"] is False + assert "image-to-image" in out["error"] + # Must NOT have submitted anything. + assert capture == {} + + def test_edit_skips_upscaler(self, cfg_home, monkeypatch): + import tools.image_generation_tool as image_tool + + # flux-2-pro has upscale=True for text-to-image, but edits must skip it. + _write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/flux-2-pro"}}) + capture: dict = {} + self._patch_submit(monkeypatch, image_tool, capture) + upscale_called = {"hit": False} + monkeypatch.setattr( + image_tool, "_upscale_image", + lambda *a, **k: upscale_called.__setitem__("hit", True) or None, + ) + + raw = image_tool.image_generate_tool( + prompt="tweak", image_url="https://in/src.png", + ) + out = json.loads(raw) + assert out["success"] is True + assert out["modality"] == "image" + assert upscale_called["hit"] is False + + +# --------------------------------------------------------------------------- +# Plugin dispatch forwarding +# --------------------------------------------------------------------------- + + +class _EditCapableProvider(ImageGenProvider): + def __init__(self): + self.received: Dict[str, Any] = {} + + @property + def name(self) -> str: + return "editcap" + + def capabilities(self) -> Dict[str, Any]: + return {"modalities": ["text", "image"], "max_reference_images": 4} + + def generate(self, prompt, aspect_ratio="landscape", *, image_url=None, + reference_image_urls=None, **kwargs): + self.received = { + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "image_url": image_url, + "reference_image_urls": reference_image_urls, + } + return { + "success": True, "image": "/tmp/out.png", "model": "editcap-1", + "prompt": prompt, "aspect_ratio": aspect_ratio, + "modality": "image" if image_url else "text", "provider": "editcap", + } + + +class _LegacyProvider(ImageGenProvider): + """Provider whose generate() predates image_url (no **kwargs absorb).""" + + @property + def name(self) -> str: + return "legacy" + + def generate(self, prompt, aspect_ratio="landscape"): # narrow signature + return {"success": True, "image": "/tmp/legacy.png", "provider": "legacy"} + + +class TestPluginDispatchImageToImage: + def test_dispatch_forwards_image_url(self, cfg_home, monkeypatch): + import tools.image_generation_tool as image_tool + from hermes_cli import plugins as plugins_module + from agent import image_gen_registry as reg + + provider = _EditCapableProvider() + reg.register_provider(provider) + monkeypatch.setattr(image_tool, "_read_configured_image_provider", lambda: "editcap") + monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda *a, **k: None) + monkeypatch.setattr(reg, "get_provider", lambda n: provider if n == "editcap" else None) + + raw = image_tool._dispatch_to_plugin_provider( + "make night", "square", + image_url="https://in/src.png", + reference_image_urls=["https://in/ref.png"], + ) + out = json.loads(raw) + assert out["success"] is True + assert out["modality"] == "image" + assert provider.received["image_url"] == "https://in/src.png" + assert provider.received["reference_image_urls"] == ["https://in/ref.png"] + + def test_dispatch_text_only_when_no_image(self, cfg_home, monkeypatch): + import tools.image_generation_tool as image_tool + from hermes_cli import plugins as plugins_module + from agent import image_gen_registry as reg + + provider = _EditCapableProvider() + reg.register_provider(provider) + monkeypatch.setattr(image_tool, "_read_configured_image_provider", lambda: "editcap") + monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda *a, **k: None) + monkeypatch.setattr(reg, "get_provider", lambda n: provider if n == "editcap" else None) + + raw = image_tool._dispatch_to_plugin_provider("a dog", "landscape") + out = json.loads(raw) + assert out["success"] is True + assert provider.received["image_url"] is None + assert "reference_image_urls" not in provider.received or provider.received["reference_image_urls"] is None + + def test_legacy_provider_edit_request_surfaces_clear_error(self, cfg_home, monkeypatch): + import tools.image_generation_tool as image_tool + from hermes_cli import plugins as plugins_module + from agent import image_gen_registry as reg + + provider = _LegacyProvider() + reg.register_provider(provider) + monkeypatch.setattr(image_tool, "_read_configured_image_provider", lambda: "legacy") + monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda *a, **k: None) + monkeypatch.setattr(reg, "get_provider", lambda n: provider if n == "legacy" else None) + + raw = image_tool._dispatch_to_plugin_provider( + "edit it", "square", image_url="https://in/src.png", + ) + out = json.loads(raw) + assert out["success"] is False + assert out["error_type"] == "modality_unsupported" + + +# --------------------------------------------------------------------------- +# Dynamic schema reflects active capabilities +# --------------------------------------------------------------------------- + + +class _PluginBothProvider(ImageGenProvider): + @property + def name(self) -> str: + return "both" + + def is_available(self) -> bool: + return True + + def default_model(self) -> Optional[str]: + return "both-v1" + + def capabilities(self) -> Dict[str, Any]: + return {"modalities": ["text", "image"], "max_reference_images": 5} + + def generate(self, prompt, aspect_ratio="landscape", *, image_url=None, + reference_image_urls=None, **kwargs): + return {"success": True} + + +class TestDynamicSchema: + def _no_discovery(self, monkeypatch): + import hermes_cli.plugins as plugins_module + monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda *a, **k: None) + + def test_fal_edit_model_advertises_both(self, cfg_home, monkeypatch): + from tools.image_generation_tool import _build_dynamic_image_schema + + _write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/nano-banana-pro"}}) + desc = _build_dynamic_image_schema()["description"] + assert "text-to-image" in desc and "image-to-image" in desc + assert "routes automatically" in desc + + def test_fal_text_only_model_warns(self, cfg_home, monkeypatch): + from tools.image_generation_tool import _build_dynamic_image_schema + + _write_cfg(cfg_home, {"image_gen": {"model": "fal-ai/z-image/turbo"}}) + desc = _build_dynamic_image_schema()["description"] + assert "text-to-image only" in desc + assert "NOT capable of image-to-image" in desc + + def test_plugin_both_provider_advertises_refs(self, cfg_home, monkeypatch): + from tools.image_generation_tool import _build_dynamic_image_schema + from agent import image_gen_registry as reg + + _write_cfg(cfg_home, {"image_gen": {"provider": "both"}}) + reg.register_provider(_PluginBothProvider()) + self._no_discovery(monkeypatch) + + desc = _build_dynamic_image_schema()["description"] + assert "image-to-image / editing" in desc + assert "up to 5 reference image(s)" in desc + + def test_builder_wired_into_registry(self): + from tools.registry import discover_builtin_tools, registry + + discover_builtin_tools() + entry = registry._tools["image_generate"] + assert entry.dynamic_schema_overrides is not None + out = entry.dynamic_schema_overrides() + assert "description" in out diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index d7eeb30d175..3213068ddd9 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -116,6 +116,14 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = { "output_format", "enable_safety_checker", }, "upscale": False, + # Image-to-image / editing: FLUX.2 [klein] 9B edit endpoint takes + # `image_urls` (list). Natural-language edits, multi-ref. + "edit_endpoint": "fal-ai/flux-2/klein/9b/edit", + "edit_supports": { + "prompt", "image_urls", "num_inference_steps", "seed", + "output_format", "enable_safety_checker", + }, + "max_reference_images": 9, }, "fal-ai/flux-2-pro": { "display": "FLUX 2 Pro", @@ -143,6 +151,14 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = { "safety_tolerance", "sync_mode", "seed", }, "upscale": True, # Backward-compat: current default behavior. + # Edit endpoint accepts up to 9 reference images. + "edit_endpoint": "fal-ai/flux-2-pro/edit", + "edit_supports": { + "prompt", "image_urls", "num_inference_steps", "guidance_scale", + "num_images", "output_format", "enable_safety_checker", + "safety_tolerance", "sync_mode", "seed", + }, + "max_reference_images": 9, }, "fal-ai/z-image/turbo": { "display": "Z-Image Turbo", @@ -194,6 +210,15 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = { "enable_web_search", "limit_generations", }, "upscale": False, + # Nano Banana Pro edit (Gemini 3 Pro Image): natural-language edits + # with up to 2 reference images via `image_urls`. + "edit_endpoint": "fal-ai/nano-banana-pro/edit", + "edit_supports": { + "prompt", "image_urls", "aspect_ratio", "num_images", + "output_format", "safety_tolerance", "seed", "sync_mode", + "resolution", "enable_web_search", "limit_generations", + }, + "max_reference_images": 2, }, "fal-ai/gpt-image-1.5": { "display": "GPT Image 1.5", @@ -218,6 +243,13 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = { "background", "sync_mode", }, "upscale": False, + # Edit endpoint: high-fidelity edits preserving composition/lighting. + "edit_endpoint": "fal-ai/gpt-image-1.5/edit", + "edit_supports": { + "prompt", "image_urls", "image_size", "quality", "num_images", + "output_format", "sync_mode", + }, + "max_reference_images": 16, }, "fal-ai/gpt-image-2": { "display": "GPT Image 2", @@ -250,6 +282,15 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = { # through the shared FAL billing path. }, "upscale": False, + # GPT Image 2 edit endpoint lives under the OpenAI namespace on FAL + # (NOT fal-ai/). Takes `image_urls` (list) + optional mask. We don't + # send `image_size` on edit so the model auto-infers from input. + "edit_endpoint": "openai/gpt-image-2/edit", + "edit_supports": { + "prompt", "image_urls", "quality", "num_images", "output_format", + "sync_mode", "mask_image_url", + }, + "max_reference_images": 16, }, "fal-ai/ideogram/v3": { "display": "Ideogram V3", @@ -272,6 +313,13 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = { "style", "seed", }, "upscale": False, + # Ideogram V3 edit endpoint takes `image_urls` (list). + "edit_endpoint": "fal-ai/ideogram/v3/edit", + "edit_supports": { + "prompt", "image_urls", "rendering_speed", "expand_prompt", + "style", "seed", + }, + "max_reference_images": 1, }, "fal-ai/recraft/v4/pro/text-to-image": { "display": "Recraft V4 Pro", @@ -317,6 +365,14 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = { "num_images", "output_format", "acceleration", "seed", "sync_mode", }, "upscale": False, + # Qwen edit uses the Qwen Image 2.0 Pro editing endpoint, which takes + # `image_urls` (list) + natural-language edit instructions. + "edit_endpoint": "fal-ai/qwen-image-2/pro/edit", + "edit_supports": { + "prompt", "image_urls", "num_inference_steps", "guidance_scale", + "num_images", "output_format", "acceleration", "seed", "sync_mode", + }, + "max_reference_images": 3, }, # Krea 2 — Krea's first foundation image model, day-0 partner launch on # fal (2026-05-27). Same model family as our direct ``plugins/image_gen/krea`` @@ -554,6 +610,55 @@ def _build_fal_payload( return {k: v for k, v in payload.items() if k in supports} +def _build_fal_edit_payload( + model_id: str, + prompt: str, + image_urls: list, + aspect_ratio: str = DEFAULT_ASPECT_RATIO, + seed: Optional[int] = None, + overrides: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build a FAL *edit* request payload (image-to-image) from unified inputs. + + Every FAL edit endpoint takes ``image_urls`` (a list of source/reference + image URLs) plus the prompt. Size handling differs from text-to-image: + most edit endpoints auto-infer output dimensions from the input image, so + we only send ``image_size`` / ``aspect_ratio`` when the edit endpoint's + ``edit_supports`` whitelist accepts it. Keys outside ``edit_supports`` are + stripped before submission. + """ + meta = FAL_MODELS[model_id] + edit_supports = meta.get("edit_supports") or set() + size_style = meta["size_style"] + sizes = meta["sizes"] + + aspect = (aspect_ratio or DEFAULT_ASPECT_RATIO).lower().strip() + if aspect not in sizes: + aspect = DEFAULT_ASPECT_RATIO + + payload: Dict[str, Any] = dict(meta.get("defaults", {})) + payload["prompt"] = (prompt or "").strip() + payload["image_urls"] = list(image_urls) + + # Only express output size when the edit endpoint advertises the key. + # gpt-image-2 edit auto-infers size from the input, so `image_size` is + # intentionally absent from its edit_supports whitelist. + if size_style in {"image_size_preset", "gpt_literal"} and "image_size" in edit_supports: + payload["image_size"] = sizes[aspect] + elif size_style == "aspect_ratio" and "aspect_ratio" in edit_supports: + payload["aspect_ratio"] = sizes[aspect] + + if seed is not None and isinstance(seed, int): + payload["seed"] = seed + + if overrides: + for k, v in overrides.items(): + if v is not None: + payload[k] = v + + return {k: v for k, v in payload.items() if k in edit_supports} + + # --------------------------------------------------------------------------- # Upscaler # --------------------------------------------------------------------------- @@ -729,19 +834,39 @@ def image_generate_tool( num_images: Optional[int] = None, output_format: Optional[str] = None, seed: Optional[int] = None, + image_url: Optional[str] = None, + reference_image_urls: Optional[list] = None, ) -> str: - """Generate an image from a text prompt using the configured FAL model. + """Generate an image from a text prompt, or edit a source image, via FAL. - The agent-facing schema exposes only ``prompt`` and ``aspect_ratio``; the - remaining kwargs are overrides for direct Python callers and are filtered - per-model via the ``supports`` whitelist (unsupported overrides are - silently dropped so legacy callers don't break when switching models). + Routing: when ``image_url`` (or ``reference_image_urls``) is provided AND + the configured model declares an ``edit_endpoint``, the call routes to that + image-to-image / edit endpoint; otherwise it's plain text-to-image. + + The agent-facing schema exposes ``prompt``, ``aspect_ratio``, ``image_url`` + and ``reference_image_urls``; the remaining kwargs are overrides for direct + Python callers and are filtered per-model via the ``supports`` / + ``edit_supports`` whitelist (unsupported overrides are silently dropped so + legacy callers don't break when switching models). Returns a JSON string with ``{"success": bool, "image": url | None, - "error": str, "error_type": str}``. + "modality": "text" | "image", "error": str, "error_type": str}``. """ model_id, meta = _resolve_fal_model() + # Collect any source images (primary + references) into one ordered list. + source_images: list = [] + if isinstance(image_url, str) and image_url.strip(): + source_images.append(image_url.strip()) + if isinstance(reference_image_urls, (list, tuple)): + for ref in reference_image_urls: + if isinstance(ref, str) and ref.strip(): + source_images.append(ref.strip()) + + edit_endpoint = meta.get("edit_endpoint") + use_edit = bool(source_images) and bool(edit_endpoint) + modality = "image" if use_edit else "text" + debug_call_data = { "model": model_id, "parameters": { @@ -752,6 +877,8 @@ def image_generate_tool( "num_images": num_images, "output_format": output_format, "seed": seed, + "modality": modality, + "source_images": len(source_images), }, "error": None, "success": False, @@ -768,6 +895,17 @@ def image_generate_tool( if not (fal_key_is_configured() or _resolve_managed_fal_gateway()): raise ValueError(_build_no_backend_setup_message()) + # If the caller supplied source images but the active model has no + # edit endpoint, fail with a clear, actionable message instead of + # silently dropping the images and producing an unrelated picture. + if source_images and not edit_endpoint: + raise ValueError( + f"Model '{meta.get('display', model_id)}' ({model_id}) is not " + f"capable of image-to-image / editing. Provide a text-only " + f"prompt (omit image_url), or switch to an edit-capable model " + f"via `hermes tools` → Image Generation." + ) + aspect_lc = (aspect_ratio or DEFAULT_ASPECT_RATIO).lower().strip() if aspect_lc not in VALID_ASPECT_RATIOS: logger.warning( @@ -786,16 +924,31 @@ def image_generate_tool( if output_format is not None: overrides["output_format"] = output_format - arguments = _build_fal_payload( - model_id, prompt, aspect_lc, seed=seed, overrides=overrides, - ) + if use_edit: + # Clamp reference count to the model's declared cap. + max_refs = int(meta.get("max_reference_images") or 1) + clamped_sources = source_images[:max_refs] if max_refs > 0 else source_images + arguments = _build_fal_edit_payload( + model_id, prompt, clamped_sources, aspect_lc, + seed=seed, overrides=overrides, + ) + endpoint = edit_endpoint + logger.info( + "Editing image with %s (%s) — %d source image(s), prompt: %s", + meta.get("display", model_id), endpoint, len(clamped_sources), + prompt[:80], + ) + else: + arguments = _build_fal_payload( + model_id, prompt, aspect_lc, seed=seed, overrides=overrides, + ) + endpoint = model_id + logger.info( + "Generating image with %s (%s) — prompt: %s", + meta.get("display", model_id), model_id, prompt[:80], + ) - logger.info( - "Generating image with %s (%s) — prompt: %s", - meta.get("display", model_id), model_id, prompt[:80], - ) - - handler = _submit_fal_request(model_id, arguments=arguments) + handler = _submit_fal_request(endpoint, arguments=arguments) result = handler.get() generation_time = (datetime.datetime.now() - start_time).total_seconds() @@ -807,7 +960,9 @@ def image_generate_tool( if not images: raise ValueError("No images were generated") - should_upscale = bool(meta.get("upscale", False)) + # Edit endpoints already return the final composition; the Clarity + # upscaler is a text-to-image quality pass, so skip it for edits. + should_upscale = bool(meta.get("upscale", False)) and not use_edit formatted_images = [] for img in images: @@ -834,13 +989,15 @@ def image_generate_tool( upscaled_count = sum(1 for img in formatted_images if img.get("upscaled")) logger.info( - "Generated %s image(s) in %.1fs (%s upscaled) via %s", - len(formatted_images), generation_time, upscaled_count, model_id, + "Generated %s image(s) in %.1fs (%s upscaled) via %s [%s]", + len(formatted_images), generation_time, upscaled_count, endpoint, + modality, ) response_data = { "success": True, "image": formatted_images[0]["url"] if formatted_images else None, + "modality": modality, } debug_call_data["success"] = True @@ -1001,22 +1158,34 @@ from tools.registry import registry, tool_error IMAGE_GENERATE_SCHEMA = { "name": "image_generate", + # Placeholder — the real description is rebuilt dynamically at + # get_tool_definitions() time so it reflects the active backend's actual + # capabilities (whether the selected model supports image-to-image / + # editing). See _build_dynamic_image_schema() below and the + # dynamic-tool-schemas skill. "description": ( - "Generate high-quality images from text prompts. The underlying " - "backend (FAL, OpenAI, etc.) and model are user-configured and not " - "selectable by the agent. Returns either a URL or an absolute file " - "path in the `image` field; display it with markdown " - "![description](url-or-path) and the gateway will deliver it. When " - "the active terminal backend has a different filesystem, successful " - "local-file results may also include `agent_visible_image` for " - "follow-up terminal/file operations." + "Generate high-quality images from text prompts (text-to-image), or " + "edit / transform an existing image (image-to-image) when the active " + "model supports it. Pass `image_url` to edit that image; add " + "`reference_image_urls` for style/composition references; omit both " + "for text-to-image. The underlying backend (FAL, OpenAI, xAI, etc.) " + "and model are user-configured and not selectable by the agent. " + "Returns either a URL or an absolute file path in the `image` field; " + "display it with markdown ![description](url-or-path) and the gateway " + "will deliver it. When the active terminal backend has a different " + "filesystem, successful local-file results may also include " + "`agent_visible_image` for follow-up terminal/file operations." ), "parameters": { "type": "object", "properties": { "prompt": { "type": "string", - "description": "The text prompt describing the desired image. Be detailed and descriptive.", + "description": ( + "The text prompt describing the desired image (text-to-" + "image) or the edit to apply (image-to-image). Be detailed " + "and descriptive." + ), }, "aspect_ratio": { "type": "string", @@ -1024,6 +1193,28 @@ IMAGE_GENERATE_SCHEMA = { "description": "The aspect ratio of the generated image. 'landscape' is 16:9 wide, 'portrait' is 16:9 tall, 'square' is 1:1.", "default": DEFAULT_ASPECT_RATIO, }, + "image_url": { + "type": "string", + "description": ( + "Optional source image to edit/transform (image-to-image). " + "When provided, the active backend routes to its image " + "editing endpoint; when omitted, it generates from text " + "alone. Pass a public URL or an absolute local file path " + "from the conversation. Only honored by models that " + "support editing — the description above indicates whether " + "the active model does." + ), + }, + "reference_image_urls": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Optional list of additional reference image URLs / paths " + "(style, character, or composition references) to guide an " + "image-to-image edit. Supported only by some models and " + "capped per-model; the description above indicates the max." + ), + }, }, "required": ["prompt"], }, @@ -1069,7 +1260,12 @@ def _read_configured_image_provider(): return None -def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str): +def _dispatch_to_plugin_provider( + prompt: str, + aspect_ratio: str, + image_url: Optional[str] = None, + reference_image_urls: Optional[list] = None, +): """Route the call to a plugin-registered provider when one is selected. Returns a JSON string on dispatch, or ``None`` to fall through to the @@ -1080,6 +1276,10 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str): ``plugins/image_gen/fal/`` plugin (the plugin re-enters this module's pipeline via ``_it`` indirection so behavior is identical to the direct call, just routed through the registry). + + ``image_url`` / ``reference_image_urls`` enable image-to-image / editing: + they are forwarded to the provider's ``generate()`` so the backend can + route to its edit endpoint. """ configured = _read_configured_image_provider() if not configured: @@ -1122,11 +1322,53 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str): "error_type": "provider_not_registered", }) + kwargs: Dict[str, Any] = {"prompt": prompt, "aspect_ratio": aspect_ratio} try: - kwargs = {"prompt": prompt, "aspect_ratio": aspect_ratio} if configured_model: kwargs["model"] = configured_model + if isinstance(image_url, str) and image_url.strip(): + kwargs["image_url"] = image_url.strip() + norm_refs = None + if reference_image_urls is not None: + from agent.image_gen_provider import normalize_reference_images + + norm_refs = normalize_reference_images(reference_image_urls) + if norm_refs: + kwargs["reference_image_urls"] = norm_refs result = provider.generate(**kwargs) + except TypeError as exc: + # A provider whose generate() signature predates image_url support + # (third-party plugin not yet updated) — retry without the new kwargs + # so text-to-image keeps working, but surface a clear note when the + # user actually asked for an edit. + if "image_url" in kwargs or "reference_image_urls" in kwargs: + logger.warning( + "image_gen provider '%s' rejected image-to-image kwargs " + "(signature too narrow): %s", + getattr(provider, "name", "?"), exc, + ) + return json.dumps({ + "success": False, + "image": None, + "error": ( + f"Provider '{getattr(provider, 'name', '?')}' does not " + f"support image-to-image / editing (its generate() " + f"signature is out of date with the image_generate schema). " + f"Omit image_url for text-to-image, or pick a backend that " + f"supports editing via `hermes tools` → Image Generation." + ), + "error_type": "modality_unsupported", + }) + logger.warning( + "Image gen provider '%s' raised TypeError: %s", + getattr(provider, "name", "?"), exc, + ) + return json.dumps({ + "success": False, + "image": None, + "error": f"Provider '{getattr(provider, 'name', '?')}' error: {exc}", + "error_type": "provider_exception", + }) except Exception as exc: logger.warning( "Image gen provider '%s' raised: %s", @@ -1153,21 +1395,144 @@ def _handle_image_generate(args, **kw): if not prompt: return tool_error("prompt is required for image generation") aspect_ratio = args.get("aspect_ratio", DEFAULT_ASPECT_RATIO) + image_url = args.get("image_url") + reference_image_urls = args.get("reference_image_urls") task_id = kw.get("task_id") # Route to a plugin-registered provider if one is active (and it's # not the in-tree FAL path). - dispatched = _dispatch_to_plugin_provider(prompt, aspect_ratio) + dispatched = _dispatch_to_plugin_provider( + prompt, aspect_ratio, + image_url=image_url, + reference_image_urls=reference_image_urls, + ) if dispatched is not None: return _postprocess_image_generate_result(dispatched, task_id=task_id) raw = image_generate_tool( prompt=prompt, aspect_ratio=aspect_ratio, + image_url=image_url, + reference_image_urls=reference_image_urls, ) return _postprocess_image_generate_result(raw, task_id=task_id) +# --------------------------------------------------------------------------- +# Dynamic schema — reflect the active backend's image-to-image capability +# --------------------------------------------------------------------------- +# +# Why dynamic: whether the active model supports image-to-image / editing +# depends entirely on the user's configured backend + model. Telling the +# model up front ("the active model is text-to-image only — image_url will be +# rejected") saves a wasted turn. Memoized by config.yaml mtime in +# model_tools.get_tool_definitions(), so it rebuilds when the user switches +# model/provider via `hermes tools` or `/skills`. + + +_GENERIC_IMAGE_DESCRIPTION = IMAGE_GENERATE_SCHEMA["description"] + + +def _active_image_capabilities() -> Dict[str, Any]: + """Best-effort: return the active backend/model's image capabilities. + + Resolution order mirrors the runtime dispatch: + 1. If ``image_gen.provider`` is set, ask that plugin provider. + 2. Otherwise inspect the in-tree FAL model catalog for the active model. + + Returns a dict like ``{"modalities": [...], "max_reference_images": N, + "model": "...", "provider": "..."}``. Never raises. + """ + info: Dict[str, Any] = {"modalities": ["text"], "max_reference_images": 0} + + configured_provider = _read_configured_image_provider() + if configured_provider and configured_provider != "fal": + try: + from agent.image_gen_registry import get_provider + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + provider = get_provider(configured_provider) + if provider is not None: + caps = {} + try: + caps = provider.capabilities() or {} + except Exception: # noqa: BLE001 + caps = {} + info["provider"] = provider.display_name + info["model"] = _read_configured_image_model() or (provider.default_model() or "") + if caps.get("modalities"): + info["modalities"] = list(caps["modalities"]) + if caps.get("max_reference_images"): + info["max_reference_images"] = int(caps["max_reference_images"]) + return info + except Exception: # noqa: BLE001 + pass + + # In-tree FAL path (provider unset or == "fal"). + try: + model_id, meta = _resolve_fal_model() + info["provider"] = "FAL.ai" + info["model"] = meta.get("display", model_id) + if meta.get("edit_endpoint"): + info["modalities"] = ["text", "image"] + info["max_reference_images"] = int(meta.get("max_reference_images") or 1) + else: + info["modalities"] = ["text"] + info["max_reference_images"] = 0 + except Exception: # noqa: BLE001 + pass + + return info + + +def _build_dynamic_image_schema() -> Dict[str, Any]: + """Build a description reflecting whether the active model supports editing.""" + parts = [_GENERIC_IMAGE_DESCRIPTION] + + try: + info = _active_image_capabilities() + except Exception: # noqa: BLE001 + return {"description": _GENERIC_IMAGE_DESCRIPTION} + + provider = info.get("provider") + model = info.get("model") + modalities = set(info.get("modalities") or ["text"]) + + line = "\nActive backend" + if provider: + line += f": {provider}" + if model: + line += f" · model: {model}" + parts.append(line) + + if "image" in modalities and "text" in modalities: + max_refs = info.get("max_reference_images") or 0 + ref_note = ( + f"; up to {max_refs} reference image(s) via reference_image_urls" + if max_refs and max_refs > 1 + else "" + ) + parts.append( + "- supports both text-to-image (omit image_url) and " + f"image-to-image / editing (pass image_url){ref_note} — " + "routes automatically" + ) + elif "image" in modalities and "text" not in modalities: + parts.append( + "- this model is image-to-image / edit only — image_url is REQUIRED" + ) + else: + parts.append( + "- this model is text-to-image only — it is NOT capable of " + "image-to-image / editing; do not pass image_url or " + "reference_image_urls (they will be rejected). Provide a " + "text-only prompt." + ) + + return {"description": "\n".join(parts)} + + registry.register( name="image_generate", toolset="image_gen", @@ -1177,4 +1542,5 @@ registry.register( requires_env=[], is_async=False, # sync fal_client API to avoid "Event loop is closed" in gateway emoji="🎨", + dynamic_schema_overrides=_build_dynamic_image_schema, ) diff --git a/website/docs/developer-guide/image-gen-provider-plugin.md b/website/docs/developer-guide/image-gen-provider-plugin.md index c9823d1cedd..b746ce82229 100644 --- a/website/docs/developer-guide/image-gen-provider-plugin.md +++ b/website/docs/developer-guide/image-gen-provider-plugin.md @@ -47,6 +47,7 @@ from agent.image_gen_provider import ( DEFAULT_ASPECT_RATIO, ImageGenProvider, error_response, + normalize_reference_images, resolve_aspect_ratio, save_b64_image, success_response, @@ -112,10 +113,20 @@ class MyBackendImageGenProvider(ImageGenProvider): ], } + def capabilities(self) -> Dict[str, Any]: + # Declare whether this backend supports image-to-image / editing. + # The tool layer surfaces this in the dynamic schema so the model + # knows when `image_url` is honored. Default (if you omit this) is + # text-only: {"modalities": ["text"], "max_reference_images": 0}. + return {"modalities": ["text", "image"], "max_reference_images": 4} + def generate( self, prompt: str, aspect_ratio: str = DEFAULT_ASPECT_RATIO, + *, + image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, **kwargs: Any, ) -> Dict[str, Any]: prompt = (prompt or "").strip() @@ -130,6 +141,15 @@ class MyBackendImageGenProvider(ImageGenProvider): aspect_ratio=aspect_ratio, ) + # Routing: if image_url (or reference_image_urls) is set, the call is + # an image-to-image / edit request; otherwise text-to-image. Report + # which path you took via the `modality` field of success_response. + sources = [] + if image_url: + sources.append(image_url) + sources.extend(normalize_reference_images(reference_image_urls) or []) + modality = "image" if sources else "text" + # Model selection precedence: env var → config → default. The helper # _resolve_model() in the built-in openai plugin is a good reference. model_id = kwargs.get("model") or self.default_model() or "my-model-fast" @@ -137,11 +157,18 @@ class MyBackendImageGenProvider(ImageGenProvider): try: import my_backend_sdk client = my_backend_sdk.Client(api_key=os.environ["MY_BACKEND_API_KEY"]) - result = client.generate( - prompt=prompt, - model=model_id, - aspect_ratio=aspect_ratio, - ) + if modality == "image": + result = client.edit( + prompt=prompt, + model=model_id, + image_urls=sources, + ) + else: + result = client.generate( + prompt=prompt, + model=model_id, + aspect_ratio=aspect_ratio, + ) # Two shapes supported: # - URL string: return it as `image` @@ -162,6 +189,7 @@ class MyBackendImageGenProvider(ImageGenProvider): prompt=prompt, aspect_ratio=aspect_ratio, provider=self.name, + modality=modality, ) except Exception as exc: return error_response( diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 2393a9db7d1..1f6b86c0063 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -114,7 +114,7 @@ Scoped to the Feishu document-comment handler. Drives comment read/write operati | Tool | Description | Requires environment | |------|-------------|----------------------| -| `image_generate` | Generate high-quality images from text prompts using FAL.ai. The underlying model is user-configured (default: FLUX 2 Klein 9B, sub-1s generation) and is not selectable by the agent. Returns a single image URL. Display it using… | FAL_KEY | +| `image_generate` | Generate images from text prompts (text-to-image) or edit/transform an existing image (image-to-image) via the user-configured backend (FAL.ai, OpenAI, xAI, Krea). Pass `image_url` to edit an image and `reference_image_urls` for style references; omit both for text-to-image. The model is user-configured and not selectable by the agent. Returns a single image URL or local path. | FAL_KEY / OPENAI_API_KEY / xAI OAuth / KREA_API_KEY | ## `kanban` toolset diff --git a/website/docs/user-guide/features/image-generation.md b/website/docs/user-guide/features/image-generation.md index 4f225ee00b1..62dfe7bd127 100644 --- a/website/docs/user-guide/features/image-generation.md +++ b/website/docs/user-guide/features/image-generation.md @@ -86,6 +86,46 @@ Create a square portrait of a wise old owl — use the typography model Make me a futuristic cityscape, landscape orientation ``` +## Image-to-Image / Editing + +The same `image_generate` tool also **edits existing images** when the active +model supports it — pass a source image and the backend routes to its editing +endpoint automatically (mirrors how `video_generate` handles image-to-video). +Omit the source image and it's plain text-to-image. + +``` +Take this photo and make it a rainy Tokyo street at night → <image> +``` + +``` +Blend these two product shots into one hero image → <image1> <image2> +``` + +Two inputs drive the edit: + +- **`image_url`** — the primary source image to edit/transform (public URL or local path). +- **`reference_image_urls`** — additional style/composition references (capped per-model). + +### Which backends support editing + +| Backend | Image-to-image | Reference cap | How | +|---|---|---|---| +| **FAL.ai** (edit-capable models below) | ✓ | up to 9 | routes to the model's `/edit` endpoint | +| **OpenAI** (`gpt-image-2`) | ✓ | up to 16 | `images.edit()` | +| **xAI** (Grok Imagine) | ✓ | 1 | `/v1/images/edits` (`grok-imagine-image-quality`) | +| **Krea** (`Krea 2`) | ✓ | up to 10 | reference-guided generation (`image_style_references`) | +| **OpenAI (Codex auth)** | ✗ | — | text-to-image only | + +FAL models with an editing endpoint: `flux-2/klein/9b`, `flux-2-pro`, +`nano-banana-pro`, `gpt-image-1.5`, `gpt-image-2`, `ideogram/v3`, and +`qwen-image`. Pure text-to-image FAL models (`z-image/turbo`, `recraft`, +`krea/*`) reject image inputs with a clear error pointing you at an +edit-capable model. + +The active model's editing capability is surfaced in the tool description at +runtime, so the agent knows whether `image_url` will be honored before it +calls the tool. + ## Aspect Ratios Every model accepts the same three aspect ratios from the agent's perspective. Internally, each model's native size spec is filled in automatically: @@ -152,7 +192,7 @@ Debug logs go to `./logs/image_tools_debug_<session_id>.json` with per-call deta ## Limitations -- **Requires FAL credentials** (direct `FAL_KEY` or Nous Subscription) -- **Text-to-image only** — no inpainting, img2img, or editing via this tool -- **Temporary URLs** — FAL returns hosted URLs that expire after hours/days; save locally if needed -- **Per-model constraints** — some models don't support `seed`, `num_inference_steps`, etc. The `supports` filter silently drops unsupported params; this is expected behavior +- **Requires credentials** for the active backend (FAL `FAL_KEY` / Nous Subscription, `OPENAI_API_KEY`, xAI OAuth, `KREA_API_KEY`) +- **Editing is model-dependent** — image-to-image works only on edit-capable models (see the table above); text-to-image-only models reject image inputs with a clear error +- **Temporary URLs** — backends return hosted URLs that expire after hours/days; Hermes materializes them to the local cache so delivery still works after expiry +- **Per-model constraints** — some models don't support `seed`, `num_inference_steps`, etc. The `supports` / `edit_supports` filter silently drops unsupported params; this is expected behavior From 245b95b09470bb3887943122a7d0de5bf20da055 Mon Sep 17 00:00:00 2001 From: AhmetArif0 <147827411+AhmetArif0@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:34:26 +0300 Subject: [PATCH 033/470] fix(terminal): block gateway lifecycle commands from inside the gateway process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit systemctl --user restart hermes-gateway run via the terminal tool is a child of the gateway itself. When systemd delivers SIGTERM the gateway kills this subprocess before it can complete, so the service may never restart — reproducing issue #37453. The hermes gateway restart/stop guard (hermes_cli/gateway.py) and the cron-path guard (hermes_cli/cron.py) already block equivalent commands in their respective paths but the terminal tool had no such defense. Add a hard-block before command execution in terminal_tool: when _HERMES_GATEWAY=1 and the command matches _contains_gateway_lifecycle_command, return an error immediately. force=True cannot bypass it — unlike the normal dangerous-command approval flow, here even a user-approved restart would fail because the SIGTERM propagates to child processes. Also extend _GATEWAY_LIFECYCLE_PATTERNS to match systemctl with flags (e.g. systemctl --user restart) — the previous regex required the action word immediately after systemctl with no flags in between. Adds 9 regression tests: 6 blocked variants (parametrized), force bypass attempt, safe systemctl passthrough, and guard-inactive-outside-gateway. --- hermes_cli/cron.py | 2 +- tests/hermes_cli/test_gateway_restart_loop.py | 107 ++++++++++++++++++ tools/terminal_tool.py | 23 ++++ 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 717c1e97658..86f8e6b09e2 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -25,7 +25,7 @@ _GATEWAY_LIFECYCLE_PATTERNS = re.compile( r"(?i)" r"(hermes\s+gateway\s+(restart|stop|start))" r"|(launchctl\s+(kickstart|unload|load|stop|restart)\s+.*hermes)" - r"|(systemctl\s+(restart|stop|start)\s+.*hermes)" + r"|(systemctl\s+(-\S+\s+)*(restart|stop|start)\s+.*hermes)" r"|(p?kill\s+.*hermes.*gateway)" ) diff --git a/tests/hermes_cli/test_gateway_restart_loop.py b/tests/hermes_cli/test_gateway_restart_loop.py index d6c9bb06cec..74ee9e4934e 100644 --- a/tests/hermes_cli/test_gateway_restart_loop.py +++ b/tests/hermes_cli/test_gateway_restart_loop.py @@ -6,6 +6,7 @@ Covers: - _contains_gateway_lifecycle_command pattern matching """ +import json import os from argparse import Namespace @@ -250,3 +251,109 @@ class TestGatewaySelfTargetingGuard: args = Namespace(gateway_command="restart", all=False, system=False) with pytest.raises(_Reached): gw.gateway_command(args) + + +# --------------------------------------------------------------------------- +# Defense 3: terminal_tool hard-blocks gateway lifecycle commands inside gateway +# --------------------------------------------------------------------------- + +class TestTerminalToolGatewayLifecycleGuard: + """terminal_tool must refuse gateway lifecycle commands when _HERMES_GATEWAY=1. + + Issue #37453: systemctl --user restart hermes-gateway runs as a child of the + gateway process. When systemd delivers SIGTERM the gateway kills its own + restart command mid-execution — the service may never restart. The guard + must fire before execution, unconditionally (force=True cannot bypass it). + """ + + def _make_fake_env(self): + class _FakeEnv: + env = {} + def execute(self, command, **kwargs): # pragma: no cover + raise AssertionError("execute must not be reached") + return _FakeEnv() + + def _minimal_config(self): + return {"env_type": "local", "cwd": "/tmp", "timeout": 60, "lifetime_seconds": 3600} + + def _patch_env(self, monkeypatch, fake_env, *, inside_gateway: bool): + import tools.terminal_tool as tt + eid = "default" + monkeypatch.setattr(tt, "_active_environments", {eid: fake_env}) + monkeypatch.setattr(tt, "_last_activity", {eid: 0.0}) + monkeypatch.setattr(tt, "_task_env_overrides", {}) + monkeypatch.setattr(tt, "_get_env_config", self._minimal_config) + if inside_gateway: + monkeypatch.setenv("_HERMES_GATEWAY", "1") + else: + monkeypatch.delenv("_HERMES_GATEWAY", raising=False) + + @pytest.mark.parametrize("cmd", [ + "systemctl restart hermes-gateway", + "systemctl --user restart hermes-gateway", + "systemctl stop hermes-gateway.service", + "hermes gateway restart", + "launchctl kickstart gui/501/ai.hermes.gateway", + "pkill -f hermes.*gateway", + ]) + def test_blocks_lifecycle_commands_inside_gateway(self, monkeypatch, cmd): + import tools.terminal_tool as tt + self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True) + + result = json.loads(tt.terminal_tool(command=cmd)) + + assert result["exit_code"] == 1 + assert "Blocked" in result["error"] + + def test_force_true_cannot_bypass_block(self, monkeypatch): + import tools.terminal_tool as tt + self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True) + + result = json.loads(tt.terminal_tool( + command="systemctl restart hermes-gateway", force=True + )) + + assert result["exit_code"] == 1 + assert "Blocked" in result["error"] + + def test_safe_systemctl_commands_pass_through(self, monkeypatch): + """Non-hermes systemctl commands must not be blocked by this guard.""" + import tools.terminal_tool as tt + + calls = [] + + class _FakeEnv: + env = {} + def execute(self, command, **kwargs): + calls.append(command) + return {"output": "Active: running", "returncode": 0} + + self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=True) + monkeypatch.setattr(tt, "_check_all_guards", lambda cmd, env: {"approved": True}) + + result = json.loads(tt.terminal_tool(command="systemctl status nginx")) + + assert result["exit_code"] == 0 + assert calls == ["systemctl status nginx"] + + def test_guard_inactive_outside_gateway(self, monkeypatch): + """Without _HERMES_GATEWAY=1 the lifecycle guard must not fire.""" + import tools.terminal_tool as tt + + calls = [] + + class _FakeEnv: + env = {} + def execute(self, command, **kwargs): + calls.append(command) + return {"output": "restarting...", "returncode": 0} + + self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=False) + monkeypatch.setattr(tt, "_check_all_guards", lambda cmd, env: {"approved": True}) + + result = json.loads(tt.terminal_tool(command="systemctl restart hermes-gateway")) + + # Outside the gateway the lifecycle guard doesn't block — the normal + # approval flow handles it (here mocked as approved). + assert result["exit_code"] == 0 + assert calls == ["systemctl restart hermes-gateway"] diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 71907a3a3cc..26d0f425c56 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -2058,6 +2058,29 @@ def terminal_tool( env = new_env logger.info("%s environment ready for task %s", env_type, effective_task_id[:8]) + # Hard-block: gateway lifecycle commands (systemctl/launchctl/hermes + # restart|stop targeting hermes-gateway) must never run inside the + # gateway process itself. The restart would SIGTERM the gateway, which + # kills this very subprocess before it can complete — the service may + # never restart. This mirrors the `hermes gateway restart` guard in + # hermes_cli/gateway.py and the cron-path guard in hermes_cli/cron.py, + # but applies unconditionally (force=True cannot help here). + if os.environ.get("_HERMES_GATEWAY") == "1": + from hermes_cli.cron import _contains_gateway_lifecycle_command + if _contains_gateway_lifecycle_command(command): + return json.dumps({ + "output": "", + "exit_code": 1, + "error": ( + "Blocked: cannot restart or stop the gateway from inside the " + "gateway process. The gateway would kill this command before " + "it could complete (SIGTERM propagates to child processes). " + "Run `hermes gateway restart` from a separate shell outside " + "the running gateway." + ), + "status": "error", + }, ensure_ascii=False) + # Pre-exec security checks (tirith + dangerous command detection) # Skip check if force=True (user has confirmed they want to run it) approval_note = None From a64fc490fe61dfe865e9b189aa5f4c5f1598b285 Mon Sep 17 00:00:00 2001 From: Ben Barclay <ben@nousresearch.com> Date: Fri, 19 Jun 2026 16:30:24 +1000 Subject: [PATCH 034/470] fix(relay): make hosted gateways actually connect AND complete the inbound/outbound round-trip (#48828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(relay): enable RELAY platform + normalize dial URL so hosted gateways actually connect Three bugs blocked a self-provisioned hosted gateway from ever establishing its inbound relay WS (found while standing up the live staging end-to-end). Each masked the next; all three are needed for inbound to work. 1. RELAY platform never enabled in config.platforms (gateway/config.py). register_relay_adapter() puts the adapter in the platform_registry, but start_gateway()'s connect loop iterates self.config.platforms — which never contained Platform.RELAY. So the adapter was "registered" but never connected (logs showed "relay adapter registered" then "No messaging platforms enabled"). Fix: _apply_env_overrides now enables Platform.RELAY (mirroring relay_url into extra for the connected-checker) when GATEWAY_RELAY_URL (env) or gateway.relay_url (yaml) is set. Absent -> no RELAY entry (direct/ single-tenant gateways unaffected). 2. URL scheme not converted for the WS dial (gateway/relay/ws_transport.py). The relay URL is configured once as the http(s):// base (used as-is for the provision POST), but websockets.connect rejects http(s):// with "scheme isn't ws or wss". Fix: _ws_dial_url converts https->wss / http->ws. 3. /relay path not appended (same helper). The connector mounts its WebSocketServer at path "/relay" and returns HTTP 400 on an upgrade to any other path. GATEWAY_RELAY_URL is the base (no /relay), so the dial hit "/" -> 400. Fix: _ws_dial_url ensures the path ends in /relay. Idempotent — a URL already carrying ws(s):// and/or /relay is unchanged, so provision's _provision_url (which derives /relay/provision from either form) still works. Why the cross-repo E2E missed #2/#3: the stub connector binds ws://host:port and its websockets.serve accepts ANY path, so neither the scheme nor the /relay path was exercised. Real connector needs both. Verified live on staging hermes-agent-stg-automated-perception-5054: after the fixes the gateway logs "Connecting to relay..." -> "✓ relay connected" -> "Gateway running with 1 platform(s)" against wss://gateway-gateway.staging-nousresearch.com/relay, stable. Tests: added _ws_dial_url scheme+path+idempotency cases (test_ws_transport.py) and RELAY-platform-enablement cases for env + yaml + absent (test_config.py). Full gateway/relay + config suites green (191 passed). Relay-adapter lane. EXPERIMENTAL. * fix(relay): re-attach guild_id to outbound so connector egress resolves the tenant The final bug in the hosted-relay round-trip. Inbound worked end to end (Discord -> connector -> bus -> agent WS -> agent runs -> reply), but the reply's egress was declined by the connector: "discord egress declined: target not routed to an onboarded tenant". Cause: the connector's routedEgressGuard resolves the owning tenant from the OUTBOUND action's metadata.guild_id (Discord's routing discriminator). The gateway's generic delivery path builds outbound metadata via run.py _thread_metadata_for_source, which only carries thread_id (and returns None entirely for a non-threaded message) — so guild_id never reached the connector, tenant resolution failed, and the shared bot refused to post. Fix (relay-adapter-local, no perturbation of the generic delivery path or other platforms): RelayAdapter learns chat_id -> guild_id from each inbound event (_capture_scope) and re-attaches it to the outbound action's metadata in send() (_with_scope) when not already present. No-op for chats we never saw inbound (e.g. DMs) and never overwrites an explicit guild_id. Verified live on staging hermes-agent-stg-automated-perception-5054: an @mention in #general now produces a visible bot reply — full multi-tenant relay round-trip (real Discord -> shared connector bot -> tenant routing -> agent WS -> reply egress -> Discord). Tests: _capture_scope/_with_scope reattach, no-scope no-op, explicit-guild_id preserved (test_relay_adapter.py). Full relay + config suites green (160 passed). Relay-adapter lane. EXPERIMENTAL. --- gateway/config.py | 19 +++++++ gateway/relay/adapter.py | 36 ++++++++++++- gateway/relay/ws_transport.py | 31 ++++++++++- tests/gateway/relay/test_relay_adapter.py | 65 +++++++++++++++++++++++ tests/gateway/relay/test_ws_transport.py | 22 ++++++++ tests/gateway/test_config.py | 49 +++++++++++++++++ 6 files changed, 220 insertions(+), 2 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 0ebf23e12d0..c63b9523d73 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -2143,5 +2143,24 @@ def _apply_env_overrides(config: GatewayConfig) -> None: except Exception as e: logger.debug("Plugin platform enable pass failed: %s", e) + # Relay (generic connector-fronted platform, EXPERIMENTAL). Enabled when a + # connector relay URL is configured via GATEWAY_RELAY_URL (env) or + # gateway.relay_url (config.yaml). The adapter is registered into the + # platform_registry at gateway startup (gateway.relay.register_relay_adapter) + # and dials OUT to the connector — so, like Telegram/Matrix, it has no public + # inbound port and just needs Platform.RELAY present+enabled in + # config.platforms for start_gateway()'s connect loop to bring it up. The + # connected-checker (Platform.RELAY in _PLATFORM_CONNECTED_CHECKERS) keys on + # extra["relay_url"], so mirror the URL into extra here. + relay_url_env = os.getenv("GATEWAY_RELAY_URL", "").strip() + relay_url_yaml = "" + existing_relay = config.platforms.get(Platform.RELAY) + if existing_relay is not None: + relay_url_yaml = str(existing_relay.extra.get("relay_url") or "").strip() + relay_url_val = relay_url_env or relay_url_yaml + if relay_url_val: + relay_config = _enable_from_env(Platform.RELAY) + relay_config.extra["relay_url"] = relay_url_val.rstrip("/") + for platform_config in config.platforms.values(): platform_config.extra.pop("_enabled_explicit", None) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index fc4e5f40ee7..a1a7826f8f8 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -57,6 +57,13 @@ class RelayAdapter(BasePlatformAdapter): self._transport = transport # Capability surface read by stream_consumer (getattr(..., 4096)). self.MAX_MESSAGE_LENGTH = descriptor.max_message_length + # chat_id -> guild_id (Discord) / workspace scope, learned from inbound + # events. The connector's egress guard resolves the owning tenant from + # the OUTBOUND action's metadata.guild_id; the gateway's generic delivery + # path (run.py _thread_metadata_for_source) only carries thread_id, so we + # re-attach the scope here from what we saw inbound. Keyed by chat_id + # (channel) since that's what send() receives. See routedEgressGuard.ts. + self._scope_by_chat: Dict[str, str] = {} self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain") # ── capability surface (from descriptor) ───────────────────────────── @@ -108,8 +115,35 @@ class RelayAdapter(BasePlatformAdapter): async def _on_inbound(self, event) -> None: """Bridge a connector-delivered MessageEvent into the normal adapter path.""" + self._capture_scope(event) await self.handle_message(event) + def _capture_scope(self, event) -> None: + """Remember chat_id -> guild scope from an inbound event so our outbound + (the agent's reply) can re-assert it for the connector's egress tenant + resolution. Never raises — scope tracking must not break inbound.""" + try: + src = getattr(event, "source", None) + scope = getattr(src, "guild_id", None) if src else None + chat = getattr(src, "chat_id", None) if src else None + if scope and chat: + self._scope_by_chat[str(chat)] = str(scope) + except Exception: # noqa: BLE001 - scope tracking must never break inbound + pass + + def _with_scope(self, chat_id: str, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Ensure the outbound metadata carries guild_id for the connector's + egress tenant resolution. The connector resolves the owning tenant from + metadata.guild_id (Discord); without it egress is declined as + 'target not routed to an onboarded tenant'. No-op when we have no scope + for this chat (e.g. DMs) or it's already present.""" + meta: Dict[str, Any] = dict(metadata or {}) + if not meta.get("guild_id"): + scope = self._scope_by_chat.get(str(chat_id)) + if scope: + meta["guild_id"] = scope + return meta + async def on_interrupt(self, session_key: str, chat_id: str) -> None: """Bridge a connector-delivered /stop into the adapter's interrupt path. @@ -140,7 +174,7 @@ class RelayAdapter(BasePlatformAdapter): "chat_id": chat_id, "content": content, "reply_to": reply_to, - "metadata": metadata or {}, + "metadata": self._with_scope(chat_id, metadata), } ) return SendResult( diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index b2e8eda09cd..b091d44faa8 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -54,6 +54,35 @@ _HANDSHAKE_TIMEOUT_S = 30.0 _OUTBOUND_TIMEOUT_S = 30.0 +def _ws_dial_url(url: str) -> str: + """Normalize a connector URL to the ``ws(s)://…/relay`` dial target. + + The relay URL is configured once (``GATEWAY_RELAY_URL`` / ``gateway.relay_url``) + as the connector's BASE URL (e.g. ``https://connector.example``) and shared by + both the provision POST (which needs ``http(s)://…/relay/provision`` — see + ``_provision_url``) and the WS dial (which needs ``ws(s)://…/relay``, the path + the connector mounts its ``WebSocketServer`` on). Two normalizations, both + load-bearing: + + - scheme: ``https -> wss``, ``http -> ws`` (``websockets.connect`` raises + "scheme isn't ws or wss" on an http(s) URL). + - path: ensure it ends in ``/relay`` (the connector returns HTTP 400 on an + upgrade to any other path, since the WS server is mounted at ``/relay``). + + Idempotent: an already-``ws(s)://…/relay`` URL is returned unchanged, so a URL + configured WITH the scheme and/or ``/relay`` still works. + """ + raw = (url or "").strip() + if raw.startswith("https://"): + raw = "wss://" + raw[len("https://"):] + elif raw.startswith("http://"): + raw = "ws://" + raw[len("http://"):] + raw = raw.rstrip("/") + if not raw.endswith("/relay"): + raw = f"{raw}/relay" + return raw + + def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: """Rebuild a MessageEvent from the connector's normalized inbound payload. @@ -118,7 +147,7 @@ class WebSocketRelayTransport: "WebSocketRelayTransport requires the 'websockets' package " "(install the messaging extra)." ) - self._url = url + self._url = _ws_dial_url(url) self._platform = platform self._bot_id = bot_id self._connect_timeout_s = connect_timeout_s diff --git a/tests/gateway/relay/test_relay_adapter.py b/tests/gateway/relay/test_relay_adapter.py index 64d6aab2f86..f176eb5728c 100644 --- a/tests/gateway/relay/test_relay_adapter.py +++ b/tests/gateway/relay/test_relay_adapter.py @@ -75,3 +75,68 @@ async def test_send_without_transport_returns_failure(): result = await a.send("chat1", "hello") assert result.success is False assert result.error == "no transport" + + +class _CaptureTransport: + """Minimal RelayTransport stand-in that records the outbound action.""" + + def __init__(self): + self.sent = None + + def set_inbound_handler(self, h): # noqa: D401 + self._h = h + + async def send_outbound(self, action): + self.sent = action + return {"success": True, "message_id": "m1"} + + +def _make_event(chat_id="chan-1", guild_id="guild-9"): + from gateway.platforms.base import MessageEvent, MessageType + from gateway.session import SessionSource + + src = SessionSource( + platform=Platform.RELAY, + chat_id=chat_id, + chat_type="channel", + guild_id=guild_id, + ) + return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT) + + +@pytest.mark.asyncio +async def test_send_reattaches_guild_id_from_inbound_scope(): + """The connector's egress guard resolves the owning tenant from + metadata.guild_id; the gateway's generic delivery path drops it, so the + relay adapter must re-attach the guild scope learned from the inbound event. + Regression for live 'discord egress declined: target not routed to an + onboarded tenant'.""" + t = _CaptureTransport() + a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t) + # Simulate the connector delivering an inbound message in guild-9 / chan-1, + # but don't run the full handle_message pipeline — just the scope capture. + a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9")) + + await a.send("chan-1", "the reply") + + assert t.sent["metadata"].get("guild_id") == "guild-9" + + +@pytest.mark.asyncio +async def test_send_without_known_scope_omits_guild_id(): + """A chat we never saw inbound (e.g. a DM) gets no guild_id — no-op, never + invents a scope.""" + t = _CaptureTransport() + a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t) + await a.send("unknown-chat", "hi") + assert "guild_id" not in t.sent["metadata"] + + +@pytest.mark.asyncio +async def test_send_preserves_explicit_guild_id(): + """An explicitly-provided metadata.guild_id is never overwritten.""" + t = _CaptureTransport() + a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t) + a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9")) + await a.send("chan-1", "hi", metadata={"guild_id": "explicit-1"}) + assert t.sent["metadata"]["guild_id"] == "explicit-1" diff --git a/tests/gateway/relay/test_ws_transport.py b/tests/gateway/relay/test_ws_transport.py index dcb3f6c714f..00aa9b43327 100644 --- a/tests/gateway/relay/test_ws_transport.py +++ b/tests/gateway/relay/test_ws_transport.py @@ -177,3 +177,25 @@ async def test_disconnect_fails_pending_waiters_cleanly(server): # After disconnect, an outbound returns a structured failure rather than hanging. result = await t.send_outbound({"op": "send", "chat_id": "c", "content": "x"}) assert result["success"] is False + + +def test_https_url_normalized_to_wss(): + """The relay URL is configured once as the http(s):// BASE (for the provision + POST), but websockets.connect needs ws(s):// and the connector mounts its WS + server at /relay. The transport must convert scheme AND ensure the /relay + path. Regression for the live staging failures 'scheme isn't ws or wss' then + 'server rejected WebSocket connection: HTTP 400' (wrong path).""" + t = WebSocketRelayTransport("https://connector.example", "discord", "b") + assert t._url == "wss://connector.example/relay" + t2 = WebSocketRelayTransport("http://connector.local:8080", "discord", "b") + assert t2._url == "ws://connector.local:8080/relay" + + +def test_ws_dial_url_idempotent_with_scheme_and_path(): + # Already ws(s):// and/or already ending in /relay -> unchanged (no double append). + t = WebSocketRelayTransport("wss://connector.example/relay", "discord", "b") + assert t._url == "wss://connector.example/relay" + t2 = WebSocketRelayTransport("https://connector.example/relay/", "discord", "b") + assert t2._url == "wss://connector.example/relay" + t3 = WebSocketRelayTransport("ws://127.0.0.1:9", "discord", "b") + assert t3._url == "ws://127.0.0.1:9/relay" diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 9e74dd355ad..9f38f9b8a0d 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -311,6 +311,55 @@ class TestLoadGatewayConfig: assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}} + def test_relay_platform_enabled_from_env_url(self, tmp_path, monkeypatch): + """GATEWAY_RELAY_URL must enable Platform.RELAY in config.platforms so + start_gateway()'s connect loop actually dials the connector. Registering + the adapter in the platform_registry is NOT enough — the connect loop + iterates config.platforms, so an un-enabled RELAY never connects (the + 'relay registered but no inbound' bug).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("GATEWAY_RELAY_URL", "https://connector.example/relay/") + + config = load_gateway_config() + + assert Platform.RELAY in config.platforms + relay = config.platforms[Platform.RELAY] + assert relay.enabled is True + # Trailing slash stripped; mirrored into extra for the connected-checker. + assert relay.extra.get("relay_url") == "https://connector.example/relay" + assert Platform.RELAY in config.get_connected_platforms() + + def test_relay_platform_absent_when_url_unset(self, tmp_path, monkeypatch): + """No relay URL -> no RELAY platform, so direct/single-tenant gateways + are unaffected.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("GATEWAY_RELAY_URL", raising=False) + + config = load_gateway_config() + + assert Platform.RELAY not in config.platforms + + def test_relay_platform_enabled_from_config_yaml(self, tmp_path, monkeypatch): + """gateway.relay_url in config.yaml also enables RELAY (env-less path).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "gateway:\n platforms:\n relay:\n extra:\n relay_url: https://connector.example/relay\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("GATEWAY_RELAY_URL", raising=False) + + config = load_gateway_config() + + assert Platform.RELAY in config.platforms + assert config.platforms[Platform.RELAY].enabled is True + def test_bridges_group_sessions_per_user_from_config_yaml(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() From 12dfcfdf73ed0543617ce0f4779aae8a9acb1e33 Mon Sep 17 00:00:00 2001 From: Shannon Sands <shannon.sands.1979@gmail.com> Date: Fri, 19 Jun 2026 16:11:55 +1000 Subject: [PATCH 035/470] fix(tui): restart dashboard chat on idle exit hotkeys --- hermes_cli/web_server.py | 1 + tests/hermes_cli/test_web_server.py | 1 + ui-tui/src/__tests__/gatewayClient.test.ts | 40 +++++++++++++++++++ ui-tui/src/__tests__/gracefulExit.test.ts | 11 +++++ ui-tui/src/__tests__/useInputHandlers.test.ts | 39 +++++++++++++++++- ui-tui/src/app/useInputHandlers.ts | 36 +++++++++++++++-- ui-tui/src/config/env.ts | 8 ++++ ui-tui/src/entry.tsx | 9 ++++- ui-tui/src/gatewayClient.ts | 7 ++++ ui-tui/src/gatewayTypes.ts | 1 + ui-tui/src/lib/gracefulExit.ts | 28 +++++++++++-- web/src/components/ChatSidebar.tsx | 23 ++++++++--- web/src/pages/ChatPage.tsx | 21 +++++++++- 13 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 ui-tui/src/__tests__/gracefulExit.test.ts diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b2544ce9d77..ba6f4277deb 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -10830,6 +10830,7 @@ def _resolve_chat_argv( # the dashboard PTY path. env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1") env.setdefault("HERMES_TUI_INLINE", "1") + env["HERMES_TUI_DASHBOARD"] = "1" if profile_dir is not None: env["HERMES_HOME"] = str(profile_dir) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index e0ad77dfc8a..e65a28101cd 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -5062,6 +5062,7 @@ class TestPtyWebSocket: _argv, _cwd, env = self.ws_module._resolve_chat_argv() + assert env["HERMES_TUI_DASHBOARD"] == "1" assert env["HERMES_TUI_INLINE"] == "1" assert env["HERMES_TUI_DISABLE_MOUSE"] == "1" diff --git a/ui-tui/src/__tests__/gatewayClient.test.ts b/ui-tui/src/__tests__/gatewayClient.test.ts index a872a008ddb..43d96add35a 100644 --- a/ui-tui/src/__tests__/gatewayClient.test.ts +++ b/ui-tui/src/__tests__/gatewayClient.test.ts @@ -187,6 +187,46 @@ describe('GatewayClient websocket attach mode', () => { gw.kill() }) + it('publishes local dashboard-control events to the sidecar websocket', async () => { + process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc' + process.env.HERMES_TUI_SIDECAR_URL = 'ws://gateway.test/api/pub?token=abc&channel=demo' + + const gw = new GatewayClient() + const seen: string[] = [] + + gw.on('event', ev => seen.push(ev.type)) + gw.start() + + const gatewaySocket = FakeWebSocket.instances[0]! + + gatewaySocket.open() + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)) + + const sidecarSocket = FakeWebSocket.instances[1]! + + sidecarSocket.open() + gw.drain() + + gw.publishLocalEvent({ + payload: { reason: 'idle_exit_hotkey' }, + session_id: 'sid-old', + type: 'dashboard.new_session_requested' + }) + + expect(seen).toContain('dashboard.new_session_requested') + expect(JSON.parse(sidecarSocket.sent.at(-1) ?? '{}')).toEqual({ + jsonrpc: '2.0', + method: 'event', + params: { + payload: { reason: 'idle_exit_hotkey' }, + session_id: 'sid-old', + type: 'dashboard.new_session_requested' + } + }) + + gw.kill() + }) + it('emits exit when attached websocket closes', () => { process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc' const gw = new GatewayClient() diff --git a/ui-tui/src/__tests__/gracefulExit.test.ts b/ui-tui/src/__tests__/gracefulExit.test.ts new file mode 100644 index 00000000000..6c805dfce7c --- /dev/null +++ b/ui-tui/src/__tests__/gracefulExit.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' + +import { shouldExitForSignal } from '../lib/gracefulExit.js' + +describe('shouldExitForSignal', () => { + it('ignores only the signals explicitly disabled for embedded dashboard chat', () => { + expect(shouldExitForSignal('SIGINT', ['SIGINT'])).toBe(false) + expect(shouldExitForSignal('SIGTERM', ['SIGINT'])).toBe(true) + expect(shouldExitForSignal('SIGHUP', ['SIGINT'])).toBe(true) + }) +}) diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts index 0d3fd69c1ed..fa9372d5356 100644 --- a/ui-tui/src/__tests__/useInputHandlers.test.ts +++ b/ui-tui/src/__tests__/useInputHandlers.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest' -import { applyVoiceRecordResponse, shouldFallThroughForScroll } from '../app/useInputHandlers.js' +import { + applyVoiceRecordResponse, + handleIdleHotkeyExit, + shouldAllowIdleHotkeyExit, + shouldFallThroughForScroll +} from '../app/useInputHandlers.js' const baseKey = { downArrow: false, @@ -42,6 +47,38 @@ describe('shouldFallThroughForScroll — keep transcript scrolling alive during }) }) +describe('shouldAllowIdleHotkeyExit', () => { + it('keeps idle exit hotkeys enabled in normal terminals', () => { + expect(shouldAllowIdleHotkeyExit(false)).toBe(true) + }) + + it('disables idle exit hotkeys in dashboard chat', () => { + expect(shouldAllowIdleHotkeyExit(true)).toBe(false) + }) +}) + +describe('handleIdleHotkeyExit', () => { + it('exits in normal terminals', () => { + const actions = { die: vi.fn(), sys: vi.fn() } + + handleIdleHotkeyExit(actions, false) + + expect(actions.die).toHaveBeenCalledTimes(1) + expect(actions.sys).not.toHaveBeenCalled() + }) + + it('asks the dashboard for a fresh chat instead of leaving a ghost session', () => { + const actions = { die: vi.fn(), sys: vi.fn() } + const requestDashboardNewSession = vi.fn() + + handleIdleHotkeyExit(actions, true, requestDashboardNewSession) + + expect(actions.die).not.toHaveBeenCalled() + expect(requestDashboardNewSession).toHaveBeenCalledTimes(1) + expect(actions.sys).toHaveBeenCalledWith('starting a fresh dashboard chat...') + }) +}) + describe('applyVoiceRecordResponse', () => { it('reverts optimistic REC state when the gateway reports voice busy', () => { const setProcessing = vi.fn() diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 20d3493f547..f19cccfe5b5 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -2,6 +2,7 @@ import { forceRedraw, useInput } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useEffect, useRef } from 'react' +import { DASHBOARD_TUI_MODE } from '../config/env.js' import { TYPING_IDLE_MS } from '../config/timing.js' import type { ApprovalRespondResponse, @@ -15,13 +16,30 @@ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionW import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' import { getInputSelection } from './inputSelectionStore.js' -import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' +import type { InputHandlerActions, InputHandlerContext, InputHandlerResult } from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' import { patchTurnState } from './turnStore.js' import { getUiState } from './uiStore.js' const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target +const DASHBOARD_NEW_SESSION_MESSAGE = 'starting a fresh dashboard chat...' + +export const shouldAllowIdleHotkeyExit = (dashboardTuiMode = DASHBOARD_TUI_MODE) => !dashboardTuiMode + +export function handleIdleHotkeyExit( + actions: Pick<InputHandlerActions, 'die' | 'sys'>, + dashboardTuiMode = DASHBOARD_TUI_MODE, + requestDashboardNewSession?: () => void +) { + if (!shouldAllowIdleHotkeyExit(dashboardTuiMode)) { + requestDashboardNewSession?.() + + return actions.sys(DASHBOARD_NEW_SESSION_MESSAGE) + } + + return actions.die() +} /** * Approval / clarify / confirm overlays mount their own `useInput` handlers @@ -505,11 +523,23 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return cActions.clearIn() } - return actions.die() + return handleIdleHotkeyExit(actions, DASHBOARD_TUI_MODE, () => { + gateway.gw.publishLocalEvent({ + payload: { reason: 'idle_exit_hotkey' }, + session_id: live.sid ?? undefined, + type: 'dashboard.new_session_requested' + }) + }) } if (isAction(key, ch, 'd')) { - return actions.die() + return handleIdleHotkeyExit(actions, DASHBOARD_TUI_MODE, () => { + gateway.gw.publishLocalEvent({ + payload: { reason: 'idle_exit_hotkey' }, + session_id: live.sid ?? undefined, + type: 'dashboard.new_session_requested' + }) + }) } if (isAction(key, ch, 'l')) { diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index 3b5b9bee4d4..843512ed76a 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -1,4 +1,5 @@ import type { MouseTrackingMode } from '@hermes/ink' + import { isTermuxTuiMode } from '../lib/termux.js' const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim()) @@ -43,12 +44,19 @@ export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim() // behavior. const mouseTrackingOverride = parseToggle(process.env.HERMES_TUI_MOUSE_TRACKING) const mouseTrackingDisabledLegacy = truthy(process.env.HERMES_TUI_DISABLE_MOUSE) + const resolvedBootMouseEnabled = mouseTrackingOverride ?? (TERMUX_TUI_MODE ? false : !mouseTrackingDisabledLegacy) + export const MOUSE_TRACKING: MouseTrackingMode = resolvedBootMouseEnabled ? 'all' : 'off' export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM) +// Set by the dashboard PTY launcher. This is intentionally narrower than +// INLINE_MODE: users can opt into inline terminal rendering locally, but the +// browser-embedded TUI has no healthy restart path after an idle exit. +export const DASHBOARD_TUI_MODE = truthy(process.env.HERMES_TUI_DASHBOARD) + // HERMES_DEV_CREDITS — dev-only live-spend readout (Δ status segment + "(dev credits)" // banner). Throwaway dev scaffolding; the whole readout gates on this one flag. export const DEV_CREDITS_MODE = truthy(process.env.HERMES_DEV_CREDITS) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 22fee6bccbd..de60d966760 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -5,7 +5,7 @@ import './lib/forceTruecolor.js' import type { FrameEvent } from '@hermes/ink' -import { TERMUX_TUI_MODE } from './config/env.js' +import { DASHBOARD_TUI_MODE, TERMUX_TUI_MODE } from './config/env.js' import { GatewayClient } from './gatewayClient.js' import { setupGracefulExit } from './lib/gracefulExit.js' import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js' @@ -76,7 +76,12 @@ setupGracefulExit({ recordParentLifecycle(`graceful-exit received signal=${signal} → killing gateway`) resetTerminalModes() process.stderr.write(`hermes-tui lifecycle: received ${signal}\n`) - } + }, + // The dashboard chat tab has no in-page restart path after the PTY child + // exits. Ignore SIGINT there so Ctrl+C cannot kill the embedded TUI if raw + // mode briefly drops and the terminal driver turns the keystroke into a + // signal instead of input bytes. SIGTERM/SIGHUP still cleanly shut down. + ignoredSignals: DASHBOARD_TUI_MODE ? ['SIGINT'] : [] }) const stopMemoryMonitor = startMemoryMonitor({ diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 5dfbe880fb1..88ddc0fcdc3 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -307,6 +307,13 @@ export class GatewayClient extends EventEmitter { } } + publishLocalEvent(ev: GatewayEvent) { + const frame = JSON.stringify({ jsonrpc: '2.0', method: 'event', params: ev }) + + this.mirrorEventToSidecar(frame) + this.publish(ev) + } + private handleWebSocketFrame(raw: unknown) { const text = asWireText(raw) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 016171008c1..74a6f7627d1 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -634,6 +634,7 @@ export type GatewayEvent = } | { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' } | { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' } + | { payload?: { reason?: string }; session_id?: string; type: 'dashboard.new_session_requested' } | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } | { payload?: { level?: 'info' | 'warn' | 'error'; message?: string } diff --git a/ui-tui/src/lib/gracefulExit.ts b/ui-tui/src/lib/gracefulExit.ts index 2896fd12651..089269ac1ae 100644 --- a/ui-tui/src/lib/gracefulExit.ts +++ b/ui-tui/src/lib/gracefulExit.ts @@ -1,11 +1,16 @@ interface SetupOptions { cleanups?: (() => Promise<void> | void)[] failsafeMs?: number + ignoredSignals?: GracefulSignal[] onError?: (scope: 'uncaughtException' | 'unhandledRejection', err: unknown) => void onSignal?: (signal: NodeJS.Signals) => void } -const SIGNAL_EXIT_CODE: Record<'SIGHUP' | 'SIGINT' | 'SIGTERM', number> = { +export type GracefulSignal = 'SIGHUP' | 'SIGINT' | 'SIGTERM' + +const SIGNALS: readonly GracefulSignal[] = ['SIGINT', 'SIGTERM', 'SIGHUP'] + +const SIGNAL_EXIT_CODE: Record<GracefulSignal, number> = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143 @@ -13,7 +18,16 @@ const SIGNAL_EXIT_CODE: Record<'SIGHUP' | 'SIGINT' | 'SIGTERM', number> = { let wired = false -export function setupGracefulExit({ cleanups = [], failsafeMs = 4000, onError, onSignal }: SetupOptions = {}) { +export const shouldExitForSignal = (signal: GracefulSignal, ignoredSignals: readonly GracefulSignal[] = []) => + !ignoredSignals.includes(signal) + +export function setupGracefulExit({ + cleanups = [], + failsafeMs = 4000, + ignoredSignals = [], + onError, + onSignal +}: SetupOptions = {}) { if (wired) { return } @@ -38,8 +52,14 @@ export function setupGracefulExit({ cleanups = [], failsafeMs = 4000, onError, o void Promise.allSettled(cleanups.map(fn => Promise.resolve().then(fn))).finally(() => process.exit(code)) } - for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { - process.on(sig, () => exit(SIGNAL_EXIT_CODE[sig], sig)) + for (const sig of SIGNALS) { + process.on(sig, () => { + if (!shouldExitForSignal(sig, ignoredSignals)) { + return + } + + exit(SIGNAL_EXIT_CODE[sig], sig) + }) } process.on('uncaughtException', err => onError?.('uncaughtException', err)) diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 1a53741d8fd..e6e3437781a 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -74,9 +74,15 @@ interface ChatSidebarProps { /** Management profile from the dashboard switcher — scopes session.create. */ profile?: string; className?: string; + onDashboardNewSessionRequest?: () => void; } -export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) { +export function ChatSidebar({ + channel, + profile, + className, + onDashboardNewSessionRequest, +}: ChatSidebarProps) { // `version` bumps on reconnect; gw is derived so we never call setState // for it inside an effect (React 19's set-state-in-effect rule). The // counter is the dependency on purpose — it's not read in the memo body, @@ -112,9 +118,12 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) { useEffect(() => { let cancelled = false; - setSessionId(null); - setInfo({}); - setError(null); + queueMicrotask(() => { + if (cancelled) return; + setSessionId(null); + setInfo({}); + setError(null); + }); const offState = gw.onState(setState); const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => { @@ -233,7 +242,9 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) { const { type, payload } = frame.params; - if (type === "tool.start") { + if (type === "dashboard.new_session_requested") { + onDashboardNewSessionRequest?.(); + } else if (type === "tool.start") { const p = payload as | { tool_id?: string; name?: string; context?: string } | undefined; @@ -309,7 +320,7 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) { unmounting = true; ws?.close(); }; - }, [channel, version]); + }, [channel, onDashboardNewSessionRequest, version]); const reconnect = useCallback(() => { setError(null); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 4e3a6c23151..dcb006e0da2 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -153,6 +153,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { setBanner(null); setReconnectNonce((n) => n + 1); }, []); + const startFreshDashboardChat = useCallback(() => { + const next = new URLSearchParams(searchParams); + + next.delete("resume"); + setSearchParams(next, { replace: true }); + setSessionEnded(false); + setBanner(null); + setReconnectNonce((n) => n + 1); + }, [searchParams, setSearchParams]); // Raw state for the mobile side-sheet + a derived value that force- // closes whenever the chat tab isn't active. The *derived* value is // what side-effects (body-scroll lock, keydown listener, portal render) @@ -881,7 +890,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { "border-t border-current/10", )} > - <ChatSidebar channel={channel} profile={scopedProfile} /> + <ChatSidebar + channel={channel} + profile={scopedProfile} + onDashboardNewSessionRequest={startFreshDashboardChat} + /> </div> </div> </>, @@ -967,7 +980,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { className="flex min-h-0 shrink-0 flex-col overflow-hidden lg:h-full lg:w-80" > <div className="min-h-0 flex-1 overflow-hidden"> - <ChatSidebar channel={channel} profile={scopedProfile} /> + <ChatSidebar + channel={channel} + profile={scopedProfile} + onDashboardNewSessionRequest={startFreshDashboardChat} + /> </div> </div> )} From f741e70791c1c69b501fdb98da80bec3e4d130c0 Mon Sep 17 00:00:00 2001 From: Shannon Sands <shannon.sands.1979@gmail.com> Date: Fri, 19 Jun 2026 14:27:42 +1000 Subject: [PATCH 036/470] Add Slack allowed users setup field --- hermes_cli/config.py | 7 +++++ hermes_cli/web_server.py | 22 ++++++++++++-- tests/hermes_cli/test_web_server.py | 47 +++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f698c11d5ac..8c790e7e856 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3439,6 +3439,13 @@ OPTIONAL_ENV_VARS = { "password": True, "category": "messaging", }, + "SLACK_ALLOWED_USERS": { + "description": "Comma-separated Slack member IDs allowed to use Hermes, e.g. U01ABC2DEF3. Without this, Slack may connect but deny messages by default.", + "prompt": "Allowed Slack member IDs", + "url": "https://api.slack.com/apps", + "password": False, + "category": "messaging", + }, "MATTERMOST_URL": { "description": "Mattermost server URL (e.g. https://mm.example.com)", "prompt": "Mattermost server URL", diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 2dbb316d32d..b1320875c53 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2325,6 +2325,23 @@ def _gateway_display_command(profile: Optional[str], verb: str) -> str: return " ".join(["hermes", *_gateway_subcommand(profile, verb)]) +def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> None: + """Reject platform credentials that are clearly in the wrong field.""" + if platform_id != "slack" or not value: + return + + if key == "SLACK_BOT_TOKEN" and not value.startswith("xoxb-"): + raise HTTPException( + status_code=400, + detail="Slack Bot Token must start with xoxb-. Paste the bot token from OAuth & Permissions.", + ) + if key == "SLACK_APP_TOKEN" and not value.startswith("xapp-"): + raise HTTPException( + status_code=400, + detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.", + ) + + def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]: """Spawn ``hermes gateway restart``, reusing an in-flight restart. @@ -4155,9 +4172,9 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { }, "slack": { "name": "Slack", - "description": "Use Hermes from Slack via Socket Mode.", + "description": "Use Hermes from Slack via Socket Mode. Add allowed Slack member IDs so connected bots can respond.", "docs_url": "https://api.slack.com/apps", - "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), + "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"), "required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), }, "mattermost": { @@ -5221,6 +5238,7 @@ async def update_messaging_platform( ) trimmed = value.strip() if trimmed: + _validate_messaging_env_value(platform_id, key, trimmed) save_env_value(key, trimmed) if body.enabled is not None: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index e65a28101cd..3f6ed3e0435 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1552,6 +1552,24 @@ class TestWebServerEndpoints: assert telegram["enabled"] is False assert any(field["key"] == "TELEGRAM_BOT_TOKEN" and field["required"] for field in telegram["env_vars"]) + def test_slack_messaging_platform_exposes_user_allowlist(self): + resp = self.client.get("/api/messaging/platforms") + + assert resp.status_code == 200 + platforms = resp.json()["platforms"] + slack = next(platform for platform in platforms if platform["id"] == "slack") + fields = {field["key"]: field for field in slack["env_vars"]} + + assert "allowed Slack member IDs" in slack["description"] + assert set(fields) >= { + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + "SLACK_ALLOWED_USERS", + } + assert fields["SLACK_ALLOWED_USERS"]["prompt"] == "Allowed Slack member IDs" + assert fields["SLACK_ALLOWED_USERS"]["is_password"] is False + assert "member IDs" in fields["SLACK_ALLOWED_USERS"]["description"] + def test_weixin_messaging_metadata_describes_personal_ilink_setup(self): resp = self.client.get("/api/messaging/platforms") @@ -1628,6 +1646,35 @@ class TestWebServerEndpoints: telegram = next(platform for platform in status if platform["id"] == "telegram") assert telegram["enabled"] is False + def test_update_messaging_platform_saves_slack_allowed_users(self): + from hermes_cli.config import load_env + + resp = self.client.put( + "/api/messaging/platforms/slack", + json={"env": {"SLACK_ALLOWED_USERS": "U01ABC2DEF3,U04XYZ5LMN6"}}, + ) + + assert resp.status_code == 200 + assert load_env()["SLACK_ALLOWED_USERS"] == "U01ABC2DEF3,U04XYZ5LMN6" + + def test_update_messaging_platform_rejects_swapped_slack_bot_token(self): + resp = self.client.put( + "/api/messaging/platforms/slack", + json={"env": {"SLACK_BOT_TOKEN": "xapp-wrong-token-type"}}, + ) + + assert resp.status_code == 400 + assert "xoxb-" in resp.json()["detail"] + + def test_update_messaging_platform_rejects_swapped_slack_app_token(self): + resp = self.client.put( + "/api/messaging/platforms/slack", + json={"env": {"SLACK_APP_TOKEN": "xoxb-wrong-token-type"}}, + ) + + assert resp.status_code == 400 + assert "xapp-" in resp.json()["detail"] + def test_messaging_platform_test_reports_missing_required_setup(self): resp = self.client.put("/api/messaging/platforms/discord", json={"enabled": True}) assert resp.status_code == 200 From d9190491a687d7f29fee5e09c2418d66025e9660 Mon Sep 17 00:00:00 2001 From: Shannon Sands <shannon.sands.1979@gmail.com> Date: Fri, 19 Jun 2026 14:37:16 +1000 Subject: [PATCH 037/470] Add Slack setup hints and field validation --- hermes_cli/config.py | 3 + hermes_cli/web_server.py | 13 +++++ tests/hermes_cli/test_web_server.py | 12 ++++ web/src/lib/api.ts | 1 + web/src/pages/ChannelsPage.tsx | 85 ++++++++++++++++++++++++++--- 5 files changed, 106 insertions(+), 8 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8c790e7e856..c81df25c03b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3426,6 +3426,7 @@ OPTIONAL_ENV_VARS = { "Required scopes: chat:write, app_mentions:read, channels:history, groups:history, " "im:history, im:read, im:write, users:read, files:read, files:write", "prompt": "Slack Bot Token (xoxb-...)", + "help": "In your Slack app, add the required bot scopes, install the app to the workspace, then copy OAuth & Permissions > Bot User OAuth Token.", "url": "https://api.slack.com/apps", "password": True, "category": "messaging", @@ -3435,6 +3436,7 @@ OPTIONAL_ENV_VARS = { "App-Level Tokens. Also ensure Event Subscriptions include: message.im, " "message.channels, message.groups, app_mention", "prompt": "Slack App Token (xapp-...)", + "help": "In your Slack app, enable Socket Mode, then create Basic Information > App-Level Tokens with the connections:write scope.", "url": "https://api.slack.com/apps", "password": True, "category": "messaging", @@ -3442,6 +3444,7 @@ OPTIONAL_ENV_VARS = { "SLACK_ALLOWED_USERS": { "description": "Comma-separated Slack member IDs allowed to use Hermes, e.g. U01ABC2DEF3. Without this, Slack may connect but deny messages by default.", "prompt": "Allowed Slack member IDs", + "help": "In Slack, open your profile, choose More or the three-dot menu, then Copy member ID. Add multiple IDs comma-separated.", "url": "https://api.slack.com/apps", "password": False, "category": "messaging", diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b1320875c53..b890f68649e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2340,6 +2340,18 @@ def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> Non status_code=400, detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.", ) + if key == "SLACK_ALLOWED_USERS": + user_ids = [part.strip() for part in value.split(",")] + invalid = [ + user_id + for user_id in user_ids + if not user_id or not re.fullmatch(r"[UW][A-Z0-9]{2,}", user_id) + ] + if invalid: + raise HTTPException( + status_code=400, + detail="Slack allowed user IDs must be comma-separated member IDs like U01ABC2DEF3.", + ) def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]: @@ -4659,6 +4671,7 @@ def _messaging_env_info(key: str) -> dict[str, Any]: return { "description": info.get("description", ""), "prompt": info.get("prompt", key), + "help": info.get("help", ""), "url": info.get("url"), "is_password": info.get("password", False), "advanced": info.get("advanced", False), diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 3f6ed3e0435..d44c789b3e3 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1569,6 +1569,9 @@ class TestWebServerEndpoints: assert fields["SLACK_ALLOWED_USERS"]["prompt"] == "Allowed Slack member IDs" assert fields["SLACK_ALLOWED_USERS"]["is_password"] is False assert "member IDs" in fields["SLACK_ALLOWED_USERS"]["description"] + assert "Bot User OAuth Token" in fields["SLACK_BOT_TOKEN"]["help"] + assert "App-Level Tokens" in fields["SLACK_APP_TOKEN"]["help"] + assert "Copy member ID" in fields["SLACK_ALLOWED_USERS"]["help"] def test_weixin_messaging_metadata_describes_personal_ilink_setup(self): resp = self.client.get("/api/messaging/platforms") @@ -1675,6 +1678,15 @@ class TestWebServerEndpoints: assert resp.status_code == 400 assert "xapp-" in resp.json()["detail"] + def test_update_messaging_platform_rejects_invalid_slack_allowed_users(self): + resp = self.client.put( + "/api/messaging/platforms/slack", + json={"env": {"SLACK_ALLOWED_USERS": "U01ABC2DEF3,not-a-user"}}, + ) + + assert resp.status_code == 400 + assert "member IDs" in resp.json()["detail"] + def test_messaging_platform_test_reports_missing_required_setup(self): resp = self.client.put("/api/messaging/platforms/discord", json={"enabled": True}) assert resp.status_code == 200 diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ec03997b6c6..3955d3324c9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1346,6 +1346,7 @@ export interface MessagingPlatformEnvVar { redacted_value: string | null; description: string; prompt: string; + help: string; url: string | null; is_password: boolean; advanced: boolean; diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index d42ab7b9e74..84791738a25 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -4,6 +4,7 @@ import { Check, CheckCircle2, ExternalLink, + Info, PlugZap, QrCode, Radio, @@ -55,6 +56,34 @@ function stateBadge(state: string) { } const TELEGRAM_USER_ID_RE = /^\d+$/; +const SLACK_MEMBER_ID_RE = /^[UW][A-Z0-9]{2,}$/; +const SLACK_TOKEN_PREFIXES: Record<string, string> = { + SLACK_BOT_TOKEN: "xoxb-", + SLACK_APP_TOKEN: "xapp-", +}; + +function validateMessagingEnvField(field: MessagingPlatformEnvVar, value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + const expectedPrefix = SLACK_TOKEN_PREFIXES[field.key]; + if (expectedPrefix && !trimmed.startsWith(expectedPrefix)) { + return `${field.prompt || field.key} must start with ${expectedPrefix}`; + } + + if (field.key === "SLACK_ALLOWED_USERS") { + const parts = trimmed.split(",").map((part) => part.trim()); + if (parts.some((part) => !part)) { + return "Slack member IDs must be comma-separated without empty entries."; + } + const invalid = parts.find((part) => !SLACK_MEMBER_ID_RE.test(part)); + if (invalid) { + return `${invalid} does not look like a Slack member ID. Use IDs like U01ABC2DEF3.`; + } + } + + return null; +} function formatExpiry(expiresAt: string): string { const ms = Date.parse(expiresAt) - Date.now(); @@ -83,8 +112,12 @@ export default function ChannelsPage() { // Config modal state const [editing, setEditing] = useState<MessagingPlatform | null>(null); const [draftEnv, setDraftEnv] = useState<Record<string, string>>({}); + const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); const [saving, setSaving] = useState(false); - const closeEdit = useCallback(() => setEditing(null), []); + const closeEdit = useCallback(() => { + setEditing(null); + setFieldErrors({}); + }, []); const editModalRef = useModalBehavior({ open: editing !== null, onClose: closeEdit }); // Per-card busy + restart-needed tracking @@ -116,6 +149,7 @@ export default function ChannelsPage() { initial[v.key] = ""; }); setDraftEnv(initial); + setFieldErrors({}); setEditing(platform); }; @@ -138,6 +172,16 @@ export default function ChannelsPage() { showToast(`${missing[0].prompt || missing[0].key} is required`, "error"); return; } + const nextFieldErrors: Record<string, string> = {}; + editing.env_vars.forEach((field) => { + const message = validateMessagingEnvField(field, draftEnv[field.key] || ""); + if (message) nextFieldErrors[field.key] = message; + }); + if (Object.keys(nextFieldErrors).length > 0) { + setFieldErrors(nextFieldErrors); + showToast("Fix the highlighted fields before saving.", "error"); + return; + } setSaving(true); try { const body: MessagingPlatformUpdate = { env, enabled: true }; @@ -326,10 +370,22 @@ export default function ChannelsPage() { </p> {editing.env_vars.map((field: MessagingPlatformEnvVar) => ( <div className="grid gap-1.5" key={field.key}> - <Label htmlFor={`field-${field.key}`}> - {field.prompt || field.key} - {field.required ? " *" : ""} - </Label> + <div className="flex items-center gap-1.5"> + <Label htmlFor={`field-${field.key}`}> + {field.prompt || field.key} + {field.required ? " *" : ""} + </Label> + {field.help && ( + <span + aria-label={field.help} + className="inline-flex text-muted-foreground hover:text-foreground" + role="img" + title={field.help} + > + <Info className="h-3.5 w-3.5" /> + </span> + )} + </div> {field.description && ( <span className="text-xs text-muted-foreground"> {field.description} @@ -344,10 +400,23 @@ export default function ChannelsPage() { : field.key } value={draftEnv[field.key] ?? ""} - onChange={(e) => - setDraftEnv((prev) => ({ ...prev, [field.key]: e.target.value })) - } + aria-invalid={Boolean(fieldErrors[field.key])} + onChange={(e) => { + const nextValue = e.target.value; + setDraftEnv((prev) => ({ ...prev, [field.key]: nextValue })); + setFieldErrors((prev) => { + if (!prev[field.key]) return prev; + const next = { ...prev }; + delete next[field.key]; + return next; + }); + }} /> + {fieldErrors[field.key] && ( + <span className="text-xs text-destructive"> + {fieldErrors[field.key]} + </span> + )} </div> ))} From 83c034bd5bc855955a825ff4acd1ed11edab6c3d Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:18:15 +0530 Subject: [PATCH 038/470] fix(dashboard): accept Slack allow-all wildcard in allowed-users validation The new SLACK_ALLOWED_USERS validation rejected '*', but the Slack gateway honors '*' as an allow-all wildcard (gateway/platforms/slack.py DM auth, slash-confirm, and approval-button paths). Accept '*' as a valid list entry in both the API validator and the dashboard form so a value the runtime honors is no longer blocked at setup. --- hermes_cli/web_server.py | 4 +++- tests/hermes_cli/test_web_server.py | 13 +++++++++++++ web/src/pages/ChannelsPage.tsx | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b890f68649e..316bc154fa4 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2342,10 +2342,12 @@ def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> Non ) if key == "SLACK_ALLOWED_USERS": user_ids = [part.strip() for part in value.split(",")] + # "*" is the gateway's allow-all wildcard (see gateway/platforms/slack.py), + # so accept it as a valid entry alongside Slack member IDs (U.../W...). invalid = [ user_id for user_id in user_ids - if not user_id or not re.fullmatch(r"[UW][A-Z0-9]{2,}", user_id) + if user_id != "*" and (not user_id or not re.fullmatch(r"[UW][A-Z0-9]{2,}", user_id)) ] if invalid: raise HTTPException( diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index d44c789b3e3..d7a4dbcbbf9 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1687,6 +1687,19 @@ class TestWebServerEndpoints: assert resp.status_code == 400 assert "member IDs" in resp.json()["detail"] + def test_update_messaging_platform_accepts_slack_allowed_users_wildcard(self): + # "*" is the gateway's allow-all wildcard (gateway/platforms/slack.py), + # so the dashboard must accept it rather than rejecting it as malformed. + from hermes_cli.config import load_env + + resp = self.client.put( + "/api/messaging/platforms/slack", + json={"env": {"SLACK_ALLOWED_USERS": "*"}}, + ) + + assert resp.status_code == 200 + assert load_env()["SLACK_ALLOWED_USERS"] == "*" + def test_messaging_platform_test_reports_missing_required_setup(self): resp = self.client.put("/api/messaging/platforms/discord", json={"enabled": True}) assert resp.status_code == 200 diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index 84791738a25..db56beb1925 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -76,7 +76,7 @@ function validateMessagingEnvField(field: MessagingPlatformEnvVar, value: string if (parts.some((part) => !part)) { return "Slack member IDs must be comma-separated without empty entries."; } - const invalid = parts.find((part) => !SLACK_MEMBER_ID_RE.test(part)); + const invalid = parts.find((part) => part !== "*" && !SLACK_MEMBER_ID_RE.test(part)); if (invalid) { return `${invalid} does not look like a Slack member ID. Use IDs like U01ABC2DEF3.`; } From 1ab6f34791e28559911185b308d8bd1b0be5f393 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:22:30 +0530 Subject: [PATCH 039/470] refactor(dashboard): align Slack allowlist validation with gateway parse - Drop empty entries before validating SLACK_ALLOWED_USERS so a trailing or interior comma (which the gateway silently tolerates in gateway/platforms/slack.py) is no longer rejected at the dashboard. - Hoist the member-ID regex to a module-level _SLACK_MEMBER_ID_RE constant and note it stays in sync with the frontend SLACK_MEMBER_ID_RE. - Add a regression test for the trailing-comma case. --- hermes_cli/web_server.py | 14 ++++++++++---- tests/hermes_cli/test_web_server.py | 13 +++++++++++++ web/src/pages/ChannelsPage.tsx | 11 +++++++---- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 316bc154fa4..b0d51e2481e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2325,6 +2325,11 @@ def _gateway_display_command(profile: Optional[str], verb: str) -> str: return " ".join(["hermes", *_gateway_subcommand(profile, verb)]) +# Slack member IDs (users U..., Enterprise Grid W...). Kept in sync with the +# frontend SLACK_MEMBER_ID_RE in web/src/pages/ChannelsPage.tsx. +_SLACK_MEMBER_ID_RE = re.compile(r"[UW][A-Z0-9]{2,}") + + def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> None: """Reject platform credentials that are clearly in the wrong field.""" if platform_id != "slack" or not value: @@ -2341,13 +2346,14 @@ def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> Non detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.", ) if key == "SLACK_ALLOWED_USERS": - user_ids = [part.strip() for part in value.split(",")] - # "*" is the gateway's allow-all wildcard (see gateway/platforms/slack.py), - # so accept it as a valid entry alongside Slack member IDs (U.../W...). + # Mirror the gateway's parse (gateway/platforms/slack.py): split on comma, + # strip, and drop empty entries so a trailing/interior comma isn't rejected + # here when the runtime would accept it. "*" is the allow-all wildcard. + user_ids = [part.strip() for part in value.split(",") if part.strip()] invalid = [ user_id for user_id in user_ids - if user_id != "*" and (not user_id or not re.fullmatch(r"[UW][A-Z0-9]{2,}", user_id)) + if user_id != "*" and not _SLACK_MEMBER_ID_RE.fullmatch(user_id) ] if invalid: raise HTTPException( diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index d7a4dbcbbf9..7416ec0b87a 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1700,6 +1700,19 @@ class TestWebServerEndpoints: assert resp.status_code == 200 assert load_env()["SLACK_ALLOWED_USERS"] == "*" + def test_update_messaging_platform_accepts_slack_allowed_users_trailing_comma(self): + # The gateway drops empty entries (gateway/platforms/slack.py), so a + # trailing/interior comma must not be rejected by the dashboard. + from hermes_cli.config import load_env + + resp = self.client.put( + "/api/messaging/platforms/slack", + json={"env": {"SLACK_ALLOWED_USERS": "U01ABC2DEF3,,W04XYZ5LMN6,"}}, + ) + + assert resp.status_code == 200 + assert load_env()["SLACK_ALLOWED_USERS"] == "U01ABC2DEF3,,W04XYZ5LMN6," + def test_messaging_platform_test_reports_missing_required_setup(self): resp = self.client.put("/api/messaging/platforms/discord", json={"enabled": True}) assert resp.status_code == 200 diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index db56beb1925..7658c0cd61a 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -72,10 +72,13 @@ function validateMessagingEnvField(field: MessagingPlatformEnvVar, value: string } if (field.key === "SLACK_ALLOWED_USERS") { - const parts = trimmed.split(",").map((part) => part.trim()); - if (parts.some((part) => !part)) { - return "Slack member IDs must be comma-separated without empty entries."; - } + // Mirror the gateway's parse (gateway/platforms/slack.py): drop empty + // entries so a trailing/interior comma isn't rejected here. "*" is the + // allow-all wildcard the gateway honors. + const parts = trimmed + .split(",") + .map((part) => part.trim()) + .filter(Boolean); const invalid = parts.find((part) => part !== "*" && !SLACK_MEMBER_ID_RE.test(part)); if (invalid) { return `${invalid} does not look like a Slack member ID. Use IDs like U01ABC2DEF3.`; From c7b7f92ec14a5c43deef844804f0bf6a7f2d992d Mon Sep 17 00:00:00 2001 From: Eurekaxun <eurekaxun@163.com> Date: Tue, 2 Jun 2026 14:33:12 +0800 Subject: [PATCH 040/470] fix(openviking): sync structured turns with tool parts --- plugins/memory/openviking/__init__.py | 339 +++++++++++++++++- tests/openviking_plugin/test_openviking.py | 274 ++++++++++++++ .../memory/test_openviking_provider.py | 47 ++- 3 files changed, 639 insertions(+), 21 deletions(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 7ebe6869a46..c7b05a4864c 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -70,6 +70,8 @@ _TIMEOUT = 30.0 _SESSION_DRAIN_TIMEOUT = 10.0 _DEFERRED_COMMIT_TIMEOUT = (_TIMEOUT * 2) + 5.0 _REMOTE_RESOURCE_PREFIXES = ("http://", "https://", "git@", "ssh://", "git://") +_SYNC_TRACE_ENV = "HERMES_OPENVIKING_SYNC_TRACE" +_OPENVIKING_RECALL_TOOL_NAMES = {"viking_search", "viking_read", "viking_browse"} # Maps the viking_remember `category` enum to a viking:// subdirectory. # Keep in sync with REMEMBER_SCHEMA.parameters.properties.category.enum. @@ -156,6 +158,18 @@ def _derive_openviking_user_text(content: Any) -> str: return extract_user_instruction_from_skill_message(content) or "" +def _sync_trace_enabled() -> bool: + return os.environ.get(_SYNC_TRACE_ENV, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _preview(value: Any, limit: int = 160) -> str: + text = "" if value is None else str(value) + text = text.replace("\n", "\\n") + if len(text) > limit: + return text[:limit] + "..." + return text + + # --------------------------------------------------------------------------- # Process-level atexit safety net — ensures pending sessions are committed # even if shutdown_memory_provider is never called (e.g. gateway crash, @@ -2221,7 +2235,10 @@ class OpenVikingMemoryProvider(MemoryProvider): def _commit_session(self, sid: str, turn_count: int, *, context: str) -> bool: try: - self._client.post(f"/api/v1/sessions/{sid}/commit") + self._client.post( + f"/api/v1/sessions/{sid}/commit", + {"keep_recent_count": 0}, + ) self._mark_session_committed(sid) logger.info("OpenViking session %s committed %s (%d turns)", sid, context, turn_count) return True @@ -2293,7 +2310,261 @@ class OpenVikingMemoryProvider(MemoryProvider): with self._prefetch_lock: self._prefetch_result = "" - def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None: + @staticmethod + def _message_text(content: Any) -> str: + """Extract text from OpenAI-style string/list content.""" + if isinstance(content, str): + return content + if isinstance(content, list): + chunks = [] + for block in content: + if isinstance(block, str): + chunks.append(block) + elif isinstance(block, dict): + if block.get("type") == "text" and isinstance(block.get("text"), str): + chunks.append(block["text"]) + elif isinstance(block.get("content"), str): + chunks.append(block["content"]) + return "\n".join(chunk for chunk in chunks if chunk) + if content is None: + return "" + return str(content) + + @classmethod + def _message_matches_text(cls, message: Dict[str, Any], expected: Any) -> bool: + expected_text = cls._message_text(expected).strip() + if not expected_text: + return False + actual_text = cls._message_text(message.get("content")).strip() + return actual_text == expected_text + + @classmethod + def _extract_current_turn_messages( + cls, + messages: Optional[List[Dict[str, Any]]], + user_content: str, + assistant_content: str, + ) -> List[Dict[str, Any]]: + """Slice the completed turn out of Hermes' full canonical transcript.""" + if not messages: + return [] + + end_idx: Optional[int] = None + if cls._message_text(assistant_content).strip(): + for idx in range(len(messages) - 1, -1, -1): + message = messages[idx] + if ( + isinstance(message, dict) + and message.get("role") == "assistant" + and cls._message_matches_text(message, assistant_content) + ): + end_idx = idx + break + if end_idx is None: + for idx in range(len(messages) - 1, -1, -1): + message = messages[idx] + if isinstance(message, dict) and message.get("role") == "assistant": + end_idx = idx + break + if end_idx is None: + end_idx = len(messages) - 1 + + start_idx: Optional[int] = None + if cls._message_text(user_content).strip(): + for idx in range(end_idx, -1, -1): + message = messages[idx] + if ( + isinstance(message, dict) + and message.get("role") == "user" + and cls._message_matches_text(message, user_content) + ): + start_idx = idx + break + if start_idx is None: + for idx in range(end_idx, -1, -1): + message = messages[idx] + if isinstance(message, dict) and message.get("role") == "user": + start_idx = idx + break + if start_idx is None: + return [] + + return [message for message in messages[start_idx : end_idx + 1] if isinstance(message, dict)] + + @staticmethod + def _tool_call_id(tool_call: Dict[str, Any]) -> str: + return str(tool_call.get("id") or tool_call.get("tool_call_id") or "") + + @staticmethod + def _tool_call_name(tool_call: Dict[str, Any]) -> str: + function = tool_call.get("function") + if isinstance(function, dict): + return str(function.get("name") or "") + return str(tool_call.get("name") or "") + + @staticmethod + def _is_openviking_recall_tool_name(tool_name: Any) -> bool: + return str(tool_name or "").strip().lower() in _OPENVIKING_RECALL_TOOL_NAMES + + @staticmethod + def _tool_call_input(tool_call: Dict[str, Any]) -> Dict[str, Any]: + function = tool_call.get("function") + raw_args: Any = None + if isinstance(function, dict): + raw_args = function.get("arguments") + if raw_args is None: + raw_args = tool_call.get("args") + if raw_args is None: + return {} + if isinstance(raw_args, dict): + return raw_args + if isinstance(raw_args, str): + if not raw_args.strip(): + return {} + try: + parsed = json.loads(raw_args) + except Exception: + return {"value": raw_args} + if isinstance(parsed, dict): + return parsed + return {"value": parsed} + return {"value": raw_args} + + @classmethod + def _tool_result_status(cls, message: Dict[str, Any]) -> str: + raw_status = str(message.get("status") or message.get("tool_status") or "").lower() + if raw_status in {"error", "failed", "failure"}: + return "error" + if raw_status in {"completed", "complete", "success", "succeeded"}: + return "completed" + + text = cls._message_text(message.get("content")).strip() + if text: + try: + parsed = json.loads(text) + except Exception: + parsed = None + if isinstance(parsed, dict): + status = str(parsed.get("status") or "").lower() + exit_code = parsed.get("exit_code") + if ( + status in {"error", "failed", "failure"} + or parsed.get("success") is False + or bool(parsed.get("error")) + or (isinstance(exit_code, int) and exit_code != 0) + ): + return "error" + return "completed" + + @classmethod + def _messages_to_openviking_batch( + cls, + messages: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """Convert Hermes canonical messages into OpenViking batch payloads.""" + tool_calls_by_id: Dict[str, Dict[str, Any]] = {} + completed_tool_ids: set[str] = set() + skipped_tool_ids: set[str] = set() + for message in messages: + if not isinstance(message, dict): + continue + if message.get("role") == "tool": + tool_id = str(message.get("tool_call_id") or message.get("id") or "") + if tool_id: + completed_tool_ids.add(tool_id) + if cls._is_openviking_recall_tool_name(message.get("name")): + skipped_tool_ids.add(tool_id) + continue + if message.get("role") != "assistant": + continue + for tool_call in message.get("tool_calls") or []: + if not isinstance(tool_call, dict): + continue + tool_id = cls._tool_call_id(tool_call) + tool_name = cls._tool_call_name(tool_call) + if tool_id: + tool_calls_by_id[tool_id] = { + "tool_name": tool_name, + "tool_input": cls._tool_call_input(tool_call), + } + if cls._is_openviking_recall_tool_name(tool_name): + skipped_tool_ids.add(tool_id) + + payload_messages: List[Dict[str, Any]] = [] + pending_tool_parts: List[Dict[str, Any]] = [] + + def flush_tool_parts() -> None: + nonlocal pending_tool_parts + if pending_tool_parts: + payload_messages.append({"role": "user", "parts": pending_tool_parts}) + pending_tool_parts = [] + + for message in messages: + if not isinstance(message, dict): + continue + + role = str(message.get("role") or "") + if role in {"system", "developer"}: + continue + + if role == "tool": + tool_id = str(message.get("tool_call_id") or message.get("id") or "") + prior_call = tool_calls_by_id.get(tool_id, {}) + tool_name = str(message.get("name") or prior_call.get("tool_name") or "") + if tool_id in skipped_tool_ids or cls._is_openviking_recall_tool_name(tool_name): + continue + tool_part = { + "type": "tool", + "tool_id": tool_id, + "tool_name": tool_name, + "tool_input": prior_call.get("tool_input", {}), + "tool_output": cls._message_text(message.get("content")), + "tool_status": cls._tool_result_status(message), + } + pending_tool_parts.append(tool_part) + continue + + if role not in {"user", "assistant"}: + continue + + flush_tool_parts() + parts: List[Dict[str, Any]] = [] + text = cls._message_text(message.get("content")) + if text: + parts.append({"type": "text", "text": text}) + + if role == "assistant": + for tool_call in message.get("tool_calls") or []: + if not isinstance(tool_call, dict): + continue + tool_id = cls._tool_call_id(tool_call) + tool_name = cls._tool_call_name(tool_call) + if tool_id in skipped_tool_ids or cls._is_openviking_recall_tool_name(tool_name): + continue + if tool_id in completed_tool_ids: + continue + parts.append({ + "type": "tool", + "tool_id": tool_id, + "tool_name": tool_name, + "tool_input": cls._tool_call_input(tool_call), + "tool_status": "pending", + }) + + if parts: + payload_messages.append({"role": role, "parts": parts}) + + flush_tool_parts() + return payload_messages + + def sync_turn( + self, + user_content: str, + assistant_content: str, + *, + session_id: str = "", + messages: Optional[List[Dict[str, Any]]] = None, + ) -> None: """Record the conversation turn in OpenViking's session (non-blocking).""" if not self._client: return @@ -2302,6 +2573,37 @@ class OpenVikingMemoryProvider(MemoryProvider): if not user_content: return + turn_messages = ( + self._extract_current_turn_messages(messages, user_content, assistant_content) + if messages is not None + else [] + ) + if turn_messages: + turn_messages = [dict(message) for message in turn_messages] + for message in turn_messages: + if message.get("role") == "user": + message["content"] = user_content + break + batch_messages = self._messages_to_openviking_batch(turn_messages) + + if _sync_trace_enabled(): + logger.info( + "OpenViking sync_turn trace: session_arg=%r cached_session=%r " + "messages_param_supported=true messages_present=%s message_count=%s " + "turn_message_count=%d batch_message_count=%d user_len=%d assistant_len=%d " + "user_preview=%r assistant_preview=%r", + session_id, + self._session_id, + messages is not None, + len(messages) if messages is not None else None, + len(turn_messages), + len(batch_messages), + len(str(user_content or "")), + len(str(assistant_content or "")), + _preview(user_content), + _preview(assistant_content), + ) + # Snapshot the sid and bump the turn counter atomically so a # concurrent on_session_switch/on_session_end can't interleave its # snapshot+reset between the read and the increment (lost turn) and so @@ -2313,24 +2615,39 @@ class OpenVikingMemoryProvider(MemoryProvider): self._turn_count += 1 def _sync(): - try: - client = self._new_client() + def _post_turn(client: _VikingClient) -> None: + if batch_messages: + payload = {"messages": batch_messages} + if _sync_trace_enabled(): + logger.info( + "OpenViking sync_turn trace: POST /api/v1/sessions/%s/messages/batch payload=%s", + sid, + json.dumps(payload, ensure_ascii=False), + ) + try: + client.post(f"/api/v1/sessions/{sid}/messages/batch", payload) + return + except Exception as batch_error: + logger.warning( + "OpenViking structured sync failed; falling back to text sync: %s", + batch_error, + ) + self._post_session_turn( client, sid, user_content[:4000], - assistant_content[:4000], + self._message_text(assistant_content)[:4000], ) + + try: + client = self._new_client() + _post_turn(client) except Exception as e: logger.debug("OpenViking sync_turn failed, reconnecting: %s", e) try: client = self._new_client() - self._post_session_turn( - client, - sid, - user_content[:4000], - assistant_content[:4000], - ) + _post_turn(client) except Exception as retry_error: logger.warning("OpenViking sync_turn failed: %s", retry_error) diff --git a/tests/openviking_plugin/test_openviking.py b/tests/openviking_plugin/test_openviking.py index f10fc502000..ee5d1eb2373 100644 --- a/tests/openviking_plugin/test_openviking.py +++ b/tests/openviking_plugin/test_openviking.py @@ -265,6 +265,280 @@ class TestOpenVikingSkillQuerySafety: assert RecordingVikingClient.calls == [] +class TestOpenVikingTurnConversion: + def test_extract_current_turn_anchors_on_latest_matching_user_and_assistant(self): + messages = [ + {"role": "user", "content": "Please inspect the repository for assemble hooks."}, + {"role": "assistant", "content": "Earlier answer."}, + {"role": "user", "content": "Please inspect the repository for assemble hooks."}, + { + "role": "assistant", + "content": "I will search the codebase.", + "tool_calls": [ + { + "id": "call_rg_1", + "type": "function", + "function": { + "name": "shell_command", + "arguments": json.dumps({"command": "rg assemble"}), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_rg_1", + "name": "shell_command", + "content": "agent/context_engine.py: no preassemble hook", + }, + {"role": "assistant", "content": "The current main does not expose assemble."}, + ] + + turn = OpenVikingMemoryProvider._extract_current_turn_messages( + messages, + "Please inspect the repository for assemble hooks.", + "The current main does not expose assemble.", + ) + + assert turn == messages[2:] + + def test_messages_to_openviking_batch_coalesces_tool_results(self): + turn = [ + {"role": "user", "content": "Please inspect the repository for assemble hooks."}, + { + "role": "assistant", + "content": "I will search the codebase.", + "tool_calls": [ + { + "id": "call_rg_1", + "type": "function", + "function": { + "name": "shell_command", + "arguments": json.dumps({"command": "rg assemble"}), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_rg_1", + "name": "shell_command", + "content": "agent/context_engine.py: no preassemble hook", + }, + {"role": "assistant", "content": "The current main does not expose assemble."}, + ] + + batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn) + + assert [message["role"] for message in batch] == ["user", "assistant", "user", "assistant"] + assert batch[0]["parts"] == [ + {"type": "text", "text": "Please inspect the repository for assemble hooks."} + ] + assert batch[1]["parts"] == [ + {"type": "text", "text": "I will search the codebase."} + ] + assert batch[2]["parts"] == [ + { + "type": "tool", + "tool_id": "call_rg_1", + "tool_name": "shell_command", + "tool_input": {"command": "rg assemble"}, + "tool_output": "agent/context_engine.py: no preassemble hook", + "tool_status": "completed", + } + ] + assert batch[3]["parts"] == [ + {"type": "text", "text": "The current main does not expose assemble."} + ] + + def test_messages_to_openviking_batch_marks_json_tool_error_results(self): + turn = [ + {"role": "user", "content": "Check the file."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_read_1", + "type": "function", + "function": { + "name": "read_file", + "arguments": json.dumps({"path": "missing.md"}), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_read_1", + "name": "read_file", + "content": json.dumps({"error": "File not found", "exit_code": 1}), + }, + ] + + batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn) + + assert batch[1]["parts"] == [ + { + "type": "tool", + "tool_id": "call_read_1", + "tool_name": "read_file", + "tool_input": {"path": "missing.md"}, + "tool_output": json.dumps({"error": "File not found", "exit_code": 1}), + "tool_status": "error", + } + ] + + def test_messages_to_openviking_batch_keeps_pending_tool_call_without_result(self): + turn = [ + {"role": "user", "content": "Start a long running check."}, + { + "role": "assistant", + "content": "Starting it now.", + "tool_calls": [ + { + "id": "call_long_1", + "type": "function", + "function": { + "name": "long_check", + "arguments": json.dumps({"target": "repo"}), + }, + } + ], + }, + ] + + batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn) + + assert batch[1]["parts"] == [ + {"type": "text", "text": "Starting it now."}, + { + "type": "tool", + "tool_id": "call_long_1", + "tool_name": "long_check", + "tool_input": {"target": "repo"}, + "tool_status": "pending", + }, + ] + + def test_messages_to_openviking_batch_coalesces_adjacent_tool_results(self): + turn = [ + {"role": "user", "content": "Run both tools."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_a", + "type": "function", + "function": { + "name": "first_tool", + "arguments": json.dumps({"x": 1}), + }, + }, + { + "id": "call_b", + "type": "function", + "function": { + "name": "second_tool", + "arguments": json.dumps({"y": 2}), + }, + }, + ], + }, + {"role": "tool", "tool_call_id": "call_a", "name": "first_tool", "content": "a"}, + {"role": "tool", "tool_call_id": "call_b", "name": "second_tool", "content": "b"}, + {"role": "assistant", "content": "Done."}, + ] + + batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn) + + assert [message["role"] for message in batch] == ["user", "user", "assistant"] + assert batch[1]["parts"] == [ + { + "type": "tool", + "tool_id": "call_a", + "tool_name": "first_tool", + "tool_input": {"x": 1}, + "tool_output": "a", + "tool_status": "completed", + }, + { + "type": "tool", + "tool_id": "call_b", + "tool_name": "second_tool", + "tool_input": {"y": 2}, + "tool_output": "b", + "tool_status": "completed", + }, + ] + + def test_messages_to_openviking_batch_skips_openviking_recall_tool_results(self): + for recall_tool_name in ("viking_search", "viking_read", "viking_browse"): + turn = [ + {"role": "user", "content": "What did we decide about context assembly?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_recall_1", + "type": "function", + "function": { + "name": recall_tool_name, + "arguments": json.dumps({"query": "context assembly decision"}), + }, + }, + { + "id": "call_shell_1", + "type": "function", + "function": { + "name": "shell_command", + "arguments": json.dumps({"command": "rg preassemble"}), + }, + }, + ], + }, + { + "role": "tool", + "tool_call_id": "call_recall_1", + "name": recall_tool_name, + "content": json.dumps({ + "results": [ + { + "uri": "viking://user/hermes/memories/context", + "abstract": "Old OpenViking memory content", + } + ] + }), + }, + { + "role": "tool", + "tool_call_id": "call_shell_1", + "name": "shell_command", + "content": "plugins/memory/openviking/__init__.py", + }, + {"role": "assistant", "content": "We decided to keep sync_turn scoped to ingestion."}, + ] + + batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn) + + assert [message["role"] for message in batch] == ["user", "user", "assistant"] + assert batch[1]["parts"] == [ + { + "type": "tool", + "tool_id": "call_shell_1", + "tool_name": "shell_command", + "tool_input": {"command": "rg preassemble"}, + "tool_output": "plugins/memory/openviking/__init__.py", + "tool_status": "completed", + } + ] + batch_text = json.dumps(batch) + assert recall_tool_name not in batch_text + assert "Old OpenViking memory content" not in batch_text + + class TestOpenVikingRead: def test_overview_read_normalizes_uri_and_unwraps_result(self): provider = OpenVikingMemoryProvider() diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py index 954385fa54e..2863566b367 100644 --- a/tests/plugins/memory/test_openviking_provider.py +++ b/tests/plugins/memory/test_openviking_provider.py @@ -1975,7 +1975,10 @@ def test_on_session_switch_commits_old_session_and_rotates_id(): provider.on_session_switch("new-sid", parent_session_id="old-sid") - provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit") + provider._client.post.assert_called_once_with( + "/api/v1/sessions/old-sid/commit", + {"keep_recent_count": 0}, + ) assert provider._session_id == "new-sid" assert provider._turn_count == 0 @@ -1998,7 +2001,10 @@ def test_on_session_switch_commits_pending_tokens_without_turn_count(): provider.on_session_switch("new-sid") provider._client.get.assert_called_once_with("/api/v1/sessions/old-sid") - provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit") + provider._client.post.assert_called_once_with( + "/api/v1/sessions/old-sid/commit", + {"keep_recent_count": 0}, + ) assert provider._session_id == "new-sid" assert provider._turn_count == 0 @@ -2051,7 +2057,10 @@ def test_on_session_switch_waits_for_inflight_sync_thread(): provider.on_session_switch("new-sid") assert join_calls, "expected on_session_switch to join the in-flight sync thread" - provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit") + provider._client.post.assert_called_once_with( + "/api/v1/sessions/old-sid/commit", + {"keep_recent_count": 0}, + ) def test_on_session_switch_noop_on_empty_new_id(): @@ -2206,7 +2215,10 @@ def test_on_session_end_marks_session_clean_after_successful_commit(): provider.on_session_end([]) - provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit") + provider._client.post.assert_called_once_with( + "/api/v1/sessions/old-sid/commit", + {"keep_recent_count": 0}, + ) assert provider._turn_count == 0 @@ -2228,7 +2240,10 @@ def test_on_session_end_commits_pending_tokens_without_turn_count(): provider.on_session_end([]) provider._client.get.assert_called_once_with("/api/v1/sessions/old-sid") - provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit") + provider._client.post.assert_called_once_with( + "/api/v1/sessions/old-sid/commit", + {"keep_recent_count": 0}, + ) def test_end_then_switch_does_not_double_commit(): @@ -2241,7 +2256,10 @@ def test_end_then_switch_does_not_double_commit(): provider.on_session_switch("new-sid", parent_session_id="old-sid") # Exactly one commit call, on the OLD session, fired by on_session_end. - provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit") + provider._client.post.assert_called_once_with( + "/api/v1/sessions/old-sid/commit", + {"keep_recent_count": 0}, + ) assert provider._session_id == "new-sid" assert provider._turn_count == 0 @@ -2253,7 +2271,10 @@ def test_end_then_switch_with_pending_tokens_does_not_double_commit(): provider.on_session_end([]) provider.on_session_switch("new-sid", parent_session_id="old-sid") - provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit") + provider._client.post.assert_called_once_with( + "/api/v1/sessions/old-sid/commit", + {"keep_recent_count": 0}, + ) assert provider._session_id == "new-sid" assert provider._turn_count == 0 @@ -2400,7 +2421,10 @@ def test_on_session_switch_does_not_block_caller_on_slow_drain(): # Let the finalizer finish so it doesn't leak past the test. release_drain.set() assert provider._drain_finalizers(timeout=5.0) - provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit") + provider._client.post.assert_called_once_with( + "/api/v1/sessions/old-sid/commit", + {"keep_recent_count": 0}, + ) def test_on_session_switch_defers_old_commit_to_finalizer_thread(): @@ -2415,7 +2439,7 @@ def test_on_session_switch_defers_old_commit_to_finalizer_thread(): committed = threading.Event() drain_timeouts = [] - def fake_post(path): + def fake_post(path, payload=None): committed.set() return {} @@ -2433,7 +2457,10 @@ def test_on_session_switch_defers_old_commit_to_finalizer_thread(): assert provider._turn_count == 0 # The old-session commit lands on the finalizer thread, not inline. assert committed.wait(timeout=5.0), "old session was not finalized off-thread" - provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit") + provider._client.post.assert_called_once_with( + "/api/v1/sessions/old-sid/commit", + {"keep_recent_count": 0}, + ) # The finalizer drains with the deferred (longer) budget, not inline 10s. assert drain_timeouts == [_DEFERRED_COMMIT_TIMEOUT] From d7cd0bc0863cda1a203f00422b1441ca2d9890ed Mon Sep 17 00:00:00 2001 From: Hao Zhe <haozhe4547@gmail.com> Date: Fri, 19 Jun 2026 13:42:36 +0800 Subject: [PATCH 041/470] fix(openviking): preserve structured sync attribution --- agent/codex_runtime.py | 1 + agent/message_content.py | 50 +++++++++++++ plugins/memory/openviking/__init__.py | 36 +++++----- tests/agent/test_message_content.py | 25 +++++++ tests/openviking_plugin/test_openviking.py | 36 +++++++++- .../memory/test_openviking_provider.py | 72 +++++++++++++++++++ .../test_codex_app_server_integration.py | 13 +++- 7 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 agent/message_content.py create mode 100644 tests/agent/test_message_content.py diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 7f175fff97f..4ff67871934 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -290,6 +290,7 @@ def run_codex_app_server_turn( original_user_message=original_user_message, final_response=turn.final_text, interrupted=False, + messages=messages, ) except Exception: logger.debug("external memory sync raised", exc_info=True) diff --git a/agent/message_content.py b/agent/message_content.py new file mode 100644 index 00000000000..c42bf408550 --- /dev/null +++ b/agent/message_content.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +_NON_TEXT_PART_TYPES = {"image", "image_url", "input_image", "audio", "input_audio"} +_TEXT_KEYS = ("text", "content", "input_text", "output_text", "summary_text") + + +def _field(value: Any, key: str) -> Any: + if isinstance(value, Mapping): + return value.get(key) + return getattr(value, key, None) + + +def _text_from_part(part: Any) -> str: + if part is None: + return "" + if isinstance(part, str): + return part + + part_type = str(_field(part, "type") or "").strip().lower() + if part_type in _NON_TEXT_PART_TYPES: + return "" + + for key in _TEXT_KEYS: + text = _field(part, key) + if isinstance(text, str): + return text + return "" + + +def flatten_message_text(content: Any, *, sep: str = "\n") -> str: + """Return the visible text from common chat/Responses message content shapes.""" + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + chunks = [_text_from_part(part) for part in content] + return sep.join(chunk for chunk in chunks if chunk) + + text = _text_from_part(content) + if text: + return text + try: + return str(content) + except Exception: + return "" diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index c7b05a4864c..82f1f26a0a0 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -45,6 +45,7 @@ from typing import Any, Callable, Dict, List, Optional, Set from urllib.parse import urlparse from urllib.request import url2pathname +from agent.message_content import flatten_message_text from agent.memory_provider import MemoryProvider from agent.skill_commands import extract_user_instruction_from_skill_message from tools.registry import tool_error @@ -2313,22 +2314,7 @@ class OpenVikingMemoryProvider(MemoryProvider): @staticmethod def _message_text(content: Any) -> str: """Extract text from OpenAI-style string/list content.""" - if isinstance(content, str): - return content - if isinstance(content, list): - chunks = [] - for block in content: - if isinstance(block, str): - chunks.append(block) - elif isinstance(block, dict): - if block.get("type") == "text" and isinstance(block.get("text"), str): - chunks.append(block["text"]) - elif isinstance(block.get("content"), str): - chunks.append(block["content"]) - return "\n".join(chunk for chunk in chunks if chunk) - if content is None: - return "" - return str(content) + return flatten_message_text(content) @classmethod def _message_matches_text(cls, message: Dict[str, Any], expected: Any) -> bool: @@ -2460,8 +2446,11 @@ class OpenVikingMemoryProvider(MemoryProvider): def _messages_to_openviking_batch( cls, messages: List[Dict[str, Any]], + *, + assistant_peer_id: str = "", ) -> List[Dict[str, Any]]: """Convert Hermes canonical messages into OpenViking batch payloads.""" + assistant_peer_id = str(assistant_peer_id or "").strip() tool_calls_by_id: Dict[str, Dict[str, Any]] = {} completed_tool_ids: set[str] = set() skipped_tool_ids: set[str] = set() @@ -2493,10 +2482,16 @@ class OpenVikingMemoryProvider(MemoryProvider): payload_messages: List[Dict[str, Any]] = [] pending_tool_parts: List[Dict[str, Any]] = [] + def payload_message(role: str, parts: List[Dict[str, Any]]) -> Dict[str, Any]: + payload: Dict[str, Any] = {"role": role, "parts": parts} + if role == "assistant" and assistant_peer_id: + payload["peer_id"] = assistant_peer_id + return payload + def flush_tool_parts() -> None: nonlocal pending_tool_parts if pending_tool_parts: - payload_messages.append({"role": "user", "parts": pending_tool_parts}) + payload_messages.append(payload_message("assistant", pending_tool_parts)) pending_tool_parts = [] for message in messages: @@ -2552,7 +2547,7 @@ class OpenVikingMemoryProvider(MemoryProvider): }) if parts: - payload_messages.append({"role": role, "parts": parts}) + payload_messages.append(payload_message(role, parts)) flush_tool_parts() return payload_messages @@ -2584,7 +2579,10 @@ class OpenVikingMemoryProvider(MemoryProvider): if message.get("role") == "user": message["content"] = user_content break - batch_messages = self._messages_to_openviking_batch(turn_messages) + batch_messages = self._messages_to_openviking_batch( + turn_messages, + assistant_peer_id=getattr(self, "_agent", _DEFAULT_AGENT), + ) if _sync_trace_enabled(): logger.info( diff --git a/tests/agent/test_message_content.py b/tests/agent/test_message_content.py new file mode 100644 index 00000000000..0207d63600b --- /dev/null +++ b/tests/agent/test_message_content.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from agent.message_content import flatten_message_text + + +def test_flatten_message_text_accepts_chat_and_responses_text_parts(): + content = [ + {"type": "text", "text": "chat text"}, + {"type": "input_text", "text": "user text"}, + {"type": "output_text", "text": "assistant text"}, + {"type": "summary_text", "text": "summary text"}, + ] + + assert flatten_message_text(content) == "chat text\nuser text\nassistant text\nsummary text" + + +def test_flatten_message_text_accepts_object_parts(): + content = [ + SimpleNamespace(type="output_text", text="object text"), + {"content": "legacy content"}, + ] + + assert flatten_message_text(content) == "object text\nlegacy content" diff --git a/tests/openviking_plugin/test_openviking.py b/tests/openviking_plugin/test_openviking.py index ee5d1eb2373..3a743287672 100644 --- a/tests/openviking_plugin/test_openviking.py +++ b/tests/openviking_plugin/test_openviking.py @@ -330,7 +330,7 @@ class TestOpenVikingTurnConversion: batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn) - assert [message["role"] for message in batch] == ["user", "assistant", "user", "assistant"] + assert [message["role"] for message in batch] == ["user", "assistant", "assistant", "assistant"] assert batch[0]["parts"] == [ {"type": "text", "text": "Please inspect the repository for assemble hooks."} ] @@ -378,6 +378,7 @@ class TestOpenVikingTurnConversion: batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn) + assert batch[1]["role"] == "assistant" assert batch[1]["parts"] == [ { "type": "tool", @@ -453,7 +454,7 @@ class TestOpenVikingTurnConversion: batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn) - assert [message["role"] for message in batch] == ["user", "user", "assistant"] + assert [message["role"] for message in batch] == ["user", "assistant", "assistant"] assert batch[1]["parts"] == [ { "type": "tool", @@ -523,7 +524,7 @@ class TestOpenVikingTurnConversion: batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn) - assert [message["role"] for message in batch] == ["user", "user", "assistant"] + assert [message["role"] for message in batch] == ["user", "assistant", "assistant"] assert batch[1]["parts"] == [ { "type": "tool", @@ -538,6 +539,35 @@ class TestOpenVikingTurnConversion: assert recall_tool_name not in batch_text assert "Old OpenViking memory content" not in batch_text + def test_messages_to_openviking_batch_preserves_responses_text_parts(self): + turn = [ + {"role": "user", "content": [{"type": "input_text", "text": "hello"}]}, + {"role": "assistant", "content": [{"type": "output_text", "text": "answer"}]}, + ] + + batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn) + + assert batch == [ + {"role": "user", "parts": [{"type": "text", "text": "hello"}]}, + {"role": "assistant", "parts": [{"type": "text", "text": "answer"}]}, + ] + + def test_messages_to_openviking_batch_adds_assistant_peer_id_when_requested(self): + turn = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "answer"}, + ] + + batch = OpenVikingMemoryProvider._messages_to_openviking_batch( + turn, + assistant_peer_id="hermes", + ) + + assert batch == [ + {"role": "user", "parts": [{"type": "text", "text": "hello"}]}, + {"role": "assistant", "parts": [{"type": "text", "text": "answer"}], "peer_id": "hermes"}, + ] + class TestOpenVikingRead: def test_overview_read_normalizes_uri_and_unwraps_result(self): diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py index 2863566b367..28f2d8e9d46 100644 --- a/tests/plugins/memory/test_openviking_provider.py +++ b/tests/plugins/memory/test_openviking_provider.py @@ -2195,6 +2195,78 @@ def test_sync_turn_retries_batch_write_with_fresh_client(): )] +def test_sync_turn_structured_messages_include_assistant_peer_id(): + provider = OpenVikingMemoryProvider() + provider._client = MagicMock() + provider._endpoint = "http://test" + provider._api_key = "" + provider._account = "acct" + provider._user = "usr" + provider._agent = "hermes" + provider._session_id = "sid-structured" + + captured = [] + + class StubClient: + def __init__(self, *a, **kw): + pass + + def post(self, path, payload=None, **kwargs): + captured.append((path, payload)) + return {} + + import plugins.memory.openviking as _mod + + real_client_cls = _mod._VikingClient + _mod._VikingClient = StubClient + messages = [ + {"role": "user", "content": [{"type": "input_text", "text": "u"}]}, + { + "role": "assistant", + "content": "Looking.", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "shell_command", "arguments": json.dumps({"cmd": "pwd"})}, + } + ], + }, + {"role": "tool", "tool_call_id": "call-1", "name": "shell_command", "content": "ok"}, + {"role": "assistant", "content": [{"type": "output_text", "text": "a"}]}, + ] + try: + provider.sync_turn("u", "a", messages=messages) + assert provider._drain_writers("sid-structured", timeout=2.0) + finally: + _mod._VikingClient = real_client_cls + + assert captured == [( + "/api/v1/sessions/sid-structured/messages/batch", + { + "messages": [ + {"role": "user", "parts": [{"type": "text", "text": "u"}]}, + {"role": "assistant", "parts": [{"type": "text", "text": "Looking."}], "peer_id": "hermes"}, + { + "role": "assistant", + "parts": [ + { + "type": "tool", + "tool_id": "call-1", + "tool_name": "shell_command", + "tool_input": {"cmd": "pwd"}, + "tool_output": "ok", + "tool_status": "completed", + } + ], + "peer_id": "hermes", + }, + {"role": "assistant", "parts": [{"type": "text", "text": "a"}], "peer_id": "hermes"}, + ] + }, + )] + + def test_sync_turn_noop_when_session_id_blank(): provider = OpenVikingMemoryProvider() provider._client = MagicMock() diff --git a/tests/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py index 14c058178b9..b0d2ec23861 100644 --- a/tests/run_agent/test_codex_app_server_integration.py +++ b/tests/run_agent/test_codex_app_server_integration.py @@ -12,7 +12,7 @@ Verifies that: from __future__ import annotations -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -148,6 +148,17 @@ class TestRunConversationCodexPath: and m.get("content") == "echo: hello"] assert final, f"expected final assistant message in {msgs}" + def test_projected_messages_are_synced_to_external_memory(self, fake_session): + agent = _make_codex_agent() + agent._memory_manager = MagicMock() + agent._memory_manager.build_system_prompt.return_value = "" + + with patch.object(agent, "_spawn_background_review", return_value=None): + result = agent.run_conversation("hello") + + agent._memory_manager.sync_all.assert_called_once() + assert agent._memory_manager.sync_all.call_args.kwargs["messages"] == result["messages"] + def test_nudge_counters_tick(self, fake_session): """The skill nudge counter must accumulate tool_iterations across turns. The memory nudge counter is gated on memory being configured From 15e3b64b7538bb0a38e4bfd91d9c8a4f8110ce8f Mon Sep 17 00:00:00 2001 From: Shannon Sands <shannon.sands.1979@gmail.com> Date: Fri, 19 Jun 2026 11:25:05 +1000 Subject: [PATCH 042/470] fix(tui): keep hosted dashboard chat alive on exit --- .../src/__tests__/createSlashHandler.test.ts | 30 +++++++++++++++++++ ui-tui/src/app/slash/commands/core.ts | 24 ++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index a671063e5e9..c0247795af3 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -9,6 +9,10 @@ describe('createSlashHandler', () => { beforeEach(() => { resetOverlayState() resetUiState() + delete process.env.HERMES_TUI_INLINE + delete process.env.HERMES_HOME + delete process.env.HERMES_WRITE_SAFE_ROOT + delete process.env.HERMES_DISABLE_LAZY_INSTALLS }) it('opens the unified sessions overlay for /resume', () => { @@ -68,6 +72,32 @@ describe('createSlashHandler', () => { expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) + it('keeps hosted dashboard chat alive for /exit', () => { + process.env.HERMES_TUI_INLINE = '1' + process.env.HERMES_HOME = '/opt/data/profiles/worker' + process.env.HERMES_WRITE_SAFE_ROOT = '/opt/data' + process.env.HERMES_DISABLE_LAZY_INSTALLS = '1' + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/exit')).toBe(true) + expect(ctx.session.die).not.toHaveBeenCalled() + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith( + 'exit is disabled in hosted dashboard chat — use /new to start a fresh session' + ) + }) + + it('keeps /quit available outside hosted dashboard chat', () => { + process.env.HERMES_TUI_INLINE = '1' + process.env.HERMES_HOME = '/Users/example/.hermes' + process.env.HERMES_WRITE_SAFE_ROOT = '/Users/example/.hermes' + process.env.HERMES_DISABLE_LAZY_INSTALLS = '1' + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/quit')).toBe(true) + expect(ctx.session.die).toHaveBeenCalledTimes(1) + }) + it('handles /update locally and exits with code 42 via dieWithCode', () => { vi.useFakeTimers() const ctx = buildCtx() diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 5c021dbcdf9..b5d72cf7712 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -76,6 +76,20 @@ const DETAILS_USAGE = const DETAILS_SECTION_USAGE = 'usage: /details <section> [hidden|collapsed|expanded|reset]' +const truthyEnv = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim()) + +const hostedInlineDashboardChat = () => { + const hermesHome = (process.env.HERMES_HOME ?? '').trim() + const hostedHome = hermesHome === '/opt/data' || hermesHome.startsWith('/opt/data/') + + return ( + process.env.HERMES_TUI_INLINE === '1' && + hostedHome && + process.env.HERMES_WRITE_SAFE_ROOT === '/opt/data' && + truthyEnv(process.env.HERMES_DISABLE_LAZY_INSTALLS) + ) +} + export const coreCommands: SlashCommand[] = [ { help: 'list commands + hotkeys', @@ -113,7 +127,15 @@ export const coreCommands: SlashCommand[] = [ aliases: ['exit'], help: 'exit hermes', name: 'quit', - run: (_arg, ctx) => ctx.session.die() + run: (_arg, ctx) => { + if (hostedInlineDashboardChat()) { + ctx.transcript.sys('exit is disabled in hosted dashboard chat — use /new to start a fresh session') + + return + } + + ctx.session.die() + } }, { From 3f0e9849e7a2753931ef32c624cae33a7461e653 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:29:19 +0530 Subject: [PATCH 043/470] refactor(tui): reuse DASHBOARD_TUI_MODE for hosted /exit guard Follow-up to the salvaged hosted /exit fix. Instead of a separate 4-env-var fingerprint (HERMES_TUI_INLINE + /opt/data HERMES_HOME + HERMES_WRITE_SAFE_ROOT + HERMES_DISABLE_LAZY_INSTALLS), gate /exit and /quit on the existing DASHBOARD_TUI_MODE flag (HERMES_TUI_DASHBOARD) that the keyboard idle-exit (useInputHandlers) and SIGINT-ignore (entry.tsx) paths already use. One hosted detection mechanism instead of two divergent ones. Extract the refusal text to an exported DASHBOARD_EXIT_DISABLED_MESSAGE so the test asserts the same source of truth as production (no change-detector on the literal). Test mocks only the DASHBOARD_TUI_MODE export via importActual so the other env exports stay real. --- .../src/__tests__/createSlashHandler.test.ts | 35 +++++++++++-------- ui-tui/src/app/slash/commands/core.ts | 30 ++++++++-------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index c0247795af3..415dd4c0f3c 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -2,17 +2,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createSlashHandler } from '../app/createSlashHandler.js' import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' +import { DASHBOARD_EXIT_DISABLED_MESSAGE } from '../app/slash/commands/core.js' import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' +// DASHBOARD_TUI_MODE resolves once at module load from HERMES_TUI_DASHBOARD, +// so toggling process.env in a test body can't move it. Mock just that one +// export (everything else stays real) and flip the holder per test. +const envState = { dashboardTuiMode: false } +vi.mock('../config/env.js', async importActual => { + const actual = await importActual<typeof import('../config/env.js')>() + + return { + ...actual, + get DASHBOARD_TUI_MODE() { + return envState.dashboardTuiMode + } + } +}) + describe('createSlashHandler', () => { beforeEach(() => { resetOverlayState() resetUiState() - delete process.env.HERMES_TUI_INLINE - delete process.env.HERMES_HOME - delete process.env.HERMES_WRITE_SAFE_ROOT - delete process.env.HERMES_DISABLE_LAZY_INSTALLS + envState.dashboardTuiMode = false }) it('opens the unified sessions overlay for /resume', () => { @@ -73,25 +86,17 @@ describe('createSlashHandler', () => { }) it('keeps hosted dashboard chat alive for /exit', () => { - process.env.HERMES_TUI_INLINE = '1' - process.env.HERMES_HOME = '/opt/data/profiles/worker' - process.env.HERMES_WRITE_SAFE_ROOT = '/opt/data' - process.env.HERMES_DISABLE_LAZY_INSTALLS = '1' + envState.dashboardTuiMode = true const ctx = buildCtx() expect(createSlashHandler(ctx)('/exit')).toBe(true) expect(ctx.session.die).not.toHaveBeenCalled() expect(ctx.gateway.gw.request).not.toHaveBeenCalled() - expect(ctx.transcript.sys).toHaveBeenCalledWith( - 'exit is disabled in hosted dashboard chat — use /new to start a fresh session' - ) + expect(ctx.transcript.sys).toHaveBeenCalledWith(DASHBOARD_EXIT_DISABLED_MESSAGE) }) it('keeps /quit available outside hosted dashboard chat', () => { - process.env.HERMES_TUI_INLINE = '1' - process.env.HERMES_HOME = '/Users/example/.hermes' - process.env.HERMES_WRITE_SAFE_ROOT = '/Users/example/.hermes' - process.env.HERMES_DISABLE_LAZY_INSTALLS = '1' + envState.dashboardTuiMode = false const ctx = buildCtx() expect(createSlashHandler(ctx)('/quit')).toBe(true) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index b5d72cf7712..7c5a79505ad 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,6 +1,6 @@ import { forceRedraw, type MouseTrackingMode } from '@hermes/ink' -import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' +import { DASHBOARD_TUI_MODE, NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js' import { dailyFortune, randomFortune } from '../../../content/fortunes.js' import { HOTKEYS } from '../../../content/hotkeys.js' import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js' @@ -76,19 +76,10 @@ const DETAILS_USAGE = const DETAILS_SECTION_USAGE = 'usage: /details <section> [hidden|collapsed|expanded|reset]' -const truthyEnv = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim()) - -const hostedInlineDashboardChat = () => { - const hermesHome = (process.env.HERMES_HOME ?? '').trim() - const hostedHome = hermesHome === '/opt/data' || hermesHome.startsWith('/opt/data/') - - return ( - process.env.HERMES_TUI_INLINE === '1' && - hostedHome && - process.env.HERMES_WRITE_SAFE_ROOT === '/opt/data' && - truthyEnv(process.env.HERMES_DISABLE_LAZY_INSTALLS) - ) -} +// Shown when /exit or /quit is refused in the hosted dashboard chat. Kept as a +// constant so the test asserts against the same source of truth as production. +export const DASHBOARD_EXIT_DISABLED_MESSAGE = + 'exit is disabled in hosted dashboard chat — use /new to start a fresh session' export const coreCommands: SlashCommand[] = [ { @@ -128,8 +119,15 @@ export const coreCommands: SlashCommand[] = [ help: 'exit hermes', name: 'quit', run: (_arg, ctx) => { - if (hostedInlineDashboardChat()) { - ctx.transcript.sys('exit is disabled in hosted dashboard chat — use /new to start a fresh session') + // In the hosted dashboard chat there is no in-page restart path after + // the PTY child exits, so quitting bricks the tab until a refresh. The + // keyboard idle-exit (Ctrl+C / Ctrl+D) and SIGINT handling already refuse + // to die in this mode (see useInputHandlers + entry.tsx); gate /exit and + // /quit on the same DASHBOARD_TUI_MODE flag. Unlike the keyboard path + // (which auto-starts a fresh chat), the explicit quit command refuses and + // instructs the user to run /new themselves. + if (DASHBOARD_TUI_MODE) { + ctx.transcript.sys(DASHBOARD_EXIT_DISABLED_MESSAGE) return } From 5a856bdfa355bb45330a23ecb63abdf9b810e865 Mon Sep 17 00:00:00 2001 From: Hao Zhe <haozhe4547@gmail.com> Date: Fri, 19 Jun 2026 15:38:25 +0800 Subject: [PATCH 044/470] chore(release): add OpenViking contributor attribution --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 6c5d33ec3a1..4e5f8844439 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1577,6 +1577,7 @@ AUTHOR_MAP = { "sunsky.lau@gmail.com": "liuhao1024", # PR #45494 salvage (claim session slot before auto-resume task; #45456) "andrewdmwalker@gmail.com": "capt-marbles", # PR #38440 salvage (resolve xAI OAuth credentials across profiles; #43589) "infinitycrew39@gmail.com": "infinitycrew39", # PR #47945 salvage (scope langfuse trace state by turn/request ids; #48292) + "eurekaxun@163.com": "huangxun375-stack", # PR #37251 / #48894 structured OpenViking sync } From 9362ce2575e00f5a795285b74e79d54c02e1326c Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:32:31 +0530 Subject: [PATCH 045/470] feat(skills): add html-artifact skill, fold in sketch + architecture-diagram + concept-diagrams (#48899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skills): add html-artifact skill, fold in sketch + architecture-diagram + concept-diagrams Adds a unified `html-artifact` creative skill that produces self-contained, single-file HTML artifacts — concept explainers, implementation plans, status/incident reports, code-review walkthroughs, technical + educational SVG diagrams, multi-variant design comparisons, and throwaway editors that export their state back to the clipboard. Grounded in Anthropic's html-effectiveness gallery (MIT); the house style (token block, serif/sans/ mono split, hand-rolled diffs, inline-SVG diagrams, graceful degradation) is distilled from reading all 20 reference files. Supersedes and removes three overlapping skills, folding their unique value in: - sketch -> the fidelity dial (throwaway vs presentation) + the multi-variant comparison layouts + the browser-vision verify loop (references/fidelity-and-verify.md) - architecture-diagram-> the dark "infra" token variant + double-rect masking + semantic component palette (references/dark-tech.md, templates/diagram.html infra mode) - concept-diagrams -> the 9-ramp educational color system + the concept archetype library (references/concept-archetypes.md, the light design system in templates/diagram.html) Structure: - SKILL.md (description exactly 60 chars), 6 references, 3 templates - templates verified by headless-Chrome render + vision inspection - editor export logic (file://-safe clipboard, Promise-normalized) verified in node Cross-references updated in claude-design (new disambiguation table row drawing the design-taste vs information-artifact boundary), design-md, pretext, spike, and kanban-video-orchestrator. Website skill docs + catalogs regenerated; stale EN/zh-Hans per-skill pages pruned and i18n cross-refs fixed. Not folded (intentionally orthogonal): excalidraw (.excalidraw JSON), p5js (generative canvas), claude-design / popular-web-designs / design-md (visual design taste / brand vocab / token spec). * feat(skills): ship html-effectiveness gallery as fetched reference examples Add scripts/fetch-examples.sh (idempotent clone/pull of Anthropic's MIT html-effectiveness gallery) + references/examples.md mapping each of the 20 example files to a mode so the agent reads the right worked example. The clone lands in references/examples/ and is gitignored (it's a 384KB upstream repo, not vendored). SKILL.md workflow + reference list now point at it; falls back to the distilled pattern references when offline. * feat(skills): make reading a gallery example a required authoring step Reading the matching html-effectiveness example is now workflow step 2 (was an optional aside in step 3): fetch the gallery, read_file the file for your mode, mirror its structure. Models skip optional steps; the examples are the ground truth, so consulting one is mandatory. Added an 'Example' column to the mode->build quick-reference table and a 'don't skip the example' pitfall. Also dogfooded the skill: read 03-code-review-pr.html and 13-flowchart-diagram.html raw and reconciled the distilled references against source — aligned diff-row tint opacity to the source's 0.15 (was 0.18) and added the .ctx/.hunk rows in house-style.md + base.html so they match 03-code-review-pr.html verbatim. * docs(skills): explain the consolidation + bundled-vs-optional rationale The supersession note only stated *what* was folded, not *why* the prune is sound. Expand SKILL.md's intro into a 'Why this skill exists' section: the three former skills emitted the same artifact and overlapped, so consolidating removes which-one-do-I-load ambiguity; and the optional->bundled promotion of concept-diagrams is footprint-safe because this skill has zero deps (only cost is the 60-char description; everything else is progressive-disclosure). States the bundling dividing line explicitly: zero install cost + broadly useful gets bundled, real install cost (hyperframes: Node+FFmpeg+Chromium) stays optional. Regenerated website per-skill page to match. --- .../creative/concept-diagrams/SKILL.md | 362 ----------------- .../apartment-floor-plan-conversion.md | 244 ----------- .../examples/automated-password-reset-flow.md | 276 ------------- .../autonomous-llm-research-agent-flow.md | 240 ----------- .../banana-journey-tree-to-smoothie.md | 161 -------- .../examples/commercial-aircraft-structure.md | 209 ---------- .../examples/cpu-ooo-microarchitecture.md | 236 ----------- .../examples/electricity-grid-flow.md | 182 --------- .../feature-film-production-pipeline.md | 172 -------- .../hospital-emergency-department-flow.md | 165 -------- .../ml-benchmark-grouped-bar-chart.md | 114 ------ .../examples/place-order-uml-sequence.md | 325 --------------- .../examples/smart-city-infrastructure.md | 173 -------- .../examples/smartphone-layer-anatomy.md | 154 ------- .../examples/sn2-reaction-mechanism.md | 247 ------------ .../examples/wind-turbine-structure.md | 338 ---------------- .../references/dashboard-patterns.md | 43 -- .../references/infrastructure-patterns.md | 144 ------- .../references/physical-shape-cookbook.md | 42 -- .../concept-diagrams/templates/template.html | 174 -------- .../kanban-video-orchestrator/SKILL.md | 2 +- .../references/intake.md | 3 +- .../references/role-archetypes.md | 5 +- .../references/tool-matrix.md | 4 +- skills/creative/architecture-diagram/SKILL.md | 148 ------- .../templates/template.html | 319 --------------- skills/creative/claude-design/SKILL.md | 12 +- skills/creative/design-md/SKILL.md | 2 +- skills/creative/html-artifact/SKILL.md | 184 +++++++++ .../html-artifact/references/.gitignore | 3 + .../references/concept-archetypes.md | 94 +++++ .../html-artifact/references/dark-tech.md | 92 +++++ .../html-artifact/references/examples.md | 64 +++ .../references/fidelity-and-verify.md | 78 ++++ .../html-artifact/references/house-style.md | 179 +++++++++ .../html-artifact/references/svg-diagrams.md | 123 ++++++ .../references/throwaway-editors.md | 114 ++++++ .../html-artifact/scripts/fetch-examples.sh | 43 ++ .../html-artifact/templates/base.html | 104 +++++ .../html-artifact/templates/diagram.html | 127 ++++++ .../html-artifact/templates/editor.html | 120 ++++++ skills/creative/pretext/SKILL.md | 2 +- skills/creative/sketch/SKILL.md | 218 ---------- skills/software-development/spike/SKILL.md | 2 +- .../docs/reference/optional-skills-catalog.md | 1 - website/docs/reference/skills-catalog.md | 3 +- .../autonomous-ai-agents-hermes-agent.md | 4 +- .../creative/creative-architecture-diagram.md | 165 -------- .../creative/creative-claude-design.md | 12 +- .../bundled/creative/creative-design-md.md | 2 +- .../creative/creative-html-artifact.md | 202 ++++++++++ .../bundled/creative/creative-pretext.md | 2 +- .../bundled/creative/creative-sketch.md | 238 ----------- .../creative/creative-touchdesigner-mcp.md | 2 +- .../skills/bundled/email/email-himalaya.md | 5 + .../bundled/github/github-github-auth.md | 4 +- .../github/github-github-code-review.md | 4 +- .../bundled/github/github-github-issues.md | 4 +- .../github/github-github-pr-workflow.md | 4 +- .../github/github-github-repo-management.md | 4 +- .../skills/bundled/media/media-gif-search.md | 2 +- .../note-taking/note-taking-obsidian.md | 2 +- .../productivity/productivity-airtable.md | 4 +- .../productivity/productivity-notion.md | 4 +- .../productivity-teams-meeting-pipeline.md | 2 +- .../bundled/research/research-llm-wiki.md | 2 +- .../research-research-paper-writing.md | 2 +- ...tware-development-node-inspect-debugger.md | 2 +- .../software-development-python-debugpy.md | 2 +- .../software-development-spike.md | 2 +- .../autonomous-ai-agents-honcho.md | 4 +- .../blockchain/blockchain-hyperliquid.md | 4 +- .../creative/creative-concept-diagrams.md | 379 ------------------ .../creative-kanban-video-orchestrator.md | 4 +- .../optional/devops/devops-pinggy-tunnel.md | 2 +- .../skills/optional/devops/devops-watchers.md | 2 +- .../skills/optional/mcp/mcp-fastmcp.md | 2 +- .../payments/payments-stripe-projects.md | 2 +- .../productivity/productivity-canvas.md | 2 +- .../productivity/productivity-shopify.md | 2 +- .../productivity/productivity-siyuan.md | 2 +- .../productivity/productivity-telephony.md | 8 +- .../research/research-gitnexus-explorer.md | 2 +- .../skills/optional/research/research-qmd.md | 2 +- .../optional/security/security-1password.md | 2 +- .../optional/security/security-godmode.md | 2 +- ...software-development-rest-graphql-debug.md | 2 +- .../reference/optional-skills-catalog.md | 1 - .../current/reference/skills-catalog.md | 2 - .../creative/creative-architecture-diagram.md | 165 -------- .../creative/creative-claude-design.md | 2 +- .../bundled/creative/creative-design-md.md | 2 +- .../bundled/creative/creative-pretext.md | 2 +- .../bundled/creative/creative-sketch.md | 238 ----------- .../software-development-spike.md | 2 +- .../creative/creative-concept-diagrams.md | 379 ------------------ .../creative-kanban-video-orchestrator.md | 2 +- website/sidebars.ts | 5 +- 98 files changed, 1610 insertions(+), 6336 deletions(-) delete mode 100644 optional-skills/creative/concept-diagrams/SKILL.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md delete mode 100644 optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md delete mode 100644 optional-skills/creative/concept-diagrams/references/dashboard-patterns.md delete mode 100644 optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md delete mode 100644 optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md delete mode 100644 optional-skills/creative/concept-diagrams/templates/template.html delete mode 100644 skills/creative/architecture-diagram/SKILL.md delete mode 100644 skills/creative/architecture-diagram/templates/template.html create mode 100644 skills/creative/html-artifact/SKILL.md create mode 100644 skills/creative/html-artifact/references/.gitignore create mode 100644 skills/creative/html-artifact/references/concept-archetypes.md create mode 100644 skills/creative/html-artifact/references/dark-tech.md create mode 100644 skills/creative/html-artifact/references/examples.md create mode 100644 skills/creative/html-artifact/references/fidelity-and-verify.md create mode 100644 skills/creative/html-artifact/references/house-style.md create mode 100644 skills/creative/html-artifact/references/svg-diagrams.md create mode 100644 skills/creative/html-artifact/references/throwaway-editors.md create mode 100755 skills/creative/html-artifact/scripts/fetch-examples.sh create mode 100644 skills/creative/html-artifact/templates/base.html create mode 100644 skills/creative/html-artifact/templates/diagram.html create mode 100644 skills/creative/html-artifact/templates/editor.html delete mode 100644 skills/creative/sketch/SKILL.md delete mode 100644 website/docs/user-guide/skills/bundled/creative/creative-architecture-diagram.md create mode 100644 website/docs/user-guide/skills/bundled/creative/creative-html-artifact.md delete mode 100644 website/docs/user-guide/skills/bundled/creative/creative-sketch.md delete mode 100644 website/docs/user-guide/skills/optional/creative/creative-concept-diagrams.md delete mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-architecture-diagram.md delete mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-sketch.md delete mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-concept-diagrams.md diff --git a/optional-skills/creative/concept-diagrams/SKILL.md b/optional-skills/creative/concept-diagrams/SKILL.md deleted file mode 100644 index 6017d4fd121..00000000000 --- a/optional-skills/creative/concept-diagrams/SKILL.md +++ /dev/null @@ -1,362 +0,0 @@ ---- -name: concept-diagrams -description: Generate flat, minimal light/dark-aware SVG diagrams as standalone HTML files, using a unified educational visual language with 9 semantic color ramps, sentence-case typography, and automatic dark mode. Best suited for educational and non-software visuals — physics setups, chemistry mechanisms, math curves, physical objects (aircraft, turbines, smartphones, mechanical watches), anatomy, floor plans, cross-sections, narrative journeys (lifecycle of X, process of Y), hub-spoke system integrations (smart city, IoT), and exploded layer views. If a more specialized skill exists for the subject (dedicated software/cloud architecture, hand-drawn sketches, animated explainers, etc.), prefer that — otherwise this skill can also serve as a general-purpose SVG diagram fallback with a clean educational look. Ships with 15 example diagrams. -version: 0.1.0 -author: v1k22 (original PR), ported into hermes-agent -license: MIT -dependencies: [] -platforms: [linux, macos, windows] -metadata: - hermes: - tags: [diagrams, svg, visualization, education, physics, chemistry, engineering] - related_skills: [architecture-diagram, excalidraw, generative-widgets] ---- - -# Concept Diagrams - -Generate production-quality SVG diagrams with a unified flat, minimal design system. Output is a single self-contained HTML file that renders identically in any modern browser, with automatic light/dark mode. - -## Scope - -**Best suited for:** -- Physics setups, chemistry mechanisms, math curves, biology -- Physical objects (aircraft, turbines, smartphones, mechanical watches, cells) -- Anatomy, cross-sections, exploded layer views -- Floor plans, architectural conversions -- Narrative journeys (lifecycle of X, process of Y) -- Hub-spoke system integrations (smart city, IoT networks, electricity grids) -- Educational / textbook-style visuals in any domain -- Quantitative charts (grouped bars, energy profiles) - -**Look elsewhere first for:** -- Dedicated software / cloud infrastructure architecture with a dark tech aesthetic (consider `architecture-diagram` if available) -- Hand-drawn whiteboard sketches (consider `excalidraw` if available) -- Animated explainers or video output (consider an animation skill) - -If a more specialized skill is available for the subject, prefer that. If none fits, this skill can serve as a general-purpose SVG diagram fallback — the output will carry the clean educational aesthetic described below, which is a reasonable default for almost any subject. - -## Workflow - -1. Decide on the diagram type (see Diagram Types below). -2. Lay out components using the Design System rules. -3. Write the full HTML page using `templates/template.html` as the wrapper — paste your SVG where the template says `<!-- PASTE SVG HERE -->`. -4. Save as a standalone `.html` file (for example `~/my-diagram.html` or `./my-diagram.html`). -5. User opens it directly in a browser — no server, no dependencies. - -Optional: if the user wants a browsable gallery of multiple diagrams, see "Local Preview Server" at the bottom. - -Load the HTML template: -``` -skill_view(name="concept-diagrams", file_path="templates/template.html") -``` - -The template embeds the full CSS design system (`c-*` color classes, text classes, light/dark variables, arrow marker styles). The SVG you generate relies on these classes being present on the hosting page. - ---- - -## Design System - -### Philosophy - -- **Flat**: no gradients, drop shadows, blur, glow, or neon effects. -- **Minimal**: show the essential. No decorative icons inside boxes. -- **Consistent**: same colors, spacing, typography, and stroke widths across every diagram. -- **Dark-mode ready**: all colors auto-adapt via CSS classes — no per-mode SVG. - -### Color Palette - -9 color ramps, each with 7 stops. Put the class name on a `<g>` or shape element; the template CSS handles both modes. - -| Class | 50 (lightest) | 100 | 200 | 400 | 600 | 800 | 900 (darkest) | -|------------|---------------|---------|---------|---------|---------|---------|---------------| -| `c-purple` | #EEEDFE | #CECBF6 | #AFA9EC | #7F77DD | #534AB7 | #3C3489 | #26215C | -| `c-teal` | #E1F5EE | #9FE1CB | #5DCAA5 | #1D9E75 | #0F6E56 | #085041 | #04342C | -| `c-coral` | #FAECE7 | #F5C4B3 | #F0997B | #D85A30 | #993C1D | #712B13 | #4A1B0C | -| `c-pink` | #FBEAF0 | #F4C0D1 | #ED93B1 | #D4537E | #993556 | #72243E | #4B1528 | -| `c-gray` | #F1EFE8 | #D3D1C7 | #B4B2A9 | #888780 | #5F5E5A | #444441 | #2C2C2A | -| `c-blue` | #E6F1FB | #B5D4F4 | #85B7EB | #378ADD | #185FA5 | #0C447C | #042C53 | -| `c-green` | #EAF3DE | #C0DD97 | #97C459 | #639922 | #3B6D11 | #27500A | #173404 | -| `c-amber` | #FAEEDA | #FAC775 | #EF9F27 | #BA7517 | #854F0B | #633806 | #412402 | -| `c-red` | #FCEBEB | #F7C1C1 | #F09595 | #E24B4A | #A32D2D | #791F1F | #501313 | - -#### Color Assignment Rules - -Color encodes **meaning**, not sequence. Never cycle through colors like a rainbow. - -- Group nodes by **category** — all nodes of the same type share one color. -- Use `c-gray` for neutral/structural nodes (start, end, generic steps, users). -- Use **2-3 colors per diagram**, not 6+. -- Prefer `c-purple`, `c-teal`, `c-coral`, `c-pink` for general categories. -- Reserve `c-blue`, `c-green`, `c-amber`, `c-red` for semantic meaning (info, success, warning, error). - -Light/dark stop mapping (handled by the template CSS — just use the class): -- Light mode: 50 fill + 600 stroke + 800 title / 600 subtitle -- Dark mode: 800 fill + 200 stroke + 100 title / 200 subtitle - -### Typography - -Only two font sizes. No exceptions. - -| Class | Size | Weight | Use | -|-------|------|--------|-----| -| `th` | 14px | 500 | Node titles, region labels | -| `ts` | 12px | 400 | Subtitles, descriptions, arrow labels | -| `t` | 14px | 400 | General text | - -- **Sentence case always.** Never Title Case, never ALL CAPS. -- Every `<text>` MUST carry a class (`t`, `ts`, or `th`). No unclassed text. -- `dominant-baseline="central"` on all text inside boxes. -- `text-anchor="middle"` for centered text in boxes. - -**Width estimation (approx):** -- 14px weight 500: ~8px per character -- 12px weight 400: ~6.5px per character -- Always verify: `box_width >= (char_count × px_per_char) + 48` (24px padding each side) - -### Spacing & Layout - -- **ViewBox**: `viewBox="0 0 680 H"` where H = content height + 40px buffer. -- **Safe area**: x=40 to x=640, y=40 to y=(H-40). -- **Between boxes**: 60px minimum gap. -- **Inside boxes**: 24px horizontal padding, 12px vertical padding. -- **Arrowhead gap**: 10px between arrowhead and box edge. -- **Single-line box**: 44px height. -- **Two-line box**: 56px height, 18px between title and subtitle baselines. -- **Container padding**: 20px minimum inside every container. -- **Max nesting**: 2-3 levels deep. Deeper gets unreadable at 680px width. - -### Stroke & Shape - -- **Stroke width**: 0.5px on all node borders. Not 1px, not 2px. -- **Rect rounding**: `rx="8"` for nodes, `rx="12"` for inner containers, `rx="16"` to `rx="20"` for outer containers. -- **Connector paths**: MUST have `fill="none"`. SVG defaults to `fill: black` otherwise. - -### Arrow Marker - -Include this `<defs>` block at the start of **every** SVG: - -```xml -<defs> - <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" - markerWidth="6" markerHeight="6" orient="auto-start-reverse"> - <path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" - stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> - </marker> -</defs> -``` - -Use `marker-end="url(#arrow)"` on lines. The arrowhead inherits the line color via `context-stroke`. - -### CSS Classes (Provided by the Template) - -The template page provides: - -- Text: `.t`, `.ts`, `.th` -- Neutral: `.box`, `.arr`, `.leader`, `.node` -- Color ramps: `.c-purple`, `.c-teal`, `.c-coral`, `.c-pink`, `.c-gray`, `.c-blue`, `.c-green`, `.c-amber`, `.c-red` (all with automatic light/dark mode) - -You do **not** need to redefine these — just apply them in your SVG. The template file contains the full CSS definitions. - ---- - -## SVG Boilerplate - -Every SVG inside the template page starts with this exact structure: - -```xml -<svg width="100%" viewBox="0 0 680 {HEIGHT}" xmlns="http://www.w3.org/2000/svg"> - <defs> - <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" - markerWidth="6" markerHeight="6" orient="auto-start-reverse"> - <path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" - stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> - </marker> - </defs> - - <!-- Diagram content here --> - -</svg> -``` - -Replace `{HEIGHT}` with the actual computed height (last element bottom + 40px). - -### Node Patterns - -**Single-line node (44px):** -```xml -<g class="node c-blue"> - <rect x="100" y="20" width="180" height="44" rx="8" stroke-width="0.5"/> - <text class="th" x="190" y="42" text-anchor="middle" dominant-baseline="central">Service name</text> -</g> -``` - -**Two-line node (56px):** -```xml -<g class="node c-teal"> - <rect x="100" y="20" width="200" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="200" y="38" text-anchor="middle" dominant-baseline="central">Service name</text> - <text class="ts" x="200" y="56" text-anchor="middle" dominant-baseline="central">Short description</text> -</g> -``` - -**Connector (no label):** -```xml -<line x1="200" y1="76" x2="200" y2="120" class="arr" marker-end="url(#arrow)"/> -``` - -**Container (dashed or solid):** -```xml -<g class="c-purple"> - <rect x="40" y="92" width="600" height="300" rx="16" stroke-width="0.5"/> - <text class="th" x="66" y="116">Container label</text> - <text class="ts" x="66" y="134">Subtitle info</text> -</g> -``` - ---- - -## Diagram Types - -Choose the layout that fits the subject: - -1. **Flowchart** — CI/CD pipelines, request lifecycles, approval workflows, data processing. Single-direction flow (top-down or left-right). Max 4-5 nodes per row. -2. **Structural / Containment** — Cloud infrastructure nesting, system architecture with layers. Large outer containers with inner regions. Dashed rects for logical groupings. -3. **API / Endpoint Map** — REST routes, GraphQL schemas. Tree from root, branching to resource groups, each containing endpoint nodes. -4. **Microservice Topology** — Service mesh, event-driven systems. Services as nodes, arrows for communication patterns, message queues between. -5. **Data Flow** — ETL pipelines, streaming architectures. Left-to-right flow from sources through processing to sinks. -6. **Physical / Structural** — Vehicles, buildings, hardware, anatomy. Use shapes that match the physical form — `<path>` for curved bodies, `<polygon>` for tapered shapes, `<ellipse>`/`<circle>` for cylindrical parts, nested `<rect>` for compartments. See `references/physical-shape-cookbook.md`. -7. **Infrastructure / Systems Integration** — Smart cities, IoT networks, multi-domain systems. Hub-spoke layout with central platform connecting subsystems. Semantic line styles (`.data-line`, `.power-line`, `.water-pipe`, `.road`). See `references/infrastructure-patterns.md`. -8. **UI / Dashboard Mockups** — Admin panels, monitoring dashboards. Screen frame with nested chart/gauge/indicator elements. See `references/dashboard-patterns.md`. - -For physical, infrastructure, and dashboard diagrams, load the matching reference file before generating — each one provides ready-made CSS classes and shape primitives. - ---- - -## Validation Checklist - -Before finalizing any SVG, verify ALL of the following: - -1. Every `<text>` has class `t`, `ts`, or `th`. -2. Every `<text>` inside a box has `dominant-baseline="central"`. -3. Every connector `<path>` or `<line>` used as arrow has `fill="none"`. -4. No arrow line crosses through an unrelated box. -5. `box_width >= (longest_label_chars × 8) + 48` for 14px text. -6. `box_width >= (longest_label_chars × 6.5) + 48` for 12px text. -7. ViewBox height = bottom-most element + 40px. -8. All content stays within x=40 to x=640. -9. Color classes (`c-*`) are on `<g>` or shape elements, never on `<path>` connectors. -10. Arrow `<defs>` block is present. -11. No gradients, shadows, blur, or glow effects. -12. Stroke width is 0.5px on all node borders. - ---- - -## Output & Preview - -### Default: standalone HTML file - -Write a single `.html` file the user can open directly. No server, no dependencies, works offline. Pattern: - -```python -# 1. Load the template -template = skill_view("concept-diagrams", "templates/template.html") - -# 2. Fill in title, subtitle, and paste your SVG -html = template.replace( - "<!-- DIAGRAM TITLE HERE -->", "SN2 reaction mechanism" -).replace( - "<!-- OPTIONAL SUBTITLE HERE -->", "Bimolecular nucleophilic substitution" -).replace( - "<!-- PASTE SVG HERE -->", svg_content -) - -# 3. Write to a user-chosen path (or ./ by default) -write_file("./sn2-mechanism.html", html) -``` - -Tell the user how to open it: - -``` -# macOS -open ./sn2-mechanism.html -# Linux -xdg-open ./sn2-mechanism.html -``` - -### Optional: local preview server (multi-diagram gallery) - -Only use this when the user explicitly wants a browsable gallery of multiple diagrams. - -**Rules:** -- Bind to `127.0.0.1` only. Never `0.0.0.0`. Exposing diagrams on all network interfaces is a security hazard on shared networks. -- Pick a free port (do NOT hard-code one) and tell the user the chosen URL. -- The server is optional and opt-in — prefer the standalone HTML file first. - -Recommended pattern (lets the OS pick a free ephemeral port): - -```bash -# Put each diagram in its own folder under .diagrams/ -mkdir -p .diagrams/sn2-mechanism -# ...write .diagrams/sn2-mechanism/index.html... - -# Serve on loopback only, free port -cd .diagrams && python3 -c " -import http.server, socketserver -with socketserver.TCPServer(('127.0.0.1', 0), http.server.SimpleHTTPRequestHandler) as s: - print(f'Serving at http://127.0.0.1:{s.server_address[1]}/') - s.serve_forever() -" & -``` - -If the user insists on a fixed port, use `127.0.0.1:<port>` — still never `0.0.0.0`. Document how to stop the server (`kill %1` or `pkill -f "http.server"`). - ---- - -## Examples Reference - -The `examples/` directory ships 15 complete, tested diagrams. Browse them for working patterns before writing a new diagram of a similar type: - -| File | Type | Demonstrates | -|------|------|--------------| -| `hospital-emergency-department-flow.md` | Flowchart | Priority routing with semantic colors | -| `feature-film-production-pipeline.md` | Flowchart | Phased workflow, horizontal sub-flows | -| `automated-password-reset-flow.md` | Flowchart | Auth flow with error branches | -| `autonomous-llm-research-agent-flow.md` | Flowchart | Loop-back arrows, decision branches | -| `place-order-uml-sequence.md` | Sequence | UML sequence diagram style | -| `commercial-aircraft-structure.md` | Physical | Paths, polygons, ellipses for realistic shapes | -| `wind-turbine-structure.md` | Physical cross-section | Underground/above-ground separation, color coding | -| `smartphone-layer-anatomy.md` | Exploded view | Alternating left/right labels, layered components | -| `apartment-floor-plan-conversion.md` | Floor plan | Walls, doors, proposed changes in dotted red | -| `banana-journey-tree-to-smoothie.md` | Narrative journey | Winding path, progressive state changes | -| `cpu-ooo-microarchitecture.md` | Hardware pipeline | Fan-out, memory hierarchy sidebar | -| `sn2-reaction-mechanism.md` | Chemistry | Molecules, curved arrows, energy profile | -| `smart-city-infrastructure.md` | Hub-spoke | Semantic line styles per system | -| `electricity-grid-flow.md` | Multi-stage flow | Voltage hierarchy, flow markers | -| `ml-benchmark-grouped-bar-chart.md` | Chart | Grouped bars, dual axis | - -Load any example with: -``` -skill_view(name="concept-diagrams", file_path="examples/<filename>") -``` - ---- - -## Quick Reference: What to Use When - -| User says | Diagram type | Suggested colors | -|-----------|--------------|------------------| -| "show the pipeline" | Flowchart | gray start/end, purple steps, red errors, teal deploy | -| "draw the data flow" | Data pipeline (left-right) | gray sources, purple processing, teal sinks | -| "visualize the system" | Structural (containment) | purple container, teal services, coral data | -| "map the endpoints" | API tree | purple root, one ramp per resource group | -| "show the services" | Microservice topology | gray ingress, teal services, purple bus, coral workers | -| "draw the aircraft/vehicle" | Physical | paths, polygons, ellipses for realistic shapes | -| "smart city / IoT" | Hub-spoke integration | semantic line styles per subsystem | -| "show the dashboard" | UI mockup | dark screen, chart colors: teal, purple, coral for alerts | -| "power grid / electricity" | Multi-stage flow | voltage hierarchy (HV/MV/LV line weights) | -| "wind turbine / turbine" | Physical cross-section | foundation + tower cutaway + nacelle color-coded | -| "journey of X / lifecycle" | Narrative journey | winding path, progressive state changes | -| "layers of X / exploded" | Exploded layer view | vertical stack, alternating labels | -| "CPU / pipeline" | Hardware pipeline | vertical stages, fan-out to execution ports | -| "floor plan / apartment" | Floor plan | walls, doors, proposed changes in dotted red | -| "reaction mechanism" | Chemistry | atoms, bonds, curved arrows, transition state, energy profile | diff --git a/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md b/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md deleted file mode 100644 index 7c11d3401e5..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md +++ /dev/null @@ -1,244 +0,0 @@ -# Apartment Floor Plan: 3 BHK to 4 BHK Conversion - -An architectural floor plan showing a 1,500 sq ft apartment with proposed modifications to convert from 3 BHK to 4 BHK. Demonstrates architectural drawing conventions, room layouts, proposed changes with dotted lines, and area comparison tables. - -## Key Patterns Used - -- **Architectural floor plan**: Top-down view with walls, doors, windows -- **Proposed modifications**: Dotted red lines for new walls -- **Room color coding**: Light fills to distinguish room types -- **Circulation paths**: Arrows showing new access routes -- **Data table**: Before/after area comparison with highlighting -- **Architectural symbols**: North arrow, scale bar, door swings - -## Diagram Type - -This is an **architectural floor plan** with: -- **Plan view**: Top-down orthographic projection -- **Overlay technique**: Existing structure + proposed changes -- **Quantitative data**: Area measurements and comparison table - -## Architectural Drawing Elements - -### Wall Styles - -```xml -<!-- Outer walls (thick) --> -<line class="wall" x1="0" y1="0" x2="560" y2="0"/> - -<!-- Internal walls (thinner) --> -<line class="wall-thin" x1="180" y1="0" x2="180" y2="140"/> - -<!-- Proposed new walls (dotted red) --> -<line class="proposed-wall" x1="125" y1="170" x2="125" y2="330"/> -``` - -```css -.wall { stroke: var(--text-primary); stroke-width: 6; fill: none; stroke-linecap: square; } -.wall-thin { stroke: var(--text-primary); stroke-width: 3; fill: none; } -.proposed-wall { stroke: #A32D2D; stroke-width: 4; fill: none; stroke-dasharray: 8 4; } -``` - -### Door Symbols - -```xml -<!-- Door opening with swing arc --> -<rect x="150" y="137" width="25" height="6" fill="var(--bg-primary)"/> -<path class="door" d="M150,140 L150,165"/> -<path class="door-swing" d="M150,140 A25,25 0 0,0 175,140"/> - -<!-- Sliding door (balcony) --> -<rect x="60" y="327" width="60" height="6" fill="var(--bg-primary)" stroke="var(--text-secondary)" stroke-width="1"/> -<line x1="60" y1="330" x2="90" y2="330" stroke="var(--text-secondary)" stroke-width="2"/> -<line x1="90" y1="330" x2="120" y2="330" stroke="var(--text-secondary)" stroke-width="2" stroke-dasharray="3 3"/> - -<!-- Proposed door (dotted) --> -<rect x="143" y="292" width="22" height="6" fill="var(--bg-primary)" stroke="#A32D2D" stroke-width="1" stroke-dasharray="3 2"/> -<path d="M165,295 A22,22 0 0,0 165,273" stroke="#A32D2D" stroke-width="1" stroke-dasharray="3 2" fill="none"/> -``` - -```css -.door { stroke: var(--text-secondary); stroke-width: 1.5; fill: none; } -.door-swing { stroke: var(--text-tertiary); stroke-width: 1; fill: none; stroke-dasharray: 3 2; } -``` - -### Window Symbols - -```xml -<!-- Window with glass indication --> -<rect class="window" x="-3" y="30" width="6" height="50"/> -<line class="window-glass" x1="0" y1="35" x2="0" y2="75"/> - -<!-- Horizontal window (top wall) --> -<rect class="window" x="220" y="-3" width="60" height="6"/> -<line class="window-glass" x1="225" y1="0" x2="275" y2="0"/> -``` - -```css -.window { stroke: var(--text-primary); stroke-width: 1; fill: var(--bg-primary); } -.window-glass { stroke: #378ADD; stroke-width: 2; fill: none; } -``` - -### Room Fills - -```xml -<!-- Different colors for room types --> -<rect class="room-master" x="3" y="3" width="174" height="134" rx="2"/> -<rect class="room-bed2" x="183" y="3" width="134" height="104" rx="2"/> -<rect class="room-living" x="3" y="173" width="554" height="154" rx="2"/> -<rect class="room-kitchen" x="443" y="3" width="114" height="104" rx="2"/> -<rect class="room-bath" x="183" y="113" width="54" height="54" rx="2"/> - -<!-- Proposed new room (highlighted) --> -<rect class="room-new" x="3" y="223" width="120" height="104"/> -``` - -```css -.room-master { fill: rgba(206, 203, 246, 0.3); } /* purple tint */ -.room-bed2 { fill: rgba(159, 225, 203, 0.3); } /* teal tint */ -.room-bed3 { fill: rgba(250, 199, 117, 0.3); } /* amber tint */ -.room-living { fill: rgba(245, 196, 179, 0.3); } /* coral tint */ -.room-kitchen { fill: rgba(237, 147, 177, 0.3); } /* pink tint */ -.room-bath { fill: rgba(133, 183, 235, 0.3); } /* blue tint */ -.room-new { fill: rgba(163, 45, 45, 0.15); } /* red tint for proposed */ -``` - -### Support Fixtures - -```xml -<!-- Kitchen counter hint --> -<rect x="450" y="15" width="50" height="25" fill="none" stroke="var(--text-tertiary)" stroke-width="0.5" rx="2"/> -<text class="tx" x="475" y="30" text-anchor="middle">Counter</text> - -<!-- Balcony (dashed outline) --> -<rect class="balcony-fill" x="3" y="333" width="200" height="50"/> -``` - -```css -.balcony { fill: none; stroke: var(--text-secondary); stroke-width: 2; stroke-dasharray: 6 3; } -.balcony-fill { fill: rgba(93, 202, 165, 0.1); } -``` - -### Room Labels - -```xml -<!-- Room name and area --> -<text class="room-label" x="90" y="65" text-anchor="middle">MASTER</text> -<text class="room-label" x="90" y="78" text-anchor="middle">BEDROOM</text> -<text class="area-label" x="90" y="95" text-anchor="middle">195 sq ft</text> - -<!-- Proposed room (in red) --> -<text class="room-label" x="63" y="268" text-anchor="middle" fill="#A32D2D">BEDROOM 4</text> -<text class="tx" x="63" y="282" text-anchor="middle" fill="#A32D2D">(NEW)</text> -``` - -```css -.room-label { font-family: system-ui; font-size: 11px; fill: var(--text-primary); font-weight: 500; } -.area-label { font-family: system-ui; font-size: 9px; fill: var(--text-tertiary); } -``` - -### Circulation Arrow - -```xml -<defs> - <marker id="circ-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto"> - <path d="M0,0 L10,5 L0,10 Z" class="circulation-fill"/> - </marker> -</defs> - -<path class="circulation" d="M300,250 L200,250 L145,250 L145,280" marker-end="url(#circ-arrow)"/> -<text class="tx" x="250" y="242" fill="#3B6D11" font-weight="500">New corridor access</text> -``` - -```css -.circulation { stroke: #3B6D11; stroke-width: 2; fill: none; } -.circulation-fill { fill: #3B6D11; } -``` - -### North Arrow and Scale Bar - -```xml -<!-- North arrow --> -<g transform="translate(520, 260)"> - <circle cx="0" cy="0" r="20" fill="none" stroke="var(--text-tertiary)" stroke-width="0.5"/> - <polygon points="0,-18 -5,5 0,0 5,5" fill="var(--text-primary)"/> - <text class="tx" x="0" y="-22" text-anchor="middle">N</text> -</g> - -<!-- Scale bar --> -<g transform="translate(420, 300)"> - <line x1="0" y1="0" x2="100" y2="0" stroke="var(--text-primary)" stroke-width="2"/> - <line x1="0" y1="-5" x2="0" y2="5" stroke="var(--text-primary)" stroke-width="1"/> - <line x1="50" y1="-3" x2="50" y2="3" stroke="var(--text-primary)" stroke-width="1"/> - <line x1="100" y1="-5" x2="100" y2="5" stroke="var(--text-primary)" stroke-width="1"/> - <text class="tx" x="0" y="15" text-anchor="middle">0</text> - <text class="tx" x="50" y="15" text-anchor="middle">5'</text> - <text class="tx" x="100" y="15" text-anchor="middle">10'</text> -</g> -``` - -## Area Comparison Table - -### Table Structure - -```xml -<!-- Header row --> -<rect class="table-header" x="0" y="0" width="180" height="28" rx="4 4 0 0"/> -<text class="ts" x="90" y="18" text-anchor="middle" font-weight="500">Room</text> - -<!-- Normal row --> -<rect class="table-row" x="0" y="28" width="180" height="24"/> -<text class="tx" x="10" y="44">Master Bedroom</text> -<text class="tx" x="230" y="44" text-anchor="middle">195</text> - -<!-- Alternating row --> -<rect class="table-row-alt" x="0" y="52" width="180" height="24"/> - -<!-- Highlighted row (for changes) --> -<rect class="table-highlight" x="0" y="100" width="180" height="24"/> -<text class="tx" x="10" y="116" fill="#A32D2D" font-weight="500">Bedroom 4 (NEW)</text> -<text class="tx" x="430" y="116" text-anchor="middle" fill="#3B6D11">+100</text> - -<!-- Total row --> -<rect x="0" y="268" width="180" height="28" fill="var(--bg-secondary)" stroke="var(--border)" stroke-width="1"/> -<text class="ts" x="10" y="286" font-weight="500">TOTAL CARPET AREA</text> -``` - -```css -.table-header { fill: var(--bg-secondary); } -.table-row { fill: var(--bg-primary); stroke: var(--border); stroke-width: 0.5; } -.table-row-alt { fill: var(--bg-tertiary); stroke: var(--border); stroke-width: 0.5; } -.table-highlight { fill: rgba(163, 45, 45, 0.1); stroke: #A32D2D; stroke-width: 0.5; } -``` - -## Layout Notes - -- **ViewBox**: 800×780 (portrait for floor plan + table) -- **Scale**: 10px = 1 foot (apartment ~50ft × 33ft) -- **Floor plan origin**: Offset at (50, 60) for margins -- **Wall thickness**: 6px outer, 3px inner (represents ~6" walls) -- **Room labels**: Centered in each room with area below -- **Table placement**: Below floor plan with full width - -## Color Coding - -| Element | Color | Usage | -|---------|-------|-------| -| Proposed walls | Red (#A32D2D) dotted | New construction | -| New room fill | Red 15% opacity | Bedroom 4 area | -| Circulation | Green (#3B6D11) | New access path | -| Window glass | Blue (#378ADD) | Glass indication | -| Bedrooms | Purple/Teal/Amber tints | Room differentiation | -| Wet areas | Blue tint | Bathrooms | -| Living | Coral tint | Common areas | - -## When to Use This Pattern - -Use this diagram style for: -- Apartment/house floor plans -- Office layout planning -- Renovation proposals showing before/after -- Space planning with area calculations -- Real estate marketing materials -- Interior design presentations -- Building permit documentation diff --git a/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md b/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md deleted file mode 100644 index 86cd1cc0782..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md +++ /dev/null @@ -1,276 +0,0 @@ -# Automated Password Reset Flow - -A two-section flowchart tracing the full user journey for a web application password reset: the initial request phase (forgot password → email check → token generation) and the reset-form phase (link click → new password entry → token/password validation). Demonstrates multi-exit decision diamonds, a three-column branching layout, a loop-back path, and a cross-section separator arrow. - -## Key Patterns Used - -- **Three-column layout**: Left column (error/terminal branches at cx=115), center column (main happy path at cx=340), right column (expired-token branch at cx=552) — allows side branches to live at the same y-level as center nodes without overlap -- **Decision diamonds with `<polygon>`**: Each decision uses a `<g class="decision">` wrapper containing a `<polygon>` and centered `<text>`; the diamond points are computed as `cx±hw, cy±hh` (hw=100, hh=28) -- **Pill-shaped terminals**: Start and end nodes use `rx=22` on their `<rect>` to signal entry/exit points; all mid-flow process nodes use `rx=8` -- **Three-branch decision paths**: Each diamond has a "Yes" branch (down, short `<line>`) and a "No" branch (`<path>` going horizontal then vertical to a side column) -- **Loop-back path**: Mismatch error node loops back to the password-entry node via a routing corridor at x=215 — a 5-px gap between the left column (right edge x=210) and center column (left edge x=220); the path exits the bottom of the error node, drops below it, travels right to x=215, then goes up to the target node's center y, then right 5 px into the node's left edge -- **Section separator**: A dashed horizontal `<line>` at y=452 splits the two phases; the connecting arrow crosses it with a faded label ("user receives email") to preserve flow continuity -- **Italic annotation**: The exact UX copy for the generic message ("If that email exists…") is shown as a faded italic `ts` text block below the left-branch terminal node -- **Legend row**: Five inline swatches (gray, purple, teal, red, amber diamond) at the bottom explain the color-to-role mapping - -## Diagram - -```xml -<svg width="100%" viewBox="0 0 680 960" xmlns="http://www.w3.org/2000/svg"> - <defs> - <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" - markerWidth="6" markerHeight="6" orient="auto-start-reverse"> - <path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" - stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> - </marker> - </defs> - - <!-- - Column layout (680px viewBox, safe area x=40–640): - Left col : x=20, w=190, cx=115 (error / terminal branches) - Center col: x=220, w=240, cx=340 (main happy path) - Right col: x=465, w=175, cx=552 (expired-token branch) - Loop corridor at x=215 (5-px gap between left and center cols) - --> - - <!-- ═══ SECTION 1 — Forgot password request ═══ --> - <text class="ts" x="40" y="38" opacity=".45">Section 1 — Forgot password request</text> - - <!-- START terminal (pill rx=22 signals start/end) --> - <g class="c-gray"> - <rect x="220" y="46" width="240" height="44" rx="22"/> - <text class="th" x="340" y="68" text-anchor="middle" dominant-baseline="central">User: "Forgot password"</text> - </g> - - <line x1="340" y1="90" x2="340" y2="108" class="arr" marker-end="url(#arrow)"/> - - <!-- N2 · Enter email --> - <g class="c-gray"> - <rect x="220" y="108" width="240" height="44" rx="8"/> - <text class="th" x="340" y="130" text-anchor="middle" dominant-baseline="central">Enter email address</text> - </g> - - <line x1="340" y1="152" x2="340" y2="172" class="arr" marker-end="url(#arrow)"/> - - <!-- D1 · Email in system? diamond: center=(340,200) hw=100 hh=28 --> - <g class="decision"> - <polygon points="340,172 440,200 340,228 240,200"/> - <text class="th" x="340" y="200" text-anchor="middle" dominant-baseline="central">Email in system?</text> - </g> - - <!-- D1 "No" → left column --> - <path d="M 240,200 L 115,200 L 115,248" class="arr" marker-end="url(#arrow)"/> - <text class="ts" x="178" y="193" text-anchor="middle" opacity=".75">No</text> - - <!-- D1 "Yes" → continue down --> - <line x1="340" y1="228" x2="340" y2="248" class="arr" marker-end="url(#arrow)"/> - <text class="ts" x="348" y="242" text-anchor="start" opacity=".75">Yes</text> - - <!-- ── Left branch (D1 = No): generic security message → end ── --> - - <!-- L1 · Generic message (security: never confirm email existence) --> - <g class="c-gray"> - <rect x="20" y="248" width="190" height="56" rx="8"/> - <text class="th" x="115" y="269" text-anchor="middle" dominant-baseline="central">Generic message shown</text> - <text class="ts" x="115" y="287" text-anchor="middle" dominant-baseline="central">Email sent if found</text> - </g> - - <line x1="115" y1="304" x2="115" y2="324" class="arr" marker-end="url(#arrow)"/> - - <!-- L2 · End terminal (left) --> - <g class="c-gray"> - <rect x="20" y="324" width="190" height="44" rx="22"/> - <text class="th" x="115" y="346" text-anchor="middle" dominant-baseline="central">Request handled</text> - </g> - - <!-- Italic annotation: actual UX copy shown below the end node --> - <text class="ts" x="20" y="384" opacity=".45" font-style="italic">"If that email exists, a reset</text> - <text class="ts" x="20" y="398" opacity=".45" font-style="italic">link has been sent."</text> - - <!-- ── Center Yes branch: system generates & sends token ── --> - - <!-- N3 · Generate unique token --> - <g class="c-purple"> - <rect x="220" y="248" width="240" height="56" rx="8"/> - <text class="th" x="340" y="269" text-anchor="middle" dominant-baseline="central">Generate unique token</text> - <text class="ts" x="340" y="287" text-anchor="middle" dominant-baseline="central">Time-limited, cryptographic</text> - </g> - - <line x1="340" y1="304" x2="340" y2="324" class="arr" marker-end="url(#arrow)"/> - - <!-- N4 · Store token + user ID --> - <g class="c-purple"> - <rect x="220" y="324" width="240" height="44" rx="8"/> - <text class="th" x="340" y="346" text-anchor="middle" dominant-baseline="central">Store token + user ID</text> - </g> - - <line x1="340" y1="368" x2="340" y2="388" class="arr" marker-end="url(#arrow)"/> - - <!-- N5 · Send reset email --> - <g class="c-teal"> - <rect x="220" y="388" width="240" height="44" rx="8"/> - <text class="th" x="340" y="410" text-anchor="middle" dominant-baseline="central">Send reset link via email</text> - </g> - - <!-- ═══ Section separator ═══ --> - <line x1="40" y1="452" x2="640" y2="452" - stroke="var(--border)" stroke-width="1" stroke-dasharray="8 5"/> - - <!-- Arrow crossing separator (with inline label) --> - <line x1="340" y1="432" x2="340" y2="472" class="arr" marker-end="url(#arrow)"/> - <text class="ts" x="348" y="448" text-anchor="start" opacity=".55">user receives email</text> - - <text class="ts" x="40" y="464" opacity=".45">Section 2 — Password reset form</text> - - <!-- ═══ SECTION 2 — Password reset form ═══ --> - - <!-- N6 · User clicks reset link --> - <g class="c-gray"> - <rect x="220" y="480" width="240" height="44" rx="8"/> - <text class="th" x="340" y="502" text-anchor="middle" dominant-baseline="central">User clicks reset link</text> - </g> - - <line x1="340" y1="524" x2="340" y2="544" class="arr" marker-end="url(#arrow)"/> - - <!-- N7 · Enter new password ×2 --> - <g class="c-gray"> - <rect x="220" y="544" width="240" height="56" rx="8"/> - <text class="th" x="340" y="565" text-anchor="middle" dominant-baseline="central">Enter new password ×2</text> - <text class="ts" x="340" y="583" text-anchor="middle" dominant-baseline="central">Confirm both passwords match</text> - </g> - - <line x1="340" y1="600" x2="340" y2="620" class="arr" marker-end="url(#arrow)"/> - - <!-- D2 · Token expired? diamond: center=(340,648) hw=100 hh=28 --> - <g class="decision"> - <polygon points="340,620 440,648 340,676 240,648"/> - <text class="th" x="340" y="648" text-anchor="middle" dominant-baseline="central">Token expired?</text> - </g> - - <!-- D2 "Yes" → right column (expired-token branch) --> - <path d="M 440,648 L 552,648 L 552,692" class="arr" marker-end="url(#arrow)"/> - <text class="ts" x="496" y="641" text-anchor="middle" opacity=".75">Yes</text> - - <!-- D2 "No" → down to password-match check --> - <line x1="340" y1="676" x2="340" y2="714" class="arr" marker-end="url(#arrow)"/> - <text class="ts" x="348" y="698" text-anchor="start" opacity=".75">No</text> - - <!-- ── Right branch (D2 = Yes): token expired → dead end ── --> - - <!-- R1 · Token expired error --> - <g class="c-red"> - <rect x="465" y="692" width="175" height="56" rx="8"/> - <text class="th" x="552" y="713" text-anchor="middle" dominant-baseline="central">Token expired</text> - <text class="ts" x="552" y="731" text-anchor="middle" dominant-baseline="central">Show expiry error</text> - </g> - - <line x1="552" y1="748" x2="552" y2="768" class="arr" marker-end="url(#arrow)"/> - - <!-- R2 · End terminal (right) --> - <g class="c-gray"> - <rect x="465" y="768" width="175" height="44" rx="22"/> - <text class="th" x="552" y="790" text-anchor="middle" dominant-baseline="central">End — request again</text> - </g> - - <!-- D3 · Passwords match? diamond: center=(340,742) hw=100 hh=28 --> - <g class="decision"> - <polygon points="340,714 440,742 340,770 240,742"/> - <text class="th" x="340" y="742" text-anchor="middle" dominant-baseline="central">Passwords match?</text> - </g> - - <!-- D3 "No" → left column (mismatch branch) --> - <path d="M 240,742 L 115,742 L 115,786" class="arr" marker-end="url(#arrow)"/> - <text class="ts" x="178" y="735" text-anchor="middle" opacity=".75">No</text> - - <!-- D3 "Yes" → down to reset --> - <line x1="340" y1="770" x2="340" y2="790" class="arr" marker-end="url(#arrow)"/> - <text class="ts" x="348" y="783" text-anchor="start" opacity=".75">Yes</text> - - <!-- ── Left branch (D3 = No): passwords don't match → loop back ── --> - - <!-- L3 · Password mismatch error --> - <g class="c-red"> - <rect x="20" y="786" width="190" height="56" rx="8"/> - <text class="th" x="115" y="807" text-anchor="middle" dominant-baseline="central">Password mismatch</text> - <text class="ts" x="115" y="825" text-anchor="middle" dominant-baseline="central">Passwords do not match</text> - </g> - - <!-- Loop-back arrow: exits L3 bottom → drops to y=862 → - travels right to corridor x=215 → climbs to N7 center y=572 → - enters N7 left edge at (220, 572) pointing right --> - <path d="M 115,842 L 115,862 L 215,862 L 215,572 L 220,572" - class="arr" marker-end="url(#arrow)"/> - <text class="ts" x="224" y="538" text-anchor="start" opacity=".6">retry</text> - - <!-- ── Center Yes branch (D3 = Yes): reset password & invalidate token ── --> - - <!-- N8 · Reset password --> - <g class="c-teal"> - <rect x="220" y="790" width="240" height="56" rx="8"/> - <text class="th" x="340" y="811" text-anchor="middle" dominant-baseline="central">Reset password</text> - <text class="ts" x="340" y="829" text-anchor="middle" dominant-baseline="central">Invalidate used token</text> - </g> - - <line x1="340" y1="846" x2="340" y2="866" class="arr" marker-end="url(#arrow)"/> - - <!-- N9 · Success terminal --> - <g class="c-green"> - <rect x="220" y="866" width="240" height="44" rx="22"/> - <text class="th" x="340" y="888" text-anchor="middle" dominant-baseline="central">Password reset complete</text> - </g> - - <!-- ═══ Legend ═══ --> - <text class="ts" x="40" y="930" opacity=".4">Legend —</text> - <rect x="108" y="920" width="13" height="13" rx="2" fill="#F1EFE8" stroke="#5F5E5A" stroke-width="0.5"/> - <text class="ts" x="126" y="930" opacity=".7">User action</text> - <rect x="210" y="920" width="13" height="13" rx="2" fill="#EEEDFE" stroke="#534AB7" stroke-width="0.5"/> - <text class="ts" x="228" y="930" opacity=".7">System process</text> - <rect x="334" y="920" width="13" height="13" rx="2" fill="#E1F5EE" stroke="#0F6E56" stroke-width="0.5"/> - <text class="ts" x="352" y="930" opacity=".7">Email / success</text> - <rect x="455" y="920" width="13" height="13" rx="2" fill="#FCEBEB" stroke="#A32D2D" stroke-width="0.5"/> - <text class="ts" x="473" y="930" opacity=".7">Error state</text> - <polygon points="556,926 566,932 556,938 546,932" fill="#FAEEDA" stroke="#854F0B" stroke-width="0.5"/> - <text class="ts" x="572" y="932" opacity=".7">Decision</text> - -</svg> -``` - -## Custom CSS - -Add these classes to the hosting page `<style>` block (in addition to the standard skill CSS): - -```css -/* Decision diamond — amber fill, same palette as c-amber */ -.decision > polygon { fill: #FAEEDA; stroke: #854F0B; stroke-width: 0.5; } -.decision > .th { fill: #633806; } - -@media (prefers-color-scheme: dark) { - .decision > polygon { fill: #633806; stroke: #EF9F27; } - .decision > .th { fill: #FAC775; } -} -``` - -## Color Assignments - -| Element | Color | Reason | -|---------|-------|--------| -| Start / end terminals | `c-gray` | Neutral entry and exit points | -| User actions (enter email, click link, enter password) | `c-gray` | User-facing steps with no system processing | -| Generic message + request-handled terminal | `c-gray` | Intentionally neutral — the security message must not reveal data | -| Generate & store token | `c-purple` | Backend system operations | -| Send reset email | `c-teal` | Positive external action (outbound communication) | -| Token expired error | `c-red` | Failure / blocking error state | -| Password mismatch error | `c-red` | Validation failure | -| Reset password + success | `c-teal` / `c-green` | Positive outcome: teal for the action, green pill for the terminal | -| Decision diamonds | `c-amber` (custom `.decision`) | Warning / branch point — matches amber semantic meaning | - -## Layout Notes - -- **ViewBox**: 680×960 — tall flowchart with two phases -- **Three-column structure**: Left (cx=115), center (cx=340), right (cx=552) — each branch stays within its column; only `<path>` arrows cross column boundaries -- **Diamond formula**: `<polygon points="cx,cy-hh cx+hw,cy cx,cy+hh cx-hw,cy"/>` with hw=100, hh=28 gives a 200×56px diamond that sits flush with the center column (x=220–460) -- **Branch routing pattern**: "No" paths use `<path d="M left_point,cy L side_cx,cy L side_cx,node_top">` — one horizontal segment + one vertical segment, no curves needed -- **Loop corridor**: The 5-px gap at x=210–220 between left and center columns provides a clean vertical channel for the loop-back path without any node overlap; the path exits node bottom, drops 20px, goes right to x=215, climbs to target y, enters from left -- **Section separator**: A dashed `<line>` at y=452 with `stroke-dasharray="8 5"` provides a visual phase break; the single connecting arrow crosses it at center, with a faded label on the arrow -- **Pill terminals**: `rx=22` (half the 44px node height) produces a perfect capsule/pill shape — use this consistently for all start/end terminals -- **Error annotation**: The exact UX copy is rendered as faded (`opacity=".45"`) italic `ts` text below the relevant node, keeping it informative without cluttering the flow diff --git a/optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md b/optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md deleted file mode 100644 index f0959f003a3..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md +++ /dev/null @@ -1,240 +0,0 @@ -# Autonomous LLM Research Agent Flow - -A multi-section flowchart showing Karpathy's autoresearch framework: human-agent handoff, the autonomous experiment loop with keep/discard decision branching, and the modifiable training pipeline. Demonstrates loop-back arrows, convergent decision paths, and semantic color coding for outcomes. - -## Key Patterns Used - -- **Three-section layout**: Setup row, main loop container, and detail container — each visually distinct -- **Neutral dashed containers**: Loop and training pipeline use `var(--bg-secondary)` fill with dashed borders to recede behind colored content nodes -- **Decision branching with convergence**: "val_bpb improved?" splits into Keep (green) and Discard (red), then both converge back to "Log to results.tsv" -- **Loop-back arrow**: Dashed path with rounded corners on the right side of the container showing infinite repetition -- **Semantic color for outcomes**: Green = improvement (keep), Red = no improvement (discard) — not arbitrary decoration -- **Highlighted key step**: "Run training" uses `c-coral` to visually distinguish the most important step from other `c-teal` actions -- **Horizontal pipeline flow**: Training details section uses left-to-right arrow-connected nodes (GPT → MuonAdamW → Evaluation) -- **Footer metadata**: Fixed constraints shown as subtle centered text below the pipeline nodes -- **Legend row**: Color key at the bottom explaining what each color means - -## Diagram - -```xml -<svg width="100%" viewBox="0 0 680 920" xmlns="http://www.w3.org/2000/svg"> - <defs> - <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" - markerWidth="6" markerHeight="6" orient="auto-start-reverse"> - <path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" - stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> - </marker> - </defs> - - <!-- ========================================== --> - <!-- SECTION 1: SETUP (Human → program.md → AI) --> - <!-- ========================================== --> - - <text class="ts" x="40" y="30" text-anchor="start" opacity=".5">One-time setup</text> - - <!-- Human --> - <g class="node c-gray"> - <rect x="60" y="42" width="140" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="130" y="62" text-anchor="middle" dominant-baseline="central">Human</text> - <text class="ts" x="130" y="82" text-anchor="middle" dominant-baseline="central">Researcher</text> - </g> - - <!-- Arrow: Human → program.md --> - <line x1="200" y1="70" x2="250" y2="70" class="arr" marker-end="url(#arrow)"/> - - <!-- program.md --> - <g class="node c-gray"> - <rect x="250" y="42" width="180" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="340" y="62" text-anchor="middle" dominant-baseline="central">program.md</text> - <text class="ts" x="340" y="82" text-anchor="middle" dominant-baseline="central">Agent instructions</text> - </g> - - <!-- Arrow: program.md → AI Agent --> - <line x1="430" y1="70" x2="470" y2="70" class="arr" marker-end="url(#arrow)"/> - - <!-- AI Agent --> - <g class="node c-purple"> - <rect x="470" y="42" width="160" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="550" y="62" text-anchor="middle" dominant-baseline="central">AI agent</text> - <text class="ts" x="550" y="82" text-anchor="middle" dominant-baseline="central">Claude / Codex</text> - </g> - - <!-- Arrow: Setup row → Loop (from program.md center down) --> - <line x1="340" y1="98" x2="340" y2="142" class="arr" marker-end="url(#arrow)"/> - - <!-- ========================================== --> - <!-- SECTION 2: AUTONOMOUS EXPERIMENT LOOP --> - <!-- ========================================== --> - - <!-- Loop container (neutral dashed) --> - <g> - <rect x="40" y="142" width="600" height="528" rx="16" - stroke-width="1" stroke-dasharray="6 4" - fill="var(--bg-secondary)" stroke="var(--border)"/> - <text class="th" x="66" y="170">Autonomous experiment loop</text> - <text class="ts" x="66" y="188">~12 experiments/hour — runs until manually stopped</text> - </g> - - <!-- Step 1: Read code + past results --> - <g class="node c-teal"> - <rect x="170" y="208" width="280" height="44" rx="8" stroke-width="0.5"/> - <text class="th" x="310" y="230" text-anchor="middle" dominant-baseline="central">Read code + past results</text> - </g> - - <!-- Arrow: S1 → S2 --> - <line x1="310" y1="252" x2="310" y2="274" class="arr" marker-end="url(#arrow)"/> - - <!-- Step 2: Propose + edit train.py --> - <g class="node c-teal"> - <rect x="170" y="274" width="280" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="310" y="294" text-anchor="middle" dominant-baseline="central">Propose + edit train.py</text> - <text class="ts" x="310" y="314" text-anchor="middle" dominant-baseline="central">Arch, optimizer, hyperparameters</text> - </g> - - <!-- Arrow: S2 → S3 --> - <line x1="310" y1="330" x2="310" y2="352" class="arr" marker-end="url(#arrow)"/> - - <!-- Step 3: Run training (highlighted — key step) --> - <g class="node c-coral"> - <rect x="170" y="352" width="280" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="310" y="372" text-anchor="middle" dominant-baseline="central">Run training</text> - <text class="ts" x="310" y="392" text-anchor="middle" dominant-baseline="central">uv run train.py (5 min budget)</text> - </g> - - <!-- Arrow: S3 → S4 --> - <line x1="310" y1="408" x2="310" y2="430" class="arr" marker-end="url(#arrow)"/> - - <!-- Step 4: Decision — val_bpb improved? --> - <g class="node c-gray"> - <rect x="170" y="430" width="280" height="44" rx="8" stroke-width="0.5"/> - <text class="th" x="310" y="452" text-anchor="middle" dominant-baseline="central">val_bpb improved?</text> - </g> - - <!-- Decision arrows to Keep / Discard --> - <line x1="240" y1="474" x2="175" y2="508" class="arr" marker-end="url(#arrow)"/> - <line x1="380" y1="474" x2="445" y2="508" class="arr" marker-end="url(#arrow)"/> - - <!-- Decision labels --> - <text class="ts" x="195" y="496" opacity=".6">yes</text> - <text class="ts" x="416" y="496" opacity=".6">no</text> - - <!-- Keep — advance branch --> - <g class="node c-green"> - <rect x="70" y="508" width="210" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="175" y="528" text-anchor="middle" dominant-baseline="central">Keep</text> - <text class="ts" x="175" y="548" text-anchor="middle" dominant-baseline="central">Advance git branch</text> - </g> - - <!-- Discard — git reset --> - <g class="node c-red"> - <rect x="340" y="508" width="210" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="445" y="528" text-anchor="middle" dominant-baseline="central">Discard</text> - <text class="ts" x="445" y="548" text-anchor="middle" dominant-baseline="central">Git reset to previous</text> - </g> - - <!-- Converge arrows: Keep → Log, Discard → Log --> - <line x1="175" y1="564" x2="250" y2="590" class="arr" marker-end="url(#arrow)"/> - <line x1="445" y1="564" x2="370" y2="590" class="arr" marker-end="url(#arrow)"/> - - <!-- Step 6: Log to results.tsv --> - <g class="node c-teal"> - <rect x="170" y="590" width="280" height="44" rx="8" stroke-width="0.5"/> - <text class="th" x="310" y="612" text-anchor="middle" dominant-baseline="central">Log to results.tsv</text> - </g> - - <!-- Loop-back arrow (dashed, right side) --> - <path d="M 450 612 L 564 612 Q 576 612 576 600 L 576 242 Q 576 230 564 230 L 450 230" - fill="none" class="arr" stroke-dasharray="4 3" marker-end="url(#arrow)"/> - - <!-- ========================================== --> - <!-- SECTION 3: TRAINING PIPELINE DETAILS --> - <!-- ========================================== --> - - <!-- Connection arrow: Loop → Training details --> - <line x1="310" y1="670" x2="310" y2="710" class="arr" marker-end="url(#arrow)"/> - - <!-- Training container (neutral dashed) --> - <g> - <rect x="40" y="710" width="600" height="170" rx="16" - stroke-width="1" stroke-dasharray="6 4" - fill="var(--bg-secondary)" stroke="var(--border)"/> - <text class="th" x="66" y="738">train.py — modifiable training pipeline</text> - <text class="ts" x="66" y="756">Runs during each training step — single GPU, single file</text> - </g> - - <!-- GPT model --> - <g class="node c-coral"> - <rect x="70" y="774" width="155" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="147" y="794" text-anchor="middle" dominant-baseline="central">GPT model</text> - <text class="ts" x="147" y="814" text-anchor="middle" dominant-baseline="central">RoPE, FlashAttn3</text> - </g> - - <!-- Arrow: GPT → MuonAdamW --> - <line x1="225" y1="802" x2="260" y2="802" class="arr" marker-end="url(#arrow)"/> - - <!-- MuonAdamW optimizer --> - <g class="node c-coral"> - <rect x="260" y="774" width="155" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="337" y="794" text-anchor="middle" dominant-baseline="central">MuonAdamW</text> - <text class="ts" x="337" y="814" text-anchor="middle" dominant-baseline="central">Hybrid optimizer</text> - </g> - - <!-- Arrow: MuonAdamW → Evaluation --> - <line x1="415" y1="802" x2="450" y2="802" class="arr" marker-end="url(#arrow)"/> - - <!-- Evaluation --> - <g class="node c-amber"> - <rect x="450" y="774" width="155" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="527" y="794" text-anchor="middle" dominant-baseline="central">Evaluation</text> - <text class="ts" x="527" y="814" text-anchor="middle" dominant-baseline="central">val_bpb metric</text> - </g> - - <!-- Footer: fixed constraints --> - <text class="ts" x="340" y="856" text-anchor="middle" opacity=".5">climbmix-400b data · 8K BPE vocab · 300s budget · 2048 context</text> - - <!-- ========================================== --> - <!-- LEGEND --> - <!-- ========================================== --> - - <g class="c-teal"><rect x="40" y="890" width="14" height="14" rx="3" stroke-width="0.5"/></g> - <text class="ts" x="62" y="902">Agent actions</text> - - <g class="c-coral"><rect x="170" y="890" width="14" height="14" rx="3" stroke-width="0.5"/></g> - <text class="ts" x="192" y="902">Training run</text> - - <g class="c-green"><rect x="300" y="890" width="14" height="14" rx="3" stroke-width="0.5"/></g> - <text class="ts" x="322" y="902">Improvement</text> - - <g class="c-red"><rect x="430" y="890" width="14" height="14" rx="3" stroke-width="0.5"/></g> - <text class="ts" x="452" y="902">No improvement</text> - -</svg> -``` - -## Color Assignments - -| Element | Color | Reason | -|---------|-------|--------| -| Human, program.md | `c-gray` | Neutral setup / input nodes | -| AI agent | `c-purple` | The active intelligent actor | -| Loop action steps | `c-teal` | Agent's analytical/editing actions | -| Run training | `c-coral` | Highlighted key step — the 5-min training run | -| Decision check | `c-gray` | Neutral evaluation checkpoint | -| Keep (improved) | `c-green` | Semantic success — val_bpb decreased | -| Discard (not improved) | `c-red` | Semantic failure — no improvement | -| Training pipeline nodes | `c-coral` | Training infrastructure components | -| Evaluation node | `c-amber` | Distinct from training — measurement/metric role | -| Containers | Neutral (dashed) | Subtle grouping that recedes behind content | - -## Layout Notes - -- **ViewBox**: 680×920 (standard width, tall for 3 sections) -- **Three sections**: Setup row (y=30–98), loop container (y=142–670), training details (y=710–880) -- **Container style**: Dashed border (`stroke-dasharray="6 4"`), neutral fill (`var(--bg-secondary)`), `stroke-width="1"` — not colored, so inner nodes pop -- **Loop-back arrow**: Dashed `<path>` with quadratic curves (`Q`) at corners for smooth rounded turns, running up the right side of the loop container from "Log" back to "Read code" -- **Decision pattern**: Single question node ("val_bpb improved?") with diagonal arrows to Keep/Discard, then convergent diagonal arrows back to "Log to results.tsv" -- **Decision labels**: "yes"/"no" labels placed along the diagonal arrows with `opacity=".6"` to stay subtle -- **Key step highlight**: "Run training" uses `c-coral` while surrounding steps use `c-teal`, drawing the eye to the most important step -- **Horizontal sub-flow**: Training pipeline uses left-to-right arrow-connected nodes (GPT model → MuonAdamW → Evaluation) -- **Footer metadata**: Fixed constraints (data, vocab, budget, context) shown as a single centered `ts` text line with `opacity=".5"` -- **Legend**: Four color swatches at the bottom explaining the semantic meaning of each color used diff --git a/optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md b/optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md deleted file mode 100644 index d4fe3bea159..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md +++ /dev/null @@ -1,161 +0,0 @@ -# Journey of a Banana: From Tree to Smoothie - -A narrative journey diagram following a single banana across 3,000 miles and 3 weeks, from harvest in Costa Rica to a smoothie in the consumer's kitchen. Demonstrates storytelling through visualization, winding path layout, and progressive state changes. - -## Key Patterns Used - -- **Winding journey path**: S-curve connecting all stages visually -- **Location markers**: Country flags and place names for geographic context -- **Progressive state changes**: Banana color changes (green → yellow → brown → frozen → smoothie) -- **Narrative details**: Fun elements like spider check, stickers, price tags -- **Timeline**: Bottom timeline showing duration of journey -- **Environmental context**: Ocean waves, gas clouds, store awning - -## New Shape Techniques - -### Banana (curved fruit shape) -```xml -<!-- Green banana --> -<path class="banana-green" d="M 5 0 Q 0 10 3 20 Q 6 25 10 20 Q 13 10 8 0 Z"/> - -<!-- Yellow banana --> -<path class="banana-yellow" d="M 0 5 Q -6 18 0 32 Q 7 40 15 30 Q 20 15 12 5 Z"/> - -<!-- Brown overripe banana with spots --> -<path class="banana-brown" d="M 0 5 Q -5 15 0 28 Q 6 35 14 26 Q 18 14 12 5 Z"/> -<circle class="banana-spots" cx="5" cy="15" r="1.5"/> -<circle class="banana-spots" cx="9" cy="20" r="1"/> -``` - -### Banana Tree -```xml -<!-- Trunk --> -<rect class="tree-trunk" x="55" y="50" width="15" height="60" rx="3"/> -<!-- Leaves (rotated ellipses) --> -<ellipse class="tree-leaf" cx="62" cy="45" rx="40" ry="15" transform="rotate(-20, 62, 45)"/> -<ellipse class="tree-leaf" cx="62" cy="50" rx="35" ry="12" transform="rotate(25, 62, 50)"/> -<!-- Banana bunch hanging --> -<g transform="translate(40, 55)"> - <path class="banana-green" d="M 5 0 Q 0 10 3 20 Q 6 25 10 20 Q 13 10 8 0 Z"/> - <path class="banana-green" d="M 12 2 Q 8 12 11 22 Q 14 27 18 22 Q 21 12 16 2 Z"/> - <rect class="stem" x="8" y="-5" width="12" height="8" rx="2"/> -</g> -``` - -### Cargo Ship -```xml -<!-- Ocean waves --> -<path class="ocean" d="M 0 90 Q 30 85 60 90 Q 90 95 120 90 Q 150 85 180 90 L 180 110 L 0 110 Z" opacity="0.5"/> -<!-- Hull --> -<path class="ship-hull" d="M 20 90 L 30 60 L 160 60 L 170 90 Q 150 95 95 95 Q 40 95 20 90 Z"/> -<!-- Deck --> -<rect class="ship-deck" x="40" y="45" width="110" height="18" rx="2"/> -<!-- Reefer containers --> -<rect class="container" x="45" y="25" width="30" height="22" rx="2"/> -<!-- Refrigeration symbol --> -<text x="60" y="40" text-anchor="middle" fill="#185FA5" style="font-size:10px">❄</text> -<!-- Smoke stack --> -<rect x="145" y="35" width="8" height="15" fill="#444441"/> -``` - -### Inspector Figure -```xml -<!-- Body --> -<rect class="inspector" x="10" y="20" width="25" height="35" rx="3"/> -<!-- Head --> -<circle class="inspector" cx="22" cy="12" r="10"/> -<!-- Hat --> -<rect x="12" y="2" width="20" height="6" rx="2" fill="#534AB7"/> -<!-- Clipboard --> -<rect class="clipboard" x="38" y="28" width="15" height="20" rx="2"/> -<line x1="42" y1="34" x2="50" y2="34" stroke="#888780" stroke-width="1"/> -``` - -### Spider with "No" Symbol -```xml -<circle cx="15" cy="15" r="18" fill="none" stroke="#A32D2D" stroke-width="2"/> -<line x1="3" y1="3" x2="27" y2="27" stroke="#A32D2D" stroke-width="2"/> -<!-- Spider body --> -<ellipse class="spider" cx="15" cy="15" rx="4" ry="5"/> -<ellipse class="spider" cx="15" cy="10" rx="3" ry="3"/> -<!-- Legs --> -<line x1="12" y1="14" x2="5" y2="10" stroke="#2C2C2A" stroke-width="1"/> -<line x1="18" y1="14" x2="25" y2="10" stroke="#2C2C2A" stroke-width="1"/> -``` - -### Blender with Smoothie -```xml -<!-- Blender jar --> -<path class="blender" d="M 5 5 L 0 45 L 35 45 L 30 5 Z"/> -<!-- Smoothie inside (wavy top) --> -<path class="smoothie" d="M 3 20 L 0 45 L 35 45 L 32 20 Q 25 18 17 22 Q 10 18 3 20 Z"/> -<!-- Blender base --> -<rect class="blender" x="-2" y="45" width="40" height="12" rx="3"/> -<!-- Lid --> -<rect x="8" y="0" width="20" height="8" rx="2" fill="#AFA9EC" stroke="#534AB7"/> -<!-- Banana chunks floating --> -<ellipse cx="12" cy="32" rx="4" ry="2" fill="#FAC775"/> -``` - -### Winding Journey Path -```xml -<path class="journey-path" d=" - M 80 100 - L 200 100 - Q 280 100 280 150 - L 280 180 - Q 280 220 320 220 - L 520 220 - Q 560 220 560 260 - L 560 320 - Q 560 360 520 360 - L 280 360 - ... -"/> -``` - -## CSS Classes - -```css -/* Journey */ -.journey-path { stroke: #D3D1C7; stroke-width: 3; fill: none; stroke-linecap: round; } - -/* Banana ripeness stages */ -.banana-green { fill: #97C459; stroke: #3B6D11; stroke-width: 0.5; } -.banana-yellow { fill: #FAC775; stroke: #BA7517; stroke-width: 0.5; } -.banana-brown { fill: #854F0B; stroke: #633806; stroke-width: 0.5; } -.banana-spots { fill: #633806; } - -/* Environment elements */ -.tree-trunk { fill: #854F0B; stroke: #633806; stroke-width: 1; } -.tree-leaf { fill: #97C459; stroke: #3B6D11; stroke-width: 0.5; } -.ocean { fill: #85B7EB; } -.ship-hull { fill: #5F5E5A; stroke: #444441; stroke-width: 1; } -.container { fill: #E6F1FB; stroke: #185FA5; stroke-width: 1; } -.gas-cloud { fill: #C0DD97; stroke: #97C459; stroke-width: 0.5; opacity: 0.6; } - -/* Buildings */ -.packhouse { fill: #F1EFE8; stroke: #5F5E5A; stroke-width: 1; } -.warehouse { fill: #FAEEDA; stroke: #854F0B; stroke-width: 1; } -.store { fill: #E1F5EE; stroke: #0F6E56; stroke-width: 1; } - -/* Kitchen */ -.counter { fill: #FAECE7; stroke: #993C1D; stroke-width: 1; } -.blender { fill: #EEEDFE; stroke: #534AB7; stroke-width: 1; } -.smoothie { fill: #FAC775; } -.freezer { fill: #E6F1FB; stroke: #185FA5; stroke-width: 1; } - -/* Details */ -.sticker { fill: #378ADD; stroke: #185FA5; stroke-width: 0.3; } -.spider { fill: #2C2C2A; stroke: #1a1a18; stroke-width: 0.3; } -``` - -## Layout Notes - -- **ViewBox**: 850×680 (tall for winding path) -- **Path style**: S-curve winding path connects all 7 stages -- **Location labels**: Country flags + place names anchor geographic context -- **State progression**: Same object (banana) shown in different states throughout -- **Timeline**: Horizontal timeline at bottom shows journey duration -- **Narrative elements**: Fun details (spider, stickers, price tags) add storytelling value -- **Environmental context**: Ocean waves, gas clouds, awnings create sense of place diff --git a/optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md b/optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md deleted file mode 100644 index 0e02944d737..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md +++ /dev/null @@ -1,209 +0,0 @@ -# Commercial Aircraft Structure - -A physical/structural diagram showing an aircraft side profile using appropriate SVG shapes beyond rectangles - paths, polygons, ellipses for realistic representation. - -## Key Patterns Used - -- **Path elements**: Curved fuselage body with nose cone using quadratic bezier curves -- **Polygon elements**: Tapered wing shape, triangular stabilizers, control surfaces -- **Ellipse elements**: Engines (cylinders), wheels (circles) -- **Line elements**: Landing gear struts, leader lines for labels -- **Dashed strokes**: Interior sections (fuel tank), movable control surfaces (rudder, elevator) -- **Layered composition**: Cabin sections drawn inside the fuselage shape -- **Leader lines with labels**: Connect labels to components they describe - -## Diagram - -```xml -<svg width="100%" viewBox="0 0 680 400" xmlns="http://www.w3.org/2000/svg"> - - <!-- FUSELAGE - main body cylinder with nose cone --> - <path class="fuselage" d=" - M 80 180 - Q 40 180 40 200 - Q 40 220 80 220 - L 560 220 - Q 580 220 580 200 - Q 580 180 560 180 - Z - "/> - - <!-- Nose cone --> - <path class="fuselage" d=" - M 80 180 - Q 50 180 35 200 - Q 50 220 80 220 - " fill="none" stroke-width="1"/> - - <!-- COCKPIT windows --> - <path class="cockpit" d=" - M 45 190 - L 75 185 - L 75 200 - L 50 200 - Z - "/> - <line x1="55" y1="188" x2="55" y2="200" stroke="#534AB7" stroke-width="0.5"/> - <line x1="65" y1="186" x2="65" y2="200" stroke="#534AB7" stroke-width="0.5"/> - - <!-- CABIN SECTIONS (inside fuselage) --> - <!-- First class --> - <rect class="first-class" x="85" y="183" width="50" height="34" rx="2"/> - <text class="tl" x="110" y="203" text-anchor="middle">First</text> - - <!-- Business class --> - <rect class="business-class" x="140" y="183" width="80" height="34" rx="2"/> - <text class="tl" x="180" y="203" text-anchor="middle">Business</text> - - <!-- Economy class --> - <rect class="economy-class" x="225" y="183" width="200" height="34" rx="2"/> - <text class="tl" x="325" y="203" text-anchor="middle">Economy</text> - - <!-- CARGO HOLD (lower section indication) --> - <line x1="85" y1="217" x2="520" y2="217" class="leader"/> - <text class="tl" x="300" y="228" text-anchor="middle" opacity=".6">Cargo hold below deck</text> - - <!-- WING - main wing shape --> - <polygon class="wing" points=" - 200,220 - 120,300 - 130,305 - 160,305 - 340,235 - 340,220 - "/> - - <!-- Wing fuel tank (dashed interior) --> - <polygon class="fuel-tank" points=" - 210,225 - 150,280 - 160,283 - 180,283 - 310,232 - 310,225 - "/> - <text class="tl" x="220" y="260" opacity=".7">Fuel</text> - - <!-- Flaps (trailing edge) --> - <polygon class="flap" points=" - 130,300 - 120,305 - 160,310 - 165,305 - "/> - <text class="tl" x="143" y="320">Flaps</text> - - <!-- ENGINE under wing --> - <ellipse class="engine" cx="175" cy="285" rx="25" ry="12"/> - <ellipse cx="155" cy="285" rx="8" ry="10" fill="none" stroke="#993C1D" stroke-width="0.5"/> - <!-- Engine pylon --> - <line x1="175" y1="273" x2="190" y2="245" stroke="#5F5E5A" stroke-width="2"/> - <text class="tl" x="175" y="308" text-anchor="middle">Engine</text> - - <!-- TAIL SECTION --> - <!-- Vertical stabilizer --> - <polygon class="tail-v" points=" - 520,180 - 560,100 - 580,100 - 580,180 - "/> - <text class="tl" x="565" y="150" text-anchor="middle">Vertical</text> - <text class="tl" x="565" y="162" text-anchor="middle">stabilizer</text> - - <!-- Rudder --> - <polygon points="575,105 590,105 590,178 580,178" fill="none" stroke="#185FA5" stroke-width="0.5" stroke-dasharray="3 2"/> - <text class="tl" x="595" y="145" opacity=".6">Rudder</text> - - <!-- Horizontal stabilizer --> - <polygon class="tail-h" points=" - 500,195 - 460,175 - 465,170 - 580,170 - 580,180 - 520,195 - "/> - <text class="tl" x="510" y="166">Horizontal stabilizer</text> - - <!-- Elevator --> - <polygon points="462,174 450,168 455,163 467,169" fill="none" stroke="#185FA5" stroke-width="0.5" stroke-dasharray="3 2"/> - <text class="tl" x="440" y="158" opacity=".6">Elevator</text> - - <!-- LANDING GEAR --> - <!-- Nose gear --> - <line class="gear" x1="100" y1="220" x2="100" y2="260" stroke-width="3"/> - <ellipse class="wheel" cx="100" cy="268" rx="8" ry="10"/> - <text class="tl" x="100" y="290" text-anchor="middle">Nose gear</text> - - <!-- Main gear (under wing/fuselage junction) --> - <line class="gear" x1="280" y1="220" x2="280" y2="270" stroke-width="4"/> - <line class="gear" x1="268" y1="265" x2="292" y2="265" stroke-width="3"/> - <ellipse class="wheel" cx="268" cy="278" rx="10" ry="12"/> - <ellipse class="wheel" cx="292" cy="278" rx="10" ry="12"/> - <text class="tl" x="280" y="302" text-anchor="middle">Main gear</text> - - <!-- LABELS with leader lines --> - <!-- Cockpit label --> - <line class="leader" x1="60" y1="175" x2="60" y2="140"/> - <text class="ts" x="60" y="132" text-anchor="middle">Cockpit</text> - - <!-- Wing label --> - <line class="leader" x1="250" y1="250" x2="290" y2="330"/> - <text class="ts" x="290" y="345" text-anchor="middle">Wing structure</text> - <text class="tl" x="290" y="358" text-anchor="middle">Spars, ribs, skin</text> - - <!-- Fuselage label --> - <line class="leader" x1="400" y1="180" x2="400" y2="140"/> - <text class="ts" x="400" y="132" text-anchor="middle">Fuselage</text> - <text class="tl" x="400" y="145" text-anchor="middle">Pressure vessel</text> - -</svg> -``` - -## CSS Classes for Physical Diagrams - -When creating physical/structural diagrams, define semantic classes for each component type: - -```css -/* Structure shapes */ -.fuselage { fill: #F1EFE8; stroke: #5F5E5A; stroke-width: 1; } -.wing { fill: #E6F1FB; stroke: #185FA5; stroke-width: 1; } -.tail-v { fill: #E6F1FB; stroke: #185FA5; stroke-width: 1; } -.tail-h { fill: #E6F1FB; stroke: #185FA5; stroke-width: 1; } - -/* Interior sections */ -.cockpit { fill: #EEEDFE; stroke: #534AB7; stroke-width: 1; } -.first-class { fill: #FBEAF0; stroke: #993556; stroke-width: 0.5; } -.business-class { fill: #FAECE7; stroke: #993C1D; stroke-width: 0.5; } -.economy-class { fill: #E1F5EE; stroke: #0F6E56; stroke-width: 0.5; } -.cargo { fill: #D3D1C7; stroke: #5F5E5A; stroke-width: 0.5; } - -/* Systems */ -.engine { fill: #FAECE7; stroke: #993C1D; stroke-width: 1; } -.fuel-tank { fill: #FAEEDA; stroke: #854F0B; stroke-width: 0.5; stroke-dasharray: 3 2; } -.flap { fill: #E1F5EE; stroke: #0F6E56; stroke-width: 0.5; } - -/* Mechanical */ -.gear { fill: #444441; stroke: #2C2C2A; stroke-width: 0.5; } -.wheel { fill: #2C2C2A; stroke: #1a1a18; stroke-width: 0.5; } -``` - -## Shape Selection Guide - -| Physical form | SVG element | Example | -|---------------|-------------|---------| -| Curved body | `<path>` with Q (quadratic) or C (cubic) curves | Fuselage, nose cone | -| Tapered/angular | `<polygon>` | Wings, stabilizers | -| Cylindrical | `<ellipse>` | Engines, wheels, tanks | -| Linear structure | `<line>` | Struts, pylons, gear legs | -| Internal sections | `<rect>` inside parent shape | Cabin classes | -| Dashed boundaries | `stroke-dasharray` on any shape | Fuel tanks, control surfaces | - -## Layout Notes - -- **ViewBox**: 680×400 (wider aspect ratio suits side profile) -- **Layering**: Draw outer structures first, then interior details on top -- **Leader lines**: Use `.leader` class (dashed) to connect labels to components -- **Text sizes**: Use `.tl` (10px) for component labels, `.ts` (12px) for section labels -- **Semantic colors**: Group by system (structure=blue, propulsion=coral, fuel=amber, etc.) diff --git a/optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md b/optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md deleted file mode 100644 index 10258129716..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md +++ /dev/null @@ -1,236 +0,0 @@ -# Out-of-Order CPU Core Microarchitecture - -A structural diagram showing the internal pipeline stages of a modern superscalar out-of-order CPU core. Demonstrates multi-stage vertical flow with parallel paths, fan-out patterns for execution ports, and a separate memory hierarchy sidebar. - -## Key Patterns Used - -- **Multi-stage vertical flow**: Six pipeline stages (Front End → Rename → Schedule → Execute → Retire) -- **Parallel decode paths**: Main decode and µop cache bypass (dashed line for cache hit) -- **Container grouping**: Logical stages grouped in colored containers -- **Fan-out pattern**: Single scheduler dispatching to 6 execution ports -- **Sidebar layout**: Memory hierarchy placed in separate column on right -- **Stage labels**: Left-aligned labels indicating pipeline phase -- **Color-coded semantics**: Different colors for each functional unit category - -## Diagram Type - -This is a **hybrid structural/flow** diagram: -- **Flow aspect**: Instructions move top-to-bottom through pipeline stages -- **Structural aspect**: Components are grouped by function (rename unit, execution cluster) -- **Sidebar**: Memory hierarchy is architecturally separate but connected via data paths - -## Pipeline Stage Breakdown - -### Front End (Purple) -```xml -<!-- Fetch Unit --> -<g class="node c-purple"> - <rect x="40" y="70" width="140" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="110" y="90" text-anchor="middle" dominant-baseline="central">Fetch unit</text> - <text class="ts" x="110" y="110" text-anchor="middle" dominant-baseline="central">6-wide, 32B/cycle</text> -</g> - -<!-- Branch Predictor (subordinate) --> -<g class="node c-purple"> - <rect x="40" y="140" width="140" height="44" rx="8" stroke-width="0.5"/> - <text class="th" x="110" y="162" text-anchor="middle" dominant-baseline="central">Branch predictor</text> -</g> - -<!-- Decode --> -<g class="node c-purple"> - <rect x="230" y="70" width="160" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="310" y="90" text-anchor="middle" dominant-baseline="central">Decode</text> - <text class="ts" x="310" y="110" text-anchor="middle" dominant-baseline="central">x86 → µops, 6-wide</text> -</g> -``` - -### µop Cache Bypass Path (Teal) -The µop cache (Decoded Stream Buffer) provides an alternate path that bypasses the complex decoder: - -```xml -<!-- µop Cache parallel to decode --> -<g class="node c-teal"> - <rect x="230" y="150" width="160" height="50" rx="8" stroke-width="0.5"/> - <text class="th" x="310" y="168" text-anchor="middle" dominant-baseline="central">µop cache (DSB)</text> - <text class="ts" x="310" y="186" text-anchor="middle" dominant-baseline="central">4K entries, 8-wide</text> -</g> - -<!-- Dashed bypass path indicating cache hit --> -<path d="M180 110 L205 110 L205 175 L230 175" fill="none" class="arr" - stroke-dasharray="4 3" marker-end="url(#arrow)"/> -<text class="tx" x="164" y="148" opacity=".6">hit</text> -``` - -### Rename/Allocate Container (Coral) -Groups related rename components in a container: - -```xml -<!-- Outer container --> -<g class="c-coral"> - <rect x="40" y="250" width="530" height="130" rx="12" stroke-width="0.5"/> - <text class="th" x="60" y="274">Rename / allocate</text> - <text class="ts" x="60" y="292">Map architectural → physical registers</text> -</g> - -<!-- Inner components --> -<g class="node c-coral"> - <rect x="60" y="310" width="180" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="150" y="330" text-anchor="middle" dominant-baseline="central">Register alias table</text> - <text class="ts" x="150" y="350" text-anchor="middle" dominant-baseline="central">180 physical regs</text> -</g> -``` - -### Scheduler Fan-Out Pattern (Amber → Teal) -Single unified scheduler dispatching to multiple execution ports: - -```xml -<!-- Unified Scheduler --> -<g class="node c-amber"> - <rect x="140" y="420" width="330" height="50" rx="8" stroke-width="0.5"/> - <text class="th" x="305" y="438" text-anchor="middle" dominant-baseline="central">Unified scheduler</text> - <text class="ts" x="305" y="456" text-anchor="middle" dominant-baseline="central">97 entries, out-of-order dispatch</text> -</g> - -<!-- Fan-out arrows to 6 ports --> -<line x1="170" y1="470" x2="90" y2="540" class="arr" marker-end="url(#arrow)"/> -<line x1="215" y1="470" x2="170" y2="540" class="arr" marker-end="url(#arrow)"/> -<line x1="265" y1="470" x2="250" y2="540" class="arr" marker-end="url(#arrow)"/> -<line x1="305" y1="470" x2="330" y2="540" class="arr" marker-end="url(#arrow)"/> -<line x1="355" y1="470" x2="410" y2="540" class="arr" marker-end="url(#arrow)"/> -<line x1="420" y1="470" x2="490" y2="540" class="arr" marker-end="url(#arrow)"/> -``` - -### Execution Port Box Pattern -Compact boxes showing port number and capabilities: - -```xml -<!-- Execution port with multi-line capability --> -<g class="node c-teal"> - <rect x="55" y="540" width="70" height="64" rx="6" stroke-width="0.5"/> - <text class="th" x="90" y="560" text-anchor="middle" dominant-baseline="central">Port 0</text> - <text class="tx" x="90" y="576" text-anchor="middle" dominant-baseline="central">ALU</text> - <text class="tx" x="90" y="590" text-anchor="middle" dominant-baseline="central">DIV</text> -</g> -``` - -### Reorder Buffer (Pink) -Wide horizontal bar at bottom showing retirement: - -```xml -<g class="c-pink"> - <rect x="40" y="670" width="530" height="40" rx="10" stroke-width="0.5"/> - <text class="th" x="305" y="694" text-anchor="middle" dominant-baseline="central">Reorder buffer (ROB) — 512 entries, 8-wide retire</text> -</g> -``` - -### Memory Hierarchy Sidebar (Blue) -Separate column showing cache levels: - -```xml -<!-- Container --> -<g class="c-blue"> - <rect x="600" y="30" width="190" height="360" rx="16" stroke-width="0.5"/> - <text class="th" x="695" y="54" text-anchor="middle">Memory hierarchy</text> -</g> - -<!-- Cache levels stacked vertically --> -<g class="node c-blue"> - <rect x="620" y="70" width="150" height="50" rx="8" stroke-width="0.5"/> - <text class="th" x="695" y="88" text-anchor="middle" dominant-baseline="central">L1-I cache</text> - <text class="ts" x="695" y="106" text-anchor="middle" dominant-baseline="central">32 KB, 8-way</text> -</g> -<!-- Additional levels follow same pattern --> -``` - -## Connection Patterns - -### Instruction Fetch Path -Horizontal arrow from L1-I cache to fetch unit: -```xml -<path d="M620 95 L200 95" fill="none" class="arr" marker-end="url(#arrow)"/> -<text class="tx" x="410" y="88" text-anchor="middle" opacity=".6">instruction fetch</text> -``` - -### Load/Store Path -Complex path from execution ports to L1-D cache: -```xml -<path d="M250 604 L250 640 L580 640 L580 160 L620 160" fill="none" class="arr" marker-end="url(#arrow)"/> -<text class="tx" x="415" y="652" text-anchor="middle" opacity=".6">load / store</text> -``` - -### Commit Path (dashed) -Dashed line showing write-back from ROB to register file: -```xml -<path d="M550 690 L580 690 L580 445 L595 445" fill="none" class="arr" stroke-dasharray="4 3"/> -<text class="tx" x="590" y="578" opacity=".6" transform="rotate(-90 590 578)">commit</text> -``` - -### Path Merge (Decode + µop Cache) -Two paths converging before rename: -```xml -<line x1="390" y1="98" x2="430" y2="98" class="arr"/> -<line x1="390" y1="175" x2="430" y2="175" class="arr"/> -<path d="M430 98 L430 175" fill="none" stroke="var(--text-secondary)" stroke-width="1.5"/> -<line x1="430" y1="136" x2="470" y2="136" class="arr" marker-end="url(#arrow)"/> -``` - -## Text Classes - -This diagram uses an additional text class for very small labels: - -```css -.tx { font-family: system-ui, -apple-system, sans-serif; font-size: 10px; fill: var(--text-secondary); } -``` - -Used for: -- Execution port capability labels (ALU, Branch, Load, etc.) -- Connection labels (instruction fetch, load/store, commit) -- DRAM latency annotation - -## Color Semantic Mapping - -| Color | Stage | Components | -|-------|-------|------------| -| `c-purple` | Front end | Fetch, Branch predictor, Decode | -| `c-teal` | Execution | µop cache, Execution ports | -| `c-coral` | Rename | RAT, Physical RF, Free list | -| `c-amber` | Schedule | Unified scheduler | -| `c-pink` | Retire | Reorder buffer | -| `c-blue` | Memory | L1-I, L1-D, L2, DRAM | -| `c-gray` | External | Off-chip DRAM | - -## Layout Notes - -- **ViewBox**: 820×720 (taller than wide for vertical pipeline flow) -- **Main pipeline**: x=40 to x=570 (530px width) -- **Memory sidebar**: x=600 to x=790 (190px width) -- **Stage labels**: x=30, left-aligned, 50% opacity -- **Vertical spacing**: ~80-100px between major stages -- **Container padding**: 20px inside containers -- **Port spacing**: 80px between execution port centers -- **Legend**: Bottom-right of memory sidebar, explains color coding - -## Architectural Details Shown - -| Component | Specification | Notes | -|-----------|---------------|-------| -| Fetch | 6-wide, 32B/cycle | Typical modern Intel/AMD | -| Decode | 6-wide, x86→µops | Complex decoder | -| µop Cache | 4K entries, 8-wide | Bypass for hot code | -| RAT | 180 physical regs | Supports deep OoO | -| Scheduler | 97 entries | Unified RS | -| Execution | 6 ports | ALU×2, Load, Store×2, Vector | -| ROB | 512 entries, 8-wide | In-order retirement | -| L1-I | 32 KB, 8-way | Instruction cache | -| L1-D | 48 KB, 12-way | Data cache | -| L2 | 1.25 MB, 20-way | Unified | -| DRAM | DDR5-6400, ~80ns | Off-chip | - -## When to Use This Pattern - -Use this diagram style for: -- CPU/GPU microarchitecture visualization -- Compiler pipeline stages -- Network packet processing pipelines -- Any system with parallel execution units fed by a scheduler -- Hardware designs with multiple functional units diff --git a/optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md b/optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md deleted file mode 100644 index 9b6acc66db1..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md +++ /dev/null @@ -1,182 +0,0 @@ -# Electricity Grid: Generation to Consumption - -A left-to-right flow diagram showing electricity from multiple generation sources through transmission and distribution networks to end consumers. Demonstrates multi-stage flow layout, voltage level visual hierarchy, and smart grid data overlay. - -## Key Patterns Used - -- **Multi-stage horizontal flow**: Four distinct columns (Generation → Transmission → Distribution → Consumption) -- **Stage dividers**: Vertical dashed lines separating each phase -- **Voltage level hierarchy**: Different line weights/colors for HV, MV, LV -- **Smart grid data overlay**: Dashed data flow lines from control center -- **Capacity labels**: Power ratings on generation sources -- **Multiple source convergence**: Four generators feeding into single transmission grid - -## New Shape Techniques - -### Nuclear Plant (cooling tower + reactor) -```xml -<!-- Cooling tower (hyperbolic curve) --> -<path class="nuclear-tower" d="M 25 80 Q 15 60 20 40 Q 25 20 40 15 Q 55 20 60 40 Q 65 60 55 80 Z"/> -<!-- Steam clouds --> -<ellipse class="nuclear-steam" cx="40" cy="8" rx="12" ry="6"/> -<!-- Reactor dome --> -<rect class="nuclear-building" x="65" y="45" width="40" height="35" rx="3"/> -<ellipse class="nuclear-building" cx="85" cy="45" rx="20" ry="8"/> -``` - -### Gas Peaker Plant (with flames) -```xml -<rect class="gas-plant" x="0" y="25" width="70" height="40" rx="3"/> -<!-- Smokestacks --> -<rect class="gas-stack" x="15" y="5" width="8" height="25" rx="1"/> -<!-- Flame --> -<path class="gas-flame" d="M 19 5 Q 17 0 19 -3 Q 21 0 19 5"/> -<!-- Turbine housing --> -<ellipse class="gas-plant" cx="55" cy="45" rx="12" ry="8"/> -``` - -### Transmission Pylon with Insulators -```xml -<!-- Tapered tower --> -<polygon class="pylon" points="20,0 25,0 30,80 15,80"/> -<!-- Cross arms --> -<line class="pylon-arm" x1="5" y1="10" x2="40" y2="10"/> -<line class="pylon-arm" x1="8" y1="25" x2="37" y2="25"/> -<!-- Insulators (where lines attach) --> -<circle class="insulator" cx="8" cy="10" r="3"/> -<circle class="insulator" cx="37" cy="10" r="3"/> -``` - -### Transformer Symbol -```xml -<!-- Two coils with core --> -<circle class="transformer-coil" cx="25" cy="25" r="12"/> -<circle class="transformer-coil" cx="55" cy="25" r="12"/> -<rect class="transformer-core" x="35" y="15" width="10" height="20" rx="2"/> -<!-- Busbars --> -<line x1="0" y1="15" x2="-10" y2="15" stroke="#EF9F27" stroke-width="3"/> -``` - -### Pole-mounted Transformer -```xml -<rect class="pole" x="18" y="0" width="4" height="60"/> -<line x1="10" y1="8" x2="30" y2="8" stroke="#854F0B" stroke-width="2"/> -<rect class="dist-transformer" x="8" y="15" width="24" height="18" rx="2"/> -<line class="lv-line" x1="20" y1="33" x2="20" y2="60"/> -``` - -### House with Roof -```xml -<rect class="home" x="0" y="25" width="35" height="30" rx="2"/> -<polygon class="home-roof" points="0,25 17,8 35,25"/> -<!-- Door --> -<rect x="8" y="35" width="8" height="15" fill="#085041"/> -<!-- Window --> -<rect x="22" y="32" width="8" height="8" fill="#9FE1CB"/> -``` - -### Factory Building -```xml -<rect class="factory" x="0" y="15" width="90" height="50" rx="3"/> -<!-- Smokestacks --> -<rect class="factory-stack" x="15" y="0" width="10" height="20"/> -<!-- Windows row --> -<rect x="10" y="30" width="15" height="12" fill="#F5C4B3"/> -<rect x="30" y="30" width="15" height="12" fill="#F5C4B3"/> -<!-- Loading dock --> -<rect x="55" y="50" width="30" height="15" fill="#993C1D"/> -``` - -### EV Charger with Car -```xml -<!-- Charging station --> -<rect class="ev-charger" x="20" y="0" width="25" height="45" rx="3"/> -<rect x="24" y="5" width="17" height="12" rx="1" fill="#3C3489"/> -<!-- Cable --> -<path d="M 32 20 Q 32 35 45 40" stroke="#534AB7" stroke-width="2" fill="none"/> -<circle cx="45" cy="40" r="4" fill="#534AB7"/> -<!-- Status light --> -<circle cx="32" cy="38" r="3" fill="#97C459"/> - -<!-- EV Car --> -<path class="ev-car" d="M 5 20 L 5 12 Q 5 5 15 5 L 45 5 Q 55 5 55 12 L 55 20 Z"/> -<!-- Windows --> -<rect x="10" y="8" width="15" height="8" rx="2" fill="#534AB7"/> -<!-- Wheels --> -<circle cx="15" cy="22" r="5" fill="#2C2C2A"/> -<!-- Charging bolt icon --> -<path d="M 28 12 L 32 8 L 30 11 L 34 11 L 30 16 L 32 13 Z" fill="#97C459"/> -``` - -## Voltage Level Line Styles - -```css -/* High voltage (transmission) - thick, bright */ -.hv-line { stroke: #EF9F27; stroke-width: 2.5; fill: none; } - -/* Medium voltage (distribution) - medium */ -.mv-line { stroke: #BA7517; stroke-width: 2; fill: none; } - -/* Low voltage (consumer) - thin, darker */ -.lv-line { stroke: #854F0B; stroke-width: 1.5; fill: none; } - -/* Smart grid data - dashed purple */ -.data-flow { stroke: #7F77DD; stroke-width: 1; fill: none; stroke-dasharray: 3 2; opacity: 0.7; } -``` - -## Flow Arrow Marker - -```xml -<defs> - <marker id="flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" - markerWidth="6" markerHeight="6" orient="auto"> - <path d="M0,0 L10,5 L0,10 Z" fill="#EF9F27"/> - </marker> -</defs> -<!-- Usage --> -<line x1="140" y1="105" x2="210" y2="105" class="hv-line" marker-end="url(#flow-arrow)"/> -``` - -## CSS Classes - -```css -/* Generation */ -.nuclear-tower { fill: #B4B2A9; stroke: #5F5E5A; stroke-width: 1; } -.nuclear-building { fill: #EEEDFE; stroke: #534AB7; stroke-width: 1; } -.solar-panel { fill: #3C3489; stroke: #534AB7; stroke-width: 0.5; } -.wind-tower { fill: #B4B2A9; stroke: #5F5E5A; stroke-width: 1; } -.wind-blade { fill: #F1EFE8; stroke: #888780; stroke-width: 0.5; } -.gas-plant { fill: #FAECE7; stroke: #993C1D; stroke-width: 1; } -.gas-flame { fill: #EF9F27; } - -/* Transmission */ -.pylon { fill: #5F5E5A; stroke: #444441; stroke-width: 0.5; } -.insulator { fill: #FAEEDA; stroke: #854F0B; stroke-width: 0.5; } -.substation { fill: #E6F1FB; stroke: #185FA5; stroke-width: 1; } -.transformer-coil { fill: none; stroke: #185FA5; stroke-width: 1.5; } - -/* Distribution */ -.pole { fill: #854F0B; stroke: #633806; stroke-width: 0.5; } -.dist-transformer { fill: #E1F5EE; stroke: #0F6E56; stroke-width: 1; } - -/* Consumption */ -.home { fill: #E1F5EE; stroke: #0F6E56; stroke-width: 1; } -.home-roof { fill: #0F6E56; stroke: #085041; stroke-width: 0.5; } -.factory { fill: #FAECE7; stroke: #993C1D; stroke-width: 1; } -.ev-charger { fill: #EEEDFE; stroke: #534AB7; stroke-width: 1; } -.ev-car { fill: #3C3489; stroke: #534AB7; stroke-width: 0.5; } - -/* Smart grid */ -.smart-grid { fill: #EEEDFE; stroke: #534AB7; stroke-width: 1.5; } -``` - -## Layout Notes - -- **ViewBox**: 820×520 (wide for 4-column layout) -- **Column widths**: ~200px per stage -- **Stage dividers**: Vertical dashed lines at x=200, 420, 620 -- **Stage labels**: Top of diagram, uppercase for emphasis -- **Flow direction**: Left-to-right with arrows showing power flow -- **Data overlay**: Smart grid data lines use different style (dashed purple) to distinguish from power lines -- **Capacity labels**: Show MW ratings on generators for context -- **Voltage labels**: Show transformation ratios at substations diff --git a/optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md b/optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md deleted file mode 100644 index 76f5f86fc6e..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md +++ /dev/null @@ -1,172 +0,0 @@ -# Feature Film Production Pipeline - -A phased workflow showing the five stages of filmmaking, using containers with inner nodes and horizontal sub-flows within a phase. - -## Key Patterns Used - -- **Phase containers**: Large rounded rectangles with neutral background and dashed borders -- **Inner task nodes**: Smaller colored nodes inside containers for sub-tasks -- **Horizontal flow within container**: Post-production shows sequential pipeline with arrows (Editing → Color → VFX → Sound → Score) -- **Consistent phase spacing**: ~30px gap between phase containers -- **Phase labels with subtitles**: Each container has title + description - -## Diagram - -```xml -<svg width="100%" viewBox="0 0 680 780" xmlns="http://www.w3.org/2000/svg"> - <defs> - <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" - markerWidth="6" markerHeight="6" orient="auto-start-reverse"> - <path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" - stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> - </marker> - </defs> - - <!-- Phase 1: Development --> - <g> - <rect x="40" y="30" width="600" height="110" rx="16" stroke-width="1" stroke-dasharray="6 4" fill="var(--bg-secondary)" stroke="var(--border)"/> - <text class="th" x="66" y="56">Development</text> - <text class="ts" x="66" y="74">Concept to greenlight</text> - </g> - <g class="node c-purple"> - <rect x="70" y="90" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="150" y="108" text-anchor="middle" dominant-baseline="central">Script / screenplay</text> - </g> - <g class="node c-purple"> - <rect x="260" y="90" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="340" y="108" text-anchor="middle" dominant-baseline="central">Financing / budget</text> - </g> - <g class="node c-purple"> - <rect x="450" y="90" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="530" y="108" text-anchor="middle" dominant-baseline="central">Casting leads</text> - </g> - - <!-- Arrow to Phase 2 --> - <line x1="340" y1="140" x2="340" y2="170" class="arr" marker-end="url(#arrow)"/> - - <!-- Phase 2: Pre-production --> - <g> - <rect x="40" y="170" width="600" height="110" rx="16" stroke-width="1" stroke-dasharray="6 4" fill="var(--bg-secondary)" stroke="var(--border)"/> - <text class="th" x="66" y="196">Pre-production</text> - <text class="ts" x="66" y="214">Planning and preparation</text> - </g> - <g class="node c-teal"> - <rect x="70" y="230" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="150" y="248" text-anchor="middle" dominant-baseline="central">Storyboards</text> - </g> - <g class="node c-teal"> - <rect x="260" y="230" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="340" y="248" text-anchor="middle" dominant-baseline="central">Location scouting</text> - </g> - <g class="node c-teal"> - <rect x="450" y="230" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="530" y="248" text-anchor="middle" dominant-baseline="central">Crew hiring</text> - </g> - - <!-- Arrow to Phase 3 --> - <line x1="340" y1="280" x2="340" y2="310" class="arr" marker-end="url(#arrow)"/> - - <!-- Phase 3: Production --> - <g> - <rect x="40" y="310" width="600" height="110" rx="16" stroke-width="1" stroke-dasharray="6 4" fill="var(--bg-secondary)" stroke="var(--border)"/> - <text class="th" x="66" y="336">Production</text> - <text class="ts" x="66" y="354">Principal photography</text> - </g> - <g class="node c-coral"> - <rect x="70" y="370" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="150" y="388" text-anchor="middle" dominant-baseline="central">Filming / shooting</text> - </g> - <g class="node c-coral"> - <rect x="260" y="370" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="340" y="388" text-anchor="middle" dominant-baseline="central">Production sound</text> - </g> - <g class="node c-coral"> - <rect x="450" y="370" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="530" y="388" text-anchor="middle" dominant-baseline="central">VFX plates</text> - </g> - - <!-- Arrow to Phase 4 --> - <line x1="340" y1="420" x2="340" y2="450" class="arr" marker-end="url(#arrow)"/> - - <!-- Phase 4: Post-production --> - <g> - <rect x="40" y="450" width="600" height="150" rx="16" stroke-width="1" stroke-dasharray="6 4" fill="var(--bg-secondary)" stroke="var(--border)"/> - <text class="th" x="66" y="476">Post-production</text> - <text class="ts" x="66" y="494">Assembly and finishing</text> - </g> - <g class="node c-amber"> - <rect x="70" y="510" width="110" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="125" y="528" text-anchor="middle" dominant-baseline="central">Editing</text> - </g> - <g class="node c-amber"> - <rect x="195" y="510" width="110" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="250" y="528" text-anchor="middle" dominant-baseline="central">Color grade</text> - </g> - <g class="node c-amber"> - <rect x="320" y="510" width="90" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="365" y="528" text-anchor="middle" dominant-baseline="central">VFX</text> - </g> - <g class="node c-amber"> - <rect x="425" y="510" width="100" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="475" y="528" text-anchor="middle" dominant-baseline="central">Sound mix</text> - </g> - <g class="node c-amber"> - <rect x="540" y="510" width="80" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="580" y="528" text-anchor="middle" dominant-baseline="central">Score</text> - </g> - <!-- Flow arrows within post --> - <line x1="180" y1="528" x2="195" y2="528" class="arr" marker-end="url(#arrow)"/> - <line x1="305" y1="528" x2="320" y2="528" class="arr" marker-end="url(#arrow)"/> - <line x1="410" y1="528" x2="425" y2="528" class="arr" marker-end="url(#arrow)"/> - <line x1="525" y1="528" x2="540" y2="528" class="arr" marker-end="url(#arrow)"/> - <!-- Final delivery label --> - <g class="node c-amber"> - <rect x="240" y="556" width="200" height="32" rx="6" stroke-width="0.5"/> - <text class="ts" x="340" y="572" text-anchor="middle" dominant-baseline="central">Final master / DCP</text> - </g> - <line x1="340" y1="546" x2="340" y2="556" class="arr" marker-end="url(#arrow)"/> - - <!-- Arrow to Phase 5 --> - <line x1="340" y1="600" x2="340" y2="630" class="arr" marker-end="url(#arrow)"/> - - <!-- Phase 5: Distribution --> - <g> - <rect x="40" y="630" width="600" height="110" rx="16" stroke-width="1" stroke-dasharray="6 4" fill="var(--bg-secondary)" stroke="var(--border)"/> - <text class="th" x="66" y="656">Distribution</text> - <text class="ts" x="66" y="674">Release and exhibition</text> - </g> - <g class="node c-blue"> - <rect x="70" y="690" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="150" y="708" text-anchor="middle" dominant-baseline="central">Film festivals</text> - </g> - <g class="node c-blue"> - <rect x="260" y="690" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="340" y="708" text-anchor="middle" dominant-baseline="central">Theatrical release</text> - </g> - <g class="node c-blue"> - <rect x="450" y="690" width="160" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="530" y="708" text-anchor="middle" dominant-baseline="central">Streaming / VOD</text> - </g> -</svg> -``` - -## Color Assignments - -| Element | Color | Reason | -|---------|-------|--------| -| Phase containers | Neutral (dashed) | Subtle grouping, doesn't compete with content | -| Development tasks | `c-purple` | Creative/concept work | -| Pre-production tasks | `c-teal` | Planning and preparation | -| Production tasks | `c-coral` | Active filming (main event) | -| Post-production tasks | `c-amber` | Processing/refinement | -| Distribution tasks | `c-blue` | Outward delivery/release | - -## Layout Notes - -- **ViewBox**: 680×780 (standard width, tall for 5 phases) -- **Container style**: Dashed border (`stroke-dasharray="6 4"`), neutral fill (`var(--bg-secondary)`), `stroke-width="1"` -- **Container height**: 110px for 3-node phases, 150px for post-production (more complex) -- **Inner node dimensions**: 160×36px for standard tasks, variable width for post-production sequential flow -- **Phase gap**: 30px between containers -- **Horizontal sub-flow**: Post-production uses tightly packed nodes with arrows between them to show sequence -- **Convergence node**: "Final master / DCP" sits below the horizontal flow, collecting all post outputs diff --git a/optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md b/optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md deleted file mode 100644 index a64c50e5d44..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md +++ /dev/null @@ -1,165 +0,0 @@ -# Hospital Emergency Department Flow - -A multi-path flowchart showing patient journey through an emergency department with priority-based routing using semantic colors (red=critical, amber=urgent, green=stable). - -## Key Patterns Used - -- **Semantic color coding**: Red/amber/green for priority levels (not arbitrary decoration) -- **Stage labels**: Left-aligned faded labels marking workflow phases -- **Convergent paths**: Multiple entry points merging, then branching, then converging again -- **Nested containers**: Diagnostics grouped in a container with inner nodes -- **Legend**: Color key at bottom explaining priority levels - -## Diagram - -```xml -<svg width="100%" viewBox="0 0 680 620" xmlns="http://www.w3.org/2000/svg"> - <defs> - <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" - markerWidth="6" markerHeight="6" orient="auto-start-reverse"> - <path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" - stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> - </marker> - </defs> - - <!-- Stage labels --> - <text class="ts" x="40" y="68" text-anchor="start" opacity=".5">Arrival</text> - <text class="ts" x="40" y="168" text-anchor="start" opacity=".5">Assessment</text> - <text class="ts" x="40" y="288" text-anchor="start" opacity=".5">Priority routing</text> - <text class="ts" x="40" y="418" text-anchor="start" opacity=".5">Diagnostics</text> - <text class="ts" x="40" y="518" text-anchor="start" opacity=".5">Outcome</text> - - <!-- Arrival: Ambulance --> - <g class="node c-gray"> - <rect x="140" y="40" width="160" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="220" y="60" text-anchor="middle" dominant-baseline="central">Ambulance</text> - <text class="ts" x="220" y="80" text-anchor="middle" dominant-baseline="central">Emergency transport</text> - </g> - - <!-- Arrival: Walk-in --> - <g class="node c-gray"> - <rect x="380" y="40" width="160" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="460" y="60" text-anchor="middle" dominant-baseline="central">Walk-in</text> - <text class="ts" x="460" y="80" text-anchor="middle" dominant-baseline="central">Self-arrival</text> - </g> - - <!-- Arrows to Triage --> - <line x1="220" y1="96" x2="300" y2="140" class="arr" marker-end="url(#arrow)"/> - <line x1="460" y1="96" x2="380" y2="140" class="arr" marker-end="url(#arrow)"/> - - <!-- Triage --> - <g class="node c-purple"> - <rect x="240" y="140" width="200" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="340" y="160" text-anchor="middle" dominant-baseline="central">Triage</text> - <text class="ts" x="340" y="180" text-anchor="middle" dominant-baseline="central">Nurse assessment, vitals</text> - </g> - - <!-- Arrows from Triage to Priority --> - <line x1="280" y1="196" x2="140" y2="260" class="arr" marker-end="url(#arrow)"/> - <line x1="340" y1="196" x2="340" y2="260" class="arr" marker-end="url(#arrow)"/> - <line x1="400" y1="196" x2="540" y2="260" class="arr" marker-end="url(#arrow)"/> - - <!-- Priority: Red - Trauma --> - <g class="node c-red"> - <rect x="60" y="260" width="160" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="140" y="280" text-anchor="middle" dominant-baseline="central">Trauma bay</text> - <text class="ts" x="140" y="300" text-anchor="middle" dominant-baseline="central">Priority: critical</text> - </g> - - <!-- Priority: Yellow - Exam rooms --> - <g class="node c-amber"> - <rect x="260" y="260" width="160" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="340" y="280" text-anchor="middle" dominant-baseline="central">Exam rooms</text> - <text class="ts" x="340" y="300" text-anchor="middle" dominant-baseline="central">Priority: urgent</text> - </g> - - <!-- Priority: Green - Waiting --> - <g class="node c-green"> - <rect x="460" y="260" width="160" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="540" y="280" text-anchor="middle" dominant-baseline="central">Waiting area</text> - <text class="ts" x="540" y="300" text-anchor="middle" dominant-baseline="central">Priority: stable</text> - </g> - - <!-- Arrows to Diagnostics --> - <line x1="140" y1="316" x2="220" y2="390" class="arr" marker-end="url(#arrow)"/> - <line x1="340" y1="316" x2="340" y2="390" class="arr" marker-end="url(#arrow)"/> - <line x1="540" y1="316" x2="460" y2="390" class="arr" marker-end="url(#arrow)"/> - - <!-- Diagnostics container --> - <g class="c-teal"> - <rect x="140" y="390" width="400" height="56" rx="12" stroke-width="0.5"/> - </g> - - <!-- Labs --> - <g class="node c-teal"> - <rect x="160" y="400" width="110" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="215" y="418" text-anchor="middle" dominant-baseline="central">Labs</text> - </g> - - <!-- Imaging --> - <g class="node c-teal"> - <rect x="285" y="400" width="110" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="340" y="418" text-anchor="middle" dominant-baseline="central">Imaging</text> - </g> - - <!-- Diagnosis --> - <g class="node c-teal"> - <rect x="410" y="400" width="110" height="36" rx="6" stroke-width="0.5"/> - <text class="ts" x="465" y="418" text-anchor="middle" dominant-baseline="central">Diagnosis</text> - </g> - - <!-- Arrows to Outcomes --> - <line x1="215" y1="446" x2="160" y2="490" class="arr" marker-end="url(#arrow)"/> - <line x1="340" y1="446" x2="340" y2="490" class="arr" marker-end="url(#arrow)"/> - <line x1="465" y1="446" x2="520" y2="490" class="arr" marker-end="url(#arrow)"/> - - <!-- Outcome: Admission --> - <g class="node c-coral"> - <rect x="80" y="490" width="160" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="160" y="510" text-anchor="middle" dominant-baseline="central">Admission</text> - <text class="ts" x="160" y="530" text-anchor="middle" dominant-baseline="central">Inpatient ward</text> - </g> - - <!-- Outcome: Surgery --> - <g class="node c-coral"> - <rect x="260" y="490" width="160" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="340" y="510" text-anchor="middle" dominant-baseline="central">Surgery</text> - <text class="ts" x="340" y="530" text-anchor="middle" dominant-baseline="central">Operating room</text> - </g> - - <!-- Outcome: Discharge --> - <g class="node c-coral"> - <rect x="440" y="490" width="160" height="56" rx="8" stroke-width="0.5"/> - <text class="th" x="520" y="510" text-anchor="middle" dominant-baseline="central">Discharge</text> - <text class="ts" x="520" y="530" text-anchor="middle" dominant-baseline="central">Home with instructions</text> - </g> - - <!-- Legend --> - <text class="ts" x="140" y="580" opacity=".5">Priority levels</text> - <g class="c-red"><rect x="140" y="592" width="14" height="14" rx="3" stroke-width="0.5"/></g> - <text class="ts" x="162" y="604">Critical</text> - <g class="c-amber"><rect x="240" y="592" width="14" height="14" rx="3" stroke-width="0.5"/></g> - <text class="ts" x="262" y="604">Urgent</text> - <g class="c-green"><rect x="340" y="592" width="14" height="14" rx="3" stroke-width="0.5"/></g> - <text class="ts" x="362" y="604">Stable</text> -</svg> -``` - -## Color Assignments - -| Element | Color | Reason | -|---------|-------|--------| -| Entry points (Ambulance, Walk-in) | `c-gray` | Neutral starting points | -| Triage | `c-purple` | Processing/assessment step | -| Trauma bay | `c-red` | Critical priority (semantic) | -| Exam rooms | `c-amber` | Urgent priority (semantic) | -| Waiting area | `c-green` | Stable priority (semantic) | -| Diagnostics | `c-teal` | Clinical services category | -| Outcomes | `c-coral` | Final disposition category | - -## Layout Notes - -- **ViewBox**: 680×620 (standard width, extended height for 5 stages) -- **Stage spacing**: ~110-130px between stage rows -- **Diagonal arrows**: Connect nodes across columns naturally -- **Container with inner nodes**: Diagnostics uses outer `c-teal` rect with inner node rects diff --git a/optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md b/optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md deleted file mode 100644 index be6a4cd1b60..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md +++ /dev/null @@ -1,114 +0,0 @@ -# ML Benchmark Grouped Bar Chart with Dual Axis - -A quantitative data visualization comparing LLM inference speed across quantization levels with dual Y-axes, threshold markers, and an inset accuracy table. - -## Key Patterns Used - -- **Grouped bars**: Min/max range pairs per category using semantic color pairs (lighter=min, darker=max) -- **Dual Y-axis**: Left axis for primary metric (tok/s), right axis for secondary metric (VRAM GB) -- **Overlay line graph**: `<polyline>` with labeled dots showing VRAM usage across categories -- **Threshold marker**: Dashed red horizontal line indicating hardware limit (24 GB GPU) -- **Zone annotations**: Subtle text labels above/below threshold for context -- **Inset data table**: Alternating row fills below chart with quantitative accuracy data -- **Semantic color coding**: Each quantization level gets its own color from the skill palette (red=OOM, amber=slow, teal=sweet spot, blue=fast) - -## Diagram Type - -This is a **quantitative data chart** with: -- **Grouped vertical bars**: Range bars showing min–max performance per category -- **Secondary axis line**: VRAM usage overlaid as a connected scatter plot -- **Threshold annotation**: Hardware constraint line -- **Inset table**: Supporting accuracy metrics - -## Chart Layout Formula - -``` -Chart area: x=90–590, y=70–410 (500px wide, 340px tall) -Left Y-axis: Primary metric (tok/s) - y = 410 − (val / max_val) × 340 -Right Y-axis: Secondary metric (VRAM GB) - Same formula, different scale labels -Groups: Divide width by number of categories -Bars: Each group → min bar (34px) + 8px gap + max bar (34px) -Line overlay: <polyline> connecting data points across group centers -Threshold: Horizontal dashed line at critical value -Table: Below chart, alternating row fills -``` - -## Data Mapped - -| Quantization | Model Size | Speed (tok/s) | VRAM (GB) | MMLU Pro | Status | -|-------------|-----------|---------------|-----------|----------|--------| -| FP16 | 62 GB | 0.5–2 | 62 | 75.2 | OOM / unusable | -| Q8_0 | 32 GB | 3–5 | 32 | 75.0 | Partial offload | -| Q4_K_M | 16.8 GB | 8–12 | 16.8 | 73.1 | Fits in VRAM ✓ | -| IQ3_M | 12 GB | 12–15 | 12 | 70.5 | Full GPU speed | - -## Bar CSS Classes - -```css -/* Light mode */ -.bar-fp16-min { fill: #FCEBEB; stroke: #A32D2D; stroke-width: 0.75; } -.bar-fp16-max { fill: #F7C1C1; stroke: #A32D2D; stroke-width: 0.75; } -.bar-q8-min { fill: #FAEEDA; stroke: #854F0B; stroke-width: 0.75; } -.bar-q8-max { fill: #FAC775; stroke: #854F0B; stroke-width: 0.75; } -.bar-q4-min { fill: #E1F5EE; stroke: #0F6E56; stroke-width: 0.75; } -.bar-q4-max { fill: #9FE1CB; stroke: #0F6E56; stroke-width: 0.75; } -.bar-iq3-min { fill: #E6F1FB; stroke: #185FA5; stroke-width: 0.75; } -.bar-iq3-max { fill: #B5D4F4; stroke: #185FA5; stroke-width: 0.75; } - -/* Dark mode */ -@media (prefers-color-scheme: dark) { - .bar-fp16-min { fill: #501313; stroke: #F09595; } - .bar-fp16-max { fill: #791F1F; stroke: #F09595; } - .bar-q8-min { fill: #412402; stroke: #EF9F27; } - .bar-q8-max { fill: #633806; stroke: #EF9F27; } - .bar-q4-min { fill: #04342C; stroke: #5DCAA5; } - .bar-q4-max { fill: #085041; stroke: #5DCAA5; } - .bar-iq3-min { fill: #042C53; stroke: #85B7EB; } - .bar-iq3-max { fill: #0C447C; stroke: #85B7EB; } -} -``` - -## Overlay Line CSS - -```css -.vram-line { stroke: #534AB7; stroke-width: 2.5; fill: none; } -.vram-dot { fill: #534AB7; stroke: var(--bg-primary); stroke-width: 2; } -.vram-label { font-family: system-ui, sans-serif; font-size: 10px; fill: #534AB7; font-weight: 500; } -``` - -## Threshold CSS - -```css -.threshold { stroke: #A32D2D; stroke-width: 1; stroke-dasharray: 6 3; fill: none; } -.threshold-label { font-family: system-ui, sans-serif; font-size: 10px; fill: #A32D2D; font-weight: 500; } -``` - -## Table CSS - -```css -.tbl-header { fill: var(--bg-secondary); stroke: var(--border); stroke-width: 0.5; } -.tbl-row { fill: transparent; stroke: var(--border); stroke-width: 0.25; } -.tbl-alt { fill: var(--bg-secondary); stroke: var(--border); stroke-width: 0.25; } -``` - -## Layout Notes - -- **ViewBox**: 680×660 (portrait, chart + legend + table) -- **Chart area**: y=70–410, x=90–590 -- **Legend row**: y=458–470 -- **Inset table**: y=490–620 -- **Bar width**: 34px each, 8px gap between min/max pair -- **Group spacing**: 125px center-to-center -- **Dot halo**: White circle (r=6) behind colored dot (r=5) for legibility over bars/grid - -## When to Use This Pattern - -Use this diagram style for: -- Model benchmark comparisons across quantization levels -- Performance vs. resource usage tradeoff analysis -- Any multi-metric comparison with a hardware/software constraint -- GPU/TPU/accelerator benchmarking dashboards -- Accuracy vs. speed Pareto frontiers -- Hardware requirement sizing charts diff --git a/optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md b/optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md deleted file mode 100644 index dfb4f6744d9..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md +++ /dev/null @@ -1,325 +0,0 @@ -# Place Order — UML Sequence Diagram - -A UML sequence diagram for the 'Place Order' use case in an e-commerce system. Six lifelines (:Customer, :ShoppingCart, :OrderController, :PaymentGateway, :InventorySystem, :EmailService) interact across 14 numbered messages. An **alt** combined fragment (amber) covers the three conditional outcomes — payment authorized, payment failed, and item unavailable. A **par** combined fragment (teal) nested inside the success branch shows concurrent email confirmation and stock-level update. Demonstrates activation bars, two distinct arrowhead types, UML pentagon fragment tags, and guard conditions. - -## Key Patterns Used - -- **6 lifelines at equal spacing**: Lifeline centers placed at x=90, 190, 290, 390, 490, 590 (100px apart) so the first box left-edge lands at x=40 and the last right-edge lands at x=640 — exactly filling the safe area -- **Two-row actor headers**: Each lifeline box shows `":"` (small, tertiary color) on one line and the class name (slightly larger, bold) on a second line, matching the UML anonymous-instance notation `:ClassName` -- **Two separate arrowhead markers**: `#arr-call` is a filled triangle (`<polygon>`) for synchronous calls; `#arr-ret` is an open chevron (`fill="none"`) for dashed return messages — both use `context-stroke` to inherit line color -- **Activation bars**: Narrow 8px-wide rectangles (`class="activation"`) layered on top of lifeline stems to show object execution periods; OrderController's bar spans the entire interaction; shorter bars mark PaymentGateway, InventorySystem, and EmailService during their active windows -- **Combined fragment pentagon tag**: Each `alt` / `par` frame uses a `<polygon>` dog-eared label shape in the top-left corner — points follow the pattern `(x,y) (x+w,y) (x+w+6,y+6) (x+w+6,y+18) (x,y+18)` creating the characteristic UML notch -- **Nested par inside alt**: The `par` rect (teal) sits inside branch 1 of the `alt` rect (amber); inner rect uses inset x/y (+15/+2) so both borders remain visible and distinguishable -- **Guard conditions**: Italic text in `[square brackets]` placed immediately after each alt frame divider line, or just inside the top frame for branch 1 — rendered with a dedicated `guard-lbl` class (italic, amber color) -- **Alt branch dividers**: Solid horizontal lines (`.frag-alt-div`) span the full alt rect width to separate the three branches; par branch separator uses a dashed line (`.frag-par-div`) per UML spec -- **Lifeline end caps**: Short 14px horizontal tick marks at y=590 (bottom of all lifeline stems) to formally terminate each lifeline -- **Message sequence annotation**: A faint counter row below the legend (①–③ / ④–⑩ / ⑪–⑫ / ⑬–⑭) explains the four message groups without adding noise to the diagram body - -## Diagram - -```xml -<svg width="100%" viewBox="0 0 680 648" xmlns="http://www.w3.org/2000/svg"> - <defs> - <!-- Open chevron arrowhead — return messages --> - <marker id="arr-ret" viewBox="0 0 10 10" refX="8" refY="5" - markerWidth="6" markerHeight="6" orient="auto-start-reverse"> - <path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" - stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> - </marker> - - <!-- Filled triangle arrowhead — synchronous calls --> - <marker id="arr-call" viewBox="0 0 10 10" refX="9" refY="5" - markerWidth="7" markerHeight="7" orient="auto"> - <polygon points="0,1 10,5 0,9" fill="context-stroke"/> - </marker> - </defs> - - <!-- - Lifeline centres (x): - L1 :Customer → 90 - L2 :ShoppingCart → 190 - L3 :OrderController → 290 - L4 :PaymentGateway → 390 - L5 :InventorySystem → 490 - L6 :EmailService → 590 - Actor boxes: x = cx−50, y=20, w=100, h=56, rx=6 - Lifelines: x = cx, y1=76, y2=590 - --> - - <!-- ── 1. LIFELINE DASHED STEMS (drawn first, behind everything) ── --> - <line x1="90" y1="76" x2="90" y2="590" class="lifeline"/> - <line x1="190" y1="76" x2="190" y2="590" class="lifeline"/> - <line x1="290" y1="76" x2="290" y2="590" class="lifeline"/> - <line x1="390" y1="76" x2="390" y2="590" class="lifeline"/> - <line x1="490" y1="76" x2="490" y2="590" class="lifeline"/> - <line x1="590" y1="76" x2="590" y2="590" class="lifeline"/> - - <!-- ── 2. ACTOR HEADER BOXES ── --> - - <!-- :Customer --> - <rect x="40" y="20" width="100" height="56" rx="6" class="actor"/> - <text class="actor-colon" x="90" y="40" text-anchor="middle" dominant-baseline="central">:</text> - <text class="actor-name" x="90" y="58" text-anchor="middle" dominant-baseline="central">Customer</text> - - <!-- :ShoppingCart --> - <rect x="140" y="20" width="100" height="56" rx="6" class="actor"/> - <text class="actor-colon" x="190" y="37" text-anchor="middle" dominant-baseline="central">:</text> - <text class="actor-name" x="190" y="55" text-anchor="middle" dominant-baseline="central">ShoppingCart</text> - - <!-- :OrderController --> - <rect x="240" y="20" width="100" height="56" rx="6" class="actor"/> - <text class="actor-colon" x="290" y="37" text-anchor="middle" dominant-baseline="central">:</text> - <text class="actor-name" x="290" y="55" text-anchor="middle" dominant-baseline="central">OrderController</text> - - <!-- :PaymentGateway --> - <rect x="340" y="20" width="100" height="56" rx="6" class="actor"/> - <text class="actor-colon" x="390" y="37" text-anchor="middle" dominant-baseline="central">:</text> - <text class="actor-name" x="390" y="55" text-anchor="middle" dominant-baseline="central">PaymentGateway</text> - - <!-- :InventorySystem --> - <rect x="440" y="20" width="100" height="56" rx="6" class="actor"/> - <text class="actor-colon" x="490" y="37" text-anchor="middle" dominant-baseline="central">:</text> - <text class="actor-name" x="490" y="55" text-anchor="middle" dominant-baseline="central">InventorySystem</text> - - <!-- :EmailService --> - <rect x="540" y="20" width="100" height="56" rx="6" class="actor"/> - <text class="actor-colon" x="590" y="37" text-anchor="middle" dominant-baseline="central">:</text> - <text class="actor-name" x="590" y="55" text-anchor="middle" dominant-baseline="central">EmailService</text> - - <!-- ── 3. ACTIVATION BARS ── --> - <!-- ShoppingCart: active while forwarding checkout → placeOrder --> - <rect x="186" y="102" width="8" height="26" rx="1" class="activation"/> - <!-- OrderController: active throughout full sequence --> - <rect x="286" y="128" width="8" height="415" rx="1" class="activation"/> - <!-- PaymentGateway: active during auth check (happy-path branch only) --> - <rect x="386" y="154" width="8" height="46" rx="1" class="activation"/> - <!-- InventorySystem: active from reserveItems → updateStockLevels end --> - <rect x="486" y="225" width="8" height="128" rx="1" class="activation"/> - <!-- EmailService: active during confirmation send --> - <rect x="586" y="290" width="8" height="25" rx="1" class="activation"/> - - <!-- ── 4. PRE-ALT MESSAGES ── --> - - <!-- ① checkout() :Customer → :ShoppingCart --> - <line x1="90" y1="102" x2="186" y2="102" class="msg-call" marker-end="url(#arr-call)"/> - <text class="mlbl" x="140" y="97" text-anchor="middle">checkout()</text> - - <!-- ② placeOrder(cartItems) :ShoppingCart → :OrderController --> - <line x1="194" y1="128" x2="286" y2="128" class="msg-call" marker-end="url(#arr-call)"/> - <text class="mlbl" x="242" y="123" text-anchor="middle">placeOrder(cartItems)</text> - - <!-- ③ authorizePayment(amount) :OrderController → :PaymentGateway --> - <line x1="294" y1="154" x2="386" y2="154" class="msg-call" marker-end="url(#arr-call)"/> - <text class="mlbl" x="342" y="149" text-anchor="middle">authorizePayment(amount)</text> - - <!-- ── 5. ALT COMBINED FRAGMENT y=166 → y=563 ── --> - - <!-- Outer alt rectangle --> - <rect x="45" y="166" width="590" height="397" rx="3" class="frag-alt-bg"/> - - <!-- Pentagon "alt" tag: TL corner notch shape --> - <polygon points="45,166 84,166 90,173 90,185 45,185" class="frag-alt-tag"/> - <text class="frag-alt-kw" x="67" y="178" text-anchor="middle" dominant-baseline="central">alt</text> - - <!-- Guard: branch 1 --> - <text class="guard-lbl" x="96" y="179" dominant-baseline="central">[payment authorized]</text> - - <!-- ─── Branch 1: payment authorized ─── --> - - <!-- ④ « authorized » :PaymentGateway → :OrderController (dashed return) --> - <line x1="386" y1="200" x2="294" y2="200" class="msg-ret" marker-end="url(#arr-ret)"/> - <text class="rlbl" x="342" y="195" text-anchor="middle">« authorized »</text> - - <!-- ⑤ reserveItems(cartItems) :OrderController → :InventorySystem --> - <line x1="294" y1="225" x2="486" y2="225" class="msg-call" marker-end="url(#arr-call)"/> - <text class="mlbl" x="392" y="220" text-anchor="middle">reserveItems(cartItems)</text> - - <!-- ⑥ « itemsReserved » :InventorySystem → :OrderController (dashed return) --> - <line x1="486" y1="250" x2="294" y2="250" class="msg-ret" marker-end="url(#arr-ret)"/> - <text class="rlbl" x="392" y="245" text-anchor="middle">« itemsReserved »</text> - - <!-- ── 6. PAR COMBINED FRAGMENT (nested inside alt branch 1) y=266 → y=373 ── --> - - <!-- Inner par rectangle --> - <rect x="60" y="266" width="560" height="107" rx="3" class="frag-par-bg"/> - - <!-- Pentagon "par" tag --> - <polygon points="60,266 97,266 102,272 102,284 60,284" class="frag-par-tag"/> - <text class="frag-par-kw" x="81" y="275" text-anchor="middle" dominant-baseline="central">par</text> - - <!-- Par branch 1: email confirmation --> - - <!-- ⑦ sendConfirmationEmail() :OrderController → :EmailService --> - <line x1="294" y1="295" x2="586" y2="295" class="msg-call" marker-end="url(#arr-call)"/> - <text class="mlbl" x="442" y="290" text-anchor="middle">sendConfirmationEmail()</text> - - <!-- ⑧ « emailQueued » :EmailService → :OrderController (dashed return) --> - <line x1="586" y1="318" x2="294" y2="318" class="msg-ret" marker-end="url(#arr-ret)"/> - <text class="rlbl" x="442" y="313" text-anchor="middle">« emailQueued »</text> - - <!-- Par branch divider (dashed, per UML spec) --> - <line x1="60" y1="336" x2="620" y2="336" class="frag-par-div"/> - - <!-- Par branch 2: stock level update --> - - <!-- ⑨ updateStockLevels() :OrderController → :InventorySystem --> - <line x1="294" y1="355" x2="486" y2="355" class="msg-call" marker-end="url(#arr-call)"/> - <text class="mlbl" x="392" y="350" text-anchor="middle">updateStockLevels()</text> - - <!-- PAR fragment ends at y=373 --> - - <!-- ⑩ « orderPlaced » :OrderController → :Customer (dashed return, after par) --> - <line x1="286" y1="395" x2="90" y2="395" class="msg-ret" marker-end="url(#arr-ret)"/> - <text class="rlbl" x="190" y="390" text-anchor="middle">« orderPlaced »</text> - - <!-- ─── Alt else: [payment failed] ─── --> - - <!-- Alt branch divider 1 (solid line) --> - <line x1="45" y1="415" x2="635" y2="415" class="frag-alt-div"/> - <text class="guard-lbl" x="50" y="429" dominant-baseline="central">[payment failed]</text> - - <!-- ⑪ « authFailed » :PaymentGateway → :OrderController (dashed return) --> - <line x1="390" y1="448" x2="294" y2="448" class="msg-ret" marker-end="url(#arr-ret)"/> - <text class="rlbl" x="344" y="443" text-anchor="middle">« authFailed »</text> - - <!-- ⑫ error(PAYMENT_FAILED) :OrderController → :Customer --> - <line x1="286" y1="470" x2="90" y2="470" class="msg-call" marker-end="url(#arr-call)"/> - <text class="mlbl" x="190" y="465" text-anchor="middle">error(PAYMENT_FAILED)</text> - - <!-- ─── Alt else: [item unavailable] ─── --> - - <!-- Alt branch divider 2 (solid line) --> - <line x1="45" y1="490" x2="635" y2="490" class="frag-alt-div"/> - <text class="guard-lbl" x="50" y="504" dominant-baseline="central">[item unavailable]</text> - - <!-- ⑬ « unavailable » :InventorySystem → :OrderController (dashed return) --> - <line x1="486" y1="523" x2="294" y2="523" class="msg-ret" marker-end="url(#arr-ret)"/> - <text class="rlbl" x="392" y="518" text-anchor="middle">« unavailable »</text> - - <!-- ⑭ error(ITEM_UNAVAILABLE) :OrderController → :Customer --> - <line x1="286" y1="545" x2="90" y2="545" class="msg-call" marker-end="url(#arr-call)"/> - <text class="mlbl" x="190" y="540" text-anchor="middle">error(ITEM_UNAVAILABLE)</text> - - <!-- ALT fragment ends at y=563 --> - - <!-- ── 7. LIFELINE END CAPS (short horizontal tick at y=590) ── --> - <line x1="83" y1="590" x2="97" y2="590" stroke="var(--text-tertiary)" stroke-width="1.5"/> - <line x1="183" y1="590" x2="197" y2="590" stroke="var(--text-tertiary)" stroke-width="1.5"/> - <line x1="283" y1="590" x2="297" y2="590" stroke="var(--text-tertiary)" stroke-width="1.5"/> - <line x1="383" y1="590" x2="397" y2="590" stroke="var(--text-tertiary)" stroke-width="1.5"/> - <line x1="483" y1="590" x2="497" y2="590" stroke="var(--text-tertiary)" stroke-width="1.5"/> - <line x1="583" y1="590" x2="597" y2="590" stroke="var(--text-tertiary)" stroke-width="1.5"/> - - <!-- ── 8. LEGEND ── --> - <text class="ts" x="45" y="612" opacity=".45">Legend —</text> - - <line x1="110" y1="609" x2="148" y2="609" - stroke="var(--text-primary)" stroke-width="1.5" marker-end="url(#arr-call)"/> - <text class="ts" x="154" y="613" opacity=".75">Synchronous call</text> - - <line x1="288" y1="609" x2="326" y2="609" - stroke="var(--text-secondary)" stroke-width="1.5" - stroke-dasharray="5 3" marker-end="url(#arr-ret)"/> - <text class="ts" x="332" y="613" opacity=".75">Return message</text> - - <rect x="458" y="603" width="22" height="13" rx="2" - fill="#FAEEDA" fill-opacity="0.5" stroke="#854F0B" stroke-width="0.75"/> - <text class="ts" x="484" y="613" opacity=".75">alt fragment</text> - - <rect x="558" y="603" width="22" height="13" rx="2" - fill="#E1F5EE" fill-opacity="0.6" stroke="#0F6E56" stroke-width="0.75"/> - <text class="ts" x="584" y="613" opacity=".75">par fragment</text> - - <!-- Message group annotation --> - <text class="ts" x="45" y="632" opacity=".35"> - ①–③ pre-condition · ④–⑩ happy path · ⑪–⑫ payment failure · ⑬–⑭ item unavailable - </text> - -</svg> -``` - -## Custom CSS - -Add these classes to the hosting page `<style>` block (in addition to the standard skill CSS): - -```css -/* ── Actor lifeline header boxes ── */ -.actor { fill: var(--bg-secondary); stroke: var(--text-secondary); stroke-width: 0.5; } -.actor-name { font-family: system-ui, sans-serif; font-size: 11.5px; font-weight: 600; - fill: var(--text-primary); } -.actor-colon { font-family: system-ui, sans-serif; font-size: 10px; fill: var(--text-tertiary); } - -/* ── Lifeline dashed stems ── */ -.lifeline { stroke: var(--text-tertiary); stroke-width: 1; stroke-dasharray: 6 4; fill: none; } - -/* ── Activation bars ── */ -.activation { fill: var(--bg-secondary); stroke: var(--text-secondary); stroke-width: 0.75; } - -/* ── Message arrows ── */ -.msg-call { stroke: var(--text-primary); stroke-width: 1.5; fill: none; } -.msg-ret { stroke: var(--text-secondary); stroke-width: 1.5; fill: none; stroke-dasharray: 6 3; } - -/* ── Message labels ── */ -.mlbl { font-family: system-ui, sans-serif; font-size: 11px; fill: var(--text-primary); } -.rlbl { font-family: system-ui, sans-serif; font-size: 11px; fill: var(--text-secondary); - font-style: italic; } - -/* ── Combined fragment: alt (amber) ── */ -.frag-alt-bg { fill: #FAEEDA; fill-opacity: 0.18; stroke: #854F0B; stroke-width: 1; } -.frag-alt-tag { fill: #FAEEDA; stroke: #854F0B; stroke-width: 0.75; } -.frag-alt-kw { font-family: system-ui, sans-serif; font-size: 11px; font-weight: 700; - fill: #633806; } -.frag-alt-div { stroke: #854F0B; stroke-width: 0.75; fill: none; } -.guard-lbl { font-family: system-ui, sans-serif; font-size: 10.5px; font-style: italic; - fill: #854F0B; } - -/* ── Combined fragment: par (teal) ── */ -.frag-par-bg { fill: #E1F5EE; fill-opacity: 0.35; stroke: #0F6E56; stroke-width: 1; } -.frag-par-tag { fill: #E1F5EE; stroke: #0F6E56; stroke-width: 0.75; } -.frag-par-kw { font-family: system-ui, sans-serif; font-size: 11px; font-weight: 700; - fill: #085041; } -.frag-par-div { stroke: #0F6E56; stroke-width: 0.75; stroke-dasharray: 5 3; fill: none; } - -/* ── Dark mode overrides ── */ -@media (prefers-color-scheme: dark) { - .actor { fill: #2c2c2a; stroke: #b4b2a9; } - .actor-name { fill: #e8e6de; } - .actor-colon { fill: #888780; } - .frag-alt-bg { fill: #633806; fill-opacity: 0.25; stroke: #EF9F27; } - .frag-alt-tag { fill: #633806; stroke: #EF9F27; } - .frag-alt-kw { fill: #FAC775; } - .frag-alt-div { stroke: #EF9F27; } - .guard-lbl { fill: #EF9F27; } - .frag-par-bg { fill: #085041; fill-opacity: 0.35; stroke: #5DCAA5; } - .frag-par-tag { fill: #085041; stroke: #5DCAA5; } - .frag-par-kw { fill: #9FE1CB; } - .frag-par-div { stroke: #5DCAA5; } -} -``` - -## Color Assignments - -| Element | Color | Reason | -|---------|-------|--------| -| Actor header boxes | Neutral (`var(--bg-secondary)`) | Structural / non-semantic — all lifelines share one style | -| Activation bars | Neutral (`var(--bg-secondary)`) | Show execution periods without adding semantic color | -| Synchronous call arrows | `var(--text-primary)` + filled triangle | High contrast for calls — the primary interaction direction | -| Return / dashed arrows | `var(--text-secondary)` + open chevron | Lower contrast for returns — secondary flow direction | -| `alt` fragment | Amber (`#FAEEDA` / `#854F0B`) | Warning / conditional — matches `c-amber` semantic meaning | -| Guard condition text | Amber italic | Belongs visually to the alt fragment | -| `par` fragment | Teal (`#E1F5EE` / `#0F6E56`) | Concurrent success path — matches `c-teal` semantic meaning | -| Alt branch dividers | Amber solid line | Continuity with the alt frame color | -| Par branch divider | Teal dashed line | UML spec: par branches separated by dashed lines | - -## Layout Notes - -- **ViewBox**: 680×648 (standard width; height = lifeline bottom y=590 + legend + annotation + 16px buffer) -- **Lifeline spacing formula**: `(safe_area_width) / (n_lifelines − 1) = 600 / 5 = 120px` — but use `spacing = 100px` starting at `x=90` so that first box left = 40 and last box right = 640 exactly -- **Actor box split-label trick**: Two separate `<text>` elements per box — one for `":"` (10px, tertiary color) and one for the class name (11.5px bold, primary color) — avoids the 14px font needing ~150px+ per box for long names like "OrderController" -- **Pentagon tag formula**: For a fragment starting at `(fx, fy)`, the tag polygon points are `(fx,fy) (fx+w,fy) (fx+w+6,fy+6) (fx+w+6,fy+18) (fx,fy+18)` where `w` = approximate text width of the keyword + 8px padding each side -- **Nested fragment inset**: The `par` rect uses `x = alt_x + 15` and `y = alt_y_current + 2` so both borders remain simultaneously visible — inset enough to separate visually, not so much that it wastes vertical space -- **Activation bar placement**: `x = lifeline_cx − 4`, `width = 8` — centered on the lifeline and narrow enough not to obscure the dashed stem behind it -- **Message label y-offset**: All labels are placed at `y = arrow_y − 5` to sit just above the arrow line; this applies to both left-going and right-going arrows since `text-anchor="middle"` handles horizontal centering automatically -- **Return arrows entering activation bars**: End `x1/x2` at lifeline center (e.g. x=294 for OrderController) rather than the bar edge (x=286) — the small overlap is intentional and clarifies the target object -- **Alt guard label placement**: Branch 1 guard goes at `y = frame_top + 13` to the right of the pentagon tag; subsequent branch guards go at `divider_y + 14` so they sit just inside the new branch -- **Lifeline end cap pattern**: `<line x1="cx−7" y1="590" x2="cx+7" y2="590" stroke-width="1.5"/>` — a simple symmetric tick, no special marker needed diff --git a/optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md b/optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md deleted file mode 100644 index 4069ede0491..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md +++ /dev/null @@ -1,173 +0,0 @@ -# Smart City Infrastructure - -A multi-system integration diagram showing interconnected city infrastructure (power, water, transport) connected through a central IoT platform with a citizen dashboard on top. Demonstrates hub-spoke layout, diverse physical shapes, and UI mockups. - -## Key Patterns Used - -- **Hub-spoke layout**: Central IoT platform with radiating data connections to subsystems -- **Connection dots**: Visual indicators where data lines attach to the central hub -- **Dashboard/UI mockup**: Screen with mini-charts, gauges, and status indicators -- **Multi-system integration**: Three independent systems unified by central platform -- **Semantic line styles**: Different stroke styles for data (dashed), power, water, roads -- **Physical infrastructure shapes**: Solar panels, wind turbines, dams, pipes, roads, vehicles - -## New Shape Techniques - -### Solar Panels (angled polygons with grid lines) -```xml -<polygon class="solar-panel" points="0,25 35,8 38,12 3,29"/> -<line class="solar-frame" x1="12" y1="22" x2="24" y2="13"/> -<line x1="19" y1="29" x2="19" y2="40" stroke="#5F5E5A" stroke-width="2"/> -``` - -### Wind Turbine (tower + nacelle + blades) -```xml -<!-- Tapered tower --> -<polygon class="wind-tower" points="20,70 30,70 28,25 22,25"/> -<!-- Nacelle --> -<rect class="wind-hub" x="18" y="20" width="14" height="8" rx="2"/> -<!-- Hub --> -<circle class="wind-hub" cx="25" cy="18" r="5"/> -<!-- Blades (rotated ellipses) --> -<ellipse class="wind-blade" cx="25" cy="5" rx="3" ry="13"/> -<ellipse class="wind-blade" cx="14" cy="26" rx="3" ry="13" transform="rotate(-120, 25, 18)"/> -<ellipse class="wind-blade" cx="36" cy="26" rx="3" ry="13" transform="rotate(120, 25, 18)"/> -``` - -### Battery with Charge Level -```xml -<rect class="battery" x="0" y="0" width="45" height="65" rx="5"/> -<!-- Terminals --> -<rect x="10" y="-6" width="10" height="8" rx="2" fill="#27500A"/> -<rect x="25" y="-6" width="10" height="8" rx="2" fill="#27500A"/> -<!-- Charge level fill --> -<rect class="battery-level" x="5" y="12" width="35" height="48" rx="3"/> -<text x="22" y="42" text-anchor="middle" fill="#173404" style="font-size:10px">85%</text> -``` - -### Dam/Reservoir with Water Waves -```xml -<!-- Dam wall --> -<polygon class="reservoir-wall" points="0,60 10,0 70,0 80,60"/> -<!-- Water behind dam --> -<polygon class="water" points="12,10 68,10 68,55 75,55 75,58 5,58 5,55 12,55"/> -<!-- Wave effect --> -<path d="M 15 25 Q 25 22 35 25 Q 45 28 55 25" fill="none" stroke="#378ADD" stroke-width="1" opacity="0.5"/> -``` - -### Pipe Network with Joints and Valves -```xml -<path class="pipe" d="M 80 85 L 110 85"/> -<circle class="pipe-joint" cx="10" cy="30" r="8"/> -<circle class="valve" cx="190" cy="85" r="6"/> -<!-- Distribution branches --> -<path class="pipe-thin" d="M 18 30 L 50 30"/> -<path class="pipe-thin" d="M 10 22 L 10 5 L 50 5"/> -``` - -### Road Intersection with Lane Markings -```xml -<!-- Road surface --> -<line class="road" x1="0" y1="50" x2="170" y2="50"/> -<line class="road-mark" x1="10" y1="50" x2="160" y2="50"/> -<!-- Cross road --> -<line class="road" x1="85" y1="0" x2="85" y2="100"/> -<line class="road-mark" x1="85" y1="10" x2="85" y2="90"/> -<!-- Embedded sensors --> -<circle class="sensor" cx="40" cy="50" r="5"/> -``` - -### Traffic Light with Signal States -```xml -<rect class="traffic-light" x="0" y="0" width="14" height="32" rx="3"/> -<circle class="light-red" cx="7" cy="8" r="4"/> -<circle class="light-off" cx="7" cy="16" r="4"/> -<circle class="light-off" cx="7" cy="24" r="4"/> -``` - -### Bus with Windows and Wheels -```xml -<rect class="bus" x="0" y="0" width="55" height="28" rx="6"/> -<!-- Windows --> -<rect class="bus-window" x="5" y="5" width="12" height="12" rx="2"/> -<rect class="bus-window" x="20" y="5" width="12" height="12" rx="2"/> -<!-- Wheels with hubcaps --> -<circle cx="14" cy="30" r="6" fill="#2C2C2A"/> -<circle cx="14" cy="30" r="3" fill="#5F5E5A"/> -``` - -### Dashboard UI Mockup -```xml -<!-- Monitor frame --> -<rect class="dashboard" x="0" y="0" width="200" height="120" rx="8"/> -<!-- Screen --> -<rect class="screen" x="10" y="10" width="180" height="85" rx="4"/> -<!-- Mini bar chart --> -<rect class="screen-content" x="18" y="18" width="50" height="35" rx="2"/> -<rect class="screen-chart" x="22" y="38" width="8" height="12"/> -<rect class="screen-chart" x="33" y="32" width="8" height="18"/> -<!-- Gauge --> -<circle class="screen-bar" cx="100" cy="35" r="12"/> -<text x="100" y="39" text-anchor="middle" fill="#E8E6DE" style="font-size:8px">78%</text> -<!-- Status indicators --> -<circle cx="35" cy="74" r="6" fill="#97C459"/> -<circle cx="75" cy="74" r="6" fill="#97C459"/> -<circle cx="115" cy="74" r="6" fill="#EF9F27"/> -``` - -### Hexagonal IoT Hub with Connection Points -```xml -<!-- Outer hexagon --> -<polygon class="iot-hex" points="0,-45 39,-22 39,22 0,45 -39,22 -39,-22"/> -<!-- Inner hexagon --> -<polygon class="iot-inner" points="0,-20 17,-10 17,10 0,20 -17,10 -17,-10"/> -<!-- Connection dots on data lines --> -<circle cx="321" cy="248" r="4" fill="#7F77DD"/> -``` - -## CSS Classes for Infrastructure - -```css -/* Power system */ -.solar-panel { fill: #3C3489; stroke: #534AB7; stroke-width: 0.5; } -.solar-frame { fill: none; stroke: #EEEDFE; stroke-width: 0.5; } -.wind-tower { fill: #B4B2A9; stroke: #5F5E5A; stroke-width: 1; } -.wind-blade { fill: #F1EFE8; stroke: #888780; stroke-width: 0.5; } -.battery { fill: #27500A; stroke: #3B6D11; stroke-width: 1.5; } -.battery-level { fill: #97C459; } -.power-line { stroke: #EF9F27; stroke-width: 2; fill: none; } - -/* Water system */ -.reservoir-wall { fill: #B4B2A9; stroke: #5F5E5A; stroke-width: 1; } -.water { fill: #85B7EB; stroke: #378ADD; stroke-width: 0.5; } -.pipe { fill: none; stroke: #378ADD; stroke-width: 4; stroke-linecap: round; } -.pipe-joint { fill: #185FA5; stroke: #0C447C; stroke-width: 1; } -.valve { fill: #0C447C; stroke: #185FA5; stroke-width: 1; } - -/* Transport */ -.road { stroke: #888780; stroke-width: 8; fill: none; stroke-linecap: round; } -.road-mark { stroke: #F1EFE8; stroke-width: 1; fill: none; stroke-dasharray: 6 4; } -.traffic-light { fill: #444441; stroke: #2C2C2A; stroke-width: 0.5; } -.light-red { fill: #E24B4A; } -.light-green { fill: #97C459; } -.light-off { fill: #2C2C2A; } -.bus { fill: #E1F5EE; stroke: #0F6E56; stroke-width: 1.5; } - -/* Data/IoT */ -.data-line { stroke: #7F77DD; stroke-width: 2; fill: none; stroke-dasharray: 4 3; } -.iot-hex { fill: #EEEDFE; stroke: #534AB7; stroke-width: 2; } - -/* Dashboard */ -.dashboard { fill: #F1EFE8; stroke: #5F5E5A; stroke-width: 1.5; } -.screen { fill: #1a1a18; } -.screen-chart { fill: #5DCAA5; } -``` - -## Layout Notes - -- **ViewBox**: 720×620 (wider for three-column system layout) -- **Hub position**: Central IoT at (360, 270) - geometric center -- **Data lines**: Use quadratic curves or L-shaped paths, add connection dots at hub attachment points -- **System spacing**: ~200px width per system section -- **Vertical layers**: Dashboard (top) → IoT Hub (middle) → Systems (bottom) -- **Component grouping**: Use `<g transform="translate(x,y)">` for each major component for easy positioning diff --git a/optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md b/optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md deleted file mode 100644 index 101be640b94..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md +++ /dev/null @@ -1,154 +0,0 @@ -# Smartphone Layer Anatomy - -An exploded view diagram showing all internal layers of a smartphone from front glass to back, with alternating left/right labels to avoid overlap. Demonstrates layered product teardown visualization and component detail. - -## Key Patterns Used - -- **Exploded vertical stack**: Layers separated vertically to show internal structure -- **Alternating labels**: Left/right label placement prevents text overlap -- **Component detail**: Chips, coils, lenses rendered with realistic shapes -- **Thickness scale**: Measurement indicator on the side -- **Progressive depth**: Each layer slightly offset to create 3D stack effect - -## New Shape Techniques - -### Capacitive Touch Grid -```xml -<rect class="digitizer" x="0" y="0" width="140" height="90" rx="14"/> -<g transform="translate(8, 8)"> - <!-- Horizontal lines --> - <line class="digitizer-grid" x1="0" y1="15" x2="124" y2="15"/> - <line class="digitizer-grid" x1="0" y1="37" x2="124" y2="37"/> - <!-- Vertical lines --> - <line class="digitizer-grid" x1="20" y1="0" x2="20" y2="74"/> - <line class="digitizer-grid" x1="50" y1="0" x2="50" y2="74"/> -</g> -<!-- Touch point indicator --> -<circle cx="70" cy="45" r="12" fill="none" stroke="#7F77DD" stroke-width="2" opacity="0.6"/> -<circle cx="70" cy="45" r="5" fill="#7F77DD" opacity="0.4"/> -``` - -### OLED RGB Subpixels -```xml -<rect class="oled-panel" x="0" y="0" width="140" height="90" rx="12"/> -<g transform="translate(10, 10)"> - <!-- RGB pixel group --> - <rect class="oled-subpixel-r" x="0" y="0" width="2" height="6"/> - <rect class="oled-subpixel-g" x="3" y="0" width="2" height="6"/> - <rect class="oled-subpixel-b" x="6" y="0" width="2" height="6"/> - <!-- Repeat pattern --> - <rect class="oled-subpixel-r" x="11" y="0" width="2" height="6"/> - <rect class="oled-subpixel-g" x="14" y="0" width="2" height="6"/> - <rect class="oled-subpixel-b" x="17" y="0" width="2" height="6"/> -</g> -``` - -### Logic Board with Chips -```xml -<rect class="pcb" x="0" y="0" width="116" height="106" rx="3"/> -<!-- PCB traces --> -<path class="pcb-trace" d="M 8 50 L 30 50 L 30 35"/> - -<!-- CPU chip --> -<rect class="chip-cpu" x="30" y="20" width="55" height="35" rx="3"/> -<text class="chip-label" x="57" y="35" text-anchor="middle">A17 Pro</text> - -<!-- RAM chip --> -<rect class="chip-ram" x="30" y="62" width="35" height="18" rx="2"/> -<text class="chip-label" x="47" y="74" text-anchor="middle">8GB RAM</text> - -<!-- Storage chip --> -<rect class="chip-storage" x="30" y="85" width="55" height="16" rx="2"/> -<text class="chip-label" x="57" y="96" text-anchor="middle">256GB NAND</text> -``` - -### Camera Lens Array -```xml -<!-- Main camera --> -<circle class="camera-lens" cx="20" cy="20" r="18"/> -<circle class="camera-lens-inner" cx="20" cy="20" r="13"/> -<circle class="camera-sensor" cx="20" cy="20" r="8"/> -<circle cx="20" cy="20" r="3" fill="#1a1a18"/> - -<!-- Secondary camera (smaller) --> -<circle class="camera-lens" cx="15" cy="15" r="13"/> -<circle class="camera-lens-inner" cx="15" cy="15" r="9"/> -<circle class="camera-sensor" cx="15" cy="15" r="5"/> -``` - -### Wireless Charging Coil with Magnets -```xml -<!-- Concentric coil rings --> -<circle class="charging-coil-outer" cx="0" cy="0" r="30"/> -<circle class="charging-coil" cx="0" cy="0" r="23"/> -<circle class="charging-coil" cx="0" cy="0" r="16"/> -<circle class="charging-coil" cx="0" cy="0" r="9"/> - -<!-- MagSafe magnet ring --> -<circle class="magnet" cx="0" cy="-35" r="3"/> -<circle class="magnet" cx="25" cy="-25" r="3"/> -<circle class="magnet" cx="35" cy="0" r="3"/> -<circle class="magnet" cx="25" cy="25" r="3"/> -<!-- ... continue around circle --> -``` - -### Battery Cell -```xml -<rect class="battery" x="0" y="0" width="140" height="90" rx="10"/> -<rect class="battery-cell" x="10" y="12" width="120" height="60" rx="6"/> - -<text x="70" y="38" text-anchor="middle" fill="#27500A" style="font-size:9px">Li-Ion Polymer</text> -<text x="70" y="52" text-anchor="middle" fill="#27500A" style="font-size:12px; font-weight:bold">4422 mAh</text> - -<rect class="battery-connector" x="55" y="75" width="30" height="10" rx="2"/> -``` - -## CSS Classes - -```css -/* Glass */ -.front-glass { fill: #E8E6DE; stroke: #888780; stroke-width: 1; opacity: 0.9; } -.back-glass { fill: #2C2C2A; stroke: #444441; stroke-width: 1; } - -/* Touch digitizer */ -.digitizer { fill: #EEEDFE; stroke: #534AB7; stroke-width: 1; } -.digitizer-grid { stroke: #AFA9EC; stroke-width: 0.3; fill: none; } - -/* OLED */ -.oled-panel { fill: #1a1a18; stroke: #444441; stroke-width: 1; } -.oled-subpixel-r { fill: #E24B4A; } -.oled-subpixel-g { fill: #97C459; } -.oled-subpixel-b { fill: #378ADD; } - -/* Midframe */ -.midframe { fill: #B4B2A9; stroke: #5F5E5A; stroke-width: 1.5; } - -/* Logic board */ -.pcb { fill: #0F6E56; stroke: #085041; stroke-width: 1; } -.pcb-trace { stroke: #5DCAA5; stroke-width: 0.3; fill: none; } -.chip-cpu { fill: #3C3489; stroke: #534AB7; stroke-width: 0.5; } -.chip-ram { fill: #185FA5; stroke: #378ADD; stroke-width: 0.5; } -.chip-storage { fill: #27500A; stroke: #3B6D11; stroke-width: 0.5; } - -/* Battery */ -.battery { fill: #EAF3DE; stroke: #3B6D11; stroke-width: 1.5; } -.battery-cell { fill: #97C459; stroke: #639922; stroke-width: 0.5; } - -/* Camera */ -.camera-lens { fill: #0C447C; stroke: #185FA5; stroke-width: 0.5; } -.camera-lens-inner { fill: #1a1a18; stroke: #378ADD; stroke-width: 0.3; } -.camera-sensor { fill: #3C3489; stroke: #534AB7; stroke-width: 0.3; } - -/* Wireless charging */ -.charging-coil { fill: none; stroke: #EF9F27; stroke-width: 1.5; } -.magnet { fill: #5F5E5A; stroke: #444441; stroke-width: 0.5; } -``` - -## Layout Notes - -- **ViewBox**: 900×780 (tall for vertical stack) -- **Layer offset**: Each layer offset 10px right and down for depth effect -- **Label alternation**: Odd layers → RIGHT labels, Even layers → LEFT labels -- **Thickness scale**: Vertical measurement bar on left side -- **Front/Back markers**: Text labels at top and bottom -- **Chip labels**: Use small white text (6px) directly on chip shapes diff --git a/optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md b/optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md deleted file mode 100644 index 3f335d85d3d..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md +++ /dev/null @@ -1,247 +0,0 @@ -# SN2 Reaction Mechanism - -A chemistry diagram showing the bimolecular nucleophilic substitution (SN2) mechanism between hydroxide ion and methyl bromide. Demonstrates molecular structure rendering, electron movement arrows, transition state notation, and reaction energy profiles. - -## Key Patterns Used - -- **Molecular structures**: Ball-and-stick style atoms with bonds -- **Electron movement**: Curved arrows showing nucleophilic attack -- **Transition state**: Bracketed pentacoordinate intermediate with partial charges -- **Stereochemistry**: Wedge/dash bonds showing 3D configuration -- **Energy profile**: Potential energy vs reaction coordinate plot -- **Annotation boxes**: Key features and mechanistic notes - -## Diagram Type - -This is a **chemistry mechanism diagram** with: -- **Molecular rendering**: Atoms as colored circles with element symbols -- **Bond notation**: Solid, wedge, dash, and partial (dashed) bonds -- **Reaction arrows**: Curved for electron movement, straight for reaction progress -- **Energy landscape**: Quantitative energy profile below mechanism - -## Molecular Structure Elements - -### Atom Rendering - -```xml -<!-- Carbon atom (dark) --> -<circle cx="0" cy="0" r="14" class="carbon"/> -<text class="chem" x="0" y="5" text-anchor="middle" fill="white" font-weight="500">C</text> - -<!-- Oxygen atom (red) --> -<circle cx="0" cy="0" r="14" class="oxygen"/> -<text class="chem" x="0" y="5" text-anchor="middle" fill="white" font-weight="500">O</text> - -<!-- Hydrogen atom (light with border) --> -<circle cx="38" cy="0" r="8" class="hydrogen"/> -<text class="chem-sm" x="38" y="4" text-anchor="middle">H</text> - -<!-- Bromine atom (brown) --> -<circle cx="52" cy="0" r="16" class="bromine"/> -<text class="chem" x="52" y="5" text-anchor="middle" fill="white" font-weight="500">Br</text> -``` - -```css -.carbon { fill: #2C2C2A; } -.hydrogen { fill: #F1EFE8; stroke: #888780; stroke-width: 1; } -.oxygen { fill: #E24B4A; } -.bromine { fill: #993C1D; } -.nitrogen { fill: #378ADD; } /* for other reactions */ -``` - -### Bond Types - -```xml -<!-- Single bond (solid) --> -<line x1="14" y1="0" x2="38" y2="0" class="bond"/> - -<!-- Wedge bond (coming toward viewer) --> -<polygon class="bond-wedge" points="0,-14 -6,-35 6,-35"/> - -<!-- Dash bond (going away from viewer) --> -<line x1="-10" y1="10" x2="-28" y2="28" class="bond-dash"/> - -<!-- Partial bond (forming/breaking) --> -<line x1="-40" y1="0" x2="-14" y2="0" class="bond-partial"/> -``` - -```css -.bond { stroke: var(--text-primary); stroke-width: 2.5; fill: none; stroke-linecap: round; } -.bond-thin { stroke: var(--text-primary); stroke-width: 1.5; fill: none; } -.bond-partial { stroke: var(--text-primary); stroke-width: 2; fill: none; stroke-dasharray: 4 3; } -.bond-wedge { fill: var(--text-primary); stroke: none; } -.bond-dash { stroke: var(--text-primary); stroke-width: 2; fill: none; stroke-dasharray: 2 2; } -``` - -### Lone Pairs and Charges - -```xml -<!-- Lone pair electrons (dots) --> -<circle cx="-8" cy="-18" r="2" fill="var(--text-primary)"/> -<circle cx="0" cy="-18" r="2" fill="var(--text-primary)"/> - -<!-- Formal negative charge --> -<text class="charge" x="12" y="-12" fill="#A32D2D" font-weight="bold">⊖</text> - -<!-- Partial charges (delta notation) --> -<text class="partial" x="0" y="-18" text-anchor="middle" fill="#A32D2D">δ⁻</text> -<text class="partial" x="0" y="-22" text-anchor="middle" fill="#3B6D11">δ⁺</text> -``` - -```css -.charge { font-family: "Times New Roman", Georgia, serif; font-size: 12px; } -.partial { font-family: "Times New Roman", Georgia, serif; font-size: 11px; font-style: italic; } -``` - -### Curved Arrow (Electron Movement) - -```xml -<defs> - <marker id="curved-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto"> - <path d="M0,0 L10,5 L0,10 L3,5 Z" class="arrow-fill"/> - </marker> -</defs> - -<!-- Nucleophilic attack arrow --> -<path d="M -5,15 Q 30,60 70,25" class="arrow-curved" marker-end="url(#curved-arrow)"/> -``` - -```css -.arrow-curved { stroke: #534AB7; stroke-width: 2; fill: none; } -.arrow-fill { fill: #534AB7; } -``` - -### Transition State Brackets - -```xml -<!-- Left bracket --> -<path d="M -75,-70 L -85,-70 L -85,75 L -75,75" class="ts-bracket"/> - -<!-- Right bracket --> -<path d="M 95,-70 L 105,-70 L 105,75 L 95,75" class="ts-bracket"/> - -<!-- Double dagger symbol --> -<text class="chem" x="115" y="-60" fill="var(--text-primary)">‡</text> -``` - -```css -.ts-bracket { stroke: var(--text-primary); stroke-width: 1.5; fill: none; } -``` - -## Energy Profile Diagram - -### Axes - -```xml -<!-- Y-axis (Energy) --> -<line x1="0" y1="280" x2="0" y2="0" class="axis" marker-end="url(#straight-arrow)"/> -<text class="t" x="-15" y="-10" text-anchor="middle" transform="rotate(-90 -15 140)">Potential Energy</text> - -<!-- X-axis (Reaction Coordinate) --> -<line x1="0" y1="280" x2="600" y2="280" class="axis" marker-end="url(#straight-arrow)"/> -<text class="t" x="580" y="305" text-anchor="middle">Reaction Coordinate</text> -``` - -### Energy Curve - -```xml -<!-- Filled area under curve --> -<path class="energy-fill" d=" - M 40,200 - Q 150,200 250,50 - Q 350,200 500,220 - L 500,280 L 40,280 Z -"/> - -<!-- Curve line --> -<path class="energy-curve" d=" - M 40,200 - Q 100,200 150,150 - Q 200,80 250,50 - Q 300,80 350,150 - Q 400,210 500,220 -"/> -``` - -```css -.energy-curve { stroke: #534AB7; stroke-width: 2.5; fill: none; } -.energy-fill { fill: rgba(83, 74, 183, 0.1); } -``` - -### Energy Levels and Annotations - -```xml -<!-- Reactants level --> -<line x1="20" y1="200" x2="80" y2="200" stroke="#3B6D11" stroke-width="2"/> -<text class="ts" x="50" y="218" text-anchor="middle">Reactants</text> - -<!-- Transition state peak --> -<circle cx="250" cy="50" r="5" fill="#534AB7"/> -<line x1="250" y1="50" x2="250" y2="280" class="energy-level"/> -<text class="ts" x="250" y="30" text-anchor="middle" fill="#534AB7" font-weight="500">Transition State [‡]</text> - -<!-- Products level (lower = exergonic) --> -<line x1="470" y1="220" x2="530" y2="220" stroke="#3B6D11" stroke-width="2"/> - -<!-- Activation energy arrow --> -<line x1="100" y1="200" x2="100" y2="55" class="delta-arrow" marker-end="url(#delta-arrow)"/> -<text class="ts" x="85" y="125" text-anchor="end" fill="#3B6D11">E<tspan baseline-shift="sub" font-size="8">a</tspan></text> -``` - -```css -.energy-level { stroke: var(--text-secondary); stroke-width: 1; stroke-dasharray: 4 2; fill: none; } -.delta-arrow { stroke: #3B6D11; stroke-width: 1.5; fill: none; } -.delta-fill { fill: #3B6D11; } -``` - -## Chemistry Text Styles - -```css -/* Chemistry notation (serif font for formulas) */ -.chem { font-family: "Times New Roman", Georgia, serif; font-size: 16px; fill: var(--text-primary); } -.chem-sm { font-family: "Times New Roman", Georgia, serif; font-size: 12px; fill: var(--text-primary); } -.chem-lg { font-family: "Times New Roman", Georgia, serif; font-size: 18px; fill: var(--text-primary); } -``` - -## Subscript/Superscript in SVG - -```xml -<!-- Subscript using tspan --> -<text class="ts">E<tspan baseline-shift="sub" font-size="8">a</tspan></text> - -<!-- Superscript for charges --> -<text class="chem-sm">OH⁻</text> <!-- Using Unicode superscript minus --> -<text class="chem-sm">CH₃Br</text> <!-- Using Unicode subscript 3 --> -``` - -## Color Coding - -| Element | Color | Hex | -|---------|-------|-----| -| Carbon | Dark gray | #2C2C2A | -| Hydrogen | Light cream | #F1EFE8 | -| Oxygen | Red | #E24B4A | -| Bromine | Brown | #993C1D | -| Nitrogen | Blue | #378ADD | -| Electron arrows | Purple | #534AB7 | -| Positive charge | Green | #3B6D11 | -| Negative charge | Red | #A32D2D | - -## Layout Notes - -- **ViewBox**: 800×680 (landscape for mechanism + energy profile) -- **Mechanism section**: y=60-300, showing reactants → TS → products -- **Energy profile**: y=320-630, with axes and curve -- **Atom sizes**: C/O/Br ~12-16px radius, H ~7-8px radius -- **Bond lengths**: ~25-40px between atom centers -- **Spacing**: ~140px between mechanism stages - -## When to Use This Pattern - -Use this diagram style for: -- Organic reaction mechanisms (SN1, SN2, E1, E2, additions, eliminations) -- Reaction energy profiles and kinetics -- Stereochemistry illustrations -- Enzyme mechanism diagrams -- Transition state theory visualization -- Any chemistry concept requiring molecular structures diff --git a/optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md b/optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md deleted file mode 100644 index 795b040d1da..00000000000 --- a/optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md +++ /dev/null @@ -1,338 +0,0 @@ -# Modern Onshore Wind Turbine Structure - -A physical/structural cross-section diagram showing all major components of a modern wind turbine from underground foundation to blade tips. - -## Key Patterns Used - -- **Underground section**: Soil layers, deep concrete foundation with rebar reinforcement grid, spread footing -- **Cross-section view**: Tower wall thickness shown, internal components visible -- **Tapered tower**: Path elements creating realistic tower silhouette that narrows toward top -- **Internal access**: Ladder with rungs, elevator shaft inside tower -- **Cable routing**: Power cables running from nacelle down through tower to transformer -- **Nacelle cutaway**: Gearbox, generator, brake, yaw system all visible inside housing -- **Rotor assembly**: Hub with pitch motors at blade roots, three composite blades with gradient fill -- **Ground level marker**: Clear separation between above/below ground -- **Component color coding**: Each system type has distinct color (blue=generator, gold=gearbox, red=brake, green=yaw, purple=pitch) -- **Legend bar**: Quick reference for color meanings - -## Diagram - -```xml -<svg width="100%" viewBox="0 0 680 920" xmlns="http://www.w3.org/2000/svg"> - <defs> - <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" - markerWidth="6" markerHeight="6" orient="auto-start-reverse"> - <path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" - stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> - </marker> - <!-- Blade gradient for 3D effect --> - <linearGradient id="bladeGrad" x1="0%" y1="0%" x2="100%" y2="0%"> - <stop offset="0%" style="stop-color:#D3D1C7"/> - <stop offset="50%" style="stop-color:#F1EFE8"/> - <stop offset="100%" style="stop-color:#B4B2A9"/> - </linearGradient> - </defs> - - <!-- ===== GROUND LEVEL LINE ===== --> - <line x1="40" y1="680" x2="640" y2="680" stroke="#3B6D11" stroke-width="2"/> - <text class="tl" x="45" y="675">Ground level</text> - - <!-- ===== UNDERGROUND: FOUNDATION ===== --> - - <!-- Soil layers --> - <rect x="120" y="680" width="300" height="180" class="soil"/> - <rect x="120" y="780" width="300" height="80" class="soil-dark"/> - - <!-- Deep concrete foundation --> - <path d="M170 680 L170 820 L200 850 L340 850 L370 820 L370 680 Z" class="concrete"/> - <!-- Foundation base spread --> - <path d="M140 820 L170 820 L200 850 L340 850 L370 820 L400 820 L400 860 L140 860 Z" class="concrete-dark"/> - - <!-- Rebar reinforcement --> - <g class="rebar"> - <line x1="185" y1="700" x2="185" y2="840"/> - <line x1="210" y1="700" x2="210" y2="845"/> - <line x1="235" y1="700" x2="235" y2="848"/> - <line x1="260" y1="700" x2="260" y2="848"/> - <line x1="285" y1="700" x2="285" y2="848"/> - <line x1="310" y1="700" x2="310" y2="845"/> - <line x1="335" y1="700" x2="335" y2="840"/> - <!-- Horizontal rebar --> - <line x1="175" y1="720" x2="365" y2="720"/> - <line x1="175" y1="760" x2="365" y2="760"/> - <line x1="175" y1="800" x2="365" y2="800"/> - <line x1="155" y1="835" x2="385" y2="835"/> - </g> - - <!-- Foundation labels --> - <line x1="410" y1="770" x2="480" y2="770" class="leader"/> - <text class="ts" x="485" y="766">Deep concrete foundation</text> - <text class="tl" x="485" y="778">Reinforced with steel rebar</text> - <text class="tl" x="485" y="790">15-25m deep typical</text> - - <line x1="400" y1="850" x2="480" y2="870" class="leader"/> - <text class="ts" x="485" y="866">Foundation spread footing</text> - <text class="tl" x="485" y="878">Distributes load to soil</text> - - <!-- ===== TOWER BASE ===== --> - - <!-- Tower base flange --> - <ellipse cx="270" cy="680" rx="70" ry="12" class="concrete-dark"/> - <rect x="200" y="668" width="140" height="12" class="tower"/> - - <!-- Transformer at base --> - <g transform="translate(470, 640)"> - <rect x="0" y="0" width="50" height="40" rx="3" class="transformer"/> - <!-- Cooling fins --> - <rect x="52" y="5" width="4" height="30" class="transformer-fin"/> - <rect x="58" y="5" width="4" height="30" class="transformer-fin"/> - <rect x="64" y="5" width="4" height="30" class="transformer-fin"/> - <!-- Connection box --> - <rect x="10" y="-8" width="30" height="10" rx="2" class="transformer-fin"/> - </g> - <line x1="470" y1="660" x2="430" y2="640" class="leader"/> - <text class="ts" x="385" y="636" text-anchor="end">Transformer</text> - <text class="tl" x="385" y="648" text-anchor="end">Steps up voltage for grid</text> - - <!-- ===== TUBULAR STEEL TOWER ===== --> - - <!-- Tower outer shell (tapered) --> - <path d="M200 680 L220 200 L320 200 L340 680 Z" class="tower"/> - - <!-- Tower inner surface (cutaway) --> - <path d="M215 680 L232 210 L308 210 L325 680 Z" class="tower-inner"/> - - <!-- Tower section joints --> - <line x1="205" y1="550" x2="335" y2="550" class="tower-section"/> - <line x1="210" y1="420" x2="330" y2="420" class="tower-section"/> - <line x1="215" y1="300" x2="325" y2="300" class="tower-section"/> - - <!-- Internal ladder (left side) --> - <g transform="translate(225, 220)"> - <!-- Ladder rails --> - <line x1="0" y1="0" x2="8" y2="450" class="ladder"/> - <line x1="15" y1="0" x2="23" y2="450" class="ladder"/> - <!-- Rungs --> - <g class="ladder-rung"> - <line x1="1" y1="20" x2="22" y2="21"/> - <line x1="1" y1="50" x2="22" y2="52"/> - <line x1="2" y1="80" x2="22" y2="83"/> - <line x1="2" y1="110" x2="23" y2="114"/> - <line x1="2" y1="140" x2="23" y2="145"/> - <line x1="3" y1="170" x2="23" y2="176"/> - <line x1="3" y1="200" x2="24" y2="207"/> - <line x1="3" y1="230" x2="24" y2="238"/> - <line x1="4" y1="260" x2="24" y2="269"/> - <line x1="4" y1="290" x2="25" y2="300"/> - <line x1="4" y1="320" x2="25" y2="331"/> - <line x1="5" y1="350" x2="25" y2="362"/> - <line x1="5" y1="380" x2="26" y2="393"/> - <line x1="6" y1="410" x2="26" y2="424"/> - <line x1="6" y1="440" x2="27" y2="455"/> - </g> - </g> - - <!-- Elevator shaft (right side) --> - <rect x="280" y="230" width="25" height="430" rx="2" class="elevator"/> - <text class="tl" x="292" y="450" text-anchor="middle" transform="rotate(-90, 292, 450)" fill="#185FA5">ELEVATOR</text> - - <!-- Electrical cables running down --> - <path d="M270 220 C270 300 268 400 268 500 C268 600 268 650 310 665 L470 665" class="cable"/> - <path d="M260 225 C258 350 256 500 256 600 C256 650 256 670 256 680" class="cable-thin"/> - - <!-- Tower labels --> - <line x1="340" y1="350" x2="400" y2="320" class="leader"/> - <text class="ts" x="405" y="316">Tubular steel tower</text> - <text class="tl" x="405" y="328">80-120m height typical</text> - <text class="tl" x="405" y="340">Tapered for strength</text> - - <line x1="248" y1="400" x2="130" y2="380" class="leader"/> - <text class="ts" x="125" y="376" text-anchor="end">Internal ladder</text> - <text class="tl" x="125" y="388" text-anchor="end">Service access</text> - - <line x1="305" y1="500" x2="400" y2="520" class="leader"/> - <text class="ts" x="405" y="516">Service elevator</text> - - <line x1="268" y1="580" x2="130" y2="600" class="leader"/> - <text class="ts" x="125" y="596" text-anchor="end">Power cables</text> - <text class="tl" x="125" y="608" text-anchor="end">To transformer</text> - - <!-- ===== NACELLE ===== --> - - <g transform="translate(270, 160)"> - <!-- Nacelle base/bedplate --> - <rect x="-60" y="30" width="120" height="15" class="nacelle"/> - - <!-- Yaw bearing --> - <ellipse cx="0" cy="42" rx="35" ry="6" class="bearing"/> - - <!-- Yaw motors --> - <rect x="-55" y="32" width="12" height="18" rx="2" class="yaw"/> - <rect x="43" y="32" width="12" height="18" rx="2" class="yaw"/> - - <!-- Nacelle housing --> - <path d="M-65 30 L-70 -10 L-65 -35 L70 -35 L85 -10 L85 30 Z" class="nacelle-cover"/> - - <!-- Main shaft --> - <rect x="-90" y="-8" width="35" height="16" rx="2" fill="#888780" stroke="#5F5E5A" stroke-width="0.5"/> - - <!-- Gearbox --> - <rect x="-55" y="-25" width="40" height="45" rx="3" class="gearbox"/> - <text class="tl" x="-35" y="5" text-anchor="middle" fill="#633806">GEAR</text> - - <!-- Generator --> - <rect x="-10" y="-20" width="50" height="38" rx="4" class="generator"/> - <ellipse cx="15" cy="0" rx="15" ry="15" fill="none" stroke="#0C447C" stroke-width="1"/> - <text class="tl" x="15" y="4" text-anchor="middle" fill="#E6F1FB">GEN</text> - - <!-- Brake disc --> - <rect x="45" y="-12" width="8" height="24" rx="1" class="brake"/> - - <!-- Electrical cabinet --> - <rect x="58" y="-25" width="20" height="35" rx="2" fill="#5F5E5A" stroke="#444441" stroke-width="0.5"/> - - <!-- Anemometer on top --> - <line x1="60" y1="-35" x2="60" y2="-50" stroke="#5F5E5A" stroke-width="1"/> - <ellipse cx="60" cy="-52" rx="8" ry="3" fill="#D3D1C7" stroke="#888780" stroke-width="0.5"/> - </g> - - <!-- Nacelle labels --> - <line x1="215" y1="135" x2="130" y2="115" class="leader"/> - <text class="ts" x="125" y="111" text-anchor="end">Gearbox</text> - <text class="tl" x="125" y="123" text-anchor="end">Speed multiplier</text> - - <line x1="285" y1="145" x2="400" y2="125" class="leader"/> - <text class="ts" x="405" y="121">Generator</text> - <text class="tl" x="405" y="133">Converts rotation to electricity</text> - - <line x1="315" y1="155" x2="400" y2="165" class="leader"/> - <text class="ts" x="405" y="161">Brake system</text> - - <line x1="215" y1="200" x2="130" y2="220" class="leader"/> - <text class="ts" x="125" y="216" text-anchor="end">Yaw motors</text> - <text class="tl" x="125" y="228" text-anchor="end">Rotate nacelle to face wind</text> - - <line x1="330" y1="108" x2="400" y2="90" class="leader"/> - <text class="ts" x="405" y="86">Anemometer</text> - <text class="tl" x="405" y="98">Wind speed sensor</text> - - <!-- ===== ROTOR HUB & BLADES ===== --> - - <!-- Hub --> - <g transform="translate(180, 152)"> - <!-- Hub body --> - <ellipse cx="0" cy="0" rx="25" ry="30" class="hub"/> - <!-- Hub nose cone --> - <path d="M-25 -20 Q-50 0 -25 20 Q-30 0 -25 -20" class="hub-cap"/> - - <!-- Blade roots with pitch motors --> - <!-- Blade 1 (up) --> - <g transform="translate(-10, -25) rotate(-80)"> - <ellipse cx="0" cy="0" rx="12" ry="8" class="blade-root"/> - <rect x="-8" y="-5" width="10" height="10" rx="2" class="pitch-motor"/> - </g> - - <!-- Blade 2 (lower left) --> - <g transform="translate(-18, 18) rotate(40)"> - <ellipse cx="0" cy="0" rx="12" ry="8" class="blade-root"/> - <rect x="-8" y="-5" width="10" height="10" rx="2" class="pitch-motor"/> - </g> - - <!-- Blade 3 (lower right) --> - <g transform="translate(5, 22) rotate(160)"> - <ellipse cx="0" cy="0" rx="12" ry="8" class="blade-root"/> - <rect x="-8" y="-5" width="10" height="10" rx="2" class="pitch-motor"/> - </g> - </g> - - <!-- Blade 1 (pointing up-left) --> - <path d="M165 125 Q140 80 130 40 Q125 20 115 15 Q110 18 112 25 Q115 50 125 90 Q140 120 158 128 Z" class="blade" fill="url(#bladeGrad)"/> - - <!-- Blade 2 (pointing down-left) --> - <path d="M158 175 Q120 200 80 230 Q60 245 55 255 Q60 258 68 252 Q95 235 130 210 Q155 190 163 178 Z" class="blade" fill="url(#bladeGrad)"/> - - <!-- Blade 3 (pointing down-right, partially visible) --> - <path d="M188 175 Q195 200 205 230 Q210 250 215 255 Q220 252 218 245 Q212 220 202 195 Q192 175 186 172 Z" class="blade" fill="url(#bladeGrad)"/> - - <!-- Blade labels --> - <line x1="115" y1="35" x2="60" y2="35" class="leader"/> - <text class="ts" x="55" y="31" text-anchor="end">Composite blade</text> - <text class="tl" x="55" y="43" text-anchor="end">Fiberglass/carbon fiber</text> - <text class="tl" x="55" y="55" text-anchor="end">40-80m length each</text> - - <line x1="170" y1="130" x2="130" y2="155" class="leader"/> - <text class="ts" x="85" y="151" text-anchor="end">Pitch motor</text> - <text class="tl" x="85" y="163" text-anchor="end">Adjusts blade angle</text> - - <line x1="180" y1="152" x2="130" y2="180" class="leader"/> - <text class="ts" x="85" y="183" text-anchor="end">Rotor hub</text> - - <!-- ===== LEGEND ===== --> - <g transform="translate(40, 895)"> - <rect x="0" y="-15" width="600" height="30" rx="4" fill="none" stroke="#D3D1C7" stroke-width="0.5"/> - - <rect x="15" y="-5" width="12" height="12" rx="2" class="generator"/> - <text class="tl" x="32" y="5">Generator</text> - - <rect x="95" y="-5" width="12" height="12" rx="2" class="gearbox"/> - <text class="tl" x="112" y="5">Gearbox</text> - - <rect x="170" y="-5" width="12" height="12" rx="2" class="brake"/> - <text class="tl" x="187" y="5">Brake</text> - - <rect x="230" y="-5" width="12" height="12" rx="2" class="yaw"/> - <text class="tl" x="247" y="5">Yaw system</text> - - <rect x="320" y="-5" width="12" height="12" rx="2" class="pitch-motor"/> - <text class="tl" x="337" y="5">Pitch motor</text> - - <line x1="415" y1="1" x2="435" y2="1" class="cable" style="stroke-width:2"/> - <text class="tl" x="440" y="5">Power cable</text> - - <rect x="515" y="-5" width="12" height="12" rx="2" class="transformer"/> - <text class="tl" x="532" y="5">Transformer</text> - </g> - -</svg> -``` - -## CSS Classes - -```css -/* Foundation */ -.concrete { fill: #B4B2A9; stroke: #5F5E5A; stroke-width: 1; } -.concrete-dark { fill: #888780; stroke: #5F5E5A; stroke-width: 1; } -.rebar { stroke: #854F0B; stroke-width: 1.5; fill: none; } -.soil { fill: #8B7355; stroke: #5F5E5A; stroke-width: 0.5; } -.soil-dark { fill: #6B5344; } - -/* Tower */ -.tower { fill: #F1EFE8; stroke: #5F5E5A; stroke-width: 1; } -.tower-inner { fill: #D3D1C7; stroke: #888780; stroke-width: 0.5; } -.tower-section { stroke: #888780; stroke-width: 0.5; stroke-dasharray: 2 4; } -.ladder { stroke: #5F5E5A; stroke-width: 1; fill: none; } -.ladder-rung { stroke: #888780; stroke-width: 0.8; } -.elevator { fill: #E6F1FB; stroke: #185FA5; stroke-width: 0.5; } -.cable { stroke: #E24B4A; stroke-width: 2; fill: none; } -.cable-thin { stroke: #E24B4A; stroke-width: 1.5; fill: none; } - -/* Nacelle */ -.nacelle { fill: #F1EFE8; stroke: #5F5E5A; stroke-width: 1; } -.nacelle-cover { fill: #D3D1C7; stroke: #5F5E5A; stroke-width: 1; } -.gearbox { fill: #BA7517; stroke: #633806; stroke-width: 0.5; } -.generator { fill: #378ADD; stroke: #0C447C; stroke-width: 0.5; } -.brake { fill: #E24B4A; stroke: #791F1F; stroke-width: 0.5; } -.yaw { fill: #5DCAA5; stroke: #085041; stroke-width: 0.5; } -.bearing { fill: #444441; stroke: #2C2C2A; stroke-width: 0.5; } - -/* Rotor */ -.hub { fill: #D3D1C7; stroke: #5F5E5A; stroke-width: 1; } -.hub-cap { fill: #F1EFE8; stroke: #5F5E5A; stroke-width: 1; } -.blade { fill: #F1EFE8; stroke: #888780; stroke-width: 1; } -.blade-root { fill: #D3D1C7; stroke: #5F5E5A; stroke-width: 0.5; } -.pitch-motor { fill: #7F77DD; stroke: #3C3489; stroke-width: 0.5; } - -/* Transformer */ -.transformer { fill: #27500A; stroke: #173404; stroke-width: 1; } -.transformer-fin { fill: #3B6D11; stroke: #27500A; stroke-width: 0.5; } -``` diff --git a/optional-skills/creative/concept-diagrams/references/dashboard-patterns.md b/optional-skills/creative/concept-diagrams/references/dashboard-patterns.md deleted file mode 100644 index 528f185ea7f..00000000000 --- a/optional-skills/creative/concept-diagrams/references/dashboard-patterns.md +++ /dev/null @@ -1,43 +0,0 @@ -# Dashboard Patterns - -Building blocks for UI/dashboard mockups inside a concept diagram — admin panels, monitoring dashboards, control interfaces, status displays. - -## Pattern - -A "screen" is a rounded dark rect inside a lighter "frame" rect, with chart/gauge/indicator elements nested on top. - -```xml -<!-- Monitor frame --> -<rect class="dashboard" x="0" y="0" width="200" height="120" rx="8"/> -<!-- Screen --> -<rect class="screen" x="10" y="10" width="180" height="85" rx="4"/> -<!-- Mini bar chart --> -<rect class="screen-content" x="18" y="18" width="50" height="35" rx="2"/> -<rect class="screen-chart" x="22" y="38" width="8" height="12"/> -<rect class="screen-chart" x="33" y="32" width="8" height="18"/> -<!-- Gauge --> -<circle class="screen-bar" cx="100" cy="35" r="12"/> -<text x="100" y="39" text-anchor="middle" fill="#E8E6DE" style="font-size:8px">78%</text> -<!-- Status indicators --> -<circle cx="35" cy="74" r="6" fill="#97C459"/> <!-- green = ok --> -<circle cx="75" cy="74" r="6" fill="#EF9F27"/> <!-- amber = warning --> -<circle cx="115" cy="74" r="6" fill="#E24B4A"/> <!-- red = alert --> -``` - -## CSS - -```css -.dashboard { fill: #F1EFE8; stroke: #5F5E5A; stroke-width: 1.5; } -.screen { fill: #1a1a18; } -.screen-content { fill: #2C2C2A; } -.screen-chart { fill: #5DCAA5; } -.screen-bar { fill: #7F77DD; } -.screen-alert { fill: #E24B4A; } -``` - -## Tips - -- Dashboard screens stay dark in both light and dark mode — they represent actual monitor glass. -- Keep on-screen text small (`font-size:8px` or `10px`) and high-contrast (near-white fill on dark). -- Use the status triad green/amber/red consistently — OK / warning / alert. -- A single dashboard usually sits on top of an infrastructure hub diagram as a unified view (see `examples/smart-city-infrastructure.md`). diff --git a/optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md b/optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md deleted file mode 100644 index 82c070e57fa..00000000000 --- a/optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md +++ /dev/null @@ -1,144 +0,0 @@ -# Infrastructure Patterns - -Reusable shapes and line styles for infrastructure / systems-integration diagrams (smart cities, IoT networks, industrial systems, multi-domain architectures). - -## Layout pattern: hub-spoke - -- **Central hub**: Hexagon or circle representing the integration platform -- **Radiating connections**: Data lines from hub to each subsystem with connection dots -- **Subsystem sections**: Each system (power, water, transport) in its own region -- **Dashboard on top**: Optional UI mockup showing a unified view (see `dashboard-patterns.md`) - -```xml -<!-- Central hub (hexagon) --> -<polygon class="iot-hex" points="0,-45 39,-22 39,22 0,45 -39,22 -39,-22"/> - -<!-- Data lines with connection dots --> -<path class="data-line" d="M 321 248 L 200 248 L 120 380" stroke-dasharray="4 3"/> -<circle cx="321" cy="248" r="4" fill="#7F77DD"/> -``` - -## Semantic line styles - -Use a dedicated CSS class per subsystem so every diagram reads the same way: - -```css -.data-line { stroke: #7F77DD; stroke-width: 2; fill: none; stroke-dasharray: 4 3; } -.power-line { stroke: #EF9F27; stroke-width: 2; fill: none; } -.water-pipe { stroke: #378ADD; stroke-width: 4; stroke-linecap: round; fill: none; } -.road { stroke: #888780; stroke-width: 8; stroke-linecap: round; fill: none; } -``` - -## Power systems - -**Solar panel (angled):** -```xml -<polygon class="solar-panel" points="0,25 35,8 38,12 3,29"/> -<line class="solar-frame" x1="12" y1="22" x2="24" y2="13"/> -``` - -**Wind turbine:** -```xml -<polygon class="wind-tower" points="20,70 30,70 28,25 22,25"/> -<circle class="wind-hub" cx="25" cy="18" r="5"/> -<ellipse class="wind-blade" cx="25" cy="5" rx="3" ry="13"/> -<ellipse class="wind-blade" cx="14" cy="26" rx="3" ry="13" transform="rotate(-120, 25, 18)"/> -<ellipse class="wind-blade" cx="36" cy="26" rx="3" ry="13" transform="rotate(120, 25, 18)"/> -``` - -**Battery with charge level:** -```xml -<rect class="battery" x="0" y="0" width="45" height="65" rx="5"/> -<rect x="10" y="-6" width="10" height="8" rx="2" fill="#27500A"/> <!-- terminal --> -<rect class="battery-level" x="5" y="12" width="35" height="48" rx="3"/> <!-- fill level --> -``` - -**Power pylon:** -```xml -<polygon class="pylon" points="30,0 35,0 40,60 25,60"/> -<line x1="15" y1="10" x2="45" y2="10" stroke="#5F5E5A" stroke-width="3"/> -<circle cx="18" cy="10" r="3" fill="#FAEEDA" stroke="#854F0B"/> <!-- insulator --> -``` - -## Water systems - -**Reservoir/dam:** -```xml -<polygon class="reservoir-wall" points="0,60 10,0 70,0 80,60"/> -<polygon class="water" points="12,10 68,10 68,55 75,55 75,58 5,58 5,55 12,55"/> -<!-- Wave effect --> -<path d="M 15 25 Q 25 22 35 25 Q 45 28 55 25" fill="none" stroke="#378ADD" opacity="0.5"/> -``` - -**Treatment tank:** -```xml -<ellipse class="treatment-tank" cx="35" cy="45" rx="30" ry="18"/> -<rect class="treatment-tank" x="5" y="20" width="60" height="25"/> -<!-- Bubbles --> -<circle cx="20" cy="32" r="2" fill="#378ADD" opacity="0.6"/> -``` - -**Pipe with joint and valve:** -```xml -<path class="pipe" d="M 80 85 L 110 85"/> -<circle class="pipe-joint" cx="110" cy="85" r="8"/> -<circle class="valve" cx="95" cy="85" r="6"/> -``` - -## Transport systems - -**Road with lane markings:** -```xml -<line class="road" x1="0" y1="50" x2="170" y2="50"/> -<line class="road-mark" x1="10" y1="50" x2="160" y2="50"/> -``` - -**Traffic light:** -```xml -<rect class="traffic-light" x="0" y="0" width="14" height="32" rx="3"/> -<circle class="light-red" cx="7" cy="8" r="4"/> -<circle class="light-off" cx="7" cy="16" r="4"/> -<circle class="light-green" cx="7" cy="24" r="4"/> -``` - -**Bus:** -```xml -<rect class="bus" x="0" y="0" width="55" height="28" rx="6"/> -<rect class="bus-window" x="5" y="5" width="12" height="12" rx="2"/> -<circle cx="14" cy="30" r="6" fill="#2C2C2A"/> <!-- wheel --> -<circle cx="14" cy="30" r="3" fill="#5F5E5A"/> <!-- hubcap --> -``` - -## Full CSS block (add to the host page or inline <style>) - -```css -/* Power */ -.solar-panel { fill: #3C3489; stroke: #534AB7; stroke-width: 0.5; } -.wind-tower { fill: #B4B2A9; stroke: #5F5E5A; stroke-width: 1; } -.wind-blade { fill: #F1EFE8; stroke: #888780; stroke-width: 0.5; } -.battery { fill: #27500A; stroke: #3B6D11; stroke-width: 1.5; } -.battery-level { fill: #97C459; } -.power-line { stroke: #EF9F27; stroke-width: 2; fill: none; } - -/* Water */ -.reservoir-wall { fill: #B4B2A9; stroke: #5F5E5A; stroke-width: 1; } -.water { fill: #85B7EB; stroke: #378ADD; stroke-width: 0.5; } -.pipe { fill: none; stroke: #378ADD; stroke-width: 4; stroke-linecap: round; } -.pipe-joint { fill: #185FA5; stroke: #0C447C; stroke-width: 1; } -.valve { fill: #0C447C; stroke: #185FA5; stroke-width: 1; } - -/* Transport */ -.road { stroke: #888780; stroke-width: 8; fill: none; stroke-linecap: round; } -.road-mark { stroke: #F1EFE8; stroke-width: 1; stroke-dasharray: 6 4; fill: none; } -.traffic-light { fill: #444441; stroke: #2C2C2A; stroke-width: 0.5; } -.light-red { fill: #E24B4A; } -.light-green { fill: #97C459; } -.light-off { fill: #2C2C2A; } -.bus { fill: #E1F5EE; stroke: #0F6E56; stroke-width: 1.5; } -``` - -## Reference examples - -- `examples/smart-city-infrastructure.md` — hub-spoke with multiple subsystems -- `examples/electricity-grid-flow.md` — voltage hierarchy, flow markers -- `examples/wind-turbine-structure.md` — cross-section with legend diff --git a/optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md b/optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md deleted file mode 100644 index 1a999203f07..00000000000 --- a/optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md +++ /dev/null @@ -1,42 +0,0 @@ -# Physical Shape Cookbook - -Guidance for drawing physical objects (vehicles, buildings, hardware, mechanical systems, anatomy) — when rectangles aren't enough. - -## Shape selection - -| Physical form | SVG element | Example use | -|---------------|-------------|-------------| -| Curved bodies | `<path>` with Q/C curves | Fuselage, tanks, pipes | -| Tapered/angular shapes | `<polygon>` | Wings, fins, wedges | -| Cylindrical/round | `<ellipse>`, `<circle>` | Engines, wheels, buttons | -| Linear structures | `<line>` | Struts, beams, connections | -| Internal sections | `<rect>` inside parent | Compartments, rooms | -| Dashed boundaries | `stroke-dasharray` | Hidden parts, fuel tanks | - -## Layering approach - -1. Draw outer structure first (fuselage, frame, hull) -2. Add internal sections on top (cabins, compartments) -3. Add detail elements (engines, wheels, controls) -4. Add leader lines with labels - -## Semantic CSS classes (instead of c-* ramps) - -For physical diagrams, define component-specific classes directly rather than applying `c-*` color classes. This makes each part self-documenting and lets you keep a restrained palette: - -```css -.fuselage { fill: #F1EFE8; stroke: #5F5E5A; stroke-width: 1; } -.wing { fill: #E6F1FB; stroke: #185FA5; stroke-width: 1; } -.engine { fill: #FAECE7; stroke: #993C1D; stroke-width: 1; } -``` - -Add these to a local `<style>` inside the SVG (or extend the host page's `<style>` block). The light-mode/dark-mode pattern still works — use the CSS variables from the template (`var(--bg-secondary)`, `var(--border)`, `var(--text-primary)`) if you want dark-mode awareness. - -## Reference examples - -Look at these example files for working physical-diagram patterns: - -- `examples/commercial-aircraft-structure.md` — fuselage curves + tapered wings + ellipse engines -- `examples/wind-turbine-structure.md` — underground foundation, tubular tower, nacelle cutaway -- `examples/smartphone-layer-anatomy.md` — exploded-view stack with alternating labels -- `examples/apartment-floor-plan-conversion.md` — walls, doors, windows, proposed changes diff --git a/optional-skills/creative/concept-diagrams/templates/template.html b/optional-skills/creative/concept-diagrams/templates/template.html deleted file mode 100644 index 2b48e08d166..00000000000 --- a/optional-skills/creative/concept-diagrams/templates/template.html +++ /dev/null @@ -1,174 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> -<meta charset="UTF-8"> -<meta name="viewport" content="width=device-width, initial-scale=1.0"> -<title>Concept Diagram - - - -
-

-

- -
- - diff --git a/optional-skills/creative/kanban-video-orchestrator/SKILL.md b/optional-skills/creative/kanban-video-orchestrator/SKILL.md index c5ac2a8c96e..f323406300b 100644 --- a/optional-skills/creative/kanban-video-orchestrator/SKILL.md +++ b/optional-skills/creative/kanban-video-orchestrator/SKILL.md @@ -8,7 +8,7 @@ platforms: [linux, macos, windows] metadata: hermes: tags: [video, kanban, multi-agent, orchestration, production-pipeline] - related_skills: [kanban-orchestrator, kanban-worker, ascii-video, manim-video, p5js, comfyui, touchdesigner-mcp, blender-mcp, pixel-art, ascii-art, songwriting-and-ai-music, heartmula, songsee, spotify, youtube-content, claude-design, excalidraw, architecture-diagram, concept-diagrams, baoyu-comic, baoyu-infographic, humanizer, gif-search, meme-generation] + related_skills: [kanban-orchestrator, kanban-worker, ascii-video, manim-video, p5js, comfyui, touchdesigner-mcp, blender-mcp, pixel-art, ascii-art, songwriting-and-ai-music, heartmula, songsee, spotify, youtube-content, claude-design, excalidraw, html-artifact, baoyu-comic, baoyu-infographic, humanizer, gif-search, meme-generation] credits: | The single-project workspace layout, profile-config patching pattern, SOUL.md-per-profile model, TEAM.md task-graph convention, and diff --git a/optional-skills/creative/kanban-video-orchestrator/references/intake.md b/optional-skills/creative/kanban-video-orchestrator/references/intake.md index d290b606f49..1f817da020b 100644 --- a/optional-skills/creative/kanban-video-orchestrator/references/intake.md +++ b/optional-skills/creative/kanban-video-orchestrator/references/intake.md @@ -96,8 +96,7 @@ texture inside the final scene. - **Terminal-only or with GUI?** - **Voiceover for narration?** - **Diagram support needed?** — Often these benefit from a diagram skill - alongside the screen-capture/render step (`excalidraw`, - `architecture-diagram`, `concept-diagrams`) + alongside the screen-capture/render step (`excalidraw`, `html-artifact`) ### ASCII / terminal art diff --git a/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md b/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md index 95eaeb33b66..c5e15c06f4b 100644 --- a/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md +++ b/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md @@ -59,7 +59,7 @@ local skills. - **Toolsets:** kanban, terminal, file - **Skills:** `kanban-worker` plus any project-specific design skill — - `claude-design` (UI/web), `sketch` (quick mockup variants), + `claude-design` (UI/web), `html-artifact` (quick mockup variants, explainers, diagrams), `popular-web-designs` (matching known web aesthetic), `pixel-art` (retro), `ascii-art` (terminal/retro), `excalidraw` (hand-drawn frames), `design-md` (text-based design docs) @@ -72,8 +72,7 @@ film and music video. Often pairs with a diagramming tool. - **Toolsets:** kanban, file - **Skills:** `kanban-worker` plus a diagram skill — `excalidraw` (sketch), - `architecture-diagram` (technical/system), `concept-diagrams` (educational/ - scientific) + `html-artifact` (technical/system + educational/scientific diagrams) - **Outputs:** `storyboard.md` with one row per scene/shot, optional storyboard sketches diff --git a/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md b/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md index b5e59c31478..2f27ffc41e7 100644 --- a/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +++ b/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md @@ -30,10 +30,8 @@ called from the terminal toolset; they don't appear in `always_load`. | `claude-design` | Design one-off HTML artifacts (landing, deck, prototype) | Concept artist for product video style frames; storyboarder for UI-heavy content | | `design-md` | Design markdown docs | Concept artist documenting visual specs | | `popular-web-designs` | Reference patterns for popular web designs | Concept artist; cinematographer when matching a known UI aesthetic | -| `sketch` | Throwaway HTML mockups (2-3 design variants to compare) | Concept artist exploring directions; storyboarder for UI flows | | `excalidraw` | Excalidraw-style hand-drawn diagrams | Storyboarder; concept artist for sketch-style frames | -| `architecture-diagram` | Software architecture diagrams | Storyboarder for technical content; explainer scenes about systems | -| `concept-diagrams` *(optional)* | Flat, minimal SVG diagrams (educational visual language; physics, chemistry, math, anatomy, etc.) | Renderer / storyboarder for explainer scenes with clean educational diagrams | +| `html-artifact` | Self-contained HTML artifacts: throwaway mockup variants, explainers, dark-tech architecture + educational SVG diagrams | Concept artist exploring directions; storyboarder for UI flows + technical/educational explainer scenes | | `pretext` | Mathematical/scientific content authoring | Writer / cinematographer for technical-explainer pretexts | | `creative-ideation` | Constraint-driven project ideation | Director / cinematographer when the brief is wide-open and needs framing | | `humanizer` | Strip AI-isms from text, add real voice | Writer / copywriter post-process to avoid AI-tells in scripts and VO copy | diff --git a/skills/creative/architecture-diagram/SKILL.md b/skills/creative/architecture-diagram/SKILL.md deleted file mode 100644 index 2c813c53c13..00000000000 --- a/skills/creative/architecture-diagram/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: architecture-diagram -description: "Dark-themed SVG architecture/cloud/infra diagrams as HTML." -version: 1.0.0 -author: Cocoon AI (hello@cocoon-ai.com), ported by Hermes Agent -license: MIT -dependencies: [] -platforms: [linux, macos, windows] -metadata: - hermes: - tags: [architecture, diagrams, SVG, HTML, visualization, infrastructure, cloud] - related_skills: [concept-diagrams, excalidraw] ---- - -# Architecture Diagram Skill - -Generate professional, dark-themed technical architecture diagrams as standalone HTML files with inline SVG graphics. No external tools, no API keys, no rendering libraries — just write the HTML file and open it in a browser. - -## Scope - -**Best suited for:** -- Software system architecture (frontend / backend / database layers) -- Cloud infrastructure (VPC, regions, subnets, managed services) -- Microservice / service-mesh topology -- Database + API map, deployment diagrams -- Anything with a tech-infra subject that fits a dark, grid-backed aesthetic - -**Look elsewhere first for:** -- Physics, chemistry, math, biology, or other scientific subjects -- Physical objects (vehicles, hardware, anatomy, cross-sections) -- Floor plans, narrative journeys, educational / textbook-style visuals -- Hand-drawn whiteboard sketches (consider `excalidraw`) -- Animated explainers (consider an animation skill) - -If a more specialized skill is available for the subject, prefer that. If none fits, this skill can also serve as a general SVG diagram fallback — the output will just carry the dark tech aesthetic described below. - -Based on [Cocoon AI's architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator) (MIT). - -## Workflow - -1. User describes their system architecture (components, connections, technologies) -2. Generate the HTML file following the design system below -3. Save with `write_file` to a `.html` file (e.g. `~/architecture-diagram.html`) -4. User opens in any browser — works offline, no dependencies - -### Output Location - -Save diagrams to a user-specified path, or default to the current working directory: -``` -./[project-name]-architecture.html -``` - -### Preview - -After saving, suggest the user open it: -```bash -# macOS -open ./my-architecture.html -# Linux -xdg-open ./my-architecture.html -``` - -## Design System & Visual Language - -### Color Palette (Semantic Mapping) - -Use specific `rgba` fills and hex strokes to categorize components: - -| Component Type | Fill (rgba) | Stroke (Hex) | -| :--- | :--- | :--- | -| **Frontend** | `rgba(8, 51, 68, 0.4)` | `#22d3ee` (cyan-400) | -| **Backend** | `rgba(6, 78, 59, 0.4)` | `#34d399` (emerald-400) | -| **Database** | `rgba(76, 29, 149, 0.4)` | `#a78bfa` (violet-400) | -| **AWS/Cloud** | `rgba(120, 53, 15, 0.3)` | `#fbbf24` (amber-400) | -| **Security** | `rgba(136, 19, 55, 0.4)` | `#fb7185` (rose-400) | -| **Message Bus** | `rgba(251, 146, 60, 0.3)` | `#fb923c` (orange-400) | -| **External** | `rgba(30, 41, 59, 0.5)` | `#94a3b8` (slate-400) | - -### Typography & Background -- **Font:** JetBrains Mono (Monospace), loaded from Google Fonts -- **Sizes:** 12px (Names), 9px (Sublabels), 8px (Annotations), 7px (Tiny labels) -- **Background:** Slate-950 (`#020617`) with a subtle 40px grid pattern - -```svg - - - - -``` - -## Technical Implementation Details - -### Component Rendering -Components are rounded rectangles (`rx="6"`) with 1.5px strokes. To prevent arrows from showing through semi-transparent fills, use a **double-rect masking technique**: -1. Draw an opaque background rect (`#0f172a`) -2. Draw the semi-transparent styled rect on top - -### Connection Rules -- **Z-Order:** Draw arrows *early* in the SVG (after the grid) so they render behind component boxes -- **Arrowheads:** Defined via SVG markers -- **Security Flows:** Use dashed lines in rose color (`#fb7185`) -- **Boundaries:** - - *Security Groups:* Dashed (`4,4`), rose color - - *Regions:* Large dashed (`8,4`), amber color, `rx="12"` - -### Spacing & Layout Logic -- **Standard Height:** 60px (Services); 80-120px (Large components) -- **Vertical Gap:** Minimum 40px between components -- **Message Buses:** Must be placed *in the gap* between services, not overlapping them -- **Legend Placement:** **CRITICAL.** Must be placed outside all boundary boxes. Calculate the lowest Y-coordinate of all boundaries and place the legend at least 20px below it. - -## Document Structure - -The generated HTML file follows a four-part layout: -1. **Header:** Title with a pulsing dot indicator and subtitle -2. **Main SVG:** The diagram contained within a rounded border card -3. **Summary Cards:** A grid of three cards below the diagram for high-level details -4. **Footer:** Minimal metadata - -### Info Card Pattern -```html -
-
-
-

Title

-
-
    -
  • • Item one
  • -
  • • Item two
  • -
-
-``` - -## Output Requirements -- **Single File:** One self-contained `.html` file -- **No External Dependencies:** All CSS and SVG must be inline (except Google Fonts) -- **No JavaScript:** Use pure CSS for any animations (like pulsing dots) -- **Compatibility:** Must render correctly in any modern web browser - -## Template Reference - -Load the full HTML template for the exact structure, CSS, and SVG component examples: - -``` -skill_view(name="architecture-diagram", file_path="templates/template.html") -``` - -The template contains working examples of every component type (frontend, backend, database, cloud, security), arrow styles (standard, dashed, curved), security groups, region boundaries, and the legend — use it as your structural reference when generating diagrams. diff --git a/skills/creative/architecture-diagram/templates/template.html b/skills/creative/architecture-diagram/templates/template.html deleted file mode 100644 index f5b32fbe7fd..00000000000 --- a/skills/creative/architecture-diagram/templates/template.html +++ /dev/null @@ -1,319 +0,0 @@ - - - - - - [PROJECT NAME] Architecture Diagram - - - - -
- -
-
-
-

[PROJECT NAME] Architecture

-
-

[Subtitle description]

-
- - -
- - - - - - - - - - - - - - - - - - - Users - Browser/Mobile - - - - Auth Provider - OAuth 2.0 - - - - AWS Region: us-west-2 - - - - CloudFront - CDN - - - - S3 Buckets - • bucket-one - • bucket-two - • bucket-three - OAI Protected - - - - sg-name :port - - - - Load Balancer - HTTPS :443 - - - - API Server - FastAPI :8000 - - - - Database - PostgreSQL - - - - Frontend - React + TypeScript - Additional detail - More info - domain.example.com - - - - - - HTTPS - - - - - - - OAI - - - - - TLS - - - - JWT + PKCE - - - Legend - - - Frontend - - - Backend - - - Cloud Service - - - Database - - - Security - - - Auth Flow - - - Security Group - -
- - -
-
-
-
-

Card Title 1

-
-
    -
  • • Item one
  • -
  • • Item two
  • -
  • • Item three
  • -
  • • Item four
  • -
-
- -
-
-
-

Card Title 2

-
-
    -
  • • Item one
  • -
  • • Item two
  • -
  • • Item three
  • -
  • • Item four
  • -
-
- -
-
-
-

Card Title 3

-
-
    -
  • • Item one
  • -
  • • Item two
  • -
  • • Item three
  • -
  • • Item four
  • -
-
-
- - - -
- - diff --git a/skills/creative/claude-design/SKILL.md b/skills/creative/claude-design/SKILL.md index 673d1ff827a..d61dbcb2f00 100644 --- a/skills/creative/claude-design/SKILL.md +++ b/skills/creative/claude-design/SKILL.md @@ -8,7 +8,7 @@ platforms: [linux, macos, windows] metadata: hermes: tags: [design, html, prototype, ux, ui, creative, artifact, deck, motion, design-system] - related_skills: [design-md, popular-web-designs, excalidraw, architecture-diagram] + related_skills: [html-artifact, design-md, popular-web-designs, excalidraw] --- # Claude Design for CLI/API Agents @@ -19,19 +19,21 @@ The goal is to preserve Claude Design's useful design behavior and taste while r **Before starting, check for other web-design skills like `popular-web-designs` (ready-to-paste design systems for Stripe, Linear, Vercel, Notion, etc.) and `design-md` (Google's DESIGN.md token spec format).** If the user wants a known brand's look, load `popular-web-designs` alongside this one and let it supply the visual vocabulary. If the deliverable is a token spec file rather than a rendered artifact, use `design-md` instead. Full decision table below. -## When To Use This Skill vs `popular-web-designs` vs `design-md` +## When To Use This Skill vs `html-artifact` vs `popular-web-designs` vs `design-md` -Hermes has three design-related skills under `skills/creative/`. They do different jobs — load the right one (or combine them): +Several skills produce HTML — they do different jobs. Load the right one (or combine them): | Skill | What it gives you | Use when the user wants... | |---|---|---| -| **claude-design** (this one) | Design *process and taste* — how to scope a brief, gather context, produce variants, verify a local HTML artifact, avoid AI-design slop | a from-scratch designed artifact (landing page, prototype, deck, component lab, motion study) with no specific brand or token system dictated | +| **claude-design** (this one) | Visual design *process and taste* — how to scope a brief, gather context, produce variants, verify a local HTML artifact, avoid AI-design slop | a from-scratch *designed* artifact (landing page, prototype, deck, component lab, motion study) where the look itself is the point and no specific brand or token system is dictated | +| **html-artifact** | A house style for *information* artifacts — explainers, plans, reports, code reviews, technical/educational diagrams, throwaway editors | to *explain / plan / report / diagram / review* something as a shareable HTML page — the content is the point, not bespoke visual design | | **popular-web-designs** | 54 ready-to-paste design systems — exact colors, typography, components, CSS values for sites like Stripe, Linear, Vercel, Notion, Airbnb | "make it look like Stripe / Linear / Vercel", a page styled after a known brand, or a visual starting point pulled from a real product | | **design-md** | Google's DESIGN.md spec format — author/validate/diff/export design-token files, WCAG contrast checking, Tailwind/DTCG export | a formal, persistent, machine-readable design-system *spec file* (tokens + rationale) that lives in a repo and gets consumed by agents over time | Rule of thumb: -- **Process + taste, one-off artifact** → claude-design +- **Bespoke visual design, taste-driven artifact** → claude-design +- **Explain / plan / report / diagram as a shareable page** → html-artifact - **Match a known brand's look** → popular-web-designs (and let claude-design drive the process) - **Author the tokens spec itself** → design-md diff --git a/skills/creative/design-md/SKILL.md b/skills/creative/design-md/SKILL.md index 6604be1979d..e0534d9ba72 100644 --- a/skills/creative/design-md/SKILL.md +++ b/skills/creative/design-md/SKILL.md @@ -8,7 +8,7 @@ platforms: [linux, macos, windows] metadata: hermes: tags: [design, design-system, tokens, ui, accessibility, wcag, tailwind, dtcg, google] - related_skills: [popular-web-designs, claude-design, excalidraw, architecture-diagram] + related_skills: [popular-web-designs, claude-design, excalidraw, html-artifact] --- # DESIGN.md Skill diff --git a/skills/creative/html-artifact/SKILL.md b/skills/creative/html-artifact/SKILL.md new file mode 100644 index 00000000000..4883e1ff4c1 --- /dev/null +++ b/skills/creative/html-artifact/SKILL.md @@ -0,0 +1,184 @@ +--- +name: html-artifact +description: Build self-contained HTML files to explain, plan, or review. +version: 1.0.0 +author: Anthropic (html-effectiveness gallery, MIT), adapted for Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [html, artifact, explainer, plan, report, code-review, diagram, svg, design, prototype, editor] + related_skills: [claude-design, popular-web-designs, design-md, excalidraw, p5js] +--- + +# HTML Artifact Skill + +Produce a single self-contained `.html` file — no build step, no dependencies, no +CDN — whenever the deliverable is something a human should *read, share, or poke at*: +a concept explainer, an implementation plan, a status/incident report, a code-review +walkthrough, a technical or educational diagram, a set of design variants, or a +throwaway editor that exports its result back to you. + +HTML beats Markdown once a doc has color, layout, diagrams, tables, code, or +interaction. It opens in any browser, shares as a link, stays readable past 100 +lines, and can carry SVG diagrams and live controls Markdown can't. Default to an +HTML artifact when the user says "make an HTML file/artifact", or asks you to +*explain how X works*, *write up a plan/PR/report*, *diagram* something, *compare* +options, or *prototype* an interaction — even when they don't say "HTML". + +## Why this skill exists (and what it replaced) + +This skill **supersedes** three former skills — `sketch` (throwaway multi-variant +HTML mockups), `architecture-diagram` (dark-tech infra SVG), and `concept-diagrams` +(educational SVG). They were consolidated for a concrete reason: all three emitted +the *same artifact* — a single self-contained HTML file with inline CSS/SVG — and +overlapped heavily (three "diagram" skills, two "compare variants" paths, no shared +token system). Folding them into one mode-switched skill removes the +which-one-do-I-load ambiguity and gives every output the same house style, while +keeping each skill's unique value: the fidelity dial + verify loop (from `sketch`), +the dark infra aesthetic (from `architecture-diagram`), and the 9-ramp educational +system + archetype library (from `concept-diagrams`). + +The consolidation is footprint-safe: this skill has **zero dependencies** (no Node, +FFmpeg, Chromium, or pip packages — it authors plain HTML/CSS/SVG), so even though it +ships **bundled** (active by default) where `concept-diagrams` was optional, the only +always-in-context cost is this skill's one-line description. All references, +templates, and the example gallery load on demand. `concept-diagrams` was optional +because it was niche, not because it had an install cost — promoting that capability +into a general-purpose, zero-dep bundled skill is the right home for it. Diagram-style +work with a *real* install cost (e.g. `hyperframes`: Node + FFmpeg + Chromium) +deliberately stays optional and is **not** folded in here. + +Use a different skill when: matching a known brand's look → `popular-web-designs`; a +formal design-token spec file → `design-md`; a *bespoke visually-designed* artifact +where the look itself is the point → `claude-design`; hand-drawn/whiteboard +`.excalidraw` files → `excalidraw`; generative/animated canvas art → `p5js`. This +skill is for everything else that ships as a readable, shareable HTML page. + +## Reference files (load on demand) + +- `references/house-style.md` — the canonical `:root` token block, type system, + card/table/callout/code-block patterns. **Read this before authoring any artifact.** +- `references/examples.md` — 20 complete reference HTML files (Anthropic's + html-effectiveness gallery, MIT) keyed to each mode, plus the script to fetch them. + Read/fetch one that matches your task to calibrate the house style from a full example. +- `references/svg-diagrams.md` — hand-authored inline SVG: arrow markers, node + groups, decision diamonds, edge semantics, coordinate-grid discipline. Read for + any flowchart / architecture / concept diagram. +- `references/concept-archetypes.md` — the 9-ramp educational color system + a + library of diagram archetypes (timeline, tree, quadrant, layered stack, + before/after, hub-spoke, cross-section). Read for educational / non-software visuals. +- `references/dark-tech.md` — the dark "infra" token variant (carries the old + architecture-diagram aesthetic). Read for cloud/infra/system architecture diagrams. +- `references/throwaway-editors.md` — the single-file editor recipe and the + copy-to-clipboard export pattern that survives `file://`. Read when the artifact + needs interactive controls that export state back to a prompt. +- `references/fidelity-and-verify.md` — the throwaway↔presentation fidelity dial, + the multi-variant comparison layout, and the mandatory browser-vision verify loop. + +## Templates + +- `templates/base.html` — document scaffold with the house-style ` + + +
+

Section · Context

+

Artifact Title

+

One-sentence framing of what this artifact is and who it's for.

+ +

Overview

+

Body copy. Keep paragraphs readable; let layout carry structure.

+ +
+

Metric

42
+

Metric

7
+

Needs attention

3
+

Metric

98%
+
+ +
Note. Use callouts for the one thing the reader must not miss.
+ + + +
+ + diff --git a/skills/creative/html-artifact/templates/diagram.html b/skills/creative/html-artifact/templates/diagram.html new file mode 100644 index 00000000000..93522119d36 --- /dev/null +++ b/skills/creative/html-artifact/templates/diagram.html @@ -0,0 +1,127 @@ + + + + + +Diagram + + + + + +
+

+

+ + +
+ + diff --git a/skills/creative/html-artifact/templates/editor.html b/skills/creative/html-artifact/templates/editor.html new file mode 100644 index 00000000000..88ee378d7a3 --- /dev/null +++ b/skills/creative/html-artifact/templates/editor.html @@ -0,0 +1,120 @@ + + + + + +Editor + + + + +
+

Throwaway editor

+

Toggle what ships, copy the result

+
+
+ + +
+
+ + + + diff --git a/skills/creative/pretext/SKILL.md b/skills/creative/pretext/SKILL.md index 78f5ab2d959..c526d000ddd 100644 --- a/skills/creative/pretext/SKILL.md +++ b/skills/creative/pretext/SKILL.md @@ -8,7 +8,7 @@ platforms: [linux, macos, windows] metadata: hermes: tags: [creative-coding, typography, pretext, ascii-art, canvas, generative, text-layout, kinetic-typography] - related_skills: [p5js, claude-design, excalidraw, architecture-diagram] + related_skills: [p5js, claude-design, excalidraw, html-artifact] --- # Pretext Creative Demos diff --git a/skills/creative/sketch/SKILL.md b/skills/creative/sketch/SKILL.md deleted file mode 100644 index 6e49585acd4..00000000000 --- a/skills/creative/sketch/SKILL.md +++ /dev/null @@ -1,218 +0,0 @@ ---- -name: sketch -description: "Throwaway HTML mockups: 2-3 design variants to compare." -version: 1.0.0 -author: Hermes Agent (adapted from gsd-build/get-shit-done) -license: MIT -platforms: [linux, macos, windows] -metadata: - hermes: - tags: [sketch, mockup, design, ui, prototype, html, variants, exploration, wireframe, comparison] - related_skills: [spike, claude-design, popular-web-designs, excalidraw] ---- - -# Sketch - -Use this skill when the user wants to **see a design direction before committing** to one — exploring a UI/UX idea as disposable HTML mockups. The point is to generate 2-3 interactive variants so the user can compare visual directions side-by-side, not to produce shippable code. - -Load this when the user says things like "sketch this screen", "show me what X could look like", "compare layout A vs B", "give me 2-3 takes on this UI", "let me see some variants", "mockup this before I build". - -## When NOT to use this - -- User wants a production component — use `claude-design` or build it properly -- User wants a polished one-off HTML artifact (landing page, deck) — `claude-design` -- User wants a diagram — `excalidraw`, `architecture-diagram` -- The design is already locked — just build it - -## If the user has the full GSD system installed - -If `gsd-sketch` shows up as a sibling skill (installed via `npx get-shit-done-cc --hermes`), prefer **`gsd-sketch`** for the full workflow: persistent `.planning/sketches/` with MANIFEST, frontier mode analysis, consistency audits across past sketches, and integration with the rest of GSD. This skill is the lightweight standalone version — one-off sketching without the state machinery. - -## Core method - -``` -intake → variants → head-to-head → pick winner (or iterate) -``` - -### 1. Intake (skip if the user already gave you enough) - -Before generating variants, get three things — one question at a time, not all at once: - -1. **Feel.** "What should this feel like? Adjectives, emotions, a vibe." — *"calm, editorial, like Linear"* tells you more than *"minimal"*. -2. **References.** "What apps, sites, or products capture the feel you're imagining?" — actual references beat abstract descriptions. -3. **Core action.** "What's the single most important thing a user does on this screen?" — the variants should all serve this well; if they don't, they're just decoration. - -Reflect each answer briefly before the next question. If the user already gave you all three upfront, skip straight to variants. - -### 2. Variants (2-3, never 1, rarely 4+) - -Produce **2-3 variants** in one go. Each variant is a complete, standalone HTML file. Don't describe variants — build them. The point is comparison. - -Each variant should take a **different design stance**, not different pixel values. Three good variant axes: - -- **Density:** compact / airy / ultra-dense (pick two contrasting poles) -- **Emphasis:** content-first / action-first / tool-first -- **Aesthetic:** editorial / utilitarian / playful -- **Layout:** single-column / sidebar / split-pane -- **Grounding:** card-based / bare-content / document-style - -Pick one axis and pull apart from it. Two variants that differ only in accent color are wasted effort — the user can't distinguish them. - -**Variant naming:** describe the stance, not the number. - -``` -sketches/ -├── 001-calm-editorial/ -│ ├── index.html -│ └── README.md -├── 001-utilitarian-dense/ -│ ├── index.html -│ └── README.md -└── 001-playful-split/ - ├── index.html - └── README.md -``` - -### 3. Make them real HTML - -Each variant is a **single self-contained HTML file**: - -- Inline ` -``` - -### 4. Variant README - -Each variant's `README.md` answers: - -```markdown -## Variant: {stance name} - -### Design stance -One sentence on the principle driving this variant. - -### Key choices -- Layout: ... -- Typography: ... -- Color: ... -- Interaction: ... - -### Trade-offs -- Strong at: ... -- Weak at: ... - -### Best for -- The kind of user or use case this variant actually serves -``` - -### 5. Head-to-head - -After all variants are built, present them as a comparison. Don't just list — **opinionate**: - -```markdown -## Three takes on the home screen - -| Dimension | Calm editorial | Utilitarian dense | Playful split | -|-----------|----------------|-------------------|---------------| -| Density | Low | High | Medium | -| Primary action visibility | Low | High | Medium | -| Scan-ability | High | Medium | Low | -| Feel | Calm, trusted | Sharp, tool-like | Inviting, energetic | - -**My take:** Utilitarian dense for power users, calm editorial for content-forward audiences. Playful split is weakest — tries to do both and commits to neither. -``` - -Let the user pick a winner, or combine two into a hybrid, or ask for another round. - -## Theming (when the project has a visual identity) - -If the user has an existing theme (colors, fonts, tokens), put shared tokens in `sketches/themes/tokens.css` and `@import` them in each variant. Keep tokens minimal: - -```css -/* sketches/themes/tokens.css */ -:root { - --color-bg: #fafafa; - --color-fg: #1a1a1a; - --color-accent: #0066ff; - --color-muted: #666; - --radius: 8px; - --font-display: "Inter", sans-serif; - --font-body: -apple-system, BlinkMacSystemFont, sans-serif; -} -``` - -Don't over-tokenize a throwaway sketch — three colors and one font is usually enough. - -## Interactivity bar - -A sketch is interactive enough when the user can: - -1. **Click a primary action** and something visible happens (state change, modal, toast, navigation feint) -2. **See one meaningful state transition** (filter a list, toggle a mode, open/close a panel) -3. **Hover recognizable affordances** (buttons, rows, tabs) - -More than that is over-engineering a throwaway. Less than that is a screenshot. - -## Frontier mode (picking what to sketch next) - -If sketches already exist and the user says "what should I sketch next?": - -- **Consistency gaps** — two winning variants from different sketches made independent choices that haven't been composed together yet -- **Unsketched screens** — referenced but never explored -- **State coverage** — happy path sketched, but not empty / loading / error / 1000-items -- **Responsive gaps** — validated at one viewport; does it hold at mobile / ultrawide? -- **Interaction patterns** — static layouts exist; transitions, drag, scroll behavior don't - -Propose 2-4 named candidates. Let the user pick. - -## Output - -- Create `sketches/` (or `.planning/sketches/` if the user is using GSD conventions) in the repo root -- One subdir per variant: `NNN-stance-name/index.html` + `README.md` -- Tell the user how to open them: `open sketches/001-calm-editorial/index.html` on macOS, `xdg-open` on Linux, `start` on Windows -- Keep variants disposable — a sketch that you felt the need to preserve should be promoted into real project code, not curated as an asset - -**Typical tool sequence for one variant:** - -``` -terminal("mkdir -p sketches/001-calm-editorial") -write_file("sketches/001-calm-editorial/index.html", "...") -write_file("sketches/001-calm-editorial/README.md", "## Variant: Calm editorial\n...") -browser_navigate(url="file://$(pwd)/sketches/001-calm-editorial/index.html") -browser_vision(question="How does this look? Any obvious layout issues?") -``` - -Repeat for each variant, then present the comparison table. - -## Attribution - -Adapted from the GSD (Get Shit Done) project's `/gsd-sketch` workflow — MIT © 2025 Lex Christopherson ([gsd-build/get-shit-done](https://github.com/gsd-build/get-shit-done)). The full GSD system ships persistent sketch state, theme/variant pattern references, and consistency-audit workflows; install with `npx get-shit-done-cc --hermes --global`. diff --git a/skills/software-development/spike/SKILL.md b/skills/software-development/spike/SKILL.md index 2a980f0ade9..313cbe7fb9c 100644 --- a/skills/software-development/spike/SKILL.md +++ b/skills/software-development/spike/SKILL.md @@ -8,7 +8,7 @@ platforms: [linux, macos, windows] metadata: hermes: tags: [spike, prototype, experiment, feasibility, throwaway, exploration, research, planning, mvp, proof-of-concept] - related_skills: [sketch, subagent-driven-development, plan] + related_skills: [html-artifact, subagent-driven-development, plan] --- # Spike diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index 4e2b2524fe2..a9e27dfd90e 100644 --- a/website/docs/reference/optional-skills-catalog.md +++ b/website/docs/reference/optional-skills-catalog.md @@ -58,7 +58,6 @@ hermes skills uninstall | [**baoyu-article-illustrator**](/docs/user-guide/skills/optional/creative/creative-baoyu-article-illustrator) | Article illustrations: type × style × palette consistency. | | [**baoyu-comic**](/docs/user-guide/skills/optional/creative/creative-baoyu-comic) | Knowledge comics (知识漫画): educational, biography, tutorial. | | [**blender-mcp**](/docs/user-guide/skills/optional/creative/creative-blender-mcp) | Control Blender directly from Hermes via socket connection to the blender-mcp addon. Create 3D objects, materials, animations, and run arbitrary Blender Python (bpy) code. Use when user wants to create or modify anything in Blender. | -| [**concept-diagrams**](/docs/user-guide/skills/optional/creative/creative-concept-diagrams) | Generate flat, minimal light/dark-aware SVG diagrams as standalone HTML files, using a unified educational visual language with 9 semantic color ramps, sentence-case typography, and automatic dark mode. Best suited for educational and no... | | [**ideation**](/docs/user-guide/skills/optional/creative/creative-creative-ideation) | Generate project ideas via creative constraints. | | [**hyperframes**](/docs/user-guide/skills/optional/creative/creative-hyperframes) | Create HTML-based video compositions, animated title cards, social overlays, captioned talking-head videos, audio-reactive visuals, and shader transitions using HyperFrames. HTML is the source of truth for video. Use when the user wants... | | [**kanban-video-orchestrator**](/docs/user-guide/skills/optional/creative/creative-kanban-video-orchestrator) | Plan, set up, and monitor a multi-agent video production pipeline backed by Hermes Kanban. Use when the user wants to make ANY video — narrative film, product/marketing, music video, explainer, ASCII/terminal art, abstract/generative loo... | diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md index 5ccb1f5f5ca..3ae519a07f8 100644 --- a/website/docs/reference/skills-catalog.md +++ b/website/docs/reference/skills-catalog.md @@ -35,7 +35,6 @@ If a skill is missing from this list but present in the repo, the catalog is reg | Skill | Description | Path | |-------|-------------|------| -| [`architecture-diagram`](/docs/user-guide/skills/bundled/creative/creative-architecture-diagram) | Dark-themed SVG architecture/cloud/infra diagrams as HTML. | `creative/architecture-diagram` | | [`ascii-art`](/docs/user-guide/skills/bundled/creative/creative-ascii-art) | ASCII art: pyfiglet, cowsay, boxes, image-to-ascii. | `creative/ascii-art` | | [`ascii-video`](/docs/user-guide/skills/bundled/creative/creative-ascii-video) | ASCII video: convert video/audio to colored ASCII MP4/GIF. | `creative/ascii-video` | | [`baoyu-infographic`](/docs/user-guide/skills/bundled/creative/creative-baoyu-infographic) | Infographics: 21 layouts x 21 styles (信息图, 可视化). | `creative/baoyu-infographic` | @@ -43,12 +42,12 @@ If a skill is missing from this list but present in the repo, the catalog is reg | [`comfyui`](/docs/user-guide/skills/bundled/creative/creative-comfyui) | Generate images, video, and audio with ComfyUI — install, launch, manage nodes/models, run workflows with parameter injection. Uses the official comfy-cli for lifecycle and direct REST/WebSocket API for execution. | `creative/comfyui` | | [`design-md`](/docs/user-guide/skills/bundled/creative/creative-design-md) | Author/validate/export Google's DESIGN.md token spec files. | `creative/design-md` | | [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw) | Hand-drawn Excalidraw JSON diagrams (arch, flow, seq). | `creative/excalidraw` | +| [`html-artifact`](/docs/user-guide/skills/bundled/creative/creative-html-artifact) | Build self-contained HTML files to explain, plan, or review. | `creative/html-artifact` | | [`humanizer`](/docs/user-guide/skills/bundled/creative/creative-humanizer) | Humanize text: strip AI-isms and add real voice. | `creative/humanizer` | | [`manim-video`](/docs/user-guide/skills/bundled/creative/creative-manim-video) | Manim CE animations: 3Blue1Brown math/algo videos. | `creative/manim-video` | | [`p5js`](/docs/user-guide/skills/bundled/creative/creative-p5js) | p5.js sketches: gen art, shaders, interactive, 3D. | `creative/p5js` | | [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs) | 54 real design systems (Stripe, Linear, Vercel) as HTML/CSS. | `creative/popular-web-designs` | | [`pretext`](/docs/user-guide/skills/bundled/creative/creative-pretext) | Use when building creative browser demos with @chenglou/pretext — DOM-free text layout for ASCII art, typographic flow around obstacles, text-as-geometry games, kinetic typography, and text-powered generative art. Produces single-file HT... | `creative/pretext` | -| [`sketch`](/docs/user-guide/skills/bundled/creative/creative-sketch) | Throwaway HTML mockups: 2-3 design variants to compare. | `creative/sketch` | | [`songwriting-and-ai-music`](/docs/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music) | Songwriting craft and Suno AI music prompts. | `creative/songwriting-and-ai-music` | | [`touchdesigner-mcp`](/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp) | Control a running TouchDesigner instance via twozero MCP — create operators, set parameters, wire connections, execute Python, build real-time visuals. 36 native tools. | `creative/touchdesigner-mcp` | diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index 77f81db14b6..089ea173923 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -360,7 +360,7 @@ The registry of record is `hermes_cli/commands.py` — every consumer ``` ~/.hermes/config.yaml Main configuration -~/.hermes/.env API keys and secrets +~/.hermes/.env API keys and secrets (under $HERMES_HOME if set) $HERMES_HOME/skills/ Installed skills ~/.hermes/sessions/ Gateway routing index, request dumps, *.jsonl transcripts (and optional per-session JSON snapshots when sessions.write_json_snapshots: true) ~/.hermes/state.db Canonical session store (SQLite + FTS5) @@ -927,7 +927,7 @@ hermes-agent/ ``` -Config: `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys). +Config: `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys) — both under `$HERMES_HOME` when it is set. ### Adding a Tool (3 files) diff --git a/website/docs/user-guide/skills/bundled/creative/creative-architecture-diagram.md b/website/docs/user-guide/skills/bundled/creative/creative-architecture-diagram.md deleted file mode 100644 index ad816a370ad..00000000000 --- a/website/docs/user-guide/skills/bundled/creative/creative-architecture-diagram.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -title: "Architecture Diagram — Dark-themed SVG architecture/cloud/infra diagrams as HTML" -sidebar_label: "Architecture Diagram" -description: "Dark-themed SVG architecture/cloud/infra diagrams as HTML" ---- - -{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} - -# Architecture Diagram - -Dark-themed SVG architecture/cloud/infra diagrams as HTML. - -## Skill metadata - -| | | -|---|---| -| Source | Bundled (installed by default) | -| Path | `skills/creative/architecture-diagram` | -| Version | `1.0.0` | -| Author | Cocoon AI (hello@cocoon-ai.com), ported by Hermes Agent | -| License | MIT | -| Platforms | linux, macos, windows | -| Tags | `architecture`, `diagrams`, `SVG`, `HTML`, `visualization`, `infrastructure`, `cloud` | -| Related skills | [`concept-diagrams`](/docs/user-guide/skills/optional/creative/creative-concept-diagrams), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw) | - -## Reference: full SKILL.md - -:::info -The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. -::: - -# Architecture Diagram Skill - -Generate professional, dark-themed technical architecture diagrams as standalone HTML files with inline SVG graphics. No external tools, no API keys, no rendering libraries — just write the HTML file and open it in a browser. - -## Scope - -**Best suited for:** -- Software system architecture (frontend / backend / database layers) -- Cloud infrastructure (VPC, regions, subnets, managed services) -- Microservice / service-mesh topology -- Database + API map, deployment diagrams -- Anything with a tech-infra subject that fits a dark, grid-backed aesthetic - -**Look elsewhere first for:** -- Physics, chemistry, math, biology, or other scientific subjects -- Physical objects (vehicles, hardware, anatomy, cross-sections) -- Floor plans, narrative journeys, educational / textbook-style visuals -- Hand-drawn whiteboard sketches (consider `excalidraw`) -- Animated explainers (consider an animation skill) - -If a more specialized skill is available for the subject, prefer that. If none fits, this skill can also serve as a general SVG diagram fallback — the output will just carry the dark tech aesthetic described below. - -Based on [Cocoon AI's architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator) (MIT). - -## Workflow - -1. User describes their system architecture (components, connections, technologies) -2. Generate the HTML file following the design system below -3. Save with `write_file` to a `.html` file (e.g. `~/architecture-diagram.html`) -4. User opens in any browser — works offline, no dependencies - -### Output Location - -Save diagrams to a user-specified path, or default to the current working directory: -``` -./[project-name]-architecture.html -``` - -### Preview - -After saving, suggest the user open it: -```bash -# macOS -open ./my-architecture.html -# Linux -xdg-open ./my-architecture.html -``` - -## Design System & Visual Language - -### Color Palette (Semantic Mapping) - -Use specific `rgba` fills and hex strokes to categorize components: - -| Component Type | Fill (rgba) | Stroke (Hex) | -| :--- | :--- | :--- | -| **Frontend** | `rgba(8, 51, 68, 0.4)` | `#22d3ee` (cyan-400) | -| **Backend** | `rgba(6, 78, 59, 0.4)` | `#34d399` (emerald-400) | -| **Database** | `rgba(76, 29, 149, 0.4)` | `#a78bfa` (violet-400) | -| **AWS/Cloud** | `rgba(120, 53, 15, 0.3)` | `#fbbf24` (amber-400) | -| **Security** | `rgba(136, 19, 55, 0.4)` | `#fb7185` (rose-400) | -| **Message Bus** | `rgba(251, 146, 60, 0.3)` | `#fb923c` (orange-400) | -| **External** | `rgba(30, 41, 59, 0.5)` | `#94a3b8` (slate-400) | - -### Typography & Background -- **Font:** JetBrains Mono (Monospace), loaded from Google Fonts -- **Sizes:** 12px (Names), 9px (Sublabels), 8px (Annotations), 7px (Tiny labels) -- **Background:** Slate-950 (`#020617`) with a subtle 40px grid pattern - -```svg - - - - -``` - -## Technical Implementation Details - -### Component Rendering -Components are rounded rectangles (`rx="6"`) with 1.5px strokes. To prevent arrows from showing through semi-transparent fills, use a **double-rect masking technique**: -1. Draw an opaque background rect (`#0f172a`) -2. Draw the semi-transparent styled rect on top - -### Connection Rules -- **Z-Order:** Draw arrows *early* in the SVG (after the grid) so they render behind component boxes -- **Arrowheads:** Defined via SVG markers -- **Security Flows:** Use dashed lines in rose color (`#fb7185`) -- **Boundaries:** - - *Security Groups:* Dashed (`4,4`), rose color - - *Regions:* Large dashed (`8,4`), amber color, `rx="12"` - -### Spacing & Layout Logic -- **Standard Height:** 60px (Services); 80-120px (Large components) -- **Vertical Gap:** Minimum 40px between components -- **Message Buses:** Must be placed *in the gap* between services, not overlapping them -- **Legend Placement:** **CRITICAL.** Must be placed outside all boundary boxes. Calculate the lowest Y-coordinate of all boundaries and place the legend at least 20px below it. - -## Document Structure - -The generated HTML file follows a four-part layout: -1. **Header:** Title with a pulsing dot indicator and subtitle -2. **Main SVG:** The diagram contained within a rounded border card -3. **Summary Cards:** A grid of three cards below the diagram for high-level details -4. **Footer:** Minimal metadata - -### Info Card Pattern -```html -
-
-
-

Title

-
-
    -
  • • Item one
  • -
  • • Item two
  • -
-
-``` - -## Output Requirements -- **Single File:** One self-contained `.html` file -- **No External Dependencies:** All CSS and SVG must be inline (except Google Fonts) -- **No JavaScript:** Use pure CSS for any animations (like pulsing dots) -- **Compatibility:** Must render correctly in any modern web browser - -## Template Reference - -Load the full HTML template for the exact structure, CSS, and SVG component examples: - -``` -skill_view(name="architecture-diagram", file_path="templates/template.html") -``` - -The template contains working examples of every component type (frontend, backend, database, cloud, security), arrow styles (standard, dashed, curved), security groups, region boundaries, and the legend — use it as your structural reference when generating diagrams. diff --git a/website/docs/user-guide/skills/bundled/creative/creative-claude-design.md b/website/docs/user-guide/skills/bundled/creative/creative-claude-design.md index bf6f4eafaa3..8fa3c563bbf 100644 --- a/website/docs/user-guide/skills/bundled/creative/creative-claude-design.md +++ b/website/docs/user-guide/skills/bundled/creative/creative-claude-design.md @@ -21,7 +21,7 @@ Design one-off HTML artifacts (landing, deck, prototype). | License | MIT | | Platforms | linux, macos, windows | | Tags | `design`, `html`, `prototype`, `ux`, `ui`, `creative`, `artifact`, `deck`, `motion`, `design-system` | -| Related skills | [`design-md`](/docs/user-guide/skills/bundled/creative/creative-design-md), [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/docs/user-guide/skills/bundled/creative/creative-architecture-diagram) | +| Related skills | [`html-artifact`](/docs/user-guide/skills/bundled/creative/creative-html-artifact), [`design-md`](/docs/user-guide/skills/bundled/creative/creative-design-md), [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw) | ## Reference: full SKILL.md @@ -37,19 +37,21 @@ The goal is to preserve Claude Design's useful design behavior and taste while r **Before starting, check for other web-design skills like `popular-web-designs` (ready-to-paste design systems for Stripe, Linear, Vercel, Notion, etc.) and `design-md` (Google's DESIGN.md token spec format).** If the user wants a known brand's look, load `popular-web-designs` alongside this one and let it supply the visual vocabulary. If the deliverable is a token spec file rather than a rendered artifact, use `design-md` instead. Full decision table below. -## When To Use This Skill vs `popular-web-designs` vs `design-md` +## When To Use This Skill vs `html-artifact` vs `popular-web-designs` vs `design-md` -Hermes has three design-related skills under `skills/creative/`. They do different jobs — load the right one (or combine them): +Several skills produce HTML — they do different jobs. Load the right one (or combine them): | Skill | What it gives you | Use when the user wants... | |---|---|---| -| **claude-design** (this one) | Design *process and taste* — how to scope a brief, gather context, produce variants, verify a local HTML artifact, avoid AI-design slop | a from-scratch designed artifact (landing page, prototype, deck, component lab, motion study) with no specific brand or token system dictated | +| **claude-design** (this one) | Visual design *process and taste* — how to scope a brief, gather context, produce variants, verify a local HTML artifact, avoid AI-design slop | a from-scratch *designed* artifact (landing page, prototype, deck, component lab, motion study) where the look itself is the point and no specific brand or token system is dictated | +| **html-artifact** | A house style for *information* artifacts — explainers, plans, reports, code reviews, technical/educational diagrams, throwaway editors | to *explain / plan / report / diagram / review* something as a shareable HTML page — the content is the point, not bespoke visual design | | **popular-web-designs** | 54 ready-to-paste design systems — exact colors, typography, components, CSS values for sites like Stripe, Linear, Vercel, Notion, Airbnb | "make it look like Stripe / Linear / Vercel", a page styled after a known brand, or a visual starting point pulled from a real product | | **design-md** | Google's DESIGN.md spec format — author/validate/diff/export design-token files, WCAG contrast checking, Tailwind/DTCG export | a formal, persistent, machine-readable design-system *spec file* (tokens + rationale) that lives in a repo and gets consumed by agents over time | Rule of thumb: -- **Process + taste, one-off artifact** → claude-design +- **Bespoke visual design, taste-driven artifact** → claude-design +- **Explain / plan / report / diagram as a shareable page** → html-artifact - **Match a known brand's look** → popular-web-designs (and let claude-design drive the process) - **Author the tokens spec itself** → design-md diff --git a/website/docs/user-guide/skills/bundled/creative/creative-design-md.md b/website/docs/user-guide/skills/bundled/creative/creative-design-md.md index a96723ddb7f..687916eb2dc 100644 --- a/website/docs/user-guide/skills/bundled/creative/creative-design-md.md +++ b/website/docs/user-guide/skills/bundled/creative/creative-design-md.md @@ -21,7 +21,7 @@ Author/validate/export Google's DESIGN.md token spec files. | License | MIT | | Platforms | linux, macos, windows | | Tags | `design`, `design-system`, `tokens`, `ui`, `accessibility`, `wcag`, `tailwind`, `dtcg`, `google` | -| Related skills | [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs), [`claude-design`](/docs/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/docs/user-guide/skills/bundled/creative/creative-architecture-diagram) | +| Related skills | [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs), [`claude-design`](/docs/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`html-artifact`](/docs/user-guide/skills/bundled/creative/creative-html-artifact) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/bundled/creative/creative-html-artifact.md b/website/docs/user-guide/skills/bundled/creative/creative-html-artifact.md new file mode 100644 index 00000000000..0f34348ef2e --- /dev/null +++ b/website/docs/user-guide/skills/bundled/creative/creative-html-artifact.md @@ -0,0 +1,202 @@ +--- +title: "Html Artifact — Build self-contained HTML files to explain, plan, or review" +sidebar_label: "Html Artifact" +description: "Build self-contained HTML files to explain, plan, or review" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Html Artifact + +Build self-contained HTML files to explain, plan, or review. + +## Skill metadata + +| | | +|---|---| +| Source | Bundled (installed by default) | +| Path | `skills/creative/html-artifact` | +| Version | `1.0.0` | +| Author | Anthropic (html-effectiveness gallery, MIT), adapted for Hermes Agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `html`, `artifact`, `explainer`, `plan`, `report`, `code-review`, `diagram`, `svg`, `design`, `prototype`, `editor` | +| Related skills | [`claude-design`](/docs/user-guide/skills/bundled/creative/creative-claude-design), [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs), [`design-md`](/docs/user-guide/skills/bundled/creative/creative-design-md), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`p5js`](/docs/user-guide/skills/bundled/creative/creative-p5js) | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# HTML Artifact Skill + +Produce a single self-contained `.html` file — no build step, no dependencies, no +CDN — whenever the deliverable is something a human should *read, share, or poke at*: +a concept explainer, an implementation plan, a status/incident report, a code-review +walkthrough, a technical or educational diagram, a set of design variants, or a +throwaway editor that exports its result back to you. + +HTML beats Markdown once a doc has color, layout, diagrams, tables, code, or +interaction. It opens in any browser, shares as a link, stays readable past 100 +lines, and can carry SVG diagrams and live controls Markdown can't. Default to an +HTML artifact when the user says "make an HTML file/artifact", or asks you to +*explain how X works*, *write up a plan/PR/report*, *diagram* something, *compare* +options, or *prototype* an interaction — even when they don't say "HTML". + +## Why this skill exists (and what it replaced) + +This skill **supersedes** three former skills — `sketch` (throwaway multi-variant +HTML mockups), `architecture-diagram` (dark-tech infra SVG), and `concept-diagrams` +(educational SVG). They were consolidated for a concrete reason: all three emitted +the *same artifact* — a single self-contained HTML file with inline CSS/SVG — and +overlapped heavily (three "diagram" skills, two "compare variants" paths, no shared +token system). Folding them into one mode-switched skill removes the +which-one-do-I-load ambiguity and gives every output the same house style, while +keeping each skill's unique value: the fidelity dial + verify loop (from `sketch`), +the dark infra aesthetic (from `architecture-diagram`), and the 9-ramp educational +system + archetype library (from `concept-diagrams`). + +The consolidation is footprint-safe: this skill has **zero dependencies** (no Node, +FFmpeg, Chromium, or pip packages — it authors plain HTML/CSS/SVG), so even though it +ships **bundled** (active by default) where `concept-diagrams` was optional, the only +always-in-context cost is this skill's one-line description. All references, +templates, and the example gallery load on demand. `concept-diagrams` was optional +because it was niche, not because it had an install cost — promoting that capability +into a general-purpose, zero-dep bundled skill is the right home for it. Diagram-style +work with a *real* install cost (e.g. `hyperframes`: Node + FFmpeg + Chromium) +deliberately stays optional and is **not** folded in here. + +Use a different skill when: matching a known brand's look → `popular-web-designs`; a +formal design-token spec file → `design-md`; a *bespoke visually-designed* artifact +where the look itself is the point → `claude-design`; hand-drawn/whiteboard +`.excalidraw` files → `excalidraw`; generative/animated canvas art → `p5js`. This +skill is for everything else that ships as a readable, shareable HTML page. + +## Reference files (load on demand) + +- `references/house-style.md` — the canonical `:root` token block, type system, + card/table/callout/code-block patterns. **Read this before authoring any artifact.** +- `references/examples.md` — 20 complete reference HTML files (Anthropic's + html-effectiveness gallery, MIT) keyed to each mode, plus the script to fetch them. + Read/fetch one that matches your task to calibrate the house style from a full example. +- `references/svg-diagrams.md` — hand-authored inline SVG: arrow markers, node + groups, decision diamonds, edge semantics, coordinate-grid discipline. Read for + any flowchart / architecture / concept diagram. +- `references/concept-archetypes.md` — the 9-ramp educational color system + a + library of diagram archetypes (timeline, tree, quadrant, layered stack, + before/after, hub-spoke, cross-section). Read for educational / non-software visuals. +- `references/dark-tech.md` — the dark "infra" token variant (carries the old + architecture-diagram aesthetic). Read for cloud/infra/system architecture diagrams. +- `references/throwaway-editors.md` — the single-file editor recipe and the + copy-to-clipboard export pattern that survives `file://`. Read when the artifact + needs interactive controls that export state back to a prompt. +- `references/fidelity-and-verify.md` — the throwaway↔presentation fidelity dial, + the multi-variant comparison layout, and the mandatory browser-vision verify loop. + +## Templates + +- `templates/base.html` — document scaffold with the house-style ` -``` - -### 4. Variant README - -Each variant's `README.md` answers: - -```markdown -## Variant: {stance name} - -### Design stance -One sentence on the principle driving this variant. - -### Key choices -- Layout: ... -- Typography: ... -- Color: ... -- Interaction: ... - -### Trade-offs -- Strong at: ... -- Weak at: ... - -### Best for -- The kind of user or use case this variant actually serves -``` - -### 5. Head-to-head - -After all variants are built, present them as a comparison. Don't just list — **opinionate**: - -```markdown -## Three takes on the home screen - -| Dimension | Calm editorial | Utilitarian dense | Playful split | -|-----------|----------------|-------------------|---------------| -| Density | Low | High | Medium | -| Primary action visibility | Low | High | Medium | -| Scan-ability | High | Medium | Low | -| Feel | Calm, trusted | Sharp, tool-like | Inviting, energetic | - -**My take:** Utilitarian dense for power users, calm editorial for content-forward audiences. Playful split is weakest — tries to do both and commits to neither. -``` - -Let the user pick a winner, or combine two into a hybrid, or ask for another round. - -## Theming (when the project has a visual identity) - -If the user has an existing theme (colors, fonts, tokens), put shared tokens in `sketches/themes/tokens.css` and `@import` them in each variant. Keep tokens minimal: - -```css -/* sketches/themes/tokens.css */ -:root { - --color-bg: #fafafa; - --color-fg: #1a1a1a; - --color-accent: #0066ff; - --color-muted: #666; - --radius: 8px; - --font-display: "Inter", sans-serif; - --font-body: -apple-system, BlinkMacSystemFont, sans-serif; -} -``` - -Don't over-tokenize a throwaway sketch — three colors and one font is usually enough. - -## Interactivity bar - -A sketch is interactive enough when the user can: - -1. **Click a primary action** and something visible happens (state change, modal, toast, navigation feint) -2. **See one meaningful state transition** (filter a list, toggle a mode, open/close a panel) -3. **Hover recognizable affordances** (buttons, rows, tabs) - -More than that is over-engineering a throwaway. Less than that is a screenshot. - -## Frontier mode (picking what to sketch next) - -If sketches already exist and the user says "what should I sketch next?": - -- **Consistency gaps** — two winning variants from different sketches made independent choices that haven't been composed together yet -- **Unsketched screens** — referenced but never explored -- **State coverage** — happy path sketched, but not empty / loading / error / 1000-items -- **Responsive gaps** — validated at one viewport; does it hold at mobile / ultrawide? -- **Interaction patterns** — static layouts exist; transitions, drag, scroll behavior don't - -Propose 2-4 named candidates. Let the user pick. - -## Output - -- Create `sketches/` (or `.planning/sketches/` if the user is using GSD conventions) in the repo root -- One subdir per variant: `NNN-stance-name/index.html` + `README.md` -- Tell the user how to open them: `open sketches/001-calm-editorial/index.html` on macOS, `xdg-open` on Linux, `start` on Windows -- Keep variants disposable — a sketch that you felt the need to preserve should be promoted into real project code, not curated as an asset - -**Typical tool sequence for one variant:** - -``` -terminal("mkdir -p sketches/001-calm-editorial") -write_file("sketches/001-calm-editorial/index.html", "...") -write_file("sketches/001-calm-editorial/README.md", "## Variant: Calm editorial\n...") -browser_navigate(url="file://$(pwd)/sketches/001-calm-editorial/index.html") -browser_vision(question="How does this look? Any obvious layout issues?") -``` - -Repeat for each variant, then present the comparison table. - -## Attribution - -Adapted from the GSD (Get Shit Done) project's `/gsd-sketch` workflow — MIT © 2025 Lex Christopherson ([gsd-build/get-shit-done](https://github.com/gsd-build/get-shit-done)). The full GSD system ships persistent sketch state, theme/variant pattern references, and consistency-audit workflows; install with `npx get-shit-done-cc --hermes --global`. diff --git a/website/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp.md b/website/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp.md index 2577f1f741c..9a14bceffd9 100644 --- a/website/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp.md +++ b/website/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp.md @@ -21,7 +21,7 @@ Control a running TouchDesigner instance via twozero MCP — create operators, s | License | MIT | | Platforms | linux, macos, windows | | Tags | `TouchDesigner`, `MCP`, `twozero`, `creative-coding`, `real-time-visuals`, `generative-art`, `audio-reactive`, `VJ`, `installation`, `GLSL` | -| Related skills | [`native-mcp`](/docs/user-guide/skills/bundled/mcp/mcp-native-mcp), [`ascii-video`](/docs/user-guide/skills/bundled/creative/creative-ascii-video), [`manim-video`](/docs/user-guide/skills/bundled/creative/creative-manim-video), `hermes-video` | +| Related skills | `native-mcp`, [`ascii-video`](/docs/user-guide/skills/bundled/creative/creative-ascii-video), [`manim-video`](/docs/user-guide/skills/bundled/creative/creative-manim-video), `hermes-video` | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/bundled/email/email-himalaya.md b/website/docs/user-guide/skills/bundled/email/email-himalaya.md index adf3d973635..34c868e9f26 100644 --- a/website/docs/user-guide/skills/bundled/email/email-himalaya.md +++ b/website/docs/user-guide/skills/bundled/email/email-himalaya.md @@ -32,6 +32,11 @@ The following is the complete skill definition that Hermes loads when this skill Himalaya is a CLI email client that lets you manage emails from the terminal using IMAP, SMTP, Notmuch, or Sendmail backends. +This skill is separate from the Hermes Email gateway adapter. The gateway +adapter lets people email the agent and uses Hermes' built-in IMAP/SMTP +adapter; this skill lets the agent operate a mailbox from terminal tools and +requires the external `himalaya` CLI. + ## References - `references/configuration.md` (config file setup + IMAP/SMTP authentication) diff --git a/website/docs/user-guide/skills/bundled/github/github-github-auth.md b/website/docs/user-guide/skills/bundled/github/github-github-auth.md index 92b9d9f6690..35e631fb237 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-auth.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-auth.md @@ -238,8 +238,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then echo "AUTH_METHOD=gh" elif [ -n "$GITHUB_TOKEN" ]; then echo "AUTH_METHOD=curl" -elif [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then - export GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') +elif _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then + export GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') echo "AUTH_METHOD=curl" elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then export GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') diff --git a/website/docs/user-guide/skills/bundled/github/github-github-code-review.md b/website/docs/user-guide/skills/bundled/github/github-github-code-review.md index 56e8fa97ad2..a7adc59e119 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-code-review.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-code-review.md @@ -46,8 +46,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then else AUTH="git" if [ -z "$GITHUB_TOKEN" ]; then - if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') + if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi diff --git a/website/docs/user-guide/skills/bundled/github/github-github-issues.md b/website/docs/user-guide/skills/bundled/github/github-github-issues.md index 6f99685d71a..fa3dc52c7e2 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-issues.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-issues.md @@ -46,8 +46,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then else AUTH="git" if [ -z "$GITHUB_TOKEN" ]; then - if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') + if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi diff --git a/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md b/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md index 48aa4ea9fff..a0221be3d73 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md @@ -48,8 +48,8 @@ else AUTH="git" # Ensure we have a token for API calls if [ -z "$GITHUB_TOKEN" ]; then - if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') + if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi diff --git a/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md b/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md index 0921e3dbccc..b87a7abdf37 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md @@ -45,8 +45,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then else AUTH="git" if [ -z "$GITHUB_TOKEN" ]; then - if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') + if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi diff --git a/website/docs/user-guide/skills/bundled/media/media-gif-search.md b/website/docs/user-guide/skills/bundled/media/media-gif-search.md index c26c5fd4a5e..31d0e03eb88 100644 --- a/website/docs/user-guide/skills/bundled/media/media-gif-search.md +++ b/website/docs/user-guide/skills/bundled/media/media-gif-search.md @@ -38,7 +38,7 @@ Useful for finding reaction GIFs, creating visual content, and sending GIFs in c ## Setup -Set your Tenor API key in your environment (add to `~/.hermes/.env`): +Set your Tenor API key in your environment (add to `${HERMES_HOME:-~/.hermes}/.env`): ```bash TENOR_API_KEY=your_key_here diff --git a/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md b/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md index e8315c2fd4f..49f317144d7 100644 --- a/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md +++ b/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md @@ -32,7 +32,7 @@ Use this skill for filesystem-first Obsidian vault work: reading notes, listing Use a known or resolved vault path before calling file tools. -The documented vault-path convention is the `OBSIDIAN_VAULT_PATH` environment variable, for example from `~/.hermes/.env`. If it is unset, use `~/Documents/Obsidian Vault`. +The documented vault-path convention is the `OBSIDIAN_VAULT_PATH` environment variable, for example from `${HERMES_HOME:-~/.hermes}/.env`. If it is unset, use `~/Documents/Obsidian Vault`. File tools do not expand shell variables. Do not pass paths containing `$OBSIDIAN_VAULT_PATH` to `read_file`, `write_file`, `patch`, or `search_files`; resolve the vault path first and pass a concrete absolute path. Vault paths may contain spaces, which is another reason to prefer file tools over shell commands. diff --git a/website/docs/user-guide/skills/bundled/productivity/productivity-airtable.md b/website/docs/user-guide/skills/bundled/productivity/productivity-airtable.md index bc4b4686433..05a3e13fba0 100644 --- a/website/docs/user-guide/skills/bundled/productivity/productivity-airtable.md +++ b/website/docs/user-guide/skills/bundled/productivity/productivity-airtable.md @@ -40,7 +40,7 @@ Work with Airtable's REST API directly via `curl` using the `terminal` tool. No - `data.records:write` — create / update / delete rows - `schema.bases:read` — list bases and tables 3. **Important:** in the same token UI, add each base you want to access to the token's **Access** list. PATs are scoped per-base — a valid token on the wrong base returns `403`. -4. Store the token in `~/.hermes/.env` (or via `hermes setup`): +4. Store the token in `${HERMES_HOME:-~/.hermes}/.env` (or via `hermes setup`): ``` AIRTABLE_API_KEY=pat_your_token_here ``` @@ -236,7 +236,7 @@ done ## Important Notes for Hermes - **Always use the `terminal` tool with `curl`.** Do NOT use `web_extract` (it can't send auth headers) or `browser_navigate` (needs UI auth and is slow). -- **`AIRTABLE_API_KEY` flows from `~/.hermes/.env` into the subprocess automatically** when this skill is loaded — no need to re-export it before each `curl` call. +- **`AIRTABLE_API_KEY` flows from `${HERMES_HOME:-~/.hermes}/.env` into the subprocess automatically** when this skill is loaded — no need to re-export it before each `curl` call. - **Escape curly braces in formulas carefully.** In a heredoc body, `{Status}` is literal. In a shell argument, `{Status}` is safe outside `{...}` brace-expansion context — but pass dynamic strings through `python3 urllib.parse.quote` before splicing into a URL. - **Pretty-print with `python3 -m json.tool`** (always present) rather than `jq` (optional). Only reach for `jq` when you need filtering/projection. - **Pagination is per-page, not global.** Airtable's 100-record cap is a hard limit; there is no way to bump it. Loop with `offset` until the field is absent. diff --git a/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md b/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md index 80487d6b88f..985240ca41f 100644 --- a/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md +++ b/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md @@ -41,7 +41,7 @@ Talk to Notion two ways. Same integration token works for both — pick by what' 1. Create an integration at https://notion.so/my-integrations 2. Copy the API key (starts with `ntn_` or `secret_`) -3. Store in `~/.hermes/.env`: +3. Store in `${HERMES_HOME:-~/.hermes}/.env`: ``` NOTION_API_KEY=ntn_your_key_here ``` @@ -65,7 +65,7 @@ export NOTION_API_TOKEN=$NOTION_API_KEY # ntn reads NOTION_API_TOKEN export NOTION_KEYRING=0 # don't try to use the OS keychain ``` -Add those exports to your shell profile (or to `~/.hermes/.env`) so every session inherits them. +Add those exports to your shell profile (or to `${HERMES_HOME:-~/.hermes}/.env`) so every session inherits them. ### 3. Choose path at runtime diff --git a/website/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline.md b/website/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline.md index 125021bc4cb..8fb4c066302 100644 --- a/website/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline.md +++ b/website/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline.md @@ -50,7 +50,7 @@ Multilingual trigger examples (not exhaustive): ## Prerequisites -Before using the pipeline, verify these are set in `~/.hermes/.env`: +Before using the pipeline, verify these are set in `${HERMES_HOME:-~/.hermes}/.env`: ```bash MSGRAPH_TENANT_ID=... diff --git a/website/docs/user-guide/skills/bundled/research/research-llm-wiki.md b/website/docs/user-guide/skills/bundled/research/research-llm-wiki.md index 419c7cd7cb2..a6097a1a07c 100644 --- a/website/docs/user-guide/skills/bundled/research/research-llm-wiki.md +++ b/website/docs/user-guide/skills/bundled/research/research-llm-wiki.md @@ -52,7 +52,7 @@ Use this skill when the user: ## Wiki Location -**Location:** Set via `WIKI_PATH` environment variable (e.g. in `~/.hermes/.env`). +**Location:** Set via `WIKI_PATH` environment variable (e.g. in `${HERMES_HOME:-~/.hermes}/.env`). If unset, defaults to `~/wiki`. diff --git a/website/docs/user-guide/skills/bundled/research/research-research-paper-writing.md b/website/docs/user-guide/skills/bundled/research/research-research-paper-writing.md index 9dc216ebac7..611215c06c3 100644 --- a/website/docs/user-guide/skills/bundled/research/research-research-paper-writing.md +++ b/website/docs/user-guide/skills/bundled/research/research-research-paper-writing.md @@ -22,7 +22,7 @@ Write ML papers for NeurIPS/ICML/ICLR: design→submit. | Dependencies | `semanticscholar`, `arxiv`, `habanero`, `requests`, `scipy`, `numpy`, `matplotlib`, `SciencePlots` | | Platforms | linux, macos | | Tags | `Research`, `Paper Writing`, `Experiments`, `ML`, `AI`, `NeurIPS`, `ICML`, `ICLR`, `ACL`, `AAAI`, `COLM`, `LaTeX`, `Citations`, `Statistical Analysis` | -| Related skills | [`arxiv`](/docs/user-guide/skills/bundled/research/research-arxiv), `ml-paper-writing`, [`subagent-driven-development`](/docs/user-guide/skills/bundled/software-development/software-development-subagent-driven-development), [`plan`](/docs/user-guide/skills/bundled/software-development/software-development-plan) | +| Related skills | [`arxiv`](/docs/user-guide/skills/bundled/research/research-arxiv), `ml-paper-writing`, [`subagent-driven-development`](/docs/user-guide/skills/optional/software-development/software-development-subagent-driven-development), [`plan`](/docs/user-guide/skills/bundled/software-development/software-development-plan) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger.md b/website/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger.md index deddf5dafdb..5257512e9e6 100644 --- a/website/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger.md +++ b/website/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger.md @@ -21,7 +21,7 @@ Debug Node.js via --inspect + Chrome DevTools Protocol CLI. | License | MIT | | Platforms | linux, macos, windows | | Tags | `debugging`, `nodejs`, `node-inspect`, `cdp`, `breakpoints`, `ui-tui` | -| Related skills | [`systematic-debugging`](/docs/user-guide/skills/bundled/software-development/software-development-systematic-debugging), [`python-debugpy`](/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy), [`debugging-hermes-tui-commands`](/docs/user-guide/skills/bundled/software-development/software-development-debugging-hermes-tui-commands) | +| Related skills | [`systematic-debugging`](/docs/user-guide/skills/bundled/software-development/software-development-systematic-debugging), [`python-debugpy`](/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy), `debugging-hermes-tui-commands` | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md b/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md index 0524b1f3ab9..dbc26409efe 100644 --- a/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md +++ b/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md @@ -21,7 +21,7 @@ Debug Python: pdb REPL + debugpy remote (DAP). | License | MIT | | Platforms | linux, macos | | Tags | `debugging`, `python`, `pdb`, `debugpy`, `breakpoints`, `dap`, `post-mortem` | -| Related skills | [`systematic-debugging`](/docs/user-guide/skills/bundled/software-development/software-development-systematic-debugging), [`node-inspect-debugger`](/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger), [`debugging-hermes-tui-commands`](/docs/user-guide/skills/bundled/software-development/software-development-debugging-hermes-tui-commands) | +| Related skills | [`systematic-debugging`](/docs/user-guide/skills/bundled/software-development/software-development-systematic-debugging), [`node-inspect-debugger`](/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger), `debugging-hermes-tui-commands` | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/bundled/software-development/software-development-spike.md b/website/docs/user-guide/skills/bundled/software-development/software-development-spike.md index 56c0954b698..694cdcbf7af 100644 --- a/website/docs/user-guide/skills/bundled/software-development/software-development-spike.md +++ b/website/docs/user-guide/skills/bundled/software-development/software-development-spike.md @@ -21,7 +21,7 @@ Throwaway experiments to validate an idea before build. | License | MIT | | Platforms | linux, macos, windows | | Tags | `spike`, `prototype`, `experiment`, `feasibility`, `throwaway`, `exploration`, `research`, `planning`, `mvp`, `proof-of-concept` | -| Related skills | [`sketch`](/docs/user-guide/skills/bundled/creative/creative-sketch), [`subagent-driven-development`](/docs/user-guide/skills/optional/software-development/software-development-subagent-driven-development), [`plan`](/docs/user-guide/skills/bundled/software-development/software-development-plan) | +| Related skills | [`html-artifact`](/docs/user-guide/skills/bundled/creative/creative-html-artifact), [`subagent-driven-development`](/docs/user-guide/skills/optional/software-development/software-development-subagent-driven-development), [`plan`](/docs/user-guide/skills/bundled/software-development/software-development-plan) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho.md b/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho.md index 1b989116636..a54a2a0dea0 100644 --- a/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho.md +++ b/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho.md @@ -47,14 +47,14 @@ Honcho provides AI-native cross-session user modeling. It learns who the user is ### Cloud (app.honcho.dev) ```bash -hermes honcho setup +hermes memory setup honcho # select "cloud", paste API key from https://app.honcho.dev ``` ### Self-hosted ```bash -hermes honcho setup +hermes memory setup honcho # select "local", enter base URL (e.g. http://localhost:8000) ``` diff --git a/website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md b/website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md index 8651bc979f6..177dfe36a10 100644 --- a/website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md +++ b/website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md @@ -53,7 +53,7 @@ Read-only — no API key, no signing, no order placement. Stdlib only — no external packages, no API key. -The script reads `~/.hermes/.env` for two optional defaults: +The script reads `${HERMES_HOME:-~/.hermes}/.env` for two optional defaults: - `HYPERLIQUID_API_URL` — defaults to `https://api.hyperliquid.xyz`. Set to `https://api.hyperliquid-testnet.xyz` for testnet. @@ -97,7 +97,7 @@ hyperliquid_client.py export [--interval 1h] [--hours N] [--output PATH] ``` For `state`, `spot-balances`, `fills`, `orders`, and `review`, the address is -optional when `HYPERLIQUID_USER_ADDRESS` is set in `~/.hermes/.env`. +optional when `HYPERLIQUID_USER_ADDRESS` is set in `${HERMES_HOME:-~/.hermes}/.env`. --- diff --git a/website/docs/user-guide/skills/optional/creative/creative-concept-diagrams.md b/website/docs/user-guide/skills/optional/creative/creative-concept-diagrams.md deleted file mode 100644 index 9b3ba92b3bd..00000000000 --- a/website/docs/user-guide/skills/optional/creative/creative-concept-diagrams.md +++ /dev/null @@ -1,379 +0,0 @@ ---- -title: "Concept Diagrams" -sidebar_label: "Concept Diagrams" -description: "Generate flat, minimal light/dark-aware SVG diagrams as standalone HTML files, using a unified educational visual language with 9 semantic color ramps, sente..." ---- - -{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} - -# Concept Diagrams - -Generate flat, minimal light/dark-aware SVG diagrams as standalone HTML files, using a unified educational visual language with 9 semantic color ramps, sentence-case typography, and automatic dark mode. Best suited for educational and non-software visuals — physics setups, chemistry mechanisms, math curves, physical objects (aircraft, turbines, smartphones, mechanical watches), anatomy, floor plans, cross-sections, narrative journeys (lifecycle of X, process of Y), hub-spoke system integrations (smart city, IoT), and exploded layer views. If a more specialized skill exists for the subject (dedicated software/cloud architecture, hand-drawn sketches, animated explainers, etc.), prefer that — otherwise this skill can also serve as a general-purpose SVG diagram fallback with a clean educational look. Ships with 15 example diagrams. - -## Skill metadata - -| | | -|---|---| -| Source | Optional — install with `hermes skills install official/creative/concept-diagrams` | -| Path | `optional-skills/creative/concept-diagrams` | -| Version | `0.1.0` | -| Author | v1k22 (original PR), ported into hermes-agent | -| License | MIT | -| Platforms | linux, macos, windows | -| Tags | `diagrams`, `svg`, `visualization`, `education`, `physics`, `chemistry`, `engineering` | -| Related skills | [`architecture-diagram`](/docs/user-guide/skills/bundled/creative/creative-architecture-diagram), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), `generative-widgets` | - -## Reference: full SKILL.md - -:::info -The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. -::: - -# Concept Diagrams - -Generate production-quality SVG diagrams with a unified flat, minimal design system. Output is a single self-contained HTML file that renders identically in any modern browser, with automatic light/dark mode. - -## Scope - -**Best suited for:** -- Physics setups, chemistry mechanisms, math curves, biology -- Physical objects (aircraft, turbines, smartphones, mechanical watches, cells) -- Anatomy, cross-sections, exploded layer views -- Floor plans, architectural conversions -- Narrative journeys (lifecycle of X, process of Y) -- Hub-spoke system integrations (smart city, IoT networks, electricity grids) -- Educational / textbook-style visuals in any domain -- Quantitative charts (grouped bars, energy profiles) - -**Look elsewhere first for:** -- Dedicated software / cloud infrastructure architecture with a dark tech aesthetic (consider `architecture-diagram` if available) -- Hand-drawn whiteboard sketches (consider `excalidraw` if available) -- Animated explainers or video output (consider an animation skill) - -If a more specialized skill is available for the subject, prefer that. If none fits, this skill can serve as a general-purpose SVG diagram fallback — the output will carry the clean educational aesthetic described below, which is a reasonable default for almost any subject. - -## Workflow - -1. Decide on the diagram type (see Diagram Types below). -2. Lay out components using the Design System rules. -3. Write the full HTML page using `templates/template.html` as the wrapper — paste your SVG where the template says ``. -4. Save as a standalone `.html` file (for example `~/my-diagram.html` or `./my-diagram.html`). -5. User opens it directly in a browser — no server, no dependencies. - -Optional: if the user wants a browsable gallery of multiple diagrams, see "Local Preview Server" at the bottom. - -Load the HTML template: -``` -skill_view(name="concept-diagrams", file_path="templates/template.html") -``` - -The template embeds the full CSS design system (`c-*` color classes, text classes, light/dark variables, arrow marker styles). The SVG you generate relies on these classes being present on the hosting page. - ---- - -## Design System - -### Philosophy - -- **Flat**: no gradients, drop shadows, blur, glow, or neon effects. -- **Minimal**: show the essential. No decorative icons inside boxes. -- **Consistent**: same colors, spacing, typography, and stroke widths across every diagram. -- **Dark-mode ready**: all colors auto-adapt via CSS classes — no per-mode SVG. - -### Color Palette - -9 color ramps, each with 7 stops. Put the class name on a `` or shape element; the template CSS handles both modes. - -| Class | 50 (lightest) | 100 | 200 | 400 | 600 | 800 | 900 (darkest) | -|------------|---------------|---------|---------|---------|---------|---------|---------------| -| `c-purple` | #EEEDFE | #CECBF6 | #AFA9EC | #7F77DD | #534AB7 | #3C3489 | #26215C | -| `c-teal` | #E1F5EE | #9FE1CB | #5DCAA5 | #1D9E75 | #0F6E56 | #085041 | #04342C | -| `c-coral` | #FAECE7 | #F5C4B3 | #F0997B | #D85A30 | #993C1D | #712B13 | #4A1B0C | -| `c-pink` | #FBEAF0 | #F4C0D1 | #ED93B1 | #D4537E | #993556 | #72243E | #4B1528 | -| `c-gray` | #F1EFE8 | #D3D1C7 | #B4B2A9 | #888780 | #5F5E5A | #444441 | #2C2C2A | -| `c-blue` | #E6F1FB | #B5D4F4 | #85B7EB | #378ADD | #185FA5 | #0C447C | #042C53 | -| `c-green` | #EAF3DE | #C0DD97 | #97C459 | #639922 | #3B6D11 | #27500A | #173404 | -| `c-amber` | #FAEEDA | #FAC775 | #EF9F27 | #BA7517 | #854F0B | #633806 | #412402 | -| `c-red` | #FCEBEB | #F7C1C1 | #F09595 | #E24B4A | #A32D2D | #791F1F | #501313 | - -#### Color Assignment Rules - -Color encodes **meaning**, not sequence. Never cycle through colors like a rainbow. - -- Group nodes by **category** — all nodes of the same type share one color. -- Use `c-gray` for neutral/structural nodes (start, end, generic steps, users). -- Use **2-3 colors per diagram**, not 6+. -- Prefer `c-purple`, `c-teal`, `c-coral`, `c-pink` for general categories. -- Reserve `c-blue`, `c-green`, `c-amber`, `c-red` for semantic meaning (info, success, warning, error). - -Light/dark stop mapping (handled by the template CSS — just use the class): -- Light mode: 50 fill + 600 stroke + 800 title / 600 subtitle -- Dark mode: 800 fill + 200 stroke + 100 title / 200 subtitle - -### Typography - -Only two font sizes. No exceptions. - -| Class | Size | Weight | Use | -|-------|------|--------|-----| -| `th` | 14px | 500 | Node titles, region labels | -| `ts` | 12px | 400 | Subtitles, descriptions, arrow labels | -| `t` | 14px | 400 | General text | - -- **Sentence case always.** Never Title Case, never ALL CAPS. -- Every `` MUST carry a class (`t`, `ts`, or `th`). No unclassed text. -- `dominant-baseline="central"` on all text inside boxes. -- `text-anchor="middle"` for centered text in boxes. - -**Width estimation (approx):** -- 14px weight 500: ~8px per character -- 12px weight 400: ~6.5px per character -- Always verify: `box_width >= (char_count × px_per_char) + 48` (24px padding each side) - -### Spacing & Layout - -- **ViewBox**: `viewBox="0 0 680 H"` where H = content height + 40px buffer. -- **Safe area**: x=40 to x=640, y=40 to y=(H-40). -- **Between boxes**: 60px minimum gap. -- **Inside boxes**: 24px horizontal padding, 12px vertical padding. -- **Arrowhead gap**: 10px between arrowhead and box edge. -- **Single-line box**: 44px height. -- **Two-line box**: 56px height, 18px between title and subtitle baselines. -- **Container padding**: 20px minimum inside every container. -- **Max nesting**: 2-3 levels deep. Deeper gets unreadable at 680px width. - -### Stroke & Shape - -- **Stroke width**: 0.5px on all node borders. Not 1px, not 2px. -- **Rect rounding**: `rx="8"` for nodes, `rx="12"` for inner containers, `rx="16"` to `rx="20"` for outer containers. -- **Connector paths**: MUST have `fill="none"`. SVG defaults to `fill: black` otherwise. - -### Arrow Marker - -Include this `` block at the start of **every** SVG: - -```xml - - - - - -``` - -Use `marker-end="url(#arrow)"` on lines. The arrowhead inherits the line color via `context-stroke`. - -### CSS Classes (Provided by the Template) - -The template page provides: - -- Text: `.t`, `.ts`, `.th` -- Neutral: `.box`, `.arr`, `.leader`, `.node` -- Color ramps: `.c-purple`, `.c-teal`, `.c-coral`, `.c-pink`, `.c-gray`, `.c-blue`, `.c-green`, `.c-amber`, `.c-red` (all with automatic light/dark mode) - -You do **not** need to redefine these — just apply them in your SVG. The template file contains the full CSS definitions. - ---- - -## SVG Boilerplate - -Every SVG inside the template page starts with this exact structure: - -```xml - - - - - - - - - - -``` - -Replace `{HEIGHT}` with the actual computed height (last element bottom + 40px). - -### Node Patterns - -**Single-line node (44px):** -```xml - - - Service name - -``` - -**Two-line node (56px):** -```xml - - - Service name - Short description - -``` - -**Connector (no label):** -```xml - -``` - -**Container (dashed or solid):** -```xml - - - Container label - Subtitle info - -``` - ---- - -## Diagram Types - -Choose the layout that fits the subject: - -1. **Flowchart** — CI/CD pipelines, request lifecycles, approval workflows, data processing. Single-direction flow (top-down or left-right). Max 4-5 nodes per row. -2. **Structural / Containment** — Cloud infrastructure nesting, system architecture with layers. Large outer containers with inner regions. Dashed rects for logical groupings. -3. **API / Endpoint Map** — REST routes, GraphQL schemas. Tree from root, branching to resource groups, each containing endpoint nodes. -4. **Microservice Topology** — Service mesh, event-driven systems. Services as nodes, arrows for communication patterns, message queues between. -5. **Data Flow** — ETL pipelines, streaming architectures. Left-to-right flow from sources through processing to sinks. -6. **Physical / Structural** — Vehicles, buildings, hardware, anatomy. Use shapes that match the physical form — `` for curved bodies, `` for tapered shapes, ``/`` for cylindrical parts, nested `` for compartments. See `references/physical-shape-cookbook.md`. -7. **Infrastructure / Systems Integration** — Smart cities, IoT networks, multi-domain systems. Hub-spoke layout with central platform connecting subsystems. Semantic line styles (`.data-line`, `.power-line`, `.water-pipe`, `.road`). See `references/infrastructure-patterns.md`. -8. **UI / Dashboard Mockups** — Admin panels, monitoring dashboards. Screen frame with nested chart/gauge/indicator elements. See `references/dashboard-patterns.md`. - -For physical, infrastructure, and dashboard diagrams, load the matching reference file before generating — each one provides ready-made CSS classes and shape primitives. - ---- - -## Validation Checklist - -Before finalizing any SVG, verify ALL of the following: - -1. Every `` has class `t`, `ts`, or `th`. -2. Every `` inside a box has `dominant-baseline="central"`. -3. Every connector `` or `` used as arrow has `fill="none"`. -4. No arrow line crosses through an unrelated box. -5. `box_width >= (longest_label_chars × 8) + 48` for 14px text. -6. `box_width >= (longest_label_chars × 6.5) + 48` for 12px text. -7. ViewBox height = bottom-most element + 40px. -8. All content stays within x=40 to x=640. -9. Color classes (`c-*`) are on `` or shape elements, never on `` connectors. -10. Arrow `` block is present. -11. No gradients, shadows, blur, or glow effects. -12. Stroke width is 0.5px on all node borders. - ---- - -## Output & Preview - -### Default: standalone HTML file - -Write a single `.html` file the user can open directly. No server, no dependencies, works offline. Pattern: - -```python -# 1. Load the template -template = skill_view("concept-diagrams", "templates/template.html") - -# 2. Fill in title, subtitle, and paste your SVG -html = template.replace( - "", "SN2 reaction mechanism" -).replace( - "", "Bimolecular nucleophilic substitution" -).replace( - "", svg_content -) - -# 3. Write to a user-chosen path (or ./ by default) -write_file("./sn2-mechanism.html", html) -``` - -Tell the user how to open it: - -``` -# macOS -open ./sn2-mechanism.html -# Linux -xdg-open ./sn2-mechanism.html -``` - -### Optional: local preview server (multi-diagram gallery) - -Only use this when the user explicitly wants a browsable gallery of multiple diagrams. - -**Rules:** -- Bind to `127.0.0.1` only. Never `0.0.0.0`. Exposing diagrams on all network interfaces is a security hazard on shared networks. -- Pick a free port (do NOT hard-code one) and tell the user the chosen URL. -- The server is optional and opt-in — prefer the standalone HTML file first. - -Recommended pattern (lets the OS pick a free ephemeral port): - -```bash -# Put each diagram in its own folder under .diagrams/ -mkdir -p .diagrams/sn2-mechanism -# ...write .diagrams/sn2-mechanism/index.html... - -# Serve on loopback only, free port -cd .diagrams && python3 -c " -import http.server, socketserver -with socketserver.TCPServer(('127.0.0.1', 0), http.server.SimpleHTTPRequestHandler) as s: - print(f'Serving at http://127.0.0.1:{s.server_address[1]}/') - s.serve_forever() -" & -``` - -If the user insists on a fixed port, use `127.0.0.1:` — still never `0.0.0.0`. Document how to stop the server (`kill %1` or `pkill -f "http.server"`). - ---- - -## Examples Reference - -The `examples/` directory ships 15 complete, tested diagrams. Browse them for working patterns before writing a new diagram of a similar type: - -| File | Type | Demonstrates | -|------|------|--------------| -| `hospital-emergency-department-flow.md` | Flowchart | Priority routing with semantic colors | -| `feature-film-production-pipeline.md` | Flowchart | Phased workflow, horizontal sub-flows | -| `automated-password-reset-flow.md` | Flowchart | Auth flow with error branches | -| `autonomous-llm-research-agent-flow.md` | Flowchart | Loop-back arrows, decision branches | -| `place-order-uml-sequence.md` | Sequence | UML sequence diagram style | -| `commercial-aircraft-structure.md` | Physical | Paths, polygons, ellipses for realistic shapes | -| `wind-turbine-structure.md` | Physical cross-section | Underground/above-ground separation, color coding | -| `smartphone-layer-anatomy.md` | Exploded view | Alternating left/right labels, layered components | -| `apartment-floor-plan-conversion.md` | Floor plan | Walls, doors, proposed changes in dotted red | -| `banana-journey-tree-to-smoothie.md` | Narrative journey | Winding path, progressive state changes | -| `cpu-ooo-microarchitecture.md` | Hardware pipeline | Fan-out, memory hierarchy sidebar | -| `sn2-reaction-mechanism.md` | Chemistry | Molecules, curved arrows, energy profile | -| `smart-city-infrastructure.md` | Hub-spoke | Semantic line styles per system | -| `electricity-grid-flow.md` | Multi-stage flow | Voltage hierarchy, flow markers | -| `ml-benchmark-grouped-bar-chart.md` | Chart | Grouped bars, dual axis | - -Load any example with: -``` -skill_view(name="concept-diagrams", file_path="examples/") -``` - ---- - -## Quick Reference: What to Use When - -| User says | Diagram type | Suggested colors | -|-----------|--------------|------------------| -| "show the pipeline" | Flowchart | gray start/end, purple steps, red errors, teal deploy | -| "draw the data flow" | Data pipeline (left-right) | gray sources, purple processing, teal sinks | -| "visualize the system" | Structural (containment) | purple container, teal services, coral data | -| "map the endpoints" | API tree | purple root, one ramp per resource group | -| "show the services" | Microservice topology | gray ingress, teal services, purple bus, coral workers | -| "draw the aircraft/vehicle" | Physical | paths, polygons, ellipses for realistic shapes | -| "smart city / IoT" | Hub-spoke integration | semantic line styles per subsystem | -| "show the dashboard" | UI mockup | dark screen, chart colors: teal, purple, coral for alerts | -| "power grid / electricity" | Multi-stage flow | voltage hierarchy (HV/MV/LV line weights) | -| "wind turbine / turbine" | Physical cross-section | foundation + tower cutaway + nacelle color-coded | -| "journey of X / lifecycle" | Narrative journey | winding path, progressive state changes | -| "layers of X / exploded" | Exploded layer view | vertical stack, alternating labels | -| "CPU / pipeline" | Hardware pipeline | vertical stages, fan-out to execution ports | -| "floor plan / apartment" | Floor plan | walls, doors, proposed changes in dotted red | -| "reaction mechanism" | Chemistry | atoms, bonds, curved arrows, transition state, energy profile | diff --git a/website/docs/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md b/website/docs/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md index 8fa3cdf127f..a148ba6d2d6 100644 --- a/website/docs/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md +++ b/website/docs/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md @@ -21,7 +21,7 @@ Plan, set up, and monitor a multi-agent video production pipeline backed by Herm | License | MIT | | Platforms | linux, macos, windows | | Tags | `video`, `kanban`, `multi-agent`, `orchestration`, `production-pipeline` | -| Related skills | [`kanban-orchestrator`](/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator), [`kanban-worker`](/docs/user-guide/skills/bundled/devops/devops-kanban-worker), [`ascii-video`](/docs/user-guide/skills/bundled/creative/creative-ascii-video), [`manim-video`](/docs/user-guide/skills/bundled/creative/creative-manim-video), [`p5js`](/docs/user-guide/skills/bundled/creative/creative-p5js), [`comfyui`](/docs/user-guide/skills/bundled/creative/creative-comfyui), [`touchdesigner-mcp`](/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp), [`blender-mcp`](/docs/user-guide/skills/optional/creative/creative-blender-mcp), [`pixel-art`](/docs/user-guide/skills/bundled/creative/creative-pixel-art), [`ascii-art`](/docs/user-guide/skills/bundled/creative/creative-ascii-art), [`songwriting-and-ai-music`](/docs/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music), [`heartmula`](/docs/user-guide/skills/bundled/media/media-heartmula), [`songsee`](/docs/user-guide/skills/bundled/media/media-songsee), [`spotify`](/docs/user-guide/skills/bundled/media/media-spotify), [`youtube-content`](/docs/user-guide/skills/bundled/media/media-youtube-content), [`claude-design`](/docs/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/docs/user-guide/skills/bundled/creative/creative-architecture-diagram), [`concept-diagrams`](/docs/user-guide/skills/optional/creative/creative-concept-diagrams), [`baoyu-comic`](/docs/user-guide/skills/bundled/creative/creative-baoyu-comic), [`baoyu-infographic`](/docs/user-guide/skills/bundled/creative/creative-baoyu-infographic), [`humanizer`](/docs/user-guide/skills/bundled/creative/creative-humanizer), [`gif-search`](/docs/user-guide/skills/bundled/media/media-gif-search), [`meme-generation`](/docs/user-guide/skills/optional/creative/creative-meme-generation) | +| Related skills | [`kanban-orchestrator`](/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator), [`kanban-worker`](/docs/user-guide/skills/bundled/devops/devops-kanban-worker), [`ascii-video`](/docs/user-guide/skills/bundled/creative/creative-ascii-video), [`manim-video`](/docs/user-guide/skills/bundled/creative/creative-manim-video), [`p5js`](/docs/user-guide/skills/bundled/creative/creative-p5js), [`comfyui`](/docs/user-guide/skills/bundled/creative/creative-comfyui), [`touchdesigner-mcp`](/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp), [`blender-mcp`](/docs/user-guide/skills/optional/creative/creative-blender-mcp), [`pixel-art`](/docs/user-guide/skills/optional/creative/creative-pixel-art), [`ascii-art`](/docs/user-guide/skills/bundled/creative/creative-ascii-art), [`songwriting-and-ai-music`](/docs/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music), [`heartmula`](/docs/user-guide/skills/bundled/media/media-heartmula), [`songsee`](/docs/user-guide/skills/bundled/media/media-songsee), `spotify`, [`youtube-content`](/docs/user-guide/skills/bundled/media/media-youtube-content), [`claude-design`](/docs/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`html-artifact`](/docs/user-guide/skills/bundled/creative/creative-html-artifact), [`baoyu-comic`](/docs/user-guide/skills/optional/creative/creative-baoyu-comic), [`baoyu-infographic`](/docs/user-guide/skills/bundled/creative/creative-baoyu-infographic), [`humanizer`](/docs/user-guide/skills/bundled/creative/creative-humanizer), [`gif-search`](/docs/user-guide/skills/bundled/media/media-gif-search), [`meme-generation`](/docs/user-guide/skills/optional/creative/creative-meme-generation) | ## Reference: full SKILL.md @@ -194,7 +194,7 @@ task graphs. See **[references/examples.md](https://github.com/NousResearch/herm right human-review gates. 8. **Verify API keys BEFORE firing.** External APIs (TTS, image-gen, - image-to-video) need keys in `~/.hermes/.env` or the user's secret store. + image-to-video) need keys in `${HERMES_HOME:-~/.hermes}/.env` or the user's secret store. A worker that hits a missing-key error wastes a task slot. The setup script's `check_key` helper aborts cleanly if a required key is missing. diff --git a/website/docs/user-guide/skills/optional/devops/devops-pinggy-tunnel.md b/website/docs/user-guide/skills/optional/devops/devops-pinggy-tunnel.md index 19f431f1967..18fb572bdcb 100644 --- a/website/docs/user-guide/skills/optional/devops/devops-pinggy-tunnel.md +++ b/website/docs/user-guide/skills/optional/devops/devops-pinggy-tunnel.md @@ -21,7 +21,7 @@ Zero-install localhost tunnels over SSH via Pinggy. | License | MIT | | Platforms | linux, macos, windows | | Tags | `Pinggy`, `Tunnel`, `Networking`, `SSH`, `Webhook`, `Localhost` | -| Related skills | `cloudflared-quick-tunnel`, [`webhook-subscriptions`](/docs/user-guide/skills/bundled/devops/devops-webhook-subscriptions) | +| Related skills | `cloudflared-quick-tunnel`, `webhook-subscriptions` | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/optional/devops/devops-watchers.md b/website/docs/user-guide/skills/optional/devops/devops-watchers.md index 8a56162bdb8..9d2fc7f7523 100644 --- a/website/docs/user-guide/skills/optional/devops/devops-watchers.md +++ b/website/docs/user-guide/skills/optional/devops/devops-watchers.md @@ -77,7 +77,7 @@ python $HERMES_HOME/skills/devops/watchers/scripts/watch_rss.py \ --name hn --url https://news.ycombinator.com/rss --max 5 ``` -Watch a GitHub repo (set `GITHUB_TOKEN` in `~/.hermes/.env` to avoid the 60 req/hr anonymous rate limit): +Watch a GitHub repo (set `GITHUB_TOKEN` in `${HERMES_HOME:-~/.hermes}/.env` to avoid the 60 req/hr anonymous rate limit): ```bash python $HERMES_HOME/skills/devops/watchers/scripts/watch_github.py \ diff --git a/website/docs/user-guide/skills/optional/mcp/mcp-fastmcp.md b/website/docs/user-guide/skills/optional/mcp/mcp-fastmcp.md index 2defe89d4eb..3efe47b12b8 100644 --- a/website/docs/user-guide/skills/optional/mcp/mcp-fastmcp.md +++ b/website/docs/user-guide/skills/optional/mcp/mcp-fastmcp.md @@ -21,7 +21,7 @@ Build, test, inspect, install, and deploy MCP servers with FastMCP in Python. Us | License | MIT | | Platforms | linux, macos, windows | | Tags | `MCP`, `FastMCP`, `Python`, `Tools`, `Resources`, `Prompts`, `Deployment` | -| Related skills | [`native-mcp`](/docs/user-guide/skills/bundled/mcp/mcp-native-mcp), [`mcporter`](/docs/user-guide/skills/optional/mcp/mcp-mcporter) | +| Related skills | `native-mcp`, [`mcporter`](/docs/user-guide/skills/optional/mcp/mcp-mcporter) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md b/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md index 74e60876bf5..fcd20673edd 100644 --- a/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md +++ b/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md @@ -44,7 +44,7 @@ Trigger phrases: - "manage my stack credentials", "rotate this key", "upgrade my plan" - "what providers can I add?" -If the user already has a provider account, this skill can still connect it with `stripe projects link <provider>`. If the user wants to use an existing provider resource, such as an existing database or Vercel project, check provider support first; many providers currently support provisioning new resources but not importing existing ones. +If the user already has a provider account, this skill can still connect it with `stripe projects link `. If the user wants to use an existing provider resource, such as an existing database or Vercel project, check provider support first; many providers currently support provisioning new resources but not importing existing ones. ## Prerequisites diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-canvas.md b/website/docs/user-guide/skills/optional/productivity/productivity-canvas.md index e94a81b0407..11bbf7e2006 100644 --- a/website/docs/user-guide/skills/optional/productivity/productivity-canvas.md +++ b/website/docs/user-guide/skills/optional/productivity/productivity-canvas.md @@ -42,7 +42,7 @@ Read-only access to Canvas LMS for listing courses and assignments. 2. Go to **Account → Settings** (click your profile icon, then Settings) 3. Scroll to **Approved Integrations** and click **+ New Access Token** 4. Name the token (e.g., "Hermes Agent"), set an optional expiry, and click **Generate Token** -5. Copy the token and add to `~/.hermes/.env`: +5. Copy the token and add to `${HERMES_HOME:-~/.hermes}/.env`: ``` CANVAS_API_TOKEN=your_token_here diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-shopify.md b/website/docs/user-guide/skills/optional/productivity/productivity-shopify.md index 61bc95cfa66..97d4116d82d 100644 --- a/website/docs/user-guide/skills/optional/productivity/productivity-shopify.md +++ b/website/docs/user-guide/skills/optional/productivity/productivity-shopify.md @@ -40,7 +40,7 @@ The REST Admin API is legacy since 2024-04 and only receives security fixes. **U 1. In Shopify admin: **Settings → Apps and sales channels → Develop apps → Create an app**. 2. Click **Configure Admin API scopes**, select what you need (examples below), save. 3. **Install app** → the Admin API access token appears ONCE. Copy it immediately — Shopify will never show it again. Tokens start with `shpat_`. -4. Save to `~/.hermes/.env`: +4. Save to `${HERMES_HOME:-~/.hermes}/.env`: ``` SHOPIFY_ACCESS_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxx SHOPIFY_STORE_DOMAIN=my-store.myshopify.com diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-siyuan.md b/website/docs/user-guide/skills/optional/productivity/productivity-siyuan.md index 58263053fdd..777ee265d11 100644 --- a/website/docs/user-guide/skills/optional/productivity/productivity-siyuan.md +++ b/website/docs/user-guide/skills/optional/productivity/productivity-siyuan.md @@ -37,7 +37,7 @@ Use the [SiYuan](https://github.com/siyuan-note/siyuan) kernel API via curl to s 1. Install and run SiYuan (desktop or Docker) 2. Get your API token: **Settings > About > API token** -3. Store it in `~/.hermes/.env`: +3. Store it in `${HERMES_HOME:-~/.hermes}/.env`: ``` SIYUAN_TOKEN=your_token_here SIYUAN_URL=http://127.0.0.1:6806 diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-telephony.md b/website/docs/user-guide/skills/optional/productivity/productivity-telephony.md index f6c15444cbb..03d08bdc399 100644 --- a/website/docs/user-guide/skills/optional/productivity/productivity-telephony.md +++ b/website/docs/user-guide/skills/optional/productivity/productivity-telephony.md @@ -34,7 +34,7 @@ The following is the complete skill definition that Hermes loads when this skill This optional skill gives Hermes practical phone capabilities while keeping telephony out of the core tool list. It ships with a helper script, `scripts/telephony.py`, that can: -- save provider credentials into `~/.hermes/.env` +- save provider credentials into `${HERMES_HOME:-~/.hermes}/.env` - search for and buy a Twilio phone number - remember that owned number for later sessions - send SMS / MMS from the owned number @@ -121,7 +121,7 @@ Why: The skill persists telephony state in two places: -### `~/.hermes/.env` +### `${HERMES_HOME:-~/.hermes}/.env` Used for long-lived provider credentials and owned-number IDs, for example: - `TWILIO_ACCOUNT_SID` - `TWILIO_AUTH_TOKEN` @@ -258,7 +258,7 @@ python3 "$SCRIPT" save-twilio AC... auth_token_here python3 "$SCRIPT" twilio-search --country US --area-code 702 --limit 10 ``` -3. Buy it and save it into `~/.hermes/.env` + state: +3. Buy it and save it into `${HERMES_HOME:-~/.hermes}/.env` + state: ```bash python3 "$SCRIPT" twilio-buy "+17025551234" --save-env ``` @@ -420,7 +420,7 @@ After setup, you should be able to do all of the following with just this skill: 1. `diagnose` shows provider readiness and remembered state 2. search and buy a Twilio number -3. persist that number to `~/.hermes/.env` +3. persist that number to `${HERMES_HOME:-~/.hermes}/.env` 4. send an SMS from the owned number 5. poll inbound texts for the owned number later 6. place a direct Twilio call diff --git a/website/docs/user-guide/skills/optional/research/research-gitnexus-explorer.md b/website/docs/user-guide/skills/optional/research/research-gitnexus-explorer.md index 5b1f62458d1..a5f062dc373 100644 --- a/website/docs/user-guide/skills/optional/research/research-gitnexus-explorer.md +++ b/website/docs/user-guide/skills/optional/research/research-gitnexus-explorer.md @@ -21,7 +21,7 @@ Index a codebase with GitNexus and serve an interactive knowledge graph via web | License | MIT | | Platforms | linux, macos, windows | | Tags | `gitnexus`, `code-intelligence`, `knowledge-graph`, `visualization` | -| Related skills | [`native-mcp`](/docs/user-guide/skills/bundled/mcp/mcp-native-mcp), [`codebase-inspection`](/docs/user-guide/skills/bundled/github/github-codebase-inspection) | +| Related skills | `native-mcp`, [`codebase-inspection`](/docs/user-guide/skills/bundled/github/github-codebase-inspection) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/optional/research/research-qmd.md b/website/docs/user-guide/skills/optional/research/research-qmd.md index 47cf81634b8..8d145080b45 100644 --- a/website/docs/user-guide/skills/optional/research/research-qmd.md +++ b/website/docs/user-guide/skills/optional/research/research-qmd.md @@ -21,7 +21,7 @@ Search personal knowledge bases, notes, docs, and meeting transcripts locally us | License | MIT | | Platforms | macos, linux | | Tags | `Search`, `Knowledge-Base`, `RAG`, `Notes`, `MCP`, `Local-AI` | -| Related skills | [`obsidian`](/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian), [`native-mcp`](/docs/user-guide/skills/bundled/mcp/mcp-native-mcp), [`arxiv`](/docs/user-guide/skills/bundled/research/research-arxiv) | +| Related skills | [`obsidian`](/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian), `native-mcp`, [`arxiv`](/docs/user-guide/skills/bundled/research/research-arxiv) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/optional/security/security-1password.md b/website/docs/user-guide/skills/optional/security/security-1password.md index 4ed526a87b6..c2c3fccb6e9 100644 --- a/website/docs/user-guide/skills/optional/security/security-1password.md +++ b/website/docs/user-guide/skills/optional/security/security-1password.md @@ -51,7 +51,7 @@ Use this skill when the user wants secrets managed through 1Password instead of ### Service Account (recommended for Hermes) -Set `OP_SERVICE_ACCOUNT_TOKEN` in `~/.hermes/.env` (the skill will prompt for this on first load). +Set `OP_SERVICE_ACCOUNT_TOKEN` in `${HERMES_HOME:-~/.hermes}/.env` (the skill will prompt for this on first load). No desktop app needed. Supports `op read`, `op inject`, `op run`. ```bash diff --git a/website/docs/user-guide/skills/optional/security/security-godmode.md b/website/docs/user-guide/skills/optional/security/security-godmode.md index ee12f700f6d..f41975a4966 100644 --- a/website/docs/user-guide/skills/optional/security/security-godmode.md +++ b/website/docs/user-guide/skills/optional/security/security-godmode.md @@ -418,4 +418,4 @@ Claude Sonnet 4 is robust against all current techniques for clearly harmful con 9. **Always use `load_godmode.py` in execute_code** — The individual scripts (`parseltongue.py`, `godmode_race.py`, `auto_jailbreak.py`) have argparse CLI entry points with `if __name__ == '__main__'` blocks. When loaded via `exec()` in execute_code, `__name__` is `'__main__'` and argparse fires, crashing the script. The `load_godmode.py` loader handles this by setting `__name__` to a non-main value and managing sys.argv. 10. **boundary_inversion is model-version specific** — Works on Claude 3.5 Sonnet but NOT Claude Sonnet 4 or Claude 4.6. The strategy order in auto_jailbreak tries it first for Claude models, but falls through to refusal_inversion when it fails. Update the strategy order if you know the model version. 11. **Gray-area vs hard queries** — Jailbreak techniques work much better on "dual-use" queries (lock picking, security tools, chemistry) than on overtly harmful ones (phishing templates, malware). For hard queries, skip directly to ULTRAPLINIAN or use Hermes/Grok models that don't refuse. -12. **execute_code sandbox has no env vars** — When Hermes runs auto_jailbreak via execute_code, the sandbox doesn't inherit `~/.hermes/.env`. Load dotenv explicitly: `from dotenv import load_dotenv; load_dotenv(os.path.expanduser("~/.hermes/.env"))` +12. **execute_code sandbox has no env vars** — When Hermes runs auto_jailbreak via execute_code, the sandbox doesn't inherit the Hermes `.env`. Load dotenv explicitly: `import os; from dotenv import load_dotenv; load_dotenv(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), ".env"))` diff --git a/website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md b/website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md index 0698d855f5f..6c9f84bafcb 100644 --- a/website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md +++ b/website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md @@ -414,7 +414,7 @@ class TestAPISmoke: ### Token handling - Never log full tokens. Redact: `Bearer `. -- Never hardcode tokens in scripts. Read from env (`os.environ["API_TOKEN"]`) or `~/.hermes/.env`. +- Never hardcode tokens in scripts. Read from env (`os.environ["API_TOKEN"]`) or `${HERMES_HOME:-~/.hermes}/.env`. - Rotate immediately if a token surfaces in logs, error messages, or git history. ### Safe logging diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md index aed044b3099..ff9b48cef6f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md @@ -53,7 +53,6 @@ hermes skills uninstall | 技能 | 描述 | |-------|-------------| | [**blender-mcp**](/user-guide/skills/optional/creative/creative-blender-mcp) | 通过 socket 连接 blender-mcp 插件,直接从 Hermes 控制 Blender。创建 3D 对象、材质、动画,并运行任意 Blender Python(bpy)代码。适用于用户希望在 Blender 中创建或修改任何内容的场景。 | -| [**concept-diagrams**](/user-guide/skills/optional/creative/creative-concept-diagrams) | 生成扁平、极简、支持亮色/暗色模式的 SVG 图表,输出为独立 HTML 文件,采用统一的教育视觉语言,包含 9 种语义色阶、句首大写排版及自动暗色模式。最适合教育和说明类内容。 | | [**hyperframes**](/user-guide/skills/optional/creative/creative-hyperframes) | 使用 HyperFrames 创建基于 HTML 的视频合成、动态标题卡、社交叠层、字幕访谈视频、音频响应视觉效果及着色器转场。HTML 是视频的唯一来源。适用于用户希望制作任何视频内容的场景。 | | [**kanban-video-orchestrator**](/user-guide/skills/optional/creative/creative-kanban-video-orchestrator) | 规划、搭建并监控由 Hermes Kanban 支撑的多 agent 视频制作流水线。适用于用户希望制作任何类型视频的场景 — 叙事影片、产品/营销视频、MV、解说视频、ASCII/终端艺术、抽象/生成式循环等。 | | [**meme-generation**](/user-guide/skills/optional/creative/creative-meme-generation) | 通过选取模板并使用 Pillow 叠加文字来生成真实的 meme 图片,输出实际的 .png 文件。 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/skills-catalog.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/skills-catalog.md index 20773484b6c..f6f24bd932d 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/skills-catalog.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/skills-catalog.md @@ -35,7 +35,6 @@ Hermes 在执行 `hermes update` 时也会同步内置技能,但同步清单 | 技能 | 描述 | 路径 | |-------|-------------|------| -| [`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram) | 以 HTML 形式生成深色主题的 SVG 架构/云/基础设施图。 | `creative/architecture-diagram` | | [`ascii-art`](/user-guide/skills/bundled/creative/creative-ascii-art) | ASCII 艺术:pyfiglet、cowsay、boxes、图像转 ASCII。 | `creative/ascii-art` | | [`ascii-video`](/user-guide/skills/bundled/creative/creative-ascii-video) | ASCII 视频:将视频/音频转换为彩色 ASCII MP4/GIF。 | `creative/ascii-video` | | [`baoyu-infographic`](/user-guide/skills/bundled/creative/creative-baoyu-infographic) | 信息图(可视化):21 种布局 × 21 种风格。 | `creative/baoyu-infographic` | @@ -48,7 +47,6 @@ Hermes 在执行 `hermes update` 时也会同步内置技能,但同步清单 | [`p5js`](/user-guide/skills/bundled/creative/creative-p5js) | p5.js 草图:生成艺术、着色器、交互、3D。 | `creative/p5js` | | [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs) | 54 种真实设计系统(Stripe、Linear、Vercel)的 HTML/CSS 实现。 | `creative/popular-web-designs` | | [`pretext`](/user-guide/skills/bundled/creative/creative-pretext) | 使用 @chenglou/pretext 构建创意浏览器 demo——无 DOM 的文本布局,支持 ASCII 艺术、绕障碍物的排版流、文字即几何游戏、动态排版和文字驱动的生成艺术。生成单文件 HTML。 | `creative/pretext` | -| [`sketch`](/user-guide/skills/bundled/creative/creative-sketch) | 一次性 HTML 原型:生成 2-3 个设计变体供对比。 | `creative/sketch` | | [`songwriting-and-ai-music`](/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music) | 歌曲创作技巧与 Suno AI 音乐 prompt(提示词)。 | `creative/songwriting-and-ai-music` | | [`touchdesigner-mcp`](/user-guide/skills/bundled/creative/creative-touchdesigner-mcp) | 通过 twozero MCP 控制运行中的 TouchDesigner 实例——创建算子、设置参数、连接节点、执行 Python、构建实时视觉效果。36 个原生工具。 | `creative/touchdesigner-mcp` | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-architecture-diagram.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-architecture-diagram.md deleted file mode 100644 index 60846a64f16..00000000000 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-architecture-diagram.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -title: "Architecture Diagram — 深色主题 SVG 架构/云/基础设施图表(HTML 格式)" -sidebar_label: "Architecture Diagram" -description: "深色主题 SVG 架构/云/基础设施图表(HTML 格式)" ---- - -{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} - -# Architecture Diagram - -深色主题 SVG 架构/云/基础设施图表,以 HTML 格式输出。 - -## Skill 元数据 - -| | | -|---|---| -| 来源 | 内置(默认安装) | -| 路径 | `skills/creative/architecture-diagram` | -| 版本 | `1.0.0` | -| 作者 | Cocoon AI (hello@cocoon-ai.com),由 Hermes Agent 移植 | -| 许可证 | MIT | -| 平台 | linux, macos, windows | -| 标签 | `architecture`, `diagrams`, `SVG`, `HTML`, `visualization`, `infrastructure`, `cloud` | -| 相关 skill | [`concept-diagrams`](/user-guide/skills/optional/creative/creative-concept-diagrams), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw) | - -## 参考:完整 SKILL.md - -:::info -以下是 Hermes 在触发该 skill 时加载的完整 skill 定义。这是 agent 在 skill 激活时所看到的指令内容。 -::: - -# Architecture Diagram Skill - -生成专业的深色主题技术架构图,输出为包含内联 SVG 图形的独立 HTML 文件。无需外部工具、无需 API 密钥、无需渲染库——只需写入 HTML 文件并在浏览器中打开即可。 - -## 适用范围 - -**最适合:** -- 软件系统架构(前端/后端/数据库层) -- 云基础设施(VPC、区域、子网、托管服务) -- 微服务/服务网格拓扑 -- 数据库 + API 映射、部署图 -- 任何具有技术基础设施主题、适合深色网格背景风格的内容 - -**以下场景请优先考虑其他工具:** -- 物理、化学、数学、生物或其他科学学科 -- 实物对象(车辆、硬件、解剖结构、截面图) -- 平面图、叙事流程、教育/教科书风格的视觉内容 -- 手绘白板草图(建议使用 `excalidraw`) -- 动画说明(建议使用动画相关 skill) - -如果有更专业的 skill 适用于该主题,请优先使用。如果没有合适的,本 skill 也可作为通用 SVG 图表的备选方案——输出内容将带有下述深色技术风格。 - -基于 [Cocoon AI 的 architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator)(MIT 许可证)。 - -## 工作流程 - -1. 用户描述其系统架构(组件、连接关系、技术栈) -2. 按照下方设计规范生成 HTML 文件 -3. 使用 `write_file` 保存为 `.html` 文件(例如 `~/architecture-diagram.html`) -4. 用户在任意浏览器中打开——支持离线使用,无需任何依赖 - -### 输出位置 - -将图表保存到用户指定路径,或默认保存至当前工作目录: -``` -./[project-name]-architecture.html -``` - -### 预览 - -保存后,建议用户通过以下命令打开: -```bash -# macOS -open ./my-architecture.html -# Linux -xdg-open ./my-architecture.html -``` - -## 设计规范与视觉语言 - -### 颜色方案(语义映射) - -使用特定的 `rgba` 填充色和十六进制描边色对组件进行分类: - -| 组件类型 | 填充色(rgba) | 描边色(Hex) | -| :--- | :--- | :--- | -| **前端** | `rgba(8, 51, 68, 0.4)` | `#22d3ee`(cyan-400) | -| **后端** | `rgba(6, 78, 59, 0.4)` | `#34d399`(emerald-400) | -| **数据库** | `rgba(76, 29, 149, 0.4)` | `#a78bfa`(violet-400) | -| **AWS/云** | `rgba(120, 53, 15, 0.3)` | `#fbbf24`(amber-400) | -| **安全** | `rgba(136, 19, 55, 0.4)` | `#fb7185`(rose-400) | -| **消息总线** | `rgba(251, 146, 60, 0.3)` | `#fb923c`(orange-400) | -| **外部** | `rgba(30, 41, 59, 0.5)` | `#94a3b8`(slate-400) | - -### 字体与背景 -- **字体:** JetBrains Mono(等宽字体),从 Google Fonts 加载 -- **字号:** 12px(名称)、9px(副标签)、8px(注释)、7px(极小标签) -- **背景:** Slate-950(`#020617`),带有细腻的 40px 网格图案 - -```svg - - - - -``` - -## 技术实现细节 - -### 组件渲染 -组件为圆角矩形(`rx="6"`),描边宽度 1.5px。为防止箭头透过半透明填充色显现,使用**双矩形遮罩技术**: -1. 绘制不透明背景矩形(`#0f172a`) -2. 在其上方绘制半透明样式矩形 - -### 连接规则 -- **Z 轴顺序:** 在 SVG 早期绘制箭头(在网格之后),使其渲染在组件框的下方 -- **箭头头部:** 通过 SVG marker 定义 -- **安全流:** 使用 rose 色(`#fb7185`)虚线 -- **边界:** - - *安全组:* 虚线(`4,4`),rose 色 - - *区域:* 大虚线(`8,4`),amber 色,`rx="12"` - -### 间距与布局规则 -- **标准高度:** 60px(服务);80–120px(大型组件) -- **垂直间距:** 组件之间最小 40px -- **消息总线:** 必须放置在服务之间的间隙中,不得与其重叠 -- **图例位置:** **关键。** 必须放置在所有边界框的外部。计算所有边界的最低 Y 坐标,并将图例放置在其下方至少 20px 处。 - -## 文档结构 - -生成的 HTML 文件遵循四段式布局: -1. **页眉:** 带有脉冲点指示器的标题和副标题 -2. **主 SVG:** 包含在圆角边框卡片中的图表 -3. **摘要卡片:** 图表下方的三张卡片网格,用于展示高层次详情 -4. **页脚:** 简洁的元数据信息 - -### 信息卡片模式 -```html -
-
-
-

Title

-
-
    -
  • • Item one
  • -
  • • Item two
  • -
-
-``` - -## 输出要求 -- **单文件:** 一个自包含的 `.html` 文件 -- **无外部依赖:** 所有 CSS 和 SVG 必须内联(Google Fonts 除外) -- **无 JavaScript:** 所有动画(如脉冲点)使用纯 CSS 实现 -- **兼容性:** 必须在任何现代浏览器中正确渲染 - -## 模板参考 - -加载完整 HTML 模板以获取精确的结构、CSS 和 SVG 组件示例: - -``` -skill_view(name="architecture-diagram", file_path="templates/template.html") -``` - -模板包含每种组件类型(前端、后端、数据库、云、安全)、箭头样式(标准、虚线、曲线)、安全组、区域边界和图例的完整示例——生成图表时请以此作为结构参考。 \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-claude-design.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-claude-design.md index 6d1b7529ab3..7aaa2d26f2d 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-claude-design.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-claude-design.md @@ -21,7 +21,7 @@ description: "设计一次性 HTML 制品(落地页、幻灯片、原型)" | 许可证 | MIT | | 平台 | linux, macos, windows | | 标签 | `design`, `html`, `prototype`, `ux`, `ui`, `creative`, `artifact`, `deck`, `motion`, `design-system` | -| 相关 skill | [`design-md`](/user-guide/skills/bundled/creative/creative-design-md), [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram) | +| 相关 skill | [`design-md`](/user-guide/skills/bundled/creative/creative-design-md), [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`html-artifact`](/user-guide/skills/bundled/creative/creative-html-artifact) | ## 参考:完整 SKILL.md diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-design-md.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-design-md.md index 4d21eb7f671..e9fc5aade25 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-design-md.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-design-md.md @@ -21,7 +21,7 @@ description: "编写/验证/导出 Google 的 DESIGN" | 许可证 | MIT | | 平台 | linux, macos, windows | | 标签 | `design`, `design-system`, `tokens`, `ui`, `accessibility`, `wcag`, `tailwind`, `dtcg`, `google` | -| 相关 skill | [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs), [`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram) | +| 相关 skill | [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs), [`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`html-artifact`](/user-guide/skills/bundled/creative/creative-html-artifact) | ## 参考:完整 SKILL.md diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-pretext.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-pretext.md index 83dadb74c8d..243e776f6a7 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-pretext.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-pretext.md @@ -21,7 +21,7 @@ description: "适用于使用 @chenglou/pretext 构建创意浏览器演示 — | 许可证 | MIT | | 平台 | linux, macos, windows | | 标签 | `creative-coding`, `typography`, `pretext`, `ascii-art`, `canvas`, `generative`, `text-layout`, `kinetic-typography` | -| 相关 skill | [`p5js`](/user-guide/skills/bundled/creative/creative-p5js), [`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram) | +| 相关 skill | [`p5js`](/user-guide/skills/bundled/creative/creative-p5js), [`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`html-artifact`](/user-guide/skills/bundled/creative/creative-html-artifact) | ## 参考:完整 SKILL.md diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-sketch.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-sketch.md deleted file mode 100644 index 6478c87f362..00000000000 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-sketch.md +++ /dev/null @@ -1,238 +0,0 @@ ---- -title: "Sketch — 一次性 HTML 原型:2-3 个设计方案对比" -sidebar_label: "Sketch" -description: "一次性 HTML 原型:2-3 个设计方案对比" ---- - -{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} - -# Sketch - -一次性 HTML 原型:2-3 个设计方案对比。 - -## Skill 元数据 - -| | | -|---|---| -| 来源 | 内置(默认安装) | -| 路径 | `skills/creative/sketch` | -| 版本 | `1.0.0` | -| 作者 | Hermes Agent(改编自 gsd-build/get-shit-done) | -| 许可证 | MIT | -| 平台 | linux, macos, windows | -| 标签 | `sketch`, `mockup`, `design`, `ui`, `prototype`, `html`, `variants`, `exploration`, `wireframe`, `comparison` | -| 相关 skill | [`spike`](/user-guide/skills/bundled/software-development/software-development-spike), [`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design), [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw) | - -## 参考:完整 SKILL.md - -:::info -以下是 Hermes 在触发该 skill 时加载的完整 skill 定义。这是 agent 在 skill 激活时所看到的指令内容。 -::: - -# Sketch - -当用户希望**在确定方向之前先看到设计效果**时使用此 skill——以一次性 HTML 原型的形式探索 UI/UX 想法。目的是生成 2-3 个可交互的方案,让用户并排对比视觉方向,而非产出可交付的代码。 - -当用户说以下内容时加载此 skill:"sketch this screen"、"show me what X could look like"、"compare layout A vs B"、"give me 2-3 takes on this UI"、"let me see some variants"、"mockup this before I build"。 - -## 不适用场景 - -- 用户需要生产级组件——使用 `claude-design` 或正式构建 -- 用户需要精良的一次性 HTML 产物(落地页、幻灯片)——使用 `claude-design` -- 用户需要图表——使用 `excalidraw`、`architecture-diagram` -- 设计已确定——直接构建即可 - -## 如果用户安装了完整的 GSD 系统 - -如果 `gsd-sketch` 作为同级 skill 出现(通过 `npx get-shit-done-cc --hermes` 安装),优先使用 **`gsd-sketch`** 以获得完整工作流:持久化的 `.planning/sketches/` 目录(含 MANIFEST)、前沿模式分析、跨历史草图的一致性审计,以及与 GSD 其余部分的集成。本 skill 是轻量级独立版本——无状态机制的一次性草图。 - -## 核心方法 - -``` -intake → variants → head-to-head → pick winner (or iterate) -``` - -### 1. Intake(如果用户已提供足够信息则跳过) - -在生成方案之前,获取三项信息——每次只问一个问题,不要一次全问: - -1. **感觉。** "这个应该给人什么感觉?形容词、情绪、氛围。"——*"calm, editorial, like Linear"* 比 *"minimal"* 更有参考价值。 -2. **参考。** "哪些 app、网站或产品接近你想象中的感觉?"——实际参考比抽象描述更有效。 -3. **核心操作。** "用户在这个页面上最重要的单一操作是什么?"——所有方案都应服务于此;否则只是装饰。 - -每次回答后简短复述,再问下一个问题。如果用户已一次性提供了全部三项,直接跳到方案生成。 - -### 2. 方案(2-3 个,不少于 1 个,极少超过 4 个) - -一次性生成 **2-3 个方案**。每个方案是一个完整的独立 HTML 文件。不要描述方案——直接构建。目的是对比。 - -每个方案应采取**不同的设计立场**,而非不同的像素值。三种有效的方案维度: - -- **密度:** 紧凑 / 宽松 / 极密(选两个对比极端) -- **重点:** 内容优先 / 操作优先 / 工具优先 -- **美学:** 编辑风格 / 实用主义 / 趣味性 -- **布局:** 单列 / 侧边栏 / 分屏 -- **基调:** 卡片式 / 纯内容 / 文档风格 - -选定一个维度并从中拉开差距。两个仅在强调色上不同的方案是无效的——用户无法区分。 - -**方案命名:** 描述立场,而非编号。 - - -``` -sketches/ -├── 001-calm-editorial/ -│ ├── index.html -│ └── README.md -├── 001-utilitarian-dense/ -│ ├── index.html -│ └── README.md -└── 001-playful-split/ - ├── index.html - └── README.md -``` - - -### 3. 制作真实的 HTML - -每个方案是一个**单一自包含的 HTML 文件**: - -- 内联 ` -``` - -### 4. 方案 README - -每个方案的 `README.md` 回答以下内容: - -```markdown -## Variant: {stance name} - -### Design stance -One sentence on the principle driving this variant. - -### Key choices -- Layout: ... -- Typography: ... -- Color: ... -- Interaction: ... - -### Trade-offs -- Strong at: ... -- Weak at: ... - -### Best for -- The kind of user or use case this variant actually serves -``` - -### 5. 正面对比 - -所有方案构建完成后,以对比形式呈现。不要只是罗列——**给出观点**: - -```markdown -## Three takes on the home screen - -| Dimension | Calm editorial | Utilitarian dense | Playful split | -|-----------|----------------|-------------------|---------------| -| Density | Low | High | Medium | -| Primary action visibility | Low | High | Medium | -| Scan-ability | High | Medium | Low | -| Feel | Calm, trusted | Sharp, tool-like | Inviting, energetic | - -**My take:** Utilitarian dense for power users, calm editorial for content-forward audiences. Playful split is weakest — tries to do both and commits to neither. -``` - -让用户选出胜出方案,或将两个方案合并为混合版,或要求新一轮迭代。 - -## 主题化(当项目有视觉标识时) - -如果用户有现有主题(颜色、字体、token),将共享 token 放入 `sketches/themes/tokens.css` 并在每个方案中 `@import`。保持 token 精简: - -```css -/* sketches/themes/tokens.css */ -:root { - --color-bg: #fafafa; - --color-fg: #1a1a1a; - --color-accent: #0066ff; - --color-muted: #666; - --radius: 8px; - --font-display: "Inter", sans-serif; - --font-body: -apple-system, BlinkMacSystemFont, sans-serif; -} -``` - -不要对一次性草图过度 token 化——三种颜色加一种字体通常已足够。 - -## 交互基准 - -当用户能够完成以下操作时,草图的交互程度即为合格: - -1. **点击主要操作**并看到可见的变化(状态变更、模态框、toast、导航模拟) -2. **看到一个有意义的状态转换**(筛选列表、切换模式、展开/收起面板) -3. **悬停可识别的交互元素**(按钮、行、标签页) - -超过此程度是对一次性草图的过度工程化。低于此程度则只是截图。 - -## 前沿模式(决定下一步草图内容) - -如果草图已存在且用户询问"接下来应该草图什么?": - -- **一致性缺口**——来自不同草图的两个胜出方案做出了独立选择,尚未组合在一起 -- **未草图的页面**——被引用但从未探索过 -- **状态覆盖**——已草图了正常路径,但未覆盖空状态 / 加载中 / 错误 / 千条数据 -- **响应式缺口**——在某一视口下验证过;在移动端 / 超宽屏下是否成立? -- **交互模式**——静态布局已存在;过渡动效、拖拽、滚动行为尚未探索 - -提出 2-4 个命名候选项,让用户选择。 - -## 输出 - -- 在仓库根目录创建 `sketches/`(如果用户使用 GSD 约定则为 `.planning/sketches/`) -- 每个方案一个子目录:`NNN-stance-name/index.html` + `README.md` -- 告知用户如何打开:macOS 上用 `open sketches/001-calm-editorial/index.html`,Linux 上用 `xdg-open`,Windows 上用 `start` -- 保持方案的一次性特性——如果你觉得有必要保留某个草图,应将其提升为真实项目代码,而非作为资产保管 - -**单个方案的典型工具调用序列:** - -``` -terminal("mkdir -p sketches/001-calm-editorial") -write_file("sketches/001-calm-editorial/index.html", "...") -write_file("sketches/001-calm-editorial/README.md", "## Variant: Calm editorial\n...") -browser_navigate(url="file://$(pwd)/sketches/001-calm-editorial/index.html") -browser_vision(question="How does this look? Any obvious layout issues?") -``` - -对每个方案重复上述步骤,然后呈现对比表格。 - -## 致谢 - -改编自 GSD(Get Shit Done)项目的 `/gsd-sketch` 工作流——MIT © 2025 Lex Christopherson([gsd-build/get-shit-done](https://github.com/gsd-build/get-shit-done))。完整 GSD 系统提供持久化草图状态、主题/方案模式参考及一致性审计工作流;通过 `npx get-shit-done-cc --hermes --global` 安装。 \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-spike.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-spike.md index e5486edd0d3..be869779937 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-spike.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-spike.md @@ -21,7 +21,7 @@ description: "在构建前验证想法的一次性实验" | 许可证 | MIT | | 平台 | linux, macos, windows | | 标签 | `spike`, `prototype`, `experiment`, `feasibility`, `throwaway`, `exploration`, `research`, `planning`, `mvp`, `proof-of-concept` | -| 相关 skill | [`sketch`](/user-guide/skills/bundled/creative/creative-sketch)、[`writing-plans`](/user-guide/skills/bundled/software-development/software-development-writing-plans)、[`subagent-driven-development`](/user-guide/skills/bundled/software-development/software-development-subagent-driven-development)、[`plan`](/user-guide/skills/bundled/software-development/software-development-plan) | +| 相关 skill | [`html-artifact`](/user-guide/skills/bundled/creative/creative-html-artifact)、[`writing-plans`](/user-guide/skills/bundled/software-development/software-development-writing-plans)、[`subagent-driven-development`](/user-guide/skills/bundled/software-development/software-development-subagent-driven-development)、[`plan`](/user-guide/skills/bundled/software-development/software-development-plan) | ## 参考:完整 SKILL.md diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-concept-diagrams.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-concept-diagrams.md deleted file mode 100644 index 405f658a22b..00000000000 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-concept-diagrams.md +++ /dev/null @@ -1,379 +0,0 @@ ---- -title: "概念图" -sidebar_label: "概念图" -description: "以统一的教育视觉语言生成扁平、简约、支持明暗模式的 SVG 图表,输出为独立 HTML 文件,包含 9 种语义色阶、句首大写排版及自动暗色模式。..." ---- - -{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} - -# 概念图 - -以统一的教育视觉语言生成扁平、简约、支持明暗模式的 SVG 图表,输出为独立 HTML 文件,包含 9 种语义色阶、句首大写排版及自动暗色模式。最适合教育类和非软件类视觉内容——物理装置、化学机制、数学曲线、实物(飞机、涡轮机、智能手机、机械表)、解剖图、平面图、截面图、叙事流程(X 的生命周期、Y 的过程)、中心辐射型系统集成(智慧城市、IoT)以及爆炸分层视图。若已有更专业的 skill 适用于该主题(专用软件/云架构、手绘草图、动画说明等),优先使用那些 skill——否则本 skill 也可作为通用 SVG 图表的备选方案,具备简洁的教育风格外观。内置 15 个示例图表。 - -## Skill 元数据 - -| | | -|---|---| -| 来源 | 可选 — 通过 `hermes skills install official/creative/concept-diagrams` 安装 | -| 路径 | `optional-skills/creative/concept-diagrams` | -| 版本 | `0.1.0` | -| 作者 | v1k22(原始 PR),移植至 hermes-agent | -| 许可证 | MIT | -| 平台 | linux, macos, windows | -| 标签 | `diagrams`, `svg`, `visualization`, `education`, `physics`, `chemistry`, `engineering` | -| 相关 skills | [`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), `generative-widgets` | - -## 参考:完整 SKILL.md - -:::info -以下是 Hermes 在触发本 skill 时加载的完整 skill 定义。这是 agent 在 skill 激活时所看到的指令内容。 -::: - -# 概念图 - -使用统一的扁平、简约设计系统生成生产级 SVG 图表。输出为单个自包含 HTML 文件,可在任何现代浏览器中一致渲染,并自动支持明暗模式。 - -## 适用范围 - -**最适合:** -- 物理装置、化学机制、数学曲线、生物学 -- 实物(飞机、涡轮机、智能手机、机械表、细胞) -- 解剖图、截面图、爆炸分层视图 -- 平面图、建筑改造图 -- 叙事流程(X 的生命周期、Y 的过程) -- 中心辐射型系统集成(智慧城市、IoT 网络、电网) -- 任何领域的教育/教科书风格视觉内容 -- 定量图表(分组柱状图、能量曲线) - -**优先考虑其他方案:** -- 具有深色科技风格的专用软件/云基础设施架构(如有 `architecture-diagram` 可用,优先使用) -- 手绘白板草图(如有 `excalidraw` 可用,优先使用) -- 动画说明或视频输出(考虑动画 skill) - -若已有更专业的 skill 适用于该主题,优先使用。若无合适选项,本 skill 可作为通用 SVG 图表备选方案——输出将呈现下文描述的简洁教育风格,适用于几乎任何主题。 - -## 工作流程 - -1. 确定图表类型(见下方"图表类型")。 -2. 使用设计系统规则布局组件。 -3. 使用 `templates/template.html` 作为包装器编写完整 HTML 页面——将 SVG 粘贴到模板中 `` 的位置。 -4. 保存为独立 `.html` 文件(例如 `~/my-diagram.html` 或 `./my-diagram.html`)。 -5. 用户直接在浏览器中打开——无需服务器,无需依赖。 - -可选:若用户需要可浏览的多图表画廊,参见底部"本地预览服务器"。 - -加载 HTML 模板: -``` -skill_view(name="concept-diagrams", file_path="templates/template.html") -``` - -模板内嵌完整 CSS 设计系统(`c-*` 颜色类、文本类、明暗变量、箭头标记样式)。你生成的 SVG 依赖这些类存在于宿主页面中。 - ---- - -## 设计系统 - -### 设计理念 - -- **扁平**:无渐变、无投影、无模糊、无发光、无霓虹效果。 -- **简约**:只展示核心内容,框内无装饰性图标。 -- **一致**:每张图表使用相同的颜色、间距、排版和描边宽度。 -- **暗色模式就绪**:所有颜色通过 CSS 类自动适配——无需为每种模式单独编写 SVG。 - -### 调色板 - -9 种色阶,每种 7 个色阶值。将类名放在 `` 或形状元素上;模板 CSS 自动处理明暗两种模式。 - -| 类名 | 50(最浅) | 100 | 200 | 400 | 600 | 800 | 900(最深) | -|------------|---------------|---------|---------|---------|---------|---------|---------------| -| `c-purple` | #EEEDFE | #CECBF6 | #AFA9EC | #7F77DD | #534AB7 | #3C3489 | #26215C | -| `c-teal` | #E1F5EE | #9FE1CB | #5DCAA5 | #1D9E75 | #0F6E56 | #085041 | #04342C | -| `c-coral` | #FAECE7 | #F5C4B3 | #F0997B | #D85A30 | #993C1D | #712B13 | #4A1B0C | -| `c-pink` | #FBEAF0 | #F4C0D1 | #ED93B1 | #D4537E | #993556 | #72243E | #4B1528 | -| `c-gray` | #F1EFE8 | #D3D1C7 | #B4B2A9 | #888780 | #5F5E5A | #444441 | #2C2C2A | -| `c-blue` | #E6F1FB | #B5D4F4 | #85B7EB | #378ADD | #185FA5 | #0C447C | #042C53 | -| `c-green` | #EAF3DE | #C0DD97 | #97C459 | #639922 | #3B6D11 | #27500A | #173404 | -| `c-amber` | #FAEEDA | #FAC775 | #EF9F27 | #BA7517 | #854F0B | #633806 | #412402 | -| `c-red` | #FCEBEB | #F7C1C1 | #F09595 | #E24B4A | #A32D2D | #791F1F | #501313 | - -#### 颜色分配规则 - -颜色编码**语义**,而非顺序。切勿像彩虹一样循环使用颜色。 - -- 按**类别**对节点分组——同类型的所有节点共用一种颜色。 -- 对中性/结构性节点(起点、终点、通用步骤、用户)使用 `c-gray`。 -- 每张图表使用 **2-3 种颜色**,而非 6 种以上。 -- 通用类别优先使用 `c-purple`、`c-teal`、`c-coral`、`c-pink`。 -- 将 `c-blue`、`c-green`、`c-amber`、`c-red` 保留用于语义含义(信息、成功、警告、错误)。 - -明暗色阶映射(由模板 CSS 处理——直接使用类名即可): -- 亮色模式:50 填充 + 600 描边 + 800 标题 / 600 副标题 -- 暗色模式:800 填充 + 200 描边 + 100 标题 / 200 副标题 - -### 排版 - -只有两种字体大小,不得例外。 - -| 类名 | 大小 | 字重 | 用途 | -|-------|------|--------|-----| -| `th` | 14px | 500 | 节点标题、区域标签 | -| `ts` | 12px | 400 | 副标题、描述、箭头标签 | -| `t` | 14px | 400 | 通用文本 | - -- **始终使用句首大写。** 禁止首字母大写(Title Case),禁止全大写(ALL CAPS)。 -- 每个 `` 必须带有类名(`t`、`ts` 或 `th`),不得有无类名的文本。 -- 框内所有文本使用 `dominant-baseline="central"`。 -- 框内居中文本使用 `text-anchor="middle"`。 - -**宽度估算(近似值):** -- 14px 字重 500:每字符约 8px -- 12px 字重 400:每字符约 6.5px -- 始终验证:`box_width >= (字符数 × px/字符) + 48`(每侧 24px 内边距) - -### 间距与布局 - -- **ViewBox**:`viewBox="0 0 680 H"`,其中 H = 内容高度 + 40px 缓冲。 -- **安全区域**:x=40 至 x=640,y=40 至 y=(H-40)。 -- **框间距**:最小 60px。 -- **框内边距**:水平 24px,垂直 12px。 -- **箭头间隙**:箭头与框边缘之间 10px。 -- **单行框**:高度 44px。 -- **双行框**:高度 56px,标题与副标题基线间距 18px。 -- **容器内边距**:每个容器内部最小 20px。 -- **最大嵌套层级**:2-3 层。在 680px 宽度下更深的嵌套会难以阅读。 - -### 描边与形状 - -- **描边宽度**:所有节点边框 0.5px,不得使用 1px 或 2px。 -- **矩形圆角**:节点使用 `rx="8"`,内层容器使用 `rx="12"`,外层容器使用 `rx="16"` 至 `rx="20"`。 -- **连接路径**:必须设置 `fill="none"`,否则 SVG 默认填充为黑色。 - -### 箭头标记 - -在**每个** SVG 开头包含以下 `` 块: - -```xml - - - - - -``` - -在线条上使用 `marker-end="url(#arrow)"`。箭头通过 `context-stroke` 继承线条颜色。 - -### CSS 类(由模板提供) - -模板页面提供: - -- 文本:`.t`、`.ts`、`.th` -- 中性:`.box`、`.arr`、`.leader`、`.node` -- 色阶:`.c-purple`、`.c-teal`、`.c-coral`、`.c-pink`、`.c-gray`、`.c-blue`、`.c-green`、`.c-amber`、`.c-red`(均自动支持明暗模式) - -你**无需**重新定义这些类——直接在 SVG 中应用即可。模板文件包含完整的 CSS 定义。 - ---- - -## SVG 样板代码 - -模板页面中的每个 SVG 均以如下结构开头: - -```xml - - - - - - - - - - -``` - -将 `{HEIGHT}` 替换为实际计算高度(最后一个元素底部 + 40px)。 - -### 节点模式 - -**单行节点(44px):** -```xml - - - Service name - -``` - -**双行节点(56px):** -```xml - - - Service name - Short description - -``` - -**连接线(无标签):** -```xml - -``` - -**容器(虚线或实线):** -```xml - - - Container label - Subtitle info - -``` - ---- - -## 图表类型 - -根据主题选择合适的布局: - -1. **流程图** — CI/CD 流水线、请求生命周期、审批工作流、数据处理。单向流(从上到下或从左到右),每行最多 4-5 个节点。 -2. **结构/包含图** — 云基础设施嵌套、分层系统架构。大型外层容器包含内层区域,虚线矩形表示逻辑分组。 -3. **API/端点映射** — REST 路由、GraphQL schema。从根节点树状展开,分支到资源组,每组包含端点节点。 -4. **微服务拓扑** — 服务网格、事件驱动系统。服务作为节点,箭头表示通信模式,消息队列位于服务之间。 -5. **数据流图** — ETL 流水线、流式架构。从数据源经处理流向数据汇,方向从左到右。 -6. **实物/结构图** — 交通工具、建筑、硬件、解剖图。使用与实物形态匹配的形状——弯曲体用 ``,锥形用 ``,圆柱部件用 ``/``,隔间用嵌套 ``。参见 `references/physical-shape-cookbook.md`。 -7. **基础设施/系统集成图** — 智慧城市、IoT 网络、多域系统。中心辐射布局,中央平台连接各子系统。按系统使用语义线型(`.data-line`、`.power-line`、`.water-pipe`、`.road`)。参见 `references/infrastructure-patterns.md`。 -8. **UI/仪表盘原型** — 管理面板、监控仪表盘。屏幕框架内嵌套图表/仪表/指示器元素。参见 `references/dashboard-patterns.md`。 - -对于实物图、基础设施图和仪表盘图,生成前请先加载对应的参考文件——每个文件提供现成的 CSS 类和形状原语。 - ---- - -## 验证清单 - -在最终确定任何 SVG 之前,验证以下**所有**项目: - -1. 每个 `` 都有类名 `t`、`ts` 或 `th`。 -2. 框内每个 `` 都有 `dominant-baseline="central"`。 -3. 用作箭头的每个连接 `` 或 `` 都有 `fill="none"`。 -4. 没有箭头线穿过无关的框。 -5. 14px 文本:`box_width >= (最长标签字符数 × 8) + 48`。 -6. 12px 文本:`box_width >= (最长标签字符数 × 6.5) + 48`。 -7. ViewBox 高度 = 最底部元素 + 40px。 -8. 所有内容在 x=40 至 x=640 范围内。 -9. 颜色类(`c-*`)放在 `` 或形状元素上,不得放在 `` 连接线上。 -10. 箭头 `` 块存在。 -11. 无渐变、投影、模糊或发光效果。 -12. 所有节点边框描边宽度为 0.5px。 - ---- - -## 输出与预览 - -### 默认:独立 HTML 文件 - -写入单个 `.html` 文件,用户可直接打开。无需服务器,无需依赖,离线可用。模式: - -```python -# 1. Load the template -template = skill_view("concept-diagrams", "templates/template.html") - -# 2. Fill in title, subtitle, and paste your SVG -html = template.replace( - "", "SN2 reaction mechanism" -).replace( - "", "Bimolecular nucleophilic substitution" -).replace( - "", svg_content -) - -# 3. Write to a user-chosen path (or ./ by default) -write_file("./sn2-mechanism.html", html) -``` - -告知用户如何打开: - -``` -# macOS -open ./sn2-mechanism.html -# Linux -xdg-open ./sn2-mechanism.html -``` - -### 可选:本地预览服务器(多图表画廊) - -仅在用户明确需要可浏览的多图表画廊时使用。 - -**规则:** -- 仅绑定到 `127.0.0.1`,绝不使用 `0.0.0.0`。在共享网络上将图表暴露在所有网络接口上存在安全风险。 -- 选择空闲端口(不得硬编码),并告知用户所选 URL。 -- 服务器是可选的、需用户主动选择的——优先使用独立 HTML 文件。 - -推荐模式(让操作系统选择空闲的临时端口): - -```bash -# Put each diagram in its own folder under .diagrams/ -mkdir -p .diagrams/sn2-mechanism -# ...write .diagrams/sn2-mechanism/index.html... - -# Serve on loopback only, free port -cd .diagrams && python3 -c " -import http.server, socketserver -with socketserver.TCPServer(('127.0.0.1', 0), http.server.SimpleHTTPRequestHandler) as s: - print(f'Serving at http://127.0.0.1:{s.server_address[1]}/') - s.serve_forever() -" & -``` - -若用户坚持使用固定端口,使用 `127.0.0.1:`——仍然不得使用 `0.0.0.0`。说明如何停止服务器(`kill %1` 或 `pkill -f "http.server"`)。 - ---- - -## 示例参考 - -`examples/` 目录内置 15 个完整、经过测试的图表。在编写同类型新图表之前,先浏览这些示例以获取可用模式: - -| 文件 | 类型 | 演示内容 | -|------|------|--------------| -| `hospital-emergency-department-flow.md` | 流程图 | 带语义颜色的优先级路由 | -| `feature-film-production-pipeline.md` | 流程图 | 分阶段工作流、水平子流程 | -| `automated-password-reset-flow.md` | 流程图 | 带错误分支的认证流程 | -| `autonomous-llm-research-agent-flow.md` | 流程图 | 回环箭头、决策分支 | -| `place-order-uml-sequence.md` | 时序图 | UML 时序图风格 | -| `commercial-aircraft-structure.md` | 实物图 | 使用路径、多边形、椭圆绘制真实形状 | -| `wind-turbine-structure.md` | 实物截面图 | 地下/地上分离、颜色编码 | -| `smartphone-layer-anatomy.md` | 爆炸视图 | 左右交替标签、分层组件 | -| `apartment-floor-plan-conversion.md` | 平面图 | 墙体、门、虚线红色标注改造方案 | -| `banana-journey-tree-to-smoothie.md` | 叙事流程 | 蜿蜒路径、渐进状态变化 | -| `cpu-ooo-microarchitecture.md` | 硬件流水线 | 扇出、内存层次侧边栏 | -| `sn2-reaction-mechanism.md` | 化学图 | 分子、弯曲箭头、能量曲线 | -| `smart-city-infrastructure.md` | 中心辐射图 | 每个系统使用语义线型 | -| `electricity-grid-flow.md` | 多阶段流程图 | 电压层次、流向标记 | -| `ml-benchmark-grouped-bar-chart.md` | 图表 | 分组柱状图、双轴 | - -使用以下命令加载任意示例: -``` -skill_view(name="concept-diagrams", file_path="examples/") -``` - ---- - -## 快速参考:何时使用何种图表 - -| 用户说 | 图表类型 | 建议颜色 | -|-----------|--------------|------------------| -| "展示流水线" | 流程图 | 灰色起止点,紫色步骤,红色错误,青色部署 | -| "画数据流" | 数据流水线(从左到右) | 灰色数据源,紫色处理,青色数据汇 | -| "可视化系统" | 结构图(包含关系) | 紫色容器,青色服务,珊瑚色数据 | -| "映射端点" | API 树状图 | 紫色根节点,每个资源组一种色阶 | -| "展示服务" | 微服务拓扑 | 灰色入口,青色服务,紫色总线,珊瑚色 worker | -| "画飞机/交通工具" | 实物图 | 路径、多边形、椭圆绘制真实形状 | -| "智慧城市/IoT" | 中心辐射集成图 | 每个子系统使用语义线型 | -| "展示仪表盘" | UI 原型 | 深色屏幕,图表颜色:青色、紫色、珊瑚色告警 | -| "电网/电力" | 多阶段流程图 | 电压层次(高/中/低压线宽) | -| "风力涡轮机/涡轮机" | 实物截面图 | 基础 + 塔筒截面 + 机舱颜色编码 | -| "X 的旅程/生命周期" | 叙事流程 | 蜿蜒路径,渐进状态变化 | -| "X 的层次/爆炸图" | 爆炸分层视图 | 垂直堆叠,交替标签 | -| "CPU/流水线" | 硬件流水线 | 垂直阶段,扇出到执行端口 | -| "平面图/公寓" | 平面图 | 墙体、门,虚线红色标注改造方案 | -| "反应机制" | 化学图 | 原子、化学键、弯曲箭头、过渡态、能量曲线 | \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md index 15bbaaec8d1..b8f0a7946c1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md @@ -21,7 +21,7 @@ description: "规划、搭建并监控由 Hermes Kanban 支撑的多智能体视 | 许可证 | MIT | | 平台 | linux, macos, windows | | 标签 | `video`, `kanban`, `multi-agent`, `orchestration`, `production-pipeline` | -| 相关技能 | [`kanban-orchestrator`](/user-guide/skills/bundled/devops/devops-kanban-orchestrator)、[`kanban-worker`](/user-guide/skills/bundled/devops/devops-kanban-worker)、[`ascii-video`](/user-guide/skills/bundled/creative/creative-ascii-video)、[`manim-video`](/user-guide/skills/bundled/creative/creative-manim-video)、[`p5js`](/user-guide/skills/bundled/creative/creative-p5js)、[`comfyui`](/user-guide/skills/bundled/creative/creative-comfyui)、[`touchdesigner-mcp`](/user-guide/skills/bundled/creative/creative-touchdesigner-mcp)、[`blender-mcp`](/user-guide/skills/optional/creative/creative-blender-mcp)、[`pixel-art`](/user-guide/skills/bundled/creative/creative-pixel-art)、[`ascii-art`](/user-guide/skills/bundled/creative/creative-ascii-art)、[`songwriting-and-ai-music`](/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music)、[`heartmula`](/user-guide/skills/bundled/media/media-heartmula)、[`songsee`](/user-guide/skills/bundled/media/media-songsee)、[`spotify`](/user-guide/skills/bundled/media/media-spotify)、[`youtube-content`](/user-guide/skills/bundled/media/media-youtube-content)、[`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design)、[`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw)、[`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram)、[`concept-diagrams`](/user-guide/skills/optional/creative/creative-concept-diagrams)、[`baoyu-comic`](/user-guide/skills/bundled/creative/creative-baoyu-comic)、[`baoyu-infographic`](/user-guide/skills/bundled/creative/creative-baoyu-infographic)、[`humanizer`](/user-guide/skills/bundled/creative/creative-humanizer)、[`gif-search`](/user-guide/skills/bundled/media/media-gif-search)、[`meme-generation`](/user-guide/skills/optional/creative/creative-meme-generation) | +| 相关技能 | [`kanban-orchestrator`](/user-guide/skills/bundled/devops/devops-kanban-orchestrator)、[`kanban-worker`](/user-guide/skills/bundled/devops/devops-kanban-worker)、[`ascii-video`](/user-guide/skills/bundled/creative/creative-ascii-video)、[`manim-video`](/user-guide/skills/bundled/creative/creative-manim-video)、[`p5js`](/user-guide/skills/bundled/creative/creative-p5js)、[`comfyui`](/user-guide/skills/bundled/creative/creative-comfyui)、[`touchdesigner-mcp`](/user-guide/skills/bundled/creative/creative-touchdesigner-mcp)、[`blender-mcp`](/user-guide/skills/optional/creative/creative-blender-mcp)、[`pixel-art`](/user-guide/skills/bundled/creative/creative-pixel-art)、[`ascii-art`](/user-guide/skills/bundled/creative/creative-ascii-art)、[`songwriting-and-ai-music`](/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music)、[`heartmula`](/user-guide/skills/bundled/media/media-heartmula)、[`songsee`](/user-guide/skills/bundled/media/media-songsee)、[`spotify`](/user-guide/skills/bundled/media/media-spotify)、[`youtube-content`](/user-guide/skills/bundled/media/media-youtube-content)、[`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design)、[`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw)、[`html-artifact`](/user-guide/skills/bundled/creative/creative-html-artifact)、[`baoyu-comic`](/user-guide/skills/bundled/creative/creative-baoyu-comic)、[`baoyu-infographic`](/user-guide/skills/bundled/creative/creative-baoyu-infographic)、[`humanizer`](/user-guide/skills/bundled/creative/creative-humanizer)、[`gif-search`](/user-guide/skills/bundled/media/media-gif-search)、[`meme-generation`](/user-guide/skills/optional/creative/creative-meme-generation) | ## 参考:完整 SKILL.md diff --git a/website/sidebars.ts b/website/sidebars.ts index dec160700e2..b8efcef0624 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -150,7 +150,6 @@ const sidebars: SidebarsConfig = { 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code', 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex', 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent', - 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-kanban-codex-lane', 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-opencode', ], }, @@ -160,7 +159,6 @@ const sidebars: SidebarsConfig = { key: 'skills-bundled-creative', collapsed: true, items: [ - 'user-guide/skills/bundled/creative/creative-architecture-diagram', 'user-guide/skills/bundled/creative/creative-ascii-art', 'user-guide/skills/bundled/creative/creative-ascii-video', 'user-guide/skills/bundled/creative/creative-baoyu-infographic', @@ -168,12 +166,12 @@ const sidebars: SidebarsConfig = { 'user-guide/skills/bundled/creative/creative-comfyui', 'user-guide/skills/bundled/creative/creative-design-md', 'user-guide/skills/bundled/creative/creative-excalidraw', + 'user-guide/skills/bundled/creative/creative-html-artifact', 'user-guide/skills/bundled/creative/creative-humanizer', 'user-guide/skills/bundled/creative/creative-manim-video', 'user-guide/skills/bundled/creative/creative-p5js', 'user-guide/skills/bundled/creative/creative-popular-web-designs', 'user-guide/skills/bundled/creative/creative-pretext', - 'user-guide/skills/bundled/creative/creative-sketch', 'user-guide/skills/bundled/creative/creative-songwriting-and-ai-music', 'user-guide/skills/bundled/creative/creative-touchdesigner-mcp', ], @@ -387,7 +385,6 @@ const sidebars: SidebarsConfig = { 'user-guide/skills/optional/creative/creative-baoyu-article-illustrator', 'user-guide/skills/optional/creative/creative-baoyu-comic', 'user-guide/skills/optional/creative/creative-blender-mcp', - 'user-guide/skills/optional/creative/creative-concept-diagrams', 'user-guide/skills/optional/creative/creative-creative-ideation', 'user-guide/skills/optional/creative/creative-hyperframes', 'user-guide/skills/optional/creative/creative-kanban-video-orchestrator', From fcac0f94d4844f904a6eaa8a2b667299408b9f92 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:53:39 +0530 Subject: [PATCH 046/470] fix(openviking): guard empty tool_id in batch skip set; reuse env_var_enabled Two follow-up fixes on top of the cherry-picked structured-sync work: - _messages_to_openviking_batch only added a recall tool result's id to skipped_tool_ids when the id was non-empty. An empty tool_call_id (which the canonical transcript can carry; agent_runtime_helpers defaults it to "") poisoned the skip set with "", silently dropping any *other* tool result that also lacked an id. Move the recall-skip add inside the existing `if tool_id:` guard. Adds a regression test (mutation-checked: fails on pre-fix code, passes after). - _sync_trace_enabled() open-coded the canonical truthy-env check; reuse utils.env_var_enabled (byte-identical {1,true,yes,on} semantics). --- plugins/memory/openviking/__init__.py | 8 ++-- tests/openviking_plugin/test_openviking.py | 45 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 82f1f26a0a0..a57a60e67bd 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -49,7 +49,7 @@ from agent.message_content import flatten_message_text from agent.memory_provider import MemoryProvider from agent.skill_commands import extract_user_instruction_from_skill_message from tools.registry import tool_error -from utils import atomic_json_write +from utils import atomic_json_write, env_var_enabled logger = logging.getLogger(__name__) @@ -160,7 +160,7 @@ def _derive_openviking_user_text(content: Any) -> str: def _sync_trace_enabled() -> bool: - return os.environ.get(_SYNC_TRACE_ENV, "").strip().lower() in {"1", "true", "yes", "on"} + return env_var_enabled(_SYNC_TRACE_ENV) def _preview(value: Any, limit: int = 160) -> str: @@ -2461,8 +2461,8 @@ class OpenVikingMemoryProvider(MemoryProvider): tool_id = str(message.get("tool_call_id") or message.get("id") or "") if tool_id: completed_tool_ids.add(tool_id) - if cls._is_openviking_recall_tool_name(message.get("name")): - skipped_tool_ids.add(tool_id) + if cls._is_openviking_recall_tool_name(message.get("name")): + skipped_tool_ids.add(tool_id) continue if message.get("role") != "assistant": continue diff --git a/tests/openviking_plugin/test_openviking.py b/tests/openviking_plugin/test_openviking.py index 3a743287672..171e6abc8ac 100644 --- a/tests/openviking_plugin/test_openviking.py +++ b/tests/openviking_plugin/test_openviking.py @@ -539,6 +539,51 @@ class TestOpenVikingTurnConversion: assert recall_tool_name not in batch_text assert "Old OpenViking memory content" not in batch_text + def test_messages_to_openviking_batch_empty_tool_id_does_not_drop_other_results(self): + # A recall tool result that arrives with an empty tool_call_id must not + # poison the skip set with "" and silently drop unrelated tool results + # that also lack an id. Empty tool_call_id is reachable in the canonical + # transcript (agent_runtime_helpers defaults it to ""). + turn = [ + {"role": "user", "content": "What did we decide?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "", + "type": "function", + "function": { + "name": "viking_search", + "arguments": json.dumps({"query": "decision"}), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "", + "name": "viking_search", + "content": json.dumps({"results": ["recall stuff"]}), + }, + { + "role": "tool", + "tool_call_id": "", + "name": "shell_command", + "content": "important shell output", + }, + {"role": "assistant", "content": "done"}, + ] + + batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn) + + batch_text = json.dumps(batch) + # The unrelated (empty-id) shell result must survive. + assert "important shell output" in batch_text + # The recall tool result must still be excluded. + assert "recall stuff" not in batch_text + assert "viking_search" not in batch_text + def test_messages_to_openviking_batch_preserves_responses_text_parts(self): turn = [ {"role": "user", "content": [{"type": "input_text", "text": "hello"}]}, From 3ca0ef7e3f68c5a9684d4a7446e46c21b0731e3c Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:57:12 +0530 Subject: [PATCH 047/470] fix(nix): hashless npm deps via importNpmLock (#48883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm workspace pins a single npmDepsHash for fetchNpmDeps. Any change to package-lock.json that doesn't also refresh that hash breaks the bundled hermes-tui / hermes-desktop-renderer build for Nix flake consumers, and no nix CI catches it — the workflow that ran fix-lockfiles was removed in 9eb0bcd6 ("change(ci): rip out nix ci for now"). Fetch the workspace deps with pkgs.importNpmLock instead. It resolves each package from the lockfile's own integrity hashes, so package-lock.json is the single source of truth and there is no separate hash to drift. This also removes: - the fix-lockfiles checker/refresher and its devShell wiring — it existed only to keep npmDepsHash in sync, so it is dead once the hash is gone, and its sole CI consumer was already removed in 9eb0bcd6; - the patchPhase that normalized lockfile trailing newlines — importNpmLock's npmConfigHook overwrites the lockfile rather than diffing it, so the normalization is unnecessary. npm-lockfile-fix is retained: importNpmLock requires an integrity-complete lockfile, which that tool guarantees when the lockfile is regenerated. Co-authored-by: ak2k <19240940+ak2k@users.noreply.github.com> --- nix/devShell.nix | 3 +- nix/lib.nix | 238 ++++------------------------------------------- nix/packages.nix | 2 - 3 files changed, 19 insertions(+), 224 deletions(-) diff --git a/nix/devShell.nix b/nix/devShell.nix index 2670c579541..c131bbb5ba7 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -12,7 +12,6 @@ let packages = builtins.attrValues self'.packages; hermesNpmLib = self'.packages.default.passthru.hermesNpmLib; - fixLockfilesExe = pkgs.lib.getExe self'.packages.fix-lockfiles; # Collect all packageJsonPath values from npm workspace packages. npmPackageJsonPaths = builtins.filter (p: p != null) ( @@ -33,7 +32,7 @@ shellHook = '' echo "Hermes Agent dev shell" ${combinedNonNpm} - ${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths fixLockfilesExe} + ${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths} echo "Ready. Run 'hermes' to start." ''; }; diff --git a/nix/lib.nix b/nix/lib.nix index 180f00f2ee0..a7a6eab7c5b 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -2,8 +2,7 @@ # # All npm packages in this repo are workspace members sharing a single # root package-lock.json. mkNpmPassthru provides the shared src, npmDeps, -# npmRoot, and npmDepsFetcherVersion so individual .nix files don't -# duplicate them. One hash to rule them all. +# npmRoot, and npmConfigHook so individual .nix files don't duplicate them. # # mkNpmPassthru returns packageJsonPath (e.g. "ui-tui/package.json") # instead of a per-package devShellHook. The root devshell hook @@ -19,28 +18,19 @@ let # The workspace root — where the single package-lock.json lives. src = ../.; - # Single npm deps fetch from the workspace root lockfile. - # All workspace packages share this derivation. - npmDepsHash = "sha256-kbjJksq7limRIYqP3DwI+GNgCXkG96tXcsQqmuEedxo="; - - npmDeps = pkgs.fetchNpmDeps { - inherit src; - fetcherVersion = 2; - hash = npmDepsHash; - }; + # npm dependencies for the workspace, shared by all members. importNpmLock + # resolves each package from the lockfile's own `integrity` hashes, so the + # lockfile is the single source of truth — no separate dependency hash to + # keep in sync with it. + npmDeps = pkgs.importNpmLock.importNpmLock { npmRoot = src; }; in { # Returns a buildNpmPackage-compatible attrs set that provides: - # src, npmDeps, npmRoot, npmDepsFetcherVersion - # patchPhase — ensures root lockfile has exactly one trailing newline - # nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more) - # passthru.packageJsonPath — relative path to this workspace's package.json - # nodejs — fixed nodejs version for all packages we use in the repo - # - # NOTE: npmConfigHook runs `diff` between the source lockfile and the - # npm-deps cache lockfile. fetchNpmDeps preserves whatever trailing - # newlines the lockfile has. The patchPhase normalizes to exactly one - # trailing newline so both sides always match. + # src, npmDeps, npmRoot — workspace source + importNpmLock dep set + # npmConfigHook — importNpmLock's offline `npm install` hook + # nativeBuildInputs — [ updateLockfileScript ] (list, prepend with ++ for more) + # passthru.packageJsonPath — relative path to this workspace's package.json + # nodejs — fixed nodejs version for all packages we use in the repo # # Usage: # npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; }; @@ -62,35 +52,15 @@ in in { inherit src npmDeps nodejs; + # importNpmLock's hook installs the rewritten lockfile (every `resolved` + # rewritten to a /nix/store file: path) into the unpacked workspace and + # runs `npm install` offline, so every workspace member's dependencies + # resolve without network access. + npmConfigHook = pkgs.importNpmLock.npmConfigHook; npmRoot = "."; - npmDepsFetcherVersion = 2; ELECTRON_SKIP_BINARY_DOWNLOAD = 1; - patchPhase = '' - runHook prePatch - # Normalize trailing newlines on the root lockfile so source and - # npm-deps always match, regardless of what fetchNpmDeps preserves. - sed -i -z 's/\\n*$/\\n/' package-lock.json - - # Make npmConfigHook's byte-for-byte diff newline-agnostic by - # replacing its hardcoded /nix/store/.../diff with a wrapper that - # normalizes trailing newlines on both sides before comparing. - mkdir -p "$TMPDIR/bin" - cat > "$TMPDIR/bin/diff" << DIFFWRAP - #!/bin/sh - f1=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$1" > "\\$f1" - f2=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$2" > "\\$f2" - ${pkgs.diffutils}/bin/diff "\\$f1" "\\$f2" && rc=0 || rc=\\$? - rm -f "\\$f1" "\\$f2" - exit \\$rc - DIFFWRAP - chmod +x "$TMPDIR/bin/diff" - export PATH="$TMPDIR/bin:$PATH" - - runHook postPatch - ''; - nativeBuildInputs = [ (pkgs.writeShellScriptBin "update_${attr}_lockfile" '' set -euox pipefail @@ -104,7 +74,6 @@ in CI=true ${pkgs.lib.getExe' nodejs "npm"} install --workspaces ${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json - # Hash lives in lib.nix — just rebuild to verify. nix build .#${attr} echo "Lockfile updated and build verified for .#${attr}" '') @@ -120,12 +89,9 @@ in # Takes a list of package.json relative paths (from mkNpmPassthru .passthru.packageJsonPath), # stamps all of them, and if any changed: # 1. Runs `npm i --package-lock-only` from root to update the lockfile - # 2. If the lockfile changed, runs `npm ci` + fix-lockfiles - # - # fixLockfilesExe: absolute path to the fix-lockfiles binary - # (from pkgs.lib.getExe self'.packages.fix-lockfiles in devShell.nix). + # 2. If the lockfile changed, runs `npm ci` mkNpmDevShellHook = - packageJsonPaths: fixLockfilesExe: + packageJsonPaths: pkgs.writeShellScript "npm-dev-hook" '' REPO_ROOT=$(git rev-parse --show-toplevel) @@ -158,172 +124,4 @@ in echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP" fi ''; - - # Build `fix-lockfiles` bin that checks/updates the single npmDepsHash - # fix-lockfiles --check # exit 1 if any hash is stale - # fix-lockfiles --apply # rewrite stale hashes in place - # fix-lockfiles # alias of --apply - # Writes machine-readable fields (stale, changed, report) to $GITHUB_OUTPUT - # when set, so CI workflows can post a sticky PR comment directly. - mkFixLockfiles = - { - attr, # flake package attr for fallback verification build, e.g. "tui" - }: - pkgs.writeShellScriptBin "fix-lockfiles" '' - set -uox pipefail - MODE="''${1:---apply}" - case "$MODE" in - --check|--apply) ;; - -h|--help) - echo "usage: fix-lockfiles [--check|--apply]" - exit 0 ;; - *) - echo "usage: fix-lockfiles [--check|--apply]" >&2 - exit 2 ;; - esac - - REPO_ROOT="$(git rev-parse --show-toplevel)" - cd "$REPO_ROOT" - - # When running in GH Actions, emit Markdown links in the report pointing - # at the offending line of the nix file (and the lockfile) at the exact - # commit that was checked. LINK_SHA should be set by the workflow to the - # PR head SHA; falls back to GITHUB_SHA (which on pull_request is the - # test-merge commit, still browseable). - LINK_SERVER="''${GITHUB_SERVER_URL:-https://github.com}" - LINK_REPO="''${GITHUB_REPOSITORY:-}" - LINK_SHA="''${LINK_SHA:-''${GITHUB_SHA:-}}" - - STALE=0 - FIXED=0 - REPORT="" - - # All workspace packages share the root package-lock.json, so - # we only need to check the hash once. - LOCK_FILE="package-lock.json" - LIB_FILE="nix/lib.nix" - NEW_HASH=$(${pkgs.lib.getExe pkgs.prefetch-npm-deps} "$LOCK_FILE" 2>/dev/null) - if [ -z "$NEW_HASH" ]; then - echo "prefetch-npm-deps failed, falling back to nix build" >&2 - OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1) - STATUS=$? - if [ "$STATUS" -eq 0 ]; then - echo "ok (via nix build)" - exit 0 - fi - NEW_HASH=$(echo "$OUTPUT" | awk '/got:/ {print $2; exit}') - if [ -z "$NEW_HASH" ]; then - if echo "$OUTPUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then - echo "skipped (transient cache failure — see primary nix build for real status)" >&2 - echo "$OUTPUT" | tail -8 >&2 - exit 0 - fi - echo "build failed with no hash mismatch:" >&2 - echo "$OUTPUT" | tail -40 >&2 - exit 1 - fi - fi - - OLD_HASH=$(grep -oE 'npmDepsHash = "sha256-[^"]+"' "$LIB_FILE" | head -1 \ - | sed -E 's/npmDepsHash = "(.*)"/\1/') - - # prefetch-npm-deps says the hash already matches — but it only hashes the - # lockfile *contents* and can disagree with fetchNpmDeps + npmConfigHook, - # which validate the full source lockfile against the realized deps cache. - # Trusting prefetch alone produced false "ok" results while the actual - # build was broken (e.g. lockfile engines/os/cpu fields the pinned nixpkgs - # strips from the deps cache, tripping npmConfigHook). So when prefetch - # claims the hash is current, confirm with a real consumer build before - # believing it. - if [ "$NEW_HASH" = "$OLD_HASH" ]; then - if VERIFY_OUT=$(nix build ".#${attr}" --no-link --print-build-logs 2>&1); then - echo "ok" - if [ -n "''${GITHUB_OUTPUT:-}" ]; then - { echo "stale=false"; echo "changed=false"; } >> "$GITHUB_OUTPUT" - fi - exit 0 - fi - # Build failed despite a matching hash. A fixed-output 'got:' means - # prefetch genuinely disagreed with fetchNpmDeps — adopt the real hash - # and fall through to the stale-handling path below. - CORRECT_HASH=$(echo "$VERIFY_OUT" | awk '/got:/ {print $2; exit}') - if [ -n "$CORRECT_HASH" ]; then - echo "prefetch-npm-deps reported current ($OLD_HASH) but fetchNpmDeps wants $CORRECT_HASH" >&2 - NEW_HASH="$CORRECT_HASH" - elif echo "$VERIFY_OUT" | grep -qE "throttled|HTTP error 418|substituter .* is disabled|some outputs of .* are not valid"; then - echo "skipped (transient cache failure — see primary nix build for real status)" >&2 - echo "$VERIFY_OUT" | tail -8 >&2 - exit 0 - else - # Not a stale-hash problem — surface it honestly instead of "ok". - echo "::error::nix build .#${attr} failed and it is NOT a stale npmDepsHash (no 'got:' hash in output)." >&2 - echo "The committed lockfile may be incompatible with the pinned nixpkgs" >&2 - echo "(e.g. engines/os/cpu fields that prefetch-npm-deps strips from the" >&2 - echo "deps cache, tripping npmConfigHook). fix-lockfiles cannot repair this." >&2 - echo "$VERIFY_OUT" | tail -40 >&2 - if [ -n "''${GITHUB_OUTPUT:-}" ]; then - { echo "stale=false"; echo "changed=false"; } >> "$GITHUB_OUTPUT" - fi - exit 1 - fi - fi - - HASH_LINE=$(grep -n 'npmDepsHash = "sha256-' "$LIB_FILE" | head -1 | cut -d: -f1) - echo "stale: $LIB_FILE:$HASH_LINE $OLD_HASH -> $NEW_HASH" - STALE=1 - - if [ -n "$LINK_REPO" ] && [ -n "$LINK_SHA" ]; then - LIB_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LIB_FILE#L$HASH_LINE" - LOCK_URL="$LINK_SERVER/$LINK_REPO/blob/$LINK_SHA/$LOCK_FILE" - REPORT="- [\`$LIB_FILE:$HASH_LINE\`]($LIB_URL): \`$OLD_HASH\` → \`$NEW_HASH\` — lockfile: [\`$LOCK_FILE\`]($LOCK_URL)"$'\\n' - else - REPORT="- \`$LIB_FILE:$HASH_LINE\`: \`$OLD_HASH\` → \`$NEW_HASH\`"$'\\n' - fi - - if [ "$MODE" = "--apply" ]; then - sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$NEW_HASH\";|" "$LIB_FILE" - if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>/dev/null; then - # prefetch-npm-deps may disagree with fetchNpmDeps (it hashes - # the lockfile contents, not the full source tree). Extract the - # correct hash from the nix build error and retry. - RETRY_OUTPUT=$(nix build ".#${attr}.npmDeps" --no-link --print-build-logs 2>&1) - CORRECT_HASH=$(echo "$RETRY_OUTPUT" | awk '/got:/ {print $2; exit}') - if [ -n "$CORRECT_HASH" ]; then - echo "prefetch-npm-deps gave $NEW_HASH but nix wants $CORRECT_HASH — retrying" >&2 - sed -i -E "s|npmDepsHash = \"sha256-[^\"]+\";|npmDepsHash = \"$CORRECT_HASH\";|" "$LIB_FILE" - if ! nix build ".#${attr}.npmDeps" --no-link --print-build-logs; then - echo "verification build failed after hash retry" >&2 - exit 1 - fi - NEW_HASH="$CORRECT_HASH" - else - echo "verification build failed after hash update" >&2 - exit 1 - fi - fi - FIXED=1 - echo "fixed" - fi - - if [ -n "''${GITHUB_OUTPUT:-}" ]; then - { - [ "$STALE" -eq 1 ] && echo "stale=true" || echo "stale=false" - [ "$FIXED" -eq 1 ] && echo "changed=true" || echo "changed=false" - if [ -n "$REPORT" ]; then - echo "report<> "$GITHUB_OUTPUT" - fi - - if [ "$STALE" -eq 1 ] && [ "$MODE" = "--check" ]; then - echo - echo "Stale lockfile hash detected. Run:" - echo " nix run .#fix-lockfiles" - exit 1 - fi - - exit 0 - ''; } diff --git a/nix/packages.nix b/nix/packages.nix index d585beec6b4..131444fb3fd 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -50,8 +50,6 @@ tui = hermesAgent.hermesTui; web = hermesAgent.hermesWeb; desktop = hermesAgent.hermesDesktop; - - fix-lockfiles = hermesAgent.hermesNpmLib.mkFixLockfiles { attr = "tui"; }; }; }; } From 27a6e188c4b4bc66f52b321f055fe18aa866b545 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:01:16 +0530 Subject: [PATCH 048/470] refactor(openviking): derive recall-tool name set from canonical schemas _OPENVIKING_RECALL_TOOL_NAMES hardcoded the three read-tool names as string literals, which can silently desync from the *_SCHEMA["name"] constants on a rename (the same drift the adjacent _CATEGORY_SUBDIR_MAP comment warns about). Derive the set from SEARCH/READ/BROWSE_SCHEMA["name"] instead. Write tools (viking_remember / viking_add_resource) remain intentionally excluded. Set contents are unchanged. --- plugins/memory/openviking/__init__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index a57a60e67bd..95edaca47d8 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -72,7 +72,6 @@ _SESSION_DRAIN_TIMEOUT = 10.0 _DEFERRED_COMMIT_TIMEOUT = (_TIMEOUT * 2) + 5.0 _REMOTE_RESOURCE_PREFIXES = ("http://", "https://", "git@", "ssh://", "git://") _SYNC_TRACE_ENV = "HERMES_OPENVIKING_SYNC_TRACE" -_OPENVIKING_RECALL_TOOL_NAMES = {"viking_search", "viking_read", "viking_browse"} # Maps the viking_remember `category` enum to a viking:// subdirectory. # Keep in sync with REMEMBER_SCHEMA.parameters.properties.category.enum. @@ -503,6 +502,17 @@ ADD_RESOURCE_SCHEMA = { } +# Recall tools (read-only) whose results we never re-ingest into OpenViking — +# echoing recalled memory back into the session transcript would re-store it. +# Write tools (viking_remember / viking_add_resource) are intentionally NOT +# here. Derived from the canonical schema names so renames can't desync. +_OPENVIKING_RECALL_TOOL_NAMES = { + SEARCH_SCHEMA["name"], + READ_SCHEMA["name"], + BROWSE_SCHEMA["name"], +} + + def _zip_directory(dir_path: Path) -> Path: """Create a temporary zip file containing a directory tree.""" root = dir_path.resolve() From 2d4046c6de975eff194d6ebdfa4180e5ed86c422 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:03:49 +0530 Subject: [PATCH 049/470] refactor(openviking): reuse pre-scanned tool_input for pending tool calls _messages_to_openviking_batch's pre-scan already parses and caches each tool call's arguments into tool_calls_by_id. The pending-tool-call branch re-parsed them via _tool_call_input(), a second parse and a second source of truth. Reuse the cached tool_input when the id was cached (non-empty), falling back to a parse only for the uncached empty-id case so arguments are never dropped. No behavior change. --- plugins/memory/openviking/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 95edaca47d8..9c1029d4a89 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -2548,11 +2548,20 @@ class OpenVikingMemoryProvider(MemoryProvider): continue if tool_id in completed_tool_ids: continue + # Reuse the tool_input parsed in the pre-scan when available + # (non-empty ids are cached); fall back to parsing for the + # uncached empty-id case so we never drop arguments. + prior_call = tool_calls_by_id.get(tool_id) if tool_id else None + tool_input = ( + prior_call["tool_input"] + if prior_call is not None + else cls._tool_call_input(tool_call) + ) parts.append({ "type": "tool", "tool_id": tool_id, "tool_name": tool_name, - "tool_input": cls._tool_call_input(tool_call), + "tool_input": tool_input, "tool_status": "pending", }) From be2c2beb96e578542b24bdb275071044a853ebbd Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:05:40 +0530 Subject: [PATCH 050/470] refactor(openviking): name tool_status constants and alias sets The batch tool_status values ('completed'/'error'/'pending') and the inbound status alias sets were inline magic strings, duplicated across two checks in _tool_result_status. Hoist them to module-level constants (_TOOL_STATUS_* + _TOOL_STATUS_{ERROR,COMPLETED}_ALIASES) so the canonical wire values and the alias->canonical mapping live in one place. Emitted values are unchanged. --- plugins/memory/openviking/__init__.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 9c1029d4a89..b4d44be88af 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -512,6 +512,14 @@ _OPENVIKING_RECALL_TOOL_NAMES = { BROWSE_SCHEMA["name"], } +# Canonical tool_status values emitted in OpenViking batch tool parts. +_TOOL_STATUS_COMPLETED = "completed" +_TOOL_STATUS_ERROR = "error" +_TOOL_STATUS_PENDING = "pending" +# Inbound status aliases (from varied tool-result shapes) -> canonical above. +_TOOL_STATUS_ERROR_ALIASES = {"error", "failed", "failure"} +_TOOL_STATUS_COMPLETED_ALIASES = {"completed", "complete", "success", "succeeded"} + def _zip_directory(dir_path: Path) -> Path: """Create a temporary zip file containing a directory tree.""" @@ -2429,10 +2437,10 @@ class OpenVikingMemoryProvider(MemoryProvider): @classmethod def _tool_result_status(cls, message: Dict[str, Any]) -> str: raw_status = str(message.get("status") or message.get("tool_status") or "").lower() - if raw_status in {"error", "failed", "failure"}: - return "error" - if raw_status in {"completed", "complete", "success", "succeeded"}: - return "completed" + if raw_status in _TOOL_STATUS_ERROR_ALIASES: + return _TOOL_STATUS_ERROR + if raw_status in _TOOL_STATUS_COMPLETED_ALIASES: + return _TOOL_STATUS_COMPLETED text = cls._message_text(message.get("content")).strip() if text: @@ -2444,13 +2452,14 @@ class OpenVikingMemoryProvider(MemoryProvider): status = str(parsed.get("status") or "").lower() exit_code = parsed.get("exit_code") if ( - status in {"error", "failed", "failure"} + status in _TOOL_STATUS_ERROR_ALIASES or parsed.get("success") is False or bool(parsed.get("error")) or (isinstance(exit_code, int) and exit_code != 0) ): - return "error" - return "completed" + return _TOOL_STATUS_ERROR + + return _TOOL_STATUS_COMPLETED @classmethod def _messages_to_openviking_batch( @@ -2562,7 +2571,7 @@ class OpenVikingMemoryProvider(MemoryProvider): "tool_id": tool_id, "tool_name": tool_name, "tool_input": tool_input, - "tool_status": "pending", + "tool_status": _TOOL_STATUS_PENDING, }) if parts: From e738c083360649c0c9ac7b497660b4178c3f665c Mon Sep 17 00:00:00 2001 From: xxxigm Date: Fri, 19 Jun 2026 14:15:30 +0700 Subject: [PATCH 051/470] fix(backup): exclude regeneratable dependency and cache dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes backup` walked every file under HERMES_HOME, excluding only hermes-agent / node_modules / __pycache__ / backups / checkpoints. Python dependency trees (plugin and MCP-server venvs, site-packages) and pip/uv tool caches that live under HERMES_HOME were swept in file-by-file, ballooning a backup to hundreds of thousands of entries that crawl for hours — the reported "backup stuck for days / 426543 files" symptom. Add the canonical regeneratable-dir names (.venv, venv, site-packages, .tox, .nox, .pytest_cache, .mypy_cache, .ruff_cache — mirroring agent.skill_utils.EXCLUDED_SKILL_DIRS) plus .cache to the backup's exclusion set, used by both run_backup and the pre-update/pre-migration _write_full_zip_backup. .archive is intentionally left in so the curator's restorable archived skills still get backed up. Tests cover each new dir name (excluded at any depth), that .archive and cache-resembling files are kept, and an integration check that a planted venv/site-packages/cache is pruned from the actual backup zip while skills/config survive. --- hermes_cli/backup.py | 26 +++++++++++++- tests/hermes_cli/test_backup.py | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 0064881c43f..770a8de4569 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -34,14 +34,38 @@ logger = logging.getLogger(__name__) # ``hermes-agent`` is special-cased to root level only in ``_should_exclude`` # so that skill directories like ``skills/autonomous-ai-agents/hermes-agent/`` # are not accidentally excluded. +# +# The dependency/cache entries below matter for more than tidiness: without +# them a single plugin venv, MCP-server install, or pip/uv cache living under +# HERMES_HOME gets walked file-by-file, ballooning a backup to hundreds of +# thousands of entries that crawl for hours — the exact "backup stuck for +# days / 426543 files" symptom users hit. The dependency/test-env names mostly +# mirror ``agent.skill_utils.EXCLUDED_SKILL_DIRS`` (the project's canonical +# "regeneratable dir" set); ``.cache`` is an additional backup-only entry, as +# it names a broad regeneratable cache convention (pip/uv/etc.) that the skill +# scanner doesn't need to prune but a backup walk does. We deliberately do NOT +# exclude ``.archive`` here because the curator's ``skills/.archive/`` holds +# restorable user skills that must survive a backup. _EXCLUDED_DIRS = { "hermes-agent", # the codebase repo — re-clone instead "__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 + "node_modules", # js deps — reinstalled on demand "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 + # Python dependency trees (plugin / MCP-server venvs under HERMES_HOME) — + # regenerated by reinstalling; never irreplaceable state. + ".venv", + "venv", + "site-packages", + # Tool / build caches — all regeneratable. + ".cache", + ".tox", + ".nox", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", } # File-name suffixes to skip diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 762af37069c..e768d2a996c 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -153,6 +153,39 @@ class TestShouldExclude: assert not _should_exclude(Path("skills/autonomous-ai-agents/hermes-agent/SKILL.md")) assert not _should_exclude(Path("skills/autonomous-ai-agents/hermes-agent/sub/item.txt")) + @pytest.mark.parametrize( + "rel", + [ + "plugins/my-plugin/.venv/lib/python3.12/site-packages/x/__init__.py", + "plugins/my-plugin/venv/bin/python", + "mcp/server/site-packages/pkg/mod.py", + ".cache/uv/wheels/abc.whl", + "plugins/p/.cache/pip/http/deadbeef", + ".tox/py312/log.txt", + ".nox/tests/bin/pytest", + "plugins/p/.pytest_cache/v/cache/lastfailed", + ".mypy_cache/3.12/agent.meta.json", + ".ruff_cache/0.4.0/abc", + ], + ) + def test_excludes_regeneratable_dependency_and_cache_dirs(self, rel): + """Python dep trees and tool caches under HERMES_HOME must be skipped — + these are what balloon a backup to hundreds of thousands of files.""" + from hermes_cli.backup import _should_exclude + assert _should_exclude(Path(rel)) + + def test_does_not_exclude_curator_archive(self): + """skills/.archive/ holds restorable archived skills and MUST survive + a backup — it is intentionally NOT in the exclusion set.""" + from hermes_cli.backup import _should_exclude + assert not _should_exclude(Path("skills/.archive/old-skill/SKILL.md")) + + def test_does_not_exclude_legit_files_resembling_cache_names(self): + """Only directory-component matches are excluded; a normal file is kept.""" + from hermes_cli.backup import _should_exclude + assert not _should_exclude(Path("skills/my-skill/venv-notes.md")) + assert not _should_exclude(Path("memories/cache.json")) + # --------------------------------------------------------------------------- # Backup tests # --------------------------------------------------------------------------- @@ -272,6 +305,37 @@ class TestBackup: agent_files = [n for n in names if "hermes-agent" in n] assert agent_files == [], f"hermes-agent files leaked into backup: {agent_files}" + def test_excludes_dependency_and_cache_trees(self, tmp_path, monkeypatch): + """A plugin venv / site-packages / pip cache under HERMES_HOME must be + pruned by the walk, while real data (skills, config) is preserved. + This is the regression guard for the ballooning-backup bug.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + _make_hermes_tree(hermes_home) + + # Simulate the heavy regeneratable trees that ballooned the backup. + venv_pkg = hermes_home / "plugins" / "heavy" / ".venv" / "lib" / "site-packages" / "dep" + venv_pkg.mkdir(parents=True) + (venv_pkg / "__init__.py").write_text("# dep\n") + pip_cache = hermes_home / ".cache" / "uv" / "wheels" + pip_cache.mkdir(parents=True) + (pip_cache / "abc.whl").write_bytes(b"\x00") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + out_zip = tmp_path / "backup.zip" + from hermes_cli.backup import run_backup + run_backup(Namespace(output=str(out_zip))) + + with zipfile.ZipFile(out_zip, "r") as zf: + names = zf.namelist() + leaked = [n for n in names if ".venv" in n or "site-packages" in n or ".cache" in n] + assert leaked == [], f"regeneratable trees leaked into backup: {leaked}" + # Real data still present. + assert "skills/my-skill/SKILL.md" in names + assert "config.yaml" in names + def test_includes_nested_hermes_agent_in_skills(self, tmp_path, monkeypatch): """Backup includes skills/.../hermes-agent/ but NOT root hermes-agent/.""" hermes_home = tmp_path / ".hermes" From 1699525638ed4feba3fd35f0be5c6d4d2d326a49 Mon Sep 17 00:00:00 2001 From: kyssta-exe Date: Fri, 19 Jun 2026 14:53:33 +0530 Subject: [PATCH 052/470] fix(tui): route pending-input commands via command.dispatch (#48848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When /goal (and other _PENDING_INPUT_COMMANDS: retry, queue, q, steer, plan, undo) were typed in the TUI desktop app, slash.exec returned error 4018 instructing the frontend to fall back to command.dispatch. Some clients failed that client-side fallback, leaving the command empty and surfacing "empty command" — the user's typed text was silently dropped. slash.exec now routes pending-input commands to command.dispatch internally, eliminating the fragile client-side fallback hop. The response is exactly what command.dispatch would have produced, so the TUI client behaves identically once the round-trip succeeds. Salvaged from #48944 — rebased onto current main. The original PR's source change and test_goal_command.py update are correct, but it missed the second test surface: tests/tui_gateway/test_protocol.py's parametrized test_slash_exec_rejects_pending_input_commands still asserted the old 4018 rejection for retry/queue/q/steer/plan, turning CI red (5 failures). That test is rewritten here as a behavior contract: slash.exec for a pending-input command must yield the same payload as a direct command.dispatch call, and must no longer emit the old "pending-input command" fallback rejection. Co-authored-by: kyssta-exe --- tests/tui_gateway/test_goal_command.py | 16 +++++----- tests/tui_gateway/test_protocol.py | 41 +++++++++++++++++++++----- tui_gateway/server.py | 16 ++++++++-- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/tests/tui_gateway/test_goal_command.py b/tests/tui_gateway/test_goal_command.py index d06f5b8fbbd..cfff285f1ef 100644 --- a/tests/tui_gateway/test_goal_command.py +++ b/tests/tui_gateway/test_goal_command.py @@ -185,15 +185,17 @@ def test_goal_requires_session(server): # ── slash.exec /goal routing ────────────────────────────────────────── -def test_slash_exec_rejects_goal_routes_to_command_dispatch(server, session): - """slash.exec must reject /goal with 4018 so the TUI client falls through - to command.dispatch. Without this, the HermesCLI slash-worker subprocess - would set the goal but silently drop the kickoff — the queue is in-proc.""" +def test_slash_exec_routes_goal_to_command_dispatch(server, session): + """slash.exec must route /goal directly to command.dispatch internally + instead of returning an error. Previously the 4018 error required the + TUI client to retry via command.dispatch, but some clients failed the + fallback, leaving the command empty ("empty command").""" sid, _, _ = session r = _call(server, "slash.exec", command="goal status", session_id=sid) - assert "error" in r - assert r["error"]["code"] == 4018 - assert "command.dispatch" in r["error"]["message"] + # Should succeed by routing to command.dispatch internally + assert "result" in r + assert r["result"]["type"] == "exec" + assert "No active goal" in r["result"]["output"] def test_pending_input_commands_includes_goal(server): diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 60d3c7a5c4f..775a07cb317 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -1121,20 +1121,45 @@ def test_slash_exec_plugin_handler_error_returns_output(server): @pytest.mark.parametrize("cmd", ["retry", "queue hello", "q hello", "steer fix the test", "plan"]) -def test_slash_exec_rejects_pending_input_commands(server, cmd): - """slash.exec must reject commands that use _pending_input in the CLI.""" - sid = "test-session" - server._sessions[sid] = {"session_key": sid, "agent": None} +def test_slash_exec_routes_pending_input_commands_to_dispatch(server, cmd): + """slash.exec must route _pending_input commands to command.dispatch + internally instead of returning the old 4018 "use command.dispatch" + fallback error (#48848). Some TUI clients failed that client-side + fallback, dropping the input and surfacing "empty command". - resp = server.handle_request({ + The contract is that slash.exec produces exactly the response + command.dispatch would for the same command — no fragile retry hop. + """ + base, _, arg = cmd.partition(" ") + + def fresh_session(): + return {"session_key": "test-session", "agent": None} + + sid = "test-session" + + # Response from the (new) internal routing in slash.exec. + server._sessions[sid] = fresh_session() + routed = server.handle_request({ "id": "r1", "method": "slash.exec", "params": {"command": cmd, "session_id": sid}, }) - assert "error" in resp - assert resp["error"]["code"] == 4018 - assert "pending-input command" in resp["error"]["message"] + # Response from calling command.dispatch directly with the parsed parts. + server._sessions[sid] = fresh_session() + direct = server.handle_request({ + "id": "r1", + "method": "command.dispatch", + "params": {"name": base, "arg": arg, "session_id": sid}, + }) + + # slash.exec must no longer emit the old client-fallback rejection. + if "error" in routed: + assert "pending-input command" not in routed["error"]["message"] + + # Internal routing must yield the same payload as command.dispatch. + assert routed.get("result") == direct.get("result") + assert routed.get("error") == direct.get("error") def test_command_dispatch_queue_sends_message(server): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 1b92831df3d..d65cdf49343 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -8462,7 +8462,9 @@ _TUI_EXTRA: list[tuple[str, str, str]] = [ # Commands that queue messages onto _pending_input in the CLI. # In the TUI the slash worker subprocess has no reader for that queue, -# so slash.exec rejects them → TUI falls through to command.dispatch. +# so slash.exec routes them to command.dispatch internally (which handles +# them and returns a structured payload) instead of erroring out and +# relying on a client-side fallback. See #48848. _PENDING_INPUT_COMMANDS: frozenset[str] = frozenset( { "retry", @@ -9729,8 +9731,16 @@ def _(rid, params: dict) -> dict: _cmd_arg = _cmd_parts[1] if len(_cmd_parts) > 1 else "" if _cmd_base in _PENDING_INPUT_COMMANDS: - return _err( - rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}" + # Route directly to command.dispatch instead of returning an error + # that requires the frontend to retry. Some TUI clients fail the + # fallback, leaving the command empty and showing "empty command". + return _methods["command.dispatch"]( + rid, + { + "name": _cmd_base, + "arg": _cmd_arg, + "session_id": params.get("session_id", ""), + }, ) if _cmd_base in _WORKER_BLOCKED_COMMANDS: From fd27c9087055fbb0504766d22495d2ec5c75405a Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:46:14 +0530 Subject: [PATCH 053/470] chore: add tt-a1i to AUTHOR_MAP For PR #48933 (SSE-only Anthropic stream aggregation, fixes #48923). --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 4e5f8844439..7e5901fd568 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -415,6 +415,7 @@ AUTHOR_MAP = { "androidhtml@yandex.com": "hllqkb", "25840394+Bongulielmi@users.noreply.github.com": "Bongulielmi", "jonathan.troyer@overmatch.com": "JTroyerOvermatch", + "53142663+tt-a1i@users.noreply.github.com": "tt-a1i", # PR #48933 (SSE-only Anthropic stream aggregation, #48923) "harryykyle1@gmail.com": "hharry11", "wysie@users.noreply.github.com": "wysie", "ronhi@buildabear1.localdomain": "RonHillDev", # PR #29523 salvage (machine-local commit email) From ab8f063814089c17b2a457e3f4041a89e45b042e Mon Sep 17 00:00:00 2001 From: fyzanshaik Date: Fri, 19 Jun 2026 15:18:29 +0530 Subject: [PATCH 054/470] fix(tui): disable fast-echo bypass inside tmux to prevent cursor drift --- .../src/__tests__/textInputFastEcho.test.ts | 20 +++++++++++++++++++ ui-tui/src/components/textInput.tsx | 7 +++++++ 2 files changed, 27 insertions(+) diff --git a/ui-tui/src/__tests__/textInputFastEcho.test.ts b/ui-tui/src/__tests__/textInputFastEcho.test.ts index 6221314a062..03805aa3886 100644 --- a/ui-tui/src/__tests__/textInputFastEcho.test.ts +++ b/ui-tui/src/__tests__/textInputFastEcho.test.ts @@ -178,6 +178,26 @@ describe('supportsFastEchoTerminal', () => { expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false) }) + it('disables fast-echo inside tmux', () => { + expect(supportsFastEchoTerminal({ TMUX: '/tmp/tmux-1000/default,1234,0' } as NodeJS.ProcessEnv)).toBe(false) + expect(supportsFastEchoTerminal({ TMUX: '/private/tmp/tmux-501/default' } as NodeJS.ProcessEnv)).toBe(false) + }) + + it('tmux wins over Termux fast-echo opt-in', () => { + expect( + supportsFastEchoTerminal({ + TMUX: '/tmp/tmux-1000/default,1234,0', + HERMES_TUI_TERMUX_FAST_ECHO: '1', + TERMUX_VERSION: '0.118.0' + } as NodeJS.ProcessEnv) + ).toBe(false) + }) + + it('keeps fast-echo enabled when TMUX is empty or unset', () => { + expect(supportsFastEchoTerminal({ TMUX: '' } as NodeJS.ProcessEnv)).toBe(true) + expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true) + }) + it('disables fast-echo by default in Termux mode', () => { expect( supportsFastEchoTerminal({ TERMUX_VERSION: '0.118.0', PREFIX: '/data/data/com.termux/files/usr' } as NodeJS.ProcessEnv) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 564484999f6..ff6c9dad7b3 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -359,6 +359,13 @@ export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): return false } + // tmux adds a PTY multiplexing layer that desyncs stdout.write() cursor + // advances from its internal cursor model, causing cursor drift and ghost + // whitespace under the fast-echo bypass path. + if ((env.TMUX ?? '').trim().length > 0) { + return false + } + // Termux terminals are especially sensitive to bypass-path cursor drift and // stale paints at soft-wrap boundaries on tall/narrow viewports. Keep this // off by default in Termux mode; allow explicit opt-in for local debugging. From e52fffb607fe560604d5645f57d84d71d6c8b51e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:09:33 +0530 Subject: [PATCH 055/470] harden(tui): also disable fast-echo for tmux-flavored TERM (SSH-from-tmux) TMUX is not forwarded over SSH, so a TUI launched on a remote host from inside local tmux only sees TERM=tmux/tmux-256color with no TMUX var -- the cursor-drift bug still applies there. Extend supportsFastEchoTerminal() to also fall back when TERM is tmux-flavored. Deliberately scoped to tmux* only, NOT screen*: GNU screen sets the same screen/screen-256color TERM and has no reported drift, so widening to screen would disable the optimization for those users with no evidence of a bug (matching the original PR's stated out-of-scope note). Adds tests for tmux-flavored TERM (disabled) and screen/xterm TERM (stays enabled) to guard against accidental widening. --- ui-tui/src/__tests__/textInputFastEcho.test.ts | 17 +++++++++++++++++ ui-tui/src/components/textInput.tsx | 11 ++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/__tests__/textInputFastEcho.test.ts b/ui-tui/src/__tests__/textInputFastEcho.test.ts index 03805aa3886..98928d1baf1 100644 --- a/ui-tui/src/__tests__/textInputFastEcho.test.ts +++ b/ui-tui/src/__tests__/textInputFastEcho.test.ts @@ -198,6 +198,23 @@ describe('supportsFastEchoTerminal', () => { expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true) }) + it('disables fast-echo when only a tmux-flavored TERM is present (SSH from tmux, no TMUX forwarded)', () => { + // OpenSSH forwards TERM but not TMUX, so a TUI on a remote host launched + // from inside local tmux sees TERM=tmux-256color with no TMUX var. The + // cursor-drift bug still applies, so fast-echo must stay off. + expect(supportsFastEchoTerminal({ TERM: 'tmux' } as NodeJS.ProcessEnv)).toBe(false) + expect(supportsFastEchoTerminal({ TERM: 'tmux-256color' } as NodeJS.ProcessEnv)).toBe(false) + }) + + it('does NOT disable fast-echo for screen-flavored TERM (GNU screen out of scope, no reported drift)', () => { + // GNU screen sets TERM=screen/screen-256color and has no reported drift. + // We must not widen the tmux guard to screen* and regress its perf. + expect(supportsFastEchoTerminal({ TERM: 'screen' } as NodeJS.ProcessEnv)).toBe(true) + expect(supportsFastEchoTerminal({ TERM: 'screen-256color' } as NodeJS.ProcessEnv)).toBe(true) + // And an unrelated 256color TERM must stay enabled. + expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true) + }) + it('disables fast-echo by default in Termux mode', () => { expect( supportsFastEchoTerminal({ TERMUX_VERSION: '0.118.0', PREFIX: '/data/data/com.termux/files/usr' } as NodeJS.ProcessEnv) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index ff6c9dad7b3..deb22914695 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -362,7 +362,16 @@ export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): // tmux adds a PTY multiplexing layer that desyncs stdout.write() cursor // advances from its internal cursor model, causing cursor drift and ghost // whitespace under the fast-echo bypass path. - if ((env.TMUX ?? '').trim().length > 0) { + // + // `TMUX` catches the local case. It is NOT forwarded over SSH, so when the + // TUI runs on a remote host launched from inside local tmux we only see a + // tmux-flavored `TERM` (tmux sets `tmux`/`tmux-256color`); match that too so + // remote-over-tmux sessions still fall back to the safe render path. We + // deliberately do NOT match `screen*`: GNU screen sets the same TERM and has + // no reported drift, so widening to screen would disable the optimization for + // those users with no evidence of a bug. + const term = (env.TERM ?? '').trim().toLowerCase() + if ((env.TMUX ?? '').trim().length > 0 || term === 'tmux' || term.startsWith('tmux-')) { return false } From dc5cb0a440d2d5baa1b9e60cc4ea7316cb937250 Mon Sep 17 00:00:00 2001 From: Alex Yates <43525405+yatesjalex@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:06:57 -0700 Subject: [PATCH 056/470] fix(dashboard): refresh Sessions list in real time when new sessions are created The dashboard's FastAPI server and a terminal CLI are separate processes sharing one SQLite session DB; there is no inter-process push channel. The Sessions page polled the 50 newest sessions every 5s for the "overview" card but only re-fetched the paginated sessions list on page change or delete, so a session started in a terminal never appeared in the list until the user navigated. Reuse the existing 5s overview poll as a change signal: when the head session id changes, silently reload the current page (no loading spinner flicker, no scroll/reset of expanded rows or bulk selection, which are keyed by id). The detection logic is extracted into a pure shouldRefreshSessions() helper with unit tests. Adds a minimal vitest setup for web/ (test script + config). --- web/package.json | 3 ++- web/src/lib/session-refresh.test.ts | 21 +++++++++++++++ web/src/lib/session-refresh.ts | 26 +++++++++++++++++++ web/src/pages/SessionsPage.tsx | 40 +++++++++++++++++++++++++---- web/vitest.config.ts | 16 ++++++++++++ 5 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/session-refresh.test.ts create mode 100644 web/src/lib/session-refresh.ts create mode 100644 web/vitest.config.ts diff --git a/web/package.json b/web/package.json index 665a780c71d..91f16ac2a04 100644 --- a/web/package.json +++ b/web/package.json @@ -48,6 +48,7 @@ "three": "^0.180.0", "typescript": "^6.0.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.5" } } diff --git a/web/src/lib/session-refresh.test.ts b/web/src/lib/session-refresh.test.ts new file mode 100644 index 00000000000..0348835860a --- /dev/null +++ b/web/src/lib/session-refresh.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { shouldRefreshSessions } from "./session-refresh"; + +describe("shouldRefreshSessions", () => { + it("returns false on the first poll (no baseline yet)", () => { + expect(shouldRefreshSessions(null, "s2")).toBe(false); + }); + + it("returns false when the current response has no sessions", () => { + expect(shouldRefreshSessions("s1", null)).toBe(false); + expect(shouldRefreshSessions(null, null)).toBe(false); + }); + + it("returns false when the newest session id is unchanged", () => { + expect(shouldRefreshSessions("s1", "s1")).toBe(false); + }); + + it("returns true when a new session appears at the head of the list", () => { + expect(shouldRefreshSessions("s1", "s2")).toBe(true); + }); +}); diff --git a/web/src/lib/session-refresh.ts b/web/src/lib/session-refresh.ts new file mode 100644 index 00000000000..637c7f00eb1 --- /dev/null +++ b/web/src/lib/session-refresh.ts @@ -0,0 +1,26 @@ +/** + * Decide whether the paginated sessions list should be silently + * re-fetched after an overview poll. + * + * The dashboard's FastAPI server and a terminal CLI are separate + * processes that share the same SQLite session DB. There is no + * inter-process push channel, so the Sessions page polls the 50 newest + * sessions every few seconds (the "overview" poll). When that poll + * surfaces a session id at the head of the list that we have not seen + * before, a new session was created in another process and the + * paginated list is stale — refresh it. + * + * Returns false on the very first poll (no baseline yet) and when + * either id is null (empty DB / transient empty response), so we never + * trigger a spurious reload on mount or while the DB is empty. + */ +export function shouldRefreshSessions( + prevNewestId: string | null, + currentNewestId: string | null, +): boolean { + return ( + prevNewestId !== null && + currentNewestId !== null && + prevNewestId !== currentNewestId + ); +} diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index 2d70c399af2..1746cc48184 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -30,6 +30,7 @@ import { Archive, } from "lucide-react"; import { api } from "@/lib/api"; +import { shouldRefreshSessions } from "@/lib/session-refresh"; import type { SessionInfo, SessionMessage, @@ -805,8 +806,12 @@ export default function SessionsPage() { }; }, [setEnd]); - const loadSessions = useCallback((p: number) => { - setLoading(true); + const loadSessions = useCallback((p: number, silent = false) => { + // ``silent`` skips the loading spinner so background refreshes + // (triggered when the overview poll detects a new session from + // another process) don't flicker the whole page or drop the user's + // scroll position. + if (!silent) setLoading(true); api .getSessions(PAGE_SIZE, p * PAGE_SIZE) .then((resp) => { @@ -814,7 +819,9 @@ export default function SessionsPage() { setTotal(resp.total); }) .catch(() => {}) - .finally(() => setLoading(false)); + .finally(() => { + if (!silent) setLoading(false); + }); }, []); const loadStats = useCallback(() => { @@ -828,6 +835,15 @@ export default function SessionsPage() { loadStats(); }, [loadStats]); + // Refs for the overview poll's new-session detection. The poll effect + // below is mounted once with stable deps, so it reads the current page + // and the last-seen newest session id through refs instead of capturing + // stale values. ``newestSeenRef`` starts null so the first poll sets a + // baseline without triggering a redundant reload (mount already loads). + const newestSeenRef = useRef(null); + const pageRef = useRef(page); + pageRef.current = page; + useEffect(() => { loadSessions(page); refreshEmptyCount(); @@ -841,13 +857,27 @@ export default function SessionsPage() { .catch(() => {}); api .getSessions(50) - .then((r) => setOverviewSessions(r.sessions)) + .then((r) => { + setOverviewSessions(r.sessions); + // The dashboard server and a terminal CLI are separate + // processes sharing one session DB — there is no push channel, + // so we detect sessions created in another process here. The + // overview poll already fetches the 50 newest sessions, so we + // reuse its head id as a cheap change signal: when it changes, + // silently refresh the paginated list so the new session shows + // up in real time without a visible loading flicker. + const newest = r.sessions[0]?.id ?? null; + if (shouldRefreshSessions(newestSeenRef.current, newest)) { + loadSessions(pageRef.current, true); + } + newestSeenRef.current = newest; + }) .catch(() => {}); }; loadOverview(); const id = setInterval(loadOverview, 5000); return () => clearInterval(id); - }, []); + }, [loadSessions]); useEffect(() => { const el = logScrollRef.current; diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 00000000000..34baae684e8 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.{ts,tsx}"], + }, +}); From f37bb21ff6a81b79432109c4f628e68d188d06f0 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:50:40 +0530 Subject: [PATCH 057/470] chore(dashboard): wire vitest into npm test script The salvaged PR added the vitest devDep + config + a unit test but never added a "test" script to web/package.json, so "npm run test" errored with "Missing script: test" and the new suite was unrunnable. Add the script so "npm run test" runs the suite as the PR body claimed (4/4 pass). --- web/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index 91f16ac2a04..6666773c737 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "typecheck": "tsc -p . --noEmit" + "typecheck": "tsc -p . --noEmit", + "test": "vitest run" }, "dependencies": { "@nous-research/ui": "0.18.2", From 46f9d53468cc691d3a15dfe79decc65ce7b50d2d Mon Sep 17 00:00:00 2001 From: tt-a1i <53142663+tt-a1i@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:51:41 +0800 Subject: [PATCH 058/470] fix(agent): aggregate anthropic aux calls via stream --- agent/anthropic_adapter.py | 53 ++++++++++++ agent/auxiliary_client.py | 4 +- run_agent.py | 10 ++- tests/agent/test_auxiliary_client.py | 45 +++++++++++ tests/run_agent/test_run_agent.py | 116 ++++++++++++++++++++++++++- 5 files changed, 221 insertions(+), 7 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 4a586d7f0fd..03e8b58e16c 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -2535,3 +2535,56 @@ def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any: sorted(leaked), ) return api_kwargs + + +def _is_stream_unavailable_error(exc: Exception) -> bool: + """Return True when an Anthropic stream call should fall back to create().""" + err_lower = str(exc).lower() + if "stream" in err_lower and "not supported" in err_lower: + return True + if "invokemodelwithresponsestream" in err_lower: + from agent.bedrock_adapter import is_streaming_access_denied_error + + return is_streaming_access_denied_error(exc) + return False + + +def create_anthropic_message( + client: Any, + api_kwargs: dict, + *, + log_prefix: str = "", + prefer_stream: bool = True, +) -> Any: + """Create an Anthropic message, aggregating via stream when available. + + Some Anthropic-compatible gateways are SSE-only: they ignore non-streaming + requests and return ``text/event-stream`` even for ``messages.create()``. + The SDK can surface that as raw text, so callers that expect a Message then + crash on ``.content``. Prefer ``messages.stream().get_final_message()`` to + match the main turn path, falling back to ``create()`` only for providers + that explicitly do not support streaming, such as restricted Bedrock roles. + """ + sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix) + + messages_api = getattr(client, "messages", None) + stream_fn = getattr(messages_api, "stream", None) + if prefer_stream and callable(stream_fn): + stream_kwargs = dict(api_kwargs) + stream_kwargs.pop("stream", None) + try: + with stream_fn(**stream_kwargs) as stream: + return stream.get_final_message() + except Exception as exc: + if not _is_stream_unavailable_error(exc): + raise + logger.debug( + "%sAnthropic Messages stream unavailable; falling back to " + "messages.create(): %s", + log_prefix, + exc, + ) + + create_kwargs = dict(api_kwargs) + create_kwargs.pop("stream", None) + return messages_api.create(**create_kwargs) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 86a1c765a78..f28b5f60156 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -997,7 +997,7 @@ class _AnthropicCompletionsAdapter: self._is_oauth = is_oauth def create(self, **kwargs) -> Any: - from agent.anthropic_adapter import build_anthropic_kwargs + from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message from agent.transports import get_transport messages = kwargs.get("messages", []) @@ -1041,7 +1041,7 @@ class _AnthropicCompletionsAdapter: if not _forbids_sampling_params(model): anthropic_kwargs["temperature"] = temperature - response = self._client.messages.create(**anthropic_kwargs) + response = create_anthropic_message(self._client, anthropic_kwargs) _transport = get_transport("anthropic_messages") _nr = _transport.normalize_response( response, strip_tool_prefix=self._is_oauth diff --git a/run_agent.py b/run_agent.py index 65b95483e54..7c195b35ca8 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4076,11 +4076,13 @@ class AIAgent: # Defensive: strip Responses-only kwargs that can leak in under an # api_mode-flip race (the Anthropic SDK raises a non-retryable # TypeError on them). See #31673. - from agent.anthropic_adapter import sanitize_anthropic_kwargs - sanitize_anthropic_kwargs( - api_kwargs, log_prefix=getattr(self, "log_prefix", "") + from agent.anthropic_adapter import create_anthropic_message + return create_anthropic_message( + self._anthropic_client, + api_kwargs, + log_prefix=getattr(self, "log_prefix", ""), + prefer_stream=not bool(getattr(self, "_disable_streaming", False)), ) - return self._anthropic_client.messages.create(**api_kwargs) def _rebuild_anthropic_client(self) -> None: """Rebuild the Anthropic client after an interrupt or stale call. diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index b2960b703c7..8ec6102f2e5 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -38,6 +38,20 @@ def _jwt_with_claims(claims: dict) -> str: return f"{header}.{payload}.sig" +class _FakeAnthropicStream: + def __init__(self, final_message): + self._final_message = final_message + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get_final_message(self): + return self._final_message + + @pytest.fixture(autouse=True) def _clean_env(monkeypatch): """Strip provider env vars so each test starts clean.""" @@ -990,6 +1004,37 @@ class TestVisionClientFallback: assert client.__class__.__name__ == "AnthropicAuxiliaryClient" assert model == "claude-haiku-4-5-20251001" + def test_anthropic_auxiliary_client_aggregates_stream_response(self): + from agent.auxiliary_client import AnthropicAuxiliaryClient + + final_message = SimpleNamespace( + content=[SimpleNamespace(type="text", text="streamed aux response")], + stop_reason="end_turn", + usage=SimpleNamespace(input_tokens=3, output_tokens=4), + ) + messages_api = SimpleNamespace( + stream=MagicMock(return_value=_FakeAnthropicStream(final_message)), + create=MagicMock(return_value="raw event-stream text"), + ) + real_client = SimpleNamespace(messages=messages_api) + client = AnthropicAuxiliaryClient( + real_client, + "claude-sonnet-4-20250514", + "sk-test", + "https://sse-only.example/v1", + ) + + response = client.chat.completions.create( + messages=[{"role": "user", "content": "summarize"}], + max_tokens=16, + ) + + messages_api.stream.assert_called_once() + messages_api.create.assert_not_called() + assert response.choices[0].message.content == "streamed aux response" + assert response.usage.prompt_tokens == 3 + assert response.usage.completion_tokens == 4 + class TestAuxiliaryPoolAwareness: def test_try_nous_uses_pool_entry(self): diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index f2787628d4d..385a296f889 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -5813,12 +5813,126 @@ class TestAnthropicCredentialRefresh: response = SimpleNamespace(content=[]) agent._anthropic_client = MagicMock() - agent._anthropic_client.messages.create.return_value = response + stream_cm = MagicMock() + stream_cm.__enter__.return_value.get_final_message.return_value = response + agent._anthropic_client.messages.stream.return_value = stream_cm with patch.object(agent, "_try_refresh_anthropic_client_credentials", return_value=True) as refresh: result = agent._anthropic_messages_create({"model": "claude-sonnet-4-20250514"}) refresh.assert_called_once_with() + agent._anthropic_client.messages.stream.assert_called_once_with(model="claude-sonnet-4-20250514") + agent._anthropic_client.messages.create.assert_not_called() + assert result is response + + def test_anthropic_messages_create_falls_back_when_stream_unavailable(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant-oat01-current-token", + base_url="https://openrouter.ai/api/v1", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + response = SimpleNamespace(content=[]) + agent._anthropic_client = MagicMock() + agent._anthropic_client.messages.stream.side_effect = RuntimeError( + "stream is not supported by this provider" + ) + agent._anthropic_client.messages.create.return_value = response + + with patch.object(agent, "_try_refresh_anthropic_client_credentials", return_value=False): + result = agent._anthropic_messages_create({"model": "claude-sonnet-4-20250514"}) + + agent._anthropic_client.messages.stream.assert_called_once_with(model="claude-sonnet-4-20250514") + agent._anthropic_client.messages.create.assert_called_once_with(model="claude-sonnet-4-20250514") + assert result is response + + def test_anthropic_messages_create_honors_disable_streaming(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant-oat01-current-token", + base_url="https://openrouter.ai/api/v1", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + response = SimpleNamespace(content=[]) + agent._disable_streaming = True + agent._anthropic_client = MagicMock() + agent._anthropic_client.messages.create.return_value = response + + with patch.object(agent, "_try_refresh_anthropic_client_credentials", return_value=False): + result = agent._anthropic_messages_create({"model": "claude-sonnet-4-20250514"}) + + agent._anthropic_client.messages.stream.assert_not_called() + agent._anthropic_client.messages.create.assert_called_once_with(model="claude-sonnet-4-20250514") + assert result is response + + def test_anthropic_messages_create_does_not_mask_bedrock_stream_validation_errors(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant-oat01-current-token", + base_url="https://bedrock-runtime.us-east-1.amazonaws.com", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + exc = RuntimeError("ValidationException: InvokeModelWithResponseStream input malformed") + agent._anthropic_client = MagicMock() + agent._anthropic_client.messages.stream.side_effect = exc + + with ( + patch.object(agent, "_try_refresh_anthropic_client_credentials", return_value=False), + pytest.raises(RuntimeError, match="input malformed"), + ): + agent._anthropic_messages_create({"model": "claude-sonnet-4-20250514"}) + + agent._anthropic_client.messages.create.assert_not_called() + + def test_anthropic_messages_create_falls_back_for_bedrock_stream_access_denied(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant-oat01-current-token", + base_url="https://bedrock-runtime.us-east-1.amazonaws.com", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + response = SimpleNamespace(content=[]) + agent._anthropic_client = MagicMock() + agent._anthropic_client.messages.stream.side_effect = RuntimeError( + "User is not authorized to perform: bedrock:InvokeModelWithResponseStream" + ) + agent._anthropic_client.messages.create.return_value = response + + with patch.object(agent, "_try_refresh_anthropic_client_credentials", return_value=False): + result = agent._anthropic_messages_create({"model": "claude-sonnet-4-20250514"}) + agent._anthropic_client.messages.create.assert_called_once_with(model="claude-sonnet-4-20250514") assert result is response From 6ad0bc20f53d5fe240cc99ac0a105543aa895818 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Fri, 19 Jun 2026 18:13:18 +0700 Subject: [PATCH 059/470] fix(sessions): let a compression continuation reclaim its base title When context compression rotates a session, the original is ended and the continuation is auto-numbered (e.g. "name" -> "name #2"). The session list projects the ended root behind its live tip, so the user never sees the predecessor. But set_session_title's uniqueness check compared against ALL sessions, so renaming the visible tip back to "name" dead-ended with "Title 'name' is already in use by session ". When the conflicting title is held by a compression ancestor of the session being renamed, transfer the title instead of raising: clear it from the ended predecessor and apply it to the continuation. Uniqueness is preserved (still exactly one session carries the title) and the parent-link lineage is untouched, so resume-by-title and tip projection keep working. Genuine conflicts with unrelated sessions, and with non-compression children (delegate/branch), still raise as before. --- hermes_state.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 36e5c91fe8a..2ca3c657d13 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1836,6 +1836,48 @@ class SessionDB: return cleaned + def _is_compression_ancestor( + self, conn, *, ancestor_id: str, descendant_id: str + ) -> bool: + """Return True if *ancestor_id* is a compression predecessor of + *descendant_id* (walking parent links up the continuation chain). + + Uses the same edge definition as :meth:`get_compression_tip`: a + parent → child edge counts as a compression continuation only when the + parent ended with ``end_reason = 'compression'`` and the child started + at or after the parent's ``ended_at`` (which distinguishes continuations + from delegate subagents / branch children that also carry a + ``parent_session_id``). + """ + if not ancestor_id or not descendant_id or ancestor_id == descendant_id: + return False + current = descendant_id + # Bound the walk defensively, mirroring get_compression_tip. + for _ in range(100): + row = conn.execute( + "SELECT parent_session_id, started_at FROM sessions WHERE id = ?", + (current,), + ).fetchone() + if row is None or not row["parent_session_id"]: + return False + parent_id = row["parent_session_id"] + parent = conn.execute( + "SELECT ended_at, end_reason FROM sessions WHERE id = ?", + (parent_id,), + ).fetchone() + if ( + parent is None + or parent["end_reason"] != "compression" + or parent["ended_at"] is None + or row["started_at"] is None + or row["started_at"] < parent["ended_at"] + ): + return False + if parent_id == ancestor_id: + return True + current = parent_id + return False + def set_session_title(self, session_id: str, title: str) -> bool: """Set or update a session's title. @@ -1854,9 +1896,29 @@ class SessionDB: ) conflict = cursor.fetchone() if conflict: - raise ValueError( - f"Title '{title}' is already in use by session {conflict['id']}" - ) + conflict_id = conflict["id"] + # A compression continuation is the live, projected-forward + # head of its conversation; its compressed predecessors are + # ended and hidden from the session list (list_sessions_rich + # projects roots → tip). When the title that "conflicts" is + # held by such a hidden ancestor, the user has no way to free + # it — renaming the visible tip back to the base name would + # dead-end with "already in use by ". + # Treat this as a transfer: move the title off the ancestor + # onto the continuation. Uniqueness is preserved (still only + # one session carries the exact title) and the parent-link + # lineage is untouched. + if self._is_compression_ancestor( + conn, ancestor_id=conflict_id, descendant_id=session_id + ): + conn.execute( + "UPDATE sessions SET title = NULL WHERE id = ?", + (conflict_id,), + ) + else: + raise ValueError( + f"Title '{title}' is already in use by session {conflict_id}" + ) cursor = conn.execute( "UPDATE sessions SET title = ? WHERE id = ?", (title, session_id), From 65d050cf0e94a2c435db4c2f8d46a2952515193e Mon Sep 17 00:00:00 2001 From: xxxigm Date: Fri, 19 Jun 2026 18:13:24 +0700 Subject: [PATCH 060/470] test(sessions): cover title reclaim across a compression lineage Regression tests for renaming a compression continuation back to its base title: single- and multi-level chains transfer the title off the ended predecessor, while unrelated sessions and non-compression children (created while the parent was live) still raise the uniqueness conflict. --- tests/test_hermes_state.py | 83 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index e4650ed5dc7..1d727132a8c 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2065,6 +2065,89 @@ class TestSessionTitle: assert session["ended_at"] is not None +class TestSessionTitleLineage: + """Renaming a compression continuation back to its base title must succeed + by transferring the title off the ended, hidden predecessor. + + After a context compaction the original session is ended and projected + behind its live tip in the session list (list_sessions_rich), so the user + cannot see or free it. Without lineage-aware handling, renaming the visible + tip back to the base name dead-ends with "already in use by ". + """ + + def _make_compression_chain(self, db, t0, *, root="root", tip="tip"): + db.create_session(root, "cli") + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, root)) + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?", + (t0 + 100, root), + ) + db.create_session(tip, "cli", parent_session_id=root) + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 200, tip)) + db._conn.commit() + + def test_rename_continuation_back_to_base_transfers_title(self, db): + import time as _time + self._make_compression_chain(db, _time.time() - 3600) + db.set_session_title("root", "fingerprint-scanner") + db.set_session_title("tip", "fingerprint-scanner #2") + + # User renames the visible tip back to the base name — must succeed. + assert db.set_session_title("tip", "fingerprint-scanner") is True + assert db.get_session("tip")["title"] == "fingerprint-scanner" + # Title transferred off the hidden ancestor — no duplicate titles. + assert db.get_session("root")["title"] is None + + def test_transfer_walks_multi_level_chain(self, db): + import time as _time + t0 = _time.time() - 7200 + # root (compression) -> mid (compression) -> tip + self._make_compression_chain(db, t0, root="root", tip="mid") + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason='compression' WHERE id=?", + (t0 + 300, "mid"), + ) + db.create_session("tip", "cli", parent_session_id="mid") + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 400, "tip")) + db._conn.commit() + + db.set_session_title("root", "deep-dive") + assert db.set_session_title("tip", "deep-dive") is True + assert db.get_session("tip")["title"] == "deep-dive" + assert db.get_session("root")["title"] is None + + def test_unrelated_session_still_conflicts(self, db): + db.create_session("a", "cli") + db.create_session("b", "cli") + db.set_session_title("a", "shared") + with pytest.raises(ValueError, match="already in use"): + db.set_session_title("b", "shared") + # The unrelated holder keeps its title. + assert db.get_session("a")["title"] == "shared" + + def test_non_compression_child_still_conflicts(self, db): + """A child whose parent did NOT end via compression (delegate/branch + spawned while the parent was live) is not a continuation, so renaming it + to the parent's title must still raise.""" + import time as _time + t0 = _time.time() - 3600 + db.create_session("parent", "cli") + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "parent")) + db.create_session("child", "cli", parent_session_id="parent") + # Child started BEFORE parent ended, and parent ended for a non- + # compression reason — not a continuation edge. + db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0 + 10, "child")) + db._conn.execute( + "UPDATE sessions SET ended_at=?, end_reason='user_exit' WHERE id=?", + (t0 + 100, "parent"), + ) + db._conn.commit() + db.set_session_title("parent", "shared") + with pytest.raises(ValueError, match="already in use"): + db.set_session_title("child", "shared") + + class TestSanitizeTitle: """Tests for SessionDB.sanitize_title() validation and cleaning.""" From 8c70346e33e34d204ecf9ef1c29e8d374182d56c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:37:39 +0530 Subject: [PATCH 061/470] refactor(sessions): express compression-ancestor check as one recursive CTE _is_compression_ancestor walked parent links in a 100-hop Python loop issuing two SELECTs per hop and hand-re-encoded the compression continuation edge a fourth time. Collapse it into a single recursive CTE that reuses the canonical _COMPRESSION_CHILD_SQL fragment (already shared by _ephemeral_child_sql and set_session_archived), so the edge definition lives in exactly one place. The UNION recursion also dedups visited nodes, making it cycle-safe without the defensive hop cap. Behavior is unchanged (all TestSessionTitleLineage + existing title-command tests pass). --- hermes_state.py | 55 ++++++++++++++++++++++--------------------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 2ca3c657d13..8847593d47c 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1842,41 +1842,36 @@ class SessionDB: """Return True if *ancestor_id* is a compression predecessor of *descendant_id* (walking parent links up the continuation chain). - Uses the same edge definition as :meth:`get_compression_tip`: a - parent → child edge counts as a compression continuation only when the + The continuation edge is the canonical one shared with + :func:`_ephemeral_child_sql` / :meth:`set_session_archived` + (``_COMPRESSION_CHILD_SQL``): a parent → child edge counts only when the parent ended with ``end_reason = 'compression'`` and the child started - at or after the parent's ``ended_at`` (which distinguishes continuations + at or after the parent's ``ended_at``, which distinguishes continuations from delegate subagents / branch children that also carry a - ``parent_session_id``). + ``parent_session_id``. Expressed as a single recursive CTE rather than a + per-hop Python walk so the edge definition lives in exactly one place. """ if not ancestor_id or not descendant_id or ancestor_id == descendant_id: return False - current = descendant_id - # Bound the walk defensively, mirroring get_compression_tip. - for _ in range(100): - row = conn.execute( - "SELECT parent_session_id, started_at FROM sessions WHERE id = ?", - (current,), - ).fetchone() - if row is None or not row["parent_session_id"]: - return False - parent_id = row["parent_session_id"] - parent = conn.execute( - "SELECT ended_at, end_reason FROM sessions WHERE id = ?", - (parent_id,), - ).fetchone() - if ( - parent is None - or parent["end_reason"] != "compression" - or parent["ended_at"] is None - or row["started_at"] is None - or row["started_at"] < parent["ended_at"] - ): - return False - if parent_id == ancestor_id: - return True - current = parent_id - return False + # Walk parent links up from the descendant, following only compression + # continuation edges, and check whether ancestor_id is reached. + edge = _COMPRESSION_CHILD_SQL.format(a="child") + row = conn.execute( + f""" + WITH RECURSIVE ancestors(id) AS ( + SELECT ? + UNION + SELECT parent.id + FROM ancestors a + JOIN sessions child ON child.id = a.id + JOIN sessions parent ON parent.id = child.parent_session_id + WHERE {edge} + ) + SELECT 1 FROM ancestors WHERE id = ? AND id != ? LIMIT 1 + """, + (descendant_id, ancestor_id, descendant_id), + ).fetchone() + return row is not None def set_session_title(self, session_id: str, title: str) -> bool: """Set or update a session's title. From f9ffe0bc3f619fc2100bd3e77622090e9c794603 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Fri, 19 Jun 2026 18:54:27 +0700 Subject: [PATCH 062/470] fix(desktop): resume stored session id on notification click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native notifications (approval / sudo / secret / clarify) are tagged with the gateway *runtime* session id — the key under which the session lives in the gateway's in-memory `_sessions` map and the id every event carries (`tui_gateway/server.py` `_emit(event, sid, ...)`). The chat route, however, is keyed by the *stored* session id (`stored_session_id`), which is a different value: a new chat gets its runtime id immediately but its stored id only once the first turn persists. `onFocusSession` navigated straight to `sessionRoute()`, so clicking a notification (e.g. an approval prompt) sent the route-resume path a runtime id where it expects a stored id. `useRouteResume` then resumed it as a stored session -> REST `/api/sessions/` 404 "session not found", and the running session was navigated away, which the user experiences as the session being destroyed. Translate runtime -> stored before navigating via the existing `runtimeIdByStoredSessionId` map (new `storedSessionIdForNotification` helper), falling back to the id as-is when no mapping is known. The Approve/Reject notification button path is untouched: `approval.respond` is routed by the runtime id (`_sess()` -> `_sessions[session_id]`), so it must keep carrying the runtime id. --- apps/desktop/src/app/desktop-controller.tsx | 11 ++++++--- apps/desktop/src/lib/session-ids.ts | 26 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/lib/session-ids.ts diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 05dfbbc764f..c2523bf3654 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -20,6 +20,7 @@ import { MESSAGING_SESSION_SOURCE_IDS, normalizeSessionSource } from '../lib/session-source' +import { storedSessionIdForNotification } from '../lib/session-ids' import { latestSessionTodos } from '../lib/todos' import { setCronFocusJobId, setCronJobs } from '../store/cron' import { @@ -276,16 +277,20 @@ export function DesktopController() { } }, []) - // Notification click: the main process already focused the window; jump to its session. + // Notification click: the main process already focused the window; jump to its + // session. Notifications are tagged with the gateway *runtime* session id, but + // the chat route is keyed by the *stored* id — navigating with the runtime id + // resumes a non-existent stored session ("session not found") and strands the + // user. Translate runtime -> stored before navigating. useEffect(() => { const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => { if (sessionId) { - navigate(sessionRoute(sessionId)) + navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionIdRef.current))) } }) return () => unsubscribe?.() - }, [navigate]) + }, [navigate, runtimeIdByStoredSessionIdRef]) // Notification action button (Approve/Reject) — resolve in place, no navigation. useEffect(() => { diff --git a/apps/desktop/src/lib/session-ids.ts b/apps/desktop/src/lib/session-ids.ts new file mode 100644 index 00000000000..c97cadc2628 --- /dev/null +++ b/apps/desktop/src/lib/session-ids.ts @@ -0,0 +1,26 @@ +// The gateway tags every event — and therefore every native notification — +// with the *runtime* session id (the key under which the session lives in the +// gateway's in-memory `_sessions` map). The chat route, however, is keyed by +// the *stored* session id (`stored_session_id`), which is a different value: +// a brand-new chat gets a runtime id immediately but its stored id is assigned +// when the first turn persists. Navigating to a runtime id therefore tries to +// resume a stored session that does not exist ("session not found") and +// strands the user, who experiences it as the running session being destroyed. +// +// `runtimeIdByStoredSessionId` maps stored -> runtime; this resolves the +// reverse so notification-click navigation lands on the real route. The id is +// returned unchanged when no mapping is known — it may already be a stored id +// (e.g. a notification for a session this window never opened), in which case +// the normal resume/REST lookup handles it. +export function storedSessionIdForNotification( + id: string, + runtimeIdByStoredSessionId: ReadonlyMap +): string { + for (const [storedId, runtimeId] of runtimeIdByStoredSessionId) { + if (runtimeId === id) { + return storedId + } + } + + return id +} From 069011dd0c8f714519d145f4fe46785cfc3fe00b Mon Sep 17 00:00:00 2001 From: xxxigm Date: Fri, 19 Jun 2026 18:54:27 +0700 Subject: [PATCH 063/470] test(desktop): cover runtime->stored notification id resolution Unit-test `storedSessionIdForNotification`: runtime ids resolve to their stored id, unknown ids and empty maps pass through unchanged, the right stored id is picked among several sessions, and stored ids (map keys) are never rewritten. --- apps/desktop/src/app/desktop-controller.tsx | 2 +- apps/desktop/src/lib/session-ids.test.ts | 44 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/session-ids.test.ts diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index c2523bf3654..5ca73061135 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -14,13 +14,13 @@ import { useSkinCommand } from '@/themes/use-skin-command' import { formatRefValue } from '../components/assistant-ui/directive-text' import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes' import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages' +import { storedSessionIdForNotification } from '../lib/session-ids' import { isMessagingSource, LOCAL_SESSION_SOURCE_IDS, MESSAGING_SESSION_SOURCE_IDS, normalizeSessionSource } from '../lib/session-source' -import { storedSessionIdForNotification } from '../lib/session-ids' import { latestSessionTodos } from '../lib/todos' import { setCronFocusJobId, setCronJobs } from '../store/cron' import { diff --git a/apps/desktop/src/lib/session-ids.test.ts b/apps/desktop/src/lib/session-ids.test.ts new file mode 100644 index 00000000000..b5653c8eecd --- /dev/null +++ b/apps/desktop/src/lib/session-ids.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { storedSessionIdForNotification } from './session-ids' + +describe('storedSessionIdForNotification', () => { + it('translates a runtime id back to its stored id', () => { + // The route is keyed by the stored id, but notifications carry the runtime + // id. Resolving runtime -> stored keeps notification-click navigation from + // resuming a non-existent stored session ("session not found"). + const map = new Map([['stored-abc', 'runtime-123']]) + + expect(storedSessionIdForNotification('runtime-123', map)).toBe('stored-abc') + }) + + it('returns the id unchanged when no mapping is known', () => { + // A notification for a session this window never opened may already carry a + // stored id; let the resume/REST lookup handle it as-is. + const map = new Map([['stored-abc', 'runtime-123']]) + + expect(storedSessionIdForNotification('stored-xyz', map)).toBe('stored-xyz') + }) + + it('returns the id unchanged for an empty map', () => { + expect(storedSessionIdForNotification('runtime-123', new Map())).toBe('runtime-123') + }) + + it('resolves the correct stored id among several sessions', () => { + const map = new Map([ + ['stored-1', 'runtime-1'], + ['stored-2', 'runtime-2'], + ['stored-3', 'runtime-3'] + ]) + + expect(storedSessionIdForNotification('runtime-2', map)).toBe('stored-2') + }) + + it('does not treat a stored id as a runtime id (keys are not matched)', () => { + // The map is stored -> runtime. A value that only appears as a *key* must + // not be rewritten, otherwise an already-stored id could be mangled. + const map = new Map([['stored-1', 'runtime-1']]) + + expect(storedSessionIdForNotification('stored-1', map)).toBe('stored-1') + }) +}) From bce1e36b5769791b8e050a9f174982b2b6a6215a Mon Sep 17 00:00:00 2001 From: Kenny John Jacob Date: Tue, 2 Jun 2026 02:01:27 +0000 Subject: [PATCH 064/470] fix(discord): unwrap dict choices + soft-boundary truncate clarify buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surfaced from production usage in #37134: 1. Dict choices rendered as Python repr. LLMs sometimes emit [{"description": "..."}] instead of bare strings; the old str(c).strip() coercion turned the whole dict into "{'description': '...'}" on the button label. Fix: add a _flatten_choice helper that unwraps dicts against the canonical LLM tool-call user-facing keys (label, description, text, title) in that order. Dicts with none of those keys are dropped. The "name" and "value" keys are deliberately NOT in the priority list — they're Discord-component-shaped fields that could appear in dicts that aren't meant to be choices (a developer-error wiring that passes a Button-shaped object); picking them would leak raw enum values or 4-char model identifiers onto user-facing buttons. 2. Mid-word truncation on long button labels. The old choice[:72] + "..." cut at position 72, mid-word. Worse, the three-char ellipsis ate into the 80-char Discord label cap, leaving only 75 chars of body. Fix: budget-aware cut strategy with three tiers: a. Last space in the trailing half of the budget (word boundary). b. Last soft boundary (- , . )) in the trailing half — used only when no word boundary exists. c. Hard cut at the budget limit (last resort). Use single U+2026 (…) to fit the cap. Cut AT soft boundaries (inclusive) so the label ends on the boundary char rather than on the alpha char that followed it. Tests: - test_unwraps_dict_choices_to_description: reproduces the screenshot in #37134, asserts the Python repr is gone. - test_unwrap_prefers_description_over_name_in_multi_key_dict: regression guard for the name-key order in the unwrap list. - test_unwrap_prefers_label_over_description: regression guard for label winning over description. - test_unwrap_does_not_pick_value_or_name_alone: regression guard for the "name"/"value" fields being absent. - test_truncates_long_choice_label: 200-char input, asserts total <= 80 and U+2026. - test_truncates_long_choice_label_breaks_on_word_boundary: asserts the cut is on a space, not mid-word. - test_truncates_long_no_space_choice_on_soft_boundary: adversarial input where position 76 is mid-word alpha, asserts the renderer falls back to a soft boundary. Parity: telegram clarify suite (12 tests) still passes; the helper is a Discord adapter local, not shared with the gateway. Follow-up: gateway/platforms/telegram.py has the same str(c).strip() pattern in its own send_clarify and will need a similar fix (separate PR to keep this diff reviewable). Fixes #37134 --- plugins/platforms/discord/adapter.py | 81 +++++++- tests/gateway/test_discord_clarify_buttons.py | 178 +++++++++++++++++- 2 files changed, 253 insertions(+), 6 deletions(-) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 8146ca9de10..6ca199dcfaf 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -4566,6 +4566,13 @@ class DiscordAdapter(BasePlatformAdapter): Open-ended mode (``choices`` empty/None): renders the question as plain embed text — no buttons. The gateway's text-intercept captures the next message in this session and resolves the clarify. + + Choice normalisation: ``choices`` may contain bare strings OR dicts + (LLMs sometimes emit ``[{"description": "..."}]`` instead of bare + strings, which would otherwise render as raw Python repr on the + button label). Dict choices are unwrapped against the canonical + LLM tool-call keys ``label``, ``description``, ``text``, ``title`` + in that order. Dicts with none of those keys are dropped. """ if not self._client or not DISCORD_AVAILABLE: return SendResult(success=False, error="Not connected") @@ -4591,8 +4598,37 @@ class DiscordAdapter(BasePlatformAdapter): color=discord.Color.orange(), ) + # Normalise choices: LLMs sometimes emit `[{"description": "..."}]` + # instead of bare strings, which would render as raw Python repr on + # the button label. Unwrap the common shapes, then stringify. + def _flatten_choice(c): + if c is None: + return "" + if isinstance(c, str): + return c.strip() + if isinstance(c, dict): + # Prefer the canonical LLM tool-call user-facing keys + # in the order the LLM is most likely to emit them. + # 'name' and 'value' are deliberately NOT here: they're + # Discord-component-shaped fields that could appear in + # dicts that aren't meant to be choices (e.g., a + # developer-error wiring that passes a Button-shaped + # object). Picking them would leak raw enum values + # or 4-char model identifiers onto user-facing buttons. + # If a dict has none of the canonical keys, drop it + # rather than picking some random field — a garbage + # button label is worse than no button at all. + for key in ("label", "description", "text", "title"): + v = c.get(key) + if isinstance(v, str) and v.strip(): + return v.strip() + return "" + if isinstance(c, (list, tuple)): + return " ".join(_flatten_choice(x) for x in c).strip() + return str(c).strip() + clean_choices = [ - str(c).strip() for c in (choices or []) if c is not None and str(c).strip() + s for s in (_flatten_choice(c) for c in (choices or [])) if s ] # Discord allows up to 5 buttons per row, 5 rows per view = 25. # We reserve one slot for the "Other" button, so cap at 24 choices. @@ -6129,10 +6165,47 @@ def _define_discord_view_classes() -> None: self.resolved = False for index, choice in enumerate(self.choices): - # Discord button labels are capped at 80 chars. - label_body = choice if len(choice) <= 75 else choice[:72] + "..." + # Discord button labels are capped at 80 chars. On mobile the + # visible width is much narrower (often <40 chars before it + # wraps to 2 lines and the second line gets cut off), so we + # cap aggressively and cut at a word boundary when possible + # to keep the trailing text readable. + # + # Cut strategy (most-preferred to least-preferred): + # 1. Last space in the trailing half of the budget + # (cleanest word boundary) + # 2. Last soft boundary in the trailing half of the + # budget (hyphen, comma, period, paren) + # 3. Hard cut at the budget limit (last resort) + prefix = f"{index + 1}. " + budget = 80 - len(prefix) + if len(choice) <= budget: + label_body = choice + else: + truncated = choice[: budget - 1].rstrip() + cut_at = -1 + # 1. Last space in the trailing half of the budget. + space = truncated.rfind(" ") + if space >= budget // 2: + cut_at = space + # 2. Soft boundary — only if no word boundary found. + # Find the latest soft boundary in the trailing half + # of the budget; that maximizes preserved text length. + # Cut AT the soft boundary (inclusive) so the label + # ends on the soft char (e.g. "-" or ",") rather than + # on the alpha char that followed it. + if cut_at < 0: + latest_soft = max( + (truncated.rfind(s) for s in ("-", ",", ".", ")")), + default=-1, + ) + if latest_soft >= budget // 2: + cut_at = latest_soft + 1 + if cut_at > 0: + truncated = truncated[:cut_at] + label_body = truncated.rstrip() + "…" button = discord.ui.Button( - label=f"{index + 1}. {label_body}", + label=f"{prefix}{label_body}", style=discord.ButtonStyle.primary, custom_id=f"clarify:{clarify_id}:{index}", ) diff --git a/tests/gateway/test_discord_clarify_buttons.py b/tests/gateway/test_discord_clarify_buttons.py index c83e52dba5a..b8b5dc10ed2 100644 --- a/tests/gateway/test_discord_clarify_buttons.py +++ b/tests/gateway/test_discord_clarify_buttons.py @@ -122,13 +122,56 @@ class TestClarifyChoiceViewConstruction: clarify_id="cidZ", allowed_user_ids=set(), ) - # 75 chars + 3 ellipsis chars in the body, plus "1. " prefix + # 78 chars + single-char ellipsis in the body, plus "1. " prefix. + # Uses U+2026 (…) instead of "..." to fit the 80-char Discord cap. first_label = view.children[0].label assert first_label.startswith("1. ") - assert first_label.endswith("...") + assert first_label.endswith("\u2026") # Final label total <= 80 (Discord cap on button labels) assert len(first_label) <= 80 + def test_truncates_long_choice_label_breaks_on_word_boundary(self): + # Long choice with spaces — should cut at the last whole word so the + # trailing text stays readable on Discord mobile. + long_choice = ( + "Tight, well-illustrated, covers all 3 audiences " + "(patients, families, curious general readers)" + ) + view = ClarifyChoiceView( + choices=[long_choice], + clarify_id="cidW", + allowed_user_ids=set(), + ) + first_label = view.children[0].label + assert first_label.startswith("1. ") + assert first_label.endswith("\u2026") + # No mid-word fragment before the ellipsis. + assert not first_label.rstrip("\u2026").endswith("(") + + def test_truncates_long_no_space_choice_on_soft_boundary(self): + # A long choice with soft boundaries (commas, hyphens) but no spaces + # should still cut on a soft boundary, not mid-word. We use an input + # where position 76 is NOT a soft boundary — the test only passes + # if the renderer actively searches backward for a soft char + # rather than blindly cutting at the budget limit. + long_choice = "a" * 30 + "-" + "b" * 30 + "-" + "c" * 30 + "-" + "d" * 30 + # 30a-30b-30c-30d = 30 + 1 + 30 + 1 + 30 + 1 + 30 = 123 chars + # Position 76 is 'b' (a mid-word alpha). The renderer must look back + # for a '-' to cut on. + view = ClarifyChoiceView( + choices=[long_choice], + clarify_id="cidSB", + allowed_user_ids=set(), + ) + first_label = view.children[0].label + assert first_label.endswith("\u2026") + assert len(first_label) <= 80 + body = first_label[len("1. "):].rstrip("\u2026") + last_char = body[-1] + assert last_char in {"-", ",", ".", ")", " "}, ( + f"Label cuts mid-word at {last_char!r}: {first_label!r}" + ) + # =========================================================================== # Choice callback → resolve_gateway_clarify @@ -404,3 +447,134 @@ class TestDiscordSendClarify: # Only 1 real choice + 1 Other = 2 children assert len(view.children) == 2 assert "real-choice" in view.children[0].label + + @pytest.mark.asyncio + async def test_unwraps_dict_choices_to_description(self): + # LLMs sometimes emit [{"description": "..."}] instead of bare strings + # — the renderer must unwrap common dict shapes, not str() the whole + # dict into a Python repr on the button label. + adapter = _make_adapter() + channel = MagicMock() + sent_msg = MagicMock() + sent_msg.id = 555 + channel.send = AsyncMock(return_value=sent_msg) + adapter._client.get_channel = MagicMock(return_value=channel) + + malformed = [ + {"description": "Tight, well-illustrated"}, + {"label": "Use label key"}, + {"text": "Use text key"}, + "normal-string", # strings still pass through + ] + await adapter.send_clarify( + chat_id="9001", + question="?", + choices=malformed, + clarify_id="cidU", + session_key="sk-U", + ) + kwargs = channel.send.call_args.kwargs + view = kwargs["view"] + labels = [b.label for b in view.children[:-1]] # exclude Other + # No raw Python repr should leak onto any label. + for label in labels: + assert "{'" not in label + assert "':" not in label + # Each dict unwrapped to its inner string. + assert any("Tight, well-illustrated" in lbl for lbl in labels) + assert any("Use label key" in lbl for lbl in labels) + assert any("Use text key" in lbl for lbl in labels) + assert any("normal-string" in lbl for lbl in labels) + + @pytest.mark.asyncio + async def test_unwrap_prefers_description_over_name_in_multi_key_dict(self): + # When the LLM emits both 'name' (often a short identifier in + # OpenAI-style tool calls) and 'description' (the user-facing text), + # the renderer must surface 'description'. The user should never see + # a 4-char model identifier on a button label. + adapter = _make_adapter() + channel = MagicMock() + sent_msg = MagicMock() + sent_msg.id = 666 + channel.send = AsyncMock(return_value=sent_msg) + adapter._client.get_channel = MagicMock(return_value=channel) + + await adapter.send_clarify( + chat_id="9001", + question="?", + choices=[{"name": "tight", "description": "Tight, well-illustrated"}], + clarify_id="cidN", + session_key="sk-N", + ) + kwargs = channel.send.call_args.kwargs + view = kwargs["view"] + choice_label = view.children[0].label + assert "Tight, well-illustrated" in choice_label + # The 'name' value (a short identifier) must NOT have leaked. + body = choice_label.split("1. ", 1)[1].rstrip("\u2026") + assert "tight" not in body, f"'name' leaked onto button: {choice_label!r}" + + @pytest.mark.asyncio + async def test_unwrap_prefers_label_over_description(self): + # When both 'label' and 'description' are present, 'label' wins. + # 'label' is the canonical short user-facing text in most LLM tool + # conventions; 'description' is the longer explanation. + adapter = _make_adapter() + channel = MagicMock() + sent_msg = MagicMock() + sent_msg.id = 777 + channel.send = AsyncMock(return_value=sent_msg) + adapter._client.get_channel = MagicMock(return_value=channel) + + await adapter.send_clarify( + chat_id="9001", + question="?", + choices=[{"label": "Short", "description": "Long verbose explanation"}], + clarify_id="cidL", + session_key="sk-L", + ) + kwargs = channel.send.call_args.kwargs + view = kwargs["view"] + choice_label = view.children[0].label + assert "Short" in choice_label + # The longer description must NOT have leaked. + assert "Long verbose" not in choice_label, ( + f"'description' leaked over 'label': {choice_label!r}" + ) + + @pytest.mark.asyncio + async def test_unwrap_does_not_pick_value_or_name_alone(self): + # 'name' and 'value' are Discord-component-shaped fields that could + # accidentally appear in dicts not intended as choices (e.g., a + # developer-error in the gateway wiring). The renderer should not + # surface them as button labels — only the well-known LLM tool-call + # keys (label, description, text, title) should win. + adapter = _make_adapter() + channel = MagicMock() + sent_msg = MagicMock() + sent_msg.id = 888 + channel.send = AsyncMock(return_value=sent_msg) + adapter._client.get_channel = MagicMock(return_value=channel) + + await adapter.send_clarify( + chat_id="9001", + question="?", + choices=[ + {"name": "only_name_here"}, # should be filtered out + {"value": "only_value_here"}, # should be filtered out + {"description": "real choice"}, + ], + clarify_id="cidNV", + session_key="sk-NV", + ) + kwargs = channel.send.call_args.kwargs + view = kwargs["view"] + choice_labels = [b.label for b in view.children[:-1]] # exclude Other + # Only the well-formed dict survives. + assert len(choice_labels) == 1, ( + f"Expected 1 choice, got {len(choice_labels)}: {choice_labels!r}" + ) + assert "real choice" in choice_labels[0] + for label in choice_labels: + assert "only_name_here" not in label, f"name leaked: {label!r}" + assert "only_value_here" not in label, f"value leaked: {label!r}" From 2c3aebcadccef685c96b8106361abed904a43a26 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:16:57 -0700 Subject: [PATCH 065/470] fix(clarify): unwrap dict choices at the source so every surface gets clean text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Discord fix (previous commit) handles dict-shaped clarify choices at the Discord adapter only. The same dict-repr leak originates upstream at tools/clarify_tool.py's str(c).strip() normalization — the single platform-agnostic point both the CLI and every gateway adapter flow through. When an LLM emits [{"description": "..."}] instead of bare strings, str(c) produced {'description': '...'} which leaked onto the CLI panel (cli.py:13048/13081), was returned verbatim as the user's answer (cli.py:11945), and hit Telegram's numbered list too. Add _flatten_choice (same label->description->text->title unwrap as the Discord adapter, name/value excluded, keyless dicts dropped) and apply it at the normalization line. Fixes CLI + Telegram + all platforms at the root; the Discord smart-truncation now operates on already-clean text. Adds johnjacobkenny to AUTHOR_MAP for the salvaged commit. --- scripts/release.py | 1 + tests/tools/test_clarify_tool.py | 65 ++++++++++++++++++++++++++++++++ tools/clarify_tool.py | 40 +++++++++++++++++++- 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/scripts/release.py b/scripts/release.py index 7e5901fd568..20c6a6bfa0a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -103,6 +103,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "johnjacobkenny@users.noreply.github.com": "johnjacobkenny", "chanyoung.kim@nota.ai": "channkim", "stevenn.damatoo@gmail.com": "x1erra", "evansrory@gmail.com": "zimigit2020", diff --git a/tests/tools/test_clarify_tool.py b/tests/tools/test_clarify_tool.py index 8659e1f13af..0c38961dd8d 100644 --- a/tests/tools/test_clarify_tool.py +++ b/tests/tools/test_clarify_tool.py @@ -9,6 +9,7 @@ from tools.clarify_tool import ( check_clarify_requirements, MAX_CHOICES, CLARIFY_SCHEMA, + _flatten_choice, ) @@ -164,6 +165,70 @@ class TestCheckClarifyRequirements: assert check_clarify_requirements() is True +class TestClarifyDictChoices: + """Dict-shaped choices must be unwrapped to user-facing text at the source. + + LLMs sometimes emit [{"description": "..."}] instead of bare strings. The + naive str(c) coercion leaked the Python dict repr onto every surface (CLI + panel, Discord buttons, Telegram list) AND returned it verbatim as the + user's answer. _flatten_choice normalises at the one platform-agnostic + entry point so the whole class is fixed in one place. + """ + + def test_flatten_unwraps_label_first(self): + assert _flatten_choice({"label": "Short", "description": "Long"}) == "Short" + + def test_flatten_unwraps_description_when_no_label(self): + assert _flatten_choice({"description": "A loose layout"}) == "A loose layout" + + def test_flatten_unwrap_order_label_over_description(self): + assert _flatten_choice({"description": "verbose", "label": "tight"}) == "tight" + + def test_flatten_drops_name_value_only_dict(self): + # name/value are component-shaped fields, not user-facing labels — + # picking them would leak raw enum values / short model ids. + assert _flatten_choice({"name": "tight", "value": "x"}) == "" + + def test_flatten_prefers_canonical_key_over_name(self): + assert _flatten_choice({"name": "tight", "description": "Tight desc"}) == "Tight desc" + + def test_flatten_drops_keyless_dict(self): + assert _flatten_choice({"foo": "bar", "n": 1}) == "" + + def test_flatten_passthrough_string_and_scalar(self): + assert _flatten_choice("plain") == "plain" + assert _flatten_choice(7) == "7" + assert _flatten_choice(None) == "" + + def test_dict_choices_reach_callback_as_clean_text(self): + """The whole point: the UI callback never sees a dict repr.""" + seen = [] + + def cb(question, choices): + seen.extend(choices or []) + return choices[0] + + result = json.loads(clarify_tool( + "Pick a layout", + choices=[ + {"choice": "Tight", "description": "Tight, covers all 3 points"}, + {"description": "Loose layout"}, + {"name": "modelid", "value": "abc"}, # dropped, not leaked + "A plain string choice", + ], + callback=cb, + )) # type: ignore + assert seen == [ + "Tight, covers all 3 points", + "Loose layout", + "A plain string choice", + ] + # and the resolved answer is clean text, not a dict repr + assert result["user_response"] == "Tight, covers all 3 points" + assert "{" not in result["user_response"] + assert all("{" not in c for c in result["choices_offered"]) + + class TestClarifySchema: """Tests for the OpenAI function-calling schema.""" diff --git a/tools/clarify_tool.py b/tools/clarify_tool.py index c44787554cc..3560ccf6126 100644 --- a/tools/clarify_tool.py +++ b/tools/clarify_tool.py @@ -20,6 +20,39 @@ from typing import List, Optional, Callable MAX_CHOICES = 4 +def _flatten_choice(c) -> str: + """Coerce a single choice into its user-facing display string. + + The schema declares choices as bare strings, but LLMs sometimes emit + dict-shaped choices like ``[{"description": "..."}]``. A naive ``str(c)`` + turns the whole dict into its Python repr — ``{'description': '...'}`` — + which then leaks onto every surface that renders the choice (CLI panel, + Discord buttons, Telegram numbered list) AND is returned verbatim as the + user's answer. Normalising here, at the one platform-agnostic entry point, + fixes the whole class in one place instead of per-adapter. + + Dict unwrap order is the canonical LLM tool-call user-facing keys: + ``label`` → ``description`` → ``text`` → ``title``. ``name`` and ``value`` + are deliberately excluded — they're component-shaped fields that could + carry raw enum values or short identifiers, not human-readable labels. A + dict with none of the canonical keys is dropped (returns ""), since a + garbage label is worse than no choice at all. + """ + if c is None: + return "" + if isinstance(c, str): + return c.strip() + if isinstance(c, dict): + for key in ("label", "description", "text", "title"): + v = c.get(key) + if isinstance(v, str) and v.strip(): + return v.strip() + return "" + if isinstance(c, (list, tuple)): + return " ".join(_flatten_choice(x) for x in c).strip() + return str(c).strip() + + def clarify_tool( question: str, choices: Optional[List[str]] = None, @@ -48,7 +81,12 @@ def clarify_tool( if choices is not None: if not isinstance(choices, list): return tool_error("choices must be a list of strings.") - choices = [str(c).strip() for c in choices if str(c).strip()] + # LLMs sometimes emit dict-shaped choices (e.g. [{"description": "..."}]) + # instead of bare strings. _flatten_choice unwraps them to their + # user-facing text here — the single platform-agnostic entry point — + # so the CLI panel, Discord buttons, and Telegram list all render clean + # text and the resolved answer is never a raw Python dict repr. + choices = [s for s in (_flatten_choice(c) for c in choices) if s] if len(choices) > MAX_CHOICES: choices = choices[:MAX_CHOICES] if not choices: From 460b1e50e515fd9b0b8f472f66f8773336862d88 Mon Sep 17 00:00:00 2001 From: infinitycrew39 Date: Thu, 18 Jun 2026 07:28:28 +0700 Subject: [PATCH 066/470] fix(gateway): refresh max_turns before resolving runtime budget --- gateway/platforms/api_server.py | 10 ++++++++-- gateway/run.py | 19 +++++++++++-------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index da86952a09d..54720f2b300 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1033,7 +1033,13 @@ class APIServerAdapter(BasePlatformAdapter): — matching the semantics of the native gateway's ``session_key``. """ from run_agent import AIAgent - from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config, GatewayRunner + from gateway.run import ( + _current_max_iterations, + _resolve_runtime_agent_kwargs, + _resolve_gateway_model, + _load_gateway_config, + GatewayRunner, + ) from hermes_cli.tools_config import _get_platform_tools runtime_kwargs = _resolve_runtime_agent_kwargs() @@ -1043,7 +1049,7 @@ class APIServerAdapter(BasePlatformAdapter): user_config = _load_gateway_config() enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server")) - max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) + max_iterations = _current_max_iterations() # Load fallback provider chain so the API server platform has the # same fallback behaviour as Telegram/Discord/Slack (fixes #4954). diff --git a/gateway/run.py b/gateway/run.py index e24afd035e7..59dd890f8c9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1196,6 +1196,15 @@ def _reload_runtime_env_preserving_config_authority() -> None: os.environ["HERMES_MAX_ITERATIONS"] = str(agent_cfg["max_turns"]) +def _current_max_iterations() -> int: + """Return the current per-turn iteration budget after runtime env refresh.""" + _reload_runtime_env_preserving_config_authority() + try: + return int(os.getenv("HERMES_MAX_ITERATIONS", "90")) + except (TypeError, ValueError): + return 90 + + _DOCKER_VOLUME_SPEC_RE = re.compile(r"^(?P.+):(?P/[^:]+?)(?::(?P[^:]+))?$") _DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS = {"/output", "/outputs"} @@ -10633,7 +10642,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew disabled_toolsets = agent_cfg.get("disabled_toolsets") or None pr = self._provider_routing - max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) + max_iterations = _current_max_iterations() reasoning_config = self._resolve_session_reasoning_config(source=source) self._reasoning_config = reasoning_config self._service_tier = self._load_service_tier() @@ -14581,9 +14590,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # session_key is now set via contextvars in _set_session_env() # (concurrency-safe). Keep os.environ as fallback for CLI/cron. os.environ["HERMES_SESSION_KEY"] = session_key or "" - - # Read from env var or use default (same as CLI) - max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) # Map platform enum to the platform hint key the agent understands. # Platform.LOCAL ("local") maps to "cli"; others pass through as-is. @@ -14598,10 +14604,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if self._ephemeral_system_prompt: combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip() - # Re-read .env and config for fresh credentials (gateway is long-lived, - # keys may change without restart). Keep config.yaml authoritative for - # runtime budget settings bridged into env vars. - _reload_runtime_env_preserving_config_authority() + max_iterations = _current_max_iterations() try: model, runtime_kwargs = self._resolve_session_agent_runtime( From dcac719527c519f068d7cd6d5230aca64e657201 Mon Sep 17 00:00:00 2001 From: infinitycrew39 Date: Thu, 18 Jun 2026 07:28:28 +0700 Subject: [PATCH 067/470] test(gateway): cover runtime max_turns refresh --- tests/gateway/test_api_server.py | 34 +++++++++++++++++++ ...est_runtime_env_reload_config_authority.py | 15 ++++++++ 2 files changed, 49 insertions(+) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 95d49d8b4f1..ac5e29c4d3c 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -337,6 +337,40 @@ class TestAdapterInit: assert isinstance(agent, FakeAgent) assert captured["reasoning_config"] == {"enabled": True, "effort": "xhigh"} + def test_create_agent_refreshes_max_iterations_from_runtime_config(self, monkeypatch): + captured = {} + + class FakeAgent: + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr("run_agent.AIAgent", FakeAgent) + monkeypatch.setattr( + "gateway.run._resolve_runtime_agent_kwargs", + lambda: { + "provider": "openai", + "base_url": "https://example.test/v1", + "api_mode": "chat_completions", + }, + ) + monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "gpt-5") + monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {"agent": {"max_turns": 200}}) + monkeypatch.setattr( + "gateway.run.GatewayRunner._load_reasoning_config", + staticmethod(lambda: {}), + ) + monkeypatch.setattr("gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None)) + monkeypatch.setattr("gateway.run._current_max_iterations", lambda: 200) + monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set()) + + adapter = APIServerAdapter(PlatformConfig(enabled=True)) + monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None) + + agent = adapter._create_agent(session_id="api-session") + + assert isinstance(agent, FakeAgent) + assert captured["max_iterations"] == 200 + # --------------------------------------------------------------------------- # Auth checking diff --git a/tests/gateway/test_runtime_env_reload_config_authority.py b/tests/gateway/test_runtime_env_reload_config_authority.py index 92d54b8863c..d90b58297e8 100644 --- a/tests/gateway/test_runtime_env_reload_config_authority.py +++ b/tests/gateway/test_runtime_env_reload_config_authority.py @@ -51,3 +51,18 @@ def test_reload_runtime_env_keeps_env_max_iterations_when_config_omits_key( gateway_run._reload_runtime_env_preserving_config_authority() assert os.environ["HERMES_MAX_ITERATIONS"] == "123" + + +def test_current_max_iterations_reloads_before_reading(monkeypatch) -> None: + monkeypatch.setenv("HERMES_MAX_ITERATIONS", "90") + + def _fake_reload() -> None: + os.environ["HERMES_MAX_ITERATIONS"] = "200" + + monkeypatch.setattr( + gateway_run, + "_reload_runtime_env_preserving_config_authority", + _fake_reload, + ) + + assert gateway_run._current_max_iterations() == 200 From ca92e9a362503bcb7013233f6b0b5c5e9c23c92b Mon Sep 17 00:00:00 2001 From: infinitycrew39 Date: Fri, 19 Jun 2026 10:50:30 +0700 Subject: [PATCH 068/470] fix(gateway): refresh cached agent max_iterations from current config When a gateway agent is reused from cache, it retains the max_iterations from its initial creation. If config.yaml agent.max_turns or HERMES_MAX_ITERATIONS changed between turns, the cached agent's budget becomes stale. Before reusing a cached agent, refresh agent.max_iterations from the freshly-resolved value (read from env/config at line 14585). Fixes partial issue from PR #48127: handles fresh agent creation + cached agent reuse. --- gateway/run.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 59dd890f8c9..741f2a235ad 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -14802,6 +14802,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except KeyError: pass self._init_cached_agent_for_turn(agent, _interrupt_depth) + # Refresh agent max_iterations from current config + # (cached agent may have been created with old config) + agent.max_iterations = max_iterations logger.debug("Reusing cached agent for session %s", session_key) if agent is None: From 144834b2f752262e2017ce5f4090b18c5922f795 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:44:56 -0700 Subject: [PATCH 069/470] test(gateway): real cached-agent max_iterations regression test Replaces the tautological test from the original PR (which asserted a plain assignment it performed itself in the test body) with one that exercises the actual contracts: _init_cached_agent_for_turn leaves max_iterations untouched, and the per-turn IterationBudget rebuild (turn_context.py) propagates a refreshed cap. --- .../test_cached_agent_max_iterations.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/gateway/test_cached_agent_max_iterations.py diff --git a/tests/gateway/test_cached_agent_max_iterations.py b/tests/gateway/test_cached_agent_max_iterations.py new file mode 100644 index 00000000000..fcd523c70ef --- /dev/null +++ b/tests/gateway/test_cached_agent_max_iterations.py @@ -0,0 +1,92 @@ +"""Regression tests for PR #48127: cached agent max_iterations refresh. + +When a long-lived gateway reuses an agent from its cache, the agent must run +the *current* configured iteration budget — not the budget it was constructed +with on the first turn of that session. Two pieces make that true: + +1. ``GatewayRunner._init_cached_agent_for_turn`` must NOT reset + ``max_iterations`` itself (the gateway refreshes it explicitly right after, + from current config). If this helper ever started clobbering it, the + gateway's refresh would be silently undone. +2. The per-turn budget object is rebuilt from ``agent.max_iterations`` at the + start of every turn (``agent/turn_context.py`` -> ``IterationBudget``), so + refreshing ``max_iterations`` on the cached agent is sufficient to change + the operative cap the agent loop checks. + +These tests exercise the real code paths rather than asserting a plain +assignment, so they fail if either contract regresses. +""" + +import time +from types import SimpleNamespace + +from agent.iteration_budget import IterationBudget + + +def _make_cached_agent(max_iterations: int) -> SimpleNamespace: + """A minimal stand-in cached agent with the attributes the helpers touch.""" + # The turn loop checks both api_call_count >= max_iterations AND + # iteration_budget.remaining <= 0 (turn_finalizer.py), so the budget must + # also reflect the new cap. Seed it with the stale value to prove the + # refresh propagates. + return SimpleNamespace( + _last_activity_ts=time.time() - 1000, + _last_activity_desc="previous turn", + _api_call_count=42, + _last_flushed_db_idx=5, + max_iterations=max_iterations, + iteration_budget=IterationBudget(max_iterations), + ) + + +def test_init_cached_agent_for_turn_does_not_touch_max_iterations(): + """The per-turn reset helper must leave max_iterations untouched. + + The gateway refreshes max_iterations explicitly right after calling this + helper; if the helper ever reset it, that refresh would be undone. + """ + from gateway.run import GatewayRunner + + agent = _make_cached_agent(90) + GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=0) + + # Per-turn state was reset... + assert agent._api_call_count == 0 + assert agent._last_activity_desc == "starting new turn (cached)" + assert agent._last_flushed_db_idx == 0 + # ...but the iteration budget was NOT changed by the helper itself. + assert agent.max_iterations == 90 + + +def test_init_cached_agent_preserves_max_iterations_on_interrupt_depth(): + """Interrupt-recursive turns must also leave max_iterations alone.""" + from gateway.run import GatewayRunner + + agent = _make_cached_agent(200) + GatewayRunner._init_cached_agent_for_turn(agent, interrupt_depth=1) + + # Activity timestamps preserved for the inactivity watchdog (#15654)... + assert agent._last_activity_desc == "previous turn" + # ...and max_iterations untouched. + assert agent.max_iterations == 200 + + +def test_refreshed_max_iterations_propagates_to_turn_budget(): + """Refreshing max_iterations on a cached agent changes the operative cap. + + The gateway sets ``agent.max_iterations = max_iterations`` on cache reuse; + the new turn's setup then rebuilds ``iteration_budget`` from it. This proves + the refresh actually moves the budget the agent loop enforces — the cached + agent started at 90 and ends a new turn capped at 200. + """ + agent = _make_cached_agent(90) + assert agent.iteration_budget.max_total == 90 + + # Gateway refresh on cache reuse: + agent.max_iterations = 200 + + # Start-of-turn budget rebuild (agent/turn_context.py:166): + agent.iteration_budget = IterationBudget(agent.max_iterations) + + assert agent.iteration_budget.max_total == 200 + assert agent.iteration_budget.remaining == 200 From fd92a3a5c9da0079cea0731bf3adf7bb288caa1e Mon Sep 17 00:00:00 2001 From: Charles Power Date: Sun, 7 Jun 2026 21:39:14 -0700 Subject: [PATCH 070/470] fix(gateway): Windows restart no longer causes a silent outage `hermes gateway restart` on Windows could take the gateway offline with no replacement. restart() was stop() -> sleep(1.0) -> start(), but the graceful drain can run up to ~180s while the detached pythonw process stays alive. The 1s sleep let start() run against the still-draining old process; its "already running" guard then no-opped, and when the old process finally exited nothing relaunched it. Two root causes, both fixed: 1. Loose PID detection. `_scan_gateway_pids` and the gateway.status helpers used substring matches ("... gateway" in cmdline) for lifecycle decisions, so they false-matched `gateway status`/`dashboard` siblings and unrelated processes like `python -m tui_gateway`, plus stale gateway.pid records. Add a shared strict matcher `looks_like_gateway_command_line()` in gateway/status.py that requires the real `gateway run` subcommand (or the dedicated entrypoints), and route `_looks_like_gateway_process`, `_record_looks_like_gateway`, and `_scan_gateway_pids` through it. 2. restart() race. Wait until the gateway is authoritatively gone (`get_running_pid()` + strict `_gateway_pids()`) before relaunch; force-kill once if it lingers and raise rather than start a duplicate; verify the relaunch produced a running gateway and raise loudly if not (no more exit-0 silent outage). Scoped to Windows; systemd/launchd restart paths are already drain-aware. Adds tests/gateway/test_gateway_command_line_matcher.py. Co-Authored-By: Claude Opus 4.8 (1M context) --- gateway/status.py | 63 +++++++++++++------ hermes_cli/gateway.py | 32 +++------- hermes_cli/gateway_windows.py | 46 +++++++++++++- .../test_gateway_command_line_matcher.py | 48 ++++++++++++++ 4 files changed, 147 insertions(+), 42 deletions(-) create mode 100644 tests/gateway/test_gateway_command_line_matcher.py diff --git a/gateway/status.py b/gateway/status.py index 367ac33c4d7..5e5584a1ed8 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -14,6 +14,7 @@ concurrently under distinct configurations). import hashlib import json import os +import re import signal import subprocess import sys @@ -164,20 +165,53 @@ def _read_process_cmdline(pid: int) -> Optional[str]: return None +def looks_like_gateway_command_line(command: str | None) -> bool: + """Return True only for a real ``gateway run`` process command line. + + Lifecycle decisions (is the gateway up? did restart relaunch it?) must not + fire on loose substring matches. The previous ``"... gateway" in cmdline`` + test also matched ``hermes_cli.main gateway status`` and even unrelated + processes like ``python -m tui_gateway`` -- which made ``restart()`` race + against a still-draining old process and ``status``/``start`` report false + positives. This requires the actual ``gateway`` subcommand to be followed + by ``run`` (or the gateway-dedicated entrypoints), excluding the other + ``gateway`` management subcommands and any process that merely contains the + word "gateway". + """ + if not command: + return False + normalized = command.replace("\\", "/").lower() + + # Gateway-dedicated entrypoints carry no subcommand to inspect. + if re.search(r"(^|[/\s])gateway/run\.py(\s|$)", normalized): + return True + if re.search(r"(^|[/\s])hermes-gateway(?:\.exe)?(\s|$)", normalized): + return True + + has_gateway_entry = ( + "hermes_cli.main" in normalized + or "hermes_cli/main.py" in normalized + or re.search(r"(^|[/\s])hermes(?:\.exe)?(\s|$)", normalized) is not None + ) + if not has_gateway_entry: + return False + + tokens = [t.strip("\"'").replace("\\", "/").lower() for t in command.split()] + for i, token in enumerate(tokens): + if token != "gateway": + continue + if i + 1 >= len(tokens): + return True # bare `hermes gateway` defaults to `run` + return tokens[i + 1] == "run" + return False + + def _looks_like_gateway_process(pid: int) -> bool: """Return True when the live PID still looks like the Hermes gateway.""" cmdline = _read_process_cmdline(pid) if not cmdline: return False - - patterns = ( - "hermes_cli.main gateway", - "hermes_cli/main.py gateway", - "hermes gateway", - "hermes-gateway", - "gateway/run.py", - ) - return any(pattern in cmdline for pattern in patterns) + return looks_like_gateway_command_line(cmdline) def _record_looks_like_gateway(record: dict[str, Any]) -> bool: @@ -189,15 +223,8 @@ def _record_looks_like_gateway(record: dict[str, Any]) -> bool: if not isinstance(argv, list) or not argv: return False - # Normalize Windows backslashes so patterns match cross-platform. - cmdline = " ".join(str(part) for part in argv).replace("\\", "/") - patterns = ( - "hermes_cli.main gateway", - "hermes_cli/main.py gateway", - "hermes gateway", - "gateway/run.py", - ) - return any(pattern in cmdline for pattern in patterns) + cmdline = " ".join(str(part) for part in argv) + return looks_like_gateway_command_line(cmdline) def _build_pid_record() -> dict: diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 7e5406a11dd..06f9c49b916 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -319,23 +319,12 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li # gateway. See #13242. exclude_pids = exclude_pids | _get_ancestor_pids() pids: list[int] = [] - patterns = [ - "hermes_cli.main gateway", - "hermes_cli.main --profile", - "hermes_cli.main -p", - "hermes_cli/main.py gateway", - "hermes_cli/main.py --profile", - "hermes_cli/main.py -p", - "hermes gateway", - # Windows: only match invocations that actually carry the ``gateway`` - # subcommand or the gateway-dedicated console-script shim. Bare - # ``hermes.exe --profile`` / ``hermes.exe -p`` would also match - # ``hermes.exe --profile foo dashboard`` and other CLI subcommands, - # producing false-positive gateway PIDs (Copilot review). - "hermes.exe gateway", - "hermes-gateway.exe", - "gateway/run.py", - ] + # Strict command-line matcher shared with gateway.status: requires the + # actual ``gateway run`` subcommand (or the dedicated entrypoints), so this + # scan no longer false-matches ``gateway status``/``dashboard`` siblings or + # unrelated processes like ``python -m tui_gateway``. Lazy import mirrors the + # circular-import avoidance used elsewhere in this module. + from gateway.status import looks_like_gateway_command_line current_home = str(get_hermes_home().resolve()) current_home_lc = current_home.lower() current_profile_arg = _profile_arg(current_home) @@ -430,8 +419,7 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li current_cmd = line[len("CommandLine=") :] elif line.startswith("ProcessId="): pid_str = line[len("ProcessId=") :] - current_cmd_lc = current_cmd.lower() - if any(p in current_cmd_lc for p in patterns) and ( + if looks_like_gateway_command_line(current_cmd) and ( all_profiles or _matches_current_profile(current_cmd) ): try: @@ -456,8 +444,7 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li with open(f"/proc/{pid}/cmdline", "rb") as _f: cmdline = _f.read().decode("utf-8", errors="replace") cmdline = cmdline.replace("\x00", " ") - cmdline_lc = cmdline.lower() - if any(p in cmdline_lc for p in patterns) and ( + if looks_like_gateway_command_line(cmdline) and ( all_profiles or _matches_current_profile(cmdline) ): _append_unique_pid(pids, pid, exclude_pids) @@ -500,8 +487,7 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li if pid is None: continue - command_lc = command.lower() - if any(pattern in command_lc for pattern in patterns) and ( + if looks_like_gateway_command_line(command) and ( all_profiles or _matches_current_profile(command) ): _append_unique_pid(pids, pid, exclude_pids) diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index 08c7d8c019c..466031bfaa7 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -1302,10 +1302,54 @@ def stop() -> None: print("✗ No gateway was running") +def _wait_for_gateway_absent(timeout_s: float = 30.0, interval_s: float = 0.5) -> bool: + """Block until no gateway process is detectable, or the timeout elapses. + + ``stop()`` can return while the previous gateway is still draining + in-flight agents (the drain runs up to the restart-drain timeout). Uses the + authoritative ``get_running_pid()`` (lock + liveness + start-time + + gateway-shape) plus the now-strict ``_gateway_pids()`` scan so a relaunch + never races a still-alive old process. + """ + from gateway.status import get_running_pid + + deadline = time.monotonic() + max(timeout_s, interval_s) + while time.monotonic() < deadline: + if get_running_pid() is None and not _gateway_pids(): + return True + time.sleep(interval_s) + return get_running_pid() is None and not _gateway_pids() + + def restart() -> None: - """Stop the gateway then start it again.""" + """Stop the gateway then start it again. + + Waits for the old gateway to be authoritatively gone before relaunching -- + otherwise ``start()``'s "already running" guard sees the still-draining old + process and no-ops, and when that process later exits nothing replaces it (a + silent outage). Fails loudly if the process can't be cleared or the relaunch + doesn't produce a running gateway. + """ _assert_windows() + from hermes_cli.gateway import kill_gateway_processes + stop() + + if not _wait_for_gateway_absent(timeout_s=30.0): + print("⚠ Gateway still present after stop; forcing termination before restart...") + kill_gateway_processes(all_profiles=False, force=True) + if not _wait_for_gateway_absent(timeout_s=10.0): + raise RuntimeError( + "Gateway process still detected after force kill; refusing to " + "start a duplicate. Investigate stray PIDs before retrying." + ) + # Give Windows a moment to release the listening port. time.sleep(1.0) start() + + if not _wait_for_gateway_ready(timeout_s=15.0): + raise RuntimeError( + "Gateway restart did not produce a running gateway process. " + "Check logs/gateway.log and run `hermes gateway status`." + ) diff --git a/tests/gateway/test_gateway_command_line_matcher.py b/tests/gateway/test_gateway_command_line_matcher.py new file mode 100644 index 00000000000..5b8b16a7d54 --- /dev/null +++ b/tests/gateway/test_gateway_command_line_matcher.py @@ -0,0 +1,48 @@ +"""Tests for the strict gateway command-line matcher. + +Regression guard for the Windows ``hermes gateway restart`` silent-outage bug: +the previous loose substring match (``"... gateway" in cmdline``) false-matched +``gateway status``/``dashboard`` siblings and unrelated processes such as +``python -m tui_gateway``, which let ``restart()`` race a still-draining old +process and ``status``/``start`` report false positives. +""" + +from __future__ import annotations + +import pytest + +from gateway.status import looks_like_gateway_command_line as matches + + +ACCEPT = [ + "pythonw.exe -m hermes_cli.main gateway run", + r"C:\Users\me\hermes\venv\Scripts\pythonw.exe -m hermes_cli.main gateway run", + "python -m hermes_cli.main --profile work gateway run", + "python -m hermes_cli.main gateway run --replace", + "python -m hermes_cli/main.py gateway run", + "python gateway/run.py", + "hermes-gateway.exe", + "hermes gateway", # bare `hermes gateway` defaults to run + "hermes gateway run", +] + +REJECT = [ + "python -m tui_gateway", # unrelated module + "python -m hermes_cli.main gateway status", # other subcommand + "python -m hermes_cli.main gateway restart", + "python -m hermes_cli.main gateway stop", + "python -m hermes_cli.main --profile x dashboard", # non-gateway subcommand + "some random python -m mygateway thing", + "", + None, +] + + +@pytest.mark.parametrize("cmd", ACCEPT) +def test_accepts_real_gateway_run(cmd): + assert matches(cmd) is True + + +@pytest.mark.parametrize("cmd", REJECT) +def test_rejects_non_gateway_run(cmd): + assert matches(cmd) is False From b12c0cd9970ba7631d094f20c28f6189d4b065b9 Mon Sep 17 00:00:00 2001 From: Charles Power Date: Sun, 7 Jun 2026 21:44:46 -0700 Subject: [PATCH 071/470] test(windows): run pytest-timeout in thread mode on Windows The pyproject addopts pin `--timeout-method=signal` relies on signal.SIGALRM, which doesn't exist on Windows. pytest-timeout raised AttributeError at timer setup and aborted the entire run before any test executed, so the suite was unrunnable on Windows by default. Override timeout_method to "thread" on Windows in pytest_configure; POSIX keeps the more reliable signal method. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 2da7d4a1eb4..468926b0f51 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -534,6 +534,14 @@ def pytest_configure(config): # noqa: D401 — pytest hook "behaviour — e.g. PTY tests that signal their own child).", ) + # The pyproject addopts pin ``--timeout-method=signal`` relies on + # ``signal.SIGALRM``, which does not exist on Windows — pytest-timeout + # raises AttributeError at timer setup and the whole run aborts before any + # test executes. Fall back to the thread-based timer on Windows so the + # suite runs natively there (POSIX keeps the more reliable signal method). + if sys.platform == "win32" and getattr(config.option, "timeout_method", None) == "signal": + config.option.timeout_method = "thread" + @pytest.fixture(autouse=True) def _live_system_guard(request, monkeypatch): From 715fa9ea1c8f1e1b49b698ec32a1ba822e5a7ce3 Mon Sep 17 00:00:00 2001 From: Charles Power Date: Sun, 7 Jun 2026 21:57:20 -0700 Subject: [PATCH 072/470] fix(gateway): harden gateway command-line matcher (review findings) Address correctness gaps found in pre-PR review of the strict matcher: - Profile selectors can appear on EITHER side of the `gateway` token (`_apply_profile_override` strips `--profile`/`-p` from anywhere in argv before argparse), so `hermes gateway --profile work run` and `python -m hermes_cli.main gateway -p work run` are valid launches the previous matcher wrongly rejected. Strip `--profile`/`-p`/`--profile=`/`-p=` from anywhere before locating the subcommand. - A profile literally named `gateway` (`hermes -p gateway gateway run`) made the old token scan stop on the profile value; stripping the selector+value first fixes it. - Tokenize quote-aware with `shlex` so quoted Windows paths containing spaces (`"C:\Program Files\Hermes\hermes-gateway.exe"`) are no longer split mid-path and the dedicated-entrypoint match survives. Without these, the matcher could MISS a real running gateway -> the opposite failure (restart/status reporting "down" when up). Adds regression tests for all three shapes. Co-Authored-By: Claude Opus 4.8 (1M context) --- gateway/status.py | 63 ++++++++++++++----- .../test_gateway_command_line_matcher.py | 12 ++++ 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/gateway/status.py b/gateway/status.py index 5e5584a1ed8..2b4bd08ba39 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -14,7 +14,7 @@ concurrently under distinct configurations). import hashlib import json import os -import re +import shlex import signal import subprocess import sys @@ -173,36 +173,69 @@ def looks_like_gateway_command_line(command: str | None) -> bool: test also matched ``hermes_cli.main gateway status`` and even unrelated processes like ``python -m tui_gateway`` -- which made ``restart()`` race against a still-draining old process and ``status``/``start`` report false - positives. This requires the actual ``gateway`` subcommand to be followed - by ``run`` (or the gateway-dedicated entrypoints), excluding the other + positives. This requires the actual ``gateway`` subcommand followed by + ``run`` (or one of the gateway-dedicated entrypoints), excluding the other ``gateway`` management subcommands and any process that merely contains the word "gateway". + + Tokenizes quote-aware (``shlex``) so quoted Windows paths with spaces + (``"C:\\Program Files\\...\\hermes-gateway.exe"``) survive, and strips + ``--profile``/``-p`` selectors from anywhere in argv -- Hermes's + ``_apply_profile_override`` removes them before argparse, so the profile + flag (and a profile literally named ``gateway``) can legally appear on + either side of the ``gateway`` subcommand. """ if not command: return False - normalized = command.replace("\\", "/").lower() + + try: + raw_tokens = shlex.split(command, posix=False) + except ValueError: + raw_tokens = command.split() + # Strip surrounding quotes, normalize slashes + case per token. + tokens = [t.strip("\"'").replace("\\", "/").lower() for t in raw_tokens] + if not tokens: + return False # Gateway-dedicated entrypoints carry no subcommand to inspect. - if re.search(r"(^|[/\s])gateway/run\.py(\s|$)", normalized): - return True - if re.search(r"(^|[/\s])hermes-gateway(?:\.exe)?(\s|$)", normalized): - return True + for token in tokens: + if token == "gateway/run.py" or token.endswith("/gateway/run.py"): + return True + basename = token.rsplit("/", 1)[-1] + if basename in ("hermes-gateway", "hermes-gateway.exe"): + return True + joined = " ".join(tokens) has_gateway_entry = ( - "hermes_cli.main" in normalized - or "hermes_cli/main.py" in normalized - or re.search(r"(^|[/\s])hermes(?:\.exe)?(\s|$)", normalized) is not None + "hermes_cli.main" in joined + or "hermes_cli/main.py" in joined + or any(t.rsplit("/", 1)[-1] in ("hermes", "hermes.exe") for t in tokens) ) if not has_gateway_entry: return False - tokens = [t.strip("\"'").replace("\\", "/").lower() for t in command.split()] - for i, token in enumerate(tokens): + # Drop profile selectors anywhere: --profile X / -p X / --profile=X / -p=X. + # This consumes a profile VALUE of "gateway" too, so the real subcommand + # token is the one we land on below. + filtered: list[str] = [] + skip_next = False + for token in tokens: + if skip_next: + skip_next = False + continue + if token in ("--profile", "-p"): + skip_next = True + continue + if token.startswith("--profile=") or token.startswith("-p="): + continue + filtered.append(token) + + for i, token in enumerate(filtered): if token != "gateway": continue - if i + 1 >= len(tokens): + if i + 1 >= len(filtered): return True # bare `hermes gateway` defaults to `run` - return tokens[i + 1] == "run" + return filtered[i + 1] == "run" return False diff --git a/tests/gateway/test_gateway_command_line_matcher.py b/tests/gateway/test_gateway_command_line_matcher.py index 5b8b16a7d54..bc8113b91a0 100644 --- a/tests/gateway/test_gateway_command_line_matcher.py +++ b/tests/gateway/test_gateway_command_line_matcher.py @@ -24,6 +24,18 @@ ACCEPT = [ "hermes-gateway.exe", "hermes gateway", # bare `hermes gateway` defaults to run "hermes gateway run", + # profile selector AFTER the `gateway` token (argv is profile-position + # agnostic — _apply_profile_override strips --profile/-p anywhere) + "hermes gateway --profile work run", + "python -m hermes_cli.main gateway -p work run", + "hermes gateway --profile=work run", + # a profile literally NAMED "gateway" + "hermes -p gateway gateway run", + "python -m hermes_cli.main --profile gateway gateway run", + # quoted Windows paths with spaces (shlex-aware tokenization) + r'"C:\Program Files\Hermes\hermes-gateway.exe"', + r'"C:\Program Files\Hermes\gateway\run.py" run', + r'"C:\Program Files\Py\pythonw.exe" -m hermes_cli.main gateway run', ] REJECT = [ From b922d7dfb24f4405148dbdef4f7deea173a53b49 Mon Sep 17 00:00:00 2001 From: teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:38:02 -0700 Subject: [PATCH 073/470] chore(release): add salesondemandio to AUTHOR_MAP for PR #42664 --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 20c6a6bfa0a..0ff464e61f0 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "charles@salesondemand.io": "salesondemandio", "victor@rocketfueldev.com": "victor-kyriazakos", "87440198+JoaoMarcos44@users.noreply.github.com": "JoaoMarcos44", "286497132+srojk34@users.noreply.github.com": "srojk34", From 92451151c6429e1d2774c5e7f43269ebcf8c64aa Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 19 Jun 2026 06:38:28 -0700 Subject: [PATCH 074/470] Revert "feat(skills): add html-artifact skill, fold in sketch + architecture-diagram + concept-diagrams (#48899)" This reverts commit 9362ce2575e00f5a795285b74e79d54c02e1326c. --- .../creative/concept-diagrams/SKILL.md | 362 +++++++++++++++++ .../apartment-floor-plan-conversion.md | 244 +++++++++++ .../examples/automated-password-reset-flow.md | 276 +++++++++++++ .../autonomous-llm-research-agent-flow.md | 240 +++++++++++ .../banana-journey-tree-to-smoothie.md | 161 ++++++++ .../examples/commercial-aircraft-structure.md | 209 ++++++++++ .../examples/cpu-ooo-microarchitecture.md | 236 +++++++++++ .../examples/electricity-grid-flow.md | 182 +++++++++ .../feature-film-production-pipeline.md | 172 ++++++++ .../hospital-emergency-department-flow.md | 165 ++++++++ .../ml-benchmark-grouped-bar-chart.md | 114 ++++++ .../examples/place-order-uml-sequence.md | 325 +++++++++++++++ .../examples/smart-city-infrastructure.md | 173 ++++++++ .../examples/smartphone-layer-anatomy.md | 154 +++++++ .../examples/sn2-reaction-mechanism.md | 247 ++++++++++++ .../examples/wind-turbine-structure.md | 338 ++++++++++++++++ .../references/dashboard-patterns.md | 43 ++ .../references/infrastructure-patterns.md | 144 +++++++ .../references/physical-shape-cookbook.md | 42 ++ .../concept-diagrams/templates/template.html | 174 ++++++++ .../kanban-video-orchestrator/SKILL.md | 2 +- .../references/intake.md | 3 +- .../references/role-archetypes.md | 5 +- .../references/tool-matrix.md | 4 +- skills/creative/architecture-diagram/SKILL.md | 148 +++++++ .../templates/template.html | 319 +++++++++++++++ skills/creative/claude-design/SKILL.md | 12 +- skills/creative/design-md/SKILL.md | 2 +- skills/creative/html-artifact/SKILL.md | 184 --------- .../html-artifact/references/.gitignore | 3 - .../references/concept-archetypes.md | 94 ----- .../html-artifact/references/dark-tech.md | 92 ----- .../html-artifact/references/examples.md | 64 --- .../references/fidelity-and-verify.md | 78 ---- .../html-artifact/references/house-style.md | 179 --------- .../html-artifact/references/svg-diagrams.md | 123 ------ .../references/throwaway-editors.md | 114 ------ .../html-artifact/scripts/fetch-examples.sh | 43 -- .../html-artifact/templates/base.html | 104 ----- .../html-artifact/templates/diagram.html | 127 ------ .../html-artifact/templates/editor.html | 120 ------ skills/creative/pretext/SKILL.md | 2 +- skills/creative/sketch/SKILL.md | 218 ++++++++++ skills/software-development/spike/SKILL.md | 2 +- .../docs/reference/optional-skills-catalog.md | 1 + website/docs/reference/skills-catalog.md | 3 +- .../autonomous-ai-agents-hermes-agent.md | 4 +- .../creative/creative-architecture-diagram.md | 165 ++++++++ .../creative/creative-claude-design.md | 12 +- .../bundled/creative/creative-design-md.md | 2 +- .../creative/creative-html-artifact.md | 202 ---------- .../bundled/creative/creative-pretext.md | 2 +- .../bundled/creative/creative-sketch.md | 238 +++++++++++ .../creative/creative-touchdesigner-mcp.md | 2 +- .../skills/bundled/email/email-himalaya.md | 5 - .../bundled/github/github-github-auth.md | 4 +- .../github/github-github-code-review.md | 4 +- .../bundled/github/github-github-issues.md | 4 +- .../github/github-github-pr-workflow.md | 4 +- .../github/github-github-repo-management.md | 4 +- .../skills/bundled/media/media-gif-search.md | 2 +- .../note-taking/note-taking-obsidian.md | 2 +- .../productivity/productivity-airtable.md | 4 +- .../productivity/productivity-notion.md | 4 +- .../productivity-teams-meeting-pipeline.md | 2 +- .../bundled/research/research-llm-wiki.md | 2 +- .../research-research-paper-writing.md | 2 +- ...tware-development-node-inspect-debugger.md | 2 +- .../software-development-python-debugpy.md | 2 +- .../software-development-spike.md | 2 +- .../autonomous-ai-agents-honcho.md | 4 +- .../blockchain/blockchain-hyperliquid.md | 4 +- .../creative/creative-concept-diagrams.md | 379 ++++++++++++++++++ .../creative-kanban-video-orchestrator.md | 4 +- .../optional/devops/devops-pinggy-tunnel.md | 2 +- .../skills/optional/devops/devops-watchers.md | 2 +- .../skills/optional/mcp/mcp-fastmcp.md | 2 +- .../payments/payments-stripe-projects.md | 2 +- .../productivity/productivity-canvas.md | 2 +- .../productivity/productivity-shopify.md | 2 +- .../productivity/productivity-siyuan.md | 2 +- .../productivity/productivity-telephony.md | 8 +- .../research/research-gitnexus-explorer.md | 2 +- .../skills/optional/research/research-qmd.md | 2 +- .../optional/security/security-1password.md | 2 +- .../optional/security/security-godmode.md | 2 +- ...software-development-rest-graphql-debug.md | 2 +- .../reference/optional-skills-catalog.md | 1 + .../current/reference/skills-catalog.md | 2 + .../creative/creative-architecture-diagram.md | 165 ++++++++ .../creative/creative-claude-design.md | 2 +- .../bundled/creative/creative-design-md.md | 2 +- .../bundled/creative/creative-pretext.md | 2 +- .../bundled/creative/creative-sketch.md | 238 +++++++++++ .../software-development-spike.md | 2 +- .../creative/creative-concept-diagrams.md | 379 ++++++++++++++++++ .../creative-kanban-video-orchestrator.md | 2 +- website/sidebars.ts | 5 +- 98 files changed, 6336 insertions(+), 1610 deletions(-) create mode 100644 optional-skills/creative/concept-diagrams/SKILL.md create mode 100644 optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md create mode 100644 optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md create mode 100644 optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md create mode 100644 optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md create mode 100644 optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md create mode 100644 optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md create mode 100644 optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md create mode 100644 optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md create mode 100644 optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md create mode 100644 optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md create mode 100644 optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md create mode 100644 optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md create mode 100644 optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md create mode 100644 optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md create mode 100644 optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md create mode 100644 optional-skills/creative/concept-diagrams/references/dashboard-patterns.md create mode 100644 optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md create mode 100644 optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md create mode 100644 optional-skills/creative/concept-diagrams/templates/template.html create mode 100644 skills/creative/architecture-diagram/SKILL.md create mode 100644 skills/creative/architecture-diagram/templates/template.html delete mode 100644 skills/creative/html-artifact/SKILL.md delete mode 100644 skills/creative/html-artifact/references/.gitignore delete mode 100644 skills/creative/html-artifact/references/concept-archetypes.md delete mode 100644 skills/creative/html-artifact/references/dark-tech.md delete mode 100644 skills/creative/html-artifact/references/examples.md delete mode 100644 skills/creative/html-artifact/references/fidelity-and-verify.md delete mode 100644 skills/creative/html-artifact/references/house-style.md delete mode 100644 skills/creative/html-artifact/references/svg-diagrams.md delete mode 100644 skills/creative/html-artifact/references/throwaway-editors.md delete mode 100755 skills/creative/html-artifact/scripts/fetch-examples.sh delete mode 100644 skills/creative/html-artifact/templates/base.html delete mode 100644 skills/creative/html-artifact/templates/diagram.html delete mode 100644 skills/creative/html-artifact/templates/editor.html create mode 100644 skills/creative/sketch/SKILL.md create mode 100644 website/docs/user-guide/skills/bundled/creative/creative-architecture-diagram.md delete mode 100644 website/docs/user-guide/skills/bundled/creative/creative-html-artifact.md create mode 100644 website/docs/user-guide/skills/bundled/creative/creative-sketch.md create mode 100644 website/docs/user-guide/skills/optional/creative/creative-concept-diagrams.md create mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-architecture-diagram.md create mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-sketch.md create mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-concept-diagrams.md diff --git a/optional-skills/creative/concept-diagrams/SKILL.md b/optional-skills/creative/concept-diagrams/SKILL.md new file mode 100644 index 00000000000..6017d4fd121 --- /dev/null +++ b/optional-skills/creative/concept-diagrams/SKILL.md @@ -0,0 +1,362 @@ +--- +name: concept-diagrams +description: Generate flat, minimal light/dark-aware SVG diagrams as standalone HTML files, using a unified educational visual language with 9 semantic color ramps, sentence-case typography, and automatic dark mode. Best suited for educational and non-software visuals — physics setups, chemistry mechanisms, math curves, physical objects (aircraft, turbines, smartphones, mechanical watches), anatomy, floor plans, cross-sections, narrative journeys (lifecycle of X, process of Y), hub-spoke system integrations (smart city, IoT), and exploded layer views. If a more specialized skill exists for the subject (dedicated software/cloud architecture, hand-drawn sketches, animated explainers, etc.), prefer that — otherwise this skill can also serve as a general-purpose SVG diagram fallback with a clean educational look. Ships with 15 example diagrams. +version: 0.1.0 +author: v1k22 (original PR), ported into hermes-agent +license: MIT +dependencies: [] +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [diagrams, svg, visualization, education, physics, chemistry, engineering] + related_skills: [architecture-diagram, excalidraw, generative-widgets] +--- + +# Concept Diagrams + +Generate production-quality SVG diagrams with a unified flat, minimal design system. Output is a single self-contained HTML file that renders identically in any modern browser, with automatic light/dark mode. + +## Scope + +**Best suited for:** +- Physics setups, chemistry mechanisms, math curves, biology +- Physical objects (aircraft, turbines, smartphones, mechanical watches, cells) +- Anatomy, cross-sections, exploded layer views +- Floor plans, architectural conversions +- Narrative journeys (lifecycle of X, process of Y) +- Hub-spoke system integrations (smart city, IoT networks, electricity grids) +- Educational / textbook-style visuals in any domain +- Quantitative charts (grouped bars, energy profiles) + +**Look elsewhere first for:** +- Dedicated software / cloud infrastructure architecture with a dark tech aesthetic (consider `architecture-diagram` if available) +- Hand-drawn whiteboard sketches (consider `excalidraw` if available) +- Animated explainers or video output (consider an animation skill) + +If a more specialized skill is available for the subject, prefer that. If none fits, this skill can serve as a general-purpose SVG diagram fallback — the output will carry the clean educational aesthetic described below, which is a reasonable default for almost any subject. + +## Workflow + +1. Decide on the diagram type (see Diagram Types below). +2. Lay out components using the Design System rules. +3. Write the full HTML page using `templates/template.html` as the wrapper — paste your SVG where the template says ``. +4. Save as a standalone `.html` file (for example `~/my-diagram.html` or `./my-diagram.html`). +5. User opens it directly in a browser — no server, no dependencies. + +Optional: if the user wants a browsable gallery of multiple diagrams, see "Local Preview Server" at the bottom. + +Load the HTML template: +``` +skill_view(name="concept-diagrams", file_path="templates/template.html") +``` + +The template embeds the full CSS design system (`c-*` color classes, text classes, light/dark variables, arrow marker styles). The SVG you generate relies on these classes being present on the hosting page. + +--- + +## Design System + +### Philosophy + +- **Flat**: no gradients, drop shadows, blur, glow, or neon effects. +- **Minimal**: show the essential. No decorative icons inside boxes. +- **Consistent**: same colors, spacing, typography, and stroke widths across every diagram. +- **Dark-mode ready**: all colors auto-adapt via CSS classes — no per-mode SVG. + +### Color Palette + +9 color ramps, each with 7 stops. Put the class name on a `` or shape element; the template CSS handles both modes. + +| Class | 50 (lightest) | 100 | 200 | 400 | 600 | 800 | 900 (darkest) | +|------------|---------------|---------|---------|---------|---------|---------|---------------| +| `c-purple` | #EEEDFE | #CECBF6 | #AFA9EC | #7F77DD | #534AB7 | #3C3489 | #26215C | +| `c-teal` | #E1F5EE | #9FE1CB | #5DCAA5 | #1D9E75 | #0F6E56 | #085041 | #04342C | +| `c-coral` | #FAECE7 | #F5C4B3 | #F0997B | #D85A30 | #993C1D | #712B13 | #4A1B0C | +| `c-pink` | #FBEAF0 | #F4C0D1 | #ED93B1 | #D4537E | #993556 | #72243E | #4B1528 | +| `c-gray` | #F1EFE8 | #D3D1C7 | #B4B2A9 | #888780 | #5F5E5A | #444441 | #2C2C2A | +| `c-blue` | #E6F1FB | #B5D4F4 | #85B7EB | #378ADD | #185FA5 | #0C447C | #042C53 | +| `c-green` | #EAF3DE | #C0DD97 | #97C459 | #639922 | #3B6D11 | #27500A | #173404 | +| `c-amber` | #FAEEDA | #FAC775 | #EF9F27 | #BA7517 | #854F0B | #633806 | #412402 | +| `c-red` | #FCEBEB | #F7C1C1 | #F09595 | #E24B4A | #A32D2D | #791F1F | #501313 | + +#### Color Assignment Rules + +Color encodes **meaning**, not sequence. Never cycle through colors like a rainbow. + +- Group nodes by **category** — all nodes of the same type share one color. +- Use `c-gray` for neutral/structural nodes (start, end, generic steps, users). +- Use **2-3 colors per diagram**, not 6+. +- Prefer `c-purple`, `c-teal`, `c-coral`, `c-pink` for general categories. +- Reserve `c-blue`, `c-green`, `c-amber`, `c-red` for semantic meaning (info, success, warning, error). + +Light/dark stop mapping (handled by the template CSS — just use the class): +- Light mode: 50 fill + 600 stroke + 800 title / 600 subtitle +- Dark mode: 800 fill + 200 stroke + 100 title / 200 subtitle + +### Typography + +Only two font sizes. No exceptions. + +| Class | Size | Weight | Use | +|-------|------|--------|-----| +| `th` | 14px | 500 | Node titles, region labels | +| `ts` | 12px | 400 | Subtitles, descriptions, arrow labels | +| `t` | 14px | 400 | General text | + +- **Sentence case always.** Never Title Case, never ALL CAPS. +- Every `` MUST carry a class (`t`, `ts`, or `th`). No unclassed text. +- `dominant-baseline="central"` on all text inside boxes. +- `text-anchor="middle"` for centered text in boxes. + +**Width estimation (approx):** +- 14px weight 500: ~8px per character +- 12px weight 400: ~6.5px per character +- Always verify: `box_width >= (char_count × px_per_char) + 48` (24px padding each side) + +### Spacing & Layout + +- **ViewBox**: `viewBox="0 0 680 H"` where H = content height + 40px buffer. +- **Safe area**: x=40 to x=640, y=40 to y=(H-40). +- **Between boxes**: 60px minimum gap. +- **Inside boxes**: 24px horizontal padding, 12px vertical padding. +- **Arrowhead gap**: 10px between arrowhead and box edge. +- **Single-line box**: 44px height. +- **Two-line box**: 56px height, 18px between title and subtitle baselines. +- **Container padding**: 20px minimum inside every container. +- **Max nesting**: 2-3 levels deep. Deeper gets unreadable at 680px width. + +### Stroke & Shape + +- **Stroke width**: 0.5px on all node borders. Not 1px, not 2px. +- **Rect rounding**: `rx="8"` for nodes, `rx="12"` for inner containers, `rx="16"` to `rx="20"` for outer containers. +- **Connector paths**: MUST have `fill="none"`. SVG defaults to `fill: black` otherwise. + +### Arrow Marker + +Include this `` block at the start of **every** SVG: + +```xml + + + + + +``` + +Use `marker-end="url(#arrow)"` on lines. The arrowhead inherits the line color via `context-stroke`. + +### CSS Classes (Provided by the Template) + +The template page provides: + +- Text: `.t`, `.ts`, `.th` +- Neutral: `.box`, `.arr`, `.leader`, `.node` +- Color ramps: `.c-purple`, `.c-teal`, `.c-coral`, `.c-pink`, `.c-gray`, `.c-blue`, `.c-green`, `.c-amber`, `.c-red` (all with automatic light/dark mode) + +You do **not** need to redefine these — just apply them in your SVG. The template file contains the full CSS definitions. + +--- + +## SVG Boilerplate + +Every SVG inside the template page starts with this exact structure: + +```xml + + + + + + + + + + +``` + +Replace `{HEIGHT}` with the actual computed height (last element bottom + 40px). + +### Node Patterns + +**Single-line node (44px):** +```xml + + + Service name + +``` + +**Two-line node (56px):** +```xml + + + Service name + Short description + +``` + +**Connector (no label):** +```xml + +``` + +**Container (dashed or solid):** +```xml + + + Container label + Subtitle info + +``` + +--- + +## Diagram Types + +Choose the layout that fits the subject: + +1. **Flowchart** — CI/CD pipelines, request lifecycles, approval workflows, data processing. Single-direction flow (top-down or left-right). Max 4-5 nodes per row. +2. **Structural / Containment** — Cloud infrastructure nesting, system architecture with layers. Large outer containers with inner regions. Dashed rects for logical groupings. +3. **API / Endpoint Map** — REST routes, GraphQL schemas. Tree from root, branching to resource groups, each containing endpoint nodes. +4. **Microservice Topology** — Service mesh, event-driven systems. Services as nodes, arrows for communication patterns, message queues between. +5. **Data Flow** — ETL pipelines, streaming architectures. Left-to-right flow from sources through processing to sinks. +6. **Physical / Structural** — Vehicles, buildings, hardware, anatomy. Use shapes that match the physical form — `` for curved bodies, `` for tapered shapes, ``/`` for cylindrical parts, nested `` for compartments. See `references/physical-shape-cookbook.md`. +7. **Infrastructure / Systems Integration** — Smart cities, IoT networks, multi-domain systems. Hub-spoke layout with central platform connecting subsystems. Semantic line styles (`.data-line`, `.power-line`, `.water-pipe`, `.road`). See `references/infrastructure-patterns.md`. +8. **UI / Dashboard Mockups** — Admin panels, monitoring dashboards. Screen frame with nested chart/gauge/indicator elements. See `references/dashboard-patterns.md`. + +For physical, infrastructure, and dashboard diagrams, load the matching reference file before generating — each one provides ready-made CSS classes and shape primitives. + +--- + +## Validation Checklist + +Before finalizing any SVG, verify ALL of the following: + +1. Every `` has class `t`, `ts`, or `th`. +2. Every `` inside a box has `dominant-baseline="central"`. +3. Every connector `` or `` used as arrow has `fill="none"`. +4. No arrow line crosses through an unrelated box. +5. `box_width >= (longest_label_chars × 8) + 48` for 14px text. +6. `box_width >= (longest_label_chars × 6.5) + 48` for 12px text. +7. ViewBox height = bottom-most element + 40px. +8. All content stays within x=40 to x=640. +9. Color classes (`c-*`) are on `` or shape elements, never on `` connectors. +10. Arrow `` block is present. +11. No gradients, shadows, blur, or glow effects. +12. Stroke width is 0.5px on all node borders. + +--- + +## Output & Preview + +### Default: standalone HTML file + +Write a single `.html` file the user can open directly. No server, no dependencies, works offline. Pattern: + +```python +# 1. Load the template +template = skill_view("concept-diagrams", "templates/template.html") + +# 2. Fill in title, subtitle, and paste your SVG +html = template.replace( + "", "SN2 reaction mechanism" +).replace( + "", "Bimolecular nucleophilic substitution" +).replace( + "", svg_content +) + +# 3. Write to a user-chosen path (or ./ by default) +write_file("./sn2-mechanism.html", html) +``` + +Tell the user how to open it: + +``` +# macOS +open ./sn2-mechanism.html +# Linux +xdg-open ./sn2-mechanism.html +``` + +### Optional: local preview server (multi-diagram gallery) + +Only use this when the user explicitly wants a browsable gallery of multiple diagrams. + +**Rules:** +- Bind to `127.0.0.1` only. Never `0.0.0.0`. Exposing diagrams on all network interfaces is a security hazard on shared networks. +- Pick a free port (do NOT hard-code one) and tell the user the chosen URL. +- The server is optional and opt-in — prefer the standalone HTML file first. + +Recommended pattern (lets the OS pick a free ephemeral port): + +```bash +# Put each diagram in its own folder under .diagrams/ +mkdir -p .diagrams/sn2-mechanism +# ...write .diagrams/sn2-mechanism/index.html... + +# Serve on loopback only, free port +cd .diagrams && python3 -c " +import http.server, socketserver +with socketserver.TCPServer(('127.0.0.1', 0), http.server.SimpleHTTPRequestHandler) as s: + print(f'Serving at http://127.0.0.1:{s.server_address[1]}/') + s.serve_forever() +" & +``` + +If the user insists on a fixed port, use `127.0.0.1:` — still never `0.0.0.0`. Document how to stop the server (`kill %1` or `pkill -f "http.server"`). + +--- + +## Examples Reference + +The `examples/` directory ships 15 complete, tested diagrams. Browse them for working patterns before writing a new diagram of a similar type: + +| File | Type | Demonstrates | +|------|------|--------------| +| `hospital-emergency-department-flow.md` | Flowchart | Priority routing with semantic colors | +| `feature-film-production-pipeline.md` | Flowchart | Phased workflow, horizontal sub-flows | +| `automated-password-reset-flow.md` | Flowchart | Auth flow with error branches | +| `autonomous-llm-research-agent-flow.md` | Flowchart | Loop-back arrows, decision branches | +| `place-order-uml-sequence.md` | Sequence | UML sequence diagram style | +| `commercial-aircraft-structure.md` | Physical | Paths, polygons, ellipses for realistic shapes | +| `wind-turbine-structure.md` | Physical cross-section | Underground/above-ground separation, color coding | +| `smartphone-layer-anatomy.md` | Exploded view | Alternating left/right labels, layered components | +| `apartment-floor-plan-conversion.md` | Floor plan | Walls, doors, proposed changes in dotted red | +| `banana-journey-tree-to-smoothie.md` | Narrative journey | Winding path, progressive state changes | +| `cpu-ooo-microarchitecture.md` | Hardware pipeline | Fan-out, memory hierarchy sidebar | +| `sn2-reaction-mechanism.md` | Chemistry | Molecules, curved arrows, energy profile | +| `smart-city-infrastructure.md` | Hub-spoke | Semantic line styles per system | +| `electricity-grid-flow.md` | Multi-stage flow | Voltage hierarchy, flow markers | +| `ml-benchmark-grouped-bar-chart.md` | Chart | Grouped bars, dual axis | + +Load any example with: +``` +skill_view(name="concept-diagrams", file_path="examples/") +``` + +--- + +## Quick Reference: What to Use When + +| User says | Diagram type | Suggested colors | +|-----------|--------------|------------------| +| "show the pipeline" | Flowchart | gray start/end, purple steps, red errors, teal deploy | +| "draw the data flow" | Data pipeline (left-right) | gray sources, purple processing, teal sinks | +| "visualize the system" | Structural (containment) | purple container, teal services, coral data | +| "map the endpoints" | API tree | purple root, one ramp per resource group | +| "show the services" | Microservice topology | gray ingress, teal services, purple bus, coral workers | +| "draw the aircraft/vehicle" | Physical | paths, polygons, ellipses for realistic shapes | +| "smart city / IoT" | Hub-spoke integration | semantic line styles per subsystem | +| "show the dashboard" | UI mockup | dark screen, chart colors: teal, purple, coral for alerts | +| "power grid / electricity" | Multi-stage flow | voltage hierarchy (HV/MV/LV line weights) | +| "wind turbine / turbine" | Physical cross-section | foundation + tower cutaway + nacelle color-coded | +| "journey of X / lifecycle" | Narrative journey | winding path, progressive state changes | +| "layers of X / exploded" | Exploded layer view | vertical stack, alternating labels | +| "CPU / pipeline" | Hardware pipeline | vertical stages, fan-out to execution ports | +| "floor plan / apartment" | Floor plan | walls, doors, proposed changes in dotted red | +| "reaction mechanism" | Chemistry | atoms, bonds, curved arrows, transition state, energy profile | diff --git a/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md b/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md new file mode 100644 index 00000000000..7c11d3401e5 --- /dev/null +++ b/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md @@ -0,0 +1,244 @@ +# Apartment Floor Plan: 3 BHK to 4 BHK Conversion + +An architectural floor plan showing a 1,500 sq ft apartment with proposed modifications to convert from 3 BHK to 4 BHK. Demonstrates architectural drawing conventions, room layouts, proposed changes with dotted lines, and area comparison tables. + +## Key Patterns Used + +- **Architectural floor plan**: Top-down view with walls, doors, windows +- **Proposed modifications**: Dotted red lines for new walls +- **Room color coding**: Light fills to distinguish room types +- **Circulation paths**: Arrows showing new access routes +- **Data table**: Before/after area comparison with highlighting +- **Architectural symbols**: North arrow, scale bar, door swings + +## Diagram Type + +This is an **architectural floor plan** with: +- **Plan view**: Top-down orthographic projection +- **Overlay technique**: Existing structure + proposed changes +- **Quantitative data**: Area measurements and comparison table + +## Architectural Drawing Elements + +### Wall Styles + +```xml + + + + + + + + +``` + +```css +.wall { stroke: var(--text-primary); stroke-width: 6; fill: none; stroke-linecap: square; } +.wall-thin { stroke: var(--text-primary); stroke-width: 3; fill: none; } +.proposed-wall { stroke: #A32D2D; stroke-width: 4; fill: none; stroke-dasharray: 8 4; } +``` + +### Door Symbols + +```xml + + + + + + + + + + + + + +``` + +```css +.door { stroke: var(--text-secondary); stroke-width: 1.5; fill: none; } +.door-swing { stroke: var(--text-tertiary); stroke-width: 1; fill: none; stroke-dasharray: 3 2; } +``` + +### Window Symbols + +```xml + + + + + + + +``` + +```css +.window { stroke: var(--text-primary); stroke-width: 1; fill: var(--bg-primary); } +.window-glass { stroke: #378ADD; stroke-width: 2; fill: none; } +``` + +### Room Fills + +```xml + + + + + + + + + +``` + +```css +.room-master { fill: rgba(206, 203, 246, 0.3); } /* purple tint */ +.room-bed2 { fill: rgba(159, 225, 203, 0.3); } /* teal tint */ +.room-bed3 { fill: rgba(250, 199, 117, 0.3); } /* amber tint */ +.room-living { fill: rgba(245, 196, 179, 0.3); } /* coral tint */ +.room-kitchen { fill: rgba(237, 147, 177, 0.3); } /* pink tint */ +.room-bath { fill: rgba(133, 183, 235, 0.3); } /* blue tint */ +.room-new { fill: rgba(163, 45, 45, 0.15); } /* red tint for proposed */ +``` + +### Support Fixtures + +```xml + + +Counter + + + +``` + +```css +.balcony { fill: none; stroke: var(--text-secondary); stroke-width: 2; stroke-dasharray: 6 3; } +.balcony-fill { fill: rgba(93, 202, 165, 0.1); } +``` + +### Room Labels + +```xml + +MASTER +BEDROOM +195 sq ft + + +BEDROOM 4 +(NEW) +``` + +```css +.room-label { font-family: system-ui; font-size: 11px; fill: var(--text-primary); font-weight: 500; } +.area-label { font-family: system-ui; font-size: 9px; fill: var(--text-tertiary); } +``` + +### Circulation Arrow + +```xml + + + + + + + +New corridor access +``` + +```css +.circulation { stroke: #3B6D11; stroke-width: 2; fill: none; } +.circulation-fill { fill: #3B6D11; } +``` + +### North Arrow and Scale Bar + +```xml + + + + + N + + + + + + + + + 0 + 5' + 10' + +``` + +## Area Comparison Table + +### Table Structure + +```xml + + +Room + + + +Master Bedroom +195 + + + + + + +Bedroom 4 (NEW) ++100 + + + +TOTAL CARPET AREA +``` + +```css +.table-header { fill: var(--bg-secondary); } +.table-row { fill: var(--bg-primary); stroke: var(--border); stroke-width: 0.5; } +.table-row-alt { fill: var(--bg-tertiary); stroke: var(--border); stroke-width: 0.5; } +.table-highlight { fill: rgba(163, 45, 45, 0.1); stroke: #A32D2D; stroke-width: 0.5; } +``` + +## Layout Notes + +- **ViewBox**: 800×780 (portrait for floor plan + table) +- **Scale**: 10px = 1 foot (apartment ~50ft × 33ft) +- **Floor plan origin**: Offset at (50, 60) for margins +- **Wall thickness**: 6px outer, 3px inner (represents ~6" walls) +- **Room labels**: Centered in each room with area below +- **Table placement**: Below floor plan with full width + +## Color Coding + +| Element | Color | Usage | +|---------|-------|-------| +| Proposed walls | Red (#A32D2D) dotted | New construction | +| New room fill | Red 15% opacity | Bedroom 4 area | +| Circulation | Green (#3B6D11) | New access path | +| Window glass | Blue (#378ADD) | Glass indication | +| Bedrooms | Purple/Teal/Amber tints | Room differentiation | +| Wet areas | Blue tint | Bathrooms | +| Living | Coral tint | Common areas | + +## When to Use This Pattern + +Use this diagram style for: +- Apartment/house floor plans +- Office layout planning +- Renovation proposals showing before/after +- Space planning with area calculations +- Real estate marketing materials +- Interior design presentations +- Building permit documentation diff --git a/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md b/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md new file mode 100644 index 00000000000..86cd1cc0782 --- /dev/null +++ b/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md @@ -0,0 +1,276 @@ +# Automated Password Reset Flow + +A two-section flowchart tracing the full user journey for a web application password reset: the initial request phase (forgot password → email check → token generation) and the reset-form phase (link click → new password entry → token/password validation). Demonstrates multi-exit decision diamonds, a three-column branching layout, a loop-back path, and a cross-section separator arrow. + +## Key Patterns Used + +- **Three-column layout**: Left column (error/terminal branches at cx=115), center column (main happy path at cx=340), right column (expired-token branch at cx=552) — allows side branches to live at the same y-level as center nodes without overlap +- **Decision diamonds with ``**: Each decision uses a `` wrapper containing a `` and centered ``; the diamond points are computed as `cx±hw, cy±hh` (hw=100, hh=28) +- **Pill-shaped terminals**: Start and end nodes use `rx=22` on their `` to signal entry/exit points; all mid-flow process nodes use `rx=8` +- **Three-branch decision paths**: Each diamond has a "Yes" branch (down, short ``) and a "No" branch (`` going horizontal then vertical to a side column) +- **Loop-back path**: Mismatch error node loops back to the password-entry node via a routing corridor at x=215 — a 5-px gap between the left column (right edge x=210) and center column (left edge x=220); the path exits the bottom of the error node, drops below it, travels right to x=215, then goes up to the target node's center y, then right 5 px into the node's left edge +- **Section separator**: A dashed horizontal `` at y=452 splits the two phases; the connecting arrow crosses it with a faded label ("user receives email") to preserve flow continuity +- **Italic annotation**: The exact UX copy for the generic message ("If that email exists…") is shown as a faded italic `ts` text block below the left-branch terminal node +- **Legend row**: Five inline swatches (gray, purple, teal, red, amber diamond) at the bottom explain the color-to-role mapping + +## Diagram + +```xml + + + + + + + + + + + Section 1 — Forgot password request + + + + + User: "Forgot password" + + + + + + + + Enter email address + + + + + + + + Email in system? + + + + + No + + + + Yes + + + + + + + Generic message shown + Email sent if found + + + + + + + + Request handled + + + + "If that email exists, a reset + link has been sent." + + + + + + + Generate unique token + Time-limited, cryptographic + + + + + + + + Store token + user ID + + + + + + + + Send reset link via email + + + + + + + + user receives email + + Section 2 — Password reset form + + + + + + + User clicks reset link + + + + + + + + Enter new password ×2 + Confirm both passwords match + + + + + + + + Token expired? + + + + + Yes + + + + No + + + + + + + Token expired + Show expiry error + + + + + + + + End — request again + + + + + + Passwords match? + + + + + No + + + + Yes + + + + + + + Password mismatch + Passwords do not match + + + + + retry + + + + + + + Reset password + Invalidate used token + + + + + + + + Password reset complete + + + + Legend — + + User action + + System process + + Email / success + + Error state + + Decision + + +``` + +## Custom CSS + +Add these classes to the hosting page ` + + +
+

+

+ +
+ + diff --git a/optional-skills/creative/kanban-video-orchestrator/SKILL.md b/optional-skills/creative/kanban-video-orchestrator/SKILL.md index f323406300b..c5ac2a8c96e 100644 --- a/optional-skills/creative/kanban-video-orchestrator/SKILL.md +++ b/optional-skills/creative/kanban-video-orchestrator/SKILL.md @@ -8,7 +8,7 @@ platforms: [linux, macos, windows] metadata: hermes: tags: [video, kanban, multi-agent, orchestration, production-pipeline] - related_skills: [kanban-orchestrator, kanban-worker, ascii-video, manim-video, p5js, comfyui, touchdesigner-mcp, blender-mcp, pixel-art, ascii-art, songwriting-and-ai-music, heartmula, songsee, spotify, youtube-content, claude-design, excalidraw, html-artifact, baoyu-comic, baoyu-infographic, humanizer, gif-search, meme-generation] + related_skills: [kanban-orchestrator, kanban-worker, ascii-video, manim-video, p5js, comfyui, touchdesigner-mcp, blender-mcp, pixel-art, ascii-art, songwriting-and-ai-music, heartmula, songsee, spotify, youtube-content, claude-design, excalidraw, architecture-diagram, concept-diagrams, baoyu-comic, baoyu-infographic, humanizer, gif-search, meme-generation] credits: | The single-project workspace layout, profile-config patching pattern, SOUL.md-per-profile model, TEAM.md task-graph convention, and diff --git a/optional-skills/creative/kanban-video-orchestrator/references/intake.md b/optional-skills/creative/kanban-video-orchestrator/references/intake.md index 1f817da020b..d290b606f49 100644 --- a/optional-skills/creative/kanban-video-orchestrator/references/intake.md +++ b/optional-skills/creative/kanban-video-orchestrator/references/intake.md @@ -96,7 +96,8 @@ texture inside the final scene. - **Terminal-only or with GUI?** - **Voiceover for narration?** - **Diagram support needed?** — Often these benefit from a diagram skill - alongside the screen-capture/render step (`excalidraw`, `html-artifact`) + alongside the screen-capture/render step (`excalidraw`, + `architecture-diagram`, `concept-diagrams`) ### ASCII / terminal art diff --git a/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md b/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md index c5e15c06f4b..95eaeb33b66 100644 --- a/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md +++ b/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md @@ -59,7 +59,7 @@ local skills. - **Toolsets:** kanban, terminal, file - **Skills:** `kanban-worker` plus any project-specific design skill — - `claude-design` (UI/web), `html-artifact` (quick mockup variants, explainers, diagrams), + `claude-design` (UI/web), `sketch` (quick mockup variants), `popular-web-designs` (matching known web aesthetic), `pixel-art` (retro), `ascii-art` (terminal/retro), `excalidraw` (hand-drawn frames), `design-md` (text-based design docs) @@ -72,7 +72,8 @@ film and music video. Often pairs with a diagramming tool. - **Toolsets:** kanban, file - **Skills:** `kanban-worker` plus a diagram skill — `excalidraw` (sketch), - `html-artifact` (technical/system + educational/scientific diagrams) + `architecture-diagram` (technical/system), `concept-diagrams` (educational/ + scientific) - **Outputs:** `storyboard.md` with one row per scene/shot, optional storyboard sketches diff --git a/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md b/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md index 2f27ffc41e7..b5e59c31478 100644 --- a/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md +++ b/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md @@ -30,8 +30,10 @@ called from the terminal toolset; they don't appear in `always_load`. | `claude-design` | Design one-off HTML artifacts (landing, deck, prototype) | Concept artist for product video style frames; storyboarder for UI-heavy content | | `design-md` | Design markdown docs | Concept artist documenting visual specs | | `popular-web-designs` | Reference patterns for popular web designs | Concept artist; cinematographer when matching a known UI aesthetic | +| `sketch` | Throwaway HTML mockups (2-3 design variants to compare) | Concept artist exploring directions; storyboarder for UI flows | | `excalidraw` | Excalidraw-style hand-drawn diagrams | Storyboarder; concept artist for sketch-style frames | -| `html-artifact` | Self-contained HTML artifacts: throwaway mockup variants, explainers, dark-tech architecture + educational SVG diagrams | Concept artist exploring directions; storyboarder for UI flows + technical/educational explainer scenes | +| `architecture-diagram` | Software architecture diagrams | Storyboarder for technical content; explainer scenes about systems | +| `concept-diagrams` *(optional)* | Flat, minimal SVG diagrams (educational visual language; physics, chemistry, math, anatomy, etc.) | Renderer / storyboarder for explainer scenes with clean educational diagrams | | `pretext` | Mathematical/scientific content authoring | Writer / cinematographer for technical-explainer pretexts | | `creative-ideation` | Constraint-driven project ideation | Director / cinematographer when the brief is wide-open and needs framing | | `humanizer` | Strip AI-isms from text, add real voice | Writer / copywriter post-process to avoid AI-tells in scripts and VO copy | diff --git a/skills/creative/architecture-diagram/SKILL.md b/skills/creative/architecture-diagram/SKILL.md new file mode 100644 index 00000000000..2c813c53c13 --- /dev/null +++ b/skills/creative/architecture-diagram/SKILL.md @@ -0,0 +1,148 @@ +--- +name: architecture-diagram +description: "Dark-themed SVG architecture/cloud/infra diagrams as HTML." +version: 1.0.0 +author: Cocoon AI (hello@cocoon-ai.com), ported by Hermes Agent +license: MIT +dependencies: [] +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [architecture, diagrams, SVG, HTML, visualization, infrastructure, cloud] + related_skills: [concept-diagrams, excalidraw] +--- + +# Architecture Diagram Skill + +Generate professional, dark-themed technical architecture diagrams as standalone HTML files with inline SVG graphics. No external tools, no API keys, no rendering libraries — just write the HTML file and open it in a browser. + +## Scope + +**Best suited for:** +- Software system architecture (frontend / backend / database layers) +- Cloud infrastructure (VPC, regions, subnets, managed services) +- Microservice / service-mesh topology +- Database + API map, deployment diagrams +- Anything with a tech-infra subject that fits a dark, grid-backed aesthetic + +**Look elsewhere first for:** +- Physics, chemistry, math, biology, or other scientific subjects +- Physical objects (vehicles, hardware, anatomy, cross-sections) +- Floor plans, narrative journeys, educational / textbook-style visuals +- Hand-drawn whiteboard sketches (consider `excalidraw`) +- Animated explainers (consider an animation skill) + +If a more specialized skill is available for the subject, prefer that. If none fits, this skill can also serve as a general SVG diagram fallback — the output will just carry the dark tech aesthetic described below. + +Based on [Cocoon AI's architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator) (MIT). + +## Workflow + +1. User describes their system architecture (components, connections, technologies) +2. Generate the HTML file following the design system below +3. Save with `write_file` to a `.html` file (e.g. `~/architecture-diagram.html`) +4. User opens in any browser — works offline, no dependencies + +### Output Location + +Save diagrams to a user-specified path, or default to the current working directory: +``` +./[project-name]-architecture.html +``` + +### Preview + +After saving, suggest the user open it: +```bash +# macOS +open ./my-architecture.html +# Linux +xdg-open ./my-architecture.html +``` + +## Design System & Visual Language + +### Color Palette (Semantic Mapping) + +Use specific `rgba` fills and hex strokes to categorize components: + +| Component Type | Fill (rgba) | Stroke (Hex) | +| :--- | :--- | :--- | +| **Frontend** | `rgba(8, 51, 68, 0.4)` | `#22d3ee` (cyan-400) | +| **Backend** | `rgba(6, 78, 59, 0.4)` | `#34d399` (emerald-400) | +| **Database** | `rgba(76, 29, 149, 0.4)` | `#a78bfa` (violet-400) | +| **AWS/Cloud** | `rgba(120, 53, 15, 0.3)` | `#fbbf24` (amber-400) | +| **Security** | `rgba(136, 19, 55, 0.4)` | `#fb7185` (rose-400) | +| **Message Bus** | `rgba(251, 146, 60, 0.3)` | `#fb923c` (orange-400) | +| **External** | `rgba(30, 41, 59, 0.5)` | `#94a3b8` (slate-400) | + +### Typography & Background +- **Font:** JetBrains Mono (Monospace), loaded from Google Fonts +- **Sizes:** 12px (Names), 9px (Sublabels), 8px (Annotations), 7px (Tiny labels) +- **Background:** Slate-950 (`#020617`) with a subtle 40px grid pattern + +```svg + + + + +``` + +## Technical Implementation Details + +### Component Rendering +Components are rounded rectangles (`rx="6"`) with 1.5px strokes. To prevent arrows from showing through semi-transparent fills, use a **double-rect masking technique**: +1. Draw an opaque background rect (`#0f172a`) +2. Draw the semi-transparent styled rect on top + +### Connection Rules +- **Z-Order:** Draw arrows *early* in the SVG (after the grid) so they render behind component boxes +- **Arrowheads:** Defined via SVG markers +- **Security Flows:** Use dashed lines in rose color (`#fb7185`) +- **Boundaries:** + - *Security Groups:* Dashed (`4,4`), rose color + - *Regions:* Large dashed (`8,4`), amber color, `rx="12"` + +### Spacing & Layout Logic +- **Standard Height:** 60px (Services); 80-120px (Large components) +- **Vertical Gap:** Minimum 40px between components +- **Message Buses:** Must be placed *in the gap* between services, not overlapping them +- **Legend Placement:** **CRITICAL.** Must be placed outside all boundary boxes. Calculate the lowest Y-coordinate of all boundaries and place the legend at least 20px below it. + +## Document Structure + +The generated HTML file follows a four-part layout: +1. **Header:** Title with a pulsing dot indicator and subtitle +2. **Main SVG:** The diagram contained within a rounded border card +3. **Summary Cards:** A grid of three cards below the diagram for high-level details +4. **Footer:** Minimal metadata + +### Info Card Pattern +```html +
+
+
+

Title

+
+
    +
  • • Item one
  • +
  • • Item two
  • +
+
+``` + +## Output Requirements +- **Single File:** One self-contained `.html` file +- **No External Dependencies:** All CSS and SVG must be inline (except Google Fonts) +- **No JavaScript:** Use pure CSS for any animations (like pulsing dots) +- **Compatibility:** Must render correctly in any modern web browser + +## Template Reference + +Load the full HTML template for the exact structure, CSS, and SVG component examples: + +``` +skill_view(name="architecture-diagram", file_path="templates/template.html") +``` + +The template contains working examples of every component type (frontend, backend, database, cloud, security), arrow styles (standard, dashed, curved), security groups, region boundaries, and the legend — use it as your structural reference when generating diagrams. diff --git a/skills/creative/architecture-diagram/templates/template.html b/skills/creative/architecture-diagram/templates/template.html new file mode 100644 index 00000000000..f5b32fbe7fd --- /dev/null +++ b/skills/creative/architecture-diagram/templates/template.html @@ -0,0 +1,319 @@ + + + + + + [PROJECT NAME] Architecture Diagram + + + + +
+ +
+
+
+

[PROJECT NAME] Architecture

+
+

[Subtitle description]

+
+ + +
+ + + + + + + + + + + + + + + + + + + Users + Browser/Mobile + + + + Auth Provider + OAuth 2.0 + + + + AWS Region: us-west-2 + + + + CloudFront + CDN + + + + S3 Buckets + • bucket-one + • bucket-two + • bucket-three + OAI Protected + + + + sg-name :port + + + + Load Balancer + HTTPS :443 + + + + API Server + FastAPI :8000 + + + + Database + PostgreSQL + + + + Frontend + React + TypeScript + Additional detail + More info + domain.example.com + + + + + + HTTPS + + + + + + + OAI + + + + + TLS + + + + JWT + PKCE + + + Legend + + + Frontend + + + Backend + + + Cloud Service + + + Database + + + Security + + + Auth Flow + + + Security Group + +
+ + +
+
+
+
+

Card Title 1

+
+
    +
  • • Item one
  • +
  • • Item two
  • +
  • • Item three
  • +
  • • Item four
  • +
+
+ +
+
+
+

Card Title 2

+
+
    +
  • • Item one
  • +
  • • Item two
  • +
  • • Item three
  • +
  • • Item four
  • +
+
+ +
+
+
+

Card Title 3

+
+
    +
  • • Item one
  • +
  • • Item two
  • +
  • • Item three
  • +
  • • Item four
  • +
+
+
+ + + +
+ + diff --git a/skills/creative/claude-design/SKILL.md b/skills/creative/claude-design/SKILL.md index d61dbcb2f00..673d1ff827a 100644 --- a/skills/creative/claude-design/SKILL.md +++ b/skills/creative/claude-design/SKILL.md @@ -8,7 +8,7 @@ platforms: [linux, macos, windows] metadata: hermes: tags: [design, html, prototype, ux, ui, creative, artifact, deck, motion, design-system] - related_skills: [html-artifact, design-md, popular-web-designs, excalidraw] + related_skills: [design-md, popular-web-designs, excalidraw, architecture-diagram] --- # Claude Design for CLI/API Agents @@ -19,21 +19,19 @@ The goal is to preserve Claude Design's useful design behavior and taste while r **Before starting, check for other web-design skills like `popular-web-designs` (ready-to-paste design systems for Stripe, Linear, Vercel, Notion, etc.) and `design-md` (Google's DESIGN.md token spec format).** If the user wants a known brand's look, load `popular-web-designs` alongside this one and let it supply the visual vocabulary. If the deliverable is a token spec file rather than a rendered artifact, use `design-md` instead. Full decision table below. -## When To Use This Skill vs `html-artifact` vs `popular-web-designs` vs `design-md` +## When To Use This Skill vs `popular-web-designs` vs `design-md` -Several skills produce HTML — they do different jobs. Load the right one (or combine them): +Hermes has three design-related skills under `skills/creative/`. They do different jobs — load the right one (or combine them): | Skill | What it gives you | Use when the user wants... | |---|---|---| -| **claude-design** (this one) | Visual design *process and taste* — how to scope a brief, gather context, produce variants, verify a local HTML artifact, avoid AI-design slop | a from-scratch *designed* artifact (landing page, prototype, deck, component lab, motion study) where the look itself is the point and no specific brand or token system is dictated | -| **html-artifact** | A house style for *information* artifacts — explainers, plans, reports, code reviews, technical/educational diagrams, throwaway editors | to *explain / plan / report / diagram / review* something as a shareable HTML page — the content is the point, not bespoke visual design | +| **claude-design** (this one) | Design *process and taste* — how to scope a brief, gather context, produce variants, verify a local HTML artifact, avoid AI-design slop | a from-scratch designed artifact (landing page, prototype, deck, component lab, motion study) with no specific brand or token system dictated | | **popular-web-designs** | 54 ready-to-paste design systems — exact colors, typography, components, CSS values for sites like Stripe, Linear, Vercel, Notion, Airbnb | "make it look like Stripe / Linear / Vercel", a page styled after a known brand, or a visual starting point pulled from a real product | | **design-md** | Google's DESIGN.md spec format — author/validate/diff/export design-token files, WCAG contrast checking, Tailwind/DTCG export | a formal, persistent, machine-readable design-system *spec file* (tokens + rationale) that lives in a repo and gets consumed by agents over time | Rule of thumb: -- **Bespoke visual design, taste-driven artifact** → claude-design -- **Explain / plan / report / diagram as a shareable page** → html-artifact +- **Process + taste, one-off artifact** → claude-design - **Match a known brand's look** → popular-web-designs (and let claude-design drive the process) - **Author the tokens spec itself** → design-md diff --git a/skills/creative/design-md/SKILL.md b/skills/creative/design-md/SKILL.md index e0534d9ba72..6604be1979d 100644 --- a/skills/creative/design-md/SKILL.md +++ b/skills/creative/design-md/SKILL.md @@ -8,7 +8,7 @@ platforms: [linux, macos, windows] metadata: hermes: tags: [design, design-system, tokens, ui, accessibility, wcag, tailwind, dtcg, google] - related_skills: [popular-web-designs, claude-design, excalidraw, html-artifact] + related_skills: [popular-web-designs, claude-design, excalidraw, architecture-diagram] --- # DESIGN.md Skill diff --git a/skills/creative/html-artifact/SKILL.md b/skills/creative/html-artifact/SKILL.md deleted file mode 100644 index 4883e1ff4c1..00000000000 --- a/skills/creative/html-artifact/SKILL.md +++ /dev/null @@ -1,184 +0,0 @@ ---- -name: html-artifact -description: Build self-contained HTML files to explain, plan, or review. -version: 1.0.0 -author: Anthropic (html-effectiveness gallery, MIT), adapted for Hermes Agent -license: MIT -platforms: [linux, macos, windows] -metadata: - hermes: - tags: [html, artifact, explainer, plan, report, code-review, diagram, svg, design, prototype, editor] - related_skills: [claude-design, popular-web-designs, design-md, excalidraw, p5js] ---- - -# HTML Artifact Skill - -Produce a single self-contained `.html` file — no build step, no dependencies, no -CDN — whenever the deliverable is something a human should *read, share, or poke at*: -a concept explainer, an implementation plan, a status/incident report, a code-review -walkthrough, a technical or educational diagram, a set of design variants, or a -throwaway editor that exports its result back to you. - -HTML beats Markdown once a doc has color, layout, diagrams, tables, code, or -interaction. It opens in any browser, shares as a link, stays readable past 100 -lines, and can carry SVG diagrams and live controls Markdown can't. Default to an -HTML artifact when the user says "make an HTML file/artifact", or asks you to -*explain how X works*, *write up a plan/PR/report*, *diagram* something, *compare* -options, or *prototype* an interaction — even when they don't say "HTML". - -## Why this skill exists (and what it replaced) - -This skill **supersedes** three former skills — `sketch` (throwaway multi-variant -HTML mockups), `architecture-diagram` (dark-tech infra SVG), and `concept-diagrams` -(educational SVG). They were consolidated for a concrete reason: all three emitted -the *same artifact* — a single self-contained HTML file with inline CSS/SVG — and -overlapped heavily (three "diagram" skills, two "compare variants" paths, no shared -token system). Folding them into one mode-switched skill removes the -which-one-do-I-load ambiguity and gives every output the same house style, while -keeping each skill's unique value: the fidelity dial + verify loop (from `sketch`), -the dark infra aesthetic (from `architecture-diagram`), and the 9-ramp educational -system + archetype library (from `concept-diagrams`). - -The consolidation is footprint-safe: this skill has **zero dependencies** (no Node, -FFmpeg, Chromium, or pip packages — it authors plain HTML/CSS/SVG), so even though it -ships **bundled** (active by default) where `concept-diagrams` was optional, the only -always-in-context cost is this skill's one-line description. All references, -templates, and the example gallery load on demand. `concept-diagrams` was optional -because it was niche, not because it had an install cost — promoting that capability -into a general-purpose, zero-dep bundled skill is the right home for it. Diagram-style -work with a *real* install cost (e.g. `hyperframes`: Node + FFmpeg + Chromium) -deliberately stays optional and is **not** folded in here. - -Use a different skill when: matching a known brand's look → `popular-web-designs`; a -formal design-token spec file → `design-md`; a *bespoke visually-designed* artifact -where the look itself is the point → `claude-design`; hand-drawn/whiteboard -`.excalidraw` files → `excalidraw`; generative/animated canvas art → `p5js`. This -skill is for everything else that ships as a readable, shareable HTML page. - -## Reference files (load on demand) - -- `references/house-style.md` — the canonical `:root` token block, type system, - card/table/callout/code-block patterns. **Read this before authoring any artifact.** -- `references/examples.md` — 20 complete reference HTML files (Anthropic's - html-effectiveness gallery, MIT) keyed to each mode, plus the script to fetch them. - Read/fetch one that matches your task to calibrate the house style from a full example. -- `references/svg-diagrams.md` — hand-authored inline SVG: arrow markers, node - groups, decision diamonds, edge semantics, coordinate-grid discipline. Read for - any flowchart / architecture / concept diagram. -- `references/concept-archetypes.md` — the 9-ramp educational color system + a - library of diagram archetypes (timeline, tree, quadrant, layered stack, - before/after, hub-spoke, cross-section). Read for educational / non-software visuals. -- `references/dark-tech.md` — the dark "infra" token variant (carries the old - architecture-diagram aesthetic). Read for cloud/infra/system architecture diagrams. -- `references/throwaway-editors.md` — the single-file editor recipe and the - copy-to-clipboard export pattern that survives `file://`. Read when the artifact - needs interactive controls that export state back to a prompt. -- `references/fidelity-and-verify.md` — the throwaway↔presentation fidelity dial, - the multi-variant comparison layout, and the mandatory browser-vision verify loop. - -## Templates - -- `templates/base.html` — document scaffold with the house-style ` - - -
-

Section · Context

-

Artifact Title

-

One-sentence framing of what this artifact is and who it's for.

- -

Overview

-

Body copy. Keep paragraphs readable; let layout carry structure.

- -
-

Metric

42
-

Metric

7
-

Needs attention

3
-

Metric

98%
-
- -
Note. Use callouts for the one thing the reader must not miss.
- - - -
- - diff --git a/skills/creative/html-artifact/templates/diagram.html b/skills/creative/html-artifact/templates/diagram.html deleted file mode 100644 index 93522119d36..00000000000 --- a/skills/creative/html-artifact/templates/diagram.html +++ /dev/null @@ -1,127 +0,0 @@ - - - - - -Diagram - - - - - -
-

-

- - -
- - diff --git a/skills/creative/html-artifact/templates/editor.html b/skills/creative/html-artifact/templates/editor.html deleted file mode 100644 index 88ee378d7a3..00000000000 --- a/skills/creative/html-artifact/templates/editor.html +++ /dev/null @@ -1,120 +0,0 @@ - - - - - -Editor - - - - -
-

Throwaway editor

-

Toggle what ships, copy the result

-
-
- - -
-
- - - - diff --git a/skills/creative/pretext/SKILL.md b/skills/creative/pretext/SKILL.md index c526d000ddd..78f5ab2d959 100644 --- a/skills/creative/pretext/SKILL.md +++ b/skills/creative/pretext/SKILL.md @@ -8,7 +8,7 @@ platforms: [linux, macos, windows] metadata: hermes: tags: [creative-coding, typography, pretext, ascii-art, canvas, generative, text-layout, kinetic-typography] - related_skills: [p5js, claude-design, excalidraw, html-artifact] + related_skills: [p5js, claude-design, excalidraw, architecture-diagram] --- # Pretext Creative Demos diff --git a/skills/creative/sketch/SKILL.md b/skills/creative/sketch/SKILL.md new file mode 100644 index 00000000000..6e49585acd4 --- /dev/null +++ b/skills/creative/sketch/SKILL.md @@ -0,0 +1,218 @@ +--- +name: sketch +description: "Throwaway HTML mockups: 2-3 design variants to compare." +version: 1.0.0 +author: Hermes Agent (adapted from gsd-build/get-shit-done) +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [sketch, mockup, design, ui, prototype, html, variants, exploration, wireframe, comparison] + related_skills: [spike, claude-design, popular-web-designs, excalidraw] +--- + +# Sketch + +Use this skill when the user wants to **see a design direction before committing** to one — exploring a UI/UX idea as disposable HTML mockups. The point is to generate 2-3 interactive variants so the user can compare visual directions side-by-side, not to produce shippable code. + +Load this when the user says things like "sketch this screen", "show me what X could look like", "compare layout A vs B", "give me 2-3 takes on this UI", "let me see some variants", "mockup this before I build". + +## When NOT to use this + +- User wants a production component — use `claude-design` or build it properly +- User wants a polished one-off HTML artifact (landing page, deck) — `claude-design` +- User wants a diagram — `excalidraw`, `architecture-diagram` +- The design is already locked — just build it + +## If the user has the full GSD system installed + +If `gsd-sketch` shows up as a sibling skill (installed via `npx get-shit-done-cc --hermes`), prefer **`gsd-sketch`** for the full workflow: persistent `.planning/sketches/` with MANIFEST, frontier mode analysis, consistency audits across past sketches, and integration with the rest of GSD. This skill is the lightweight standalone version — one-off sketching without the state machinery. + +## Core method + +``` +intake → variants → head-to-head → pick winner (or iterate) +``` + +### 1. Intake (skip if the user already gave you enough) + +Before generating variants, get three things — one question at a time, not all at once: + +1. **Feel.** "What should this feel like? Adjectives, emotions, a vibe." — *"calm, editorial, like Linear"* tells you more than *"minimal"*. +2. **References.** "What apps, sites, or products capture the feel you're imagining?" — actual references beat abstract descriptions. +3. **Core action.** "What's the single most important thing a user does on this screen?" — the variants should all serve this well; if they don't, they're just decoration. + +Reflect each answer briefly before the next question. If the user already gave you all three upfront, skip straight to variants. + +### 2. Variants (2-3, never 1, rarely 4+) + +Produce **2-3 variants** in one go. Each variant is a complete, standalone HTML file. Don't describe variants — build them. The point is comparison. + +Each variant should take a **different design stance**, not different pixel values. Three good variant axes: + +- **Density:** compact / airy / ultra-dense (pick two contrasting poles) +- **Emphasis:** content-first / action-first / tool-first +- **Aesthetic:** editorial / utilitarian / playful +- **Layout:** single-column / sidebar / split-pane +- **Grounding:** card-based / bare-content / document-style + +Pick one axis and pull apart from it. Two variants that differ only in accent color are wasted effort — the user can't distinguish them. + +**Variant naming:** describe the stance, not the number. + +``` +sketches/ +├── 001-calm-editorial/ +│ ├── index.html +│ └── README.md +├── 001-utilitarian-dense/ +│ ├── index.html +│ └── README.md +└── 001-playful-split/ + ├── index.html + └── README.md +``` + +### 3. Make them real HTML + +Each variant is a **single self-contained HTML file**: + +- Inline ` +``` + +### 4. Variant README + +Each variant's `README.md` answers: + +```markdown +## Variant: {stance name} + +### Design stance +One sentence on the principle driving this variant. + +### Key choices +- Layout: ... +- Typography: ... +- Color: ... +- Interaction: ... + +### Trade-offs +- Strong at: ... +- Weak at: ... + +### Best for +- The kind of user or use case this variant actually serves +``` + +### 5. Head-to-head + +After all variants are built, present them as a comparison. Don't just list — **opinionate**: + +```markdown +## Three takes on the home screen + +| Dimension | Calm editorial | Utilitarian dense | Playful split | +|-----------|----------------|-------------------|---------------| +| Density | Low | High | Medium | +| Primary action visibility | Low | High | Medium | +| Scan-ability | High | Medium | Low | +| Feel | Calm, trusted | Sharp, tool-like | Inviting, energetic | + +**My take:** Utilitarian dense for power users, calm editorial for content-forward audiences. Playful split is weakest — tries to do both and commits to neither. +``` + +Let the user pick a winner, or combine two into a hybrid, or ask for another round. + +## Theming (when the project has a visual identity) + +If the user has an existing theme (colors, fonts, tokens), put shared tokens in `sketches/themes/tokens.css` and `@import` them in each variant. Keep tokens minimal: + +```css +/* sketches/themes/tokens.css */ +:root { + --color-bg: #fafafa; + --color-fg: #1a1a1a; + --color-accent: #0066ff; + --color-muted: #666; + --radius: 8px; + --font-display: "Inter", sans-serif; + --font-body: -apple-system, BlinkMacSystemFont, sans-serif; +} +``` + +Don't over-tokenize a throwaway sketch — three colors and one font is usually enough. + +## Interactivity bar + +A sketch is interactive enough when the user can: + +1. **Click a primary action** and something visible happens (state change, modal, toast, navigation feint) +2. **See one meaningful state transition** (filter a list, toggle a mode, open/close a panel) +3. **Hover recognizable affordances** (buttons, rows, tabs) + +More than that is over-engineering a throwaway. Less than that is a screenshot. + +## Frontier mode (picking what to sketch next) + +If sketches already exist and the user says "what should I sketch next?": + +- **Consistency gaps** — two winning variants from different sketches made independent choices that haven't been composed together yet +- **Unsketched screens** — referenced but never explored +- **State coverage** — happy path sketched, but not empty / loading / error / 1000-items +- **Responsive gaps** — validated at one viewport; does it hold at mobile / ultrawide? +- **Interaction patterns** — static layouts exist; transitions, drag, scroll behavior don't + +Propose 2-4 named candidates. Let the user pick. + +## Output + +- Create `sketches/` (or `.planning/sketches/` if the user is using GSD conventions) in the repo root +- One subdir per variant: `NNN-stance-name/index.html` + `README.md` +- Tell the user how to open them: `open sketches/001-calm-editorial/index.html` on macOS, `xdg-open` on Linux, `start` on Windows +- Keep variants disposable — a sketch that you felt the need to preserve should be promoted into real project code, not curated as an asset + +**Typical tool sequence for one variant:** + +``` +terminal("mkdir -p sketches/001-calm-editorial") +write_file("sketches/001-calm-editorial/index.html", "...") +write_file("sketches/001-calm-editorial/README.md", "## Variant: Calm editorial\n...") +browser_navigate(url="file://$(pwd)/sketches/001-calm-editorial/index.html") +browser_vision(question="How does this look? Any obvious layout issues?") +``` + +Repeat for each variant, then present the comparison table. + +## Attribution + +Adapted from the GSD (Get Shit Done) project's `/gsd-sketch` workflow — MIT © 2025 Lex Christopherson ([gsd-build/get-shit-done](https://github.com/gsd-build/get-shit-done)). The full GSD system ships persistent sketch state, theme/variant pattern references, and consistency-audit workflows; install with `npx get-shit-done-cc --hermes --global`. diff --git a/skills/software-development/spike/SKILL.md b/skills/software-development/spike/SKILL.md index 313cbe7fb9c..2a980f0ade9 100644 --- a/skills/software-development/spike/SKILL.md +++ b/skills/software-development/spike/SKILL.md @@ -8,7 +8,7 @@ platforms: [linux, macos, windows] metadata: hermes: tags: [spike, prototype, experiment, feasibility, throwaway, exploration, research, planning, mvp, proof-of-concept] - related_skills: [html-artifact, subagent-driven-development, plan] + related_skills: [sketch, subagent-driven-development, plan] --- # Spike diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index a9e27dfd90e..4e2b2524fe2 100644 --- a/website/docs/reference/optional-skills-catalog.md +++ b/website/docs/reference/optional-skills-catalog.md @@ -58,6 +58,7 @@ hermes skills uninstall | [**baoyu-article-illustrator**](/docs/user-guide/skills/optional/creative/creative-baoyu-article-illustrator) | Article illustrations: type × style × palette consistency. | | [**baoyu-comic**](/docs/user-guide/skills/optional/creative/creative-baoyu-comic) | Knowledge comics (知识漫画): educational, biography, tutorial. | | [**blender-mcp**](/docs/user-guide/skills/optional/creative/creative-blender-mcp) | Control Blender directly from Hermes via socket connection to the blender-mcp addon. Create 3D objects, materials, animations, and run arbitrary Blender Python (bpy) code. Use when user wants to create or modify anything in Blender. | +| [**concept-diagrams**](/docs/user-guide/skills/optional/creative/creative-concept-diagrams) | Generate flat, minimal light/dark-aware SVG diagrams as standalone HTML files, using a unified educational visual language with 9 semantic color ramps, sentence-case typography, and automatic dark mode. Best suited for educational and no... | | [**ideation**](/docs/user-guide/skills/optional/creative/creative-creative-ideation) | Generate project ideas via creative constraints. | | [**hyperframes**](/docs/user-guide/skills/optional/creative/creative-hyperframes) | Create HTML-based video compositions, animated title cards, social overlays, captioned talking-head videos, audio-reactive visuals, and shader transitions using HyperFrames. HTML is the source of truth for video. Use when the user wants... | | [**kanban-video-orchestrator**](/docs/user-guide/skills/optional/creative/creative-kanban-video-orchestrator) | Plan, set up, and monitor a multi-agent video production pipeline backed by Hermes Kanban. Use when the user wants to make ANY video — narrative film, product/marketing, music video, explainer, ASCII/terminal art, abstract/generative loo... | diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md index 3ae519a07f8..5ccb1f5f5ca 100644 --- a/website/docs/reference/skills-catalog.md +++ b/website/docs/reference/skills-catalog.md @@ -35,6 +35,7 @@ If a skill is missing from this list but present in the repo, the catalog is reg | Skill | Description | Path | |-------|-------------|------| +| [`architecture-diagram`](/docs/user-guide/skills/bundled/creative/creative-architecture-diagram) | Dark-themed SVG architecture/cloud/infra diagrams as HTML. | `creative/architecture-diagram` | | [`ascii-art`](/docs/user-guide/skills/bundled/creative/creative-ascii-art) | ASCII art: pyfiglet, cowsay, boxes, image-to-ascii. | `creative/ascii-art` | | [`ascii-video`](/docs/user-guide/skills/bundled/creative/creative-ascii-video) | ASCII video: convert video/audio to colored ASCII MP4/GIF. | `creative/ascii-video` | | [`baoyu-infographic`](/docs/user-guide/skills/bundled/creative/creative-baoyu-infographic) | Infographics: 21 layouts x 21 styles (信息图, 可视化). | `creative/baoyu-infographic` | @@ -42,12 +43,12 @@ If a skill is missing from this list but present in the repo, the catalog is reg | [`comfyui`](/docs/user-guide/skills/bundled/creative/creative-comfyui) | Generate images, video, and audio with ComfyUI — install, launch, manage nodes/models, run workflows with parameter injection. Uses the official comfy-cli for lifecycle and direct REST/WebSocket API for execution. | `creative/comfyui` | | [`design-md`](/docs/user-guide/skills/bundled/creative/creative-design-md) | Author/validate/export Google's DESIGN.md token spec files. | `creative/design-md` | | [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw) | Hand-drawn Excalidraw JSON diagrams (arch, flow, seq). | `creative/excalidraw` | -| [`html-artifact`](/docs/user-guide/skills/bundled/creative/creative-html-artifact) | Build self-contained HTML files to explain, plan, or review. | `creative/html-artifact` | | [`humanizer`](/docs/user-guide/skills/bundled/creative/creative-humanizer) | Humanize text: strip AI-isms and add real voice. | `creative/humanizer` | | [`manim-video`](/docs/user-guide/skills/bundled/creative/creative-manim-video) | Manim CE animations: 3Blue1Brown math/algo videos. | `creative/manim-video` | | [`p5js`](/docs/user-guide/skills/bundled/creative/creative-p5js) | p5.js sketches: gen art, shaders, interactive, 3D. | `creative/p5js` | | [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs) | 54 real design systems (Stripe, Linear, Vercel) as HTML/CSS. | `creative/popular-web-designs` | | [`pretext`](/docs/user-guide/skills/bundled/creative/creative-pretext) | Use when building creative browser demos with @chenglou/pretext — DOM-free text layout for ASCII art, typographic flow around obstacles, text-as-geometry games, kinetic typography, and text-powered generative art. Produces single-file HT... | `creative/pretext` | +| [`sketch`](/docs/user-guide/skills/bundled/creative/creative-sketch) | Throwaway HTML mockups: 2-3 design variants to compare. | `creative/sketch` | | [`songwriting-and-ai-music`](/docs/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music) | Songwriting craft and Suno AI music prompts. | `creative/songwriting-and-ai-music` | | [`touchdesigner-mcp`](/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp) | Control a running TouchDesigner instance via twozero MCP — create operators, set parameters, wire connections, execute Python, build real-time visuals. 36 native tools. | `creative/touchdesigner-mcp` | diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index 089ea173923..77f81db14b6 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -360,7 +360,7 @@ The registry of record is `hermes_cli/commands.py` — every consumer ``` ~/.hermes/config.yaml Main configuration -~/.hermes/.env API keys and secrets (under $HERMES_HOME if set) +~/.hermes/.env API keys and secrets $HERMES_HOME/skills/ Installed skills ~/.hermes/sessions/ Gateway routing index, request dumps, *.jsonl transcripts (and optional per-session JSON snapshots when sessions.write_json_snapshots: true) ~/.hermes/state.db Canonical session store (SQLite + FTS5) @@ -927,7 +927,7 @@ hermes-agent/ ``` -Config: `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys) — both under `$HERMES_HOME` when it is set. +Config: `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys). ### Adding a Tool (3 files) diff --git a/website/docs/user-guide/skills/bundled/creative/creative-architecture-diagram.md b/website/docs/user-guide/skills/bundled/creative/creative-architecture-diagram.md new file mode 100644 index 00000000000..ad816a370ad --- /dev/null +++ b/website/docs/user-guide/skills/bundled/creative/creative-architecture-diagram.md @@ -0,0 +1,165 @@ +--- +title: "Architecture Diagram — Dark-themed SVG architecture/cloud/infra diagrams as HTML" +sidebar_label: "Architecture Diagram" +description: "Dark-themed SVG architecture/cloud/infra diagrams as HTML" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Architecture Diagram + +Dark-themed SVG architecture/cloud/infra diagrams as HTML. + +## Skill metadata + +| | | +|---|---| +| Source | Bundled (installed by default) | +| Path | `skills/creative/architecture-diagram` | +| Version | `1.0.0` | +| Author | Cocoon AI (hello@cocoon-ai.com), ported by Hermes Agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `architecture`, `diagrams`, `SVG`, `HTML`, `visualization`, `infrastructure`, `cloud` | +| Related skills | [`concept-diagrams`](/docs/user-guide/skills/optional/creative/creative-concept-diagrams), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw) | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# Architecture Diagram Skill + +Generate professional, dark-themed technical architecture diagrams as standalone HTML files with inline SVG graphics. No external tools, no API keys, no rendering libraries — just write the HTML file and open it in a browser. + +## Scope + +**Best suited for:** +- Software system architecture (frontend / backend / database layers) +- Cloud infrastructure (VPC, regions, subnets, managed services) +- Microservice / service-mesh topology +- Database + API map, deployment diagrams +- Anything with a tech-infra subject that fits a dark, grid-backed aesthetic + +**Look elsewhere first for:** +- Physics, chemistry, math, biology, or other scientific subjects +- Physical objects (vehicles, hardware, anatomy, cross-sections) +- Floor plans, narrative journeys, educational / textbook-style visuals +- Hand-drawn whiteboard sketches (consider `excalidraw`) +- Animated explainers (consider an animation skill) + +If a more specialized skill is available for the subject, prefer that. If none fits, this skill can also serve as a general SVG diagram fallback — the output will just carry the dark tech aesthetic described below. + +Based on [Cocoon AI's architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator) (MIT). + +## Workflow + +1. User describes their system architecture (components, connections, technologies) +2. Generate the HTML file following the design system below +3. Save with `write_file` to a `.html` file (e.g. `~/architecture-diagram.html`) +4. User opens in any browser — works offline, no dependencies + +### Output Location + +Save diagrams to a user-specified path, or default to the current working directory: +``` +./[project-name]-architecture.html +``` + +### Preview + +After saving, suggest the user open it: +```bash +# macOS +open ./my-architecture.html +# Linux +xdg-open ./my-architecture.html +``` + +## Design System & Visual Language + +### Color Palette (Semantic Mapping) + +Use specific `rgba` fills and hex strokes to categorize components: + +| Component Type | Fill (rgba) | Stroke (Hex) | +| :--- | :--- | :--- | +| **Frontend** | `rgba(8, 51, 68, 0.4)` | `#22d3ee` (cyan-400) | +| **Backend** | `rgba(6, 78, 59, 0.4)` | `#34d399` (emerald-400) | +| **Database** | `rgba(76, 29, 149, 0.4)` | `#a78bfa` (violet-400) | +| **AWS/Cloud** | `rgba(120, 53, 15, 0.3)` | `#fbbf24` (amber-400) | +| **Security** | `rgba(136, 19, 55, 0.4)` | `#fb7185` (rose-400) | +| **Message Bus** | `rgba(251, 146, 60, 0.3)` | `#fb923c` (orange-400) | +| **External** | `rgba(30, 41, 59, 0.5)` | `#94a3b8` (slate-400) | + +### Typography & Background +- **Font:** JetBrains Mono (Monospace), loaded from Google Fonts +- **Sizes:** 12px (Names), 9px (Sublabels), 8px (Annotations), 7px (Tiny labels) +- **Background:** Slate-950 (`#020617`) with a subtle 40px grid pattern + +```svg + + + + +``` + +## Technical Implementation Details + +### Component Rendering +Components are rounded rectangles (`rx="6"`) with 1.5px strokes. To prevent arrows from showing through semi-transparent fills, use a **double-rect masking technique**: +1. Draw an opaque background rect (`#0f172a`) +2. Draw the semi-transparent styled rect on top + +### Connection Rules +- **Z-Order:** Draw arrows *early* in the SVG (after the grid) so they render behind component boxes +- **Arrowheads:** Defined via SVG markers +- **Security Flows:** Use dashed lines in rose color (`#fb7185`) +- **Boundaries:** + - *Security Groups:* Dashed (`4,4`), rose color + - *Regions:* Large dashed (`8,4`), amber color, `rx="12"` + +### Spacing & Layout Logic +- **Standard Height:** 60px (Services); 80-120px (Large components) +- **Vertical Gap:** Minimum 40px between components +- **Message Buses:** Must be placed *in the gap* between services, not overlapping them +- **Legend Placement:** **CRITICAL.** Must be placed outside all boundary boxes. Calculate the lowest Y-coordinate of all boundaries and place the legend at least 20px below it. + +## Document Structure + +The generated HTML file follows a four-part layout: +1. **Header:** Title with a pulsing dot indicator and subtitle +2. **Main SVG:** The diagram contained within a rounded border card +3. **Summary Cards:** A grid of three cards below the diagram for high-level details +4. **Footer:** Minimal metadata + +### Info Card Pattern +```html +
+
+
+

Title

+
+
    +
  • • Item one
  • +
  • • Item two
  • +
+
+``` + +## Output Requirements +- **Single File:** One self-contained `.html` file +- **No External Dependencies:** All CSS and SVG must be inline (except Google Fonts) +- **No JavaScript:** Use pure CSS for any animations (like pulsing dots) +- **Compatibility:** Must render correctly in any modern web browser + +## Template Reference + +Load the full HTML template for the exact structure, CSS, and SVG component examples: + +``` +skill_view(name="architecture-diagram", file_path="templates/template.html") +``` + +The template contains working examples of every component type (frontend, backend, database, cloud, security), arrow styles (standard, dashed, curved), security groups, region boundaries, and the legend — use it as your structural reference when generating diagrams. diff --git a/website/docs/user-guide/skills/bundled/creative/creative-claude-design.md b/website/docs/user-guide/skills/bundled/creative/creative-claude-design.md index 8fa3c563bbf..bf6f4eafaa3 100644 --- a/website/docs/user-guide/skills/bundled/creative/creative-claude-design.md +++ b/website/docs/user-guide/skills/bundled/creative/creative-claude-design.md @@ -21,7 +21,7 @@ Design one-off HTML artifacts (landing, deck, prototype). | License | MIT | | Platforms | linux, macos, windows | | Tags | `design`, `html`, `prototype`, `ux`, `ui`, `creative`, `artifact`, `deck`, `motion`, `design-system` | -| Related skills | [`html-artifact`](/docs/user-guide/skills/bundled/creative/creative-html-artifact), [`design-md`](/docs/user-guide/skills/bundled/creative/creative-design-md), [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw) | +| Related skills | [`design-md`](/docs/user-guide/skills/bundled/creative/creative-design-md), [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/docs/user-guide/skills/bundled/creative/creative-architecture-diagram) | ## Reference: full SKILL.md @@ -37,21 +37,19 @@ The goal is to preserve Claude Design's useful design behavior and taste while r **Before starting, check for other web-design skills like `popular-web-designs` (ready-to-paste design systems for Stripe, Linear, Vercel, Notion, etc.) and `design-md` (Google's DESIGN.md token spec format).** If the user wants a known brand's look, load `popular-web-designs` alongside this one and let it supply the visual vocabulary. If the deliverable is a token spec file rather than a rendered artifact, use `design-md` instead. Full decision table below. -## When To Use This Skill vs `html-artifact` vs `popular-web-designs` vs `design-md` +## When To Use This Skill vs `popular-web-designs` vs `design-md` -Several skills produce HTML — they do different jobs. Load the right one (or combine them): +Hermes has three design-related skills under `skills/creative/`. They do different jobs — load the right one (or combine them): | Skill | What it gives you | Use when the user wants... | |---|---|---| -| **claude-design** (this one) | Visual design *process and taste* — how to scope a brief, gather context, produce variants, verify a local HTML artifact, avoid AI-design slop | a from-scratch *designed* artifact (landing page, prototype, deck, component lab, motion study) where the look itself is the point and no specific brand or token system is dictated | -| **html-artifact** | A house style for *information* artifacts — explainers, plans, reports, code reviews, technical/educational diagrams, throwaway editors | to *explain / plan / report / diagram / review* something as a shareable HTML page — the content is the point, not bespoke visual design | +| **claude-design** (this one) | Design *process and taste* — how to scope a brief, gather context, produce variants, verify a local HTML artifact, avoid AI-design slop | a from-scratch designed artifact (landing page, prototype, deck, component lab, motion study) with no specific brand or token system dictated | | **popular-web-designs** | 54 ready-to-paste design systems — exact colors, typography, components, CSS values for sites like Stripe, Linear, Vercel, Notion, Airbnb | "make it look like Stripe / Linear / Vercel", a page styled after a known brand, or a visual starting point pulled from a real product | | **design-md** | Google's DESIGN.md spec format — author/validate/diff/export design-token files, WCAG contrast checking, Tailwind/DTCG export | a formal, persistent, machine-readable design-system *spec file* (tokens + rationale) that lives in a repo and gets consumed by agents over time | Rule of thumb: -- **Bespoke visual design, taste-driven artifact** → claude-design -- **Explain / plan / report / diagram as a shareable page** → html-artifact +- **Process + taste, one-off artifact** → claude-design - **Match a known brand's look** → popular-web-designs (and let claude-design drive the process) - **Author the tokens spec itself** → design-md diff --git a/website/docs/user-guide/skills/bundled/creative/creative-design-md.md b/website/docs/user-guide/skills/bundled/creative/creative-design-md.md index 687916eb2dc..a96723ddb7f 100644 --- a/website/docs/user-guide/skills/bundled/creative/creative-design-md.md +++ b/website/docs/user-guide/skills/bundled/creative/creative-design-md.md @@ -21,7 +21,7 @@ Author/validate/export Google's DESIGN.md token spec files. | License | MIT | | Platforms | linux, macos, windows | | Tags | `design`, `design-system`, `tokens`, `ui`, `accessibility`, `wcag`, `tailwind`, `dtcg`, `google` | -| Related skills | [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs), [`claude-design`](/docs/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`html-artifact`](/docs/user-guide/skills/bundled/creative/creative-html-artifact) | +| Related skills | [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs), [`claude-design`](/docs/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/docs/user-guide/skills/bundled/creative/creative-architecture-diagram) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/bundled/creative/creative-html-artifact.md b/website/docs/user-guide/skills/bundled/creative/creative-html-artifact.md deleted file mode 100644 index 0f34348ef2e..00000000000 --- a/website/docs/user-guide/skills/bundled/creative/creative-html-artifact.md +++ /dev/null @@ -1,202 +0,0 @@ ---- -title: "Html Artifact — Build self-contained HTML files to explain, plan, or review" -sidebar_label: "Html Artifact" -description: "Build self-contained HTML files to explain, plan, or review" ---- - -{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} - -# Html Artifact - -Build self-contained HTML files to explain, plan, or review. - -## Skill metadata - -| | | -|---|---| -| Source | Bundled (installed by default) | -| Path | `skills/creative/html-artifact` | -| Version | `1.0.0` | -| Author | Anthropic (html-effectiveness gallery, MIT), adapted for Hermes Agent | -| License | MIT | -| Platforms | linux, macos, windows | -| Tags | `html`, `artifact`, `explainer`, `plan`, `report`, `code-review`, `diagram`, `svg`, `design`, `prototype`, `editor` | -| Related skills | [`claude-design`](/docs/user-guide/skills/bundled/creative/creative-claude-design), [`popular-web-designs`](/docs/user-guide/skills/bundled/creative/creative-popular-web-designs), [`design-md`](/docs/user-guide/skills/bundled/creative/creative-design-md), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`p5js`](/docs/user-guide/skills/bundled/creative/creative-p5js) | - -## Reference: full SKILL.md - -:::info -The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. -::: - -# HTML Artifact Skill - -Produce a single self-contained `.html` file — no build step, no dependencies, no -CDN — whenever the deliverable is something a human should *read, share, or poke at*: -a concept explainer, an implementation plan, a status/incident report, a code-review -walkthrough, a technical or educational diagram, a set of design variants, or a -throwaway editor that exports its result back to you. - -HTML beats Markdown once a doc has color, layout, diagrams, tables, code, or -interaction. It opens in any browser, shares as a link, stays readable past 100 -lines, and can carry SVG diagrams and live controls Markdown can't. Default to an -HTML artifact when the user says "make an HTML file/artifact", or asks you to -*explain how X works*, *write up a plan/PR/report*, *diagram* something, *compare* -options, or *prototype* an interaction — even when they don't say "HTML". - -## Why this skill exists (and what it replaced) - -This skill **supersedes** three former skills — `sketch` (throwaway multi-variant -HTML mockups), `architecture-diagram` (dark-tech infra SVG), and `concept-diagrams` -(educational SVG). They were consolidated for a concrete reason: all three emitted -the *same artifact* — a single self-contained HTML file with inline CSS/SVG — and -overlapped heavily (three "diagram" skills, two "compare variants" paths, no shared -token system). Folding them into one mode-switched skill removes the -which-one-do-I-load ambiguity and gives every output the same house style, while -keeping each skill's unique value: the fidelity dial + verify loop (from `sketch`), -the dark infra aesthetic (from `architecture-diagram`), and the 9-ramp educational -system + archetype library (from `concept-diagrams`). - -The consolidation is footprint-safe: this skill has **zero dependencies** (no Node, -FFmpeg, Chromium, or pip packages — it authors plain HTML/CSS/SVG), so even though it -ships **bundled** (active by default) where `concept-diagrams` was optional, the only -always-in-context cost is this skill's one-line description. All references, -templates, and the example gallery load on demand. `concept-diagrams` was optional -because it was niche, not because it had an install cost — promoting that capability -into a general-purpose, zero-dep bundled skill is the right home for it. Diagram-style -work with a *real* install cost (e.g. `hyperframes`: Node + FFmpeg + Chromium) -deliberately stays optional and is **not** folded in here. - -Use a different skill when: matching a known brand's look → `popular-web-designs`; a -formal design-token spec file → `design-md`; a *bespoke visually-designed* artifact -where the look itself is the point → `claude-design`; hand-drawn/whiteboard -`.excalidraw` files → `excalidraw`; generative/animated canvas art → `p5js`. This -skill is for everything else that ships as a readable, shareable HTML page. - -## Reference files (load on demand) - -- `references/house-style.md` — the canonical `:root` token block, type system, - card/table/callout/code-block patterns. **Read this before authoring any artifact.** -- `references/examples.md` — 20 complete reference HTML files (Anthropic's - html-effectiveness gallery, MIT) keyed to each mode, plus the script to fetch them. - Read/fetch one that matches your task to calibrate the house style from a full example. -- `references/svg-diagrams.md` — hand-authored inline SVG: arrow markers, node - groups, decision diamonds, edge semantics, coordinate-grid discipline. Read for - any flowchart / architecture / concept diagram. -- `references/concept-archetypes.md` — the 9-ramp educational color system + a - library of diagram archetypes (timeline, tree, quadrant, layered stack, - before/after, hub-spoke, cross-section). Read for educational / non-software visuals. -- `references/dark-tech.md` — the dark "infra" token variant (carries the old - architecture-diagram aesthetic). Read for cloud/infra/system architecture diagrams. -- `references/throwaway-editors.md` — the single-file editor recipe and the - copy-to-clipboard export pattern that survives `file://`. Read when the artifact - needs interactive controls that export state back to a prompt. -- `references/fidelity-and-verify.md` — the throwaway↔presentation fidelity dial, - the multi-variant comparison layout, and the mandatory browser-vision verify loop. - -## Templates - -- `templates/base.html` — document scaffold with the house-style ` +``` + +### 4. Variant README + +Each variant's `README.md` answers: + +```markdown +## Variant: {stance name} + +### Design stance +One sentence on the principle driving this variant. + +### Key choices +- Layout: ... +- Typography: ... +- Color: ... +- Interaction: ... + +### Trade-offs +- Strong at: ... +- Weak at: ... + +### Best for +- The kind of user or use case this variant actually serves +``` + +### 5. Head-to-head + +After all variants are built, present them as a comparison. Don't just list — **opinionate**: + +```markdown +## Three takes on the home screen + +| Dimension | Calm editorial | Utilitarian dense | Playful split | +|-----------|----------------|-------------------|---------------| +| Density | Low | High | Medium | +| Primary action visibility | Low | High | Medium | +| Scan-ability | High | Medium | Low | +| Feel | Calm, trusted | Sharp, tool-like | Inviting, energetic | + +**My take:** Utilitarian dense for power users, calm editorial for content-forward audiences. Playful split is weakest — tries to do both and commits to neither. +``` + +Let the user pick a winner, or combine two into a hybrid, or ask for another round. + +## Theming (when the project has a visual identity) + +If the user has an existing theme (colors, fonts, tokens), put shared tokens in `sketches/themes/tokens.css` and `@import` them in each variant. Keep tokens minimal: + +```css +/* sketches/themes/tokens.css */ +:root { + --color-bg: #fafafa; + --color-fg: #1a1a1a; + --color-accent: #0066ff; + --color-muted: #666; + --radius: 8px; + --font-display: "Inter", sans-serif; + --font-body: -apple-system, BlinkMacSystemFont, sans-serif; +} +``` + +Don't over-tokenize a throwaway sketch — three colors and one font is usually enough. + +## Interactivity bar + +A sketch is interactive enough when the user can: + +1. **Click a primary action** and something visible happens (state change, modal, toast, navigation feint) +2. **See one meaningful state transition** (filter a list, toggle a mode, open/close a panel) +3. **Hover recognizable affordances** (buttons, rows, tabs) + +More than that is over-engineering a throwaway. Less than that is a screenshot. + +## Frontier mode (picking what to sketch next) + +If sketches already exist and the user says "what should I sketch next?": + +- **Consistency gaps** — two winning variants from different sketches made independent choices that haven't been composed together yet +- **Unsketched screens** — referenced but never explored +- **State coverage** — happy path sketched, but not empty / loading / error / 1000-items +- **Responsive gaps** — validated at one viewport; does it hold at mobile / ultrawide? +- **Interaction patterns** — static layouts exist; transitions, drag, scroll behavior don't + +Propose 2-4 named candidates. Let the user pick. + +## Output + +- Create `sketches/` (or `.planning/sketches/` if the user is using GSD conventions) in the repo root +- One subdir per variant: `NNN-stance-name/index.html` + `README.md` +- Tell the user how to open them: `open sketches/001-calm-editorial/index.html` on macOS, `xdg-open` on Linux, `start` on Windows +- Keep variants disposable — a sketch that you felt the need to preserve should be promoted into real project code, not curated as an asset + +**Typical tool sequence for one variant:** + +``` +terminal("mkdir -p sketches/001-calm-editorial") +write_file("sketches/001-calm-editorial/index.html", "...") +write_file("sketches/001-calm-editorial/README.md", "## Variant: Calm editorial\n...") +browser_navigate(url="file://$(pwd)/sketches/001-calm-editorial/index.html") +browser_vision(question="How does this look? Any obvious layout issues?") +``` + +Repeat for each variant, then present the comparison table. + +## Attribution + +Adapted from the GSD (Get Shit Done) project's `/gsd-sketch` workflow — MIT © 2025 Lex Christopherson ([gsd-build/get-shit-done](https://github.com/gsd-build/get-shit-done)). The full GSD system ships persistent sketch state, theme/variant pattern references, and consistency-audit workflows; install with `npx get-shit-done-cc --hermes --global`. diff --git a/website/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp.md b/website/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp.md index 9a14bceffd9..2577f1f741c 100644 --- a/website/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp.md +++ b/website/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp.md @@ -21,7 +21,7 @@ Control a running TouchDesigner instance via twozero MCP — create operators, s | License | MIT | | Platforms | linux, macos, windows | | Tags | `TouchDesigner`, `MCP`, `twozero`, `creative-coding`, `real-time-visuals`, `generative-art`, `audio-reactive`, `VJ`, `installation`, `GLSL` | -| Related skills | `native-mcp`, [`ascii-video`](/docs/user-guide/skills/bundled/creative/creative-ascii-video), [`manim-video`](/docs/user-guide/skills/bundled/creative/creative-manim-video), `hermes-video` | +| Related skills | [`native-mcp`](/docs/user-guide/skills/bundled/mcp/mcp-native-mcp), [`ascii-video`](/docs/user-guide/skills/bundled/creative/creative-ascii-video), [`manim-video`](/docs/user-guide/skills/bundled/creative/creative-manim-video), `hermes-video` | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/bundled/email/email-himalaya.md b/website/docs/user-guide/skills/bundled/email/email-himalaya.md index 34c868e9f26..adf3d973635 100644 --- a/website/docs/user-guide/skills/bundled/email/email-himalaya.md +++ b/website/docs/user-guide/skills/bundled/email/email-himalaya.md @@ -32,11 +32,6 @@ The following is the complete skill definition that Hermes loads when this skill Himalaya is a CLI email client that lets you manage emails from the terminal using IMAP, SMTP, Notmuch, or Sendmail backends. -This skill is separate from the Hermes Email gateway adapter. The gateway -adapter lets people email the agent and uses Hermes' built-in IMAP/SMTP -adapter; this skill lets the agent operate a mailbox from terminal tools and -requires the external `himalaya` CLI. - ## References - `references/configuration.md` (config file setup + IMAP/SMTP authentication) diff --git a/website/docs/user-guide/skills/bundled/github/github-github-auth.md b/website/docs/user-guide/skills/bundled/github/github-github-auth.md index 35e631fb237..92b9d9f6690 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-auth.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-auth.md @@ -238,8 +238,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then echo "AUTH_METHOD=gh" elif [ -n "$GITHUB_TOKEN" ]; then echo "AUTH_METHOD=curl" -elif _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then - export GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') +elif [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then + export GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') echo "AUTH_METHOD=curl" elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then export GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') diff --git a/website/docs/user-guide/skills/bundled/github/github-github-code-review.md b/website/docs/user-guide/skills/bundled/github/github-github-code-review.md index a7adc59e119..56e8fa97ad2 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-code-review.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-code-review.md @@ -46,8 +46,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then else AUTH="git" if [ -z "$GITHUB_TOKEN" ]; then - if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') + if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi diff --git a/website/docs/user-guide/skills/bundled/github/github-github-issues.md b/website/docs/user-guide/skills/bundled/github/github-github-issues.md index fa3dc52c7e2..6f99685d71a 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-issues.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-issues.md @@ -46,8 +46,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then else AUTH="git" if [ -z "$GITHUB_TOKEN" ]; then - if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') + if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi diff --git a/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md b/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md index a0221be3d73..48aa4ea9fff 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-pr-workflow.md @@ -48,8 +48,8 @@ else AUTH="git" # Ensure we have a token for API calls if [ -z "$GITHUB_TOKEN" ]; then - if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') + if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi diff --git a/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md b/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md index b87a7abdf37..0921e3dbccc 100644 --- a/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md +++ b/website/docs/user-guide/skills/bundled/github/github-github-repo-management.md @@ -45,8 +45,8 @@ if command -v gh &>/dev/null && gh auth status &>/dev/null; then else AUTH="git" if [ -z "$GITHUB_TOKEN" ]; then - if _hermes_env="${HERMES_HOME:-$HOME/.hermes}/.env"; [ -f "$_hermes_env" ] && grep -q "^GITHUB_TOKEN=" "$_hermes_env"; then - GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" "$_hermes_env" | head -1 | cut -d= -f2 | tr -d '\n\r') + if [ -f ~/.hermes/.env ] && grep -q "^GITHUB_TOKEN=" ~/.hermes/.env; then + GITHUB_TOKEN=$(grep "^GITHUB_TOKEN=" ~/.hermes/.env | head -1 | cut -d= -f2 | tr -d '\n\r') elif grep -q "github.com" ~/.git-credentials 2>/dev/null; then GITHUB_TOKEN=$(grep "github.com" ~/.git-credentials 2>/dev/null | head -1 | sed 's|https://[^:]*:\([^@]*\)@.*|\1|') fi diff --git a/website/docs/user-guide/skills/bundled/media/media-gif-search.md b/website/docs/user-guide/skills/bundled/media/media-gif-search.md index 31d0e03eb88..c26c5fd4a5e 100644 --- a/website/docs/user-guide/skills/bundled/media/media-gif-search.md +++ b/website/docs/user-guide/skills/bundled/media/media-gif-search.md @@ -38,7 +38,7 @@ Useful for finding reaction GIFs, creating visual content, and sending GIFs in c ## Setup -Set your Tenor API key in your environment (add to `${HERMES_HOME:-~/.hermes}/.env`): +Set your Tenor API key in your environment (add to `~/.hermes/.env`): ```bash TENOR_API_KEY=your_key_here diff --git a/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md b/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md index 49f317144d7..e8315c2fd4f 100644 --- a/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md +++ b/website/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian.md @@ -32,7 +32,7 @@ Use this skill for filesystem-first Obsidian vault work: reading notes, listing Use a known or resolved vault path before calling file tools. -The documented vault-path convention is the `OBSIDIAN_VAULT_PATH` environment variable, for example from `${HERMES_HOME:-~/.hermes}/.env`. If it is unset, use `~/Documents/Obsidian Vault`. +The documented vault-path convention is the `OBSIDIAN_VAULT_PATH` environment variable, for example from `~/.hermes/.env`. If it is unset, use `~/Documents/Obsidian Vault`. File tools do not expand shell variables. Do not pass paths containing `$OBSIDIAN_VAULT_PATH` to `read_file`, `write_file`, `patch`, or `search_files`; resolve the vault path first and pass a concrete absolute path. Vault paths may contain spaces, which is another reason to prefer file tools over shell commands. diff --git a/website/docs/user-guide/skills/bundled/productivity/productivity-airtable.md b/website/docs/user-guide/skills/bundled/productivity/productivity-airtable.md index 05a3e13fba0..bc4b4686433 100644 --- a/website/docs/user-guide/skills/bundled/productivity/productivity-airtable.md +++ b/website/docs/user-guide/skills/bundled/productivity/productivity-airtable.md @@ -40,7 +40,7 @@ Work with Airtable's REST API directly via `curl` using the `terminal` tool. No - `data.records:write` — create / update / delete rows - `schema.bases:read` — list bases and tables 3. **Important:** in the same token UI, add each base you want to access to the token's **Access** list. PATs are scoped per-base — a valid token on the wrong base returns `403`. -4. Store the token in `${HERMES_HOME:-~/.hermes}/.env` (or via `hermes setup`): +4. Store the token in `~/.hermes/.env` (or via `hermes setup`): ``` AIRTABLE_API_KEY=pat_your_token_here ``` @@ -236,7 +236,7 @@ done ## Important Notes for Hermes - **Always use the `terminal` tool with `curl`.** Do NOT use `web_extract` (it can't send auth headers) or `browser_navigate` (needs UI auth and is slow). -- **`AIRTABLE_API_KEY` flows from `${HERMES_HOME:-~/.hermes}/.env` into the subprocess automatically** when this skill is loaded — no need to re-export it before each `curl` call. +- **`AIRTABLE_API_KEY` flows from `~/.hermes/.env` into the subprocess automatically** when this skill is loaded — no need to re-export it before each `curl` call. - **Escape curly braces in formulas carefully.** In a heredoc body, `{Status}` is literal. In a shell argument, `{Status}` is safe outside `{...}` brace-expansion context — but pass dynamic strings through `python3 urllib.parse.quote` before splicing into a URL. - **Pretty-print with `python3 -m json.tool`** (always present) rather than `jq` (optional). Only reach for `jq` when you need filtering/projection. - **Pagination is per-page, not global.** Airtable's 100-record cap is a hard limit; there is no way to bump it. Loop with `offset` until the field is absent. diff --git a/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md b/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md index 985240ca41f..80487d6b88f 100644 --- a/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md +++ b/website/docs/user-guide/skills/bundled/productivity/productivity-notion.md @@ -41,7 +41,7 @@ Talk to Notion two ways. Same integration token works for both — pick by what' 1. Create an integration at https://notion.so/my-integrations 2. Copy the API key (starts with `ntn_` or `secret_`) -3. Store in `${HERMES_HOME:-~/.hermes}/.env`: +3. Store in `~/.hermes/.env`: ``` NOTION_API_KEY=ntn_your_key_here ``` @@ -65,7 +65,7 @@ export NOTION_API_TOKEN=$NOTION_API_KEY # ntn reads NOTION_API_TOKEN export NOTION_KEYRING=0 # don't try to use the OS keychain ``` -Add those exports to your shell profile (or to `${HERMES_HOME:-~/.hermes}/.env`) so every session inherits them. +Add those exports to your shell profile (or to `~/.hermes/.env`) so every session inherits them. ### 3. Choose path at runtime diff --git a/website/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline.md b/website/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline.md index 8fb4c066302..125021bc4cb 100644 --- a/website/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline.md +++ b/website/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline.md @@ -50,7 +50,7 @@ Multilingual trigger examples (not exhaustive): ## Prerequisites -Before using the pipeline, verify these are set in `${HERMES_HOME:-~/.hermes}/.env`: +Before using the pipeline, verify these are set in `~/.hermes/.env`: ```bash MSGRAPH_TENANT_ID=... diff --git a/website/docs/user-guide/skills/bundled/research/research-llm-wiki.md b/website/docs/user-guide/skills/bundled/research/research-llm-wiki.md index a6097a1a07c..419c7cd7cb2 100644 --- a/website/docs/user-guide/skills/bundled/research/research-llm-wiki.md +++ b/website/docs/user-guide/skills/bundled/research/research-llm-wiki.md @@ -52,7 +52,7 @@ Use this skill when the user: ## Wiki Location -**Location:** Set via `WIKI_PATH` environment variable (e.g. in `${HERMES_HOME:-~/.hermes}/.env`). +**Location:** Set via `WIKI_PATH` environment variable (e.g. in `~/.hermes/.env`). If unset, defaults to `~/wiki`. diff --git a/website/docs/user-guide/skills/bundled/research/research-research-paper-writing.md b/website/docs/user-guide/skills/bundled/research/research-research-paper-writing.md index 611215c06c3..9dc216ebac7 100644 --- a/website/docs/user-guide/skills/bundled/research/research-research-paper-writing.md +++ b/website/docs/user-guide/skills/bundled/research/research-research-paper-writing.md @@ -22,7 +22,7 @@ Write ML papers for NeurIPS/ICML/ICLR: design→submit. | Dependencies | `semanticscholar`, `arxiv`, `habanero`, `requests`, `scipy`, `numpy`, `matplotlib`, `SciencePlots` | | Platforms | linux, macos | | Tags | `Research`, `Paper Writing`, `Experiments`, `ML`, `AI`, `NeurIPS`, `ICML`, `ICLR`, `ACL`, `AAAI`, `COLM`, `LaTeX`, `Citations`, `Statistical Analysis` | -| Related skills | [`arxiv`](/docs/user-guide/skills/bundled/research/research-arxiv), `ml-paper-writing`, [`subagent-driven-development`](/docs/user-guide/skills/optional/software-development/software-development-subagent-driven-development), [`plan`](/docs/user-guide/skills/bundled/software-development/software-development-plan) | +| Related skills | [`arxiv`](/docs/user-guide/skills/bundled/research/research-arxiv), `ml-paper-writing`, [`subagent-driven-development`](/docs/user-guide/skills/bundled/software-development/software-development-subagent-driven-development), [`plan`](/docs/user-guide/skills/bundled/software-development/software-development-plan) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger.md b/website/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger.md index 5257512e9e6..deddf5dafdb 100644 --- a/website/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger.md +++ b/website/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger.md @@ -21,7 +21,7 @@ Debug Node.js via --inspect + Chrome DevTools Protocol CLI. | License | MIT | | Platforms | linux, macos, windows | | Tags | `debugging`, `nodejs`, `node-inspect`, `cdp`, `breakpoints`, `ui-tui` | -| Related skills | [`systematic-debugging`](/docs/user-guide/skills/bundled/software-development/software-development-systematic-debugging), [`python-debugpy`](/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy), `debugging-hermes-tui-commands` | +| Related skills | [`systematic-debugging`](/docs/user-guide/skills/bundled/software-development/software-development-systematic-debugging), [`python-debugpy`](/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy), [`debugging-hermes-tui-commands`](/docs/user-guide/skills/bundled/software-development/software-development-debugging-hermes-tui-commands) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md b/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md index dbc26409efe..0524b1f3ab9 100644 --- a/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md +++ b/website/docs/user-guide/skills/bundled/software-development/software-development-python-debugpy.md @@ -21,7 +21,7 @@ Debug Python: pdb REPL + debugpy remote (DAP). | License | MIT | | Platforms | linux, macos | | Tags | `debugging`, `python`, `pdb`, `debugpy`, `breakpoints`, `dap`, `post-mortem` | -| Related skills | [`systematic-debugging`](/docs/user-guide/skills/bundled/software-development/software-development-systematic-debugging), [`node-inspect-debugger`](/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger), `debugging-hermes-tui-commands` | +| Related skills | [`systematic-debugging`](/docs/user-guide/skills/bundled/software-development/software-development-systematic-debugging), [`node-inspect-debugger`](/docs/user-guide/skills/bundled/software-development/software-development-node-inspect-debugger), [`debugging-hermes-tui-commands`](/docs/user-guide/skills/bundled/software-development/software-development-debugging-hermes-tui-commands) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/bundled/software-development/software-development-spike.md b/website/docs/user-guide/skills/bundled/software-development/software-development-spike.md index 694cdcbf7af..56c0954b698 100644 --- a/website/docs/user-guide/skills/bundled/software-development/software-development-spike.md +++ b/website/docs/user-guide/skills/bundled/software-development/software-development-spike.md @@ -21,7 +21,7 @@ Throwaway experiments to validate an idea before build. | License | MIT | | Platforms | linux, macos, windows | | Tags | `spike`, `prototype`, `experiment`, `feasibility`, `throwaway`, `exploration`, `research`, `planning`, `mvp`, `proof-of-concept` | -| Related skills | [`html-artifact`](/docs/user-guide/skills/bundled/creative/creative-html-artifact), [`subagent-driven-development`](/docs/user-guide/skills/optional/software-development/software-development-subagent-driven-development), [`plan`](/docs/user-guide/skills/bundled/software-development/software-development-plan) | +| Related skills | [`sketch`](/docs/user-guide/skills/bundled/creative/creative-sketch), [`subagent-driven-development`](/docs/user-guide/skills/optional/software-development/software-development-subagent-driven-development), [`plan`](/docs/user-guide/skills/bundled/software-development/software-development-plan) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho.md b/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho.md index a54a2a0dea0..1b989116636 100644 --- a/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho.md +++ b/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho.md @@ -47,14 +47,14 @@ Honcho provides AI-native cross-session user modeling. It learns who the user is ### Cloud (app.honcho.dev) ```bash -hermes memory setup honcho +hermes honcho setup # select "cloud", paste API key from https://app.honcho.dev ``` ### Self-hosted ```bash -hermes memory setup honcho +hermes honcho setup # select "local", enter base URL (e.g. http://localhost:8000) ``` diff --git a/website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md b/website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md index 177dfe36a10..8651bc979f6 100644 --- a/website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md +++ b/website/docs/user-guide/skills/optional/blockchain/blockchain-hyperliquid.md @@ -53,7 +53,7 @@ Read-only — no API key, no signing, no order placement. Stdlib only — no external packages, no API key. -The script reads `${HERMES_HOME:-~/.hermes}/.env` for two optional defaults: +The script reads `~/.hermes/.env` for two optional defaults: - `HYPERLIQUID_API_URL` — defaults to `https://api.hyperliquid.xyz`. Set to `https://api.hyperliquid-testnet.xyz` for testnet. @@ -97,7 +97,7 @@ hyperliquid_client.py export [--interval 1h] [--hours N] [--output PATH] ``` For `state`, `spot-balances`, `fills`, `orders`, and `review`, the address is -optional when `HYPERLIQUID_USER_ADDRESS` is set in `${HERMES_HOME:-~/.hermes}/.env`. +optional when `HYPERLIQUID_USER_ADDRESS` is set in `~/.hermes/.env`. --- diff --git a/website/docs/user-guide/skills/optional/creative/creative-concept-diagrams.md b/website/docs/user-guide/skills/optional/creative/creative-concept-diagrams.md new file mode 100644 index 00000000000..9b3ba92b3bd --- /dev/null +++ b/website/docs/user-guide/skills/optional/creative/creative-concept-diagrams.md @@ -0,0 +1,379 @@ +--- +title: "Concept Diagrams" +sidebar_label: "Concept Diagrams" +description: "Generate flat, minimal light/dark-aware SVG diagrams as standalone HTML files, using a unified educational visual language with 9 semantic color ramps, sente..." +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Concept Diagrams + +Generate flat, minimal light/dark-aware SVG diagrams as standalone HTML files, using a unified educational visual language with 9 semantic color ramps, sentence-case typography, and automatic dark mode. Best suited for educational and non-software visuals — physics setups, chemistry mechanisms, math curves, physical objects (aircraft, turbines, smartphones, mechanical watches), anatomy, floor plans, cross-sections, narrative journeys (lifecycle of X, process of Y), hub-spoke system integrations (smart city, IoT), and exploded layer views. If a more specialized skill exists for the subject (dedicated software/cloud architecture, hand-drawn sketches, animated explainers, etc.), prefer that — otherwise this skill can also serve as a general-purpose SVG diagram fallback with a clean educational look. Ships with 15 example diagrams. + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/creative/concept-diagrams` | +| Path | `optional-skills/creative/concept-diagrams` | +| Version | `0.1.0` | +| Author | v1k22 (original PR), ported into hermes-agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `diagrams`, `svg`, `visualization`, `education`, `physics`, `chemistry`, `engineering` | +| Related skills | [`architecture-diagram`](/docs/user-guide/skills/bundled/creative/creative-architecture-diagram), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), `generative-widgets` | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# Concept Diagrams + +Generate production-quality SVG diagrams with a unified flat, minimal design system. Output is a single self-contained HTML file that renders identically in any modern browser, with automatic light/dark mode. + +## Scope + +**Best suited for:** +- Physics setups, chemistry mechanisms, math curves, biology +- Physical objects (aircraft, turbines, smartphones, mechanical watches, cells) +- Anatomy, cross-sections, exploded layer views +- Floor plans, architectural conversions +- Narrative journeys (lifecycle of X, process of Y) +- Hub-spoke system integrations (smart city, IoT networks, electricity grids) +- Educational / textbook-style visuals in any domain +- Quantitative charts (grouped bars, energy profiles) + +**Look elsewhere first for:** +- Dedicated software / cloud infrastructure architecture with a dark tech aesthetic (consider `architecture-diagram` if available) +- Hand-drawn whiteboard sketches (consider `excalidraw` if available) +- Animated explainers or video output (consider an animation skill) + +If a more specialized skill is available for the subject, prefer that. If none fits, this skill can serve as a general-purpose SVG diagram fallback — the output will carry the clean educational aesthetic described below, which is a reasonable default for almost any subject. + +## Workflow + +1. Decide on the diagram type (see Diagram Types below). +2. Lay out components using the Design System rules. +3. Write the full HTML page using `templates/template.html` as the wrapper — paste your SVG where the template says ``. +4. Save as a standalone `.html` file (for example `~/my-diagram.html` or `./my-diagram.html`). +5. User opens it directly in a browser — no server, no dependencies. + +Optional: if the user wants a browsable gallery of multiple diagrams, see "Local Preview Server" at the bottom. + +Load the HTML template: +``` +skill_view(name="concept-diagrams", file_path="templates/template.html") +``` + +The template embeds the full CSS design system (`c-*` color classes, text classes, light/dark variables, arrow marker styles). The SVG you generate relies on these classes being present on the hosting page. + +--- + +## Design System + +### Philosophy + +- **Flat**: no gradients, drop shadows, blur, glow, or neon effects. +- **Minimal**: show the essential. No decorative icons inside boxes. +- **Consistent**: same colors, spacing, typography, and stroke widths across every diagram. +- **Dark-mode ready**: all colors auto-adapt via CSS classes — no per-mode SVG. + +### Color Palette + +9 color ramps, each with 7 stops. Put the class name on a `` or shape element; the template CSS handles both modes. + +| Class | 50 (lightest) | 100 | 200 | 400 | 600 | 800 | 900 (darkest) | +|------------|---------------|---------|---------|---------|---------|---------|---------------| +| `c-purple` | #EEEDFE | #CECBF6 | #AFA9EC | #7F77DD | #534AB7 | #3C3489 | #26215C | +| `c-teal` | #E1F5EE | #9FE1CB | #5DCAA5 | #1D9E75 | #0F6E56 | #085041 | #04342C | +| `c-coral` | #FAECE7 | #F5C4B3 | #F0997B | #D85A30 | #993C1D | #712B13 | #4A1B0C | +| `c-pink` | #FBEAF0 | #F4C0D1 | #ED93B1 | #D4537E | #993556 | #72243E | #4B1528 | +| `c-gray` | #F1EFE8 | #D3D1C7 | #B4B2A9 | #888780 | #5F5E5A | #444441 | #2C2C2A | +| `c-blue` | #E6F1FB | #B5D4F4 | #85B7EB | #378ADD | #185FA5 | #0C447C | #042C53 | +| `c-green` | #EAF3DE | #C0DD97 | #97C459 | #639922 | #3B6D11 | #27500A | #173404 | +| `c-amber` | #FAEEDA | #FAC775 | #EF9F27 | #BA7517 | #854F0B | #633806 | #412402 | +| `c-red` | #FCEBEB | #F7C1C1 | #F09595 | #E24B4A | #A32D2D | #791F1F | #501313 | + +#### Color Assignment Rules + +Color encodes **meaning**, not sequence. Never cycle through colors like a rainbow. + +- Group nodes by **category** — all nodes of the same type share one color. +- Use `c-gray` for neutral/structural nodes (start, end, generic steps, users). +- Use **2-3 colors per diagram**, not 6+. +- Prefer `c-purple`, `c-teal`, `c-coral`, `c-pink` for general categories. +- Reserve `c-blue`, `c-green`, `c-amber`, `c-red` for semantic meaning (info, success, warning, error). + +Light/dark stop mapping (handled by the template CSS — just use the class): +- Light mode: 50 fill + 600 stroke + 800 title / 600 subtitle +- Dark mode: 800 fill + 200 stroke + 100 title / 200 subtitle + +### Typography + +Only two font sizes. No exceptions. + +| Class | Size | Weight | Use | +|-------|------|--------|-----| +| `th` | 14px | 500 | Node titles, region labels | +| `ts` | 12px | 400 | Subtitles, descriptions, arrow labels | +| `t` | 14px | 400 | General text | + +- **Sentence case always.** Never Title Case, never ALL CAPS. +- Every `` MUST carry a class (`t`, `ts`, or `th`). No unclassed text. +- `dominant-baseline="central"` on all text inside boxes. +- `text-anchor="middle"` for centered text in boxes. + +**Width estimation (approx):** +- 14px weight 500: ~8px per character +- 12px weight 400: ~6.5px per character +- Always verify: `box_width >= (char_count × px_per_char) + 48` (24px padding each side) + +### Spacing & Layout + +- **ViewBox**: `viewBox="0 0 680 H"` where H = content height + 40px buffer. +- **Safe area**: x=40 to x=640, y=40 to y=(H-40). +- **Between boxes**: 60px minimum gap. +- **Inside boxes**: 24px horizontal padding, 12px vertical padding. +- **Arrowhead gap**: 10px between arrowhead and box edge. +- **Single-line box**: 44px height. +- **Two-line box**: 56px height, 18px between title and subtitle baselines. +- **Container padding**: 20px minimum inside every container. +- **Max nesting**: 2-3 levels deep. Deeper gets unreadable at 680px width. + +### Stroke & Shape + +- **Stroke width**: 0.5px on all node borders. Not 1px, not 2px. +- **Rect rounding**: `rx="8"` for nodes, `rx="12"` for inner containers, `rx="16"` to `rx="20"` for outer containers. +- **Connector paths**: MUST have `fill="none"`. SVG defaults to `fill: black` otherwise. + +### Arrow Marker + +Include this `` block at the start of **every** SVG: + +```xml + + + + + +``` + +Use `marker-end="url(#arrow)"` on lines. The arrowhead inherits the line color via `context-stroke`. + +### CSS Classes (Provided by the Template) + +The template page provides: + +- Text: `.t`, `.ts`, `.th` +- Neutral: `.box`, `.arr`, `.leader`, `.node` +- Color ramps: `.c-purple`, `.c-teal`, `.c-coral`, `.c-pink`, `.c-gray`, `.c-blue`, `.c-green`, `.c-amber`, `.c-red` (all with automatic light/dark mode) + +You do **not** need to redefine these — just apply them in your SVG. The template file contains the full CSS definitions. + +--- + +## SVG Boilerplate + +Every SVG inside the template page starts with this exact structure: + +```xml + + + + + + + + + + +``` + +Replace `{HEIGHT}` with the actual computed height (last element bottom + 40px). + +### Node Patterns + +**Single-line node (44px):** +```xml + + + Service name + +``` + +**Two-line node (56px):** +```xml + + + Service name + Short description + +``` + +**Connector (no label):** +```xml + +``` + +**Container (dashed or solid):** +```xml + + + Container label + Subtitle info + +``` + +--- + +## Diagram Types + +Choose the layout that fits the subject: + +1. **Flowchart** — CI/CD pipelines, request lifecycles, approval workflows, data processing. Single-direction flow (top-down or left-right). Max 4-5 nodes per row. +2. **Structural / Containment** — Cloud infrastructure nesting, system architecture with layers. Large outer containers with inner regions. Dashed rects for logical groupings. +3. **API / Endpoint Map** — REST routes, GraphQL schemas. Tree from root, branching to resource groups, each containing endpoint nodes. +4. **Microservice Topology** — Service mesh, event-driven systems. Services as nodes, arrows for communication patterns, message queues between. +5. **Data Flow** — ETL pipelines, streaming architectures. Left-to-right flow from sources through processing to sinks. +6. **Physical / Structural** — Vehicles, buildings, hardware, anatomy. Use shapes that match the physical form — `` for curved bodies, `` for tapered shapes, ``/`` for cylindrical parts, nested `` for compartments. See `references/physical-shape-cookbook.md`. +7. **Infrastructure / Systems Integration** — Smart cities, IoT networks, multi-domain systems. Hub-spoke layout with central platform connecting subsystems. Semantic line styles (`.data-line`, `.power-line`, `.water-pipe`, `.road`). See `references/infrastructure-patterns.md`. +8. **UI / Dashboard Mockups** — Admin panels, monitoring dashboards. Screen frame with nested chart/gauge/indicator elements. See `references/dashboard-patterns.md`. + +For physical, infrastructure, and dashboard diagrams, load the matching reference file before generating — each one provides ready-made CSS classes and shape primitives. + +--- + +## Validation Checklist + +Before finalizing any SVG, verify ALL of the following: + +1. Every `` has class `t`, `ts`, or `th`. +2. Every `` inside a box has `dominant-baseline="central"`. +3. Every connector `` or `` used as arrow has `fill="none"`. +4. No arrow line crosses through an unrelated box. +5. `box_width >= (longest_label_chars × 8) + 48` for 14px text. +6. `box_width >= (longest_label_chars × 6.5) + 48` for 12px text. +7. ViewBox height = bottom-most element + 40px. +8. All content stays within x=40 to x=640. +9. Color classes (`c-*`) are on `` or shape elements, never on `` connectors. +10. Arrow `` block is present. +11. No gradients, shadows, blur, or glow effects. +12. Stroke width is 0.5px on all node borders. + +--- + +## Output & Preview + +### Default: standalone HTML file + +Write a single `.html` file the user can open directly. No server, no dependencies, works offline. Pattern: + +```python +# 1. Load the template +template = skill_view("concept-diagrams", "templates/template.html") + +# 2. Fill in title, subtitle, and paste your SVG +html = template.replace( + "", "SN2 reaction mechanism" +).replace( + "", "Bimolecular nucleophilic substitution" +).replace( + "", svg_content +) + +# 3. Write to a user-chosen path (or ./ by default) +write_file("./sn2-mechanism.html", html) +``` + +Tell the user how to open it: + +``` +# macOS +open ./sn2-mechanism.html +# Linux +xdg-open ./sn2-mechanism.html +``` + +### Optional: local preview server (multi-diagram gallery) + +Only use this when the user explicitly wants a browsable gallery of multiple diagrams. + +**Rules:** +- Bind to `127.0.0.1` only. Never `0.0.0.0`. Exposing diagrams on all network interfaces is a security hazard on shared networks. +- Pick a free port (do NOT hard-code one) and tell the user the chosen URL. +- The server is optional and opt-in — prefer the standalone HTML file first. + +Recommended pattern (lets the OS pick a free ephemeral port): + +```bash +# Put each diagram in its own folder under .diagrams/ +mkdir -p .diagrams/sn2-mechanism +# ...write .diagrams/sn2-mechanism/index.html... + +# Serve on loopback only, free port +cd .diagrams && python3 -c " +import http.server, socketserver +with socketserver.TCPServer(('127.0.0.1', 0), http.server.SimpleHTTPRequestHandler) as s: + print(f'Serving at http://127.0.0.1:{s.server_address[1]}/') + s.serve_forever() +" & +``` + +If the user insists on a fixed port, use `127.0.0.1:` — still never `0.0.0.0`. Document how to stop the server (`kill %1` or `pkill -f "http.server"`). + +--- + +## Examples Reference + +The `examples/` directory ships 15 complete, tested diagrams. Browse them for working patterns before writing a new diagram of a similar type: + +| File | Type | Demonstrates | +|------|------|--------------| +| `hospital-emergency-department-flow.md` | Flowchart | Priority routing with semantic colors | +| `feature-film-production-pipeline.md` | Flowchart | Phased workflow, horizontal sub-flows | +| `automated-password-reset-flow.md` | Flowchart | Auth flow with error branches | +| `autonomous-llm-research-agent-flow.md` | Flowchart | Loop-back arrows, decision branches | +| `place-order-uml-sequence.md` | Sequence | UML sequence diagram style | +| `commercial-aircraft-structure.md` | Physical | Paths, polygons, ellipses for realistic shapes | +| `wind-turbine-structure.md` | Physical cross-section | Underground/above-ground separation, color coding | +| `smartphone-layer-anatomy.md` | Exploded view | Alternating left/right labels, layered components | +| `apartment-floor-plan-conversion.md` | Floor plan | Walls, doors, proposed changes in dotted red | +| `banana-journey-tree-to-smoothie.md` | Narrative journey | Winding path, progressive state changes | +| `cpu-ooo-microarchitecture.md` | Hardware pipeline | Fan-out, memory hierarchy sidebar | +| `sn2-reaction-mechanism.md` | Chemistry | Molecules, curved arrows, energy profile | +| `smart-city-infrastructure.md` | Hub-spoke | Semantic line styles per system | +| `electricity-grid-flow.md` | Multi-stage flow | Voltage hierarchy, flow markers | +| `ml-benchmark-grouped-bar-chart.md` | Chart | Grouped bars, dual axis | + +Load any example with: +``` +skill_view(name="concept-diagrams", file_path="examples/") +``` + +--- + +## Quick Reference: What to Use When + +| User says | Diagram type | Suggested colors | +|-----------|--------------|------------------| +| "show the pipeline" | Flowchart | gray start/end, purple steps, red errors, teal deploy | +| "draw the data flow" | Data pipeline (left-right) | gray sources, purple processing, teal sinks | +| "visualize the system" | Structural (containment) | purple container, teal services, coral data | +| "map the endpoints" | API tree | purple root, one ramp per resource group | +| "show the services" | Microservice topology | gray ingress, teal services, purple bus, coral workers | +| "draw the aircraft/vehicle" | Physical | paths, polygons, ellipses for realistic shapes | +| "smart city / IoT" | Hub-spoke integration | semantic line styles per subsystem | +| "show the dashboard" | UI mockup | dark screen, chart colors: teal, purple, coral for alerts | +| "power grid / electricity" | Multi-stage flow | voltage hierarchy (HV/MV/LV line weights) | +| "wind turbine / turbine" | Physical cross-section | foundation + tower cutaway + nacelle color-coded | +| "journey of X / lifecycle" | Narrative journey | winding path, progressive state changes | +| "layers of X / exploded" | Exploded layer view | vertical stack, alternating labels | +| "CPU / pipeline" | Hardware pipeline | vertical stages, fan-out to execution ports | +| "floor plan / apartment" | Floor plan | walls, doors, proposed changes in dotted red | +| "reaction mechanism" | Chemistry | atoms, bonds, curved arrows, transition state, energy profile | diff --git a/website/docs/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md b/website/docs/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md index a148ba6d2d6..8fa3cdf127f 100644 --- a/website/docs/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md +++ b/website/docs/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md @@ -21,7 +21,7 @@ Plan, set up, and monitor a multi-agent video production pipeline backed by Herm | License | MIT | | Platforms | linux, macos, windows | | Tags | `video`, `kanban`, `multi-agent`, `orchestration`, `production-pipeline` | -| Related skills | [`kanban-orchestrator`](/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator), [`kanban-worker`](/docs/user-guide/skills/bundled/devops/devops-kanban-worker), [`ascii-video`](/docs/user-guide/skills/bundled/creative/creative-ascii-video), [`manim-video`](/docs/user-guide/skills/bundled/creative/creative-manim-video), [`p5js`](/docs/user-guide/skills/bundled/creative/creative-p5js), [`comfyui`](/docs/user-guide/skills/bundled/creative/creative-comfyui), [`touchdesigner-mcp`](/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp), [`blender-mcp`](/docs/user-guide/skills/optional/creative/creative-blender-mcp), [`pixel-art`](/docs/user-guide/skills/optional/creative/creative-pixel-art), [`ascii-art`](/docs/user-guide/skills/bundled/creative/creative-ascii-art), [`songwriting-and-ai-music`](/docs/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music), [`heartmula`](/docs/user-guide/skills/bundled/media/media-heartmula), [`songsee`](/docs/user-guide/skills/bundled/media/media-songsee), `spotify`, [`youtube-content`](/docs/user-guide/skills/bundled/media/media-youtube-content), [`claude-design`](/docs/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`html-artifact`](/docs/user-guide/skills/bundled/creative/creative-html-artifact), [`baoyu-comic`](/docs/user-guide/skills/optional/creative/creative-baoyu-comic), [`baoyu-infographic`](/docs/user-guide/skills/bundled/creative/creative-baoyu-infographic), [`humanizer`](/docs/user-guide/skills/bundled/creative/creative-humanizer), [`gif-search`](/docs/user-guide/skills/bundled/media/media-gif-search), [`meme-generation`](/docs/user-guide/skills/optional/creative/creative-meme-generation) | +| Related skills | [`kanban-orchestrator`](/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator), [`kanban-worker`](/docs/user-guide/skills/bundled/devops/devops-kanban-worker), [`ascii-video`](/docs/user-guide/skills/bundled/creative/creative-ascii-video), [`manim-video`](/docs/user-guide/skills/bundled/creative/creative-manim-video), [`p5js`](/docs/user-guide/skills/bundled/creative/creative-p5js), [`comfyui`](/docs/user-guide/skills/bundled/creative/creative-comfyui), [`touchdesigner-mcp`](/docs/user-guide/skills/bundled/creative/creative-touchdesigner-mcp), [`blender-mcp`](/docs/user-guide/skills/optional/creative/creative-blender-mcp), [`pixel-art`](/docs/user-guide/skills/bundled/creative/creative-pixel-art), [`ascii-art`](/docs/user-guide/skills/bundled/creative/creative-ascii-art), [`songwriting-and-ai-music`](/docs/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music), [`heartmula`](/docs/user-guide/skills/bundled/media/media-heartmula), [`songsee`](/docs/user-guide/skills/bundled/media/media-songsee), [`spotify`](/docs/user-guide/skills/bundled/media/media-spotify), [`youtube-content`](/docs/user-guide/skills/bundled/media/media-youtube-content), [`claude-design`](/docs/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/docs/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/docs/user-guide/skills/bundled/creative/creative-architecture-diagram), [`concept-diagrams`](/docs/user-guide/skills/optional/creative/creative-concept-diagrams), [`baoyu-comic`](/docs/user-guide/skills/bundled/creative/creative-baoyu-comic), [`baoyu-infographic`](/docs/user-guide/skills/bundled/creative/creative-baoyu-infographic), [`humanizer`](/docs/user-guide/skills/bundled/creative/creative-humanizer), [`gif-search`](/docs/user-guide/skills/bundled/media/media-gif-search), [`meme-generation`](/docs/user-guide/skills/optional/creative/creative-meme-generation) | ## Reference: full SKILL.md @@ -194,7 +194,7 @@ task graphs. See **[references/examples.md](https://github.com/NousResearch/herm right human-review gates. 8. **Verify API keys BEFORE firing.** External APIs (TTS, image-gen, - image-to-video) need keys in `${HERMES_HOME:-~/.hermes}/.env` or the user's secret store. + image-to-video) need keys in `~/.hermes/.env` or the user's secret store. A worker that hits a missing-key error wastes a task slot. The setup script's `check_key` helper aborts cleanly if a required key is missing. diff --git a/website/docs/user-guide/skills/optional/devops/devops-pinggy-tunnel.md b/website/docs/user-guide/skills/optional/devops/devops-pinggy-tunnel.md index 18fb572bdcb..19f431f1967 100644 --- a/website/docs/user-guide/skills/optional/devops/devops-pinggy-tunnel.md +++ b/website/docs/user-guide/skills/optional/devops/devops-pinggy-tunnel.md @@ -21,7 +21,7 @@ Zero-install localhost tunnels over SSH via Pinggy. | License | MIT | | Platforms | linux, macos, windows | | Tags | `Pinggy`, `Tunnel`, `Networking`, `SSH`, `Webhook`, `Localhost` | -| Related skills | `cloudflared-quick-tunnel`, `webhook-subscriptions` | +| Related skills | `cloudflared-quick-tunnel`, [`webhook-subscriptions`](/docs/user-guide/skills/bundled/devops/devops-webhook-subscriptions) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/optional/devops/devops-watchers.md b/website/docs/user-guide/skills/optional/devops/devops-watchers.md index 9d2fc7f7523..8a56162bdb8 100644 --- a/website/docs/user-guide/skills/optional/devops/devops-watchers.md +++ b/website/docs/user-guide/skills/optional/devops/devops-watchers.md @@ -77,7 +77,7 @@ python $HERMES_HOME/skills/devops/watchers/scripts/watch_rss.py \ --name hn --url https://news.ycombinator.com/rss --max 5 ``` -Watch a GitHub repo (set `GITHUB_TOKEN` in `${HERMES_HOME:-~/.hermes}/.env` to avoid the 60 req/hr anonymous rate limit): +Watch a GitHub repo (set `GITHUB_TOKEN` in `~/.hermes/.env` to avoid the 60 req/hr anonymous rate limit): ```bash python $HERMES_HOME/skills/devops/watchers/scripts/watch_github.py \ diff --git a/website/docs/user-guide/skills/optional/mcp/mcp-fastmcp.md b/website/docs/user-guide/skills/optional/mcp/mcp-fastmcp.md index 3efe47b12b8..2defe89d4eb 100644 --- a/website/docs/user-guide/skills/optional/mcp/mcp-fastmcp.md +++ b/website/docs/user-guide/skills/optional/mcp/mcp-fastmcp.md @@ -21,7 +21,7 @@ Build, test, inspect, install, and deploy MCP servers with FastMCP in Python. Us | License | MIT | | Platforms | linux, macos, windows | | Tags | `MCP`, `FastMCP`, `Python`, `Tools`, `Resources`, `Prompts`, `Deployment` | -| Related skills | `native-mcp`, [`mcporter`](/docs/user-guide/skills/optional/mcp/mcp-mcporter) | +| Related skills | [`native-mcp`](/docs/user-guide/skills/bundled/mcp/mcp-native-mcp), [`mcporter`](/docs/user-guide/skills/optional/mcp/mcp-mcporter) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md b/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md index fcd20673edd..74e60876bf5 100644 --- a/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md +++ b/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md @@ -44,7 +44,7 @@ Trigger phrases: - "manage my stack credentials", "rotate this key", "upgrade my plan" - "what providers can I add?" -If the user already has a provider account, this skill can still connect it with `stripe projects link `. If the user wants to use an existing provider resource, such as an existing database or Vercel project, check provider support first; many providers currently support provisioning new resources but not importing existing ones. +If the user already has a provider account, this skill can still connect it with `stripe projects link <provider>`. If the user wants to use an existing provider resource, such as an existing database or Vercel project, check provider support first; many providers currently support provisioning new resources but not importing existing ones. ## Prerequisites diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-canvas.md b/website/docs/user-guide/skills/optional/productivity/productivity-canvas.md index 11bbf7e2006..e94a81b0407 100644 --- a/website/docs/user-guide/skills/optional/productivity/productivity-canvas.md +++ b/website/docs/user-guide/skills/optional/productivity/productivity-canvas.md @@ -42,7 +42,7 @@ Read-only access to Canvas LMS for listing courses and assignments. 2. Go to **Account → Settings** (click your profile icon, then Settings) 3. Scroll to **Approved Integrations** and click **+ New Access Token** 4. Name the token (e.g., "Hermes Agent"), set an optional expiry, and click **Generate Token** -5. Copy the token and add to `${HERMES_HOME:-~/.hermes}/.env`: +5. Copy the token and add to `~/.hermes/.env`: ``` CANVAS_API_TOKEN=your_token_here diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-shopify.md b/website/docs/user-guide/skills/optional/productivity/productivity-shopify.md index 97d4116d82d..61bc95cfa66 100644 --- a/website/docs/user-guide/skills/optional/productivity/productivity-shopify.md +++ b/website/docs/user-guide/skills/optional/productivity/productivity-shopify.md @@ -40,7 +40,7 @@ The REST Admin API is legacy since 2024-04 and only receives security fixes. **U 1. In Shopify admin: **Settings → Apps and sales channels → Develop apps → Create an app**. 2. Click **Configure Admin API scopes**, select what you need (examples below), save. 3. **Install app** → the Admin API access token appears ONCE. Copy it immediately — Shopify will never show it again. Tokens start with `shpat_`. -4. Save to `${HERMES_HOME:-~/.hermes}/.env`: +4. Save to `~/.hermes/.env`: ``` SHOPIFY_ACCESS_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxx SHOPIFY_STORE_DOMAIN=my-store.myshopify.com diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-siyuan.md b/website/docs/user-guide/skills/optional/productivity/productivity-siyuan.md index 777ee265d11..58263053fdd 100644 --- a/website/docs/user-guide/skills/optional/productivity/productivity-siyuan.md +++ b/website/docs/user-guide/skills/optional/productivity/productivity-siyuan.md @@ -37,7 +37,7 @@ Use the [SiYuan](https://github.com/siyuan-note/siyuan) kernel API via curl to s 1. Install and run SiYuan (desktop or Docker) 2. Get your API token: **Settings > About > API token** -3. Store it in `${HERMES_HOME:-~/.hermes}/.env`: +3. Store it in `~/.hermes/.env`: ``` SIYUAN_TOKEN=your_token_here SIYUAN_URL=http://127.0.0.1:6806 diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-telephony.md b/website/docs/user-guide/skills/optional/productivity/productivity-telephony.md index 03d08bdc399..f6c15444cbb 100644 --- a/website/docs/user-guide/skills/optional/productivity/productivity-telephony.md +++ b/website/docs/user-guide/skills/optional/productivity/productivity-telephony.md @@ -34,7 +34,7 @@ The following is the complete skill definition that Hermes loads when this skill This optional skill gives Hermes practical phone capabilities while keeping telephony out of the core tool list. It ships with a helper script, `scripts/telephony.py`, that can: -- save provider credentials into `${HERMES_HOME:-~/.hermes}/.env` +- save provider credentials into `~/.hermes/.env` - search for and buy a Twilio phone number - remember that owned number for later sessions - send SMS / MMS from the owned number @@ -121,7 +121,7 @@ Why: The skill persists telephony state in two places: -### `${HERMES_HOME:-~/.hermes}/.env` +### `~/.hermes/.env` Used for long-lived provider credentials and owned-number IDs, for example: - `TWILIO_ACCOUNT_SID` - `TWILIO_AUTH_TOKEN` @@ -258,7 +258,7 @@ python3 "$SCRIPT" save-twilio AC... auth_token_here python3 "$SCRIPT" twilio-search --country US --area-code 702 --limit 10 ``` -3. Buy it and save it into `${HERMES_HOME:-~/.hermes}/.env` + state: +3. Buy it and save it into `~/.hermes/.env` + state: ```bash python3 "$SCRIPT" twilio-buy "+17025551234" --save-env ``` @@ -420,7 +420,7 @@ After setup, you should be able to do all of the following with just this skill: 1. `diagnose` shows provider readiness and remembered state 2. search and buy a Twilio number -3. persist that number to `${HERMES_HOME:-~/.hermes}/.env` +3. persist that number to `~/.hermes/.env` 4. send an SMS from the owned number 5. poll inbound texts for the owned number later 6. place a direct Twilio call diff --git a/website/docs/user-guide/skills/optional/research/research-gitnexus-explorer.md b/website/docs/user-guide/skills/optional/research/research-gitnexus-explorer.md index a5f062dc373..5b1f62458d1 100644 --- a/website/docs/user-guide/skills/optional/research/research-gitnexus-explorer.md +++ b/website/docs/user-guide/skills/optional/research/research-gitnexus-explorer.md @@ -21,7 +21,7 @@ Index a codebase with GitNexus and serve an interactive knowledge graph via web | License | MIT | | Platforms | linux, macos, windows | | Tags | `gitnexus`, `code-intelligence`, `knowledge-graph`, `visualization` | -| Related skills | `native-mcp`, [`codebase-inspection`](/docs/user-guide/skills/bundled/github/github-codebase-inspection) | +| Related skills | [`native-mcp`](/docs/user-guide/skills/bundled/mcp/mcp-native-mcp), [`codebase-inspection`](/docs/user-guide/skills/bundled/github/github-codebase-inspection) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/optional/research/research-qmd.md b/website/docs/user-guide/skills/optional/research/research-qmd.md index 8d145080b45..47cf81634b8 100644 --- a/website/docs/user-guide/skills/optional/research/research-qmd.md +++ b/website/docs/user-guide/skills/optional/research/research-qmd.md @@ -21,7 +21,7 @@ Search personal knowledge bases, notes, docs, and meeting transcripts locally us | License | MIT | | Platforms | macos, linux | | Tags | `Search`, `Knowledge-Base`, `RAG`, `Notes`, `MCP`, `Local-AI` | -| Related skills | [`obsidian`](/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian), `native-mcp`, [`arxiv`](/docs/user-guide/skills/bundled/research/research-arxiv) | +| Related skills | [`obsidian`](/docs/user-guide/skills/bundled/note-taking/note-taking-obsidian), [`native-mcp`](/docs/user-guide/skills/bundled/mcp/mcp-native-mcp), [`arxiv`](/docs/user-guide/skills/bundled/research/research-arxiv) | ## Reference: full SKILL.md diff --git a/website/docs/user-guide/skills/optional/security/security-1password.md b/website/docs/user-guide/skills/optional/security/security-1password.md index c2c3fccb6e9..4ed526a87b6 100644 --- a/website/docs/user-guide/skills/optional/security/security-1password.md +++ b/website/docs/user-guide/skills/optional/security/security-1password.md @@ -51,7 +51,7 @@ Use this skill when the user wants secrets managed through 1Password instead of ### Service Account (recommended for Hermes) -Set `OP_SERVICE_ACCOUNT_TOKEN` in `${HERMES_HOME:-~/.hermes}/.env` (the skill will prompt for this on first load). +Set `OP_SERVICE_ACCOUNT_TOKEN` in `~/.hermes/.env` (the skill will prompt for this on first load). No desktop app needed. Supports `op read`, `op inject`, `op run`. ```bash diff --git a/website/docs/user-guide/skills/optional/security/security-godmode.md b/website/docs/user-guide/skills/optional/security/security-godmode.md index f41975a4966..ee12f700f6d 100644 --- a/website/docs/user-guide/skills/optional/security/security-godmode.md +++ b/website/docs/user-guide/skills/optional/security/security-godmode.md @@ -418,4 +418,4 @@ Claude Sonnet 4 is robust against all current techniques for clearly harmful con 9. **Always use `load_godmode.py` in execute_code** — The individual scripts (`parseltongue.py`, `godmode_race.py`, `auto_jailbreak.py`) have argparse CLI entry points with `if __name__ == '__main__'` blocks. When loaded via `exec()` in execute_code, `__name__` is `'__main__'` and argparse fires, crashing the script. The `load_godmode.py` loader handles this by setting `__name__` to a non-main value and managing sys.argv. 10. **boundary_inversion is model-version specific** — Works on Claude 3.5 Sonnet but NOT Claude Sonnet 4 or Claude 4.6. The strategy order in auto_jailbreak tries it first for Claude models, but falls through to refusal_inversion when it fails. Update the strategy order if you know the model version. 11. **Gray-area vs hard queries** — Jailbreak techniques work much better on "dual-use" queries (lock picking, security tools, chemistry) than on overtly harmful ones (phishing templates, malware). For hard queries, skip directly to ULTRAPLINIAN or use Hermes/Grok models that don't refuse. -12. **execute_code sandbox has no env vars** — When Hermes runs auto_jailbreak via execute_code, the sandbox doesn't inherit the Hermes `.env`. Load dotenv explicitly: `import os; from dotenv import load_dotenv; load_dotenv(os.path.join(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")), ".env"))` +12. **execute_code sandbox has no env vars** — When Hermes runs auto_jailbreak via execute_code, the sandbox doesn't inherit `~/.hermes/.env`. Load dotenv explicitly: `from dotenv import load_dotenv; load_dotenv(os.path.expanduser("~/.hermes/.env"))` diff --git a/website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md b/website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md index 6c9f84bafcb..0698d855f5f 100644 --- a/website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md +++ b/website/docs/user-guide/skills/optional/software-development/software-development-rest-graphql-debug.md @@ -414,7 +414,7 @@ class TestAPISmoke: ### Token handling - Never log full tokens. Redact: `Bearer `. -- Never hardcode tokens in scripts. Read from env (`os.environ["API_TOKEN"]`) or `${HERMES_HOME:-~/.hermes}/.env`. +- Never hardcode tokens in scripts. Read from env (`os.environ["API_TOKEN"]`) or `~/.hermes/.env`. - Rotate immediately if a token surfaces in logs, error messages, or git history. ### Safe logging diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md index ff9b48cef6f..aed044b3099 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/optional-skills-catalog.md @@ -53,6 +53,7 @@ hermes skills uninstall | 技能 | 描述 | |-------|-------------| | [**blender-mcp**](/user-guide/skills/optional/creative/creative-blender-mcp) | 通过 socket 连接 blender-mcp 插件,直接从 Hermes 控制 Blender。创建 3D 对象、材质、动画,并运行任意 Blender Python(bpy)代码。适用于用户希望在 Blender 中创建或修改任何内容的场景。 | +| [**concept-diagrams**](/user-guide/skills/optional/creative/creative-concept-diagrams) | 生成扁平、极简、支持亮色/暗色模式的 SVG 图表,输出为独立 HTML 文件,采用统一的教育视觉语言,包含 9 种语义色阶、句首大写排版及自动暗色模式。最适合教育和说明类内容。 | | [**hyperframes**](/user-guide/skills/optional/creative/creative-hyperframes) | 使用 HyperFrames 创建基于 HTML 的视频合成、动态标题卡、社交叠层、字幕访谈视频、音频响应视觉效果及着色器转场。HTML 是视频的唯一来源。适用于用户希望制作任何视频内容的场景。 | | [**kanban-video-orchestrator**](/user-guide/skills/optional/creative/creative-kanban-video-orchestrator) | 规划、搭建并监控由 Hermes Kanban 支撑的多 agent 视频制作流水线。适用于用户希望制作任何类型视频的场景 — 叙事影片、产品/营销视频、MV、解说视频、ASCII/终端艺术、抽象/生成式循环等。 | | [**meme-generation**](/user-guide/skills/optional/creative/creative-meme-generation) | 通过选取模板并使用 Pillow 叠加文字来生成真实的 meme 图片,输出实际的 .png 文件。 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/skills-catalog.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/skills-catalog.md index f6f24bd932d..20773484b6c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/skills-catalog.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/skills-catalog.md @@ -35,6 +35,7 @@ Hermes 在执行 `hermes update` 时也会同步内置技能,但同步清单 | 技能 | 描述 | 路径 | |-------|-------------|------| +| [`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram) | 以 HTML 形式生成深色主题的 SVG 架构/云/基础设施图。 | `creative/architecture-diagram` | | [`ascii-art`](/user-guide/skills/bundled/creative/creative-ascii-art) | ASCII 艺术:pyfiglet、cowsay、boxes、图像转 ASCII。 | `creative/ascii-art` | | [`ascii-video`](/user-guide/skills/bundled/creative/creative-ascii-video) | ASCII 视频:将视频/音频转换为彩色 ASCII MP4/GIF。 | `creative/ascii-video` | | [`baoyu-infographic`](/user-guide/skills/bundled/creative/creative-baoyu-infographic) | 信息图(可视化):21 种布局 × 21 种风格。 | `creative/baoyu-infographic` | @@ -47,6 +48,7 @@ Hermes 在执行 `hermes update` 时也会同步内置技能,但同步清单 | [`p5js`](/user-guide/skills/bundled/creative/creative-p5js) | p5.js 草图:生成艺术、着色器、交互、3D。 | `creative/p5js` | | [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs) | 54 种真实设计系统(Stripe、Linear、Vercel)的 HTML/CSS 实现。 | `creative/popular-web-designs` | | [`pretext`](/user-guide/skills/bundled/creative/creative-pretext) | 使用 @chenglou/pretext 构建创意浏览器 demo——无 DOM 的文本布局,支持 ASCII 艺术、绕障碍物的排版流、文字即几何游戏、动态排版和文字驱动的生成艺术。生成单文件 HTML。 | `creative/pretext` | +| [`sketch`](/user-guide/skills/bundled/creative/creative-sketch) | 一次性 HTML 原型:生成 2-3 个设计变体供对比。 | `creative/sketch` | | [`songwriting-and-ai-music`](/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music) | 歌曲创作技巧与 Suno AI 音乐 prompt(提示词)。 | `creative/songwriting-and-ai-music` | | [`touchdesigner-mcp`](/user-guide/skills/bundled/creative/creative-touchdesigner-mcp) | 通过 twozero MCP 控制运行中的 TouchDesigner 实例——创建算子、设置参数、连接节点、执行 Python、构建实时视觉效果。36 个原生工具。 | `creative/touchdesigner-mcp` | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-architecture-diagram.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-architecture-diagram.md new file mode 100644 index 00000000000..60846a64f16 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-architecture-diagram.md @@ -0,0 +1,165 @@ +--- +title: "Architecture Diagram — 深色主题 SVG 架构/云/基础设施图表(HTML 格式)" +sidebar_label: "Architecture Diagram" +description: "深色主题 SVG 架构/云/基础设施图表(HTML 格式)" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Architecture Diagram + +深色主题 SVG 架构/云/基础设施图表,以 HTML 格式输出。 + +## Skill 元数据 + +| | | +|---|---| +| 来源 | 内置(默认安装) | +| 路径 | `skills/creative/architecture-diagram` | +| 版本 | `1.0.0` | +| 作者 | Cocoon AI (hello@cocoon-ai.com),由 Hermes Agent 移植 | +| 许可证 | MIT | +| 平台 | linux, macos, windows | +| 标签 | `architecture`, `diagrams`, `SVG`, `HTML`, `visualization`, `infrastructure`, `cloud` | +| 相关 skill | [`concept-diagrams`](/user-guide/skills/optional/creative/creative-concept-diagrams), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw) | + +## 参考:完整 SKILL.md + +:::info +以下是 Hermes 在触发该 skill 时加载的完整 skill 定义。这是 agent 在 skill 激活时所看到的指令内容。 +::: + +# Architecture Diagram Skill + +生成专业的深色主题技术架构图,输出为包含内联 SVG 图形的独立 HTML 文件。无需外部工具、无需 API 密钥、无需渲染库——只需写入 HTML 文件并在浏览器中打开即可。 + +## 适用范围 + +**最适合:** +- 软件系统架构(前端/后端/数据库层) +- 云基础设施(VPC、区域、子网、托管服务) +- 微服务/服务网格拓扑 +- 数据库 + API 映射、部署图 +- 任何具有技术基础设施主题、适合深色网格背景风格的内容 + +**以下场景请优先考虑其他工具:** +- 物理、化学、数学、生物或其他科学学科 +- 实物对象(车辆、硬件、解剖结构、截面图) +- 平面图、叙事流程、教育/教科书风格的视觉内容 +- 手绘白板草图(建议使用 `excalidraw`) +- 动画说明(建议使用动画相关 skill) + +如果有更专业的 skill 适用于该主题,请优先使用。如果没有合适的,本 skill 也可作为通用 SVG 图表的备选方案——输出内容将带有下述深色技术风格。 + +基于 [Cocoon AI 的 architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator)(MIT 许可证)。 + +## 工作流程 + +1. 用户描述其系统架构(组件、连接关系、技术栈) +2. 按照下方设计规范生成 HTML 文件 +3. 使用 `write_file` 保存为 `.html` 文件(例如 `~/architecture-diagram.html`) +4. 用户在任意浏览器中打开——支持离线使用,无需任何依赖 + +### 输出位置 + +将图表保存到用户指定路径,或默认保存至当前工作目录: +``` +./[project-name]-architecture.html +``` + +### 预览 + +保存后,建议用户通过以下命令打开: +```bash +# macOS +open ./my-architecture.html +# Linux +xdg-open ./my-architecture.html +``` + +## 设计规范与视觉语言 + +### 颜色方案(语义映射) + +使用特定的 `rgba` 填充色和十六进制描边色对组件进行分类: + +| 组件类型 | 填充色(rgba) | 描边色(Hex) | +| :--- | :--- | :--- | +| **前端** | `rgba(8, 51, 68, 0.4)` | `#22d3ee`(cyan-400) | +| **后端** | `rgba(6, 78, 59, 0.4)` | `#34d399`(emerald-400) | +| **数据库** | `rgba(76, 29, 149, 0.4)` | `#a78bfa`(violet-400) | +| **AWS/云** | `rgba(120, 53, 15, 0.3)` | `#fbbf24`(amber-400) | +| **安全** | `rgba(136, 19, 55, 0.4)` | `#fb7185`(rose-400) | +| **消息总线** | `rgba(251, 146, 60, 0.3)` | `#fb923c`(orange-400) | +| **外部** | `rgba(30, 41, 59, 0.5)` | `#94a3b8`(slate-400) | + +### 字体与背景 +- **字体:** JetBrains Mono(等宽字体),从 Google Fonts 加载 +- **字号:** 12px(名称)、9px(副标签)、8px(注释)、7px(极小标签) +- **背景:** Slate-950(`#020617`),带有细腻的 40px 网格图案 + +```svg + + + + +``` + +## 技术实现细节 + +### 组件渲染 +组件为圆角矩形(`rx="6"`),描边宽度 1.5px。为防止箭头透过半透明填充色显现,使用**双矩形遮罩技术**: +1. 绘制不透明背景矩形(`#0f172a`) +2. 在其上方绘制半透明样式矩形 + +### 连接规则 +- **Z 轴顺序:** 在 SVG 早期绘制箭头(在网格之后),使其渲染在组件框的下方 +- **箭头头部:** 通过 SVG marker 定义 +- **安全流:** 使用 rose 色(`#fb7185`)虚线 +- **边界:** + - *安全组:* 虚线(`4,4`),rose 色 + - *区域:* 大虚线(`8,4`),amber 色,`rx="12"` + +### 间距与布局规则 +- **标准高度:** 60px(服务);80–120px(大型组件) +- **垂直间距:** 组件之间最小 40px +- **消息总线:** 必须放置在服务之间的间隙中,不得与其重叠 +- **图例位置:** **关键。** 必须放置在所有边界框的外部。计算所有边界的最低 Y 坐标,并将图例放置在其下方至少 20px 处。 + +## 文档结构 + +生成的 HTML 文件遵循四段式布局: +1. **页眉:** 带有脉冲点指示器的标题和副标题 +2. **主 SVG:** 包含在圆角边框卡片中的图表 +3. **摘要卡片:** 图表下方的三张卡片网格,用于展示高层次详情 +4. **页脚:** 简洁的元数据信息 + +### 信息卡片模式 +```html +
+
+
+

Title

+
+
    +
  • • Item one
  • +
  • • Item two
  • +
+
+``` + +## 输出要求 +- **单文件:** 一个自包含的 `.html` 文件 +- **无外部依赖:** 所有 CSS 和 SVG 必须内联(Google Fonts 除外) +- **无 JavaScript:** 所有动画(如脉冲点)使用纯 CSS 实现 +- **兼容性:** 必须在任何现代浏览器中正确渲染 + +## 模板参考 + +加载完整 HTML 模板以获取精确的结构、CSS 和 SVG 组件示例: + +``` +skill_view(name="architecture-diagram", file_path="templates/template.html") +``` + +模板包含每种组件类型(前端、后端、数据库、云、安全)、箭头样式(标准、虚线、曲线)、安全组、区域边界和图例的完整示例——生成图表时请以此作为结构参考。 \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-claude-design.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-claude-design.md index 7aaa2d26f2d..6d1b7529ab3 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-claude-design.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-claude-design.md @@ -21,7 +21,7 @@ description: "设计一次性 HTML 制品(落地页、幻灯片、原型)" | 许可证 | MIT | | 平台 | linux, macos, windows | | 标签 | `design`, `html`, `prototype`, `ux`, `ui`, `creative`, `artifact`, `deck`, `motion`, `design-system` | -| 相关 skill | [`design-md`](/user-guide/skills/bundled/creative/creative-design-md), [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`html-artifact`](/user-guide/skills/bundled/creative/creative-html-artifact) | +| 相关 skill | [`design-md`](/user-guide/skills/bundled/creative/creative-design-md), [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram) | ## 参考:完整 SKILL.md diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-design-md.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-design-md.md index e9fc5aade25..4d21eb7f671 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-design-md.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-design-md.md @@ -21,7 +21,7 @@ description: "编写/验证/导出 Google 的 DESIGN" | 许可证 | MIT | | 平台 | linux, macos, windows | | 标签 | `design`, `design-system`, `tokens`, `ui`, `accessibility`, `wcag`, `tailwind`, `dtcg`, `google` | -| 相关 skill | [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs), [`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`html-artifact`](/user-guide/skills/bundled/creative/creative-html-artifact) | +| 相关 skill | [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs), [`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram) | ## 参考:完整 SKILL.md diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-pretext.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-pretext.md index 243e776f6a7..83dadb74c8d 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-pretext.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-pretext.md @@ -21,7 +21,7 @@ description: "适用于使用 @chenglou/pretext 构建创意浏览器演示 — | 许可证 | MIT | | 平台 | linux, macos, windows | | 标签 | `creative-coding`, `typography`, `pretext`, `ascii-art`, `canvas`, `generative`, `text-layout`, `kinetic-typography` | -| 相关 skill | [`p5js`](/user-guide/skills/bundled/creative/creative-p5js), [`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`html-artifact`](/user-guide/skills/bundled/creative/creative-html-artifact) | +| 相关 skill | [`p5js`](/user-guide/skills/bundled/creative/creative-p5js), [`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), [`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram) | ## 参考:完整 SKILL.md diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-sketch.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-sketch.md new file mode 100644 index 00000000000..6478c87f362 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-sketch.md @@ -0,0 +1,238 @@ +--- +title: "Sketch — 一次性 HTML 原型:2-3 个设计方案对比" +sidebar_label: "Sketch" +description: "一次性 HTML 原型:2-3 个设计方案对比" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Sketch + +一次性 HTML 原型:2-3 个设计方案对比。 + +## Skill 元数据 + +| | | +|---|---| +| 来源 | 内置(默认安装) | +| 路径 | `skills/creative/sketch` | +| 版本 | `1.0.0` | +| 作者 | Hermes Agent(改编自 gsd-build/get-shit-done) | +| 许可证 | MIT | +| 平台 | linux, macos, windows | +| 标签 | `sketch`, `mockup`, `design`, `ui`, `prototype`, `html`, `variants`, `exploration`, `wireframe`, `comparison` | +| 相关 skill | [`spike`](/user-guide/skills/bundled/software-development/software-development-spike), [`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design), [`popular-web-designs`](/user-guide/skills/bundled/creative/creative-popular-web-designs), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw) | + +## 参考:完整 SKILL.md + +:::info +以下是 Hermes 在触发该 skill 时加载的完整 skill 定义。这是 agent 在 skill 激活时所看到的指令内容。 +::: + +# Sketch + +当用户希望**在确定方向之前先看到设计效果**时使用此 skill——以一次性 HTML 原型的形式探索 UI/UX 想法。目的是生成 2-3 个可交互的方案,让用户并排对比视觉方向,而非产出可交付的代码。 + +当用户说以下内容时加载此 skill:"sketch this screen"、"show me what X could look like"、"compare layout A vs B"、"give me 2-3 takes on this UI"、"let me see some variants"、"mockup this before I build"。 + +## 不适用场景 + +- 用户需要生产级组件——使用 `claude-design` 或正式构建 +- 用户需要精良的一次性 HTML 产物(落地页、幻灯片)——使用 `claude-design` +- 用户需要图表——使用 `excalidraw`、`architecture-diagram` +- 设计已确定——直接构建即可 + +## 如果用户安装了完整的 GSD 系统 + +如果 `gsd-sketch` 作为同级 skill 出现(通过 `npx get-shit-done-cc --hermes` 安装),优先使用 **`gsd-sketch`** 以获得完整工作流:持久化的 `.planning/sketches/` 目录(含 MANIFEST)、前沿模式分析、跨历史草图的一致性审计,以及与 GSD 其余部分的集成。本 skill 是轻量级独立版本——无状态机制的一次性草图。 + +## 核心方法 + +``` +intake → variants → head-to-head → pick winner (or iterate) +``` + +### 1. Intake(如果用户已提供足够信息则跳过) + +在生成方案之前,获取三项信息——每次只问一个问题,不要一次全问: + +1. **感觉。** "这个应该给人什么感觉?形容词、情绪、氛围。"——*"calm, editorial, like Linear"* 比 *"minimal"* 更有参考价值。 +2. **参考。** "哪些 app、网站或产品接近你想象中的感觉?"——实际参考比抽象描述更有效。 +3. **核心操作。** "用户在这个页面上最重要的单一操作是什么?"——所有方案都应服务于此;否则只是装饰。 + +每次回答后简短复述,再问下一个问题。如果用户已一次性提供了全部三项,直接跳到方案生成。 + +### 2. 方案(2-3 个,不少于 1 个,极少超过 4 个) + +一次性生成 **2-3 个方案**。每个方案是一个完整的独立 HTML 文件。不要描述方案——直接构建。目的是对比。 + +每个方案应采取**不同的设计立场**,而非不同的像素值。三种有效的方案维度: + +- **密度:** 紧凑 / 宽松 / 极密(选两个对比极端) +- **重点:** 内容优先 / 操作优先 / 工具优先 +- **美学:** 编辑风格 / 实用主义 / 趣味性 +- **布局:** 单列 / 侧边栏 / 分屏 +- **基调:** 卡片式 / 纯内容 / 文档风格 + +选定一个维度并从中拉开差距。两个仅在强调色上不同的方案是无效的——用户无法区分。 + +**方案命名:** 描述立场,而非编号。 + + +``` +sketches/ +├── 001-calm-editorial/ +│ ├── index.html +│ └── README.md +├── 001-utilitarian-dense/ +│ ├── index.html +│ └── README.md +└── 001-playful-split/ + ├── index.html + └── README.md +``` + + +### 3. 制作真实的 HTML + +每个方案是一个**单一自包含的 HTML 文件**: + +- 内联 ` +``` + +### 4. 方案 README + +每个方案的 `README.md` 回答以下内容: + +```markdown +## Variant: {stance name} + +### Design stance +One sentence on the principle driving this variant. + +### Key choices +- Layout: ... +- Typography: ... +- Color: ... +- Interaction: ... + +### Trade-offs +- Strong at: ... +- Weak at: ... + +### Best for +- The kind of user or use case this variant actually serves +``` + +### 5. 正面对比 + +所有方案构建完成后,以对比形式呈现。不要只是罗列——**给出观点**: + +```markdown +## Three takes on the home screen + +| Dimension | Calm editorial | Utilitarian dense | Playful split | +|-----------|----------------|-------------------|---------------| +| Density | Low | High | Medium | +| Primary action visibility | Low | High | Medium | +| Scan-ability | High | Medium | Low | +| Feel | Calm, trusted | Sharp, tool-like | Inviting, energetic | + +**My take:** Utilitarian dense for power users, calm editorial for content-forward audiences. Playful split is weakest — tries to do both and commits to neither. +``` + +让用户选出胜出方案,或将两个方案合并为混合版,或要求新一轮迭代。 + +## 主题化(当项目有视觉标识时) + +如果用户有现有主题(颜色、字体、token),将共享 token 放入 `sketches/themes/tokens.css` 并在每个方案中 `@import`。保持 token 精简: + +```css +/* sketches/themes/tokens.css */ +:root { + --color-bg: #fafafa; + --color-fg: #1a1a1a; + --color-accent: #0066ff; + --color-muted: #666; + --radius: 8px; + --font-display: "Inter", sans-serif; + --font-body: -apple-system, BlinkMacSystemFont, sans-serif; +} +``` + +不要对一次性草图过度 token 化——三种颜色加一种字体通常已足够。 + +## 交互基准 + +当用户能够完成以下操作时,草图的交互程度即为合格: + +1. **点击主要操作**并看到可见的变化(状态变更、模态框、toast、导航模拟) +2. **看到一个有意义的状态转换**(筛选列表、切换模式、展开/收起面板) +3. **悬停可识别的交互元素**(按钮、行、标签页) + +超过此程度是对一次性草图的过度工程化。低于此程度则只是截图。 + +## 前沿模式(决定下一步草图内容) + +如果草图已存在且用户询问"接下来应该草图什么?": + +- **一致性缺口**——来自不同草图的两个胜出方案做出了独立选择,尚未组合在一起 +- **未草图的页面**——被引用但从未探索过 +- **状态覆盖**——已草图了正常路径,但未覆盖空状态 / 加载中 / 错误 / 千条数据 +- **响应式缺口**——在某一视口下验证过;在移动端 / 超宽屏下是否成立? +- **交互模式**——静态布局已存在;过渡动效、拖拽、滚动行为尚未探索 + +提出 2-4 个命名候选项,让用户选择。 + +## 输出 + +- 在仓库根目录创建 `sketches/`(如果用户使用 GSD 约定则为 `.planning/sketches/`) +- 每个方案一个子目录:`NNN-stance-name/index.html` + `README.md` +- 告知用户如何打开:macOS 上用 `open sketches/001-calm-editorial/index.html`,Linux 上用 `xdg-open`,Windows 上用 `start` +- 保持方案的一次性特性——如果你觉得有必要保留某个草图,应将其提升为真实项目代码,而非作为资产保管 + +**单个方案的典型工具调用序列:** + +``` +terminal("mkdir -p sketches/001-calm-editorial") +write_file("sketches/001-calm-editorial/index.html", "...") +write_file("sketches/001-calm-editorial/README.md", "## Variant: Calm editorial\n...") +browser_navigate(url="file://$(pwd)/sketches/001-calm-editorial/index.html") +browser_vision(question="How does this look? Any obvious layout issues?") +``` + +对每个方案重复上述步骤,然后呈现对比表格。 + +## 致谢 + +改编自 GSD(Get Shit Done)项目的 `/gsd-sketch` 工作流——MIT © 2025 Lex Christopherson([gsd-build/get-shit-done](https://github.com/gsd-build/get-shit-done))。完整 GSD 系统提供持久化草图状态、主题/方案模式参考及一致性审计工作流;通过 `npx get-shit-done-cc --hermes --global` 安装。 \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-spike.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-spike.md index be869779937..e5486edd0d3 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-spike.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/software-development/software-development-spike.md @@ -21,7 +21,7 @@ description: "在构建前验证想法的一次性实验" | 许可证 | MIT | | 平台 | linux, macos, windows | | 标签 | `spike`, `prototype`, `experiment`, `feasibility`, `throwaway`, `exploration`, `research`, `planning`, `mvp`, `proof-of-concept` | -| 相关 skill | [`html-artifact`](/user-guide/skills/bundled/creative/creative-html-artifact)、[`writing-plans`](/user-guide/skills/bundled/software-development/software-development-writing-plans)、[`subagent-driven-development`](/user-guide/skills/bundled/software-development/software-development-subagent-driven-development)、[`plan`](/user-guide/skills/bundled/software-development/software-development-plan) | +| 相关 skill | [`sketch`](/user-guide/skills/bundled/creative/creative-sketch)、[`writing-plans`](/user-guide/skills/bundled/software-development/software-development-writing-plans)、[`subagent-driven-development`](/user-guide/skills/bundled/software-development/software-development-subagent-driven-development)、[`plan`](/user-guide/skills/bundled/software-development/software-development-plan) | ## 参考:完整 SKILL.md diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-concept-diagrams.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-concept-diagrams.md new file mode 100644 index 00000000000..405f658a22b --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-concept-diagrams.md @@ -0,0 +1,379 @@ +--- +title: "概念图" +sidebar_label: "概念图" +description: "以统一的教育视觉语言生成扁平、简约、支持明暗模式的 SVG 图表,输出为独立 HTML 文件,包含 9 种语义色阶、句首大写排版及自动暗色模式。..." +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# 概念图 + +以统一的教育视觉语言生成扁平、简约、支持明暗模式的 SVG 图表,输出为独立 HTML 文件,包含 9 种语义色阶、句首大写排版及自动暗色模式。最适合教育类和非软件类视觉内容——物理装置、化学机制、数学曲线、实物(飞机、涡轮机、智能手机、机械表)、解剖图、平面图、截面图、叙事流程(X 的生命周期、Y 的过程)、中心辐射型系统集成(智慧城市、IoT)以及爆炸分层视图。若已有更专业的 skill 适用于该主题(专用软件/云架构、手绘草图、动画说明等),优先使用那些 skill——否则本 skill 也可作为通用 SVG 图表的备选方案,具备简洁的教育风格外观。内置 15 个示例图表。 + +## Skill 元数据 + +| | | +|---|---| +| 来源 | 可选 — 通过 `hermes skills install official/creative/concept-diagrams` 安装 | +| 路径 | `optional-skills/creative/concept-diagrams` | +| 版本 | `0.1.0` | +| 作者 | v1k22(原始 PR),移植至 hermes-agent | +| 许可证 | MIT | +| 平台 | linux, macos, windows | +| 标签 | `diagrams`, `svg`, `visualization`, `education`, `physics`, `chemistry`, `engineering` | +| 相关 skills | [`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram), [`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw), `generative-widgets` | + +## 参考:完整 SKILL.md + +:::info +以下是 Hermes 在触发本 skill 时加载的完整 skill 定义。这是 agent 在 skill 激活时所看到的指令内容。 +::: + +# 概念图 + +使用统一的扁平、简约设计系统生成生产级 SVG 图表。输出为单个自包含 HTML 文件,可在任何现代浏览器中一致渲染,并自动支持明暗模式。 + +## 适用范围 + +**最适合:** +- 物理装置、化学机制、数学曲线、生物学 +- 实物(飞机、涡轮机、智能手机、机械表、细胞) +- 解剖图、截面图、爆炸分层视图 +- 平面图、建筑改造图 +- 叙事流程(X 的生命周期、Y 的过程) +- 中心辐射型系统集成(智慧城市、IoT 网络、电网) +- 任何领域的教育/教科书风格视觉内容 +- 定量图表(分组柱状图、能量曲线) + +**优先考虑其他方案:** +- 具有深色科技风格的专用软件/云基础设施架构(如有 `architecture-diagram` 可用,优先使用) +- 手绘白板草图(如有 `excalidraw` 可用,优先使用) +- 动画说明或视频输出(考虑动画 skill) + +若已有更专业的 skill 适用于该主题,优先使用。若无合适选项,本 skill 可作为通用 SVG 图表备选方案——输出将呈现下文描述的简洁教育风格,适用于几乎任何主题。 + +## 工作流程 + +1. 确定图表类型(见下方"图表类型")。 +2. 使用设计系统规则布局组件。 +3. 使用 `templates/template.html` 作为包装器编写完整 HTML 页面——将 SVG 粘贴到模板中 `` 的位置。 +4. 保存为独立 `.html` 文件(例如 `~/my-diagram.html` 或 `./my-diagram.html`)。 +5. 用户直接在浏览器中打开——无需服务器,无需依赖。 + +可选:若用户需要可浏览的多图表画廊,参见底部"本地预览服务器"。 + +加载 HTML 模板: +``` +skill_view(name="concept-diagrams", file_path="templates/template.html") +``` + +模板内嵌完整 CSS 设计系统(`c-*` 颜色类、文本类、明暗变量、箭头标记样式)。你生成的 SVG 依赖这些类存在于宿主页面中。 + +--- + +## 设计系统 + +### 设计理念 + +- **扁平**:无渐变、无投影、无模糊、无发光、无霓虹效果。 +- **简约**:只展示核心内容,框内无装饰性图标。 +- **一致**:每张图表使用相同的颜色、间距、排版和描边宽度。 +- **暗色模式就绪**:所有颜色通过 CSS 类自动适配——无需为每种模式单独编写 SVG。 + +### 调色板 + +9 种色阶,每种 7 个色阶值。将类名放在 `` 或形状元素上;模板 CSS 自动处理明暗两种模式。 + +| 类名 | 50(最浅) | 100 | 200 | 400 | 600 | 800 | 900(最深) | +|------------|---------------|---------|---------|---------|---------|---------|---------------| +| `c-purple` | #EEEDFE | #CECBF6 | #AFA9EC | #7F77DD | #534AB7 | #3C3489 | #26215C | +| `c-teal` | #E1F5EE | #9FE1CB | #5DCAA5 | #1D9E75 | #0F6E56 | #085041 | #04342C | +| `c-coral` | #FAECE7 | #F5C4B3 | #F0997B | #D85A30 | #993C1D | #712B13 | #4A1B0C | +| `c-pink` | #FBEAF0 | #F4C0D1 | #ED93B1 | #D4537E | #993556 | #72243E | #4B1528 | +| `c-gray` | #F1EFE8 | #D3D1C7 | #B4B2A9 | #888780 | #5F5E5A | #444441 | #2C2C2A | +| `c-blue` | #E6F1FB | #B5D4F4 | #85B7EB | #378ADD | #185FA5 | #0C447C | #042C53 | +| `c-green` | #EAF3DE | #C0DD97 | #97C459 | #639922 | #3B6D11 | #27500A | #173404 | +| `c-amber` | #FAEEDA | #FAC775 | #EF9F27 | #BA7517 | #854F0B | #633806 | #412402 | +| `c-red` | #FCEBEB | #F7C1C1 | #F09595 | #E24B4A | #A32D2D | #791F1F | #501313 | + +#### 颜色分配规则 + +颜色编码**语义**,而非顺序。切勿像彩虹一样循环使用颜色。 + +- 按**类别**对节点分组——同类型的所有节点共用一种颜色。 +- 对中性/结构性节点(起点、终点、通用步骤、用户)使用 `c-gray`。 +- 每张图表使用 **2-3 种颜色**,而非 6 种以上。 +- 通用类别优先使用 `c-purple`、`c-teal`、`c-coral`、`c-pink`。 +- 将 `c-blue`、`c-green`、`c-amber`、`c-red` 保留用于语义含义(信息、成功、警告、错误)。 + +明暗色阶映射(由模板 CSS 处理——直接使用类名即可): +- 亮色模式:50 填充 + 600 描边 + 800 标题 / 600 副标题 +- 暗色模式:800 填充 + 200 描边 + 100 标题 / 200 副标题 + +### 排版 + +只有两种字体大小,不得例外。 + +| 类名 | 大小 | 字重 | 用途 | +|-------|------|--------|-----| +| `th` | 14px | 500 | 节点标题、区域标签 | +| `ts` | 12px | 400 | 副标题、描述、箭头标签 | +| `t` | 14px | 400 | 通用文本 | + +- **始终使用句首大写。** 禁止首字母大写(Title Case),禁止全大写(ALL CAPS)。 +- 每个 `` 必须带有类名(`t`、`ts` 或 `th`),不得有无类名的文本。 +- 框内所有文本使用 `dominant-baseline="central"`。 +- 框内居中文本使用 `text-anchor="middle"`。 + +**宽度估算(近似值):** +- 14px 字重 500:每字符约 8px +- 12px 字重 400:每字符约 6.5px +- 始终验证:`box_width >= (字符数 × px/字符) + 48`(每侧 24px 内边距) + +### 间距与布局 + +- **ViewBox**:`viewBox="0 0 680 H"`,其中 H = 内容高度 + 40px 缓冲。 +- **安全区域**:x=40 至 x=640,y=40 至 y=(H-40)。 +- **框间距**:最小 60px。 +- **框内边距**:水平 24px,垂直 12px。 +- **箭头间隙**:箭头与框边缘之间 10px。 +- **单行框**:高度 44px。 +- **双行框**:高度 56px,标题与副标题基线间距 18px。 +- **容器内边距**:每个容器内部最小 20px。 +- **最大嵌套层级**:2-3 层。在 680px 宽度下更深的嵌套会难以阅读。 + +### 描边与形状 + +- **描边宽度**:所有节点边框 0.5px,不得使用 1px 或 2px。 +- **矩形圆角**:节点使用 `rx="8"`,内层容器使用 `rx="12"`,外层容器使用 `rx="16"` 至 `rx="20"`。 +- **连接路径**:必须设置 `fill="none"`,否则 SVG 默认填充为黑色。 + +### 箭头标记 + +在**每个** SVG 开头包含以下 `` 块: + +```xml + + + + + +``` + +在线条上使用 `marker-end="url(#arrow)"`。箭头通过 `context-stroke` 继承线条颜色。 + +### CSS 类(由模板提供) + +模板页面提供: + +- 文本:`.t`、`.ts`、`.th` +- 中性:`.box`、`.arr`、`.leader`、`.node` +- 色阶:`.c-purple`、`.c-teal`、`.c-coral`、`.c-pink`、`.c-gray`、`.c-blue`、`.c-green`、`.c-amber`、`.c-red`(均自动支持明暗模式) + +你**无需**重新定义这些类——直接在 SVG 中应用即可。模板文件包含完整的 CSS 定义。 + +--- + +## SVG 样板代码 + +模板页面中的每个 SVG 均以如下结构开头: + +```xml + + + + + + + + + + +``` + +将 `{HEIGHT}` 替换为实际计算高度(最后一个元素底部 + 40px)。 + +### 节点模式 + +**单行节点(44px):** +```xml + + + Service name + +``` + +**双行节点(56px):** +```xml + + + Service name + Short description + +``` + +**连接线(无标签):** +```xml + +``` + +**容器(虚线或实线):** +```xml + + + Container label + Subtitle info + +``` + +--- + +## 图表类型 + +根据主题选择合适的布局: + +1. **流程图** — CI/CD 流水线、请求生命周期、审批工作流、数据处理。单向流(从上到下或从左到右),每行最多 4-5 个节点。 +2. **结构/包含图** — 云基础设施嵌套、分层系统架构。大型外层容器包含内层区域,虚线矩形表示逻辑分组。 +3. **API/端点映射** — REST 路由、GraphQL schema。从根节点树状展开,分支到资源组,每组包含端点节点。 +4. **微服务拓扑** — 服务网格、事件驱动系统。服务作为节点,箭头表示通信模式,消息队列位于服务之间。 +5. **数据流图** — ETL 流水线、流式架构。从数据源经处理流向数据汇,方向从左到右。 +6. **实物/结构图** — 交通工具、建筑、硬件、解剖图。使用与实物形态匹配的形状——弯曲体用 ``,锥形用 ``,圆柱部件用 ``/``,隔间用嵌套 ``。参见 `references/physical-shape-cookbook.md`。 +7. **基础设施/系统集成图** — 智慧城市、IoT 网络、多域系统。中心辐射布局,中央平台连接各子系统。按系统使用语义线型(`.data-line`、`.power-line`、`.water-pipe`、`.road`)。参见 `references/infrastructure-patterns.md`。 +8. **UI/仪表盘原型** — 管理面板、监控仪表盘。屏幕框架内嵌套图表/仪表/指示器元素。参见 `references/dashboard-patterns.md`。 + +对于实物图、基础设施图和仪表盘图,生成前请先加载对应的参考文件——每个文件提供现成的 CSS 类和形状原语。 + +--- + +## 验证清单 + +在最终确定任何 SVG 之前,验证以下**所有**项目: + +1. 每个 `` 都有类名 `t`、`ts` 或 `th`。 +2. 框内每个 `` 都有 `dominant-baseline="central"`。 +3. 用作箭头的每个连接 `` 或 `` 都有 `fill="none"`。 +4. 没有箭头线穿过无关的框。 +5. 14px 文本:`box_width >= (最长标签字符数 × 8) + 48`。 +6. 12px 文本:`box_width >= (最长标签字符数 × 6.5) + 48`。 +7. ViewBox 高度 = 最底部元素 + 40px。 +8. 所有内容在 x=40 至 x=640 范围内。 +9. 颜色类(`c-*`)放在 `` 或形状元素上,不得放在 `` 连接线上。 +10. 箭头 `` 块存在。 +11. 无渐变、投影、模糊或发光效果。 +12. 所有节点边框描边宽度为 0.5px。 + +--- + +## 输出与预览 + +### 默认:独立 HTML 文件 + +写入单个 `.html` 文件,用户可直接打开。无需服务器,无需依赖,离线可用。模式: + +```python +# 1. Load the template +template = skill_view("concept-diagrams", "templates/template.html") + +# 2. Fill in title, subtitle, and paste your SVG +html = template.replace( + "", "SN2 reaction mechanism" +).replace( + "", "Bimolecular nucleophilic substitution" +).replace( + "", svg_content +) + +# 3. Write to a user-chosen path (or ./ by default) +write_file("./sn2-mechanism.html", html) +``` + +告知用户如何打开: + +``` +# macOS +open ./sn2-mechanism.html +# Linux +xdg-open ./sn2-mechanism.html +``` + +### 可选:本地预览服务器(多图表画廊) + +仅在用户明确需要可浏览的多图表画廊时使用。 + +**规则:** +- 仅绑定到 `127.0.0.1`,绝不使用 `0.0.0.0`。在共享网络上将图表暴露在所有网络接口上存在安全风险。 +- 选择空闲端口(不得硬编码),并告知用户所选 URL。 +- 服务器是可选的、需用户主动选择的——优先使用独立 HTML 文件。 + +推荐模式(让操作系统选择空闲的临时端口): + +```bash +# Put each diagram in its own folder under .diagrams/ +mkdir -p .diagrams/sn2-mechanism +# ...write .diagrams/sn2-mechanism/index.html... + +# Serve on loopback only, free port +cd .diagrams && python3 -c " +import http.server, socketserver +with socketserver.TCPServer(('127.0.0.1', 0), http.server.SimpleHTTPRequestHandler) as s: + print(f'Serving at http://127.0.0.1:{s.server_address[1]}/') + s.serve_forever() +" & +``` + +若用户坚持使用固定端口,使用 `127.0.0.1:`——仍然不得使用 `0.0.0.0`。说明如何停止服务器(`kill %1` 或 `pkill -f "http.server"`)。 + +--- + +## 示例参考 + +`examples/` 目录内置 15 个完整、经过测试的图表。在编写同类型新图表之前,先浏览这些示例以获取可用模式: + +| 文件 | 类型 | 演示内容 | +|------|------|--------------| +| `hospital-emergency-department-flow.md` | 流程图 | 带语义颜色的优先级路由 | +| `feature-film-production-pipeline.md` | 流程图 | 分阶段工作流、水平子流程 | +| `automated-password-reset-flow.md` | 流程图 | 带错误分支的认证流程 | +| `autonomous-llm-research-agent-flow.md` | 流程图 | 回环箭头、决策分支 | +| `place-order-uml-sequence.md` | 时序图 | UML 时序图风格 | +| `commercial-aircraft-structure.md` | 实物图 | 使用路径、多边形、椭圆绘制真实形状 | +| `wind-turbine-structure.md` | 实物截面图 | 地下/地上分离、颜色编码 | +| `smartphone-layer-anatomy.md` | 爆炸视图 | 左右交替标签、分层组件 | +| `apartment-floor-plan-conversion.md` | 平面图 | 墙体、门、虚线红色标注改造方案 | +| `banana-journey-tree-to-smoothie.md` | 叙事流程 | 蜿蜒路径、渐进状态变化 | +| `cpu-ooo-microarchitecture.md` | 硬件流水线 | 扇出、内存层次侧边栏 | +| `sn2-reaction-mechanism.md` | 化学图 | 分子、弯曲箭头、能量曲线 | +| `smart-city-infrastructure.md` | 中心辐射图 | 每个系统使用语义线型 | +| `electricity-grid-flow.md` | 多阶段流程图 | 电压层次、流向标记 | +| `ml-benchmark-grouped-bar-chart.md` | 图表 | 分组柱状图、双轴 | + +使用以下命令加载任意示例: +``` +skill_view(name="concept-diagrams", file_path="examples/") +``` + +--- + +## 快速参考:何时使用何种图表 + +| 用户说 | 图表类型 | 建议颜色 | +|-----------|--------------|------------------| +| "展示流水线" | 流程图 | 灰色起止点,紫色步骤,红色错误,青色部署 | +| "画数据流" | 数据流水线(从左到右) | 灰色数据源,紫色处理,青色数据汇 | +| "可视化系统" | 结构图(包含关系) | 紫色容器,青色服务,珊瑚色数据 | +| "映射端点" | API 树状图 | 紫色根节点,每个资源组一种色阶 | +| "展示服务" | 微服务拓扑 | 灰色入口,青色服务,紫色总线,珊瑚色 worker | +| "画飞机/交通工具" | 实物图 | 路径、多边形、椭圆绘制真实形状 | +| "智慧城市/IoT" | 中心辐射集成图 | 每个子系统使用语义线型 | +| "展示仪表盘" | UI 原型 | 深色屏幕,图表颜色:青色、紫色、珊瑚色告警 | +| "电网/电力" | 多阶段流程图 | 电压层次(高/中/低压线宽) | +| "风力涡轮机/涡轮机" | 实物截面图 | 基础 + 塔筒截面 + 机舱颜色编码 | +| "X 的旅程/生命周期" | 叙事流程 | 蜿蜒路径,渐进状态变化 | +| "X 的层次/爆炸图" | 爆炸分层视图 | 垂直堆叠,交替标签 | +| "CPU/流水线" | 硬件流水线 | 垂直阶段,扇出到执行端口 | +| "平面图/公寓" | 平面图 | 墙体、门,虚线红色标注改造方案 | +| "反应机制" | 化学图 | 原子、化学键、弯曲箭头、过渡态、能量曲线 | \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md index b8f0a7946c1..15bbaaec8d1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-kanban-video-orchestrator.md @@ -21,7 +21,7 @@ description: "规划、搭建并监控由 Hermes Kanban 支撑的多智能体视 | 许可证 | MIT | | 平台 | linux, macos, windows | | 标签 | `video`, `kanban`, `multi-agent`, `orchestration`, `production-pipeline` | -| 相关技能 | [`kanban-orchestrator`](/user-guide/skills/bundled/devops/devops-kanban-orchestrator)、[`kanban-worker`](/user-guide/skills/bundled/devops/devops-kanban-worker)、[`ascii-video`](/user-guide/skills/bundled/creative/creative-ascii-video)、[`manim-video`](/user-guide/skills/bundled/creative/creative-manim-video)、[`p5js`](/user-guide/skills/bundled/creative/creative-p5js)、[`comfyui`](/user-guide/skills/bundled/creative/creative-comfyui)、[`touchdesigner-mcp`](/user-guide/skills/bundled/creative/creative-touchdesigner-mcp)、[`blender-mcp`](/user-guide/skills/optional/creative/creative-blender-mcp)、[`pixel-art`](/user-guide/skills/bundled/creative/creative-pixel-art)、[`ascii-art`](/user-guide/skills/bundled/creative/creative-ascii-art)、[`songwriting-and-ai-music`](/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music)、[`heartmula`](/user-guide/skills/bundled/media/media-heartmula)、[`songsee`](/user-guide/skills/bundled/media/media-songsee)、[`spotify`](/user-guide/skills/bundled/media/media-spotify)、[`youtube-content`](/user-guide/skills/bundled/media/media-youtube-content)、[`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design)、[`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw)、[`html-artifact`](/user-guide/skills/bundled/creative/creative-html-artifact)、[`baoyu-comic`](/user-guide/skills/bundled/creative/creative-baoyu-comic)、[`baoyu-infographic`](/user-guide/skills/bundled/creative/creative-baoyu-infographic)、[`humanizer`](/user-guide/skills/bundled/creative/creative-humanizer)、[`gif-search`](/user-guide/skills/bundled/media/media-gif-search)、[`meme-generation`](/user-guide/skills/optional/creative/creative-meme-generation) | +| 相关技能 | [`kanban-orchestrator`](/user-guide/skills/bundled/devops/devops-kanban-orchestrator)、[`kanban-worker`](/user-guide/skills/bundled/devops/devops-kanban-worker)、[`ascii-video`](/user-guide/skills/bundled/creative/creative-ascii-video)、[`manim-video`](/user-guide/skills/bundled/creative/creative-manim-video)、[`p5js`](/user-guide/skills/bundled/creative/creative-p5js)、[`comfyui`](/user-guide/skills/bundled/creative/creative-comfyui)、[`touchdesigner-mcp`](/user-guide/skills/bundled/creative/creative-touchdesigner-mcp)、[`blender-mcp`](/user-guide/skills/optional/creative/creative-blender-mcp)、[`pixel-art`](/user-guide/skills/bundled/creative/creative-pixel-art)、[`ascii-art`](/user-guide/skills/bundled/creative/creative-ascii-art)、[`songwriting-and-ai-music`](/user-guide/skills/bundled/creative/creative-songwriting-and-ai-music)、[`heartmula`](/user-guide/skills/bundled/media/media-heartmula)、[`songsee`](/user-guide/skills/bundled/media/media-songsee)、[`spotify`](/user-guide/skills/bundled/media/media-spotify)、[`youtube-content`](/user-guide/skills/bundled/media/media-youtube-content)、[`claude-design`](/user-guide/skills/bundled/creative/creative-claude-design)、[`excalidraw`](/user-guide/skills/bundled/creative/creative-excalidraw)、[`architecture-diagram`](/user-guide/skills/bundled/creative/creative-architecture-diagram)、[`concept-diagrams`](/user-guide/skills/optional/creative/creative-concept-diagrams)、[`baoyu-comic`](/user-guide/skills/bundled/creative/creative-baoyu-comic)、[`baoyu-infographic`](/user-guide/skills/bundled/creative/creative-baoyu-infographic)、[`humanizer`](/user-guide/skills/bundled/creative/creative-humanizer)、[`gif-search`](/user-guide/skills/bundled/media/media-gif-search)、[`meme-generation`](/user-guide/skills/optional/creative/creative-meme-generation) | ## 参考:完整 SKILL.md diff --git a/website/sidebars.ts b/website/sidebars.ts index b8efcef0624..dec160700e2 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -150,6 +150,7 @@ const sidebars: SidebarsConfig = { 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code', 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex', 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent', + 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-kanban-codex-lane', 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-opencode', ], }, @@ -159,6 +160,7 @@ const sidebars: SidebarsConfig = { key: 'skills-bundled-creative', collapsed: true, items: [ + 'user-guide/skills/bundled/creative/creative-architecture-diagram', 'user-guide/skills/bundled/creative/creative-ascii-art', 'user-guide/skills/bundled/creative/creative-ascii-video', 'user-guide/skills/bundled/creative/creative-baoyu-infographic', @@ -166,12 +168,12 @@ const sidebars: SidebarsConfig = { 'user-guide/skills/bundled/creative/creative-comfyui', 'user-guide/skills/bundled/creative/creative-design-md', 'user-guide/skills/bundled/creative/creative-excalidraw', - 'user-guide/skills/bundled/creative/creative-html-artifact', 'user-guide/skills/bundled/creative/creative-humanizer', 'user-guide/skills/bundled/creative/creative-manim-video', 'user-guide/skills/bundled/creative/creative-p5js', 'user-guide/skills/bundled/creative/creative-popular-web-designs', 'user-guide/skills/bundled/creative/creative-pretext', + 'user-guide/skills/bundled/creative/creative-sketch', 'user-guide/skills/bundled/creative/creative-songwriting-and-ai-music', 'user-guide/skills/bundled/creative/creative-touchdesigner-mcp', ], @@ -385,6 +387,7 @@ const sidebars: SidebarsConfig = { 'user-guide/skills/optional/creative/creative-baoyu-article-illustrator', 'user-guide/skills/optional/creative/creative-baoyu-comic', 'user-guide/skills/optional/creative/creative-blender-mcp', + 'user-guide/skills/optional/creative/creative-concept-diagrams', 'user-guide/skills/optional/creative/creative-creative-ideation', 'user-guide/skills/optional/creative/creative-hyperframes', 'user-guide/skills/optional/creative/creative-kanban-video-orchestrator', From 9a2f2756f7e6d1ca1b761ad330c6fd2c0b02d95e Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 19 Jun 2026 08:59:09 -0500 Subject: [PATCH 075/470] fix(desktop): allow selecting slash output and shell logs in thread (#49063) System messages (/debug, /status, etc.) were not in the desktop app's text-selection allowlist, so log output in the thread could not be copied. --- apps/desktop/src/components/assistant-ui/thread.tsx | 5 ++++- apps/desktop/src/components/chat/terminal-output.tsx | 6 +++++- apps/desktop/src/styles.css | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index c5b20cedd3e..1ac97c200ca 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -859,7 +859,10 @@ const ProcessNotificationNote: FC<{ text: string }> = ({ text }) => { output -
+          
             {detail}
           
diff --git a/apps/desktop/src/components/chat/terminal-output.tsx b/apps/desktop/src/components/chat/terminal-output.tsx index 946ec2386be..034f20f2a81 100644 --- a/apps/desktop/src/components/chat/terminal-output.tsx +++ b/apps/desktop/src/components/chat/terminal-output.tsx @@ -41,7 +41,11 @@ export function TerminalOutput({ className, text }: TerminalOutputProps) { }, [text]) return ( -
+
         {text}
       
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 03b348c9d84..2aff7a21c77 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -680,6 +680,7 @@ textarea, [contenteditable]:not([contenteditable='false']), [data-slot='aui_user-message-root'], [data-slot='aui_assistant-message-content'], +[data-slot='aui_system-message-root'], [data-selectable-text='true'], [data-selectable-text='true'] * { -webkit-user-select: text; From a7b4fbcbc179dd51913f065dc2fe44d862ac5464 Mon Sep 17 00:00:00 2001 From: srojk34 <286497132+srojk34@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:49:38 +0300 Subject: [PATCH 076/470] fix(tui): guard /update against hosted dashboard mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /update calls dieWithCode(42) which tears down the gateway and hard-exits the Node process — the same PTY-killing path that /exit and /quit use. In the hosted dashboard chat there is no Python update wrapper to catch exit code 42, and the PTY death bricks the tab until a browser refresh. Mirror the DASHBOARD_TUI_MODE guard that #48882 added for /exit and /quit: refuse early with an explanatory message. --- .../src/__tests__/createSlashHandler.test.ts | 18 +++++++++++++++++- ui-tui/src/app/slash/commands/core.ts | 9 +++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 415dd4c0f3c..8f49dd9a513 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createSlashHandler } from '../app/createSlashHandler.js' import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' -import { DASHBOARD_EXIT_DISABLED_MESSAGE } from '../app/slash/commands/core.js' +import { DASHBOARD_EXIT_DISABLED_MESSAGE, DASHBOARD_UPDATE_DISABLED_MESSAGE } from '../app/slash/commands/core.js' import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' @@ -118,6 +118,22 @@ describe('createSlashHandler', () => { vi.useRealTimers() }) + it('refuses /update in hosted dashboard chat instead of killing the PTY', () => { + vi.useFakeTimers() + envState.dashboardTuiMode = true + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/update')).toBe(true) + expect(ctx.session.dieWithCode).not.toHaveBeenCalled() + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith(DASHBOARD_UPDATE_DISABLED_MESSAGE) + + vi.advanceTimersByTime(150) + expect(ctx.session.dieWithCode).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + it('routes /status to live session.status instead of slash worker', async () => { patchUiState({ sid: 'sid-abc' }) const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' })) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 7c5a79505ad..5c74eb3eb42 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -81,6 +81,9 @@ const DETAILS_SECTION_USAGE = 'usage: /details
[hidden|collapsed|expan export const DASHBOARD_EXIT_DISABLED_MESSAGE = 'exit is disabled in hosted dashboard chat — use /new to start a fresh session' +export const DASHBOARD_UPDATE_DISABLED_MESSAGE = + 'update is disabled in hosted dashboard chat — the hosted environment is managed separately' + export const coreCommands: SlashCommand[] = [ { help: 'list commands + hotkeys', @@ -140,6 +143,12 @@ export const coreCommands: SlashCommand[] = [ help: 'update Hermes Agent to the latest version (exits TUI)', name: 'update', run: (_arg, ctx) => { + if (DASHBOARD_TUI_MODE) { + ctx.transcript.sys(DASHBOARD_UPDATE_DISABLED_MESSAGE) + + return + } + ctx.transcript.sys('exiting TUI to run update...') // Exit code 42 signals the Python wrapper to exec `hermes update`. // Use dieWithCode for proper cleanup (gateway kill + Ink unmount). From 160bb565b4ec05b89c57808f2b8d425b39591475 Mon Sep 17 00:00:00 2001 From: Cdddo Date: Thu, 18 Jun 2026 20:51:37 -0600 Subject: [PATCH 077/470] feat(tts): expose speaker_id on built-in Piper provider The built-in Piper provider (tts.provider: piper, Python piper-tts package) already constructs piper.SynthesisConfig for the advanced tuning knobs, but did not forward speaker_id from the user config. This wires tts.piper.speaker_id through to SynthesisConfig.speaker_id so multi-speaker ONNX models (e.g. libritts_r) can be addressed via config without dropping to the command-provider path. Changes: - Add speaker_id to the has_advanced tuple so setting it triggers SynthesisConfig construction (same gating as the other knobs). - Pass speaker_id=speaker_id to SynthesisConfig. Defaults to 0 (Piper's own default; single-speaker models ignore the field). - Tolerant parse: bad input (non-int strings, lists, dicts) is dropped to 0 instead of raising. Booleans are rejected outright (True/False would silently coerce to 1/0 and hide a config mistake). Mirrors the same shape as the command-provider's _resolve_command_tts_optional_number helper. speaker_id is applied per-call via syn_config.speaker_id, so the PiperVoice cache key is intentionally left as just (model, cuda) -- the same loaded model serves all speakers. Tests cover the config knob, the tolerant parse, and the no-reload invariant. sentence_silence is intentionally not added here: the Python piper-tts SynthesisConfig does not expose that field (CLI-only). --- tests/tools/test_tts_piper.py | 93 ++++++++++++++++++++++++++++++++++- tools/tts_tool.py | 22 ++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_tts_piper.py b/tests/tools/test_tts_piper.py index c30b26dc9b9..78567adf9bb 100644 --- a/tests/tools/test_tts_piper.py +++ b/tests/tools/test_tts_piper.py @@ -8,6 +8,7 @@ without requiring the ``piper-tts`` package to actually be installed import json import sys +import types from pathlib import Path from unittest.mock import MagicMock, patch @@ -219,7 +220,7 @@ class TestGeneratePiperTts: # The SynthesisConfig import happens inline inside _generate_piper_tts # via ``from piper import SynthesisConfig``. Inject a fake piper - # module so that import resolves. + # module so that that import resolves. monkeypatch.setitem(sys.modules, "piper", FakePiperModule) config = { @@ -239,6 +240,96 @@ class TestGeneratePiperTts: assert kwargs["length_scale"] == 2.0 assert kwargs["volume"] == 0.8 + def test_speaker_id_passed_through_to_synconfig(self, tmp_path, monkeypatch): + """speaker_id flows from config to SynthesisConfig when set.""" + model = self._prepare_voice_files(tmp_path) + monkeypatch.setattr(tts_tool, "_import_piper", lambda: _StubPiperVoice) + + fake_syn_cls = MagicMock() + monkeypatch.setitem(sys.modules, "piper", types.SimpleNamespace(SynthesisConfig=fake_syn_cls)) + + config = {"piper": {"voice": str(model), "speaker_id": 2}} + tts_tool._generate_piper_tts("hi", str(tmp_path / "out.wav"), config) + + fake_syn_cls.assert_called_once() + assert fake_syn_cls.call_args.kwargs["speaker_id"] == 2 + + def test_speaker_id_alone_triggers_synconfig(self, tmp_path, monkeypatch): + """Setting ONLY speaker_id (no other advanced knobs) still constructs SynthesisConfig. + + Regression guard: has_advanced must include speaker_id, otherwise + this knob gets silently dropped on the simplest configuration. + """ + model = self._prepare_voice_files(tmp_path) + monkeypatch.setattr(tts_tool, "_import_piper", lambda: _StubPiperVoice) + + fake_syn_cls = MagicMock() + monkeypatch.setitem(sys.modules, "piper", types.SimpleNamespace(SynthesisConfig=fake_syn_cls)) + + config = {"piper": {"voice": str(model), "speaker_id": 1}} + tts_tool._generate_piper_tts("hi", str(tmp_path / "out.wav"), config) + + fake_syn_cls.assert_called_once() + + def test_speaker_id_default_zero_when_unset(self, tmp_path, monkeypatch): + """No speaker_id in config → SynthesisConfig.speaker_id == 0 (Piper's default).""" + model = self._prepare_voice_files(tmp_path) + monkeypatch.setattr(tts_tool, "_import_piper", lambda: _StubPiperVoice) + + fake_syn_cls = MagicMock() + monkeypatch.setitem(sys.modules, "piper", types.SimpleNamespace(SynthesisConfig=fake_syn_cls)) + + config = {"piper": {"voice": str(model), "length_scale": 1.5}} + tts_tool._generate_piper_tts("hi", str(tmp_path / "out.wav"), config) + + assert fake_syn_cls.call_args.kwargs["speaker_id"] == 0 + + def test_speaker_id_bool_rejected_to_zero(self, tmp_path, monkeypatch): + """True/False would coerce to 1/0 and hide a config mistake — reject outright.""" + model = self._prepare_voice_files(tmp_path) + monkeypatch.setattr(tts_tool, "_import_piper", lambda: _StubPiperVoice) + + fake_syn_cls = MagicMock() + monkeypatch.setitem(sys.modules, "piper", types.SimpleNamespace(SynthesisConfig=fake_syn_cls)) + + for bad in (True, False): + fake_syn_cls.reset_mock() + config = {"piper": {"voice": str(model), "speaker_id": bad}} + tts_tool._generate_piper_tts("hi", str(tmp_path / f"out-{bad}.wav"), config) + assert fake_syn_cls.call_args.kwargs["speaker_id"] == 0 + + def test_speaker_id_non_int_dropped_to_zero(self, tmp_path, monkeypatch): + """Unparseable config (string, list, dict) drops to 0 instead of raising.""" + model = self._prepare_voice_files(tmp_path) + monkeypatch.setattr(tts_tool, "_import_piper", lambda: _StubPiperVoice) + + fake_syn_cls = MagicMock() + monkeypatch.setitem(sys.modules, "piper", types.SimpleNamespace(SynthesisConfig=fake_syn_cls)) + + for bad in ("two", [1, 2], {"k": 1}, None): + fake_syn_cls.reset_mock() + config = {"piper": {"voice": str(model), "speaker_id": bad}} + tts_tool._generate_piper_tts("hi", str(tmp_path / f"out-{type(bad).__name__}.wav"), config) + assert fake_syn_cls.call_args.kwargs["speaker_id"] == 0 + + def test_speaker_id_does_not_invalidate_voice_cache(self, tmp_path, monkeypatch): + """Switching speaker_id between calls must NOT trigger a model reload. + + PiperVoice is bound to a model, not a speaker — speaker is applied + per-call via syn_config.speaker_id. The voice cache should serve the + same PiperVoice instance for the same (model, cuda) regardless of + how many distinct speaker_ids the user cycles through. + """ + model = self._prepare_voice_files(tmp_path) + monkeypatch.setattr(tts_tool, "_import_piper", lambda: _StubPiperVoice) + + for speaker in (0, 1, 2, 3): + config = {"piper": {"voice": str(model), "speaker_id": speaker}} + tts_tool._generate_piper_tts("hi", str(tmp_path / f"out-{speaker}.wav"), config) + + # Only one PiperVoice.load() call across four calls with different speakers. + assert _StubPiperVoice.loaded == [str(model)] + # --------------------------------------------------------------------------- # text_to_speech_tool end-to-end (provider == "piper") diff --git a/tools/tts_tool.py b/tools/tts_tool.py index c6e7c22de0f..02fe4e5bda5 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -1889,6 +1889,18 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) model_path = _resolve_piper_voice_path(voice_name, download_dir) + # Tolerant speaker_id parse: drop bad input (non-int strings, lists, dicts) + # to 0 (Piper's own default). Booleans are rejected outright — True/False + # would silently coerce to 1/0 and hide a config mistake. + _raw_speaker = piper_config.get("speaker_id", 0) + if isinstance(_raw_speaker, bool) or not isinstance(_raw_speaker, int): + speaker_id = 0 + else: + speaker_id = _raw_speaker + + # speaker_id is applied per-call via syn_config.speaker_id — the same + # PiperVoice instance serves all speakers, so it stays out of the cache + # key. Multi-speaker workflows share one model load. cache_key = f"{model_path}::cuda={use_cuda}" global _piper_voice_cache if cache_key not in _piper_voice_cache: @@ -1903,7 +1915,14 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) syn_config = None has_advanced = any( k in piper_config - for k in ("length_scale", "noise_scale", "noise_w_scale", "volume", "normalize_audio") + for k in ( + "length_scale", + "noise_scale", + "noise_w_scale", + "volume", + "normalize_audio", + "speaker_id", + ) ) if has_advanced: try: @@ -1914,6 +1933,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) noise_w_scale=float(piper_config.get("noise_w_scale", 0.8)), volume=float(piper_config.get("volume", 1.0)), normalize_audio=bool(piper_config.get("normalize_audio", True)), + speaker_id=speaker_id, ) except ImportError: logger.warning( From ddca590cac5443f72b09039906f41aa259cef004 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 19 Jun 2026 06:46:47 -0700 Subject: [PATCH 078/470] chore: add Cdddo to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 0ff464e61f0..452b59964e3 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -57,6 +57,7 @@ AUTHOR_MAP = { "despitemeguru@gmail.com": "definitelynotguru", "chaslui@outlook.com": "ChasLui", "rio.jeong@thebytesize.ai": "rio-jeong", + "cdddo@users.noreply.github.com": "Cdddo", "yehaotian@xuanshudeMac-mini.local": "ArcanePivot", "dbeyer7@gmail.com": "benegessarit", "264773240+MrDiamondBallz@users.noreply.github.com": "MrDiamondBallz", From 01a6f11896673764a97fd51a5a36dfc73e8ab0b9 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:07:47 +0530 Subject: [PATCH 079/470] fix(debug): include gui.log (dashboard/TUI/pty/websocket) in hermes debug share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gui.log was registered in hermes_cli/logs.py::LOG_FILES (and surfaced by `hermes logs gui`) but was never wired into `hermes debug share`. The share report captured agent/errors/gateway/desktop tails plus full agent/gateway/ desktop logs — but nothing from gui.log, the surface the dashboard, TUI-over- PTY bridge, and websocket layer (hermes_cli.web_server / pty_bridge / tui_gateway) actually write to. A user reporting a dashboard or TUI bug shared zero breadcrumbs from the broken surface. Wire gui.log through all three share surfaces, matching the existing pattern: - _capture_default_log_snapshots(): capture the gui snapshot (redacted like the rest) - collect_debug_report(): add the gui.log summary tail block - build_debug_share(): pull gui full_text, prepend dump header + redaction banner, add to the upload loop - run_debug_share() --local branch: same, plus the local print block - _PRIVACY_NOTICE: name gui.log in both bullets Redaction is inherited for free — the gui snapshot goes through the same _capture_log_snapshot(..., redact=redact) path, so secrets are scrubbed in both the tail and full text (verified E2E: seeded key masked by default, passes through under --no-redact, raw token never leaks). Tests: seed gui.log in the fixture, add test_report_includes_gui_log, and bump the upload-count tripwire 4->5 (test_share_uploads_five_pastes). --- hermes_cli/debug.py | 27 ++++++++++++++++++++---- tests/hermes_cli/test_debug.py | 29 ++++++++++++++++++++------ website/docs/reference/cli-commands.md | 2 +- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index 809676d1fc8..e5627f24bf5 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -191,10 +191,10 @@ _PRIVACY_NOTICE = """\ ⚠️ This will upload the following to a public paste service: • System info (OS, Python version, Hermes version, provider, which API keys are configured — NOT the actual keys) - • Recent log lines (agent.log, errors.log, gateway.log, desktop.log — may - contain conversation fragments and file paths) - • Full agent.log, gateway.log, and desktop.log (up to 512 KB each — likely - contains conversation content, tool outputs, and file paths) + • Recent log lines (agent.log, errors.log, gateway.log, gui.log, desktop.log + — may contain conversation fragments and file paths) + • Full agent.log, gateway.log, gui.log, and desktop.log (up to 512 KB each — + likely contains conversation content, tool outputs, and file paths) Pastes auto-delete after 6 hours. """ @@ -503,6 +503,9 @@ def _capture_default_log_snapshots( "gateway": _capture_log_snapshot( "gateway", tail_lines=errors_lines, redact=redact ), + "gui": _capture_log_snapshot( + "gui", tail_lines=errors_lines, redact=redact + ), "desktop": _capture_log_snapshot( "desktop", tail_lines=errors_lines, redact=redact ), @@ -574,6 +577,10 @@ def collect_debug_report( buf.write(log_snapshots["gateway"].tail_text) buf.write("\n\n") + buf.write(f"--- gui.log (last {errors_lines} lines) ---\n") + buf.write(log_snapshots["gui"].tail_text) + buf.write("\n\n") + buf.write(f"--- desktop.log (last {errors_lines} lines) ---\n") buf.write(log_snapshots["desktop"].tail_text) buf.write("\n") @@ -639,6 +646,7 @@ def build_debug_share( ) agent_log = log_snapshots["agent"].full_text gateway_log = log_snapshots["gateway"].full_text + gui_log = log_snapshots["gui"].full_text desktop_log = log_snapshots["desktop"].full_text # Prepend dump header to each full log so every paste is self-contained. @@ -646,6 +654,8 @@ def build_debug_share( agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log if gateway_log: gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log + if gui_log: + gui_log = dump_text + "\n\n--- full gui.log ---\n" + gui_log if desktop_log: desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log @@ -657,6 +667,8 @@ def build_debug_share( agent_log = _REDACTION_BANNER + agent_log if gateway_log: gateway_log = _REDACTION_BANNER + gateway_log + if gui_log: + gui_log = _REDACTION_BANNER + gui_log if desktop_log: desktop_log = _REDACTION_BANNER + desktop_log @@ -670,6 +682,7 @@ def build_debug_share( for label, content in ( ("agent.log", agent_log), ("gateway.log", gateway_log), + ("gui.log", gui_log), ("desktop.log", desktop_log), ): if not content: @@ -712,11 +725,14 @@ def run_debug_share(args): ) agent_log = log_snapshots["agent"].full_text gateway_log = log_snapshots["gateway"].full_text + gui_log = log_snapshots["gui"].full_text desktop_log = log_snapshots["desktop"].full_text if agent_log: agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log if gateway_log: gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log + if gui_log: + gui_log = dump_text + "\n\n--- full gui.log ---\n" + gui_log if desktop_log: desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log if redact: @@ -725,12 +741,15 @@ def run_debug_share(args): agent_log = _REDACTION_BANNER + agent_log if gateway_log: gateway_log = _REDACTION_BANNER + gateway_log + if gui_log: + gui_log = _REDACTION_BANNER + gui_log if desktop_log: desktop_log = _REDACTION_BANNER + desktop_log print(report) for title, body in ( ("FULL agent.log", agent_log), ("FULL gateway.log", gateway_log), + ("FULL gui.log", gui_log), ("FULL desktop.log", desktop_log), ): if body: diff --git a/tests/hermes_cli/test_debug.py b/tests/hermes_cli/test_debug.py index 615e379f7d2..f8d958ffa86 100644 --- a/tests/hermes_cli/test_debug.py +++ b/tests/hermes_cli/test_debug.py @@ -31,6 +31,9 @@ def hermes_home(tmp_path, monkeypatch): (logs_dir / "gateway.log").write_text( "2026-04-12 17:00:10 INFO gateway.run: started\n" ) + (logs_dir / "gui.log").write_text( + "2026-04-12 17:00:12 INFO hermes_cli.web_server: dashboard request\n" + ) (logs_dir / "desktop.log").write_text( "2026-04-12 17:00:15 INFO desktop: backend spawned\n" ) @@ -454,6 +457,15 @@ class TestCollectDebugReport: assert "--- gateway.log" in report + def test_report_includes_gui_log(self, hermes_home): + from hermes_cli.debug import collect_debug_report + + with patch("hermes_cli.dump.run_dump"): + report = collect_debug_report(log_lines=50) + + assert "--- gui.log" in report + assert "dashboard request" in report + def test_report_includes_desktop_log(self, hermes_home): from hermes_cli.debug import collect_debug_report @@ -538,8 +550,8 @@ class TestRunDebugShare: assert "FULL agent.log" in out assert "FULL gateway.log" in out - def test_share_uploads_four_pastes(self, hermes_home, capsys): - """Successful share uploads report + agent.log + gateway.log + desktop.log.""" + def test_share_uploads_five_pastes(self, hermes_home, capsys): + """Successful share uploads report + agent.log + gateway.log + gui.log + desktop.log.""" from hermes_cli.debug import run_debug_share args = MagicMock() @@ -561,15 +573,17 @@ class TestRunDebugShare: run_debug_share(args) out = capsys.readouterr().out - # Should have 4 uploads: report, agent.log, gateway.log, desktop.log - assert call_count[0] == 4 + # Should have 5 uploads: report, agent.log, gateway.log, gui.log, desktop.log + assert call_count[0] == 5 assert "paste.rs/paste1" in out # Report assert "paste.rs/paste2" in out # agent.log assert "paste.rs/paste3" in out # gateway.log - assert "paste.rs/paste4" in out # desktop.log + assert "paste.rs/paste4" in out # gui.log + assert "paste.rs/paste5" in out # desktop.log assert "Report" in out assert "agent.log" in out assert "gateway.log" in out + assert "gui.log" in out assert "desktop.log" in out # Each log paste should start with the dump header @@ -579,7 +593,10 @@ class TestRunDebugShare: gateway_paste = uploaded_content[2] assert "--- hermes dump ---" in gateway_paste assert "--- full gateway.log ---" in gateway_paste - desktop_paste = uploaded_content[3] + gui_paste = uploaded_content[3] + assert "--- hermes dump ---" in gui_paste + assert "--- full gui.log ---" in gui_paste + desktop_paste = uploaded_content[4] assert "--- hermes dump ---" in desktop_paste assert "--- full desktop.log ---" in desktop_paste diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 3071ac0e5fc..90bc1ef83a6 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -734,7 +734,7 @@ Upload a debug report (system info + recent logs) to a paste service and get a s | `--expire ` | Paste expiry in days (default: 7). | | `--local` | Print the report locally instead of uploading. | -The report includes system info (OS, Python version, Hermes version), recent agent and gateway logs (512 KB limit per file), and redacted API key status. Keys are always redacted — no secrets are uploaded. +The report includes system info (OS, Python version, Hermes version), recent agent, gateway, GUI/dashboard, and desktop logs (512 KB limit per file), and redacted API key status. Keys are always redacted — no secrets are uploaded. Paste services tried in order: paste.rs, dpaste.com. From c1ffd4c3b4cfb8c3daa33594d908d5985825d48b Mon Sep 17 00:00:00 2001 From: OYLFLMH Date: Thu, 18 Jun 2026 07:59:37 +0000 Subject: [PATCH 080/470] fix(cli): make refresh_interval configurable, default to 0 (disabled) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 6724daa2c added refresh_interval=1.0 to keep the idle clock ticking, but unconditional 1 Hz redraws in non-fullscreen prompt_toolkit mode cause terminal emulators (Xshell, iTerm2, Windows Terminal) to auto-scroll to the bottom on every tick — breaking scroll-up to read history. Drive it from display.cli_refresh_interval (0 = disabled, the default) so users who want the ticking clock can opt in without affecting everyone. Fixes: #48309 Related: 6724daa2c, 8972a151a --- cli.py | 14 +++++++------- hermes_cli/config.py | 6 ++++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/cli.py b/cli.py index f6a9393d34a..e0a8676ceee 100644 --- a/cli.py +++ b/cli.py @@ -13527,13 +13527,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): style=style, full_screen=False, mouse_support=False, - # The status bar contains wall-clock read-outs (live prompt elapsed - # and idle-since-last-turn). Once a turn finishes there may be no - # further events to invalidate the app, so prompt_toolkit would keep - # rendering the first post-turn value (usually ``✓ 0s``) forever. - # A low-rate refresh keeps the clock honest without reintroducing a - # custom repaint thread or touching conversation state. - refresh_interval=1.0, + # Read from display.cli_refresh_interval (default 0 = disabled). + # When non-zero, prompt_toolkit redraws the UI on this cadence + # during idle, keeping wall-clock status-bar read-outs ticking. + # Set to 0 to suppress background redraws entirely — avoids + # fighting terminal auto-scroll in non-fullscreen mode (Xshell, + # iTerm2, Windows Terminal). See #48309. + refresh_interval=float(CLI_CONFIG.get("display", {}).get("cli_refresh_interval", 0)), # Erase the live bottom chrome (status bar, input box, separator # rules) on exit instead of freezing a final copy into scrollback. # Without this, prompt_toolkit's render_as_done teardown repaints diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c81df25c03b..3b12cacb37b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1581,6 +1581,12 @@ DEFAULT_CONFIG = { # TUI busy indicator style: kaomoji (default), emoji, unicode (braille # spinner), or ascii. Live-swappable via `/indicator -

Signed in to Google.

-

You can close this tab and return to your terminal.

-""" - -_ERROR_PAGE = """ -Hermes — sign-in failed - -

Sign-in failed

{message}

-

Return to your terminal — Hermes will walk you through a manual paste fallback.

-""" - - -def _bind_callback_server(preferred_port: int = DEFAULT_REDIRECT_PORT) -> Tuple[http.server.HTTPServer, int]: - try: - server = http.server.HTTPServer((REDIRECT_HOST, preferred_port), _OAuthCallbackHandler) - return server, preferred_port - except OSError as exc: - logger.info( - "Preferred OAuth callback port %d unavailable (%s); requesting ephemeral port", - preferred_port, exc, - ) - server = http.server.HTTPServer((REDIRECT_HOST, 0), _OAuthCallbackHandler) - return server, server.server_address[1] - - -def _is_headless() -> bool: - return any(os.getenv(k) for k in _HEADLESS_ENV_VARS) - - -# ============================================================================= -# Main login flow -# ============================================================================= - -def start_oauth_flow( - *, - force_relogin: bool = False, - open_browser: bool = True, - callback_wait_seconds: float = CALLBACK_WAIT_SECONDS, - project_id: str = "", -) -> GoogleCredentials: - """Run the interactive browser OAuth flow and persist credentials. - - Args: - force_relogin: If False and valid creds already exist, return them. - open_browser: If False, skip webbrowser.open and print the URL only. - callback_wait_seconds: Max seconds to wait for the browser callback. - project_id: Initial GCP project ID to bake into the stored creds. - Can be discovered/updated later via update_project_ids(). - """ - if not force_relogin: - existing = load_credentials() - if existing and existing.access_token: - logger.info("Google OAuth credentials already present; skipping login.") - return existing - - client_id = _require_client_id() # raises GoogleOAuthError with install hints - client_secret = _get_client_secret() - - verifier, challenge = _generate_pkce_pair() - state = secrets.token_urlsafe(16) - - # If headless, skip the listener and go straight to paste mode - if _is_headless() and open_browser: - logger.info("Headless environment detected; using paste-mode OAuth fallback.") - return _paste_mode_login(verifier, challenge, state, client_id, client_secret, project_id) - - server, port = _bind_callback_server(DEFAULT_REDIRECT_PORT) - redirect_uri = f"http://{REDIRECT_HOST}:{port}{CALLBACK_PATH}" - - _OAuthCallbackHandler.expected_state = state - _OAuthCallbackHandler.captured_code = None - _OAuthCallbackHandler.captured_error = None - ready = threading.Event() - _OAuthCallbackHandler.ready = ready - - params = { - "client_id": client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "scope": OAUTH_SCOPES, - "state": state, - "code_challenge": challenge, - "code_challenge_method": "S256", - "access_type": "offline", - "prompt": "consent", - } - auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params) + "#hermes" - - server_thread = threading.Thread(target=server.serve_forever, daemon=True) - server_thread.start() - - print() - print("Opening your browser to sign in to Google…") - print(f"If it does not open automatically, visit:\n {auth_url}") - print() - - if open_browser: - try: - import webbrowser - - try: - from hermes_cli.auth import ( - _can_open_graphical_browser as _can_open_gui, - ) - except Exception: - _can_open_gui = lambda: True # noqa: E731 - - if _can_open_gui(): - webbrowser.open(auth_url, new=1, autoraise=True) - except Exception as exc: - logger.debug("webbrowser.open failed: %s", exc) - - code: Optional[str] = None - try: - if ready.wait(timeout=callback_wait_seconds): - code = _OAuthCallbackHandler.captured_code - error = _OAuthCallbackHandler.captured_error - if error: - raise GoogleOAuthError( - f"Authorization failed: {error}", - code="google_oauth_authorization_failed", - ) - else: - logger.info("Callback server timed out — offering manual paste fallback.") - code = _prompt_paste_fallback() - finally: - try: - server.shutdown() - except Exception: - pass - try: - server.server_close() - except Exception: - pass - server_thread.join(timeout=2.0) - - if not code: - raise GoogleOAuthError( - "No authorization code received. Aborting.", - code="google_oauth_no_code", - ) - - token_resp = exchange_code( - code, verifier, redirect_uri, - client_id=client_id, client_secret=client_secret, - ) - return _persist_token_response(token_resp, project_id=project_id) - - -def _paste_mode_login( - verifier: str, - challenge: str, - state: str, - client_id: str, - client_secret: str, - project_id: str, -) -> GoogleCredentials: - """Run OAuth flow without a local callback server.""" - # Use a placeholder redirect URI; user will paste the full URL back - redirect_uri = f"http://{REDIRECT_HOST}:{DEFAULT_REDIRECT_PORT}{CALLBACK_PATH}" - params = { - "client_id": client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "scope": OAUTH_SCOPES, - "state": state, - "code_challenge": challenge, - "code_challenge_method": "S256", - "access_type": "offline", - "prompt": "consent", - } - auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params) + "#hermes" - - print() - print("Open this URL in a browser on any device:") - print(f" {auth_url}") - print() - print("After signing in, Google will redirect to localhost (which won't load).") - print("Copy the full URL from your browser and paste it below.") - print() - - code = _prompt_paste_fallback() - if not code: - raise GoogleOAuthError("No authorization code provided.", code="google_oauth_no_code") - - token_resp = exchange_code( - code, verifier, redirect_uri, - client_id=client_id, client_secret=client_secret, - ) - return _persist_token_response(token_resp, project_id=project_id) - - -def _prompt_paste_fallback() -> Optional[str]: - print() - print("Paste the full redirect URL Google showed you, OR just the 'code=' parameter value.") - raw = input("Callback URL or code: ").strip() - if not raw: - return None - if raw.startswith("http://") or raw.startswith("https://"): - parsed = urllib.parse.urlparse(raw) - params = urllib.parse.parse_qs(parsed.query) - return (params.get("code") or [""])[0] or None - # Accept a bare query string as well - if raw.startswith("?"): - params = urllib.parse.parse_qs(raw[1:]) - return (params.get("code") or [""])[0] or None - return raw - - -def _persist_token_response( - token_resp: Dict[str, Any], - *, - project_id: str = "", -) -> GoogleCredentials: - access_token = str(token_resp.get("access_token", "") or "").strip() - refresh_token = str(token_resp.get("refresh_token", "") or "").strip() - expires_in = int(token_resp.get("expires_in", 0) or 0) - if not access_token or not refresh_token: - raise GoogleOAuthError( - "Google token response missing access_token or refresh_token.", - code="google_oauth_incomplete_token_response", - ) - creds = GoogleCredentials( - access_token=access_token, - refresh_token=refresh_token, - expires_ms=int((time.time() + max(60, expires_in)) * 1000), - email=_fetch_user_email(access_token), - project_id=project_id, - managed_project_id="", - ) - save_credentials(creds) - logger.info("Google OAuth credentials saved to %s", _credentials_path()) - return creds - - -# ============================================================================= -# Pool-compatible variant -# ============================================================================= - -def run_gemini_oauth_login_pure() -> Dict[str, Any]: - """Run the login flow and return a dict matching the credential pool shape.""" - creds = start_oauth_flow(force_relogin=True) - return { - "access_token": creds.access_token, - "refresh_token": creds.refresh_token, - "expires_at_ms": creds.expires_ms, - "email": creds.email, - "project_id": creds.project_id, - } - - -# ============================================================================= -# Project ID resolution -# ============================================================================= - -def resolve_project_id_from_env() -> str: - """Return a GCP project ID from env vars, in priority order.""" - for var in ( - "HERMES_GEMINI_PROJECT_ID", - "GOOGLE_CLOUD_PROJECT", - "GOOGLE_CLOUD_PROJECT_ID", - ): - val = (os.getenv(var) or "").strip() - if val: - return val - return "" diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 9a4794732d3..42e81dc30e7 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -437,10 +437,6 @@ class ChatCompletionsTransport(ProviderTransport): extra_body["extra_body"] = openai_compat_extra elif raw_thinking_config: extra_body["thinking_config"] = raw_thinking_config - elif provider_name in {"google-gemini-cli", "google-antigravity"}: - thinking_config = _build_gemini_thinking_config(model, reasoning_config) - if thinking_config: - extra_body["thinking_config"] = thinking_config # Merge any pre-built extra_body additions additions = params.get("extra_body_additions") diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 5fc9ba134cc..5295cd6866f 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -74,7 +74,6 @@ export const PROVIDER_GROUPS: ProviderPrefix[] = [ priority: 4 }, { prefix: 'GEMINI_', name: 'Gemini', priority: 4 }, - { prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 }, { prefix: 'DEEPSEEK_', name: 'DeepSeek', diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 1a8d0eba994..847d4d65ae7 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -132,9 +132,9 @@ describe('settings helpers', () => { // KIMI_CN_ likewise must beat KIMI_. expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)') expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot') - // HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem. + // HERMES_QWEN_ shares the HERMES_ stem with other integrations. expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)') - expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini') + expect(providerGroup('GEMINI_API_KEY')).toBe('Gemini') }) it('falls back to "Other" for un-grouped env vars', () => { diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index f9ae934edf4..7d24460f046 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -150,7 +150,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ const NO_DESKTOP_SURFACE: Record = { terminal: [ '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details', - '/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs', + '/exit', '/footer', '/gateway', '/history', '/image', '/indicator', '/logs', '/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart', '/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose' ], diff --git a/cli.py b/cli.py index 10846775fc2..4627ce2b2af 100644 --- a/cli.py +++ b/cli.py @@ -7837,8 +7837,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._handle_model_switch(cmd_original) elif canonical == "codex-runtime": self._handle_codex_runtime(cmd_original) - elif canonical == "gquota": - self._handle_gquota_command(cmd_original) elif canonical == "personality": # Use original case (handler lowercases the personality name itself) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0756a6fdad7..4271ec20417 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -138,13 +138,6 @@ SERVICE_PROVIDER_NAMES: Dict[str, str] = { "spotify": "Spotify", } -# Google Gemini OAuth (google-gemini-cli provider, Cloud Code Assist backend) -DEFAULT_GEMINI_CLOUDCODE_BASE_URL = "cloudcode-pa://google" -GEMINI_OAUTH_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60 # refresh 60s before expiry - -# Google Antigravity OAuth (Antigravity Code Assist backend) -DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL = "antigravity-pa://google" - # LM Studio's default no-auth mode still requires *some* non-empty bearer for # the API-key code paths (auxiliary_client, runtime resolver) to treat the # provider as configured. This sentinel is sent only to LM Studio, never to @@ -209,18 +202,6 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { auth_type="oauth_external", inference_base_url=DEFAULT_QWEN_BASE_URL, ), - "google-gemini-cli": ProviderConfig( - id="google-gemini-cli", - name="Google Gemini (OAuth)", - auth_type="oauth_external", - inference_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL, - ), - "google-antigravity": ProviderConfig( - id="google-antigravity", - name="Google Antigravity (OAuth)", - auth_type="oauth_external", - inference_base_url=DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL, - ), "lmstudio": ProviderConfig( id="lmstudio", name="LM Studio", @@ -1538,8 +1519,7 @@ def resolve_provider( "github-models": "copilot", "github-model": "copilot", "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp", "opencode": "opencode-zen", "zen": "opencode-zen", - "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "google-gemini-cli": "google-gemini-cli", "gemini-cli": "google-gemini-cli", "gemini-oauth": "google-gemini-cli", - "google-antigravity": "google-antigravity", "google-antigravity-oauth": "google-antigravity", "antigravity": "google-antigravity", "antigravity-oauth": "google-antigravity", "antigravity-cli": "google-antigravity", "agy": "google-antigravity", "agy-cli": "google-antigravity", + "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", "tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub", @@ -2165,163 +2145,6 @@ def get_qwen_auth_status() -> Dict[str, Any]: # ============================================================================= -# Google Gemini OAuth (google-gemini-cli) — PKCE flow + Cloud Code Assist. -# -# Tokens live in ~/.hermes/auth/google_oauth.json (managed by agent.google_oauth). -# The `base_url` here is the marker "cloudcode-pa://google" that run_agent.py -# uses to construct a GeminiCloudCodeClient instead of the default OpenAI SDK. -# Actual HTTP traffic goes to https://cloudcode-pa.googleapis.com/v1internal:*. -# ============================================================================= - -def _mark_google_gemini_cli_active(creds: Dict[str, Any]) -> None: - """Set active_provider to google-gemini-cli in auth.json. - - The actual OAuth tokens live in the Google credential file managed by - agent.google_oauth. This function only writes a minimal provider-state - entry (email for display) and sets active_provider so that - get_active_provider() and _model_section_has_credentials() detect the - provider for the setup wizard and status commands. - """ - with _auth_store_lock(): - auth_store = _load_auth_store() - state: Dict[str, Any] = {} - if creds.get("email"): - state["email"] = str(creds["email"]) - _save_provider_state(auth_store, "google-gemini-cli", state) - _save_auth_store(auth_store) - - -def resolve_gemini_oauth_runtime_credentials( - *, - force_refresh: bool = False, -) -> Dict[str, Any]: - """Resolve runtime OAuth creds for google-gemini-cli.""" - try: - from agent.google_oauth import ( - GoogleOAuthError, - _credentials_path, - get_valid_access_token, - load_credentials, - ) - except ImportError as exc: - raise AuthError( - f"agent.google_oauth is not importable: {exc}", - provider="google-gemini-cli", - code="google_oauth_module_missing", - ) from exc - - try: - access_token = get_valid_access_token(force_refresh=force_refresh) - except GoogleOAuthError as exc: - raise AuthError( - str(exc), - provider="google-gemini-cli", - code=exc.code, - ) from exc - - creds = load_credentials() - base_url = DEFAULT_GEMINI_CLOUDCODE_BASE_URL - return { - "provider": "google-gemini-cli", - "base_url": base_url, - "api_key": access_token, - "source": "google-oauth", - "expires_at_ms": (creds.expires_ms if creds else None), - "auth_file": str(_credentials_path()), - "email": (creds.email if creds else "") or "", - "project_id": (creds.project_id if creds else "") or "", - } - - -def get_gemini_oauth_auth_status() -> Dict[str, Any]: - """Return a status dict for `hermes auth list` / `hermes status`.""" - try: - from agent.google_oauth import _credentials_path, load_credentials - except ImportError: - return {"logged_in": False, "error": "agent.google_oauth unavailable"} - auth_path = _credentials_path() - creds = load_credentials() - if creds is None or not creds.access_token: - return { - "logged_in": False, - "auth_file": str(auth_path), - "error": "not logged in", - } - return { - "logged_in": True, - "auth_file": str(auth_path), - "source": "google-oauth", - "api_key": creds.access_token, - "expires_at_ms": creds.expires_ms, - "email": creds.email, - "project_id": creds.project_id, - } - - -def resolve_antigravity_oauth_runtime_credentials( - *, - force_refresh: bool = False, -) -> Dict[str, Any]: - """Resolve runtime OAuth creds for google-antigravity.""" - try: - from agent.antigravity_oauth import ( - AntigravityOAuthError, - _credentials_path, - get_valid_access_token, - load_credentials, - ) - except ImportError as exc: - raise AuthError( - f"agent.antigravity_oauth is not importable: {exc}", - provider="google-antigravity", - code="antigravity_oauth_module_missing", - ) from exc - - try: - access_token = get_valid_access_token(force_refresh=force_refresh) - except AntigravityOAuthError as exc: - raise AuthError( - str(exc), - provider="google-antigravity", - code=exc.code, - ) from exc - - creds = load_credentials() - return { - "provider": "google-antigravity", - "base_url": DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL, - "api_key": access_token, - "source": "antigravity-oauth", - "expires_at_ms": (creds.expires_ms if creds else None), - "auth_file": str(_credentials_path()), - "email": (creds.email if creds else "") or "", - "project_id": (creds.project_id if creds else "") or "", - } - - -def get_antigravity_oauth_auth_status() -> Dict[str, Any]: - """Return a status dict for `hermes auth list` / `hermes status`.""" - try: - from agent.antigravity_oauth import _credentials_path, load_credentials - except ImportError: - return {"logged_in": False, "error": "agent.antigravity_oauth unavailable"} - auth_path = _credentials_path() - creds = load_credentials() - if creds is None or not creds.access_token: - return { - "logged_in": False, - "auth_file": str(auth_path), - "error": "not logged in", - } - return { - "logged_in": True, - "auth_file": str(auth_path), - "source": "antigravity-oauth", - "api_key": creds.access_token, - "expires_at_ms": creds.expires_ms, - "email": creds.email, - "project_id": creds.project_id, - } # Spotify auth — PKCE tokens stored in ~/.hermes/auth.json # ============================================================================= @@ -6265,10 +6088,6 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: return get_xai_oauth_auth_status() if target == "qwen-oauth": return get_qwen_auth_status() - if target == "google-gemini-cli": - return get_gemini_oauth_auth_status() - if target == "google-antigravity": - return get_antigravity_oauth_auth_status() if target == "minimax-oauth": return get_minimax_oauth_auth_status() if target == "copilot-acp": diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index dbec732be45..decf30dea0f 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -34,7 +34,7 @@ from hermes_cli.secret_prompt import masked_secret_prompt # Providers that support OAuth login in addition to API keys. -_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "google-gemini-cli", "google-antigravity", "minimax-oauth"} +_OAUTH_CAPABLE_PROVIDERS = {"anthropic", "nous", "openai-codex", "xai-oauth", "qwen-oauth", "minimax-oauth"} def _get_custom_provider_names() -> list: @@ -314,7 +314,7 @@ def auth_add_command(args) -> None: _oauth_default_label(provider, len(pool.entries()) + 1), ) # Add a distinct, self-contained pool entry per account (matching the - # xai-oauth / google-gemini-cli / qwen-oauth patterns) instead of + # xai-oauth / qwen-oauth patterns) instead of # routing through the singleton ``_save_codex_tokens`` save path. # The singleton round-trip collapsed every added account into the # latest login: a second ``hermes auth add openai-codex`` overwrote @@ -364,49 +364,6 @@ def auth_add_command(args) -> None: print(f'Saved {provider} OAuth credentials: "{shown_label}"') return - if provider == "google-gemini-cli": - from agent.google_oauth import run_gemini_oauth_login_pure - - creds = run_gemini_oauth_login_pure() - auth_mod._mark_google_gemini_cli_active(creds) - label = (getattr(args, "label", None) or "").strip() or ( - creds.get("email") or _oauth_default_label(provider, len(pool.entries()) + 1) - ) - entry = PooledCredential( - provider=provider, - id=uuid.uuid4().hex[:6], - label=label, - auth_type=AUTH_TYPE_OAUTH, - priority=0, - source=f"{SOURCE_MANUAL}:google_pkce", - access_token=creds["access_token"], - refresh_token=creds.get("refresh_token"), - ) - pool.add_entry(entry) - print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') - return - - if provider == "google-antigravity": - from agent.antigravity_oauth import run_antigravity_oauth_login_pure - - creds = run_antigravity_oauth_login_pure() - label = (getattr(args, "label", None) or "").strip() or ( - creds.get("email") or _oauth_default_label(provider, len(pool.entries()) + 1) - ) - entry = PooledCredential( - provider=provider, - id=uuid.uuid4().hex[:6], - label=label, - auth_type=AUTH_TYPE_OAUTH, - priority=0, - source=f"{SOURCE_MANUAL}:antigravity_pkce", - access_token=creds["access_token"], - refresh_token=creds.get("refresh_token"), - ) - pool.add_entry(entry) - print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"') - return - if provider == "qwen-oauth": creds = auth_mod.resolve_qwen_runtime_credentials(refresh_if_expiring=False) auth_mod._mark_qwen_oauth_active(creds) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 499f8e9a1a5..a3e33ddb493 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -947,52 +947,6 @@ class CLICommandsMixin: _cprint(f" Original session: {parent_session_id}") _cprint(f" Branch session: {new_session_id}") - def _handle_gquota_command(self, cmd_original: str) -> None: - """Show Google Gemini Code Assist quota usage for the current OAuth account.""" - try: - from agent.google_oauth import get_valid_access_token, GoogleOAuthError, load_credentials - from agent.google_code_assist import retrieve_user_quota, CodeAssistError - except ImportError as exc: - self._console_print(f" [red]Gemini modules unavailable: {exc}[/]") - return - - try: - access_token = get_valid_access_token() - except GoogleOAuthError as exc: - self._console_print(f" [yellow]{exc}[/]") - self._console_print(" Run [bold]/model[/] and pick 'Google Gemini (OAuth)' to sign in.") - return - - creds = load_credentials() - project_id = (creds.project_id if creds else "") or "" - - try: - buckets = retrieve_user_quota(access_token, project_id=project_id) - except CodeAssistError as exc: - self._console_print(f" [red]Quota lookup failed:[/] {exc}") - return - - if not buckets: - self._console_print(" [dim]No quota buckets reported (account may be on legacy/unmetered tier).[/]") - return - - # Sort for stable display, group by model - buckets.sort(key=lambda b: (b.model_id, b.token_type)) - self._console_print() - self._console_print(f" [bold]Gemini Code Assist quota[/] (project: {project_id or '(auto / free-tier)'})") - self._console_print() - for b in buckets: - pct = max(0.0, min(1.0, b.remaining_fraction)) - width = 20 - filled = int(round(pct * width)) - bar = "▓" * filled + "░" * (width - filled) - pct_str = f"{int(pct * 100):3d}%" - header = b.model_id - if b.token_type: - header += f" [{b.token_type}]" - self._console_print(f" {header:40s} {bar} {pct_str}") - self._console_print() - def _handle_personality_command(self, cmd: str): """Handle the /personality command to set predefined personalities.""" from cli import save_config_value diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 4141f8852e9..2c7a69c4082 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -128,8 +128,6 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models", "Configuration", aliases=("codex_runtime",), args_hint="[auto|codex_app_server]"), - CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info", - cli_only=True), CommandDef("personality", "Set a predefined personality", "Configuration", args_hint="[name]"), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 173f04ec5dd..dd212cfdb8e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -169,8 +169,8 @@ _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") # the dashboard. ``config.yaml`` is the supported surface for these. # # IMPORTANT: ``HERMES_*`` overall is NOT blocked. Many legitimate -# integration credentials follow that prefix (HERMES_GEMINI_CLIENT_ID, -# HERMES_LANGFUSE_PUBLIC_KEY, HERMES_SPOTIFY_CLIENT_ID, ...). The +# integration credentials follow that prefix (HERMES_LANGFUSE_PUBLIC_KEY, +# HERMES_SPOTIFY_CLIENT_ID, ...). The # denylist is name-by-name on purpose so the gate stays narrow and # doesn't accidentally break provider setup wizards. # @@ -3082,62 +3082,6 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, - "HERMES_GEMINI_CLIENT_ID": { - "description": "Google OAuth client ID for google-gemini-cli (optional; defaults to Google's public gemini-cli client)", - "prompt": "Google OAuth client ID (optional — leave empty to use the public default)", - "url": "https://console.cloud.google.com/apis/credentials", - "password": False, - "category": "provider", - "advanced": True, - }, - "HERMES_GEMINI_CLIENT_SECRET": { - "description": "Google OAuth client secret for google-gemini-cli (optional)", - "prompt": "Google OAuth client secret (optional)", - "url": "https://console.cloud.google.com/apis/credentials", - "password": True, - "category": "provider", - "advanced": True, - }, - "HERMES_GEMINI_PROJECT_ID": { - "description": "GCP project ID for paid Gemini tiers (free tier auto-provisions)", - "prompt": "GCP project ID for Gemini OAuth (leave empty for free tier)", - "url": None, - "password": False, - "category": "provider", - "advanced": True, - }, - "HERMES_ANTIGRAVITY_CLIENT_ID": { - "description": "Google OAuth client ID for google-antigravity (optional; discovered from agy when omitted)", - "prompt": "Antigravity OAuth client ID (optional — leave empty to discover from agy)", - "url": "https://console.cloud.google.com/apis/credentials", - "password": False, - "category": "provider", - "advanced": True, - }, - "HERMES_ANTIGRAVITY_CLIENT_SECRET": { - "description": "Google OAuth client secret for google-antigravity (optional)", - "prompt": "Antigravity OAuth client secret (optional)", - "url": "https://console.cloud.google.com/apis/credentials", - "password": True, - "category": "provider", - "advanced": True, - }, - "HERMES_ANTIGRAVITY_CLI_PATH": { - "description": "Path to agy/Antigravity CLI for OAuth client credential discovery", - "prompt": "Antigravity CLI path (leave empty to search PATH/default locations)", - "url": None, - "password": False, - "category": "provider", - "advanced": True, - }, - "HERMES_ANTIGRAVITY_PROJECT_ID": { - "description": "GCP project ID for Antigravity OAuth (auto-discovered when omitted)", - "prompt": "GCP project ID for Antigravity OAuth (leave empty to auto-discover)", - "url": None, - "password": False, - "category": "provider", - "advanced": True, - }, "OPENCODE_ZEN_API_KEY": { "description": "OpenCode Zen API key (pay-as-you-go access to curated models)", "prompt": "OpenCode Zen API key", diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 2998a31e0d4..7aadc58f5f2 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -158,12 +158,6 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool that direct-key problem into the final blocking summary. """ normalized = (provider_label or "").strip().lower() - if normalized in {"google / gemini", "gemini"}: - try: - from hermes_cli.auth import get_gemini_oauth_auth_status - return bool((get_gemini_oauth_auth_status() or {}).get("logged_in")) - except Exception: - return False if normalized == "minimax": try: from hermes_cli.auth import get_minimax_oauth_auth_status @@ -1077,7 +1071,6 @@ def run_doctor(args): from hermes_cli.auth import ( get_nous_auth_status, get_codex_auth_status, - get_gemini_oauth_auth_status, get_minimax_oauth_auth_status, ) @@ -1105,20 +1098,6 @@ def run_doctor(args): "from an existing Codex CLI login)" ) - gemini_status = get_gemini_oauth_auth_status() - if gemini_status.get("logged_in"): - email = gemini_status.get("email") or "" - project = gemini_status.get("project_id") or "" - pieces = [] - if email: - pieces.append(email) - if project: - pieces.append(f"project={project}") - suffix = f" ({', '.join(pieces)})" if pieces else "" - check_ok("Google Gemini OAuth", f"(logged in{suffix})") - else: - check_warn("Google Gemini OAuth", "(not logged in)") - minimax_status = get_minimax_oauth_auth_status() if minimax_status.get("logged_in"): region = minimax_status.get("region", "global") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 99c6c8d2695..62784c1b3dc 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -602,8 +602,6 @@ from hermes_cli.model_setup_flows import ( _model_flow_xai_oauth, _model_flow_qwen_oauth, _model_flow_minimax_oauth, - _model_flow_google_gemini_cli, - _model_flow_google_antigravity, _model_flow_custom, _model_flow_azure_foundry, _model_flow_named_custom, @@ -3073,10 +3071,6 @@ def select_provider_and_model(args=None): _model_flow_qwen_oauth(config, current_model) elif selected_provider == "minimax-oauth": _model_flow_minimax_oauth(config, current_model, args=args) - elif selected_provider == "google-gemini-cli": - _model_flow_google_gemini_cli(config, current_model) - elif selected_provider == "google-antigravity": - _model_flow_google_antigravity(config, current_model) elif selected_provider == "copilot-acp": _model_flow_copilot_acp(config, current_model) elif selected_provider == "copilot": @@ -11254,7 +11248,7 @@ def _build_provider_choices() -> list[str]: # Fallback: static list guarantees the CLI always works return [ "auto", "openrouter", "nous", "openai-codex", "xai-oauth", "copilot-acp", "copilot", - "anthropic", "gemini", "google-gemini-cli", "google-antigravity", "xai", "bedrock", "azure-foundry", + "anthropic", "gemini", "xai", "bedrock", "azure-foundry", "ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee", "nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go", diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index 29fcbe403a5..2c309963a65 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -633,142 +633,6 @@ def _model_flow_minimax_oauth(config, current_model="", args=None): _update_config_for_provider("minimax-oauth", creds["base_url"]) print(f"\u2713 Using MiniMax model: {selected}") -def _model_flow_google_gemini_cli(_config, current_model=""): - """Google Gemini OAuth (PKCE) via Cloud Code Assist — supports free AND paid tiers. - - Flow: - 1. Show upfront warning about Google's ToS stance (per opencode-gemini-auth). - 2. If creds missing, run PKCE browser OAuth via agent.google_oauth. - 3. Resolve project context (env -> config -> auto-discover -> free tier). - 4. Prompt user to pick a model. - 5. Save to ~/.hermes/config.yaml. - """ - from hermes_cli.auth import ( - DEFAULT_GEMINI_CLOUDCODE_BASE_URL, - get_gemini_oauth_auth_status, - resolve_gemini_oauth_runtime_credentials, - _prompt_model_selection, - _save_model_choice, - _update_config_for_provider, - ) - from hermes_cli.models import _PROVIDER_MODELS - - print() - print("⚠ Google considers using the Gemini CLI OAuth client with third-party") - print(" software a policy violation. Some users have reported account") - print(" restrictions. You can use your own API key via 'gemini' provider") - print(" for the lowest-risk experience.") - print() - try: - proceed = input("Continue with OAuth login? [y/N]: ").strip().lower() - except (EOFError, KeyboardInterrupt): - print("Cancelled.") - return - if proceed not in {"y", "yes"}: - print("Cancelled.") - return - - status = get_gemini_oauth_auth_status() - if not status.get("logged_in"): - try: - from agent.google_oauth import resolve_project_id_from_env, start_oauth_flow - - env_project = resolve_project_id_from_env() - start_oauth_flow(force_relogin=True, project_id=env_project) - except Exception as exc: - print(f"OAuth login failed: {exc}") - return - - # Verify creds resolve + trigger project discovery - try: - creds = resolve_gemini_oauth_runtime_credentials(force_refresh=False) - project_id = creds.get("project_id", "") - if project_id: - print(f" Using GCP project: {project_id}") - else: - print( - " No GCP project configured — free tier will be auto-provisioned on first request." - ) - except Exception as exc: - print(f"Failed to resolve Gemini credentials: {exc}") - return - - models = list(_PROVIDER_MODELS.get("google-gemini-cli") or []) - default = current_model or (models[0] if models else "gemini-3-flash-preview") - selected = _prompt_model_selection( - models, - current_model=default, - confirm_provider="google-gemini-cli", - confirm_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL, - ) - if selected: - _save_model_choice(selected) - _update_config_for_provider( - "google-gemini-cli", DEFAULT_GEMINI_CLOUDCODE_BASE_URL - ) - print( - f"Default model set to: {selected} (via Google Gemini OAuth / Code Assist)" - ) - else: - print("No change.") - - -def _model_flow_google_antigravity(_config, current_model=""): - """Google Antigravity OAuth via Antigravity Code Assist. - - Antigravity is Google's consumer successor to the Gemini CLI. It reuses the - Code Assist backend with a distinct OAuth client + scopes. Leaves the - `google-gemini-cli` provider (Enterprise Code Assist) untouched. - """ - from hermes_cli.auth import ( - DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL, - get_antigravity_oauth_auth_status, - resolve_antigravity_oauth_runtime_credentials, - _prompt_model_selection, - _save_model_choice, - _update_config_for_provider, - ) - from hermes_cli.models import provider_model_ids - - status = get_antigravity_oauth_auth_status() - if not status.get("logged_in"): - try: - from agent.antigravity_oauth import resolve_project_id_from_env, start_oauth_flow - - env_project = resolve_project_id_from_env() - start_oauth_flow(force_relogin=True, project_id=env_project) - except Exception as exc: - print(f"OAuth login failed: {exc}") - return - - try: - creds = resolve_antigravity_oauth_runtime_credentials(force_refresh=False) - project_id = creds.get("project_id", "") - if project_id: - print(f" Using Antigravity project: {project_id}") - except Exception as exc: - print(f"Failed to resolve Antigravity credentials: {exc}") - return - - models = provider_model_ids("google-antigravity") - default = current_model or (models[0] if models else "gemini-3-flash-agent") - selected = _prompt_model_selection( - models, - current_model=default, - confirm_provider="google-antigravity", - confirm_base_url=DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL, - ) - if selected: - _save_model_choice(selected) - _update_config_for_provider( - "google-antigravity", DEFAULT_ANTIGRAVITY_CLOUDCODE_BASE_URL - ) - print( - f"Default model set to: {selected} (via Google Antigravity OAuth / Code Assist)" - ) - else: - print("No change.") - def _model_flow_custom(config): """Custom endpoint: collect URL, API key, and model name. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index e57ffa3da0b..86840ab0fa5 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -265,26 +265,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "gemini-3.5-flash", "gemini-3.1-flash-lite-preview", ], - "google-gemini-cli": [ - "gemini-3.1-pro-preview", - "gemini-3-pro-preview", - # Code Assist serves two flash slugs with different access gates - # (gemini-cli models.ts): gemini-3-flash-preview is the preview flash - # that subscription/free-tier OAuth users actually reach, while - # gemini-3.5-flash is GA-channel-gated. Offer both so non-GA users - # aren't stuck with a slug cloudcode-pa 404s for them. - "gemini-3-flash-preview", - "gemini-3.5-flash", - ], - "google-antigravity": [ - "gemini-3-flash-agent", - "gemini-3.5-flash-low", - "gemini-pro-agent", - "gemini-3.1-pro-low", - "claude-sonnet-4-6", - "claude-opus-4-6-thinking", - "gpt-oss-120b-medium", - ], "zai": [ "glm-5.2", "glm-5.1", @@ -1037,8 +1017,6 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (Spawns copilot --acp --stdio)"), ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers"), ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Native Gemini API)"), - ProviderEntry("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth + Code Assist (Code Assist OAuth flow)"), - ProviderEntry("google-antigravity", "Google Antigravity (OAuth)", "Google Antigravity via OAuth + Code Assist (Gemini 3.5/3.1, Claude, GPT-OSS where entitled)"), ProviderEntry("deepseek", "DeepSeek", "DeepSeek (V3, R1, coder, direct API)"), ProviderEntry("xai", "xAI", "xAI Grok (Direct API)"), ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu direct API)"), @@ -1109,7 +1087,7 @@ PROVIDER_GROUPS: dict[str, tuple[str, str, list[str]]] = { "kimi": ("Kimi / Moonshot", "Coding Plan, Moonshot global & China endpoints", ["kimi-coding", "kimi-coding-cn"]), "minimax": ("MiniMax", "Global, OAuth Coding Plan & China endpoints", ["minimax", "minimax-oauth", "minimax-cn"]), "xai": ("xAI Grok", "Direct API or SuperGrok / Premium+ OAuth", ["xai", "xai-oauth"]), - "google": ("Google Gemini", "AI Studio API or OAuth + Code Assist", ["gemini", "google-gemini-cli"]), + "google": ("Google Gemini", "Google AI Studio (API key)", ["gemini"]), "openai": ("OpenAI", "Codex CLI or direct OpenAI API", ["openai-codex", "openai-api"]), "opencode": ("OpenCode", "Zen pay-as-you-go or Go subscription", ["opencode-zen", "opencode-go"]), "copilot": ("GitHub Copilot", "GitHub token API or copilot --acp process", ["copilot", "copilot-acp"]), @@ -1230,14 +1208,6 @@ _PROVIDER_ALIASES = { "qwen": "alibaba", "alibaba-cloud": "alibaba", "qwen-portal": "qwen-oauth", - "gemini-cli": "google-gemini-cli", - "gemini-oauth": "google-gemini-cli", - "antigravity": "google-antigravity", - "antigravity-oauth": "google-antigravity", - "antigravity-cli": "google-antigravity", - "google-antigravity-oauth": "google-antigravity", - "agy": "google-antigravity", - "agy-cli": "google-antigravity", "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", @@ -1805,13 +1775,10 @@ _AGGREGATOR_PROVIDERS = frozenset( ) # Subscription/OAuth providers whose catalogs RE-EXPOSE other vendors' models -# (e.g. google-antigravity serves Claude / Gemini / GPT-OSS where the account -# is entitled). For bare short-alias resolution (`sonnet`, `opus`, ...) these -# must NOT hijack the alias away from the model's native vendor provider -# (`anthropic`, `gemini`, ...). They're tried only as a last resort, after -# every native-vendor catalog. They are NOT aggregators (an explicit switch TO -# them is still valid), so they stay out of _AGGREGATOR_PROVIDERS. -_BORROWED_MODEL_PROVIDERS = frozenset({"google-antigravity"}) +# would be listed here (tried only as a last resort for bare short-alias +# resolution, after every native-vendor catalog, so they never hijack an alias +# away from the model's native vendor). None are currently defined. +_BORROWED_MODEL_PROVIDERS: frozenset[str] = frozenset() def _resolve_static_model_alias( @@ -1863,9 +1830,9 @@ def _resolve_static_model_alias( if provider in current_keys and (matched := _match(provider)): return provider, matched - # Last resort: providers that re-expose other vendors' models (e.g. - # google-antigravity serving Claude). Only reached when no native-vendor - # catalog matched — so `sonnet` resolves to anthropic, not antigravity. + # Last resort: providers that re-expose other vendors' models. Only reached + # when no native-vendor catalog matched — so `sonnet` resolves to anthropic. + # None are currently defined (_BORROWED_MODEL_PROVIDERS is empty). for provider in _BORROWED_MODEL_PROVIDERS: if provider in current_keys and (matched := _match(provider)): return provider, matched @@ -2240,32 +2207,6 @@ def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]: return merged -def _fetch_antigravity_models(*, force_refresh: bool = False) -> list[str]: - try: - from agent import antigravity_oauth - from agent.antigravity_code_assist import ( - fetch_available_models_with_fallbacks, - load_code_assist, - parse_agent_model_ids, - ) - from hermes_cli.auth import resolve_antigravity_oauth_runtime_credentials - - creds = resolve_antigravity_oauth_runtime_credentials(force_refresh=force_refresh) - access_token = str(creds.get("api_key") or "").strip() - project_id = str(creds.get("project_id") or "").strip() - if not access_token: - return [] - if not project_id: - info = load_code_assist(access_token) - project_id = info.project_id - if project_id: - antigravity_oauth.update_project_ids(project_id=project_id, managed_project_id=project_id) - payload = fetch_available_models_with_fallbacks(access_token, project_id=project_id) - return parse_agent_model_ids(payload) - except Exception: - return [] - - def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]: """Return the best known model catalog for a provider. @@ -2296,10 +2237,6 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) return get_codex_model_ids(access_token=access_token) if normalized == "xai-oauth": return list(_PROVIDER_MODELS.get("xai-oauth", _PROVIDER_MODELS.get("xai", []))) - if normalized == "google-antigravity": - live = _fetch_antigravity_models(force_refresh=force_refresh) - if live: - return live if normalized in {"copilot", "copilot-acp"}: try: live = _fetch_github_models(_resolve_copilot_catalog_api_key()) diff --git a/hermes_cli/provider_catalog.py b/hermes_cli/provider_catalog.py index 6dba5d8842f..9f8184be456 100644 --- a/hermes_cli/provider_catalog.py +++ b/hermes_cli/provider_catalog.py @@ -57,7 +57,7 @@ _ACCOUNTS_AUTH_TYPES: frozenset[str] = frozenset( class ProviderDescriptor: """One provider, as seen by every surface (CLI picker + both GUI tabs).""" - slug: str # canonical id, e.g. "google-gemini-cli" + slug: str # canonical id, e.g. "openai-codex" label: str # human display name description: str # one-line description auth_type: str # api_key | oauth_* | external_process | copilot | aws_sdk diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 15c5cb0b508..44f1892d5de 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -76,16 +76,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { base_url_override="https://portal.qwen.ai/v1", base_url_env_var="HERMES_QWEN_BASE_URL", ), - "google-gemini-cli": HermesOverlay( - transport="openai_chat", - auth_type="oauth_external", - base_url_override="cloudcode-pa://google", - ), - "google-antigravity": HermesOverlay( - transport="openai_chat", - auth_type="oauth_external", - base_url_override="antigravity-pa://google", - ), "lmstudio": HermesOverlay( transport="openai_chat", auth_type="api_key", @@ -315,18 +305,6 @@ ALIASES: Dict[str, str] = { "alibaba-coding": "alibaba-coding-plan", "alibaba_coding_plan": "alibaba-coding-plan", - # google-gemini-cli (OAuth + Code Assist) - "gemini-cli": "google-gemini-cli", - "gemini-oauth": "google-gemini-cli", - - # google-antigravity (OAuth + Antigravity Code Assist) - "antigravity": "google-antigravity", - "antigravity-oauth": "google-antigravity", - "antigravity-cli": "google-antigravity", - "google-antigravity-oauth": "google-antigravity", - "agy": "google-antigravity", - "agy-cli": "google-antigravity", - # huggingface "hf": "huggingface", "hugging-face": "huggingface", diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index da0eee11dca..2c5dd0a7fd4 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -26,8 +26,6 @@ from hermes_cli.auth import ( resolve_codex_runtime_credentials, resolve_xai_oauth_runtime_credentials, resolve_qwen_runtime_credentials, - resolve_gemini_oauth_runtime_credentials, - resolve_antigravity_oauth_runtime_credentials, resolve_api_key_provider_credentials, resolve_external_process_provider_credentials, has_usable_secret, @@ -332,12 +330,6 @@ def _resolve_runtime_from_pool_entry( elif provider == "qwen-oauth": api_mode = "chat_completions" base_url = base_url or DEFAULT_QWEN_BASE_URL - elif provider == "google-gemini-cli": - api_mode = "chat_completions" - base_url = base_url or "cloudcode-pa://google" - elif provider == "google-antigravity": - api_mode = "chat_completions" - base_url = base_url or "antigravity-pa://google" elif provider == "minimax-oauth": # MiniMax OAuth tokens are valid only against the Anthropic Messages # compatible endpoint. Do not honor stale model.api_mode values from a @@ -1618,46 +1610,6 @@ def resolve_runtime_provider( "requested_provider": requested_provider, } - if provider == "google-gemini-cli": - try: - creds = resolve_gemini_oauth_runtime_credentials() - return { - "provider": "google-gemini-cli", - "api_mode": "chat_completions", - "base_url": creds.get("base_url", ""), - "api_key": creds.get("api_key", ""), - "source": creds.get("source", "google-oauth"), - "expires_at_ms": creds.get("expires_at_ms"), - "email": creds.get("email", ""), - "project_id": creds.get("project_id", ""), - "requested_provider": requested_provider, - } - except AuthError: - if requested_provider != "auto": - raise - logger.info("Google Gemini OAuth credentials failed; " - "falling through to next provider.") - - if provider == "google-antigravity": - try: - creds = resolve_antigravity_oauth_runtime_credentials() - return { - "provider": "google-antigravity", - "api_mode": "chat_completions", - "base_url": creds.get("base_url", ""), - "api_key": creds.get("api_key", ""), - "source": creds.get("source", "antigravity-oauth"), - "expires_at_ms": creds.get("expires_at_ms"), - "email": creds.get("email", ""), - "project_id": creds.get("project_id", ""), - "requested_provider": requested_provider, - } - except AuthError: - if requested_provider != "auto": - raise - logger.info("Google Antigravity OAuth credentials failed; " - "falling through to next provider.") - if provider == "copilot-acp": creds = resolve_external_process_provider_credentials(provider) return { diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 1c446c81782..bac18131ee2 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -420,7 +420,6 @@ TIPS = [ '/platforms shows gateway and messaging-platform connection status right from inside chat.', '/commands paginates the full slash-command + installed-skill list — useful on platforms without tab completion.', '/toolsets lists every available toolset so you know what -t/--toolsets accepts.', - '/gquota shows Google Gemini Code Assist quota usage with progress bars when that provider is active.', '/voice tts toggles TTS-only mode — agent replies out loud but you still type your prompts.', '/reload-skills re-scans ~/.hermes/skills/ so drop-in skills appear without restarting the session.', '/indicator kaomoji|emoji|unicode|ascii picks the TUI busy-indicator style shown during agent runs.', diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f9fe3307bee..b89eafecfa2 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5640,23 +5640,6 @@ def _claude_code_only_status() -> Dict[str, Any]: return {"logged_in": False, "source": None} -def _gemini_cli_status() -> Dict[str, Any]: - """Status for the google-gemini-cli OAuth provider (Code Assist login).""" - try: - from hermes_cli import auth as hauth - raw = hauth.get_gemini_oauth_auth_status() - except Exception as e: - return {"logged_in": False, "error": str(e)} - return { - "logged_in": bool(raw.get("logged_in")), - "source": raw.get("source") or "google_oauth", - "source_label": raw.get("email") or raw.get("auth_file") or "Google Code Assist", - "token_preview": _truncate_token(raw.get("api_key")), - "expires_at": None, - "has_refresh_token": True, - } - - def _copilot_acp_status() -> Dict[str, Any]: """Status for copilot-acp — credentials are owned by the Copilot CLI. @@ -5736,14 +5719,6 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = ( "docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth", "status_fn": None, # dispatched via auth.get_xai_oauth_auth_status }, - { - "id": "google-gemini-cli", - "name": "Google Gemini (OAuth + Code Assist)", - "flow": "external", - "cli_command": "hermes auth add google-gemini-cli", - "docs_url": "https://ai.google.dev/gemini-api/docs", - "status_fn": _gemini_cli_status, - }, { "id": "copilot-acp", "name": "GitHub Copilot (ACP)", diff --git a/plans/gemini-oauth-provider.md b/plans/gemini-oauth-provider.md deleted file mode 100644 index a466183e805..00000000000 --- a/plans/gemini-oauth-provider.md +++ /dev/null @@ -1,80 +0,0 @@ -# Gemini OAuth Provider — Implementation Plan - -## Goal -Add a first-class `gemini` provider that authenticates via Google OAuth, using the standard Gemini API (not Cloud Code Assist). Users who have a Google AI subscription or Gemini API access can authenticate through the browser without needing to manually copy API keys. - -## Architecture Decision -- **Path A (chosen):** Standard Gemini API at `generativelanguage.googleapis.com/v1beta` -- **NOT Path B:** Cloud Code Assist (`cloudcode-pa.googleapis.com`) — rate-limited free tier, internal API, account ban risk -- Standard `chat_completions` api_mode via OpenAI SDK — no new api_mode needed -- Our own OAuth credentials — NOT sharing tokens with Gemini CLI - -## OAuth Flow -- **Type:** Authorization Code + PKCE (S256) — same pattern as clawdbot/pi-mono -- **Auth URL:** `https://accounts.google.com/o/oauth2/v2/auth` -- **Token URL:** `https://oauth2.googleapis.com/token` -- **Redirect:** `http://localhost:8085/oauth2callback` (localhost callback server) -- **Fallback:** Manual URL paste for remote/WSL/headless environments -- **Scopes:** `https://www.googleapis.com/auth/cloud-platform`, `https://www.googleapis.com/auth/userinfo.email` -- **PKCE:** S256 code challenge, 32-byte random verifier - -## Client ID -- Need to register a "Desktop app" OAuth client on a Nous Research GCP project -- Ship client_id + client_secret in code (Google considers installed app secrets non-confidential) -- Alternatively: accept user-provided client_id via env vars as override - -## Token Lifecycle -- Store at `~/.hermes/gemini_oauth.json` (NOT sharing with `~/.gemini/oauth_creds.json`) -- Fields: `client_id`, `client_secret`, `refresh_token`, `access_token`, `expires_at`, `email` -- File permissions: 0o600 -- Before each API call: check expiry, refresh if within 5 min of expiration -- Refresh: POST to token URL with `grant_type=refresh_token` -- File locking for concurrent access (multiple agent sessions) - -## API Integration -- Base URL: `https://generativelanguage.googleapis.com/v1beta` -- Auth: native Gemini API authentication handled by the provider adapter -- api_mode: `chat_completions` (standard facade over native transport) -- Models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.0-flash, etc. - -## Files to Create/Modify - -### New files -1. `agent/google_oauth.py` — OAuth flow (PKCE, localhost server, token exchange, refresh) - - `start_oauth_flow()` — opens browser, starts callback server - - `exchange_code()` — code → tokens - - `refresh_access_token()` — refresh flow - - `load_credentials()` / `save_credentials()` — file I/O with locking - - `get_valid_access_token()` — check expiry, refresh if needed - - ~200 lines - -### Existing files to modify -2. `hermes_cli/auth.py` — Add ProviderConfig for "gemini" with auth_type="oauth_google" -3. `hermes_cli/models.py` — Add Gemini model catalog -4. `hermes_cli/runtime_provider.py` — Add gemini branch (read OAuth token, build OpenAI client) -5. `hermes_cli/main.py` — Add `_model_flow_gemini()`, add to provider choices -6. `hermes_cli/setup.py` — Add gemini auth flow (trigger browser OAuth) -7. `run_agent.py` — Token refresh before API calls (like Copilot pattern) -8. `agent/auxiliary_client.py` — Add gemini to aux resolution chain -9. `agent/model_metadata.py` — Add Gemini model context lengths - -### Tests -10. `tests/agent/test_google_oauth.py` — OAuth flow unit tests -11. `tests/test_api_key_providers.py` — Add gemini provider test - -### Docs -12. `website/docs/getting-started/quickstart.md` — Add gemini to provider table -13. `website/docs/user-guide/configuration.md` — Gemini setup section -14. `website/docs/reference/environment-variables.md` — New env vars - -## Estimated scope -~400 lines new code, ~150 lines modifications, ~100 lines tests, ~50 lines docs = ~700 lines total - -## Prerequisites -- Nous Research GCP project with Desktop OAuth client registered -- OR: accept user-provided client_id via HERMES_GEMINI_CLIENT_ID env var - -## Reference implementations -- clawdbot: `extensions/google/oauth.flow.ts` (PKCE + localhost server) -- pi-mono: `packages/ai/src/utils/oauth/google-gemini-cli.ts` (same flow) -- hermes-agent Copilot OAuth: `hermes_cli/main.py` `_copilot_device_flow()` (different flow type but same lifecycle pattern) diff --git a/plugins/model-providers/gemini/__init__.py b/plugins/model-providers/gemini/__init__.py index ad21a3b9c7e..94e8bba66c7 100644 --- a/plugins/model-providers/gemini/__init__.py +++ b/plugins/model-providers/gemini/__init__.py @@ -1,11 +1,9 @@ """Google Gemini provider profiles. gemini: Google AI Studio (API key) — uses GeminiNativeClient -google-gemini-cli: Google Cloud Code Assist (OAuth) — uses GeminiCloudCodeClient -google-antigravity: Google Antigravity Code Assist (OAuth) — uses AntigravityCloudCodeClient -Both report api_mode="chat_completions" but use custom native clients -that bypass the standard OpenAI transport. The profile captures auth +Reports api_mode="chat_completions" but uses a custom native client +that bypasses the standard OpenAI transport. The profile captures auth and endpoint metadata for auth.py / runtime_provider.py migration, and carries the thinking_config translation hook so the transport's profile path produces the same extra_body shape the legacy flag path did. @@ -60,31 +58,4 @@ gemini = GeminiProfile( default_aux_model="gemini-3.5-flash", ) -google_gemini_cli = GeminiProfile( - name="google-gemini-cli", - aliases=("gemini-cli", "gemini-oauth"), - api_mode="chat_completions", - env_vars=(), # OAuth — no API key - base_url="cloudcode-pa://google", # Cloud Code Assist internal scheme - auth_type="oauth_external", -) - -google_antigravity = GeminiProfile( - name="google-antigravity", - aliases=( - "antigravity", - "antigravity-oauth", - "antigravity-cli", - "google-antigravity-oauth", - "agy", - "agy-cli", - ), - api_mode="chat_completions", - env_vars=(), # OAuth — no API key - base_url="antigravity-pa://google", # Antigravity Code Assist internal scheme - auth_type="oauth_external", -) - register_provider(gemini) -register_provider(google_gemini_cli) -register_provider(google_antigravity) diff --git a/run_agent.py b/run_agent.py index 3d295caf278..63050980934 100644 --- a/run_agent.py +++ b/run_agent.py @@ -273,7 +273,7 @@ def _pool_may_recover_from_rate_limit( return False # CloudCode / Gemini CLI quotas are account-wide — all pool entries share # the same throttle window, so rotation can't recover. Prefer fallback. - if provider == "google-gemini-cli" or str(base_url or "").startswith("cloudcode-pa://"): + if str(base_url or "").startswith("cloudcode-pa://"): return False return len(pool.entries()) > 1 @@ -4093,8 +4093,7 @@ class AIAgent: if pool is None: return False if ( - self.provider == "google-gemini-cli" - or str(getattr(self, "base_url", "")).startswith("cloudcode-pa://") + str(getattr(self, "base_url", "")).startswith("cloudcode-pa://") ): # CloudCode/Gemini quota windows are usually account-level throttles. # Prefer the configured fallback immediately instead of waiting out diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index 61604d324f4..c96a29745e0 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -336,7 +336,6 @@ The registry of record is `hermes_cli/commands.py` — every consumer /commands [page] Browse all commands (gateway) /usage Token usage /insights [days] Usage analytics -/gquota Show Google Gemini Code Assist quota usage (CLI) /status Session info (gateway) /profile Active profile info /debug Upload debug report (system info + logs) and get shareable links diff --git a/tests/agent/test_antigravity_cloudcode.py b/tests/agent/test_antigravity_cloudcode.py deleted file mode 100644 index 8bdcc9a8903..00000000000 --- a/tests/agent/test_antigravity_cloudcode.py +++ /dev/null @@ -1,405 +0,0 @@ -"""Tests for the google-antigravity OAuth + Antigravity Code Assist provider.""" - -from __future__ import annotations - -import json -import os -import stat -import time -import threading -import urllib.parse -from io import BytesIO -from pathlib import Path - -import pytest - - -@pytest.fixture(autouse=True) -def _isolate_env(monkeypatch, tmp_path): - home = tmp_path / ".hermes" - home.mkdir(parents=True) - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(home)) - for key in ( - "HERMES_ANTIGRAVITY_CLIENT_ID", - "HERMES_ANTIGRAVITY_CLIENT_SECRET", - "HERMES_ANTIGRAVITY_CLI_PATH", - "HERMES_ANTIGRAVITY_PROJECT_ID", - "GOOGLE_CLOUD_PROJECT", - "GOOGLE_CLOUD_PROJECT_ID", - "LOCALAPPDATA", - "APPDATA", - "ProgramFiles", - "ProgramFiles(x86)", - ): - monkeypatch.delenv(key, raising=False) - monkeypatch.setattr("shutil.which", lambda _: None) - try: - from agent import antigravity_oauth - - antigravity_oauth._discovered_creds_cache.clear() - except Exception: - pass - return home - - -class TestAntigravityCredentials: - def test_save_load_uses_separate_file_and_0600_permissions(self): - from agent.antigravity_oauth import ( - AntigravityCredentials, - _credentials_path, - load_credentials, - save_credentials, - ) - - save_credentials(AntigravityCredentials( - access_token="at", - refresh_token="rt", - expires_ms=int((time.time() + 3600) * 1000), - email="user@example.com", - project_id="proj-123", - )) - - assert _credentials_path().name == "antigravity_oauth.json" - loaded = load_credentials() - assert loaded is not None - assert loaded.refresh_token == "rt" - assert loaded.project_id == "proj-123" - if os.name != "nt": - assert stat.S_IMODE(_credentials_path().stat().st_mode) == 0o600 - - def test_env_override_client_id(self, monkeypatch): - from agent.antigravity_oauth import _get_client_id - - monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_ID", "custom.apps.googleusercontent.com") - assert _get_client_id() == "custom.apps.googleusercontent.com" - - def test_env_override_client_secret(self, monkeypatch): - from agent.antigravity_oauth import _get_client_secret - - monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_SECRET", "custom-secret") - assert _get_client_secret() == "custom-secret" - - def test_discovers_client_credentials_from_configured_agy_path(self, tmp_path, monkeypatch): - from agent import antigravity_oauth - - fake_client_id = ( - "1071006060591-" - + "fakefakefakefakefakefakefake" - + ".apps.google" - + "usercontent.com" - ) - fake_client_secret = "GOC" + "SPX-" + "fake-secret-value-placeholde" - fake_agy = tmp_path / "agy.exe" - fake_agy.write_text( - f'oauthClientId="{fake_client_id}";\n' - f'oauthClientSecret="{fake_client_secret}";\n', - encoding="utf-8", - ) - monkeypatch.setenv("HERMES_ANTIGRAVITY_CLI_PATH", str(fake_agy)) - antigravity_oauth._discovered_creds_cache.clear() - - assert antigravity_oauth._get_client_id().startswith("1071006060591-") - assert antigravity_oauth._get_client_secret() == fake_client_secret - - def test_missing_discovery_falls_back_to_public_default(self, monkeypatch): - # With no env override and no discoverable agy install, the public - # baked-in Antigravity desktop OAuth client is used as the floor so - # users without `agy` installed can still authenticate (PKCE makes the - # installed-app "secret" non-confidential, same as gemini-cli). - from agent import antigravity_oauth - from agent.antigravity_oauth import ( - _DEFAULT_CLIENT_ID, - _DEFAULT_CLIENT_SECRET, - _require_client_id, - ) - - monkeypatch.delenv("HERMES_ANTIGRAVITY_CLIENT_ID", raising=False) - monkeypatch.delenv("HERMES_ANTIGRAVITY_CLIENT_SECRET", raising=False) - monkeypatch.delenv("HERMES_ANTIGRAVITY_CLI_PATH", raising=False) - antigravity_oauth._discovered_creds_cache.clear() - - assert _require_client_id() == _DEFAULT_CLIENT_ID - assert antigravity_oauth._get_client_secret() == _DEFAULT_CLIENT_SECRET - assert _DEFAULT_CLIENT_ID.startswith("1071006060591-") - - def test_pkce_challenge_is_s256(self): - import base64 - import hashlib - - from agent.antigravity_oauth import _generate_pkce_pair - - verifier, challenge = _generate_pkce_pair() - expected = base64.urlsafe_b64encode( - hashlib.sha256(verifier.encode("ascii")).digest() - ).rstrip(b"=").decode("ascii") - assert challenge == expected - assert 43 <= len(verifier) <= 128 - - def test_exchange_code_posts_pkce_payload(self, monkeypatch): - from agent import antigravity_oauth - - captured = {} - - def fake_post(url, data, timeout): - captured.update({"url": url, "data": data, "timeout": timeout}) - return {"access_token": "at"} - - monkeypatch.setattr(antigravity_oauth, "_post_form", fake_post) - monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_ID", "client.apps.googleusercontent.com") - monkeypatch.setenv("HERMES_ANTIGRAVITY_CLIENT_SECRET", "secret") - - assert antigravity_oauth.exchange_code("code", "verifier", "http://localhost/cb") == { - "access_token": "at" - } - assert captured["url"] == antigravity_oauth.TOKEN_ENDPOINT - assert captured["data"]["grant_type"] == "authorization_code" - assert captured["data"]["code_verifier"] == "verifier" - assert captured["data"]["redirect_uri"] == "http://localhost/cb" - assert captured["data"]["client_id"] == "client.apps.googleusercontent.com" - assert captured["data"]["client_secret"] == "secret" - - def test_refresh_tries_discovered_client_secret_candidates(self, monkeypatch): - from agent import antigravity_oauth - from agent.antigravity_oauth import AntigravityOAuthError - - calls = [] - monkeypatch.setattr( - antigravity_oauth, - "_iter_client_credential_candidates", - lambda: [ - ("client.apps.googleusercontent.com", "wrong-secret"), - ("client.apps.googleusercontent.com", "right-secret"), - ], - ) - - def fake_post(url, data, timeout): - calls.append(data["client_secret"]) - if data["client_secret"] == "wrong-secret": - raise AntigravityOAuthError( - "invalid client", - code="antigravity_oauth_invalid_client", - ) - return {"access_token": "new-token", "expires_in": 3600} - - monkeypatch.setattr(antigravity_oauth, "_post_form", fake_post) - - assert antigravity_oauth.refresh_access_token("refresh-token")["access_token"] == "new-token" - assert calls == ["wrong-secret", "right-secret"] - - def test_invalid_grant_refresh_clears_credentials(self, monkeypatch): - from agent import antigravity_oauth - from agent.antigravity_oauth import ( - AntigravityCredentials, - AntigravityOAuthError, - load_credentials, - save_credentials, - ) - - save_credentials(AntigravityCredentials( - access_token="expired", - refresh_token="rt", - expires_ms=int((time.time() - 3600) * 1000), - )) - - def invalid_grant(_refresh_token): - raise AntigravityOAuthError("revoked", code="antigravity_oauth_invalid_grant") - - monkeypatch.setattr(antigravity_oauth, "refresh_access_token", invalid_grant) - with pytest.raises(AntigravityOAuthError, match="revoked"): - antigravity_oauth.get_valid_access_token() - assert load_credentials() is None - - def test_callback_handler_captures_code_on_handler_class(self): - from agent.antigravity_oauth import CALLBACK_PATH, _OAuthCallbackHandler - - handler_cls = type("TestAntigravityOAuthCallbackHandler", (_OAuthCallbackHandler,), {}) - handler_cls.expected_state = "state-123" - handler_cls.captured_code = None - handler_cls.captured_error = None - handler_cls.ready = threading.Event() - - handler = handler_cls.__new__(handler_cls) - handler.path = CALLBACK_PATH + "?" + urllib.parse.urlencode({ - "state": "state-123", - "code": "auth-code", - }) - handler.wfile = BytesIO() - responses = [] - headers = [] - handler.send_response = lambda code: responses.append(code) - handler.send_header = lambda key, value: headers.append((key, value)) - handler.end_headers = lambda: None - - handler.do_GET() - - assert responses == [200] - assert handler_cls.captured_code == "auth-code" - assert handler_cls.captured_error is None - assert handler_cls.ready.is_set() - assert "captured_code" not in handler.__dict__ - - -class TestAntigravityModelCatalog: - def test_parse_agent_model_ids_prefers_recommended_group(self): - from agent.antigravity_code_assist import parse_agent_model_ids - - payload = { - "defaultAgentModelId": "gemini-3-flash-agent", - "agentModelSorts": [ - { - "displayName": "Experimental", - "modelIds": ["tab_flash_lite_preview", "chat_23310"], - }, - { - "displayName": "Recommended", - "modelIds": [ - "gemini-3-flash-agent", - "gemini-3.5-flash-low", - "gemini-3.1-pro-high", - "gemini-pro-agent", - "claude-sonnet-4-6", - ], - }, - ], - "models": [{"id": "gpt-oss-120b-medium"}], - } - - assert parse_agent_model_ids(payload) == [ - "gemini-3-flash-agent", - "gemini-3.5-flash-low", - "gemini-pro-agent", - "claude-sonnet-4-6", - ] - - def test_headers_include_antigravity_metadata(self): - from agent.antigravity_code_assist import build_headers - - headers = build_headers("tok") - assert headers["Authorization"] == "Bearer tok" - assert headers["User-Agent"].startswith("antigravity/") - assert headers["X-Goog-Api-Client"] == "google-cloud-sdk vscode_cloudshelleditor/0.1" - metadata = json.loads(headers["Client-Metadata"]) - assert metadata["ideType"] == "ANTIGRAVITY" - assert metadata["platform"] == "PLATFORM_UNSPECIFIED" - - -class TestAntigravityClient: - def test_client_exposes_openai_interface(self): - from agent.antigravity_cloudcode_adapter import AntigravityCloudCodeClient - - client = AntigravityCloudCodeClient(api_key="dummy") - try: - assert hasattr(client, "chat") - assert hasattr(client.chat, "completions") - assert callable(client.chat.completions.create) - finally: - client.close() - - def test_create_uses_antigravity_endpoint_and_headers(self, monkeypatch): - from agent import antigravity_oauth - from agent.antigravity_cloudcode_adapter import AntigravityCloudCodeClient - from agent.antigravity_code_assist import ANTIGRAVITY_CODE_ASSIST_ENDPOINT - - monkeypatch.setattr(antigravity_oauth, "get_valid_access_token", lambda: "live-token") - - class _Response: - status_code = 200 - - def json(self): - return { - "response": { - "candidates": [{ - "content": {"parts": [{"text": "ok"}]}, - "finishReason": "STOP", - }] - } - } - - class _Http: - def __init__(self): - self.calls = [] - - def post(self, url, json=None, headers=None): - self.calls.append((url, json, headers)) - return _Response() - - def close(self): - pass - - client = AntigravityCloudCodeClient(project_id="proj-123") - client._http = _Http() - try: - result = client.chat.completions.create( - model="gemini-3-flash-agent", - messages=[{"role": "user", "content": "hi"}], - ) - finally: - client.close() - - assert result.choices[0].message.content == "ok" - url, body, headers = client._http.calls[0] - assert url == f"{ANTIGRAVITY_CODE_ASSIST_ENDPOINT}/v1internal:generateContent" - assert body["project"] == "proj-123" - assert body["model"] == "gemini-3-flash-agent" - assert headers["Authorization"] == "Bearer live-token" - assert json.loads(headers["Client-Metadata"])["ideType"] == "ANTIGRAVITY" - - -class TestAntigravityRegistration: - def test_registry_entry_and_aliases(self): - from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider - - assert "google-antigravity" in PROVIDER_REGISTRY - assert PROVIDER_REGISTRY["google-antigravity"].auth_type == "oauth_external" - assert resolve_provider("antigravity") == "google-antigravity" - assert resolve_provider("antigravity-oauth") == "google-antigravity" - assert resolve_provider("google-antigravity-oauth") == "google-antigravity" - assert resolve_provider("agy") == "google-antigravity" - - def test_runtime_provider_raises_when_not_logged_in(self): - from hermes_cli.auth import AuthError - from hermes_cli.runtime_provider import resolve_runtime_provider - - with pytest.raises(AuthError) as exc_info: - resolve_runtime_provider(requested="google-antigravity") - assert exc_info.value.code == "antigravity_oauth_not_logged_in" - - def test_runtime_provider_returns_correct_shape_when_logged_in(self): - from agent.antigravity_oauth import AntigravityCredentials, save_credentials - from hermes_cli.runtime_provider import resolve_runtime_provider - - save_credentials(AntigravityCredentials( - access_token="live-tok", - refresh_token="rt", - expires_ms=int((time.time() + 3600) * 1000), - project_id="my-proj", - email="t@e.com", - )) - - result = resolve_runtime_provider(requested="google-antigravity") - assert result["provider"] == "google-antigravity" - assert result["api_mode"] == "chat_completions" - assert result["api_key"] == "live-tok" - assert result["base_url"] == "antigravity-pa://google" - assert result["project_id"] == "my-proj" - assert result["email"] == "t@e.com" - - def test_provider_model_ids_uses_live_antigravity_catalog(self, monkeypatch): - from hermes_cli import models - - monkeypatch.setattr( - models, - "_fetch_antigravity_models", - lambda force_refresh=False: ["gemini-3-flash-agent", "claude-sonnet-4-6"], - ) - - assert models.provider_model_ids("agy") == [ - "gemini-3-flash-agent", - "claude-sonnet-4-6", - ] - - def test_oauth_capable_set_includes_antigravity(self): - from hermes_cli.auth_commands import _OAUTH_CAPABLE_PROVIDERS - - assert "google-antigravity" in _OAUTH_CAPABLE_PROVIDERS diff --git a/tests/agent/test_gemini_cloudcode.py b/tests/agent/test_gemini_cloudcode.py deleted file mode 100644 index 1c72088221d..00000000000 --- a/tests/agent/test_gemini_cloudcode.py +++ /dev/null @@ -1,1228 +0,0 @@ -"""Tests for the google-gemini-cli OAuth + Code Assist inference provider. - -Covers: -- agent/google_oauth.py — PKCE, credential I/O with packed refresh format, - token refresh dedup, invalid_grant handling, headless paste fallback -- agent/google_code_assist.py — project discovery, VPC-SC fallback, onboarding - with LRO polling, quota retrieval -- agent/gemini_cloudcode_adapter.py — OpenAI↔Gemini translation, request - envelope wrapping, response unwrapping, tool calls bidirectional, streaming -- Provider registration — registry entry, aliases, runtime dispatch, auth - status, _OAUTH_CAPABLE_PROVIDERS regression guard -""" -from __future__ import annotations - -import base64 -import hashlib -import json -import stat -import time -from pathlib import Path - -import pytest - - -# ============================================================================= -# Fixtures -# ============================================================================= - -@pytest.fixture(autouse=True) -def _isolate_env(monkeypatch, tmp_path): - home = tmp_path / ".hermes" - home.mkdir(parents=True) - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(home)) - for key in ( - "HERMES_GEMINI_CLIENT_ID", - "HERMES_GEMINI_CLIENT_SECRET", - "HERMES_GEMINI_PROJECT_ID", - "GOOGLE_CLOUD_PROJECT", - "GOOGLE_CLOUD_PROJECT_ID", - "SSH_CONNECTION", - "SSH_CLIENT", - "SSH_TTY", - "HERMES_HEADLESS", - ): - monkeypatch.delenv(key, raising=False) - return home - - -# ============================================================================= -# google_oauth.py — PKCE + packed refresh format -# ============================================================================= - -class TestPkce: - def test_verifier_and_challenge_s256_roundtrip(self): - from agent.google_oauth import _generate_pkce_pair - - verifier, challenge = _generate_pkce_pair() - expected = base64.urlsafe_b64encode( - hashlib.sha256(verifier.encode("ascii")).digest() - ).rstrip(b"=").decode("ascii") - assert challenge == expected - assert 43 <= len(verifier) <= 128 - - -class TestRefreshParts: - def test_parse_bare_token(self): - from agent.google_oauth import RefreshParts - - p = RefreshParts.parse("abc-token") - assert p.refresh_token == "abc-token" - assert p.project_id == "" - assert p.managed_project_id == "" - - def test_parse_packed(self): - from agent.google_oauth import RefreshParts - - p = RefreshParts.parse("rt|proj-123|mgr-456") - assert p.refresh_token == "rt" - assert p.project_id == "proj-123" - assert p.managed_project_id == "mgr-456" - - def test_format_bare_token(self): - from agent.google_oauth import RefreshParts - - assert RefreshParts(refresh_token="rt").format() == "rt" - - def test_format_with_project(self): - from agent.google_oauth import RefreshParts - - packed = RefreshParts( - refresh_token="rt", project_id="p1", managed_project_id="m1", - ).format() - assert packed == "rt|p1|m1" - # Roundtrip - parsed = RefreshParts.parse(packed) - assert parsed.refresh_token == "rt" - assert parsed.project_id == "p1" - assert parsed.managed_project_id == "m1" - - def test_format_empty_refresh_token_returns_empty(self): - from agent.google_oauth import RefreshParts - - assert RefreshParts(refresh_token="").format() == "" - - -class TestClientCredResolution: - def test_env_override(self, monkeypatch): - from agent.google_oauth import _get_client_id - - monkeypatch.setenv("HERMES_GEMINI_CLIENT_ID", "custom-id.apps.googleusercontent.com") - assert _get_client_id() == "custom-id.apps.googleusercontent.com" - - def test_shipped_default_used_when_no_env(self): - """Out of the box, the public gemini-cli desktop client is used.""" - from agent.google_oauth import _get_client_id, _DEFAULT_CLIENT_ID - - # Confirmed PUBLIC: baked into Google's open-source gemini-cli - assert _DEFAULT_CLIENT_ID.endswith(".apps.googleusercontent.com") - assert _DEFAULT_CLIENT_ID.startswith("681255809395-") - assert _get_client_id() == _DEFAULT_CLIENT_ID - - def test_shipped_default_secret_present(self): - from agent.google_oauth import _DEFAULT_CLIENT_SECRET, _get_client_secret - - assert _DEFAULT_CLIENT_SECRET.startswith("GOCSPX-") - assert len(_DEFAULT_CLIENT_SECRET) >= 20 - assert _get_client_secret() == _DEFAULT_CLIENT_SECRET - - def test_falls_back_to_scrape_when_defaults_wiped(self, tmp_path, monkeypatch): - """Forks that wipe the shipped defaults should still work with gemini-cli.""" - from agent import google_oauth - - monkeypatch.setattr(google_oauth, "_DEFAULT_CLIENT_ID", "") - monkeypatch.setattr(google_oauth, "_DEFAULT_CLIENT_SECRET", "") - - fake_bin = tmp_path / "bin" / "gemini" - fake_bin.parent.mkdir(parents=True) - fake_bin.write_text("#!/bin/sh\n") - oauth_dir = tmp_path / "node_modules" / "@google" / "gemini-cli-core" / "dist" / "src" / "code_assist" - oauth_dir.mkdir(parents=True) - (oauth_dir / "oauth2.js").write_text( - 'const OAUTH_CLIENT_ID = "99999-fakescrapedxyz.apps.googleusercontent.com";\n' - 'const OAUTH_CLIENT_SECRET = "GOCSPX-scraped-test-value-placeholder";\n' - ) - - monkeypatch.setattr("shutil.which", lambda _: str(fake_bin)) - google_oauth._scraped_creds_cache.clear() - - assert google_oauth._get_client_id().startswith("99999-") - - def test_missing_everything_raises_with_install_hint(self, monkeypatch): - """When env + defaults + scrape all fail, raise with install instructions.""" - from agent import google_oauth - - monkeypatch.setattr(google_oauth, "_DEFAULT_CLIENT_ID", "") - monkeypatch.setattr(google_oauth, "_DEFAULT_CLIENT_SECRET", "") - google_oauth._scraped_creds_cache.clear() - monkeypatch.setattr("shutil.which", lambda _: None) - - with pytest.raises(google_oauth.GoogleOAuthError) as exc_info: - google_oauth._require_client_id() - assert exc_info.value.code == "google_oauth_client_id_missing" - - def test_locate_gemini_cli_oauth_js_when_absent(self, monkeypatch): - from agent import google_oauth - - monkeypatch.setattr("shutil.which", lambda _: None) - assert google_oauth._locate_gemini_cli_oauth_js() is None - - def test_scrape_client_credentials_parses_id_and_secret(self, tmp_path, monkeypatch): - from agent import google_oauth - - # Create a fake gemini binary and oauth2.js - fake_gemini_bin = tmp_path / "bin" / "gemini" - fake_gemini_bin.parent.mkdir(parents=True) - fake_gemini_bin.write_text("#!/bin/sh\necho gemini\n") - - oauth_js_dir = tmp_path / "node_modules" / "@google" / "gemini-cli-core" / "dist" / "src" / "code_assist" - oauth_js_dir.mkdir(parents=True) - oauth_js = oauth_js_dir / "oauth2.js" - # Synthesize a harmless test fingerprint (valid shape, obvious test values) - oauth_js.write_text( - 'const OAUTH_CLIENT_ID = "12345678-testfakenotrealxyz.apps.googleusercontent.com";\n' - 'const OAUTH_CLIENT_SECRET = "GOCSPX-aaaaaaaaaaaaaaaaaaaaaaaa";\n' - ) - - monkeypatch.setattr("shutil.which", lambda _: str(fake_gemini_bin)) - google_oauth._scraped_creds_cache.clear() - - cid, cs = google_oauth._scrape_client_credentials() - assert cid == "12345678-testfakenotrealxyz.apps.googleusercontent.com" - assert cs.startswith("GOCSPX-") - - -class TestCredentialIo: - def _make(self): - from agent.google_oauth import GoogleCredentials - - return GoogleCredentials( - access_token="at-1", - refresh_token="rt-1", - expires_ms=int((time.time() + 3600) * 1000), - email="user@example.com", - project_id="proj-abc", - ) - - def test_save_and_load_packed_refresh(self): - from agent.google_oauth import load_credentials, save_credentials - - creds = self._make() - save_credentials(creds) - loaded = load_credentials() - assert loaded is not None - assert loaded.refresh_token == "rt-1" - assert loaded.project_id == "proj-abc" - - def test_save_uses_0600_permissions(self): - from agent.google_oauth import _credentials_path, save_credentials - - save_credentials(self._make()) - mode = stat.S_IMODE(_credentials_path().stat().st_mode) - assert mode == 0o600 - - def test_disk_format_is_packed(self): - from agent.google_oauth import _credentials_path, save_credentials - - save_credentials(self._make()) - data = json.loads(_credentials_path().read_text()) - # The refresh field on disk is the packed string, not a dict - assert data["refresh"] == "rt-1|proj-abc|" - - def test_update_project_ids(self): - from agent.google_oauth import ( - load_credentials, save_credentials, update_project_ids, - ) - from agent.google_oauth import GoogleCredentials - - save_credentials(GoogleCredentials( - access_token="at", refresh_token="rt", - expires_ms=int((time.time() + 3600) * 1000), - )) - update_project_ids(project_id="new-proj", managed_project_id="mgr-xyz") - - loaded = load_credentials() - assert loaded.project_id == "new-proj" - assert loaded.managed_project_id == "mgr-xyz" - - -class TestAccessTokenExpired: - def test_fresh_token_not_expired(self): - from agent.google_oauth import GoogleCredentials - - creds = GoogleCredentials( - access_token="at", refresh_token="rt", - expires_ms=int((time.time() + 3600) * 1000), - ) - assert creds.access_token_expired() is False - - def test_near_expiry_considered_expired(self): - """60s skew — a token with 30s left is considered expired.""" - from agent.google_oauth import GoogleCredentials - - creds = GoogleCredentials( - access_token="at", refresh_token="rt", - expires_ms=int((time.time() + 30) * 1000), - ) - assert creds.access_token_expired() is True - - def test_no_token_is_expired(self): - from agent.google_oauth import GoogleCredentials - - creds = GoogleCredentials( - access_token="", refresh_token="rt", expires_ms=999999999, - ) - assert creds.access_token_expired() is True - - -class TestGetValidAccessToken: - def _save(self, **over): - from agent.google_oauth import GoogleCredentials, save_credentials - - defaults = { - "access_token": "at", - "refresh_token": "rt", - "expires_ms": int((time.time() + 3600) * 1000), - } - defaults.update(over) - save_credentials(GoogleCredentials(**defaults)) - - def test_returns_cached_when_fresh(self): - from agent.google_oauth import get_valid_access_token - - self._save(access_token="cached-token") - assert get_valid_access_token() == "cached-token" - - def test_refreshes_when_near_expiry(self, monkeypatch): - from agent import google_oauth - - self._save(expires_ms=int((time.time() + 30) * 1000)) - monkeypatch.setattr( - google_oauth, "_post_form", - lambda *a, **kw: {"access_token": "refreshed", "expires_in": 3600}, - ) - assert google_oauth.get_valid_access_token() == "refreshed" - - def test_invalid_grant_clears_credentials(self, monkeypatch): - from agent import google_oauth - - self._save(expires_ms=int((time.time() - 10) * 1000)) - - def boom(*a, **kw): - raise google_oauth.GoogleOAuthError( - "invalid_grant", code="google_oauth_invalid_grant", - ) - - monkeypatch.setattr(google_oauth, "_post_form", boom) - - with pytest.raises(google_oauth.GoogleOAuthError) as exc_info: - google_oauth.get_valid_access_token() - assert exc_info.value.code == "google_oauth_invalid_grant" - # Credentials should be wiped - assert google_oauth.load_credentials() is None - - def test_preserves_refresh_when_google_omits(self, monkeypatch): - from agent import google_oauth - - self._save(expires_ms=int((time.time() + 30) * 1000), refresh_token="original-rt") - monkeypatch.setattr( - google_oauth, "_post_form", - lambda *a, **kw: {"access_token": "new", "expires_in": 3600}, - ) - google_oauth.get_valid_access_token() - assert google_oauth.load_credentials().refresh_token == "original-rt" - - -class TestProjectIdResolution: - @pytest.mark.parametrize("env_var", [ - "HERMES_GEMINI_PROJECT_ID", - "GOOGLE_CLOUD_PROJECT", - "GOOGLE_CLOUD_PROJECT_ID", - ]) - def test_env_vars_checked(self, monkeypatch, env_var): - from agent.google_oauth import resolve_project_id_from_env - - monkeypatch.setenv(env_var, "test-proj") - assert resolve_project_id_from_env() == "test-proj" - - def test_priority_order(self, monkeypatch): - from agent.google_oauth import resolve_project_id_from_env - - monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "lower-priority") - monkeypatch.setenv("HERMES_GEMINI_PROJECT_ID", "higher-priority") - assert resolve_project_id_from_env() == "higher-priority" - - def test_no_env_returns_empty(self): - from agent.google_oauth import resolve_project_id_from_env - - assert resolve_project_id_from_env() == "" - - -class TestHeadlessDetection: - def test_detects_ssh(self, monkeypatch): - from agent.google_oauth import _is_headless - - monkeypatch.setenv("SSH_CONNECTION", "1.2.3.4 22 5.6.7.8 9876") - assert _is_headless() is True - - def test_detects_hermes_headless(self, monkeypatch): - from agent.google_oauth import _is_headless - - monkeypatch.setenv("HERMES_HEADLESS", "1") - assert _is_headless() is True - - def test_default_not_headless(self): - from agent.google_oauth import _is_headless - - assert _is_headless() is False - - -# ============================================================================= -# google_code_assist.py — project discovery, onboarding, quota, VPC-SC -# ============================================================================= - -class TestCodeAssistVpcScDetection: - def test_detects_vpc_sc_in_json(self): - from agent.google_code_assist import _is_vpc_sc_violation - - body = json.dumps({ - "error": { - "details": [{"reason": "SECURITY_POLICY_VIOLATED"}], - "message": "blocked by policy", - } - }) - assert _is_vpc_sc_violation(body) is True - - def test_detects_vpc_sc_in_message(self): - from agent.google_code_assist import _is_vpc_sc_violation - - body = '{"error": {"message": "SECURITY_POLICY_VIOLATED"}}' - assert _is_vpc_sc_violation(body) is True - - def test_non_vpc_sc_returns_false(self): - from agent.google_code_assist import _is_vpc_sc_violation - - assert _is_vpc_sc_violation('{"error": {"message": "not found"}}') is False - assert _is_vpc_sc_violation("") is False - - -class TestLoadCodeAssist: - def test_parses_response(self, monkeypatch): - from agent import google_code_assist - - fake = { - "currentTier": {"id": "free-tier"}, - "cloudaicompanionProject": "proj-123", - "allowedTiers": [{"id": "free-tier"}, {"id": "standard-tier"}], - } - monkeypatch.setattr(google_code_assist, "_post_json", lambda *a, **kw: fake) - - info = google_code_assist.load_code_assist("access-token") - assert info.current_tier_id == "free-tier" - assert info.cloudaicompanion_project == "proj-123" - assert "free-tier" in info.allowed_tiers - assert "standard-tier" in info.allowed_tiers - - def test_vpc_sc_forces_standard_tier(self, monkeypatch): - from agent import google_code_assist - - def boom(*a, **kw): - raise google_code_assist.CodeAssistError( - "VPC-SC policy violation", code="code_assist_vpc_sc", - ) - - monkeypatch.setattr(google_code_assist, "_post_json", boom) - - info = google_code_assist.load_code_assist("access-token", project_id="corp-proj") - assert info.current_tier_id == "standard-tier" - assert info.cloudaicompanion_project == "corp-proj" - - -class TestOnboardUser: - def test_paid_tier_requires_project_id(self): - from agent import google_code_assist - - with pytest.raises(google_code_assist.ProjectIdRequiredError): - google_code_assist.onboard_user( - "at", tier_id="standard-tier", project_id="", - ) - - def test_free_tier_no_project_required(self, monkeypatch): - from agent import google_code_assist - - monkeypatch.setattr( - google_code_assist, "_post_json", - lambda *a, **kw: {"done": True, "response": {"cloudaicompanionProject": "gen-123"}}, - ) - resp = google_code_assist.onboard_user("at", tier_id="free-tier") - assert resp["done"] is True - - def test_lro_polling(self, monkeypatch): - """Simulate a long-running operation that completes on the second poll.""" - from agent import google_code_assist - - call_count = {"n": 0} - - def fake_post(url, body, token, **kw): - call_count["n"] += 1 - if call_count["n"] == 1: - return {"name": "operations/op-abc", "done": False} - return {"name": "operations/op-abc", "done": True, "response": {}} - - monkeypatch.setattr(google_code_assist, "_post_json", fake_post) - monkeypatch.setattr(google_code_assist.time, "sleep", lambda *_: None) - - resp = google_code_assist.onboard_user( - "at", tier_id="free-tier", - ) - assert resp["done"] is True - assert call_count["n"] >= 2 - - -class TestRetrieveUserQuota: - def test_parses_buckets(self, monkeypatch): - from agent import google_code_assist - - fake = { - "buckets": [ - { - "modelId": "gemini-2.5-pro", - "tokenType": "input", - "remainingFraction": 0.75, - "resetTime": "2026-04-17T00:00:00Z", - }, - { - "modelId": "gemini-2.5-flash", - "remainingFraction": 0.9, - }, - ] - } - monkeypatch.setattr(google_code_assist, "_post_json", lambda *a, **kw: fake) - - buckets = google_code_assist.retrieve_user_quota("at", project_id="p1") - assert len(buckets) == 2 - assert buckets[0].model_id == "gemini-2.5-pro" - assert buckets[0].remaining_fraction == 0.75 - assert buckets[1].remaining_fraction == 0.9 - - -class TestResolveProjectContext: - def test_configured_shortcircuits(self, monkeypatch): - from agent.google_code_assist import resolve_project_context - - # Should NOT call loadCodeAssist when configured_project_id is set - def should_not_be_called(*a, **kw): - raise AssertionError("should short-circuit") - - monkeypatch.setattr( - "agent.google_code_assist._post_json", should_not_be_called, - ) - ctx = resolve_project_context("at", configured_project_id="proj-abc") - assert ctx.project_id == "proj-abc" - assert ctx.source == "config" - - def test_env_shortcircuits(self, monkeypatch): - from agent.google_code_assist import resolve_project_context - - monkeypatch.setattr( - "agent.google_code_assist._post_json", - lambda *a, **kw: (_ for _ in ()).throw(AssertionError("nope")), - ) - ctx = resolve_project_context("at", env_project_id="env-proj") - assert ctx.project_id == "env-proj" - assert ctx.source == "env" - - def test_discovers_via_load_code_assist(self, monkeypatch): - from agent import google_code_assist - - monkeypatch.setattr( - google_code_assist, "_post_json", - lambda *a, **kw: { - "currentTier": {"id": "free-tier"}, - "cloudaicompanionProject": "discovered-proj", - }, - ) - ctx = google_code_assist.resolve_project_context("at") - assert ctx.project_id == "discovered-proj" - assert ctx.tier_id == "free-tier" - assert ctx.source == "discovered" - - -# ============================================================================= -# gemini_cloudcode_adapter.py — request/response translation -# ============================================================================= - -class TestBuildGeminiRequest: - def test_user_assistant_messages(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request(messages=[ - {"role": "user", "content": "hi"}, - {"role": "assistant", "content": "hello"}, - ]) - assert req["contents"][0] == { - "role": "user", "parts": [{"text": "hi"}], - } - assert req["contents"][1] == { - "role": "model", "parts": [{"text": "hello"}], - } - - def test_system_instruction_separated(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request(messages=[ - {"role": "system", "content": "You are helpful"}, - {"role": "user", "content": "hi"}, - ]) - assert req["systemInstruction"]["parts"][0]["text"] == "You are helpful" - # System should NOT appear in contents - assert all(c["role"] != "system" for c in req["contents"]) - - def test_multiple_system_messages_joined(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request(messages=[ - {"role": "system", "content": "A"}, - {"role": "system", "content": "B"}, - {"role": "user", "content": "hi"}, - ]) - assert "A\nB" in req["systemInstruction"]["parts"][0]["text"] - - def test_tool_call_translation(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request(messages=[ - {"role": "user", "content": "what's the weather?"}, - { - "role": "assistant", - "content": None, - "tool_calls": [{ - "id": "call_1", - "type": "function", - "function": {"name": "get_weather", "arguments": '{"city": "SF"}'}, - }], - }, - ]) - # Assistant turn should have a functionCall part - model_turn = req["contents"][1] - assert model_turn["role"] == "model" - fc_part = next(p for p in model_turn["parts"] if "functionCall" in p) - assert fc_part["functionCall"]["name"] == "get_weather" - assert fc_part["functionCall"]["args"] == {"city": "SF"} - assert fc_part["functionCall"]["id"] == "call_1" - - def test_tool_result_translation(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request(messages=[ - {"role": "user", "content": "q"}, - {"role": "assistant", "tool_calls": [{ - "id": "c1", "type": "function", - "function": {"name": "get_weather", "arguments": "{}"}, - }]}, - { - "role": "tool", - "name": "get_weather", - "tool_call_id": "c1", - "content": '{"temp": 72}', - }, - ]) - # Last content turn should carry functionResponse - last = req["contents"][-1] - fr_part = next(p for p in last["parts"] if "functionResponse" in p) - assert fr_part["functionResponse"]["name"] == "get_weather" - assert fr_part["functionResponse"]["response"] == {"temp": 72} - assert fr_part["functionResponse"]["id"] == "c1" - - def test_tools_translated_to_function_declarations(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - tools=[ - {"type": "function", "function": { - "name": "fn1", "description": "foo", - "parameters": {"type": "object"}, - }}, - ], - ) - decls = req["tools"][0]["functionDeclarations"] - assert decls[0]["name"] == "fn1" - assert decls[0]["description"] == "foo" - assert decls[0]["parameters"] == {"type": "object"} - - def test_tools_strip_json_schema_only_fields_from_parameters(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - tools=[ - {"type": "function", "function": { - "name": "fn1", - "description": "foo", - "parameters": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": False, - "properties": { - "city": { - "type": "string", - "$schema": "ignored", - "description": "City name", - "additionalProperties": False, - } - }, - "required": ["city"], - }, - }}, - ], - ) - params = req["tools"][0]["functionDeclarations"][0]["parameters"] - assert "$schema" not in params - assert "additionalProperties" not in params - assert params["type"] == "object" - assert params["required"] == ["city"] - assert params["properties"]["city"] == { - "type": "string", - "description": "City name", - } - - def test_tool_choice_auto(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - tool_choice="auto", - ) - assert req["toolConfig"]["functionCallingConfig"]["mode"] == "AUTO" - - def test_tool_choice_required(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - tool_choice="required", - ) - assert req["toolConfig"]["functionCallingConfig"]["mode"] == "ANY" - - def test_tool_choice_specific_function(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - tool_choice={"type": "function", "function": {"name": "my_fn"}}, - ) - cfg = req["toolConfig"]["functionCallingConfig"] - assert cfg["mode"] == "ANY" - assert cfg["allowedFunctionNames"] == ["my_fn"] - - def test_generation_config_params(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - temperature=0.7, - max_tokens=512, - top_p=0.9, - stop=["###", "END"], - ) - gc = req["generationConfig"] - assert gc["temperature"] == 0.7 - assert gc["maxOutputTokens"] == 512 - assert gc["topP"] == 0.9 - assert gc["stopSequences"] == ["###", "END"] - - def test_thinking_config_normalization(self): - from agent.gemini_cloudcode_adapter import build_gemini_request - - req = build_gemini_request( - messages=[{"role": "user", "content": "hi"}], - thinking_config={"thinking_budget": 1024, "include_thoughts": True}, - ) - tc = req["generationConfig"]["thinkingConfig"] - assert tc["thinkingBudget"] == 1024 - assert tc["includeThoughts"] is True - - -class TestWrapCodeAssistRequest: - def test_envelope_shape(self): - from agent.gemini_cloudcode_adapter import wrap_code_assist_request - - inner = {"contents": [], "generationConfig": {}} - wrapped = wrap_code_assist_request( - project_id="p1", model="gemini-2.5-pro", inner_request=inner, - ) - assert wrapped["project"] == "p1" - assert wrapped["model"] == "gemini-2.5-pro" - assert wrapped["request"] is inner - assert "user_prompt_id" in wrapped - assert len(wrapped["user_prompt_id"]) > 10 - - -class TestTranslateGeminiResponse: - def test_text_response(self): - from agent.gemini_cloudcode_adapter import _translate_gemini_response - - resp = { - "response": { - "candidates": [{ - "content": {"parts": [{"text": "hello world"}]}, - "finishReason": "STOP", - }], - "usageMetadata": { - "promptTokenCount": 10, - "candidatesTokenCount": 5, - "totalTokenCount": 15, - }, - } - } - result = _translate_gemini_response(resp, model="gemini-2.5-flash") - assert result.choices[0].message.content == "hello world" - assert result.choices[0].message.tool_calls is None - assert result.choices[0].finish_reason == "stop" - assert result.usage.prompt_tokens == 10 - assert result.usage.completion_tokens == 5 - assert result.usage.total_tokens == 15 - - def test_function_call_response(self): - from agent.gemini_cloudcode_adapter import _translate_gemini_response - - resp = { - "response": { - "candidates": [{ - "content": {"parts": [{ - "functionCall": {"name": "lookup", "args": {"q": "weather"}, "id": "provider-call-1"}, - }]}, - "finishReason": "STOP", - }], - } - } - result = _translate_gemini_response(resp, model="gemini-2.5-flash") - tc = result.choices[0].message.tool_calls[0] - assert tc.id == "provider-call-1" - assert tc.function.name == "lookup" - assert json.loads(tc.function.arguments) == {"q": "weather"} - assert result.choices[0].finish_reason == "tool_calls" - - def test_thought_parts_go_to_reasoning(self): - from agent.gemini_cloudcode_adapter import _translate_gemini_response - - resp = { - "response": { - "candidates": [{ - "content": {"parts": [ - {"thought": True, "text": "let me think"}, - {"text": "final answer"}, - ]}, - }], - } - } - result = _translate_gemini_response(resp, model="gemini-2.5-flash") - assert result.choices[0].message.content == "final answer" - assert result.choices[0].message.reasoning == "let me think" - - def test_unwraps_direct_format(self): - """If response is already at top level (no 'response' wrapper), still parse.""" - from agent.gemini_cloudcode_adapter import _translate_gemini_response - - resp = { - "candidates": [{ - "content": {"parts": [{"text": "hi"}]}, - "finishReason": "STOP", - }], - } - result = _translate_gemini_response(resp, model="gemini-2.5-flash") - assert result.choices[0].message.content == "hi" - - def test_empty_candidates(self): - from agent.gemini_cloudcode_adapter import _translate_gemini_response - - result = _translate_gemini_response({"response": {"candidates": []}}, model="gemini-2.5-flash") - assert result.choices[0].message.content == "" - assert result.choices[0].finish_reason == "stop" - - def test_finish_reason_mapping(self): - from agent.gemini_cloudcode_adapter import _map_gemini_finish_reason - - assert _map_gemini_finish_reason("STOP") == "stop" - assert _map_gemini_finish_reason("MAX_TOKENS") == "length" - assert _map_gemini_finish_reason("SAFETY") == "content_filter" - assert _map_gemini_finish_reason("RECITATION") == "content_filter" - - -class TestTranslateStreamEvent: - def test_parallel_calls_to_same_tool_get_unique_indices(self): - """Gemini may emit several functionCall parts with the same name in a - single turn (e.g. parallel file reads). Each must get its own OpenAI - ``index`` — otherwise downstream aggregators collapse them into one. - """ - from agent.gemini_cloudcode_adapter import _translate_stream_event - - event = { - "response": { - "candidates": [{ - "content": {"parts": [ - {"functionCall": {"name": "read_file", "args": {"path": "a"}}}, - {"functionCall": {"name": "read_file", "args": {"path": "b"}}}, - {"functionCall": {"name": "read_file", "args": {"path": "c"}}}, - ]}, - }], - } - } - counter = [0] - chunks = _translate_stream_event(event, model="gemini-2.5-flash", - tool_call_counter=counter) - indices = [c.choices[0].delta.tool_calls[0].index for c in chunks] - assert indices == [0, 1, 2] - assert counter[0] == 3 - - def test_counter_persists_across_events(self): - """Index assignment must continue across SSE events in the same stream.""" - from agent.gemini_cloudcode_adapter import _translate_stream_event - - def _event(name): - return {"response": {"candidates": [{ - "content": {"parts": [{"functionCall": {"name": name, "args": {}}}]}, - }]}} - - counter = [0] - chunks_a = _translate_stream_event(_event("foo"), model="m", tool_call_counter=counter) - chunks_b = _translate_stream_event(_event("bar"), model="m", tool_call_counter=counter) - chunks_c = _translate_stream_event(_event("foo"), model="m", tool_call_counter=counter) - - assert chunks_a[0].choices[0].delta.tool_calls[0].index == 0 - assert chunks_b[0].choices[0].delta.tool_calls[0].index == 1 - assert chunks_c[0].choices[0].delta.tool_calls[0].index == 2 - - def test_finish_reason_switches_to_tool_calls_when_any_seen(self): - from agent.gemini_cloudcode_adapter import _translate_stream_event - - counter = [0] - # First event emits one tool call. - _translate_stream_event( - {"response": {"candidates": [{ - "content": {"parts": [{"functionCall": {"name": "x", "args": {}}}]}, - }]}}, - model="m", tool_call_counter=counter, - ) - # Second event carries only the terminal finishReason. - chunks = _translate_stream_event( - {"response": {"candidates": [{"finishReason": "STOP"}]}}, - model="m", tool_call_counter=counter, - ) - assert chunks[-1].choices[0].finish_reason == "tool_calls" - - -class TestMakeStreamChunk: - def test_reasoning_only_chunk_has_content_none(self): - from agent.gemini_cloudcode_adapter import _make_stream_chunk - - chunk = _make_stream_chunk(model="m", reasoning="think") - delta = chunk.choices[0].delta - assert delta.content is None - assert delta.reasoning == "think" - - def test_content_only_chunk_has_reasoning_none(self): - from agent.gemini_cloudcode_adapter import _make_stream_chunk - - chunk = _make_stream_chunk(model="m", content="hello") - delta = chunk.choices[0].delta - assert delta.content == "hello" - assert delta.reasoning is None - assert delta.tool_calls is None - - def test_finish_only_chunk_has_all_fields_none(self): - from agent.gemini_cloudcode_adapter import _make_stream_chunk - - chunk = _make_stream_chunk(model="m", finish_reason="stop") - delta = chunk.choices[0].delta - assert delta.content is None - assert delta.reasoning is None - assert delta.tool_calls is None - assert chunk.choices[0].finish_reason == "stop" - - -class TestGeminiCloudCodeClient: - def test_client_exposes_openai_interface(self): - from agent.gemini_cloudcode_adapter import GeminiCloudCodeClient - - client = GeminiCloudCodeClient(api_key="dummy") - try: - assert hasattr(client, "chat") - assert hasattr(client.chat, "completions") - assert callable(client.chat.completions.create) - finally: - client.close() - - -class TestGeminiHttpErrorParsing: - """Regression coverage for _gemini_http_error Google-envelope parsing. - - These are the paths that users actually hit during Google-side throttling - (April 2026: gemini-2.5-pro MODEL_CAPACITY_EXHAUSTED, gemma-4-26b-it - returning 404). The error needs to carry status_code + response so the - main loop's error_classifier and Retry-After logic work. - """ - - @staticmethod - def _fake_response(status: int, body: dict | str = "", headers=None): - """Minimal httpx.Response stand-in (duck-typed for _gemini_http_error).""" - class _FakeResponse: - def __init__(self): - self.status_code = status - if isinstance(body, dict): - self.text = json.dumps(body) - else: - self.text = body - self.headers = headers or {} - return _FakeResponse() - - def test_model_capacity_exhausted_produces_friendly_message(self): - from agent.gemini_cloudcode_adapter import _gemini_http_error - - body = { - "error": { - "code": 429, - "message": "Resource has been exhausted (e.g. check quota).", - "status": "RESOURCE_EXHAUSTED", - "details": [ - { - "@type": "type.googleapis.com/google.rpc.ErrorInfo", - "reason": "MODEL_CAPACITY_EXHAUSTED", - "domain": "googleapis.com", - "metadata": {"model": "gemini-2.5-pro"}, - }, - { - "@type": "type.googleapis.com/google.rpc.RetryInfo", - "retryDelay": "30s", - }, - ], - } - } - err = _gemini_http_error(self._fake_response(429, body)) - assert err.status_code == 429 - assert err.code == "code_assist_capacity_exhausted" - assert err.retry_after == 30.0 - assert err.details["reason"] == "MODEL_CAPACITY_EXHAUSTED" - # Message must be user-friendly, not a raw JSON dump. - message = str(err) - assert "gemini-2.5-pro" in message - assert "capacity exhausted" in message.lower() - assert "30s" in message - # response attr is preserved for run_agent's Retry-After header path. - assert err.response is not None - - def test_resource_exhausted_without_reason(self): - from agent.gemini_cloudcode_adapter import _gemini_http_error - - body = { - "error": { - "code": 429, - "message": "Quota exceeded for requests per minute.", - "status": "RESOURCE_EXHAUSTED", - } - } - err = _gemini_http_error(self._fake_response(429, body)) - assert err.status_code == 429 - assert err.code == "code_assist_rate_limited" - message = str(err) - assert "quota" in message.lower() - - def test_404_model_not_found_produces_model_retired_message(self): - from agent.gemini_cloudcode_adapter import _gemini_http_error - - body = { - "error": { - "code": 404, - "message": "models/gemma-4-26b-it is not found for API version v1internal", - "status": "NOT_FOUND", - } - } - err = _gemini_http_error(self._fake_response(404, body)) - assert err.status_code == 404 - message = str(err) - assert "not available" in message.lower() or "retired" in message.lower() - # Error message should reference the actual model text from Google. - assert "gemma-4-26b-it" in message - - def test_unauthorized_preserves_status_code(self): - from agent.gemini_cloudcode_adapter import _gemini_http_error - - err = _gemini_http_error(self._fake_response( - 401, {"error": {"code": 401, "message": "Invalid token", "status": "UNAUTHENTICATED"}}, - )) - assert err.status_code == 401 - assert err.code == "code_assist_unauthorized" - - def test_retry_after_header_fallback(self): - """If the body has no RetryInfo detail, fall back to Retry-After header.""" - from agent.gemini_cloudcode_adapter import _gemini_http_error - - resp = self._fake_response( - 429, - {"error": {"code": 429, "message": "Rate limited", "status": "RESOURCE_EXHAUSTED"}}, - headers={"Retry-After": "45"}, - ) - err = _gemini_http_error(resp) - assert err.retry_after == 45.0 - - def test_malformed_body_still_produces_structured_error(self): - """Non-JSON body must not swallow status_code — we still want the classifier path.""" - from agent.gemini_cloudcode_adapter import _gemini_http_error - - err = _gemini_http_error(self._fake_response(500, "internal error")) - assert err.status_code == 500 - # Raw body snippet must still be there for debugging. - assert "500" in str(err) - - def test_status_code_flows_through_error_classifier(self): - """End-to-end: CodeAssistError from a 429 must classify as rate_limit. - - This is the whole point of adding status_code to CodeAssistError — - _extract_status_code must see it and FailoverReason.rate_limit must - fire, so the main loop triggers fallback_providers. - """ - from agent.gemini_cloudcode_adapter import _gemini_http_error - from agent.error_classifier import classify_api_error, FailoverReason - - body = { - "error": { - "code": 429, - "message": "Resource has been exhausted", - "status": "RESOURCE_EXHAUSTED", - "details": [ - { - "@type": "type.googleapis.com/google.rpc.ErrorInfo", - "reason": "MODEL_CAPACITY_EXHAUSTED", - "metadata": {"model": "gemini-2.5-pro"}, - } - ], - } - } - err = _gemini_http_error(self._fake_response(429, body)) - - classified = classify_api_error( - err, provider="google-gemini-cli", model="gemini-2.5-pro", - ) - assert classified.status_code == 429 - assert classified.reason == FailoverReason.rate_limit - - -# ============================================================================= -# Provider registration -# ============================================================================= - -class TestProviderRegistration: - def test_registry_entry(self): - from hermes_cli.auth import PROVIDER_REGISTRY - - assert "google-gemini-cli" in PROVIDER_REGISTRY - assert PROVIDER_REGISTRY["google-gemini-cli"].auth_type == "oauth_external" - - def test_google_gemini_alias_still_goes_to_api_key_gemini(self): - """Regression guard: don't shadow the existing google-gemini → gemini alias.""" - from hermes_cli.auth import resolve_provider - - assert resolve_provider("google-gemini") == "gemini" - - def test_runtime_provider_raises_when_not_logged_in(self): - from hermes_cli.auth import AuthError - from hermes_cli.runtime_provider import resolve_runtime_provider - - with pytest.raises(AuthError) as exc_info: - resolve_runtime_provider(requested="google-gemini-cli") - assert exc_info.value.code == "google_oauth_not_logged_in" - - def test_runtime_provider_returns_correct_shape_when_logged_in(self): - from agent.google_oauth import GoogleCredentials, save_credentials - from hermes_cli.runtime_provider import resolve_runtime_provider - - save_credentials(GoogleCredentials( - access_token="live-tok", - refresh_token="rt", - expires_ms=int((time.time() + 3600) * 1000), - project_id="my-proj", - email="t@e.com", - )) - - result = resolve_runtime_provider(requested="google-gemini-cli") - assert result["provider"] == "google-gemini-cli" - assert result["api_mode"] == "chat_completions" - assert result["api_key"] == "live-tok" - assert result["base_url"] == "cloudcode-pa://google" - assert result["project_id"] == "my-proj" - assert result["email"] == "t@e.com" - - def test_determine_api_mode(self): - from hermes_cli.providers import determine_api_mode - - assert determine_api_mode("google-gemini-cli", "cloudcode-pa://google") == "chat_completions" - - def test_oauth_capable_set_preserves_existing(self): - from hermes_cli.auth_commands import _OAUTH_CAPABLE_PROVIDERS - - for required in ("anthropic", "nous", "openai-codex", "qwen-oauth", "google-gemini-cli"): - assert required in _OAUTH_CAPABLE_PROVIDERS - - def test_config_env_vars_registered(self): - from hermes_cli.config import OPTIONAL_ENV_VARS - - for key in ( - "HERMES_GEMINI_CLIENT_ID", - "HERMES_GEMINI_CLIENT_SECRET", - "HERMES_GEMINI_PROJECT_ID", - ): - assert key in OPTIONAL_ENV_VARS - - -class TestAuthStatus: - def test_not_logged_in(self): - from hermes_cli.auth import get_auth_status - - s = get_auth_status("google-gemini-cli") - assert s["logged_in"] is False - - def test_logged_in_reports_email_and_project(self): - from agent.google_oauth import GoogleCredentials, save_credentials - from hermes_cli.auth import get_auth_status - - save_credentials(GoogleCredentials( - access_token="tok", refresh_token="rt", - expires_ms=int((time.time() + 3600) * 1000), - email="tek@nous.ai", - project_id="tek-proj", - )) - - s = get_auth_status("google-gemini-cli") - assert s["logged_in"] is True - assert s["email"] == "tek@nous.ai" - assert s["project_id"] == "tek-proj" - - -class TestGquotaCommand: - def test_gquota_registered(self): - from hermes_cli.commands import COMMANDS - - assert "/gquota" in COMMANDS - - -class TestRunGeminiOauthLoginPure: - def test_returns_pool_compatible_dict(self, monkeypatch): - from agent import google_oauth - - def fake_start(**kw): - return google_oauth.GoogleCredentials( - access_token="at", refresh_token="rt", - expires_ms=int((time.time() + 3600) * 1000), - email="u@e.com", project_id="p", - ) - - monkeypatch.setattr(google_oauth, "start_oauth_flow", fake_start) - - result = google_oauth.run_gemini_oauth_login_pure() - assert result["access_token"] == "at" - assert result["refresh_token"] == "rt" - assert result["email"] == "u@e.com" - assert result["project_id"] == "p" - assert isinstance(result["expires_at_ms"], int) diff --git a/tests/agent/test_gemini_fast_fallback.py b/tests/agent/test_gemini_fast_fallback.py index 41fafca8a50..4439eec1e07 100644 --- a/tests/agent/test_gemini_fast_fallback.py +++ b/tests/agent/test_gemini_fast_fallback.py @@ -22,7 +22,7 @@ def _pool(entries: int = 2): def test_cloudcode_provider_skips_pool_rotation(): assert _pool_may_recover_from_rate_limit( _pool(entries=3), - provider="google-gemini-cli", + provider="auto", base_url="cloudcode-pa://google", ) is False diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 665df0c3221..af24400ff51 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -404,34 +404,6 @@ class TestChatCompletionsBuildKwargs: ) assert kw["extra_body"]["extra_body"]["google"]["thinking_config"]["thinking_level"] == "high" - def test_google_gemini_cli_keeps_top_level_thinking_config(self, transport): - msgs = [{"role": "user", "content": "Hi"}] - kw = transport.build_kwargs( - model="gemini-3-flash-preview", - messages=msgs, - provider_name="google-gemini-cli", - reasoning_config={"enabled": True, "effort": "high"}, - ) - assert kw["extra_body"]["thinking_config"] == { - "includeThoughts": True, - "thinkingLevel": "high", - } - assert "google" not in kw["extra_body"] - - def test_google_antigravity_keeps_top_level_thinking_config(self, transport): - msgs = [{"role": "user", "content": "Hi"}] - kw = transport.build_kwargs( - model="gemini-3-flash-agent", - messages=msgs, - provider_name="google-antigravity", - reasoning_config={"enabled": True, "effort": "high"}, - ) - assert kw["extra_body"]["thinking_config"] == { - "includeThoughts": True, - "thinkingLevel": "high", - } - assert "google" not in kw["extra_body"] - def test_gemini_flash_minimal_clamps_to_low(self, transport): # Gemini 3 Flash documents low/medium/high; "minimal" isn't accepted, # so clamp it down to "low" rather than forwarding it verbatim. diff --git a/tests/agent/transports/test_codex_app_server_runtime.py b/tests/agent/transports/test_codex_app_server_runtime.py index 55bbc8bc6d3..e965d921b76 100644 --- a/tests/agent/transports/test_codex_app_server_runtime.py +++ b/tests/agent/transports/test_codex_app_server_runtime.py @@ -85,7 +85,6 @@ class TestMaybeApplyCodexAppServerRuntime: "openrouter", "xai", "qwen-oauth", - "google-gemini-cli", "opencode-zen", "bedrock", "", diff --git a/tests/cli/test_gquota_command.py b/tests/cli/test_gquota_command.py deleted file mode 100644 index 0740e001262..00000000000 --- a/tests/cli/test_gquota_command.py +++ /dev/null @@ -1,21 +0,0 @@ -from unittest.mock import MagicMock, patch - - -def test_gquota_uses_chat_console_when_tui_is_live(): - from agent.google_oauth import GoogleOAuthError - from cli import HermesCLI - - cli = HermesCLI.__new__(HermesCLI) - cli.console = MagicMock() - cli._app = object() - - live_console = MagicMock() - - with patch("cli.ChatConsole", return_value=live_console), \ - patch("agent.google_oauth.get_valid_access_token", side_effect=GoogleOAuthError("No Google OAuth credentials found")), \ - patch("agent.google_oauth.load_credentials", return_value=None), \ - patch("agent.google_code_assist.retrieve_user_quota"): - cli._handle_gquota_command("/gquota") - - assert live_console.print.call_count == 2 - cli.console.print.assert_not_called() diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 949a936962b..eba225a96b5 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -129,51 +129,6 @@ def test_auth_add_anthropic_oauth_persists_pool_entry(tmp_path, monkeypatch): assert entry["expires_at_ms"] == 1711234567000 -def test_auth_add_google_gemini_cli_sets_active_provider(tmp_path, monkeypatch): - """hermes auth add google-gemini-cli must set active_provider in auth.json. - - Tokens are managed by agent.google_oauth (written to the Google credential - file by start_oauth_flow). The auth.json entry must record active_provider - so get_active_provider() and _model_section_has_credentials() detect the - provider — without storing tokens that would become stale. - """ - monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) - _write_auth_store(tmp_path, {"version": 1, "providers": {}}) - monkeypatch.setattr( - "agent.google_oauth.run_gemini_oauth_login_pure", - lambda: { - "access_token": "ya29.test-token", - "refresh_token": "google-refresh", - "email": "user@example.com", - "expires_at_ms": 9999999999000, - "project_id": "my-project", - }, - ) - - from hermes_cli.auth_commands import auth_add_command - - class _Args: - provider = "google-gemini-cli" - auth_type = "oauth" - api_key = None - label = None - - auth_add_command(_Args()) - - payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) - assert payload["active_provider"] == "google-gemini-cli" - state = payload["providers"]["google-gemini-cli"] - # Only email stored — no access_token/refresh_token (those live in - # the Google OAuth credential file managed by agent.google_oauth). - assert state.get("email") == "user@example.com" - assert "access_token" not in state - assert "refresh_token" not in state - # pool entry from pool.add_entry() still present for hermes auth list - entries = payload["credential_pool"]["google-gemini-cli"] - entry = next(item for item in entries if item["source"] == "manual:google_pkce") - assert entry["access_token"] == "ya29.test-token" - - def test_auth_add_qwen_oauth_sets_active_provider(tmp_path, monkeypatch): """hermes auth add qwen-oauth must set active_provider in auth.json. diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 5f84004ee80..5235a1bd205 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -1056,7 +1056,6 @@ class TestEnvWriteDenylist: @pytest.mark.parametrize( "allowed_key", [ - "HERMES_GEMINI_CLIENT_ID", "HERMES_LANGFUSE_PUBLIC_KEY", "HERMES_SPOTIFY_CLIENT_ID", "HERMES_QWEN_BASE_URL", diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index ba2032b8efa..11b6033844f 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -473,7 +473,6 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) - monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {}) except Exception: pass @@ -915,7 +914,6 @@ def _run_doctor_with_healthy_oauth_fallback( env_key: str, bad_key: str, failing_host: str, - gemini_oauth_status: dict, minimax_oauth_status: dict, xai_oauth_status: dict | None = None, ) -> str: @@ -952,7 +950,6 @@ def _run_doctor_with_healthy_oauth_fallback( monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) - monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: gemini_oauth_status) monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: minimax_oauth_status) _xai_status = xai_oauth_status if xai_oauth_status is not None else {} monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: _xai_status) @@ -972,22 +969,12 @@ def _run_doctor_with_healthy_oauth_fallback( @pytest.mark.parametrize( - ("env_key", "bad_key", "failing_host", "gemini_oauth_status", "minimax_oauth_status", "xai_oauth_status", "unexpected_issue"), + ("env_key", "bad_key", "failing_host", "minimax_oauth_status", "xai_oauth_status", "unexpected_issue"), [ - ( - "GOOGLE_API_KEY", - "bad-gemini-key", - "googleapis.com", - {"logged_in": True, "email": "user@example.com"}, - {}, - None, - "Check GOOGLE_API_KEY in .env", - ), ( "MINIMAX_API_KEY", "bad-minimax-key", "minimax.io", - {}, {"logged_in": True, "region": "global"}, None, "Check MINIMAX_API_KEY in .env", @@ -997,7 +984,6 @@ def _run_doctor_with_healthy_oauth_fallback( "bad-xai-key", "api.x.ai", {}, - {}, {"logged_in": True, "auth_mode": "oauth_pkce"}, "Check XAI_API_KEY in .env", ), @@ -1009,7 +995,6 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy( env_key, bad_key, failing_host, - gemini_oauth_status, minimax_oauth_status, xai_oauth_status, unexpected_issue, @@ -1020,7 +1005,6 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy( env_key=env_key, bad_key=bad_key, failing_host=failing_host, - gemini_oauth_status=gemini_oauth_status, minimax_oauth_status=minimax_oauth_status, xai_oauth_status=xai_oauth_status, ) @@ -1062,16 +1046,6 @@ class TestHasHealthyOauthFallbackForXai: from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False - def test_xai_import_failure_does_not_affect_gemini(self, monkeypatch): - import sys - from hermes_cli import auth as _auth_mod - # xAI function missing, but Gemini is healthy - monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False) - monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": True}) - monkeypatch.delitem(sys.modules, "hermes_cli.doctor", raising=False) - from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider - assert _has_healthy_oauth_fallback_for_apikey_provider("gemini") is True - # --------------------------------------------------------------------------- # ◆ Auth Providers — xAI OAuth display in run_doctor() @@ -1107,7 +1081,6 @@ class TestDoctorXaiOAuthStatus: from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False}) - monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False}) monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False}) monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", xai_auth_fn) @@ -1182,7 +1155,6 @@ class TestDoctorXaiOAuthStatus: from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False}) - monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False}) monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False}) monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False) @@ -1214,7 +1186,6 @@ class TestDoctorXaiOAuthStatus: from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False}) - monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False}) monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False}) monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False) @@ -1275,7 +1246,6 @@ class TestDoctorCodexCliHintPlacement: from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": codex_logged_in}) - monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False}) monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False}) monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {"logged_in": False}) @@ -1317,12 +1287,16 @@ class TestDoctorCodexCliHintPlacement: def test_hint_never_attaches_to_minimax_row(self, monkeypatch, tmp_path): out = self._run(monkeypatch, tmp_path, codex_logged_in=False, codex_cli_present=False) - # The MiniMax OAuth row and the hint must not be adjacent — the hint - # belongs to the Codex auth row directly above it. + # The hint belongs to the Codex auth row that precedes it, never to the + # MiniMax row that follows (#27975). The MiniMax row itself must not be + # the hint line, and the hint must sit strictly above MiniMax. lines = [l for l in out.splitlines() if l.strip()] + codex_idx = next(i for i, l in enumerate(lines) if "OpenAI Codex auth" in l) + hint_idx = next(i for i, l in enumerate(lines) if self._hint_line() in l) minimax_idx = next(i for i, l in enumerate(lines) if "MiniMax OAuth" in l) - assert self._hint_line() not in lines[minimax_idx - 1] - assert minimax_idx + 1 >= len(lines) or self._hint_line() not in lines[minimax_idx + 1] + # Hint sits under Codex and above MiniMax; the MiniMax row is not the hint. + assert codex_idx < hint_idx < minimax_idx + assert self._hint_line() not in lines[minimax_idx] class TestDoctorStaleMaxIterationsDrift: diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index a791eac0af1..75eb5b8dc70 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -316,41 +316,6 @@ class TestProviderPersistsAfterModelSave: assert model.get("default") == "minimax-m2.5" assert model.get("api_mode") == "anthropic_messages" - def test_antigravity_oauth_provider_saved_when_selected(self, config_home): - """_model_flow_google_antigravity should persist provider/base_url/model together.""" - from hermes_cli.main import _model_flow_google_antigravity - from hermes_cli.config import load_config - - with patch( - "hermes_cli.auth.get_antigravity_oauth_auth_status", - return_value={"logged_in": True, "email": "user@example.com"}, - ), patch( - "hermes_cli.auth.resolve_antigravity_oauth_runtime_credentials", - return_value={ - "provider": "google-antigravity", - "api_key": "tok", - "base_url": "antigravity-pa://google", - "project_id": "proj-123", - }, - ), patch( - "hermes_cli.models.provider_model_ids", - return_value=["gemini-3-flash-agent", "claude-sonnet-4-6"], - ), patch( - "hermes_cli.auth._prompt_model_selection", - return_value="claude-sonnet-4-6", - ): - _model_flow_google_antigravity(load_config(), "old-model") - - import yaml - - config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} - model = config.get("model") - assert isinstance(model, dict), f"model should be dict, got {type(model)}" - assert model.get("provider") == "google-antigravity" - assert model.get("base_url") == "antigravity-pa://google" - assert model.get("default") == "claude-sonnet-4-6" - assert "api_mode" not in model - class TestBaseUrlValidation: diff --git a/tests/hermes_cli/test_provider_catalog.py b/tests/hermes_cli/test_provider_catalog.py index 508c18aae75..1b0ecc252c5 100644 --- a/tests/hermes_cli/test_provider_catalog.py +++ b/tests/hermes_cli/test_provider_catalog.py @@ -62,8 +62,6 @@ def test_api_key_providers_route_to_keys_oauth_to_accounts(): # api_key → keys assert by["kilocode"].tab == "keys" assert by["openai-api"].tab == "keys" - # account / sign-in flows → accounts - assert by["google-gemini-cli"].tab == "accounts" assert by["copilot-acp"].tab == "accounts" diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 016cd932f58..f478a5b5967 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -489,14 +489,13 @@ def test_accounts_offers_every_oauth_provider_from_catalog(): ) -def test_gemini_cli_and_copilot_acp_now_in_accounts(): - """Regression: google-gemini-cli and copilot-acp were canonical providers the - CLI could configure, but had no Accounts card (the reported GUI/CLI drift). +def test_copilot_acp_now_in_accounts(): + """Regression: copilot-acp was a canonical provider the CLI could configure, + but had no Accounts card (the reported GUI/CLI drift). """ resp = client.get("/api/providers/oauth", headers=HEADERS) assert resp.status_code == 200, resp.text providers = {p["id"]: p for p in resp.json()["providers"]} - assert "google-gemini-cli" in providers assert "copilot-acp" in providers # copilot-acp is managed by an external CLI: read-only card, not auto-removable. assert providers["copilot-acp"]["flow"] == "external" diff --git a/tests/skills/test_google_oauth_setup.py b/tests/skills/test_google_oauth_setup.py deleted file mode 100644 index 1b7b0e17d21..00000000000 --- a/tests/skills/test_google_oauth_setup.py +++ /dev/null @@ -1,447 +0,0 @@ -"""Regression tests for Google Workspace OAuth setup. - -These tests cover the headless/manual auth-code flow where the browser step and -code exchange happen in separate process invocations. -""" - -import importlib.util -import json -import sys -import types -from pathlib import Path - -import pytest - - -SCRIPT_PATH = ( - Path(__file__).resolve().parents[2] - / "skills/productivity/google-workspace/scripts/setup.py" -) - - -class FakeCredentials: - def __init__(self, payload=None): - self._payload = payload or { - "token": "access-token", - "refresh_token": "refresh-token", - "token_uri": "https://oauth2.googleapis.com/token", - "client_id": "client-id", - "client_secret": "client-secret", - "scopes": [ - "https://www.googleapis.com/auth/gmail.readonly", - "https://www.googleapis.com/auth/gmail.send", - "https://www.googleapis.com/auth/gmail.modify", - "https://www.googleapis.com/auth/calendar", - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/contacts.readonly", - "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/documents.readonly", - ], - } - - def to_json(self): - return json.dumps(self._payload) - - -class FakeFlow: - created = [] - default_state = "generated-state" - default_verifier = "generated-code-verifier" - credentials_payload = None - fetch_error = None - - def __init__( - self, - client_secrets_file, - scopes, - *, - redirect_uri=None, - state=None, - code_verifier=None, - autogenerate_code_verifier=False, - ): - self.client_secrets_file = client_secrets_file - self.scopes = scopes - self.redirect_uri = redirect_uri - self.state = state - self.code_verifier = code_verifier - self.autogenerate_code_verifier = autogenerate_code_verifier - self.authorization_kwargs = None - self.fetch_token_calls = [] - self.credentials = FakeCredentials(self.credentials_payload) - - if autogenerate_code_verifier and not self.code_verifier: - self.code_verifier = self.default_verifier - if not self.state: - self.state = self.default_state - - @classmethod - def reset(cls): - cls.created = [] - cls.default_state = "generated-state" - cls.default_verifier = "generated-code-verifier" - cls.credentials_payload = None - cls.fetch_error = None - - @classmethod - def from_client_secrets_file(cls, client_secrets_file, scopes, **kwargs): - inst = cls(client_secrets_file, scopes, **kwargs) - cls.created.append(inst) - return inst - - def authorization_url(self, **kwargs): - self.authorization_kwargs = kwargs - return f"https://auth.example/authorize?state={self.state}", self.state - - def fetch_token(self, **kwargs): - self.fetch_token_calls.append(kwargs) - if self.fetch_error: - raise self.fetch_error - - -@pytest.fixture -def setup_module(monkeypatch, tmp_path): - FakeFlow.reset() - - google_auth_module = types.ModuleType("google_auth_oauthlib") - flow_module = types.ModuleType("google_auth_oauthlib.flow") - flow_module.Flow = FakeFlow - google_auth_module.flow = flow_module - monkeypatch.setitem(sys.modules, "google_auth_oauthlib", google_auth_module) - monkeypatch.setitem(sys.modules, "google_auth_oauthlib.flow", flow_module) - - spec = importlib.util.spec_from_file_location("google_workspace_setup_test", SCRIPT_PATH) - module = importlib.util.module_from_spec(spec) - assert spec.loader is not None - spec.loader.exec_module(module) - - monkeypatch.setattr(module, "_ensure_deps", lambda: None) - monkeypatch.setattr(module, "CLIENT_SECRET_PATH", tmp_path / "google_client_secret.json") - monkeypatch.setattr(module, "TOKEN_PATH", tmp_path / "google_token.json") - monkeypatch.setattr(module, "PENDING_AUTH_PATH", tmp_path / "google_oauth_pending.json", raising=False) - - client_secret = { - "installed": { - "client_id": "client-id", - "client_secret": "client-secret", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - } - } - module.CLIENT_SECRET_PATH.write_text(json.dumps(client_secret)) - return module - - -class TestGetAuthUrl: - def test_persists_state_and_code_verifier_for_later_exchange(self, setup_module, capsys): - setup_module.get_auth_url() - - out = capsys.readouterr().out.strip() - assert out == "https://auth.example/authorize?state=generated-state" - - saved = json.loads(setup_module.PENDING_AUTH_PATH.read_text()) - assert saved["state"] == "generated-state" - assert saved["code_verifier"] == "generated-code-verifier" - - flow = FakeFlow.created[-1] - assert flow.autogenerate_code_verifier is True - assert flow.authorization_kwargs == {"access_type": "offline", "prompt": "consent"} - - -class TestExchangeAuthCode: - def test_reuses_saved_pkce_material_for_plain_code(self, setup_module): - setup_module.PENDING_AUTH_PATH.write_text( - json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) - ) - - setup_module.exchange_auth_code("4/test-auth-code") - - flow = FakeFlow.created[-1] - assert flow.state == "saved-state" - assert flow.code_verifier == "saved-verifier" - assert flow.fetch_token_calls == [{"code": "4/test-auth-code"}] - saved = json.loads(setup_module.TOKEN_PATH.read_text()) - assert saved["token"] == "access-token" - assert saved["type"] == "authorized_user" - assert not setup_module.PENDING_AUTH_PATH.exists() - - def test_extracts_code_from_redirect_url_and_checks_state(self, setup_module): - setup_module.PENDING_AUTH_PATH.write_text( - json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) - ) - - setup_module.exchange_auth_code( - "http://localhost:1/?code=4/extracted-code&state=saved-state&scope=gmail" - ) - - flow = FakeFlow.created[-1] - assert flow.fetch_token_calls == [{"code": "4/extracted-code"}] - - def test_passes_scopes_from_redirect_url_to_flow(self, setup_module): - """Callback URL carries space-delimited scope list; Flow must receive it (not full SCOPES).""" - setup_module.PENDING_AUTH_PATH.write_text( - json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) - ) - g1 = "https://www.googleapis.com/auth/gmail.readonly" - g2 = "https://www.googleapis.com/auth/calendar" - from urllib.parse import quote - - scope_q = quote(f"{g1} {g2}", safe="") - setup_module.exchange_auth_code( - f"http://localhost:1/?code=4/extracted-code&state=saved-state&scope={scope_q}" - ) - flow = FakeFlow.created[-1] - assert flow.scopes == [g1, g2] - - def test_rejects_state_mismatch(self, setup_module, capsys): - setup_module.PENDING_AUTH_PATH.write_text( - json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) - ) - - with pytest.raises(SystemExit): - setup_module.exchange_auth_code( - "http://localhost:1/?code=4/extracted-code&state=wrong-state" - ) - - out = capsys.readouterr().out - assert "state mismatch" in out.lower() - assert not setup_module.TOKEN_PATH.exists() - - def test_requires_pending_auth_session(self, setup_module, capsys): - with pytest.raises(SystemExit): - setup_module.exchange_auth_code("4/test-auth-code") - - out = capsys.readouterr().out - assert "run --auth-url first" in out.lower() - assert not setup_module.TOKEN_PATH.exists() - - def test_keeps_pending_auth_session_when_exchange_fails(self, setup_module, capsys): - setup_module.PENDING_AUTH_PATH.write_text( - json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) - ) - FakeFlow.fetch_error = Exception("invalid_grant: Missing code verifier") - - with pytest.raises(SystemExit): - setup_module.exchange_auth_code("4/test-auth-code") - - out = capsys.readouterr().out - assert "token exchange failed" in out.lower() - assert setup_module.PENDING_AUTH_PATH.exists() - assert not setup_module.TOKEN_PATH.exists() - - def test_accepts_narrower_scopes_with_warning(self, setup_module, capsys): - """Partial scopes are accepted with a warning (gws migration: v2.0).""" - setup_module.PENDING_AUTH_PATH.write_text( - json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) - ) - setup_module.TOKEN_PATH.write_text(json.dumps({"token": "***", "scopes": setup_module.SCOPES})) - FakeFlow.credentials_payload = { - "token": "***", - "refresh_token": "***", - "token_uri": "https://oauth2.googleapis.com/token", - "client_id": "client-id", - "client_secret": "client-secret", - "scopes": [ - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/spreadsheets", - ], - } - - setup_module.exchange_auth_code("4/test-auth-code") - - out = capsys.readouterr().out - assert "warning" in out.lower() - assert "missing" in out.lower() - # Token is saved (partial scopes accepted) - assert setup_module.TOKEN_PATH.exists() - # Pending auth is cleaned up - assert not setup_module.PENDING_AUTH_PATH.exists() - - -class TestHermesConstantsFallback: - """Tests for _hermes_home.py fallback when hermes_constants is unavailable.""" - - HELPER_PATH = ( - Path(__file__).resolve().parents[2] - / "skills/productivity/google-workspace/scripts/_hermes_home.py" - ) - - def _load_helper(self, monkeypatch): - """Load _hermes_home.py with hermes_constants blocked.""" - monkeypatch.setitem(sys.modules, "hermes_constants", None) - spec = importlib.util.spec_from_file_location("_hermes_home_test", self.HELPER_PATH) - module = importlib.util.module_from_spec(spec) - assert spec.loader is not None - spec.loader.exec_module(module) - return module - - def test_fallback_uses_hermes_home_env_var(self, monkeypatch, tmp_path): - """When hermes_constants is missing, HERMES_HOME comes from env var.""" - monkeypatch.setenv("HERMES_HOME", str(tmp_path / "custom-hermes")) - module = self._load_helper(monkeypatch) - assert module.get_hermes_home() == tmp_path / "custom-hermes" - - def test_fallback_defaults_to_dot_hermes(self, monkeypatch): - """When hermes_constants is missing and HERMES_HOME unset, default to ~/.hermes.""" - monkeypatch.delenv("HERMES_HOME", raising=False) - module = self._load_helper(monkeypatch) - assert module.get_hermes_home() == Path.home() / ".hermes" - - def test_fallback_ignores_empty_hermes_home(self, monkeypatch): - """Empty/whitespace HERMES_HOME is treated as unset.""" - monkeypatch.setenv("HERMES_HOME", " ") - module = self._load_helper(monkeypatch) - assert module.get_hermes_home() == Path.home() / ".hermes" - - def test_fallback_display_hermes_home_shortens_path(self, monkeypatch): - """Fallback display_hermes_home() uses ~/ shorthand like the real one.""" - monkeypatch.delenv("HERMES_HOME", raising=False) - module = self._load_helper(monkeypatch) - assert module.display_hermes_home() == "~/.hermes" - - def test_fallback_display_hermes_home_profile_path(self, monkeypatch): - """Fallback display_hermes_home() handles profile paths under ~/.""" - monkeypatch.setenv("HERMES_HOME", str(Path.home() / ".hermes/profiles/coder")) - module = self._load_helper(monkeypatch) - assert module.display_hermes_home() == "~/.hermes/profiles/coder" - - def test_fallback_display_hermes_home_custom_path(self, monkeypatch): - """Fallback display_hermes_home() returns full path for non-home locations.""" - monkeypatch.setenv("HERMES_HOME", "/opt/hermes-custom") - module = self._load_helper(monkeypatch) - assert module.display_hermes_home() == "/opt/hermes-custom" - - def test_delegates_to_hermes_constants_when_available(self): - """When hermes_constants IS importable, _hermes_home delegates to it.""" - spec = importlib.util.spec_from_file_location( - "_hermes_home_happy", self.HELPER_PATH - ) - module = importlib.util.module_from_spec(spec) - assert spec.loader is not None - spec.loader.exec_module(module) - import hermes_constants - assert module.get_hermes_home is hermes_constants.get_hermes_home - assert module.display_hermes_home is hermes_constants.display_hermes_home - - -def _load_setup_module(monkeypatch): - """Load setup.py without stubbing _ensure_deps (for install_deps tests).""" - spec = importlib.util.spec_from_file_location( - "google_workspace_setup_installdeps_test", SCRIPT_PATH - ) - module = importlib.util.module_from_spec(spec) - assert spec.loader is not None - spec.loader.exec_module(module) - return module - - -def _force_deps_missing(monkeypatch): - """Make `import googleapiclient` / `import google_auth_oauthlib` fail so - install_deps() proceeds past its early-return short-circuit.""" - for name in ("googleapiclient", "google_auth_oauthlib"): - monkeypatch.setitem(sys.modules, name, None) - - -class TestInstallDeps: - """Tests for install_deps() interpreter/installer selection. - - Regression coverage for the Hermes Docker image, whose venv is built with - `uv sync` and ships without pip — `sys.executable -m pip install` fails - with `No module named pip`, so install_deps() must fall back to uv. - """ - - def test_returns_early_when_already_installed(self, monkeypatch): - """If both libs import, no installer subprocess runs at all.""" - module = _load_setup_module(monkeypatch) - # Don't force-missing: real test env has the libs importable. Guard - # against any subprocess being spawned. - calls = [] - monkeypatch.setattr( - module.subprocess, "check_call", lambda *a, **k: calls.append(a) - ) - # google_auth_oauthlib may not be installed in the test env; only run - # this assertion when the early-return path is actually reachable. - try: - import googleapiclient # noqa: F401 - import google_auth_oauthlib # noqa: F401 - except ImportError: - pytest.skip("Google libs not installed in test env") - assert module.install_deps() is True - assert calls == [] - - def test_uses_pip_when_available(self, monkeypatch): - """When pip works, install_deps succeeds via pip and never calls uv.""" - module = _load_setup_module(monkeypatch) - _force_deps_missing(monkeypatch) - - recorded = [] - - def fake_check_call(cmd, **kwargs): - recorded.append(cmd) - # pip path is the first attempt — succeed. - return 0 - - which_calls = [] - monkeypatch.setattr(module.subprocess, "check_call", fake_check_call) - monkeypatch.setattr( - module.shutil, "which", lambda name: which_calls.append(name) - ) - - assert module.install_deps() is True - assert recorded[0][:3] == [module.sys.executable, "-m", "pip"] - # Control: uv must NOT be consulted when pip succeeds. - assert which_calls == [] - - def test_falls_back_to_uv_when_pip_missing(self, monkeypatch): - """No pip → uv pip install --python is used.""" - module = _load_setup_module(monkeypatch) - _force_deps_missing(monkeypatch) - - recorded = [] - - def fake_check_call(cmd, **kwargs): - recorded.append(cmd) - if cmd[:3] == [module.sys.executable, "-m", "pip"]: - raise module.subprocess.CalledProcessError(1, cmd) - return 0 # uv invocation succeeds - - monkeypatch.setattr(module.subprocess, "check_call", fake_check_call) - monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv") - - assert module.install_deps() is True - assert len(recorded) == 2 - uv_cmd = recorded[1] - assert uv_cmd[0] == "/usr/local/bin/uv" - assert uv_cmd[1:5] == ["pip", "install", "--python", module.sys.executable] - for pkg in module.REQUIRED_PACKAGES: - assert pkg in uv_cmd - - def test_returns_false_when_no_pip_and_no_uv(self, monkeypatch, capsys): - """No pip AND no uv → failure, with the [google] extra hint printed.""" - module = _load_setup_module(monkeypatch) - _force_deps_missing(monkeypatch) - - def fake_check_call(cmd, **kwargs): - raise module.subprocess.CalledProcessError(1, cmd) - - monkeypatch.setattr(module.subprocess, "check_call", fake_check_call) - monkeypatch.setattr(module.shutil, "which", lambda name: None) - - assert module.install_deps() is False - out = capsys.readouterr().out - assert "hermes-agent[google]" in out - - def test_returns_false_when_uv_fallback_also_fails(self, monkeypatch, capsys): - """uv present but its install fails → failure surfaced (not swallowed).""" - module = _load_setup_module(monkeypatch) - _force_deps_missing(monkeypatch) - - def fake_check_call(cmd, **kwargs): - raise module.subprocess.CalledProcessError(1, cmd) - - monkeypatch.setattr(module.subprocess, "check_call", fake_check_call) - monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/local/bin/uv") - - assert module.install_deps() is False - out = capsys.readouterr().out - assert "via uv" in out diff --git a/website/docs/developer-guide/adding-providers.md b/website/docs/developer-guide/adding-providers.md index f21b6341cf6..0898d698ac8 100644 --- a/website/docs/developer-guide/adding-providers.md +++ b/website/docs/developer-guide/adding-providers.md @@ -127,7 +127,7 @@ See `plugins/model-providers/nvidia/` or `plugins/model-providers/gmi/` as a tem Use the full checklist below when your provider needs any of the following: -- OAuth or token refresh (Nous Portal, Codex, Google Gemini, Qwen Portal, Copilot) +- OAuth or token refresh (Nous Portal, Codex, Qwen Portal, Copilot) - A non-OpenAI API shape that requires a new adapter (Anthropic Messages, Codex Responses) - Custom endpoint detection or multi-region probing (z.ai, Kimi) - A curated static model catalog or live `/models` fetch diff --git a/website/docs/developer-guide/model-provider-plugin.md b/website/docs/developer-guide/model-provider-plugin.md index 8df59f5781e..f12ed3abf33 100644 --- a/website/docs/developer-guide/model-provider-plugin.md +++ b/website/docs/developer-guide/model-provider-plugin.md @@ -195,7 +195,7 @@ Set `profile.api_mode` to match the default your provider ships — it acts as a |---|---|---| | `api_key` | Single env var carries a static API key | Most providers | | `oauth_device_code` | Device-code OAuth flow | — | -| `oauth_external` | User signs in elsewhere, tokens land in `auth.json` | Anthropic OAuth, MiniMax OAuth, Gemini Cloud Code, Qwen Portal, Nous Portal | +| `oauth_external` | User signs in elsewhere, tokens land in `auth.json` | Anthropic OAuth, MiniMax OAuth, Qwen Portal, Nous Portal | | `copilot` | GitHub Copilot token refresh cycle | `copilot` plugin only | | `aws_sdk` | AWS SDK credential chain (IAM role, profile, env) | `bedrock` plugin only | | `external_process` | Auth handled by a subprocess the agent spawns | `copilot-acp` plugin only | diff --git a/website/docs/developer-guide/provider-runtime.md b/website/docs/developer-guide/provider-runtime.md index c7aee421ca5..49f6ac2f565 100644 --- a/website/docs/developer-guide/provider-runtime.md +++ b/website/docs/developer-guide/provider-runtime.md @@ -47,7 +47,7 @@ Current provider families include (see `plugins/model-providers/` for the comple - OpenAI Codex - Copilot / Copilot ACP - Anthropic (native) -- Google / Gemini (`gemini`, `google-gemini-cli`, `google-antigravity`) +- Google / Gemini (`gemini`) - Alibaba / DashScope (`alibaba`, `alibaba-coding-plan`) - DeepSeek - Z.AI diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index f348828a55f..907af9c2402 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -126,7 +126,6 @@ Good defaults: | **AWS Bedrock** | Claude, Nova, Llama, DeepSeek via native Converse API | IAM role or `aws configure` ([guide](../guides/aws-bedrock.md)) | | **Azure Foundry** | Azure AI Foundry-hosted models | Set `AZURE_FOUNDRY_API_KEY` + `AZURE_FOUNDRY_BASE_URL` | | **Google AI Studio** | Gemini models via direct API | Set `GOOGLE_API_KEY` / `GEMINI_API_KEY` | -| **Google Gemini (OAuth)** | Gemini via the `google-gemini-cli` OAuth flow — no key needed | `hermes model` → Google Gemini (OAuth) | | **xAI** | Grok models via direct API | Set `XAI_API_KEY` | | **xAI Grok OAuth** | SuperGrok / Premium+ subscription, no API key needed | `hermes model` → xAI Grok OAuth | | **NovitaAI** | Multi-model API gateway | Set `NOVITA_API_KEY` | diff --git a/website/docs/guides/google-gemini.md b/website/docs/guides/google-gemini.md index bf090025ac1..7a00eabf8df 100644 --- a/website/docs/guides/google-gemini.md +++ b/website/docs/guides/google-gemini.md @@ -1,15 +1,13 @@ --- sidebar_position: 16 title: "Google Gemini" -description: "Use Hermes Agent with Google Gemini — native AI Studio API, API-key setup, OAuth option, tool calling, streaming, and quota guidance" +description: "Use Hermes Agent with Google Gemini — native AI Studio API, API-key setup, tool calling, streaming, and quota guidance" --- # Google Gemini Hermes Agent supports Google Gemini as a native provider using the **Google AI Studio / Gemini API** — not the OpenAI-compatible endpoint. This lets Hermes translate its internal OpenAI-shaped message and tool loop into Gemini's native `generateContent` API while preserving tool calling, streaming, multimodal inputs, and Gemini-specific response metadata. -Hermes also supports a separate **Google Gemini (OAuth)** provider that uses the same Cloud Code Assist backend as Google's Gemini CLI. Use the API-key provider (`gemini`) for the lowest-risk official API path. - ## Prerequisites - **Google AI Studio API key** — create one at [aistudio.google.com/apikey](https://aistudio.google.com/apikey) @@ -100,30 +98,6 @@ If you previously set `GEMINI_BASE_URL` to the `/openai` URL, remove it or chang GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta ``` -### OAuth Provider - -Hermes also has a `google-gemini-cli` provider: - -```bash -hermes model -# → Choose "Google Gemini (OAuth)" -``` - -This uses browser PKCE login and the Cloud Code Assist backend. It can be useful for users who want Gemini CLI-style OAuth, but Hermes shows an explicit warning because Google may treat use of the Gemini CLI OAuth client from third-party software as a policy violation. For production or lowest-risk usage, prefer the API-key provider above. - -Hermes also supports `google-antigravity` for Antigravity Code Assist: - -```bash -hermes model -# → Choose "Google Antigravity (OAuth)" -``` - -That provider uses a separate Antigravity OAuth login and stores separate -credentials at `~/.hermes/auth/antigravity_oauth.json`. Its model picker uses -live Antigravity model discovery, so the list reflects the signed-in account's -subscription and can include Antigravity-only Gemini agent models plus other -entitled model families. - ## Available Models The `hermes model` picker shows Gemini models maintained in Hermes' provider registry. Common choices include: @@ -205,18 +179,8 @@ hermes doctor The doctor checks: - Whether `GOOGLE_API_KEY` or `GEMINI_API_KEY` is available -- Whether Gemini OAuth credentials exist for `google-gemini-cli` -- Whether Antigravity OAuth credentials exist for `google-antigravity` - Whether configured provider credentials can be resolved -For OAuth quota usage, run this inside a Hermes session: - -```text -/gquota -``` - -`/gquota` applies to the `google-gemini-cli` OAuth provider, not the AI Studio API-key provider. - ## Gateway (Messaging Platforms) Gemini works with all Hermes gateway platforms (Telegram, Discord, Slack, WhatsApp, LINE, Feishu, etc.). Configure Gemini as your provider, then start the gateway normally: @@ -278,10 +242,6 @@ Change it to the native endpoint or remove the override: GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta ``` -### OAuth login warning - -The `google-gemini-cli` provider uses a Gemini CLI / Cloud Code Assist OAuth flow. Hermes warns before starting it because this is distinct from the official AI Studio API-key path. Use `provider: gemini` with `GOOGLE_API_KEY` for the official API-key integration. - ### Tool calling fails with schema errors Upgrade Hermes and rerun `hermes model`. The native Gemini adapter sanitizes tool schemas for Gemini's stricter function-declaration format; older builds or custom endpoints may not. diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index e51b46cb69e..1378762f346 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -40,7 +40,6 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | **DeepSeek** | `DEEPSEEK_API_KEY` in `~/.hermes/.env` (provider: `deepseek`) | | **Hugging Face** | `HF_TOKEN` in `~/.hermes/.env` (provider: `huggingface`, aliases: `hf`) | | **Google / Gemini** | `GOOGLE_API_KEY` (or `GEMINI_API_KEY`) in `~/.hermes/.env` (provider: `gemini`) | -| **Google Gemini (OAuth)** | `hermes model` → "Google Gemini (OAuth)" (provider: `google-gemini-cli`, free tier supported, browser PKCE login) | | **OpenAI API (direct)** | `OPENAI_API_KEY` in `~/.hermes/.env` (provider: `openai-api`, optional `OPENAI_BASE_URL`) | | **Azure AI Foundry** | `hermes model` → "Azure AI Foundry" (provider: `azure-foundry`; uses Azure OpenAI / Foundry endpoint and key) | | **AWS Bedrock** | `hermes model` → "AWS Bedrock" (provider: `bedrock`; standard AWS credentials chain via boto3) | @@ -49,7 +48,6 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | **Qwen OAuth** | `hermes model` → "Qwen OAuth" (provider: `qwen-oauth`; browser PKCE login) | | **MiniMax OAuth** | `hermes model` → "MiniMax (OAuth)" (provider: `minimax-oauth`; browser PKCE login) | | **StepFun** | `STEPFUN_API_KEY` in `~/.hermes/.env` (provider: `stepfun`) | -| **Google Antigravity (OAuth)** | `hermes model` → "Google Antigravity (OAuth)" (provider: `google-antigravity`, aliases: `antigravity`, `antigravity-oauth`, `agy`) | | **LM Studio** | `hermes model` → "LM Studio" (provider: `lmstudio`, optional `LM_API_KEY`) | | **Custom Endpoint** | `hermes model` → choose "Custom endpoint" (saved in `config.yaml`) | @@ -79,64 +77,6 @@ Don't have a subscription yet? Get one at [portal.nousresearch.com/manage-subscr **JWT auth (automatic).** Hermes prefers scoped `inference:invoke` JWTs for Portal requests with the legacy opaque session-key path as a fallback. No configuration is required — credentials are managed by the OAuth flow and rotate transparently. Revoked refresh tokens are quarantined to avoid replay loops. -### Google Antigravity via OAuth (`google-antigravity`) - -The `google-antigravity` provider uses Antigravity's Code Assist backend and -Antigravity OAuth scopes. It is a native Hermes integration: Hermes runs its -own browser PKCE login, stores credentials under -`~/.hermes/auth/antigravity_oauth.json`, and talks directly to the Antigravity -Code Assist endpoints. It does not shell out to `agy` for inference, and it -does not depend on the Antigravity CLI's local token storage. - -**Quick start:** - -```bash -hermes model -# -> pick "Google Antigravity (OAuth)" -# -> browser opens to accounts.google.com, sign in -# -> pick one of the models available to your Antigravity account -``` - -Hermes discovers Antigravity models from `fetchAvailableModels` after login. -The visible list depends on the authenticated account and subscription, and can -include Antigravity-only Gemini agent models plus Claude and GPT-OSS entries -when the account is entitled. If live discovery fails, Hermes falls back to a -small curated list so the provider remains selectable. - -Supported aliases: - -```text -google-antigravity -google-antigravity-oauth -antigravity -antigravity-oauth -antigravity-cli -agy -agy-cli -``` - -Optional overrides: - -```bash -HERMES_ANTIGRAVITY_CLIENT_ID=your-client.apps.googleusercontent.com -HERMES_ANTIGRAVITY_CLIENT_SECRET=... -HERMES_ANTIGRAVITY_CLI_PATH=/path/to/agy -HERMES_ANTIGRAVITY_PROJECT_ID=your-project -``` - -If the client ID/secret are not set explicitly, Hermes tries to discover the -desktop OAuth client credentials from the installed Antigravity CLI (`agy`) on -`PATH`, `HERMES_ANTIGRAVITY_CLI_PATH`, or common Antigravity install/cache -locations. Those client credentials are used only to start and refresh Hermes' -own OAuth session; Hermes still keeps its access/refresh tokens in `~/.hermes`. - -:::note Windows credential storage -The Antigravity CLI may keep its own login in platform-specific storage such as -Windows Credential Manager. Hermes intentionally keeps separate credentials in -`~/.hermes` so development profiles and production Hermes profiles do not share -tokens accidentally. -::: - :::info Codex Note The OpenAI Codex provider authenticates via device code (open a URL, enter a code). Hermes stores the resulting credentials in its own auth store under `~/.hermes/auth.json` and can import existing Codex CLI credentials from `~/.codex/auth.json` when present. No Codex CLI installation is required. @@ -592,91 +532,6 @@ You can append routing suffixes to model names: `:fastest` (default), `:cheapest The base URL can be overridden with `HF_BASE_URL`. -### Google Gemini via OAuth (`google-gemini-cli`) - -The `google-gemini-cli` provider uses Google's Cloud Code Assist backend — the -same API that Google's own `gemini-cli` tool uses. This supports both the -**free tier** (generous daily quota for personal accounts) and **paid tiers** -(Standard/Enterprise via a GCP project). - -**Quick start:** - -```bash -hermes model -# → pick "Google Gemini (OAuth)" -# → see policy warning, confirm -# → browser opens to accounts.google.com, sign in -# → done — Hermes auto-provisions your free tier on first request -``` - -Hermes ships Google's **public** `gemini-cli` desktop OAuth client by default — -the same credentials Google includes in their open-source `gemini-cli`. Desktop -OAuth clients are not confidential (PKCE provides the security). You do not -need to install `gemini-cli` or register your own GCP OAuth client. - -**How auth works:** -- PKCE Authorization Code flow against `accounts.google.com` -- Browser callback at `http://127.0.0.1:8085/oauth2callback` (with ephemeral-port fallback if busy) -- Tokens stored at `~/.hermes/auth/google_oauth.json` (chmod 0600, atomic write, cross-process `fcntl` lock) -- Automatic refresh 60 s before expiry -- Headless environments (SSH, `HERMES_HEADLESS=1`) → paste-mode fallback -- Inflight refresh deduplication — two concurrent requests won't double-refresh -- `invalid_grant` (revoked refresh) → credential file wiped, user prompted to re-login - -**How inference works:** -- Traffic goes to `https://cloudcode-pa.googleapis.com/v1internal:generateContent` - (or `:streamGenerateContent?alt=sse` for streaming), NOT the paid `v1beta/openai` endpoint -- Request body wrapped `{project, model, user_prompt_id, request}` -- OpenAI-shaped `messages[]`, `tools[]`, `tool_choice` are translated to Gemini's native - `contents[]`, `tools[].functionDeclarations`, `toolConfig` shape -- Responses translated back to OpenAI shape so the rest of Hermes works unchanged - -**Tiers & project IDs:** - -| Your situation | What to do | -|---|---| -| Personal Google account, want free tier | Nothing — sign in, start chatting | -| Workspace / Standard / Enterprise account | Set `HERMES_GEMINI_PROJECT_ID` or `GOOGLE_CLOUD_PROJECT` to your GCP project ID | -| VPC-SC-protected org | Hermes detects `SECURITY_POLICY_VIOLATED` and forces `standard-tier` automatically | - -Free tier auto-provisions a Google-managed project on first use. No GCP setup required. - -**Quota monitoring:** - -``` -/gquota -``` - -Shows remaining Code Assist quota per model with progress bars: - -``` -Gemini Code Assist quota (project: 123-abc) - - gemini-2.5-pro ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░ 85% - gemini-2.5-flash [input] ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░ 92% -``` - -:::warning Policy risk -Google considers using the Gemini CLI OAuth client with third-party software a -policy violation. Some users have reported account restrictions. For the lowest-risk -experience, use your own API key via the `gemini` provider instead. Hermes shows -an upfront warning and requires explicit confirmation before OAuth begins. -::: - -**Custom OAuth client (optional):** - -If you'd rather register your own Google OAuth client — e.g., to keep quota -and consent scoped to your own GCP project — set: - -```bash -HERMES_GEMINI_CLIENT_ID=your-client.apps.googleusercontent.com -HERMES_GEMINI_CLIENT_SECRET=... # optional for Desktop clients -``` - -Register a **Desktop app** OAuth client at -[console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) -with the Generative Language API enabled. - ## Custom & Self-Hosted LLM Providers Hermes Agent works with **any OpenAI-compatible API endpoint**. If a server implements `/v1/chat/completions`, you can point Hermes at it. This means you can use local models, GPU inference servers, multi-provider routers, or any third-party API. @@ -1591,7 +1446,7 @@ fallback_model: When activated, the fallback swaps the model and provider mid-session without losing your conversation. The chain is tried entry-by-entry; activation is one-shot per session. -Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `google-antigravity`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`. +Supported providers: `openrouter`, `nous`, `novita`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `huggingface`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `bedrock`, `azure-foundry`, `opencode-zen`, `opencode-go`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `stepfun`, `lmstudio`, `alibaba`, `alibaba-coding-plan`, `tencent-tokenhub`, `custom`. :::tip Fallback is configured exclusively through `config.yaml` — or interactively via `hermes fallback`. For full details on when it triggers, how the chain advances, and how it interacts with auxiliary tasks and delegation, see [Fallback Providers](/user-guide/features/fallback-providers). diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 2f64f04c59f..5511f3c8e9a 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -100,7 +100,7 @@ Common options: | `-q`, `--query "..."` | One-shot, non-interactive prompt. | | `-m`, `--model ` | Override the model for this run. | | `-t`, `--toolsets ` | Enable a comma-separated set of toolsets. | -| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `google-gemini-cli`, `google-antigravity` (aliases: `antigravity`, `antigravity-oauth`, `agy`), `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). | +| `--provider ` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `huggingface`, `novita` (aliases `novita-ai`, `novitaai`), `openai-api`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `kilocode`, `xiaomi`, `arcee`, `gmi`, `alibaba`, `alibaba-coding-plan` (alias `alibaba_coding`), `deepseek`, `nvidia`, `ollama-cloud`, `xai` (alias `grok`), `xai-oauth` (alias `grok-oauth`), `qwen-oauth`, `bedrock`, `opencode-zen`, `opencode-go`, `azure-foundry`, `lmstudio`, `stepfun`, `tencent-tokenhub` (alias `tencent`, `tokenhub`). | | `-s`, `--skills ` | Preload one or more skills for the session (can be repeated or comma-separated). | | `-v`, `--verbose` | Verbose output. | | `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 41a099eb7ac..3387c80c70d 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -67,13 +67,6 @@ Hermes reads environment variables from the process environment and, for user-ma | `GOOGLE_API_KEY` | Google AI Studio API key ([aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)) | | `GEMINI_API_KEY` | Alias for `GOOGLE_API_KEY` | | `GEMINI_BASE_URL` | Override Google AI Studio base URL | -| `HERMES_GEMINI_CLIENT_ID` | OAuth client ID for `google-gemini-cli` PKCE login (optional; defaults to Google's public gemini-cli client) | -| `HERMES_GEMINI_CLIENT_SECRET` | OAuth client secret for `google-gemini-cli` (optional) | -| `HERMES_GEMINI_PROJECT_ID` | GCP project ID for paid Gemini tiers (free tier auto-provisions) | -| `HERMES_ANTIGRAVITY_CLIENT_ID` | OAuth client ID for `google-antigravity` PKCE login (optional; discovered from installed `agy` when omitted) | -| `HERMES_ANTIGRAVITY_CLIENT_SECRET` | OAuth client secret for `google-antigravity` (optional; discovered from installed `agy` when omitted) | -| `HERMES_ANTIGRAVITY_CLI_PATH` | Path to the `agy` executable or install file used for Antigravity OAuth client credential discovery | -| `HERMES_ANTIGRAVITY_PROJECT_ID` | GCP project ID for Antigravity Code Assist when you want to pin one explicitly | | `ANTHROPIC_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) | | `ANTHROPIC_BASE_URL` | Override the Anthropic API base URL | | `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override | diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index c95a62859a0..761b8920063 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -20,7 +20,7 @@ Hermes Agent works with any OpenAI-compatible API. Supported providers include: - **[Nous Portal](/integrations/nous-portal)** — Nous Research's subscription gateway — 300+ models plus web/image/TTS/browser through one OAuth login (recommended for newcomers) - **OpenAI** — GPT-5.4, GPT-5-codex, GPT-4.1, GPT-4o, etc. - **Anthropic** — Claude models (direct API, OAuth via `hermes auth add anthropic`, OpenRouter, or any compatible proxy) -- **Google** — Gemini models (direct API via `gemini` provider, the `google-gemini-cli` OAuth provider, the `google-antigravity` OAuth provider, OpenRouter, or compatible proxy) +- **Google** — Gemini models (direct API via `gemini` provider, OpenRouter, or compatible proxy) - **z.ai / ZhipuAI** — GLM models - **Kimi / Moonshot AI** — Kimi models - **MiniMax** — global and China endpoints diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 6f36eb015bd..072442f70c6 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -115,7 +115,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/image ` | Attach a local image file for your next prompt. | | `/debug` | Upload debug report (system info + logs) and get shareable links. Also available in messaging. | | `/profile` | Show active profile name and home directory | -| `/gquota` | Show Google Gemini Code Assist quota usage with progress bars (only available when the `google-gemini-cli` provider is active). | ### Exit @@ -246,7 +245,7 @@ The messaging gateway supports the following built-in commands inside Telegram, ## Notes -- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands. +- `/skin`, `/snapshot`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/billing`, and `/quit` are **CLI-only** commands. - `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands. diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 8c97de1b17a..d8796ae42f5 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -959,7 +959,7 @@ Every model slot in Hermes — auxiliary tasks, compression, fallback — uses t When `base_url` is set, Hermes ignores the provider and calls that endpoint directly (using `api_key` or `OPENAI_API_KEY` for auth). When only `provider` is set, Hermes uses that provider's built-in auth and base URL. -Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `google-gemini-cli`, `google-antigravity`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`). +Available providers for auxiliary tasks: `auto`, `main`, plus any provider in the [provider registry](/reference/environment-variables) — `openrouter`, `nous`, `openai-codex`, `copilot`, `copilot-acp`, `anthropic`, `gemini`, `qwen-oauth`, `zai`, `kimi-coding`, `kimi-coding-cn`, `minimax`, `minimax-cn`, `minimax-oauth`, `deepseek`, `nvidia`, `xai`, `xai-oauth`, `ollama-cloud`, `alibaba`, `bedrock`, `huggingface`, `arcee`, `xiaomi`, `kilocode`, `opencode-zen`, `opencode-go`, `azure-foundry` — or any named custom provider from your `custom_providers` list (e.g. `provider: "beans"`). :::tip MiniMax OAuth `minimax-oauth` logs in via browser OAuth (no API key needed). Run `hermes model` and select **MiniMax (OAuth)** to authenticate. Auxiliary tasks use `MiniMax-M2.7-highspeed` automatically. See the [MiniMax OAuth guide](../guides/minimax-oauth.md). diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index 28a5d0e1fce..05629af590f 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -62,8 +62,6 @@ Each entry requires both `provider` and `model`. Entries missing either field ar | GMI Cloud | `gmi` | `GMI_API_KEY` (optional: `GMI_BASE_URL`) | | StepFun | `stepfun` | `STEPFUN_API_KEY` (optional: `STEPFUN_BASE_URL`) | | Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` | -| Google Gemini (OAuth) | `google-gemini-cli` | `hermes model` (Google OAuth; optional: `HERMES_GEMINI_PROJECT_ID`) | -| Google Antigravity (OAuth) | `google-antigravity` | `hermes model` (Antigravity OAuth; optional: `HERMES_ANTIGRAVITY_PROJECT_ID`) | | Google AI Studio | `gemini` | `GOOGLE_API_KEY` (alias: `GEMINI_API_KEY`) | | xAI (Grok) | `xai` (alias `grok`) | `XAI_API_KEY` (optional: `XAI_BASE_URL`) | | xAI Grok OAuth (SuperGrok) | `xai-oauth` (alias `grok-oauth`) | `hermes model` → xAI Grok OAuth (browser login; SuperGrok subscription) | diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index 8a29c919716..7d0381969de 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -343,7 +343,6 @@ The registry of record is `hermes_cli/commands.py` — every consumer /commands [page] Browse all commands (gateway) /usage Token usage /insights [days] Usage analytics -/gquota Show Google Gemini Code Assist quota usage (CLI) /status Session info (gateway) /profile Active profile info /debug Upload debug report (system info + logs) and get shareable links diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md index 1165d1e8091..04245b32e1c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-providers.md @@ -127,7 +127,7 @@ Hermes 已经可以通过自定义 provider 路径与任何 OpenAI 兼容的端 当你的 provider 需要以下任何内容时,使用下面的完整清单: -- OAuth 或 token 刷新(Nous Portal、Codex、Google Gemini、Qwen Portal、Copilot) +- OAuth 或 token 刷新(Nous Portal、Codex、Qwen Portal、Copilot) - 需要新适配器的非 OpenAI API 格式(Anthropic Messages、Codex Responses) - 自定义端点检测或多区域探测(z.ai、Kimi) - 精选的静态模型目录或实时 `/models` 获取 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md index f2b136bb6e0..e649fe5d23a 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/model-provider-plugin.md @@ -194,7 +194,7 @@ register_provider(ProviderProfile( |---|---|---| | `api_key` | 单个环境变量携带静态 API key | 大多数提供商 | | `oauth_device_code` | 设备码 OAuth 流程 | — | -| `oauth_external` | 用户在其他地方登录,token 存入 `auth.json` | Anthropic OAuth、MiniMax OAuth、Gemini Cloud Code、Qwen Portal、Nous Portal | +| `oauth_external` | 用户在其他地方登录,token 存入 `auth.json` | Anthropic OAuth、MiniMax OAuth、Qwen Portal、Nous Portal | | `copilot` | GitHub Copilot token 刷新周期 | 仅 `copilot` 插件 | | `aws_sdk` | AWS SDK 凭据链(IAM role、profile、env) | 仅 `bedrock` 插件 | | `external_process` | 认证由 agent 启动的子进程处理 | 仅 `copilot-acp` 插件 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md index beeae3f889b..181c996c9e8 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/provider-runtime.md @@ -47,7 +47,7 @@ Hermes 拥有一个共享的 provider 运行时解析器,用于以下场景: - OpenAI Codex - Copilot / Copilot ACP - Anthropic(原生) -- Google / Gemini(`gemini`、`google-gemini-cli`) +- Google / Gemini(`gemini`) - Alibaba / DashScope(`alibaba`、`alibaba-coding-plan`) - DeepSeek - Z.AI diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/google-gemini.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/google-gemini.md index d45bbc8c1a1..f1fa70f4dd6 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/google-gemini.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/google-gemini.md @@ -1,15 +1,13 @@ --- sidebar_position: 16 title: "Google Gemini" -description: "将 Hermes Agent 与 Google Gemini 配合使用——原生 AI Studio API、API 密钥配置、OAuth 选项、工具调用、流式传输及配额说明" +description: "将 Hermes Agent 与 Google Gemini 配合使用——原生 AI Studio API、API 密钥配置、工具调用、流式传输及配额说明" --- # Google Gemini Hermes Agent 通过 **Google AI Studio / Gemini API** 原生支持 Google Gemini——而非 OpenAI 兼容端点。这使 Hermes 能够将其内部 OpenAI 格式的消息和工具循环转换为 Gemini 原生的 `generateContent` API,同时保留工具调用、流式传输、多模态输入以及 Gemini 特有的响应元数据。 -Hermes 还支持独立的 **Google Gemini(OAuth)** provider,使用与 Google Gemini CLI 相同的 Cloud Code Assist 后端。如需最低风险的官方 API 路径,请使用 API 密钥 provider(`gemini`)。 - ## 前提条件 - **Google AI Studio API 密钥** — 在 [aistudio.google.com/apikey](https://aistudio.google.com/apikey) 创建 @@ -100,17 +98,6 @@ https://generativelanguage.googleapis.com/v1beta/openai/ GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta ``` -### OAuth Provider - -Hermes 还提供 `google-gemini-cli` provider: - -```bash -hermes model -# → 选择 "Google Gemini (OAuth)" -``` - -该方式使用浏览器 PKCE 登录和 Cloud Code Assist 后端。对于希望使用 Gemini CLI 风格 OAuth 的用户可能有用,但 Hermes 会显示明确警告,因为 Google 可能将第三方软件使用 Gemini CLI OAuth 客户端的行为视为违反政策。对于生产环境或最低风险使用场景,请优先使用上述 API 密钥 provider。 - ## 可用模型 `hermes model` 选择器显示 Hermes provider 注册表中维护的 Gemini 模型。常见选项包括: @@ -192,17 +179,8 @@ hermes doctor doctor 命令检查: - `GOOGLE_API_KEY` 或 `GEMINI_API_KEY` 是否可用 -- `google-gemini-cli` 的 Gemini OAuth 凭据是否存在 - 已配置的 provider 凭据是否可以解析 -如需查看 OAuth 配额使用情况,请在 Hermes 会话中运行: - -```text -/gquota -``` - -`/gquota` 适用于 `google-gemini-cli` OAuth provider,不适用于 AI Studio API 密钥 provider。 - ## Gateway(消息平台) Gemini 可与所有 Hermes gateway 平台配合使用(Telegram、Discord、Slack、WhatsApp、LINE、飞书等)。将 Gemini 配置为你的 provider,然后正常启动 gateway: @@ -264,10 +242,6 @@ GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai/ GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta ``` -### OAuth 登录警告 - -`google-gemini-cli` provider 使用 Gemini CLI / Cloud Code Assist OAuth 流程。Hermes 在启动前会发出警告,因为这与官方 AI Studio API 密钥路径不同。如需官方 API 密钥集成,请使用 `provider: gemini` 配合 `GOOGLE_API_KEY`。 - ### 工具调用因 schema 错误而失败 升级 Hermes 并重新运行 `hermes model`。原生 Gemini 适配器会针对 Gemini 更严格的函数声明格式对工具 schema 进行清理;旧版本或自定义端点可能不支持此功能。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md index 35c28794b9b..68d7d5d0767 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md @@ -40,7 +40,6 @@ sidebar_position: 1 | **DeepSeek** | `~/.hermes/.env` 中的 `DEEPSEEK_API_KEY`(provider: `deepseek`) | | **Hugging Face** | `~/.hermes/.env` 中的 `HF_TOKEN`(provider: `huggingface`,别名:`hf`) | | **Google / Gemini** | `~/.hermes/.env` 中的 `GOOGLE_API_KEY`(或 `GEMINI_API_KEY`)(provider: `gemini`) | -| **Google Gemini(OAuth)** | `hermes model` → "Google Gemini (OAuth)"(provider: `google-gemini-cli`,支持免费层,浏览器 PKCE 登录) | | **LM Studio** | `hermes model` → "LM Studio"(provider: `lmstudio`,可选 `LM_API_KEY`) | | **自定义端点** | `hermes model` → 选择"Custom endpoint"(保存在 `config.yaml`) | @@ -512,79 +511,6 @@ model: 基础 URL 可通过 `HF_BASE_URL` 覆盖。 -### 通过 OAuth 使用 Google Gemini(`google-gemini-cli`) - -`google-gemini-cli` 提供商使用 Google 的 Cloud Code Assist 后端——与 Google 自己的 `gemini-cli` 工具使用的 API 相同。支持**免费层**(个人账户每日配额充足)和**付费层**(通过 GCP 项目的 Standard/Enterprise)。 - -**快速开始:** - -```bash -hermes model -# → 选择"Google Gemini (OAuth)" -# → 查看政策警告,确认 -# → 浏览器打开 accounts.google.com,登录 -# → 完成——Hermes 在首次请求时自动开通免费层 -``` - -Hermes 默认使用 Google 的**公开** `gemini-cli` 桌面 OAuth 客户端——与 Google 在其开源 `gemini-cli` 中包含的凭据相同。桌面 OAuth 客户端不是机密客户端(PKCE 提供安全保障)。你无需安装 `gemini-cli` 或注册自己的 GCP OAuth 客户端。 - -**认证工作原理:** -- 针对 `accounts.google.com` 的 PKCE 授权码流程 -- 浏览器回调地址 `http://127.0.0.1:8085/oauth2callback`(端口占用时自动回退到临时端口) -- Token 存储在 `~/.hermes/auth/google_oauth.json`(chmod 0600,原子写入,跨进程 `fcntl` 锁) -- 到期前 60 秒自动刷新 -- 无头环境(SSH、`HERMES_HEADLESS=1`)→ 粘贴模式回退 -- 并发刷新去重——两个并发请求不会触发双重刷新 -- `invalid_grant`(刷新 token 被撤销)→ 凭据文件被清除,提示用户重新登录 - -**推理工作原理:** -- 流量发送到 `https://cloudcode-pa.googleapis.com/v1internal:generateContent` - (流式传输为 `:streamGenerateContent?alt=sse`),而非付费的 `v1beta/openai` 端点 -- 请求体封装为 `{project, model, user_prompt_id, request}` -- OpenAI 格式的 `messages[]`、`tools[]`、`tool_choice` 被转换为 Gemini 原生的 - `contents[]`、`tools[].functionDeclarations`、`toolConfig` 格式 -- 响应转换回 OpenAI 格式,Hermes 其余部分无感知 - -**层级与项目 ID:** - -| 你的情况 | 操作 | -|---|---| -| 个人 Google 账户,使用免费层 | 无需操作——登录即可开始聊天 | -| Workspace / Standard / Enterprise 账户 | 将 `HERMES_GEMINI_PROJECT_ID` 或 `GOOGLE_CLOUD_PROJECT` 设置为你的 GCP 项目 ID | -| VPC-SC 保护的组织 | Hermes 检测到 `SECURITY_POLICY_VIOLATED` 后自动强制使用 `standard-tier` | - -免费层在首次使用时自动开通 Google 托管项目。无需 GCP 配置。 - -**配额监控:** - -``` -/gquota -``` - -以进度条显示每个模型的剩余 Code Assist 配额: - -``` -Gemini Code Assist quota (project: 123-abc) - - gemini-2.5-pro ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░ 85% - gemini-2.5-flash [input] ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░ 92% -``` - -:::warning 政策风险 -Google 认为将 Gemini CLI OAuth 客户端用于第三方软件违反政策。部分用户反映账户受到限制。为降低风险,建议改用 `gemini` 提供商并通过 API key 访问。Hermes 会在 OAuth 开始前显示警告并要求明确确认。 -::: - -**自定义 OAuth 客户端(可选):** - -如果你希望注册自己的 Google OAuth 客户端——例如将配额和授权范围限定在自己的 GCP 项目内——请设置: - -```bash -HERMES_GEMINI_CLIENT_ID=your-client.apps.googleusercontent.com -HERMES_GEMINI_CLIENT_SECRET=... # 桌面客户端可选 -``` - -在 [console.cloud.google.com/apis/credentials](https://console.cloud.google.com/apis/credentials) 注册一个**桌面应用** OAuth 客户端,并启用 Generative Language API。 - ## 自定义与自托管 LLM 提供商 Hermes Agent 可与**任何 OpenAI 兼容 API 端点**配合使用。只要服务器实现了 `/v1/chat/completions`,就可以将 Hermes 指向它。这意味着你可以使用本地模型、GPU 推理服务器、多提供商路由器或任何第三方 API。 @@ -1477,7 +1403,7 @@ fallback_model: 激活时,故障转移在不丢失对话的情况下中途切换模型和提供商。链按条目逐一尝试;每个会话激活一次。 -支持的提供商:`openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`google-gemini-cli`、`qwen-oauth`、`huggingface`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`bedrock`、`azure-foundry`、`opencode-zen`、`opencode-go`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`stepfun`、`lmstudio`、`alibaba`、`alibaba-coding-plan`、`tencent-tokenhub`、`custom`。 +支持的提供商:`openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`qwen-oauth`、`huggingface`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`bedrock`、`azure-foundry`、`opencode-zen`、`opencode-go`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`stepfun`、`lmstudio`、`alibaba`、`alibaba-coding-plan`、`tencent-tokenhub`、`custom`。 :::tip 故障转移仅通过 `config.yaml` 配置——或通过 `hermes fallback` 交互式配置。有关触发时机、链推进方式以及与辅助任务和委托的交互,参见[故障转移提供商](/user-guide/features/fallback-providers)。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md index 24e896253a6..0643d50a19e 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md @@ -95,7 +95,7 @@ hermes chat [options] | `-q`, `--query "..."` | 单次非交互式 prompt。 | | `-m`, `--model ` | 覆盖本次运行的模型。 | | `-t`, `--toolsets ` | 启用逗号分隔的 toolset 集合。 | -| `--provider ` | 强制指定 provider:`auto`、`openrouter`、`nous`、`openai-codex`、`copilot-acp`、`copilot`、`anthropic`、`gemini`、`google-gemini-cli`、`huggingface`、`novita`(别名 `novita-ai`、`novitaai`)、`openai-api`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`alibaba`、`alibaba-coding-plan`(别名 `alibaba_coding`)、`deepseek`、`nvidia`、`ollama-cloud`、`xai`(别名 `grok`)、`xai-oauth`(别名 `grok-oauth`)、`qwen-oauth`、`bedrock`、`opencode-zen`、`opencode-go`、`azure-foundry`、`lmstudio`、`stepfun`、`tencent-tokenhub`(别名 `tencent`、`tokenhub`)。 | +| `--provider ` | 强制指定 provider:`auto`、`openrouter`、`nous`、`openai-codex`、`copilot-acp`、`copilot`、`anthropic`、`gemini`、`huggingface`、`novita`(别名 `novita-ai`、`novitaai`)、`openai-api`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`kilocode`、`xiaomi`、`arcee`、`gmi`、`alibaba`、`alibaba-coding-plan`(别名 `alibaba_coding`)、`deepseek`、`nvidia`、`ollama-cloud`、`xai`(别名 `grok`)、`xai-oauth`(别名 `grok-oauth`)、`qwen-oauth`、`bedrock`、`opencode-zen`、`opencode-go`、`azure-foundry`、`lmstudio`、`stepfun`、`tencent-tokenhub`(别名 `tencent`、`tokenhub`)。 | | `-s`, `--skills ` | 为会话预加载一个或多个 skill(可重复或逗号分隔)。 | | `-v`, `--verbose` | 详细输出。 | | `-Q`, `--quiet` | 程序化模式:抑制横幅/spinner/工具预览。 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md index 72f6a49387a..87f835a5bfb 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md @@ -63,9 +63,6 @@ description: "Hermes Agent 使用的所有环境变量完整参考" | `GOOGLE_API_KEY` | Google AI Studio API 密钥([aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)) | | `GEMINI_API_KEY` | `GOOGLE_API_KEY` 的别名 | | `GEMINI_BASE_URL` | 覆盖 Google AI Studio base URL | -| `HERMES_GEMINI_CLIENT_ID` | `google-gemini-cli` PKCE 登录的 OAuth 客户端 ID(可选;默认使用 Google 公共 gemini-cli 客户端) | -| `HERMES_GEMINI_CLIENT_SECRET` | `google-gemini-cli` 的 OAuth 客户端密钥(可选) | -| `HERMES_GEMINI_PROJECT_ID` | 付费 Gemini 层级的 GCP 项目 ID(免费层级自动配置) | | `ANTHROPIC_API_KEY` | Anthropic Console API 密钥([console.anthropic.com](https://console.anthropic.com/)) | | `ANTHROPIC_TOKEN` | 手动或旧版 Anthropic OAuth/setup-token 覆盖 | | `DASHSCOPE_API_KEY` | Qwen Cloud(阿里巴巴 DashScope)Qwen 模型 API 密钥([modelstudio.console.alibabacloud.com](https://modelstudio.console.alibabacloud.com/)) | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md index f062651dcf9..2294119f36b 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md @@ -20,7 +20,7 @@ Hermes Agent 可与任何兼容 OpenAI 的 API 配合使用。支持的提供商 - **Nous Portal** — Nous Research 自有推理端点 - **OpenAI** — GPT-5.4、GPT-5-codex、GPT-4.1、GPT-4o 等 - **Anthropic** — Claude 模型(直接 API、通过 `hermes auth add anthropic` 进行 OAuth、OpenRouter 或任何兼容代理) -- **Google** — Gemini 模型(通过 `gemini` 提供商直接调用 API、`google-gemini-cli` OAuth 提供商、OpenRouter 或兼容代理) +- **Google** — Gemini 模型(通过 `gemini` 提供商直接调用 API、OpenRouter 或兼容代理) - **z.ai / ZhipuAI** — GLM 模型 - **Kimi / Moonshot AI** — Kimi 模型 - **MiniMax** — 全球及中国区端点 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md index 665a6a3579b..be7e1ca69ac 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/slash-commands.md @@ -115,7 +115,6 @@ Hermes 有两个斜杠命令入口,均由 `hermes_cli/commands.py` 中的中 | `/image ` | 为下一条 prompt 附加本地图片文件。 | | `/debug` | 上传调试报告(系统信息 + 日志)并获取可分享链接。消息平台中也可用。 | | `/profile` | 显示活动 profile 名称和主目录 | -| `/gquota` | 以进度条形式显示 Google Gemini Code Assist 配额用量(仅在 `google-gemini-cli` 提供商激活时可用)。 | ### 退出 @@ -246,7 +245,7 @@ hermes config set model.aliases.grok x-ai/grok-4 ## 注意事项 -- `/skin`、`/snapshot`、`/gquota`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/busy`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。 +- `/skin`、`/snapshot`、`/reload`、`/tools`、`/toolsets`、`/browser`、`/config`、`/cron`、`/platforms`、`/paste`、`/image`、`/statusbar`、`/plugins`、`/busy`、`/indicator`、`/redraw`、`/clear`、`/history`、`/save`、`/copy`、`/handoff`、`/billing` 和 `/quit` 是**仅限 CLI** 的命令。 - `/skills` **仅在搜索/浏览/安装时属于 CLI-only**;其写入审批子命令(`pending`、`approve`、`reject`、`diff`、`approval`)在 `skills.write_approval` 开启时也可在消息平台使用。`/memory` 可在**两个表面**使用。 - `/verbose` **默认仅限 CLI**,但可通过在 `config.yaml` 中设置 `display.tool_progress_command: true` 为消息平台启用。启用后,它会循环切换 `display.tool_progress` 模式并保存到配置。 - `/sethome`、`/update`、`/restart`、`/approve`、`/deny`、`/topic`、`/platform` 和 `/commands` 是**仅限消息平台**的命令。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md index 1dbdab3befc..cd3748530d3 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md @@ -774,7 +774,7 @@ Hermes 中的每个模型槽位 —— 辅助任务、压缩、回退 —— 使 当设置 `base_url` 时,Hermes 忽略 provider 并直接调用该端点(使用 `api_key` 或 `OPENAI_API_KEY` 进行认证)。当仅设置 `provider` 时,Hermes 使用该 provider 的内置认证和基础 URL。 -辅助任务的可用 providers:`auto`、`main`,以及[provider 注册表](/reference/environment-variables)中的任何 provider —— `openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`google-gemini-cli`、`qwen-oauth`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`alibaba`、`bedrock`、`huggingface`、`arcee`、`xiaomi`、`kilocode`、`opencode-zen`、`opencode-go`、`azure-foundry` —— 或您 `custom_providers` 列表中任何命名的自定义 provider(例如 `provider: "beans"`)。 +辅助任务的可用 providers:`auto`、`main`,以及[provider 注册表](/reference/environment-variables)中的任何 provider —— `openrouter`、`nous`、`openai-codex`、`copilot`、`copilot-acp`、`anthropic`、`gemini`、`qwen-oauth`、`zai`、`kimi-coding`、`kimi-coding-cn`、`minimax`、`minimax-cn`、`minimax-oauth`、`deepseek`、`nvidia`、`xai`、`xai-oauth`、`ollama-cloud`、`alibaba`、`bedrock`、`huggingface`、`arcee`、`xiaomi`、`kilocode`、`opencode-zen`、`opencode-go`、`azure-foundry` —— 或您 `custom_providers` 列表中任何命名的自定义 provider(例如 `provider: "beans"`)。 :::tip MiniMax OAuth `minimax-oauth` 通过浏览器 OAuth 登录(无需 API 密钥)。运行 `hermes model` 并选择 **MiniMax (OAuth)** 进行认证。辅助任务自动使用 `MiniMax-M2.7-highspeed`。参阅 [MiniMax OAuth 指南](../guides/minimax-oauth.md)。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md index 4fd4125ee66..383be7370c3 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/fallback-providers.md @@ -62,7 +62,6 @@ fallback_model: | GMI Cloud | `gmi` | `GMI_API_KEY`(可选:`GMI_BASE_URL`) | | StepFun | `stepfun` | `STEPFUN_API_KEY`(可选:`STEPFUN_BASE_URL`) | | Ollama Cloud | `ollama-cloud` | `OLLAMA_API_KEY` | -| Google Gemini(OAuth) | `google-gemini-cli` | `hermes model`(Google OAuth;可选:`HERMES_GEMINI_PROJECT_ID`) | | Google AI Studio | `gemini` | `GOOGLE_API_KEY`(别名:`GEMINI_API_KEY`) | | xAI(Grok) | `xai`(别名 `grok`) | `XAI_API_KEY`(可选:`XAI_BASE_URL`) | | xAI Grok OAuth(SuperGrok) | `xai-oauth`(别名 `grok-oauth`) | `hermes model` → xAI Grok OAuth(浏览器登录;需 SuperGrok 订阅) | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index eee73a2b4aa..52e09c32604 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -332,7 +332,6 @@ hermes uninstall Uninstall Hermes /commands [page] Browse all commands (gateway) /usage Token usage /insights [days] Usage analytics -/gquota Show Google Gemini Code Assist quota usage (CLI) /status Session info (gateway) /profile Active profile info /debug Upload debug report (system info + logs) and get shareable links From 0768ed3b33e43df7de05c59017c997bb5e2960f5 Mon Sep 17 00:00:00 2001 From: TutkuEroglu Date: Mon, 22 Jun 2026 02:59:54 +0300 Subject: [PATCH 447/470] docs(agents): fix stale platform adapter path in token-lock note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gateway/platforms/telegram.py no longer exists (adapters moved to plugins/platforms//adapter.py) and telegram no longer uses the scoped-lock pattern. Point the token-lock canonical-pattern reference to plugins/platforms/irc/adapter.py, which acquires the lock in connect() and releases it in disconnect() — and is already cited as a canonical example in ADDING_A_PLATFORM.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index eb769fa2502..30deedf5bf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1175,7 +1175,7 @@ automatically scope to the active profile. a unique credential (bot token, API key), call `acquire_scoped_lock()` from `gateway.status` in the `connect()`/`start()` method and `release_scoped_lock()` in `disconnect()`/`stop()`. This prevents two profiles from using the same credential. - See `gateway/platforms/telegram.py` for the canonical pattern. + See `plugins/platforms/irc/adapter.py` for the canonical pattern. 6. **Profile operations are HOME-anchored, not HERMES_HOME-anchored** — `_get_profiles_root()` returns `Path.home() / ".hermes" / "profiles"`, NOT `get_hermes_home() / "profiles"`. From 4c1934dd8731fdd36e714f8caa422741e82cc391 Mon Sep 17 00:00:00 2001 From: Hermes Agent <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:04:22 -0700 Subject: [PATCH 448/470] docs: repoint remaining stale gateway/platforms adapter refs to plugins/platforms Sibling-site follow-up to the AGENTS.md token-lock fix (#50481). Platform adapters migrated from gateway/platforms/.py to plugins/platforms//adapter.py; a handful (signal, weixin, bluebubbles, qqbot, yuanbao, msgraph_webhook, webhook, api_server) still live in gateway/platforms/. - adding-platform-adapters.md: new-adapter creation path + reference-impl table - gateway-internals.md: rewrite the adapter tree to reflect the actual split - zh-Hans mirrors of both kept in parity - scripts/release.py: add TutkuEroglu to AUTHOR_MAP (CI gate) --- scripts/release.py | 1 + .../adding-platform-adapters.md | 4 +- .../docs/developer-guide/gateway-internals.md | 41 +++++++++++-------- .../adding-platform-adapters.md | 4 +- .../developer-guide/gateway-internals.md | 41 +++++++++++-------- 5 files changed, 51 insertions(+), 40 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index a943efe066e..e10ffcb7144 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "rrandqua@gmail.com": "TutkuEroglu", # PR #50481 salvage (AGENTS.md stale token-lock adapter path) "pedro.m.simoes@gmail.com": "pmos69", # PR #29474 salvage (native Antigravity OAuth provider; Gemini CLI sunset #29294/#49701) "mediratta01.pally@gmail.com": "orbisai0security", # PR #9560 salvage (session.py path-traversal guard, V-009) "panghuer023@users.noreply.github.com": "panghuer023", # PR #37994 salvage (interrupt unblocks pending gateway approval; #8697) diff --git a/website/docs/developer-guide/adding-platform-adapters.md b/website/docs/developer-guide/adding-platform-adapters.md index 9e8340c8e11..652beed4fcd 100644 --- a/website/docs/developer-guide/adding-platform-adapters.md +++ b/website/docs/developer-guide/adding-platform-adapters.md @@ -476,7 +476,7 @@ class Platform(str, Enum): ### 2. Adapter File -Create `gateway/platforms/newplat.py`: +Create `plugins/platforms/newplat/adapter.py`: ```python from gateway.config import Platform, PlatformConfig @@ -689,4 +689,4 @@ async def disconnect(self): | `bluebubbles.py` | REST + webhook | Medium | Simple REST API integration | | `weixin.py` | Long-poll + CDN | High | Media handling, encryption | | `wecom_callback.py` | Callback/webhook | Medium | HTTP server, AES crypto, multi-app | -| `telegram.py` | Long-poll + Bot API | High | Full-featured adapter with groups, threads | +| `plugins/platforms/irc/adapter.py` | Long-poll + IRC protocol | High | Full-featured plugin adapter with scoped token lock | diff --git a/website/docs/developer-guide/gateway-internals.md b/website/docs/developer-guide/gateway-internals.md index bdf6b153efc..146b0587b49 100644 --- a/website/docs/developer-guide/gateway-internals.md +++ b/website/docs/developer-guide/gateway-internals.md @@ -143,32 +143,37 @@ Unlike the CLI (which uses `load_cli_config()` with hardcoded defaults), the gat ## Platform Adapters -Each messaging platform has an adapter in `gateway/platforms/`: +Most messaging platforms ship as plugin adapters under `plugins/platforms//adapter.py`; a few legacy adapters still live directly in `gateway/platforms/`. All extend `BasePlatformAdapter` from `gateway/platforms/base.py`: ```text -gateway/platforms/ -├── base.py # BaseAdapter — shared logic for all platforms -├── telegram.py # Telegram Bot API (long polling or webhook) -├── discord.py # Discord bot via discord.py -├── slack.py # Slack Socket Mode -├── whatsapp.py # WhatsApp Business Cloud API +plugins/platforms/ # plugin-packaged adapters (one dir each) +├── telegram/adapter.py # Telegram Bot API (long polling or webhook) +├── discord/adapter.py # Discord bot via discord.py +├── slack/adapter.py # Slack Socket Mode +├── whatsapp/adapter.py # WhatsApp Business Cloud API +├── matrix/adapter.py # Matrix via mautrix (optional E2EE) +├── mattermost/adapter.py # Mattermost WebSocket API +├── email/adapter.py # Email via IMAP/SMTP +├── sms/adapter.py # SMS via Twilio +├── dingtalk/adapter.py # DingTalk WebSocket +├── feishu/adapter.py # Feishu/Lark WebSocket or webhook +├── wecom/adapter.py # WeCom (WeChat Work) callback +├── line/adapter.py # LINE Messaging API +├── teams/adapter.py # Microsoft Teams +├── irc/adapter.py # IRC (canonical scoped-lock example) +├── homeassistant/adapter.py # Home Assistant conversation integration +└── … # google_chat, ntfy, photon, raft, simplex, … + +gateway/platforms/ # core base + legacy direct adapters +├── base.py # BasePlatformAdapter — shared logic for all platforms ├── signal.py # Signal via signal-cli REST API -├── matrix.py # Matrix via mautrix (optional E2EE) -├── mattermost.py # Mattermost WebSocket API -├── email.py # Email via IMAP/SMTP -├── sms.py # SMS via Twilio -├── dingtalk.py # DingTalk WebSocket -├── feishu.py # Feishu/Lark WebSocket or webhook -├── wecom.py # WeCom (WeChat Work) callback ├── weixin.py # Weixin (personal WeChat) via iLink Bot API ├── bluebubbles.py # Apple iMessage via BlueBubbles macOS server -├── qqbot/ # QQ Bot (Tencent QQ) via Official API v2 (sub-package: adapter.py, crypto.py, keyboards.py, …) +├── qqbot/ # QQ Bot (Tencent QQ) via Official API v2 (sub-package) ├── yuanbao.py # Yuanbao (Tencent) DM/group adapter -├── feishu_comment.py # Feishu document/drive comment-reply handler ├── msgraph_webhook.py # Microsoft Graph change-notification webhook (Teams, Outlook, etc.) ├── webhook.py # Inbound/outbound webhook adapter -├── api_server.py # REST API server adapter -└── homeassistant.py # Home Assistant conversation integration +└── api_server.py # REST API server adapter ``` Experimental connector-backed platforms use the generic relay adapter in `gateway/relay/` instead of a direct platform module. When `GATEWAY_RELAY_URL` or `gateway.relay_url` is configured, the gateway registers the `relay` platform, dials the connector over an outbound WebSocket, and receives `descriptor`, `inbound`, and `interrupt_inbound` frames on that same socket. The connector advertises a `CapabilityDescriptor`; Hermes can send normal outbound replies, token-less `follow_up` operations, and interrupt frames back through the relay. The source-grounded wire contract lives in [`docs/relay-connector-contract.md`](https://github.com/NousResearch/hermes-agent/blob/main/docs/relay-connector-contract.md). diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-platform-adapters.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-platform-adapters.md index 0a947fa16db..43bd0b49fe3 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-platform-adapters.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/adding-platform-adapters.md @@ -472,7 +472,7 @@ class Platform(str, Enum): ### 2. 适配器文件 -创建 `gateway/platforms/newplat.py`: +创建 `plugins/platforms/newplat/adapter.py`: ```python from gateway.config import Platform, PlatformConfig @@ -685,4 +685,4 @@ async def disconnect(self): | `bluebubbles.py` | REST + webhook | 中 | 简单 REST API 集成 | | `weixin.py` | 长轮询 + CDN | 高 | 媒体处理、加密 | | `wecom_callback.py` | 回调/webhook | 中 | HTTP 服务器、AES 加密、多应用 | -| `telegram.py` | 长轮询 + Bot API | 高 | 支持群组、线程的全功能适配器 | \ No newline at end of file +| `plugins/platforms/irc/adapter.py` | 长轮询 + IRC 协议 | 高 | 带作用域令牌锁的全功能插件适配器 | \ No newline at end of file diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/gateway-internals.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/gateway-internals.md index 50de95a1ebf..63c89d7e802 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/gateway-internals.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/gateway-internals.md @@ -143,32 +143,37 @@ Gateway 从多个来源读取配置: ## 平台适配器 -每个消息平台在 `gateway/platforms/` 下均有对应适配器: +大多数消息平台以插件适配器形式位于 `plugins/platforms//adapter.py`;少数旧适配器仍直接位于 `gateway/platforms/`。它们都继承 `gateway/platforms/base.py` 中的 `BasePlatformAdapter`: ```text -gateway/platforms/ -├── base.py # BaseAdapter — 所有平台的共享逻辑 -├── telegram.py # Telegram Bot API(长轮询或 webhook) -├── discord.py # Discord bot(通过 discord.py) -├── slack.py # Slack Socket Mode -├── whatsapp.py # WhatsApp Business Cloud API +plugins/platforms/ # 插件打包的适配器(每个一个目录) +├── telegram/adapter.py # Telegram Bot API(长轮询或 webhook) +├── discord/adapter.py # Discord bot(通过 discord.py) +├── slack/adapter.py # Slack Socket Mode +├── whatsapp/adapter.py # WhatsApp Business Cloud API +├── matrix/adapter.py # Matrix(通过 mautrix,可选 E2EE) +├── mattermost/adapter.py # Mattermost WebSocket API +├── email/adapter.py # 电子邮件(通过 IMAP/SMTP) +├── sms/adapter.py # 短信(通过 Twilio) +├── dingtalk/adapter.py # 钉钉 WebSocket +├── feishu/adapter.py # 飞书/Lark WebSocket 或 webhook +├── wecom/adapter.py # 企业微信(WeCom)回调 +├── line/adapter.py # LINE Messaging API +├── teams/adapter.py # Microsoft Teams +├── irc/adapter.py # IRC(作用域锁的标准示例) +├── homeassistant/adapter.py # Home Assistant 对话集成 +└── … # google_chat、ntfy、photon、raft、simplex 等 + +gateway/platforms/ # 核心 base 与旧的直接适配器 +├── base.py # BasePlatformAdapter — 所有平台的共享逻辑 ├── signal.py # Signal(通过 signal-cli REST API) -├── matrix.py # Matrix(通过 mautrix,可选 E2EE) -├── mattermost.py # Mattermost WebSocket API -├── email.py # 电子邮件(通过 IMAP/SMTP) -├── sms.py # 短信(通过 Twilio) -├── dingtalk.py # 钉钉 WebSocket -├── feishu.py # 飞书/Lark WebSocket 或 webhook -├── wecom.py # 企业微信(WeCom)回调 ├── weixin.py # 微信(个人版,通过 iLink Bot API) ├── bluebubbles.py # Apple iMessage(通过 BlueBubbles macOS 服务端) -├── qqbot/ # QQ Bot(腾讯 QQ,通过官方 API v2,子包:adapter.py、crypto.py、keyboards.py 等) +├── qqbot/ # QQ Bot(腾讯 QQ,通过官方 API v2,子包) ├── yuanbao.py # 元宝(腾讯)私信/群组适配器 -├── feishu_comment.py # 飞书文档/云盘评论回复处理器 ├── msgraph_webhook.py # Microsoft Graph 变更通知 webhook(Teams、Outlook 等) ├── webhook.py # 入站/出站 webhook 适配器 -├── api_server.py # REST API 服务器适配器 -└── homeassistant.py # Home Assistant 对话集成 +└── api_server.py # REST API 服务器适配器 ``` 适配器实现统一接口: From b0a25980f89fc42b495d7d6ec17bf879c9b5d5c3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:00:06 -0700 Subject: [PATCH 449/470] fix(terminal): make hermes install dir reachable in subshell PATH (#50534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins shelling out to bare `hermes` via the terminal tool hit `command not found` (exit 127) when the gateway was launched without the hermes install dir on PATH (systemd, service managers, cron, desktop launchers) — even though `hermes` works in the user's own interactive terminal, which sources the shell rc that exports that dir. The terminal tool's subshell PATH was the agent process PATH plus a static set of system dirs (_SANE_PATH); it never included wherever the hermes console-script actually lives (~/.local/bin, the venv bin/Scripts, pipx, nix). Resolve that dir once (which/argv0/sys.executable) and prepend-if-missing it so bare `hermes` resolves regardless of launch method. --- tests/tools/test_local_env_blocklist.py | 92 +++++++++++++++++++++++++ tools/environments/local.py | 86 ++++++++++++++++++++++- 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 875b8a15ccb..2a016d49f4d 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -12,6 +12,8 @@ import os import threading from unittest.mock import MagicMock, patch +import pytest + from tools.environments.local import ( LocalEnvironment, _HERMES_PROVIDER_ENV_BLOCKLIST, @@ -379,6 +381,18 @@ class TestBlocklistCoverage: class TestSanePathIncludesHomebrew: """Verify _SANE_PATH includes macOS Homebrew directories.""" + @pytest.fixture(autouse=True) + def _disable_hermes_bin_injection(self): + """These tests assert the sane-path merge in isolation. Disable the + hermes-install-dir prepend (a separate concern, covered by + TestHermesBinDirOnPath) so a real ``hermes`` on the test runner's PATH + doesn't shift the asserted PATH layout.""" + from tools.environments import local as local_mod + saved = local_mod._HERMES_BIN_DIR + local_mod._HERMES_BIN_DIR = None # resolved -> no dir to inject + yield + local_mod._HERMES_BIN_DIR = saved + def test_sane_path_includes_homebrew_bin(self): from tools.environments.local import _SANE_PATH assert "/opt/homebrew/bin" in _SANE_PATH @@ -471,3 +485,81 @@ class TestSanePathIncludesHomebrew: result = _make_run_env({}) assert result["Path"] == windows_env["Path"] assert "PATH" not in result + + +class TestHermesBinDirOnPath: + """The hermes install dir is reachable in the terminal subshell PATH. + + Plugins shelling out to bare ``hermes`` via the terminal tool must work + even when the gateway was launched without the hermes install dir on + PATH (systemd, service managers, cron). See the discussion that motivated + _resolve_hermes_bin_dir / _prepend_hermes_bin_dir. + """ + + def _reset_cache(self): + from tools.environments import local as local_mod + local_mod._HERMES_BIN_DIR = local_mod._SENTINEL + + def test_resolves_via_which(self, monkeypatch): + from tools.environments import local as local_mod + self._reset_cache() + monkeypatch.setattr(local_mod.shutil, "which", + lambda name: "/opt/hermes/bin/hermes" if name == "hermes" else None) + monkeypatch.setattr(local_mod.os.path, "isdir", lambda p: p == "/opt/hermes/bin") + assert local_mod._resolve_hermes_bin_dir() == "/opt/hermes/bin" + + def test_resolves_via_sys_executable_dir(self, monkeypatch, tmp_path): + from tools.environments import local as local_mod + self._reset_cache() + venv_bin = tmp_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "hermes").write_text("#!/bin/sh\n") + monkeypatch.setattr(local_mod.shutil, "which", lambda name: None) + monkeypatch.setattr(local_mod.sys, "argv", ["python"]) + monkeypatch.setattr(local_mod.sys, "executable", str(venv_bin / "python")) + monkeypatch.setattr(local_mod, "_IS_WINDOWS", False) + assert local_mod._resolve_hermes_bin_dir() == str(venv_bin) + + def test_returns_none_when_unresolvable(self, monkeypatch): + from tools.environments import local as local_mod + self._reset_cache() + monkeypatch.setattr(local_mod.shutil, "which", lambda name: None) + monkeypatch.setattr(local_mod.sys, "argv", ["python"]) + monkeypatch.setattr(local_mod.sys, "executable", "/nonexistent/python") + assert local_mod._resolve_hermes_bin_dir() is None + + def test_prepend_adds_missing_dir_at_front(self, monkeypatch): + from tools.environments import local as local_mod + self._reset_cache() + local_mod._HERMES_BIN_DIR = "/opt/hermes/bin" + out = local_mod._prepend_hermes_bin_dir("/usr/bin:/bin") + assert out.split(os.pathsep)[0] == "/opt/hermes/bin" + assert "/usr/bin" in out.split(os.pathsep) + + def test_prepend_is_idempotent(self, monkeypatch): + from tools.environments import local as local_mod + self._reset_cache() + local_mod._HERMES_BIN_DIR = "/opt/hermes/bin" + once = local_mod._prepend_hermes_bin_dir("/usr/bin:/bin") + twice = local_mod._prepend_hermes_bin_dir(once) + assert twice == once + assert once.split(os.pathsep).count("/opt/hermes/bin") == 1 + + def test_prepend_noop_when_unresolved(self, monkeypatch): + from tools.environments import local as local_mod + self._reset_cache() + local_mod._HERMES_BIN_DIR = None + assert local_mod._prepend_hermes_bin_dir("/usr/bin:/bin") == "/usr/bin:/bin" + + def test_make_run_env_injects_hermes_bin_dir(self, monkeypatch): + """A gateway env missing the hermes dir gets it back in the subshell PATH.""" + from tools.environments import local as local_mod + from tools.environments.local import _make_run_env + self._reset_cache() + local_mod._HERMES_BIN_DIR = "/opt/hermes/bin" + monkeypatch.setattr(local_mod, "_IS_WINDOWS", False) + with patch.dict(os.environ, {"PATH": "/usr/bin:/bin"}, clear=True): + result = _make_run_env({}) + entries = result["PATH"].split(os.pathsep) + assert entries[0] == "/opt/hermes/bin" + assert "/usr/bin" in entries diff --git a/tools/environments/local.py b/tools/environments/local.py index b808816ef16..baec8fa2138 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -7,6 +7,7 @@ import re import shutil import signal import subprocess +import sys import tempfile import time from pathlib import Path @@ -296,6 +297,85 @@ _SANE_PATH = ( "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ) +# Cached directory containing the ``hermes`` console-script. +# ``_SENTINEL`` distinguishes "not resolved yet" from a resolved ``None``. +_SENTINEL = object() +_HERMES_BIN_DIR: "str | None | object" = _SENTINEL + + +def _resolve_hermes_bin_dir() -> str | None: + """Return the directory holding the ``hermes`` console-script, or None. + + The terminal tool runs in a freshly-spawned subshell whose PATH is the + agent process's PATH plus a static set of system dirs (``_SANE_PATH``). + When the gateway is launched by something that does NOT source the user's + shell rc — systemd, a service manager, a desktop launcher, cron — the + hermes install dir (``~/.local/bin``, the venv ``bin``/``Scripts``, pipx, + nix) is absent from that PATH, so plugins shelling out to bare ``hermes`` + via the terminal tool hit ``command not found`` (exit 127) even though + ``hermes`` works fine in the user's own interactive terminal. + + We resolve the install dir once (it never changes within a process) and + prepend-if-missing it to the subshell PATH so bare ``hermes`` resolves + regardless of how the gateway was started. + + Resolution order (cheap, no heavy imports): + 1. ``shutil.which("hermes")`` — normal PATH-installed shim. + 2. The directory of ``sys.argv[0]`` when it's an absolute path to a + real ``hermes`` executable (covers nix-store / venv wrappers). + 3. The directory of ``sys.executable`` — the running interpreter's + venv ``bin``/``Scripts`` is where its console-scripts live. + """ + global _HERMES_BIN_DIR + if _HERMES_BIN_DIR is not _SENTINEL: + return _HERMES_BIN_DIR # type: ignore[return-value] + + candidate: str | None = None + + which = shutil.which("hermes") + if which: + candidate = os.path.dirname(which) + + if candidate is None: + argv0 = sys.argv[0] if sys.argv else "" + base = os.path.basename(argv0).lower() + if ( + os.path.isabs(argv0) + and (base == "hermes" or base.startswith("hermes.")) + and os.path.isfile(argv0) + ): + candidate = os.path.dirname(argv0) + + if candidate is None: + exe_dir = os.path.dirname(sys.executable) if sys.executable else "" + if exe_dir: + shim = "hermes.exe" if _IS_WINDOWS else "hermes" + if os.path.isfile(os.path.join(exe_dir, shim)): + candidate = exe_dir + + if candidate and not os.path.isdir(candidate): + candidate = None + + _HERMES_BIN_DIR = candidate + return candidate + + +def _prepend_hermes_bin_dir(existing_path: str) -> str: + """Prepend the hermes install dir to ``existing_path`` if it's missing. + + Cross-platform (uses ``os.pathsep``). First-occurrence wins, so a PATH + that already contains the dir is returned unchanged. Returns the input + unchanged when the install dir can't be resolved. + """ + bin_dir = _resolve_hermes_bin_dir() + if not bin_dir: + return existing_path + sep = os.pathsep + entries = [e for e in existing_path.split(sep) if e] if existing_path else [] + if bin_dir in entries: + return existing_path + return sep.join([bin_dir, *entries]) + def _append_missing_sane_path_entries(existing_path: str) -> str: """Return a normalised POSIX PATH with missing sane entries appended. @@ -380,7 +460,11 @@ def _make_run_env(env: dict) -> dict: run_env[k] = v path_key = _path_env_key(run_env) if path_key is not None: - run_env[path_key] = _append_missing_sane_path_entries(run_env.get(path_key, "")) + new_path = _append_missing_sane_path_entries(run_env.get(path_key, "")) + # Ensure the hermes install dir is reachable so plugins can shell out + # to bare ``hermes`` via the terminal tool even when the gateway was + # launched without it on PATH (systemd, service managers, cron, etc.). + run_env[path_key] = _prepend_hermes_bin_dir(new_path) _inject_context_hermes_home(run_env) From 95d53c3bcb066ab4180f1c6e2493727ef2ecdee6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:21:11 -0700 Subject: [PATCH 450/470] =?UTF-8?q?feat(cli):=20/reasoning=20full=20?= =?UTF-8?q?=E2=80=94=20show=20complete=20thinking,=20not=2010-line=20clamp?= =?UTF-8?q?=20(#50499)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): /reasoning full to show complete thinking, not 10-line clamp The post-response Reasoning recap box hard-clamped long thinking to the first 10 lines, so there was no way to see the full reasoning trace after a turn (live streaming already shows it in full). Add display.reasoning_full (default off) plus /reasoning full|clamp to toggle it at runtime; the clamp truncation note now points at the command. Addresses repeated user requests to show all thinking tokens. * test(gateway): de-snapshot /reasoning help assertion The test froze the exact args-hint literal '/reasoning [level|show|hide]', which the new full/clamp args change to '[level|show|hide|full|clamp]'. Convert to an invariant: assert /reasoning is in help and carries its core args, not the exact hint string. * feat(tui): /reasoning full|clamp parity in tui_gateway The classic-CLI reasoning_full toggle had no TUI equivalent — typing /reasoning full in the TUI fell through to parse_reasoning_effort and errored. The TUI renders thinking as an expand/collapse section (no fixed 10-line recap), so map full -> sections.thinking=expanded (raw, uncapped via thinkingPreview mode='full') and clamp -> collapsed, persisting display.reasoning_full for cross-surface config consistency. --- cli.py | 11 ++- hermes_cli/cli_commands_mixin.py | 22 ++++- hermes_cli/commands.py | 4 +- hermes_cli/config.py | 4 + tests/gateway/test_reasoning_command.py | 6 +- .../hermes_cli/test_reasoning_full_command.py | 81 +++++++++++++++++++ tests/test_tui_gateway_server.py | 27 +++++++ tui_gateway/server.py | 39 +++++++++ 8 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 tests/hermes_cli/test_reasoning_full_command.py diff --git a/cli.py b/cli.py index 4627ce2b2af..641044bc924 100644 --- a/cli.py +++ b/cli.py @@ -452,6 +452,7 @@ def load_cli_config() -> Dict[str, Any]: "resume_max_assistant_lines": 3, "resume_skip_tool_only": True, "show_reasoning": False, + "reasoning_full": False, "streaming": True, "busy_input_mode": "interrupt", "persistent_output": True, @@ -3405,6 +3406,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): 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) + # reasoning_full: when reasoning display is on, print the post-response + # recap box uncollapsed instead of clamping to the first 10 lines. + self.reasoning_full = CLI_CONFIG["display"].get("reasoning_full", False) _configure_output_history( enabled=CLI_CONFIG["display"].get("persistent_output", True), max_lines=CLI_CONFIG["display"].get("persistent_output_max_lines", 200), @@ -11543,11 +11547,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): r_fill = w - 2 - len(r_label) r_top = f"{_DIM}┌─{r_label}{'─' * max(r_fill - 1, 0)}┐{_RST}" r_bot = f"{_DIM}└{'─' * (w - 2)}┘{_RST}" - # Collapse long reasoning: show first 10 lines + # Collapse long reasoning to the first 10 lines unless the + # user opted into full display via /reasoning full. lines = reasoning.strip().splitlines() - if len(lines) > 10: + if len(lines) > 10 and not getattr(self, "reasoning_full", False): display_reasoning = "\n".join(lines[:10]) - display_reasoning += f"\n{_DIM} ... ({len(lines) - 10} more lines){_RST}" + display_reasoning += f"\n{_DIM} ... ({len(lines) - 10} more lines — /reasoning full to show){_RST}" else: display_reasoning = reasoning.strip() _cprint(f"\n{r_top}\n{_DIM}{display_reasoning}{_RST}\n{r_bot}") diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index a3e33ddb493..f4c05060140 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2021,6 +2021,8 @@ class CLICommandsMixin: /reasoning Set reasoning effort (none, minimal, low, medium, high, xhigh) /reasoning show|on Show model thinking/reasoning in output /reasoning hide|off Hide model thinking/reasoning from output + /reasoning full Show complete thinking (no 10-line clamp) + /reasoning clamp Collapse long thinking to the first 10 lines """ from cli import _ACCENT, _DIM, _RST, _cprint, _parse_reasoning_config, save_config_value parts = cmd.strip().split(maxsplit=1) @@ -2035,9 +2037,10 @@ class CLICommandsMixin: else: level = rc.get("effort", "medium") display_state = "on ✓" if self.show_reasoning else "off" + full_state = "full" if getattr(self, "reasoning_full", False) else "clamped to 10 lines" _cprint(f" {_ACCENT}Reasoning effort: {level}{_RST}") - _cprint(f" {_ACCENT}Reasoning display: {display_state}{_RST}") - _cprint(f" {_DIM}Usage: /reasoning {_RST}") + _cprint(f" {_ACCENT}Reasoning display: {display_state} ({full_state}){_RST}") + _cprint(f" {_DIM}Usage: /reasoning {_RST}") return arg = parts[1].strip().lower() @@ -2059,6 +2062,21 @@ class CLICommandsMixin: _cprint(f" {_ACCENT}✓ Reasoning display: OFF (saved){_RST}") return + # Full / clamped recap toggle + if arg in {"full", "all"}: + self.reasoning_full = True + save_config_value("display.reasoning_full", True) + _cprint(f" {_ACCENT}✓ Reasoning display: FULL (saved){_RST}") + _cprint(f" {_DIM} The post-response recap box will print complete thinking.{_RST}") + if not self.show_reasoning: + _cprint(f" {_DIM} Note: reasoning display is OFF — run /reasoning show to see it.{_RST}") + return + if arg in {"clamp", "collapse", "short"}: + self.reasoning_full = False + save_config_value("display.reasoning_full", False) + _cprint(f" {_ACCENT}✓ Reasoning display: CLAMPED to 10 lines (saved){_RST}") + return + # Effort level change parsed = _parse_reasoning_config(arg) if parsed is None: diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 2c7a69c4082..a0d0882dcbb 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -142,8 +142,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)", "Configuration"), CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", - args_hint="[level|show|hide]", - subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "show", "hide", "on", "off")), + args_hint="[level|show|hide|full|clamp]", + subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "show", "hide", "on", "off", "full", "clamp")), CommandDef("fast", "Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode (Normal/Fast)", "Configuration", args_hint="[normal|fast|status]", subcommands=("normal", "fast", "status", "on", "off")), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index dd212cfdb8e..f51d3ee2fe3 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1573,6 +1573,10 @@ DEFAULT_CONFIG = { "tui_agents_nudge": True, "bell_on_complete": False, "show_reasoning": False, + # When reasoning display is on, the post-response "Reasoning" recap box + # collapses long thinking to the first 10 lines. Set true to print the + # complete thinking text uncollapsed (live streaming is always full). + "reasoning_full": False, # Background self-improvement review notifications surfaced in chat. # "off" — no chat notification (the review still runs and writes) # "on" — generic "💾 Memory updated" line (default) diff --git a/tests/gateway/test_reasoning_command.py b/tests/gateway/test_reasoning_command.py index f22704dedf6..09600fb6f5a 100644 --- a/tests/gateway/test_reasoning_command.py +++ b/tests/gateway/test_reasoning_command.py @@ -71,7 +71,11 @@ class TestReasoningCommand: result = await runner._handle_help_command(event) - assert "/reasoning [level|show|hide]" in result + # Behaviour contract: /reasoning is surfaced in help. Don't freeze the + # exact args-hint literal — it changes whenever a new arg is added + # (e.g. full/clamp). Assert the command + its category-defining args. + assert "/reasoning" in result + assert "level" in result and "show" in result and "hide" in result def test_reasoning_is_known_command(self): source = inspect.getsource(gateway_run.GatewayRunner._handle_message) diff --git a/tests/hermes_cli/test_reasoning_full_command.py b/tests/hermes_cli/test_reasoning_full_command.py new file mode 100644 index 00000000000..afea65771c3 --- /dev/null +++ b/tests/hermes_cli/test_reasoning_full_command.py @@ -0,0 +1,81 @@ +"""Tests for the CLI `/reasoning full` / `/reasoning clamp` recap toggle. + +The post-response "Reasoning" recap box clamps long thinking to the first +10 lines. `/reasoning full` opts into uncapped display (Taelin's "show all +thinking tokens" ask); `/reasoning clamp` restores the 10-line collapse. +These assert the toggle sets the instance flag, persists to config.yaml, +and that the clamp gate honours the flag. +""" + +import os + +import yaml + +from hermes_cli.cli_commands_mixin import CLICommandsMixin +from hermes_cli.config import DEFAULT_CONFIG + + +class _Stub(CLICommandsMixin): + """Minimal carrier for the attributes `_handle_reasoning_command` reads.""" + + def __init__(self): + self.reasoning_config = None + self.show_reasoning = True + self.reasoning_full = False + self.agent = None + + def _current_reasoning_callback(self): + return None + + +def test_default_config_clamps_reasoning(): + # Behaviour contract: the recap defaults to clamped, not full. + assert DEFAULT_CONFIG["display"]["reasoning_full"] is False + + +def _seed_config(tmp_path, monkeypatch): + hh = tmp_path / ".hermes" + hh.mkdir() + (hh / "config.yaml").write_text("display:\n show_reasoning: true\n") + monkeypatch.setenv("HERMES_HOME", str(hh)) + # cli captures _hermes_home at import; force it to the temp home. + import cli + + monkeypatch.setattr(cli, "_hermes_home", hh, raising=False) + return hh + + +def test_reasoning_full_sets_and_persists(tmp_path, monkeypatch): + hh = _seed_config(tmp_path, monkeypatch) + s = _Stub() + + s._handle_reasoning_command("/reasoning full") + assert s.reasoning_full is True + saved = yaml.safe_load((hh / "config.yaml").read_text()) + assert saved["display"]["reasoning_full"] is True + + +def test_reasoning_clamp_resets_and_persists(tmp_path, monkeypatch): + hh = _seed_config(tmp_path, monkeypatch) + s = _Stub() + s.reasoning_full = True + + s._handle_reasoning_command("/reasoning clamp") + assert s.reasoning_full is False + saved = yaml.safe_load((hh / "config.yaml").read_text()) + assert saved["display"]["reasoning_full"] is False + + +def test_reasoning_all_is_alias_for_full(tmp_path, monkeypatch): + _seed_config(tmp_path, monkeypatch) + s = _Stub() + s._handle_reasoning_command("/reasoning all") + assert s.reasoning_full is True + + +def test_clamp_gate_honours_flag(): + # The display gate at cli.py: clamp only when long AND not reasoning_full. + reasoning = "\n".join(f"line{i}" for i in range(25)) + lines = reasoning.strip().splitlines() + assert (len(lines) > 10 and not False) is True # full=False -> clamp + assert (len(lines) > 10 and not True) is False # full=True -> show all diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index b9729924104..61c86d519f4 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -3064,6 +3064,33 @@ def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypat assert server._sessions["sid"]["show_reasoning"] is False assert server._load_cfg()["display"]["sections"]["thinking"] == "hidden" + # /reasoning full | clamp — parity with the classic CLI reasoning_full + # toggle. In the TUI these map to the thinking section's expand/collapse + # rendering (no fixed 10-line recap exists here). + resp_full = server.handle_request( + { + "id": "4", + "method": "config.set", + "params": {"session_id": "sid", "key": "reasoning", "value": "full"}, + } + ) + assert resp_full["result"]["value"] == "full" + cfg_full = server._load_cfg() + assert cfg_full["display"]["reasoning_full"] is True + assert cfg_full["display"]["sections"]["thinking"] == "expanded" + + resp_clamp = server.handle_request( + { + "id": "5", + "method": "config.set", + "params": {"session_id": "sid", "key": "reasoning", "value": "clamp"}, + } + ) + assert resp_clamp["result"]["value"] == "clamp" + cfg_clamp = server._load_cfg() + assert cfg_clamp["display"]["reasoning_full"] is False + assert cfg_clamp["display"]["sections"]["thinking"] == "collapsed" + def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch): monkeypatch.setattr(server, "_hermes_home", tmp_path) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 861e60bc743..7a63aec263c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -7981,6 +7981,45 @@ def _(rid, params: dict) -> dict: session["show_reasoning"] = False return _ok(rid, {"key": key, "value": "hide"}) + # /reasoning full | clamp — parity with the classic CLI's + # reasoning_full toggle. The TUI renders thinking as an + # expand/collapse section rather than a fixed 10-line recap, so + # full maps to sections.thinking=expanded and clamp to collapsed. + # display.reasoning_full is persisted too so the config key stays + # consistent across the CLI and TUI surfaces. + if arg in {"full", "all"}: + cfg = _load_cfg() + display = ( + cfg.get("display") if isinstance(cfg.get("display"), dict) else {} + ) + sections = ( + display.get("sections") + if isinstance(display.get("sections"), dict) + else {} + ) + display["reasoning_full"] = True + sections["thinking"] = "expanded" + display["sections"] = sections + cfg["display"] = display + _save_cfg(cfg) + return _ok(rid, {"key": key, "value": "full"}) + if arg in {"clamp", "collapse", "short"}: + cfg = _load_cfg() + display = ( + cfg.get("display") if isinstance(cfg.get("display"), dict) else {} + ) + sections = ( + display.get("sections") + if isinstance(display.get("sections"), dict) + else {} + ) + display["reasoning_full"] = False + sections["thinking"] = "collapsed" + display["sections"] = sections + cfg["display"] = display + _save_cfg(cfg) + return _ok(rid, {"key": key, "value": "clamp"}) + parsed = parse_reasoning_effort(arg) if parsed is None: return _err(rid, 4002, f"unknown reasoning value: {value}") From 9e96e709951824be8336c5a733bb0d98d6ab32da Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:21:33 -0700 Subject: [PATCH 451/470] =?UTF-8?q?feat(cli):=20/prompt=20=E2=80=94=20comp?= =?UTF-8?q?ose=20your=20next=20prompt=20in=20$EDITOR=20(#50509)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): /prompt — compose your next prompt in $EDITOR Adds /prompt (alias /compose): opens $VISUAL/$EDITOR on a temp markdown file so you can hand-edit a multi-line prompt, then sends the saved buffer as the next agent turn. Text after the command pre-seeds the buffer; an empty save cancels. Reuses the one-shot _pending_agent_seed the interactive loop already consumes (same mechanism as /blueprint), so no changes to the input event loop or message pipeline. CLI-only. * feat(tui): /prompt slash command opens $EDITOR (parity with CLI) The TUI already opens $EDITOR via Ctrl+G (openEditor), but had no /prompt slash command like the classic CLI. Wire openEditor into the slash handler context and register /prompt (alias /compose) to call it; inline text after the command is dropped into the composer first so it carries into the editor, matching the CLI's /prompt . --- cli.py | 2 + hermes_cli/cli_commands_mixin.py | 73 ++++++++++++++++++ hermes_cli/commands.py | 2 + .../hermes_cli/test_prompt_compose_command.py | 76 +++++++++++++++++++ .../src/__tests__/createSlashHandler.test.ts | 17 +++++ ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/slash/commands/core.ts | 18 +++++ ui-tui/src/app/useMainApp.ts | 1 + 8 files changed, 190 insertions(+) create mode 100644 tests/hermes_cli/test_prompt_compose_command.py diff --git a/cli.py b/cli.py index 641044bc924..fa9ac41b130 100644 --- a/cli.py +++ b/cli.py @@ -7850,6 +7850,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): if retry_msg and hasattr(self, '_pending_input'): # Re-queue the message so process_loop sends it to the agent self._pending_input.put(retry_msg) + elif canonical == "prompt": + self._handle_prompt_compose_command(cmd_original) elif canonical == "undo": # Parse optional turn count: "/undo" → 1, "/undo 3" → 3. _undo_n = 1 diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index f4c05060140..d93897d2609 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1960,6 +1960,79 @@ class CLICommandsMixin: if self._apply_tui_skin_style(): print(" Prompt + TUI colors updated.") + def _compose_in_editor(self, initial_text: str = "") -> str: + """Open ``$VISUAL``/``$EDITOR`` on a temp markdown file and return the + saved buffer (comment lines starting with ``#!`` stripped). + + Returns the composed prompt text, or an empty string if the editor + could not be launched or the buffer was left empty. Factored out so + the read-back/strip logic is unit-testable without spawning an editor. + """ + import os + import shlex + import subprocess + import tempfile + + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") + if not editor: + editor = "notepad" if os.name == "nt" else "nano" + + header = ( + "#! Compose your prompt below. Lines starting with '#!' are ignored.\n" + "#! Save and quit to send; leave empty to cancel.\n\n" + ) + fd, path = tempfile.mkstemp(suffix=".md", prefix="hermes_prompt_") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(header) + if initial_text: + fh.write(initial_text) + try: + subprocess.call([*shlex.split(editor), path]) + except Exception: + # Fall back to a bare invocation (editor value may not be a + # simple argv-splittable string on some platforms). + subprocess.call(f"{editor} {shlex.quote(path)}", shell=True) + with open(path, "r", encoding="utf-8") as fh: + raw = fh.read() + finally: + try: + os.unlink(path) + except OSError: + pass + + lines = [ln for ln in raw.splitlines() if not ln.startswith("#!")] + return "\n".join(lines).strip() + + def _handle_prompt_compose_command(self, cmd_original: str) -> None: + """Handle /prompt — compose the next prompt in $EDITOR and send it. + + Opens the user's editor on a temporary markdown file (optionally + seeded with text passed after the command), then queues the saved + buffer as the next agent turn via the one-shot ``_pending_agent_seed`` + the interactive loop already consumes (same path as /blueprint). + """ + from cli import _DIM, _RST, _cprint + + initial = "" + parts = (cmd_original or "").strip().split(None, 1) + if len(parts) > 1: + initial = parts[1] + + try: + composed = self._compose_in_editor(initial) + except Exception as exc: + _cprint(f" {_DIM}(>_<) Could not open editor: {exc}{_RST}") + return + + if not composed: + _cprint(f" {_DIM}(._.) Empty prompt — nothing sent.{_RST}") + return + + # One-shot seed: the interactive loop runs this as the next agent turn + # right after process_command() returns (see cli.py main loop). + self._pending_agent_seed = composed + def _handle_footer_command(self, cmd_original: str) -> None: """Toggle or inspect ``display.runtime_footer.enabled`` from the CLI. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index a0d0882dcbb..d5cc9cee8c1 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -78,6 +78,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("save", "Save the current conversation", "Session", cli_only=True), CommandDef("retry", "Retry the last message (resend to agent)", "Session"), + CommandDef("prompt", "Compose your next prompt in $EDITOR (markdown), then send it", "Session", + cli_only=True, args_hint="[initial text]", aliases=("compose",)), CommandDef("undo", "Back up N user turns and re-prompt (default 1)", "Session", args_hint="[N]"), CommandDef("title", "Set a title for the current session", "Session", diff --git a/tests/hermes_cli/test_prompt_compose_command.py b/tests/hermes_cli/test_prompt_compose_command.py new file mode 100644 index 00000000000..eae36a5a1aa --- /dev/null +++ b/tests/hermes_cli/test_prompt_compose_command.py @@ -0,0 +1,76 @@ +"""Tests for the CLI `/prompt` editor-compose command. + +`/prompt` opens `$VISUAL`/`$EDITOR` on a temp markdown file so the user can +hand-edit a multi-line prompt, then queues the saved buffer as the next +agent turn via the one-shot `_pending_agent_seed` (same path `/blueprint` +uses). These drive a fake editor subprocess to verify read-back, header +stripping, seeding, and the empty-buffer cancel path. +""" + +import os +import stat +import tempfile + +import pytest + +from hermes_cli.cli_commands_mixin import CLICommandsMixin +from hermes_cli.commands import resolve_command + + +class _Stub(CLICommandsMixin): + def __init__(self): + self._pending_agent_seed = None + + +def _fake_editor(body: str, mode: str = "append") -> str: + """Write a tiny shell 'editor' that mutates the file it is handed.""" + f = tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False) + if mode == "append": + f.write("#!/usr/bin/env bash\n") + f.write(f"cat >> \"$1\" <<'EOF'\n{body}\nEOF\n") + else: # clear + f.write("#!/usr/bin/env bash\n: > \"$1\"\n") + f.close() + os.chmod(f.name, os.stat(f.name).st_mode | stat.S_IEXEC) + return f.name + + +@pytest.fixture(autouse=True) +def _no_visual(monkeypatch): + monkeypatch.delenv("VISUAL", raising=False) + + +def test_command_registered(): + cd = resolve_command("prompt") + assert cd and cd.name == "prompt" + assert resolve_command("compose").name == "prompt" + + +def test_compose_reads_and_strips_header(monkeypatch): + monkeypatch.setenv("EDITOR", _fake_editor("Refactor the auth module.\nUse pytest.")) + out = _Stub()._compose_in_editor("") + assert "Refactor the auth module." in out + assert "Use pytest." in out + assert "#!" not in out # the instructional header is stripped + + +def test_prompt_sets_pending_seed(monkeypatch): + monkeypatch.setenv("EDITOR", _fake_editor("Write a haiku about caching.")) + s = _Stub() + s._handle_prompt_compose_command("/prompt") + assert s._pending_agent_seed + assert "haiku about caching" in s._pending_agent_seed + + +def test_initial_text_is_seeded(monkeypatch): + # The fake editor appends, so the initial text leads the buffer. + monkeypatch.setenv("EDITOR", _fake_editor("rest of prompt")) + out = _Stub()._compose_in_editor("DRAFT: ") + assert out.startswith("DRAFT:") + + +def test_empty_buffer_does_not_seed(monkeypatch): + monkeypatch.setenv("EDITOR", _fake_editor("", mode="clear")) + s = _Stub() + s._handle_prompt_compose_command("/prompt") + assert s._pending_agent_seed is None diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 1057578093f..f7ea42df537 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -77,6 +77,22 @@ describe('createSlashHandler', () => { expect(ctx.transcript.sys).toHaveBeenCalledWith('ui redrawn') }) + it('opens the editor locally for /prompt without slash worker fallback', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/prompt')).toBe(true) + expect(ctx.composer.openEditor).toHaveBeenCalledTimes(1) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + }) + + it('routes /compose to the editor and seeds inline text', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/compose draft text')).toBe(true) + expect(ctx.composer.setInput).toHaveBeenCalledWith('draft text') + expect(ctx.composer.openEditor).toHaveBeenCalledTimes(1) + }) + it('exits locally for /quit', () => { const ctx = buildCtx() @@ -875,6 +891,7 @@ const buildCtx = (overrides: Partial = {}): Ctx => ({ const buildComposer = () => ({ enqueue: vi.fn(), hasSelection: false, + openEditor: vi.fn(async () => {}), paste: vi.fn(), queueRef: { current: [] as string[] }, selection: { copySelection: vi.fn(async () => '') }, diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index f570cf2b6ab..a4d21412c88 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -333,6 +333,7 @@ export interface SlashHandlerContext { composer: { enqueue: (text: string) => void hasSelection: boolean + openEditor: () => Promise paste: (quiet?: boolean) => void queueRef: MutableRefObject selection: SelectionApi diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 5c74eb3eb42..d87a1ec7513 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -429,6 +429,24 @@ export const coreCommands: SlashCommand[] = [ run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.paste()) }, + { + aliases: ['compose'], + help: 'compose your next prompt in $EDITOR (same as Ctrl+G)', + name: 'prompt', + run: (arg, ctx) => { + if (arg) { + // The TUI editor opens with the current composer draft; there is no + // separate seed arg. Drop any inline text into the composer first so + // it carries into the editor, matching the CLI's /prompt . + ctx.composer.setInput(arg) + } + + void ctx.composer.openEditor().catch((err: unknown) => { + ctx.transcript.sys(`editor failed: ${String(err)}`) + }) + } + }, + { help: 'configure IDE terminal keybindings for multiline + undo/redo', name: 'terminal-setup', diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index d11e8e08dba..b0db1e1f945 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -833,6 +833,7 @@ export function useMainApp(gw: GatewayClient) { composer: { enqueue: composerActions.enqueue, hasSelection, + openEditor: composerActions.openEditor, paste, queueRef: composerRefs.queueRef, selection, From e448b21414b9dece9b74c3281f04ba4f5c79a771 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:21:48 -0700 Subject: [PATCH 452/470] feat(dashboard): interactive auth setup on no-provider non-loopback bind (#50551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `hermes dashboard --host 0.0.0.0` is run interactively with the auth gate engaged but no DashboardAuthProvider configured, prompt to set up the bundled username/password provider on the spot (or point at `hermes dashboard register` for OAuth) instead of only emitting the fail-closed error. - main.py: `_maybe_setup_dashboard_auth_interactively()` runs before start_server. No-ops on loopback binds, when a provider is already registered, or when stdin/stdout isn't a TTY (Docker/s6, CI, piped runs) so the fail-closed SystemExit stays the backstop for unattended deploys. On the password path it writes dashboard.basic_auth.{username,password_hash,secret} to config.yaml (scrypt hash, never plaintext), then force-rediscovers plugins so the basic provider registers before the gate check. - web_server.py: fix the fail-closed hint — it told operators to set `dashboard_auth.basic.username` but the provider reads `dashboard.basic_auth`. - docs: note the interactive setup under Fail-closed semantics. No new env vars; reuses the existing dashboard.basic_auth config surface. --- hermes_cli/main.py | 148 ++++++++++++++++++ hermes_cli/web_server.py | 2 +- .../docs/user-guide/features/web-dashboard.md | 2 + 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 62784c1b3dc..6050e80b2c1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10981,6 +10981,147 @@ def _dashboard_listening(host: str, port: int) -> bool: return False +def _maybe_setup_dashboard_auth_interactively(args) -> None: + """Offer to configure dashboard auth when a non-loopback bind has none. + + Called from ``cmd_dashboard`` just before ``start_server``. The auth + gate engages on every non-loopback bind (``--insecure`` is a no-op since + the June 2026 hardening), and ``start_server`` fails closed when no + ``DashboardAuthProvider`` is registered. Rather than greet an interactive + operator with that hard error, prompt them to set up the bundled + username/password provider on the spot — or point them at + ``hermes dashboard register`` for OAuth. + + No-ops (so the existing fail-closed ``SystemExit`` remains the backstop) + when: + * the bind is loopback (gate never engages), or + * a provider is already registered, or + * stdin/stdout isn't a TTY (Docker/s6, CI, piped ``--no-open`` runs). + """ + host = getattr(args, "host", "127.0.0.1") or "127.0.0.1" + + try: + from hermes_cli.web_server import should_require_auth + if not should_require_auth(host): + return # loopback bind — gate never engages + except Exception: + return # if we can't tell, defer to start_server's own gate + + try: + from hermes_cli.dashboard_auth import list_providers + if list_providers(): + return # a provider is already configured/registered + except Exception: + return + + # Only prompt an interactive operator. Non-TTY callers fall through to + # start_server's fail-closed SystemExit (with the corrected fix hint). + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return + + print() + print( + f"⚠ The dashboard is binding to a non-loopback address ({host}) and " + f"needs an auth provider." + ) + print( + " Non-loopback binds always require authentication " + "(--insecure no longer bypasses this)." + ) + print() + print(" How do you want to authenticate the dashboard?") + print(" [1] Username & password (quickest; for a trusted LAN / VPN)") + print(" [2] OAuth via Nous Portal (run `hermes dashboard register`)") + print(" [3] Cancel") + print() + + try: + choice = input(" Choice [1]: ").strip() or "1" + except (EOFError, KeyboardInterrupt): + print("\n Cancelled.") + sys.exit(1) + + if choice == "2": + print() + print( + " Run this on the host where the dashboard lives, then start " + "the dashboard again:\n" + " hermes dashboard register\n" + " It provisions a Nous Portal OAuth client and writes " + "HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env for you.\n" + " Docs: https://hermes-agent.nousresearch.com/docs/" + "user-guide/features/web-dashboard#authentication-gated-mode" + ) + sys.exit(0) + + if choice not in ("1",): + print(" Cancelled.") + sys.exit(1) + + # ── Username/password setup ────────────────────────────────────────── + import getpass + import secrets + + print() + try: + username = input(" Username [admin]: ").strip() or "admin" + password = getpass.getpass(" Password: ") + confirm = getpass.getpass(" Confirm password: ") + except (EOFError, KeyboardInterrupt): + print("\n Cancelled.") + sys.exit(1) + + if not password: + print(" ✗ Empty password — aborting.") + sys.exit(1) + if password != confirm: + print(" ✗ Passwords don't match — aborting.") + sys.exit(1) + + try: + from plugins.dashboard_auth.basic import hash_password + except Exception as exc: + print(f" ✗ Could not load the password provider: {exc}") + sys.exit(1) + + password_hash = hash_password(password) + # A stable token-signing secret so sessions survive a dashboard restart. + secret = secrets.token_urlsafe(32) + + try: + from hermes_cli.config import load_config, save_config + + cfg = load_config() + dash = cfg.setdefault("dashboard", {}) + basic = dash.setdefault("basic_auth", {}) + basic["username"] = username + basic["password_hash"] = password_hash + # Never persist plaintext: clear any stale plaintext password key. + basic["password"] = "" + if not str(basic.get("secret", "") or "").strip(): + basic["secret"] = secret + save_config(cfg) + except Exception as exc: + print(f" ✗ Failed to write config.yaml: {exc}") + sys.exit(1) + + # Re-run plugin discovery so the basic provider registers from the + # just-written config before start_server's gate check runs. + try: + from hermes_cli.plugins import discover_plugins + + discover_plugins(force=True) + except Exception as exc: + print(f" ⚠ Plugin re-discovery failed ({exc}); the gate may still " + "fail closed. Set the password again or restart the dashboard.") + + print() + print(f" ✓ Username/password auth configured (user: {username}).") + print(" Saved to config.yaml under dashboard.basic_auth.") + print(" Sign in at the dashboard with these credentials.") + print() + + def cmd_dashboard(args): """Start the web UI server, or (with --stop/--status) manage running ones.""" # --status: report running dashboards and exit, no deps needed. @@ -11172,6 +11313,13 @@ def cmd_dashboard(args): from hermes_cli.web_server import start_server + # Interactive auth setup: if this bind will engage the auth gate but no + # provider is registered yet, offer to configure one here (TTY only) + # instead of hard-failing inside start_server. Non-interactive callers + # (Docker/s6, CI, --no-open pipelines) fall through to start_server's + # fail-closed SystemExit unchanged. + _maybe_setup_dashboard_auth_interactively(args) + # The in-browser Chat tab (the embedded TUI over PTY/WebSocket) is always # available — the desktop app and the dashboard's own Chat tab both rely on # the `/api/ws` + `/api/pty` sockets, so there is no reason to gate them. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b89eafecfa2..ade50c60051 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -12867,7 +12867,7 @@ def start_server( _fix_hint = ( "Configure an auth provider before exposing the dashboard:\n" - " • Password: set dashboard_auth.basic.username + " + " • Password: set dashboard.basic_auth.username + " "password_hash in config.yaml\n" " (hash with: python -c \"from " "plugins.dashboard_auth.basic import hash_password; " diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index d562879c243..64db237cae4 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -585,6 +585,8 @@ The gate is on if and only if: If the gate would engage but **no** `DashboardAuthProvider` is registered (no Nous plugin, no custom plugin), `hermes dashboard` refuses to bind with an explicit error message. There is no "default-deny but accept everything" fallback — a misconfigured gated dashboard never starts. +When you run `hermes dashboard --host 0.0.0.0` **interactively** (a real terminal) and no provider is configured yet, Hermes doesn't just fail — it offers to set one up on the spot: pick **username & password** (writes `dashboard.basic_auth` to `config.yaml` and you're running in seconds) or **OAuth** (points you at `hermes dashboard register`). Non-interactive callers — Docker/s6, CI, piped runs — skip the prompt and hit the fail-closed error above, so an unattended deploy still never starts without auth. + ### Default provider: Nous Research The bundled `plugins/dashboard_auth/nous` plugin is **always installed** and auto-loaded. It auto-registers a `DashboardAuthProvider` named `nous` when a client ID is configured. From 6202fdfc354df566a8c0a1110ba292b3ed7ca297 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Mon, 22 Jun 2026 15:35:38 +1000 Subject: [PATCH 453/470] fix(container): detect dashboard role under s6-overlay v3 (#49196) (#50600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gateway): walk /proc/*/cmdline to find main-wrapper.sh under s6-overlay v3 (#49196) (cherry picked from commit 3a108c2df0edce4ce0e6f9f3a8eb8db3839a4630) * fix(container): peel s6-v3 rc.init prefix so dashboard role is detected kyssta-exe's preceding commit (#49238) fixed _read_container_argv() to locate the rc.init-launched main-wrapper.sh process under s6-overlay v3, but the skip still never fired: _strip_container_argv_prefix() only peeled a prefix when args[0] was init/main-wrapper.sh/hermes. Under s6 v3 the matched argv is /bin/sh -e /run/s6/basedir/scripts/rc.init top /opt/hermes/docker/main-wrapper.sh dashboard ... so args[0] stayed /bin/sh, _is_dashboard_container() returned False, and the dashboard container reconciled + started its own gateway-default — the exact dual Telegram getUpdates 409 in issue #49196. Fix: strip everything up to and including the main-wrapper.sh token (the stable boundary the image owns), covering both the v2 (/init ...) and v3 (/bin/sh ... rc.init top ...) shapes with one rule, instead of matching launcher tokens positionally. This also repairs _is_legacy_gateway_run_request() under v3, which shares the same strip helper (the issue called this out). Tests: extend the dashboard true/false parametrize sets with the s6-v3 argv shape, and add test_main_skips_reconcile_in_dashboard_container_s6v3 exercising main() end-to-end with the v3 argv. Verified via mutation that both new v3 assertions fail under the old positional strip and pass with the fix. --------- Co-authored-by: kyssta-exe --- hermes_cli/container_boot.py | 85 +++++++++++++++++--- tests/hermes_cli/test_container_boot.py | 100 ++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 12 deletions(-) diff --git a/hermes_cli/container_boot.py b/hermes_cli/container_boot.py index 647545dd5da..c299bbcf966 100644 --- a/hermes_cli/container_boot.py +++ b/hermes_cli/container_boot.py @@ -199,28 +199,89 @@ def _maybe_migrate_legacy_gateway_run_state( def _read_container_argv() -> tuple[str, ...]: - """Best-effort read of the container PID 1 argv.""" + """Best-effort read of the container's main program argv. + + Under s6-overlay v2, PID 1 is ``/init`` and its argv contains the + ``main-wrapper.sh`` path. Under s6-overlay v3, PID 1 is + ``s6-svscan`` and the actual command (``rc.init top main-wrapper.sh + ...``) lives on a different PID. We try PID 1 first (fast path, + covers v2 and pre-s6 images), then fall back to scanning + ``/proc/*/cmdline`` for a process whose argv contains + ``main-wrapper.sh`` (the rc.init-launched PID in v3). + """ + # Fast path: PID 1 is the command itself (s6-overlay v2 / tini). try: raw = Path("/proc/1/cmdline").read_bytes() + argv = tuple( + part.decode("utf-8", "replace") for part in raw.split(b"\0") if part + ) + if any("main-wrapper.sh" in part for part in argv): + return argv except OSError: - return () - return tuple(part.decode("utf-8", "replace") for part in raw.split(b"\0") if part) + pass + + # Slow path: s6-overlay v3 — PID 1 is s6-svscan; find the + # rc.init-launched process whose argv contains main-wrapper.sh. + try: + proc_dir = Path("/proc") + for entry in proc_dir.iterdir(): + if not entry.name.isdigit(): + continue + try: + raw = (entry / "cmdline").read_bytes() + except OSError: + continue + argv = tuple( + part.decode("utf-8", "replace") + for part in raw.split(b"\0") + if part + ) + if any("main-wrapper.sh" in part for part in argv): + return argv + except OSError: + pass + + return () def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]: - """Strip the s6/wrapper prefix off PID 1 argv, leaving the hermes args. + """Strip the s6/wrapper prefix off the container argv, leaving the hermes args. - The container PID 1 argv looks like - ``/init /opt/hermes/docker/main-wrapper.sh [args...]`` and - the wrapper re-execs ``hermes ``. Peel ``init`` → - ``main-wrapper.sh`` → ``hermes`` so callers can match on the bare - subcommand. Shared by the legacy-gateway and dashboard role detectors. + Two container-command argv shapes are handled: + + * **s6-overlay v2 / tini:** PID 1 argv is + ``/init /opt/hermes/docker/main-wrapper.sh [args...]``. + * **s6-overlay v3:** PID 1 is ``s6-svscan`` and the command lives on the + rc.init-launched process as ``/bin/sh -e + /run/s6/basedir/scripts/rc.init top /opt/hermes/docker/main-wrapper.sh + [args...]`` (see :func:`_read_container_argv`). + + Rather than peel each leading token positionally (which silently breaks + the moment s6 changes its launcher shape again — exactly what happened + in the v2→v3 bump), drop everything up to and including the + ``main-wrapper.sh`` token: that wrapper path is the stable boundary the + image owns, and the subcommand always follows it. Pre-s6 / direct + ``hermes`` invocations carry no wrapper, so fall back to peeling a bare + ``init`` prefix. The wrapper re-execs ``hermes ``, so an + explicit leading ``hermes`` is peeled too. Shared by the legacy-gateway + and dashboard role detectors. """ args = list(argv) - if args and Path(args[0]).name == "init": - args = args[1:] - if args and args[0].endswith("main-wrapper.sh"): + + # Preferred boundary: everything through main-wrapper.sh is launcher + # prefix. Covers s6-overlay v2 (`/init …main-wrapper.sh …`) and v3 + # (`/bin/sh -e …rc.init top …main-wrapper.sh …`) with one rule. + wrapper_idx = next( + (i for i, a in enumerate(args) if a.endswith("main-wrapper.sh")), + None, + ) + if wrapper_idx is not None: + args = args[wrapper_idx + 1 :] + elif args and Path(args[0]).name == "init": + # Defensive: an `init` prefix with no wrapper token in argv. args = args[1:] + + # The wrapper re-execs `hermes `; peel an explicit hermes. if args and Path(args[0]).name == "hermes": args = args[1:] return args diff --git a/tests/hermes_cli/test_container_boot.py b/tests/hermes_cli/test_container_boot.py index a86321a6887..7dac6ced1a6 100644 --- a/tests/hermes_cli/test_container_boot.py +++ b/tests/hermes_cli/test_container_boot.py @@ -25,6 +25,29 @@ from hermes_cli.container_boot import ( # --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def _hermetic_container_argv(monkeypatch: pytest.MonkeyPatch) -> None: + """Default ``_read_container_argv()`` to empty for the whole module. + + ``_read_container_argv()`` walks the entire ``/proc`` table looking for + a process whose argv contains ``main-wrapper.sh`` (the s6-overlay v3 + fallback). On a host that is *also* running hermes containers, those + containers' ``main-wrapper.sh`` processes are visible in the host's + ``/proc`` (shared PID view), so the scan would pick up a foreign + ``gateway run`` argv and make ``_maybe_migrate_legacy_gateway_run_state`` + synthesize ``running`` state — flaking any test that reconciles without + injecting ``container_argv``. Inside the real container ``/proc`` is the + container's own PID namespace, so production is unaffected; this fixture + just makes the unit suite hermetic. Tests that need a specific argv + either pass ``container_argv=`` to ``reconcile_profile_gateways`` or + monkeypatch ``_read_container_argv`` themselves (both override this). + """ + monkeypatch.setattr( + "hermes_cli.container_boot._read_container_argv", + lambda: (), + ) + + def _make_profile( hermes_home: Path, name: str, @@ -733,6 +756,24 @@ def test_profiles_default_subdir_is_skipped_with_warning( ), # Wrapper that kept the explicit `hermes` argv0. ("/init", "/opt/hermes/docker/main-wrapper.sh", "hermes", "dashboard"), + # s6-overlay v3: PID 1 is s6-svscan, so the role is read off the + # rc.init-launched process whose argv is + # `/bin/sh -e .../rc.init top .../main-wrapper.sh dashboard ...`. + # This is the exact shape that regressed in issue #49196. + ( + "/bin/sh", + "-e", + "/run/s6/basedir/scripts/rc.init", + "top", + "/opt/hermes/docker/main-wrapper.sh", + "dashboard", + "--host", + "0.0.0.0", + "--port", + "9119", + "--no-open", + "--insecure", + ), ], ) def test_is_dashboard_container_true_for_dashboard_argv( @@ -756,6 +797,17 @@ def test_is_dashboard_container_true_for_dashboard_argv( # we key on is the SUBCOMMAND, and `gateway run -p dashboard` is a # gateway container. ("gateway", "run", "-p", "dashboard"), + # s6-overlay v3 gateway container — the rc.init-launched argv for a + # gateway role must still read as non-dashboard (issue #49196 shape). + ( + "/bin/sh", + "-e", + "/run/s6/basedir/scripts/rc.init", + "top", + "/opt/hermes/docker/main-wrapper.sh", + "gateway", + "run", + ), ], ) def test_is_dashboard_container_false_for_non_dashboard_argv( @@ -798,6 +850,54 @@ def test_main_skips_reconcile_in_dashboard_container( assert "skipping (dashboard container" in capsys.readouterr().out +def test_main_skips_reconcile_in_dashboard_container_s6v3( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The dashboard skip must fire under the s6-overlay v3 argv shape. + + Regression test for issue #49196: under s6-overlay v3 the container + command is read off the rc.init-launched process, whose argv is + ``/bin/sh -e .../rc.init top .../main-wrapper.sh dashboard ...`` — not a + bare ``/init`` prefix. Before the fix, the prefix-strip left ``/bin/sh`` + at args[0], so the role read as non-dashboard, the dashboard container + reconciled, and it started its own gateway-default (dual Telegram + getUpdates 409). Asserting the slot is absent proves the skip fires. + """ + from hermes_cli import container_boot + + scandir = tmp_path / "run-service"; scandir.mkdir() + _make_profile(tmp_path, "worker", state="running") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("S6_PROFILE_GATEWAY_SCANDIR", str(scandir)) + monkeypatch.setattr( + container_boot, + "_read_container_argv", + lambda: ( + "/bin/sh", + "-e", + "/run/s6/basedir/scripts/rc.init", + "top", + "/opt/hermes/docker/main-wrapper.sh", + "dashboard", + "--host", + "0.0.0.0", + "--port", + "9119", + "--no-open", + "--insecure", + ), + ) + + rc = container_boot.main() + + assert rc == 0 + assert not (scandir / "gateway-worker").exists() + assert not (scandir / "gateway-default").exists() + assert "skipping (dashboard container" in capsys.readouterr().out + + def test_main_reconciles_in_gateway_container( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From de6b3ae3774fb0bb48f288159e7bb326d8f48bc2 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Mon, 22 Jun 2026 15:41:23 +1000 Subject: [PATCH 454/470] fix(terminal): bridge docker_extra_args to TERMINAL_DOCKER_EXTRA_ARGS in CLI + gateway (#50631) terminal.docker_extra_args passes flags verbatim to `docker run` (e.g. --gpus=all, --shm-size=16g). It was wired into DEFAULT_CONFIG, TERMINAL_CONFIG_ENV_MAP (so `hermes config set` bridged it), terminal_tool._get_env_config (reads TERMINAL_DOCKER_EXTRA_ARGS), and DockerEnvironment (applies extra_args) -- but it was MISSING from cli.py's env_mappings and gateway/run.py's _terminal_env_map. Consequence: a user who hand-edits config.yaml (rather than running `hermes config set`) has docker_extra_args silently dropped on the CLI and gateway/desktop startup paths, while docker_image / docker_volumes (which ARE in those maps) bridge correctly -- producing the reported 'Hermes partially reads the Docker config' symptom where --gpus=all and --shm-size=16g never reach docker run. This is the same bridge-coverage bug class that shipped before for docker_run_as_host_user (cli + gateway) and docker_mount_cwd_to_workspace (gateway). Fix by adding the key to both maps, plus a dedicated regression pin in test_terminal_config_env_sync.py mirroring the existing test_docker_*_is_bridged_everywhere guards. --- cli.py | 1 + gateway/run.py | 1 + tests/tools/test_terminal_config_env_sync.py | 21 ++++++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/cli.py b/cli.py index fa9ac41b130..a195f8ab5f2 100644 --- a/cli.py +++ b/cli.py @@ -621,6 +621,7 @@ def load_cli_config() -> Dict[str, Any]: "container_persistent": "TERMINAL_CONTAINER_PERSISTENT", "docker_volumes": "TERMINAL_DOCKER_VOLUMES", "docker_env": "TERMINAL_DOCKER_ENV", + "docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS", "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", diff --git a/gateway/run.py b/gateway/run.py index 3d822c7dcef..3b35d3e3638 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1464,6 +1464,7 @@ if _config_path.exists(): "container_persistent": "TERMINAL_CONTAINER_PERSISTENT", "docker_volumes": "TERMINAL_DOCKER_VOLUMES", "docker_env": "TERMINAL_DOCKER_ENV", + "docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS", "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", diff --git a/tests/tools/test_terminal_config_env_sync.py b/tests/tools/test_terminal_config_env_sync.py index 85d1a013f3d..5f6668fd62a 100644 --- a/tests/tools/test_terminal_config_env_sync.py +++ b/tests/tools/test_terminal_config_env_sync.py @@ -233,6 +233,27 @@ def test_docker_env_is_bridged_everywhere(): assert "TERMINAL_DOCKER_ENV" in _terminal_tool_env_var_names() +def test_docker_extra_args_is_bridged_everywhere(): + """Regression pin for docker_extra_args config key being silently ignored. + + ``terminal.docker_extra_args`` in config.yaml passes extra flags verbatim + to ``docker run`` (e.g. ``--gpus=all``, ``--shm-size=16g``). The key was + present in DEFAULT_CONFIG, TERMINAL_CONFIG_ENV_MAP (so ``hermes config + set`` bridged it), terminal_tool._get_env_config (reads + TERMINAL_DOCKER_EXTRA_ARGS), and DockerEnvironment (applies extra_args) -- + but it was MISSING from cli.py's env_mappings and gateway/run.py's + _terminal_env_map. So a user who hand-edited config.yaml had their GPU / + shm-size flags silently dropped on the CLI and gateway/desktop paths, + while ``image``/``volumes`` (which were in those maps) bridged fine -- + producing the "Hermes partially reads the Docker config" symptom. Guard + all four bridging points so this cannot regress. + """ + assert "docker_extra_args" in _cli_env_map_keys() + assert "docker_extra_args" in _gateway_env_map_keys() + assert "docker_extra_args" in _save_config_env_sync_keys() + assert "TERMINAL_DOCKER_EXTRA_ARGS" in _terminal_tool_env_var_names() + + def test_docker_persist_across_processes_is_bridged_everywhere(): """Regression pin for the cross-process container reuse toggle. From 4314d451ca961cb50c3430197a3a2c7a8575fd0e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:31:40 -0700 Subject: [PATCH 455/470] fix(gateway): accept any inbound file type across all messaging platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authorization to message the agent is the gate, not the file extension. Previously the inbound-attachment allowlist (SUPPORTED_DOCUMENT_TYPES) was opt-OUT on Discord (allow_any_attachment defaulted false) and had no bypass at all on Telegram/Slack — so an .html (or any non-allowlisted type) was dropped or hard-rejected before the agent saw it. Now every authorized upload is cached and surfaced to the agent regardless of type: - base.cache_media_bytes(): unknown types cache as octet-stream (or the caller-supplied MIME) instead of returning None — fixes the chokepoint that Teams/Telegram-media route through. - discord/telegram/slack adapters: removed the allowlist reject/skip; any non-media attachment is typed DOCUMENT and cached. Known types keep their precise MIME. - Text inlining now gates on a shared _TEXT_INJECT_EXTENSIONS set (text + code + config + markup) instead of a blind UTF-8 decode, so binary formats (PDF/zip/docx) with ASCII headers are never inlined. - gateway/run.py emits the path-pointing context note for every DOCUMENT, including non text/application MIME types. - discord.allow_any_attachment is now a documented no-op kept for config back-compat. Validation: 357 gateway tests pass; E2E confirms .html/.bin/custom types cache, known types stay precise, PDFs are not inlined. --- gateway/platforms/base.py | 53 ++++++- gateway/run.py | 7 +- hermes_cli/config.py | 11 +- plugins/platforms/discord/adapter.py | 141 +++++++++--------- plugins/platforms/slack/adapter.py | 45 +++--- plugins/platforms/telegram/adapter.py | 41 +++-- .../gateway/test_discord_document_handling.py | 80 ++++------ tests/gateway/test_document_cache.py | 21 ++- tests/gateway/test_telegram_documents.py | 17 ++- website/docs/user-guide/messaging/discord.md | 15 +- 10 files changed, 239 insertions(+), 192 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 38bbec4cd66..46339b81471 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1248,6 +1248,33 @@ SUPPORTED_DOCUMENT_TYPES = { } +# --------------------------------------------------------------------------- +# Text-injection extension allowlist +# +# Files whose contents are safe to inline into the prompt (UTF-8 text) when +# small enough. This is intentionally an extension/MIME gate, NOT a blind +# UTF-8 decode: binary formats like PDF/zip/docx can begin with decodable +# ASCII headers and must never be inlined. Any uploaded file is still cached +# and surfaced to the agent regardless of whether it lands in this set — +# this only controls inline-vs-path-pointer for the prompt. +# --------------------------------------------------------------------------- + +_TEXT_INJECT_EXTENSIONS = { + ".txt", ".md", ".markdown", ".csv", ".tsv", ".log", + ".json", ".jsonl", ".ndjson", ".xml", ".yaml", ".yml", ".toml", + ".ini", ".cfg", ".conf", ".env", ".properties", + ".html", ".htm", ".css", ".scss", ".sass", ".less", + ".py", ".pyi", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", + ".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", + ".c", ".h", ".cpp", ".cc", ".hpp", ".cs", ".java", ".kt", + ".go", ".rs", ".rb", ".php", ".pl", ".lua", ".r", ".jl", + ".swift", ".m", ".scala", ".clj", ".ex", ".exs", ".erl", + ".sql", ".graphql", ".proto", ".tf", ".hcl", + ".dockerfile", ".makefile", ".cmake", ".gradle", + ".rst", ".tex", ".srt", ".vtt", ".diff", ".patch", +} + + # --------------------------------------------------------------------------- # Image document types # @@ -1454,9 +1481,10 @@ def cache_media_bytes( ``default_kind`` ("image"/"video"/"audio"/"document") biases classification when the extension/MIME are ambiguous — e.g. a Telegram native photo whose - file has no usable name. Unsupported document types return None so the - caller can record an "unsupported" note. Images that fail validation - (``cache_image_from_bytes`` raises ValueError) also return None. + file has no usable name. Any non-image/video/audio file is cached as a + document and surfaced to the agent (arbitrary types get + ``application/octet-stream``); only images that fail validation + (``cache_image_from_bytes`` raises ValueError) return None. """ from tools.credential_files import to_agent_visible_cache_path @@ -1492,11 +1520,20 @@ def cache_media_bytes( out_mime = mime if mime.startswith("audio/") else f"audio/{aud_ext.lstrip('.')}" return CachedMedia(to_agent_visible_cache_path(path), out_mime, "audio", display) - if ext not in SUPPORTED_DOCUMENT_TYPES: - return None - - path = cache_document_from_bytes(data, filename or f"document{ext}") - return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_DOCUMENT_TYPES[ext], "document", display or f"document{ext}") + # Any other file type is cached and surfaced to the agent as a local path + # so it can be inspected with terminal / read_file / etc. Authorization to + # talk to the agent is the gate that matters — once a user is allowed to + # message it, the file-extension allowlist must not silently drop their + # uploads. Known extensions keep their precise MIME; everything else is + # tagged application/octet-stream (or the caller-supplied MIME) so the + # agent knows it's an arbitrary file and reaches for terminal tools. + fallback_name = filename or (f"document{ext}" if ext else "document.bin") + path = cache_document_from_bytes(data, fallback_name) + if ext in SUPPORTED_DOCUMENT_TYPES: + out_mime = SUPPORTED_DOCUMENT_TYPES[ext] + else: + out_mime = mime if mime else "application/octet-stream" + return CachedMedia(to_agent_visible_cache_path(path), out_mime, "document", display or fallback_name) class MessageType(Enum): diff --git a/gateway/run.py b/gateway/run.py index 3b35d3e3638..5b7c63a42f9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8688,8 +8688,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew guessed, _ = _mimetypes.guess_type(path) if guessed: mtype = guessed - if not mtype.startswith(("application/", "text/")): - continue + else: + mtype = "application/octet-stream" + # Any accepted file gets a path-pointing context note — we accept + # all file types now, so a non-text/non-application MIME (font/*, + # model/*, etc.) must still tell the agent the file exists. basename = os.path.basename(path) parts = basename.split("_", 2) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f51d3ee2fe3..49f516da15d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2118,12 +2118,11 @@ DEFAULT_CONFIG = { # list_roles, member_info, search_members, fetch_messages, list_pins, # pin_message, unpin_message, create_thread, add_role, remove_role. "server_actions": "", - # Accept arbitrary attachment file types (not just SUPPORTED_DOCUMENT_TYPES). - # When True, any uploaded file is cached to disk with mime - # application/octet-stream and the path is surfaced to the agent so it - # can use terminal/read_file/etc. against it. Default False preserves - # the historical allowlist behaviour. - # Env override: DISCORD_ALLOW_ANY_ATTACHMENT. + # DEPRECATED / no-op. Any uploaded file is now always cached and + # surfaced to the agent regardless of file type — authorization to + # message the agent is the gate, not the extension. Kept so existing + # configs that set it do not error. Env override: + # DISCORD_ALLOW_ANY_ATTACHMENT. "allow_any_attachment": False, # Maximum bytes per attachment the gateway will cache. The whole file # is held in memory while being written, so unlimited uploads carry a diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 1fc6692eac5..dc62aabf763 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -116,6 +116,7 @@ from gateway.platforms.base import ( cache_audio_from_bytes, cache_document_from_bytes, SUPPORTED_DOCUMENT_TYPES, + _TEXT_INJECT_EXTENSIONS, validate_inbound_media_size, ) from tools.url_safety import is_safe_url @@ -5288,8 +5289,9 @@ class DiscordAdapter(BasePlatformAdapter): if normalized_content.startswith("/"): msg_type = MessageType.COMMAND elif all_attachments: - _allow_any = self._discord_allow_any_attachment() - # Check attachment types + # Check attachment types. Any non-media attachment is treated as a + # DOCUMENT regardless of extension — authorization to message the + # agent is the gate, not the file type. for att in all_attachments: if att.content_type: if att.content_type.startswith("image/"): @@ -5302,14 +5304,9 @@ class DiscordAdapter(BasePlatformAdapter): else: msg_type = MessageType.AUDIO else: - doc_ext = "" - if att.filename: - _, doc_ext = os.path.splitext(att.filename) - doc_ext = doc_ext.lower() - if doc_ext in SUPPORTED_DOCUMENT_TYPES or _allow_any: - msg_type = MessageType.DOCUMENT + msg_type = MessageType.DOCUMENT break - elif _allow_any: + else: # No content_type at all (rare — discord usually fills it # in). Treat as a document so downstream pipelines surface # the path to the agent. @@ -5398,71 +5395,79 @@ class DiscordAdapter(BasePlatformAdapter): if not ext and content_type: mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} ext = mime_to_ext.get(content_type, "") - allow_any_attachment = self._discord_allow_any_attachment() in_allowlist = ext in SUPPORTED_DOCUMENT_TYPES - if not in_allowlist and not allow_any_attachment: + # Any file type is accepted — authorization to message the agent + # is the gate, not the file extension. Known types keep their + # precise MIME; unknown types fall back to the source content_type + # or octet-stream so the agent reaches for terminal tools. + max_doc_bytes = self._discord_max_attachment_bytes() + if max_doc_bytes and att.size and att.size > max_doc_bytes: logger.warning( - "[Discord] Unsupported document type '%s' (%s), skipping", - ext or "unknown", content_type, + "[Discord] Document too large (%s bytes > cap %s), skipping: %s", + att.size, max_doc_bytes, att.filename, ) else: - max_doc_bytes = self._discord_max_attachment_bytes() - if max_doc_bytes and att.size and att.size > max_doc_bytes: - logger.warning( - "[Discord] Document too large (%s bytes > cap %s), skipping: %s", - att.size, max_doc_bytes, att.filename, + try: + raw_bytes = await self._cache_discord_document(att, ext) + cached_path = cache_document_from_bytes( + raw_bytes, att.filename or f"document{ext or '.bin'}" ) - else: - try: - raw_bytes = await self._cache_discord_document(att, ext) - cached_path = cache_document_from_bytes( - raw_bytes, att.filename or f"document{ext or '.bin'}" - ) - if in_allowlist: - doc_mime = SUPPORTED_DOCUMENT_TYPES[ext] - else: - # allow_any_attachment path: untyped file. Use the - # source content_type if discord gave us one, - # otherwise fall back to octet-stream so the agent - # knows it's binary and reaches for terminal tools. - doc_mime = ( - content_type - if content_type and content_type != "unknown" - else "application/octet-stream" - ) - media_urls.append(cached_path) - media_types.append(doc_mime) - logger.info( - "[Discord] Cached user %s: %s", - "document" if in_allowlist else "attachment", - cached_path, - ) - # Inject text content for plain-text documents (capped at 100 KB) - MAX_TEXT_INJECT_BYTES = 100 * 1024 - if in_allowlist and ext in {".md", ".txt", ".log"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: - try: - text_content = raw_bytes.decode("utf-8") - display_name = att.filename or f"document{ext}" - display_name = re.sub(r'[^\w.\- ]', '_', display_name) - injection = f"[Content of {display_name}]:\n{text_content}" - if pending_text_injection: - pending_text_injection = f"{pending_text_injection}\n\n{injection}" - else: - pending_text_injection = injection - except UnicodeDecodeError: - pass - # NOTE: for the allow_any_attachment path we deliberately - # do NOT inject a path string here. ``gateway/run.py`` - # already detects DOCUMENT-typed events with - # ``application/octet-stream`` MIME and emits a context - # note with the sandbox-translated cache path via - # ``to_agent_visible_cache_path()`` (important for - # Docker/Modal terminal backends). - except Exception as e: - logger.warning( - "[Discord] Failed to cache document %s: %s", - att.filename, e, exc_info=True, + if in_allowlist: + doc_mime = SUPPORTED_DOCUMENT_TYPES[ext] + else: + # Untyped file. Use the source content_type if + # discord gave us one, otherwise fall back to + # octet-stream so the agent knows it's binary and + # reaches for terminal tools. + doc_mime = ( + content_type + if content_type and content_type != "unknown" + else "application/octet-stream" ) + media_urls.append(cached_path) + media_types.append(doc_mime) + logger.info( + "[Discord] Cached user %s: %s", + "document" if in_allowlist else "attachment", + cached_path, + ) + # Inject text content for any text-readable document + # Inject text content for text-readable documents + # (capped at 100 KB). Gate on a text-like extension/MIME + # — NOT a blind UTF-8 decode, since binary formats like + # PDF/zip/docx can have decodable ASCII headers. Unknown + # but clearly-textual types (text/* MIME or a known text + # extension) are inlined too; everything else relies on + # ``gateway/run.py`` to emit a path-pointing context note. + MAX_TEXT_INJECT_BYTES = 100 * 1024 + _is_text = ( + ext in _TEXT_INJECT_EXTENSIONS + or (content_type or "").startswith("text/") + ) + if _is_text and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + try: + text_content = raw_bytes.decode("utf-8") + display_name = att.filename or f"document{ext or '.txt'}" + display_name = re.sub(r'[^\w.\- ]', '_', display_name) + injection = f"[Content of {display_name}]:\n{text_content}" + if pending_text_injection: + pending_text_injection = f"{pending_text_injection}\n\n{injection}" + else: + pending_text_injection = injection + except UnicodeDecodeError: + pass + # NOTE: for the untyped-attachment path we deliberately + # do NOT inject a path string here. ``gateway/run.py`` + # already detects DOCUMENT-typed events with + # ``application/octet-stream`` MIME and emits a context + # note with the sandbox-translated cache path via + # ``to_agent_visible_cache_path()`` (important for + # Docker/Modal terminal backends). + except Exception as e: + logger.warning( + "[Discord] Failed to cache document %s: %s", + att.filename, e, exc_info=True, + ) # Use normalized_content (saved before auto-threading) instead of message.content, # to detect /slash commands in channel messages. diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 8bc0ed381e5..1ca68ec1666 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -46,6 +46,7 @@ from gateway.platforms.base import ( SendResult, SUPPORTED_DOCUMENT_TYPES, SUPPORTED_VIDEO_TYPES, + _TEXT_INJECT_EXTENSIONS, is_host_excluded_by_no_proxy, resolve_proxy_url, safe_url_for_log, @@ -2698,8 +2699,12 @@ class SlackAdapter(BasePlatformAdapter): } ext = mime_to_ext.get(mimetype, "") - if ext not in SUPPORTED_DOCUMENT_TYPES: - continue # Skip unsupported file types silently + # Any file type is accepted — authorization to message the + # agent is the gate, not the file extension. Known types keep + # their precise MIME; unknown types fall back to the source + # mimetype or octet-stream so the agent reaches for terminal + # tools. + in_allowlist = ext in SUPPORTED_DOCUMENT_TYPES # Check file size (Slack limit: 20 MB for bots) file_size = f.get("size", 0) @@ -2715,36 +2720,28 @@ class SlackAdapter(BasePlatformAdapter): url, team_id=team_id ) cached_path = cache_document_from_bytes( - raw_bytes, original_filename or f"document{ext}" + raw_bytes, original_filename or f"document{ext or '.bin'}" ) - doc_mime = SUPPORTED_DOCUMENT_TYPES[ext] + if in_allowlist: + doc_mime = SUPPORTED_DOCUMENT_TYPES[ext] + else: + doc_mime = mimetype or "application/octet-stream" media_urls.append(cached_path) media_types.append(doc_mime) - logger.debug("[Slack] Cached user document: %s", cached_path) + logger.debug("[Slack] Cached user document: %s (%s)", cached_path, doc_mime) # Inject small text-ish files directly into the prompt so - # snippets like JSON/YAML/configs are actually visible to the agent. + # snippets like JSON/YAML/configs are actually visible to the + # agent. Gate on a text-like extension/MIME — NOT a blind + # UTF-8 decode, since binary formats (PDF/zip/docx) can have + # decodable ASCII headers. Binary files are surfaced as a + # cached path only (run.py emits a path-pointing note). MAX_TEXT_INJECT_BYTES = 100 * 1024 - TEXT_INJECT_EXTENSIONS = { - ".md", - ".txt", - ".csv", - ".log", - ".json", - ".xml", - ".yaml", - ".yml", - ".toml", - ".ini", - ".cfg", - } - if ( - ext in TEXT_INJECT_EXTENSIONS - and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES - ): + _is_text = ext in _TEXT_INJECT_EXTENSIONS or (mimetype or "").startswith("text/") + if _is_text and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: try: text_content = raw_bytes.decode("utf-8") - display_name = original_filename or f"document{ext}" + display_name = original_filename or f"document{ext or '.txt'}" display_name = re.sub(r"[^\w.\- ]", "_", display_name) injection = f"[Content of {display_name}]:\n{text_content}" if text: diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 91cc4c14903..390acb61047 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -81,6 +81,7 @@ from gateway.platforms.base import ( SUPPORTED_VIDEO_TYPES, SUPPORTED_DOCUMENT_TYPES, SUPPORTED_IMAGE_DOCUMENT_TYPES, + _TEXT_INJECT_EXTENSIONS, utf16_len, ) from plugins.platforms.telegram.telegram_network import ( @@ -6526,33 +6527,30 @@ class TelegramAdapter(BasePlatformAdapter): # ext-in-SUPPORTED_IMAGE_DOCUMENT_TYPES branch would be dead # code — the extension sets are identical. - # Check if supported - if ext not in SUPPORTED_DOCUMENT_TYPES: - supported_list = ", ".join(sorted(SUPPORTED_DOCUMENT_TYPES.keys())) - event.text = ( - f"Unsupported document type '{ext or 'unknown'}'. " - f"Supported types: {supported_list}" - ) - logger.info("[Telegram] Unsupported document type: %s", ext or "unknown") - await self.handle_message(event) - return - - # Download and cache + # Download and cache. Any file type is accepted — authorization + # to message the agent is the gate, not the file extension. + # Known types keep their precise MIME; unknown types are tagged + # application/octet-stream so the agent reaches for terminal tools. file_obj = await doc.get_file() doc_bytes = await file_obj.download_as_bytearray() raw_bytes = bytes(doc_bytes) - cached_path = cache_document_from_bytes(raw_bytes, original_filename or f"document{ext}") - mime_type = SUPPORTED_DOCUMENT_TYPES[ext] + cached_path = cache_document_from_bytes(raw_bytes, original_filename or f"document{ext or '.bin'}") + mime_type = SUPPORTED_DOCUMENT_TYPES.get(ext) or doc.mime_type or "application/octet-stream" event.media_urls = [cached_path] event.media_types = [mime_type] - logger.info("[Telegram] Cached user document at %s", cached_path) + logger.info("[Telegram] Cached user document at %s (%s)", cached_path, mime_type) - # For text files, inject content into event.text (capped at 100 KB) + # For text-readable files, inject content into event.text (capped + # at 100 KB). Gate on a text-like extension/MIME — NOT a blind + # UTF-8 decode, since binary formats (PDF/zip/docx) can have + # decodable ASCII headers. Binary files are surfaced as a cached + # path only (run.py emits a path-pointing context note). MAX_TEXT_INJECT_BYTES = 100 * 1024 - if ext in {".md", ".txt"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + _is_text = ext in _TEXT_INJECT_EXTENSIONS or (doc_mime or "").startswith("text/") + if _is_text and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: try: text_content = raw_bytes.decode("utf-8") - display_name = original_filename or f"document{ext}" + display_name = original_filename or f"document{ext or '.txt'}" display_name = re.sub(r'[^\w.\- ]', '_', display_name) injection = f"[Content of {display_name}]:\n{text_content}" if event.text: @@ -6560,10 +6558,9 @@ class TelegramAdapter(BasePlatformAdapter): else: event.text = injection except UnicodeDecodeError: - logger.warning( - "[Telegram] Could not decode text file as UTF-8, skipping content injection", - exc_info=True, - ) + # Binary file — agent has the cached path and can use + # terminal/read_file against it. No inline injection. + pass except Exception as e: logger.warning("[Telegram] Failed to cache document: %s", e, exc_info=True) diff --git a/tests/gateway/test_discord_document_handling.py b/tests/gateway/test_discord_document_handling.py index 7b75c4a07f6..c9f8f53c283 100644 --- a/tests/gateway/test_discord_document_handling.py +++ b/tests/gateway/test_discord_document_handling.py @@ -387,37 +387,18 @@ class TestIncomingDocumentHandling: class TestAllowAnyAttachment: - """Cover the discord.allow_any_attachment config flag. + """Cover accept-any-file-type inbound handling. - With the flag off (default), unknown file types are dropped. With it on, - they get cached and surfaced to the agent as DOCUMENT events with - application/octet-stream MIME so gateway/run.py emits a path-pointing - context note. + Authorization to message the agent is the gate, not the file extension. + Unknown file types are cached and surfaced to the agent as DOCUMENT events + with the source content_type (or application/octet-stream) so gateway/run.py + emits a path-pointing context note. The legacy ``allow_any_attachment`` + config flag is now a no-op — acceptance is unconditional. """ @pytest.mark.asyncio - async def test_unknown_type_skipped_by_default(self, adapter): - """Default (flag off): unknown extension is dropped. - - With no text + no cached media, the adapter may legitimately decline - to dispatch the event at all, so we don't assert on call_args here — - we just verify the file wasn't cached. - """ - with _mock_aiohttp_download(b"should not be cached"): - msg = make_message([ - make_attachment(filename="weird.xyz", content_type="application/x-custom") - ]) - await adapter._handle_message(msg) - - if adapter.handle_message.call_args is not None: - event = adapter.handle_message.call_args[0][0] - assert event.media_urls == [] - - @pytest.mark.asyncio - async def test_unknown_type_cached_when_flag_on(self, adapter): - """Flag on: unknown extension is cached as application/octet-stream.""" - adapter.config.extra["allow_any_attachment"] = True - + async def test_unknown_type_cached_by_default(self, adapter): + """Default: unknown extension is cached, not dropped.""" with _mock_aiohttp_download(b"\x00\x01\x02 binary payload"): msg = make_message([ make_attachment(filename="weird.xyz", content_type="application/x-custom") @@ -430,16 +411,29 @@ class TestAllowAnyAttachment: # Falls back to the source content_type when we have one. assert event.media_types == ["application/x-custom"] assert event.message_type == MessageType.DOCUMENT - # We deliberately do NOT inline arbitrary bytes — run.py emits the - # path-pointing note based on DOCUMENT + octet-stream MIME. + # We deliberately do NOT inline arbitrary (non-UTF-8) bytes — run.py + # emits the path-pointing note based on DOCUMENT + octet-stream MIME. assert "[Content of" not in (event.text or "") @pytest.mark.asyncio - async def test_unknown_type_no_content_type_becomes_octet_stream(self, adapter): - """Flag on + no content_type from discord: MIME falls back to octet-stream.""" - adapter.config.extra["allow_any_attachment"] = True + async def test_html_cached_and_inlined(self, adapter): + """An .html upload is cached and (being UTF-8 text) inlined.""" + html = b"hi" + with _mock_aiohttp_download(html): + msg = make_message([ + make_attachment(filename="page.html", content_type="text/html") + ]) + await adapter._handle_message(msg) - with _mock_aiohttp_download(b"raw bytes"): + event = adapter.handle_message.call_args[0][0] + assert len(event.media_urls) == 1 + assert event.message_type == MessageType.DOCUMENT + assert event.media_types == ["text/html"] + + @pytest.mark.asyncio + async def test_unknown_type_no_content_type_becomes_octet_stream(self, adapter): + """No content_type from discord: MIME falls back to octet-stream.""" + with _mock_aiohttp_download(b"\x00raw bytes\x01"): msg = make_message([ make_attachment(filename="mystery.bin", content_type=None) ]) @@ -452,7 +446,6 @@ class TestAllowAnyAttachment: @pytest.mark.asyncio async def test_max_attachment_bytes_caps_uploads(self, adapter): """discord.max_attachment_bytes overrides the historical 32 MiB cap.""" - adapter.config.extra["allow_any_attachment"] = True adapter.config.extra["max_attachment_bytes"] = 1024 # 1 KiB msg = make_message([ @@ -470,7 +463,6 @@ class TestAllowAnyAttachment: @pytest.mark.asyncio async def test_max_attachment_bytes_zero_means_unlimited(self, adapter): """max_attachment_bytes=0 disables the size cap entirely.""" - adapter.config.extra["allow_any_attachment"] = True adapter.config.extra["max_attachment_bytes"] = 0 # 64 MiB — would normally exceed the historical 32 MiB hardcoded cap. @@ -488,14 +480,12 @@ class TestAllowAnyAttachment: assert len(event.media_urls) == 1 @pytest.mark.asyncio - async def test_allowlisted_doc_unchanged_when_flag_on(self, adapter): - """Flag on must not change handling of types already in SUPPORTED_DOCUMENT_TYPES. + async def test_allowlisted_doc_unchanged(self, adapter): + """Types already in SUPPORTED_DOCUMENT_TYPES keep canonical handling. - A .txt should still get its content inlined (the historical behavior), - and the MIME should still be the canonical text/plain — not whatever - discord guessed. + A .txt should still get its content inlined, and the MIME should still + be the canonical text/plain — not whatever discord guessed. """ - adapter.config.extra["allow_any_attachment"] = True file_content = b"still a text file" with _mock_aiohttp_download(file_content): @@ -510,14 +500,6 @@ class TestAllowAnyAttachment: assert "still a text file" in event.text assert event.media_types == ["text/plain"] - def test_helper_reads_env_fallback(self, adapter, monkeypatch): - """Helper falls back to DISCORD_ALLOW_ANY_ATTACHMENT env var.""" - assert adapter._discord_allow_any_attachment() is False - monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true") - assert adapter._discord_allow_any_attachment() is True - monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "no") - assert adapter._discord_allow_any_attachment() is False - def test_helper_config_overrides_env(self, adapter, monkeypatch): """config.yaml setting wins over env var.""" monkeypatch.setenv("DISCORD_ALLOW_ANY_ATTACHMENT", "true") diff --git a/tests/gateway/test_document_cache.py b/tests/gateway/test_document_cache.py index d3c01e59eb0..38cf510e28d 100644 --- a/tests/gateway/test_document_cache.py +++ b/tests/gateway/test_document_cache.py @@ -218,10 +218,25 @@ class TestCacheMediaBytes: assert result.kind == "document" assert result.media_type == "text/csv" - def test_unsupported_document_returns_none(self): + def test_unknown_document_cached_as_octet_stream(self): + """Unknown file types are cached (not dropped) so the agent can inspect them. + + Authorization to message the agent is the gate, not the file extension. + """ from gateway.platforms.base import cache_media_bytes - result = cache_media_bytes(b"MZ", filename="malware.exe", mime_type="application/x-msdownload") - assert result is None + result = cache_media_bytes(b"MZ", filename="program.exe", mime_type="application/x-msdownload") + assert result is not None + assert result.kind == "document" + # Caller-supplied MIME is preserved when present. + assert result.media_type == "application/x-msdownload" + assert os.path.exists(result.path) + + def test_unknown_document_no_mime_falls_back_to_octet_stream(self): + from gateway.platforms.base import cache_media_bytes + result = cache_media_bytes(b"\x00\x01\x02", filename="mystery.qux", mime_type="") + assert result is not None + assert result.kind == "document" + assert result.media_type == "application/octet-stream" def test_invalid_image_returns_none(self): from gateway.platforms.base import cache_media_bytes diff --git a/tests/gateway/test_telegram_documents.py b/tests/gateway/test_telegram_documents.py index b30f809fe39..a459f183c17 100644 --- a/tests/gateway/test_telegram_documents.py +++ b/tests/gateway/test_telegram_documents.py @@ -336,14 +336,25 @@ class TestDocumentDownloadBlock: assert event.media_types == ["application/pdf"] @pytest.mark.asyncio - async def test_missing_filename_and_mime_rejected(self, adapter): - doc = _make_document(file_name=None, mime_type=None, file_size=100) + async def test_missing_filename_and_mime_cached_as_octet_stream(self, adapter): + """No filename and no mime: cached anyway as application/octet-stream. + + Authorization to message the agent is the gate, not the file type — an + untyped upload is still surfaced to the agent as a cached path. + """ + content = b"\x00\x01\x02 untyped payload" + file_obj = _make_file_obj(content) + doc = _make_document( + file_name=None, mime_type=None, file_size=len(content), file_obj=file_obj, + ) msg = _make_message(document=doc) update = _make_update(msg) await adapter._handle_media_message(update, MagicMock()) event = adapter.handle_message.call_args[0][0] - assert "Unsupported" in event.text + assert len(event.media_urls) == 1 + assert event.media_types == ["application/octet-stream"] + assert "Unsupported" not in (event.text or "") @pytest.mark.asyncio async def test_unicode_decode_error_handled(self, adapter): diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 6ffa44db6c5..e54d2aef212 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -617,24 +617,25 @@ Discord's per-upload size limit depends on the server's boost tier (25 MB free, ## Receiving Arbitrary File Types -By default the bot caches uploads that match a built-in allowlist — images, audio, video, PDF, text/markdown/csv/log, JSON/XML/YAML/TOML, zip, docx/xlsx/pptx. Anything else (a `.wav`, a `.bin`, a custom-extension dump) gets logged as `Unsupported document type` and dropped before the agent sees it. +Any file type a user uploads is accepted. Authorization to message the agent is the gate — not the file extension. Every upload is downloaded, cached under `~/.hermes/cache/documents/`, and surfaced to the agent as a `DOCUMENT`-typed message event so it can inspect the file with `terminal` (`ffprobe`, `unzip`, `file`, `strings`, etc.) or `read_file`. -To accept arbitrary file types, enable `discord.allow_any_attachment`: +- Known types (PDF, docx/xlsx/pptx, zip, images/audio/video, etc.) keep their precise MIME. +- Unknown types fall back to the upload's reported content type, or `application/octet-stream` when none is given. +- Small UTF-8-decodable files (text, code, config, HTML, CSS, JSON, YAML, ...) have their contents auto-injected into the prompt up to 100 KiB. Binary files that can't be decoded are surfaced as a path-pointing context note only (auto-translated for Docker/Modal sandboxed terminals via `to_agent_visible_cache_path`), so they don't blow up the context window. + +The only inbound limit is the per-file size cap (default 32 MiB): ```yaml discord: - allow_any_attachment: true # Optional — raise/disable the per-file size cap. Default is 32 MiB. # The whole file is held in memory while being cached, so unlimited # uploads carry a real memory cost. max_attachment_bytes: 33554432 # bytes; 0 = unlimited ``` -When the flag is on, any uploaded file is downloaded, cached under `~/.hermes/cache/documents/`, and surfaced to the agent as a `DOCUMENT`-typed message event with `application/octet-stream` MIME. The agent receives a context note pointing at the local path (auto-translated for Docker/Modal sandboxed terminals via `to_agent_visible_cache_path`) and can inspect the file with `terminal` (`ffprobe`, `unzip`, `file`, `strings`, etc.) or `read_file`. The file body is **not** inlined into the prompt — only the path — so binary uploads don't blow up the context window. +Equivalent env var: `DISCORD_MAX_ATTACHMENT_BYTES=33554432` (or `0` for no cap). -Known-text formats already in the allowlist (`.txt`, `.md`, `.log`) continue to have their contents auto-injected up to 100 KiB; that behavior is unchanged when the flag is on. - -Equivalent env vars: `DISCORD_ALLOW_ANY_ATTACHMENT=true` and `DISCORD_MAX_ATTACHMENT_BYTES=33554432` (or `0` for no cap). +The legacy `discord.allow_any_attachment` flag is now a no-op — any file type is always accepted — and is kept only so existing configs don't error. :::warning Memory cost of unlimited Disabling the size cap (`max_attachment_bytes: 0`) means a user can drop a multi-GB file on the bot and the gateway will dutifully buffer it through memory while caching to disk. Only set this in trusted single-user installs. For shared bots, keep the default 32 MiB or raise it conservatively. From b5bd66eac9b18bb0e7c34f141c4631ff4eb1c72b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:43:51 -0700 Subject: [PATCH 456/470] fix(telegram): observed/replied group docs of any type are cached too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the accept-any-file-type change. The observe-unmentioned and replied-media paths relied on cache_media_bytes() returning None for unsupported document types to emit an 'unsupported, not cached' note. Now that any file type is always cached, those docs are cached and surfaced with a path-pointing note — consistent with the main document path. The remaining cached-is-None branch is image-validation-failure only; its note is reworded accordingly. Updates the group-gating test to the new contract. --- plugins/platforms/telegram/adapter.py | 5 ++++- tests/gateway/test_telegram_group_gating.py | 14 ++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 390acb61047..8e062c5c5c0 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -5861,8 +5861,11 @@ class TelegramAdapter(BasePlatformAdapter): return if cached is None: + # Only reachable for images that fail validation now — any other + # file type is always cached (authorization is the gate, not the + # extension). event.text = self._append_observed_note( - event.text, "[Observed Telegram attachment: unsupported type, not cached.]" + event.text, "[Observed Telegram attachment could not be read, not cached.]" ) return diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index d9b55fa2ad4..02362db91ec 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -1180,7 +1180,7 @@ def test_unmentioned_large_document_observed_without_download(monkeypatch): asyncio.run(_run()) -def test_unmentioned_unsupported_document_observed_without_caching(monkeypatch): +def test_unmentioned_unsupported_document_observed_and_cached(monkeypatch): async def _run(): adapter = _make_adapter( require_mention=True, allowed_chats=["-100"], @@ -1188,14 +1188,14 @@ def test_unmentioned_unsupported_document_observed_without_caching(monkeypatch): ) store = _FakeSessionStore() adapter._session_store = store - cache_doc = Mock(return_value="/tmp/malware.exe") + cache_doc = Mock(return_value="/tmp/program.exe") monkeypatch.setattr("gateway.platforms.base.cache_document_from_bytes", cache_doc) file_obj = SimpleNamespace( - file_path="documents/malware.exe", + file_path="documents/program.exe", download_as_bytearray=AsyncMock(return_value=bytearray(b"MZ")), ) document = SimpleNamespace( - file_name="malware.exe", mime_type="application/x-msdownload", + file_name="program.exe", mime_type="application/x-msdownload", file_size=2, get_file=AsyncMock(return_value=file_obj), ) update = SimpleNamespace( @@ -1204,8 +1204,10 @@ def test_unmentioned_unsupported_document_observed_without_caching(monkeypatch): await adapter._handle_media_message(update, SimpleNamespace()) - cache_doc.assert_not_called() + # Any file type is now cached — authorization is the gate, not the + # extension. The observed message records a path-pointing note. + cache_doc.assert_called_once() _, message, _ = store.messages[0] - assert "unsupported" in message["content"].lower() + assert "program.exe" in message["content"] asyncio.run(_run()) From 4b09903de5b93a92853a6c3ec398b3b077949b0c Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Thu, 18 Jun 2026 10:32:49 +1000 Subject: [PATCH 457/470] fix Nous auth refresh for idle agents --- agent/auxiliary_client.py | 62 ++++++ gateway/run.py | 14 ++ hermes_cli/nous_auth_keepalive.py | 189 ++++++++++++++++++ hermes_cli/runtime_provider.py | 30 ++- hermes_cli/web_server.py | 7 + tests/agent/test_auxiliary_client.py | 83 ++++++++ tests/hermes_cli/test_nous_auth_keepalive.py | 60 ++++++ .../test_runtime_provider_resolution.py | 60 ++++++ tests/run_agent/test_provider_parity.py | 9 + 9 files changed, 508 insertions(+), 6 deletions(-) create mode 100644 hermes_cli/nous_auth_keepalive.py create mode 100644 tests/hermes_cli/test_nous_auth_keepalive.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 4bc9440df31..0afb0add20b 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -665,6 +665,13 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str: return str(url or "").strip().rstrip("/") +def _nous_min_key_ttl_seconds() -> int: + try: + return max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))) + except (TypeError, ValueError): + return 1800 + + # ── Codex Responses → chat.completions adapter ───────────────────────────── # All auxiliary consumers call client.chat.completions.create(**kwargs) and # read response.choices[0].message.content. This adapter translates those @@ -1338,6 +1345,57 @@ def _nous_base_url() -> str: return os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL) +def _resolve_nous_pool_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[str, str]]: + """Resolve Nous auxiliary credentials from the selected pool entry.""" + try: + from hermes_cli.auth import _agent_key_is_usable + + pool = load_pool("nous") + except Exception as exc: + logger.debug("Auxiliary Nous pool credential resolution failed: %s", exc) + return None + + if not pool or not pool.has_credentials(): + return None + + try: + entry = pool.select() + except Exception as exc: + logger.debug("Auxiliary Nous pool selection failed: %s", exc) + return None + + if entry is None: + return None + + state = { + "agent_key": getattr(entry, "agent_key", None), + "agent_key_expires_at": getattr(entry, "agent_key_expires_at", None), + "scope": getattr(entry, "scope", None), + } + if force_refresh or not _agent_key_is_usable(state, _nous_min_key_ttl_seconds()): + try: + refreshed = pool.try_refresh_current() + except Exception as exc: + logger.debug("Auxiliary Nous pool refresh failed: %s", exc) + refreshed = None + if refreshed is None: + return None + entry = refreshed + + provider = { + "agent_key": getattr(entry, "agent_key", None), + "agent_key_expires_at": getattr(entry, "agent_key_expires_at", None), + "access_token": getattr(entry, "access_token", None), + "expires_at": getattr(entry, "expires_at", None), + "scope": getattr(entry, "scope", None), + } + api_key = _nous_api_key(provider) + base_url = _pool_runtime_base_url(entry, _NOUS_DEFAULT_BASE_URL) + if not api_key or not base_url: + return None + return api_key, base_url + + def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[str, str]]: """Return fresh Nous runtime credentials when available. @@ -1346,6 +1404,10 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[ relying only on whatever raw tokens happen to be sitting in auth.json or the credential pool. """ + pooled = _resolve_nous_pool_runtime_api(force_refresh=force_refresh) + if pooled is not None: + return pooled + try: from hermes_cli.auth import resolve_nous_runtime_credentials diff --git a/gateway/run.py b/gateway/run.py index 5b7c63a42f9..a388f184ad6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -17642,6 +17642,13 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = atexit.register(remove_pid_file) atexit.register(release_gateway_runtime_lock) + try: + from hermes_cli.nous_auth_keepalive import start_nous_auth_keepalive + + start_nous_auth_keepalive() + except Exception as exc: + logger.debug("Nous auth keepalive did not start: %s", exc) + _ensure_windows_gateway_venv_imports() # MCP tool discovery — run in an executor so the asyncio event loop @@ -17698,6 +17705,13 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = # Wait for shutdown await runner.wait_for_shutdown() + try: + from hermes_cli.nous_auth_keepalive import stop_nous_auth_keepalive + + stop_nous_auth_keepalive() + except Exception: + pass + if runner.should_exit_with_failure: if runner.exit_reason: logger.error("Gateway exiting with failure: %s", runner.exit_reason) diff --git a/hermes_cli/nous_auth_keepalive.py b/hermes_cli/nous_auth_keepalive.py new file mode 100644 index 00000000000..947bbd17871 --- /dev/null +++ b/hermes_cli/nous_auth_keepalive.py @@ -0,0 +1,189 @@ +"""Background keepalive for long-lived Nous Portal sessions.""" + +from __future__ import annotations + +import logging +import os +import threading +from typing import Optional + +from hermes_cli.auth import ( + ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + NOUS_INVOKE_JWT_MIN_TTL_SECONDS, + AuthError, + _agent_key_is_usable, + _is_expiring, + get_provider_auth_state, + resolve_nous_runtime_credentials, +) + +logger = logging.getLogger(__name__) + +NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS = 6 * 60 * 60 +NOUS_AUTH_KEEPALIVE_INITIAL_DELAY_SECONDS = 60 + +_keepalive_lock = threading.Lock() +_keepalive_stop = threading.Event() +_keepalive_thread: Optional[threading.Thread] = None + + +def _timeout_seconds(value: Optional[float]) -> float: + if value is not None: + return float(value) + try: + return float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")) + except (TypeError, ValueError): + return 15.0 + + +def _entry_state(entry: object) -> dict: + return { + "agent_key": getattr(entry, "agent_key", None), + "agent_key_expires_at": getattr(entry, "agent_key_expires_at", None), + "scope": getattr(entry, "scope", None), + } + + +def _refresh_selected_pool_entry( + *, + min_key_ttl_seconds: int, +) -> Optional[bool]: + """Refresh the current Nous credential pool entry when it is stale. + + Returns True when a pool entry exists and is usable/refreshed, False when a + pool exists but no entry can be used, and None when no Nous pool exists. + """ + try: + from agent.credential_pool import load_pool + + pool = load_pool("nous") + except Exception as exc: + logger.debug("Nous auth keepalive: credential pool unavailable: %s", exc) + return None + + if not pool or not pool.has_credentials(): + return None + + try: + entry = pool.select() + except Exception as exc: + logger.debug("Nous auth keepalive: credential pool selection failed: %s", exc) + return False + + if entry is None: + return False + + access_expiring = _is_expiring( + getattr(entry, "expires_at", None), + ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + ) + key_usable = _agent_key_is_usable(_entry_state(entry), min_key_ttl_seconds) + if access_expiring or not key_usable: + refreshed = pool.try_refresh_current() + if refreshed is None: + return False + logger.debug("Nous auth keepalive: refreshed credential pool entry") + return True + + return True + + +def refresh_nous_auth_keepalive_once( + *, + min_key_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS, + timeout_seconds: Optional[float] = None, +) -> bool: + """Refresh Nous auth once if credentials are configured.""" + min_key_ttl_seconds = max(60, int(min_key_ttl_seconds)) + + pool_result = _refresh_selected_pool_entry( + min_key_ttl_seconds=min_key_ttl_seconds, + ) + if pool_result is not None: + return pool_result + + state = get_provider_auth_state("nous") + if not state: + return False + + try: + resolve_nous_runtime_credentials( + timeout_seconds=_timeout_seconds(timeout_seconds), + ) + logger.debug("Nous auth keepalive: refreshed singleton auth state") + return True + except AuthError as exc: + if exc.relogin_required: + logger.info("Nous auth keepalive requires re-login: %s", exc) + else: + logger.debug("Nous auth keepalive failed: %s", exc) + return False + except Exception as exc: + logger.debug("Nous auth keepalive failed: %s", exc) + return False + + +def _keepalive_loop( + stop_event: threading.Event, + *, + interval_seconds: int, + initial_delay_seconds: int, + min_key_ttl_seconds: int, + timeout_seconds: Optional[float], +) -> None: + if initial_delay_seconds > 0 and stop_event.wait(initial_delay_seconds): + return + + while not stop_event.is_set(): + refresh_nous_auth_keepalive_once( + min_key_ttl_seconds=min_key_ttl_seconds, + timeout_seconds=timeout_seconds, + ) + stop_event.wait(interval_seconds) + + +def start_nous_auth_keepalive( + *, + interval_seconds: int = NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS, + initial_delay_seconds: int = NOUS_AUTH_KEEPALIVE_INITIAL_DELAY_SECONDS, + min_key_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS, + timeout_seconds: Optional[float] = None, +) -> Optional[threading.Thread]: + """Start the process-wide Nous auth keepalive thread.""" + if interval_seconds <= 0: + return None + + global _keepalive_thread + with _keepalive_lock: + if _keepalive_thread is not None and _keepalive_thread.is_alive(): + return _keepalive_thread + + _keepalive_stop.clear() + _keepalive_thread = threading.Thread( + target=_keepalive_loop, + args=(_keepalive_stop,), + kwargs={ + "interval_seconds": int(interval_seconds), + "initial_delay_seconds": max(0, int(initial_delay_seconds)), + "min_key_ttl_seconds": max(60, int(min_key_ttl_seconds)), + "timeout_seconds": timeout_seconds, + }, + daemon=True, + name="nous-auth-keepalive", + ) + _keepalive_thread.start() + logger.debug("Nous auth keepalive started") + return _keepalive_thread + + +def stop_nous_auth_keepalive(timeout: float = 5.0) -> None: + """Stop the keepalive thread. Intended for graceful shutdown/tests.""" + global _keepalive_thread + with _keepalive_lock: + thread = _keepalive_thread + _keepalive_stop.set() + if thread is not None and thread.is_alive(): + thread.join(timeout=timeout) + with _keepalive_lock: + if _keepalive_thread is thread: + _keepalive_thread = None diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 2c5dd0a7fd4..f15de5ba75e 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1495,10 +1495,10 @@ def resolve_runtime_provider( # For Nous, the pool entry's runtime_api_key is the agent_key # compatibility field. It must be an invoke JWT. The pool doesn't # refresh it during selection (that would trigger network calls in - # non-runtime contexts like `hermes auth list`). If the key is - # expired, clear pool_api_key so we fall through to - # resolve_nous_runtime_credentials() which handles refresh. - if provider == "nous" and entry is not None and pool_api_key: + # non-runtime contexts like `hermes auth list`). If the key is + # expired/missing, refresh the selected pool entry before falling back + # to singleton auth resolution. + if provider == "nous" and entry is not None: min_ttl = max(60, env_int("HERMES_NOUS_MIN_KEY_TTL_SECONDS", 1800)) nous_state = { "agent_key": getattr(entry, "agent_key", None), @@ -1506,8 +1506,26 @@ def resolve_runtime_provider( "scope": getattr(entry, "scope", None), } if not _agent_key_is_usable(nous_state, min_ttl): - logger.debug("Nous pool entry agent_key expired/missing, falling through to runtime resolution") - pool_api_key = "" + logger.debug("Nous pool entry agent_key expired/missing, refreshing selected pool entry") + try: + refreshed = pool.try_refresh_current() + except Exception as exc: + logger.debug("Nous pool entry refresh failed: %s", exc) + refreshed = None + if refreshed is not None: + entry = refreshed + pool_api_key = ( + getattr(entry, "runtime_api_key", None) + or getattr(entry, "access_token", "") + ) + nous_state = { + "agent_key": getattr(entry, "agent_key", None), + "agent_key_expires_at": getattr(entry, "agent_key_expires_at", None), + "scope": getattr(entry, "scope", None), + } + if not pool_api_key or not _agent_key_is_usable(nous_state, min_ttl): + logger.debug("Nous pool entry agent_key still unavailable, falling through to runtime resolution") + pool_api_key = "" if entry is not None and pool_api_key: return _resolve_runtime_from_pool_entry( provider=provider, diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ade50c60051..4227e621113 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -12823,6 +12823,13 @@ def start_server( """ import uvicorn + try: + from hermes_cli.nous_auth_keepalive import start_nous_auth_keepalive + + start_nous_auth_keepalive() + except Exception as exc: + _log.debug("Nous auth keepalive did not start: %s", exc) + # Phase 0: stash the auth-gate flag on app.state so middleware / SPA-token # injection / WS-auth paths can branch on it consistently. Phase 3.5 # uses this to decide whether to refuse the bind, log the gate-on diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 8ec6102f2e5..dac9956b494 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -1071,6 +1071,89 @@ class TestAuxiliaryPoolAwareness: assert mock_openai.call_args.kwargs["api_key"] == pooled_token assert mock_openai.call_args.kwargs["base_url"] == "https://inference.pool.example/v1" + def test_try_nous_refreshes_stale_pool_entry(self): + stale_token = _jwt_with_claims({ + "scope": "inference:invoke", + "exp": int(time.time() - 60), + }) + fresh_token = _jwt_with_claims({ + "scope": "inference:invoke", + "exp": int(time.time() + 3600), + }) + + class _Entry: + def __init__(self, token): + self.access_token = "pooled-access-token" + self.agent_key = token + self.agent_key_expires_at = "2099-01-01T00:00:00+00:00" + self.scope = "inference:invoke" + self.inference_base_url = "https://inference.pool.example/v1" + + class _Pool: + refreshed = False + + def has_credentials(self): + return True + + def select(self): + return _Entry(stale_token) + + def try_refresh_current(self): + self.refreshed = True + return _Entry(fresh_token) + + pool = _Pool() + with ( + patch("agent.auxiliary_client.load_pool", return_value=pool), + patch("agent.auxiliary_client.OpenAI") as mock_openai, + patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None), + ): + from agent.auxiliary_client import _try_nous + + client, model = _try_nous() + + assert pool.refreshed is True + assert client is not None + assert model == "google/gemini-3-flash-preview" + assert mock_openai.call_args.kwargs["api_key"] == fresh_token + assert mock_openai.call_args.kwargs["base_url"] == "https://inference.pool.example/v1" + + def test_resolve_nous_runtime_api_rejects_stale_pool_entry_when_refresh_fails(self): + stale_token = _jwt_with_claims({ + "scope": "inference:invoke", + "exp": int(time.time() - 60), + }) + + class _Entry: + access_token = "pooled-access-token" + agent_key = stale_token + agent_key_expires_at = "2099-01-01T00:00:00+00:00" + scope = "inference:invoke" + inference_base_url = "https://inference.pool.example/v1" + + class _Pool: + def has_credentials(self): + return True + + def select(self): + return _Entry() + + def try_refresh_current(self): + return None + + with ( + patch("agent.auxiliary_client.load_pool", return_value=_Pool()), + patch( + "hermes_cli.auth.resolve_nous_runtime_credentials", + side_effect=RuntimeError("no singleton auth"), + ), + ): + from agent.auxiliary_client import _resolve_nous_runtime_api + + runtime = _resolve_nous_runtime_api() + + assert runtime is None + def test_try_nous_uses_portal_recommendation_for_text(self): """When the Portal recommends a compaction model, _try_nous honors it.""" fresh_base = "https://inference-api.nousresearch.com/v1" diff --git a/tests/hermes_cli/test_nous_auth_keepalive.py b/tests/hermes_cli/test_nous_auth_keepalive.py new file mode 100644 index 00000000000..9e633a14171 --- /dev/null +++ b/tests/hermes_cli/test_nous_auth_keepalive.py @@ -0,0 +1,60 @@ +from hermes_cli import nous_auth_keepalive as keepalive + + +def test_keepalive_refreshes_stale_pool_entry(monkeypatch): + class _Entry: + access_token = "pooled-access-token" + expires_at = "2000-01-01T00:00:00+00:00" + agent_key = "" + agent_key_expires_at = None + scope = "inference:invoke" + + class _Pool: + refreshed = False + + def has_credentials(self): + return True + + def select(self): + return _Entry() + + def try_refresh_current(self): + self.refreshed = True + return _Entry() + + pool = _Pool() + monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: pool) + + assert keepalive.refresh_nous_auth_keepalive_once() is True + assert pool.refreshed is True + + +def test_keepalive_falls_back_to_singleton_state(monkeypatch): + calls = [] + + class _Pool: + def has_credentials(self): + return False + + def _resolve_nous_runtime_credentials(**kwargs): + calls.append(kwargs) + return { + "provider": "nous", + "api_key": "fresh-agent-key", + "base_url": "https://inference-api.nousresearch.com/v1", + } + + monkeypatch.setattr("agent.credential_pool.load_pool", lambda provider: _Pool()) + monkeypatch.setattr( + keepalive, + "get_provider_auth_state", + lambda provider: {"access_token": "stored-access-token"}, + ) + monkeypatch.setattr( + keepalive, + "resolve_nous_runtime_credentials", + _resolve_nous_runtime_credentials, + ) + + assert keepalive.refresh_nous_auth_keepalive_once(timeout_seconds=15.0) is True + assert calls == [{"timeout_seconds": 15.0}] diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 3e788fe3d53..8df00200d79 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -1,8 +1,25 @@ +import base64 +import json +import time + import pytest from hermes_cli import runtime_provider as rp +def _fake_invoke_jwt(ttl_seconds=3600): + header = base64.urlsafe_b64encode(b'{"alg":"none","typ":"JWT"}').decode().rstrip("=") + payload = base64.urlsafe_b64encode( + json.dumps( + { + "scope": "inference:invoke", + "exp": int(time.time() + ttl_seconds), + } + ).encode() + ).decode().rstrip("=") + return f"{header}.{payload}.sig" + + def test_resolve_runtime_provider_uses_credential_pool(monkeypatch): class _Entry: access_token = "pool-token" @@ -977,6 +994,49 @@ def test_named_custom_provider_does_not_shadow_builtin_provider(monkeypatch): assert resolved["requested_provider"] == "nous" +def test_nous_pool_entry_refreshes_expired_agent_key(monkeypatch): + stale_token = _fake_invoke_jwt(ttl_seconds=-60) + fresh_token = _fake_invoke_jwt(ttl_seconds=3600) + + class _Entry: + def __init__(self, token): + self.access_token = "pool-access-token" + self.agent_key = token + self.agent_key_expires_at = "2099-01-01T00:00:00+00:00" + self.scope = "inference:invoke" + self.base_url = "https://inference.pool.example/v1" + self.source = "manual:nous" + + @property + def runtime_api_key(self): + return self.agent_key + + class _Pool: + refreshed = False + + def has_credentials(self): + return True + + def select(self): + return _Entry(stale_token) + + def try_refresh_current(self): + self.refreshed = True + return _Entry(fresh_token) + + pool = _Pool() + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "nous") + monkeypatch.setattr(rp, "load_pool", lambda provider: pool) + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "nous"}) + + resolved = rp.resolve_runtime_provider(requested="nous") + + assert pool.refreshed is True + assert resolved["provider"] == "nous" + assert resolved["api_key"] == fresh_token + assert resolved["base_url"] == "https://inference.pool.example/v1" + + def test_named_custom_provider_wins_over_builtin_alias(monkeypatch): """A custom_providers entry named after a built-in *alias* (not a canonical provider name) must win over the built-in. Regression guard for #15743: diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index c99ab433d45..8229b0f020d 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -56,6 +56,15 @@ class _FakeOpenAI: pass +@pytest.fixture(autouse=True) +def _reset_auxiliary_provider_state(): + from agent.auxiliary_client import _reset_aux_unhealthy_cache + + _reset_aux_unhealthy_cache() + yield + _reset_aux_unhealthy_cache() + + def _make_agent(monkeypatch, provider, api_mode="chat_completions", base_url="https://openrouter.ai/api/v1", model=None): monkeypatch.setattr("run_agent.get_tool_definitions", lambda **kw: _tool_defs("web_search", "terminal")) monkeypatch.setattr("run_agent.check_toolset_requirements", lambda: {}) From 74f0dd62e87536e2d53ece79a71f9a1fa75f038c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:43:55 -0700 Subject: [PATCH 458/470] feat(cli): Ctrl+G submits the edited draft on save (TUI parity) (#50560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+G already opened $EDITOR with the current draft, but used open_in_editor(validate_and_handle=False), which only loaded the saved text back into the input area — the user still had to press Enter. The TUI's Ctrl+G (openEditor) submits the draft on a clean exit. Since CLI submission is driven by the custom Enter keybinding (not the buffer accept_handler), validate_and_handle can't route through it; instead chain a done-callback on the editor Task that calls the new _submit_editor_buffer(), which mirrors the Enter handler's idle/queue/slash branches and drops an empty save. --- cli.py | 76 ++++++++++++++++- tests/hermes_cli/test_ctrlg_editor_submit.py | 86 ++++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_ctrlg_editor_submit.py diff --git a/cli.py b/cli.py index a195f8ab5f2..6ee25e2fcec 100644 --- a/cli.py +++ b/cli.py @@ -5379,12 +5379,86 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # Set skip flag (again) so the text-change event fired when the # editor closes does not re-collapse the returned content. self._skip_paste_collapse = True - target_buffer.open_in_editor(validate_and_handle=False) + # Open the editor, then submit the saved draft on a clean exit — + # matching the TUI's Ctrl+G (openEditor), which sends the buffer + # instead of requiring a second Enter. Submission in this CLI is + # driven by the custom `enter` keybinding, NOT the buffer's + # accept_handler, so validate_and_handle can't route through it; + # chain a done-callback on the returned Task that re-uses the + # real submit pipeline via _submit_editor_buffer(). + task = target_buffer.open_in_editor(validate_and_handle=False) + if task is not None and hasattr(task, "add_done_callback"): + task.add_done_callback( + lambda _t, b=target_buffer: self._submit_editor_buffer(b) + ) return True except Exception as exc: _cprint(f"{_DIM}Failed to open external editor: {exc}{_RST}") return False + def _submit_editor_buffer(self, buffer) -> None: + """Submit the draft an external editor left in ``buffer``. + + Invoked from the Ctrl+G done-callback so saving the editor sends the + prompt (TUI parity) instead of leaving it sitting in the input area. + Mirrors the idle/queue branches of the `enter` keybinding handler: + an empty save is ignored (never submits a blank turn), a slash command + is dispatched, otherwise the text is routed through the same input + queues the normal Enter path uses. Runs on the prompt_toolkit event + loop via the Task callback, so it must be cheap and non-blocking. + """ + try: + text = (getattr(buffer, "text", "") or "").strip() + except Exception: + return + if not text: + # Editor saved empty / was cleared — match the TUI, which drops + # an empty draft instead of submitting a blank turn. + return + + app = getattr(self, "_app", None) + + # Slash commands: dispatch directly, same as the Enter handler's + # _looks_like_slash_command branch. + if _looks_like_slash_command(text): + try: + if not self.process_command(text): + self._should_exit = True + if app is not None and app.is_running: + app.exit() + except Exception as exc: + _cprint(f" {_DIM}Command failed: {exc}{_RST}") + finally: + self._reset_input_buffer(buffer) + if app is not None: + app.invalidate() + return + + # Regular prompt: route through the same queues the Enter handler uses. + if self._agent_running: + # Agent busy → honour the configured busy-input behaviour by + # queueing for the next turn (the safe default; interrupt/steer + # remain reachable via the normal Enter path). + self._interrupt_queue.put(text) if self.busy_input_mode == "interrupt" else self._pending_input.put(text) + preview = text[:80] + ("..." if len(text) > 80 else "") + _cprint(f" Queued for the next turn: {preview}") + else: + self._pending_input.put(text) + + self._reset_input_buffer(buffer) + if app is not None: + app.invalidate() + + def _reset_input_buffer(self, buffer) -> None: + """Clear an input buffer after a programmatic submit (best-effort).""" + try: + buffer.reset(append_to_history=True) + except Exception: + try: + buffer.text = "" + except Exception: + pass + def _install_tool_callbacks(self) -> None: diff --git a/tests/hermes_cli/test_ctrlg_editor_submit.py b/tests/hermes_cli/test_ctrlg_editor_submit.py new file mode 100644 index 00000000000..4864d84602a --- /dev/null +++ b/tests/hermes_cli/test_ctrlg_editor_submit.py @@ -0,0 +1,86 @@ +"""Tests for Ctrl+G external-editor submit in the classic CLI. + +Ctrl+G opens the current draft in ``$EDITOR``; on a clean save the draft is +submitted (TUI parity) rather than left in the input area. Submission in the +CLI is driven by the custom Enter keybinding, not the buffer accept_handler, +so ``_open_external_editor`` chains a done-callback that calls +``_submit_editor_buffer``. These exercise that submit helper directly. +""" + +import queue + +from cli import HermesCLI + + +class _FakeBuf: + def __init__(self, text: str): + self.text = text + self.reset_called = False + + def reset(self, append_to_history: bool = False): + self.reset_called = True + self.text = "" + + +def _make(agent_running: bool = False, busy: str = "queue") -> HermesCLI: + c = HermesCLI.__new__(HermesCLI) + c._pending_input = queue.Queue() + c._interrupt_queue = queue.Queue() + c._agent_running = agent_running + c.busy_input_mode = busy + c._app = None + c._should_exit = False + return c + + +def test_idle_prompt_routed_to_pending_input(): + c = _make() + buf = _FakeBuf("Explain vector databases.\nKeep it short.") + + c._submit_editor_buffer(buf) + + assert c._pending_input.get_nowait() == "Explain vector databases.\nKeep it short." + assert buf.reset_called + + +def test_empty_save_does_not_submit(): + c = _make() + buf = _FakeBuf(" \n \n") + + c._submit_editor_buffer(buf) + + assert c._pending_input.empty() + # An empty save must not clear-and-submit a blank turn. + assert not buf.reset_called + + +def test_running_queue_mode_queues_for_next_turn(): + c = _make(agent_running=True, busy="queue") + buf = _FakeBuf("next turn please") + + c._submit_editor_buffer(buf) + + assert c._pending_input.get_nowait() == "next turn please" + assert c._interrupt_queue.empty() + + +def test_running_interrupt_mode_uses_interrupt_queue(): + c = _make(agent_running=True, busy="interrupt") + buf = _FakeBuf("interrupt this") + + c._submit_editor_buffer(buf) + + assert c._interrupt_queue.get_nowait() == "interrupt this" + assert c._pending_input.empty() + + +def test_slash_command_dispatched_not_queued(): + c = _make() + seen = {} + c.process_command = lambda command: seen.setdefault("cmd", command) or True + buf = _FakeBuf("/status") + + c._submit_editor_buffer(buf) + + assert seen.get("cmd") == "/status" + assert c._pending_input.empty() From 2455e1801b60b8c964446339a10a9bceb85986d3 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Thu, 18 Jun 2026 14:26:45 +1000 Subject: [PATCH 459/470] Make email pairing opt-in --- gateway/authz_mixin.py | 14 ++++- gateway/config.py | 2 + hermes_cli/gateway.py | 49 ++++++++++++--- tests/gateway/test_config.py | 19 ++++++ .../gateway/test_unauthorized_dm_behavior.py | 61 +++++++++++++++++++ website/docs/user-guide/configuration.md | 3 +- website/docs/user-guide/messaging/email.md | 7 ++- website/docs/user-guide/messaging/index.md | 2 +- website/docs/user-guide/security.md | 3 +- 9 files changed, 145 insertions(+), 15 deletions(-) diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index 9ededa49130..70632d78cb3 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -458,13 +458,16 @@ class GatewayAuthorizationMixin: Resolution order: 1. Explicit per-platform ``unauthorized_dm_behavior`` in config — always wins. 2. Explicit global ``unauthorized_dm_behavior`` in config — wins when no per-platform. - 3. When an allowlist (``PLATFORM_ALLOWED_USERS``, + 3. Email defaults to ``"ignore"`` unless explicitly opted into + pairing. Inboxes may contain arbitrary unread human messages, so + replying with pairing codes is not a safe platform default. + 4. When an allowlist (``PLATFORM_ALLOWED_USERS``, ``PLATFORM_GROUP_ALLOWED_USERS`` / ``PLATFORM_GROUP_ALLOWED_CHATS``, or ``GATEWAY_ALLOWED_USERS``) is configured, default to ``"ignore"`` — the allowlist signals that the owner has deliberately restricted access; spamming unknown contacts with pairing codes is both noisy and a potential info-leak. (#9337) - 4. No allowlist and no explicit config → ``"pair"`` (open-gateway default). + 5. No allowlist and no explicit config → ``"pair"`` (open-gateway default). """ config = getattr(self, "config", None) @@ -494,6 +497,13 @@ class GatewayAuthorizationMixin: if dm_policy in {"allowlist", "disabled"}: return "ignore" + # Email is inbox-shaped, not chat-shaped: an agent mailbox may contain + # unrelated unread human email. Require an explicit per-platform + # ``unauthorized_dm_behavior: pair`` opt-in before replying to unknown + # senders with pairing codes. + if platform == Platform.EMAIL: + return "ignore" + # No explicit override. Fall back to allowlist-aware default: # if any allowlist is configured for this platform, silently drop # unauthorized messages instead of sending pairing codes. diff --git a/gateway/config.py b/gateway/config.py index d3c85e86818..6b474a34038 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -757,6 +757,8 @@ class GatewayConfig: platform_cfg.extra.get("unauthorized_dm_behavior"), self.unauthorized_dm_behavior, ) + if platform == Platform.EMAIL: + return "ignore" return self.unauthorized_dm_behavior def get_notice_delivery(self, platform: Optional[Platform] = None) -> str: diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 1a3f58ef268..b68f48476cc 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -30,6 +30,7 @@ from hermes_cli.config import ( is_managed, managed_error, read_raw_config, + save_config, save_env_value, ) @@ -4645,6 +4646,21 @@ def _runtime_health_lines() -> list[str]: return lines +def _set_platform_unauthorized_dm_behavior(platform_key: str, behavior: str) -> None: + """Persist a platform-specific unauthorized-DM policy in config.yaml.""" + cfg = read_raw_config() + platforms = cfg.setdefault("platforms", {}) + if not isinstance(platforms, dict): + platforms = {} + cfg["platforms"] = platforms + platform_cfg = platforms.setdefault(platform_key, {}) + if not isinstance(platform_cfg, dict): + platform_cfg = {} + platforms[platform_key] = platform_cfg + platform_cfg["unauthorized_dm_behavior"] = behavior + save_config(cfg) + + def _setup_standard_platform(platform: dict): """Interactive setup for Telegram, Discord, or Slack.""" emoji = platform["emoji"] @@ -4754,24 +4770,43 @@ def _setup_standard_platform(platform: dict): else: # No allowlist — ask about open access vs DM pairing print() - access_choices = [ - "Enable open access (anyone can message the bot)", - "Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')", - "Skip for now (bot will deny all users until configured)", - ] + is_email = platform.get("key") == "email" + if is_email: + access_choices = [ + "Enable open access (any email sender can message the bot)", + "Use DM pairing (unknown email senders receive a pairing code)", + "Keep unknown senders silent", + ] + default_access_idx = 2 + else: + access_choices = [ + "Enable open access (anyone can message the bot)", + "Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')", + "Skip for now (bot will deny all users until configured)", + ] + default_access_idx = 1 access_idx = prompt_choice( - " How should unauthorized users be handled?", access_choices, 1 + " How should unauthorized users be handled?", + access_choices, + default_access_idx, ) if access_idx == 0: - save_env_value("GATEWAY_ALLOW_ALL_USERS", "true") + if is_email: + save_env_value("EMAIL_ALLOW_ALL_USERS", "true") + else: + save_env_value("GATEWAY_ALLOW_ALL_USERS", "true") print_warning(" Open access enabled — anyone can use your bot!") elif access_idx == 1: + if is_email: + _set_platform_unauthorized_dm_behavior("email", "pair") print_success( " DM pairing mode — users will receive a code to request access." ) print_info( " Approve with: hermes pairing approve " ) + elif is_email: + print_success(" Unknown email senders will be ignored.") else: print_info( " Skipped — configure later with 'hermes gateway setup'" diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index f3c3b1021bf..2542ff43123 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -267,6 +267,25 @@ class TestGatewayConfigRoundtrip: assert restored.unauthorized_dm_behavior == "ignore" assert restored.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair" + def test_email_defaults_to_ignore_for_unauthorized_dm_behavior(self): + config = GatewayConfig( + platforms={Platform.EMAIL: PlatformConfig(enabled=True)}, + ) + + assert config.get_unauthorized_dm_behavior(Platform.EMAIL) == "ignore" + + def test_email_can_opt_into_pairing_for_unauthorized_dm_behavior(self): + config = GatewayConfig( + platforms={ + Platform.EMAIL: PlatformConfig( + enabled=True, + extra={"unauthorized_dm_behavior": "pair"}, + ), + }, + ) + + assert config.get_unauthorized_dm_behavior(Platform.EMAIL) == "pair" + def test_from_dict_coerces_quoted_false_always_log_local(self): restored = GatewayConfig.from_dict({"always_log_local": "false"}) assert restored.always_log_local is False diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index d2cc53aae84..f4ea14cdb70 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -801,6 +801,55 @@ async def test_no_allowlist_still_pairs_by_default(monkeypatch): assert "PAIR1234" in adapter.send.await_args.args[1] +@pytest.mark.asyncio +async def test_email_no_allowlist_ignores_unknown_senders_by_default(monkeypatch): + """Email should not send pairing codes to arbitrary unread inbox senders.""" + _clear_auth_env(monkeypatch) + + config = GatewayConfig( + platforms={Platform.EMAIL: PlatformConfig(enabled=True)}, + ) + runner, adapter = _make_runner(Platform.EMAIL, config) + runner.pairing_store.generate_code.return_value = "EMAIL123" + + result = await runner._handle_message( + _make_event(Platform.EMAIL, "stranger@example.com", "stranger@example.com") + ) + + assert result is None + runner.pairing_store.generate_code.assert_not_called() + adapter.send.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_email_pairing_requires_explicit_platform_opt_in(monkeypatch): + _clear_auth_env(monkeypatch) + + config = GatewayConfig( + platforms={ + Platform.EMAIL: PlatformConfig( + enabled=True, + extra={"unauthorized_dm_behavior": "pair"}, + ), + }, + ) + runner, adapter = _make_runner(Platform.EMAIL, config) + runner.pairing_store.generate_code.return_value = "EMAIL123" + + result = await runner._handle_message( + _make_event(Platform.EMAIL, "stranger@example.com", "stranger@example.com") + ) + + assert result is None + runner.pairing_store.generate_code.assert_called_once_with( + "email", + "stranger@example.com", + "tester", + ) + adapter.send.assert_awaited_once() + assert "EMAIL123" in adapter.send.await_args.args[1] + + def test_explicit_pair_config_overrides_allowlist_default(monkeypatch): """Explicit unauthorized_dm_behavior='pair' overrides the allowlist default. @@ -858,6 +907,18 @@ def test_get_unauthorized_dm_behavior_no_allowlist_returns_pair(monkeypatch): assert behavior == "pair" +def test_get_unauthorized_dm_behavior_email_no_allowlist_returns_ignore(monkeypatch): + _clear_auth_env(monkeypatch) + + config = GatewayConfig( + platforms={Platform.EMAIL: PlatformConfig(enabled=True)}, + ) + runner, _adapter = _make_runner(Platform.EMAIL, config) + + behavior = runner._get_unauthorized_dm_behavior(Platform.EMAIL) + assert behavior == "ignore" + + def test_qqbot_with_allowlist_ignores_unauthorized_dm(monkeypatch): """QQBOT is included in the allowlist-aware default (QQ_ALLOWED_USERS). diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index d8796ae42f5..4208868cbc4 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1618,8 +1618,9 @@ whatsapp: unauthorized_dm_behavior: ignore ``` -- `pair` is the default. Hermes denies access, but replies with a one-time pairing code in DMs. +- `pair` is the default for chat-style DM platforms. Hermes denies access, but replies with a one-time pairing code in DMs. - `ignore` silently drops unauthorized DMs. +- Email defaults to `ignore` unless `platforms.email.unauthorized_dm_behavior: pair` is set, because inboxes can contain unrelated unread mail. - Platform sections override the global default, so you can keep pairing enabled broadly while making one platform quieter. ## Quick Commands diff --git a/website/docs/user-guide/messaging/email.md b/website/docs/user-guide/messaging/email.md index d67307be771..eabde5da496 100644 --- a/website/docs/user-guide/messaging/email.md +++ b/website/docs/user-guide/messaging/email.md @@ -142,14 +142,15 @@ When enabled, attachment and inline parts are skipped before payload decoding. T ## Access Control -Email access follows the same pattern as all other Hermes platforms: +Email access is stricter by default than chat-style platforms: 1. **`EMAIL_ALLOWED_USERS` set** → only emails from those addresses are processed -2. **No allowlist set** → unknown senders get a pairing code +2. **No allowlist set** → unknown senders are ignored silently 3. **`EMAIL_ALLOW_ALL_USERS=true`** → any sender is accepted (use with caution) +4. **`platforms.email.unauthorized_dm_behavior: pair`** → unknown senders receive a pairing code :::warning -**Always configure `EMAIL_ALLOWED_USERS`.** Without it, anyone who knows the agent's email address could send commands. The agent has terminal access by default. +**Use a dedicated inbox and configure `EMAIL_ALLOWED_USERS` for normal operation.** Email pairing is opt-in because shared inboxes often contain unrelated unread messages, and Hermes should not reply to those contacts by default. ::: --- diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index f6fda312ef5..289d2eaece4 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -237,7 +237,7 @@ GATEWAY_ALLOW_ALL_USERS=true ### DM Pairing (Alternative to Allowlists) -Instead of manually configuring user IDs, unknown users receive a one-time pairing code when they DM the bot: +Instead of manually configuring user IDs, unknown users receive a one-time pairing code when they DM the bot. Email is the exception: unknown email senders are ignored unless email pairing is explicitly enabled. ```bash # The user sees: "Pairing code: XKGH5N7P" diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index 5de9497f696..c48c6db6b9d 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -272,8 +272,9 @@ whatsapp: unauthorized_dm_behavior: ignore ``` -- `pair` is the default. Unauthorized DMs get a pairing code reply. +- `pair` is the default for chat-style DM platforms. Unauthorized DMs get a pairing code reply. - `ignore` silently drops unauthorized DMs. +- Email defaults to `ignore` unless `platforms.email.unauthorized_dm_behavior: pair` is set, because inboxes can contain unrelated unread mail. - Platform sections override the global default, so you can keep pairing on Telegram while keeping WhatsApp silent. **Security features** (based on OWASP + NIST SP 800-63-4 guidance): From 5dae502b863f002c0816d7840728d1df26cd35ea Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Thu, 18 Jun 2026 17:21:43 +1000 Subject: [PATCH 460/470] Address email pairing review feedback --- gateway/authz_mixin.py | 25 ++++++++++++++----------- gateway/config.py | 7 ++++++- hermes_cli/config.py | 28 ++++++++++++++++++++++++++++ hermes_cli/gateway.py | 14 ++------------ hermes_cli/web_server.py | 13 ++----------- tests/hermes_cli/test_config.py | 19 +++++++++++++++++++ 6 files changed, 71 insertions(+), 35 deletions(-) diff --git a/gateway/authz_mixin.py b/gateway/authz_mixin.py index 70632d78cb3..bcefb4eecb4 100644 --- a/gateway/authz_mixin.py +++ b/gateway/authz_mixin.py @@ -457,17 +457,19 @@ class GatewayAuthorizationMixin: Resolution order: 1. Explicit per-platform ``unauthorized_dm_behavior`` in config — always wins. - 2. Explicit global ``unauthorized_dm_behavior`` in config — wins when no per-platform. - 3. Email defaults to ``"ignore"`` unless explicitly opted into + 2. Email defaults to ``"ignore"`` unless explicitly opted into pairing. Inboxes may contain arbitrary unread human messages, so replying with pairing codes is not a safe platform default. - 4. When an allowlist (``PLATFORM_ALLOWED_USERS``, + 3. Explicit global ``unauthorized_dm_behavior`` in config — wins for + chat-shaped platforms when no per-platform override is set. + 4. When an adapter-level DM policy opts into pairing or silent drop, honor it. + 5. When an allowlist (``PLATFORM_ALLOWED_USERS``, ``PLATFORM_GROUP_ALLOWED_USERS`` / ``PLATFORM_GROUP_ALLOWED_CHATS``, or ``GATEWAY_ALLOWED_USERS``) is configured, default to ``"ignore"`` — the allowlist signals that the owner has deliberately restricted access; spamming unknown contacts with pairing codes is both noisy and a potential info-leak. (#9337) - 5. No allowlist and no explicit config → ``"pair"`` (open-gateway default). + 6. No allowlist and no explicit config → ``"pair"`` (open-gateway default). """ config = getattr(self, "config", None) @@ -478,6 +480,14 @@ class GatewayAuthorizationMixin: # Operator explicitly configured behavior for this platform — respect it. return config.get_unauthorized_dm_behavior(platform) + # Email is inbox-shaped, not chat-shaped: an agent mailbox may contain + # unrelated unread human email. Require an explicit per-platform + # ``unauthorized_dm_behavior: pair`` opt-in before replying to unknown + # senders with pairing codes. Keep this before the global fallback to + # match GatewayConfig.get_unauthorized_dm_behavior(). + if platform == Platform.EMAIL: + return "ignore" + # Check for an explicit global config override. if config and hasattr(config, "unauthorized_dm_behavior"): if config.unauthorized_dm_behavior != "pair": # non-default → explicit override @@ -497,13 +507,6 @@ class GatewayAuthorizationMixin: if dm_policy in {"allowlist", "disabled"}: return "ignore" - # Email is inbox-shaped, not chat-shaped: an agent mailbox may contain - # unrelated unread human email. Require an explicit per-platform - # ``unauthorized_dm_behavior: pair`` opt-in before replying to unknown - # senders with pairing codes. - if platform == Platform.EMAIL: - return "ignore" - # No explicit override. Fall back to allowlist-aware default: # if any allowlist is configured for this platform, silently drop # unauthorized messages instead of sending pairing codes. diff --git a/gateway/config.py b/gateway/config.py index 6b474a34038..e1556b37d52 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -749,7 +749,12 @@ class GatewayConfig: ) def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str: - """Return the effective unauthorized-DM behavior for a platform.""" + """Return the effective unauthorized-DM behavior for a platform. + + Email is inbox-shaped, not chat-shaped, so it defaults to ``"ignore"`` + unless ``platforms.email.unauthorized_dm_behavior`` explicitly opts + into pairing. A global default does not opt email into pairing. + """ if platform: platform_cfg = self.platforms.get(platform) if platform_cfg and "unauthorized_dm_behavior" in platform_cfg.extra: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 49f516da15d..ee03744a45e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5636,6 +5636,34 @@ def load_config_readonly() -> Dict[str, Any]: return _load_config_impl(want_deepcopy=False) +def write_platform_config_field( + platform_key: str, + field_key: str, + value: Any, + *, + raw: bool = False, +) -> None: + """Persist one scalar field under ``platforms.``. + + ``raw=True`` preserves CLI setup flows that intentionally edit only the + user's raw config file. Dashboard routes use the default loaded-config path + so they retain their existing profile-scoped ``load_config`` behavior. + """ + config = read_raw_config() if raw else load_config() + platforms = config.setdefault("platforms", {}) + if not isinstance(platforms, dict): + platforms = {} + config["platforms"] = platforms + + platform_config = platforms.setdefault(platform_key, {}) + if not isinstance(platform_config, dict): + platform_config = {} + platforms[platform_key] = platform_config + + platform_config[field_key] = value + save_config(config) + + TERMINAL_CONFIG_ENV_MAP = { "backend": "TERMINAL_ENV", "modal_mode": "TERMINAL_MODAL_MODE", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index b68f48476cc..03435eac028 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -30,8 +30,8 @@ from hermes_cli.config import ( is_managed, managed_error, read_raw_config, - save_config, save_env_value, + write_platform_config_field, ) # display_hermes_home is imported lazily at call sites to avoid ImportError @@ -4648,17 +4648,7 @@ def _runtime_health_lines() -> list[str]: def _set_platform_unauthorized_dm_behavior(platform_key: str, behavior: str) -> None: """Persist a platform-specific unauthorized-DM policy in config.yaml.""" - cfg = read_raw_config() - platforms = cfg.setdefault("platforms", {}) - if not isinstance(platforms, dict): - platforms = {} - cfg["platforms"] = platforms - platform_cfg = platforms.setdefault(platform_key, {}) - if not isinstance(platform_cfg, dict): - platform_cfg = {} - platforms[platform_key] = platform_cfg - platform_cfg["unauthorized_dm_behavior"] = behavior - save_config(cfg) + write_platform_config_field(platform_key, "unauthorized_dm_behavior", behavior, raw=True) def _setup_standard_platform(platform: dict): diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 4227e621113..f869a2a43ae 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -62,6 +62,7 @@ from hermes_cli.config import ( format_docker_update_message, recommended_update_command_for_method, redact_key, + write_platform_config_field, ) from hermes_cli.memory_providers import ( MemoryProvider, @@ -5006,17 +5007,7 @@ def _messaging_platform_payload( def _write_platform_enabled(platform_id: str, enabled: bool) -> None: - config = load_config() - platforms = config.setdefault("platforms", {}) - if not isinstance(platforms, dict): - platforms = {} - config["platforms"] = platforms - platform_config = platforms.setdefault(platform_id, {}) - if not isinstance(platform_config, dict): - platform_config = {} - platforms[platform_id] = platform_config - platform_config["enabled"] = enabled - save_config(config) + write_platform_config_field(platform_id, "enabled", enabled) _TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com" diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 5235a1bd205..b6c82636892 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -21,6 +21,7 @@ from hermes_cli.config import ( save_env_value, save_env_value_secure, sanitize_env_file, + write_platform_config_field, _sanitize_env_lines, ) @@ -255,6 +256,24 @@ class TestSaveAndLoadRoundtrip: reloaded = load_config() assert reloaded["terminal"]["timeout"] == 999 + def test_write_platform_config_field_coerces_nested_platform_maps(self, tmp_path): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + (tmp_path / "config.yaml").write_text( + "model: test/custom-model\nplatforms: not-a-map\n", + encoding="utf-8", + ) + + write_platform_config_field( + "email", + "unauthorized_dm_behavior", + "pair", + raw=True, + ) + + saved = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + assert saved["model"] == "test/custom-model" + assert saved["platforms"]["email"]["unauthorized_dm_behavior"] == "pair" + class TestSaveEnvValueSecure: def test_save_env_value_writes_without_stdout(self, tmp_path, capsys): From b9b4756ab4805437003b55127c369dc18ce22b3b Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Mon, 22 Jun 2026 12:56:02 +1000 Subject: [PATCH 461/470] fix dashboard chat session titles --- tests/test_tui_gateway_server.py | 20 ++++ tui_gateway/server.py | 24 ++++- web/src/components/ChatSidebar.tsx | 163 +++++++++++++++-------------- web/src/lib/api.ts | 4 + web/src/lib/chat-title.test.ts | 35 +++++++ web/src/lib/chat-title.ts | 15 +++ web/src/pages/ChatPage.tsx | 60 ++++++++++- 7 files changed, 237 insertions(+), 84 deletions(-) create mode 100644 web/src/lib/chat-title.test.ts create mode 100644 web/src/lib/chat-title.ts diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 61c86d519f4..0c70557ce3a 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -2127,8 +2127,10 @@ def test_session_title_clears_pending_after_persist(monkeypatch): return True db = _FakeDB() + emitted = [] server._sessions["sid"] = _session(pending_title="stale") monkeypatch.setattr(server, "_get_db", lambda: db) + monkeypatch.setattr(server, "_emit", lambda *args: emitted.append(args)) try: resp = server.handle_request( { @@ -2141,6 +2143,8 @@ def test_session_title_clears_pending_after_persist(monkeypatch): assert resp["result"]["pending"] is False assert resp["result"]["title"] == "fresh" assert server._sessions["sid"]["pending_title"] is None + assert emitted[-1][0:2] == ("session.info", "sid") + assert emitted[-1][2]["title"] == "fresh" finally: server._sessions.pop("sid", None) @@ -4461,6 +4465,22 @@ def test_session_info_includes_mcp_servers(monkeypatch): assert info["mcp_servers"] == fake_status +def test_session_info_includes_session_title(monkeypatch): + class _FakeDB: + def get_session_title(self, key): + assert key == "session-key" + return "Dashboard title" + + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + + info = server._session_info( + types.SimpleNamespace(tools=[], model="test/model", provider="openai-codex"), + {"session_key": "session-key", "history": []}, + ) + + assert info["title"] == "Dashboard title" + + # --------------------------------------------------------------------------- # History-mutating commands must reject while session.running is True. # Without these guards, prompt.submit's post-run history write either diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7a63aec263c..c024cc97d89 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2696,6 +2696,9 @@ def _session_info(agent, session: dict | None = None) -> dict: session = candidate break cwd = _session_cwd(session) + session_key = str( + (session or {}).get("session_key") or getattr(agent, "session_id", "") or "" + ) cfg_personality = ((_load_cfg().get("display") or {}).get("personality") or "") personality = (session or {}).get("personality", cfg_personality) reasoning_config = getattr(agent, "reasoning_config", None) @@ -2720,8 +2723,9 @@ def _session_info(agent, session: dict | None = None) -> dict: is_session_yolo_enabled, ) - session_key = (session or {}).get("session_key") - session_yolo = bool(is_session_yolo_enabled(session_key)) if session_key else False + session_yolo = ( + bool(is_session_yolo_enabled(session_key)) if session_key else False + ) yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off" except Exception: yolo = False @@ -2738,6 +2742,7 @@ def _session_info(agent, session: dict | None = None) -> dict: "branch": _git_branch_for_cwd(cwd), "personality": str(personality or ""), "running": bool((session or {}).get("running")), + "title": _session_live_title(session or {}, session_key) if session_key else "", "desktop_contract": DESKTOP_BACKEND_CONTRACT, "version": "", "release_date": "", @@ -2802,6 +2807,16 @@ def _tool_ctx(name: str, args: dict) -> str: return "" +def _emit_session_info_for_session(sid: str, session: dict) -> None: + agent = session.get("agent") + if agent is None: + return + try: + _emit("session.info", sid, _session_info(agent, session)) + except Exception: + pass + + # Tool Args/Result text shipped to the TUI for the verbose trail line. The TUI # renders only a small persisted preview (ui-tui VERBOSE_TRAIL_MAX_CHARS), kept # all session and expanded by default — so shipping more than that is pure pipe @@ -5097,6 +5112,7 @@ def _(rid, params: dict) -> dict: session["pending_title"] = None except Exception: resolved_title = fallback + _emit_session_info_for_session(params.get("session_id", ""), session) return _ok( rid, { @@ -5110,11 +5126,13 @@ def _(rid, params: dict) -> dict: try: if db.set_session_title(key, title): session["pending_title"] = None + _emit_session_info_for_session(params.get("session_id", ""), session) return _ok(rid, {"pending": False, "title": title}) # rowcount == 0 can mean "same value" as well as "missing row". existing_row = db.get_session(key) if existing_row: session["pending_title"] = None + _emit_session_info_for_session(params.get("session_id", ""), session) return _ok( rid, { @@ -5136,10 +5154,12 @@ def _(rid, params: dict) -> dict: with _session_db(session) as scoped_db: if scoped_db is not None and scoped_db.set_session_title(key, title): session["pending_title"] = None + _emit_session_info_for_session(params.get("session_id", ""), session) return _ok(rid, {"pending": False, "title": title}) # Row creation didn't take (DB unavailable, or a concurrent writer) — # fall back to queuing so the post-turn apply block can still recover. session["pending_title"] = title + _emit_session_info_for_session(params.get("session_id", ""), session) return _ok(rid, {"pending": True, "title": title}) except ValueError as e: return _err(rid, 4022, str(e)) diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index c70f74d65bb..7bb71eb337c 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -34,6 +34,7 @@ import { ReasoningPicker } from "@/components/ReasoningPicker"; import { ToolCall, type ToolEntry } from "@/components/ToolCall"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; import { api, HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; +import { titleFromSessionInfoPayload } from "@/lib/chat-title"; import { cn } from "@/lib/utils"; import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; @@ -44,6 +45,7 @@ interface SessionInfo { model?: string; provider?: string; credential_warning?: string; + title?: string; } interface RpcEnvelope { @@ -78,6 +80,7 @@ interface ChatSidebarProps { profile?: string; className?: string; onDashboardNewSessionRequest?: () => void; + onSessionTitleChange?: (title: string | null) => void; /** * Render the tool-call activity card. Defaults to true. The dashboard Chat * tab sets this false so the right rail stays a thin model + session-list @@ -91,6 +94,7 @@ export function ChatSidebar({ profile, className, onDashboardNewSessionRequest, + onSessionTitleChange, showTools = true, }: ChatSidebarProps) { // `version` bumps on reconnect; gw is derived so we never call setState @@ -266,91 +270,96 @@ export function ChatSidebar({ }); ws.addEventListener("message", (ev) => { - let frame: RpcEnvelope; + let frame: RpcEnvelope; - try { - frame = JSON.parse(ev.data); - } catch { - return; - } - - if (frame.method !== "event" || !frame.params) { - return; - } - - const { type, payload } = frame.params; - - if (type === "dashboard.new_session_requested") { - onDashboardNewSessionRequest?.(); - } else if (type === "tool.start") { - const p = payload as - | { tool_id?: string; name?: string; context?: string } - | undefined; - const toolId = p?.tool_id; - - if (!toolId) { + try { + frame = JSON.parse(ev.data); + } catch { return; } - setTools((prev) => - [ - ...prev, - { - kind: "tool" as const, - id: `tool-${toolId}-${prev.length}`, - tool_id: toolId, - name: p?.name ?? "tool", - context: p?.context, - status: "running" as const, - startedAt: Date.now(), - }, - ].slice(-TOOL_LIMIT), - ); - } else if (type === "tool.progress") { - const p = payload as - | { name?: string; preview?: string } - | undefined; - - if (!p?.name || !p.preview) { + if (frame.method !== "event" || !frame.params) { return; } - setTools((prev) => - prev.map((t) => - t.status === "running" && t.name === p.name - ? { ...t, preview: p.preview } - : t, - ), - ); - } else if (type === "tool.complete") { - const p = payload as - | { - tool_id?: string; - summary?: string; - error?: string; - inline_diff?: string; - } - | undefined; + const { type, payload } = frame.params; - if (!p?.tool_id) { - return; + if (type === "session.info") { + const title = titleFromSessionInfoPayload(payload); + if (title !== undefined) { + onSessionTitleChange?.(title); + } + } else if (type === "dashboard.new_session_requested") { + onDashboardNewSessionRequest?.(); + } else if (type === "tool.start") { + const p = payload as + | { tool_id?: string; name?: string; context?: string } + | undefined; + const toolId = p?.tool_id; + + if (!toolId) { + return; + } + + setTools((prev) => + [ + ...prev, + { + kind: "tool" as const, + id: `tool-${toolId}-${prev.length}`, + tool_id: toolId, + name: p?.name ?? "tool", + context: p?.context, + status: "running" as const, + startedAt: Date.now(), + }, + ].slice(-TOOL_LIMIT), + ); + } else if (type === "tool.progress") { + const p = payload as + | { name?: string; preview?: string } + | undefined; + + if (!p?.name || !p.preview) { + return; + } + + setTools((prev) => + prev.map((t) => + t.status === "running" && t.name === p.name + ? { ...t, preview: p.preview } + : t, + ), + ); + } else if (type === "tool.complete") { + const p = payload as + | { + tool_id?: string; + summary?: string; + error?: string; + inline_diff?: string; + } + | undefined; + + if (!p?.tool_id) { + return; + } + + setTools((prev) => + prev.map((t) => + t.tool_id === p.tool_id + ? { + ...t, + status: p.error ? "error" : "done", + summary: p.summary, + error: p.error, + inline_diff: p.inline_diff, + completedAt: Date.now(), + } + : t, + ), + ); } - - setTools((prev) => - prev.map((t) => - t.tool_id === p.tool_id - ? { - ...t, - status: p.error ? "error" : "done", - summary: p.summary, - error: p.error, - inline_diff: p.inline_diff, - completedAt: Date.now(), - } - : t, - ), - ); - } }); })(); @@ -358,7 +367,7 @@ export function ChatSidebar({ unmounting = true; ws?.close(); }; - }, [channel, onDashboardNewSessionRequest, version]); + }, [channel, onDashboardNewSessionRequest, onSessionTitleChange, version]); // Seed the badge on mount and re-read it whenever the sockets are rebuilt // (a profile/channel switch bumps `version`). diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ba898924196..c154243bd80 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -360,6 +360,10 @@ export const api = { fetchJSON( appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/messages`, profile), ), + getSessionDetail: (id: string, profile = getManagementProfile()) => + fetchJSON( + appendProfileParam(`/api/sessions/${encodeURIComponent(id)}`, profile), + ), getSessionLatestDescendant: (id: string) => fetchJSON( `/api/sessions/${encodeURIComponent(id)}/latest-descendant`, diff --git a/web/src/lib/chat-title.test.ts b/web/src/lib/chat-title.test.ts new file mode 100644 index 00000000000..b3fb1f51f59 --- /dev/null +++ b/web/src/lib/chat-title.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeSessionTitle, titleFromSessionInfoPayload } from "./chat-title"; + +describe("normalizeSessionTitle", () => { + it("trims non-empty session titles", () => { + expect(normalizeSessionTitle(" Rename the dashboard ")).toBe( + "Rename the dashboard", + ); + }); + + it("treats blank and non-string values as no title", () => { + expect(normalizeSessionTitle(" ")).toBeNull(); + expect(normalizeSessionTitle(null)).toBeNull(); + expect(normalizeSessionTitle(42)).toBeNull(); + }); +}); + +describe("titleFromSessionInfoPayload", () => { + it("returns undefined when the payload has no title field", () => { + expect(titleFromSessionInfoPayload({ model: "test/model" })).toBeUndefined(); + expect(titleFromSessionInfoPayload(null)).toBeUndefined(); + }); + + it("returns null when the title field is present but empty", () => { + expect(titleFromSessionInfoPayload({ title: "" })).toBeNull(); + expect(titleFromSessionInfoPayload({ title: " " })).toBeNull(); + }); + + it("returns the normalized title when present", () => { + expect(titleFromSessionInfoPayload({ title: " Live session title " })).toBe( + "Live session title", + ); + }); +}); diff --git a/web/src/lib/chat-title.ts b/web/src/lib/chat-title.ts new file mode 100644 index 00000000000..c6cebebcf7f --- /dev/null +++ b/web/src/lib/chat-title.ts @@ -0,0 +1,15 @@ +export function normalizeSessionTitle(raw: unknown): string | null { + if (typeof raw !== "string") return null; + const title = raw.trim(); + return title ? title : null; +} + +export function titleFromSessionInfoPayload( + payload: unknown, +): string | null | undefined { + if (!payload || typeof payload !== "object" || !("title" in payload)) { + return undefined; + } + + return normalizeSessionTitle((payload as { title?: unknown }).title); +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 2a135ed1a57..0820ae82d34 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -36,6 +36,7 @@ import { ChatSessionList } from "@/components/ChatSessionList"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { api } from "@/lib/api"; +import { normalizeSessionTitle } from "@/lib/chat-title"; import { PluginSlot } from "@/plugins"; import { useTheme } from "@/themes"; import { useProfileScope } from "@/contexts/useProfileScope"; @@ -63,11 +64,14 @@ function buildWsUrl( // (subscriber). Generated once per mount so a tab refresh starts a fresh // channel — the previous PTY child terminates with the old WS, and its // channel auto-evicts when no subscribers remain. -function generateChannelId(): string { +function generateChannelId(scope?: string): string { + const prefix = scope ? "chat" : "chat-fresh"; if (typeof crypto !== "undefined" && "randomUUID" in crypto) { - return crypto.randomUUID(); + return `${prefix}-${crypto.randomUUID()}`; } - return `chat-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; + return `${prefix}-${Math.random().toString(36).slice(2)}-${Date.now().toString( + 36, + )}`; } // Colors for the terminal body. Matches the dashboard's dark teal canvas @@ -173,7 +177,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // tabs because the dep wouldn't change on tab switch. const [mobilePanelOpenRaw, setMobilePanelOpenRaw] = useState(false); const mobilePanelOpen = isActive && mobilePanelOpenRaw; - const { setEnd } = usePageHeader(); + const { setEnd, setTitle } = usePageHeader(); + const [sessionTitleState, setSessionTitleState] = useState<{ + scope: string; + title: string | null; + }>({ scope: "", title: null }); const { t } = useI18n(); const closeMobilePanel = useCallback(() => setMobilePanelOpenRaw(false), []); const modelToolsLabel = useMemo( @@ -207,7 +215,47 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // management profile. Changing it remounts the terminal (key below / // effect dep) so the user explicitly starts a fresh scoped session. const { profile: scopedProfile } = useProfileScope(); - const channel = useMemo(() => generateChannelId(), [resumeParam, scopedProfile]); + const channel = useMemo( + () => generateChannelId(`${resumeParam ?? ""}\0${scopedProfile}`), + [resumeParam, scopedProfile], + ); + const titleScope = `${channel}\0${reconnectNonce}`; + const sessionTitle = + sessionTitleState.scope === titleScope ? sessionTitleState.title : null; + const handleSessionTitleChange = useCallback( + (title: string | null) => setSessionTitleState({ scope: titleScope, title }), + [titleScope], + ); + + useEffect(() => { + if (!isActive) { + setTitle(null); + return; + } + + setTitle(sessionTitle); + return () => setTitle(null); + }, [isActive, sessionTitle, setTitle]); + + useEffect(() => { + if (!resumeParam) return; + + let cancelled = false; + + api + .getSessionDetail(resumeParam, scopedProfile) + .then((session) => { + if (cancelled) return; + handleSessionTitleChange(normalizeSessionTitle(session.title)); + }) + .catch(() => { + // Best-effort: the PTY-side session.info stream can still supply it. + }); + + return () => { + cancelled = true; + }; + }, [resumeParam, scopedProfile, handleSessionTitleChange]); useEffect(() => { if (!resumeParam) return; @@ -896,6 +944,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { channel={channel} profile={scopedProfile} onDashboardNewSessionRequest={startFreshDashboardChat} + onSessionTitleChange={handleSessionTitleChange} showTools={false} />
@@ -995,6 +1044,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { channel={channel} profile={scopedProfile} onDashboardNewSessionRequest={startFreshDashboardChat} + onSessionTitleChange={handleSessionTitleChange} showTools={false} />
From 5ff11a689b561fdb1404aede3fafa543bbbb86bf Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:44:25 -0700 Subject: [PATCH 462/470] feat(cli): /timestamps command + timestamps in /history (#50506) display.timestamps already drove the [HH:MM] suffix on live submitted and streamed message labels, but there was no runtime command to toggle it and /history ignored the setting entirely. Add /timestamps [on|off|status] (alias /ts) and render [HH:MM] in /history for turns that carry a stored unix timestamp (resumed sessions). Live unsaved turns without a stored time are never given a fabricated one. Uses the existing sanctioned non-wire 'timestamp' message key (stripped before the API call in chat_completions), so message-alternation and prompt-cache invariants are untouched. --- cli.py | 22 ++++- hermes_cli/cli_commands_mixin.py | 50 +++++++++++ hermes_cli/commands.py | 3 + tests/hermes_cli/test_timestamps_command.py | 98 +++++++++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_timestamps_command.py diff --git a/cli.py b/cli.py index 6ee25e2fcec..ad0a5050aa2 100644 --- a/cli.py +++ b/cli.py @@ -6216,6 +6216,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): preview_limit = 400 visible_index = 0 hidden_tool_messages = 0 + show_ts = bool(getattr(self, "show_timestamps", False)) + + def _ts_suffix(message: dict) -> str: + # Messages restored from SessionDB carry a unix `timestamp`; live + # unsaved turns may not. Only annotate when both the toggle is on + # and the turn actually has a stored time — never fabricate one. + if not show_ts: + return "" + ts = message.get("timestamp") + if not ts: + return "" + try: + from datetime import datetime + return f" [{datetime.fromtimestamp(float(ts)).strftime('%H:%M')}]" + except (ValueError, OSError, TypeError): + return "" def flush_tool_summary(): nonlocal hidden_tool_messages @@ -6249,13 +6265,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): content_text = "" if content is None else str(content) if role == "user": - print(f"\n [You #{visible_index}]") + print(f"\n [You #{visible_index}]{_ts_suffix(msg)}") print( f" {content_text[:preview_limit]}{'...' if len(content_text) > preview_limit else ''}" ) continue - print(f"\n [Hermes #{visible_index}]") + print(f"\n [Hermes #{visible_index}]{_ts_suffix(msg)}") tool_calls = msg.get("tool_calls") or [] if content_text: preview = content_text[:preview_limit] @@ -7978,6 +7994,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._status_bar_visible = not self._status_bar_visible state = "visible" if self._status_bar_visible else "hidden" self._console_print(f" Status bar {state}") + elif canonical == "timestamps": + self._handle_timestamps_command(cmd_original) elif canonical == "verbose": self._toggle_verbose() elif canonical == "footer": diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index d93897d2609..831cde7c85b 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2086,6 +2086,56 @@ class CLICommandsMixin: else: _cprint(" Failed to save runtime_footer setting to config.yaml") + def _handle_timestamps_command(self, cmd_original: str) -> None: + """Toggle or inspect ``display.timestamps`` from the CLI. + + When on, submitted and streamed message labels carry an ``[HH:MM]`` + suffix and ``/history`` prefixes each turn with its time (for turns + that carry a stored timestamp). + + Usage: + /timestamps → toggle + /timestamps on|off → explicit + /timestamps status → show current state + """ + from cli import _cprint, save_config_value + from hermes_cli.colors import Colors as _Colors + + arg = "" + try: + parts = (cmd_original or "").strip().split(None, 1) + if len(parts) > 1: + arg = parts[1].strip().lower() + except Exception: + arg = "" + + current = bool(getattr(self, "show_timestamps", False)) + + if arg in {"status", "?"}: + state = "ON" if current else "OFF" + _cprint(f" {_Colors.BOLD}Message timestamps:{_Colors.RESET} {state}") + return + + if arg in {"on", "enable", "true", "1"}: + new_state = True + elif arg in {"off", "disable", "false", "0"}: + new_state = False + elif arg == "": + new_state = not current + else: + _cprint(" Usage: /timestamps [on|off|status]") + return + + self.show_timestamps = new_state + if save_config_value("display.timestamps", new_state): + state = ( + f"{_Colors.GREEN}ON{_Colors.RESET}" if new_state + else f"{_Colors.DIM}OFF{_Colors.RESET}" + ) + _cprint(f" Message timestamps: {state}") + else: + _cprint(" Failed to save timestamps setting to config.yaml") + def _handle_reasoning_command(self, cmd: str): """Handle /reasoning — manage effort level and display toggle. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index d5cc9cee8c1..d9d9d1b3579 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -135,6 +135,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[name]"), CommandDef("statusbar", "Toggle the context/model status bar", "Configuration", cli_only=True, aliases=("sb",)), + CommandDef("timestamps", "Toggle [HH:MM] timestamps on messages and /history", "Configuration", + cli_only=True, args_hint="[on|off|status]", + subcommands=("on", "off", "status"), aliases=("ts",)), CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose", "Configuration", cli_only=True, gateway_config_gate="display.tool_progress_command"), diff --git a/tests/hermes_cli/test_timestamps_command.py b/tests/hermes_cli/test_timestamps_command.py new file mode 100644 index 00000000000..79784e85f87 --- /dev/null +++ b/tests/hermes_cli/test_timestamps_command.py @@ -0,0 +1,98 @@ +"""Tests for the CLI `/timestamps` toggle and timestamps in `/history`. + +`display.timestamps` already drove the live `[HH:MM]` label suffix on +submitted/streamed messages but had no runtime toggle and `/history` +ignored it. These assert the new `/timestamps` command flips and persists +the flag and that `/history` renders `[HH:MM]` only for turns that carry a +stored unix `timestamp` (never fabricating one for live unsaved turns). +""" + +import io +import sys +import time +from datetime import datetime + +import yaml + +from hermes_cli.cli_commands_mixin import CLICommandsMixin + + +class _Stub(CLICommandsMixin): + def __init__(self): + self.show_timestamps = False + + +def _seed(tmp_path, monkeypatch, value=False): + hh = tmp_path / ".hermes" + hh.mkdir() + (hh / "config.yaml").write_text(f"display:\n timestamps: {str(value).lower()}\n") + monkeypatch.setenv("HERMES_HOME", str(hh)) + import cli + + monkeypatch.setattr(cli, "_hermes_home", hh, raising=False) + return hh + + +def test_timestamps_on_sets_and_persists(tmp_path, monkeypatch): + hh = _seed(tmp_path, monkeypatch) + s = _Stub() + s._handle_timestamps_command("/timestamps on") + assert s.show_timestamps is True + assert yaml.safe_load((hh / "config.yaml").read_text())["display"]["timestamps"] is True + + +def test_timestamps_bare_toggles(tmp_path, monkeypatch): + _seed(tmp_path, monkeypatch) + s = _Stub() + s.show_timestamps = True + s._handle_timestamps_command("/timestamps") + assert s.show_timestamps is False + + +def test_timestamps_status_is_noop(tmp_path, monkeypatch): + _seed(tmp_path, monkeypatch) + s = _Stub() + s.show_timestamps = True + s._handle_timestamps_command("/timestamps status") + assert s.show_timestamps is True + + +def _render_history(history, show_ts): + from cli import HermesCLI + + h = HermesCLI.__new__(HermesCLI) + h.show_timestamps = show_ts + h.conversation_history = history + h._show_recent_sessions = lambda reason="history", limit=10: True + buf = io.StringIO() + old = sys.stdout + sys.stdout = buf + try: + h.show_history() + finally: + sys.stdout = old + return buf.getvalue() + + +def test_history_shows_timestamp_for_stored_turns(): + ts = time.time() + hist = [ + {"role": "user", "content": "hello", "timestamp": ts}, + {"role": "assistant", "content": "hi", "timestamp": ts + 60}, + {"role": "user", "content": "live turn, no ts"}, + ] + out = _render_history(hist, show_ts=True) + hhmm = datetime.fromtimestamp(ts).strftime("%H:%M") + assert f"[You #1] [{hhmm}]" in out + assert "[Hermes #2] [" in out + # a turn with no stored timestamp must NOT get a fabricated time + assert "[You #3]\n" in out + + +def test_history_hides_timestamps_when_off(): + ts = time.time() + hist = [{"role": "user", "content": "hello", "timestamp": ts}] + out = _render_history(hist, show_ts=False) + # label present, no [HH:MM] suffix + first_label_line = out.split("[You #1]")[1].split("\n")[0] + assert "[" not in first_label_line From 47b6b4cf857ba627070f2ae22cfa4c124c900ca1 Mon Sep 17 00:00:00 2001 From: David Gutowsky Date: Sat, 20 Jun 2026 03:02:04 +0000 Subject: [PATCH 463/470] fix #39550: detect token-only compression success Compression can materially reduce request size (tool-result pruning, in-place summarization) without reducing message count. The two compression-success checks in conversation_loop.py (413 handler and context-overflow handler) only compared len(messages) to detect success, missing token-only compression. Now re-estimates tokens after compress_context() returns and treats any >=5% reduction as a successful compression pass. Error logs also use the post-compression token count instead of the stale pre-compression estimate. Fixes: #39550 --- agent/conversation_loop.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 8726ba9bd26..421629b4b03 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -2983,6 +2983,7 @@ def run_conversation( agent._buffer_status(f"⚠️ Request payload too large (413) — compression attempt {compression_attempts}/{max_compression_attempts}...") original_len = len(messages) + original_tokens = estimate_messages_tokens_rough(messages) messages, active_system_prompt = agent._compress_context( messages, system_message, approx_tokens=approx_tokens, task_id=effective_task_id, @@ -2992,8 +2993,18 @@ def run_conversation( # messages to the new session, not skipping them. conversation_history = None - if len(messages) < original_len: - agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...") + # Re-estimate tokens after compression. Same-message-count + # compression (tool-result pruning, in-place summarization) + # can materially reduce request size without reducing the + # message array. (#39550) + new_tokens = estimate_messages_tokens_rough(messages) + approx_tokens = new_tokens # update for downstream logging + + if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95): + if len(messages) < original_len: + agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...") + else: + agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...") time.sleep(2) # Brief pause between compression retries _retry.restart_with_compressed_messages = True break @@ -3139,6 +3150,7 @@ def run_conversation( agent._buffer_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...") original_len = len(messages) + original_tokens = estimate_messages_tokens_rough(messages) messages, active_system_prompt = agent._compress_context( messages, system_message, approx_tokens=approx_tokens, task_id=effective_task_id, @@ -3148,9 +3160,18 @@ def run_conversation( # messages to the new session, not skipping them. conversation_history = None - if len(messages) < original_len or new_ctx and new_ctx < old_ctx: + # Re-estimate tokens after compression. Same-message-count + # compression (tool-result pruning, in-place summarization) + # can materially reduce request size without reducing the + # message array. (#39550) + new_tokens = estimate_messages_tokens_rough(messages) + approx_tokens = new_tokens # update for downstream logging + + if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95) or (new_ctx and new_ctx < old_ctx): if len(messages) < original_len: agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...") + else: + agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...") time.sleep(2) # Brief pause between compression retries _retry.restart_with_compressed_messages = True break @@ -3159,13 +3180,13 @@ def run_conversation( agent._flush_status_buffer() agent._vprint(f"{agent.log_prefix}❌ Context length exceeded and cannot compress further.", force=True) agent._vprint(f"{agent.log_prefix} 💡 The conversation has accumulated too much content. Try /new to start fresh, or /compress to manually trigger compression.", force=True) - logger.error(f"{agent.log_prefix}Context length exceeded: {approx_tokens:,} tokens. Cannot compress further.") + logger.error(f"{agent.log_prefix}Context length exceeded: {new_tokens:,} tokens. Cannot compress further.") agent._persist_session(messages, conversation_history) return { "messages": messages, "completed": False, "api_calls": api_call_count, - "error": f"Context length exceeded ({approx_tokens:,} tokens). Cannot compress further.", + "error": f"Context length exceeded ({new_tokens:,} tokens). Cannot compress further.", "partial": True, "failed": True, "compression_exhausted": True, From 87b60ae49a9f9bb61fa57468e68344e4d4113a64 Mon Sep 17 00:00:00 2001 From: David Gutowsky Date: Sat, 20 Jun 2026 04:06:36 +0000 Subject: [PATCH 464/470] no-mistakes(review): guard token-delta status msg on actual compression in overflow handler --- agent/conversation_loop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 421629b4b03..bbc379adf25 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -3170,7 +3170,7 @@ def run_conversation( if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95) or (new_ctx and new_ctx < old_ctx): if len(messages) < original_len: agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...") - else: + elif new_tokens > 0 and new_tokens < original_tokens * 0.95: agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...") time.sleep(2) # Brief pause between compression retries _retry.restart_with_compressed_messages = True From ebd38e12807ded8514d20c6699d880598a903c9f Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:26:29 +0530 Subject: [PATCH 465/470] test(agent): regression for token-only compression progress (#39550, #23767) Adds test_413_retries_on_token_only_compression: same message count but materially fewer tokens after compaction must count as progress and retry, not abort. Fails on main without the salvaged fix, passes with it. --- tests/run_agent/test_413_compression.py | 42 +++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py index 4801e48eda3..48ce2636c56 100644 --- a/tests/run_agent/test_413_compression.py +++ b/tests/run_agent/test_413_compression.py @@ -440,6 +440,48 @@ class TestHTTP413Compression: assert result.get("partial") is True assert "413" in result["error"] + def test_413_retries_on_token_only_compression(self, agent): + """Same message COUNT but fewer TOKENS must count as progress and retry. + + Regression for #39550/#23767: tool-result pruning / in-place + summarization can shrink request size without dropping the message + count. The old gate (len(messages) < original_len) treated that as + 'cannot compress further' and aborted; the fix re-estimates tokens and + retries when they drop materially. + """ + err_413 = _make_413_error() + ok_resp = _mock_response(content="OK after token-only compaction", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [err_413, ok_resp] + + # 3 large messages in, 3 much smaller messages out (same count, far + # fewer tokens) — exactly the token-only-progress case. + prefill = [ + {"role": "user", "content": "x" * 4000}, + {"role": "assistant", "content": "y" * 4000}, + {"role": "user", "content": "z" * 4000}, + ] + + with ( + patch.object(agent, "_compress_context") as mock_compress, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + # Same message count (3) but ~10x smaller content → token drop. + mock_compress.return_value = ( + [ + {"role": "user", "content": "x" * 300}, + {"role": "assistant", "content": "y" * 300}, + {"role": "user", "content": "z" * 300}, + ], + "compressed prompt", + ) + result = agent.run_conversation("hello", conversation_history=prefill) + + mock_compress.assert_called_once() + assert result["completed"] is True + assert result["final_response"] == "OK after token-only compaction" + class TestPreflightCompression: """Preflight compression should compress history before the first API call.""" From a61baa96157241c2e422fd85b3527bee14b41c62 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 22 Jun 2026 05:04:13 -0500 Subject: [PATCH 466/470] feat(desktop): PR-style file diffs in chat Render write_file/edit_file/patch as a reviewable diff instead of raw result JSON, closer to a Cursor/T3 per-edit review. - Unified diff via FileDiffPanel: strip git file-header + @@ hunk noise, drop the +/- gutter, color by line with a 2px gutter accent, full-bleed to the card, transparent context lines, compact scroll height. - Header shows filename + language icon + +N/-N stats; full path moves to a hover tooltip (no Edited verb, no ms). - Treat the three file-edit tools uniformly (isFileEditTool); read diff from inline_diff or patch's diff field; suppress raw-arg detail. - Reusable FileTypeIcon primitive sharing the code-block icon mapping (codiconForFilename), codicon fallback. - Per-row scaffolding fade (not the group wrapper, which trapped child opacity); expanded edits stay full, collapsed fade; keyboard-only focus lift. Hide diff-less rehydrated creates that read as dupes. --- .../assistant-ui/tool-fallback-model.test.ts | 55 +++++++- .../assistant-ui/tool-fallback-model.ts | 122 +++++++++++++++--- .../components/assistant-ui/tool-fallback.tsx | 104 ++++++++++++--- .../src/components/chat/diff-lines.tsx | 122 +++++++++++++++++- .../src/components/ui/file-type-icon.tsx | 22 ++++ apps/desktop/src/lib/markdown-code.ts | 50 +++++++ apps/desktop/src/styles.css | 26 +++- 7 files changed, 451 insertions(+), 50 deletions(-) create mode 100644 apps/desktop/src/components/ui/file-type-icon.tsx diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts index 55b7755973e..bf4409384c0 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { buildToolView, type ToolPart } from './tool-fallback-model' +import { + buildToolView, + countDiffLineStats, + inlineDiffFromResult, + type ToolPart +} from './tool-fallback-model' const part = (overrides: Partial): ToolPart => ({ args: {}, @@ -64,3 +69,51 @@ describe('buildToolView terminal exit-code status', () => { ) }) }) + +describe('buildToolView file edit diffs', () => { + const patchDiff = '--- a/src/demo.ts\n+++ b/src/demo.ts\n@@ -1 +1 @@\n-old\n+new' + + it('reads inline_diff and diff fields from patch results', () => { + expect(inlineDiffFromResult({ inline_diff: patchDiff })).toBe(patchDiff) + expect(inlineDiffFromResult({ diff: patchDiff })).toBe(patchDiff) + }) + + it('suppresses raw patch args when a diff is available', () => { + const view = buildToolView( + part({ + args: { context: 'src/demo.ts', mode: 'replace', new_string: 'new', path: 'src/demo.ts' }, + result: { diff: patchDiff, success: true }, + toolName: 'patch' + }), + patchDiff + ) + + expect(view.title).toBe('demo.ts') + expect(view.subtitle).toBe('src/demo.ts') + expect(view.detail).toBe('') + expect(view.inlineDiff).toBe(patchDiff) + }) + + it('shows path subtitle instead of patch args JSON while pending', () => { + const view = buildToolView( + part({ + args: { context: 'src/demo.ts', mode: 'replace', new_string: 'new', path: 'src/demo.ts' }, + result: undefined, + toolName: 'patch' + }), + '' + ) + + expect(view.title).toBe('demo.ts') + expect(view.subtitle).toBe('src/demo.ts') + expect(view.detail).toBe('') + }) +}) + +describe('countDiffLineStats', () => { + it('counts added and removed lines', () => { + expect( + countDiffLineStats(`--- a/x\n+++ b/x\n@@\n-old\n+new\n context\n+another`) + ).toEqual({ added: 2, removed: 1 }) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts index 3618d8011fb..6e67b0b9a4b 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts +++ b/apps/desktop/src/components/assistant-ui/tool-fallback-model.ts @@ -72,6 +72,46 @@ export interface MessageRunningStateSlice { } } +const FILE_EDIT_TOOL_NAMES = new Set(['edit_file', 'patch', 'write_file']) + +export function isFileEditTool(toolName: string): boolean { + return FILE_EDIT_TOOL_NAMES.has(toolName) +} + +export interface DiffLineStats { + added: number + removed: number +} + +export function countDiffLineStats(diff: string): DiffLineStats { + let added = 0 + let removed = 0 + + for (const line of diff.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) { + added += 1 + } else if (line.startsWith('-') && !line.startsWith('---')) { + removed += 1 + } + } + + return { added, removed } +} + +function fileEditPath(args: Record, result: Record): string { + return ( + firstStringField(args, ['path', 'file', 'filepath']) || + firstStringField(result, ['path', 'file', 'filepath', 'resolved_path']) || + htmlPathFromInlineDiff(firstStringField(result, ['inline_diff', 'diff'])) + ) +} + +function fileEditBasename(path: string): string { + const normalized = path.replace(/\\/g, '/').trim() + + return normalized.split('/').filter(Boolean).pop() || normalized +} + const TOOL_META: Record = { browser_click: { done: 'Clicked page element', pending: 'Clicking page element', icon: 'globe', tone: 'browser' }, browser_fill: { done: 'Filled form field', pending: 'Filling form field', icon: 'globe', tone: 'browser' }, @@ -95,7 +135,7 @@ const TOOL_META: Record = { execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' }, image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' }, list_files: { done: 'Listed files', pending: 'Listing files', icon: 'files', tone: 'file' }, - patch: { done: 'Patched file', pending: 'Patching file', icon: 'diff', tone: 'file' }, + patch: { done: 'Patched file', pending: 'Patching file', icon: 'edit', tone: 'file' }, read_file: { done: 'Read file', pending: 'Reading file', icon: 'file', tone: 'file' }, search_files: { done: 'Searched files', pending: 'Searching files', icon: 'search', tone: 'file' }, session_search_recall: { @@ -797,8 +837,8 @@ function toolPreviewTarget(toolName: string, args: Record, resu return looksLikeUrl(explicit) ? explicit : findFirstUrl(args, result) } - if (toolName === 'write_file' || toolName === 'edit_file') { - return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff'])) + if (isFileEditTool(toolName)) { + return htmlPathFromInlineDiff(firstStringField(result, ['inline_diff', 'diff'])) } return '' @@ -858,9 +898,17 @@ function stripDividerLines(value: string): string { } export function inlineDiffFromResult(result: unknown): string { - const value = parseMaybeObject(result).inline_diff + const record = parseMaybeObject(result) - return typeof value === 'string' ? stripInlineDiffChrome(value) : '' + for (const key of ['inline_diff', 'diff']) { + const value = record[key] + + if (typeof value === 'string' && value.trim()) { + return stripInlineDiffChrome(value) + } + } + + return '' } // Falls back to a string only when there's something concrete to render — @@ -1047,15 +1095,22 @@ function toolSubtitle( return command ? compactPreview(command, 120) : 'Executed command' } - if (toolName === 'read_file' || toolName === 'write_file' || toolName === 'edit_file') { - const path = - firstStringField(argsRecord, ['path', 'file', 'filepath']) || - htmlPathFromInlineDiff(firstStringField(resultRecord, ['inline_diff'])) + if (toolName === 'read_file' || isFileEditTool(toolName)) { + const isEdit = isFileEditTool(toolName) - return ( - path || - (firstStringField(resultRecord, ['inline_diff']) ? 'Changed file' : fallbackDetailText(argsRecord, resultRecord)) - ) + const path = isEdit + ? fileEditPath(argsRecord, resultRecord) + : firstStringField(argsRecord, ['path', 'file', 'filepath']) + + if (path) { + return path + } + + if (!isEdit) { + return fallbackDetailText(argsRecord, resultRecord) + } + + return inlineDiffFromResult(resultRecord) ? 'Changed file' : '' } if (toolName === 'web_extract') { @@ -1153,8 +1208,22 @@ function toolDetailText( } } - if (part.toolName === 'write_file' || part.toolName === 'edit_file') { - return inlineDiffFromResult(part.result) ? '' : fallbackDetailText(argsRecord, resultRecord) + if (isFileEditTool(part.toolName)) { + if (inlineDiffFromResult(part.result)) { + return '' + } + + const summary = firstStringField(resultRecord, ['message', 'summary']) + + if (summary) { + return summary + } + + if (fileEditPath(argsRecord, resultRecord)) { + return '' + } + + return fallbackDetailText(argsRecord, resultRecord) } if (part.toolName === 'web_search') { @@ -1253,8 +1322,12 @@ export function toolCopyPayload(part: ToolPart, view: ToolView): { label: string } } - if (part.toolName === 'write_file' || part.toolName === 'edit_file') { - const path = firstStringField(args, ['path', 'file', 'filepath']) + if (isFileEditTool(part.toolName)) { + if (view.inlineDiff.trim()) { + return { label: copy.file, text: view.inlineDiff } + } + + const path = fileEditPath(args, result) if (path) { return { label: copy.path, text: path } @@ -1304,6 +1377,14 @@ function dynamicTitle( } } + if (isFileEditTool(part.toolName)) { + const path = fileEditPath(args, result) + + if (path) { + return fileEditBasename(path) + } + } + return fallback } @@ -1317,7 +1398,12 @@ export function buildToolView(part: ToolPart, inlineDiff: string): ToolView { const title = dynamicTitle(part, argsRecord, resultRecord, baseTitle) const titleEnriched = title !== baseTitle const baseSubtitle = error || toolSubtitle(part, argsRecord, resultRecord) - const keepSubtitleWithTitle = part.toolName === 'terminal' || part.toolName === 'execute_code' + + const keepSubtitleWithTitle = + part.toolName === 'terminal' || + part.toolName === 'execute_code' || + (isFileEditTool(part.toolName) && Boolean(baseSubtitle.trim())) + const subtitle = titleEnriched && !error && !keepSubtitleWithTitle ? '' : baseSubtitle const detailBody = stripDividerLines(toolDetailText(part, argsRecord, resultRecord)) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index e93eabe1557..900d4767f7b 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -8,7 +8,7 @@ import { AnsiText } from '@/components/assistant-ui/ansi-text' import { useElapsedSeconds } from '@/components/chat/activity-timer' import { ActivityTimerText } from '@/components/chat/activity-timer-text' import { CompactMarkdown } from '@/components/chat/compact-markdown' -import { DiffLines } from '@/components/chat/diff-lines' +import { FileDiffPanel } from '@/components/chat/diff-lines' import { DisclosureRow } from '@/components/chat/disclosure-row' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { ZoomableImage } from '@/components/chat/zoomable-image' @@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { FadeText } from '@/components/ui/fade-text' +import { FileTypeIcon } from '@/components/ui/file-type-icon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { ToolIcon } from '@/components/ui/tool-icon' import { Tip } from '@/components/ui/tooltip' @@ -32,7 +33,9 @@ import { PendingToolApproval } from './tool-approval' import { buildToolView, cleanVisibleText, + countDiffLineStats, inlineDiffFromResult, + isFileEditTool, isPreviewableTarget, looksRedundant, type SearchResultRow, @@ -133,9 +136,21 @@ function statusGlyph(status: ToolStatus, copy: ToolStatusCopy): ReactNode { // Leading glyph for any tool-row header. Status (running/error/warning) // takes precedence; otherwise falls back to the tool's codicon. Returns // null when neither applies so callers can render unconditionally. -function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string; status?: ToolStatus }) { +function ToolGlyph({ + copy, + filePath, + icon, + status +}: { + copy: ToolStatusCopy + filePath?: string + icon?: string + status?: ToolStatus +}) { const node = status ? ( statusGlyph(status, copy) + ) : filePath ? ( + ) : icon ? ( ) : null @@ -204,8 +219,13 @@ function ToolEntry({ part }: ToolEntryProps) { const toolViewMode = useStore($toolViewMode) const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}` const dismissed = useStore($toolRowDismissed(disclosureId)) - const open = useDisclosureOpen(disclosureId) const isPending = messageRunning && part.result === undefined + const liveDiffs = useStore($toolInlineDiffs) + const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : '' + const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result) + const isFileEdit = isFileEditTool(part.toolName) + const defaultOpen = Boolean(inlineDiff) + const open = useDisclosureOpen(disclosureId, defaultOpen) const canDismiss = !isPending && !embedded // Only animate entries that mount while their message is actively // streaming — historical sessions mount with `messageRunning === false`, @@ -213,9 +233,6 @@ function ToolEntry({ part }: ToolEntryProps) { // handles its own enter animation, so embedded children skip it. const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`) const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`) - const liveDiffs = useStore($toolInlineDiffs) - const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : '' - const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result) // Stale parts (no result, but message stopped running) get a synthetic // empty result so buildToolView treats them as completed-no-output. @@ -253,11 +270,12 @@ function ToolEntry({ part }: ToolEntryProps) { const detailMatchesSubtitle = looksRedundant(view.subtitle, view.detail) const showDetail = - (view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) || - (view.status !== 'error' && - Boolean(view.detail) && - !looksRedundant(view.title, view.detail) && - !detailMatchesSubtitle) + !view.inlineDiff && + ((view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) || + (view.status !== 'error' && + Boolean(view.detail) && + !looksRedundant(view.title, view.detail) && + !detailMatchesSubtitle)) const renderDetailAsCode = view.status !== 'error' && @@ -283,6 +301,13 @@ function ToolEntry({ part }: ToolEntryProps) { const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view]) + const diffStats = useMemo( + () => (isFileEdit && view.inlineDiff ? countDiffLineStats(view.inlineDiff) : null), + [isFileEdit, view.inlineDiff] + ) + + const showDiffStats = !isPending && Boolean(diffStats && (diffStats.added > 0 || diffStats.removed > 0)) + // The header trailing slot only carries the live duration timer while the // tool is running. The copy control used to live here too, but an // `opacity-0` (yet still clickable) button straddling the caret/duration made @@ -299,7 +324,12 @@ function ToolEntry({ part }: ToolEntryProps) {
)} - {toolViewMode === 'technical' && ( + {toolViewMode === 'technical' && !(isFileEdit && view.inlineDiff) && (
               {rawTechnicalTrace(part.args, part.result)}
             
)} + {toolViewMode === 'technical' && isFileEdit && view.inlineDiff && ( +
+ Tool payload +
+                {rawTechnicalTrace(part.args, part.result)}
+              
+
+ )} )} - {open && view.inlineDiff && } ) } @@ -488,6 +555,7 @@ export const ToolGroupSlot: FC {children} diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx index a6e025ae2ac..a8a1bfc314b 100644 --- a/apps/desktop/src/components/chat/diff-lines.tsx +++ b/apps/desktop/src/components/chat/diff-lines.tsx @@ -15,11 +15,17 @@ interface DiffLineKind { const DIFF_LINE_KINDS: DiffLineKind[] = [ { - className: 'text-emerald-700 dark:text-emerald-300', + className: 'border-emerald-500 bg-emerald-500/12 text-emerald-800 dark:text-emerald-200', match: line => line.startsWith('+') && !line.startsWith('+++') }, - { className: 'text-rose-700 dark:text-rose-300', match: line => line.startsWith('-') && !line.startsWith('---') }, - { className: 'text-sky-700 dark:text-sky-300', match: line => line.startsWith('@@') }, + { + className: 'border-rose-500 bg-rose-500/12 text-rose-800 dark:text-rose-200', + match: line => line.startsWith('-') && !line.startsWith('---') + }, + { + className: 'text-sky-700 dark:text-sky-300', + match: line => line.startsWith('@@') + }, { className: 'text-muted-foreground/70', match: line => line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60)) @@ -30,25 +36,127 @@ function classifyLine(line: string): string | undefined { return DIFF_LINE_KINDS.find(kind => kind.match(line))?.className } +// Drop the leading +/-/space gutter character so changes read by color alone +// (like Cursor), keeping the rest of the indentation intact. Hunk headers +// (`@@`) and any stray file headers are left untouched. +function stripDiffMarker(line: string): string { + if (line.startsWith('@@')) { + return line + } + + if ((line.startsWith('+') && !line.startsWith('+++')) || (line.startsWith('-') && !line.startsWith('---'))) { + return line.slice(1) + } + + if (line.startsWith(' ')) { + return line.slice(1) + } + + return line +} + +interface DisplayLine { + className?: string + text: string +} + +// Build the rendered line list: drop `@@ … @@` hunk headers (git noise in a +// GUI) and the +/- gutter, but keep a blank separator between hunks so +// multi-hunk diffs don't visually merge. +function toDisplayLines(text: string): DisplayLine[] { + const out: DisplayLine[] = [] + let emitted = false + + for (const line of text.split('\n')) { + if (line.startsWith('@@')) { + if (emitted) { + out.push({ text: '' }) + } + + continue + } + + out.push({ className: classifyLine(line), text: stripDiffMarker(line) }) + emitted = true + } + + return out +} + interface DiffLinesProps extends Omit, 'children'> { text: string } export function DiffLines({ className, text, ...props }: DiffLinesProps) { + const lines = React.useMemo(() => toDisplayLines(text), [text]) + return (
-      {text.split('\n').map((line, index) => (
-        
-          {line || ' '}
+      {lines.map((line, index) => (
+        
+          {line.text || ' '}
         
       ))}
     
) } + +// Git-style unified diffs arrive with a file-header preamble — `diff --git`, +// `index …`, `--- a/path`, `+++ b/path`, and Hermes' own `a/path → b/path` +// arrow line. That preamble just repeats the path (which the tool row already +// shows) and reads especially badly for absolute paths (`a//Users/…`). Strip +// the leading header zone up to the first hunk so the panel shows only hunks + +// changes, the way Cursor does. +const DIFF_HEADER_PREFIXES = ['diff --git', 'index ', '--- ', '+++ ', 'similarity ', 'rename ', 'new file', 'deleted file'] + +function isArrowHeaderLine(line: string): boolean { + const trimmed = line.trim() + + return trimmed.includes('→') && /^\S.*→\s*\S+$/.test(trimmed) && !/^[+\-@]/.test(trimmed) +} + +/** Exported for tests. */ +export function stripDiffFileHeaders(diff: string): string { + const lines = diff.split('\n') + let start = 0 + + for (; start < lines.length; start += 1) { + const line = lines[start] + + if (line.startsWith('@@')) { + break + } + + if (line.trim() === '' || isArrowHeaderLine(line) || DIFF_HEADER_PREFIXES.some(prefix => line.startsWith(prefix))) { + continue + } + + break + } + + return lines.slice(start).join('\n') +} + +interface FileDiffPanelProps { + diff: string +} + +export function FileDiffPanel({ diff }: FileDiffPanelProps) { + const display = React.useMemo(() => stripDiffFileHeaders(diff), [diff]) + + // Bleed out of the tool-card body's `p-1.5` so changed-line tints/borders run + // flush to the card edges (rounded corners clip via the card's overflow). + // `max-w-none` lifts the base `max-w-full` cap that would otherwise stop the + // negative margins from widening the block. + return +} diff --git a/apps/desktop/src/components/ui/file-type-icon.tsx b/apps/desktop/src/components/ui/file-type-icon.tsx new file mode 100644 index 00000000000..fe40c4f2437 --- /dev/null +++ b/apps/desktop/src/components/ui/file-type-icon.tsx @@ -0,0 +1,22 @@ +import { ToolIcon, type ToolIconProps } from '@/components/ui/tool-icon' +import { codiconForFilename, codiconForLanguage } from '@/lib/markdown-code' + +export interface FileTypeIconProps extends Omit { + /** A code-fence language tag (e.g. `ts`, `json`). Used when no `path`. */ + language?: string + /** A file path or bare name; its extension selects the icon. Wins over `language`. */ + path?: string +} + +/** + * Icon for a file or code language, resolved through the one mapping shared + * with code blocks (`codiconForFilename` / `codiconForLanguage`). Renders via + * `ToolIcon`, so it uses a filled glyph when one exists and falls back to the + * outline codicon font otherwise. Pass a `path` for file rows or a `language` + * for fenced code. + */ +export function FileTypeIcon({ language, path, ...props }: FileTypeIconProps) { + const name = path ? codiconForFilename(path) : codiconForLanguage(language) + + return +} diff --git a/apps/desktop/src/lib/markdown-code.ts b/apps/desktop/src/lib/markdown-code.ts index 0b105727490..6c34b1fcac3 100644 --- a/apps/desktop/src/lib/markdown-code.ts +++ b/apps/desktop/src/lib/markdown-code.ts @@ -108,6 +108,56 @@ export function codiconForLanguage(language: string | undefined): string { return CODICON_BY_LANGUAGE[sanitizeLanguageTag(language || '')] || 'code' } +// File extension → language tag, so a filename can resolve to the same icon a +// fenced code block of that language would get. Only extensions that map to a +// non-generic codicon need an entry; everything else falls through to `code`. +const LANGUAGE_BY_EXTENSION: Record = { + bash: 'bash', + cfg: 'ini', + conf: 'ini', + css: 'css', + dockerfile: 'dockerfile', + env: 'env', + gql: 'graphql', + graphql: 'graphql', + ini: 'ini', + json: 'json', + json5: 'json', + less: 'less', + markdown: 'markdown', + md: 'markdown', + mdx: 'markdown', + mmd: 'mermaid', + ps1: 'powershell', + psql: 'sql', + sass: 'sass', + scss: 'scss', + sh: 'bash', + sql: 'sql', + svg: 'svg', + toml: 'toml', + yaml: 'yaml', + yml: 'yml', + zsh: 'zsh' +} + +// Pick an icon for a file path by its extension (or bare name like +// `Dockerfile`), reusing the language→codicon map so file-edit rows and code +// blocks share one visual vocabulary. Unknown / generic code files get `code`. +export function codiconForFilename(path: string | undefined): string { + const base = (path || '').replace(/\\/g, '/').split('/').pop()?.trim().toLowerCase() || '' + + if (!base) { + return 'code' + } + + const dot = base.lastIndexOf('.') + const token = dot > 0 ? base.slice(dot + 1) : base + const language = LANGUAGE_BY_EXTENSION[token] || token + + return codiconForLanguage(language) +} + function proseLineCount(body: string): number { return body.split('\n').filter(line => { const trimmed = line.trim() diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 36ef859ce12..f3fe3da0d28 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1214,19 +1214,33 @@ canvas { background: transparent !important; } -[data-slot='aui_assistant-message-content'] > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) { +/* Fade scaffolding so the prose reading column stays primary. Two targets: + a thinking disclosure fades as one block, and each *individual* tool row + (`[data-tool-row]`) fades on its own. We deliberately do NOT fade the tool + group wrapper (`[data-tool-group]`): opacity on a parent opens a stacking + context, so a child row can never be more opaque than the group — that made + it impossible to keep one row lit (an open diff) while its siblings faded. + With the fade per-row, each row hovers/focuses independently. */ +[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure'], +[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row] { opacity: 0.67; transition: opacity 120ms ease-out; } -[data-slot='aui_assistant-message-content'] - > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']):is(:hover, :focus-within) { +/* Lift on hover or *keyboard* focus only. `:focus-within` also matches the + focus a mouse click leaves on the disclosure toggle, which kept a row lit + after you clicked to collapse it; `:has(:focus-visible)` excludes that. */ +[data-slot='aui_assistant-message-content'] > [data-slot='aui_thinking-disclosure']:is(:hover, :has(:focus-visible)), +[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row]:is(:hover, :has(:focus-visible)) { opacity: 1; } -/* A generated image is the deliverable, not scaffolding — keep it at full - strength instead of dimming it until hover. */ -[data-slot='aui_assistant-message-content'] > [data-slot='tool-block']:has([data-slot='aui_generated-image']) { +/* File edits (write_file / edit_file / patch) are the deliverable, not + scaffolding — the diff is what the user reviews, like a PR. An *expanded* + edit stays at full strength; collapsed it fades like any other row. The + `data-file-edit` marker sits on the same row element and is only present + while the row is open. */ +[data-slot='aui_assistant-message-content'] [data-slot='tool-block'][data-tool-row][data-file-edit] { opacity: 1; } From c6fbd5a10494541ec3f29b77bc639e6ce3441c18 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 22 Jun 2026 05:05:34 -0500 Subject: [PATCH 467/470] style(desktop): lead --dt-font-mono with bundled JetBrains Mono Code/diff blocks preferred a system Cascadia Code before the bundled JetBrains Mono, so they drifted from the terminal (which leads with JetBrains Mono) on machines where Cascadia is installed. Reorder so every mono surface uses the face we actually ship. --- apps/desktop/src/styles.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index f3fe3da0d28..a56b87186df 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -299,8 +299,11 @@ 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji; /* Key caps always use the native UI face — never theme typography overrides. */ --dt-font-kbd: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; + /* JetBrains Mono first — the face we bundle (@font-face above) and the + terminal's primary — so code/diff match the terminal on every platform + instead of drifting to a system Cascadia Code where it's installed. */ --dt-font-mono: - 'Cascadia Code', 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace, 'Apple Color Emoji', + 'JetBrains Mono', 'Cascadia Code', 'SF Mono', ui-monospace, Menlo, Consolas, monospace, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', emoji; --dt-base-size: 1rem; --dt-line-height: 1.5; From ac128af1cec30238f21376273ce4f96088a800bd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 22 Jun 2026 05:10:23 -0500 Subject: [PATCH 468/470] feat(desktop): syntax-highlight inline diffs via Shiki MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify the diff renderer onto the same Shiki path as code blocks: highlight the marker-stripped change content in the file's language, then a per-line transformer layers the add/remove tint + gutter accent on top. Falls back to the plain color-only renderer when the language is unknown, over budget, or while Shiki loads. - shikiLanguageForFilename(): extension → bundled-language id (shared filename-token helper with codiconForFilename). - code display:grid so full-width line tints don't double with newline nodes; theme surface stripped so context lines stay transparent. --- .../components/assistant-ui/tool-fallback.tsx | 2 +- .../src/components/chat/diff-lines.tsx | 253 +++++++++++------- apps/desktop/src/lib/markdown-code.ts | 97 ++++++- apps/desktop/src/styles.css | 15 ++ 4 files changed, 254 insertions(+), 113 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 900d4767f7b..8d6a7eb157c 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -439,7 +439,7 @@ function ToolEntry({ part }: ToolEntryProps) { )} - {view.inlineDiff && } + {view.inlineDiff && } {showDetail && toolViewMode !== 'technical' && (view.status === 'error' ? ( diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx index a8a1bfc314b..fefc8024475 100644 --- a/apps/desktop/src/components/chat/diff-lines.tsx +++ b/apps/desktop/src/components/chat/diff-lines.tsx @@ -1,122 +1,82 @@ -import * as React from 'react' +'use client' +import type { ReactNode } from 'react' +import * as React from 'react' +import { useShikiHighlighter } from 'react-shiki' +import type { ShikiTransformer } from 'shiki' + +import { exceedsHighlightBudget } from '@/components/chat/shiki-highlighter' +import { shikiLanguageForFilename } from '@/lib/markdown-code' import { cn } from '@/lib/utils' /** - * Per-line classed renderer for unified diffs. Lives outside `CodeCard` so - * tool-result panels (already nested inside a tool card) don't double-shell; - * for markdown ` ```diff ` fences the standard `CodeCard` + Shiki path runs - * instead and gives equivalent coloring. + * Renders a unified diff for a tool's file edit. Two paths share one parse: + * - `SyntaxDiff` highlights the change *content* in the file's language via + * Shiki, then a per-line transformer paints the add/remove tint on top. + * - `DiffLines` is the color-only fallback (no language, over budget, or while + * Shiki loads). + * Both drop git file-headers + `@@` hunk noise and the `+/-` gutter so changes + * read by color + a 2px gutter accent, the way Cursor does. */ -interface DiffLineKind { - className?: string - match: (line: string) => boolean +const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const + +type DiffKind = 'add' | 'context' | 'remove' + +interface DiffLine { + kind: DiffKind + text: string } -const DIFF_LINE_KINDS: DiffLineKind[] = [ - { - className: 'border-emerald-500 bg-emerald-500/12 text-emerald-800 dark:text-emerald-200', - match: line => line.startsWith('+') && !line.startsWith('+++') - }, - { - className: 'border-rose-500 bg-rose-500/12 text-rose-800 dark:text-rose-200', - match: line => line.startsWith('-') && !line.startsWith('---') - }, - { - className: 'text-sky-700 dark:text-sky-300', - match: line => line.startsWith('@@') - }, - { - className: 'text-muted-foreground/70', - match: line => line.startsWith('---') || line.startsWith('+++') || / → /.test(line.slice(0, 60)) +// Tint + 2px gutter accent per change kind. Text color is included for the +// plain renderer; the Shiki path omits it so syntax colors win, layering only +// the background + border. +const DIFF_KIND_TINT: Record = { + add: 'border-emerald-500 bg-emerald-500/12', + context: 'border-transparent', + remove: 'border-rose-500 bg-rose-500/12' +} + +const DIFF_KIND_TEXT: Record = { + add: 'text-emerald-800 dark:text-emerald-200', + context: '', + remove: 'text-rose-800 dark:text-rose-200' +} + +const DIFF_LINE_BASE = 'block min-w-max whitespace-pre border-l-2 px-2.5 py-px' + +// Bleed out of the tool-card body's `p-1.5` so tints/borders run flush to the +// card edges (rounded corners clip via the card's overflow); compact height +// with internal scroll like a code block. +const DIFF_BOX_CLASS = + '-mx-1.5 -mb-1.5 max-h-[12rem] max-w-none min-w-0 overflow-auto overscroll-contain font-mono text-[0.7rem] leading-relaxed text-(--ui-text-secondary)' + +function diffKind(line: string): DiffKind { + if (line.startsWith('+') && !line.startsWith('+++')) { + return 'add' } -] -function classifyLine(line: string): string | undefined { - return DIFF_LINE_KINDS.find(kind => kind.match(line))?.className + if (line.startsWith('-') && !line.startsWith('---')) { + return 'remove' + } + + return 'context' } -// Drop the leading +/-/space gutter character so changes read by color alone -// (like Cursor), keeping the rest of the indentation intact. Hunk headers -// (`@@`) and any stray file headers are left untouched. +// Drop the leading +/-/space gutter so changes read by color alone, keeping the +// rest of the indentation intact. function stripDiffMarker(line: string): string { - if (line.startsWith('@@')) { - return line - } - - if ((line.startsWith('+') && !line.startsWith('+++')) || (line.startsWith('-') && !line.startsWith('---'))) { - return line.slice(1) - } - - if (line.startsWith(' ')) { + if (diffKind(line) !== 'context' || line.startsWith(' ')) { return line.slice(1) } return line } -interface DisplayLine { - className?: string - text: string -} - -// Build the rendered line list: drop `@@ … @@` hunk headers (git noise in a -// GUI) and the +/- gutter, but keep a blank separator between hunks so -// multi-hunk diffs don't visually merge. -function toDisplayLines(text: string): DisplayLine[] { - const out: DisplayLine[] = [] - let emitted = false - - for (const line of text.split('\n')) { - if (line.startsWith('@@')) { - if (emitted) { - out.push({ text: '' }) - } - - continue - } - - out.push({ className: classifyLine(line), text: stripDiffMarker(line) }) - emitted = true - } - - return out -} - -interface DiffLinesProps extends Omit, 'children'> { - text: string -} - -export function DiffLines({ className, text, ...props }: DiffLinesProps) { - const lines = React.useMemo(() => toDisplayLines(text), [text]) - - return ( -
-      {lines.map((line, index) => (
-        
-          {line.text || ' '}
-        
-      ))}
-    
- ) -} - // Git-style unified diffs arrive with a file-header preamble — `diff --git`, // `index …`, `--- a/path`, `+++ b/path`, and Hermes' own `a/path → b/path` // arrow line. That preamble just repeats the path (which the tool row already // shows) and reads especially badly for absolute paths (`a//Users/…`). Strip -// the leading header zone up to the first hunk so the panel shows only hunks + -// changes, the way Cursor does. +// the leading header zone up to the first hunk. const DIFF_HEADER_PREFIXES = ['diff --git', 'index ', '--- ', '+++ ', 'similarity ', 'rename ', 'new file', 'deleted file'] function isArrowHeaderLine(line: string): boolean { @@ -147,16 +107,101 @@ export function stripDiffFileHeaders(diff: string): string { return lines.slice(start).join('\n') } +// Cleaned diff → renderable lines: file-headers + `@@` hunks dropped (a blank +// separator kept between hunks), markers stripped, kind recorded. +function parseDiff(diff: string): DiffLine[] { + const out: DiffLine[] = [] + let emitted = false + + for (const line of stripDiffFileHeaders(diff).split('\n')) { + if (line.startsWith('@@')) { + if (emitted) { + out.push({ kind: 'context', text: '' }) + } + + continue + } + + out.push({ kind: diffKind(line), text: stripDiffMarker(line) }) + emitted = true + } + + return out +} + +function DiffBody({ lines, syntax }: { lines: DiffLine[]; syntax?: boolean }) { + return ( + <> + {lines.map((line, index) => ( + + {line.text || ' '} + + ))} + + ) +} + +// Shiki transformer: tag each `.line` with the diff tint for its kind, so the +// syntax-highlighted output keeps add/remove backgrounds + the gutter accent. +function diffLineTransformer(kinds: DiffKind[]): ShikiTransformer { + return { + line(node, line) { + const kind = kinds[line - 1] ?? 'context' + + const existing = Array.isArray(node.properties.className) + ? (node.properties.className as string[]) + : node.properties.className + ? [String(node.properties.className)] + : [] + + node.properties.className = [...existing, DIFF_LINE_BASE, DIFF_KIND_TINT[kind]] + } + } +} + +function SyntaxDiff({ language, lines }: { language: string; lines: DiffLine[] }) { + const code = React.useMemo(() => lines.map(line => line.text).join('\n'), [lines]) + const transformers = React.useMemo(() => [diffLineTransformer(lines.map(line => line.kind))], [lines]) + + const highlighted = useShikiHighlighter(code, language, SHIKI_THEME, { + defaultColor: 'light-dark()', + transformers + }) + + // Until Shiki resolves, show the plain colored diff so there's no flash. + return (highlighted as ReactNode) ?? +} + +interface DiffLinesProps extends Omit, 'children'> { + text: string +} + +export function DiffLines({ className, text, ...props }: DiffLinesProps) { + const lines = React.useMemo(() => parseDiff(text), [text]) + + return ( +
+      
+    
+ ) +} + interface FileDiffPanelProps { diff: string + path?: string } -export function FileDiffPanel({ diff }: FileDiffPanelProps) { - const display = React.useMemo(() => stripDiffFileHeaders(diff), [diff]) +export function FileDiffPanel({ diff, path }: FileDiffPanelProps) { + const lines = React.useMemo(() => parseDiff(diff), [diff]) + const language = shikiLanguageForFilename(path) + const canHighlight = Boolean(language) && !exceedsHighlightBudget(diff) - // Bleed out of the tool-card body's `p-1.5` so changed-line tints/borders run - // flush to the card edges (rounded corners clip via the card's overflow). - // `max-w-none` lifts the base `max-w-full` cap that would otherwise stop the - // negative margins from widening the block. - return + return ( +
+ {canHighlight ? : } +
+ ) } diff --git a/apps/desktop/src/lib/markdown-code.ts b/apps/desktop/src/lib/markdown-code.ts index 6c34b1fcac3..3d9f3e5e1b6 100644 --- a/apps/desktop/src/lib/markdown-code.ts +++ b/apps/desktop/src/lib/markdown-code.ts @@ -145,19 +145,100 @@ const LANGUAGE_BY_EXTENSION: Record = { // `Dockerfile`), reusing the language→codicon map so file-edit rows and code // blocks share one visual vocabulary. Unknown / generic code files get `code`. export function codiconForFilename(path: string | undefined): string { - const base = (path || '').replace(/\\/g, '/').split('/').pop()?.trim().toLowerCase() || '' - - if (!base) { - return 'code' - } - - const dot = base.lastIndexOf('.') - const token = dot > 0 ? base.slice(dot + 1) : base + const token = filenameExtToken(path) const language = LANGUAGE_BY_EXTENSION[token] || token return codiconForLanguage(language) } +// Last path segment's extension (or the bare lowercased name for `Dockerfile`, +// `Makefile`, …). Shared by the icon and Shiki-language resolvers. +function filenameExtToken(path: string | undefined): string { + const base = (path || '').replace(/\\/g, '/').split('/').pop()?.trim().toLowerCase() || '' + const dot = base.lastIndexOf('.') + + return dot > 0 ? base.slice(dot + 1) : base +} + +// File extension → Shiki bundled-language id, for syntax-highlighting diffs in +// the editing tool's own language. Unknown extensions return '' so callers fall +// back to the plain color-only diff renderer. +const SHIKI_LANGUAGE_BY_EXTENSION: Record = { + astro: 'astro', + bash: 'bash', + c: 'c', + cc: 'cpp', + cjs: 'javascript', + clj: 'clojure', + cpp: 'cpp', + cs: 'csharp', + css: 'css', + cxx: 'cpp', + dart: 'dart', + dockerfile: 'docker', + ex: 'elixir', + exs: 'elixir', + fish: 'fish', + go: 'go', + gql: 'graphql', + graphql: 'graphql', + h: 'c', + hpp: 'cpp', + hs: 'haskell', + htm: 'html', + html: 'html', + ini: 'ini', + java: 'java', + jl: 'julia', + js: 'javascript', + json: 'json', + json5: 'json5', + jsonc: 'jsonc', + jsx: 'jsx', + kt: 'kotlin', + kts: 'kotlin', + less: 'less', + lua: 'lua', + makefile: 'make', + markdown: 'markdown', + md: 'markdown', + mdx: 'mdx', + mjs: 'javascript', + ml: 'ocaml', + mts: 'typescript', + nix: 'nix', + php: 'php', + pl: 'perl', + proto: 'proto', + ps1: 'powershell', + py: 'python', + pyi: 'python', + r: 'r', + rb: 'ruby', + rs: 'rust', + sass: 'sass', + scala: 'scala', + scss: 'scss', + sh: 'bash', + sql: 'sql', + svelte: 'svelte', + swift: 'swift', + tf: 'terraform', + toml: 'toml', + ts: 'typescript', + tsx: 'tsx', + vue: 'vue', + xml: 'xml', + yaml: 'yaml', + yml: 'yaml', + zig: 'zig', + zsh: 'bash' +} + +export function shikiLanguageForFilename(path: string | undefined): string { + return SHIKI_LANGUAGE_BY_EXTENSION[filenameExtToken(path)] || '' +} + function proseLineCount(body: string): number { return body.split('\n').filter(line => { const trimmed = line.trim() diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index a56b87186df..4ddc226b305 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1238,6 +1238,21 @@ canvas { opacity: 1; } +/* Syntax-highlighted inline diff (Shiki): strip the theme's own surface + + default margins so context lines stay transparent and each changed line owns + its tint. `display: grid` on the code puts one `.line` per row and drops the + whitespace-only `\n` nodes between them — without it, full-width block lines + double up with the literal newlines (phantom blank rows). */ +[data-slot='file-diff-panel'] .shiki, +[data-slot='file-diff-panel'] .shiki code { + margin: 0; + background: transparent !important; +} + +[data-slot='file-diff-panel'] .shiki code { + display: grid; +} + /* File edits (write_file / edit_file / patch) are the deliverable, not scaffolding — the diff is what the user reviews, like a PR. An *expanded* edit stays at full strength; collapsed it fades like any other row. The From 64a507da44d273a16bc776185b54d0fd625e1460 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Mon, 22 Jun 2026 20:10:57 +1000 Subject: [PATCH 469/470] =?UTF-8?q?feat(relay):=20handle=20passthrough=5Ff?= =?UTF-8?q?orward=20over=20the=20WS=20(Phase=205=20=C2=A75.1,=20gateway=20?= =?UTF-8?q?half)=20(#50702)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connector half (gateway-gateway) moves the passthrough plane's post-ACK forward off the HTTP gatewayEndpoint onto the gateway's outbound /relay WS via a new passthrough_forward frame. This is the gateway side: the relay adapter now RECEIVES and handles that frame, so a hosted gateway (no public IP) can process forwarded Class-2/3 traffic (Discord interactions, Twilio) over the socket it already holds — closing the "passthrough inbound doesn't work for hosted gateways" gap. - ws_transport.py: decode the passthrough_forward frame; PassthroughForward dataclass + _passthrough_from_wire (base64 body -> exact bytes, byte parity with the connector's toPassthroughForward); set_passthrough_handler mirrors set_interrupt_inbound_handler. - transport.py: PassthroughHandler type + set_passthrough_handler on the RelayTransport protocol. - adapter.py: connect() wires the passthrough handler; _on_passthrough decodes the (already-sanitized, token-free) forward and, for a Discord interaction, converts it to a MessageEvent routed through the normal agent path (handle_message) — the reply egresses over the outbound / token-less follow_up path, so the gateway never holds the interaction credential. Never raises (a bad forward can't kill the read loop). Non-discord forwards (Twilio) are logged + dropped for now. - docs/relay-connector-contract.md: document the passthrough_forward frame + PassthroughForward shape + §3.1. The interaction -> MessageEvent CONVERSION semantics (slash-command vs button UX, option rendering) are the open sub-design flagged in the spec; the TRANSPORT + receive mechanism (this) is settled per Ben's Gate-2 decision: "the relay adapter handles receiving these events over the WS." Tests (tests/gateway/relay/test_relay_passthrough.py): byte-preservation round-trip (+ malformed-body tolerance), connect() wiring, application-command and message-component interactions route through handle_message with correct session source + scope capture, malformed/non-discord forwards dropped cleanly. 100 relay tests green. Pairs with the connector PR (gateway-gateway). --- docs/relay-connector-contract.md | 31 ++- gateway/relay/adapter.py | 99 ++++++++- gateway/relay/transport.py | 19 ++ gateway/relay/ws_transport.py | 68 ++++++ tests/gateway/relay/stub_connector.py | 13 ++ tests/gateway/relay/test_relay_passthrough.py | 199 ++++++++++++++++++ 6 files changed, 425 insertions(+), 4 deletions(-) create mode 100644 tests/gateway/relay/test_relay_passthrough.py diff --git a/docs/relay-connector-contract.md b/docs/relay-connector-contract.md index 54fff9406cc..4e20726197f 100644 --- a/docs/relay-connector-contract.md +++ b/docs/relay-connector-contract.md @@ -93,6 +93,16 @@ Frames (connector → gateway, over the WS): - `{"type":"inbound", "event": , "bufferId"?}` - `{"type":"interrupt_inbound", "session_key", "chat_id"}` (§5) +- `{"type":"passthrough_forward", "forward": , "bufferId"?}` (§5.1) + +`PassthroughForward` is the wire form of a forwarded passthrough-plane request +(Class-2/3 webhooks — Discord interactions, Twilio): `{platform, botId, method, +path, headers: [[k,v],…], bodyB64}`. The body is base64-encoded so arbitrary +bytes survive the newline-delimited-JSON transport; the gateway base64-decodes +back to the exact bytes the connector forwarded (the connector already verified +the provider signature and stripped any shared-identity credential at the edge — +§6 — so the gateway re-processes a sanitized, token-free body and acts on it via +the token-less `follow_up` path). See §3.1. **Trust.** The WS upgrade is authenticated with the gateway's per-gateway secret (§6.1), so the channel is trusted end to end — inbound frames are not separately @@ -106,9 +116,24 @@ old HTTP path needed). The relay-bus hop is inside the connector trust domain > every gateway to expose a reachable inbound URL — impossible for hosted > gateways, which have no public IP. The WS back-channel above replaces it; the > per-tenant delivery key is retained at provision for forward-compat but is no -> longer used for inbound. `gatewayEndpoint` remains only for the **passthrough -> plane** (Class-2/3 webhooks like Discord interactions / Twilio), which is a -> separate synchronous-forward path and out of scope for this section. +> longer used for inbound. The **passthrough plane** (Class-2/3 webhooks like +> Discord interactions / Twilio) historically still used `gatewayEndpoint` for +> its post-ACK forward; Phase 5 §5.1 moves that forward onto the WS too (the +> `passthrough_forward` frame above), so a hosted gateway needs zero public +> inbound surface and `gatewayEndpoint` is retired once the cutover lands. + +### 3.1 Passthrough-plane forward (§5.1) + +The passthrough plane answers the provider's latency-critical ACK at the +connector EDGE (e.g. Discord's deferred interaction response within ~3s), then +does a **fire-and-forget** forward of the real request to the gateway. That +forward needs no response back (the provider was already satisfied), so it rides +the same outbound WS as `inbound` via a `passthrough_forward` frame rather than +an HTTP POST. The gateway processes the decoded request through its normal agent +path (a Discord interaction is decoded to a `MessageEvent` and handled like a +message; the reply egresses over the outbound / `follow_up` path). `bufferId` is +present when the forward was buffered (Phase 5 §5.3 buffered-only flip) and the +gateway acks it after durable handoff. diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index a1a7826f8f8..9e44a34b421 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -22,9 +22,10 @@ import logging from typing import Any, Callable, Dict, Optional from gateway.config import Platform, PlatformConfig -from gateway.platforms.base import BasePlatformAdapter, SendResult +from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult from gateway.relay.descriptor import CapabilityDescriptor from gateway.relay.transport import RelayTransport +from gateway.session import SessionSource logger = logging.getLogger(__name__) @@ -89,6 +90,13 @@ class RelayAdapter(BasePlatformAdapter): set_interrupt = getattr(self._transport, "set_interrupt_inbound_handler", None) if callable(set_interrupt): set_interrupt(self.on_interrupt) + # Passthrough-plane forwards (Discord interactions, Twilio, …) also ride + # the SAME outbound WS (Phase 5 §5.1) — the connector edge-ACKed and + # forwards the real request here, so a hosted gateway needs no public + # inbound port. Bridge them to the adapter's passthrough handler. + set_passthrough = getattr(self._transport, "set_passthrough_handler", None) + if callable(set_passthrough): + set_passthrough(self._on_passthrough) ok = await self._transport.connect() if not ok: return False @@ -155,6 +163,95 @@ class RelayAdapter(BasePlatformAdapter): """ await self.interrupt_session_activity(session_key, chat_id) + async def _on_passthrough(self, forward, buffer_id: Optional[str] = None) -> None: + """Handle a connector-forwarded passthrough request (Phase 5 §5.1). + + The passthrough plane (Discord interactions, Twilio webhooks, …) answers + the provider's latency-critical ACK at the connector EDGE, then forwards + the real, ALREADY-SANITIZED request to this gateway over the outbound WS. + The connector is the trust boundary: it verified the provider signature + at the edge and stripped any shared-identity credential (e.g. a Discord + interaction follow-up token) into its vault — so this body carries no + token, and the agent later acts on it via the token-less ``follow_up`` + path (``send_follow_up``), never holding the credential. + + For a Discord interaction we decode the (JSON) body and convert it to a + normalized ``MessageEvent`` so it flows through the SAME agent path as a + chat message (``handle_message``); the agent's reply egresses over the + normal outbound/follow_up path. Non-JSON or non-interaction forwards are + logged and dropped for now (Twilio/SMS over the relay is a later unit). + + NEVER raises: a malformed forward must not kill the read loop. + + NOTE (open semantic sub-design, flagged for review): the interaction -> + MessageEvent mapping below is the v1 default. The exact agent UX for a + slash-command / button interaction (vs. a plain message) — command name + surfacing, option rendering, deferred-vs-immediate response — is the open + piece tracked in the spec; the TRANSPORT + receive mechanism (this whole + path) is settled. + """ + try: + platform = getattr(forward, "platform", "") or "" + if platform == "discord": + event = self._discord_interaction_to_event(forward) + if event is not None: + self._capture_scope(event) + await self.handle_message(event) + return + logger.info( + "relay passthrough_forward dropped (no handler): platform=%s method=%s path=%s", + platform, + getattr(forward, "method", "?"), + getattr(forward, "path", "?"), + ) + except Exception: # noqa: BLE001 - a bad forward must never break the reader + logger.warning("relay passthrough_forward handling failed", exc_info=True) + + def _discord_interaction_to_event(self, forward): + """Convert a forwarded Discord interaction body to a MessageEvent, or None. + + Builds the session source the same way the connector does for an + interaction (``interactionSessionSource`` on the connector side), so the + agent's session key matches the one the connector bound the follow-up + capability under. Returns None when the body isn't a usable interaction + (e.g. a PING, which the connector already answers at the edge and never + forwards). + """ + import json + + from gateway.platforms.base import MessageType + + try: + payload = json.loads(bytes(getattr(forward, "body", b"")).decode("utf-8")) + except Exception: # noqa: BLE001 + return None + if not isinstance(payload, dict): + return None + # type 1 = PING (answered at the edge, never forwarded); 2 = APPLICATION_COMMAND; + # 3 = MESSAGE_COMPONENT; 5 = MODAL_SUBMIT. Surface a best-effort text. + itype = payload.get("type") + data = payload.get("data") or {} + if itype == 2: + text = str(data.get("name") or "") + elif itype == 3: + text = str(data.get("custom_id") or "") + else: + text = "" + member = payload.get("member") or {} + user = (member.get("user") if isinstance(member, dict) else None) or payload.get("user") or {} + channel_id = str(payload.get("channel_id") or "") + guild_id = payload.get("guild_id") + source = SessionSource( + platform=Platform.RELAY, + chat_id=channel_id, + chat_type="channel" if guild_id else "dm", + user_id=str(user.get("id")) if isinstance(user, dict) and user.get("id") else None, + user_name=str(user.get("username")) if isinstance(user, dict) and user.get("username") else None, + guild_id=str(guild_id) if guild_id else None, + message_id=str(payload.get("id")) if payload.get("id") else None, + ) + return MessageEvent(text=text, message_type=MessageType.TEXT, source=source) + async def disconnect(self) -> None: if self._transport is not None: await self._transport.disconnect() diff --git a/gateway/relay/transport.py b/gateway/relay/transport.py index afe6f769f26..b557416c7ad 100644 --- a/gateway/relay/transport.py +++ b/gateway/relay/transport.py @@ -30,6 +30,13 @@ from gateway.relay.descriptor import CapabilityDescriptor # Callback the transport invokes for each inbound normalized event. InboundHandler = Callable[[MessageEvent], Awaitable[None]] +# Callback the transport invokes for each forwarded passthrough request (§5.1). +# The first arg is a PassthroughForward (gateway/relay/ws_transport.py) — typed +# as Any here to keep this protocol module free of a concrete-transport import +# (ws_transport imports FROM this module). The second is an optional bufferId +# (Phase 5 §5.3 buffered flip) the handler acks after durable handoff. +PassthroughHandler = Callable[[Any, Optional[str]], Awaitable[None]] + @runtime_checkable class RelayTransport(Protocol): @@ -51,6 +58,18 @@ class RelayTransport(Protocol): """Register the callback invoked with each inbound MessageEvent.""" ... + def set_passthrough_handler(self, handler: "PassthroughHandler") -> None: + """Register the callback invoked with each forwarded passthrough request. + + Phase 5 §5.1: the passthrough plane (Discord interactions, Twilio, …) + answers the provider's edge ACK at the connector, then forwards the real + request to the gateway over this same outbound socket (a hosted gateway + has no public inbound port). The transport invokes ``handler(forward, + buffer_id)`` for each ``passthrough_forward`` frame. Optional on a + transport (an in-memory stub may not implement it). + """ + ... + async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]: """Carry an outbound action (send/edit/typing) to the connector. diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py index b091d44faa8..eb17848e0b3 100644 --- a/gateway/relay/ws_transport.py +++ b/gateway/relay/ws_transport.py @@ -33,6 +33,7 @@ import asyncio import json import logging import uuid +from dataclasses import dataclass from typing import Any, Dict, Optional from gateway.platforms.base import MessageEvent, MessageType @@ -128,6 +129,54 @@ def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent: ) +@dataclass +class PassthroughForward: + """A connector-forwarded passthrough-plane request (Phase 5 §5.1). + + The connector answered the provider's latency-critical ACK at its edge, then + forwarded the real (already-sanitized) request to this gateway over the WS. + ``body`` is the exact decoded bytes the connector forwarded (the wire carries + it base64-encoded for byte parity). ``headers`` preserve arrival order. + """ + + platform: str + bot_id: str + method: str + path: str + headers: list[tuple[str, str]] + body: bytes + + +def _passthrough_from_wire(raw: Dict[str, Any]) -> PassthroughForward: + """Rebuild a PassthroughForward from the connector's wire frame. + + Mirrors the connector's ``PassthroughForward`` (relay/protocol.ts): the body + is base64-decoded back to the exact bytes the connector forwarded, so the + gateway re-processes byte-identical content (the connector is the trust + boundary; it already verified at the edge). + """ + import base64 + + body_b64 = raw.get("bodyB64", "") or "" + try: + body = base64.b64decode(body_b64) + except Exception: # noqa: BLE001 - a malformed body must not crash the reader + body = b"" + headers_raw = raw.get("headers", []) or [] + headers: list[tuple[str, str]] = [] + for pair in headers_raw: + if isinstance(pair, (list, tuple)) and len(pair) == 2: + headers.append((str(pair[0]), str(pair[1]))) + return PassthroughForward( + platform=str(raw.get("platform", "")), + bot_id=str(raw.get("botId", "")), + method=str(raw.get("method", "")), + path=str(raw.get("path", "")), + headers=headers, + body=body, + ) + + class WebSocketRelayTransport: """RelayTransport over a WebSocket connection the gateway dials to the connector.""" @@ -318,6 +367,16 @@ class WebSocketRelayTransport: handler = getattr(self, "_interrupt_inbound_handler", None) if handler is not None: await handler(frame.get("session_key", ""), frame.get("chat_id", "")) + elif ftype == "passthrough_forward": + # Phase 5 §5.1: a forwarded passthrough-plane request (Discord + # interaction, Twilio, …) the connector already edge-ACKed. It rides + # the SAME outbound WS as inbound messages so a hosted gateway needs + # no public inbound port. Dispatch to the adapter's handler; the + # bufferId (when present, §5.3 buffered flip) is passed for ack. + handler = getattr(self, "_passthrough_handler", None) + if handler is not None: + fwd = _passthrough_from_wire(frame.get("forward", {})) + await handler(fwd, frame.get("bufferId")) else: # hello/outbound/interrupt are gateway->connector; ignore if echoed. pass @@ -325,3 +384,12 @@ class WebSocketRelayTransport: def set_interrupt_inbound_handler(self, handler: Any) -> None: """Register the callback for connector->gateway interrupt_inbound frames.""" self._interrupt_inbound_handler = handler + + def set_passthrough_handler(self, handler: Any) -> None: + """Register the callback for connector->gateway passthrough_forward frames. + + Mirrors set_interrupt_inbound_handler: the runner/adapter wires this so a + forwarded passthrough request (Phase 5 §5.1) reaches the adapter over the + same outbound WS the gateway already holds. ``handler(forward, buffer_id)``. + """ + self._passthrough_handler = handler diff --git a/tests/gateway/relay/stub_connector.py b/tests/gateway/relay/stub_connector.py index 11a97cae53a..e309750d5e8 100644 --- a/tests/gateway/relay/stub_connector.py +++ b/tests/gateway/relay/stub_connector.py @@ -27,6 +27,7 @@ class StubConnector: self._descriptor = descriptor self._inbound: Optional[InboundHandler] = None self._interrupt_inbound: Optional[Any] = None + self._passthrough: Optional[Any] = None self.connected = False self.sent: List[Dict[str, Any]] = [] self.interrupts: List[Dict[str, Any]] = [] @@ -57,6 +58,12 @@ class StubConnector: bridge here so connector→gateway interrupt_inbound frames route to it.""" self._interrupt_inbound = handler + def set_passthrough_handler(self, handler: Any) -> None: + """Mirror the real WS transport: the adapter registers its passthrough + bridge here so connector→gateway passthrough_forward frames route to it + (Phase 5 §5.1).""" + self._passthrough = handler + async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]: self.sent.append(action) if action.get("op") == "send": @@ -85,3 +92,9 @@ class StubConnector: if self._interrupt_inbound is None: raise RuntimeError("no interrupt_inbound handler registered (call adapter.connect first)") await self._interrupt_inbound(session_key, chat_id) + + async def push_passthrough(self, forward: Any, buffer_id: Optional[str] = None) -> None: + """Simulate the connector forwarding a passthrough request over the WS (§5.1).""" + if self._passthrough is None: + raise RuntimeError("no passthrough handler registered (call adapter.connect first)") + await self._passthrough(forward, buffer_id) diff --git a/tests/gateway/relay/test_relay_passthrough.py b/tests/gateway/relay/test_relay_passthrough.py new file mode 100644 index 00000000000..51c5b8ee203 --- /dev/null +++ b/tests/gateway/relay/test_relay_passthrough.py @@ -0,0 +1,199 @@ +"""Relay passthrough-over-WS forwarding (Phase 5 §5.1). + +Proves the gateway side of §5.1: a connector-forwarded passthrough request +(Discord interaction, Twilio, …) arrives over the SAME outbound /relay WS as +inbound messages (a hosted gateway has no public inbound port), and the relay +adapter handles it — decoding the byte-preserved body and routing a Discord +interaction through the normal agent path (handle_message). + +Mirrors test_relay_interrupt.py's wiring discipline (connect() registers the +connector->gateway handlers on the transport). +""" + +from __future__ import annotations + +import base64 +import json + +import pytest + +from gateway.config import PlatformConfig +from gateway.relay.adapter import RelayAdapter +from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor +from gateway.relay.ws_transport import PassthroughForward, _passthrough_from_wire + +from tests.gateway.relay.stub_connector import StubConnector + + +def _desc() -> CapabilityDescriptor: + return CapabilityDescriptor( + contract_version=CONTRACT_VERSION, + platform="discord", + label="Discord", + max_message_length=2000, + supports_draft_streaming=False, + supports_edit=True, + supports_threads=True, + markdown_dialect="discord", + len_unit="chars", + ) + + +@pytest.fixture +def adapter(): + return RelayAdapter(PlatformConfig(), _desc(), transport=StubConnector(_desc())) + + +def _interaction_forward(payload: dict) -> PassthroughForward: + body = json.dumps(payload).encode("utf-8") + return PassthroughForward( + platform="discord", + bot_id="appShared", + method="POST", + path="/interactions/discord/appShared", + headers=[("content-type", "application/json")], + body=body, + ) + + +def test_passthrough_from_wire_byte_preserves_body(): + """The wire frame's base64 body decodes back to the exact bytes (parity with + the connector's toPassthroughForward).""" + original = json.dumps({"type": 2, "data": {"name": "ping"}, "guild_id": "g1"}).encode("utf-8") + wire = { + "platform": "discord", + "botId": "appShared", + "method": "POST", + "path": "/interactions/discord/appShared", + "headers": [["content-type", "application/json"]], + "bodyB64": base64.b64encode(original).decode("ascii"), + } + fwd = _passthrough_from_wire(wire) + assert fwd.platform == "discord" + assert fwd.bot_id == "appShared" + assert fwd.body == original + assert fwd.headers == [("content-type", "application/json")] + + +def test_passthrough_from_wire_tolerates_malformed_body(): + """A non-base64 body must not raise (the reader must never crash).""" + fwd = _passthrough_from_wire({"platform": "x", "bodyB64": "!!!not base64!!!"}) + assert fwd.body == b"" + + +@pytest.mark.asyncio +async def test_connect_wires_passthrough_handler_over_ws(adapter): + """connect() registers the passthrough handler on the transport so a + connector-delivered passthrough_forward frame reaches the adapter.""" + await adapter.connect() + stub = adapter._transport + assert stub._passthrough is not None + + +@pytest.mark.asyncio +async def test_discord_interaction_routes_through_handle_message(adapter, monkeypatch): + """A forwarded Discord application-command interaction is decoded and routed + through the normal agent path (handle_message) with a correct session source.""" + await adapter.connect() + stub = adapter._transport + + seen = [] + + async def fake_handle(event): + seen.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle) + + fwd = _interaction_forward( + { + "id": "interaction-1", + "type": 2, # APPLICATION_COMMAND + "channel_id": "chan-9", + "guild_id": "guild-7", + "data": {"name": "summarize"}, + "member": {"user": {"id": "user-3", "username": "ben"}}, + } + ) + await stub.push_passthrough(fwd, buffer_id=None) + + assert len(seen) == 1 + ev = seen[0] + assert ev.text == "summarize" + assert ev.source.chat_id == "chan-9" + assert ev.source.guild_id == "guild-7" + assert ev.source.user_id == "user-3" + assert ev.source.chat_type == "channel" + # Scope captured so the agent's reply re-asserts guild_id for egress. + assert adapter._scope_by_chat.get("chan-9") == "guild-7" + + +@pytest.mark.asyncio +async def test_message_component_interaction_uses_custom_id(adapter, monkeypatch): + """A MESSAGE_COMPONENT (button) interaction surfaces its custom_id as text.""" + await adapter.connect() + stub = adapter._transport + seen = [] + + async def fake_handle(event): + seen.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle) + fwd = _interaction_forward( + { + "id": "i2", + "type": 3, # MESSAGE_COMPONENT + "channel_id": "c2", + "guild_id": "g2", + "data": {"custom_id": "approve_btn"}, + "member": {"user": {"id": "u2", "username": "x"}}, + } + ) + await stub.push_passthrough(fwd) + assert len(seen) == 1 + assert seen[0].text == "approve_btn" + + +@pytest.mark.asyncio +async def test_malformed_interaction_body_does_not_raise(adapter, monkeypatch): + """A non-JSON forward is logged and dropped — never crashes the read loop.""" + await adapter.connect() + stub = adapter._transport + called = [] + + async def fake_handle(event): + called.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle) + bad = PassthroughForward( + platform="discord", + bot_id="appShared", + method="POST", + path="/x", + headers=[], + body=b"not json", + ) + await stub.push_passthrough(bad) # must not raise + assert called == [] + + +@pytest.mark.asyncio +async def test_non_discord_forward_dropped_cleanly(adapter, monkeypatch): + """A platform with no gateway-side handler yet (e.g. twilio) is dropped, not raised.""" + await adapter.connect() + stub = adapter._transport + called = [] + + async def fake_handle(event): + called.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle) + fwd = PassthroughForward( + platform="twilio", + bot_id="bot1", + method="POST", + path="/webhooks/twilio/seg", + headers=[], + body=b"From=+1&Body=hi", + ) + await stub.push_passthrough(fwd) # must not raise + assert called == [] From 61c266b0dc75562a97dc0a377a7dc141d0b0a5ac Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 22 Jun 2026 05:16:18 -0500 Subject: [PATCH 470/470] style(desktop): soften dark-mode syntax highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share one SHIKI_THEME (github-dark-dimmed) across code blocks and inline diffs so they can't drift, and pull token saturation/brightness back via a `.shiki` dark-mode filter. The dimmed theme alone only changes the background — which both surfaces strip — so the bright foregrounds needed the filter to actually calm down. --- apps/desktop/src/components/chat/diff-lines.tsx | 4 +--- apps/desktop/src/components/chat/shiki-highlighter.tsx | 5 ++++- apps/desktop/src/styles.css | 8 ++++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/chat/diff-lines.tsx b/apps/desktop/src/components/chat/diff-lines.tsx index fefc8024475..767e6029c6e 100644 --- a/apps/desktop/src/components/chat/diff-lines.tsx +++ b/apps/desktop/src/components/chat/diff-lines.tsx @@ -5,7 +5,7 @@ import * as React from 'react' import { useShikiHighlighter } from 'react-shiki' import type { ShikiTransformer } from 'shiki' -import { exceedsHighlightBudget } from '@/components/chat/shiki-highlighter' +import { exceedsHighlightBudget, SHIKI_THEME } from '@/components/chat/shiki-highlighter' import { shikiLanguageForFilename } from '@/lib/markdown-code' import { cn } from '@/lib/utils' @@ -18,8 +18,6 @@ import { cn } from '@/lib/utils' * Both drop git file-headers + `@@` hunk noise and the `+/-` gutter so changes * read by color + a 2px gutter accent, the way Cursor does. */ -const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const - type DiffKind = 'add' | 'context' | 'remove' interface DiffLine { diff --git a/apps/desktop/src/components/chat/shiki-highlighter.tsx b/apps/desktop/src/components/chat/shiki-highlighter.tsx index 5a047a62657..b984e60f3c8 100644 --- a/apps/desktop/src/components/chat/shiki-highlighter.tsx +++ b/apps/desktop/src/components/chat/shiki-highlighter.tsx @@ -30,7 +30,10 @@ interface HermesSyntaxHighlighterProps extends SyntaxHighlighterProps { defer?: boolean } -const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const +// `github-dark-dimmed` is GitHub's lower-contrast dark palette — the vivid +// `github-dark-default` tokens read harsh at our small code size. Shared by the +// inline diff renderer too (see diff-lines.tsx) so code + diffs match. +export const SHIKI_THEME = { dark: 'github-dark-dimmed', light: 'github-light-default' } as const /** * `github-light-default` colors comments `#6e7781` (~4.2:1 against the code diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 4ddc226b305..9487b636dfb 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1253,6 +1253,14 @@ canvas { display: grid; } +/* The github-dark token palette reads candy-bright at our small code size. + `github-dark-dimmed` only dims the *background* (which we strip), so soften + the token *foregrounds* directly — a small saturation + brightness pullback, + hues preserved — for both code blocks and inline diffs. Dark mode only. */ +.dark .shiki { + filter: saturate(0.82) brightness(0.92); +} + /* File edits (write_file / edit_file / patch) are the deliverable, not scaffolding — the diff is what the user reviews, like a PR. An *expanded* edit stays at full strength; collapsed it fades like any other row. The