From 1ca9b197500128cb3c6d5e87697085d66e0d39a2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:12:11 -0700 Subject: [PATCH 001/288] feat: add network.force_ipv4 config to fix IPv6 timeout issues (#8196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On servers with broken or unreachable IPv6, Python's socket.getaddrinfo returns AAAA records first. urllib/httpx/requests all try IPv6 connections first and hang for the full TCP timeout before falling back to IPv4. This affects web_extract, web_search, the OpenAI SDK, and all HTTP tools. Adds network.force_ipv4 config option (default: false) that monkey-patches socket.getaddrinfo to resolve as AF_INET when the caller didn't specify a family. Falls back to full resolution if no A record exists, so pure-IPv6 hosts still work. Applied early at all three entry points (CLI, gateway, cron scheduler) before any HTTP clients are created. Reported by user @29n — Chinese Ubuntu server with unreachable IPv6 causing timeouts on lobste.rs and other IPv6-enabled sites while Google/GitHub worked fine (IPv4-only resolution). --- cron/scheduler.py | 9 +++ gateway/run.py | 9 +++ hermes_cli/config.py | 8 +++ hermes_cli/main.py | 12 ++++ hermes_constants.py | 45 ++++++++++++++ tests/test_ipv4_preference.py | 114 ++++++++++++++++++++++++++++++++++ 6 files changed, 197 insertions(+) create mode 100644 tests/test_ipv4_preference.py diff --git a/cron/scheduler.py b/cron/scheduler.py index 1848cb29a024..34bf76b34554 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -626,6 +626,15 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: except Exception as e: logger.warning("Job '%s': failed to load config.yaml, using defaults: %s", job_id, e) + # Apply IPv4 preference if configured. + try: + from hermes_constants import apply_ipv4_preference + _net_cfg = _cfg.get("network", {}) + if isinstance(_net_cfg, dict) and _net_cfg.get("force_ipv4"): + apply_ipv4_preference(force=True) + except Exception: + pass + # Reasoning config from config.yaml from hermes_constants import parse_reasoning_effort effort = str(_cfg.get("agent", {}).get("reasoning_effort", "")).strip() diff --git a/gateway/run.py b/gateway/run.py index b4924f8f3718..1dcb87d85d85 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -206,6 +206,15 @@ def _ensure_ssl_certs() -> None: except Exception: pass # Non-fatal; gateway can still run with .env values +# Apply IPv4 preference if configured (before any HTTP clients are created). +try: + from hermes_constants import apply_ipv4_preference + _network_cfg = (_cfg if '_cfg' in dir() else {}).get("network", {}) + if isinstance(_network_cfg, dict) and _network_cfg.get("force_ipv4"): + apply_ipv4_preference(force=True) +except Exception: + pass + # Validate config structure early — log warnings so gateway operators see problems try: from hermes_cli.config import print_config_warnings diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5faa767a34c1..3d8004cd3e7e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -706,6 +706,14 @@ def _ensure_hermes_home_managed(home: Path): "backup_count": 3, # Number of rotated backup files to keep }, + # Network settings — workarounds for connectivity issues. + "network": { + # Force IPv4 connections. On servers with broken or unreachable IPv6, + # Python tries AAAA records first and hangs for the full TCP timeout + # before falling back to IPv4. Set to true to skip IPv6 entirely. + "force_ipv4": False, + }, + # Config schema version - bump this when adding new required fields "_config_version": 16, } diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 037c0a72feb0..9f73c07a5497 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -151,6 +151,18 @@ def _apply_profile_override() -> None: except Exception: pass # best-effort — don't crash the CLI if logging setup fails +# Apply IPv4 preference early, before any HTTP clients are created. +try: + from hermes_cli.config import load_config as _load_config_early + from hermes_constants import apply_ipv4_preference as _apply_ipv4 + _early_cfg = _load_config_early() + _net = _early_cfg.get("network", {}) + if isinstance(_net, dict) and _net.get("force_ipv4"): + _apply_ipv4(force=True) + del _early_cfg, _net +except Exception: + pass # best-effort — don't crash if config isn't available yet + import logging import time as _time from datetime import datetime diff --git a/hermes_constants.py b/hermes_constants.py index 85955d5482f0..40b4da5693f4 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -216,6 +216,51 @@ def get_env_path() -> Path: return get_hermes_home() / ".env" +# ─── Network Preferences ───────────────────────────────────────────────────── + + +def apply_ipv4_preference(force: bool = False) -> None: + """Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 connections. + + On servers with broken or unreachable IPv6, Python tries AAAA records + first and hangs for the full TCP timeout before falling back to IPv4. + This affects httpx, requests, urllib, the OpenAI SDK — everything that + uses ``socket.getaddrinfo``. + + When *force* is True, patches ``getaddrinfo`` so that calls with + ``family=AF_UNSPEC`` (the default) resolve as ``AF_INET`` instead, + skipping IPv6 entirely. If no A record exists, falls back to the + original unfiltered resolution so pure-IPv6 hosts still work. + + Safe to call multiple times — only patches once. + Set ``network.force_ipv4: true`` in ``config.yaml`` to enable. + """ + if not force: + return + + import socket + + # Guard against double-patching + if getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False): + return + + _original_getaddrinfo = socket.getaddrinfo + + def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + if family == 0: # AF_UNSPEC — caller didn't request a specific family + try: + return _original_getaddrinfo( + host, port, socket.AF_INET, type, proto, flags + ) + except socket.gaierror: + # No A record — fall back to full resolution (pure-IPv6 hosts) + return _original_getaddrinfo(host, port, family, type, proto, flags) + return _original_getaddrinfo(host, port, family, type, proto, flags) + + _ipv4_getaddrinfo._hermes_ipv4_patched = True # type: ignore[attr-defined] + socket.getaddrinfo = _ipv4_getaddrinfo # type: ignore[assignment] + + OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models" diff --git a/tests/test_ipv4_preference.py b/tests/test_ipv4_preference.py new file mode 100644 index 000000000000..c57016e22351 --- /dev/null +++ b/tests/test_ipv4_preference.py @@ -0,0 +1,114 @@ +"""Tests for network.force_ipv4 — the socket.getaddrinfo monkey-patch.""" + +import importlib +import socket +from unittest.mock import patch, MagicMock + +import pytest + + +def _reload_constants(): + """Reload hermes_constants to get a fresh apply_ipv4_preference.""" + import hermes_constants + importlib.reload(hermes_constants) + return hermes_constants + + +class TestApplyIPv4Preference: + """Tests for apply_ipv4_preference().""" + + def setup_method(self): + """Save the original getaddrinfo before each test.""" + self._original = socket.getaddrinfo + + def teardown_method(self): + """Restore the original getaddrinfo after each test.""" + socket.getaddrinfo = self._original + + def test_noop_when_force_false(self): + """No patch when force=False.""" + from hermes_constants import apply_ipv4_preference + original = socket.getaddrinfo + apply_ipv4_preference(force=False) + assert socket.getaddrinfo is original + + def test_patches_getaddrinfo_when_forced(self): + """Patches socket.getaddrinfo when force=True.""" + from hermes_constants import apply_ipv4_preference + original = socket.getaddrinfo + apply_ipv4_preference(force=True) + assert socket.getaddrinfo is not original + assert getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False) is True + + def test_double_patch_is_safe(self): + """Calling apply twice doesn't double-wrap.""" + from hermes_constants import apply_ipv4_preference + apply_ipv4_preference(force=True) + first_patch = socket.getaddrinfo + apply_ipv4_preference(force=True) + assert socket.getaddrinfo is first_patch + + def test_af_unspec_becomes_af_inet(self): + """AF_UNSPEC (default) calls get rewritten to AF_INET.""" + from hermes_constants import apply_ipv4_preference + + calls = [] + original = socket.getaddrinfo + + def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + calls.append(family) + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80))] + + socket.getaddrinfo = mock_getaddrinfo + apply_ipv4_preference(force=True) + + # Call with default family (AF_UNSPEC = 0) + socket.getaddrinfo("example.com", 80) + assert calls[-1] == socket.AF_INET, "AF_UNSPEC should be rewritten to AF_INET" + + def test_explicit_family_preserved(self): + """Explicit AF_INET6 requests are not intercepted.""" + from hermes_constants import apply_ipv4_preference + + calls = [] + original = socket.getaddrinfo + + def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + calls.append(family) + return [(family, socket.SOCK_STREAM, 6, "", ("::1", 80))] + + socket.getaddrinfo = mock_getaddrinfo + apply_ipv4_preference(force=True) + + socket.getaddrinfo("example.com", 80, family=socket.AF_INET6) + assert calls[-1] == socket.AF_INET6, "Explicit AF_INET6 should pass through" + + def test_fallback_on_gaierror(self): + """Falls back to AF_UNSPEC if AF_INET resolution fails.""" + from hermes_constants import apply_ipv4_preference + + call_families = [] + + def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + call_families.append(family) + if family == socket.AF_INET: + raise socket.gaierror("No A record") + # AF_UNSPEC fallback returns IPv6 + return [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 80))] + + socket.getaddrinfo = mock_getaddrinfo + apply_ipv4_preference(force=True) + + result = socket.getaddrinfo("ipv6only.example.com", 80) + # Should have tried AF_INET first, then fallen back to AF_UNSPEC + assert call_families == [socket.AF_INET, 0] + assert result[0][0] == socket.AF_INET6 + + +class TestConfigDefault: + """Verify network section exists in DEFAULT_CONFIG.""" + + def test_network_section_in_default_config(self): + from hermes_cli.config import DEFAULT_CONFIG + assert "network" in DEFAULT_CONFIG + assert DEFAULT_CONFIG["network"]["force_ipv4"] is False From 8e00b3a69e8c2910aae0c1f9ea3bf2619ab01699 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:20:39 -0700 Subject: [PATCH 002/288] fix(cron): steer model away from explicit deliver targets that lose topic context (#8187) Rewrite the cronjob tool's 'deliver' parameter description to strongly guide models toward omitting the parameter (which auto-detects origin including thread/topic). The previous description listed all platform names equally, inviting models to construct explicit targets like 'telegram:' which silently drops the thread_id. New description: - Leads with 'Omit this parameter' as the recommended path - Explicitly warns that platform:chat_id without :thread_id loses topics - Removes the long flat list of platform names that invited construction Also adds diagnostic logging at two key points: - _origin_from_env(): logs when thread_id is captured during job creation - _deliver_result(): warns when origin has thread_id but delivery target lost it; logs at debug when delivering to a specific thread Helps diagnose user-reported issue where cron responses from Telegram topics are delivered to the main chat instead of the originating topic. --- cron/scheduler.py | 15 +++++++++++++++ tools/cronjob_tools.py | 13 +++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 34bf76b34554..e6db77c0989a 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -219,6 +219,21 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option chat_id = target["chat_id"] thread_id = target.get("thread_id") + # Diagnostic: log thread_id for topic-aware delivery debugging + origin = job.get("origin") or {} + origin_thread = origin.get("thread_id") + if origin_thread and not thread_id: + logger.warning( + "Job '%s': origin has thread_id=%s but delivery target lost it " + "(deliver=%s, target=%s)", + job["id"], origin_thread, job.get("deliver", "local"), target, + ) + elif thread_id: + logger.debug( + "Job '%s': delivering to %s:%s thread_id=%s", + job["id"], platform_name, chat_id, thread_id, + ) + from tools.send_message_tool import _send_to_platform from gateway.config import load_gateway_config, Platform diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 80c88e35346d..d5c81ad7a868 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -6,12 +6,15 @@ """ import json +import logging import os import re import sys from pathlib import Path from typing import Any, Dict, List, Optional +logger = logging.getLogger(__name__) + # Import from cron module (will be available when properly installed) sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -68,11 +71,17 @@ def _origin_from_env() -> Optional[Dict[str, str]]: origin_platform = get_session_env("HERMES_SESSION_PLATFORM") origin_chat_id = get_session_env("HERMES_SESSION_CHAT_ID") if origin_platform and origin_chat_id: + thread_id = get_session_env("HERMES_SESSION_THREAD_ID") or None + if thread_id: + logger.debug( + "Cron origin captured thread_id=%s for %s:%s", + thread_id, origin_platform, origin_chat_id, + ) return { "platform": origin_platform, "chat_id": origin_chat_id, "chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None, - "thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None, + "thread_id": thread_id, } return None @@ -456,7 +465,7 @@ def remove_cronjob(job_id: str, task_id: str = None) -> str: }, "deliver": { "type": "string", - "description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, weixin, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, wecom_callback, email, sms, bluebubbles, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'" + "description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), or platform:chat_id:thread_id for a specific destination. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting." }, "skills": { "type": "array", From 4cadfef8e3edc05091a05f5c94435c55d75824db Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:22:34 -0700 Subject: [PATCH 003/288] fix(cli): restore stacked tool progress scrollback in TUI (#8201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI transition (4970705, f83e86d) replaced stacked per-tool history lines with a single live-updating spinner widget. While the spinner provides a nice live timer, it removed the scrollback history that users relied on to see what the agent did during a session. This restores stacked tool progress lines in 'all' and 'new' modes by printing persistent scrollback lines via _cprint() when tools complete, in addition to the existing live spinner display. Behavior per mode: - off: no scrollback lines, no spinner (unchanged) - new: scrollback line on completion, skipping consecutive same-tool repeats - all: scrollback line on every tool completion - verbose: no scrollback (run_agent.py handles verbose output directly) Implementation: - Store function_args from tool.started events in _pending_tool_info - On tool.completed, pop stored args and format via get_cute_tool_message() - FIFO queue per function_name handles concurrent tool execution - 'new' mode tracks _last_scrollback_tool for dedup - State cleared at end of agent run Reported by community user Mr.D — the stacked history provides transparency into what the agent is doing, which builds trust. Addresses user report from Discord about lost tool call visibility. --- cli.py | 34 ++++ tests/cli/test_tool_progress_scrollback.py | 189 +++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 tests/cli/test_tool_progress_scrollback.py diff --git a/cli.py b/cli.py index b3d51b12710b..337da08a7e57 100644 --- a/cli.py +++ b/cli.py @@ -1822,6 +1822,8 @@ def __init__( self._secret_deadline = 0 self._spinner_text: str = "" # thinking spinner text for TUI self._tool_start_time: float = 0.0 # monotonic timestamp when current tool started (for live elapsed) + self._pending_tool_info: dict = {} # function_name -> list of (preview, args) for stacked scrollback + self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup) self._command_running = False self._command_status = "" self._attached_images: list[Path] = [] @@ -6560,10 +6562,36 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview: On tool.started, records a monotonic timestamp so get_spinner_text() can show a live elapsed timer (the TUI poll loop already invalidates every ~0.15s, so the counter updates automatically). + + When tool_progress_mode is "all" or "new", also prints a persistent + stacked line to scrollback on tool.completed so users can see the + full history of tool calls (not just the current one in the spinner). """ if event_type == "tool.completed": import time as _time self._tool_start_time = 0.0 + # Print stacked scrollback line for "all" / "new" modes + if function_name and self.tool_progress_mode in ("all", "new"): + duration = kwargs.get("duration", 0.0) + is_error = kwargs.get("is_error", False) + # Pop stored args from tool.started for this function + stored = self._pending_tool_info.get(function_name) + stored_args = stored.pop(0) if stored else {} + if stored is not None and not stored: + del self._pending_tool_info[function_name] + # "new" mode: skip consecutive repeats of the same tool + if self.tool_progress_mode == "new" and function_name == self._last_scrollback_tool: + self._invalidate() + return + self._last_scrollback_tool = function_name + try: + from agent.display import get_cute_tool_message + line = get_cute_tool_message(function_name, stored_args, duration) + if is_error: + line = f"{line} [error]" + _cprint(f" {line}") + except Exception: + pass self._invalidate() return if event_type != "tool.started": @@ -6579,6 +6607,10 @@ def _on_tool_progress(self, event_type: str, function_name: str = None, preview: label = label[:_pl - 3] + "..." self._spinner_text = f"{emoji} {label}" self._tool_start_time = _time.monotonic() + # Store args for stacked scrollback line on completion + self._pending_tool_info.setdefault(function_name, []).append( + function_args if function_args is not None else {} + ) self._invalidate() if not self._voice_mode: @@ -9419,6 +9451,8 @@ def _expand_ref(m): self._agent_running = False self._spinner_text = "" self._tool_start_time = 0.0 + self._pending_tool_info.clear() + self._last_scrollback_tool = "" app.invalidate() # Refresh status line diff --git a/tests/cli/test_tool_progress_scrollback.py b/tests/cli/test_tool_progress_scrollback.py new file mode 100644 index 000000000000..7924f41598bd --- /dev/null +++ b/tests/cli/test_tool_progress_scrollback.py @@ -0,0 +1,189 @@ +"""Tests for stacked tool progress scrollback lines in the CLI TUI. + +When tool_progress_mode is "all" or "new", _on_tool_progress should print +persistent lines to scrollback on tool.completed, restoring the stacked +tool history that was lost when the TUI switched to a single-line spinner. +""" + +import os +import sys +import importlib +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# Module-level reference to the cli module (set by _make_cli on first call) +_cli_mod = None + + +def _make_cli(tool_progress="all"): + """Create a HermesCLI instance with minimal mocking.""" + global _cli_mod + _clean_config = { + "model": { + "default": "anthropic/claude-opus-4.6", + "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", + }, + "display": {"compact": False, "tool_progress": tool_progress}, + "agent": {}, + "terminal": {"env_type": "local"}, + } + clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} + prompt_toolkit_stubs = { + "prompt_toolkit": MagicMock(), + "prompt_toolkit.history": MagicMock(), + "prompt_toolkit.styles": MagicMock(), + "prompt_toolkit.patch_stdout": MagicMock(), + "prompt_toolkit.application": MagicMock(), + "prompt_toolkit.layout": MagicMock(), + "prompt_toolkit.layout.processors": MagicMock(), + "prompt_toolkit.filters": MagicMock(), + "prompt_toolkit.layout.dimension": MagicMock(), + "prompt_toolkit.layout.menus": MagicMock(), + "prompt_toolkit.widgets": MagicMock(), + "prompt_toolkit.key_binding": MagicMock(), + "prompt_toolkit.completion": MagicMock(), + "prompt_toolkit.formatted_text": MagicMock(), + "prompt_toolkit.auto_suggest": MagicMock(), + } + with patch.dict(sys.modules, prompt_toolkit_stubs), \ + patch.dict("os.environ", clean_env, clear=False): + import cli as mod + mod = importlib.reload(mod) + _cli_mod = mod + with patch.object(mod, "get_tool_definitions", return_value=[]), \ + patch.dict(mod.__dict__, {"CLI_CONFIG": _clean_config}): + return mod.HermesCLI() + + +class TestToolProgressScrollback: + """Stacked scrollback lines for 'all' and 'new' modes.""" + + def test_all_mode_prints_scrollback_on_completed(self): + """In 'all' mode, tool.completed prints a stacked line.""" + cli = _make_cli(tool_progress="all") + # Simulate tool.started + cli._on_tool_progress("tool.started", "terminal", "git log", {"command": "git log"}) + # Simulate tool.completed + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=1.5, is_error=False) + + mock_print.assert_called_once() + line = mock_print.call_args[0][0] + # Should contain tool info (the cute message format has "git log" for terminal) + assert "git log" in line or "$" in line + + def test_all_mode_prints_every_call(self): + """In 'all' mode, consecutive calls to the same tool each get a line.""" + cli = _make_cli(tool_progress="all") + with patch.object(_cli_mod, "_cprint") as mock_print: + # First call + cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False) + # Second call (same tool) + cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False) + + assert mock_print.call_count == 2 + + def test_new_mode_skips_consecutive_repeats(self): + """In 'new' mode, consecutive calls to the same tool only print once.""" + cli = _make_cli(tool_progress="new") + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False) + cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False) + + assert mock_print.call_count == 1 # Only the first read_file + + def test_new_mode_prints_when_tool_changes(self): + """In 'new' mode, a different tool name triggers a new line.""" + cli = _make_cli(tool_progress="new") + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.started", "read_file", "cli.py", {"path": "cli.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.1, is_error=False) + cli._on_tool_progress("tool.started", "search_files", "pattern", {"pattern": "test"}) + cli._on_tool_progress("tool.completed", "search_files", None, None, duration=0.3, is_error=False) + cli._on_tool_progress("tool.started", "read_file", "run_agent.py", {"path": "run_agent.py"}) + cli._on_tool_progress("tool.completed", "read_file", None, None, duration=0.2, is_error=False) + + # read_file, search_files, read_file (3rd prints because search_files broke the streak) + assert mock_print.call_count == 3 + + def test_off_mode_no_scrollback(self): + """In 'off' mode, no stacked lines are printed.""" + cli = _make_cli(tool_progress="off") + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False) + + mock_print.assert_not_called() + + def test_error_suffix_on_failed_tool(self): + """When is_error=True, the stacked line includes [error].""" + cli = _make_cli(tool_progress="all") + cli._on_tool_progress("tool.started", "terminal", "bad cmd", {"command": "bad cmd"}) + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=True) + + line = mock_print.call_args[0][0] + assert "[error]" in line + + def test_spinner_still_updates_on_started(self): + """tool.started still updates the spinner text for live display.""" + cli = _make_cli(tool_progress="all") + cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"}) + assert "git status" in cli._spinner_text + + def test_spinner_timer_clears_on_completed(self): + """tool.completed still clears the tool timer.""" + cli = _make_cli(tool_progress="all") + cli._on_tool_progress("tool.started", "terminal", "git status", {"command": "git status"}) + assert cli._tool_start_time > 0 + with patch.object(_cli_mod, "_cprint"): + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False) + assert cli._tool_start_time == 0.0 + + def test_concurrent_tools_produce_stacked_lines(self): + """Multiple tool.started followed by multiple tool.completed all produce lines.""" + cli = _make_cli(tool_progress="all") + with patch.object(_cli_mod, "_cprint") as mock_print: + # All start first (concurrent pattern) + cli._on_tool_progress("tool.started", "web_search", "query 1", {"query": "test 1"}) + cli._on_tool_progress("tool.started", "web_search", "query 2", {"query": "test 2"}) + # All complete + cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.0, is_error=False) + cli._on_tool_progress("tool.completed", "web_search", None, None, duration=1.5, is_error=False) + + assert mock_print.call_count == 2 + + def test_verbose_mode_no_duplicate_scrollback(self): + """In 'verbose' mode, scrollback lines are NOT printed (run_agent handles verbose output).""" + cli = _make_cli(tool_progress="verbose") + with patch.object(_cli_mod, "_cprint") as mock_print: + cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.5, is_error=False) + + mock_print.assert_not_called() + + def test_pending_info_stores_on_started(self): + """tool.started stores args for later use by tool.completed.""" + cli = _make_cli(tool_progress="all") + cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) + assert "terminal" in cli._pending_tool_info + assert len(cli._pending_tool_info["terminal"]) == 1 + assert cli._pending_tool_info["terminal"][0] == {"command": "ls"} + + def test_pending_info_consumed_on_completed(self): + """tool.completed consumes stored args (FIFO for concurrent).""" + cli = _make_cli(tool_progress="all") + cli._on_tool_progress("tool.started", "terminal", "ls", {"command": "ls"}) + cli._on_tool_progress("tool.started", "terminal", "pwd", {"command": "pwd"}) + assert len(cli._pending_tool_info["terminal"]) == 2 + with patch.object(_cli_mod, "_cprint"): + cli._on_tool_progress("tool.completed", "terminal", None, None, duration=0.1, is_error=False) + # First entry consumed, second remains + assert len(cli._pending_tool_info.get("terminal", [])) == 1 + assert cli._pending_tool_info["terminal"][0] == {"command": "pwd"} From 73f970fa4d7e24fc92e5da4e0b989ccb9ea0537a Mon Sep 17 00:00:00 2001 From: Teknium Date: Sat, 11 Apr 2026 23:42:19 -0700 Subject: [PATCH 004/288] fix: make gateway interrupt detection resilient to monitor task failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interrupt mechanism for regular text messages (non-commands) during active agent runs relied on a single async polling task (monitor_for_interrupt) with no error handling. If this task died silently due to an unhandled exception, stale adapter reference after reconnect, or any other failure, user messages sent during agent execution would be queued but never trigger an actual interrupt — the agent would continue running until it finished naturally, then process the queued message. Three improvements: 1. Error handling in monitor_for_interrupt(): wrap the polling body in try/except so transient errors are logged and retried instead of silently killing the task. 2. Fresh adapter reference on each poll iteration: re-resolve self.adapters.get(source.platform) every 200ms instead of capturing the adapter once at task creation time. This prevents stale references after adapter reconnects. 3. Backup interrupt check in the inactivity poll loop: both the unlimited and timeout-enabled paths now check for pending interrupts every 5 seconds (the existing poll interval). Uses a shared _interrupt_detected asyncio.Event to avoid double-firing when the primary monitor already handled the interrupt. Logs at INFO level with monitor task state for debugging. --- gateway/run.py | 96 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 78 insertions(+), 18 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 1dcb87d85d85..a5876bc14794 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8010,26 +8010,43 @@ async def track_agent(): tracking_task = asyncio.create_task(track_agent()) - # Monitor for interrupts from the adapter (new messages arriving) + # Monitor for interrupts from the adapter (new messages arriving). + # This is the PRIMARY interrupt path for regular text messages — + # Level 1 (base.py) catches them before _handle_message() is reached, + # so the Level 2 running_agent.interrupt() path never fires. + # The inactivity poll loop below has a BACKUP check in case this + # task dies (no error handling = silent death = lost interrupts). + _interrupt_detected = asyncio.Event() # shared with backup check + async def monitor_for_interrupt(): - adapter = self.adapters.get(source.platform) - if not adapter or not session_key: + if not session_key: return - + while True: await asyncio.sleep(0.2) # Check every 200ms - # Check if adapter has a pending interrupt for this session. - # Must use session_key (build_session_key output) — NOT - # source.chat_id — because the adapter stores interrupt events - # under the full session key. - if hasattr(adapter, 'has_pending_interrupt') and adapter.has_pending_interrupt(session_key): - agent = agent_holder[0] - if agent: - pending_event = adapter.get_pending_message(session_key) - pending_text = pending_event.text if pending_event else None - logger.debug("Interrupt detected from adapter, signaling agent...") - agent.interrupt(pending_text) - break + try: + # Re-resolve adapter each iteration so reconnects don't + # leave us holding a stale reference. + _adapter = self.adapters.get(source.platform) + if not _adapter: + continue + # Check if adapter has a pending interrupt for this session. + # Must use session_key (build_session_key output) — NOT + # source.chat_id — because the adapter stores interrupt events + # under the full session key. + if hasattr(_adapter, 'has_pending_interrupt') and _adapter.has_pending_interrupt(session_key): + agent = agent_holder[0] + if agent: + pending_event = _adapter.get_pending_message(session_key) + pending_text = pending_event.text if pending_event else None + logger.debug("Interrupt detected from adapter, signaling agent...") + agent.interrupt(pending_text) + _interrupt_detected.set() + break + except asyncio.CancelledError: + raise + except Exception as _mon_err: + logger.debug("monitor_for_interrupt error (will retry): %s", _mon_err) interrupt_monitor = asyncio.create_task(monitor_for_interrupt()) @@ -8094,8 +8111,34 @@ async def _notify_long_running(): _POLL_INTERVAL = 5.0 if _agent_timeout is None: - # Unlimited — just await the result. - response = await _executor_task + # Unlimited — still poll periodically for backup interrupt + # detection in case monitor_for_interrupt() silently died. + response = None + while True: + done, _ = await asyncio.wait( + {_executor_task}, timeout=_POLL_INTERVAL + ) + if done: + response = _executor_task.result() + break + # Backup interrupt check: if the monitor task died or + # missed the interrupt, catch it here. + if not _interrupt_detected.is_set() and session_key: + _backup_adapter = self.adapters.get(source.platform) + _backup_agent = agent_holder[0] + if (_backup_adapter and _backup_agent + and hasattr(_backup_adapter, 'has_pending_interrupt') + and _backup_adapter.has_pending_interrupt(session_key)): + _bp_event = _backup_adapter.get_pending_message(session_key) + _bp_text = _bp_event.text if _bp_event else None + logger.info( + "Backup interrupt detected for session %s " + "(monitor task state: %s)", + session_key[:20], + "done" if interrupt_monitor.done() else "running", + ) + _backup_agent.interrupt(_bp_text) + _interrupt_detected.set() else: # Poll loop: check the agent's built-in activity tracker # (updated by _touch_activity() on every tool call, API @@ -8139,6 +8182,23 @@ async def _notify_long_running(): if _idle_secs >= _agent_timeout: _inactivity_timeout = True break + # Backup interrupt check (same as unlimited path). + if not _interrupt_detected.is_set() and session_key: + _backup_adapter = self.adapters.get(source.platform) + _backup_agent = agent_holder[0] + if (_backup_adapter and _backup_agent + and hasattr(_backup_adapter, 'has_pending_interrupt') + and _backup_adapter.has_pending_interrupt(session_key)): + _bp_event = _backup_adapter.get_pending_message(session_key) + _bp_text = _bp_event.text if _bp_event else None + logger.info( + "Backup interrupt detected for session %s " + "(monitor task state: %s)", + session_key[:20], + "done" if interrupt_monitor.done() else "running", + ) + _backup_agent.interrupt(_bp_text) + _interrupt_detected.set() if _inactivity_timeout: # Build a diagnostic summary from the agent's activity tracker. From eb2a49f95a51c445a66dfdd55d476a236182a5a3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:33:42 -0700 Subject: [PATCH 005/288] fix: openai-codex and anthropic not appearing in /model picker for external credentials (#8224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users whose credentials exist only in external files — OpenAI Codex OAuth tokens in ~/.codex/auth.json or Anthropic Claude Code credentials in ~/.claude/.credentials.json — would not see those providers in the /model picker, even though hermes auth and hermes model detected them. Root cause: list_authenticated_providers() only checked the raw Hermes auth store and env vars. External credential file fallbacks (Codex CLI import, Claude Code file discovery) were never triggered. Fix (three parts): 1. _seed_from_singletons() in credential_pool.py: openai-codex now imports from ~/.codex/auth.json when the Hermes auth store is empty, mirroring resolve_codex_runtime_credentials(). 2. list_authenticated_providers() in model_switch.py: auth store + pool checks now run for ALL providers (not just OAuth auth_type), catching providers like anthropic that support both API key and OAuth. 3. list_authenticated_providers(): direct check for anthropic external credential files (Claude Code, Hermes PKCE). The credential pool intentionally gates anthropic behind is_provider_explicitly_configured() to prevent auxiliary tasks from silently consuming tokens. The /model picker bypasses this gate since it is discovery-oriented. --- agent/credential_pool.py | 17 ++ hermes_cli/model_switch.py | 39 ++- .../hermes_cli/test_codex_cli_model_picker.py | 241 ++++++++++++++++++ 3 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_codex_cli_model_picker.py diff --git a/agent/credential_pool.py b/agent/credential_pool.py index bff262bdc014..07afa9609929 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1128,6 +1128,23 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup elif provider == "openai-codex": state = _load_provider_state(auth_store, "openai-codex") tokens = state.get("tokens") if isinstance(state, dict) else None + # Fallback: import from Codex CLI (~/.codex/auth.json) if Hermes auth + # store has no tokens. This mirrors resolve_codex_runtime_credentials() + # so that load_pool() and list_authenticated_providers() detect tokens + # that only exist in the Codex CLI shared file. + if not (isinstance(tokens, dict) and tokens.get("access_token")): + try: + from hermes_cli.auth import _import_codex_cli_tokens, _save_codex_tokens + cli_tokens = _import_codex_cli_tokens() + if cli_tokens: + logger.info("Importing Codex CLI tokens into Hermes auth store.") + _save_codex_tokens(cli_tokens) + # Re-read state after import + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "openai-codex") + tokens = state.get("tokens") if isinstance(state, dict) else None + except Exception as exc: + logger.debug("Codex CLI token import failed: %s", exc) if isinstance(tokens, dict) and tokens.get("access_token"): active_sources.add("device_code") changed |= _upsert_entry( diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 273da0871972..988983dbadd9 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -839,8 +839,11 @@ def list_authenticated_providers( if any(os.environ.get(ev) for ev in pcfg.api_key_env_vars): has_creds = True break - if not has_creds and overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"): - # These use auth stores, not env vars — check for auth.json entries + # Check auth store and credential pool for non-env-var credentials. + # This applies to OAuth providers AND api_key providers that also + # support OAuth (e.g. anthropic supports both API key and Claude Code + # OAuth via external credential files). + if not has_creds: try: from hermes_cli.auth import _load_auth_store store = _load_auth_store() @@ -853,6 +856,38 @@ def list_authenticated_providers( has_creds = True except Exception as exc: logger.debug("Auth store check failed for %s: %s", pid, exc) + # Fallback: check the credential pool with full auto-seeding. + # This catches credentials that exist in external stores (e.g. + # Codex CLI ~/.codex/auth.json) which _seed_from_singletons() + # imports on demand but aren't in the raw auth.json yet. + if not has_creds: + try: + from agent.credential_pool import load_pool + pool = load_pool(hermes_slug) + if pool.has_credentials(): + has_creds = True + except Exception as exc: + logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc) + # Fallback: check external credential files directly. + # The credential pool gates anthropic behind + # is_provider_explicitly_configured() to prevent auxiliary tasks + # from silently consuming Claude Code tokens (PR #4210). + # But the /model picker is discovery-oriented — we WANT to show + # providers the user can switch to, even if they aren't currently + # configured. + if not has_creds and hermes_slug == "anthropic": + try: + from agent.anthropic_adapter import ( + read_claude_code_credentials, + read_hermes_oauth_credentials, + ) + hermes_creds = read_hermes_oauth_credentials() + cc_creds = read_claude_code_credentials() + if (hermes_creds and hermes_creds.get("accessToken")) or \ + (cc_creds and cc_creds.get("accessToken")): + has_creds = True + except Exception as exc: + logger.debug("Anthropic external creds check failed: %s", exc) if not has_creds: continue diff --git a/tests/hermes_cli/test_codex_cli_model_picker.py b/tests/hermes_cli/test_codex_cli_model_picker.py new file mode 100644 index 000000000000..2af837fde7fe --- /dev/null +++ b/tests/hermes_cli/test_codex_cli_model_picker.py @@ -0,0 +1,241 @@ +"""Regression test: openai-codex must appear in /model picker when +credentials are only in the Codex CLI shared file (~/.codex/auth.json) +and haven't been migrated to the Hermes auth store yet. + +Root cause: list_authenticated_providers() checked the raw Hermes auth +store but didn't know about the Codex CLI fallback import path. + +Fix: _seed_from_singletons() now imports from the Codex CLI when the +Hermes auth store has no openai-codex tokens, and +list_authenticated_providers() falls back to load_pool() for OAuth +providers. +""" + +import base64 +import json +import os +import sys +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + + +def _make_fake_jwt(expiry_offset: int = 3600) -> str: + """Build a fake JWT with a future expiry.""" + header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode() + exp = int(time.time()) + expiry_offset + payload_bytes = json.dumps({"exp": exp, "sub": "test"}).encode() + payload = base64.urlsafe_b64encode(payload_bytes).rstrip(b"=").decode() + return f"{header}.{payload}.fakesig" + + +@pytest.fixture() +def codex_cli_only_env(tmp_path, monkeypatch): + """Set up an environment where Codex tokens exist only in ~/.codex/auth.json, + NOT in the Hermes auth store.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + codex_home = tmp_path / ".codex" + codex_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + # Empty Hermes auth store + (hermes_home / "auth.json").write_text( + json.dumps({"version": 2, "providers": {}}) + ) + + # Valid Codex CLI tokens + fake_jwt = _make_fake_jwt() + (codex_home / "auth.json").write_text( + json.dumps({ + "tokens": { + "access_token": fake_jwt, + "refresh_token": "fake-refresh-token", + } + }) + ) + + # Clear provider env vars so only OAuth is a detection path + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", "GEMINI_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + return hermes_home + + +def test_codex_cli_tokens_detected_by_model_picker(codex_cli_only_env): + """openai-codex should appear when tokens only exist in ~/.codex/auth.json.""" + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="openai-codex", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "openai-codex" in slugs, ( + f"openai-codex not found in /model picker providers: {slugs}" + ) + + codex = next(p for p in providers if p["slug"] == "openai-codex") + assert codex["is_current"] is True + assert codex["total_models"] > 0 + + +def test_codex_cli_tokens_migrated_after_detection(codex_cli_only_env): + """After the /model picker detects Codex CLI tokens, they should be + migrated into the Hermes auth store for subsequent fast lookups.""" + from hermes_cli.model_switch import list_authenticated_providers + + # First call triggers migration + list_authenticated_providers(current_provider="openai-codex") + + # Verify tokens are now in Hermes auth store + auth_path = codex_cli_only_env / "auth.json" + store = json.loads(auth_path.read_text()) + providers = store.get("providers", {}) + assert "openai-codex" in providers, ( + f"openai-codex not migrated to Hermes auth store: {list(providers.keys())}" + ) + tokens = providers["openai-codex"].get("tokens", {}) + assert tokens.get("access_token"), "access_token missing after migration" + assert tokens.get("refresh_token"), "refresh_token missing after migration" + + +@pytest.fixture() +def hermes_auth_only_env(tmp_path, monkeypatch): + """Tokens already in Hermes auth store (no Codex CLI needed).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # Point CODEX_HOME to nonexistent dir to prove it's not needed + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) + + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 2, + "providers": { + "openai-codex": { + "tokens": { + "access_token": _make_fake_jwt(), + "refresh_token": "fake-refresh", + }, + "last_refresh": "2026-04-12T00:00:00Z", + } + }, + })) + + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + return hermes_home + + +def test_normal_path_still_works(hermes_auth_only_env): + """openai-codex appears when tokens are already in Hermes auth store.""" + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="openai-codex", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "openai-codex" in slugs + + +@pytest.fixture() +def claude_code_only_env(tmp_path, monkeypatch): + """Set up an environment where Anthropic credentials only exist in + ~/.claude/.credentials.json (Claude Code) — not in env vars or Hermes + auth store.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # No Codex CLI + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) + + (hermes_home / "auth.json").write_text( + json.dumps({"version": 2, "providers": {}}) + ) + + # Claude Code credentials in the correct format + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + (claude_dir / ".credentials.json").write_text(json.dumps({ + "claudeAiOauth": { + "accessToken": _make_fake_jwt(), + "refreshToken": "fake-refresh", + "expiresAt": int(time.time() * 1000) + 3_600_000, + } + })) + + # Patch Path.home() so the adapter finds the file + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + return hermes_home + + +def test_claude_code_file_detected_by_model_picker(claude_code_only_env): + """anthropic should appear when credentials only exist in ~/.claude/.credentials.json.""" + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="anthropic", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "anthropic" in slugs, ( + f"anthropic not found in /model picker providers: {slugs}" + ) + + anthropic = next(p for p in providers if p["slug"] == "anthropic") + assert anthropic["is_current"] is True + assert anthropic["total_models"] > 0 + + +def test_no_codex_when_no_credentials(tmp_path, monkeypatch): + """openai-codex should NOT appear when no credentials exist anywhere.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "no_codex")) + + (hermes_home / "auth.json").write_text( + json.dumps({"version": 2, "providers": {}}) + ) + + for var in [ + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "NOUS_API_KEY", "DEEPSEEK_API_KEY", "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", "GEMINI_API_KEY", + ]: + monkeypatch.delenv(var, raising=False) + + from hermes_cli.model_switch import list_authenticated_providers + + providers = list_authenticated_providers( + current_provider="openrouter", + max_models=10, + ) + slugs = [p["slug"] for p in providers] + assert "openai-codex" not in slugs, ( + "openai-codex should not appear without any credentials" + ) From 187122719867a7cb28388b8941bdb27ce8d97ce7 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sat, 11 Apr 2026 23:47:37 -0700 Subject: [PATCH 006/288] feat: rebrand OpenClaw references to Hermes during migration - Add rebrand_text() that replaces OpenClaw, Open Claw, Open-Claw, ClawdBot, and MoltBot with Hermes (case-insensitive, word-boundary) - Apply rebranding to memory entries (MEMORY.md, USER.md, daily memory) - Apply rebranding to SOUL.md and workspace instructions via new transform parameter on copy_file() - Fix moldbot -> moltbot typo across codebase (claw.py, migration script, docs, tests) - Add unit tests for rebrand_text and integration tests for memory and soul migration rebranding --- docs/migration/openclaw.md | 2 +- hermes_cli/claw.py | 4 +- .../scripts/openclaw_to_hermes.py | 39 ++++++-- tests/hermes_cli/test_claw.py | 6 +- tests/skills/test_openclaw_migration.py | 95 +++++++++++++++++++ website/docs/guides/migrate-from-openclaw.md | 4 +- website/docs/reference/cli-commands.md | 2 +- 7 files changed, 137 insertions(+), 15 deletions(-) diff --git a/docs/migration/openclaw.md b/docs/migration/openclaw.md index 8545636abd30..30f2f97e4d6e 100644 --- a/docs/migration/openclaw.md +++ b/docs/migration/openclaw.md @@ -118,7 +118,7 @@ For executed migrations, the full report is saved to `~/.hermes/migration/opencl ## Troubleshooting ### "OpenClaw directory not found" -The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moldbot`. If your OpenClaw is installed elsewhere, use `--source`: +The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moltbot`. If your OpenClaw is installed elsewhere, use `--source`: ```bash hermes claw migrate --source /path/to/.openclaw ``` diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index d0bfd73d23a7..b2540dffe369 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -50,7 +50,7 @@ ) # Known OpenClaw directory names (current + legacy) -_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moldbot") +_OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot") def _warn_if_gateway_running(auto_yes: bool) -> None: """Check if a Hermes gateway is running with connected platforms. @@ -216,7 +216,7 @@ def _cmd_migrate(args): source_dir = Path.home() / ".openclaw" if not source_dir.is_dir(): # Try legacy directory names - for legacy in (".clawdbot", ".moldbot"): + for legacy in (".clawdbot", ".moltbot"): candidate = Path.home() / legacy if candidate.is_dir(): source_dir = candidate diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index 759b798a56bd..d06d64f931c0 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -376,6 +376,24 @@ def backup_existing(path: Path, backup_root: Path) -> Optional[Path]: return dest +# ── Brand rewriting ───────────────────────────────────────── +# Replace OpenClaw brand names with Hermes in migrated text so that +# memory entries, user profiles, SOUL.md, and workspace instructions +# read as self-referential to the new agent identity. +_REBRAND_PATTERNS: List[Tuple[re.Pattern, str]] = [ + (re.compile(r'\bOpen[\s-]?Claw\b', re.IGNORECASE), 'Hermes'), + (re.compile(r'\bClawdBot\b', re.IGNORECASE), 'Hermes'), + (re.compile(r'\bMoltBot\b', re.IGNORECASE), 'Hermes'), +] + + +def rebrand_text(text: str) -> str: + """Replace OpenClaw / ClawdBot / MoltBot brand names with Hermes.""" + for pattern, replacement in _REBRAND_PATTERNS: + text = pattern.sub(replacement, text) + return text + + def parse_existing_memory_entries(path: Path) -> List[str]: if not path.exists(): return [] @@ -782,12 +800,13 @@ def write_overflow_entries(self, kind: str, entries: Sequence[str]) -> Optional[ path.write_text("\n".join(entries) + "\n", encoding="utf-8") return path - def copy_file(self, source: Path, destination: Path, kind: str) -> None: + def copy_file(self, source: Path, destination: Path, kind: str, + transform: Optional[Any] = None) -> None: if not source or not source.exists(): return if destination.exists(): - if sha256_file(source) == sha256_file(destination): + if not transform and sha256_file(source) == sha256_file(destination): self.record(kind, source, destination, "skipped", "Target already matches source") return if not self.overwrite: @@ -797,7 +816,13 @@ def copy_file(self, source: Path, destination: Path, kind: str) -> None: if self.execute: backup_path = self.maybe_backup(destination) ensure_parent(destination) - shutil.copy2(source, destination) + if transform: + content = read_text(source) + content = transform(content) + destination.write_text(content, encoding="utf-8") + shutil.copystat(source, destination) + else: + shutil.copy2(source, destination) self.record(kind, source, destination, "migrated", backup=str(backup_path) if backup_path else None) else: self.record(kind, source, destination, "migrated", "Would copy") @@ -807,7 +832,7 @@ def migrate_soul(self) -> None: if not source: self.record("soul", None, self.target_root / "SOUL.md", "skipped", "No OpenClaw SOUL.md found") return - self.copy_file(source, self.target_root / "SOUL.md", kind="soul") + self.copy_file(source, self.target_root / "SOUL.md", kind="soul", transform=rebrand_text) def migrate_workspace_agents(self) -> None: source = self.source_candidate( @@ -821,7 +846,7 @@ def migrate_workspace_agents(self) -> None: self.record("workspace-agents", source, None, "skipped", "No workspace target was provided") return destination = self.workspace_target / WORKSPACE_INSTRUCTIONS_FILENAME - self.copy_file(source, destination, kind="workspace-agents") + self.copy_file(source, destination, kind="workspace-agents", transform=rebrand_text) def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, kind: str) -> None: if not source or not source.exists(): @@ -832,6 +857,7 @@ def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, if not incoming: self.record(kind, source, destination, "skipped", "No importable entries found") return + incoming = [rebrand_text(entry) for entry in incoming] existing = parse_existing_memory_entries(destination) merged, stats, overflowed = merge_entries(existing, incoming, limit) @@ -927,7 +953,7 @@ def migrate_command_allowlist(self) -> None: def load_openclaw_config(self) -> Dict[str, Any]: # Check current name and legacy config filenames - for name in ("openclaw.json", "clawdbot.json", "moldbot.json"): + for name in ("openclaw.json", "clawdbot.json", "moltbot.json"): config_path = self.source_root / name if config_path.exists(): try: @@ -1543,6 +1569,7 @@ def migrate_daily_memory(self) -> None: if not all_incoming: self.record("daily-memory", source_dir, destination, "skipped", "No importable entries found in daily memory files") return + all_incoming = [rebrand_text(entry) for entry in all_incoming] existing = parse_existing_memory_entries(destination) merged, stats, overflowed = merge_entries(existing, all_incoming, self.memory_limit) diff --git a/tests/hermes_cli/test_claw.py b/tests/hermes_cli/test_claw.py index da3002f8c4d2..0094efb847b6 100644 --- a/tests/hermes_cli/test_claw.py +++ b/tests/hermes_cli/test_claw.py @@ -58,13 +58,13 @@ def test_finds_openclaw_dir(self, tmp_path): def test_finds_legacy_dirs(self, tmp_path): clawdbot = tmp_path / ".clawdbot" clawdbot.mkdir() - moldbot = tmp_path / ".moldbot" - moldbot.mkdir() + moltbot = tmp_path / ".moltbot" + moltbot.mkdir() with patch("pathlib.Path.home", return_value=tmp_path): found = claw_mod._find_openclaw_dirs() assert len(found) == 2 assert clawdbot in found - assert moldbot in found + assert moltbot in found def test_returns_empty_when_none_exist(self, tmp_path): with patch("pathlib.Path.home", return_value=tmp_path): diff --git a/tests/skills/test_openclaw_migration.py b/tests/skills/test_openclaw_migration.py index 99d126bed57d..6dc5b50fe8b8 100644 --- a/tests/skills/test_openclaw_migration.py +++ b/tests/skills/test_openclaw_migration.py @@ -722,3 +722,98 @@ def test_skill_installs_cleanly_under_skills_guard(): KNOWN_FALSE_POSITIVES = {"agent_config_mod", "python_os_environ", "hermes_config_mod"} for f in result.findings: assert f.pattern_id in KNOWN_FALSE_POSITIVES, f"Unexpected finding: {f}" + + +# ── rebrand_text tests ──────────────────────────────────────── + + +def test_rebrand_text_replaces_openclaw_variants(): + mod = load_module() + assert mod.rebrand_text("OpenClaw prefers Python 3.11") == "Hermes prefers Python 3.11" + assert mod.rebrand_text("I told Open Claw to use dark mode") == "I told Hermes to use dark mode" + assert mod.rebrand_text("Open-Claw config is great") == "Hermes config is great" + assert mod.rebrand_text("openclaw should always respond concisely") == "Hermes should always respond concisely" + assert mod.rebrand_text("OPENCLAW uses tools well") == "Hermes uses tools well" + + +def test_rebrand_text_replaces_legacy_bot_names(): + mod = load_module() + assert mod.rebrand_text("ClawdBot remembers my timezone") == "Hermes remembers my timezone" + assert mod.rebrand_text("clawdbot prefers tabs") == "Hermes prefers tabs" + assert mod.rebrand_text("MoltBot was configured for Spanish") == "Hermes was configured for Spanish" + assert mod.rebrand_text("moltbot uses Python") == "Hermes uses Python" + + +def test_rebrand_text_preserves_unrelated_content(): + mod = load_module() + text = "User prefers dark mode and lives in Las Vegas" + assert mod.rebrand_text(text) == text + + +def test_rebrand_text_handles_multiple_replacements(): + mod = load_module() + text = "OpenClaw said to ask ClawdBot about MoltBot settings" + assert mod.rebrand_text(text) == "Hermes said to ask Hermes about Hermes settings" + + +def test_migrate_memory_rebrands_entries(tmp_path): + mod = load_module() + source_root = tmp_path / "openclaw" + source_root.mkdir() + workspace = source_root / "workspace" + workspace.mkdir() + memory_md = workspace / "MEMORY.md" + memory_md.write_text( + "# Memory\n\n- OpenClaw should use Python 3.11\n- ClawdBot prefers dark mode\n", + encoding="utf-8", + ) + + target_root = tmp_path / "hermes" + target_root.mkdir() + (target_root / "memories").mkdir() + + migrator = mod.Migrator( + source_root=source_root, + target_root=target_root, + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=False, + output_dir=tmp_path / "report", + selected_options={"memory"}, + ) + migrator.migrate() + + result = (target_root / "memories" / "MEMORY.md").read_text(encoding="utf-8") + assert "OpenClaw" not in result + assert "ClawdBot" not in result + assert "Hermes" in result + + +def test_migrate_soul_rebrands_content(tmp_path): + mod = load_module() + source_root = tmp_path / "openclaw" + source_root.mkdir() + workspace = source_root / "workspace" + workspace.mkdir() + soul_md = workspace / "SOUL.md" + soul_md.write_text("You are OpenClaw, an AI assistant made by SparkLab.", encoding="utf-8") + + target_root = tmp_path / "hermes" + target_root.mkdir() + + migrator = mod.Migrator( + source_root=source_root, + target_root=target_root, + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=False, + output_dir=tmp_path / "report", + selected_options={"soul"}, + ) + migrator.migrate() + + result = (target_root / "SOUL.md").read_text(encoding="utf-8") + assert "OpenClaw" not in result + assert "You are Hermes" in result diff --git a/website/docs/guides/migrate-from-openclaw.md b/website/docs/guides/migrate-from-openclaw.md index 6322c725b0d2..5cf2f8c96fa7 100644 --- a/website/docs/guides/migrate-from-openclaw.md +++ b/website/docs/guides/migrate-from-openclaw.md @@ -23,7 +23,7 @@ hermes claw migrate --preset full --yes The migration always shows a full preview of what will be imported before making any changes. Review the list, then confirm to proceed. -Reads from `~/.openclaw/` by default. Legacy `~/.clawdbot/` or `~/.moldbot/` directories are detected automatically. Same for legacy config filenames (`clawdbot.json`, `moldbot.json`). +Reads from `~/.openclaw/` by default. Legacy `~/.clawdbot/` or `~/.moltbot/` directories are detected automatically. Same for legacy config filenames (`clawdbot.json`, `moltbot.json`). ## Options @@ -234,7 +234,7 @@ The migration resolves all three formats. For env templates and SecretRef object ### "OpenClaw directory not found" -The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moldbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`. +The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moltbot/`. If your installation is elsewhere, use `--source /path/to/your/openclaw`. ### "No provider API keys found" diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 12394ea44e96..07a2f76eb8fb 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -660,7 +660,7 @@ hermes insights [--days N] [--source platform] hermes claw migrate [options] ``` -Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moldbot`) and config filenames (`clawdbot.json`, `moldbot.json`). +Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moltbot`) and config filenames (`clawdbot.json`, `moltbot.json`). | Option | Description | |--------|-------------| From 36f57dbc51daccbc8fb32d31de7b2d2bb356d12a Mon Sep 17 00:00:00 2001 From: opriz Date: Sun, 12 Apr 2026 00:27:18 -0700 Subject: [PATCH 007/288] fix(migration): don't auto-archive OpenClaw source directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove auto-archival from hermes claw migrate — not its responsibility (hermes claw cleanup is still there for that). Skip MESSAGING_CWD when it points inside the OpenClaw source directory, which was the actual root cause of agent confusion after migration. Use Path.is_relative_to() for robust path containment check. Salvaged from PR #8192 by opriz. Co-authored-by: opriz --- hermes_cli/claw.py | 65 ++--------- .../scripts/openclaw_to_hermes.py | 12 +- tests/hermes_cli/test_claw.py | 110 +----------------- tests/skills/test_openclaw_migration.py | 32 +++++ 4 files changed, 54 insertions(+), 165 deletions(-) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index b2540dffe369..0f9e28cbcce5 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -87,8 +87,8 @@ def _warn_if_gateway_running(auto_yes: bool) -> None: print_info("Migration cancelled. Stop the gateway and try again.") sys.exit(0) -# State files commonly found in OpenClaw workspace directories that cause -# confusion after migration (the agent discovers them and writes to them) +# State files commonly found in OpenClaw workspace directories — listed +# during cleanup to help the user decide whether to archive _WORKSPACE_STATE_GLOBS = ( "*/todo.json", "*/sessions/*", @@ -133,7 +133,7 @@ def _find_openclaw_dirs() -> list[Path]: def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]: - """Scan an OpenClaw directory for workspace state files that cause confusion. + """Scan an OpenClaw directory for workspace state files. Returns a list of (path, description) tuples. """ @@ -384,65 +384,16 @@ def _cmd_migrate(args): # Print results _print_migration_report(report, dry_run=False) - # After successful migration, offer to archive the source directory - if report.get("summary", {}).get("migrated", 0) > 0: - _offer_source_archival(source_dir, auto_yes) - - -def _offer_source_archival(source_dir: Path, auto_yes: bool = False): - """After migration, offer to rename the source directory to prevent state fragmentation. - - OpenClaw workspace directories contain state files (todo.json, sessions, etc.) - that the agent may discover and write to, causing confusion. Renaming the - directory prevents this. - """ - if not source_dir.is_dir(): - return - - # Scan for state files that could cause problems - state_files = _scan_workspace_state(source_dir) - - print() - print_header("Post-Migration Cleanup") - print_info("The OpenClaw directory still exists and contains workspace state files") - print_info("that can confuse the agent (todo lists, sessions, logs).") - if state_files: - print() - print(color(" Found state files:", Colors.YELLOW)) - # Show up to 10 most relevant findings - for path, desc in state_files[:10]: - print(f" {desc}") - if len(state_files) > 10: - print(f" ... and {len(state_files) - 10} more") - print() - print_info(f"Recommend: rename {source_dir.name}/ to {source_dir.name}.pre-migration/") - print_info("This prevents the agent from discovering old workspace directories.") - print_info("You can always rename it back if needed.") - print() - - if not auto_yes and not sys.stdin.isatty(): - print_info("Non-interactive session — skipping archival.") - print_info("Run later with: hermes claw cleanup") - return - - if auto_yes or prompt_yes_no(f"Archive {source_dir} now?", default=True): - try: - archive_path = _archive_directory(source_dir) - print_success(f"Archived: {source_dir} → {archive_path}") - print_info("The original directory has been renamed, not deleted.") - print_info(f"To undo: mv {archive_path} {source_dir}") - except OSError as e: - print_error(f"Could not archive: {e}") - print_info(f"You can do it manually: mv {source_dir} {source_dir}.pre-migration") - else: - print_info("Skipped. You can archive later with: hermes claw cleanup") + # Source directory is left untouched — archiving is not the migration + # tool's responsibility. Users who want to clean up can run + # 'hermes claw cleanup' separately. def _cmd_cleanup(args): """Archive leftover OpenClaw directories after migration. Scans for OpenClaw directories that still exist after migration and offers - to rename them to .pre-migration to prevent state fragmentation. + to rename them to .pre-migration to free disk space. """ dry_run = getattr(args, "dry_run", False) auto_yes = getattr(args, "yes", False) @@ -517,7 +468,7 @@ def _cmd_cleanup(args): if state_files: print() - print(color(f" {len(state_files)} state file(s) that could cause confusion:", Colors.YELLOW)) + print(color(f" {len(state_files)} state file(s) found:", Colors.YELLOW)) for path, desc in state_files[:8]: print(f" {desc}") if len(state_files) > 8: diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index d06d64f931c0..9b58eab59210 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -1023,7 +1023,17 @@ def migrate_messaging_settings(self, config: Optional[Dict[str, Any]] = None) -> .get("workspace") ) if isinstance(workspace, str) and workspace.strip(): - additions["MESSAGING_CWD"] = workspace.strip() + ws_path = workspace.strip() + # Skip if the workspace points inside the OpenClaw source directory — + # that path will be stale after migration and would cause the Hermes + # gateway to use the old OpenClaw workspace as its cwd, picking up + # OpenClaw's AGENTS.md, MEMORY.md, etc. + try: + inside_source = Path(ws_path).resolve().is_relative_to(self.source_root.resolve()) + except (ValueError, OSError): + inside_source = False + if not inside_source: + additions["MESSAGING_CWD"] = ws_path allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json" if allowlist_path.exists(): diff --git a/tests/hermes_cli/test_claw.py b/tests/hermes_cli/test_claw.py index 0094efb847b6..d7528890e208 100644 --- a/tests/hermes_cli/test_claw.py +++ b/tests/hermes_cli/test_claw.py @@ -297,7 +297,6 @@ def test_execute_with_confirmation(self, tmp_path, capsys): patch.object(claw_mod, "_load_migration_module", return_value=fake_mod), patch.object(claw_mod, "get_config_path", return_value=config_path), patch.object(claw_mod, "prompt_yes_no", return_value=True), - patch.object(claw_mod, "_offer_source_archival"), patch("sys.stdin", mock_stdin), ): claw_mod._cmd_migrate(args) @@ -306,43 +305,8 @@ def test_execute_with_confirmation(self, tmp_path, capsys): assert "Migration Results" in captured.out assert "Migration complete!" in captured.out - def test_execute_offers_archival_on_success(self, tmp_path, capsys): - """After successful migration, _offer_source_archival should be called.""" - openclaw_dir = tmp_path / ".openclaw" - openclaw_dir.mkdir() - - fake_mod = ModuleType("openclaw_to_hermes") - fake_mod.resolve_selected_options = MagicMock(return_value={"soul"}) - fake_migrator = MagicMock() - fake_migrator.migrate.return_value = { - "summary": {"migrated": 3, "skipped": 0, "conflict": 0, "error": 0}, - "items": [ - {"kind": "soul", "status": "migrated", "destination": str(tmp_path / "SOUL.md")}, - ], - } - fake_mod.Migrator = MagicMock(return_value=fake_migrator) - - args = Namespace( - source=str(openclaw_dir), - dry_run=False, preset="full", overwrite=False, - migrate_secrets=False, workspace_target=None, - skill_conflict="skip", yes=True, - ) - - with ( - patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"), - patch.object(claw_mod, "_load_migration_module", return_value=fake_mod), - patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"), - patch.object(claw_mod, "save_config"), - patch.object(claw_mod, "load_config", return_value={}), - patch.object(claw_mod, "_offer_source_archival") as mock_archival, - ): - claw_mod._cmd_migrate(args) - - mock_archival.assert_called_once_with(openclaw_dir, True) - - def test_dry_run_skips_archival(self, tmp_path, capsys): - """Dry run should not offer archival.""" + def test_dry_run_does_not_touch_source(self, tmp_path, capsys): + """Dry run should not modify the source directory.""" openclaw_dir = tmp_path / ".openclaw" openclaw_dir.mkdir() @@ -369,11 +333,10 @@ def test_dry_run_skips_archival(self, tmp_path, capsys): patch.object(claw_mod, "get_config_path", return_value=tmp_path / "config.yaml"), patch.object(claw_mod, "save_config"), patch.object(claw_mod, "load_config", return_value={}), - patch.object(claw_mod, "_offer_source_archival") as mock_archival, ): claw_mod._cmd_migrate(args) - mock_archival.assert_not_called() + assert openclaw_dir.is_dir() # Source untouched def test_execute_cancelled_by_user(self, tmp_path, capsys): openclaw_dir = tmp_path / ".openclaw" @@ -506,73 +469,6 @@ def test_full_preset_enables_secrets(self, tmp_path, capsys): assert call_kwargs["migrate_secrets"] is True -# --------------------------------------------------------------------------- -# _offer_source_archival -# --------------------------------------------------------------------------- - - -class TestOfferSourceArchival: - """Test the post-migration archival offer.""" - - def test_archives_with_auto_yes(self, tmp_path, capsys): - source = tmp_path / ".openclaw" - source.mkdir() - (source / "workspace").mkdir() - (source / "workspace" / "todo.json").write_text("{}") - - claw_mod._offer_source_archival(source, auto_yes=True) - - captured = capsys.readouterr() - assert "Archived" in captured.out - assert not source.exists() - assert (tmp_path / ".openclaw.pre-migration").is_dir() - - def test_skips_when_user_declines(self, tmp_path, capsys): - source = tmp_path / ".openclaw" - source.mkdir() - - mock_stdin = MagicMock() - mock_stdin.isatty.return_value = True - - with ( - patch.object(claw_mod, "prompt_yes_no", return_value=False), - patch("sys.stdin", mock_stdin), - ): - claw_mod._offer_source_archival(source, auto_yes=False) - - captured = capsys.readouterr() - assert "Skipped" in captured.out - assert source.is_dir() # Still exists - - def test_noop_when_source_missing(self, tmp_path, capsys): - claw_mod._offer_source_archival(tmp_path / "nonexistent", auto_yes=True) - captured = capsys.readouterr() - assert captured.out == "" # No output - - def test_shows_state_files(self, tmp_path, capsys): - source = tmp_path / ".openclaw" - source.mkdir() - ws = source / "workspace" - ws.mkdir() - (ws / "todo.json").write_text("{}") - - with patch.object(claw_mod, "prompt_yes_no", return_value=False): - claw_mod._offer_source_archival(source, auto_yes=False) - - captured = capsys.readouterr() - assert "todo.json" in captured.out - - def test_handles_archive_error(self, tmp_path, capsys): - source = tmp_path / ".openclaw" - source.mkdir() - - with patch.object(claw_mod, "_archive_directory", side_effect=OSError("permission denied")): - claw_mod._offer_source_archival(source, auto_yes=True) - - captured = capsys.readouterr() - assert "Could not archive" in captured.out - - # --------------------------------------------------------------------------- # _cmd_cleanup # --------------------------------------------------------------------------- diff --git a/tests/skills/test_openclaw_migration.py b/tests/skills/test_openclaw_migration.py index 6dc5b50fe8b8..671d764f0d96 100644 --- a/tests/skills/test_openclaw_migration.py +++ b/tests/skills/test_openclaw_migration.py @@ -185,6 +185,38 @@ def test_migrator_optionally_imports_supported_secrets_and_messaging_settings(tm assert "TELEGRAM_BOT_TOKEN=123:abc" in env_text +def test_messaging_cwd_skipped_when_inside_source(tmp_path: Path): + """MESSAGING_CWD pointing inside the OpenClaw source dir should be skipped.""" + mod = load_module() + source = tmp_path / ".openclaw" + target = tmp_path / ".hermes" + target.mkdir() + + # Workspace path is inside the source directory + ws_path = str(source / "workspace") + (source / "credentials").mkdir(parents=True) + (source / "openclaw.json").write_text( + json.dumps({"agents": {"defaults": {"workspace": ws_path}}}), + encoding="utf-8", + ) + + migrator = mod.Migrator( + source_root=source, + target_root=target, + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=True, + output_dir=target / "migration-report", + selected_options={"messaging-settings"}, + ) + migrator.migrate() + + env_path = target / ".env" + if env_path.exists(): + assert "MESSAGING_CWD" not in env_path.read_text(encoding="utf-8") + + def test_migrator_can_execute_only_selected_categories(tmp_path: Path): mod = load_module() source = tmp_path / ".openclaw" From fdf55e0fe9cce9067c59ec1c3b2309475b1f21c2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:34:01 -0700 Subject: [PATCH 008/288] feat(cli): show random tip on new session start (#8225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'tip of the day' feature that displays a random one-liner about Hermes Agent features on every new session — CLI startup, /clear, /new, and gateway /new across all messaging platforms. - New hermes_cli/tips.py module with 210 curated tips covering slash commands, keybindings, CLI flags, config options, tools, gateway platforms, profiles, sessions, memory, skills, cron, voice, security, and more - CLI: tips display in skin-aware dim gold color after the welcome line - Gateway: tips append to the /new and /reset response on all platforms - Fully wrapped in try/except — tips are non-critical and never break startup or reset Display format (CLI): ✦ Tip: /btw asks a quick side question without tools or history. Display format (gateway): ✨ Session reset! Starting fresh. ✦ Tip: hermes -c resumes your most recent CLI session. --- cli.py | 35 +++++ gateway/run.py | 11 +- hermes_cli/tips.py | 280 ++++++++++++++++++++++++++++++++++ tests/hermes_cli/test_tips.py | 77 ++++++++++ 4 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 hermes_cli/tips.py create mode 100644 tests/hermes_cli/test_tips.py diff --git a/cli.py b/cli.py index 337da08a7e57..55372bbb00d3 100644 --- a/cli.py +++ b/cli.py @@ -5244,9 +5244,33 @@ def process_command(self, command: str) -> bool: context_length=ctx_len, ) _cprint(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n") + # Show a random tip on new session + try: + from hermes_cli.tips import get_random_tip + _tip = get_random_tip() + try: + from hermes_cli.skin_engine import get_active_skin + _tip_color = get_active_skin().get_color("banner_dim", "#B8860B") + except Exception: + _tip_color = "#B8860B" + cc.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]") + except Exception: + pass else: self.show_banner() print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n") + # Show a random tip on new session + try: + from hermes_cli.tips import get_random_tip + _tip = get_random_tip() + try: + from hermes_cli.skin_engine import get_active_skin + _tip_color = get_active_skin().get_color("banner_dim", "#B8860B") + except Exception: + _tip_color = "#B8860B" + self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]") + except Exception: + pass elif canonical == "history": self.show_history() elif canonical == "title": @@ -8075,6 +8099,17 @@ def run(self): _welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands." _welcome_color = "#FFF8DC" self.console.print(f"[{_welcome_color}]{_welcome_text}[/]") + # Show a random tip to help users discover features + try: + from hermes_cli.tips import get_random_tip + _tip = get_random_tip() + try: + _tip_color = _welcome_skin.get_color("banner_dim", "#B8860B") + except Exception: + _tip_color = "#B8860B" + self.console.print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]") + except Exception: + pass # Tips are non-critical — never break startup if self.preloaded_skills and not self._startup_skills_line_shown: skills_label = ", ".join(self.preloaded_skills) self.console.print( diff --git a/gateway/run.py b/gateway/run.py index a5876bc14794..56573d58aebf 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3965,9 +3965,16 @@ async def _handle_reset_command(self, event: MessageEvent) -> str: except Exception: pass + # Append a random tip to the reset message + try: + from hermes_cli.tips import get_random_tip + _tip_line = f"\n✦ Tip: {get_random_tip()}" + except Exception: + _tip_line = "" + if session_info: - return f"{header}\n\n{session_info}" - return header + return f"{header}\n\n{session_info}{_tip_line}" + return f"{header}{_tip_line}" async def _handle_profile_command(self, event: MessageEvent) -> str: """Handle /profile — show active profile name and home directory.""" diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py new file mode 100644 index 000000000000..223d8d54026f --- /dev/null +++ b/hermes_cli/tips.py @@ -0,0 +1,280 @@ +"""Random tips shown at CLI session start to help users discover features.""" + +import random +from typing import Optional + +# --------------------------------------------------------------------------- +# Tip corpus — ~200 one-liners covering slash commands, CLI flags, config, +# keybindings, tools, gateway, skills, profiles, and workflow tricks. +# --------------------------------------------------------------------------- + +TIPS = [ + # --- Slash Commands --- + "/btw asks a quick side question without tools or history — great for clarifications.", + "/background runs a task in a separate session while your current one stays free.", + "/branch forks the current session so you can explore a different direction without losing progress.", + "/compress manually compresses conversation context when things get long.", + "/rollback lists filesystem checkpoints — restore files the agent modified to any prior state.", + "/rollback diff 2 previews what changed since checkpoint 2 without restoring anything.", + "/rollback 2 src/file.py restores a single file from a specific checkpoint.", + "/title \"my project\" names your session — resume it later with /resume or hermes -c.", + "/resume picks up where you left off in a previously named session.", + "/queue queues a message for the next turn without interrupting the current one.", + "/undo removes the last user/assistant exchange from the conversation.", + "/retry resends your last message — useful when the agent's response wasn't quite right.", + "/verbose cycles tool progress display: off → new → all → verbose.", + "/reasoning high increases the model's thinking depth. /reasoning show displays the reasoning.", + "/fast toggles priority processing for faster API responses (provider-dependent).", + "/yolo skips all dangerous command approval prompts for the rest of the session.", + "/model lets you switch models mid-session — try /model sonnet or /model gpt-5.", + "/model --global changes your default model permanently.", + "/personality pirate sets a fun personality — 14 built-in options from kawaii to shakespeare.", + "/skin changes the CLI theme — try ares, mono, slate, poseidon, or charizard.", + "/statusbar toggles a persistent bar showing model, tokens, context fill %, cost, and duration.", + "/tools disable browser temporarily removes browser tools for the current session.", + "/browser connect attaches browser tools to your running Chrome instance via CDP.", + "/plugins lists installed plugins and their status.", + "/cron manages scheduled tasks — set up recurring prompts with delivery to any platform.", + "/reload-mcp hot-reloads MCP server configuration without restarting.", + "/usage shows token usage, cost breakdown, and session duration.", + "/insights shows usage analytics for the last 30 days.", + "/paste checks your clipboard for an image and attaches it to your next message.", + "/profile shows which profile is active and its home directory.", + "/config shows your current configuration at a glance.", + "/stop kills all running background processes spawned by the agent.", + + # --- @ Context References --- + "@file:path/to/file.py injects file contents directly into your message.", + "@file:main.py:10-50 injects only lines 10-50 of a file.", + "@folder:src/ injects a directory tree listing.", + "@diff injects your unstaged git changes into the message.", + "@staged injects your staged git changes (git diff --staged).", + "@git:5 injects the last 5 commits with full patches.", + "@url:https://example.com fetches and injects a web page's content.", + "Typing @ triggers filesystem path completion — navigate to any file interactively.", + "Combine multiple references: \"Review @file:main.py and @file:test.py for consistency.\"", + + # --- Keybindings --- + "Alt+Enter (or Ctrl+J) inserts a newline for multi-line input.", + "Ctrl+C interrupts the agent. Double-press within 2 seconds to force exit.", + "Ctrl+Z suspends Hermes to the background — run fg in your shell to resume.", + "Tab accepts auto-suggestion ghost text or autocompletes slash commands.", + "Type a new message while the agent is working to interrupt and redirect it.", + "Alt+V pastes an image from your clipboard into the conversation.", + "Pasting 5+ lines auto-saves to a file and inserts a compact reference instead.", + + # --- CLI Flags --- + "hermes -c resumes your most recent CLI session. hermes -c \"project name\" resumes by title.", + "hermes -w creates an isolated git worktree — perfect for parallel agent workflows.", + "hermes -w -q \"Fix issue #42\" combines worktree isolation with a one-shot query.", + "hermes chat -t web,terminal enables only specific toolsets for a focused session.", + "hermes chat -s github-pr-workflow preloads a skill at launch.", + "hermes chat -q \"query\" runs a single non-interactive query and exits.", + "hermes chat --max-turns 200 overrides the default 90-iteration limit per turn.", + "hermes chat --checkpoints enables filesystem snapshots before every destructive file change.", + "hermes --yolo bypasses all dangerous command approval prompts for the entire session.", + "hermes chat --source telegram tags the session for filtering in hermes sessions list.", + "hermes -p work chat runs under a specific profile without changing your default.", + + # --- CLI Subcommands --- + "hermes doctor --fix diagnoses and auto-repairs config and dependency issues.", + "hermes dump outputs a compact setup summary — great for bug reports.", + "hermes config set KEY VALUE auto-routes secrets to .env and everything else to config.yaml.", + "hermes config edit opens config.yaml in your default editor.", + "hermes config check scans for missing or stale configuration options.", + "hermes sessions browse opens an interactive session picker with search.", + "hermes sessions stats shows session counts by platform and database size.", + "hermes sessions prune --older-than 30 cleans up old sessions.", + "hermes skills search react --source skills-sh searches the skills.sh public directory.", + "hermes skills check scans installed hub skills for upstream updates.", + "hermes skills tap add myorg/skills-repo adds a custom GitHub skill source.", + "hermes skills snapshot export setup.json exports your skill configuration for backup or sharing.", + "hermes mcp add github --command npx adds MCP servers from the command line.", + "hermes mcp serve runs Hermes itself as an MCP server for other agents.", + "hermes auth add lets you add multiple API keys for credential pool rotation.", + "hermes completion bash >> ~/.bashrc enables tab completion for all commands and profiles.", + "hermes logs -f follows agent.log in real time. --level WARNING --since 1h filters output.", + "hermes backup creates a zip backup of your entire Hermes home directory.", + "hermes profile create coder creates an isolated profile that becomes its own command.", + "hermes profile create work --clone copies your current config and keys to a new profile.", + "hermes update syncs new bundled skills to ALL profiles automatically.", + "hermes gateway install sets up Hermes as a system service (systemd/launchd).", + "hermes memory setup lets you configure an external memory provider (Honcho, Mem0, etc.).", + "hermes webhook subscribe creates event-driven webhook routes with HMAC validation.", + + # --- Configuration --- + "Set display.bell_on_complete: true in config.yaml to hear a bell when long tasks finish.", + "Set display.streaming: true to see tokens appear in real time as the model generates.", + "Set display.show_reasoning: true to watch the model's chain-of-thought reasoning.", + "Set display.compact: true to reduce whitespace in output for denser information.", + "Set display.busy_input_mode: queue to queue messages instead of interrupting the agent.", + "Set display.resume_display: minimal to skip the full conversation recap on session resume.", + "Set compression.threshold: 0.50 to control when auto-compression fires (default: 50% of context).", + "Set agent.max_turns: 200 to let the agent take more tool-calling steps per turn.", + "Set file_read_max_chars: 200000 to increase the max content per read_file call.", + "Set approvals.mode: smart to let an LLM auto-approve safe commands and auto-deny dangerous ones.", + "Set fallback_model in config.yaml to automatically fail over to a backup provider.", + "Set privacy.redact_pii: true to hash user IDs and phone numbers before sending to the LLM.", + "Set browser.record_sessions: true to auto-record browser sessions as WebM videos.", + "Set worktree: true in config.yaml to always create a git worktree (same as hermes -w).", + "Set security.website_blocklist.enabled: true to block specific domains from web tools.", + "Set cron.wrap_response: false to deliver raw agent output without the cron header/footer.", + "HERMES_TIMEZONE overrides the server timezone with any IANA timezone string.", + "Environment variable substitution works in config.yaml: use ${VAR_NAME} syntax.", + "Quick commands in config.yaml run shell commands instantly with zero token usage.", + "Custom personalities can be defined in config.yaml under agent.personalities.", + "provider_routing controls OpenRouter provider sorting, whitelisting, and blacklisting.", + + # --- Tools & Capabilities --- + "execute_code runs Python scripts that call Hermes tools programmatically — results stay out of context.", + "delegate_task spawns up to 3 concurrent sub-agents with isolated contexts for parallel work.", + "web_extract works on PDF URLs — pass any PDF link and it converts to markdown.", + "search_files is ripgrep-backed and faster than grep — use it instead of terminal grep.", + "patch uses 9 fuzzy matching strategies so minor whitespace differences won't break edits.", + "patch supports V4A format for bulk multi-file edits in a single call.", + "read_file suggests similar filenames when a file isn't found.", + "read_file auto-deduplicates — re-reading an unchanged file returns a lightweight stub.", + "browser_vision takes a screenshot and analyzes it with AI — works for CAPTCHAs and visual content.", + "browser_console can evaluate JavaScript expressions in the page context.", + "image_generate creates images with FLUX 2 Pro and automatic 2x upscaling.", + "text_to_speech converts text to audio — plays as voice bubbles on Telegram.", + "send_message can reach any connected messaging platform from within a session.", + "The todo tool helps the agent track complex multi-step tasks during a session.", + "session_search performs full-text search across ALL past conversations.", + "The agent automatically saves preferences, corrections, and environment facts to memory.", + "mixture_of_agents routes hard problems through 4 frontier LLMs collaboratively.", + "Terminal commands support background mode with notify_on_complete for long-running tasks.", + "Terminal background processes support watch_patterns to alert on specific output lines.", + "The terminal tool supports 6 backends: local, Docker, SSH, Modal, Daytona, and Singularity.", + + # --- Profiles --- + "Each profile gets its own config, API keys, memory, sessions, skills, and cron jobs.", + "Profile names become shell commands — 'hermes profile create coder' creates the 'coder' command.", + "hermes profile export coder -o backup.tar.gz creates a portable profile archive.", + "If two profiles accidentally share a bot token, the second gateway is blocked with a clear error.", + + # --- Sessions --- + "Sessions auto-generate descriptive titles after the first exchange — no manual naming needed.", + "Session titles support lineage: \"my project\" → \"my project #2\" → \"my project #3\".", + "When exiting, Hermes prints a resume command with session ID and stats.", + "hermes sessions export backup.jsonl exports all sessions for backup or analysis.", + "hermes -r SESSION_ID resumes any specific past session by its ID.", + + # --- Memory --- + "Memory is a frozen snapshot — changes appear in the system prompt only at next session start.", + "Memory entries are automatically scanned for prompt injection and exfiltration patterns.", + "The agent has two memory stores: personal notes (~2200 chars) and user profile (~1375 chars).", + "Corrections you give the agent (\"no, do it this way\") are often auto-saved to memory.", + + # --- Skills --- + "Over 80 bundled skills covering github, creative, mlops, productivity, research, and more.", + "Every installed skill automatically becomes a slash command — type / to see them all.", + "hermes skills install official/security/1password installs optional skills from the repo.", + "Skills can restrict to specific OS platforms — some only load on macOS or Linux.", + "skills.external_dirs in config.yaml lets you load skills from custom directories.", + "The agent can create its own skills as procedural memory using skill_manage.", + "The plan skill saves markdown plans under .hermes/plans/ in the active workspace.", + + # --- Cron & Scheduling --- + "Cron jobs can attach skills: hermes cron add --skill blogwatcher \"Check for new posts\".", + "Cron delivery targets include telegram, discord, slack, email, sms, and 12+ more platforms.", + "If a cron response starts with [SILENT], delivery is suppressed — useful for monitoring-only jobs.", + "Cron supports relative delays (30m), intervals (every 2h), cron expressions, and ISO timestamps.", + "Cron jobs run in completely fresh agent sessions — prompts must be self-contained.", + + # --- Voice --- + "Voice mode works with zero API keys if faster-whisper is installed (free local speech-to-text).", + "Five TTS providers available: Edge TTS (free), ElevenLabs, OpenAI, NeuTTS (free local), MiniMax.", + "/voice on enables voice mode in the CLI. Ctrl+B toggles push-to-talk recording.", + "Streaming TTS plays sentences as they generate — you don't wait for the full response.", + "Voice messages on Telegram, Discord, WhatsApp, and Slack are auto-transcribed.", + + # --- Gateway & Messaging --- + "Hermes runs on 18 platforms: Telegram, Discord, Slack, WhatsApp, Signal, Matrix, email, and more.", + "hermes gateway install sets it up as a system service that starts on boot.", + "DingTalk uses Stream Mode — no webhooks or public URL needed.", + "BlueBubbles brings iMessage to Hermes via a local macOS server.", + "Webhook routes support HMAC validation, rate limiting, and event filtering.", + "The API server exposes an OpenAI-compatible endpoint compatible with Open WebUI and LibreChat.", + "Discord voice channel mode: the bot joins VC, transcribes speech, and talks back.", + "group_sessions_per_user: true gives each person their own session in group chats.", + "/sethome marks a chat as the home channel for cron job deliveries.", + "The gateway supports inactivity-based timeouts — active agents can run indefinitely.", + + # --- Security --- + "Dangerous command approval has 4 tiers: once, session, always (permanent allowlist), deny.", + "Smart approval mode uses an LLM to auto-approve safe commands and flag dangerous ones.", + "SSRF protection blocks private networks, loopback, link-local, and cloud metadata addresses.", + "Tirith pre-exec scanning detects homograph URL spoofing and pipe-to-interpreter patterns.", + "MCP subprocesses receive a filtered environment — only safe system vars pass through.", + "Context files (.hermes.md, AGENTS.md) are security-scanned for prompt injection before loading.", + "command_allowlist in config.yaml permanently approves specific shell command patterns.", + + # --- Context & Compression --- + "Context auto-compresses when it reaches the threshold — memories are flushed and history summarized.", + "The status bar turns yellow, then orange, then red as context fills up.", + "SOUL.md at ~/.hermes/SOUL.md is the agent's primary identity — customize it to shape behavior.", + "Hermes loads project context from .hermes.md, AGENTS.md, CLAUDE.md, or .cursorrules (first match).", + "Subdirectory AGENTS.md files are discovered progressively as the agent navigates into folders.", + "Context files are capped at 20,000 characters with smart head/tail truncation.", + + # --- Browser --- + "Five browser providers: local Chromium, Browserbase, Browser Use, Camofox, and Firecrawl.", + "Camofox is an anti-detection browser — Firefox fork with C++ fingerprint spoofing.", + "browser_navigate returns a page snapshot automatically — no need to call browser_snapshot after.", + "browser_vision with annotate=true overlays numbered labels on interactive elements.", + + # --- MCP --- + "MCP servers are configured in config.yaml — both stdio and HTTP transports supported.", + "Per-server tool filtering: tools.include whitelists and tools.exclude blacklists specific tools.", + "MCP servers auto-generate toolsets at runtime — hermes tools can toggle them per platform.", + "MCP OAuth support: auth: oauth enables browser-based authorization with PKCE.", + + # --- Checkpoints & Rollback --- + "Checkpoints have zero overhead when no files are modified — enabled by default.", + "A pre-rollback snapshot is saved automatically so you can undo the undo.", + "/rollback also undoes the conversation turn, so the agent doesn't remember rolled-back changes.", + "Checkpoints use shadow repos in ~/.hermes/checkpoints/ — your project's .git is never touched.", + + # --- Batch & Data --- + "batch_runner.py processes hundreds of prompts in parallel for training data generation.", + "hermes chat -Q enables quiet mode for programmatic use — suppresses banner and spinner.", + "Trajectory saving (--save-trajectories) captures full tool-use traces for model training.", + + # --- Plugins --- + "Three plugin types: general (tools/hooks), memory providers, and context engines.", + "hermes plugins install owner/repo installs plugins directly from GitHub.", + "8 external memory providers available: Honcho, OpenViking, Mem0, Hindsight, and more.", + "Plugin hooks include pre_tool_call, post_tool_call, pre_llm_call, and post_llm_call.", + + # --- Miscellaneous --- + "Prompt caching (Anthropic) reduces costs by reusing cached system prompt prefixes.", + "The agent auto-generates session titles in a background thread — zero latency impact.", + "Smart model routing can auto-route simple queries to a cheaper model.", + "Slash commands support prefix matching: /h resolves to /help, /mod to /model.", + "Dragging a file path into the terminal auto-attaches images or sends as context.", + ".worktreeinclude in your repo root lists gitignored files to copy into worktrees.", + "hermes acp runs Hermes as an ACP server for VS Code, Zed, and JetBrains integration.", + "Custom providers: save named endpoints in config.yaml under custom_providers.", + "HERMES_EPHEMERAL_SYSTEM_PROMPT injects a system prompt that's never persisted to history.", + "credential_pool_strategies supports fill_first, round_robin, least_used, and random rotation.", + "hermes login supports OAuth-based auth for Nous and OpenAI Codex providers.", + "The API server supports both Chat Completions and Responses API with server-side state.", + "tool_preview_length: 0 in config shows full file paths in the spinner's activity feed.", + "hermes status --deep runs deeper diagnostic checks across all components.", +] + + +def get_random_tip(exclude_recent: int = 0) -> str: + """Return a random tip string. + + Args: + exclude_recent: not used currently; reserved for future + deduplication across sessions. + """ + return random.choice(TIPS) + + +def get_tip_count() -> int: + """Return the total number of tips available.""" + return len(TIPS) diff --git a/tests/hermes_cli/test_tips.py b/tests/hermes_cli/test_tips.py new file mode 100644 index 000000000000..88e00e0ce61a --- /dev/null +++ b/tests/hermes_cli/test_tips.py @@ -0,0 +1,77 @@ +"""Tests for hermes_cli/tips.py — random tip display at session start.""" + +import pytest +from hermes_cli.tips import TIPS, get_random_tip, get_tip_count + + +class TestTipsCorpus: + """Validate the tip corpus itself.""" + + def test_has_at_least_200_tips(self): + assert len(TIPS) >= 200, f"Expected 200+ tips, got {len(TIPS)}" + + def test_no_duplicates(self): + assert len(TIPS) == len(set(TIPS)), "Duplicate tips found" + + def test_all_tips_are_strings(self): + for i, tip in enumerate(TIPS): + assert isinstance(tip, str), f"Tip {i} is not a string: {type(tip)}" + + def test_no_empty_tips(self): + for i, tip in enumerate(TIPS): + assert tip.strip(), f"Tip {i} is empty or whitespace-only" + + def test_max_length_reasonable(self): + """Tips should fit on a single terminal line (~120 chars max).""" + for i, tip in enumerate(TIPS): + assert len(tip) <= 150, ( + f"Tip {i} too long ({len(tip)} chars): {tip[:60]}..." + ) + + def test_no_leading_trailing_whitespace(self): + for i, tip in enumerate(TIPS): + assert tip == tip.strip(), f"Tip {i} has leading/trailing whitespace" + + +class TestGetRandomTip: + """Validate the get_random_tip() function.""" + + def test_returns_string(self): + tip = get_random_tip() + assert isinstance(tip, str) + assert len(tip) > 0 + + def test_returns_tip_from_corpus(self): + tip = get_random_tip() + assert tip in TIPS + + def test_randomness(self): + """Multiple calls should eventually return different tips.""" + seen = set() + for _ in range(50): + seen.add(get_random_tip()) + # With 200+ tips and 50 draws, we should see at least 10 unique + assert len(seen) >= 10, f"Only got {len(seen)} unique tips in 50 draws" + + +class TestGetTipCount: + def test_matches_corpus_length(self): + assert get_tip_count() == len(TIPS) + + +class TestTipIntegrationInCLI: + """Test that the tip display code in cli.py works correctly.""" + + def test_tip_import_works(self): + """The import used in cli.py must succeed.""" + from hermes_cli.tips import get_random_tip + assert callable(get_random_tip) + + def test_tip_display_format(self): + """Verify the Rich markup format doesn't break.""" + tip = get_random_tip() + color = "#B8860B" + markup = f"[dim {color}]✦ Tip: {tip}[/]" + # Should not contain nested/broken Rich tags + assert markup.count("[/]") == 1 + assert "[dim #B8860B]" in markup From f53a5a7fe1fa17514130b18e13ba220c64d9f609 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:36:22 -0700 Subject: [PATCH 009/288] fix: suppress duplicate completion notifications when agent already consumed output via wait/poll/log (#8228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent calls process(action='wait') or process(action='poll') and gets the exited status, the completion_queue notification is redundant — the agent already has the output from the tool return. Previously, the drain loops in CLI and gateway would still inject the [SYSTEM: Background process completed] message, causing the agent to receive the same information twice. Fix: track session IDs in _completion_consumed set when wait/poll/log returns an exited process. Drain loops in cli.py and gateway watcher skip completion events for consumed sessions. Watch pattern events are never suppressed (they have independent semantics). Adds 4 tests covering wait/poll/log marking and running-process negative case. --- cli.py | 15 ++++- gateway/run.py | 4 +- .../test_internal_event_bypass_pairing.py | 4 ++ tests/tools/test_notify_on_complete.py | 59 +++++++++++++++++++ tools/process_registry.py | 15 ++++- 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/cli.py b/cli.py index 55372bbb00d3..809be5131715 100644 --- a/cli.py +++ b/cli.py @@ -9385,9 +9385,14 @@ def process_loop(): from tools.process_registry import process_registry if not process_registry.completion_queue.empty(): evt = process_registry.completion_queue.get_nowait() - _synth = _format_process_notification(evt) - if _synth: - self._pending_input.put(_synth) + # Skip if the agent already consumed this via wait/poll/log + _evt_sid = evt.get("session_id", "") + if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): + pass # already delivered via tool result + else: + _synth = _format_process_notification(evt) + if _synth: + self._pending_input.put(_synth) except Exception: pass continue @@ -9513,6 +9518,10 @@ def _restart_recording(): from tools.process_registry import process_registry while not process_registry.completion_queue.empty(): evt = process_registry.completion_queue.get_nowait() + # Skip if the agent already consumed this via wait/poll/log + _evt_sid = evt.get("session_id", "") + if evt.get("type") == "completion" and process_registry.is_completion_consumed(_evt_sid): + continue # already delivered via tool result _synth = _format_process_notification(evt) if _synth: self._pending_input.put(_synth) diff --git a/gateway/run.py b/gateway/run.py index 56573d58aebf..9cf39a16050f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7009,7 +7009,9 @@ async def _run_process_watcher(self, watcher: dict) -> None: if session.exited: # --- Agent-triggered completion: inject synthetic message --- - if agent_notify: + # Skip if the agent already consumed the result via wait/poll/log + from tools.process_registry import process_registry as _pr_check + if agent_notify and not _pr_check.is_completion_consumed(session_id): from tools.ansi_strip import strip_ansi _out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else "" synth_text = ( diff --git a/tests/gateway/test_internal_event_bypass_pairing.py b/tests/gateway/test_internal_event_bypass_pairing.py index 46a96e5aa24e..1c3f9f0c946e 100644 --- a/tests/gateway/test_internal_event_bypass_pairing.py +++ b/tests/gateway/test_internal_event_bypass_pairing.py @@ -28,12 +28,16 @@ class _FakeRegistry: def __init__(self, sessions): self._sessions = list(sessions) + self._completion_consumed: set = set() def get(self, session_id): if self._sessions: return self._sessions.pop(0) return None + def is_completion_consumed(self, session_id): + return session_id in self._completion_consumed + def _build_runner(monkeypatch, tmp_path) -> GatewayRunner: """Create a GatewayRunner with notifications set to 'all'.""" diff --git a/tests/tools/test_notify_on_complete.py b/tests/tools/test_notify_on_complete.py index 411f95f7e036..64d198970cb5 100644 --- a/tests/tools/test_notify_on_complete.py +++ b/tests/tools/test_notify_on_complete.py @@ -289,3 +289,62 @@ class TestCodeExecutionBlocked: def test_notify_on_complete_blocked_in_sandbox(self): from tools.code_execution_tool import _TERMINAL_BLOCKED_PARAMS assert "notify_on_complete" in _TERMINAL_BLOCKED_PARAMS + + +# ========================================================================= +# Completion consumed suppression +# ========================================================================= + +class TestCompletionConsumed: + """Test that wait/poll/log suppress redundant completion notifications.""" + + def test_wait_marks_completion_consumed(self, registry): + """wait() returning exited status marks session as consumed.""" + s = _make_session(sid="proc_wait", notify_on_complete=True, output="done") + s.exited = True + s.exit_code = 0 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + + # Notification is in the queue + assert not registry.completion_queue.empty() + assert not registry.is_completion_consumed("proc_wait") + + # Agent calls wait() — gets the result directly + result = registry.wait("proc_wait", timeout=1) + assert result["status"] == "exited" + + # Now the completion is marked as consumed + assert registry.is_completion_consumed("proc_wait") + + def test_poll_marks_completion_consumed(self, registry): + """poll() returning exited status marks session as consumed.""" + s = _make_session(sid="proc_poll", notify_on_complete=True, output="done") + s.exited = True + s.exit_code = 0 + registry._finished[s.id] = s + + result = registry.poll("proc_poll") + assert result["status"] == "exited" + assert registry.is_completion_consumed("proc_poll") + + def test_log_marks_completion_consumed(self, registry): + """read_log() on exited session marks as consumed.""" + s = _make_session(sid="proc_log", notify_on_complete=True, output="line1\nline2") + s.exited = True + s.exit_code = 0 + registry._finished[s.id] = s + + result = registry.read_log("proc_log") + assert result["status"] == "exited" + assert registry.is_completion_consumed("proc_log") + + def test_running_process_not_consumed(self, registry): + """poll() on a still-running process does not mark as consumed.""" + s = _make_session(sid="proc_running", notify_on_complete=True, output="partial") + registry._running[s.id] = s + + result = registry.poll("proc_running") + assert result["status"] == "running" + assert not registry.is_completion_consumed("proc_running") diff --git a/tools/process_registry.py b/tools/process_registry.py index 044a4e77674b..a5dbc3b1bd44 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -136,6 +136,10 @@ def __init__(self): import queue as _queue_mod self.completion_queue: _queue_mod.Queue = _queue_mod.Queue() + # Track sessions whose completion was already consumed by the agent + # via wait/poll/log. Drain loops skip notifications for these. + self._completion_consumed: set = set() + @staticmethod def _clean_shell_noise(text: str) -> str: """Strip shell startup warnings from the beginning of output.""" @@ -613,6 +617,10 @@ def _move_to_finished(self, session: ProcessSession): # ----- Query Methods ----- + def is_completion_consumed(self, session_id: str) -> bool: + """Check if a completion notification was already consumed via wait/poll/log.""" + return session_id in self._completion_consumed + def get(self, session_id: str) -> Optional[ProcessSession]: """Get a session by ID (running or finished).""" with self._lock: @@ -640,6 +648,7 @@ def poll(self, session_id: str) -> dict: } if session.exited: result["exit_code"] = session.exit_code + self._completion_consumed.add(session_id) if session.detached: result["detached"] = True result["note"] = "Process recovered after restart -- output history unavailable" @@ -665,13 +674,16 @@ def read_log(self, session_id: str, offset: int = 0, limit: int = 200) -> dict: else: selected = lines[offset:offset + limit] - return { + result = { "session_id": session.id, "status": "exited" if session.exited else "running", "output": "\n".join(selected), "total_lines": total_lines, "showing": f"{len(selected)} lines", } + if session.exited: + self._completion_consumed.add(session_id) + return result def wait(self, session_id: str, timeout: int = None) -> dict: """ @@ -714,6 +726,7 @@ def wait(self, session_id: str, timeout: int = None) -> dict: while time.monotonic() < deadline: session = self._refresh_detached_session(session) if session.exited: + self._completion_consumed.add(session_id) result = { "status": "exited", "exit_code": session.exit_code, From 81ac62c0e95dd4de80daabc691fe2889b990055d Mon Sep 17 00:00:00 2001 From: bravohenry Date: Sun, 12 Apr 2026 00:37:47 -0700 Subject: [PATCH 010/288] fix(weixin): split chatty short replies into separate bubbles, keep structured content together Add content-aware splitting to compact mode: short chat-like exchanges (2-6 short lines without headings/lists/quotes) get separate message bubbles for a natural chat feel, while structured content (tables, headings with body, numbered lists) stays in a single message. Cherry-picked from PR #7587 by bravohenry, adapted to the compact/legacy split_per_line architecture from #7903. --- gateway/platforms/weixin.py | 46 ++++++++++++++++++++++++++++++++++-- tests/gateway/test_weixin.py | 35 +++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 5821d922f8cf..dc4e7cf96980 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -734,6 +734,42 @@ def _split_delivery_units_for_weixin(content: str) -> List[str]: return [unit for unit in units if unit] +def _looks_like_chatty_line_for_weixin(line: str) -> bool: + """Return True when a line looks like a standalone chat utterance.""" + stripped = line.strip() + if not stripped: + return False + if len(stripped) > 48: + return False + if line.startswith((" ", "\t")): + return False + if stripped.startswith((">", "-", "*", "【")): + return False + if re.match(r"^\*\*[^*]+\*\*$", stripped): + return False + if re.match(r"^\d+\.\s", stripped): + return False + return True + + +def _looks_like_heading_line_for_weixin(line: str) -> bool: + """Return True when a short line behaves like a plain-text heading.""" + stripped = line.strip() + if not stripped: + return False + return len(stripped) <= 24 and stripped.endswith((":", ":")) + + +def _should_split_short_chat_block_for_weixin(block: str) -> bool: + """Split only chat-like multiline blocks into separate bubbles.""" + lines = [line for line in block.splitlines() if line.strip()] + if not 2 <= len(lines) <= 6: + return False + if _looks_like_heading_line_for_weixin(lines[0]): + return False + return all(_looks_like_chatty_line_for_weixin(line) for line in lines) + + def _pack_markdown_blocks_for_weixin(content: str, max_length: int) -> List[str]: if len(content) <= max_length: return [content] @@ -787,9 +823,15 @@ def _split_text_for_weixin_delivery( chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length)) return chunks or [content] - # Compact (default): single message when under the limit. + # Compact (default): single message when under the limit — unless the + # content looks like a short chatty exchange, in which case split into + # separate bubbles for a more natural chat feel. if len(content) <= max_length: - return [content] + return ( + _split_delivery_units_for_weixin(content) + if _should_split_short_chat_block_for_weixin(content) + else [content] + ) return _pack_markdown_blocks_for_weixin(content, max_length) or [content] diff --git a/tests/gateway/test_weixin.py b/tests/gateway/test_weixin.py index bb439fa9a6bb..f2afe1049ac6 100644 --- a/tests/gateway/test_weixin.py +++ b/tests/gateway/test_weixin.py @@ -64,13 +64,44 @@ def test_format_message_returns_empty_string_for_none(self): class TestWeixinChunking: - def test_split_text_keeps_short_multiline_message_in_single_chunk(self): + def test_split_text_splits_short_chatty_replies_into_separate_bubbles(self): adapter = _make_adapter() content = adapter.format_message("第一行\n第二行\n第三行") chunks = adapter._split_text(content) - assert chunks == ["第一行\n第二行\n第三行"] + assert chunks == ["第一行", "第二行", "第三行"] + + def test_split_text_keeps_structured_table_block_together(self): + adapter = _make_adapter() + + content = adapter.format_message( + "- Setting: Timeout\n Value: 30s\n- Setting: Retries\n Value: 3" + ) + chunks = adapter._split_text(content) + + assert chunks == ["- Setting: Timeout\n Value: 30s\n- Setting: Retries\n Value: 3"] + + def test_split_text_keeps_four_line_structured_blocks_together(self): + adapter = _make_adapter() + + content = adapter.format_message( + "今天结论:\n" + "- 留存下降 3%\n" + "- 转化上涨 8%\n" + "- 主要问题在首日激活" + ) + chunks = adapter._split_text(content) + + assert chunks == ["今天结论:\n- 留存下降 3%\n- 转化上涨 8%\n- 主要问题在首日激活"] + + def test_split_text_keeps_heading_with_body_together(self): + adapter = _make_adapter() + + content = adapter.format_message("## 结论\n这是正文") + chunks = adapter._split_text(content) + + assert chunks == ["**结论**\n这是正文"] def test_split_text_keeps_short_reformatted_table_in_single_chunk(self): adapter = _make_adapter() From fee0e0d35e093d2062c2deee3e321a9a44ebe017 Mon Sep 17 00:00:00 2001 From: m0n5t3r Date: Sun, 12 Apr 2026 00:26:08 -0700 Subject: [PATCH 011/288] fix(docker): run as non-root user, use virtualenv (salvage #5811) - Add gosu for runtime privilege dropping from root to hermes user - Support HERMES_UID/HERMES_GID env vars for host mount permission matching - Switch to debian:13.4-slim base image - Use uv venv instead of pip install --break-system-packages - Pin uv and gosu multi-stage images with SHA256 digests - Set PLAYWRIGHT_BROWSERS_PATH to /opt/hermes/.playwright so build-time chromium install survives the /opt/data volume mount - Keep procps for container debugging Based on work by m0n5t3r in PR #5811. Stripped to hardening-only changes (non-root, virtualenv, slim base); matrix deps, fonts, xvfb, and entrypoint playwright download deferred to follow-up. --- Dockerfile | 31 ++++++++++++++++++++++++------- docker/entrypoint.sh | 27 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5c57897f572e..6934bb7c36a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,44 @@ -FROM debian:13.4 +FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source +FROM tianon/gosu:1.19-trixie@sha256:3b176695959c71e123eb390d427efc665eeb561b1540e82679c15e992006b8b9 AS gosu_source +FROM debian:13.4-slim # Disable Python stdout buffering to ensure logs are printed immediately ENV PYTHONUNBUFFERED=1 +# Store Playwright browsers outside the volume mount so the build-time +# install survives the /opt/data volume overlay at runtime. +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright + # Install system dependencies in one layer, clear APT cache RUN apt-get update && \ apt-get install -y --no-install-recommends \ - build-essential nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev procps && \ + build-essential nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps && \ rm -rf /var/lib/apt/lists/* +# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime +RUN useradd -u 10000 -m -d /opt/data hermes + +COPY --chmod=0755 --from=gosu_source /gosu /usr/local/bin/ +COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/ + COPY . /opt/hermes WORKDIR /opt/hermes -# Install Python and Node dependencies in one layer, no cache -RUN pip install --no-cache-dir uv --break-system-packages && \ - uv pip install --system --break-system-packages --no-cache -e ".[all]" && \ - npm install --prefer-offline --no-audit && \ +# Install Node dependencies and Playwright as root (--with-deps needs apt) +RUN npm install --prefer-offline --no-audit && \ npx playwright install --with-deps chromium --only-shell && \ cd /opt/hermes/scripts/whatsapp-bridge && \ npm install --prefer-offline --no-audit && \ npm cache clean --force -WORKDIR /opt/hermes +# Hand ownership to hermes user, then install Python deps in a virtualenv +RUN chown -R hermes:hermes /opt/hermes +USER hermes + +RUN uv venv && \ + uv pip install --no-cache-dir -e ".[all]" + +USER root RUN chmod +x /opt/hermes/docker/entrypoint.sh ENV HERMES_HOME=/opt/data diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 68e3b79c1d16..dc1edd32c24f 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,6 +5,33 @@ set -e HERMES_HOME="/opt/data" INSTALL_DIR="/opt/hermes" +# --- Privilege dropping via gosu --- +# When started as root (the default), optionally remap the hermes user/group +# to match host-side ownership, fix volume permissions, then re-exec as hermes. +if [ "$(id -u)" = "0" ]; then + if [ -n "$HERMES_UID" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then + echo "Changing hermes UID to $HERMES_UID" + usermod -u "$HERMES_UID" hermes + fi + + if [ -n "$HERMES_GID" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then + echo "Changing hermes GID to $HERMES_GID" + groupmod -g "$HERMES_GID" hermes + fi + + actual_hermes_uid=$(id -u hermes) + if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then + echo "$HERMES_HOME is not owned by $actual_hermes_uid, fixing" + chown -R hermes:hermes "$HERMES_HOME" + fi + + echo "Dropping root privileges" + exec gosu hermes "$0" "$@" +fi + +# --- Running as hermes from here --- +source "${INSTALL_DIR}/.venv/bin/activate" + # Create essential directory structure. Cache and platform directories # (cache/images, cache/audio, platforms/whatsapp, etc.) are created on # demand by the application — don't pre-create them here so new installs From 8b9d22a74b9c8f527731124f40c4da35ecf989b8 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 00:46:01 -0700 Subject: [PATCH 012/288] revert: keep debian:13.4 full image instead of slim The slim image drops packages that may be needed at runtime. Keep the full Debian base for compatibility. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 6934bb7c36a4..4935d222ae99 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source FROM tianon/gosu:1.19-trixie@sha256:3b176695959c71e123eb390d427efc665eeb561b1540e82679c15e992006b8b9 AS gosu_source -FROM debian:13.4-slim +FROM debian:13.4 # Disable Python stdout buffering to ensure logs are printed immediately ENV PYTHONUNBUFFERED=1 From 316247267422301ff54c2ec33d0fb8de01e26018 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:54:07 -0700 Subject: [PATCH 013/288] feat(tips): add 69 deeper hidden-gem tips (279 total) (#8237) Add lesser-known power-user tips covering: - BOOT.md gateway startup automation - Cron script attachment for data collection pipelines - Prefill messages for few-shot priming - Focus topic compression (/compress ) - Terminal exit code annotations and auto-retry - Automatic sudo password piping - execute_code built-in helpers (json_parse, shell_quote, retry) - File loop detection and staleness warnings - MCP sampling and dynamic tool discovery - Delegation heartbeat and ACP child agents (Claude Code) - 402 auto-fallback in auxiliary client - Container mode, HERMES_HOME_MODE, subprocess HOME isolation - Ctrl+C 5-tier priority system - Browser CDP URL override and stealth mode - Skills quarantine, audit log, and well-known protocol - Per-platform display overrides, human delay mode - And many more deep-cut features --- hermes_cli/tips.py | 73 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 223d8d54026f..bb9f9e60cd1d 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -4,7 +4,7 @@ from typing import Optional # --------------------------------------------------------------------------- -# Tip corpus — ~200 one-liners covering slash commands, CLI flags, config, +# Tip corpus — one-liners covering slash commands, CLI flags, config, # keybindings, tools, gateway, skills, profiles, and workflow tricks. # --------------------------------------------------------------------------- @@ -262,6 +262,77 @@ "The API server supports both Chat Completions and Responses API with server-side state.", "tool_preview_length: 0 in config shows full file paths in the spinner's activity feed.", "hermes status --deep runs deeper diagnostic checks across all components.", + + # --- Hidden Gems & Power-User Tricks --- + "BOOT.md at ~/.hermes/BOOT.md runs automatically on every gateway start — use it for startup checks.", + "Cron jobs can attach a Python script (--script) whose stdout is injected into the prompt as context.", + "Cron scripts live in ~/.hermes/scripts/ and run before the agent — perfect for data collection pipelines.", + "prefill_messages_file in config.yaml injects few-shot examples into every API call, never saved to history.", + "SOUL.md completely replaces the agent's default identity — rewrite it to make Hermes your own.", + "SOUL.md is auto-seeded with a default personality on first run. Edit ~/.hermes/SOUL.md to customize.", + "/compress allocates 60-70% of the summary budget to your topic and aggressively trims the rest.", + "On second+ compression, the compressor updates the previous summary instead of starting from scratch.", + "Before a gateway session reset, Hermes auto-flushes important facts to memory in the background.", + "network.force_ipv4: true in config.yaml fixes hangs on servers with broken IPv6 — monkey-patches socket.", + "The terminal tool annotates common exit codes: grep returning 1 = 'No matches found (not an error)'.", + "Failed foreground terminal commands auto-retry up to 3 times with exponential backoff (2s, 4s, 8s).", + "Bare sudo commands are auto-rewritten to pipe SUDO_PASSWORD from .env — no interactive prompt needed.", + "execute_code has built-in helpers: json_parse() for tolerant parsing, shell_quote(), and retry() with backoff.", + "execute_code's 7 sandbox tools (web_search, terminal, read/write/search/patch) use RPC — never enter context.", + "Reading the same file region 3+ times triggers a warning. At 4+, it's hard-blocked to prevent loops.", + "write_file and patch detect if a file was externally modified since the last read and warn about staleness.", + "V4A patch format supports Add File, Delete File, and Move File directives — not just Update.", + "MCP servers can request LLM completions back via sampling — the agent becomes a tool for the server.", + "MCP servers send notifications/tools/list_changed to trigger automatic tool re-registration without restart.", + "delegate_task with acp_command: 'claude' spawns Claude Code as a child agent from any platform.", + "Delegation has a heartbeat thread — child activity propagates to the parent, preventing gateway timeouts.", + "When a provider returns HTTP 402 (payment required), the auxiliary client auto-falls back to the next one.", + "agent.tool_use_enforcement steers models that describe actions instead of calling tools — auto for GPT/Codex.", + "agent.restart_drain_timeout (default 60s) lets running agents finish before a gateway restart takes effect.", + "The gateway caches AIAgent instances per session — destroying this cache breaks Anthropic prompt caching.", + "Any website can expose skills via /.well-known/skills/index.json — the skills hub discovers them automatically.", + "The skills audit log at ~/.hermes/skills/.hub/audit.log tracks every install and removal operation.", + "Stale git worktrees are auto-cleaned: 24-72h old with no unpushed commits get pruned on startup.", + "Each profile gets its own subprocess HOME at HERMES_HOME/home/ — isolated git, ssh, npm, gh configs.", + "HERMES_HOME_MODE env var (octal, e.g. 0701) sets custom directory permissions for web server traversal.", + "Container mode: place .container-mode in HERMES_HOME and the host CLI auto-execs into the container.", + "Ctrl+C has 5 priority tiers: cancel recording → cancel prompts → cancel picker → interrupt agent → exit.", + "Every interrupt during an agent run is logged to ~/.hermes/interrupt_debug.log with timestamps.", + "BROWSER_CDP_URL connects browser tools to any running Chrome — accepts WebSocket, HTTP, or host:port.", + "BROWSERBASE_ADVANCED_STEALTH=true enables advanced anti-detection with custom Chromium (Scale Plan).", + "The CLI auto-switches to compact mode in terminals narrower than 80 columns.", + "Quick commands support two types: exec (run shell command directly) and alias (redirect to another command).", + "Per-task delegation model: delegation.model and delegation.provider in config route subagents to cheaper models.", + "delegation.reasoning_effort independently controls thinking depth for subagents.", + "display.platforms in config.yaml allows per-platform display overrides: {telegram: {tool_progress: all}}.", + "human_delay.mode in config simulates human typing speed — configurable min_ms/max_ms range.", + "Config version migrations run automatically on load — new config keys appear without manual intervention.", + "GPT and Codex models get special system prompt guidance for tool discipline and mandatory tool use.", + "Gemini models get tailored directives for absolute paths, parallel tool calls, and non-interactive commands.", + "context.engine in config.yaml can be set to a plugin name for alternative context management strategies.", + "Browser pages over 8000 tokens are auto-summarized by the auxiliary LLM before returning to the agent.", + "The compressor does a cheap pre-pass: tool outputs over 200 chars are replaced with placeholders before the LLM runs.", + "When compression fails, further attempts are paused for 10 minutes to avoid API hammering.", + "Long dangerous commands (>70 chars) get a 'view' option in the approval prompt to see the full text first.", + "Audio level visualization shows ▁▂▃▄▅▆▇ bars during voice recording based on microphone RMS levels.", + "Profile names cannot collide with existing PATH binaries — 'hermes profile create ls' would be rejected.", + "hermes profile create backup --clone-all copies everything (config, keys, SOUL.md, memories, skills, sessions).", + "The voice record key is configurable via voice.record_key in config.yaml — not just Ctrl+B.", + ".cursorrules and .cursor/rules/*.mdc files are auto-detected and loaded as project context.", + "Context files support 10+ prompt injection patterns — invisible Unicode, 'ignore instructions', exfil attempts.", + "GPT-5 and Codex use 'developer' role instead of 'system' in the message format.", + "Per-task auxiliary overrides: auxiliary.vision.provider, auxiliary.compression.model, etc. in config.yaml.", + "The auxiliary client treats 'main' as a provider alias — resolves to your actual primary provider + model.", + "Smart routing can auto-route simple queries to a cheaper model — set smart_model_routing.enabled: true.", + "hermes claw migrate --dry-run previews OpenClaw migration without writing anything.", + "File paths pasted with quotes or escaped spaces are handled automatically — no manual cleanup needed.", + "Slash commands never trigger the large-paste collapse — /command with big arguments works correctly.", + "In interrupt mode, slash commands typed during agent execution bypass interrupt logic and run immediately.", + "HERMES_DEV=1 bypasses container mode detection for local development.", + "Each MCP server gets its own toolset (mcp-servername) that can be toggled independently via hermes tools.", + "MCP ${ENV_VAR} placeholders in config are resolved at server spawn — including vars from ~/.hermes/.env.", + "Skills from trusted repos (NousResearch) get a 'trusted' security level; community skills get extra scanning.", + "The skills quarantine at ~/.hermes/skills/.hub/quarantine/ holds skills pending security review.", ] From c52f6348b6c850dd18a6cf4fa5823bc1fac7f980 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:54:35 -0700 Subject: [PATCH 014/288] fix: list all available toolsets in delegate_task schema description (#8231) * fix: list all available toolsets in delegate_task schema description The delegate_task tool's toolsets parameter description only mentioned 'terminal', 'file', and 'web' as examples. Models (especially smaller ones like Gemma) would substitute 'web' for 'browser' because they didn't know 'browser' was a valid option. Now dynamically builds the toolset list from the TOOLSETS dict at import time, excluding blocked, composite, and platform-specific toolsets. Auto-updates when new toolsets are added. Reported by jeffutter on Discord. * chore: exclude moa and rl from delegate_task toolset list --- tools/delegate_tool.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index f00701cd94a1..73ba81272fc7 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -25,6 +25,8 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any, Dict, List, Optional +from toolsets import TOOLSETS + # Tools that children must never have access to DELEGATE_BLOCKED_TOOLS = frozenset([ @@ -35,6 +37,18 @@ "execute_code", # children should reason step-by-step, not write scripts ]) +# Build a description fragment listing toolsets available for subagents. +# Excludes toolsets where ALL tools are blocked, composite/platform toolsets +# (hermes-* prefixed), and scenario toolsets. +_EXCLUDED_TOOLSET_NAMES = frozenset({"debugging", "safe", "delegation", "moa", "rl"}) +_SUBAGENT_TOOLSETS = sorted( + name for name, defn in TOOLSETS.items() + if name not in _EXCLUDED_TOOLSET_NAMES + and not name.startswith("hermes-") + and not all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", [])) +) +_TOOLSET_LIST_STR = ", ".join(f"'{n}'" for n in _SUBAGENT_TOOLSETS) + _DEFAULT_MAX_CONCURRENT_CHILDREN = 3 MAX_DEPTH = 2 # parent (0) -> child (1) -> grandchild rejected (2) @@ -999,9 +1013,10 @@ def _load_config() -> dict: "description": ( "Toolsets to enable for this subagent. " "Default: inherits your enabled toolsets. " + f"Available toolsets: {_TOOLSET_LIST_STR}. " "Common patterns: ['terminal', 'file'] for code work, " - "['web'] for research, ['terminal', 'file', 'web'] for " - "full-stack tasks." + "['web'] for research, ['browser'] for web interaction, " + "['terminal', 'file', 'web'] for full-stack tasks." ), }, "tasks": { @@ -1014,7 +1029,7 @@ def _load_config() -> dict: "toolsets": { "type": "array", "items": {"type": "string"}, - "description": "Toolsets for this specific task. Use 'web' for network access, 'terminal' for shell.", + "description": f"Toolsets for this specific task. Available: {_TOOLSET_LIST_STR}. Use 'web' for network access, 'terminal' for shell, 'browser' for web interaction.", }, "acp_command": { "type": "string", From b1f13a8c5f47933c94c065753ceff23507b61e56 Mon Sep 17 00:00:00 2001 From: Harish Kukreja Date: Sun, 12 Apr 2026 00:10:19 -0400 Subject: [PATCH 015/288] fix(agent): route compression aux through live session runtime --- agent/auxiliary_client.py | 69 ++++++++++++++++--- agent/context_compressor.py | 11 +++ run_agent.py | 17 ++++- tests/agent/test_auxiliary_client.py | 68 ++++++++++++++++++ tests/agent/test_context_compressor.py | 31 +++++++++ .../run_agent/test_compression_feasibility.py | 31 +++++++++ 6 files changed, 216 insertions(+), 11 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 6b7bf1966896..a4c4df8e970d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1021,6 +1021,23 @@ def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]: _AGGREGATOR_PROVIDERS = frozenset({"openrouter", "nous"}) +_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode") + + +def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, str]: + """Return a sanitized copy of a live main-runtime override.""" + if not isinstance(main_runtime, dict): + return {} + normalized: Dict[str, str] = {} + for field in _MAIN_RUNTIME_FIELDS: + value = main_runtime.get(field) + if isinstance(value, str) and value.strip(): + normalized[field] = value.strip() + provider = normalized.get("provider") + if provider: + normalized["provider"] = provider.lower() + return normalized + def _get_provider_chain() -> List[tuple]: """Return the ordered provider detection chain. @@ -1130,7 +1147,7 @@ def _try_payment_fallback( return None, None, "" -def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: +def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]: """Full auto-detection chain. Priority: @@ -1142,6 +1159,12 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: """ global auxiliary_is_nous, _stale_base_url_warned auxiliary_is_nous = False # Reset — _try_nous() will set True if it wins + runtime = _normalize_main_runtime(main_runtime) + runtime_provider = runtime.get("provider", "") + runtime_model = runtime.get("model", "") + runtime_base_url = runtime.get("base_url", "") + runtime_api_key = runtime.get("api_key", "") + runtime_api_mode = runtime.get("api_mode", "") # ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named # provider (not 'custom'). This catches the common "env poisoning" @@ -1149,7 +1172,7 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: # old OPENAI_BASE_URL lingers in ~/.hermes/.env. ── if not _stale_base_url_warned: _env_base = os.getenv("OPENAI_BASE_URL", "").strip() - _cfg_provider = _read_main_provider() + _cfg_provider = runtime_provider or _read_main_provider() if (_env_base and _cfg_provider and _cfg_provider != "custom" and not _cfg_provider.startswith("custom:")): @@ -1163,12 +1186,25 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]: _stale_base_url_warned = True # ── Step 1: non-aggregator main provider → use main model directly ── - main_provider = _read_main_provider() - main_model = _read_main_model() + main_provider = runtime_provider or _read_main_provider() + main_model = runtime_model or _read_main_model() if (main_provider and main_model and main_provider not in _AGGREGATOR_PROVIDERS and main_provider not in ("auto", "")): - client, resolved = resolve_provider_client(main_provider, main_model) + resolved_provider = main_provider + explicit_base_url = None + explicit_api_key = None + if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")): + resolved_provider = "custom" + explicit_base_url = runtime_base_url + explicit_api_key = runtime_api_key or None + client, resolved = resolve_provider_client( + resolved_provider, + main_model, + explicit_base_url=explicit_base_url, + explicit_api_key=explicit_api_key, + api_mode=runtime_api_mode or None, + ) if client is not None: logger.info("Auxiliary auto-detect: using main provider %s (%s)", main_provider, resolved or main_model) @@ -1249,6 +1285,7 @@ def resolve_provider_client( explicit_base_url: str = None, explicit_api_key: str = None, api_mode: str = None, + main_runtime: Optional[Dict[str, Any]] = None, ) -> Tuple[Optional[Any], Optional[str]]: """Central router: given a provider name and optional model, return a configured client with the correct auth, base URL, and API format. @@ -1319,7 +1356,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): # ── Auto: try all providers in priority order ──────────────────── if provider == "auto": - client, resolved = _resolve_auto() + client, resolved = _resolve_auto(main_runtime=main_runtime) if client is None: return None, None # When auto-detection lands on a non-OpenRouter provider (e.g. a @@ -1543,7 +1580,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = ""): # ── Public API ────────────────────────────────────────────────────────────── -def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optional[str]]: +def get_text_auxiliary_client( + task: str = "", + *, + main_runtime: Optional[Dict[str, Any]] = None, +) -> Tuple[Optional[OpenAI], Optional[str]]: """Return (client, default_model_slug) for text-only auxiliary tasks. Args: @@ -1560,10 +1601,11 @@ def get_text_auxiliary_client(task: str = "") -> Tuple[Optional[OpenAI], Optiona explicit_base_url=base_url, explicit_api_key=api_key, api_mode=api_mode, + main_runtime=main_runtime, ) -def get_async_text_auxiliary_client(task: str = ""): +def get_async_text_auxiliary_client(task: str = "", *, main_runtime: Optional[Dict[str, Any]] = None): """Return (async_client, model_slug) for async consumers. For standard providers returns (AsyncOpenAI, model). For Codex returns @@ -1578,6 +1620,7 @@ def get_async_text_auxiliary_client(task: str = ""): explicit_base_url=base_url, explicit_api_key=api_key, api_mode=api_mode, + main_runtime=main_runtime, ) @@ -1892,6 +1935,7 @@ def _get_cached_client( base_url: str = None, api_key: str = None, api_mode: str = None, + main_runtime: Optional[Dict[str, Any]] = None, ) -> Tuple[Optional[Any], Optional[str]]: """Get or create a cached client for the given provider. @@ -1915,7 +1959,9 @@ def _get_cached_client( loop_id = id(current_loop) except RuntimeError: pass - cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id) + runtime = _normalize_main_runtime(main_runtime) + runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else () + cache_key = (provider, async_mode, base_url or "", api_key or "", api_mode or "", loop_id, runtime_key) with _client_cache_lock: if cache_key in _client_cache: cached_client, cached_default, cached_loop = _client_cache[cache_key] @@ -1940,6 +1986,7 @@ def _get_cached_client( explicit_base_url=base_url, explicit_api_key=api_key, api_mode=api_mode, + main_runtime=runtime, ) if client is not None: # For async clients, remember which loop they were created on so we @@ -2149,6 +2196,7 @@ def call_llm( model: str = None, base_url: str = None, api_key: str = None, + main_runtime: Optional[Dict[str, Any]] = None, messages: list, temperature: float = None, max_tokens: int = None, @@ -2214,6 +2262,7 @@ def call_llm( base_url=resolved_base_url, api_key=resolved_api_key, api_mode=resolved_api_mode, + main_runtime=main_runtime, ) if client is None: # When the user explicitly chose a non-OpenRouter provider but no @@ -2234,7 +2283,7 @@ def call_llm( if not resolved_base_url: logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain", task or "call", resolved_provider) - client, final_model = _get_cached_client("auto") + client, final_model = _get_cached_client("auto", main_runtime=main_runtime) if client is None: raise RuntimeError( f"No LLM provider configured for task={task} provider={resolved_provider}. " diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2701997fa6c7..4163966aaafa 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -86,12 +86,14 @@ def update_model( base_url: str = "", api_key: str = "", provider: str = "", + api_mode: str = "", ) -> None: """Update model info after a model switch or fallback activation.""" self.model = model self.base_url = base_url self.api_key = api_key self.provider = provider + self.api_mode = api_mode self.context_length = context_length self.threshold_tokens = max( int(context_length * self.threshold_percent), @@ -111,11 +113,13 @@ def __init__( api_key: str = "", config_context_length: int | None = None, provider: str = "", + api_mode: str = "", ): self.model = model self.base_url = base_url self.api_key = api_key self.provider = provider + self.api_mode = api_mode self.threshold_percent = threshold_percent self.protect_first_n = protect_first_n self.protect_last_n = protect_last_n @@ -438,6 +442,13 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi try: call_kwargs = { "task": "compression", + "main_runtime": { + "model": self.model, + "provider": self.provider, + "base_url": self.base_url, + "api_key": self.api_key, + "api_mode": self.api_mode, + }, "messages": [{"role": "user", "content": prompt}], "max_tokens": summary_budget * 2, # timeout resolved from auxiliary.compression.timeout config by call_llm diff --git a/run_agent.py b/run_agent.py index b23035454252..01863a0a146e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1307,6 +1307,7 @@ def __init__( api_key=getattr(self, "api_key", ""), config_context_length=_config_context_length, provider=self.provider, + api_mode=self.api_mode, ) self.compression_enabled = compression_enabled @@ -1563,6 +1564,7 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod base_url=self.base_url, api_key=getattr(self, "api_key", ""), provider=self.provider, + api_mode=self.api_mode, ) # ── Invalidate cached system prompt so it rebuilds next turn ── @@ -1696,6 +1698,16 @@ def _emit_status(self, message: str) -> None: except Exception: logger.debug("status_callback error in _emit_status", exc_info=True) + def _current_main_runtime(self) -> Dict[str, str]: + """Return the live main runtime for session-scoped auxiliary routing.""" + return { + "model": getattr(self, "model", "") or "", + "provider": getattr(self, "provider", "") or "", + "base_url": getattr(self, "base_url", "") or "", + "api_key": getattr(self, "api_key", "") or "", + "api_mode": getattr(self, "api_mode", "") or "", + } + def _check_compression_model_feasibility(self) -> None: """Warn at session start if the auxiliary compression model's context window is smaller than the main model's compression threshold. @@ -1716,7 +1728,10 @@ def _check_compression_model_feasibility(self) -> None: from agent.auxiliary_client import get_text_auxiliary_client from agent.model_metadata import get_model_context_length - client, aux_model = get_text_auxiliary_client("compression") + client, aux_model = get_text_auxiliary_client( + "compression", + main_runtime=self._current_main_runtime(), + ) if client is None or not aux_model: msg = ( "⚠ No auxiliary LLM provider configured — context " diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index a38b62568a8c..e1164ace8ab8 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -971,6 +971,74 @@ def test_task_without_override_uses_auto(self, monkeypatch): client, model = get_text_auxiliary_client("compression") assert model == "google/gemini-3-flash-preview" # auto → OpenRouter + def test_resolve_auto_prefers_live_main_runtime_over_persisted_config(self, monkeypatch, tmp_path): + """Session-only live model switches should override persisted config for auto routing.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "config.yaml").write_text( + """model: + default: glm-5.1 + provider: opencode-go +compression: + summary_provider: auto +""" + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + calls = [] + + def _fake_resolve(provider, model=None, *args, **kwargs): + calls.append((provider, model, kwargs)) + return MagicMock(), model or "resolved-model" + + with patch("agent.auxiliary_client.resolve_provider_client", side_effect=_fake_resolve): + client, model = _resolve_auto( + main_runtime={ + "provider": "openai-codex", + "model": "gpt-5.4", + "api_mode": "codex_responses", + } + ) + + assert client is not None + assert model == "gpt-5.4" + assert calls[0][0] == "openai-codex" + assert calls[0][1] == "gpt-5.4" + assert calls[0][2]["api_mode"] == "codex_responses" + + def test_explicit_compression_pin_still_wins_over_live_main_runtime(self, monkeypatch, tmp_path): + """Task-level compression config should beat a live session override.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "config.yaml").write_text( + """auxiliary: + compression: + provider: openrouter + model: google/gemini-3-flash-preview +model: + default: glm-5.1 + provider: opencode-go +""" + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(MagicMock(), "google/gemini-3-flash-preview")) as mock_resolve: + client, model = get_text_auxiliary_client( + "compression", + main_runtime={ + "provider": "openai-codex", + "model": "gpt-5.4", + }, + ) + + assert client is not None + assert model == "google/gemini-3-flash-preview" + assert mock_resolve.call_args.args[0] == "openrouter" + assert mock_resolve.call_args.kwargs["main_runtime"] == { + "provider": "openai-codex", + "model": "gpt-5.4", + } + def test_compression_summary_base_url_from_config(self, monkeypatch, tmp_path): """compression.summary_base_url should produce a custom-endpoint client.""" hermes_home = tmp_path / "hermes" diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index f4cf19666f63..6164d812f6b8 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -191,6 +191,37 @@ def test_summary_call_does_not_force_temperature(self): kwargs = mock_call.call_args.kwargs assert "temperature" not in kwargs + def test_summary_call_passes_live_main_runtime(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "ok" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="gpt-5.4", + provider="openai-codex", + base_url="https://chatgpt.com/backend-api/codex", + api_key="codex-token", + api_mode="codex_responses", + quiet_mode=True, + ) + + messages = [ + {"role": "user", "content": "do something"}, + {"role": "assistant", "content": "ok"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call: + c._generate_summary(messages) + + assert mock_call.call_args.kwargs["main_runtime"] == { + "model": "gpt-5.4", + "provider": "openai-codex", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "codex-token", + "api_mode": "codex_responses", + } + class TestSummaryFailureCooldown: def test_summary_failure_enters_cooldown_and_skips_retry(self): diff --git a/tests/run_agent/test_compression_feasibility.py b/tests/run_agent/test_compression_feasibility.py index 1b4423414ee6..0738b1d438de 100644 --- a/tests/run_agent/test_compression_feasibility.py +++ b/tests/run_agent/test_compression_feasibility.py @@ -26,6 +26,7 @@ def _make_agent( agent.provider = "openrouter" agent.base_url = "https://openrouter.ai/api/v1" agent.api_key = "sk-test" + agent.api_mode = "chat_completions" agent.quiet_mode = True agent.log_prefix = "" agent.compression_enabled = compression_enabled @@ -99,6 +100,36 @@ def test_no_warning_when_aux_context_sufficient(mock_get_client, mock_ctx_len): assert agent._compression_warning is None +def test_feasibility_check_passes_live_main_runtime(): + """Compression feasibility should probe using the live session runtime.""" + agent = _make_agent(main_context=200_000, threshold_percent=0.50) + agent.model = "gpt-5.4" + agent.provider = "openai-codex" + agent.base_url = "https://chatgpt.com/backend-api/codex" + agent.api_key = "codex-token" + agent.api_mode = "codex_responses" + + mock_client = MagicMock() + mock_client.base_url = "https://chatgpt.com/backend-api/codex" + mock_client.api_key = "codex-token" + + with patch("agent.auxiliary_client.get_text_auxiliary_client", return_value=(mock_client, "gpt-5.4")) as mock_get_client, \ + patch("agent.model_metadata.get_model_context_length", return_value=200_000): + agent._emit_status = lambda msg: None + agent._check_compression_model_feasibility() + + mock_get_client.assert_called_once_with( + "compression", + main_runtime={ + "model": "gpt-5.4", + "provider": "openai-codex", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "codex-token", + "api_mode": "codex_responses", + }, + ) + + @patch("agent.auxiliary_client.get_text_auxiliary_client") def test_warns_when_no_auxiliary_provider(mock_get_client): """Warning emitted when no auxiliary provider is configured.""" From 078dba015d95cafa53ce79d02e8da1d56b20cb12 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:44:18 -0700 Subject: [PATCH 016/288] fix: three provider-related bugs (#8161, #8181, #8147) (#8243) - Add openai/openai-codex -> openai mapping to PROVIDER_TO_MODELS_DEV so context-length lookups use models.dev data instead of 128k fallback. Fixes #8161. - Set api_mode from custom_providers entry when switching via hermes model, and clear stale api_mode when the entry has none. Also extract api_mode in _named_custom_provider_map(). Fixes #8181. - Convert OpenAI image_url content blocks to Anthropic image blocks when the endpoint is Anthropic-compatible (MiniMax, MiniMax-CN, or any URL containing /anthropic). Fixes #8147. --- agent/auxiliary_client.py | 79 +++++++++++++++++++ agent/models_dev.py | 2 + hermes_cli/main.py | 7 ++ tests/agent/test_auxiliary_client.py | 71 +++++++++++++++++ tests/agent/test_models_dev.py | 5 +- .../test_custom_provider_model_switch.py | 51 ++++++++++++ 6 files changed, 214 insertions(+), 1 deletion(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index a4c4df8e970d..3dcc78a98d63 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2112,6 +2112,75 @@ def _get_task_timeout(task: str, default: float = _DEFAULT_AUX_TIMEOUT) -> float return default +# --------------------------------------------------------------------------- +# Anthropic-compatible endpoint detection + image block conversion +# --------------------------------------------------------------------------- + +# Providers that use Anthropic-compatible endpoints (via OpenAI SDK wrapper). +# Their image content blocks must use Anthropic format, not OpenAI format. +_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-cn"}) + + +def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool: + """Detect if an endpoint expects Anthropic-format content blocks. + + Returns True for known Anthropic-compatible providers (MiniMax) and + any endpoint whose URL contains ``/anthropic`` in the path. + """ + if provider in _ANTHROPIC_COMPAT_PROVIDERS: + return True + url_lower = (base_url or "").lower() + return "/anthropic" in url_lower + + +def _convert_openai_images_to_anthropic(messages: list) -> list: + """Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks. + + Only touches messages that have list-type content with ``image_url`` blocks; + plain text messages pass through unchanged. + """ + converted = [] + for msg in messages: + content = msg.get("content") + if not isinstance(content, list): + converted.append(msg) + continue + new_content = [] + changed = False + for block in content: + if block.get("type") == "image_url": + image_url_val = (block.get("image_url") or {}).get("url", "") + if image_url_val.startswith("data:"): + # Parse data URI: data:;base64, + header, _, b64data = image_url_val.partition(",") + media_type = "image/png" + if ":" in header and ";" in header: + media_type = header.split(":", 1)[1].split(";", 1)[0] + new_content.append({ + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": b64data, + }, + }) + else: + # URL-based image + new_content.append({ + "type": "image", + "source": { + "type": "url", + "url": image_url_val, + }, + }) + changed = True + else: + new_content.append(block) + converted.append({**msg, "content": new_content} if changed else msg) + return converted + + + def _build_call_kwargs( provider: str, model: str, @@ -2304,6 +2373,11 @@ def call_llm( tools=tools, timeout=effective_timeout, extra_body=extra_body, base_url=resolved_base_url) + # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) + _client_base = str(getattr(client, "base_url", "") or "") + if _is_anthropic_compat_endpoint(resolved_provider, _client_base): + kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"]) + # Handle max_tokens vs max_completion_tokens retry, then payment fallback. try: return _validate_llm_response( @@ -2492,6 +2566,11 @@ async def async_call_llm( tools=tools, timeout=effective_timeout, extra_body=extra_body, base_url=resolved_base_url) + # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) + _client_base = str(getattr(client, "base_url", "") or "") + if _is_anthropic_compat_endpoint(resolved_provider, _client_base): + kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"]) + try: return _validate_llm_response( await client.chat.completions.create(**kwargs), task) diff --git a/agent/models_dev.py b/agent/models_dev.py index f9eb49dbf26e..e20a2d4144fd 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -144,6 +144,8 @@ class ProviderInfo: PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "openrouter": "openrouter", "anthropic": "anthropic", + "openai": "openai", + "openai-codex": "openai", "zai": "zai", "kimi-coding": "kimi-for-coding", "minimax": "minimax", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9f73c07a5497..2e580bea84f3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1107,6 +1107,7 @@ def _named_custom_provider_map(cfg) -> dict[str, dict[str, str]]: "base_url": base_url, "api_key": entry.get("api_key", ""), "model": entry.get("model", ""), + "api_mode": entry.get("api_mode", ""), } return custom_provider_map @@ -1955,6 +1956,12 @@ def _model_flow_named_custom(config, provider_info): model["base_url"] = base_url if api_key: model["api_key"] = api_key + # Apply api_mode from custom_providers entry, or clear stale value + custom_api_mode = provider_info.get("api_mode", "") + if custom_api_mode: + model["api_mode"] = custom_api_mode + else: + model.pop("api_mode", None) # let runtime auto-detect from URL save_config(cfg) deactivate_provider() diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index e1164ace8ab8..77004c4e107b 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -1628,3 +1628,74 @@ def test_warning_only_fires_once(self, monkeypatch, caplog): assert not any("OPENAI_BASE_URL is set" in rec.message for rec in caplog.records), \ "Warning should not fire a second time" + + +# --------------------------------------------------------------------------- +# Anthropic-compatible image block conversion +# --------------------------------------------------------------------------- + +class TestAnthropicCompatImageConversion: + """Tests for _is_anthropic_compat_endpoint and _convert_openai_images_to_anthropic.""" + + def test_known_providers_detected(self): + from agent.auxiliary_client import _is_anthropic_compat_endpoint + assert _is_anthropic_compat_endpoint("minimax", "") + assert _is_anthropic_compat_endpoint("minimax-cn", "") + + def test_openrouter_not_detected(self): + from agent.auxiliary_client import _is_anthropic_compat_endpoint + assert not _is_anthropic_compat_endpoint("openrouter", "") + assert not _is_anthropic_compat_endpoint("anthropic", "") + + def test_url_based_detection(self): + from agent.auxiliary_client import _is_anthropic_compat_endpoint + assert _is_anthropic_compat_endpoint("custom", "https://api.minimax.io/anthropic") + assert _is_anthropic_compat_endpoint("custom", "https://example.com/anthropic/v1") + assert not _is_anthropic_compat_endpoint("custom", "https://api.openai.com/v1") + + def test_base64_image_converted(self): + from agent.auxiliary_client import _convert_openai_images_to_anthropic + messages = [{ + "role": "user", + "content": [ + {"type": "text", "text": "describe"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBOR="}} + ] + }] + result = _convert_openai_images_to_anthropic(messages) + img_block = result[0]["content"][1] + assert img_block["type"] == "image" + assert img_block["source"]["type"] == "base64" + assert img_block["source"]["media_type"] == "image/png" + assert img_block["source"]["data"] == "iVBOR=" + + def test_url_image_converted(self): + from agent.auxiliary_client import _convert_openai_images_to_anthropic + messages = [{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}} + ] + }] + result = _convert_openai_images_to_anthropic(messages) + img_block = result[0]["content"][0] + assert img_block["type"] == "image" + assert img_block["source"]["type"] == "url" + assert img_block["source"]["url"] == "https://example.com/img.jpg" + + def test_text_only_messages_unchanged(self): + from agent.auxiliary_client import _convert_openai_images_to_anthropic + messages = [{"role": "user", "content": "Hello"}] + result = _convert_openai_images_to_anthropic(messages) + assert result[0] is messages[0] # same object, not copied + + def test_jpeg_media_type_parsed(self): + from agent.auxiliary_client import _convert_openai_images_to_anthropic + messages = [{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/="}} + ] + }] + result = _convert_openai_images_to_anthropic(messages) + assert result[0]["content"][0]["source"]["media_type"] == "image/jpeg" diff --git a/tests/agent/test_models_dev.py b/tests/agent/test_models_dev.py index 9f11d731e362..be4b3b139099 100644 --- a/tests/agent/test_models_dev.py +++ b/tests/agent/test_models_dev.py @@ -87,7 +87,10 @@ def test_known_providers_mapped(self): def test_unmapped_provider_not_in_dict(self): assert "nous" not in PROVIDER_TO_MODELS_DEV - assert "openai-codex" not in PROVIDER_TO_MODELS_DEV + + def test_openai_codex_mapped_to_openai(self): + assert PROVIDER_TO_MODELS_DEV["openai"] == "openai" + assert PROVIDER_TO_MODELS_DEV["openai-codex"] == "openai" class TestExtractContext: diff --git a/tests/hermes_cli/test_custom_provider_model_switch.py b/tests/hermes_cli/test_custom_provider_model_switch.py index d48610a63042..a0123670be91 100644 --- a/tests/hermes_cli/test_custom_provider_model_switch.py +++ b/tests/hermes_cli/test_custom_provider_model_switch.py @@ -122,3 +122,54 @@ def test_no_saved_model_still_works(self, config_home): model = config.get("model") assert isinstance(model, dict) assert model["default"] == "model-X" + + def test_api_mode_set_from_provider_info(self, config_home): + """When custom_providers entry has api_mode, it should be applied.""" + import yaml + from hermes_cli.main import _model_flow_named_custom + + provider_info = { + "name": "Anthropic Proxy", + "base_url": "https://proxy.example.com/anthropic", + "api_key": "***", + "model": "claude-3", + "api_mode": "anthropic_messages", + } + + with patch("hermes_cli.models.fetch_api_models", return_value=["claude-3"]), \ + patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("builtins.input", return_value="1"), \ + patch("builtins.print"): + _model_flow_named_custom({}, provider_info) + + config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} + model = config.get("model") + assert isinstance(model, dict) + assert model.get("api_mode") == "anthropic_messages" + + def test_api_mode_cleared_when_not_specified(self, config_home): + """When custom_providers entry has no api_mode, stale api_mode is removed.""" + import yaml + from hermes_cli.main import _model_flow_named_custom + + # Pre-seed a stale api_mode in config + config_path = config_home / "config.yaml" + config_path.write_text(yaml.dump({"model": {"api_mode": "anthropic_messages"}})) + + provider_info = { + "name": "My vLLM", + "base_url": "https://vllm.example.com/v1", + "api_key": "***", + "model": "llama-3", + } + + with patch("hermes_cli.models.fetch_api_models", return_value=["llama-3"]), \ + patch.dict("sys.modules", {"simple_term_menu": None}), \ + patch("builtins.input", return_value="1"), \ + patch("builtins.print"): + _model_flow_named_custom({}, provider_info) + + config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} + model = config.get("model") + assert isinstance(model, dict) + assert "api_mode" not in model, "Stale api_mode should be removed" From a1220977d35dea8e6f28cbe051dc1601cfd831ef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:46:34 -0700 Subject: [PATCH 017/288] fix: make skill loading instructions more aggressive in system prompt (#8209) The previous wording ('If one clearly matches') set too high a threshold, and 'If none match, proceed normally' was an easy escape hatch for lazy models. Now: - Lowered threshold: 'matches or is even partially relevant' - Added MUST directive and 'err on the side of loading' guidance - Replaced permissive closer with 'only proceed without if genuinely none are relevant' This should reduce cases where the agent skips loading relevant skills unless explicitly forced. --- agent/prompt_builder.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 26d913a02982..83c8eb13ffa2 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -726,8 +726,13 @@ def build_skills_system_prompt( result = ( "## Skills (mandatory)\n" - "Before replying, scan the skills below. If one clearly matches your task, " - "load it with skill_view(name) and follow its instructions. " + "Before replying, scan the skills below. If a skill matches or is even partially relevant " + "to your task, you MUST load it with skill_view(name) and follow its instructions. " + "Err on the side of loading — it is always better to have context you don't need " + "than to miss critical steps, pitfalls, or established workflows. " + "Skills contain specialized knowledge — API endpoints, tool-specific commands, " + "and proven workflows that outperform general-purpose approaches. Load the skill " + "even if you think you could handle the task with basic tools like web_search or terminal.\n" "If a skill has issues, fix it with skill_manage(action='patch').\n" "After difficult/iterative tasks, offer to save as a skill. " "If a skill you loaded was missing steps, had wrong commands, or needed " @@ -737,7 +742,7 @@ def build_skills_system_prompt( + "\n".join(index_lines) + "\n" "\n" "\n" - "If none match, proceed normally without loading a skill." + "Only proceed without loading a skill if genuinely none are relevant to the task." ) # ── Store in LRU cache ──────────────────────────────────────────── From ae6820a45a60416b75c06dcaf7fdfe3d96e6aaed Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:51:57 -0700 Subject: [PATCH 018/288] fix(setup): validate base URL input in hermes model flow (#8264) Reject non-URL values (e.g. shell commands typed by mistake) in the base URL prompt during provider setup. Previously any string was saved as-is to .env, breaking connectivity when the garbage value was used as the API endpoint. Adds http:// / https:// prefix check with a clear error message. The custom-endpoint flow already had this validation (line 1620); this brings the generic API-key provider flow to parity. Triggered by a user support case where 'nano ~/.hermes/.env' was accidentally entered as GLM_BASE_URL during Z.AI setup. --- hermes_cli/main.py | 7 +- .../test_model_provider_persistence.py | 73 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2e580bea84f3..babcad7191c6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2499,8 +2499,11 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): print() override = "" if override and base_url_env: - save_env_value(base_url_env, override) - effective_base = override + if not override.startswith(("http://", "https://")): + print(" Invalid URL — must start with http:// or https://. Keeping current value.") + else: + save_env_value(base_url_env, override) + effective_base = override # Model selection — resolution order: # 1. models.dev registry (cached, filtered for agentic/tool-capable models) diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 55f7ac69c771..a06facd300ab 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -257,3 +257,76 @@ def test_opencode_go_same_provider_switch_recomputes_api_mode(self, config_home, assert model.get("provider") == "opencode-go" assert model.get("default") == "minimax-m2.5" assert model.get("api_mode") == "anthropic_messages" + + +class TestBaseUrlValidation: + """Reject non-URL values in the base URL prompt (e.g. shell commands).""" + + def test_invalid_base_url_rejected(self, config_home, monkeypatch, capsys): + """Typing a non-URL string should not be saved as the base URL.""" + from hermes_cli.auth import PROVIDER_REGISTRY + + pconfig = PROVIDER_REGISTRY.get("zai") + if not pconfig: + pytest.skip("zai not in PROVIDER_REGISTRY") + + monkeypatch.setenv("GLM_API_KEY", "test-key") + + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config, get_env_value + + # User types a shell command instead of a URL at the base URL prompt + with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value="nano ~/.hermes/.env"): + _model_flow_api_key_provider(load_config(), "zai", "old-model") + + # The garbage value should NOT have been saved + saved = get_env_value("GLM_BASE_URL") or "" + assert not saved or saved.startswith(("http://", "https://")), \ + f"Non-URL value was saved as GLM_BASE_URL: {saved}" + captured = capsys.readouterr() + assert "Invalid URL" in captured.out + + def test_valid_base_url_accepted(self, config_home, monkeypatch): + """A proper URL should be saved normally.""" + from hermes_cli.auth import PROVIDER_REGISTRY + + pconfig = PROVIDER_REGISTRY.get("zai") + if not pconfig: + pytest.skip("zai not in PROVIDER_REGISTRY") + + monkeypatch.setenv("GLM_API_KEY", "test-key") + + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config, get_env_value + + with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value="https://custom.z.ai/api/paas/v4"): + _model_flow_api_key_provider(load_config(), "zai", "old-model") + + saved = get_env_value("GLM_BASE_URL") or "" + assert saved == "https://custom.z.ai/api/paas/v4" + + def test_empty_base_url_keeps_default(self, config_home, monkeypatch): + """Pressing Enter (empty) should not change the base URL.""" + from hermes_cli.auth import PROVIDER_REGISTRY + + pconfig = PROVIDER_REGISTRY.get("zai") + if not pconfig: + pytest.skip("zai not in PROVIDER_REGISTRY") + + monkeypatch.setenv("GLM_API_KEY", "test-key") + monkeypatch.delenv("GLM_BASE_URL", raising=False) + + from hermes_cli.main import _model_flow_api_key_provider + from hermes_cli.config import load_config, get_env_value + + with patch("hermes_cli.auth._prompt_model_selection", return_value="glm-5"), \ + patch("hermes_cli.auth.deactivate_provider"), \ + patch("builtins.input", return_value=""): + _model_flow_api_key_provider(load_config(), "zai", "old-model") + + saved = get_env_value("GLM_BASE_URL") or "" + assert saved == "", "Empty input should not save a base URL" From 4aa534eae5560a66cc2062c408680197ba01c0cc Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 01:55:31 -0700 Subject: [PATCH 019/288] fix(gateway): peek at pending message during interrupt instead of consuming it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monitor_for_interrupt() and backup interrupt checks were calling get_pending_message() which pops the message from the adapter's queue. This created a race condition: if the agent finished naturally before checking _interrupt_requested, the pending message was permanently lost. Timeline of the race: 1. Agent near completion, user sends message 2. Level 1 guard stores message in adapter._pending_messages, sets event 3. monitor_for_interrupt() detects event, POPS message, calls agent.interrupt() 4. Agent's run_conversation() was already returning (interrupted=False) 5. Post-run dequeue finds nothing (monitor already consumed it) 6. result.get('interrupted') is False so interrupt_message fallback doesn't fire 7. User message permanently lost — agent finishes without processing it Fix: change all three interrupt detection sites (primary monitor + two backup checks) from get_pending_message() (pop) to _pending_messages.get() (peek). The message stays in the adapter's queue until _dequeue_pending_event() consumes it in the post-run handler, which runs regardless of whether the agent was interrupted or finished naturally. Reported by @_SushantSays — intermittent message loss during long terminal command execution, persisting after the previous fix (73f970fa) which addressed monitor task death but not this consumption race. --- gateway/run.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 9cf39a16050f..d95d5eb1e1d7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8046,8 +8046,16 @@ async def monitor_for_interrupt(): if hasattr(_adapter, 'has_pending_interrupt') and _adapter.has_pending_interrupt(session_key): agent = agent_holder[0] if agent: - pending_event = _adapter.get_pending_message(session_key) - pending_text = pending_event.text if pending_event else None + # Peek at the pending message text WITHOUT consuming it. + # The message must remain in _pending_messages so the + # post-run dequeue at _dequeue_pending_event() can + # retrieve the full MessageEvent (with media metadata). + # If we pop here, a race exists: the agent may finish + # before checking _interrupt_requested, and the message + # is lost — neither the interrupt path nor the dequeue + # path finds it. + _peek_event = _adapter._pending_messages.get(session_key) + pending_text = _peek_event.text if _peek_event else None logger.debug("Interrupt detected from adapter, signaling agent...") agent.interrupt(pending_text) _interrupt_detected.set() @@ -8138,7 +8146,7 @@ async def _notify_long_running(): if (_backup_adapter and _backup_agent and hasattr(_backup_adapter, 'has_pending_interrupt') and _backup_adapter.has_pending_interrupt(session_key)): - _bp_event = _backup_adapter.get_pending_message(session_key) + _bp_event = _backup_adapter._pending_messages.get(session_key) _bp_text = _bp_event.text if _bp_event else None logger.info( "Backup interrupt detected for session %s " @@ -8198,7 +8206,7 @@ async def _notify_long_running(): if (_backup_adapter and _backup_agent and hasattr(_backup_adapter, 'has_pending_interrupt') and _backup_adapter.has_pending_interrupt(session_key)): - _bp_event = _backup_adapter.get_pending_message(session_key) + _bp_event = _backup_adapter._pending_messages.get(session_key) _bp_text = _bp_event.text if _bp_event else None logger.info( "Backup interrupt detected for session %s " From 6d05e3d56f49a67ec9084bdd1a74befd8723e5f2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:58:17 -0700 Subject: [PATCH 020/288] fix(gateway): evict cached agent on /model switch + add diagnostic logging (#8276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After /model switches the model (both picker and text paths), the cached agent's config signature becomes stale — the agent was updated in-place via switch_model() but the cache tuple's signature was never refreshed. The next turn *should* detect the signature mismatch and create a fresh agent, but this relies on the new model's signature differing from the old one in _agent_config_signature(). Evicting the cached agent explicitly after storing the session override is more defensive — the next turn is guaranteed to create a fresh agent from the override without depending on signature mismatch detection. Also adds debug logging at three key decision points so we can trace exactly what happens when /model + /retry interact: - _resolve_session_agent_runtime: which override path is taken (fast with api_key vs fallback), or why no override was found - _run_agent.run_sync: final resolved model/provider before agent creation Reported: /model switch to xiaomi/mimo-v2-pro followed by /retry still used the old model (glm-5.1). --- gateway/run.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index d95d5eb1e1d7..3edd905f3531 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -876,7 +876,24 @@ def _resolve_session_agent_runtime( "api_mode": override.get("api_mode"), } if override_runtime.get("api_key"): + logger.debug( + "Session model override (fast): session=%s config_model=%s -> override_model=%s provider=%s", + (resolved_session_key or "")[:30], model, override_model, + override_runtime.get("provider"), + ) return override_model, override_runtime + # Override exists but has no api_key — fall through to env-based + # resolution and apply model/provider from the override on top. + logger.debug( + "Session model override (no api_key, fallback): session=%s config_model=%s override_model=%s", + (resolved_session_key or "")[:30], model, override_model, + ) + else: + logger.debug( + "No session model override: session=%s config_model=%s override_keys=%s", + (resolved_session_key or "")[:30], model, + list(self._session_model_overrides.keys())[:5] if self._session_model_overrides else "[]", + ) runtime_kwargs = _resolve_runtime_agent_kwargs() if override and resolved_session_key: @@ -4304,6 +4321,11 @@ async def _on_model_selected( "api_mode": result.api_mode, } + # Evict cached agent so the next turn creates a fresh + # agent from the override rather than relying on the + # stale cache signature to trigger a rebuild. + _self._evict_cached_agent(_session_key) + # Build confirmation text plabel = result.provider_label or result.target_provider lines = [f"Model switched to `{result.new_model}`"] @@ -4417,6 +4439,10 @@ async def _on_model_selected( "api_mode": result.api_mode, } + # Evict cached agent so the next turn creates a fresh agent from the + # override rather than relying on cache signature mismatch detection. + self._evict_cached_agent(session_key) + # Persist to config if --global if persist_global: try: @@ -7545,6 +7571,10 @@ def run_sync(): session_key=session_key, user_config=user_config, ) + logger.debug( + "run_agent resolved: model=%s provider=%s session=%s", + model, runtime_kwargs.get("provider"), (session_key or "")[:30], + ) except Exception as exc: return { "final_response": f"⚠️ Provider authentication failed: {exc}", From 95fa78eb6c9ca364034356e54d1b050b9eee83fe Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 02:05:20 -0700 Subject: [PATCH 021/288] fix: write refreshed Codex tokens back to ~/.codex/auth.json (#8277) OpenAI OAuth refresh tokens are single-use and rotate on every refresh. When Hermes refreshes a Codex token, it consumed the old refresh_token but never wrote the new pair back to ~/.codex/auth.json. This caused Codex CLI and VS Code to fail with 'refresh_token_reused' on their next refresh attempt. This mirrors the existing Anthropic write-back pattern where refreshed tokens are written to ~/.claude/.credentials.json via _write_claude_code_credentials(). Changes: - Add _write_codex_cli_tokens() in hermes_cli/auth.py (parallel to _write_claude_code_credentials in anthropic_adapter.py) - Call it from _refresh_codex_auth_tokens() (non-pool refresh path) - Call it from credential_pool._refresh_entry() (pool happy path + retry) - Add tests for the new write-back behavior - Update existing test docstring to clarify _save_codex_tokens vs _write_codex_cli_tokens separation Fixes refresh token conflict reported by @ec12edfae2cb221 --- agent/credential_pool.py | 20 ++++ hermes_cli/auth.py | 49 ++++++++++ tests/hermes_cli/test_auth_codex_provider.py | 97 +++++++++++++++++++- 3 files changed, 164 insertions(+), 2 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 07afa9609929..e067fb90149a 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -24,6 +24,7 @@ _codex_access_token_is_expiring, _decode_jwt_claims, _import_codex_cli_tokens, + _write_codex_cli_tokens, _load_auth_store, _load_provider_state, _resolve_kimi_base_url, @@ -693,6 +694,14 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po self._replace_entry(synced, updated) self._persist() self._sync_device_code_entry_to_auth_store(updated) + try: + _write_codex_cli_tokens( + updated.access_token, + updated.refresh_token, + last_refresh=updated.last_refresh, + ) + except Exception as wexc: + logger.debug("Failed to write refreshed Codex tokens to CLI file (retry): %s", wexc) return updated except Exception as retry_exc: logger.debug("Codex retry refresh also failed: %s", retry_exc) @@ -718,6 +727,17 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po # _seed_from_singletons() on the next load_pool() sees fresh state # instead of re-seeding stale/consumed tokens. self._sync_device_code_entry_to_auth_store(updated) + # Write refreshed tokens back to ~/.codex/auth.json so Codex CLI + # and VS Code don't hit "refresh_token_reused" on their next refresh. + if self.provider == "openai-codex": + try: + _write_codex_cli_tokens( + updated.access_token, + updated.refresh_token, + last_refresh=updated.last_refresh, + ) + except Exception as wexc: + logger.debug("Failed to write refreshed Codex tokens to CLI file: %s", wexc) return updated def _entry_needs_refresh(self, entry: PooledCredential) -> bool: diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 56b9fb63c2e8..04a7d0c13790 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1303,6 +1303,49 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: } +def _write_codex_cli_tokens( + access_token: str, + refresh_token: str, + *, + last_refresh: Optional[str] = None, +) -> None: + """Write refreshed tokens back to ~/.codex/auth.json. + + OpenAI OAuth refresh tokens are single-use and rotate on every refresh. + When Hermes refreshes a token it consumes the old refresh_token; if we + don't write the new pair back, the Codex CLI (or VS Code extension) will + fail with ``refresh_token_reused`` on its next refresh attempt. + + This mirrors the Anthropic write-back to ~/.claude/.credentials.json + via ``_write_claude_code_credentials()``. + """ + codex_home = os.getenv("CODEX_HOME", "").strip() + if not codex_home: + codex_home = str(Path.home() / ".codex") + auth_path = Path(codex_home).expanduser() / "auth.json" + try: + existing: Dict[str, Any] = {} + if auth_path.is_file(): + existing = json.loads(auth_path.read_text(encoding="utf-8")) + if not isinstance(existing, dict): + existing = {} + + tokens_dict = existing.get("tokens") + if not isinstance(tokens_dict, dict): + tokens_dict = {} + tokens_dict["access_token"] = access_token + tokens_dict["refresh_token"] = refresh_token + existing["tokens"] = tokens_dict + if last_refresh is not None: + existing["last_refresh"] = last_refresh + + auth_path.parent.mkdir(parents=True, exist_ok=True) + auth_path.write_text(json.dumps(existing, indent=2), encoding="utf-8") + auth_path.chmod(0o600) + except (OSError, IOError) as exc: + logger.debug("Failed to write refreshed tokens to %s: %s", auth_path, exc) + + def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None: """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json).""" if last_refresh is None: @@ -1425,6 +1468,12 @@ def _refresh_codex_auth_tokens( updated_tokens["refresh_token"] = refreshed["refresh_token"] _save_codex_tokens(updated_tokens) + # Write back to ~/.codex/auth.json so Codex CLI / VS Code stay in sync. + _write_codex_cli_tokens( + refreshed["access_token"], + refreshed["refresh_token"], + last_refresh=refreshed.get("last_refresh"), + ) return updated_tokens diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index 4119126e6689..f05a80b6ac1a 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -14,6 +14,7 @@ PROVIDER_REGISTRY, _read_codex_tokens, _save_codex_tokens, + _write_codex_cli_tokens, _import_codex_cli_tokens, get_codex_auth_status, get_provider_auth_state, @@ -161,7 +162,7 @@ def test_import_codex_cli_tokens_missing(tmp_path, monkeypatch): def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch): - """Verify Hermes never writes to ~/.codex/auth.json.""" + """Verify _save_codex_tokens writes only to Hermes auth store, not ~/.codex/.""" hermes_home = tmp_path / "hermes" codex_home = tmp_path / "codex-cli" hermes_home.mkdir(parents=True, exist_ok=True) @@ -173,7 +174,7 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch): _save_codex_tokens({"access_token": "hermes-at", "refresh_token": "hermes-rt"}) - # ~/.codex/auth.json should NOT exist + # ~/.codex/auth.json should NOT exist — _save_codex_tokens only touches Hermes store assert not (codex_home / "auth.json").exists() # Hermes auth store should have the tokens @@ -181,6 +182,98 @@ def test_codex_tokens_not_written_to_shared_file(tmp_path, monkeypatch): assert data["tokens"]["access_token"] == "hermes-at" +def test_write_codex_cli_tokens_creates_file(tmp_path, monkeypatch): + """_write_codex_cli_tokens creates ~/.codex/auth.json with refreshed tokens.""" + codex_home = tmp_path / "codex-cli" + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + _write_codex_cli_tokens("new-access", "new-refresh", last_refresh="2026-04-12T00:00:00Z") + + auth_path = codex_home / "auth.json" + assert auth_path.exists() + data = json.loads(auth_path.read_text()) + assert data["tokens"]["access_token"] == "new-access" + assert data["tokens"]["refresh_token"] == "new-refresh" + assert data["last_refresh"] == "2026-04-12T00:00:00Z" + # Verify file permissions are restricted + assert (auth_path.stat().st_mode & 0o777) == 0o600 + + +def test_write_codex_cli_tokens_preserves_existing(tmp_path, monkeypatch): + """_write_codex_cli_tokens preserves extra fields in existing auth.json.""" + codex_home = tmp_path / "codex-cli" + codex_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + existing = { + "tokens": { + "access_token": "old-access", + "refresh_token": "old-refresh", + "extra_field": "preserved", + }, + "last_refresh": "2026-01-01T00:00:00Z", + "custom_key": "keep_me", + } + (codex_home / "auth.json").write_text(json.dumps(existing)) + + _write_codex_cli_tokens("updated-access", "updated-refresh") + + data = json.loads((codex_home / "auth.json").read_text()) + assert data["tokens"]["access_token"] == "updated-access" + assert data["tokens"]["refresh_token"] == "updated-refresh" + assert data["tokens"]["extra_field"] == "preserved" + assert data["custom_key"] == "keep_me" + # last_refresh not updated since we didn't pass it + assert data["last_refresh"] == "2026-01-01T00:00:00Z" + + +def test_write_codex_cli_tokens_handles_missing_dir(tmp_path, monkeypatch): + """_write_codex_cli_tokens creates parent directories if missing.""" + codex_home = tmp_path / "does" / "not" / "exist" + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + _write_codex_cli_tokens("at", "rt") + + assert (codex_home / "auth.json").exists() + data = json.loads((codex_home / "auth.json").read_text()) + assert data["tokens"]["access_token"] == "at" + + +def test_refresh_codex_auth_tokens_writes_back_to_cli(tmp_path, monkeypatch): + """After refreshing, _refresh_codex_auth_tokens writes back to ~/.codex/auth.json.""" + from hermes_cli.auth import _refresh_codex_auth_tokens + + hermes_home = tmp_path / "hermes" + codex_home = tmp_path / "codex-cli" + hermes_home.mkdir(parents=True, exist_ok=True) + codex_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + # Write initial CLI tokens + (codex_home / "auth.json").write_text(json.dumps({ + "tokens": {"access_token": "old-at", "refresh_token": "old-rt"}, + })) + + # Mock the pure refresh to return new tokens + monkeypatch.setattr("hermes_cli.auth.refresh_codex_oauth_pure", lambda *a, **kw: { + "access_token": "refreshed-at", + "refresh_token": "refreshed-rt", + "last_refresh": "2026-04-12T01:00:00Z", + }) + + _refresh_codex_auth_tokens( + {"access_token": "old-at", "refresh_token": "old-rt"}, + timeout_seconds=10, + ) + + # Verify CLI file was updated + cli_data = json.loads((codex_home / "auth.json").read_text()) + assert cli_data["tokens"]["access_token"] == "refreshed-at" + assert cli_data["tokens"]["refresh_token"] == "refreshed-rt" + + def test_resolve_returns_hermes_auth_store_source(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" _setup_hermes_auth(hermes_home) From 00adbd0de0f41e24a718d7a623184ec2f55a4190 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 12 Apr 2026 19:08:16 +1000 Subject: [PATCH 022/288] chore: simplify Docker image tags - Main branch push: only push :latest (remove SHA tag) - Release push: only push release tag name (remove :latest and SHA tag) --- .github/workflows/docker-publish.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index eec35fd62f25..6b360b8c641f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -69,9 +69,7 @@ jobs: file: Dockerfile push: true platforms: linux/amd64,linux/arm64 - tags: | - nousresearch/hermes-agent:latest - nousresearch/hermes-agent:${{ github.sha }} + tags: nousresearch/hermes-agent:latest cache-from: type=gha cache-to: type=gha,mode=max @@ -83,9 +81,6 @@ jobs: file: Dockerfile push: true platforms: linux/amd64,linux/arm64 - tags: | - nousresearch/hermes-agent:latest - nousresearch/hermes-agent:${{ github.event.release.tag_name }} - nousresearch/hermes-agent:${{ github.sha }} + tags: nousresearch/hermes-agent:${{ github.event.release.tag_name }} cache-from: type=gha cache-to: type=gha,mode=max From b9af4955b9bf764ebbb38758bd3ab3a07b16a885 Mon Sep 17 00:00:00 2001 From: elkimek <36666630+elkimek@users.noreply.github.com> Date: Sun, 12 Apr 2026 08:53:16 +0000 Subject: [PATCH 023/288] fix(matrix): restore verify_with_recovery_key after device key rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the PgCryptoStore migration in v0.8.0, the verify_with_recovery_key call that previously ran after share_keys() was dropped. On any rotation that uploads fresh device keys (fresh crypto.db, server had stale keys from a prior install, etc.), the new device keys carry no valid self- signing signature because the bot has no access to the self-signing private key. Peers like Element then refuse to share Megolm sessions with the rotated device, so the bot silently stops decrypting incoming messages. This restores the recovery-key bootstrap: on startup, if MATRIX_RECOVERY_KEY is set, import the cross-signing private keys from SSSS and sign_own_device(), producing a valid signature server-side. Idempotent and gated on MATRIX_RECOVERY_KEY — no behavior change for users who don't configure a recovery key. Verified end-to-end by deleting crypto.db and restarting: the bot rotates device identity keys, re-uploads, self-signs via recovery key, and decrypts+replies to fresh messages from a paired Element client. --- gateway/platforms/matrix.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 75d7e9c9f645..9ba63c083584 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -508,6 +508,19 @@ async def connect(self) -> bool: await api.session.close() return False + # Import cross-signing private keys from SSSS and self-sign + # the current device. Required after any device-key rotation + # (fresh crypto.db, share_keys re-upload) — otherwise the + # device's self-signing signature is stale and peers refuse + # to share Megolm sessions with the rotated device. + recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip() + if recovery_key: + try: + await olm.verify_with_recovery_key(recovery_key) + logger.info("Matrix: cross-signing verified via recovery key") + except Exception as exc: + logger.warning("Matrix: recovery key verification failed: %s", exc) + client.crypto = olm logger.info( "Matrix: E2EE enabled (store: %s%s)", From dd5b1063d06c66b99bef83ab767ebd7749427a4f Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 02:16:50 -0700 Subject: [PATCH 024/288] fix: register MATRIX_RECOVERY_KEY env var + document migration path Follow-up for cherry-picked PR #8272: - Add MATRIX_RECOVERY_KEY to module docstring header in matrix.py - Register in OPTIONAL_ENV_VARS (config.py) with password=True, advanced=True - Add to _NON_SETUP_ENV_VARS set - Document cross-signing verification in matrix.md E2EE section - Update migration guide with recovery key step (step 3) - Add to environment-variables.md reference --- gateway/platforms/matrix.py | 1 + hermes_cli/config.py | 9 ++++++ .../docs/reference/environment-variables.md | 1 + website/docs/user-guide/messaging/matrix.md | 30 ++++++++++++++++--- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 9ba63c083584..9f3d6358c779 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -18,6 +18,7 @@ MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true) MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true) + MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false) """ diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3d8004cd3e7e..8c46f8bba131 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -50,6 +50,7 @@ "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM", "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", + "MATRIX_RECOVERY_KEY", }) import yaml @@ -1293,6 +1294,14 @@ def _ensure_hermes_home_managed(home: Path): "category": "messaging", "advanced": True, }, + "MATRIX_RECOVERY_KEY": { + "description": "Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)", + "prompt": "Matrix recovery key", + "url": None, + "password": True, + "category": "messaging", + "advanced": True, + }, "BLUEBUBBLES_SERVER_URL": { "description": "BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)", "prompt": "BlueBubbles server URL", diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index bec5ff1c373e..ff832a0361d8 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -277,6 +277,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `MATRIX_FREE_RESPONSE_ROOMS` | Comma-separated room IDs where bot responds without `@mention` | | `MATRIX_AUTO_THREAD` | Auto-create threads for room messages (default: `true`) | | `MATRIX_DM_MENTION_THREADS` | Create a thread when bot is `@mentioned` in a DM (default: `false`) | +| `MATRIX_RECOVERY_KEY` | Recovery key for cross-signing verification after device key rotation. Recommended for E2EE setups with cross-signing enabled. | | `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) | | `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) | | `WEBHOOK_ENABLED` | Enable the webhook platform adapter (`true`/`false`) | diff --git a/website/docs/user-guide/messaging/matrix.md b/website/docs/user-guide/messaging/matrix.md index ccde0740d6bd..de03ff81783f 100644 --- a/website/docs/user-guide/messaging/matrix.md +++ b/website/docs/user-guide/messaging/matrix.md @@ -272,6 +272,18 @@ When E2EE is enabled, Hermes: - Decrypts incoming messages and encrypts outgoing messages automatically - Auto-joins encrypted rooms when invited +### Cross-Signing Verification (Recommended) + +If your Matrix account has cross-signing enabled (the default in Element), set the recovery key so the bot can self-sign its device on startup. Without this, other Matrix clients may refuse to share encryption sessions with the bot after a device key rotation. + +```bash +MATRIX_RECOVERY_KEY=EsT... your recovery key here +``` + +**Where to find it:** In Element, go to **Settings** → **Security & Privacy** → **Encryption** → your recovery key (also called the "Security Key"). This is the key you were asked to save when you first set up cross-signing. + +On each startup, if `MATRIX_RECOVERY_KEY` is set, Hermes imports cross-signing keys from the homeserver's secure secret storage and signs the current device. This is idempotent and safe to leave enabled permanently. + :::warning If you delete the `~/.hermes/platforms/matrix/store/` directory, the bot loses its encryption keys. You'll need to verify the device again in your Matrix client. Back up this directory if you want to preserve encrypted sessions. ::: @@ -374,7 +386,7 @@ changed identity keys for the same device as suspicious. -d '{ "type": "m.login.password", "identifier": {"type": "m.id.user", "user": "@hermes:your-server.org"}, - "password": "your-password", + "password": "***", "initial_device_display_name": "Hermes Agent" }' ``` @@ -388,17 +400,27 @@ changed identity keys for the same device as suspicious. rm -f ~/.hermes/platforms/matrix/store/crypto_store.* ``` -3. **Force your Matrix client to rotate the encryption session**. In Element, +3. **Set your recovery key** (if you use cross-signing — most Element users do). Add to `~/.hermes/.env`: + + ```bash + MATRIX_RECOVERY_KEY=EsT... your recovery key here + ``` + + This lets the bot self-sign with cross-signing keys on startup, so Element trusts the new device immediately. Without this, Element may see the new device as unverified and refuse to share encryption sessions. Find your recovery key in Element under **Settings** → **Security & Privacy** → **Encryption**. + +4. **Force your Matrix client to rotate the encryption session**. In Element, open the DM room with the bot and type `/discardsession`. This forces Element to create a new encryption session and share it with the bot's new device. -4. **Restart the gateway**: +5. **Restart the gateway**: ```bash hermes gateway run ``` -5. **Send a new message**. The bot should decrypt and respond normally. + If `MATRIX_RECOVERY_KEY` is set, you should see `Matrix: cross-signing verified via recovery key` in the logs. + +6. **Send a new message**. The bot should decrypt and respond normally. :::note After migration, messages sent *before* the upgrade cannot be decrypted -- the old From b32133036295ade639fea3e6d322d91bbe9ec06c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 02:26:28 -0700 Subject: [PATCH 025/288] feat: add WSL environment hint to system prompt (#8285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running inside WSL (Windows Subsystem for Linux), inject a hint into the system prompt explaining that the Windows host filesystem is mounted at /mnt/c/, /mnt/d/, etc. This lets the agent naturally translate Windows paths (Desktop, Documents) to their /mnt/ equivalents without the user needing to configure anything. Uses the existing is_wsl() detection from hermes_constants (cached, checks /proc/version for 'microsoft'). Adds build_environment_hints() in prompt_builder.py — extensible for Termux, Docker, etc. later. Closes the UX gap where WSL users had to manually explain path translation to the agent every session. --- agent/prompt_builder.py | 32 +++++++++++++++++++++++++++++- run_agent.py | 8 +++++++- tests/agent/test_prompt_builder.py | 25 +++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 83c8eb13ffa2..e33569f051f5 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -12,7 +12,7 @@ from collections import OrderedDict from pathlib import Path -from hermes_constants import get_hermes_home, get_skills_dir +from hermes_constants import get_hermes_home, get_skills_dir, is_wsl from typing import Optional from agent.skill_utils import ( @@ -366,6 +366,36 @@ def _strip_yaml_frontmatter(content: str) -> str: ), } +# --------------------------------------------------------------------------- +# Environment hints — execution-environment awareness for the agent. +# Unlike PLATFORM_HINTS (which describe the messaging channel), these describe +# the machine/OS the agent's tools actually run on. +# --------------------------------------------------------------------------- + +WSL_ENVIRONMENT_HINT = ( + "You are running inside WSL (Windows Subsystem for Linux). " + "The Windows host filesystem is mounted under /mnt/ — " + "/mnt/c/ is the C: drive, /mnt/d/ is D:, etc. " + "The user's Windows files are typically at " + "/mnt/c/Users//Desktop/, Documents/, Downloads/, etc. " + "When the user references Windows paths or desktop files, translate " + "to the /mnt/c/ equivalent. You can list /mnt/c/Users/ to discover " + "the Windows username if needed." +) + + +def build_environment_hints() -> str: + """Return environment-specific guidance for the system prompt. + + Detects WSL, and can be extended for Termux, Docker, etc. + Returns an empty string when no special environment is detected. + """ + hints: list[str] = [] + if is_wsl(): + hints.append(WSL_ENVIRONMENT_HINT) + return "\n\n".join(hints) + + CONTEXT_FILE_MAX_CHARS = 20_000 CONTEXT_TRUNCATE_HEAD_RATIO = 0.7 CONTEXT_TRUNCATE_TAIL_RATIO = 0.2 diff --git a/run_agent.py b/run_agent.py index 01863a0a146e..333dda3927fd 100644 --- a/run_agent.py +++ b/run_agent.py @@ -94,7 +94,7 @@ from agent.context_compressor import ContextCompressor from agent.subdirectory_hints import SubdirectoryHintTracker from agent.prompt_caching import apply_anthropic_cache_control -from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, DEVELOPER_ROLE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE +from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, DEVELOPER_ROLE_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE from agent.usage_pricing import estimate_usage_cost, normalize_usage from agent.display import ( KawaiiSpinner, build_tool_preview as _build_tool_preview, @@ -3193,6 +3193,12 @@ def _build_system_prompt(self, system_message: str = None) -> str: f"not on any model name returned by the API." ) + # Environment hints (WSL, Termux, etc.) — tell the agent about the + # execution environment so it can translate paths and adapt behavior. + _env_hints = build_environment_hints() + if _env_hints: + prompt_parts.append(_env_hints) + platform_key = (self.platform or "").lower().strip() if platform_key in PLATFORM_HINTS: prompt_parts.append(PLATFORM_HINTS[platform_key]) diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 1f2f6ada7735..5a222cc38bb0 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -18,6 +18,7 @@ build_skills_system_prompt, build_nous_subscription_prompt, build_context_files_prompt, + build_environment_hints, CONTEXT_FILE_MAX_CHARS, DEFAULT_AGENT_IDENTITY, TOOL_USE_ENFORCEMENT_GUIDANCE, @@ -26,6 +27,7 @@ MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, PLATFORM_HINTS, + WSL_ENVIRONMENT_HINT, ) from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures @@ -770,6 +772,29 @@ def test_platform_hints_known_platforms(self): assert "cli" in PLATFORM_HINTS +# ========================================================================= +# Environment hints +# ========================================================================= + +class TestEnvironmentHints: + def test_wsl_hint_constant_mentions_mnt(self): + assert "/mnt/c/" in WSL_ENVIRONMENT_HINT + assert "WSL" in WSL_ENVIRONMENT_HINT + + def test_build_environment_hints_on_wsl(self, monkeypatch): + import agent.prompt_builder as _pb + monkeypatch.setattr(_pb, "is_wsl", lambda: True) + result = _pb.build_environment_hints() + assert "/mnt/" in result + assert "WSL" in result + + def test_build_environment_hints_not_wsl(self, monkeypatch): + import agent.prompt_builder as _pb + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + result = _pb.build_environment_hints() + assert result == "" + + # ========================================================================= # Conditional skill activation # ========================================================================= From 56e3ee2440278d0df216b9dbcdf7b7dc597db932 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 02:33:21 -0700 Subject: [PATCH 026/288] fix: write update exit code before gateway restart (cgroup kill race) (#8288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When /update runs via Telegram, hermes update --gateway is spawned inside the gateway's systemd cgroup. The update process itself calls systemctl restart hermes-gateway, which tears down the cgroup with KillMode=mixed — SIGKILL to all remaining processes. The wrapping bash shell is killed before it can execute the exit-code epilogue, so .update_exit_code is never created. The new gateway's update watcher then polls for 30 minutes and sends a spurious timeout message. Fix: write .update_exit_code from Python inside cmd_update() immediately after the git pull + pip install succeed ("Update complete!"), before attempting the gateway restart. The shell epilogue still writes it too (idempotent overwrite), but now the marker exists even when the process is killed mid-restart. --- hermes_cli/main.py | 20 +++ .../hermes_cli/test_update_gateway_restart.py | 117 ++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index babcad7191c6..9ad4d0142b42 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3939,6 +3939,26 @@ def cmd_update(args): print() print("✓ Update complete!") + # Write exit code *before* the gateway restart attempt. + # When running as ``hermes update --gateway`` (spawned by the gateway's + # /update command), this process lives inside the gateway's systemd + # cgroup. ``systemctl restart hermes-gateway`` kills everything in the + # cgroup (KillMode=mixed → SIGKILL to remaining processes), including + # us and the wrapping bash shell. The shell never reaches its + # ``printf $status > .update_exit_code`` epilogue, so the exit-code + # marker file is never created. The new gateway's update watcher then + # polls for 30 minutes and sends a spurious timeout message. + # + # Writing the marker here — after git pull + pip install succeed but + # before we attempt the restart — ensures the new gateway sees it + # regardless of how we die. + if gateway_mode: + _exit_code_path = get_hermes_home() / ".update_exit_code" + try: + _exit_code_path.write_text("0") + except OSError: + pass + # Auto-restart ALL gateways after update. # The code update (git pull) is shared across all profiles, so every # running gateway needs restarting to pick up the new code. diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index 822b22742d57..f3f2a0444ae8 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -798,3 +798,120 @@ def fake_run(cmd, **kwargs): pids = gateway_cli.find_gateway_pids() assert pids == [100] + + +# --------------------------------------------------------------------------- +# Gateway mode writes exit code before restart (#8300) +# --------------------------------------------------------------------------- + + +class TestGatewayModeWritesExitCodeEarly: + """When running as ``hermes update --gateway``, the exit code marker must be + written *before* the gateway restart attempt. Without this, systemd's + ``KillMode=mixed`` kills the update process (and its wrapping shell) during + the cgroup teardown, so the shell epilogue that normally writes the exit + code never executes. The new gateway's update watcher then polls for 30 + minutes and sends a spurious timeout message. + """ + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_exit_code_written_in_gateway_mode( + self, mock_run, _mock_which, capsys, tmp_path, monkeypatch, + ): + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + + # Point HERMES_HOME at a temp dir so the marker file lands there + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + import hermes_cli.config as _cfg + monkeypatch.setattr(_cfg, "get_hermes_home", lambda: hermes_home) + # Also patch the module-level ref used by cmd_update + import hermes_cli.main as _main_mod + monkeypatch.setattr(_main_mod, "get_hermes_home", lambda: hermes_home) + + mock_run.side_effect = _make_run_side_effect(commit_count="1") + + args = SimpleNamespace(gateway=True) + + with patch.object(gateway_cli, "find_gateway_pids", return_value=[]): + cmd_update(args) + + exit_code_path = hermes_home / ".update_exit_code" + assert exit_code_path.exists(), ".update_exit_code not written in gateway mode" + assert exit_code_path.read_text() == "0" + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_exit_code_not_written_in_normal_mode( + self, mock_run, _mock_which, capsys, tmp_path, monkeypatch, + ): + """Non-gateway mode should NOT write the exit code (the shell does it).""" + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + import hermes_cli.config as _cfg + monkeypatch.setattr(_cfg, "get_hermes_home", lambda: hermes_home) + import hermes_cli.main as _main_mod + monkeypatch.setattr(_main_mod, "get_hermes_home", lambda: hermes_home) + + mock_run.side_effect = _make_run_side_effect(commit_count="1") + + args = SimpleNamespace(gateway=False) + + with patch.object(gateway_cli, "find_gateway_pids", return_value=[]): + cmd_update(args) + + exit_code_path = hermes_home / ".update_exit_code" + assert not exit_code_path.exists(), ".update_exit_code should not be written outside gateway mode" + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_exit_code_written_before_restart_call( + self, mock_run, _mock_which, capsys, tmp_path, monkeypatch, + ): + """Exit code must exist BEFORE systemctl restart is called.""" + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + import hermes_cli.config as _cfg + monkeypatch.setattr(_cfg, "get_hermes_home", lambda: hermes_home) + import hermes_cli.main as _main_mod + monkeypatch.setattr(_main_mod, "get_hermes_home", lambda: hermes_home) + + exit_code_path = hermes_home / ".update_exit_code" + + # Track whether exit code exists when systemctl restart is called + exit_code_existed_at_restart = [] + + original_side_effect = _make_run_side_effect( + commit_count="1", systemd_active=True, + ) + + def tracking_side_effect(cmd, **kwargs): + joined = " ".join(str(c) for c in cmd) + if "systemctl" in joined and "restart" in joined: + exit_code_existed_at_restart.append(exit_code_path.exists()) + return original_side_effect(cmd, **kwargs) + + mock_run.side_effect = tracking_side_effect + + args = SimpleNamespace(gateway=True) + + with patch.object(gateway_cli, "find_gateway_pids", return_value=[]): + cmd_update(args) + + assert exit_code_existed_at_restart, "systemctl restart was never called" + assert exit_code_existed_at_restart[0] is True, \ + ".update_exit_code must exist BEFORE systemctl restart (cgroup kill race)" From b6b6b02f0f47e8ee81650466060e9e78c61193bd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 03:03:07 -0700 Subject: [PATCH 027/288] fix: prevent unwanted session auto-reset after graceful gateway restarts (#8299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the gateway shuts down gracefully (hermes update, gateway restart, /restart), it now writes a .clean_shutdown marker file. On the next startup, if this marker exists, suspend_recently_active() is skipped and the marker is cleaned up. Previously, suspend_recently_active() fired on EVERY startup — including planned restarts from hermes update or hermes gateway restart. This caused users to lose their conversation history unexpectedly: the session would be marked as suspended, and the next message would trigger an auto-reset with a notification the user never asked for. The original purpose of suspend_recently_active() is crash recovery — preventing stuck sessions that were mid-processing when the gateway died unexpectedly. Graceful shutdowns already drain active agents via _drain_active_agents(), so there is no stuck-session risk. After a crash (no marker written), suspension still fires as before. Fixes the scenario where a user asks the agent to run hermes update, the gateway restarts, and the user's next message gets an unwanted 'Session automatically reset' notification with their history cleared. --- gateway/run.py | 34 ++- tests/gateway/test_clean_shutdown_marker.py | 226 ++++++++++++++++++++ 2 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 tests/gateway/test_clean_shutdown_marker.py diff --git a/gateway/run.py b/gateway/run.py index 3edd905f3531..556d37973ca1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1518,12 +1518,25 @@ async def start(self) -> bool: # This prevents stuck sessions from being blindly resumed on restart, # which can create an unrecoverable loop (#7536). Suspended sessions # auto-reset on the next incoming message, giving the user a clean start. - try: - suspended = self.session_store.suspend_recently_active() - if suspended: - logger.info("Suspended %d in-flight session(s) from previous run", suspended) - except Exception as e: - logger.warning("Session suspension on startup failed: %s", e) + # + # SKIP suspension after a clean (graceful) shutdown — the previous + # process already drained active agents, so sessions aren't stuck. + # This prevents unwanted auto-resets after `hermes update`, + # `hermes gateway restart`, or `/restart`. + _clean_marker = _hermes_home / ".clean_shutdown" + if _clean_marker.exists(): + logger.info("Previous gateway exited cleanly — skipping session suspension") + try: + _clean_marker.unlink() + except Exception: + pass + else: + try: + suspended = self.session_store.suspend_recently_active() + if suspended: + logger.info("Suspended %d in-flight session(s) from previous run", suspended) + except Exception as e: + logger.warning("Session suspension on startup failed: %s", e) connected_count = 0 enabled_platform_count = 0 @@ -2049,6 +2062,15 @@ async def _stop_impl() -> None: from gateway.status import remove_pid_file remove_pid_file() + # Write a clean-shutdown marker so the next startup knows this + # wasn't a crash. suspend_recently_active() only needs to run + # after unexpected exits — graceful shutdowns already drain + # active agents, so there's no stuck-session risk. + try: + (_hermes_home / ".clean_shutdown").touch() + except Exception: + pass + if self._restart_requested and self._restart_via_service: self._exit_code = GATEWAY_SERVICE_RESTART_EXIT_CODE self._exit_reason = self._exit_reason or "Gateway restart requested" diff --git a/tests/gateway/test_clean_shutdown_marker.py b/tests/gateway/test_clean_shutdown_marker.py new file mode 100644 index 000000000000..1a476bc49a57 --- /dev/null +++ b/tests/gateway/test_clean_shutdown_marker.py @@ -0,0 +1,226 @@ +"""Tests for the clean shutdown marker that prevents unwanted session auto-resets. + +When the gateway shuts down gracefully (hermes update, gateway restart, /restart), +it writes a .clean_shutdown marker. On the next startup, if the marker exists, +suspend_recently_active() is skipped so users don't lose their sessions. + +After a crash (no marker), suspension still fires as a safety net for stuck sessions. +""" + +import os +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig, SessionResetPolicy +from gateway.session import SessionEntry, SessionSource, SessionStore + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_source(platform=Platform.TELEGRAM, chat_id="123", user_id="u1"): + return SessionSource(platform=platform, chat_id=chat_id, user_id=user_id) + + +def _make_store(tmp_path, policy=None): + config = GatewayConfig() + if policy: + config.default_reset_policy = policy + return SessionStore(sessions_dir=tmp_path, config=config) + + +# --------------------------------------------------------------------------- +# SessionStore.suspend_recently_active +# --------------------------------------------------------------------------- + +class TestSuspendRecentlyActive: + """Verify suspend_recently_active only marks recent sessions.""" + + def test_suspends_recently_active_sessions(self, tmp_path): + store = _make_store(tmp_path) + source = _make_source() + entry = store.get_or_create_session(source) + assert not entry.suspended + + count = store.suspend_recently_active() + assert count == 1 + + # Re-fetch — should be suspended now + refreshed = store.get_or_create_session(source) + assert refreshed.was_auto_reset + + def test_does_not_suspend_old_sessions(self, tmp_path): + store = _make_store(tmp_path) + source = _make_source() + entry = store.get_or_create_session(source) + + # Backdate the session's updated_at beyond the cutoff + with store._lock: + entry.updated_at = datetime.now() - timedelta(seconds=300) + store._save() + + count = store.suspend_recently_active(max_age_seconds=120) + assert count == 0 + + def test_already_suspended_not_double_counted(self, tmp_path): + store = _make_store(tmp_path) + source = _make_source() + entry = store.get_or_create_session(source) + + # Suspend once + count1 = store.suspend_recently_active() + assert count1 == 1 + + # Create a new session (the old one got reset on next access) + entry2 = store.get_or_create_session(source) + + # Suspend again — the new session is recent but not yet suspended + count2 = store.suspend_recently_active() + assert count2 == 1 + + +# --------------------------------------------------------------------------- +# Clean shutdown marker integration +# --------------------------------------------------------------------------- + +class TestCleanShutdownMarker: + """Test that the marker file controls session suspension on startup.""" + + def test_marker_written_on_graceful_stop(self, tmp_path, monkeypatch): + """stop() should write .clean_shutdown marker.""" + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + marker = tmp_path / ".clean_shutdown" + assert not marker.exists() + + # Create a minimal runner and call the shutdown logic directly + from gateway.run import GatewayRunner + runner = object.__new__(GatewayRunner) + runner._restart_requested = False + runner._restart_detached = False + runner._restart_via_service = False + runner._restart_task_started = False + runner._running = True + runner._draining = False + runner._stop_task = None + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._background_tasks = set() + runner._shutdown_event = MagicMock() + runner._restart_drain_timeout = 5 + runner._exit_code = None + runner._exit_reason = None + runner.adapters = {} + runner.config = GatewayConfig() + + # Mock heavy dependencies + with patch("gateway.run.GatewayRunner._drain_active_agents", new_callable=AsyncMock, return_value=([], False)), \ + patch("gateway.run.GatewayRunner._finalize_shutdown_agents"), \ + patch("gateway.run.GatewayRunner._update_runtime_status"), \ + patch("gateway.status.remove_pid_file"), \ + patch("tools.process_registry.process_registry") as mock_proc_reg, \ + patch("tools.terminal_tool.cleanup_all_environments"), \ + patch("tools.browser_tool.cleanup_all_browsers"): + mock_proc_reg.kill_all = MagicMock() + + import asyncio + asyncio.get_event_loop().run_until_complete(runner.stop()) + + assert marker.exists(), ".clean_shutdown marker should exist after graceful stop" + + def test_marker_skips_suspension_on_startup(self, tmp_path, monkeypatch): + """If .clean_shutdown exists, suspend_recently_active should NOT be called.""" + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + + # Create the marker + marker = tmp_path / ".clean_shutdown" + marker.touch() + + # Create a store with a recently active session + store = _make_store(tmp_path) + source = _make_source() + entry = store.get_or_create_session(source) + assert not entry.suspended + + # Simulate what start() does: + if marker.exists(): + marker.unlink() + # Should NOT call suspend_recently_active + else: + store.suspend_recently_active() + + # Session should NOT be suspended + with store._lock: + store._ensure_loaded_locked() + for e in store._entries.values(): + assert not e.suspended, "Session should NOT be suspended after clean shutdown" + + assert not marker.exists(), "Marker should be cleaned up" + + def test_no_marker_triggers_suspension(self, tmp_path, monkeypatch): + """Without .clean_shutdown marker (crash), suspension should fire.""" + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + + marker = tmp_path / ".clean_shutdown" + assert not marker.exists() + + # Create a store with a recently active session + store = _make_store(tmp_path) + source = _make_source() + entry = store.get_or_create_session(source) + assert not entry.suspended + + # Simulate what start() does: + if marker.exists(): + marker.unlink() + else: + store.suspend_recently_active() + + # Session SHOULD be suspended (crash recovery) + with store._lock: + store._ensure_loaded_locked() + suspended_count = sum(1 for e in store._entries.values() if e.suspended) + assert suspended_count == 1, "Session should be suspended after crash (no marker)" + + def test_marker_written_on_restart_stop(self, tmp_path, monkeypatch): + """stop(restart=True) should also write the marker.""" + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + marker = tmp_path / ".clean_shutdown" + + from gateway.run import GatewayRunner + runner = object.__new__(GatewayRunner) + runner._restart_requested = False + runner._restart_detached = False + runner._restart_via_service = False + runner._restart_task_started = False + runner._running = True + runner._draining = False + runner._stop_task = None + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._background_tasks = set() + runner._shutdown_event = MagicMock() + runner._restart_drain_timeout = 5 + runner._exit_code = None + runner._exit_reason = None + runner.adapters = {} + runner.config = GatewayConfig() + + with patch("gateway.run.GatewayRunner._drain_active_agents", new_callable=AsyncMock, return_value=([], False)), \ + patch("gateway.run.GatewayRunner._finalize_shutdown_agents"), \ + patch("gateway.run.GatewayRunner._update_runtime_status"), \ + patch("gateway.status.remove_pid_file"), \ + patch("tools.process_registry.process_registry") as mock_proc_reg, \ + patch("tools.terminal_tool.cleanup_all_environments"), \ + patch("tools.browser_tool.cleanup_all_browsers"): + mock_proc_reg.kill_all = MagicMock() + + import asyncio + asyncio.get_event_loop().run_until_complete(runner.stop(restart=True)) + + assert marker.exists(), ".clean_shutdown marker should exist after restart-stop too" From 17c72f176d6e821b6de48cc0c4baf776617dae3d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 03:03:16 -0700 Subject: [PATCH 028/288] fix: make skill loading instructions more aggressive in system prompt (#8286) The previous wording ('If one clearly matches') set too high a threshold, and 'If none match, proceed normally' was an easy escape hatch for lazy models. Now: - Lowered threshold: 'matches or is even partially relevant' - Added MUST directive and 'err on the side of loading' guidance - Replaced permissive closer with 'only proceed without if genuinely none are relevant' This should reduce cases where the agent skips loading relevant skills unless explicitly forced. --- agent/prompt_builder.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index e33569f051f5..6eec0392bb61 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -762,7 +762,10 @@ def build_skills_system_prompt( "than to miss critical steps, pitfalls, or established workflows. " "Skills contain specialized knowledge — API endpoints, tool-specific commands, " "and proven workflows that outperform general-purpose approaches. Load the skill " - "even if you think you could handle the task with basic tools like web_search or terminal.\n" + "even if you think you could handle the task with basic tools like web_search or terminal. " + "Skills also encode the user's preferred approach, conventions, and quality standards " + "for tasks like code review, planning, and testing — load them even for tasks you " + "already know how to do, because the skill defines how it should be done here.\n" "If a skill has issues, fix it with skill_manage(action='patch').\n" "After difficult/iterative tasks, offer to save as a skill. " "If a skill you loaded was missing steps, had wrong commands, or needed " From 45e60904c62943c67179215e2c37e01dc2a0e6c3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 03:53:30 -0700 Subject: [PATCH 029/288] fix: fall back to provider's default model when model config is empty (#8303) When a user configures a provider (e.g. `hermes auth add openai-codex`) but never selects a model via `hermes model`, the gateway and CLI would pass an empty model string to the API, causing: 'Codex Responses request model must be a non-empty string' Now both gateway (_resolve_session_agent_runtime) and CLI (_ensure_runtime_credentials) detect an empty model and fill it from the provider's first catalog entry in _PROVIDER_MODELS. This covers all providers that have a static model list (openai-codex, anthropic, gemini, copilot, etc.). The fix is conservative: it only triggers when model is truly empty and a known provider was resolved. Explicit model choices are never overridden. --- cli.py | 16 ++++ gateway/run.py | 17 ++++ hermes_cli/models.py | 14 ++++ tests/test_empty_model_fallback.py | 120 +++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 tests/test_empty_model_fallback.py diff --git a/cli.py b/cli.py index 809be5131715..eb19f43f196f 100644 --- a/cli.py +++ b/cli.py @@ -2735,6 +2735,22 @@ def _ensure_runtime_credentials(self) -> bool: if runtime_model and isinstance(runtime_model, str): self.model = runtime_model + # If model is still empty (e.g. user ran `hermes auth add openai-codex` + # without `hermes model`), fall back to the provider's first catalog + # model so the API call doesn't fail with "model must be non-empty". + if not self.model and resolved_provider: + try: + from hermes_cli.models import get_default_model_for_provider + _default = get_default_model_for_provider(resolved_provider) + if _default: + self.model = _default + logger.info( + "No model configured — defaulting to %s for provider %s", + _default, resolved_provider, + ) + except Exception: + pass + # Normalize model for the resolved provider (e.g. swap non-Codex # models when provider is openai-codex). Fixes #651. model_changed = self._normalize_model_for_provider(resolved_provider) diff --git a/gateway/run.py b/gateway/run.py index 556d37973ca1..b7fbd099677c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -900,6 +900,23 @@ def _resolve_session_agent_runtime( model, runtime_kwargs = self._apply_session_model_override( resolved_session_key, model, runtime_kwargs ) + + # When the config has no model.default but a provider was resolved + # (e.g. user ran `hermes auth add openai-codex` without `hermes model`), + # fall back to the provider's first catalog model so the API call + # doesn't fail with "model must be a non-empty string". + if not model and runtime_kwargs.get("provider"): + try: + from hermes_cli.models import get_default_model_for_provider + model = get_default_model_for_provider(runtime_kwargs["provider"]) + if model: + logger.info( + "No model configured — defaulting to %s for provider %s", + model, runtime_kwargs["provider"], + ) + except Exception: + pass + return model, runtime_kwargs def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict: diff --git a/hermes_cli/models.py b/hermes_cli/models.py index ae4146415eed..857776983251 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -546,6 +546,20 @@ def check_nous_free_tier() -> bool: } +def get_default_model_for_provider(provider: str) -> str: + """Return the default model for a provider, or empty string if unknown. + + Uses the first entry in _PROVIDER_MODELS as the default. This is the + model a user would be offered first in the ``hermes model`` picker. + + Used as a fallback when the user has configured a provider but never + selected a model (e.g. ``hermes auth add openai-codex`` without + ``hermes model``). + """ + models = _PROVIDER_MODELS.get(provider, []) + return models[0] if models else "" + + def _openrouter_model_is_free(pricing: Any) -> bool: """Return True when both prompt and completion pricing are zero.""" if not isinstance(pricing, dict): diff --git a/tests/test_empty_model_fallback.py b/tests/test_empty_model_fallback.py new file mode 100644 index 000000000000..b5f4286727f7 --- /dev/null +++ b/tests/test_empty_model_fallback.py @@ -0,0 +1,120 @@ +"""Tests for empty model fallback — when provider is configured but model is missing.""" + +from unittest.mock import MagicMock, patch +import pytest + + +class TestGetDefaultModelForProvider: + """Unit tests for hermes_cli.models.get_default_model_for_provider.""" + + def test_known_provider_returns_first_model(self): + from hermes_cli.models import get_default_model_for_provider + result = get_default_model_for_provider("openai-codex") + # Should return first model from _PROVIDER_MODELS["openai-codex"] + assert result + assert isinstance(result, str) + + def test_openrouter_returns_empty(self): + """OpenRouter uses dynamic model fetch, no static catalog entry.""" + from hermes_cli.models import get_default_model_for_provider + # OpenRouter is not in _PROVIDER_MODELS — it uses live fetching + result = get_default_model_for_provider("openrouter") + assert result == "" + + def test_unknown_provider_returns_empty(self): + from hermes_cli.models import get_default_model_for_provider + assert get_default_model_for_provider("nonexistent-provider") == "" + + def test_custom_provider_returns_empty(self): + """Custom provider has no model catalog — should return empty.""" + from hermes_cli.models import get_default_model_for_provider + # Custom providers don't have entries in _PROVIDER_MODELS + assert get_default_model_for_provider("some-random-custom") == "" + + +class TestGatewayEmptyModelFallback: + """Test that _resolve_session_agent_runtime fills in empty model from provider catalog.""" + + def test_empty_model_filled_from_provider(self): + """When config has no model but provider is openai-codex, use first codex model.""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner._session_model_overrides = {} + + # Mock _resolve_gateway_model to return empty string + # Mock _resolve_runtime_agent_kwargs to return openai-codex provider + with patch("gateway.run._resolve_gateway_model", return_value=""), \ + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={ + "provider": "openai-codex", + "api_key": "test-key", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_mode": "codex_responses", + }): + model, kwargs = runner._resolve_session_agent_runtime() + + # Model should have been filled in from provider catalog + assert model, "Model should not be empty when provider is known" + assert isinstance(model, str) + assert kwargs["provider"] == "openai-codex" + + def test_nonempty_model_not_overridden(self): + """When config has a model set, don't override it.""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner._session_model_overrides = {} + + with patch("gateway.run._resolve_gateway_model", return_value="gpt-5.4"), \ + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={ + "provider": "openai-codex", + "api_key": "test-key", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_mode": "codex_responses", + }): + model, kwargs = runner._resolve_session_agent_runtime() + + assert model == "gpt-5.4", "Explicit model should not be overridden" + + def test_empty_model_no_provider_stays_empty(self): + """When both model and provider are empty, model stays empty.""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner._session_model_overrides = {} + + with patch("gateway.run._resolve_gateway_model", return_value=""), \ + patch("gateway.run._resolve_runtime_agent_kwargs", return_value={ + "provider": "", + "api_key": "test-key", + "base_url": "https://example.com", + "api_mode": "chat_completions", + }): + model, kwargs = runner._resolve_session_agent_runtime() + + # Can't fill in a default without knowing the provider + assert model == "" + + +class TestResolveGatewayModel: + """Test _resolve_gateway_model reads model from config correctly.""" + + def test_returns_default_key(self): + from gateway.run import _resolve_gateway_model + assert _resolve_gateway_model({"model": {"default": "gpt-5.4"}}) == "gpt-5.4" + + def test_returns_model_key_fallback(self): + from gateway.run import _resolve_gateway_model + assert _resolve_gateway_model({"model": {"model": "gpt-5.4"}}) == "gpt-5.4" + + def test_returns_empty_when_missing(self): + from gateway.run import _resolve_gateway_model + assert _resolve_gateway_model({"model": {}}) == "" + + def test_returns_empty_when_no_model_section(self): + from gateway.run import _resolve_gateway_model + assert _resolve_gateway_model({}) == "" + + def test_string_model_config(self): + from gateway.run import _resolve_gateway_model + assert _resolve_gateway_model({"model": "my-model"}) == "my-model" From 7a67b1350674df2d63a74d871cac98af36f59920 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 04:17:06 -0700 Subject: [PATCH 030/288] fix: title_generator no longer logs as 'compression' task Changed task='compression' to task='title_generation' so auto-title calls don't pollute logs with false compression alarms. --- agent/title_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/title_generator.py b/agent/title_generator.py index 741fe8b09c58..d6ed9200a26d 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -36,7 +36,7 @@ def generate_title(user_message: str, assistant_response: str, timeout: float = try: response = call_llm( - task="compression", # reuse compression task config (cheap/fast model) + task="title_generation", messages=messages, max_tokens=30, temperature=0.3, From 4eecaf06e48834e105cbd989ae0bae5a2a618c1d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 04:52:59 -0700 Subject: [PATCH 031/288] fix: prevent duplicate update prompt spam in gateway watcher (#8343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _watch_update_progress() poll loop never deleted .update_prompt.json after forwarding the prompt to the user, causing the same prompt to be re-sent every poll cycle (2s). Two fixes: 1. Delete .update_prompt.json after forwarding — the update process only polls for .update_response, it doesn't need the prompt file to persist. 2. Guard re-sends with _update_prompt_pending check — belt-and-suspenders to prevent duplicates even under race conditions. Add regression test asserting the prompt is sent exactly once. --- gateway/run.py | 13 +++++-- tests/gateway/test_update_streaming.py | 50 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index b7fbd099677c..207ae120bae6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6668,8 +6668,12 @@ async def _flush_buffer() -> None: if buffer.strip() and (loop.time() - last_stream_time) >= stream_interval: await _flush_buffer() - # Check for prompts - if prompt_path.exists() and session_key: + # Check for prompts — only forward if we haven't already sent + # one that's still awaiting a response. Without this guard the + # watcher would re-read the same .update_prompt.json every poll + # cycle and spam the user with duplicate prompt messages. + if (prompt_path.exists() and session_key + and not self._update_prompt_pending.get(session_key)): try: prompt_data = json.loads(prompt_path.read_text()) prompt_text = prompt_data.get("prompt", "") @@ -6701,6 +6705,11 @@ async def _flush_buffer() -> None: f"or type your answer directly." ) self._update_prompt_pending[session_key] = True + # Remove the prompt file so it isn't re-read on the + # next poll cycle. The update process only needs + # .update_response to continue — it doesn't re-check + # .update_prompt.json while waiting. + prompt_path.unlink(missing_ok=True) logger.info("Forwarded update prompt to %s: %s", session_key, prompt_text[:80]) except (json.JSONDecodeError, OSError) as e: logger.debug("Failed to read update prompt: %s", e) diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py index 8a2cefbbb6cc..c520cbc0d1e1 100644 --- a/tests/gateway/test_update_streaming.py +++ b/tests/gateway/test_update_streaming.py @@ -403,6 +403,56 @@ async def test_falls_back_when_adapter_unavailable(self, tmp_path): # Should not crash; legacy notification handles this case + @pytest.mark.asyncio + async def test_prompt_forwarded_only_once(self, tmp_path): + """Regression: prompt must not be re-sent on every poll cycle. + + Before the fix, the watcher never deleted .update_prompt.json after + forwarding, causing the same prompt to be sent every poll_interval. + """ + runner = _make_runner() + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + + pending = {"platform": "telegram", "chat_id": "111", "user_id": "222", + "session_key": "agent:main:telegram:dm:111"} + (hermes_home / ".update_pending.json").write_text(json.dumps(pending)) + (hermes_home / ".update_output.txt").write_text("") + + mock_adapter = AsyncMock() + runner.adapters = {Platform.TELEGRAM: mock_adapter} + + # Write the prompt file up front (before the watcher starts). + # The watcher should forward it exactly once, then delete it. + prompt = {"prompt": "Would you like to configure new options now? Y/n", + "default": "n", "id": "dup-test"} + (hermes_home / ".update_prompt.json").write_text(json.dumps(prompt)) + + async def finish_after_polls(): + # Wait long enough for multiple poll cycles to occur, then + # simulate a response + completion. + await asyncio.sleep(1.0) + (hermes_home / ".update_response").write_text("n") + await asyncio.sleep(0.3) + (hermes_home / ".update_exit_code").write_text("0") + + with patch("gateway.run._hermes_home", hermes_home): + task = asyncio.create_task(finish_after_polls()) + await runner._watch_update_progress( + poll_interval=0.1, + stream_interval=0.2, + timeout=10.0, + ) + await task + + # Count how many times the prompt text was sent + all_sent = [str(c) for c in mock_adapter.send.call_args_list] + prompt_sends = [s for s in all_sent if "configure new options" in s] + assert len(prompt_sends) == 1, ( + f"Prompt was sent {len(prompt_sends)} times (expected 1). " + f"All sends: {all_sent}" + ) + # --------------------------------------------------------------------------- # Message interception for update prompts From 06a17c57ae3b04d019260b28a9d858542ae0713e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:22:34 -0700 Subject: [PATCH 032/288] =?UTF-8?q?fix:=20improve=20profile=20creation=20U?= =?UTF-8?q?X=20=E2=80=94=20seed=20SOUL.md=20+=20credential=20warning=20(#8?= =?UTF-8?q?553)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh profiles (created without --clone) now: - Auto-seed a default SOUL.md immediately, so users have a file to customize right away instead of discovering it only after first use - Print a clear warning that the profile has no API keys and will inherit from the shell environment unless configured separately - Show the SOUL.md path for personality customization Previously, fresh profiles started with no SOUL.md (only seeded on first use via ensure_hermes_home), no mention of credential isolation, and no guidance about customizing personality. Users reported confusion about profiles using the wrong model/plan tokens and SOUL.md not being read — both traced to operational gaps in the creation UX. Closes #8093 (investigated: code correctly loads SOUL.md from profile HERMES_HOME; issue was operational, not a code bug). --- hermes_cli/main.py | 14 ++++++++++---- hermes_cli/profiles.py | 10 ++++++++++ tests/hermes_cli/test_profiles.py | 3 ++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9ad4d0142b42..1e04008844c3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4243,18 +4243,24 @@ def cmd_profile(args): print(f' Add to your shell config (~/.bashrc or ~/.zshrc):') print(f' export PATH="$HOME/.local/bin:$PATH"') + # Profile dir for display + try: + profile_dir_display = "~/" + str(profile_dir.relative_to(Path.home())) + except ValueError: + profile_dir_display = str(profile_dir) + # Next steps print(f"\nNext steps:") print(f" {name} setup Configure API keys and model") print(f" {name} chat Start chatting") print(f" {name} gateway start Start the messaging gateway") if clone or clone_all: - try: - profile_dir_display = "~/" + str(profile_dir.relative_to(Path.home())) - except ValueError: - profile_dir_display = str(profile_dir) print(f"\n Edit {profile_dir_display}/.env for different API keys") print(f" Edit {profile_dir_display}/SOUL.md for different personality") + else: + print(f"\n ⚠ This profile has no API keys yet. Run '{name} setup' first,") + print(f" or it will inherit keys from your shell environment.") + print(f" Edit {profile_dir_display}/SOUL.md to customize personality") print() except (ValueError, FileExistsError, FileNotFoundError) as e: diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 6735ff0f047c..1e9fcae00523 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -459,6 +459,16 @@ def create_profile( dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) + # Seed a default SOUL.md so the user has a file to customize immediately. + # Skipped when the profile already has one (from --clone / --clone-all). + soul_path = profile_dir / "SOUL.md" + if not soul_path.exists(): + try: + from hermes_cli.default_soul import DEFAULT_SOUL_MD + soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8") + except Exception: + pass # best-effort — don't fail profile creation over this + return profile_dir diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index c970cb6c5389..e6de2f67fc6d 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -177,7 +177,8 @@ def test_clone_config_missing_files_skipped(self, profile_env): # No error; optional files just not copied assert not (profile_dir / "config.yaml").exists() assert not (profile_dir / ".env").exists() - assert not (profile_dir / "SOUL.md").exists() + # SOUL.md is always seeded with the default even when clone source lacks it + assert (profile_dir / "SOUL.md").exists() # =================================================================== From 06290f6a2ff6f0c6e16c30ebca2cb6c61c304b43 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:38:03 -0700 Subject: [PATCH 033/288] fix: handle broken stdin in prompt_toolkit startup (#6393) (#8560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS with uv-managed Python, stdin (fd 0) can be invalid or unregisterable with the asyncio selector, causing: KeyError: '0 is not registered' during prompt_toolkit's app.run() → asyncio.run() → _add_reader(0). Three-layer fix: 1. Pre-flight fstat(0) check before app.run() — detects broken stdin early and prints actionable guidance instead of a raw traceback. 2. Catch KeyError/OSError around app.run() as fallback for edge cases that slip past the fstat guard. 3. Extend asyncio exception handler to suppress selector registration KeyErrors in async callbacks. Fixes #6393 --- cli.py | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/cli.py b/cli.py index eb19f43f196f..76154245402d 100644 --- a/cli.py +++ b/cli.py @@ -9569,17 +9569,37 @@ def _signal_handler(signum, frame): pass # Signal handlers may fail in restricted environments # Install a custom asyncio exception handler that suppresses the - # "Event loop is closed" RuntimeError from httpx transport cleanup. - # This is defense-in-depth — the primary fix is neuter_async_httpx_del - # which disables __del__ entirely, but older clients or SDK upgrades - # could bypass it. + # "Event loop is closed" RuntimeError from httpx transport cleanup + # and the "0 is not registered" KeyError from broken stdin (#6393). + # The RuntimeError fix is defense-in-depth — the primary fix is + # neuter_async_httpx_del which disables __del__ entirely. The + # KeyError fix handles macOS + uv-managed Python environments where + # fd 0 is not reliably available to the asyncio selector. def _suppress_closed_loop_errors(loop, context): exc = context.get("exception") if isinstance(exc, RuntimeError) and "Event loop is closed" in str(exc): return # silently suppress + if isinstance(exc, KeyError) and "is not registered" in str(exc): + return # suppress selector registration failures (#6393) # Fall back to default handler for everything else loop.default_exception_handler(context) + # Validate stdin before launching prompt_toolkit — on macOS with + # uv-managed Python, fd 0 can be invalid or unregisterable with the + # asyncio selector, causing "KeyError: '0 is not registered'" (#6393). + try: + import os as _os + _os.fstat(0) + except OSError: + print( + "Error: stdin (fd 0) is not available.\n" + "This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n" + "Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup" + ) + _run_cleanup() + self._print_exit_summary() + return + # Run the application with patch_stdout for proper output handling try: with patch_stdout(): @@ -9593,6 +9613,17 @@ def _suppress_closed_loop_errors(loop, context): app.run() except (EOFError, KeyboardInterrupt, BrokenPipeError): pass + except (KeyError, OSError) as _stdin_err: + # Catch selector registration failures from broken stdin (#6393). + # This is the fallback for cases that slip past the fstat() guard. + if "is not registered" in str(_stdin_err) or "Bad file descriptor" in str(_stdin_err): + print( + f"\nError: stdin is not usable ({_stdin_err}).\n" + "This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n" + "Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup" + ) + else: + raise finally: self._should_exit = True # Flush memories before exit (only for substantial conversations) From f295b17d929b5d156d4d7c1cfb00b814b10078a0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:38:55 -0700 Subject: [PATCH 034/288] fix: make agent_thread daemon to prevent orphan CLI processes on tab close (#8557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user closes a terminal tab, SIGHUP exits the main thread but the non-daemon agent_thread kept the entire Python process alive — stuck in the API call loop with no interrupt signal. Over many conversations, these orphan processes accumulate and cause massive swap usage (reported: 77GB on a 32GB M1 Pro). Changes: - Make agent_thread daemon=True so the process exits when the main thread finishes its cleanup. Under normal operation this changes nothing — the main thread already waits on agent_thread.is_alive(). - Interrupt the agent in the finally/exit path so the daemon thread stops making API calls promptly rather than being killed mid-flight. --- cli.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index 76154245402d..fdf6254815fa 100644 --- a/cli.py +++ b/cli.py @@ -7617,8 +7617,10 @@ def run_agent(): "error": _summary, } - # Start agent in background thread - agent_thread = threading.Thread(target=run_agent) + # Start agent in background thread (daemon so it cannot keep the + # process alive when the user closes the terminal tab — SIGHUP + # exits the main thread and daemon threads are reaped automatically). + agent_thread = threading.Thread(target=run_agent, daemon=True) agent_thread.start() # Monitor the dedicated interrupt queue while the agent runs. @@ -9626,6 +9628,15 @@ def _suppress_closed_loop_errors(loop, context): raise finally: self._should_exit = True + # Interrupt the agent immediately so its daemon thread stops making + # API calls and exits promptly (agent_thread is daemon, so the + # process will exit once the main thread finishes, but interrupting + # avoids wasted API calls and lets run_conversation clean up). + if self.agent and getattr(self, '_agent_running', False): + try: + self.agent.interrupt() + except Exception: + pass # Flush memories before exit (only for substantial conversations) if self.agent and self.conversation_history: try: From a372c14fc50b0bbaf1fea2f5d3c729adf74e6d59 Mon Sep 17 00:00:00 2001 From: Chen Chia Yang Date: Wed, 8 Apr 2026 20:26:05 +0800 Subject: [PATCH 035/288] fix: strip tags from Gemma 4 responses in _strip_think_blocks Gemma 4 (26B/31B) uses ... to wrap its reasoning output. This tag was not included in the existing list of reasoning tag variants stripped by _strip_think_blocks(), causing raw thinking blocks to leak into the visible response. Added a new re.sub() line for and extended the cleanup regex to include 'thought' alongside the existing variants. Fixes #6148 --- run_agent.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index 333dda3927fd..2fd73160da63 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1872,12 +1872,13 @@ def _strip_think_blocks(self, content: str) -> str: if not content: return "" # Strip all reasoning tag variants: , , , - # , + # , , (Gemma 4) content = re.sub(r'.*?', '', content, flags=re.DOTALL) content = re.sub(r'.*?', '', content, flags=re.DOTALL | re.IGNORECASE) content = re.sub(r'.*?', '', content, flags=re.DOTALL) content = re.sub(r'.*?', '', content, flags=re.DOTALL) - content = re.sub(r'\s*', '', content, flags=re.IGNORECASE) + content = re.sub(r'.*?', '', content, flags=re.DOTALL | re.IGNORECASE) + content = re.sub(r'\s*', '', content, flags=re.IGNORECASE) return content def _looks_like_codex_intermediate_ack( From 326d5febe58a551f7f9f3d46a07dd787a1255f8d Mon Sep 17 00:00:00 2001 From: Chen Chia Yang Date: Thu, 9 Apr 2026 12:33:34 +0800 Subject: [PATCH 036/288] fix: also strip tags during streaming in cli.py --- cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index fdf6254815fa..09cb47e38430 100644 --- a/cli.py +++ b/cli.py @@ -2420,8 +2420,8 @@ def _stream_delta(self, text) -> None: # suppress them during streaming too — unless show_reasoning is # enabled, in which case we route the inner content to the # reasoning display box instead of discarding it. - _OPEN_TAGS = ("", "", "", "", "") - _CLOSE_TAGS = ("", "", "", "", "") + _OPEN_TAGS = ("", "", "", "", "", "") + _CLOSE_TAGS = ("", "", "", "", "", "") # Append to a pre-filter buffer first self._stream_prefilt = getattr(self, "_stream_prefilt", "") + text From 400fe9b2a19f611d417a5fd85cebca3959cd298b Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 12:38:24 -0700 Subject: [PATCH 037/288] fix: add stripping to auxiliary_client + tests auxiliary_client.py had its own regex mirroring _strip_think_blocks but was missing the variant. Also adds test coverage for paired and orphaned tags. --- agent/auxiliary_client.py | 4 ++-- tests/run_agent/test_run_agent.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 3dcc78a98d63..6f2f64e9fdc4 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2454,9 +2454,9 @@ def extract_content_or_reasoning(response) -> str: if content: # Strip inline think/reasoning blocks (mirrors _strip_think_blocks) cleaned = re.sub( - r"<(?:think|thinking|reasoning|REASONING_SCRATCHPAD)>" + r"<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>" r".*?" - r"", + r"", "", content, flags=re.DOTALL | re.IGNORECASE, ).strip() if cleaned: diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index d716b59b273c..e4ae10f20c32 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -302,6 +302,17 @@ def test_mixed_orphaned_and_paired_tags(self, agent): assert "" not in result assert "visible" in result + def test_thought_block_removed(self, agent): + """Gemma 4 uses tags for inline reasoning.""" + result = agent._strip_think_blocks("internal reasoning answer") + assert "internal reasoning" not in result + assert "" not in result + assert "answer" in result + + def test_orphaned_thought_tag(self, agent): + result = agent._strip_think_blocks("orphaned reasoning without close") + assert "" not in result + class TestExtractReasoning: def test_reasoning_field(self, agent): From a9ebb331bcbf4f76be8aae9ae828467188753aa8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:00:07 -0700 Subject: [PATCH 038/288] fix: contextual error diagnostics for invalid API responses (#8565) Previously, all invalid API responses (choices=None) were diagnosed as 'fast response often indicates rate limiting' regardless of actual response time or error code. A 738s Cloudflare 524 timeout was labeled as 'fast response' and 'possible rate limit'. Now extracts the error code from response.error and classifies: - 524: upstream provider timed out (Cloudflare) - 504: upstream gateway timeout - 429: rate limited by upstream provider - 500/502: upstream server error - 503/529: upstream provider overloaded - Other codes: shown with code number - No code + <10s: likely rate limited (timing heuristic) - No code + >60s: likely upstream timeout - No code + 10-60s: neutral response time All downstream messages (retry status, final error, interrupt message) now use the classified hint instead of generic rate-limit language. Reported by community member Lumen Radley (MiMo provider timeouts). --- run_agent.py | 47 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/run_agent.py b/run_agent.py index 2fd73160da63..360ef05177a8 100644 --- a/run_agent.py +++ b/run_agent.py @@ -8225,7 +8225,8 @@ def _stop_spinner(): if self.thinking_callback: self.thinking_callback("") - # This is often rate limiting or provider returning malformed response + # Invalid response — could be rate limiting, provider timeout, + # upstream server error, or malformed response. retry_count += 1 # Eager fallback: empty/malformed responses are a common @@ -8261,11 +8262,44 @@ def _stop_spinner(): if self.verbose_logging: logging.debug(f"Response attributes for invalid response: {resp_attrs}") + # Extract error code from response for contextual diagnostics + _resp_error_code = None + if response and hasattr(response, 'error') and response.error: + _code_raw = getattr(response.error, 'code', None) + if _code_raw is None and isinstance(response.error, dict): + _code_raw = response.error.get('code') + if _code_raw is not None: + try: + _resp_error_code = int(_code_raw) + except (TypeError, ValueError): + pass + + # Build a human-readable failure hint from the error code + # and response time, instead of always assuming rate limiting. + if _resp_error_code == 524: + _failure_hint = f"upstream provider timed out (Cloudflare 524, {api_duration:.0f}s)" + elif _resp_error_code == 504: + _failure_hint = f"upstream gateway timeout (504, {api_duration:.0f}s)" + elif _resp_error_code == 429: + _failure_hint = f"rate limited by upstream provider (429)" + elif _resp_error_code in (500, 502): + _failure_hint = f"upstream server error ({_resp_error_code}, {api_duration:.0f}s)" + elif _resp_error_code in (503, 529): + _failure_hint = f"upstream provider overloaded ({_resp_error_code})" + elif _resp_error_code is not None: + _failure_hint = f"upstream error (code {_resp_error_code}, {api_duration:.0f}s)" + elif api_duration < 10: + _failure_hint = f"fast response ({api_duration:.1f}s) — likely rate limited" + elif api_duration > 60: + _failure_hint = f"slow response ({api_duration:.0f}s) — likely upstream timeout" + else: + _failure_hint = f"response time {api_duration:.1f}s" + self._vprint(f"{self.log_prefix}⚠️ Invalid API response (attempt {retry_count}/{max_retries}): {', '.join(error_details)}", force=True) self._vprint(f"{self.log_prefix} 🏢 Provider: {provider_name}", force=True) cleaned_provider_error = self._clean_error_message(error_msg) self._vprint(f"{self.log_prefix} 📝 Provider message: {cleaned_provider_error}", force=True) - self._vprint(f"{self.log_prefix} ⏱️ Response time: {api_duration:.2f}s (fast response often indicates rate limiting)", force=True) + self._vprint(f"{self.log_prefix} ⏱️ {_failure_hint}", force=True) if retry_count >= max_retries: # Try fallback before giving up @@ -8282,14 +8316,13 @@ def _stop_spinner(): "messages": messages, "completed": False, "api_calls": api_call_count, - "error": "Invalid API response shape. Likely rate limited or malformed provider response.", + "error": f"Invalid API response after {max_retries} retries: {_failure_hint}", "failed": True # Mark as failure for filtering } - # Longer backoff for rate limiting (likely cause of None choices) - # Jittered exponential: 5s base, 120s cap + random jitter + # Backoff before retry — jittered exponential: 5s base, 120s cap wait_time = jittered_backoff(retry_count, base_delay=5.0, max_delay=120.0) - self._vprint(f"{self.log_prefix}⏳ Retrying in {wait_time}s (extended backoff for possible rate limit)...", force=True) + self._vprint(f"{self.log_prefix}⏳ Retrying in {wait_time:.1f}s ({_failure_hint})...", force=True) logging.warning(f"Invalid API response (retry {retry_count}/{max_retries}): {', '.join(error_details)} | Provider: {provider_name}") # Sleep in small increments to stay responsive to interrupts @@ -8300,7 +8333,7 @@ def _stop_spinner(): self._persist_session(messages, conversation_history) self.clear_interrupt() return { - "final_response": f"Operation interrupted: retrying API call after rate limit (retry {retry_count}/{max_retries}).", + "final_response": f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries}).", "messages": messages, "api_calls": api_call_count, "completed": False, From d7785f4d5bfe2863848893a3265e4da4270362ce Mon Sep 17 00:00:00 2001 From: Shuo Date: Sat, 11 Apr 2026 16:57:16 +0800 Subject: [PATCH 039/288] feat(feishu): add scan-to-create onboarding for Feishu / Lark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a QR-based onboarding flow to `hermes gateway setup` for Feishu / Lark. Users scan a QR code with their phone and the platform creates a fully configured bot application automatically — matching the existing WeChat QR login experience. Setup flow: - Choose between QR scan-to-create (new app) or manual credential input (existing app) - Connection mode selection (WebSocket / Webhook) - DM security policy (pairing / open / allowlist / disabled) - Group chat policy (open with @mention / disabled) Implementation: - Onboard functions (init/begin/poll/QR/probe) in gateway/platforms/feishu.py - _setup_feishu() in hermes_cli/gateway.py with manual fallback - probe_bot uses lark_oapi SDK when available, raw HTTP fallback otherwise - qr_register() catches expected errors (network/protocol), propagates bugs - Poll handles HTTP 4xx JSON responses and feishu/lark domain auto-detection Tests: - 25 tests for onboard module (registration, QR, probe, contract, negative paths) - 16 tests for setup flow (credentials, connection mode, DM policy, group policy, adapter integration verifying env vars produce valid FeishuAdapterSettings) Change-Id: I720591ee84755f32dda95fbac4b26dc82cbcf823 --- gateway/platforms/feishu.py | 341 +++++++++++++++ hermes_cli/gateway.py | 179 ++++++++ tests/gateway/test_feishu_onboard.py | 436 ++++++++++++++++++++ tests/gateway/test_setup_feishu.py | 284 +++++++++++++ website/docs/user-guide/messaging/feishu.md | 13 + 5 files changed, 1253 insertions(+) create mode 100644 tests/gateway/test_feishu_onboard.py create mode 100644 tests/gateway/test_setup_feishu.py diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 16f5467b2209..7fce74def38c 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -34,6 +34,9 @@ from pathlib import Path from types import SimpleNamespace from typing import Any, Dict, List, Optional +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen # aiohttp/websockets are independent optional deps — import outside lark_oapi # so they remain available for tests and webhook mode even if lark_oapi is missing. @@ -169,6 +172,19 @@ _FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs _FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback _FEISHU_ACK_EMOJI = "OK" + +# QR onboarding constants +_ONBOARD_ACCOUNTS_URLS = { + "feishu": "https://accounts.feishu.cn", + "lark": "https://accounts.larksuite.com", +} +_ONBOARD_OPEN_URLS = { + "feishu": "https://open.feishu.cn", + "lark": "https://open.larksuite.com", +} +_REGISTRATION_PATH = "/oauth/v1/app/registration" +_ONBOARD_REQUEST_TIMEOUT_S = 10 + # --------------------------------------------------------------------------- # Fallback display strings # --------------------------------------------------------------------------- @@ -3621,3 +3637,328 @@ def _resolve_outbound_file_routing( return _FEISHU_FILE_UPLOAD_TYPE, "file" return _FEISHU_FILE_UPLOAD_TYPE, "file" + + +# ============================================================================= +# QR scan-to-create onboarding +# +# Device-code flow: user scans a QR code with Feishu/Lark mobile app and the +# platform creates a fully configured bot application automatically. +# Called by `hermes gateway setup` via _setup_feishu() in hermes_cli/gateway.py. +# ============================================================================= + + +def _accounts_base_url(domain: str) -> str: + return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"]) + + +def _onboard_open_base_url(domain: str) -> str: + return _ONBOARD_OPEN_URLS.get(domain, _ONBOARD_OPEN_URLS["feishu"]) + + +def _post_registration(base_url: str, body: Dict[str, str]) -> dict: + """POST form-encoded data to the registration endpoint, return parsed JSON. + + The registration endpoint returns JSON even on 4xx (e.g. poll returns + authorization_pending as a 400). We always parse the body regardless of + HTTP status. + """ + url = f"{base_url}{_REGISTRATION_PATH}" + data = urlencode(body).encode("utf-8") + req = Request(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) + try: + with urlopen(req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp: + return json.loads(resp.read().decode("utf-8")) + except HTTPError as exc: + body_bytes = exc.read() + if body_bytes: + try: + return json.loads(body_bytes.decode("utf-8")) + except (ValueError, json.JSONDecodeError): + raise exc from None + raise + + +def _init_registration(domain: str = "feishu") -> None: + """Verify the environment supports client_secret auth. + + Raises RuntimeError if not supported. + """ + base_url = _accounts_base_url(domain) + res = _post_registration(base_url, {"action": "init"}) + methods = res.get("supported_auth_methods") or [] + if "client_secret" not in methods: + raise RuntimeError( + f"Feishu / Lark registration environment does not support client_secret auth. " + f"Supported: {methods}" + ) + + +def _begin_registration(domain: str = "feishu") -> dict: + """Start the device-code flow. Returns device_code, qr_url, user_code, interval, expire_in.""" + base_url = _accounts_base_url(domain) + res = _post_registration(base_url, { + "action": "begin", + "archetype": "PersonalAgent", + "auth_method": "client_secret", + "request_user_info": "open_id", + }) + device_code = res.get("device_code") + if not device_code: + raise RuntimeError("Feishu / Lark registration did not return a device_code") + qr_url = res.get("verification_uri_complete", "") + if "?" in qr_url: + qr_url += "&from=hermes&tp=hermes" + else: + qr_url += "?from=hermes&tp=hermes" + return { + "device_code": device_code, + "qr_url": qr_url, + "user_code": res.get("user_code", ""), + "interval": res.get("interval") or 5, + "expire_in": res.get("expire_in") or 600, + } + + +def _poll_registration( + *, + device_code: str, + interval: int, + expire_in: int, + domain: str = "feishu", +) -> Optional[dict]: + """Poll until the user scans the QR code, or timeout/denial. + + Returns dict with app_id, app_secret, domain, open_id on success. + Returns None on failure. + """ + deadline = time.time() + expire_in + current_domain = domain + domain_switched = False + poll_count = 0 + + while time.time() < deadline: + base_url = _accounts_base_url(current_domain) + try: + res = _post_registration(base_url, { + "action": "poll", + "device_code": device_code, + "tp": "ob_app", + }) + except (URLError, OSError, json.JSONDecodeError): + time.sleep(interval) + continue + + poll_count += 1 + if poll_count == 1: + print(" Fetching configuration results...", end="", flush=True) + elif poll_count % 6 == 0: + print(".", end="", flush=True) + + # Domain auto-detection + user_info = res.get("user_info") or {} + tenant_brand = user_info.get("tenant_brand") + if tenant_brand == "lark" and not domain_switched: + current_domain = "lark" + domain_switched = True + # Fall through — server may return credentials in this same response. + + # Success + if res.get("client_id") and res.get("client_secret"): + if poll_count > 0: + print() # newline after "Fetching configuration results..." dots + return { + "app_id": res["client_id"], + "app_secret": res["client_secret"], + "domain": current_domain, + "open_id": user_info.get("open_id"), + } + + # Terminal errors + error = res.get("error", "") + if error in ("access_denied", "expired_token"): + if poll_count > 0: + print() + logger.warning("[Feishu onboard] Registration %s", error) + return None + + # authorization_pending or unknown — keep polling + time.sleep(interval) + + if poll_count > 0: + print() + logger.warning("[Feishu onboard] Poll timed out after %ds", expire_in) + return None + + +try: + import qrcode as _qrcode_mod +except (ImportError, TypeError): + _qrcode_mod = None # type: ignore[assignment] + + +def _render_qr(url: str) -> bool: + """Try to render a QR code in the terminal. Returns True if successful.""" + if _qrcode_mod is None: + return False + try: + qr = _qrcode_mod.QRCode() + qr.add_data(url) + qr.make(fit=True) + qr.print_ascii(invert=True) + return True + except Exception: + return False + + +def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]: + """Verify bot connectivity via /open-apis/bot/v3/info. + + Uses lark_oapi SDK when available, falls back to raw HTTP otherwise. + Returns {"bot_name": ..., "bot_open_id": ...} on success, None on failure. + """ + if FEISHU_AVAILABLE: + return _probe_bot_sdk(app_id, app_secret, domain) + return _probe_bot_http(app_id, app_secret, domain) + + +def _build_onboard_client(app_id: str, app_secret: str, domain: str) -> Any: + """Build a lark Client for the given credentials and domain.""" + sdk_domain = LARK_DOMAIN if domain == "lark" else FEISHU_DOMAIN + return ( + lark.Client.builder() + .app_id(app_id) + .app_secret(app_secret) + .domain(sdk_domain) + .log_level(lark.LogLevel.WARNING) + .build() + ) + + +def _parse_bot_response(data: dict) -> Optional[dict]: + """Extract bot_name and bot_open_id from a /bot/v3/info response.""" + if data.get("code") != 0: + return None + bot = data.get("bot") or data.get("data", {}).get("bot") or {} + return { + "bot_name": bot.get("bot_name"), + "bot_open_id": bot.get("open_id"), + } + + +def _probe_bot_sdk(app_id: str, app_secret: str, domain: str) -> Optional[dict]: + """Probe bot info using lark_oapi SDK.""" + try: + client = _build_onboard_client(app_id, app_secret, domain) + resp = client.request( + method="GET", + url="/open-apis/bot/v3/info", + body=None, + raw_response=True, + ) + return _parse_bot_response(json.loads(resp.content)) + except Exception as exc: + logger.debug("[Feishu onboard] SDK probe failed: %s", exc) + return None + + +def _probe_bot_http(app_id: str, app_secret: str, domain: str) -> Optional[dict]: + """Fallback probe using raw HTTP (when lark_oapi is not installed).""" + base_url = _onboard_open_base_url(domain) + try: + token_data = json.dumps({"app_id": app_id, "app_secret": app_secret}).encode("utf-8") + token_req = Request( + f"{base_url}/open-apis/auth/v3/tenant_access_token/internal", + data=token_data, + headers={"Content-Type": "application/json"}, + ) + with urlopen(token_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp: + token_res = json.loads(resp.read().decode("utf-8")) + + access_token = token_res.get("tenant_access_token") + if not access_token: + return None + + bot_req = Request( + f"{base_url}/open-apis/bot/v3/info", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + ) + with urlopen(bot_req, timeout=_ONBOARD_REQUEST_TIMEOUT_S) as resp: + bot_res = json.loads(resp.read().decode("utf-8")) + + return _parse_bot_response(bot_res) + except (URLError, OSError, KeyError, json.JSONDecodeError) as exc: + logger.debug("[Feishu onboard] HTTP probe failed: %s", exc) + return None + + +def qr_register( + *, + initial_domain: str = "feishu", + timeout_seconds: int = 600, +) -> Optional[dict]: + """Run the Feishu / Lark scan-to-create QR registration flow. + + Returns on success:: + + { + "app_id": str, + "app_secret": str, + "domain": "feishu" | "lark", + "open_id": str | None, + "bot_name": str | None, + "bot_open_id": str | None, + } + + Returns None on expected failures (network, auth denied, timeout). + Unexpected errors (bugs, protocol regressions) propagate to the caller. + """ + try: + return _qr_register_inner(initial_domain=initial_domain, timeout_seconds=timeout_seconds) + except (RuntimeError, URLError, OSError, json.JSONDecodeError) as exc: + logger.warning("[Feishu onboard] Registration failed: %s", exc) + return None + + +def _qr_register_inner( + *, + initial_domain: str, + timeout_seconds: int, +) -> Optional[dict]: + """Run init → begin → poll → probe. Raises on network/protocol errors.""" + print(" Connecting to Feishu / Lark...", end="", flush=True) + _init_registration(initial_domain) + begin = _begin_registration(initial_domain) + print(" done.") + + print() + qr_url = begin["qr_url"] + if _render_qr(qr_url): + print(f"\n Scan the QR code above, or open this URL directly:\n {qr_url}") + else: + print(f" Open this URL in Feishu / Lark on your phone:\n\n {qr_url}\n") + print(" Tip: pip install qrcode to display a scannable QR code here next time") + print() + + result = _poll_registration( + device_code=begin["device_code"], + interval=begin["interval"], + expire_in=min(begin["expire_in"], timeout_seconds), + domain=initial_domain, + ) + if not result: + return None + + # Probe bot — best-effort, don't fail the registration + bot_info = probe_bot(result["app_id"], result["app_secret"], result["domain"]) + if bot_info: + result["bot_name"] = bot_info.get("bot_name") + result["bot_open_id"] = bot_info.get("bot_open_id") + else: + result["bot_name"] = None + result["bot_open_id"] = None + + return result diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 908d8992a09c..a0a4d6735e27 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2290,6 +2290,183 @@ def _setup_weixin(): print_info(f" User ID: {user_id}") +def _setup_feishu(): + """Interactive setup for Feishu / Lark — scan-to-create or manual credentials.""" + print() + print(color(" ─── 🪽 Feishu / Lark Setup ───", Colors.CYAN)) + + existing_app_id = get_env_value("FEISHU_APP_ID") + existing_secret = get_env_value("FEISHU_APP_SECRET") + if existing_app_id and existing_secret: + print() + print_success("Feishu / Lark is already configured.") + if not prompt_yes_no(" Reconfigure Feishu / Lark?", False): + return + + # ── Choose setup method ── + print() + method_choices = [ + "Scan QR code to create a new bot automatically (recommended)", + "Enter existing App ID and App Secret manually", + ] + method_idx = prompt_choice(" How would you like to set up Feishu / Lark?", method_choices, 0) + + credentials = None + used_qr = False + + if method_idx == 0: + # ── QR scan-to-create ── + try: + from gateway.platforms.feishu import qr_register + except Exception as exc: + print_error(f" Feishu / Lark onboard import failed: {exc}") + qr_register = None + + if qr_register is not None: + try: + credentials = qr_register() + except KeyboardInterrupt: + print() + print_warning(" Feishu / Lark setup cancelled.") + return + except Exception as exc: + print_warning(f" QR registration failed: {exc}") + if credentials: + used_qr = True + if not credentials: + print_info(" QR setup did not complete. Continuing with manual input.") + + # ── Manual credential input ── + if not credentials: + print() + print_info(" Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)") + print_info(" Create an app, enable the Bot capability, and copy the credentials.") + print() + app_id = prompt(" App ID", password=False) + if not app_id: + print_warning(" Skipped — Feishu / Lark won't work without an App ID.") + return + app_secret = prompt(" App Secret", password=True) + if not app_secret: + print_warning(" Skipped — Feishu / Lark won't work without an App Secret.") + return + + domain_choices = ["feishu (China)", "lark (International)"] + domain_idx = prompt_choice(" Domain", domain_choices, 0) + domain = "lark" if domain_idx == 1 else "feishu" + + # Try to probe the bot with manual credentials + bot_name = None + try: + from gateway.platforms.feishu import probe_bot + bot_info = probe_bot(app_id, app_secret, domain) + if bot_info: + bot_name = bot_info.get("bot_name") + print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}") + else: + print_warning(" Could not verify bot connection. Credentials saved anyway.") + except Exception as exc: + print_warning(f" Credential verification skipped: {exc}") + + credentials = { + "app_id": app_id, + "app_secret": app_secret, + "domain": domain, + "open_id": None, + "bot_name": bot_name, + } + + # ── Save core credentials ── + app_id = credentials["app_id"] + app_secret = credentials["app_secret"] + domain = credentials.get("domain", "feishu") + open_id = credentials.get("open_id") + bot_name = credentials.get("bot_name") + + save_env_value("FEISHU_APP_ID", app_id) + save_env_value("FEISHU_APP_SECRET", app_secret) + save_env_value("FEISHU_DOMAIN", domain) + # Bot identity is resolved at runtime via _hydrate_bot_identity(). + + # ── Connection mode ── + if used_qr: + connection_mode = "websocket" + else: + print() + mode_choices = [ + "WebSocket (recommended — no public URL needed)", + "Webhook (requires a reachable HTTP endpoint)", + ] + mode_idx = prompt_choice(" Connection mode", mode_choices, 0) + connection_mode = "webhook" if mode_idx == 1 else "websocket" + if connection_mode == "webhook": + print_info(" Webhook defaults: 127.0.0.1:8765/feishu/webhook") + print_info(" Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH") + print_info(" For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN") + save_env_value("FEISHU_CONNECTION_MODE", connection_mode) + + if bot_name: + print() + print_success(f" Bot created: {bot_name}") + + # ── DM security policy ── + print() + access_choices = [ + "Use DM pairing approval (recommended)", + "Allow all direct messages", + "Only allow listed user IDs", + "Disable direct messages", + ] + access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) + if access_idx == 0: + save_env_value("FEISHU_ALLOW_ALL_USERS", "false") + save_env_value("FEISHU_ALLOWED_USERS", "") + print_success(" DM pairing enabled.") + print_info(" Unknown users can request access; approve with `hermes pairing approve`.") + elif access_idx == 1: + save_env_value("FEISHU_ALLOW_ALL_USERS", "true") + save_env_value("FEISHU_ALLOWED_USERS", "") + print_warning(" Open DM access enabled for Feishu / Lark.") + elif access_idx == 2: + save_env_value("FEISHU_ALLOW_ALL_USERS", "false") + default_allow = open_id or "" + allowlist = prompt(" Allowed user IDs (comma-separated)", default_allow, password=False).replace(" ", "") + save_env_value("FEISHU_ALLOWED_USERS", allowlist) + print_success(" Allowlist saved.") + else: + save_env_value("FEISHU_ALLOW_ALL_USERS", "false") + save_env_value("FEISHU_ALLOWED_USERS", "") + print_warning(" Direct messages disabled.") + + # ── Group policy ── + print() + group_choices = [ + "Respond only when @mentioned in groups (recommended)", + "Disable group chats", + ] + group_idx = prompt_choice(" How should group chats be handled?", group_choices, 0) + if group_idx == 0: + save_env_value("FEISHU_GROUP_POLICY", "open") + print_info(" Group chats enabled (bot must be @mentioned).") + else: + save_env_value("FEISHU_GROUP_POLICY", "disabled") + print_info(" Group chats disabled.") + + # ── Home channel ── + print() + home_channel = prompt(" Home chat ID (optional, for cron/notifications)", password=False) + if home_channel: + save_env_value("FEISHU_HOME_CHANNEL", home_channel) + print_success(f" Home channel set to {home_channel}") + + print() + print_success("🪽 Feishu / Lark configured!") + print_info(f" App ID: {app_id}") + print_info(f" Domain: {domain}") + if bot_name: + print_info(f" Bot: {bot_name}") + + def _setup_signal(): """Interactive setup for Signal messenger.""" import shutil @@ -2467,6 +2644,8 @@ def gateway_setup(): _setup_signal() elif platform["key"] == "weixin": _setup_weixin() + elif platform["key"] == "feishu": + _setup_feishu() else: _setup_standard_platform(platform) diff --git a/tests/gateway/test_feishu_onboard.py b/tests/gateway/test_feishu_onboard.py new file mode 100644 index 000000000000..cb998fa5a965 --- /dev/null +++ b/tests/gateway/test_feishu_onboard.py @@ -0,0 +1,436 @@ +"""Tests for gateway.platforms.feishu — Feishu scan-to-create registration.""" + +import json +from unittest.mock import patch, MagicMock +import pytest + + +def _mock_urlopen(response_data, status=200): + """Create a mock for urllib.request.urlopen that returns JSON response_data.""" + mock_response = MagicMock() + mock_response.read.return_value = json.dumps(response_data).encode("utf-8") + mock_response.status = status + mock_response.__enter__ = lambda s: s + mock_response.__exit__ = MagicMock(return_value=False) + return mock_response + + +class TestPostRegistration: + """Tests for the low-level HTTP helper.""" + + @patch("gateway.platforms.feishu.urlopen") + def test_post_registration_returns_parsed_json(self, mock_urlopen_fn): + from gateway.platforms.feishu import _post_registration + + mock_urlopen_fn.return_value = _mock_urlopen({"nonce": "abc", "supported_auth_methods": ["client_secret"]}) + result = _post_registration("https://accounts.feishu.cn", {"action": "init"}) + assert result["nonce"] == "abc" + assert "client_secret" in result["supported_auth_methods"] + + @patch("gateway.platforms.feishu.urlopen") + def test_post_registration_sends_form_encoded_body(self, mock_urlopen_fn): + from gateway.platforms.feishu import _post_registration + + mock_urlopen_fn.return_value = _mock_urlopen({}) + _post_registration("https://accounts.feishu.cn", {"action": "init", "key": "val"}) + call_args = mock_urlopen_fn.call_args + request = call_args[0][0] + body = request.data.decode("utf-8") + assert "action=init" in body + assert "key=val" in body + assert request.get_header("Content-type") == "application/x-www-form-urlencoded" + + +class TestInitRegistration: + """Tests for the init step.""" + + @patch("gateway.platforms.feishu.urlopen") + def test_init_succeeds_when_client_secret_supported(self, mock_urlopen_fn): + from gateway.platforms.feishu import _init_registration + + mock_urlopen_fn.return_value = _mock_urlopen({ + "nonce": "abc", + "supported_auth_methods": ["client_secret"], + }) + _init_registration("feishu") + + @patch("gateway.platforms.feishu.urlopen") + def test_init_raises_when_client_secret_not_supported(self, mock_urlopen_fn): + from gateway.platforms.feishu import _init_registration + + mock_urlopen_fn.return_value = _mock_urlopen({ + "nonce": "abc", + "supported_auth_methods": ["other_method"], + }) + with pytest.raises(RuntimeError, match="client_secret"): + _init_registration("feishu") + + @patch("gateway.platforms.feishu.urlopen") + def test_init_uses_lark_url_for_lark_domain(self, mock_urlopen_fn): + from gateway.platforms.feishu import _init_registration + + mock_urlopen_fn.return_value = _mock_urlopen({ + "nonce": "abc", + "supported_auth_methods": ["client_secret"], + }) + _init_registration("lark") + call_args = mock_urlopen_fn.call_args + request = call_args[0][0] + assert "larksuite.com" in request.full_url + + +class TestBeginRegistration: + """Tests for the begin step.""" + + @patch("gateway.platforms.feishu.urlopen") + def test_begin_returns_device_code_and_qr_url(self, mock_urlopen_fn): + from gateway.platforms.feishu import _begin_registration + + mock_urlopen_fn.return_value = _mock_urlopen({ + "device_code": "dc_123", + "verification_uri_complete": "https://accounts.feishu.cn/qr/abc", + "user_code": "ABCD-1234", + "interval": 5, + "expire_in": 600, + }) + result = _begin_registration("feishu") + assert result["device_code"] == "dc_123" + assert "qr_url" in result + assert "accounts.feishu.cn" in result["qr_url"] + assert result["user_code"] == "ABCD-1234" + assert result["interval"] == 5 + assert result["expire_in"] == 600 + + @patch("gateway.platforms.feishu.urlopen") + def test_begin_sends_correct_archetype(self, mock_urlopen_fn): + from gateway.platforms.feishu import _begin_registration + + mock_urlopen_fn.return_value = _mock_urlopen({ + "device_code": "dc_123", + "verification_uri_complete": "https://example.com/qr", + "user_code": "X", + "interval": 5, + "expire_in": 600, + }) + _begin_registration("feishu") + request = mock_urlopen_fn.call_args[0][0] + body = request.data.decode("utf-8") + assert "archetype=PersonalAgent" in body + assert "auth_method=client_secret" in body + + +class TestPollRegistration: + """Tests for the poll step.""" + + @patch("gateway.platforms.feishu.time") + @patch("gateway.platforms.feishu.urlopen") + def test_poll_returns_credentials_on_success(self, mock_urlopen_fn, mock_time): + from gateway.platforms.feishu import _poll_registration + + mock_time.time.side_effect = [0, 1] + mock_time.sleep = MagicMock() + + mock_urlopen_fn.return_value = _mock_urlopen({ + "client_id": "cli_app123", + "client_secret": "secret456", + "user_info": {"open_id": "ou_owner", "tenant_brand": "feishu"}, + }) + result = _poll_registration( + device_code="dc_123", interval=1, expire_in=60, domain="feishu" + ) + assert result is not None + assert result["app_id"] == "cli_app123" + assert result["app_secret"] == "secret456" + assert result["domain"] == "feishu" + assert result["open_id"] == "ou_owner" + + @patch("gateway.platforms.feishu.time") + @patch("gateway.platforms.feishu.urlopen") + def test_poll_switches_domain_on_lark_tenant_brand(self, mock_urlopen_fn, mock_time): + from gateway.platforms.feishu import _poll_registration + + mock_time.time.side_effect = [0, 1, 2] + mock_time.sleep = MagicMock() + + pending_resp = _mock_urlopen({ + "error": "authorization_pending", + "user_info": {"tenant_brand": "lark"}, + }) + success_resp = _mock_urlopen({ + "client_id": "cli_lark", + "client_secret": "secret_lark", + "user_info": {"open_id": "ou_lark", "tenant_brand": "lark"}, + }) + mock_urlopen_fn.side_effect = [pending_resp, success_resp] + + result = _poll_registration( + device_code="dc_123", interval=0, expire_in=60, domain="feishu" + ) + assert result is not None + assert result["domain"] == "lark" + + @patch("gateway.platforms.feishu.time") + @patch("gateway.platforms.feishu.urlopen") + def test_poll_success_with_lark_brand_in_same_response(self, mock_urlopen_fn, mock_time): + """Credentials and lark tenant_brand in one response must not be discarded.""" + from gateway.platforms.feishu import _poll_registration + + mock_time.time.side_effect = [0, 1] + mock_time.sleep = MagicMock() + + mock_urlopen_fn.return_value = _mock_urlopen({ + "client_id": "cli_lark_direct", + "client_secret": "secret_lark_direct", + "user_info": {"open_id": "ou_lark_direct", "tenant_brand": "lark"}, + }) + result = _poll_registration( + device_code="dc_123", interval=1, expire_in=60, domain="feishu" + ) + assert result is not None + assert result["app_id"] == "cli_lark_direct" + assert result["domain"] == "lark" + assert result["open_id"] == "ou_lark_direct" + + @patch("gateway.platforms.feishu.time") + @patch("gateway.platforms.feishu.urlopen") + def test_poll_returns_none_on_access_denied(self, mock_urlopen_fn, mock_time): + from gateway.platforms.feishu import _poll_registration + + mock_time.time.side_effect = [0, 1] + mock_time.sleep = MagicMock() + + mock_urlopen_fn.return_value = _mock_urlopen({ + "error": "access_denied", + }) + result = _poll_registration( + device_code="dc_123", interval=1, expire_in=60, domain="feishu" + ) + assert result is None + + @patch("gateway.platforms.feishu.time") + @patch("gateway.platforms.feishu.urlopen") + def test_poll_returns_none_on_timeout(self, mock_urlopen_fn, mock_time): + from gateway.platforms.feishu import _poll_registration + + mock_time.time.side_effect = [0, 999] + mock_time.sleep = MagicMock() + + mock_urlopen_fn.return_value = _mock_urlopen({ + "error": "authorization_pending", + }) + result = _poll_registration( + device_code="dc_123", interval=1, expire_in=1, domain="feishu" + ) + assert result is None + + +class TestRenderQr: + """Tests for QR code terminal rendering.""" + + @patch("gateway.platforms.feishu._qrcode_mod", create=True) + def test_render_qr_returns_true_on_success(self, mock_qrcode_mod): + from gateway.platforms.feishu import _render_qr + + mock_qr = MagicMock() + mock_qrcode_mod.QRCode.return_value = mock_qr + assert _render_qr("https://example.com/qr") is True + mock_qr.add_data.assert_called_once_with("https://example.com/qr") + mock_qr.make.assert_called_once_with(fit=True) + mock_qr.print_ascii.assert_called_once() + + def test_render_qr_returns_false_when_qrcode_missing(self): + from gateway.platforms.feishu import _render_qr + + with patch("gateway.platforms.feishu._qrcode_mod", None): + assert _render_qr("https://example.com/qr") is False + + +class TestProbeBot: + """Tests for bot connectivity verification.""" + + def test_probe_returns_bot_info_on_success(self): + from gateway.platforms.feishu import probe_bot + + with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk: + mock_sdk.return_value = {"bot_name": "TestBot", "bot_open_id": "ou_bot123"} + result = probe_bot("cli_app", "secret", "feishu") + + assert result is not None + assert result["bot_name"] == "TestBot" + assert result["bot_open_id"] == "ou_bot123" + + def test_probe_returns_none_on_failure(self): + from gateway.platforms.feishu import probe_bot + + with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk: + mock_sdk.return_value = None + result = probe_bot("bad_id", "bad_secret", "feishu") + + assert result is None + + @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False) + @patch("gateway.platforms.feishu.urlopen") + def test_http_fallback_when_sdk_unavailable(self, mock_urlopen_fn): + """Without lark_oapi, probe falls back to raw HTTP.""" + from gateway.platforms.feishu import probe_bot + + token_resp = _mock_urlopen({"code": 0, "tenant_access_token": "t-123"}) + bot_resp = _mock_urlopen({"code": 0, "bot": {"bot_name": "HttpBot", "open_id": "ou_http"}}) + mock_urlopen_fn.side_effect = [token_resp, bot_resp] + + result = probe_bot("cli_app", "secret", "feishu") + assert result is not None + assert result["bot_name"] == "HttpBot" + + @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False) + @patch("gateway.platforms.feishu.urlopen") + def test_http_fallback_returns_none_on_network_error(self, mock_urlopen_fn): + from gateway.platforms.feishu import probe_bot + from urllib.error import URLError + + mock_urlopen_fn.side_effect = URLError("connection refused") + result = probe_bot("cli_app", "secret", "feishu") + assert result is None + + +class TestQrRegister: + """Tests for the public qr_register entry point.""" + + @patch("gateway.platforms.feishu.probe_bot") + @patch("gateway.platforms.feishu._render_qr") + @patch("gateway.platforms.feishu._poll_registration") + @patch("gateway.platforms.feishu._begin_registration") + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_success_flow( + self, mock_init, mock_begin, mock_poll, mock_render, mock_probe + ): + from gateway.platforms.feishu import qr_register + + mock_begin.return_value = { + "device_code": "dc_123", + "qr_url": "https://example.com/qr", + "user_code": "ABCD", + "interval": 1, + "expire_in": 60, + } + mock_poll.return_value = { + "app_id": "cli_app", + "app_secret": "secret", + "domain": "feishu", + "open_id": "ou_owner", + } + mock_probe.return_value = {"bot_name": "MyBot", "bot_open_id": "ou_bot"} + + result = qr_register() + assert result is not None + assert result["app_id"] == "cli_app" + assert result["app_secret"] == "secret" + assert result["bot_name"] == "MyBot" + mock_init.assert_called_once() + mock_render.assert_called_once() + + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_returns_none_on_init_failure(self, mock_init): + from gateway.platforms.feishu import qr_register + + mock_init.side_effect = RuntimeError("not supported") + result = qr_register() + assert result is None + + @patch("gateway.platforms.feishu._render_qr") + @patch("gateway.platforms.feishu._poll_registration") + @patch("gateway.platforms.feishu._begin_registration") + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_returns_none_on_poll_failure( + self, mock_init, mock_begin, mock_poll, mock_render + ): + from gateway.platforms.feishu import qr_register + + mock_begin.return_value = { + "device_code": "dc_123", + "qr_url": "https://example.com/qr", + "user_code": "ABCD", + "interval": 1, + "expire_in": 60, + } + mock_poll.return_value = None + + result = qr_register() + assert result is None + + # -- Contract: expected errors → None, unexpected errors → propagate -- + + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_returns_none_on_network_error(self, mock_init): + """URLError (network down) is an expected failure → None.""" + from gateway.platforms.feishu import qr_register + from urllib.error import URLError + + mock_init.side_effect = URLError("DNS resolution failed") + result = qr_register() + assert result is None + + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_returns_none_on_json_error(self, mock_init): + """Malformed server response is an expected failure → None.""" + from gateway.platforms.feishu import qr_register + + mock_init.side_effect = json.JSONDecodeError("bad json", "", 0) + result = qr_register() + assert result is None + + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_propagates_unexpected_errors(self, mock_init): + """Bugs (e.g. AttributeError) must not be swallowed — they propagate.""" + from gateway.platforms.feishu import qr_register + + mock_init.side_effect = AttributeError("some internal bug") + with pytest.raises(AttributeError, match="some internal bug"): + qr_register() + + # -- Negative paths: partial/malformed server responses -- + + @patch("gateway.platforms.feishu._render_qr") + @patch("gateway.platforms.feishu._begin_registration") + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_returns_none_when_begin_missing_device_code( + self, mock_init, mock_begin, mock_render + ): + """Server returns begin response without device_code → RuntimeError → None.""" + from gateway.platforms.feishu import qr_register + + mock_begin.side_effect = RuntimeError("Feishu registration did not return a device_code") + result = qr_register() + assert result is None + + @patch("gateway.platforms.feishu.probe_bot") + @patch("gateway.platforms.feishu._render_qr") + @patch("gateway.platforms.feishu._poll_registration") + @patch("gateway.platforms.feishu._begin_registration") + @patch("gateway.platforms.feishu._init_registration") + def test_qr_register_succeeds_even_when_probe_fails( + self, mock_init, mock_begin, mock_poll, mock_render, mock_probe + ): + """Registration succeeds but probe fails → result with bot_name=None.""" + from gateway.platforms.feishu import qr_register + + mock_begin.return_value = { + "device_code": "dc_123", + "qr_url": "https://example.com/qr", + "user_code": "ABCD", + "interval": 1, + "expire_in": 60, + } + mock_poll.return_value = { + "app_id": "cli_app", + "app_secret": "secret", + "domain": "feishu", + "open_id": "ou_owner", + } + mock_probe.return_value = None # probe failed + + result = qr_register() + assert result is not None + assert result["app_id"] == "cli_app" + assert result["bot_name"] is None + assert result["bot_open_id"] is None diff --git a/tests/gateway/test_setup_feishu.py b/tests/gateway/test_setup_feishu.py new file mode 100644 index 000000000000..0b977cde900d --- /dev/null +++ b/tests/gateway/test_setup_feishu.py @@ -0,0 +1,284 @@ +"""Tests for _setup_feishu() in hermes_cli/gateway.py. + +Verifies that the interactive setup writes env vars that correctly drive the +Feishu adapter: credentials, connection mode, DM policy, and group policy. +""" + +import os +from unittest.mock import patch + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _run_setup_feishu( + *, + qr_result=None, + prompt_yes_no_responses=None, + prompt_choice_responses=None, + prompt_responses=None, + existing_env=None, +): + """Run _setup_feishu() with mocked I/O and return the env vars that were saved. + + Returns a dict of {env_var_name: value} for all save_env_value calls. + """ + existing_env = existing_env or {} + prompt_yes_no_responses = list(prompt_yes_no_responses or [True]) + # QR path: method(0), dm(0), group(0) — 3 choices (no connection mode) + # Manual path: method(1), domain(0), connection(0), dm(0), group(0) — 5 choices + prompt_choice_responses = list(prompt_choice_responses or [0, 0, 0]) + prompt_responses = list(prompt_responses or [""]) + + saved_env = {} + + def mock_save(name, value): + saved_env[name] = value + + def mock_get(name): + return existing_env.get(name, "") + + with patch("hermes_cli.gateway.save_env_value", side_effect=mock_save), \ + patch("hermes_cli.gateway.get_env_value", side_effect=mock_get), \ + patch("hermes_cli.gateway.prompt_yes_no", side_effect=prompt_yes_no_responses), \ + patch("hermes_cli.gateway.prompt_choice", side_effect=prompt_choice_responses), \ + patch("hermes_cli.gateway.prompt", side_effect=prompt_responses), \ + patch("hermes_cli.gateway.print_info"), \ + patch("hermes_cli.gateway.print_success"), \ + patch("hermes_cli.gateway.print_warning"), \ + patch("hermes_cli.gateway.print_error"), \ + patch("hermes_cli.gateway.color", side_effect=lambda t, c: t), \ + patch("gateway.platforms.feishu.qr_register", return_value=qr_result): + + from hermes_cli.gateway import _setup_feishu + _setup_feishu() + + return saved_env + + +# --------------------------------------------------------------------------- +# QR scan-to-create path +# --------------------------------------------------------------------------- + +class TestSetupFeishuQrPath: + """Tests for the QR scan-to-create happy path.""" + + def test_qr_success_saves_core_credentials(self): + env = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", + "app_secret": "secret_test", + "domain": "feishu", + "open_id": "ou_owner", + "bot_name": "TestBot", + "bot_open_id": "ou_bot", + }, + prompt_yes_no_responses=[True], # Start QR + prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open + prompt_responses=[""], # home channel: skip + ) + assert env["FEISHU_APP_ID"] == "cli_test" + assert env["FEISHU_APP_SECRET"] == "secret_test" + assert env["FEISHU_DOMAIN"] == "feishu" + + def test_qr_success_does_not_persist_bot_identity(self): + """Bot identity is discovered at runtime by _hydrate_bot_identity — not persisted + in env, so it stays fresh if the user renames the bot later.""" + env = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", + "app_secret": "secret_test", + "domain": "feishu", + "open_id": "ou_owner", + "bot_name": "TestBot", + "bot_open_id": "ou_bot", + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, 0, 0], + prompt_responses=[""], + ) + assert "FEISHU_BOT_OPEN_ID" not in env + assert "FEISHU_BOT_NAME" not in env + + +# --------------------------------------------------------------------------- +# Connection mode +# --------------------------------------------------------------------------- + +class TestSetupFeishuConnectionMode: + """Connection mode: QR always websocket, manual path lets user choose.""" + + def test_qr_path_defaults_to_websocket(self): + env = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": None, "bot_name": None, "bot_open_id": None, + }, + prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open + prompt_responses=[""], + ) + assert env["FEISHU_CONNECTION_MODE"] == "websocket" + + @patch("gateway.platforms.feishu.probe_bot", return_value=None) + def test_manual_path_websocket(self, _mock_probe): + env = _run_setup_feishu( + qr_result=None, + prompt_choice_responses=[1, 0, 0, 0, 0], # method=manual, domain=feishu, connection=ws, dm=pairing, group=open + prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel + ) + assert env["FEISHU_CONNECTION_MODE"] == "websocket" + + @patch("gateway.platforms.feishu.probe_bot", return_value=None) + def test_manual_path_webhook(self, _mock_probe): + env = _run_setup_feishu( + qr_result=None, + prompt_choice_responses=[1, 0, 1, 0, 0], # method=manual, domain=feishu, connection=webhook, dm=pairing, group=open + prompt_responses=["cli_manual", "secret_manual", ""], # app_id, app_secret, home_channel + ) + assert env["FEISHU_CONNECTION_MODE"] == "webhook" + + +# --------------------------------------------------------------------------- +# DM security policy +# --------------------------------------------------------------------------- + +class TestSetupFeishuDmPolicy: + """DM policy must use platform-scoped FEISHU_ALLOW_ALL_USERS, not the global flag.""" + + def _run_with_dm_choice(self, dm_choice_idx, prompt_responses=None): + return _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": "ou_owner", "bot_name": None, "bot_open_id": None, + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, dm_choice_idx, 0], # method=QR, dm=, group=open + prompt_responses=prompt_responses or [""], + ) + + def test_pairing_sets_feishu_allow_all_false(self): + env = self._run_with_dm_choice(0) + assert env["FEISHU_ALLOW_ALL_USERS"] == "false" + assert env["FEISHU_ALLOWED_USERS"] == "" + assert "GATEWAY_ALLOW_ALL_USERS" not in env + + def test_allow_all_sets_feishu_allow_all_true(self): + env = self._run_with_dm_choice(1) + assert env["FEISHU_ALLOW_ALL_USERS"] == "true" + assert env["FEISHU_ALLOWED_USERS"] == "" + assert "GATEWAY_ALLOW_ALL_USERS" not in env + + def test_allowlist_sets_feishu_allow_all_false_with_list(self): + env = self._run_with_dm_choice(2, prompt_responses=["ou_user1,ou_user2", ""]) + assert env["FEISHU_ALLOW_ALL_USERS"] == "false" + assert env["FEISHU_ALLOWED_USERS"] == "ou_user1,ou_user2" + assert "GATEWAY_ALLOW_ALL_USERS" not in env + + def test_allowlist_prepopulates_with_scan_owner_open_id(self): + """When open_id is available from QR scan, it should be the default allowlist value.""" + # We return the owner's open_id from prompt (+ empty home channel). + env = self._run_with_dm_choice(2, prompt_responses=["ou_owner", ""]) + assert env["FEISHU_ALLOWED_USERS"] == "ou_owner" + + def test_disabled_sets_feishu_allow_all_false(self): + env = self._run_with_dm_choice(3) + assert env["FEISHU_ALLOW_ALL_USERS"] == "false" + assert env["FEISHU_ALLOWED_USERS"] == "" + assert "GATEWAY_ALLOW_ALL_USERS" not in env + + +# --------------------------------------------------------------------------- +# Group policy +# --------------------------------------------------------------------------- + +class TestSetupFeishuGroupPolicy: + + def test_open_with_mention(self): + env = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": None, "bot_name": None, "bot_open_id": None, + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, 0, 0], # method=QR, dm=pairing, group=open + prompt_responses=[""], + ) + assert env["FEISHU_GROUP_POLICY"] == "open" + + def test_disabled(self): + env = _run_setup_feishu( + qr_result={ + "app_id": "cli_test", "app_secret": "s", "domain": "feishu", + "open_id": None, "bot_name": None, "bot_open_id": None, + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, 0, 1], # method=QR, dm=pairing, group=disabled + prompt_responses=[""], + ) + assert env["FEISHU_GROUP_POLICY"] == "disabled" + + +# --------------------------------------------------------------------------- +# Adapter integration: env vars → FeishuAdapterSettings +# --------------------------------------------------------------------------- + +class TestSetupFeishuAdapterIntegration: + """Verify that env vars written by _setup_feishu() produce a valid adapter config. + + This bridges the gap between 'setup wrote the right env vars' and + 'the adapter will actually initialize correctly from those vars'. + """ + + def _make_env_from_setup(self, dm_idx=0, group_idx=0): + """Run _setup_feishu via QR path and return the env vars it would write.""" + return _run_setup_feishu( + qr_result={ + "app_id": "cli_test_app", + "app_secret": "test_secret_value", + "domain": "feishu", + "open_id": "ou_owner", + "bot_name": "IntegrationBot", + "bot_open_id": "ou_bot_integration", + }, + prompt_yes_no_responses=[True], + prompt_choice_responses=[0, dm_idx, group_idx], # method=QR, dm, group + prompt_responses=[""], + ) + + @patch.dict(os.environ, {}, clear=True) + def test_qr_env_produces_valid_adapter_settings(self): + """QR setup → adapter initializes with websocket mode.""" + env = self._make_env_from_setup() + + with patch.dict(os.environ, env, clear=True): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + adapter = FeishuAdapter(PlatformConfig()) + assert adapter._app_id == "cli_test_app" + assert adapter._app_secret == "test_secret_value" + assert adapter._domain_name == "feishu" + assert adapter._connection_mode == "websocket" + + @patch.dict(os.environ, {}, clear=True) + def test_open_dm_env_sets_correct_adapter_state(self): + """Setup with 'allow all DMs' → adapter sees allow-all flag.""" + env = self._make_env_from_setup(dm_idx=1) + + with patch.dict(os.environ, env, clear=True): + from gateway.platforms.feishu import FeishuAdapter + from gateway.config import PlatformConfig + # Verify adapter initializes without error and env var is correct. + FeishuAdapter(PlatformConfig()) + assert os.getenv("FEISHU_ALLOW_ALL_USERS") == "true" + + @patch.dict(os.environ, {}, clear=True) + def test_group_open_env_sets_adapter_group_policy(self): + """Setup with 'open groups' → adapter group_policy is 'open'.""" + env = self._make_env_from_setup(group_idx=0) + + with patch.dict(os.environ, env, clear=True): + from gateway.config import PlatformConfig + from gateway.platforms.feishu import FeishuAdapter + adapter = FeishuAdapter(PlatformConfig()) + assert adapter._group_policy == "open" diff --git a/website/docs/user-guide/messaging/feishu.md b/website/docs/user-guide/messaging/feishu.md index ac4bad239fe3..4d9783d402bc 100644 --- a/website/docs/user-guide/messaging/feishu.md +++ b/website/docs/user-guide/messaging/feishu.md @@ -31,12 +31,25 @@ Set it to `false` only if you explicitly want one shared conversation per chat. ## Step 1: Create a Feishu / Lark App +### Recommended: Scan-to-Create (one command) + +```bash +hermes gateway setup +``` + +Select **Feishu / Lark** and scan the QR code with your Feishu or Lark mobile app. Hermes will automatically create a bot application with the correct permissions and save the credentials. + +### Alternative: Manual Setup + +If scan-to-create is not available, the wizard falls back to manual input: + 1. Open the Feishu or Lark developer console: - Feishu: [https://open.feishu.cn/](https://open.feishu.cn/) - Lark: [https://open.larksuite.com/](https://open.larksuite.com/) 2. Create a new app. 3. In **Credentials & Basic Info**, copy the **App ID** and **App Secret**. 4. Enable the **Bot** capability for the app. +5. Run `hermes gateway setup`, select **Feishu / Lark**, and enter the credentials when prompted. :::warning Keep the App Secret private. Anyone with it can impersonate your app. From 1179918746a7df85399f4727a3aa44b5c307b9a9 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 13:02:55 -0700 Subject: [PATCH 040/288] fix: salvage follow-ups for Feishu QR onboarding (#7706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate _setup_feishu() definition (old 3-line version left behind by cherry-pick — Python picked the new one but dead code remained) - Remove misleading 'Disable direct messages' DM option — the Feishu adapter has no DM policy mechanism, so 'disable' produced identical env vars to 'pairing'. Users who chose 'disable' would still see pairing prompts. Reduced to 3 options: pairing, allow-all, allowlist. - Fix test_probe_returns_bot_info_on_success and test_probe_returns_none_on_failure: patch FEISHU_AVAILABLE=True so probe_bot() takes the SDK path when lark_oapi is not installed --- hermes_cli/gateway.py | 13 +------------ tests/gateway/test_feishu_onboard.py | 2 ++ tests/gateway/test_setup_feishu.py | 5 ----- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index a0a4d6735e27..8cdb856c96fd 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2100,12 +2100,6 @@ def _setup_dingtalk(): _setup_standard_platform(dingtalk_platform) -def _setup_feishu(): - """Configure Feishu / Lark via the standard platform setup.""" - feishu_platform = next(p for p in _PLATFORMS if p["key"] == "feishu") - _setup_standard_platform(feishu_platform) - - def _setup_wecom(): """Configure WeCom (Enterprise WeChat) via the standard platform setup.""" wecom_platform = next(p for p in _PLATFORMS if p["key"] == "wecom") @@ -2415,7 +2409,6 @@ def _setup_feishu(): "Use DM pairing approval (recommended)", "Allow all direct messages", "Only allow listed user IDs", - "Disable direct messages", ] access_idx = prompt_choice(" How should direct messages be authorized?", access_choices, 0) if access_idx == 0: @@ -2427,16 +2420,12 @@ def _setup_feishu(): save_env_value("FEISHU_ALLOW_ALL_USERS", "true") save_env_value("FEISHU_ALLOWED_USERS", "") print_warning(" Open DM access enabled for Feishu / Lark.") - elif access_idx == 2: + else: save_env_value("FEISHU_ALLOW_ALL_USERS", "false") default_allow = open_id or "" allowlist = prompt(" Allowed user IDs (comma-separated)", default_allow, password=False).replace(" ", "") save_env_value("FEISHU_ALLOWED_USERS", allowlist) print_success(" Allowlist saved.") - else: - save_env_value("FEISHU_ALLOW_ALL_USERS", "false") - save_env_value("FEISHU_ALLOWED_USERS", "") - print_warning(" Direct messages disabled.") # ── Group policy ── print() diff --git a/tests/gateway/test_feishu_onboard.py b/tests/gateway/test_feishu_onboard.py index cb998fa5a965..1ba1a64aa3fa 100644 --- a/tests/gateway/test_feishu_onboard.py +++ b/tests/gateway/test_feishu_onboard.py @@ -248,6 +248,7 @@ def test_render_qr_returns_false_when_qrcode_missing(self): class TestProbeBot: """Tests for bot connectivity verification.""" + @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True) def test_probe_returns_bot_info_on_success(self): from gateway.platforms.feishu import probe_bot @@ -259,6 +260,7 @@ def test_probe_returns_bot_info_on_success(self): assert result["bot_name"] == "TestBot" assert result["bot_open_id"] == "ou_bot123" + @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True) def test_probe_returns_none_on_failure(self): from gateway.platforms.feishu import probe_bot diff --git a/tests/gateway/test_setup_feishu.py b/tests/gateway/test_setup_feishu.py index 0b977cde900d..26165528e24e 100644 --- a/tests/gateway/test_setup_feishu.py +++ b/tests/gateway/test_setup_feishu.py @@ -181,11 +181,6 @@ def test_allowlist_prepopulates_with_scan_owner_open_id(self): env = self._run_with_dm_choice(2, prompt_responses=["ou_owner", ""]) assert env["FEISHU_ALLOWED_USERS"] == "ou_owner" - def test_disabled_sets_feishu_allow_all_false(self): - env = self._run_with_dm_choice(3) - assert env["FEISHU_ALLOW_ALL_USERS"] == "false" - assert env["FEISHU_ALLOWED_USERS"] == "" - assert "GATEWAY_ALLOW_ALL_USERS" not in env # --------------------------------------------------------------------------- From a4593f8b21bee5dbaa61f018aa4f1751e115ede3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:06:34 -0700 Subject: [PATCH 041/288] feat: make gateway 'still working' notification interval configurable (#8572) Add agent.gateway_notify_interval config option (default 600s). Set to 0 to disable periodic 'still working' notifications. Bridged to HERMES_AGENT_NOTIFY_INTERVAL env var (same pattern as gateway_timeout and gateway_timeout_warning). The inactivity warning (gateway_timeout_warning) was already configurable; this makes the wall-clock ping configurable too. --- gateway/run.py | 12 ++++++++++-- hermes_cli/config.py | 4 ++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 207ae120bae6..94f1dde5323d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -186,6 +186,8 @@ def _ensure_ssl_certs() -> None: os.environ["HERMES_AGENT_TIMEOUT"] = str(_agent_cfg["gateway_timeout"]) if "gateway_timeout_warning" in _agent_cfg and "HERMES_AGENT_TIMEOUT_WARNING" not in os.environ: os.environ["HERMES_AGENT_TIMEOUT_WARNING"] = str(_agent_cfg["gateway_timeout_warning"]) + if "gateway_notify_interval" in _agent_cfg and "HERMES_AGENT_NOTIFY_INTERVAL" not in os.environ: + os.environ["HERMES_AGENT_NOTIFY_INTERVAL"] = str(_agent_cfg["gateway_notify_interval"]) if "restart_drain_timeout" in _agent_cfg and "HERMES_RESTART_DRAIN_TIMEOUT" not in os.environ: os.environ["HERMES_RESTART_DRAIN_TIMEOUT"] = str(_agent_cfg["restart_drain_timeout"]) _display_cfg = _cfg.get("display", {}) @@ -8146,11 +8148,17 @@ async def monitor_for_interrupt(): interrupt_monitor = asyncio.create_task(monitor_for_interrupt()) # Periodic "still working" notifications for long-running tasks. - # Fires every 10 minutes so the user knows the agent hasn't died. - _NOTIFY_INTERVAL = 600 # 10 minutes + # Fires every N seconds so the user knows the agent hasn't died. + # Config: agent.gateway_notify_interval in config.yaml, or + # HERMES_AGENT_NOTIFY_INTERVAL env var. Default 600s (10 min). + # 0 = disable notifications. + _NOTIFY_INTERVAL_RAW = float(os.getenv("HERMES_AGENT_NOTIFY_INTERVAL", 600)) + _NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None _notify_start = time.time() async def _notify_long_running(): + if _NOTIFY_INTERVAL is None: + return # Notifications disabled (gateway_notify_interval: 0) _notify_adapter = self.adapters.get(source.platform) if not _notify_adapter: return diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 8c46f8bba131..011c9bca9b65 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -355,6 +355,10 @@ def _ensure_hermes_home_managed(home: Path): # threshold before escalating to a full timeout. The warning fires # once per run and does not interrupt the agent. 0 = disable warning. "gateway_timeout_warning": 900, + # Periodic "still working" notification interval (seconds). + # Sends a status message every N seconds so the user knows the + # agent hasn't died during long tasks. 0 = disable notifications. + "gateway_notify_interval": 600, }, "terminal": { From d6785dc4d40cdd37d2ea1e28d5f012572b3cf17e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:38:11 -0700 Subject: [PATCH 042/288] fix: empty response recovery for reasoning models (mimo, qwen, GLM) (#8609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for the (empty) response bug affecting open reasoning models: 1. Allow retries after prefill exhaustion — models like mimo-v2-pro always populate reasoning fields via OpenRouter, so the old 'not _has_structured' guard on the retry path blocked retries for EVERY reasoning model after the 2 prefill attempts. Now: 2 prefills + 3 retries = 6 total attempts before (empty). 2. Reset prefill/retry counters on tool-call recovery — the counters accumulated across the entire conversation, never resetting during tool-calling turns. A model cycling empty→prefill→tools→empty burned both prefill attempts and the third empty got zero recovery. Now counters reset when prefill succeeds with tool calls. 3. Strip think blocks before _truly_empty check — inline content made the string non-empty, skipping both retry paths. Reported by users on Telegram with xiaomi/mimo-v2-pro and qwen3.5 models. Reproduced: qwen3.5-9b emits tool calls as XML in reasoning field instead of proper function calls, causing content=None + tool_calls=None + reasoning with embedded XML. Prefill recovery works but counter accumulation caused permanent (empty) in long sessions. --- run_agent.py | 40 +++++++++++++++++++++++-------- tests/run_agent/test_run_agent.py | 14 +++++------ 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/run_agent.py b/run_agent.py index 360ef05177a8..4c0d3be4b0c2 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9736,12 +9736,25 @@ def _stop_spinner(): # Pop thinking-only prefill message(s) before appending # (tool-call path — same rationale as the final-response path). + _had_prefill = False while ( messages and isinstance(messages[-1], dict) and messages[-1].get("_thinking_prefill") ): messages.pop() + _had_prefill = True + + # Reset prefill counter when tool calls follow a prefill + # recovery. Without this, the counter accumulates across + # the whole conversation — a model that intermittently + # empties (empty → prefill → tools → empty → prefill → + # tools) burns both prefill attempts and the third empty + # gets zero recovery. Resetting here treats each tool- + # call success as a fresh start. + if _had_prefill: + self._thinking_prefill_retries = 0 + self._empty_content_retries = 0 messages.append(assistant_msg) self._emit_interim_assistant_message(assistant_msg) @@ -9917,16 +9930,23 @@ def _stop_spinner(): self._save_session_log(messages) continue - # ── Empty response retry (no reasoning) ────── - # Model returned nothing — no content, no - # structured reasoning, no tool calls. Common - # with open models (transient provider issues, - # rate limits, sampling flukes). Retry up to 3 - # times before attempting fallback. Skip when - # content has inline tags (model chose - # to reason, just no visible text). - _truly_empty = not final_response.strip() - if _truly_empty and not _has_structured and self._empty_content_retries < 3: + # ── Empty response retry ────────────────────── + # Model returned nothing usable. Retry up to 3 + # times before attempting fallback. This covers + # both truly empty responses (no content, no + # reasoning) AND reasoning-only responses after + # prefill exhaustion — models like mimo-v2-pro + # always populate reasoning fields via OpenRouter, + # so the old `not _has_structured` guard blocked + # retries for every reasoning model after prefill. + _truly_empty = not self._strip_think_blocks( + final_response + ).strip() + _prefill_exhausted = ( + _has_structured + and self._thinking_prefill_retries >= 2 + ) + if _truly_empty and (not _has_structured or _prefill_exhausted) and self._empty_content_retries < 3: self._empty_content_retries += 1 logger.warning( "Empty response (no content or reasoning) — " diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index e4ae10f20c32..2112ddc3f08f 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -1741,9 +1741,9 @@ def test_reasoning_only_local_resumed_no_compression_triggered(self, agent): {"role": "assistant", "content": "old answer"}, ] - # 3 responses: original + 2 prefill continuations (structured reasoning triggers prefill) + # 6 responses: original + 2 prefill + 3 retries after prefill exhaustion with ( - patch.object(agent, "_interruptible_api_call", side_effect=[empty_resp, empty_resp, empty_resp]), + patch.object(agent, "_interruptible_api_call", side_effect=[empty_resp] * 6), patch.object(agent, "_compress_context") as mock_compress, patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), @@ -1754,18 +1754,18 @@ def test_reasoning_only_local_resumed_no_compression_triggered(self, agent): mock_compress.assert_not_called() # no compression triggered assert result["completed"] is True assert result["final_response"] == "(empty)" - assert result["api_calls"] == 3 # 1 original + 2 prefill continuations + assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries def test_reasoning_only_response_prefill_then_empty(self, agent): - """Structured reasoning-only triggers prefill continuation (up to 2), then falls through to (empty).""" + """Structured reasoning-only triggers prefill (2), then retries (3), then (empty).""" self._setup_agent(agent) empty_resp = _mock_response( content=None, finish_reason="stop", reasoning_content="structured reasoning answer", ) - # 3 responses: original + 2 prefill continuations, all reasoning-only - agent.client.chat.completions.create.side_effect = [empty_resp, empty_resp, empty_resp] + # 6 responses: 1 original + 2 prefill + 3 retries after prefill exhaustion + agent.client.chat.completions.create.side_effect = [empty_resp] * 6 with ( patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), @@ -1774,7 +1774,7 @@ def test_reasoning_only_response_prefill_then_empty(self, agent): result = agent.run_conversation("answer me") assert result["completed"] is True assert result["final_response"] == "(empty)" - assert result["api_calls"] == 3 # 1 original + 2 prefill continuations + assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries def test_reasoning_only_prefill_succeeds_on_continuation(self, agent): """When prefill continuation produces content, it becomes the final response.""" From 18ab5c99d1f67c392b7dd14e6800ba38f16bfa43 Mon Sep 17 00:00:00 2001 From: sprmn24 Date: Sun, 12 Apr 2026 23:56:55 +0300 Subject: [PATCH 043/288] fix(backup): correct marker filenames in _validate_backup_zip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backup validation checked for 'hermes_state.db' and 'memory_store.db' as telltale markers of a valid Hermes backup zip. Neither name exists in a real Hermes installation — the actual database file is 'state.db' (hermes_state.py: DEFAULT_DB_PATH = get_hermes_home() / 'state.db'). A fresh Hermes installation produces: ~/.hermes/state.db (actual name) ~/.hermes/config.yaml ~/.hermes/.env Because the marker set never matched 'state.db', a backup zip containing only 'state.db' plus 'config.yaml' would fail validation with: 'zip does not appear to be a Hermes backup' and the import would exit with sys.exit(1), silently rejecting a valid backup. Fix: replace the wrong marker names with the correct filename. Adds TestValidateBackupZip with three cases: - state.db is accepted as a valid marker - old wrong names (hermes_state.db, memory_store.db) alone are rejected - config.yaml continues to pass (existing behaviour preserved) --- hermes_cli/backup.py | 2 +- tests/hermes_cli/test_backup.py | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 9aca0f822160..3380f494f6d8 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -201,7 +201,7 @@ def _validate_backup_zip(zf: zipfile.ZipFile) -> tuple[bool, str]: return False, "zip archive is empty" # Look for telltale files that a hermes home would have - markers = {"config.yaml", ".env", "hermes_state.db", "memory_store.db"} + markers = {"config.yaml", ".env", "state.db"} found = set() for n in names: # Could be at the root or one level deep (if someone zipped the directory) diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 8ef3858962cb..a4dbae52aba7 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -232,6 +232,44 @@ def test_default_output_path(self, tmp_path, monkeypatch): assert len(zips) == 1 +# --------------------------------------------------------------------------- +# _validate_backup_zip tests +# --------------------------------------------------------------------------- + +class TestValidateBackupZip: + def _make_zip(self, zip_path: Path, filenames: list[str]) -> None: + with zipfile.ZipFile(zip_path, "w") as zf: + for name in filenames: + zf.writestr(name, "dummy") + + def test_state_db_passes(self, tmp_path): + """A zip containing state.db is accepted as a valid Hermes backup.""" + from hermes_cli.backup import _validate_backup_zip + zip_path = tmp_path / "backup.zip" + self._make_zip(zip_path, ["state.db", "sessions/abc.json"]) + with zipfile.ZipFile(zip_path, "r") as zf: + ok, reason = _validate_backup_zip(zf) + assert ok, reason + + def test_old_wrong_db_name_fails(self, tmp_path): + """A zip with only hermes_state.db (old wrong name) is rejected.""" + from hermes_cli.backup import _validate_backup_zip + zip_path = tmp_path / "old.zip" + self._make_zip(zip_path, ["hermes_state.db", "memory_store.db"]) + with zipfile.ZipFile(zip_path, "r") as zf: + ok, reason = _validate_backup_zip(zf) + assert not ok + + def test_config_yaml_passes(self, tmp_path): + """A zip containing config.yaml is accepted (existing behaviour preserved).""" + from hermes_cli.backup import _validate_backup_zip + zip_path = tmp_path / "backup.zip" + self._make_zip(zip_path, ["config.yaml", "skills/x/SKILL.md"]) + with zipfile.ZipFile(zip_path, "r") as zf: + ok, reason = _validate_backup_zip(zf) + assert ok, reason + + # --------------------------------------------------------------------------- # Import tests # --------------------------------------------------------------------------- From 5e1197a42e84ecd620afaeb7d52c3438260c3129 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sun, 12 Apr 2026 14:42:46 -0700 Subject: [PATCH 044/288] fix(gateway): harden Docker/container gateway pathway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralize container detection in hermes_constants.is_container() with process-lifetime caching, matching existing is_wsl()/is_termux() patterns. Dedup _is_inside_container() in config.py to delegate to the new function. Add _run_systemctl() wrapper that converts FileNotFoundError to RuntimeError for defense-in-depth — all 10 bare subprocess.run(_systemctl_cmd(...)) call sites now route through it. Make supports_systemd_services() return False in containers and when systemctl binary is absent (shutil.which check). Add Docker-specific guidance in gateway_command() for install/uninstall/start subcommands — exit 0 with helpful instructions instead of crashing. Make 'hermes status' show 'Manager: docker (foreground)' and 'hermes dump' show 'running (docker, pid N)' inside containers. Fix setup_gateway() to use supports_systemd instead of _is_linux for all systemd-related branches, and show Docker restart policy instructions in containers. Replace inline /.dockerenv check in voice_mode.py with is_container(). Fixes #7420 Co-authored-by: teknium1 --- hermes_cli/config.py | 22 +--- hermes_cli/dump.py | 10 ++ hermes_cli/gateway.py | 96 +++++++++++---- hermes_cli/setup.py | 32 +++-- hermes_cli/status.py | 46 ++++--- hermes_constants.py | 31 +++++ tests/hermes_cli/test_container_aware_cli.py | 55 ++------- tests/hermes_cli/test_gateway_service.py | 120 +++++++++++++++++++ tests/hermes_cli/test_setup.py | 83 ++++++++++++- tests/test_hermes_constants.py | 53 +++++++- tools/voice_mode.py | 5 +- 11 files changed, 428 insertions(+), 125 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 011c9bca9b65..b9c8106be7f3 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -148,25 +148,6 @@ def managed_error(action: str = "modify configuration"): # Container-aware CLI (NixOS container mode) # ============================================================================= -def _is_inside_container() -> bool: - """Detect if we're already running inside a Docker/Podman container.""" - # Standard Docker/Podman indicators - if os.path.exists("/.dockerenv"): - return True - # Podman uses /run/.containerenv - if os.path.exists("/run/.containerenv"): - return True - # Check cgroup for container runtime evidence (works for both Docker & Podman) - try: - with open("/proc/1/cgroup", "r") as f: - cgroup = f.read() - if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup: - return True - except OSError: - pass - return False - - def get_container_exec_info() -> Optional[dict]: """Read container mode metadata from HERMES_HOME/.container-mode. @@ -181,7 +162,8 @@ def get_container_exec_info() -> Optional[dict]: if os.environ.get("HERMES_DEV") == "1": return None - if _is_inside_container(): + from hermes_constants import is_container + if is_container(): return None container_mode_file = get_hermes_home() / ".container-mode" diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index caa6b7e8ca41..491bf6e2c377 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -44,6 +44,16 @@ def _redact(value: str) -> str: def _gateway_status() -> str: """Return a short gateway status string.""" if sys.platform.startswith("linux"): + from hermes_constants import is_container + if is_container(): + try: + from hermes_cli.gateway import find_gateway_pids + pids = find_gateway_pids() + if pids: + return f"running (docker, pid {pids[0]})" + return "stopped (docker)" + except Exception: + return "stopped (docker)" try: from hermes_cli.gateway import get_service_name svc = get_service_name() diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 8cdb856c96fd..6c2b59c964b9 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -331,7 +331,7 @@ def is_linux() -> bool: return sys.platform.startswith('linux') -from hermes_constants import is_termux, is_wsl +from hermes_constants import is_container, is_termux, is_wsl def _wsl_systemd_operational() -> bool: @@ -353,7 +353,9 @@ def _wsl_systemd_operational() -> bool: def supports_systemd_services() -> bool: - if not is_linux() or is_termux(): + if not is_linux() or is_termux() or is_container(): + return False + if shutil.which("systemctl") is None: return False if is_wsl(): return _wsl_systemd_operational() @@ -483,6 +485,21 @@ def _journalctl_cmd(system: bool = False) -> list[str]: return ["journalctl"] if system else ["journalctl", "--user"] +def _run_systemctl(args: list[str], *, system: bool = False, **kwargs) -> subprocess.CompletedProcess: + """Run a systemctl command, raising RuntimeError if systemctl is missing. + + Defense-in-depth: callers are gated by ``supports_systemd_services()``, + but this ensures any future caller that bypasses the gate still gets a + clear error instead of a raw ``FileNotFoundError`` traceback. + """ + try: + return subprocess.run(_systemctl_cmd(system) + args, **kwargs) + except FileNotFoundError: + raise RuntimeError( + "systemctl is not available on this system" + ) from None + + def _service_scope_label(system: bool = False) -> str: return "system" if system else "user" @@ -929,7 +946,7 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool: expected_user = _read_systemd_user_from_unit(unit_path) if system else None unit_path.write_text(generate_systemd_unit(system=system, run_as_user=expected_user), encoding="utf-8") - subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30) + _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) print(f"↻ Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install") return True @@ -1025,7 +1042,7 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str if not systemd_unit_is_current(system=system): print(f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}") refresh_systemd_unit_if_needed(system=system) - subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True, timeout=30) + _run_systemctl(["enable", get_service_name()], system=system, check=True, timeout=30) print(f"✓ {_service_scope_label(system).capitalize()} service definition updated") return print(f"Service already installed at: {unit_path}") @@ -1036,8 +1053,8 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}") unit_path.write_text(generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8") - subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30) - subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True, timeout=30) + _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) + _run_systemctl(["enable", get_service_name()], system=system, check=True, timeout=30) print() print(f"✓ {_service_scope_label(system).capitalize()} service installed and enabled!") @@ -1063,15 +1080,15 @@ def systemd_uninstall(system: bool = False): if system: _require_root_for_system_service("uninstall") - subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=False, timeout=90) - subprocess.run(_systemctl_cmd(system) + ["disable", get_service_name()], check=False, timeout=30) + _run_systemctl(["stop", get_service_name()], system=system, check=False, timeout=90) + _run_systemctl(["disable", get_service_name()], system=system, check=False, timeout=30) unit_path = get_systemd_unit_path(system=system) if unit_path.exists(): unit_path.unlink() print(f"✓ Removed {unit_path}") - subprocess.run(_systemctl_cmd(system) + ["daemon-reload"], check=True, timeout=30) + _run_systemctl(["daemon-reload"], system=system, check=True, timeout=30) print(f"✓ {_service_scope_label(system).capitalize()} service uninstalled") @@ -1080,7 +1097,7 @@ def systemd_start(system: bool = False): if system: _require_root_for_system_service("start") refresh_systemd_unit_if_needed(system=system) - subprocess.run(_systemctl_cmd(system) + ["start", get_service_name()], check=True, timeout=30) + _run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30) print(f"✓ {_service_scope_label(system).capitalize()} service started") @@ -1089,7 +1106,7 @@ def systemd_stop(system: bool = False): system = _select_systemd_scope(system) if system: _require_root_for_system_service("stop") - subprocess.run(_systemctl_cmd(system) + ["stop", get_service_name()], check=True, timeout=90) + _run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90) print(f"✓ {_service_scope_label(system).capitalize()} service stopped") @@ -1105,7 +1122,7 @@ def systemd_restart(system: bool = False): if pid is not None and _request_gateway_self_restart(pid): print(f"✓ {_service_scope_label(system).capitalize()} service restart requested") return - subprocess.run(_systemctl_cmd(system) + ["reload-or-restart", get_service_name()], check=True, timeout=90) + _run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90) print(f"✓ {_service_scope_label(system).capitalize()} service restarted") @@ -1129,14 +1146,16 @@ def systemd_status(deep: bool = False, system: bool = False): print(f" Run: {'sudo ' if system else ''}hermes gateway restart{scope_flag} # auto-refreshes the unit") print() - subprocess.run( - _systemctl_cmd(system) + ["status", get_service_name(), "--no-pager"], + _run_systemctl( + ["status", get_service_name(), "--no-pager"], + system=system, capture_output=False, timeout=10, ) - result = subprocess.run( - _systemctl_cmd(system) + ["is-active", get_service_name()], + result = _run_systemctl( + ["is-active", get_service_name()], + system=system, capture_output=True, text=True, timeout=10, @@ -2123,24 +2142,24 @@ def _is_service_running() -> bool: if user_unit_exists: try: - result = subprocess.run( - _systemctl_cmd(False) + ["is-active", get_service_name()], - capture_output=True, text=True, timeout=10, + result = _run_systemctl( + ["is-active", get_service_name()], + system=False, capture_output=True, text=True, timeout=10, ) if result.stdout.strip() == "active": return True - except subprocess.TimeoutExpired: + except (RuntimeError, subprocess.TimeoutExpired): pass if system_unit_exists: try: - result = subprocess.run( - _systemctl_cmd(True) + ["is-active", get_service_name()], - capture_output=True, text=True, timeout=10, + result = _run_systemctl( + ["is-active", get_service_name()], + system=True, capture_output=True, text=True, timeout=10, ) if result.stdout.strip() == "active": return True - except subprocess.TimeoutExpired: + except (RuntimeError, subprocess.TimeoutExpired): pass return False @@ -2774,6 +2793,15 @@ def gateway_command(args): print(" tmux new -s hermes 'hermes gateway run' # persistent via tmux") print(" nohup hermes gateway run > ~/.hermes/logs/gateway.log 2>&1 & # background") sys.exit(1) + elif is_container(): + print("Service installation is not needed inside a Docker container.") + print("The container runtime is your service manager — use Docker restart policies instead:") + print() + print(" docker run --restart unless-stopped ... # auto-restart on crash/reboot") + print(" docker restart # manual restart") + print() + print("To run the gateway: hermes gateway run") + sys.exit(0) else: print("Service installation not supported on this platform.") print("Run manually: hermes gateway run") @@ -2792,10 +2820,17 @@ def gateway_command(args): systemd_uninstall(system=system) elif is_macos(): launchd_uninstall() + elif is_container(): + print("Service uninstall is not applicable inside a Docker container.") + print("To stop the gateway, stop or remove the container:") + print() + print(" docker stop ") + print(" docker rm ") + sys.exit(0) else: print("Not supported on this platform.") sys.exit(1) - + elif subcmd == "start": system = getattr(args, 'system', False) if is_termux(): @@ -2816,10 +2851,19 @@ def gateway_command(args): print() print("To enable systemd: add systemd=true to /etc/wsl.conf and run 'wsl --shutdown' from PowerShell.") sys.exit(1) + elif is_container(): + print("Service start is not applicable inside a Docker container.") + print("The gateway runs as the container's main process.") + print() + print(" docker start # start a stopped container") + print(" docker restart # restart a running container") + print() + print("Or run the gateway directly: hermes gateway run") + sys.exit(0) else: print("Not supported on this platform.") sys.exit(1) - + elif subcmd == "stop": stop_all = getattr(args, 'all', False) system = getattr(args, 'system', False) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index e12f7d1a7601..5fa22afe9a29 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2232,6 +2232,7 @@ def setup_gateway(config: dict): from hermes_cli.gateway import ( _is_service_installed, _is_service_running, + supports_systemd_services, has_conflicting_systemd_units, install_linux_gateway_from_setup, print_systemd_scope_conflict_warning, @@ -2244,16 +2245,18 @@ def setup_gateway(config: dict): service_installed = _is_service_installed() service_running = _is_service_running() + supports_systemd = supports_systemd_services() + supports_service_manager = supports_systemd or _is_macos print() - if _is_linux and has_conflicting_systemd_units(): + if supports_systemd and has_conflicting_systemd_units(): print_systemd_scope_conflict_warning() print() if service_running: if prompt_yes_no(" Restart the gateway to pick up changes?", True): try: - if _is_linux: + if supports_systemd: systemd_restart() elif _is_macos: launchd_restart() @@ -2262,14 +2265,14 @@ def setup_gateway(config: dict): elif service_installed: if prompt_yes_no(" Start the gateway service?", True): try: - if _is_linux: + if supports_systemd: systemd_start() elif _is_macos: launchd_start() except Exception as e: print_error(f" Start failed: {e}") - elif _is_linux or _is_macos: - svc_name = "systemd" if _is_linux else "launchd" + elif supports_service_manager: + svc_name = "systemd" if supports_systemd else "launchd" if prompt_yes_no( f" Install the gateway as a {svc_name} service? (runs in background, starts on boot)", True, @@ -2277,7 +2280,7 @@ def setup_gateway(config: dict): try: installed_scope = None did_install = False - if _is_linux: + if supports_systemd: installed_scope, did_install = install_linux_gateway_from_setup(force=False) else: launchd_install(force=False) @@ -2285,7 +2288,7 @@ def setup_gateway(config: dict): print() if did_install and prompt_yes_no(" Start the service now?", True): try: - if _is_linux: + if supports_systemd: systemd_start(system=installed_scope == "system") elif _is_macos: launchd_start() @@ -2296,12 +2299,21 @@ def setup_gateway(config: dict): print_info(" You can try manually: hermes gateway install") else: print_info(" You can install later: hermes gateway install") - if _is_linux: + if supports_systemd: print_info(" Or as a boot-time service: sudo hermes gateway install --system") print_info(" Or run in foreground: hermes gateway") else: - print_info("Start the gateway to bring your bots online:") - print_info(" hermes gateway # Run in foreground") + from hermes_constants import is_container + if is_container(): + print_info("Start the gateway to bring your bots online:") + print_info(" hermes gateway run # Run as container main process") + print_info("") + print_info("For automatic restarts, use a Docker restart policy:") + print_info(" docker run --restart unless-stopped ...") + print_info(" docker restart # Manual restart") + else: + print_info("Start the gateway to bring your bots online:") + print_info(" hermes gateway # Run in foreground") print_info("━" * 50) diff --git a/hermes_cli/status.py b/hermes_cli/status.py index c48c0008b4e9..a7745d65f914 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -346,23 +346,35 @@ def show_status(args): print(" Note: Android may stop background jobs when Termux is suspended") elif sys.platform.startswith('linux'): - try: - from hermes_cli.gateway import get_service_name - _gw_svc = get_service_name() - except Exception: - _gw_svc = "hermes-gateway" - try: - result = subprocess.run( - ["systemctl", "--user", "is-active", _gw_svc], - capture_output=True, - text=True, - timeout=5 - ) - is_active = result.stdout.strip() == "active" - except (FileNotFoundError, subprocess.TimeoutExpired): - is_active = False - print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}") - print(" Manager: systemd (user)") + from hermes_constants import is_container + if is_container(): + # Docker/Podman: no systemd — check for running gateway processes + try: + from hermes_cli.gateway import find_gateway_pids + gateway_pids = find_gateway_pids() + is_active = len(gateway_pids) > 0 + except Exception: + is_active = False + print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}") + print(" Manager: docker (foreground)") + else: + try: + from hermes_cli.gateway import get_service_name + _gw_svc = get_service_name() + except Exception: + _gw_svc = "hermes-gateway" + try: + result = subprocess.run( + ["systemctl", "--user", "is-active", _gw_svc], + capture_output=True, + text=True, + timeout=5 + ) + is_active = result.stdout.strip() == "active" + except (FileNotFoundError, subprocess.TimeoutExpired): + is_active = False + print(f" Status: {check_mark(is_active)} {'running' if is_active else 'stopped'}") + print(" Manager: systemd (user)") elif sys.platform == 'darwin': from hermes_cli.gateway import get_launchd_label diff --git a/hermes_constants.py b/hermes_constants.py index 40b4da5693f4..a366fe05c332 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -189,6 +189,37 @@ def is_wsl() -> bool: return _wsl_detected +_container_detected: bool | None = None + + +def is_container() -> bool: + """Return True when running inside a Docker/Podman container. + + Checks ``/.dockerenv`` (Docker), ``/run/.containerenv`` (Podman), + and ``/proc/1/cgroup`` for container runtime markers. Result is + cached for the process lifetime. Import-safe — no heavy deps. + """ + global _container_detected + if _container_detected is not None: + return _container_detected + if os.path.exists("/.dockerenv"): + _container_detected = True + return True + if os.path.exists("/run/.containerenv"): + _container_detected = True + return True + try: + with open("/proc/1/cgroup", "r") as f: + cgroup = f.read() + if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup: + _container_detected = True + return True + except OSError: + pass + _container_detected = False + return False + + # ─── Well-Known Paths ───────────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_container_aware_cli.py b/tests/hermes_cli/test_container_aware_cli.py index 9e21c0b8d252..4422df845dcf 100644 --- a/tests/hermes_cli/test_container_aware_cli.py +++ b/tests/hermes_cli/test_container_aware_cli.py @@ -12,49 +12,10 @@ import pytest from hermes_cli.config import ( - _is_inside_container, get_container_exec_info, ) -# ============================================================================= -# _is_inside_container -# ============================================================================= - - -def test_is_inside_container_dockerenv(): - """Detects /.dockerenv marker file.""" - with patch("os.path.exists") as mock_exists: - mock_exists.side_effect = lambda p: p == "/.dockerenv" - assert _is_inside_container() is True - - -def test_is_inside_container_containerenv(): - """Detects Podman's /run/.containerenv marker.""" - with patch("os.path.exists") as mock_exists: - mock_exists.side_effect = lambda p: p == "/run/.containerenv" - assert _is_inside_container() is True - - -def test_is_inside_container_cgroup_docker(): - """Detects 'docker' in /proc/1/cgroup.""" - with patch("os.path.exists", return_value=False), \ - patch("builtins.open", create=True) as mock_open: - mock_open.return_value.__enter__ = lambda s: s - mock_open.return_value.__exit__ = MagicMock(return_value=False) - mock_open.return_value.read = MagicMock( - return_value="12:memory:/docker/abc123\n" - ) - assert _is_inside_container() is True - - -def test_is_inside_container_false_on_host(): - """Returns False when none of the container indicators are present.""" - with patch("os.path.exists", return_value=False), \ - patch("builtins.open", side_effect=OSError("no such file")): - assert _is_inside_container() is False - - # ============================================================================= # get_container_exec_info # ============================================================================= @@ -81,7 +42,7 @@ def container_env(tmp_path, monkeypatch): def test_get_container_exec_info_returns_metadata(container_env): """Reads .container-mode and returns all fields including exec_user.""" - with patch("hermes_cli.config._is_inside_container", return_value=False): + with patch("hermes_constants.is_container", return_value=False): info = get_container_exec_info() assert info is not None @@ -93,7 +54,7 @@ def test_get_container_exec_info_returns_metadata(container_env): def test_get_container_exec_info_none_inside_container(container_env): """Returns None when we're already inside a container.""" - with patch("hermes_cli.config._is_inside_container", return_value=True): + with patch("hermes_constants.is_container", return_value=True): info = get_container_exec_info() assert info is None @@ -106,7 +67,7 @@ def test_get_container_exec_info_none_without_file(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.delenv("HERMES_DEV", raising=False) - with patch("hermes_cli.config._is_inside_container", return_value=False): + with patch("hermes_constants.is_container", return_value=False): info = get_container_exec_info() assert info is None @@ -116,7 +77,7 @@ def test_get_container_exec_info_skipped_when_hermes_dev(container_env, monkeypa """Returns None when HERMES_DEV=1 is set (dev mode bypass).""" monkeypatch.setenv("HERMES_DEV", "1") - with patch("hermes_cli.config._is_inside_container", return_value=False): + with patch("hermes_constants.is_container", return_value=False): info = get_container_exec_info() assert info is None @@ -126,7 +87,7 @@ def test_get_container_exec_info_not_skipped_when_hermes_dev_zero(container_env, """HERMES_DEV=0 does NOT trigger bypass — only '1' does.""" monkeypatch.setenv("HERMES_DEV", "0") - with patch("hermes_cli.config._is_inside_container", return_value=False): + with patch("hermes_constants.is_container", return_value=False): info = get_container_exec_info() assert info is not None @@ -143,7 +104,7 @@ def test_get_container_exec_info_defaults(): "# minimal file with no keys\n" ) - with patch("hermes_cli.config._is_inside_container", return_value=False), \ + with patch("hermes_constants.is_container", return_value=False), \ patch("hermes_cli.config.get_hermes_home", return_value=hermes_home), \ patch.dict(os.environ, {}, clear=False): os.environ.pop("HERMES_DEV", None) @@ -165,7 +126,7 @@ def test_get_container_exec_info_docker_backend(container_env): "hermes_bin=/opt/hermes/bin/hermes\n" ) - with patch("hermes_cli.config._is_inside_container", return_value=False): + with patch("hermes_constants.is_container", return_value=False): info = get_container_exec_info() assert info["backend"] == "docker" @@ -176,7 +137,7 @@ def test_get_container_exec_info_docker_backend(container_env): def test_get_container_exec_info_crashes_on_permission_error(container_env): """PermissionError propagates instead of being silently swallowed.""" - with patch("hermes_cli.config._is_inside_container", return_value=False), \ + with patch("hermes_constants.is_container", return_value=False), \ patch("builtins.open", side_effect=PermissionError("permission denied")): with pytest.raises(PermissionError): get_container_exec_info() diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index cba3a8192f1d..ec35aa997609 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -394,6 +394,21 @@ def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, class TestGatewayServiceDetection: + def test_supports_systemd_services_requires_systemctl_binary(self, monkeypatch): + monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + monkeypatch.setattr(gateway_cli.shutil, "which", lambda name: None) + + assert gateway_cli.supports_systemd_services() is False + + def test_supports_systemd_services_returns_true_when_systemctl_present(self, monkeypatch): + monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False) + monkeypatch.setattr(gateway_cli.shutil, "which", lambda name: "/usr/bin/systemctl") + + assert gateway_cli.supports_systemd_services() is True + def test_is_service_running_checks_system_scope_when_user_scope_is_inactive(self, monkeypatch): user_unit = SimpleNamespace(exists=lambda: True) system_unit = SimpleNamespace(exists=lambda: True) @@ -418,6 +433,23 @@ def fake_run(cmd, capture_output=True, text=True, **kwargs): assert gateway_cli._is_service_running() is True + def test_is_service_running_returns_false_when_systemctl_missing(self, monkeypatch): + unit = SimpleNamespace(exists=lambda: True) + + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) + monkeypatch.setattr( + gateway_cli, + "get_systemd_unit_path", + lambda system=False: unit, + ) + + def fake_run(*args, **kwargs): + raise FileNotFoundError("systemctl") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + assert gateway_cli._is_service_running() is False + class TestGatewaySystemServiceRouting: def test_systemd_restart_self_requests_graceful_restart_without_reload_or_restart(self, monkeypatch, capsys): @@ -1001,3 +1033,91 @@ def test_system_unit_has_no_root_paths(self, monkeypatch, tmp_path): # Target user paths should be present assert "/home/alice" in unit assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" in unit + + +class TestDockerAwareGateway: + """Tests for Docker container awareness in gateway commands.""" + + def test_run_systemctl_raises_runtimeerror_when_missing(self, monkeypatch): + """_run_systemctl raises RuntimeError with container guidance when systemctl is absent.""" + import pytest + + def fake_run(cmd, **kwargs): + raise FileNotFoundError("systemctl") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + with pytest.raises(RuntimeError, match="systemctl is not available"): + gateway_cli._run_systemctl(["start", "hermes-gateway"]) + + def test_run_systemctl_passes_through_on_success(self, monkeypatch): + """_run_systemctl delegates to subprocess.run when systemctl exists.""" + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) + + result = gateway_cli._run_systemctl(["status", "hermes-gateway"]) + assert result.returncode == 0 + assert len(calls) == 1 + assert "status" in calls[0] + + def test_install_in_container_prints_docker_guidance(self, monkeypatch, capsys): + """'hermes gateway install' inside Docker exits 0 with container guidance.""" + import pytest + + monkeypatch.setattr(gateway_cli, "is_managed", lambda: False) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False) + monkeypatch.setattr(gateway_cli, "is_container", lambda: True) + + args = SimpleNamespace(gateway_command="install", force=False, system=False, run_as_user=None) + with pytest.raises(SystemExit) as exc_info: + gateway_cli.gateway_command(args) + + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "Docker" in out or "docker" in out + assert "restart" in out.lower() + + def test_uninstall_in_container_prints_docker_guidance(self, monkeypatch, capsys): + """'hermes gateway uninstall' inside Docker exits 0 with container guidance.""" + import pytest + + monkeypatch.setattr(gateway_cli, "is_managed", lambda: False) + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + monkeypatch.setattr(gateway_cli, "is_container", lambda: True) + + args = SimpleNamespace(gateway_command="uninstall", system=False) + with pytest.raises(SystemExit) as exc_info: + gateway_cli.gateway_command(args) + + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "docker" in out.lower() + + def test_start_in_container_prints_docker_guidance(self, monkeypatch, capsys): + """'hermes gateway start' inside Docker exits 0 with container guidance.""" + import pytest + + monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) + monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False) + monkeypatch.setattr(gateway_cli, "is_container", lambda: True) + + args = SimpleNamespace(gateway_command="start", system=False) + with pytest.raises(SystemExit) as exc_info: + gateway_cli.gateway_command(args) + + assert exc_info.value.code == 0 + out = capsys.readouterr().out + assert "docker" in out.lower() + assert "hermes gateway run" in out diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 4a3f5151f877..2c07d3d66713 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -1,5 +1,4 @@ -"""Tests for setup_model_provider — verifies the delegation to -select_provider_and_model() and config dict sync.""" +"""Tests for setup.py configuration flows.""" import json import sys import types @@ -8,6 +7,7 @@ from hermes_cli.auth import get_active_provider from hermes_cli.config import load_config, save_config +from hermes_cli import setup as setup_mod from hermes_cli.setup import setup_model_provider @@ -144,6 +144,85 @@ def fake_select(): assert reloaded.get("custom_providers") == [{"name": "Local", "base_url": "http://localhost:8080/v1"}] +def test_setup_gateway_skips_service_install_when_systemctl_missing(monkeypatch, capsys): + env = { + "TELEGRAM_BOT_TOKEN": "", + "TELEGRAM_HOME_CHANNEL": "", + "DISCORD_BOT_TOKEN": "", + "DISCORD_HOME_CHANNEL": "", + "SLACK_BOT_TOKEN": "", + "SLACK_HOME_CHANNEL": "", + "MATRIX_HOMESERVER": "https://matrix.example.com", + "MATRIX_USER_ID": "@alice:example.com", + "MATRIX_PASSWORD": "", + "MATRIX_ACCESS_TOKEN": "token", + "BLUEBUBBLES_SERVER_URL": "", + "BLUEBUBBLES_HOME_CHANNEL": "", + "WHATSAPP_ENABLED": "", + "WEBHOOK_ENABLED": "", + } + + monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, "")) + monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False) + monkeypatch.setattr("platform.system", lambda: "Linux") + + import hermes_cli.gateway as gateway_mod + + monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_mod, "is_macos", lambda: False) + monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False) + monkeypatch.setattr(gateway_mod, "_is_service_running", lambda: False) + + setup_mod.setup_gateway({}) + + out = capsys.readouterr().out + assert "Messaging platforms configured!" in out + assert "Start the gateway to bring your bots online:" in out + assert "hermes gateway" in out + + +def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys): + """setup_gateway() in a Docker container shows Docker-specific restart instructions.""" + env = { + "TELEGRAM_BOT_TOKEN": "", + "TELEGRAM_HOME_CHANNEL": "", + "DISCORD_BOT_TOKEN": "", + "DISCORD_HOME_CHANNEL": "", + "SLACK_BOT_TOKEN": "", + "SLACK_HOME_CHANNEL": "", + "MATRIX_HOMESERVER": "https://matrix.example.com", + "MATRIX_USER_ID": "@alice:example.com", + "MATRIX_PASSWORD": "", + "MATRIX_ACCESS_TOKEN": "token", + "BLUEBUBBLES_SERVER_URL": "", + "BLUEBUBBLES_HOME_CHANNEL": "", + "WHATSAPP_ENABLED": "", + "WEBHOOK_ENABLED": "", + } + + monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, "")) + monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False) + monkeypatch.setattr("platform.system", lambda: "Linux") + + import hermes_cli.gateway as gateway_mod + + monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) + monkeypatch.setattr(gateway_mod, "is_macos", lambda: False) + monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False) + monkeypatch.setattr(gateway_mod, "_is_service_running", lambda: False) + + # Patch is_container at the import location in setup.py + import hermes_constants + monkeypatch.setattr(hermes_constants, "is_container", lambda: True) + + setup_mod.setup_gateway({}) + + out = capsys.readouterr().out + assert "Messaging platforms configured!" in out + assert "docker" in out.lower() or "Docker" in out + assert "restart" in out.lower() + + def test_setup_syncs_custom_provider_removal_from_disk(tmp_path, monkeypatch): """Removing the last custom provider in model setup should persist.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index b3438596bb0b..d49dff813960 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -6,7 +6,8 @@ import pytest -from hermes_constants import get_default_hermes_root +import hermes_constants +from hermes_constants import get_default_hermes_root, is_container class TestGetDefaultHermesRoot: @@ -60,3 +61,53 @@ def test_docker_profile_active(self, tmp_path, monkeypatch): monkeypatch.setattr(Path, "home", lambda: tmp_path) monkeypatch.setenv("HERMES_HOME", str(profile)) assert get_default_hermes_root() == docker_root + + +class TestIsContainer: + """Tests for is_container() — Docker/Podman detection.""" + + def _reset_cache(self, monkeypatch): + """Reset the cached detection result before each test.""" + monkeypatch.setattr(hermes_constants, "_container_detected", None) + + def test_detects_dockerenv(self, monkeypatch, tmp_path): + """/.dockerenv triggers container detection.""" + self._reset_cache(monkeypatch) + monkeypatch.setattr(os.path, "exists", lambda p: p == "/.dockerenv") + assert is_container() is True + + def test_detects_containerenv(self, monkeypatch, tmp_path): + """/run/.containerenv triggers container detection (Podman).""" + self._reset_cache(monkeypatch) + monkeypatch.setattr(os.path, "exists", lambda p: p == "/run/.containerenv") + assert is_container() is True + + def test_detects_cgroup_docker(self, monkeypatch, tmp_path): + """/proc/1/cgroup containing 'docker' triggers detection.""" + import builtins + self._reset_cache(monkeypatch) + monkeypatch.setattr(os.path, "exists", lambda p: False) + cgroup_file = tmp_path / "cgroup" + cgroup_file.write_text("12:memory:/docker/abc123\n") + _real_open = builtins.open + monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == "/proc/1/cgroup" else _real_open(p, *a, **kw)) + assert is_container() is True + + def test_negative_case(self, monkeypatch, tmp_path): + """Returns False on a regular Linux host.""" + import builtins + self._reset_cache(monkeypatch) + monkeypatch.setattr(os.path, "exists", lambda p: False) + cgroup_file = tmp_path / "cgroup" + cgroup_file.write_text("12:memory:/\n") + _real_open = builtins.open + monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _real_open(str(cgroup_file), *a, **kw) if p == "/proc/1/cgroup" else _real_open(p, *a, **kw)) + assert is_container() is False + + def test_caches_result(self, monkeypatch): + """Second call uses cached value without re-probing.""" + monkeypatch.setattr(hermes_constants, "_container_detected", True) + assert is_container() is True + # Even if we make os.path.exists return False, cached value wins + monkeypatch.setattr(os.path, "exists", lambda p: False) + assert is_container() is True diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 5b6a1e3b1379..2beab4f4f713 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -106,8 +106,9 @@ def detect_audio_environment() -> dict: if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')): warnings.append("Running over SSH -- no audio devices available") - # Docker detection - if os.path.exists('/.dockerenv'): + # Docker/Podman container detection + from hermes_constants import is_container + if is_container(): warnings.append("Running inside Docker container -- no audio devices") # WSL detection — PulseAudio bridge makes audio work in WSL. From 4c6ebd077e0b0e34d565fb58f9da0cc4423e2f96 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:38:15 -0700 Subject: [PATCH 045/288] chore: sync uv.lock with matrix extra deps (aiosqlite, asyncpg) (#8661) These were already declared in pyproject.toml but missing from the lockfile. --- uv.lock | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/uv.lock b/uv.lock index c70d3e77ef44..45efc2d93ff6 100644 --- a/uv.lock +++ b/uv.lock @@ -165,6 +165,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "altair" version = "6.0.0" @@ -240,6 +249,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + [[package]] name = "atroposlib" version = "0.4.0" @@ -1672,6 +1729,8 @@ acp = [ all = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, + { name = "aiosqlite", marker = "sys_platform == 'linux'" }, + { name = "asyncpg", marker = "sys_platform == 'linux'" }, { name = "croniter" }, { name = "daytona" }, { name = "debugpy" }, @@ -1727,6 +1786,8 @@ honcho = [ { name = "honcho-ai" }, ] matrix = [ + { name = "aiosqlite" }, + { name = "asyncpg" }, { name = "markdown" }, { name = "mautrix", extra = ["encryption"] }, ] @@ -1791,7 +1852,9 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = ">=3.9.0,<4" }, { name = "aiohttp", marker = "extra == 'messaging'", specifier = ">=3.13.3,<4" }, { name = "aiohttp", marker = "extra == 'sms'", specifier = ">=3.9.0,<4" }, + { name = "aiosqlite", marker = "extra == 'matrix'", specifier = ">=0.20" }, { name = "anthropic", specifier = ">=0.39.0,<1" }, + { name = "asyncpg", marker = "extra == 'matrix'", specifier = ">=0.29" }, { name = "atroposlib", marker = "extra == 'rl'", git = "https://github.com/NousResearch/atropos.git" }, { name = "croniter", marker = "extra == 'cron'", specifier = ">=6.0.0,<7" }, { name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.148.0,<1" }, From 7e0e5ea03b36d68ba59838d6507e6c4f6a67e251 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 13:13:01 -0700 Subject: [PATCH 046/288] fix(skills): cache GitHub repo trees to avoid rate-limit exhaustion on install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills.sh installs hit the GitHub API 45 times per install because the same repo tree was fetched 6 times redundantly. Combined with search (23 API calls), this totals 68 — exceeding the unauthenticated rate limit of 60 req/hr, causing 'Could not fetch' errors for users without a GITHUB_TOKEN. Changes: - Add _get_repo_tree() cache to GitHubSource — repo info + recursive tree fetched once per repo per source instance, eliminating 10 redundant API calls (6 tree + 4 candidate 404s) - _download_directory_via_tree returns {} (not None) when cached tree shows path doesn't exist, skipping unnecessary Contents API fallback - _check_rate_limit_response() detects exhausted quota and sets is_rate_limited flag - do_install() shows actionable hint when rate limited: set GITHUB_TOKEN or install gh CLI Before: 45 API calls per install (68 total with search) After: 31 API calls per install (54 total with search — under 60/hr) Reported by community user from Vietnam (no GitHub auth configured). --- hermes_cli/skills_hub.py | 18 ++++- tools/skills_hub.py | 146 +++++++++++++++++++++++++-------------- 2 files changed, 112 insertions(+), 52 deletions(-) diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index b3ff90d0e2e1..ed922805b77c 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -335,7 +335,23 @@ def do_install(identifier: str, category: str = "", force: bool = False, meta, bundle, _matched_source = _resolve_source_meta_and_bundle(identifier, sources) if not bundle: - c.print(f"[bold red]Error:[/] Could not fetch '{identifier}' from any source.\n") + # Check if any source hit GitHub API rate limit + rate_limited = any( + getattr(src, "is_rate_limited", False) + or getattr(getattr(src, "github", None), "is_rate_limited", False) + for src in sources + ) + c.print(f"[bold red]Error:[/] Could not fetch '{identifier}' from any source.") + if rate_limited: + c.print( + "[yellow]Hint:[/] GitHub API rate limit exhausted " + "(unauthenticated: 60 requests/hour).\n" + "Set [bold]GITHUB_TOKEN[/] in your .env or install the " + "[bold]gh[/] CLI and run [bold]gh auth login[/] " + "to raise the limit to 5,000/hr.\n" + ) + else: + c.print() return # Auto-detect category for official skills (e.g. "official/autonomous-ai-agents/blackbox") diff --git a/tools/skills_hub.py b/tools/skills_hub.py index c73527ff233e..8c7b7a23fdd0 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -296,10 +296,20 @@ def __init__(self, auth: GitHubAuth, extra_taps: Optional[List[Dict]] = None): self.taps = list(self.DEFAULT_TAPS) if extra_taps: self.taps.extend(extra_taps) + # Per-instance cache: repo -> (default_branch, tree_entries) + # Survives within a single search/install flow, avoiding redundant API calls. + self._tree_cache: Dict[str, Tuple[str, List[dict]]] = {} + # Set when GitHub returns 403 with rate limit exhausted + self._rate_limited: bool = False def source_id(self) -> str: return "github" + @property + def is_rate_limited(self) -> bool: + """Whether GitHub API rate limit was hit during operations.""" + return self._rate_limited + def trust_level_for(self, identifier: str) -> str: # identifier format: "owner/repo/path/to/skill" parts = identifier.split("/", 2) @@ -443,55 +453,112 @@ def _list_skills_in_repo(self, repo: str, path: str) -> List[SkillMeta]: self._write_cache(cache_key, [self._meta_to_dict(s) for s in skills]) return skills - def _download_directory(self, repo: str, path: str) -> Dict[str, str]: - """Recursively download all text files from a GitHub directory. + # -- Repo tree cache (avoids redundant API calls) -- - Uses the Git Trees API first (single call for the entire tree) to - avoid per-directory rate limiting that causes silent subdirectory - loss. Falls back to the recursive Contents API when the tree - endpoint is unavailable or the response is truncated. + def _get_repo_tree(self, repo: str) -> Optional[Tuple[str, List[dict]]]: + """Get cached or fresh repo tree. + + Returns ``(default_branch, tree_entries)`` or ``None``. + A single install can call ``_download_directory_via_tree`` and + ``_find_skill_in_repo_tree`` multiple times for the same repo — this + cache eliminates the redundant ``GET /repos/{repo}`` + + ``GET /repos/{repo}/git/trees/{branch}`` round-trips (previously up to + 6 duplicated pairs per install, consuming ~12 of the 60/hr + unauthenticated rate limit for nothing). """ - files = self._download_directory_via_tree(repo, path) - if files is not None: - return files - logger.debug("Tree API unavailable for %s/%s, falling back to Contents API", repo, path) - return self._download_directory_recursive(repo, path) + if repo in self._tree_cache: + return self._tree_cache[repo] - def _download_directory_via_tree(self, repo: str, path: str) -> Optional[Dict[str, str]]: - """Download an entire directory using the Git Trees API (single request).""" - path = path.rstrip("/") headers = self.auth.get_headers() - # Resolve the default branch via the repo endpoint + # Resolve default branch try: - repo_url = f"https://api.github.com/repos/{repo}" - resp = httpx.get(repo_url, headers=headers, timeout=15, follow_redirects=True) + resp = httpx.get( + f"https://api.github.com/repos/{repo}", + headers=headers, timeout=15, follow_redirects=True, + ) if resp.status_code != 200: + self._check_rate_limit_response(resp) return None default_branch = resp.json().get("default_branch", "main") except (httpx.HTTPError, ValueError): return None - # Fetch the full recursive tree (branch name works as tree-ish) + # Fetch recursive tree try: - tree_url = f"https://api.github.com/repos/{repo}/git/trees/{default_branch}" resp = httpx.get( - tree_url, params={"recursive": "1"}, + f"https://api.github.com/repos/{repo}/git/trees/{default_branch}", + params={"recursive": "1"}, headers=headers, timeout=30, follow_redirects=True, ) if resp.status_code != 200: + self._check_rate_limit_response(resp) return None tree_data = resp.json() if tree_data.get("truncated"): - logger.debug("Git tree truncated for %s, falling back to Contents API", repo) + logger.debug("Git tree truncated for %s, cannot cache", repo) return None except (httpx.HTTPError, ValueError): return None - # Filter to blobs under our target path and fetch content + entries = tree_data.get("tree", []) + self._tree_cache[repo] = (default_branch, entries) + return (default_branch, entries) + + def _check_rate_limit_response(self, resp: "httpx.Response") -> None: + """Flag the instance as rate-limited when GitHub returns 403 + exhausted quota.""" + if resp.status_code == 403: + remaining = resp.headers.get("X-RateLimit-Remaining", "") + if remaining == "0": + self._rate_limited = True + logger.warning( + "GitHub API rate limit exhausted (unauthenticated: 60 req/hr). " + "Set GITHUB_TOKEN or install the gh CLI to raise the limit to 5,000/hr." + ) + + def _download_directory(self, repo: str, path: str) -> Dict[str, str]: + """Recursively download all text files from a GitHub directory. + + Uses the Git Trees API first (single call for the entire tree) to + avoid per-directory rate limiting that causes silent subdirectory + loss. Falls back to the recursive Contents API when the tree + endpoint is unavailable or the response is truncated. + """ + files = self._download_directory_via_tree(repo, path) + if files is not None: + return files + logger.debug("Tree API unavailable for %s/%s, falling back to Contents API", repo, path) + return self._download_directory_recursive(repo, path) + + def _download_directory_via_tree(self, repo: str, path: str) -> Optional[Dict[str, str]]: + """Download an entire directory using the Git Trees API (single request). + + Returns: + dict of files if the path exists and has content, + empty dict ``{}`` if the tree is cached but the path doesn't exist + (prevents unnecessary Contents API fallback), + ``None`` if the tree couldn't be fetched (triggers Contents API fallback). + """ + path = path.rstrip("/") + + cached = self._get_repo_tree(repo) + if cached is None: + return None + _default_branch, tree_entries = cached + + # Check if ANY entry lives under the target path prefix = f"{path}/" + has_entries = any( + item.get("path", "").startswith(prefix) for item in tree_entries + ) + if not has_entries: + # Path definitively doesn't exist in the repo — return empty + # instead of None to skip the Contents API fallback. + return {} + + # Filter to blobs under our target path and fetch content files: Dict[str, str] = {} - for item in tree_data.get("tree", []): + for item in tree_entries: if item.get("type") != "blob": continue item_path = item.get("path", "") @@ -548,38 +615,14 @@ def _find_skill_in_repo_tree(self, repo: str, skill_name: str) -> Optional[str]: handles deeply nested directory structures like ``cli-tool/components/skills/development//SKILL.md``. """ - # Get default branch - try: - resp = httpx.get( - f"https://api.github.com/repos/{repo}", - headers=self.auth.get_headers(), - timeout=15, - follow_redirects=True, - ) - if resp.status_code != 200: - return None - default_branch = resp.json().get("default_branch", "main") - except (httpx.HTTPError, json.JSONDecodeError): - return None - - # Get recursive tree (single API call for the entire repo) - try: - resp = httpx.get( - f"https://api.github.com/repos/{repo}/git/trees/{default_branch}", - params={"recursive": "1"}, - headers=self.auth.get_headers(), - timeout=30, - follow_redirects=True, - ) - if resp.status_code != 200: - return None - tree_data = resp.json() - except (httpx.HTTPError, json.JSONDecodeError): + cached = self._get_repo_tree(repo) + if cached is None: return None + _default_branch, tree_entries = cached # Look for SKILL.md files inside directories named skill_md_suffix = f"/{skill_name}/SKILL.md" - for entry in tree_data.get("tree", []): + for entry in tree_entries: if entry.get("type") != "blob": continue path = entry.get("path", "") @@ -601,6 +644,7 @@ def _fetch_file_content(self, repo: str, path: str) -> Optional[str]: ) if resp.status_code == 200: return resp.text + self._check_rate_limit_response(resp) except httpx.HTTPError as e: logger.debug("GitHub contents API fetch failed: %s", e) return None From 76019320fbbcde46abdbf06f053b3b75b39f4546 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 13:56:57 -0700 Subject: [PATCH 047/288] =?UTF-8?q?feat(skills):=20centralized=20skills=20?= =?UTF-8?q?index=20=E2=80=94=20eliminate=20GitHub=20API=20calls=20for=20se?= =?UTF-8?q?arch/install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a CI-built skills index served from the docs site. The index is crawled daily by GitHub Actions, resolves all GitHub paths upfront, and is cached locally by the client. When the index is available: - Search uses the cached index (0 GitHub API calls, was 23+) - Install uses resolved paths from index (6 API calls for file downloads only, was 31-45 for discovery + downloads) Total: 68 → 6 GitHub API calls for a typical search + install flow. Unauthenticated users (60 req/hr) can now search and install without hitting rate limits. Components: - scripts/build_skills_index.py: Crawl all sources (skills.sh, GitHub taps, official, clawhub, lobehub), batch-resolve GitHub paths via tree API, output JSON index - tools/skills_hub.py: HermesIndexSource class — search/fetch/inspect backed by the index, with lazy GitHubSource for file downloads - parallel_search_sources() skips external API sources when index is available (0 GitHub calls for search) - .github/workflows/skills-index.yml: twice-daily CI build + deploy - .github/workflows/deploy-site.yml: also builds index during docs deploy Graceful degradation: when the index is unavailable (first run, network down, stale), all methods return empty/None and downstream sources handle the request via direct API as before. --- .github/workflows/deploy-site.yml | 10 +- .github/workflows/skills-index.yml | 101 +++++++++ .gitignore | 1 + scripts/build_skills_index.py | 325 +++++++++++++++++++++++++++++ tools/skills_hub.py | 234 +++++++++++++++++++++ 5 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/skills-index.yml create mode 100644 scripts/build_skills_index.py diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 3c471f376d0c..c55a62908d1b 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -41,11 +41,19 @@ jobs: python-version: '3.11' - name: Install PyYAML for skill extraction - run: pip install pyyaml + run: pip install pyyaml httpx - name: Extract skill metadata for dashboard run: python3 website/scripts/extract-skills.py + - name: Build skills index (if not already present) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ ! -f website/static/api/skills-index.json ]; then + python3 scripts/build_skills_index.py || echo "Skills index build failed (non-fatal)" + fi + - name: Install dependencies run: npm ci working-directory: website diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml new file mode 100644 index 000000000000..6c03e40746ca --- /dev/null +++ b/.github/workflows/skills-index.yml @@ -0,0 +1,101 @@ +name: Build Skills Index + +on: + schedule: + # Run twice daily: 6 AM and 6 PM UTC + - cron: '0 6,18 * * *' + workflow_dispatch: # Manual trigger + push: + branches: [main] + paths: + - 'scripts/build_skills_index.py' + - '.github/workflows/skills-index.yml' + +permissions: + contents: read + +jobs: + build-index: + # Only run on the upstream repository, not on forks + if: github.repository == 'NousResearch/hermes-agent' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: pip install httpx pyyaml + + - name: Build skills index + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python scripts/build_skills_index.py + + - name: Upload index artifact + uses: actions/upload-artifact@v4 + with: + name: skills-index + path: website/static/api/skills-index.json + retention-days: 7 + + deploy-with-index: + needs: build-index + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + # Only deploy on schedule or manual trigger (not on every push to the script) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: skills-index + path: website/static/api/ + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: website/package-lock.json + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install PyYAML for skill extraction + run: pip install pyyaml + + - name: Extract skill metadata for dashboard + run: python3 website/scripts/extract-skills.py + + - name: Install dependencies + run: npm ci + working-directory: website + + - name: Build Docusaurus + run: npm run build + working-directory: website + + - name: Stage deployment + run: | + mkdir -p _site/docs + cp -r landingpage/* _site/ + cp -r website/build/* _site/docs/ + echo "hermes-agent.nousresearch.com" > _site/CNAME + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index baa31a543c18..73132e4f4a49 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ mini-swe-agent/ # Nix .direnv/ result +website/static/api/skills-index.json diff --git a/scripts/build_skills_index.py b/scripts/build_skills_index.py new file mode 100644 index 000000000000..efa1ba76edc1 --- /dev/null +++ b/scripts/build_skills_index.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Build the Hermes Skills Index — a centralized JSON catalog of all skills. + +This script crawls every skill source (skills.sh, GitHub taps, official, +clawhub, lobehub, claude-marketplace) and writes a JSON index with resolved +GitHub paths. The index is served as a static file on the docs site so that +`hermes skills search/install` can use it without hitting the GitHub API. + +Usage: + # Local (uses gh CLI or GITHUB_TOKEN for auth) + python scripts/build_skills_index.py + + # CI (set GITHUB_TOKEN as secret) + GITHUB_TOKEN=ghp_... python scripts/build_skills_index.py + +Output: website/static/api/skills-index.json +""" + +import json +import os +import sys +import time +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone + +# Allow importing from repo root +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO_ROOT) + +# Ensure HERMES_HOME is set (needed by tools/skills_hub.py imports) +os.environ.setdefault("HERMES_HOME", os.path.join(os.path.expanduser("~"), ".hermes")) + +from tools.skills_hub import ( + GitHubAuth, + GitHubSource, + SkillsShSource, + OptionalSkillSource, + WellKnownSkillSource, + ClawHubSource, + ClaudeMarketplaceSource, + LobeHubSource, + SkillMeta, +) +import httpx + +OUTPUT_PATH = os.path.join(REPO_ROOT, "website", "static", "api", "skills-index.json") +INDEX_VERSION = 1 + + +def _meta_to_dict(meta: SkillMeta) -> dict: + """Convert a SkillMeta to a serializable dict.""" + return { + "name": meta.name, + "description": meta.description, + "source": meta.source, + "identifier": meta.identifier, + "trust_level": meta.trust_level, + "repo": meta.repo or "", + "path": meta.path or "", + "tags": meta.tags or [], + "extra": meta.extra or {}, + } + + +def crawl_source(source, source_name: str, limit: int) -> list: + """Crawl a single source and return skill dicts.""" + print(f" Crawling {source_name}...", flush=True) + start = time.time() + try: + results = source.search("", limit=limit) + except Exception as e: + print(f" Error crawling {source_name}: {e}", file=sys.stderr) + return [] + skills = [_meta_to_dict(m) for m in results] + elapsed = time.time() - start + print(f" {source_name}: {len(skills)} skills ({elapsed:.1f}s)", flush=True) + return skills + + +def crawl_skills_sh(source: SkillsShSource) -> list: + """Crawl skills.sh using popular queries for broad coverage.""" + print(" Crawling skills.sh (popular queries)...", flush=True) + start = time.time() + + queries = [ + "", # featured + "react", "python", "web", "api", "database", "docker", + "testing", "scraping", "design", "typescript", "git", + "aws", "security", "data", "ml", "ai", "devops", + "frontend", "backend", "mobile", "cli", "documentation", + "kubernetes", "terraform", "rust", "go", "java", + ] + + all_skills: dict[str, dict] = {} + for query in queries: + try: + results = source.search(query, limit=50) + for meta in results: + entry = _meta_to_dict(meta) + if entry["identifier"] not in all_skills: + all_skills[entry["identifier"]] = entry + except Exception as e: + print(f" Warning: skills.sh search '{query}' failed: {e}", + file=sys.stderr) + + elapsed = time.time() - start + print(f" skills.sh: {len(all_skills)} unique skills ({elapsed:.1f}s)", + flush=True) + return list(all_skills.values()) + + +def _fetch_repo_tree(repo: str, auth: GitHubAuth) -> list: + """Fetch the recursive tree for a repo. Returns list of tree entries.""" + headers = auth.get_headers() + try: + resp = httpx.get( + f"https://api.github.com/repos/{repo}", + headers=headers, timeout=15, follow_redirects=True, + ) + if resp.status_code != 200: + return [] + branch = resp.json().get("default_branch", "main") + + resp = httpx.get( + f"https://api.github.com/repos/{repo}/git/trees/{branch}", + params={"recursive": "1"}, + headers=headers, timeout=30, follow_redirects=True, + ) + if resp.status_code != 200: + return [] + data = resp.json() + if data.get("truncated"): + return [] + return data.get("tree", []) + except Exception: + return [] + + +def batch_resolve_paths(skills: list, auth: GitHubAuth) -> list: + """Resolve GitHub paths for skills.sh entries using batch tree lookups. + + Instead of resolving each skill individually (N×M API calls), we: + 1. Group skills by repo + 2. Fetch one tree per repo (2 API calls per repo) + 3. Find all SKILL.md files in the tree + 4. Match skills to their resolved paths + """ + # Filter to skills.sh entries that need resolution + skills_sh = [s for s in skills if s["source"] in ("skills.sh", "skills-sh")] + if not skills_sh: + return skills + + print(f" Resolving paths for {len(skills_sh)} skills.sh entries...", + flush=True) + start = time.time() + + # Group by repo + by_repo: dict[str, list] = defaultdict(list) + for s in skills_sh: + repo = s.get("repo", "") + if repo: + by_repo[repo].append(s) + + print(f" {len(by_repo)} unique repos to scan", flush=True) + + resolved_count = 0 + + # Fetch trees in parallel (up to 6 concurrent) + def _resolve_repo(repo: str, entries: list): + tree = _fetch_repo_tree(repo, auth) + if not tree: + return 0 + + # Find all SKILL.md paths in this repo + skill_paths = {} # skill_dir_name -> full_path + for item in tree: + if item.get("type") != "blob": + continue + path = item.get("path", "") + if path.endswith("/SKILL.md"): + skill_dir = path[: -len("/SKILL.md")] + dir_name = skill_dir.split("/")[-1] + skill_paths[dir_name.lower()] = f"{repo}/{skill_dir}" + + # Also check SKILL.md frontmatter name if we can match by path + # For now, just index by directory name + elif path == "SKILL.md": + # Root-level SKILL.md + skill_paths["_root_"] = f"{repo}" + + count = 0 + for entry in entries: + # Try to match the skill's name/path to a tree entry + skill_name = entry.get("name", "").lower() + skill_path = entry.get("path", "").lower() + identifier = entry.get("identifier", "") + + # Extract the skill token from the identifier + # e.g. "skills-sh/d4vinci/scrapling/scrapling-official" -> "scrapling-official" + parts = identifier.replace("skills-sh/", "").replace("skills.sh/", "") + skill_token = parts.split("/")[-1].lower() if "/" in parts else "" + + # Try matching in order of likelihood + for candidate in [skill_token, skill_name, skill_path]: + if not candidate: + continue + matched = skill_paths.get(candidate) + if matched: + entry["resolved_github_id"] = matched + count += 1 + break + else: + # Try fuzzy: skill_token with common transformations + for tree_name, tree_path in skill_paths.items(): + if (skill_token and ( + tree_name.replace("-", "") == skill_token.replace("-", "") + or skill_token in tree_name + or tree_name in skill_token + )): + entry["resolved_github_id"] = tree_path + count += 1 + break + + return count + + with ThreadPoolExecutor(max_workers=6) as pool: + futures = { + pool.submit(_resolve_repo, repo, entries): repo + for repo, entries in by_repo.items() + } + for future in as_completed(futures): + try: + resolved_count += future.result() + except Exception as e: + repo = futures[future] + print(f" Warning: {repo}: {e}", file=sys.stderr) + + elapsed = time.time() - start + print(f" Resolved {resolved_count}/{len(skills_sh)} paths ({elapsed:.1f}s)", + flush=True) + return skills + + +def main(): + print("Building Hermes Skills Index...", flush=True) + overall_start = time.time() + + auth = GitHubAuth() + print(f"GitHub auth: {auth.auth_method()}") + if auth.auth_method() == "anonymous": + print("WARNING: No GitHub authentication — rate limit is 60/hr. " + "Set GITHUB_TOKEN for better results.", file=sys.stderr) + + skills_sh_source = SkillsShSource(auth=auth) + sources = { + "official": OptionalSkillSource(), + "well-known": WellKnownSkillSource(), + "github": GitHubSource(auth=auth), + "clawhub": ClawHubSource(), + "claude-marketplace": ClaudeMarketplaceSource(auth=auth), + "lobehub": LobeHubSource(), + } + + all_skills: list[dict] = [] + + # Crawl skills.sh + all_skills.extend(crawl_skills_sh(skills_sh_source)) + + # Crawl other sources in parallel + with ThreadPoolExecutor(max_workers=4) as pool: + futures = {} + for name, source in sources.items(): + futures[pool.submit(crawl_source, source, name, 500)] = name + for future in as_completed(futures): + try: + all_skills.extend(future.result()) + except Exception as e: + print(f" Error: {e}", file=sys.stderr) + + # Batch resolve GitHub paths for skills.sh entries + all_skills = batch_resolve_paths(all_skills, auth) + + # Deduplicate by identifier + seen: dict[str, dict] = {} + for skill in all_skills: + key = skill["identifier"] + if key not in seen: + seen[key] = skill + deduped = list(seen.values()) + + # Sort + source_order = {"official": 0, "skills-sh": 1, "skills.sh": 1, + "github": 2, "well-known": 3, "clawhub": 4, + "claude-marketplace": 5, "lobehub": 6} + deduped.sort(key=lambda s: (source_order.get(s["source"], 99), s["name"])) + + # Build index + index = { + "version": INDEX_VERSION, + "generated_at": datetime.now(timezone.utc).isoformat(), + "skill_count": len(deduped), + "skills": deduped, + } + + os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True) + with open(OUTPUT_PATH, "w") as f: + json.dump(index, f, separators=(",", ":"), ensure_ascii=False) + + elapsed = time.time() - overall_start + file_size = os.path.getsize(OUTPUT_PATH) + print(f"\nDone! {len(deduped)} skills indexed in {elapsed:.0f}s") + print(f"Output: {OUTPUT_PATH} ({file_size / 1024:.0f} KB)") + + from collections import Counter + by_source = Counter(s["source"] for s in deduped) + for src, count in sorted(by_source.items(), key=lambda x: -x[1]): + resolved = sum(1 for s in deduped + if s["source"] == src and s.get("resolved_github_id")) + extra = f" ({resolved} resolved)" if resolved else "" + print(f" {src}: {count}{extra}") + + +if __name__ == "__main__": + main() diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 8c7b7a23fdd0..47aef8075b7c 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -2698,6 +2698,222 @@ def check_for_skill_updates( return results +# --------------------------------------------------------------------------- +# Hermes centralized index source +# --------------------------------------------------------------------------- + +HERMES_INDEX_URL = "https://hermes-agent.nousresearch.com/docs/api/skills-index.json" +HERMES_INDEX_CACHE_FILE = INDEX_CACHE_DIR / "hermes-index.json" +HERMES_INDEX_TTL = 6 * 3600 # 6 hours + + +def _load_hermes_index() -> Optional[dict]: + """Fetch the centralized skills index, with local cache. + + The index is a JSON file hosted on the docs site, rebuilt daily by CI. + We cache it locally for HERMES_INDEX_TTL seconds to avoid repeated + downloads within a session. + """ + # Check local cache + if HERMES_INDEX_CACHE_FILE.exists(): + try: + age = time.time() - HERMES_INDEX_CACHE_FILE.stat().st_mtime + if age < HERMES_INDEX_TTL: + return json.loads(HERMES_INDEX_CACHE_FILE.read_text()) + except (OSError, json.JSONDecodeError): + pass + + # Fetch from docs site + try: + resp = httpx.get(HERMES_INDEX_URL, timeout=15, follow_redirects=True) + if resp.status_code != 200: + logger.debug("Hermes index fetch returned %d", resp.status_code) + return _load_stale_index_cache() + data = resp.json() + except (httpx.HTTPError, json.JSONDecodeError) as e: + logger.debug("Hermes index fetch failed: %s", e) + return _load_stale_index_cache() + + # Validate structure + if not isinstance(data, dict) or "skills" not in data: + return _load_stale_index_cache() + + # Cache locally + try: + HERMES_INDEX_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + HERMES_INDEX_CACHE_FILE.write_text(json.dumps(data)) + except OSError: + pass + + return data + + +def _load_stale_index_cache() -> Optional[dict]: + """Fall back to stale cache when the network fetch fails.""" + if HERMES_INDEX_CACHE_FILE.exists(): + try: + return json.loads(HERMES_INDEX_CACHE_FILE.read_text()) + except (OSError, json.JSONDecodeError): + pass + return None + + +class HermesIndexSource(SkillSource): + """Skill source backed by the centralized Hermes Skills Index. + + The index is a JSON catalog published to the docs site and rebuilt + daily by CI. It contains metadata + resolved GitHub paths for every + skill, eliminating the need for users to hit the GitHub API for + search or path discovery. + + When the index is unavailable, all methods return empty / None so + downstream sources take over transparently. + """ + + def __init__(self, auth: GitHubAuth): + self._index: Optional[dict] = None + self._loaded = False + self.auth = auth + # Lazily create GitHubSource for fetch — only used when actually + # downloading files, which requires real GitHub API calls. + self._github: Optional[GitHubSource] = None + + def _ensure_loaded(self) -> dict: + if not self._loaded: + self._index = _load_hermes_index() + self._loaded = True + return self._index or {} + + def _get_github(self) -> GitHubSource: + if self._github is None: + self._github = GitHubSource(auth=self.auth) + return self._github + + def source_id(self) -> str: + return "hermes-index" + + @property + def is_available(self) -> bool: + """Whether the index is loaded and has skills.""" + index = self._ensure_loaded() + return bool(index.get("skills")) + + def trust_level_for(self, identifier: str) -> str: + index = self._ensure_loaded() + for skill in index.get("skills", []): + if skill.get("identifier") == identifier: + return skill.get("trust_level", "community") + return "community" + + def search(self, query: str, limit: int = 10) -> List[SkillMeta]: + """Search the cached index. Zero API calls.""" + index = self._ensure_loaded() + skills = index.get("skills", []) + if not skills: + return [] + + if not query.strip(): + # No query — return featured/popular + return [self._to_meta(s) for s in skills[:limit]] + + query_lower = query.lower() + results: List[SkillMeta] = [] + for s in skills: + searchable = f"{s.get('name', '')} {s.get('description', '')} {' '.join(s.get('tags', []))}".lower() + if query_lower in searchable: + results.append(self._to_meta(s)) + if len(results) >= limit: + break + return results + + def fetch(self, identifier: str) -> Optional[SkillBundle]: + """Fetch a skill using the resolved path from the index. + + If the index has a ``resolved_github_id`` for this skill, we skip + the entire candidate/discovery chain and go directly to GitHub + with the exact path. This reduces install from ~31 API calls to + just the file content downloads (~5-22 depending on skill size). + """ + index = self._ensure_loaded() + entry = self._find_entry(identifier, index) + if not entry: + return None + + # Use resolved path if available + resolved = entry.get("resolved_github_id") + if resolved: + bundle = self._get_github().fetch(resolved) + if bundle: + bundle.source = entry.get("source", "hermes-index") + bundle.identifier = identifier + return bundle + + # Fall back to identifier-based fetch via repo/path + repo = entry.get("repo", "") + path = entry.get("path", "") + if repo and path: + github_id = f"{repo}/{path}" + bundle = self._get_github().fetch(github_id) + if bundle: + bundle.source = entry.get("source", "hermes-index") + bundle.identifier = identifier + return bundle + + return None + + def inspect(self, identifier: str) -> Optional[SkillMeta]: + """Return metadata from the index. Zero API calls.""" + index = self._ensure_loaded() + entry = self._find_entry(identifier, index) + if entry: + return self._to_meta(entry) + return None + + def _find_entry(self, identifier: str, index: dict) -> Optional[dict]: + """Look up a skill in the index by identifier or name.""" + skills = index.get("skills", []) + + # Exact identifier match + for s in skills: + if s.get("identifier") == identifier: + return s + + # Try without source prefix (e.g. "skills-sh/" stripped) + normalized = identifier + for prefix in ("skills-sh/", "skills.sh/", "official/", "github/", "clawhub/"): + if identifier.startswith(prefix): + normalized = identifier[len(prefix):] + break + + # Match on normalized identifier or name + for s in skills: + sid = s.get("identifier", "") + # Strip prefix from stored identifier too + stored_normalized = sid + for prefix in ("skills-sh/", "skills.sh/", "official/", "github/", "clawhub/"): + if sid.startswith(prefix): + stored_normalized = sid[len(prefix):] + break + if stored_normalized == normalized: + return s + + return None + + @staticmethod + def _to_meta(entry: dict) -> SkillMeta: + return SkillMeta( + name=entry.get("name", ""), + description=entry.get("description", ""), + source=entry.get("source", "hermes-index"), + identifier=entry.get("identifier", ""), + trust_level=entry.get("trust_level", "community"), + repo=entry.get("repo"), + path=entry.get("path"), + tags=entry.get("tags", []), + extra=entry.get("extra", {}), + ) + + def create_source_router(auth: Optional[GitHubAuth] = None) -> List[SkillSource]: """ Create all configured source adapters. @@ -2711,6 +2927,7 @@ def create_source_router(auth: Optional[GitHubAuth] = None) -> List[SkillSource] sources: List[SkillSource] = [ OptionalSkillSource(), # Official optional skills (highest priority) + HermesIndexSource(auth=auth), # Centralized index (search + resolved install paths) SkillsShSource(auth=auth), WellKnownSkillSource(), GitHubSource(auth=auth, extra_taps=extra_taps), @@ -2753,10 +2970,27 @@ def parallel_search_sources( per_source_limits = per_source_limits or {} active: List[SkillSource] = [] + # When the centralized index is available and the user hasn't filtered + # to a specific source, skip external API sources (github, skills-sh, + # clawhub, etc.) — the index already has their data. This avoids + # ~70 GitHub API calls per search for unauthenticated users. + _index_available = False + _api_source_ids = frozenset({"github", "skills-sh", "clawhub", + "claude-marketplace", "lobehub", "well-known"}) + if source_filter == "all": + for src in sources: + if (src.source_id() == "hermes-index" + and getattr(src, "is_available", False)): + _index_available = True + break + for src in sources: sid = src.source_id() if source_filter != "all" and sid != source_filter and sid != "official": continue + # Skip external API sources when the index covers them + if _index_available and sid in _api_source_ids: + continue active.append(src) all_results: List[SkillMeta] = [] From 5af9614f6d3df91ca9f2a6e45b2d43f6e6adde67 Mon Sep 17 00:00:00 2001 From: dirtyfancy Date: Sun, 12 Apr 2026 10:34:38 +0800 Subject: [PATCH 048/288] fix(claw): warn if OpenClaw is running before migration Add _is_openclaw_running() and _warn_if_openclaw_running() to detect OpenClaw processes (via pgrep/tasklist) before hermes claw migrate. Warns the user that messaging platforms only allow one active session per bot token, and lets them cancel or continue. Fixes #7907 --- hermes_cli/claw.py | 55 +++++++++++++++++++++- tests/hermes_cli/test_claw.py | 86 +++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 0f9e28cbcce5..bc38ee7a66d4 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -11,6 +11,7 @@ import importlib.util import logging +import subprocess import sys from datetime import datetime from pathlib import Path @@ -52,6 +53,53 @@ # Known OpenClaw directory names (current + legacy) _OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot") +def _is_openclaw_running() -> bool: + """Check whether an OpenClaw process appears to be running.""" + if sys.platform == "win32": + try: + result = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq node.exe"], + capture_output=True, text=True, timeout=5 + ) + output = result.stdout.lower() + return "openclaw" in output or "clawd" in output + except Exception: + return False + + for cmd in (["pgrep", "-f", "openclaw"], ["pgrep", "-f", "clawd"]): + try: + result = subprocess.run(cmd, capture_output=True, timeout=3) + if result.returncode == 0: + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + continue + return False + + +def _warn_if_openclaw_running(auto_yes: bool) -> None: + """Warn if OpenClaw is still running before migration. + + Telegram, Discord, and Slack only allow one active connection per bot + token. Migrating while OpenClaw is running causes both to fight for the + same token. + """ + if not _is_openclaw_running(): + return + + print() + print_error("OpenClaw appears to be running.") + print_info( + "Messaging platforms (Telegram, Discord, Slack) only allow one " + "active session per bot token. If you continue, both OpenClaw and " + "Hermes may try to use the same token, causing disconnects." + ) + print_info("Recommendation: stop OpenClaw before migrating.") + print() + if not auto_yes and not prompt_yes_no("Continue anyway?", default=False): + print_info("Migration cancelled. Stop OpenClaw and try again.") + sys.exit(0) + + def _warn_if_gateway_running(auto_yes: bool) -> None: """Check if a Hermes gateway is running with connected platforms. @@ -287,8 +335,11 @@ def _cmd_migrate(args): print_info(f"Workspace: {workspace_target}") print() - # Check if a gateway is running with connected platforms — migrating tokens - # while the gateway is active will cause conflicts (e.g. Telegram 409). + # Check if OpenClaw is still running — migrating tokens while both are + # active will cause conflicts (e.g. Telegram 409). + _warn_if_openclaw_running(auto_yes) + + # Check if a Hermes gateway is running with connected platforms. _warn_if_gateway_running(auto_yes) # Ensure config.yaml exists before migration tries to read it diff --git a/tests/hermes_cli/test_claw.py b/tests/hermes_cli/test_claw.py index d7528890e208..dc9024d6bc51 100644 --- a/tests/hermes_cli/test_claw.py +++ b/tests/hermes_cli/test_claw.py @@ -197,6 +197,11 @@ def test_shows_help_for_no_action(self, capsys): class TestCmdMigrate: """Test the migrate command handler.""" + @pytest.fixture(autouse=True) + def _mock_openclaw_running(self): + with patch.object(claw_mod, "_is_openclaw_running", return_value=False): + yield + def test_error_when_source_missing(self, tmp_path, capsys): args = Namespace( source=str(tmp_path / "nonexistent"), @@ -626,3 +631,84 @@ def test_empty_report(self, capsys): claw_mod._print_migration_report(report, dry_run=False) captured = capsys.readouterr() assert "Nothing to migrate" in captured.out + + +class TestIsOpenclawRunning: + def test_returns_true_when_pgrep_finds_openclaw(self): + with patch.object(claw_mod, "sys") as mock_sys: + mock_sys.platform = "darwin" + with patch.object(claw_mod, "subprocess") as mock_subprocess: + mock_subprocess.run.side_effect = [ + MagicMock(returncode=0), + ] + assert claw_mod._is_openclaw_running() is True + + def test_returns_true_when_pgrep_finds_clawd(self): + with patch.object(claw_mod, "sys") as mock_sys: + mock_sys.platform = "linux" + with patch.object(claw_mod, "subprocess") as mock_subprocess: + mock_subprocess.run.side_effect = [ + MagicMock(returncode=1), + MagicMock(returncode=0), + ] + assert claw_mod._is_openclaw_running() is True + + def test_returns_false_when_pgrep_finds_nothing(self): + with patch.object(claw_mod, "sys") as mock_sys: + mock_sys.platform = "darwin" + with patch.object(claw_mod, "subprocess") as mock_subprocess: + mock_subprocess.run.side_effect = [ + MagicMock(returncode=1), + MagicMock(returncode=1), + ] + assert claw_mod._is_openclaw_running() is False + + def test_returns_true_on_windows_tasklist(self): + with patch.object(claw_mod, "sys") as mock_sys: + mock_sys.platform = "win32" + with patch.object(claw_mod, "subprocess") as mock_subprocess: + mock_subprocess.run.return_value = MagicMock( + returncode=0, + stdout="node.exe openclaw-gateway", + ) + assert claw_mod._is_openclaw_running() is True + + def test_returns_false_on_windows_when_not_found(self): + with patch.object(claw_mod, "sys") as mock_sys: + mock_sys.platform = "win32" + with patch.object(claw_mod, "subprocess") as mock_subprocess: + mock_subprocess.run.return_value = MagicMock( + returncode=0, + stdout="node.exe some-other-app", + ) + assert claw_mod._is_openclaw_running() is False + + +class TestWarnIfOpenclawRunning: + def test_noop_when_not_running(self, capsys): + with patch.object(claw_mod, "_is_openclaw_running", return_value=False): + claw_mod._warn_if_openclaw_running(auto_yes=False) + captured = capsys.readouterr() + assert captured.out == "" + + def test_warns_and_exits_when_running_and_user_declines(self, capsys): + with patch.object(claw_mod, "_is_openclaw_running", return_value=True): + with patch.object(claw_mod, "prompt_yes_no", return_value=False): + with pytest.raises(SystemExit) as exc_info: + claw_mod._warn_if_openclaw_running(auto_yes=False) + assert exc_info.value.code == 0 + captured = capsys.readouterr() + assert "OpenClaw appears to be running" in captured.out + + def test_warns_and_continues_when_running_and_user_accepts(self, capsys): + with patch.object(claw_mod, "_is_openclaw_running", return_value=True): + with patch.object(claw_mod, "prompt_yes_no", return_value=True): + claw_mod._warn_if_openclaw_running(auto_yes=False) + captured = capsys.readouterr() + assert "OpenClaw appears to be running" in captured.out + + def test_warns_and_continues_in_auto_yes_mode(self, capsys): + with patch.object(claw_mod, "_is_openclaw_running", return_value=True): + claw_mod._warn_if_openclaw_running(auto_yes=True) + captured = capsys.readouterr() + assert "OpenClaw appears to be running" in captured.out From 9fb36738a75b80882dc3aba55f609f101573e299 Mon Sep 17 00:00:00 2001 From: dirtyfancy Date: Sun, 12 Apr 2026 10:48:27 +0800 Subject: [PATCH 049/288] fix(claw): address Copilot review on Windows detection and non-interactive prompt - Use PowerShell to inspect node.exe command lines on Windows, since tasklist output does not include them. - Also check for dedicated openclaw.exe/clawd.exe processes. - Skip the interactive prompt in non-interactive sessions so the preview-only behavior is preserved. - Update tests accordingly. Relates to #7907 --- hermes_cli/claw.py | 28 ++++++++++++++++--- tests/hermes_cli/test_claw.py | 51 ++++++++++++++++++++++++++--------- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index bc38ee7a66d4..b12bc0f39d3b 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -57,12 +57,27 @@ def _is_openclaw_running() -> bool: """Check whether an OpenClaw process appears to be running.""" if sys.platform == "win32": try: + # First check for dedicated executables + for exe in ("openclaw.exe", "clawd.exe"): + result = subprocess.run( + ["tasklist", "/FI", f"IMAGENAME eq {exe}"], + capture_output=True, text=True, timeout=5 + ) + if exe in result.stdout.lower(): + return True + + # Check node.exe processes for openclaw/clawd in command line. + # tasklist does not include command lines, so we use PowerShell. + ps_cmd = ( + 'Get-CimInstance Win32_Process -Filter "Name = \'node.exe\'" | ' + 'Where-Object { $_.CommandLine -match "openclaw|clawd" } | ' + 'Select-Object -First 1 ProcessId' + ) result = subprocess.run( - ["tasklist", "/FI", "IMAGENAME eq node.exe"], + ["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True, text=True, timeout=5 ) - output = result.stdout.lower() - return "openclaw" in output or "clawd" in output + return bool(result.stdout.strip()) except Exception: return False @@ -95,7 +110,12 @@ def _warn_if_openclaw_running(auto_yes: bool) -> None: ) print_info("Recommendation: stop OpenClaw before migrating.") print() - if not auto_yes and not prompt_yes_no("Continue anyway?", default=False): + if auto_yes: + return + if not sys.stdin.isatty(): + print_info("Non-interactive session — continuing to preview only.") + return + if not prompt_yes_no("Continue anyway?", default=False): print_info("Migration cancelled. Stop OpenClaw and try again.") sys.exit(0) diff --git a/tests/hermes_cli/test_claw.py b/tests/hermes_cli/test_claw.py index dc9024d6bc51..154531414ed4 100644 --- a/tests/hermes_cli/test_claw.py +++ b/tests/hermes_cli/test_claw.py @@ -663,24 +663,39 @@ def test_returns_false_when_pgrep_finds_nothing(self): ] assert claw_mod._is_openclaw_running() is False - def test_returns_true_on_windows_tasklist(self): + def test_returns_true_on_windows_when_openclaw_exe_running(self): with patch.object(claw_mod, "sys") as mock_sys: mock_sys.platform = "win32" with patch.object(claw_mod, "subprocess") as mock_subprocess: - mock_subprocess.run.return_value = MagicMock( - returncode=0, - stdout="node.exe openclaw-gateway", - ) + # First tasklist (openclaw.exe) matches + mock_subprocess.run.side_effect = [ + MagicMock(returncode=0, stdout="openclaw.exe 1234 Console 1 45,056 K\n"), + ] + assert claw_mod._is_openclaw_running() is True + + def test_returns_true_on_windows_when_node_exe_has_openclaw_in_cmdline(self): + with patch.object(claw_mod, "sys") as mock_sys: + mock_sys.platform = "win32" + with patch.object(claw_mod, "subprocess") as mock_subprocess: + # tasklist for openclaw.exe and clawd.exe both miss, + # PowerShell finds a matching node.exe process. + mock_subprocess.run.side_effect = [ + MagicMock(returncode=0, stdout=""), + MagicMock(returncode=0, stdout=""), + MagicMock(returncode=0, stdout="1234\n"), + ] assert claw_mod._is_openclaw_running() is True - def test_returns_false_on_windows_when_not_found(self): + def test_returns_false_on_windows_when_node_exe_has_no_openclaw_in_cmdline(self): with patch.object(claw_mod, "sys") as mock_sys: mock_sys.platform = "win32" with patch.object(claw_mod, "subprocess") as mock_subprocess: - mock_subprocess.run.return_value = MagicMock( - returncode=0, - stdout="node.exe some-other-app", - ) + # Neither dedicated exe nor PowerShell find anything. + mock_subprocess.run.side_effect = [ + MagicMock(returncode=0, stdout=""), + MagicMock(returncode=0, stdout=""), + MagicMock(returncode=0, stdout=""), + ] assert claw_mod._is_openclaw_running() is False @@ -694,8 +709,9 @@ def test_noop_when_not_running(self, capsys): def test_warns_and_exits_when_running_and_user_declines(self, capsys): with patch.object(claw_mod, "_is_openclaw_running", return_value=True): with patch.object(claw_mod, "prompt_yes_no", return_value=False): - with pytest.raises(SystemExit) as exc_info: - claw_mod._warn_if_openclaw_running(auto_yes=False) + with patch.object(claw_mod.sys.stdin, "isatty", return_value=True): + with pytest.raises(SystemExit) as exc_info: + claw_mod._warn_if_openclaw_running(auto_yes=False) assert exc_info.value.code == 0 captured = capsys.readouterr() assert "OpenClaw appears to be running" in captured.out @@ -703,7 +719,8 @@ def test_warns_and_exits_when_running_and_user_declines(self, capsys): def test_warns_and_continues_when_running_and_user_accepts(self, capsys): with patch.object(claw_mod, "_is_openclaw_running", return_value=True): with patch.object(claw_mod, "prompt_yes_no", return_value=True): - claw_mod._warn_if_openclaw_running(auto_yes=False) + with patch.object(claw_mod.sys.stdin, "isatty", return_value=True): + claw_mod._warn_if_openclaw_running(auto_yes=False) captured = capsys.readouterr() assert "OpenClaw appears to be running" in captured.out @@ -712,3 +729,11 @@ def test_warns_and_continues_in_auto_yes_mode(self, capsys): claw_mod._warn_if_openclaw_running(auto_yes=True) captured = capsys.readouterr() assert "OpenClaw appears to be running" in captured.out + + def test_warns_and_continues_in_non_interactive_session(self, capsys): + with patch.object(claw_mod, "_is_openclaw_running", return_value=True): + with patch.object(claw_mod.sys.stdin, "isatty", return_value=False): + claw_mod._warn_if_openclaw_running(auto_yes=False) + captured = capsys.readouterr() + assert "OpenClaw appears to be running" in captured.out + assert "Non-interactive session" in captured.out From 76f7411fca3a45b7173e4995933431ba2007979b Mon Sep 17 00:00:00 2001 From: Serhat Dolmac Date: Sun, 12 Apr 2026 22:20:24 +0300 Subject: [PATCH 050/288] fix(claw): warn and prompt if OpenClaw is still running before archival (fixes #8502) --- hermes_cli/claw.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index b12bc0f39d3b..07e45c8b4843 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -229,6 +229,34 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]: return findings +def _check_openclaw_running() -> list: + """Check if any OpenClaw processes or services are still running.""" + import subprocess + running = [] + # Check systemd service + try: + result = subprocess.run( + ["systemctl", "--user", "is-active", "openclaw-gateway.service"], + capture_output=True, text=True, timeout=5 + ) + if result.stdout.strip() == "active": + running.append("systemd service: openclaw-gateway.service") + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + # Check running processes + try: + result = subprocess.run( + ["pgrep", "-f", "openclaw"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + pids = result.stdout.strip().split() + running.append(f"openclaw process(es) running (PIDs: {', '.join(pids)})") + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return running + + def _archive_directory(source_dir: Path, dry_run: bool = False) -> Path: """Rename an OpenClaw directory to .pre-migration. @@ -500,6 +528,21 @@ def _cmd_cleanup(args): print() print_success("No OpenClaw directories found. Nothing to clean up.") return + # Warn if OpenClaw is still running + running = _check_openclaw_running() + if running: + print() + print_warning("OpenClaw appears to be still running:") + for proc in running: + print_warning(f" • {proc}") + print_warning("Archiving .openclaw/ while the service is active may cause it to") + print_warning("immediately recreate an empty skeleton directory, destroying your config.") + print_warning("Stop OpenClaw first: systemctl --user stop openclaw-gateway.service") + print() + if not auto_yes: + if not prompt_yes_no("Proceed anyway?", default=False): + print_info("Aborted. Stop OpenClaw first, then re-run: hermes claw cleanup") + return total_archived = 0 From c83674dd772ed84c65d6934682995eae6ed9dbe7 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 16:40:10 -0700 Subject: [PATCH 051/288] fix: unify OpenClaw detection, add isatty guard, fix print_warning import Combines detection from both PRs into _detect_openclaw_processes(): - Cross-platform process scan (pgrep/tasklist/PowerShell) from PR #8102 - systemd service check from PR #8555 - Returns list[str] with details about what's found Fixes in cleanup warning (from PR #8555): - print_warning -> print_error/print_info (print_warning not in import chain) - Added isatty() guard for non-interactive sessions - Removed duplicate _check_openclaw_running() in favor of shared function Updated all tests to match new API. --- hermes_cli/claw.py | 113 ++++++++++++++++++---------------- tests/hermes_cli/test_claw.py | 80 ++++++++++++++---------- 2 files changed, 105 insertions(+), 88 deletions(-) diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 07e45c8b4843..e62efe47ea38 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -53,21 +53,39 @@ # Known OpenClaw directory names (current + legacy) _OPENCLAW_DIR_NAMES = (".openclaw", ".clawdbot", ".moltbot") -def _is_openclaw_running() -> bool: - """Check whether an OpenClaw process appears to be running.""" +def _detect_openclaw_processes() -> list[str]: + """Detect running OpenClaw processes and services. + + Returns a list of human-readable descriptions of what was found. + An empty list means nothing was detected. + """ + found: list[str] = [] + + # -- systemd service (Linux) ------------------------------------------ + if sys.platform != "win32": + try: + result = subprocess.run( + ["systemctl", "--user", "is-active", "openclaw-gateway.service"], + capture_output=True, text=True, timeout=5, + ) + if result.stdout.strip() == "active": + found.append("systemd service: openclaw-gateway.service") + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + # -- process scan ------------------------------------------------------ if sys.platform == "win32": try: - # First check for dedicated executables for exe in ("openclaw.exe", "clawd.exe"): result = subprocess.run( ["tasklist", "/FI", f"IMAGENAME eq {exe}"], - capture_output=True, text=True, timeout=5 + capture_output=True, text=True, timeout=5, ) if exe in result.stdout.lower(): - return True + found.append(f"process: {exe}") - # Check node.exe processes for openclaw/clawd in command line. - # tasklist does not include command lines, so we use PowerShell. + # Node.js-hosted OpenClaw — tasklist doesn't show command lines, + # so fall back to PowerShell. ps_cmd = ( 'Get-CimInstance Win32_Process -Filter "Name = \'node.exe\'" | ' 'Where-Object { $_.CommandLine -match "openclaw|clawd" } | ' @@ -75,20 +93,25 @@ def _is_openclaw_running() -> bool: ) result = subprocess.run( ["powershell", "-NoProfile", "-Command", ps_cmd], - capture_output=True, text=True, timeout=5 + capture_output=True, text=True, timeout=5, ) - return bool(result.stdout.strip()) + if result.stdout.strip(): + found.append(f"node.exe process with openclaw in command line (PID {result.stdout.strip()})") except Exception: - return False - - for cmd in (["pgrep", "-f", "openclaw"], ["pgrep", "-f", "clawd"]): + pass + else: try: - result = subprocess.run(cmd, capture_output=True, timeout=3) + result = subprocess.run( + ["pgrep", "-f", "openclaw"], + capture_output=True, text=True, timeout=3, + ) if result.returncode == 0: - return True + pids = result.stdout.strip().split() + found.append(f"openclaw process(es) (PIDs: {', '.join(pids)})") except (FileNotFoundError, subprocess.TimeoutExpired): - continue - return False + pass + + return found def _warn_if_openclaw_running(auto_yes: bool) -> None: @@ -98,11 +121,14 @@ def _warn_if_openclaw_running(auto_yes: bool) -> None: token. Migrating while OpenClaw is running causes both to fight for the same token. """ - if not _is_openclaw_running(): + running = _detect_openclaw_processes() + if not running: return print() - print_error("OpenClaw appears to be running.") + print_error("OpenClaw appears to be running:") + for detail in running: + print_info(f" * {detail}") print_info( "Messaging platforms (Telegram, Discord, Slack) only allow one " "active session per bot token. If you continue, both OpenClaw and " @@ -229,34 +255,6 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]: return findings -def _check_openclaw_running() -> list: - """Check if any OpenClaw processes or services are still running.""" - import subprocess - running = [] - # Check systemd service - try: - result = subprocess.run( - ["systemctl", "--user", "is-active", "openclaw-gateway.service"], - capture_output=True, text=True, timeout=5 - ) - if result.stdout.strip() == "active": - running.append("systemd service: openclaw-gateway.service") - except (FileNotFoundError, subprocess.TimeoutExpired): - pass - # Check running processes - try: - result = subprocess.run( - ["pgrep", "-f", "openclaw"], - capture_output=True, text=True, timeout=5 - ) - if result.returncode == 0: - pids = result.stdout.strip().split() - running.append(f"openclaw process(es) running (PIDs: {', '.join(pids)})") - except (FileNotFoundError, subprocess.TimeoutExpired): - pass - return running - - def _archive_directory(source_dir: Path, dry_run: bool = False) -> Path: """Rename an OpenClaw directory to .pre-migration. @@ -528,18 +526,25 @@ def _cmd_cleanup(args): print() print_success("No OpenClaw directories found. Nothing to clean up.") return - # Warn if OpenClaw is still running - running = _check_openclaw_running() + + # Warn if OpenClaw is still running — archiving while the service is + # active causes it to recreate an empty skeleton directory (#8502). + running = _detect_openclaw_processes() if running: print() - print_warning("OpenClaw appears to be still running:") - for proc in running: - print_warning(f" • {proc}") - print_warning("Archiving .openclaw/ while the service is active may cause it to") - print_warning("immediately recreate an empty skeleton directory, destroying your config.") - print_warning("Stop OpenClaw first: systemctl --user stop openclaw-gateway.service") + print_error("OpenClaw appears to be still running:") + for detail in running: + print_info(f" * {detail}") + print_info( + "Archiving .openclaw/ while the service is active may cause it to " + "immediately recreate an empty skeleton directory, destroying your config." + ) + print_info("Stop OpenClaw first: systemctl --user stop openclaw-gateway.service") print() if not auto_yes: + if not sys.stdin.isatty(): + print_info("Non-interactive session — aborting. Stop OpenClaw and re-run.") + return if not prompt_yes_no("Proceed anyway?", default=False): print_info("Aborted. Stop OpenClaw first, then re-run: hermes claw cleanup") return diff --git a/tests/hermes_cli/test_claw.py b/tests/hermes_cli/test_claw.py index 154531414ed4..e32c4a1df81b 100644 --- a/tests/hermes_cli/test_claw.py +++ b/tests/hermes_cli/test_claw.py @@ -1,6 +1,7 @@ """Tests for hermes claw commands.""" from argparse import Namespace +import subprocess from types import ModuleType from unittest.mock import MagicMock, patch @@ -199,7 +200,7 @@ class TestCmdMigrate: @pytest.fixture(autouse=True) def _mock_openclaw_running(self): - with patch.object(claw_mod, "_is_openclaw_running", return_value=False): + with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]): yield def test_error_when_source_missing(self, tmp_path, capsys): @@ -633,81 +634,92 @@ def test_empty_report(self, capsys): assert "Nothing to migrate" in captured.out -class TestIsOpenclawRunning: - def test_returns_true_when_pgrep_finds_openclaw(self): +class TestDetectOpenclawProcesses: + def test_returns_match_when_pgrep_finds_openclaw(self): with patch.object(claw_mod, "sys") as mock_sys: - mock_sys.platform = "darwin" + mock_sys.platform = "linux" with patch.object(claw_mod, "subprocess") as mock_subprocess: + # systemd check misses, pgrep finds openclaw mock_subprocess.run.side_effect = [ - MagicMock(returncode=0), + MagicMock(returncode=1, stdout=""), # systemctl + MagicMock(returncode=0, stdout="1234\n"), # pgrep ] - assert claw_mod._is_openclaw_running() is True + mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired + result = claw_mod._detect_openclaw_processes() + assert len(result) == 1 + assert "1234" in result[0] - def test_returns_true_when_pgrep_finds_clawd(self): + def test_returns_empty_when_pgrep_finds_nothing(self): with patch.object(claw_mod, "sys") as mock_sys: - mock_sys.platform = "linux" + mock_sys.platform = "darwin" with patch.object(claw_mod, "subprocess") as mock_subprocess: mock_subprocess.run.side_effect = [ - MagicMock(returncode=1), - MagicMock(returncode=0), + MagicMock(returncode=1, stdout=""), # systemctl (not found) + MagicMock(returncode=1, stdout=""), # pgrep ] - assert claw_mod._is_openclaw_running() is True + mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired + result = claw_mod._detect_openclaw_processes() + assert result == [] - def test_returns_false_when_pgrep_finds_nothing(self): + def test_detects_systemd_service(self): with patch.object(claw_mod, "sys") as mock_sys: - mock_sys.platform = "darwin" + mock_sys.platform = "linux" with patch.object(claw_mod, "subprocess") as mock_subprocess: mock_subprocess.run.side_effect = [ - MagicMock(returncode=1), - MagicMock(returncode=1), + MagicMock(returncode=0, stdout="active\n"), # systemctl + MagicMock(returncode=1, stdout=""), # pgrep ] - assert claw_mod._is_openclaw_running() is False + mock_subprocess.TimeoutExpired = subprocess.TimeoutExpired + result = claw_mod._detect_openclaw_processes() + assert len(result) == 1 + assert "systemd" in result[0] - def test_returns_true_on_windows_when_openclaw_exe_running(self): + def test_returns_match_on_windows_when_openclaw_exe_running(self): with patch.object(claw_mod, "sys") as mock_sys: mock_sys.platform = "win32" with patch.object(claw_mod, "subprocess") as mock_subprocess: - # First tasklist (openclaw.exe) matches mock_subprocess.run.side_effect = [ MagicMock(returncode=0, stdout="openclaw.exe 1234 Console 1 45,056 K\n"), ] - assert claw_mod._is_openclaw_running() is True + result = claw_mod._detect_openclaw_processes() + assert len(result) >= 1 + assert any("openclaw.exe" in r for r in result) - def test_returns_true_on_windows_when_node_exe_has_openclaw_in_cmdline(self): + def test_returns_match_on_windows_when_node_exe_has_openclaw_in_cmdline(self): with patch.object(claw_mod, "sys") as mock_sys: mock_sys.platform = "win32" with patch.object(claw_mod, "subprocess") as mock_subprocess: - # tasklist for openclaw.exe and clawd.exe both miss, - # PowerShell finds a matching node.exe process. mock_subprocess.run.side_effect = [ - MagicMock(returncode=0, stdout=""), - MagicMock(returncode=0, stdout=""), - MagicMock(returncode=0, stdout="1234\n"), + MagicMock(returncode=0, stdout=""), # tasklist openclaw.exe + MagicMock(returncode=0, stdout=""), # tasklist clawd.exe + MagicMock(returncode=0, stdout="1234\n"), # PowerShell ] - assert claw_mod._is_openclaw_running() is True + result = claw_mod._detect_openclaw_processes() + assert len(result) >= 1 + assert any("node.exe" in r for r in result) - def test_returns_false_on_windows_when_node_exe_has_no_openclaw_in_cmdline(self): + def test_returns_empty_on_windows_when_nothing_found(self): with patch.object(claw_mod, "sys") as mock_sys: mock_sys.platform = "win32" with patch.object(claw_mod, "subprocess") as mock_subprocess: - # Neither dedicated exe nor PowerShell find anything. mock_subprocess.run.side_effect = [ MagicMock(returncode=0, stdout=""), MagicMock(returncode=0, stdout=""), MagicMock(returncode=0, stdout=""), ] - assert claw_mod._is_openclaw_running() is False + result = claw_mod._detect_openclaw_processes() + assert result == [] class TestWarnIfOpenclawRunning: def test_noop_when_not_running(self, capsys): - with patch.object(claw_mod, "_is_openclaw_running", return_value=False): + with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]): claw_mod._warn_if_openclaw_running(auto_yes=False) captured = capsys.readouterr() assert captured.out == "" def test_warns_and_exits_when_running_and_user_declines(self, capsys): - with patch.object(claw_mod, "_is_openclaw_running", return_value=True): + with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]): with patch.object(claw_mod, "prompt_yes_no", return_value=False): with patch.object(claw_mod.sys.stdin, "isatty", return_value=True): with pytest.raises(SystemExit) as exc_info: @@ -717,7 +729,7 @@ def test_warns_and_exits_when_running_and_user_declines(self, capsys): assert "OpenClaw appears to be running" in captured.out def test_warns_and_continues_when_running_and_user_accepts(self, capsys): - with patch.object(claw_mod, "_is_openclaw_running", return_value=True): + with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]): with patch.object(claw_mod, "prompt_yes_no", return_value=True): with patch.object(claw_mod.sys.stdin, "isatty", return_value=True): claw_mod._warn_if_openclaw_running(auto_yes=False) @@ -725,13 +737,13 @@ def test_warns_and_continues_when_running_and_user_accepts(self, capsys): assert "OpenClaw appears to be running" in captured.out def test_warns_and_continues_in_auto_yes_mode(self, capsys): - with patch.object(claw_mod, "_is_openclaw_running", return_value=True): + with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]): claw_mod._warn_if_openclaw_running(auto_yes=True) captured = capsys.readouterr() assert "OpenClaw appears to be running" in captured.out def test_warns_and_continues_in_non_interactive_session(self, capsys): - with patch.object(claw_mod, "_is_openclaw_running", return_value=True): + with patch.object(claw_mod, "_detect_openclaw_processes", return_value=["openclaw process(es) (PIDs: 1234)"]): with patch.object(claw_mod.sys.stdin, "isatty", return_value=False): claw_mod._warn_if_openclaw_running(auto_yes=False) captured = capsys.readouterr() From a266238e1e5ab438e2b1358503541a3d420285c4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:43:25 -0700 Subject: [PATCH 052/288] fix(weixin): streaming cursor, media uploads, markdown links, blank messages (#8665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes for the Weixin/WeChat adapter, synthesized from the best aspects of community PRs #8407, #8521, #8360, #7695, #8308, #8525, #7531, #8144, #8251. 1. Streaming cursor (▉) stuck permanently — WeChat doesn't support message editing, so the cursor appended during streaming can never be removed. Add SUPPORTS_MESSAGE_EDITING = False to WeixinAdapter and check it in gateway/run.py to use an empty cursor for non-edit platforms. (Fixes #8307, #8326) 2. Media upload failures — two bugs in _send_file(): a) upload_full_url path used PUT (404 on WeChat CDN); now uses POST. b) aes_key was base64(raw_bytes) but the iLink API expects base64(hex_string); images showed as grey boxes. (Fixes #8352, #7529) Also: unified both upload paths into _upload_ciphertext(), preferring upload_full_url. Added send_video/send_voice methods and voice_item media builder for audio/.silk files. Added video_md5 field. 3. Markdown links stripped — WeChat can't render [text](url), so format_message() now converts them to 'text (url)' plaintext. Code blocks are preserved. (Fixes #7617) 4. Blank message prevention — three guards: a) _split_text_for_weixin_delivery('') returns [] not [''] b) send() filters empty/whitespace chunks before _send_text_chunk c) _send_message() raises ValueError for empty text as safety net Community credit: joei4cm (#8407), lyonDan (#8521), SKFDJKLDG (#8360), tomqiaozc (#7695), joshleeeeee (#8308), luoxiao6645(#8525), longsizhuo (#7531), Astral-Yang (#8144), QingWei-Li (#8251). --- gateway/platforms/weixin.py | 141 ++++++++++++++++++++++----------- gateway/run.py | 10 ++- tests/gateway/test_weixin.py | 148 ++++++++++++++++++++++++++++++++++- 3 files changed, 253 insertions(+), 46 deletions(-) diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index dc4e7cf96980..a83dff5a8a2e 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -112,6 +112,7 @@ _HEADER_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$") _TABLE_RULE_RE = re.compile(r"^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$") _FENCE_RE = re.compile(r"^```([^\n`]*)\s*$") +_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") def check_weixin_requirements() -> bool: @@ -398,15 +399,16 @@ async def _send_message( context_token: Optional[str], client_id: str, ) -> None: + if not text or not text.strip(): + raise ValueError("_send_message: text must not be empty") message: Dict[str, Any] = { "from_user_id": "", "to_user_id": to, "client_id": client_id, "message_type": MSG_TYPE_BOT, "message_state": MSG_STATE_FINISH, + "item_list": [{"type": ITEM_TEXT, "text_item": {"text": text}}], } - if text: - message["item_list"] = [{"type": ITEM_TEXT, "text_item": {"text": text}}] if context_token: message["context_token"] = context_token await _api_post( @@ -499,13 +501,15 @@ async def _upload_ciphertext( session: "aiohttp.ClientSession", *, ciphertext: bytes, - cdn_base_url: str, - upload_param: str, - filekey: str, + upload_url: str, ) -> str: - url = _cdn_upload_url(cdn_base_url, upload_param, filekey) + """Upload encrypted media to the CDN. + + Accepts either a constructed CDN URL (from upload_param) or a direct + upload_full_url — both use POST with the raw ciphertext as the body. + """ timeout = aiohttp.ClientTimeout(total=120) - async with session.post(url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=timeout) as response: + async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}, timeout=timeout) as response: if response.status == 200: encrypted_param = response.headers.get("x-encrypted-param") if encrypted_param: @@ -649,7 +653,7 @@ def _normalize_markdown_blocks(content: str) -> str: result.append(_rewrite_table_block_for_weixin(table_lines)) continue - result.append(_rewrite_headers_for_weixin(line)) + result.append(_MARKDOWN_LINK_RE.sub(r"\1 (\2)", _rewrite_headers_for_weixin(line))) i += 1 normalized = "\n".join(item.rstrip() for item in result) @@ -811,6 +815,8 @@ def _split_text_for_weixin_delivery( ``platforms.weixin.extra.split_multiline_messages`` (``true`` / ``false``) or the env var ``WEIXIN_SPLIT_MULTILINE_MESSAGES``. """ + if not content: + return [] if split_per_line: # Legacy: one message per top-level delivery unit. if len(content) <= max_length and "\n" not in content: @@ -821,14 +827,14 @@ def _split_text_for_weixin_delivery( chunks.append(unit) continue chunks.extend(_pack_markdown_blocks_for_weixin(unit, max_length)) - return chunks or [content] + return [c for c in chunks if c] or [content] # Compact (default): single message when under the limit — unless the # content looks like a short chatty exchange, in which case split into # separate bubbles for a more natural chat feel. if len(content) <= max_length: return ( - _split_delivery_units_for_weixin(content) + [u for u in _split_delivery_units_for_weixin(content) if u] if _should_split_short_chat_block_for_weixin(content) else [content] ) @@ -1042,6 +1048,10 @@ class WeixinAdapter(BasePlatformAdapter): MAX_MESSAGE_LENGTH = 4000 + # WeChat does not support editing sent messages — streaming must use the + # fallback "send-final-only" path so the cursor (▉) is never left visible. + SUPPORTS_MESSAGE_EDITING = False + def __init__(self, config: PlatformConfig): super().__init__(config, Platform.WEIXIN) extra = config.extra or {} @@ -1451,7 +1461,7 @@ async def send( context_token = self._token_store.get(self._account_id, chat_id) last_message_id: Optional[str] = None try: - chunks = self._split_text(self.format_message(content)) + chunks = [c for c in self._split_text(self.format_message(content)) if c and c.strip()] for idx, chunk in enumerate(chunks): client_id = f"hermes-weixin-{uuid.uuid4().hex}" await self._send_text_chunk( @@ -1555,6 +1565,33 @@ async def send_document( logger.error("[%s] send_document failed to=%s: %s", self.name, _safe_id(chat_id), exc) return SendResult(success=False, error=str(exc)) + async def send_video( + self, + chat_id: str, + video_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + if not self._session or not self._token: + return SendResult(success=False, error="Not connected") + try: + message_id = await self._send_file(chat_id, video_path, caption or "") + return SendResult(success=True, message_id=message_id) + except Exception as exc: + logger.error("[%s] send_video failed to=%s: %s", self.name, _safe_id(chat_id), exc) + return SendResult(success=False, error=str(exc)) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + return await self.send_document(chat_id, audio_path, caption=caption or "", metadata=metadata) + async def _download_remote_media(self, url: str) -> str: from tools.url_safety import is_safe_url @@ -1577,6 +1614,7 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str: filekey = secrets.token_hex(16) aes_key = secrets.token_bytes(16) rawsize = len(plaintext) + rawfilemd5 = hashlib.md5(plaintext).hexdigest() upload_response = await _get_upload_url( self._session, base_url=self._base_url, @@ -1585,41 +1623,42 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str: media_type=media_type, filekey=filekey, rawsize=rawsize, - rawfilemd5=hashlib.md5(plaintext).hexdigest(), + rawfilemd5=rawfilemd5, filesize=_aes_padded_size(rawsize), aeskey_hex=aes_key.hex(), ) upload_param = str(upload_response.get("upload_param") or "") upload_full_url = str(upload_response.get("upload_full_url") or "") ciphertext = _aes128_ecb_encrypt(plaintext, aes_key) - if upload_param: - encrypted_query_param = await _upload_ciphertext( - self._session, - ciphertext=ciphertext, - cdn_base_url=self._cdn_base_url, - upload_param=upload_param, - filekey=filekey, - ) - elif upload_full_url: - timeout = aiohttp.ClientTimeout(total=120) - async with self._session.put( - upload_full_url, - data=ciphertext, - headers={"Content-Type": "application/octet-stream"}, - timeout=timeout, - ) as response: - response.raise_for_status() - encrypted_query_param = response.headers.get("x-encrypted-param") or filekey + + # Prefer upload_full_url (direct CDN), fall back to constructed CDN URL + # from upload_param. Both paths use POST — the old PUT for + # upload_full_url caused 404s on the WeChat CDN. + if upload_full_url: + upload_url = upload_full_url + elif upload_param: + upload_url = _cdn_upload_url(self._cdn_base_url, upload_param, filekey) else: raise RuntimeError(f"getUploadUrl returned neither upload_param nor upload_full_url: {upload_response}") + encrypted_query_param = await _upload_ciphertext( + self._session, + ciphertext=ciphertext, + upload_url=upload_url, + ) + context_token = self._token_store.get(self._account_id, chat_id) + # The iLink API expects aes_key as base64(hex_string), not base64(raw_bytes). + # Sending base64(raw_bytes) causes images to show as grey boxes on the + # receiver side because the decryption key doesn't match. + aes_key_for_api = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii") media_item = item_builder( encrypt_query_param=encrypted_query_param, - aes_key_b64=base64.b64encode(aes_key).decode("ascii"), + aes_key_for_api=aes_key_for_api, ciphertext_size=len(ciphertext), plaintext_size=rawsize, filename=Path(path).name, + rawfilemd5=rawfilemd5, ) last_message_id = None @@ -1659,39 +1698,53 @@ async def _send_file(self, chat_id: str, path: str, caption: str) -> str: def _outbound_media_builder(self, path: str): mime = mimetypes.guess_type(path)[0] or "application/octet-stream" if mime.startswith("image/"): - return MEDIA_IMAGE, lambda **kwargs: { + return MEDIA_IMAGE, lambda **kw: { "type": ITEM_IMAGE, "image_item": { "media": { - "encrypt_query_param": kwargs["encrypt_query_param"], - "aes_key": kwargs["aes_key_b64"], + "encrypt_query_param": kw["encrypt_query_param"], + "aes_key": kw["aes_key_for_api"], "encrypt_type": 1, }, - "mid_size": kwargs["ciphertext_size"], + "mid_size": kw["ciphertext_size"], }, } if mime.startswith("video/"): - return MEDIA_VIDEO, lambda **kwargs: { + return MEDIA_VIDEO, lambda **kw: { "type": ITEM_VIDEO, "video_item": { "media": { - "encrypt_query_param": kwargs["encrypt_query_param"], - "aes_key": kwargs["aes_key_b64"], + "encrypt_query_param": kw["encrypt_query_param"], + "aes_key": kw["aes_key_for_api"], + "encrypt_type": 1, + }, + "video_size": kw["ciphertext_size"], + "play_length": kw.get("play_length", 0), + "video_md5": kw.get("rawfilemd5", ""), + }, + } + if mime.startswith("audio/") or path.endswith(".silk"): + return MEDIA_VOICE, lambda **kw: { + "type": ITEM_VOICE, + "voice_item": { + "media": { + "encrypt_query_param": kw["encrypt_query_param"], + "aes_key": kw["aes_key_for_api"], "encrypt_type": 1, }, - "video_size": kwargs["ciphertext_size"], + "playtime": kw.get("playtime", 0), }, } - return MEDIA_FILE, lambda **kwargs: { + return MEDIA_FILE, lambda **kw: { "type": ITEM_FILE, "file_item": { "media": { - "encrypt_query_param": kwargs["encrypt_query_param"], - "aes_key": kwargs["aes_key_b64"], + "encrypt_query_param": kw["encrypt_query_param"], + "aes_key": kw["aes_key_for_api"], "encrypt_type": 1, }, - "file_name": kwargs["filename"], - "len": str(kwargs["plaintext_size"]), + "file_name": kw["filename"], + "len": str(kw["plaintext_size"]), }, } diff --git a/gateway/run.py b/gateway/run.py index 94f1dde5323d..0b778e2f67b9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7665,10 +7665,18 @@ def run_sync(): from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig _adapter = self.adapters.get(source.platform) if _adapter: + # Platforms that don't support editing sent messages + # (e.g. WeChat) must not show a cursor in intermediate + # sends — the cursor would be permanently visible because + # it can never be edited away. Use an empty cursor for + # such platforms so streaming still delivers the final + # response, just without the typing indicator. + _adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True) + _effective_cursor = _scfg.cursor if _adapter_supports_edit else "" _consumer_cfg = StreamConsumerConfig( edit_interval=_scfg.edit_interval, buffer_threshold=_scfg.buffer_threshold, - cursor=_scfg.cursor, + cursor=_effective_cursor, ) _stream_consumer = GatewayStreamConsumer( adapter=_adapter, diff --git a/tests/gateway/test_weixin.py b/tests/gateway/test_weixin.py index f2afe1049ac6..4633171fe30c 100644 --- a/tests/gateway/test_weixin.py +++ b/tests/gateway/test_weixin.py @@ -30,7 +30,7 @@ def test_format_message_preserves_markdown_and_rewrites_headers(self): assert ( adapter.format_message(content) - == "【Title】\n\n**Plan**\n\nUse **bold** and [docs](https://example.com)." + == "【Title】\n\n**Plan**\n\nUse **bold** and docs (https://example.com)." ) def test_format_message_rewrites_markdown_tables(self): @@ -374,3 +374,149 @@ def test_download_remote_media_blocks_unsafe_urls(self): assert "Blocked unsafe URL" in str(exc) else: raise AssertionError("expected ValueError for unsafe URL") + + +class TestWeixinMarkdownLinks: + """Markdown links should be converted to plaintext since WeChat can't render them.""" + + def test_format_message_converts_markdown_links_to_plain_text(self): + adapter = _make_adapter() + + content = "Check [the docs](https://example.com) and [GitHub](https://github.com) for details" + assert ( + adapter.format_message(content) + == "Check the docs (https://example.com) and GitHub (https://github.com) for details" + ) + + def test_format_message_preserves_links_inside_code_blocks(self): + adapter = _make_adapter() + + content = "See below:\n\n```\n[link](https://example.com)\n```\n\nDone." + result = adapter.format_message(content) + assert "[link](https://example.com)" in result + + +class TestWeixinBlankMessagePrevention: + """Regression tests for the blank-bubble bugs. + + Three separate guards now prevent a blank WeChat message from ever being + dispatched: + + 1. ``_split_text_for_weixin_delivery("")`` returns ``[]`` — not ``[""]``. + 2. ``send()`` filters out empty/whitespace-only chunks before calling + ``_send_text_chunk``. + 3. ``_send_message()`` raises ``ValueError`` for empty text as a last-resort + safety net. + """ + + def test_split_text_returns_empty_list_for_empty_string(self): + adapter = _make_adapter() + assert adapter._split_text("") == [] + + def test_split_text_returns_empty_list_for_empty_string_split_per_line(self): + adapter = WeixinAdapter( + PlatformConfig( + enabled=True, + extra={ + "account_id": "acct", + "token": "test-tok", + "split_multiline_messages": True, + }, + ) + ) + assert adapter._split_text("") == [] + + @patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock) + def test_send_empty_content_does_not_call_send_message(self, send_message_mock): + adapter = _make_adapter() + adapter._session = object() + adapter._token = "test-token" + adapter._base_url = "https://weixin.example.com" + adapter._token_store.get = lambda account_id, chat_id: "ctx-token" + + result = asyncio.run(adapter.send("wxid_test123", "")) + # Empty content → no chunks → no _send_message calls + assert result.success is True + send_message_mock.assert_not_awaited() + + def test_send_message_rejects_empty_text(self): + """_send_message raises ValueError for empty/whitespace text.""" + import pytest + with pytest.raises(ValueError, match="text must not be empty"): + asyncio.run( + weixin._send_message( + AsyncMock(), + base_url="https://example.com", + token="tok", + to="wxid_test", + text="", + context_token=None, + client_id="cid", + ) + ) + + +class TestWeixinStreamingCursorSuppression: + """WeChat doesn't support message editing — cursor must be suppressed.""" + + def test_supports_message_editing_is_false(self): + adapter = _make_adapter() + assert adapter.SUPPORTS_MESSAGE_EDITING is False + + +class TestWeixinMediaBuilder: + """Media builder uses base64(hex_key), not base64(raw_bytes) for aes_key.""" + + def test_image_builder_aes_key_is_base64_of_hex(self): + import base64 + adapter = _make_adapter() + media_type, builder = adapter._outbound_media_builder("photo.jpg") + assert media_type == weixin.MEDIA_IMAGE + + fake_hex_key = "0123456789abcdef0123456789abcdef" + expected_aes = base64.b64encode(fake_hex_key.encode("ascii")).decode("ascii") + item = builder( + encrypt_query_param="eq", + aes_key_for_api=expected_aes, + ciphertext_size=1024, + plaintext_size=1000, + filename="photo.jpg", + rawfilemd5="abc123", + ) + assert item["image_item"]["media"]["aes_key"] == expected_aes + + def test_video_builder_includes_md5(self): + adapter = _make_adapter() + media_type, builder = adapter._outbound_media_builder("clip.mp4") + assert media_type == weixin.MEDIA_VIDEO + + item = builder( + encrypt_query_param="eq", + aes_key_for_api="fakekey", + ciphertext_size=2048, + plaintext_size=2000, + filename="clip.mp4", + rawfilemd5="deadbeef", + ) + assert item["video_item"]["video_md5"] == "deadbeef" + + def test_voice_builder_for_audio_files(self): + adapter = _make_adapter() + media_type, builder = adapter._outbound_media_builder("note.mp3") + assert media_type == weixin.MEDIA_VOICE + + item = builder( + encrypt_query_param="eq", + aes_key_for_api="fakekey", + ciphertext_size=512, + plaintext_size=500, + filename="note.mp3", + rawfilemd5="abc", + ) + assert item["type"] == weixin.ITEM_VOICE + assert "voice_item" in item + + def test_voice_builder_for_silk_files(self): + adapter = _make_adapter() + media_type, builder = adapter._outbound_media_builder("recording.silk") + assert media_type == weixin.MEDIA_VOICE From 651419b0147722f67f9b1e3697346db2f01bb4fd Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 16:43:54 -0700 Subject: [PATCH 053/288] fix: make mimo-v2-pro the default model for Nous portal users Users who set up Nous auth without explicitly selecting a model via `hermes model` were silently falling back to anthropic/claude-opus-4.6 (the first entry in _PROVIDER_MODELS['nous']), causing unexpected charges on their Nous plan. Move xiaomi/mimo-v2-pro to the first position so unconfigured users default to a free model instead. --- hermes_cli/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 857776983251..26edd8c30127 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -70,13 +70,13 @@ def _codex_curated_models() -> list[str]: _PROVIDER_MODELS: dict[str, list[str]] = { "nous": [ + "xiaomi/mimo-v2-pro", "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "anthropic/claude-sonnet-4.5", "anthropic/claude-haiku-4.5", "openai/gpt-5.4", "openai/gpt-5.4-mini", - "xiaomi/mimo-v2-pro", "openai/gpt-5.3-codex", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview", From 8ec0656f534c01885817b503def623683fd94453 Mon Sep 17 00:00:00 2001 From: 0xbyt4 <0xbyt4@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:44:24 -0700 Subject: [PATCH 054/288] feat(tts): add speed support for Edge TTS and OpenAI TTS Read tts.speed (global) or tts..speed (provider-specific) from config. Provider-specific takes precedence over global. - Edge TTS: converts speed float to SSML prosody rate string - OpenAI TTS: passes speed param clamped to 0.25-4.0 - MiniMax: wired into global tts.speed fallback for consistency Co-authored-by: 0xbyt4 <0xbyt4@users.noreply.github.com> --- tools/tts_tool.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 1423e2e78a83..769ae30a94ae 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -188,8 +188,14 @@ async def _generate_edge_tts(text: str, output_path: str, tts_config: Dict[str, _edge_tts = _import_edge_tts() edge_config = tts_config.get("edge", {}) voice = edge_config.get("voice", DEFAULT_EDGE_VOICE) + speed = float(edge_config.get("speed", tts_config.get("speed", 1.0))) - communicate = _edge_tts.Communicate(text, voice) + kwargs = {"voice": voice} + if speed != 1.0: + pct = round((speed - 1.0) * 100) + kwargs["rate"] = f"{pct:+d}%" + + communicate = _edge_tts.Communicate(text, **kwargs) await communicate.save(output_path) return output_path @@ -261,6 +267,7 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] model = oai_config.get("model", DEFAULT_OPENAI_MODEL) voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE) base_url = oai_config.get("base_url", base_url) + speed = float(oai_config.get("speed", tts_config.get("speed", 1.0))) # Determine response format from extension if output_path.endswith(".ogg"): @@ -271,13 +278,16 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] OpenAIClient = _import_openai_client() client = OpenAIClient(api_key=api_key, base_url=base_url) try: - response = client.audio.speech.create( + create_kwargs = dict( model=model, voice=voice, input=text, response_format=response_format, extra_headers={"x-idempotency-key": str(uuid.uuid4())}, ) + if speed != 1.0: + create_kwargs["speed"] = max(0.25, min(4.0, speed)) + response = client.audio.speech.create(**create_kwargs) response.stream_to_file(output_path) return output_path @@ -314,7 +324,7 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any mm_config = tts_config.get("minimax", {}) model = mm_config.get("model", DEFAULT_MINIMAX_MODEL) voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID) - speed = mm_config.get("speed", 1) + speed = mm_config.get("speed", tts_config.get("speed", 1)) vol = mm_config.get("vol", 1) pitch = mm_config.get("pitch", 0) base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL) From 0d0d27d45e4d6eeeb30d23f1011efff17b92e37e Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 16:44:29 -0700 Subject: [PATCH 055/288] test(tts): add speed config tests for Edge, OpenAI, and MiniMax 12 tests covering: - Provider-specific speed overrides global speed - Global speed used as fallback - Default (no speed) preserves existing behavior - Edge SSML rate string conversion (positive/negative) - OpenAI speed clamping to 0.25-4.0 range --- tests/tools/test_tts_speed.py | 145 ++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/tools/test_tts_speed.py diff --git a/tests/tools/test_tts_speed.py b/tests/tools/test_tts_speed.py new file mode 100644 index 000000000000..7622a7f6227d --- /dev/null +++ b/tests/tools/test_tts_speed.py @@ -0,0 +1,145 @@ +"""Tests for TTS speed configuration across providers.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + for key in ("OPENAI_API_KEY", "MINIMAX_API_KEY", "HERMES_SESSION_PLATFORM"): + monkeypatch.delenv(key, raising=False) + + +# --------------------------------------------------------------------------- +# Edge TTS speed +# --------------------------------------------------------------------------- + +class TestEdgeTtsSpeed: + def _run(self, tts_config, tmp_path): + mock_comm = MagicMock() + mock_comm.save = AsyncMock() + mock_edge = MagicMock() + mock_edge.Communicate = MagicMock(return_value=mock_comm) + + with patch("tools.tts_tool._import_edge_tts", return_value=mock_edge): + from tools.tts_tool import _generate_edge_tts + asyncio.run(_generate_edge_tts("Hello", str(tmp_path / "out.mp3"), tts_config)) + return mock_edge.Communicate + + def test_default_no_rate_kwarg(self, tmp_path): + """No speed config => no rate kwarg passed to Communicate.""" + comm_cls = self._run({}, tmp_path) + kwargs = comm_cls.call_args[1] + assert "rate" not in kwargs + + def test_global_speed_applied(self, tmp_path): + """Global tts.speed used as fallback.""" + comm_cls = self._run({"speed": 1.5}, tmp_path) + kwargs = comm_cls.call_args[1] + assert kwargs["rate"] == "+50%" + + def test_provider_speed_overrides_global(self, tmp_path): + """tts.edge.speed takes precedence over tts.speed.""" + comm_cls = self._run({"speed": 1.5, "edge": {"speed": 2.0}}, tmp_path) + kwargs = comm_cls.call_args[1] + assert kwargs["rate"] == "+100%" + + def test_speed_below_one(self, tmp_path): + """Speed < 1.0 produces a negative rate string.""" + comm_cls = self._run({"speed": 0.5}, tmp_path) + kwargs = comm_cls.call_args[1] + assert kwargs["rate"] == "-50%" + + def test_speed_exactly_one_no_rate(self, tmp_path): + """Explicit speed=1.0 should not pass rate kwarg.""" + comm_cls = self._run({"speed": 1.0}, tmp_path) + kwargs = comm_cls.call_args[1] + assert "rate" not in kwargs + + +# --------------------------------------------------------------------------- +# OpenAI TTS speed +# --------------------------------------------------------------------------- + +class TestOpenaiTtsSpeed: + def _run(self, tts_config, tmp_path, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + mock_response = MagicMock() + mock_client = MagicMock() + mock_client.audio.speech.create.return_value = mock_response + mock_cls = MagicMock(return_value=mock_client) + + with patch("tools.tts_tool._import_openai_client", return_value=mock_cls), \ + patch("tools.tts_tool._resolve_openai_audio_client_config", + return_value=("test-key", None)): + from tools.tts_tool import _generate_openai_tts + _generate_openai_tts("Hello", str(tmp_path / "out.mp3"), tts_config) + return mock_client.audio.speech.create + + def test_default_no_speed_kwarg(self, tmp_path, monkeypatch): + """No speed config => no speed kwarg in create call.""" + create = self._run({}, tmp_path, monkeypatch) + kwargs = create.call_args[1] + assert "speed" not in kwargs + + def test_global_speed_applied(self, tmp_path, monkeypatch): + """Global tts.speed used as fallback.""" + create = self._run({"speed": 1.5}, tmp_path, monkeypatch) + kwargs = create.call_args[1] + assert kwargs["speed"] == 1.5 + + def test_provider_speed_overrides_global(self, tmp_path, monkeypatch): + """tts.openai.speed takes precedence over tts.speed.""" + create = self._run({"speed": 1.5, "openai": {"speed": 2.0}}, tmp_path, monkeypatch) + kwargs = create.call_args[1] + assert kwargs["speed"] == 2.0 + + def test_speed_clamped_low(self, tmp_path, monkeypatch): + """Speed below 0.25 is clamped to 0.25.""" + create = self._run({"speed": 0.1}, tmp_path, monkeypatch) + kwargs = create.call_args[1] + assert kwargs["speed"] == 0.25 + + def test_speed_clamped_high(self, tmp_path, monkeypatch): + """Speed above 4.0 is clamped to 4.0.""" + create = self._run({"speed": 10.0}, tmp_path, monkeypatch) + kwargs = create.call_args[1] + assert kwargs["speed"] == 4.0 + + +# --------------------------------------------------------------------------- +# MiniMax TTS speed (global fallback wired) +# --------------------------------------------------------------------------- + +class TestMinimaxTtsSpeed: + def _run(self, tts_config, tmp_path, monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": {"audio": "deadbeef"}, + "base_resp": {"status_code": 0, "status_msg": "success"}, + "extra_info": {"audio_size": 8}, + } + + # requests is imported locally inside _generate_minimax_tts + with patch("requests.post", return_value=mock_response) as mock_post: + from tools.tts_tool import _generate_minimax_tts + _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config) + return mock_post + + def test_global_speed_fallback(self, tmp_path, monkeypatch): + """Global tts.speed used when minimax.speed not set.""" + mock_post = self._run({"speed": 1.5}, tmp_path, monkeypatch) + payload = mock_post.call_args[1]["json"] + assert payload["voice_setting"]["speed"] == 1.5 + + def test_provider_speed_overrides_global(self, tmp_path, monkeypatch): + """tts.minimax.speed takes precedence over tts.speed.""" + mock_post = self._run( + {"speed": 1.5, "minimax": {"speed": 2.0}}, tmp_path, monkeypatch + ) + payload = mock_post.call_args[1]["json"] + assert payload["voice_setting"]["speed"] == 2.0 From 4a9c35655985e0881a74a494d54d136212271329 Mon Sep 17 00:00:00 2001 From: ygd58 Date: Sun, 12 Apr 2026 18:54:16 +0200 Subject: [PATCH 056/288] fix(compression): pass configured context_length to feasibility check _check_compression_model_feasibility() called get_model_context_length() without passing config_context_length, so custom endpoints that do not support /models API queries always fell through to the 128K default, ignoring auxiliary.compression.context_length in config.yaml. Fix: read auxiliary.compression.context_length from config and pass it as config_context_length (highest-priority hint) so the user-configured value is always respected regardless of API availability. Fixes #8499 --- run_agent.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/run_agent.py b/run_agent.py index 4c0d3be4b0c2..36452bc682ff 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1748,10 +1748,25 @@ def _check_compression_model_feasibility(self) -> None: aux_base_url = str(getattr(client, "base_url", "")) aux_api_key = str(getattr(client, "api_key", "")) + + # Read user-configured context_length for the compression model. + # Custom endpoints often don't support /models API queries so + # get_model_context_length() falls through to the 128K default, + # ignoring the explicit config value. Pass it as the highest- + # priority hint so the configured value is always respected. + _aux_cfg = (self.config or {}).get("auxiliary", {}).get("compression", {}) + _aux_context_config = _aux_cfg.get("context_length") if isinstance(_aux_cfg, dict) else None + if _aux_context_config is not None: + try: + _aux_context_config = int(_aux_context_config) + except (TypeError, ValueError): + _aux_context_config = None + aux_context = get_model_context_length( aux_model, base_url=aux_base_url, api_key=aux_api_key, + config_context_length=_aux_context_config, ) threshold = self.context_compressor.threshold_tokens From bc4e2744c3f117426e572f1f49ea5fb141a69708 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 17:47:14 -0700 Subject: [PATCH 057/288] test: add tests for compression config_context_length passthrough - Test that auxiliary.compression.context_length from config is forwarded to get_model_context_length (positive case) - Test that invalid/non-integer config values are silently ignored - Fix _make_agent() to set config=None (cherry-picked code reads self.config) --- .../run_agent/test_compression_feasibility.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/run_agent/test_compression_feasibility.py b/tests/run_agent/test_compression_feasibility.py index 0738b1d438de..0756fcda6a90 100644 --- a/tests/run_agent/test_compression_feasibility.py +++ b/tests/run_agent/test_compression_feasibility.py @@ -38,6 +38,7 @@ def _make_agent( agent.status_callback = None agent.tool_progress_callback = None agent._compression_warning = None + agent.config = None compressor = MagicMock(spec=ContextCompressor) compressor.context_length = main_context @@ -130,6 +131,64 @@ def test_feasibility_check_passes_live_main_runtime(): ) +@patch("agent.model_metadata.get_model_context_length", return_value=1_000_000) +@patch("agent.auxiliary_client.get_text_auxiliary_client") +def test_feasibility_check_passes_config_context_length(mock_get_client, mock_ctx_len): + """auxiliary.compression.context_length from config is forwarded to + get_model_context_length so custom endpoints that lack /models still + report the correct context window (fixes #8499).""" + agent = _make_agent(main_context=200_000, threshold_percent=0.85) + agent.config = { + "auxiliary": { + "compression": { + "context_length": 1_000_000, + }, + }, + } + mock_client = MagicMock() + mock_client.base_url = "http://custom-endpoint:8080/v1" + mock_client.api_key = "sk-custom" + mock_get_client.return_value = (mock_client, "custom/big-model") + + agent._emit_status = lambda msg: None + agent._check_compression_model_feasibility() + + mock_ctx_len.assert_called_once_with( + "custom/big-model", + base_url="http://custom-endpoint:8080/v1", + api_key="sk-custom", + config_context_length=1_000_000, + ) + + +@patch("agent.model_metadata.get_model_context_length", return_value=128_000) +@patch("agent.auxiliary_client.get_text_auxiliary_client") +def test_feasibility_check_ignores_invalid_context_length(mock_get_client, mock_ctx_len): + """Non-integer context_length in config is silently ignored.""" + agent = _make_agent(main_context=200_000, threshold_percent=0.50) + agent.config = { + "auxiliary": { + "compression": { + "context_length": "not-a-number", + }, + }, + } + mock_client = MagicMock() + mock_client.base_url = "http://custom:8080/v1" + mock_client.api_key = "sk-test" + mock_get_client.return_value = (mock_client, "custom/model") + + agent._emit_status = lambda msg: None + agent._check_compression_model_feasibility() + + mock_ctx_len.assert_called_once_with( + "custom/model", + base_url="http://custom:8080/v1", + api_key="sk-test", + config_context_length=None, + ) + + @patch("agent.auxiliary_client.get_text_auxiliary_client") def test_warns_when_no_auxiliary_provider(mock_get_client): """Warning emitted when no auxiliary provider is configured.""" From ea2829ab433acf29c9d598a1e74cee2c0675920a Mon Sep 17 00:00:00 2001 From: Sicheng Li Date: Sun, 12 Apr 2026 17:10:27 +0800 Subject: [PATCH 058/288] fix(weixin,wecom,matrix): respect system proxy via aiohttp trust_env aiohttp.ClientSession defaults to trust_env=False, ignoring HTTP_PROXY/ HTTPS_PROXY env vars. This causes QR login and all API calls to fail for users behind a proxy (e.g. Clash in fake-ip mode), which is common in China where Weixin and WeCom are primarily used. Added trust_env=True to all aiohttp.ClientSession instantiations that connect to external hosts (weixin: 3 places, wecom: 1, matrix: 1). WhatsApp sessions are excluded as they only connect to localhost. httpx-based adapters (dingtalk, signal, wecom_callback) are unaffected as httpx defaults to trust_env=True. Co-Authored-By: Claude Sonnet 4.6 --- gateway/platforms/matrix.py | 2 +- gateway/platforms/wecom.py | 2 +- gateway/platforms/weixin.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 9f3d6358c779..8855c386d9e3 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -782,7 +782,7 @@ async def send_image( # Try aiohttp first (always available), fall back to httpx try: import aiohttp as _aiohttp - async with _aiohttp.ClientSession() as http: + async with _aiohttp.ClientSession(trust_env=True) as http: async with http.get(image_url, timeout=_aiohttp.ClientTimeout(total=30)) as resp: resp.raise_for_status() data = await resp.read() diff --git a/gateway/platforms/wecom.py b/gateway/platforms/wecom.py index a0e71e01b610..0249ae6751e5 100644 --- a/gateway/platforms/wecom.py +++ b/gateway/platforms/wecom.py @@ -266,7 +266,7 @@ async def _cleanup_ws(self) -> None: async def _open_connection(self) -> None: """Open and authenticate a websocket connection.""" await self._cleanup_ws() - self._session = aiohttp.ClientSession() + self._session = aiohttp.ClientSession(trust_env=True) self._ws = await self._session.ws_connect( self._ws_url, heartbeat=HEARTBEAT_INTERVAL_SECONDS * 2, diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index a83dff5a8a2e..f8d4c5ca5be1 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -935,7 +935,7 @@ async def qr_login( if not AIOHTTP_AVAILABLE: raise RuntimeError("aiohttp is required for Weixin QR login") - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: try: qr_resp = await _api_get( session, @@ -1134,7 +1134,7 @@ async def connect(self) -> bool: except Exception as exc: logger.debug("[%s] Token lock unavailable (non-fatal): %s", self.name, exc) - self._session = aiohttp.ClientSession() + self._session = aiohttp.ClientSession(trust_env=True) self._token_store.restore(self._account_id) self._poll_task = asyncio.create_task(self._poll_loop(), name="weixin-poll") self._mark_connected() @@ -1784,7 +1784,7 @@ async def send_weixin_direct( token_store.restore(account_id) context_token = token_store.get(account_id, chat_id) - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: adapter = WeixinAdapter( PlatformConfig( enabled=True, From e8385f6f89151d9ea49e42479939d8287e027a36 Mon Sep 17 00:00:00 2001 From: AaronWong1999 Date: Sat, 11 Apr 2026 04:28:01 +0800 Subject: [PATCH 059/288] docs: add HermesClaw to community ecosystem Adds a one-line entry for HermesClaw (community WeChat bridge) to the Community section. It lets users run Hermes Agent and OpenClaw on the same WeChat account. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b77cd6202f31..ea0758c8369e 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ python -m pytest tests/ -q - 📚 [Skills Hub](https://agentskills.io) - 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues) - 💡 [Discussions](https://github.com/NousResearch/hermes-agent/discussions) +- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — Community WeChat bridge: Run Hermes Agent and OpenClaw on the same WeChat account. --- From bcad679799bda90dfbe99db04a9f5cf7891b2036 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 17:16:16 -0700 Subject: [PATCH 060/288] fix(api_server): normalize array-based content parts in chat completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some OpenAI-compatible clients (Open WebUI, LobeChat, etc.) send message content as an array of typed parts instead of a plain string: [{"type": "text", "text": "hello"}] The agent pipeline expects strings, so these array payloads caused silent failures or empty messages. Add _normalize_chat_content() with defensive limits (recursion depth, list size, output length) and apply it to both the Chat Completions and Responses API endpoints. The Responses path had inline normalization that only handled input_text/output_text — the shared function also handles the standard 'text' type. Salvaged from PR #7980 (ikelvingo) — only the content normalization; the SSE and Weixin changes in that PR were regressions and are not included. Co-authored-by: ikelvingo --- gateway/platforms/api_server.py | 75 +++++++++++++++---- tests/gateway/test_api_server_normalize.py | 87 ++++++++++++++++++++++ 2 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 tests/gateway/test_api_server_normalize.py diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 1954a2b9e5b0..df3fbe1d30a7 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -54,6 +54,66 @@ MAX_STORED_RESPONSES = 100 MAX_REQUEST_BYTES = 1_000_000 # 1 MB default limit for POST bodies CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0 +MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts +MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array + + +def _normalize_chat_content( + content: Any, *, _max_depth: int = 10, _depth: int = 0, +) -> str: + """Normalize OpenAI chat message content into a plain text string. + + Some clients (Open WebUI, LobeChat, etc.) send content as an array of + typed parts instead of a plain string:: + + [{"type": "text", "text": "hello"}, {"type": "input_text", "text": "..."}] + + This function flattens those into a single string so the agent pipeline + (which expects strings) doesn't choke. + + Defensive limits prevent abuse: recursion depth, list size, and output + length are all bounded. + """ + if _depth > _max_depth: + return "" + if content is None: + return "" + if isinstance(content, str): + return content[:MAX_NORMALIZED_TEXT_LENGTH] if len(content) > MAX_NORMALIZED_TEXT_LENGTH else content + + if isinstance(content, list): + parts: List[str] = [] + items = content[:MAX_CONTENT_LIST_SIZE] if len(content) > MAX_CONTENT_LIST_SIZE else content + for item in items: + if isinstance(item, str): + if item: + parts.append(item[:MAX_NORMALIZED_TEXT_LENGTH]) + elif isinstance(item, dict): + item_type = str(item.get("type") or "").strip().lower() + if item_type in {"text", "input_text", "output_text"}: + text = item.get("text", "") + if text: + try: + parts.append(str(text)[:MAX_NORMALIZED_TEXT_LENGTH]) + except Exception: + pass + # Silently skip image_url / other non-text parts + elif isinstance(item, list): + nested = _normalize_chat_content(item, _max_depth=_max_depth, _depth=_depth + 1) + if nested: + parts.append(nested) + # Check accumulated size + if sum(len(p) for p in parts) >= MAX_NORMALIZED_TEXT_LENGTH: + break + result = "\n".join(parts) + return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result + + # Fallback for unexpected types (int, float, bool, etc.) + try: + result = str(content) + return result[:MAX_NORMALIZED_TEXT_LENGTH] if len(result) > MAX_NORMALIZED_TEXT_LENGTH else result + except Exception: + return "" def check_api_server_requirements() -> bool: @@ -553,7 +613,7 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons for msg in messages: role = msg.get("role", "") - content = msg.get("content", "") + content = _normalize_chat_content(msg.get("content", "")) if role == "system": # Accumulate system messages if system_prompt is None: @@ -926,18 +986,7 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response": input_messages.append({"role": "user", "content": item}) elif isinstance(item, dict): role = item.get("role", "user") - content = item.get("content", "") - # Handle content that may be a list of content parts - if isinstance(content, list): - text_parts = [] - for part in content: - if isinstance(part, dict) and part.get("type") == "input_text": - text_parts.append(part.get("text", "")) - elif isinstance(part, dict) and part.get("type") == "output_text": - text_parts.append(part.get("text", "")) - elif isinstance(part, str): - text_parts.append(part) - content = "\n".join(text_parts) + content = _normalize_chat_content(item.get("content", "")) input_messages.append({"role": role, "content": content}) else: return web.json_response(_openai_error("'input' must be a string or array"), status=400) diff --git a/tests/gateway/test_api_server_normalize.py b/tests/gateway/test_api_server_normalize.py new file mode 100644 index 000000000000..2dd2c70f72df --- /dev/null +++ b/tests/gateway/test_api_server_normalize.py @@ -0,0 +1,87 @@ +"""Tests for _normalize_chat_content in the API server adapter.""" + +from gateway.platforms.api_server import _normalize_chat_content + + +class TestNormalizeChatContent: + """Content normalization converts array-based content parts to plain text.""" + + def test_none_returns_empty_string(self): + assert _normalize_chat_content(None) == "" + + def test_plain_string_returned_as_is(self): + assert _normalize_chat_content("hello world") == "hello world" + + def test_empty_string_returned_as_is(self): + assert _normalize_chat_content("") == "" + + def test_text_content_part(self): + content = [{"type": "text", "text": "hello"}] + assert _normalize_chat_content(content) == "hello" + + def test_input_text_content_part(self): + content = [{"type": "input_text", "text": "user input"}] + assert _normalize_chat_content(content) == "user input" + + def test_output_text_content_part(self): + content = [{"type": "output_text", "text": "assistant output"}] + assert _normalize_chat_content(content) == "assistant output" + + def test_multiple_text_parts_joined_with_newline(self): + content = [ + {"type": "text", "text": "first"}, + {"type": "text", "text": "second"}, + ] + assert _normalize_chat_content(content) == "first\nsecond" + + def test_mixed_string_and_dict_parts(self): + content = ["plain string", {"type": "text", "text": "dict part"}] + assert _normalize_chat_content(content) == "plain string\ndict part" + + def test_image_url_parts_silently_skipped(self): + content = [ + {"type": "text", "text": "check this:"}, + {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}, + ] + assert _normalize_chat_content(content) == "check this:" + + def test_integer_content_converted(self): + assert _normalize_chat_content(42) == "42" + + def test_boolean_content_converted(self): + assert _normalize_chat_content(True) == "True" + + def test_deeply_nested_list_respects_depth_limit(self): + """Nesting beyond max_depth returns empty string.""" + content = [[[[[[[[[[[["deep"]]]]]]]]]]]] + result = _normalize_chat_content(content) + # The deep nesting should be truncated, not crash + assert isinstance(result, str) + + def test_large_list_capped(self): + """Lists beyond MAX_CONTENT_LIST_SIZE are truncated.""" + content = [{"type": "text", "text": f"item{i}"} for i in range(2000)] + result = _normalize_chat_content(content) + # Should not contain all 2000 items + assert result.count("item") <= 1000 + + def test_oversized_string_truncated(self): + """Strings beyond 64KB are truncated.""" + huge = "x" * 100_000 + result = _normalize_chat_content(huge) + assert len(result) == 65_536 + + def test_empty_text_parts_filtered(self): + content = [ + {"type": "text", "text": ""}, + {"type": "text", "text": "actual"}, + {"type": "text", "text": ""}, + ] + assert _normalize_chat_content(content) == "actual" + + def test_dict_without_type_skipped(self): + content = [{"foo": "bar"}, {"type": "text", "text": "real"}] + assert _normalize_chat_content(content) == "real" + + def test_empty_list_returns_empty(self): + assert _normalize_chat_content([]) == "" From 88a12af58c0323d484718f75822deb8e4c5586be Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:05:14 -0700 Subject: [PATCH 061/288] =?UTF-8?q?feat:=20add=20`hermes=20debug=20share`?= =?UTF-8?q?=20=E2=80=94=20upload=20debug=20report=20to=20pastebin=20(#8681?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add `hermes debug share` — upload debug report to pastebin Adds a new `hermes debug share` command that collects system info (via hermes dump), recent logs (agent.log, errors.log, gateway.log), and uploads the combined report to a paste service (paste.rs primary, dpaste.com fallback). Returns a shareable URL for support. Options: --lines N Number of log lines per file (default: 200) --expire N Paste expiry in days (default: 7, dpaste.com only) --local Print report locally without uploading Files: hermes_cli/debug.py - New module: paste upload + report collection hermes_cli/main.py - Wire cmd_debug + argparse subparser tests/hermes_cli/test_debug.py - 19 tests covering upload, collection, CLI * feat: upload full agent.log and gateway.log as separate pastes hermes debug share now uploads up to 3 pastes: 1. Summary report (system info + log tails) — always 2. Full agent.log (last ~500KB) — if file exists 3. Full gateway.log (last ~500KB) — if file exists Each paste uploads independently; log upload failures are noted but don't block the main report. Output shows all links aligned: Report https://paste.rs/abc agent.log https://paste.rs/def gateway.log https://paste.rs/ghi Also adds _read_full_log() with size-capped tail reading to stay within paste service limits (~512KB per file). * feat: prepend hermes dump to each log paste for self-contained context Each paste (agent.log, gateway.log) now starts with the hermes dump output so clicking any single link gives full system context without needing to cross-reference the summary report. Refactored dump capture into _capture_dump() — called once and reused across the summary report and each log paste. * fix: fall back to .1 rotated log when primary log is missing or empty When gateway.log (or agent.log) doesn't exist or is empty, the debug share now checks for the .1 rotation file. This is common — the gateway rotates logs and the primary file may not exist yet. Extracted _resolve_log_path() to centralize the fallback logic for both _read_log_tail() and _read_full_log(). * chore: remove unused display_hermes_home import --- hermes_cli/debug.py | 336 ++++++++++++++++++++++++ hermes_cli/main.py | 44 ++++ tests/hermes_cli/test_debug.py | 461 +++++++++++++++++++++++++++++++++ 3 files changed, 841 insertions(+) create mode 100644 hermes_cli/debug.py create mode 100644 tests/hermes_cli/test_debug.py diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py new file mode 100644 index 000000000000..3607db9231b1 --- /dev/null +++ b/hermes_cli/debug.py @@ -0,0 +1,336 @@ +"""``hermes debug`` — debug tools for Hermes Agent. + +Currently supports: + hermes debug share Upload debug report (system info + logs) to a + paste service and print a shareable URL. +""" + +import io +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Optional + +from hermes_constants import get_hermes_home + + +# --------------------------------------------------------------------------- +# Paste services — try paste.rs first, dpaste.com as fallback. +# --------------------------------------------------------------------------- + +_PASTE_RS_URL = "https://paste.rs/" +_DPASTE_COM_URL = "https://dpaste.com/api/" + +# Maximum bytes to read from a single log file for upload. +# paste.rs caps at ~1 MB; we stay under that with headroom. +_MAX_LOG_BYTES = 512_000 + + +def _upload_paste_rs(content: str) -> str: + """Upload to paste.rs. Returns the paste URL. + + paste.rs accepts a plain POST body and returns the URL directly. + """ + data = content.encode("utf-8") + req = urllib.request.Request( + _PASTE_RS_URL, data=data, method="POST", + headers={ + "Content-Type": "text/plain; charset=utf-8", + "User-Agent": "hermes-agent/debug-share", + }, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + url = resp.read().decode("utf-8").strip() + if not url.startswith("http"): + raise ValueError(f"Unexpected response from paste.rs: {url[:200]}") + return url + + +def _upload_dpaste_com(content: str, expiry_days: int = 7) -> str: + """Upload to dpaste.com. Returns the paste URL. + + dpaste.com uses multipart form data. + """ + boundary = "----HermesDebugBoundary9f3c" + + def _field(name: str, value: str) -> str: + return ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n' + f"\r\n" + f"{value}\r\n" + ) + + body = ( + _field("content", content) + + _field("syntax", "text") + + _field("expiry_days", str(expiry_days)) + + f"--{boundary}--\r\n" + ).encode("utf-8") + + req = urllib.request.Request( + _DPASTE_COM_URL, data=body, method="POST", + headers={ + "Content-Type": f"multipart/form-data; boundary={boundary}", + "User-Agent": "hermes-agent/debug-share", + }, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + url = resp.read().decode("utf-8").strip() + if not url.startswith("http"): + raise ValueError(f"Unexpected response from dpaste.com: {url[:200]}") + return url + + +def upload_to_pastebin(content: str, expiry_days: int = 7) -> str: + """Upload *content* to a paste service, trying paste.rs then dpaste.com. + + Returns the paste URL on success, raises on total failure. + """ + errors: list[str] = [] + + # Try paste.rs first (simple, fast) + try: + return _upload_paste_rs(content) + except Exception as exc: + errors.append(f"paste.rs: {exc}") + + # Fallback: dpaste.com (supports expiry) + try: + return _upload_dpaste_com(content, expiry_days=expiry_days) + except Exception as exc: + errors.append(f"dpaste.com: {exc}") + + raise RuntimeError( + "Failed to upload to any paste service:\n " + "\n ".join(errors) + ) + + +# --------------------------------------------------------------------------- +# Log file reading +# --------------------------------------------------------------------------- + +def _resolve_log_path(log_name: str) -> Optional[Path]: + """Find the log file for *log_name*, falling back to the .1 rotation. + + Returns the path if found, or None. + """ + from hermes_cli.logs import LOG_FILES + + filename = LOG_FILES.get(log_name) + if not filename: + return None + + log_dir = get_hermes_home() / "logs" + primary = log_dir / filename + if primary.exists() and primary.stat().st_size > 0: + return primary + + # Fall back to the most recent rotated file (.1). + rotated = log_dir / f"{filename}.1" + if rotated.exists() and rotated.stat().st_size > 0: + return rotated + + return None + + +def _read_log_tail(log_name: str, num_lines: int) -> str: + """Read the last *num_lines* from a log file, or return a placeholder.""" + from hermes_cli.logs import _read_last_n_lines + + log_path = _resolve_log_path(log_name) + if log_path is None: + return "(file not found)" + + try: + lines = _read_last_n_lines(log_path, num_lines) + return "".join(lines).rstrip("\n") + except Exception as exc: + return f"(error reading: {exc})" + + +def _read_full_log(log_name: str, max_bytes: int = _MAX_LOG_BYTES) -> Optional[str]: + """Read a log file for standalone upload. + + Returns the file content (last *max_bytes* if truncated), or None if the + file doesn't exist or is empty. + """ + log_path = _resolve_log_path(log_name) + if log_path is None: + return None + + try: + size = log_path.stat().st_size + if size == 0: + return None + + if size <= max_bytes: + return log_path.read_text(encoding="utf-8", errors="replace") + + # File is larger than max_bytes — read the tail. + with open(log_path, "rb") as f: + f.seek(size - max_bytes) + # Skip partial line at the seek point. + f.readline() + content = f.read().decode("utf-8", errors="replace") + return f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{content}" + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Debug report collection +# --------------------------------------------------------------------------- + +def _capture_dump() -> str: + """Run ``hermes dump`` and return its stdout as a string.""" + from hermes_cli.dump import run_dump + + class _FakeArgs: + show_keys = False + + old_stdout = sys.stdout + sys.stdout = capture = io.StringIO() + try: + run_dump(_FakeArgs()) + except SystemExit: + pass + finally: + sys.stdout = old_stdout + + return capture.getvalue() + + +def collect_debug_report(*, log_lines: int = 200, dump_text: str = "") -> str: + """Build the summary debug report: system dump + log tails. + + Parameters + ---------- + log_lines + Number of recent lines to include per log file. + dump_text + Pre-captured dump output. If empty, ``hermes dump`` is run + internally. + + Returns the report as a plain-text string ready for upload. + """ + buf = io.StringIO() + + if not dump_text: + dump_text = _capture_dump() + buf.write(dump_text) + + # ── Recent log tails (summary only) ────────────────────────────────── + buf.write("\n\n") + buf.write(f"--- agent.log (last {log_lines} lines) ---\n") + buf.write(_read_log_tail("agent", log_lines)) + buf.write("\n\n") + + errors_lines = min(log_lines, 100) + buf.write(f"--- errors.log (last {errors_lines} lines) ---\n") + buf.write(_read_log_tail("errors", errors_lines)) + buf.write("\n\n") + + buf.write(f"--- gateway.log (last {errors_lines} lines) ---\n") + buf.write(_read_log_tail("gateway", errors_lines)) + buf.write("\n") + + return buf.getvalue() + + +# --------------------------------------------------------------------------- +# CLI entry points +# --------------------------------------------------------------------------- + +def run_debug_share(args): + """Collect debug report + full logs, upload each, print URLs.""" + log_lines = getattr(args, "lines", 200) + expiry = getattr(args, "expire", 7) + local_only = getattr(args, "local", False) + + print("Collecting debug report...") + + # Capture dump once — prepended to every paste for context. + dump_text = _capture_dump() + + report = collect_debug_report(log_lines=log_lines, dump_text=dump_text) + agent_log = _read_full_log("agent") + gateway_log = _read_full_log("gateway") + + # Prepend dump header to each full log so every paste is self-contained. + 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 local_only: + print(report) + if agent_log: + print(f"\n\n{'=' * 60}") + print("FULL agent.log") + print(f"{'=' * 60}\n") + print(agent_log) + if gateway_log: + print(f"\n\n{'=' * 60}") + print("FULL gateway.log") + print(f"{'=' * 60}\n") + print(gateway_log) + return + + print("Uploading...") + urls: dict[str, str] = {} + failures: list[str] = [] + + # 1. Summary report (required) + try: + urls["Report"] = upload_to_pastebin(report, expiry_days=expiry) + except RuntimeError as exc: + print(f"\nUpload failed: {exc}", file=sys.stderr) + print("\nFull report printed below — copy-paste it manually:\n") + print(report) + sys.exit(1) + + # 2. Full agent.log (optional) + if agent_log: + try: + urls["agent.log"] = upload_to_pastebin(agent_log, expiry_days=expiry) + except Exception as exc: + failures.append(f"agent.log: {exc}") + + # 3. Full gateway.log (optional) + if gateway_log: + try: + urls["gateway.log"] = upload_to_pastebin(gateway_log, expiry_days=expiry) + except Exception as exc: + failures.append(f"gateway.log: {exc}") + + # Print results + label_width = max(len(k) for k in urls) + print(f"\nDebug report uploaded:") + for label, url in urls.items(): + print(f" {label:<{label_width}} {url}") + + if failures: + print(f"\n (failed to upload: {', '.join(failures)})") + + print(f"\nShare these links with the Hermes team for support.") + + +def run_debug(args): + """Route debug subcommands.""" + subcmd = getattr(args, "debug_command", None) + if subcmd == "share": + run_debug_share(args) + else: + # Default: show help + print("Usage: hermes debug share [--lines N] [--expire N] [--local]") + print() + print("Commands:") + print(" share Upload debug report to a paste service and print URL") + print() + print("Options:") + print(" --lines N Number of log lines to include (default: 200)") + print(" --expire N Paste expiry in days (default: 7)") + print(" --local Print report locally instead of uploading") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1e04008844c3..aacd8efad16f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2834,6 +2834,12 @@ def cmd_dump(args): run_dump(args) +def cmd_debug(args): + """Debug tools (share report, etc.).""" + from hermes_cli.debug import run_debug + run_debug(args) + + def cmd_config(args): """Configuration management.""" from hermes_cli.config import config_command @@ -4436,6 +4442,7 @@ def main(): hermes logs -f Follow agent.log in real time hermes logs errors View errors.log hermes logs --since 1h Lines from the last hour + hermes debug share Upload debug report for support hermes update Update to latest version For more help on a command: @@ -4965,6 +4972,43 @@ def main(): ) dump_parser.set_defaults(func=cmd_dump) + # ========================================================================= + # debug command + # ========================================================================= + debug_parser = subparsers.add_parser( + "debug", + help="Debug tools — upload logs and system info for support", + description="Debug utilities for Hermes Agent. Use 'hermes debug share' to " + "upload a debug report (system info + recent logs) to a paste " + "service and get a shareable URL.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +Examples: + hermes debug share Upload debug report and print URL + hermes debug share --lines 500 Include more log lines + hermes debug share --expire 30 Keep paste for 30 days + hermes debug share --local Print report locally (no upload) +""", + ) + debug_sub = debug_parser.add_subparsers(dest="debug_command") + share_parser = debug_sub.add_parser( + "share", + help="Upload debug report to a paste service and print a shareable URL", + ) + share_parser.add_argument( + "--lines", type=int, default=200, + help="Number of log lines to include per log file (default: 200)", + ) + share_parser.add_argument( + "--expire", type=int, default=7, + help="Paste expiry in days (default: 7)", + ) + share_parser.add_argument( + "--local", action="store_true", + help="Print the report locally instead of uploading", + ) + debug_parser.set_defaults(func=cmd_debug) + # ========================================================================= # backup command # ========================================================================= diff --git a/tests/hermes_cli/test_debug.py b/tests/hermes_cli/test_debug.py new file mode 100644 index 000000000000..f733c8ab641a --- /dev/null +++ b/tests/hermes_cli/test_debug.py @@ -0,0 +1,461 @@ +"""Tests for ``hermes debug`` CLI command and debug utilities.""" + +import os +import sys +import urllib.error +from pathlib import Path +from unittest.mock import MagicMock, patch, call + +import pytest + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + """Set up an isolated HERMES_HOME with minimal logs.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + # Create log files + logs_dir = home / "logs" + logs_dir.mkdir() + (logs_dir / "agent.log").write_text( + "2026-04-12 17:00:00 INFO agent: session started\n" + "2026-04-12 17:00:01 INFO tools.terminal: running ls\n" + "2026-04-12 17:00:02 WARNING agent: high token usage\n" + ) + (logs_dir / "errors.log").write_text( + "2026-04-12 17:00:05 ERROR gateway.run: connection lost\n" + ) + (logs_dir / "gateway.log").write_text( + "2026-04-12 17:00:10 INFO gateway.run: started\n" + ) + + return home + + +# --------------------------------------------------------------------------- +# Unit tests for upload helpers +# --------------------------------------------------------------------------- + +class TestUploadPasteRs: + """Test paste.rs upload path.""" + + def test_upload_paste_rs_success(self): + from hermes_cli.debug import _upload_paste_rs + + mock_resp = MagicMock() + mock_resp.read.return_value = b"https://paste.rs/abc123\n" + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("hermes_cli.debug.urllib.request.urlopen", return_value=mock_resp): + url = _upload_paste_rs("hello world") + + assert url == "https://paste.rs/abc123" + + def test_upload_paste_rs_bad_response(self): + from hermes_cli.debug import _upload_paste_rs + + mock_resp = MagicMock() + mock_resp.read.return_value = b"error" + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("hermes_cli.debug.urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(ValueError, match="Unexpected response"): + _upload_paste_rs("test") + + def test_upload_paste_rs_network_error(self): + from hermes_cli.debug import _upload_paste_rs + + with patch( + "hermes_cli.debug.urllib.request.urlopen", + side_effect=urllib.error.URLError("connection refused"), + ): + with pytest.raises(urllib.error.URLError): + _upload_paste_rs("test") + + +class TestUploadDpasteCom: + """Test dpaste.com fallback upload path.""" + + def test_upload_dpaste_com_success(self): + from hermes_cli.debug import _upload_dpaste_com + + mock_resp = MagicMock() + mock_resp.read.return_value = b"https://dpaste.com/ABCDEFG\n" + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("hermes_cli.debug.urllib.request.urlopen", return_value=mock_resp): + url = _upload_dpaste_com("hello world", expiry_days=7) + + assert url == "https://dpaste.com/ABCDEFG" + + +class TestUploadToPastebin: + """Test the combined upload with fallback.""" + + def test_tries_paste_rs_first(self): + from hermes_cli.debug import upload_to_pastebin + + with patch("hermes_cli.debug._upload_paste_rs", + return_value="https://paste.rs/test") as prs: + url = upload_to_pastebin("content") + + assert url == "https://paste.rs/test" + prs.assert_called_once() + + def test_falls_back_to_dpaste_com(self): + from hermes_cli.debug import upload_to_pastebin + + with patch("hermes_cli.debug._upload_paste_rs", + side_effect=Exception("down")), \ + patch("hermes_cli.debug._upload_dpaste_com", + return_value="https://dpaste.com/TEST") as dp: + url = upload_to_pastebin("content") + + assert url == "https://dpaste.com/TEST" + dp.assert_called_once() + + def test_raises_when_both_fail(self): + from hermes_cli.debug import upload_to_pastebin + + with patch("hermes_cli.debug._upload_paste_rs", + side_effect=Exception("err1")), \ + patch("hermes_cli.debug._upload_dpaste_com", + side_effect=Exception("err2")): + with pytest.raises(RuntimeError, match="Failed to upload"): + upload_to_pastebin("content") + + +# --------------------------------------------------------------------------- +# Log reading +# --------------------------------------------------------------------------- + +class TestReadFullLog: + """Test _read_full_log for standalone log uploads.""" + + def test_reads_small_file(self, hermes_home): + from hermes_cli.debug import _read_full_log + + content = _read_full_log("agent") + assert content is not None + assert "session started" in content + + def test_returns_none_for_missing(self, tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli.debug import _read_full_log + assert _read_full_log("agent") is None + + def test_returns_none_for_empty(self, hermes_home): + # Truncate agent.log to empty + (hermes_home / "logs" / "agent.log").write_text("") + + from hermes_cli.debug import _read_full_log + assert _read_full_log("agent") is None + + def test_truncates_large_file(self, hermes_home): + """Files larger than max_bytes get tail-truncated.""" + from hermes_cli.debug import _read_full_log + + # Write a file larger than 1KB + big_content = "x" * 100 + "\n" + (hermes_home / "logs" / "agent.log").write_text(big_content * 200) + + content = _read_full_log("agent", max_bytes=1024) + assert content is not None + assert "truncated" in content + + def test_unknown_log_returns_none(self, hermes_home): + from hermes_cli.debug import _read_full_log + assert _read_full_log("nonexistent") is None + + def test_falls_back_to_rotated_file(self, hermes_home): + """When gateway.log doesn't exist, falls back to gateway.log.1.""" + from hermes_cli.debug import _read_full_log + + logs_dir = hermes_home / "logs" + # Remove the primary (if any) and create a .1 rotation + (logs_dir / "gateway.log").unlink(missing_ok=True) + (logs_dir / "gateway.log.1").write_text( + "2026-04-12 10:00:00 INFO gateway.run: rotated content\n" + ) + + content = _read_full_log("gateway") + assert content is not None + assert "rotated content" in content + + def test_prefers_primary_over_rotated(self, hermes_home): + """Primary log is used when it exists, even if .1 also exists.""" + from hermes_cli.debug import _read_full_log + + logs_dir = hermes_home / "logs" + (logs_dir / "gateway.log").write_text("primary content\n") + (logs_dir / "gateway.log.1").write_text("rotated content\n") + + content = _read_full_log("gateway") + assert "primary content" in content + assert "rotated" not in content + + def test_falls_back_when_primary_empty(self, hermes_home): + """Empty primary log falls back to .1 rotation.""" + from hermes_cli.debug import _read_full_log + + logs_dir = hermes_home / "logs" + (logs_dir / "agent.log").write_text("") + (logs_dir / "agent.log.1").write_text("rotated agent data\n") + + content = _read_full_log("agent") + assert content is not None + assert "rotated agent data" in content + + +# --------------------------------------------------------------------------- +# Debug report collection +# --------------------------------------------------------------------------- + +class TestCollectDebugReport: + """Test the debug report builder.""" + + def test_report_includes_dump_output(self, hermes_home): + from hermes_cli.debug import collect_debug_report + + with patch("hermes_cli.dump.run_dump") as mock_dump: + mock_dump.side_effect = lambda args: print( + "--- hermes dump ---\nversion: 0.8.0\n--- end dump ---" + ) + report = collect_debug_report(log_lines=50) + + assert "--- hermes dump ---" in report + assert "version: 0.8.0" in report + + def test_report_includes_agent_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 "--- agent.log" in report + assert "session started" in report + + def test_report_includes_errors_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 "--- errors.log" in report + assert "connection lost" in report + + def test_report_includes_gateway_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 "--- gateway.log" in report + + def test_missing_logs_handled(self, tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli.debug import collect_debug_report + + with patch("hermes_cli.dump.run_dump"): + report = collect_debug_report(log_lines=50) + + assert "(file not found)" in report + + +# --------------------------------------------------------------------------- +# CLI entry point — run_debug_share +# --------------------------------------------------------------------------- + +class TestRunDebugShare: + """Test the run_debug_share CLI handler.""" + + def test_local_flag_prints_full_logs(self, hermes_home, capsys): + """--local prints the report plus full log contents.""" + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = True + + with patch("hermes_cli.dump.run_dump"): + run_debug_share(args) + + out = capsys.readouterr().out + assert "--- agent.log" in out + assert "FULL agent.log" in out + assert "FULL gateway.log" in out + + def test_share_uploads_three_pastes(self, hermes_home, capsys): + """Successful share uploads report + agent.log + gateway.log.""" + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + call_count = [0] + uploaded_content = [] + def _mock_upload(content, expiry_days=7): + call_count[0] += 1 + uploaded_content.append(content) + return f"https://paste.rs/paste{call_count[0]}" + + with patch("hermes_cli.dump.run_dump") as mock_dump, \ + patch("hermes_cli.debug.upload_to_pastebin", + side_effect=_mock_upload): + mock_dump.side_effect = lambda a: print("--- hermes dump ---\nversion: test\n--- end dump ---") + run_debug_share(args) + + out = capsys.readouterr().out + # Should have 3 uploads: report, agent.log, gateway.log + assert call_count[0] == 3 + assert "paste.rs/paste1" in out # Report + assert "paste.rs/paste2" in out # agent.log + assert "paste.rs/paste3" in out # gateway.log + assert "Report" in out + assert "agent.log" in out + assert "gateway.log" in out + + # Each log paste should start with the dump header + agent_paste = uploaded_content[1] + assert "--- hermes dump ---" in agent_paste + assert "--- full agent.log ---" in agent_paste + gateway_paste = uploaded_content[2] + assert "--- hermes dump ---" in gateway_paste + assert "--- full gateway.log ---" in gateway_paste + + def test_share_skips_missing_logs(self, tmp_path, monkeypatch, capsys): + """Only uploads logs that exist.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + call_count = [0] + def _mock_upload(content, expiry_days=7): + call_count[0] += 1 + return f"https://paste.rs/paste{call_count[0]}" + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin", + side_effect=_mock_upload): + run_debug_share(args) + + out = capsys.readouterr().out + # Only the report should be uploaded (no log files exist) + assert call_count[0] == 1 + assert "Report" in out + + def test_share_continues_on_log_upload_failure(self, hermes_home, capsys): + """Log upload failure doesn't stop the report from being shared.""" + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + call_count = [0] + def _mock_upload(content, expiry_days=7): + call_count[0] += 1 + if call_count[0] > 1: + raise RuntimeError("upload failed") + return "https://paste.rs/report" + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin", + side_effect=_mock_upload): + run_debug_share(args) + + out = capsys.readouterr().out + assert "Report" in out + assert "paste.rs/report" in out + assert "failed to upload" in out + + def test_share_exits_on_report_upload_failure(self, hermes_home, capsys): + """If the main report fails to upload, exit with code 1.""" + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin", + side_effect=RuntimeError("all failed")): + with pytest.raises(SystemExit) as exc_info: + run_debug_share(args) + + assert exc_info.value.code == 1 + out = capsys.readouterr() + assert "all failed" in out.err + + +# --------------------------------------------------------------------------- +# run_debug router +# --------------------------------------------------------------------------- + +class TestRunDebug: + def test_no_subcommand_shows_usage(self, capsys): + from hermes_cli.debug import run_debug + + args = MagicMock() + args.debug_command = None + + run_debug(args) + + out = capsys.readouterr().out + assert "hermes debug share" in out + + def test_share_subcommand_routes(self, hermes_home): + from hermes_cli.debug import run_debug + + args = MagicMock() + args.debug_command = "share" + args.lines = 200 + args.expire = 7 + args.local = True + + with patch("hermes_cli.dump.run_dump"): + run_debug(args) + + +# --------------------------------------------------------------------------- +# Argparse integration +# --------------------------------------------------------------------------- + +class TestArgparseIntegration: + def test_module_imports_clean(self): + from hermes_cli.debug import run_debug, run_debug_share + assert callable(run_debug) + assert callable(run_debug_share) + + def test_cmd_debug_dispatches(self): + from hermes_cli.main import cmd_debug + + args = MagicMock() + args.debug_command = None + cmd_debug(args) From c7d8d109ff7d74f089905f4c9d0c8826b0a876e4 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 18:04:51 -0700 Subject: [PATCH 062/288] fix(matrix): trust m.mentions.user_ids as authoritative mention signal Port from openclaw/openclaw#64796: Per MSC3952 / Matrix v1.7, the m.mentions.user_ids field is the authoritative mention signal. Clients that populate m.mentions but don't duplicate @bot in the body text were being silently dropped when MATRIX_REQUIRE_MENTION=true. Cherry-picked from PR #8673. --- gateway/platforms/matrix.py | 25 +++++++-- tests/gateway/test_matrix_mention.py | 80 ++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 8855c386d9e3..654d77070ed7 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -1135,7 +1135,10 @@ async def _resolve_message_context( thread_id = relates_to.get("event_id") formatted_body = source_content.get("formatted_body") - is_mentioned = self._is_bot_mentioned(body, formatted_body) + # m.mentions.user_ids (MSC3952 / Matrix v1.7) — authoritative mention signal. + mentions_block = source_content.get("m.mentions") or {} + mention_user_ids = mentions_block.get("user_ids") if isinstance(mentions_block, dict) else None + is_mentioned = self._is_bot_mentioned(body, formatted_body, mention_user_ids) # Require-mention gating. if not is_dm: @@ -1822,8 +1825,24 @@ async def _refresh_dm_cache(self) -> None: # Mention detection helpers # ------------------------------------------------------------------ - def _is_bot_mentioned(self, body: str, formatted_body: Optional[str] = None) -> bool: - """Return True if the bot is mentioned in the message.""" + def _is_bot_mentioned( + self, + body: str, + formatted_body: Optional[str] = None, + mention_user_ids: Optional[list] = None, + ) -> bool: + """Return True if the bot is mentioned in the message. + + Per MSC3952, ``m.mentions.user_ids`` is the authoritative mention + signal in the Matrix spec. When the sender's client populates that + field with the bot's user-id, we trust it — even when the visible + body text does not contain an explicit ``@bot`` string (some clients + only render mention "pills" in ``formatted_body`` or use display + names). + """ + # m.mentions.user_ids — authoritative per MSC3952 / Matrix v1.7. + if mention_user_ids and self._user_id and self._user_id in mention_user_ids: + return True if not body and not formatted_body: return False if self._user_id and self._user_id in body: diff --git a/tests/gateway/test_matrix_mention.py b/tests/gateway/test_matrix_mention.py index 873b873c2369..b5db0da7c5c5 100644 --- a/tests/gateway/test_matrix_mention.py +++ b/tests/gateway/test_matrix_mention.py @@ -48,6 +48,7 @@ def _make_event( room_id="!room1:example.org", formatted_body=None, thread_id=None, + mention_user_ids=None, ): """Create a fake room message event. @@ -60,6 +61,9 @@ def _make_event( content["formatted_body"] = formatted_body content["format"] = "org.matrix.custom.html" + if mention_user_ids is not None: + content["m.mentions"] = {"user_ids": mention_user_ids} + relates_to = {} if thread_id: relates_to["rel_type"] = "m.thread" @@ -108,6 +112,44 @@ def test_partial_localpart_no_match(self): # "hermesbot" should not match word-boundary check for "hermes" assert not self.adapter._is_bot_mentioned("hermesbot is here") + # m.mentions.user_ids — MSC3952 / Matrix v1.7 authoritative mentions + # Ported from openclaw/openclaw#64796 + + def test_m_mentions_user_ids_authoritative(self): + """m.mentions.user_ids alone is sufficient — no body text needed.""" + assert self.adapter._is_bot_mentioned( + "please reply", # no @hermes anywhere in body + mention_user_ids=["@hermes:example.org"], + ) + + def test_m_mentions_user_ids_with_body_mention(self): + """Both m.mentions and body mention — should still be True.""" + assert self.adapter._is_bot_mentioned( + "hey @hermes:example.org help", + mention_user_ids=["@hermes:example.org"], + ) + + def test_m_mentions_user_ids_other_user_only(self): + """m.mentions with a different user — bot is NOT mentioned.""" + assert not self.adapter._is_bot_mentioned( + "hello", + mention_user_ids=["@alice:example.org"], + ) + + def test_m_mentions_user_ids_empty_list(self): + """Empty user_ids list — falls through to text detection.""" + assert not self.adapter._is_bot_mentioned( + "hello everyone", + mention_user_ids=[], + ) + + def test_m_mentions_user_ids_none(self): + """None mention_user_ids — falls through to text detection.""" + assert not self.adapter._is_bot_mentioned( + "hello everyone", + mention_user_ids=None, + ) + class TestStripMention: def setup_method(self): @@ -176,6 +218,44 @@ async def test_require_mention_html_pill(monkeypatch): adapter.handle_message.assert_awaited_once() +@pytest.mark.asyncio +async def test_require_mention_m_mentions_user_ids(monkeypatch): + """m.mentions.user_ids is authoritative per MSC3952 — no body mention needed. + + Ported from openclaw/openclaw#64796. + """ + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + # Body has NO mention, but m.mentions.user_ids includes the bot. + event = _make_event( + "please reply", + mention_user_ids=["@hermes:example.org"], + ) + + await adapter._on_room_message(event) + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_require_mention_m_mentions_other_user_ignored(monkeypatch): + """m.mentions.user_ids mentioning another user should NOT activate the bot.""" + monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False) + monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False) + monkeypatch.setenv("MATRIX_AUTO_THREAD", "false") + + adapter = _make_adapter() + event = _make_event( + "hey alice check this", + mention_user_ids=["@alice:example.org"], + ) + + await adapter._on_room_message(event) + adapter.handle_message.assert_not_awaited() + + @pytest.mark.asyncio async def test_require_mention_dm_always_responds(monkeypatch): """DMs always respond regardless of mention setting.""" From f724079d3b54283aa7223137203b23fed8cd89ba Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 18:05:02 -0700 Subject: [PATCH 063/288] fix(gateway): reject known-weak placeholder credentials at startup Port from openclaw/openclaw#64586: users who copy .env.example without changing placeholder values now get a clear error at startup instead of a confusing auth failure from the platform API. Also rejects placeholder API_SERVER_KEY when binding to a network-accessible address. Cherry-picked from PR #8677. --- gateway/config.py | 37 ++++- gateway/platforms/api_server.py | 17 +++ tests/gateway/test_weak_credential_guard.py | 141 ++++++++++++++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_weak_credential_guard.py diff --git a/gateway/config.py b/gateway/config.py index 342af9764885..7d6165927968 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -665,6 +665,17 @@ def load_gateway_config() -> GatewayConfig: _apply_env_overrides(config) # --- Validate loaded values --- + _validate_gateway_config(config) + + return config + + +def _validate_gateway_config(config: "GatewayConfig") -> None: + """Validate and sanitize a loaded GatewayConfig in place. + + Called by ``load_gateway_config()`` after all config sources are merged. + Extracted as a separate function for testability. + """ policy = config.default_reset_policy if not (0 <= policy.at_hour <= 23): @@ -701,7 +712,31 @@ def load_gateway_config() -> GatewayConfig: platform.value, env_name, ) - return config + # Reject known-weak placeholder tokens. + # Ported from openclaw/openclaw#64586: users who copy .env.example + # without changing placeholder values get a clear startup error instead + # of a confusing "auth failed" from the platform API. + try: + from hermes_cli.auth import has_usable_secret + except ImportError: + has_usable_secret = None # type: ignore[assignment] + + if has_usable_secret is not None: + for platform, pconfig in config.platforms.items(): + if not pconfig.enabled: + continue + env_name = _token_env_names.get(platform) + if not env_name: + continue + token = pconfig.token + if token and token.strip() and not has_usable_secret(token, min_length=4): + logger.error( + "%s is enabled but %s is set to a placeholder value ('%s'). " + "Set a real bot token before starting the gateway. " + "The adapter will NOT be started.", + platform.value, env_name, token.strip()[:6] + "...", + ) + pconfig.enabled = False def _apply_env_overrides(config: GatewayConfig) -> None: diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index df3fbe1d30a7..9a4990465958 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1819,6 +1819,23 @@ async def connect(self) -> bool: ) return False + # Refuse to start network-accessible with a placeholder key. + # Ported from openclaw/openclaw#64586. + if is_network_accessible(self._host) and self._api_key: + try: + from hermes_cli.auth import has_usable_secret + if not has_usable_secret(self._api_key, min_length=8): + logger.error( + "[%s] Refusing to start: API_SERVER_KEY is set to a " + "placeholder value. Generate a real secret " + "(e.g. `openssl rand -hex 32`) and set API_SERVER_KEY " + "before exposing the API server on %s.", + self.name, self._host, + ) + return False + except ImportError: + pass + # Port conflict detection — fail fast if port is already in use try: with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s: diff --git a/tests/gateway/test_weak_credential_guard.py b/tests/gateway/test_weak_credential_guard.py new file mode 100644 index 000000000000..7d6ea84b3f49 --- /dev/null +++ b/tests/gateway/test_weak_credential_guard.py @@ -0,0 +1,141 @@ +"""Tests for gateway weak credential rejection at startup. + +Ported from openclaw/openclaw#64586: rejects known-weak placeholder +tokens at gateway startup instead of letting them silently fail +against platform APIs. +""" + +import logging + +import pytest + +from gateway.config import PlatformConfig, Platform, _validate_gateway_config + + +# --------------------------------------------------------------------------- +# Helper: create a minimal GatewayConfig with one enabled platform +# --------------------------------------------------------------------------- + + +def _make_gateway_config(platform, token, enabled=True, **extra_kwargs): + """Create a minimal GatewayConfig-like object for validation testing.""" + from gateway.config import GatewayConfig + + config = GatewayConfig(platforms={}) + pconfig = PlatformConfig(enabled=enabled, token=token, **extra_kwargs) + config.platforms[platform] = pconfig + return config + + +def _validate_and_return(config): + """Call _validate_gateway_config and return the config (mutated in place).""" + _validate_gateway_config(config) + return config + + +# --------------------------------------------------------------------------- +# Unit tests: platform token placeholder rejection +# --------------------------------------------------------------------------- + + +class TestPlatformTokenPlaceholderGuard: + """Verify that _validate_gateway_config disables platforms with placeholder tokens.""" + + def test_rejects_triple_asterisk(self, caplog): + """'***' is the .env.example placeholder — should be rejected.""" + config = _make_gateway_config(Platform.TELEGRAM, "***") + with caplog.at_level(logging.ERROR): + _validate_and_return(config) + assert config.platforms[Platform.TELEGRAM].enabled is False + assert "placeholder" in caplog.text.lower() + + def test_rejects_changeme(self, caplog): + config = _make_gateway_config(Platform.DISCORD, "changeme") + with caplog.at_level(logging.ERROR): + _validate_and_return(config) + assert config.platforms[Platform.DISCORD].enabled is False + + def test_rejects_your_api_key(self, caplog): + config = _make_gateway_config(Platform.SLACK, "your_api_key") + with caplog.at_level(logging.ERROR): + _validate_and_return(config) + assert config.platforms[Platform.SLACK].enabled is False + + def test_rejects_placeholder(self, caplog): + config = _make_gateway_config(Platform.MATRIX, "placeholder") + with caplog.at_level(logging.ERROR): + _validate_and_return(config) + assert config.platforms[Platform.MATRIX].enabled is False + + def test_accepts_real_token(self, caplog): + """A real-looking bot token should pass validation.""" + config = _make_gateway_config( + Platform.TELEGRAM, "7123456789:AAHdqTcvCH1vGWJxfSeOfSAs0K5PALDsaw" + ) + with caplog.at_level(logging.ERROR): + _validate_and_return(config) + assert config.platforms[Platform.TELEGRAM].enabled is True + assert "placeholder" not in caplog.text.lower() + + def test_accepts_empty_token_without_error(self, caplog): + """Empty tokens get a warning (existing behavior), not a placeholder error.""" + config = _make_gateway_config(Platform.TELEGRAM, "") + with caplog.at_level(logging.WARNING): + _validate_and_return(config) + # Empty token doesn't trigger placeholder rejection — enabled stays True + # (the existing empty-token warning is separate) + assert config.platforms[Platform.TELEGRAM].enabled is True + + def test_disabled_platform_not_checked(self, caplog): + """Disabled platforms should not be validated.""" + config = _make_gateway_config(Platform.TELEGRAM, "***", enabled=False) + with caplog.at_level(logging.ERROR): + _validate_and_return(config) + assert "placeholder" not in caplog.text.lower() + + def test_rejects_whitespace_padded_placeholder(self, caplog): + """Whitespace-padded placeholders should still be caught.""" + config = _make_gateway_config(Platform.TELEGRAM, " *** ") + with caplog.at_level(logging.ERROR): + _validate_and_return(config) + assert config.platforms[Platform.TELEGRAM].enabled is False + + +# --------------------------------------------------------------------------- +# Integration test: API server placeholder key on network-accessible host +# --------------------------------------------------------------------------- + + +class TestAPIServerPlaceholderKeyGuard: + """Verify that the API server rejects placeholder keys on network hosts.""" + + @pytest.mark.asyncio + async def test_refuses_wildcard_with_placeholder_key(self): + from gateway.platforms.api_server import APIServerAdapter + + adapter = APIServerAdapter( + PlatformConfig(enabled=True, extra={"host": "0.0.0.0", "key": "changeme"}) + ) + result = await adapter.connect() + assert result is False + + @pytest.mark.asyncio + async def test_refuses_wildcard_with_asterisk_key(self): + from gateway.platforms.api_server import APIServerAdapter + + adapter = APIServerAdapter( + PlatformConfig(enabled=True, extra={"host": "0.0.0.0", "key": "***"}) + ) + result = await adapter.connect() + assert result is False + + def test_allows_loopback_with_placeholder_key(self): + """Loopback with a placeholder key is fine — not network-exposed.""" + from gateway.platforms.api_server import APIServerAdapter + from gateway.platforms.base import is_network_accessible + + adapter = APIServerAdapter( + PlatformConfig(enabled=True, extra={"host": "127.0.0.1", "key": "changeme"}) + ) + # On loopback the placeholder guard doesn't fire + assert is_network_accessible(adapter._host) is False From 3cd6cbee5ff9306a0e92a6610d7a3fcabb408f8e Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 18:08:45 -0700 Subject: [PATCH 064/288] feat: add /debug slash command for all platforms Adds /debug as a slash command available in CLI, Telegram, Discord, Slack, and all other gateway platforms. Uploads debug report + full logs to paste services and returns shareable URLs. - commands.py: CommandDef in Info category (no cli_only/gateway_only) - gateway/run.py: async handler with run_in_executor for blocking I/O - cli.py: dispatch in process_command to run_debug_share --- cli.py | 10 ++++++++ gateway/run.py | 58 ++++++++++++++++++++++++++++++++++++++++++ hermes_cli/commands.py | 1 + 3 files changed, 69 insertions(+) diff --git a/cli.py b/cli.py index 09cb47e38430..18d61ea7724a 100644 --- a/cli.py +++ b/cli.py @@ -5391,6 +5391,8 @@ def process_command(self, command: str) -> bool: self._show_usage() elif canonical == "insights": self._show_insights(cmd_original) + elif canonical == "debug": + self._handle_debug_command() elif canonical == "paste": self._handle_paste_command() elif canonical == "image": @@ -6305,6 +6307,14 @@ def _manual_compress(self, cmd_original: str = ""): except Exception as e: print(f" ❌ Compression failed: {e}") + def _handle_debug_command(self): + """Handle /debug — upload debug report + logs and print paste URLs.""" + from hermes_cli.debug import run_debug_share + from types import SimpleNamespace + + args = SimpleNamespace(lines=200, expire=7, local=False) + run_debug_share(args) + def _show_usage(self): """Show rate limits (if available) and session token usage.""" if not self.agent: diff --git a/gateway/run.py b/gateway/run.py index 0b778e2f67b9..372cd474bbb7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2757,6 +2757,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if canonical == "update": return await self._handle_update_command(event) + if canonical == "debug": + return await self._handle_debug_command(event) + if canonical == "title": return await self._handle_title_command(event) @@ -6428,6 +6431,61 @@ async def _handle_deny_command(self, event: MessageEvent) -> str: Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.LOCAL, }) + async def _handle_debug_command(self, event: MessageEvent) -> str: + """Handle /debug — upload debug report + logs and return paste URLs.""" + import asyncio + from hermes_cli.debug import ( + _capture_dump, collect_debug_report, _read_full_log, + upload_to_pastebin, + ) + + loop = asyncio.get_running_loop() + + # Run blocking I/O (dump capture, log reads, uploads) in a thread. + def _collect_and_upload(): + dump_text = _capture_dump() + report = collect_debug_report(log_lines=200, dump_text=dump_text) + agent_log = _read_full_log("agent") + gateway_log = _read_full_log("gateway") + + 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 + + urls = {} + failures = [] + + try: + urls["Report"] = upload_to_pastebin(report) + except Exception as exc: + return f"✗ Failed to upload debug report: {exc}" + + if agent_log: + try: + urls["agent.log"] = upload_to_pastebin(agent_log) + except Exception: + failures.append("agent.log") + + if gateway_log: + try: + urls["gateway.log"] = upload_to_pastebin(gateway_log) + except Exception: + failures.append("gateway.log") + + lines = ["**Debug report uploaded:**", ""] + label_width = max(len(k) for k in urls) + for label, url in urls.items(): + lines.append(f"`{label:<{label_width}}` {url}") + + if failures: + lines.append(f"\n_(failed to upload: {', '.join(failures)})_") + + lines.append("\nShare these links with the Hermes team for support.") + return "\n".join(lines) + + return await loop.run_in_executor(None, _collect_and_upload) + async def _handle_update_command(self, event: MessageEvent) -> str: """Handle /update command — update Hermes Agent to the latest version. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 1c5a298d1e02..b44a8aa8f01e 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -154,6 +154,7 @@ class CommandDef: cli_only=True, args_hint=""), CommandDef("update", "Update Hermes Agent to the latest version", "Info", gateway_only=True), + CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"), # Exit CommandDef("quit", "Exit the CLI", "Exit", From 9e992df8aea952c2cf42ee0c76822a4ab14bc3aa Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:06:20 -0700 Subject: [PATCH 065/288] fix(telegram): use UTF-16 code units for message length splitting (#8725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from nearai/ironclaw#2304: Telegram's 4096 character limit is measured in UTF-16 code units, not Unicode codepoints. Characters outside the Basic Multilingual Plane (emoji like 😀, CJK Extension B, musical symbols) are surrogate pairs: 1 Python char but 2 UTF-16 units. Previously, truncate_message() used Python's len() which counts codepoints. This could produce chunks exceeding Telegram's actual limit when messages contain many astral-plane characters. Changes: - Add utf16_len() helper and _prefix_within_utf16_limit() for UTF-16-aware string measurement and truncation - Add _custom_unit_to_cp() binary-search helper that maps a custom-unit budget to the largest safe codepoint slice position - Update truncate_message() to accept optional len_fn parameter - Telegram adapter now passes len_fn=utf16_len when splitting messages - Fix fallback truncation in Telegram error handler to use _prefix_within_utf16_limit instead of codepoint slicing - Update send_message_tool.py to use utf16_len for Telegram platform - Add comprehensive tests: utf16_len, _prefix_within_utf16_limit, truncate_message with len_fn (emoji splitting, content preservation, code block handling) - Update mock lambdas in reply_mode tests to accept **kw for len_fn --- gateway/platforms/base.py | 91 +++++++++++++-- gateway/platforms/telegram.py | 10 +- tests/gateway/test_discord_reply_mode.py | 14 +-- tests/gateway/test_platform_base.py | 134 ++++++++++++++++++++++ tests/gateway/test_telegram_reply_mode.py | 10 +- tools/send_message_tool.py | 6 +- 6 files changed, 240 insertions(+), 25 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 352aecb3331e..f7943da4739d 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -21,6 +21,59 @@ logger = logging.getLogger(__name__) +def utf16_len(s: str) -> int: + """Count UTF-16 code units in *s*. + + Telegram's message-length limit (4 096) is measured in UTF-16 code units, + **not** Unicode code-points. Characters outside the Basic Multilingual + Plane (emoji like 😀, CJK Extension B, musical symbols, …) are encoded as + surrogate pairs and therefore consume **two** UTF-16 code units each, even + though Python's ``len()`` counts them as one. + + Ported from nearai/ironclaw#2304 which discovered the same discrepancy in + Rust's ``chars().count()``. + """ + return len(s.encode("utf-16-le")) // 2 + + +def _prefix_within_utf16_limit(s: str, limit: int) -> str: + """Return the longest prefix of *s* whose UTF-16 length ≤ *limit*. + + Unlike a plain ``s[:limit]``, this respects surrogate-pair boundaries so + we never slice a multi-code-unit character in half. + """ + if utf16_len(s) <= limit: + return s + # Binary search for the longest safe prefix + lo, hi = 0, len(s) + while lo < hi: + mid = (lo + hi + 1) // 2 + if utf16_len(s[:mid]) <= limit: + lo = mid + else: + hi = mid - 1 + return s[:lo] + + +def _custom_unit_to_cp(s: str, budget: int, len_fn) -> int: + """Return the largest codepoint offset *n* such that ``len_fn(s[:n]) <= budget``. + + Used by :meth:`BasePlatformAdapter.truncate_message` when *len_fn* measures + length in units different from Python codepoints (e.g. UTF-16 code units). + Falls back to binary search which is O(log n) calls to *len_fn*. + """ + if len_fn(s) <= budget: + return len(s) + lo, hi = 0, len(s) + while lo < hi: + mid = (lo + hi + 1) // 2 + if len_fn(s[:mid]) <= budget: + lo = mid + else: + hi = mid - 1 + return lo + + def is_network_accessible(host: str) -> bool: """Return True if *host* would expose the server beyond loopback. @@ -1886,7 +1939,11 @@ def format_message(self, content: str) -> str: return content @staticmethod - def truncate_message(content: str, max_length: int = 4096) -> List[str]: + def truncate_message( + content: str, + max_length: int = 4096, + len_fn: Optional["Callable[[str], int]"] = None, + ) -> List[str]: """ Split a long message into chunks, preserving code block boundaries. @@ -1898,11 +1955,16 @@ def truncate_message(content: str, max_length: int = 4096) -> List[str]: Args: content: The full message content max_length: Maximum length per chunk (platform-specific) + len_fn: Optional length function for measuring string length. + Defaults to ``len`` (Unicode code-points). Pass + ``utf16_len`` for platforms that measure message + length in UTF-16 code units (e.g. Telegram). Returns: List of message chunks """ - if len(content) <= max_length: + _len = len_fn or len + if _len(content) <= max_length: return [content] INDICATOR_RESERVE = 10 # room for " (XX/XX)" @@ -1921,22 +1983,33 @@ def truncate_message(content: str, max_length: int = 4096) -> List[str]: # How much body text we can fit after accounting for the prefix, # a potential closing fence, and the chunk indicator. - headroom = max_length - INDICATOR_RESERVE - len(prefix) - len(FENCE_CLOSE) + headroom = max_length - INDICATOR_RESERVE - _len(prefix) - _len(FENCE_CLOSE) if headroom < 1: headroom = max_length // 2 # Everything remaining fits in one final chunk - if len(prefix) + len(remaining) <= max_length - INDICATOR_RESERVE: + if _len(prefix) + _len(remaining) <= max_length - INDICATOR_RESERVE: chunks.append(prefix + remaining) break - # Find a natural split point (prefer newlines, then spaces) - region = remaining[:headroom] + # Find a natural split point (prefer newlines, then spaces). + # When _len != len (e.g. utf16_len for Telegram), headroom is + # measured in the custom unit. We need codepoint-based slice + # positions that stay within the custom-unit budget. + # + # _safe_slice_pos() maps a custom-unit budget to the largest + # codepoint offset whose custom length ≤ budget. + if _len is not len: + # Map headroom (custom units) → codepoint slice length + _cp_limit = _custom_unit_to_cp(remaining, headroom, _len) + else: + _cp_limit = headroom + region = remaining[:_cp_limit] split_at = region.rfind("\n") - if split_at < headroom // 2: + if split_at < _cp_limit // 2: split_at = region.rfind(" ") if split_at < 1: - split_at = headroom + split_at = _cp_limit # Avoid splitting inside an inline code span (`...`). # If the text before split_at has an odd number of unescaped @@ -1956,7 +2029,7 @@ def truncate_message(content: str, max_length: int = 4096) -> List[str]: safe_split = candidate.rfind(" ", 0, last_bt) nl_split = candidate.rfind("\n", 0, last_bt) safe_split = max(safe_split, nl_split) - if safe_split > headroom // 4: + if safe_split > _cp_limit // 4: split_at = safe_split chunk_body = remaining[:split_at] diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 2653296026c7..5262e388b278 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -66,6 +66,8 @@ class _MockContextTypes: cache_audio_from_bytes, cache_document_from_bytes, SUPPORTED_DOCUMENT_TYPES, + utf16_len, + _prefix_within_utf16_limit, ) from gateway.platforms.telegram_network import ( TelegramFallbackTransport, @@ -799,7 +801,9 @@ async def send( try: # Format and split message if needed formatted = self.format_message(content) - chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + chunks = self.truncate_message( + formatted, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len, + ) if len(chunks) > 1: # truncate_message appends a raw " (1/2)" suffix. Escape the # MarkdownV2-special parentheses so Telegram doesn't reject the @@ -970,7 +974,9 @@ async def edit_message( # streaming). Truncate and succeed so the stream consumer can # split the overflow into a new message instead of dying. if "message_too_long" in err_str or "too long" in err_str: - truncated = content[: self.MAX_MESSAGE_LENGTH - 20] + "…" + truncated = _prefix_within_utf16_limit( + content, self.MAX_MESSAGE_LENGTH - 20 + ) + "…" try: await self._bot.edit_message_text( chat_id=int(chat_id), diff --git a/tests/gateway/test_discord_reply_mode.py b/tests/gateway/test_discord_reply_mode.py index 5a9bb9cd1d82..2346d086f2f3 100644 --- a/tests/gateway/test_discord_reply_mode.py +++ b/tests/gateway/test_discord_reply_mode.py @@ -124,7 +124,7 @@ class TestSendWithReplyToMode: @pytest.mark.asyncio async def test_off_mode_no_reply_reference(self): adapter, channel, ref_msg = _make_discord_adapter("off") - adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"] + adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"] await adapter.send("12345", "test content", reply_to="999") @@ -137,7 +137,7 @@ async def test_off_mode_no_reply_reference(self): @pytest.mark.asyncio async def test_first_mode_only_first_chunk_references(self): adapter, channel, ref_msg = _make_discord_adapter("first") - adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"] + adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"] await adapter.send("12345", "test content", reply_to="999") @@ -152,7 +152,7 @@ async def test_first_mode_only_first_chunk_references(self): @pytest.mark.asyncio async def test_all_mode_all_chunks_reference(self): adapter, channel, ref_msg = _make_discord_adapter("all") - adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"] + adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"] await adapter.send("12345", "test content", reply_to="999") @@ -165,7 +165,7 @@ async def test_all_mode_all_chunks_reference(self): @pytest.mark.asyncio async def test_no_reply_to_param_no_reference(self): adapter, channel, ref_msg = _make_discord_adapter("all") - adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"] + adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"] await adapter.send("12345", "test content", reply_to=None) @@ -176,7 +176,7 @@ async def test_no_reply_to_param_no_reference(self): @pytest.mark.asyncio async def test_single_chunk_respects_first_mode(self): adapter, channel, ref_msg = _make_discord_adapter("first") - adapter.truncate_message = lambda content, max_len: ["single chunk"] + adapter.truncate_message = lambda content, max_len, **kw: ["single chunk"] await adapter.send("12345", "test", reply_to="999") @@ -187,7 +187,7 @@ async def test_single_chunk_respects_first_mode(self): @pytest.mark.asyncio async def test_single_chunk_off_mode(self): adapter, channel, ref_msg = _make_discord_adapter("off") - adapter.truncate_message = lambda content, max_len: ["single chunk"] + adapter.truncate_message = lambda content, max_len, **kw: ["single chunk"] await adapter.send("12345", "test", reply_to="999") @@ -200,7 +200,7 @@ async def test_single_chunk_off_mode(self): async def test_invalid_mode_falls_back_to_first_behavior(self): """Invalid mode behaves like 'first' — only first chunk gets reference.""" adapter, channel, ref_msg = _make_discord_adapter("banana") - adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"] + adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"] await adapter.send("12345", "test", reply_to="999") diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index f2d133ea2b88..690a82095485 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -9,6 +9,8 @@ MessageEvent, MessageType, safe_url_for_log, + utf16_len, + _prefix_within_utf16_limit, ) @@ -448,3 +450,135 @@ def test_custom_mode_uses_env_vars(self): with patch.dict(os.environ, env): delay = BasePlatformAdapter._get_human_delay() assert 0.1 <= delay <= 0.2 + + +# --------------------------------------------------------------------------- +# utf16_len / _prefix_within_utf16_limit / truncate_message with len_fn +# --------------------------------------------------------------------------- +# Ported from nearai/ironclaw#2304 — Telegram counts message length in UTF-16 +# code units, not Unicode code-points. Astral-plane characters (emoji, CJK +# Extension B) are surrogate pairs: 1 Python char but 2 UTF-16 units. + + +class TestUtf16Len: + """Verify the UTF-16 length helper.""" + + def test_ascii(self): + assert utf16_len("hello") == 5 + + def test_bmp_cjk(self): + # CJK ideographs in the BMP are 1 code unit each + assert utf16_len("你好") == 2 + + def test_emoji_surrogate_pair(self): + # 😀 (U+1F600) is outside BMP → 2 UTF-16 code units + assert utf16_len("😀") == 2 + + def test_mixed(self): + # "hi😀" = 2 + 2 = 4 UTF-16 units + assert utf16_len("hi😀") == 4 + + def test_musical_symbol(self): + # 𝄞 (U+1D11E) — Musical Symbol G Clef, surrogate pair + assert utf16_len("𝄞") == 2 + + def test_empty(self): + assert utf16_len("") == 0 + + +class TestPrefixWithinUtf16Limit: + """Verify UTF-16-aware prefix truncation.""" + + def test_fits_entirely(self): + assert _prefix_within_utf16_limit("hello", 10) == "hello" + + def test_ascii_truncation(self): + result = _prefix_within_utf16_limit("hello world", 5) + assert result == "hello" + assert utf16_len(result) <= 5 + + def test_does_not_split_surrogate_pair(self): + # "a😀b" = 1 + 2 + 1 = 4 UTF-16 units; limit 2 should give "a" + result = _prefix_within_utf16_limit("a😀b", 2) + assert result == "a" + assert utf16_len(result) <= 2 + + def test_emoji_at_limit(self): + # "😀" = 2 UTF-16 units; limit 2 should include it + result = _prefix_within_utf16_limit("😀x", 2) + assert result == "😀" + + def test_all_emoji(self): + msg = "😀" * 10 # 20 UTF-16 units + result = _prefix_within_utf16_limit(msg, 6) + assert result == "😀😀😀" + assert utf16_len(result) == 6 + + def test_empty(self): + assert _prefix_within_utf16_limit("", 5) == "" + + +class TestTruncateMessageUtf16: + """Verify truncate_message respects UTF-16 lengths when len_fn=utf16_len.""" + + def test_short_emoji_message_no_split(self): + """A short message under the UTF-16 limit should not be split.""" + msg = "Hello 😀 world" + chunks = BasePlatformAdapter.truncate_message(msg, 4096, len_fn=utf16_len) + assert len(chunks) == 1 + assert chunks[0] == msg + + def test_emoji_near_limit_triggers_split(self): + """A message at 4096 codepoints but >4096 UTF-16 units must split.""" + # 2049 emoji = 2049 codepoints but 4098 UTF-16 units → exceeds 4096 + msg = "😀" * 2049 + assert len(msg) == 2049 # Python len sees 2049 chars + assert utf16_len(msg) == 4098 # but it's 4098 UTF-16 units + + # Without UTF-16 awareness, this would NOT split (2049 < 4096) + chunks_naive = BasePlatformAdapter.truncate_message(msg, 4096) + assert len(chunks_naive) == 1, "Without len_fn, no split expected" + + # With UTF-16 awareness, it MUST split + chunks = BasePlatformAdapter.truncate_message(msg, 4096, len_fn=utf16_len) + assert len(chunks) > 1, "With utf16_len, message should be split" + + # Each chunk must fit within the UTF-16 limit + for i, chunk in enumerate(chunks): + assert utf16_len(chunk) <= 4096, ( + f"Chunk {i} exceeds 4096 UTF-16 units: {utf16_len(chunk)}" + ) + + def test_each_utf16_chunk_within_limit(self): + """All chunks produced with utf16_len must fit the limit.""" + # Mix of BMP and astral-plane characters + msg = ("Hello 😀 world 🎵 test 𝄞 " * 200).strip() + max_len = 200 + chunks = BasePlatformAdapter.truncate_message(msg, max_len, len_fn=utf16_len) + for i, chunk in enumerate(chunks): + u16_len = utf16_len(chunk) + assert u16_len <= max_len + 20, ( + f"Chunk {i} UTF-16 length {u16_len} exceeds {max_len}" + ) + + def test_all_content_preserved(self): + """Splitting with utf16_len must not lose content.""" + words = ["emoji😀", "music🎵", "cjk你好", "plain"] * 100 + msg = " ".join(words) + chunks = BasePlatformAdapter.truncate_message(msg, 200, len_fn=utf16_len) + reassembled = " ".join(chunks) + for word in words: + assert word in reassembled, f"Word '{word}' lost during UTF-16 split" + + def test_code_blocks_preserved_with_utf16(self): + """Code block fence handling should work with utf16_len too.""" + msg = "Before\n```python\n" + "x = '😀'\n" * 200 + "```\nAfter" + chunks = BasePlatformAdapter.truncate_message(msg, 300, len_fn=utf16_len) + assert len(chunks) > 1 + # Each chunk should have balanced fences + for i, chunk in enumerate(chunks): + fence_count = chunk.count("```") + assert fence_count % 2 == 0, ( + f"Chunk {i} has unbalanced fences ({fence_count})" + ) + diff --git a/tests/gateway/test_telegram_reply_mode.py b/tests/gateway/test_telegram_reply_mode.py index 1218afa0c128..a433b1801633 100644 --- a/tests/gateway/test_telegram_reply_mode.py +++ b/tests/gateway/test_telegram_reply_mode.py @@ -121,7 +121,7 @@ async def test_off_mode_no_reply_threading(self, adapter_factory): adapter = adapter_factory(reply_to_mode="off") adapter._bot = MagicMock() adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) - adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"] + adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"] await adapter.send("12345", "test content", reply_to="999") @@ -133,7 +133,7 @@ async def test_first_mode_only_first_chunk_threads(self, adapter_factory): adapter = adapter_factory(reply_to_mode="first") adapter._bot = MagicMock() adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) - adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"] + adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"] await adapter.send("12345", "test content", reply_to="999") @@ -148,7 +148,7 @@ async def test_all_mode_all_chunks_thread(self, adapter_factory): adapter = adapter_factory(reply_to_mode="all") adapter._bot = MagicMock() adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) - adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"] + adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"] await adapter.send("12345", "test content", reply_to="999") @@ -162,7 +162,7 @@ async def test_no_reply_to_param_no_threading(self, adapter_factory): adapter = adapter_factory(reply_to_mode="all") adapter._bot = MagicMock() adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) - adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"] + adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"] await adapter.send("12345", "test content", reply_to=None) @@ -175,7 +175,7 @@ async def test_single_chunk_respects_mode(self, adapter_factory): adapter = adapter_factory(reply_to_mode="first") adapter._bot = MagicMock() adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) - adapter.truncate_message = lambda content, max_len: ["single chunk"] + adapter.truncate_message = lambda content, max_len, **kw: ["single chunk"] await adapter.send("12345", "test", reply_to="999") diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 60503c0bca7f..a2b3e984c0b9 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -322,7 +322,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, (preserves code-block boundaries, adds part indicators). """ from gateway.config import Platform - from gateway.platforms.base import BasePlatformAdapter + from gateway.platforms.base import BasePlatformAdapter, utf16_len from gateway.platforms.telegram import TelegramAdapter from gateway.platforms.discord import DiscordAdapter from gateway.platforms.slack import SlackAdapter @@ -354,9 +354,11 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, # Smart-chunk the message to fit within platform limits. # For short messages or platforms without a known limit this is a no-op. + # Telegram measures length in UTF-16 code units, not Unicode codepoints. max_len = _MAX_LENGTHS.get(platform) if max_len: - chunks = BasePlatformAdapter.truncate_message(message, max_len) + _len_fn = utf16_len if platform == Platform.TELEGRAM else None + chunks = BasePlatformAdapter.truncate_message(message, max_len, len_fn=_len_fn) else: chunks = [message] From 5fae356a85109cd09dd6ab7921746a173b0a5dc4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:07:14 -0700 Subject: [PATCH 066/288] fix: show full last assistant response when resuming a session (#8724) When resuming a session with --resume or -c, the last assistant response was truncated to 200 chars / 3 lines just like older messages in the recap. This forced users to waste tokens re-asking for the response. Now the last assistant message in the recap is shown in full with non-dim styling, so users can see exactly where they left off. Earlier messages remain truncated for compact display. Changes: - Track un-truncated text for the last assistant entry during collection - Replace last entry with full text after history trimming - Render last assistant entry with bold (non-dim) styling - Update existing truncation tests to use multi-message histories - Add new tests for full last response display (char + multiline) --- cli.py | 24 ++++++++++++++++- tests/cli/test_resume_display.py | 44 +++++++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/cli.py b/cli.py index 18d61ea7724a..c76ec217de38 100644 --- a/cli.py +++ b/cli.py @@ -3114,6 +3114,8 @@ def _strip_reasoning(text: str) -> str: # Collect displayable entries (skip system, tool-result messages) entries = [] # list of (role, display_text) + _last_asst_idx = None # index of last assistant entry + _last_asst_full = None # un-truncated display text for last assistant for msg in self.conversation_history: role = msg.get("role", "") content = msg.get("content") @@ -3143,7 +3145,9 @@ def _strip_reasoning(text: str) -> str: text = "" if content is None else str(content) text = _strip_reasoning(text) parts = [] + full_parts = [] # un-truncated version if text: + full_parts.append(text) lines = text.splitlines() if len(lines) > MAX_ASST_LINES: text = "\n".join(lines[:MAX_ASST_LINES]) + " ..." @@ -3163,11 +3167,15 @@ def _strip_reasoning(text: str) -> str: if len(names) > 4: names_str += ", ..." noun = "call" if tc_count == 1 else "calls" - parts.append(f"[{tc_count} tool {noun}: {names_str}]") + tc_summary = f"[{tc_count} tool {noun}: {names_str}]" + parts.append(tc_summary) + full_parts.append(tc_summary) if not parts: # Skip pure-reasoning messages that have no visible output continue entries.append(("assistant", " ".join(parts))) + _last_asst_idx = len(entries) - 1 + _last_asst_full = " ".join(full_parts) if not entries: return @@ -3178,6 +3186,13 @@ def _strip_reasoning(text: str) -> str: skipped = len(entries) - MAX_DISPLAY_EXCHANGES * 2 entries = entries[skipped:] + # Replace last assistant entry with full (un-truncated) text + # so the user can see where they left off without wasting tokens. + if _last_asst_idx is not None and _last_asst_full: + adj_idx = _last_asst_idx - skipped + if 0 <= adj_idx < len(entries): + entries[adj_idx] = ("assistant_last", _last_asst_full) + # Build the display using Rich from rich.panel import Panel from rich.text import Text @@ -3210,6 +3225,13 @@ def _strip_reasoning(text: str) -> str: lines.append(msg_lines[0] + "\n", style="dim") for ml in msg_lines[1:]: lines.append(f" {ml}\n", style="dim") + elif role == "assistant_last": + # Last assistant response shown in full, non-dim + lines.append(" ◆ Hermes: ", style=f"bold {_assistant_label_c}") + msg_lines = text.splitlines() + lines.append(msg_lines[0] + "\n", style="") + for ml in msg_lines[1:]: + lines.append(f" {ml}\n", style="") else: lines.append(" ◆ Hermes: ", style=f"dim bold {_assistant_label_c}") msg_lines = text.splitlines() diff --git a/tests/cli/test_resume_display.py b/tests/cli/test_resume_display.py index d0c156d13a61..d183e48b2bcc 100644 --- a/tests/cli/test_resume_display.py +++ b/tests/cli/test_resume_display.py @@ -180,33 +180,71 @@ def test_long_user_message_truncated(self): assert 200 <= a_count <= 310 # roughly 300 chars (±panel padding) def test_long_assistant_message_truncated(self): + """Non-last assistant messages are still truncated.""" cli = _make_cli() long_text = "B" * 400 cli.conversation_history = [ {"role": "user", "content": "Tell me a lot."}, {"role": "assistant", "content": long_text}, + {"role": "user", "content": "And more?"}, + {"role": "assistant", "content": "Short final reply."}, ] output = self._capture_display(cli) - assert "..." in output + # The non-last assistant message should be truncated assert "B" * 400 not in output + # The last assistant message shown in full + assert "Short final reply." in output def test_multiline_assistant_truncated(self): + """Non-last multiline assistant messages are truncated to 3 lines.""" cli = _make_cli() multi = "\n".join([f"Line {i}" for i in range(20)]) cli.conversation_history = [ {"role": "user", "content": "Show me lines."}, {"role": "assistant", "content": multi}, + {"role": "user", "content": "What else?"}, + {"role": "assistant", "content": "Done."}, ] output = self._capture_display(cli) - # First 3 lines should be there + # First 3 lines of non-last assistant should be there assert "Line 0" in output assert "Line 1" in output assert "Line 2" in output - # Line 19 should NOT be there (truncated after 3 lines) + # Line 19 should NOT be in the truncated message assert "Line 19" not in output + def test_last_assistant_response_shown_in_full(self): + """The last assistant response is shown un-truncated so the user + knows where they left off without wasting tokens re-asking.""" + cli = _make_cli() + long_text = "X" * 500 + cli.conversation_history = [ + {"role": "user", "content": "Tell me everything."}, + {"role": "assistant", "content": long_text}, + ] + output = self._capture_display(cli) + + # Full 500-char text should be present (may be line-wrapped by Rich) + x_count = output.count("X") + assert x_count >= 490 # allow small Rich formatting variance + + def test_last_assistant_multiline_shown_in_full(self): + """The last assistant response shows all lines, not just 3.""" + cli = _make_cli() + multi = "\n".join([f"Line {i}" for i in range(20)]) + cli.conversation_history = [ + {"role": "user", "content": "Show me everything."}, + {"role": "assistant", "content": multi}, + ] + output = self._capture_display(cli) + + # All 20 lines should be present since it's the last response + assert "Line 0" in output + assert "Line 10" in output + assert "Line 19" in output + def test_large_history_shows_truncation_indicator(self): cli = _make_cli() cli.conversation_history = _large_history(n_exchanges=15) From 15b1a3aa69da339124f6fbbfd08c2cc27c00bc2e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:20:13 -0700 Subject: [PATCH 067/288] =?UTF-8?q?fix:=20improve=20WhatsApp=20UX=20?= =?UTF-8?q?=E2=80=94=20chunking,=20formatting,=20streaming=20(#8723)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that address the poor WhatsApp experience reported by users: 1. Reclassify WhatsApp from TIER_LOW to TIER_MEDIUM in display_config.py — enables streaming and tool progress via the existing Baileys /edit bridge endpoint. Users now see progressive responses instead of minutes of silence followed by a wall of text. 2. Lower MAX_MESSAGE_LENGTH from 65536 to 4096 and add proper chunking — send() now calls format_message() and truncate_message() before sending, then loops through chunks with a small delay between them. The base class truncate_message() already handles code block boundary detection (closes/reopens fences at chunk boundaries). reply_to is only set on the first chunk. 3. Override format_message() with WhatsApp-specific markdown conversion — converts **bold** to *bold*, ~~strike~~ to ~strike~, headers to bold text, and [links](url) to text (url). Code blocks and inline code are protected from conversion via placeholder substitution. Together these fix the two user complaints: - 'sends the whole code all the time' → now chunked at 4K with proper formatting - 'terminal gets interrupted and gets cooked' → streaming + tool progress give visual feedback so users don't accidentally interrupt with follow-up messages --- gateway/display_config.py | 2 +- gateway/platforms/whatsapp.py | 129 +++++++--- tests/gateway/test_display_config.py | 6 +- tests/gateway/test_whatsapp_formatting.py | 271 ++++++++++++++++++++++ 4 files changed, 378 insertions(+), 30 deletions(-) create mode 100644 tests/gateway/test_whatsapp_formatting.py diff --git a/gateway/display_config.py b/gateway/display_config.py index e148be91035b..9375266ca681 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -82,7 +82,7 @@ # Tier 3 — no edit support, progress messages are permanent "signal": _TIER_LOW, - "whatsapp": _TIER_LOW, + "whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit "bluebubbles": _TIER_LOW, "weixin": _TIER_LOW, "wecom": _TIER_LOW, diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index c616f7244887..d1de5b856870 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -120,8 +120,9 @@ class WhatsAppAdapter(BasePlatformAdapter): - session_path: Path to store WhatsApp session data """ - # WhatsApp message limits - MAX_MESSAGE_LENGTH = 65536 # WhatsApp allows longer messages + # WhatsApp message limits — practical UX limit, not protocol max. + # WhatsApp allows ~65K but long messages are unreadable on mobile. + MAX_MESSAGE_LENGTH = 4096 # Default bridge location relative to the hermes-agent install _DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge" @@ -531,6 +532,63 @@ async def disconnect(self) -> None: self._close_bridge_log() print(f"[{self.name}] Disconnected") + def format_message(self, content: str) -> str: + """Convert standard markdown to WhatsApp-compatible formatting. + + WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```, + and monospaced `inline`. Standard markdown uses different syntax + for bold/italic/strikethrough, so we convert here. + + Code blocks (``` fenced) and inline code (`) are protected from + conversion via placeholder substitution. + """ + if not content: + return content + + # --- 1. Protect fenced code blocks from formatting changes --- + _FENCE_PH = "\x00FENCE" + fences: list[str] = [] + + def _save_fence(m: re.Match) -> str: + fences.append(m.group(0)) + return f"{_FENCE_PH}{len(fences) - 1}\x00" + + result = re.sub(r"```[\s\S]*?```", _save_fence, content) + + # --- 2. Protect inline code --- + _CODE_PH = "\x00CODE" + codes: list[str] = [] + + def _save_code(m: re.Match) -> str: + codes.append(m.group(0)) + return f"{_CODE_PH}{len(codes) - 1}\x00" + + result = re.sub(r"`[^`\n]+`", _save_code, result) + + # --- 3. Convert markdown formatting to WhatsApp syntax --- + # Bold: **text** or __text__ → *text* + result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result) + result = re.sub(r"__(.+?)__", r"*\1*", result) + # Strikethrough: ~~text~~ → ~text~ + result = re.sub(r"~~(.+?)~~", r"~\1~", result) + # Italic: *text* is already WhatsApp italic — leave as-is + # _text_ is already WhatsApp italic — leave as-is + + # --- 4. Convert markdown headers to bold text --- + # # Header → *Header* + result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE) + + # --- 5. Convert markdown links: [text](url) → text (url) --- + result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result) + + # --- 6. Restore protected sections --- + for i, fence in enumerate(fences): + result = result.replace(f"{_FENCE_PH}{i}\x00", fence) + for i, code in enumerate(codes): + result = result.replace(f"{_CODE_PH}{i}\x00", code) + + return result + async def send( self, chat_id: str, @@ -538,38 +596,57 @@ async def send( reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None ) -> SendResult: - """Send a message via the WhatsApp bridge.""" + """Send a message via the WhatsApp bridge. + + Formats markdown for WhatsApp, splits long messages into chunks + that preserve code block boundaries, and sends each chunk sequentially. + """ if not self._running or not self._http_session: return SendResult(success=False, error="Not connected") bridge_exit = await self._check_managed_bridge_exit() if bridge_exit: return SendResult(success=False, error=bridge_exit) - + + if not content or not content.strip(): + return SendResult(success=True, message_id=None) + try: import aiohttp - payload = { - "chatId": chat_id, - "message": content, - } - if reply_to: - payload["replyTo"] = reply_to - - async with self._http_session.post( - f"http://127.0.0.1:{self._bridge_port}/send", - json=payload, - timeout=aiohttp.ClientTimeout(total=30) - ) as resp: - if resp.status == 200: - data = await resp.json() - return SendResult( - success=True, - message_id=data.get("messageId"), - raw_response=data - ) - else: - error = await resp.text() - return SendResult(success=False, error=error) + # Format and chunk the message + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + + last_message_id = None + for chunk in chunks: + payload: Dict[str, Any] = { + "chatId": chat_id, + "message": chunk, + } + if reply_to and last_message_id is None: + # Only reply-to on the first chunk + payload["replyTo"] = reply_to + + async with self._http_session.post( + f"http://127.0.0.1:{self._bridge_port}/send", + json=payload, + timeout=aiohttp.ClientTimeout(total=30) + ) as resp: + if resp.status == 200: + data = await resp.json() + last_message_id = data.get("messageId") + else: + error = await resp.text() + return SendResult(success=False, error=error) + + # Small delay between chunks to avoid rate limiting + if len(chunks) > 1: + await asyncio.sleep(0.3) + + return SendResult( + success=True, + message_id=last_message_id, + ) except Exception as e: return SendResult(success=False, error=str(e)) diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py index 4dd73ebd28bb..c9ad51280968 100644 --- a/tests/gateway/test_display_config.py +++ b/tests/gateway/test_display_config.py @@ -189,14 +189,14 @@ def test_medium_tier_platforms(self): """Slack, Mattermost, Matrix default to 'new' tool progress.""" from gateway.display_config import resolve_display_setting - for plat in ("slack", "mattermost", "matrix", "feishu"): + for plat in ("slack", "mattermost", "matrix", "feishu", "whatsapp"): assert resolve_display_setting({}, plat, "tool_progress") == "new", plat def test_low_tier_platforms(self): - """Signal, WhatsApp, etc. default to 'off' tool progress.""" + """Signal, BlueBubbles, etc. default to 'off' tool progress.""" from gateway.display_config import resolve_display_setting - for plat in ("signal", "whatsapp", "bluebubbles", "weixin", "wecom", "dingtalk"): + for plat in ("signal", "bluebubbles", "weixin", "wecom", "dingtalk"): assert resolve_display_setting({}, plat, "tool_progress") == "off", plat def test_minimal_tier_platforms(self): diff --git a/tests/gateway/test_whatsapp_formatting.py b/tests/gateway/test_whatsapp_formatting.py new file mode 100644 index 000000000000..129384783538 --- /dev/null +++ b/tests/gateway/test_whatsapp_formatting.py @@ -0,0 +1,271 @@ +"""Tests for WhatsApp message formatting and chunking. + +Covers: +- format_message(): markdown → WhatsApp syntax conversion +- send(): message chunking for long responses +- MAX_MESSAGE_LENGTH: practical UX limit +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import Platform, PlatformConfig + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_adapter(): + """Create a WhatsAppAdapter with test attributes (bypass __init__).""" + from gateway.platforms.whatsapp import WhatsAppAdapter + + adapter = WhatsAppAdapter.__new__(WhatsAppAdapter) + adapter.platform = Platform.WHATSAPP + adapter.config = MagicMock() + adapter.config.extra = {} + adapter._bridge_port = 3000 + adapter._bridge_script = "/tmp/test-bridge.js" + adapter._session_path = MagicMock() + adapter._bridge_log_fh = None + adapter._bridge_log = None + adapter._bridge_process = None + adapter._reply_prefix = None + adapter._running = True + adapter._message_handler = None + adapter._fatal_error_code = None + adapter._fatal_error_message = None + adapter._fatal_error_retryable = True + adapter._fatal_error_handler = None + adapter._active_sessions = {} + adapter._pending_messages = {} + adapter._background_tasks = set() + adapter._auto_tts_disabled_chats = set() + adapter._message_queue = asyncio.Queue() + adapter._http_session = MagicMock() + adapter._mention_patterns = [] + return adapter + + +class _AsyncCM: + """Minimal async context manager returning a fixed value.""" + + def __init__(self, value): + self.value = value + + async def __aenter__(self): + return self.value + + async def __aexit__(self, *exc): + return False + + +# --------------------------------------------------------------------------- +# format_message tests +# --------------------------------------------------------------------------- + +class TestFormatMessage: + """WhatsApp markdown conversion.""" + + def test_bold_double_asterisk(self): + adapter = _make_adapter() + assert adapter.format_message("**hello**") == "*hello*" + + def test_bold_double_underscore(self): + adapter = _make_adapter() + assert adapter.format_message("__hello__") == "*hello*" + + def test_strikethrough(self): + adapter = _make_adapter() + assert adapter.format_message("~~deleted~~") == "~deleted~" + + def test_headers_converted_to_bold(self): + adapter = _make_adapter() + assert adapter.format_message("# Title") == "*Title*" + assert adapter.format_message("## Subtitle") == "*Subtitle*" + assert adapter.format_message("### Deep") == "*Deep*" + + def test_links_converted(self): + adapter = _make_adapter() + result = adapter.format_message("[click here](https://example.com)") + assert result == "click here (https://example.com)" + + def test_code_blocks_protected(self): + """Code blocks should not have their content reformatted.""" + adapter = _make_adapter() + content = "before **bold** ```python\n**not bold**\n``` after **bold**" + result = adapter.format_message(content) + assert "```python\n**not bold**\n```" in result + assert result.startswith("before *bold*") + assert result.endswith("after *bold*") + + def test_inline_code_protected(self): + """Inline code should not have its content reformatted.""" + adapter = _make_adapter() + content = "use `**raw**` here" + result = adapter.format_message(content) + assert "`**raw**`" in result + assert result.startswith("use ") + + def test_empty_content(self): + adapter = _make_adapter() + assert adapter.format_message("") == "" + assert adapter.format_message(None) is None + + def test_plain_text_unchanged(self): + adapter = _make_adapter() + assert adapter.format_message("hello world") == "hello world" + + def test_already_whatsapp_italic(self): + """Single *italic* should pass through unchanged.""" + adapter = _make_adapter() + # After bold conversion, *text* is WhatsApp italic + assert adapter.format_message("*italic*") == "*italic*" + + def test_multiline_mixed(self): + adapter = _make_adapter() + content = "# Header\n\n**Bold text** and ~~strike~~\n\n```\ncode\n```" + result = adapter.format_message(content) + assert "*Header*" in result + assert "*Bold text*" in result + assert "~strike~" in result + assert "```\ncode\n```" in result + + +# --------------------------------------------------------------------------- +# MAX_MESSAGE_LENGTH tests +# --------------------------------------------------------------------------- + +class TestMessageLimits: + """WhatsApp message length limits.""" + + def test_max_message_length_is_practical(self): + from gateway.platforms.whatsapp import WhatsAppAdapter + assert WhatsAppAdapter.MAX_MESSAGE_LENGTH == 4096 + + +# --------------------------------------------------------------------------- +# send() chunking tests +# --------------------------------------------------------------------------- + +class TestSendChunking: + """WhatsApp send() splits long messages into chunks.""" + + @pytest.mark.asyncio + async def test_short_message_single_send(self): + adapter = _make_adapter() + resp = MagicMock(status=200) + resp.json = AsyncMock(return_value={"messageId": "msg1"}) + adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp)) + + result = await adapter.send("chat1", "short message") + assert result.success + # Only one call to bridge /send + assert adapter._http_session.post.call_count == 1 + + @pytest.mark.asyncio + async def test_long_message_chunked(self): + adapter = _make_adapter() + resp = MagicMock(status=200) + resp.json = AsyncMock(return_value={"messageId": "msg1"}) + adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp)) + + # Create a message longer than MAX_MESSAGE_LENGTH (4096) + long_msg = "a " * 3000 # ~6000 chars + + result = await adapter.send("chat1", long_msg) + assert result.success + # Should have made multiple calls + assert adapter._http_session.post.call_count > 1 + + @pytest.mark.asyncio + async def test_empty_message_no_send(self): + adapter = _make_adapter() + result = await adapter.send("chat1", "") + assert result.success + assert adapter._http_session.post.call_count == 0 + + @pytest.mark.asyncio + async def test_whitespace_only_no_send(self): + adapter = _make_adapter() + result = await adapter.send("chat1", " \n ") + assert result.success + assert adapter._http_session.post.call_count == 0 + + @pytest.mark.asyncio + async def test_format_applied_before_send(self): + """Markdown should be converted to WhatsApp format before sending.""" + adapter = _make_adapter() + resp = MagicMock(status=200) + resp.json = AsyncMock(return_value={"messageId": "msg1"}) + adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp)) + + await adapter.send("chat1", "**bold text**") + + # Check the payload sent to the bridge + call_args = adapter._http_session.post.call_args + payload = call_args.kwargs.get("json") or call_args[1].get("json") + assert payload["message"] == "*bold text*" + + @pytest.mark.asyncio + async def test_reply_to_only_on_first_chunk(self): + """reply_to should only be set on the first chunk.""" + adapter = _make_adapter() + resp = MagicMock(status=200) + resp.json = AsyncMock(return_value={"messageId": "msg1"}) + adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp)) + + long_msg = "word " * 2000 # ~10000 chars, multiple chunks + + await adapter.send("chat1", long_msg, reply_to="orig123") + + calls = adapter._http_session.post.call_args_list + assert len(calls) > 1 + + # First chunk should have replyTo + first_payload = calls[0].kwargs.get("json") or calls[0][1].get("json") + assert first_payload.get("replyTo") == "orig123" + + # Subsequent chunks should NOT have replyTo + for call in calls[1:]: + payload = call.kwargs.get("json") or call[1].get("json") + assert "replyTo" not in payload + + @pytest.mark.asyncio + async def test_bridge_error_returns_failure(self): + adapter = _make_adapter() + resp = MagicMock(status=500) + resp.text = AsyncMock(return_value="Internal Server Error") + adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp)) + + result = await adapter.send("chat1", "hello") + assert not result.success + assert "Internal Server Error" in result.error + + @pytest.mark.asyncio + async def test_not_connected_returns_failure(self): + adapter = _make_adapter() + adapter._running = False + + result = await adapter.send("chat1", "hello") + assert not result.success + assert "Not connected" in result.error + + +# --------------------------------------------------------------------------- +# display_config tier classification +# --------------------------------------------------------------------------- + +class TestWhatsAppTier: + """WhatsApp should be classified as TIER_MEDIUM.""" + + def test_whatsapp_streaming_follows_global(self): + from gateway.display_config import resolve_display_setting + # TIER_MEDIUM has streaming: None (follow global), not False + assert resolve_display_setting({}, "whatsapp", "streaming") is None + + def test_whatsapp_tool_progress_is_new(self): + from gateway.display_config import resolve_display_setting + assert resolve_display_setting({}, "whatsapp", "tool_progress") == "new" From 3636f64540a3d80c8425f195f46e53e940956cba Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:38:20 -0700 Subject: [PATCH 068/288] fix: resolve npm audit vulnerabilities in browser tools and whatsapp bridge (#8745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(telegram): use UTF-16 code units for message length splitting Port from nearai/ironclaw#2304: Telegram's 4096 character limit is measured in UTF-16 code units, not Unicode codepoints. Characters outside the Basic Multilingual Plane (emoji like 😀, CJK Extension B, musical symbols) are surrogate pairs: 1 Python char but 2 UTF-16 units. Previously, truncate_message() used Python's len() which counts codepoints. This could produce chunks exceeding Telegram's actual limit when messages contain many astral-plane characters. Changes: - Add utf16_len() helper and _prefix_within_utf16_limit() for UTF-16-aware string measurement and truncation - Add _custom_unit_to_cp() binary-search helper that maps a custom-unit budget to the largest safe codepoint slice position - Update truncate_message() to accept optional len_fn parameter - Telegram adapter now passes len_fn=utf16_len when splitting messages - Fix fallback truncation in Telegram error handler to use _prefix_within_utf16_limit instead of codepoint slicing - Update send_message_tool.py to use utf16_len for Telegram platform - Add comprehensive tests: utf16_len, _prefix_within_utf16_limit, truncate_message with len_fn (emoji splitting, content preservation, code block handling) - Update mock lambdas in reply_mode tests to accept **kw for len_fn * fix: resolve npm audit vulnerabilities in browser tools and whatsapp bridge Browser tools (agent-browser): - Override lodash to 4.18.1 (fixes prototype pollution CVEs in transitive dep via node-simctl → @appium/logger). Not reachable in Hermes's code path but cleans the audit report. - basic-ftp and brace-expansion updated via npm audit fix. WhatsApp bridge: - file-type updated (fixes infinite loop in ASF parser + ZIP bomb DoS) - music-metadata updated (fixes infinite loop in ASF parser) - path-to-regexp updated (fixes ReDoS, mitigated by localhost binding) Both components now report 0 npm vulnerabilities. Ref: https://gist.github.com/jacklevin74/b41b710d3e20ba78fb7e2d42e2b83819 --- package-lock.json | 3860 ++++++++++++++++++--- package.json | 3 + scripts/whatsapp-bridge/package-lock.json | 34 +- 3 files changed, 3390 insertions(+), 507 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e54db9aa55d..de94d14675bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@askjo/camoufox-browser": "^1.0.0", "agent-browser": "^0.13.0" }, "engines": { @@ -32,11 +33,25 @@ "npm": ">=8" } }, - "node_modules/@appium/logger/node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" + "node_modules/@askjo/camoufox-browser": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@askjo/camoufox-browser/-/camoufox-browser-1.0.12.tgz", + "integrity": "sha512-MxRvjK6SkX6zJSNleoO32g9iwhJAcXpaAgj4pik7y2SrYXqcHllpG7FfLkKE7d5bnBt7pO82rdarVYu6xtW2RA==", + "deprecated": "Renamed to @askjo/camofox-browser", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "camoufox-js": "^0.8.5", + "dotenv": "^17.2.3", + "express": "^4.18.2", + "playwright": "^1.50.0", + "playwright-core": "^1.58.0", + "playwright-extra": "^4.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2" + }, + "engines": { + "node": ">=18" + } }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -55,6 +70,58 @@ "node": ">=12" } }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -105,16 +172,92 @@ "node": ">=18" } }, + "node_modules/@puppeteer/browsers/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@puppeteer/browsers/node_modules/tar-fs": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/@puppeteer/browsers/node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { - "version": "20.19.33", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", - "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -152,14 +295,14 @@ } }, "node_modules/@wdio/config": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.24.0.tgz", - "integrity": "sha512-rcHu0eG16rSEmHL0sEKDcr/vYFmGhQ5GOlmlx54r+1sgh6sf136q+kth4169s16XqviWGW3LjZbUfpTK29pGtw==", + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz", + "integrity": "sha512-9y8z7ugIbU6ycKrA2SqCpKh1/hobut2rDq9CLt/BNVzSlebBBVOTMiAt1XroZzcPnA7/ZqpbkpOsbpPUaAQuNQ==", "license": "MIT", "dependencies": { "@wdio/logger": "9.18.0", - "@wdio/types": "9.24.0", - "@wdio/utils": "9.24.0", + "@wdio/types": "9.27.0", + "@wdio/utils": "9.27.0", "deepmerge-ts": "^7.0.3", "glob": "^10.2.2", "import-meta-resolve": "^4.0.0", @@ -169,6 +312,73 @@ "node": ">=18.20.0" } }, + "node_modules/@wdio/config/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/@wdio/config/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@wdio/config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@wdio/config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@wdio/config/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@wdio/logger": { "version": "9.18.0", "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-9.18.0.tgz", @@ -186,9 +396,9 @@ } }, "node_modules/@wdio/protocols": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.24.0.tgz", - "integrity": "sha512-ozQKYddBLT4TRvU9J+fGrhVUtx3iDAe+KNCJcTDMFMxNSdDMR2xFQdNp8HLHypspk58oXTYCvz6ZYjySthhqsw==", + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.27.0.tgz", + "integrity": "sha512-rIk69BsY1+6uU2PEN5FiRpI6K7HJ86YHzZRFBe4iRzKXQgGNk1zWzbdVJIuNFoOWsnmYUkK42KSSOT4Le6EmiQ==", "license": "MIT" }, "node_modules/@wdio/repl": { @@ -204,9 +414,9 @@ } }, "node_modules/@wdio/types": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.24.0.tgz", - "integrity": "sha512-PYYunNl8Uq1r8YMJAK6ReRy/V/XIrCSyj5cpCtR5EqCL6heETOORFj7gt4uPnzidfgbtMBcCru0LgjjlMiH1UQ==", + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.27.0.tgz", + "integrity": "sha512-DQJ+OdRBqUBcQ30DN2Z651hEVh3OoxnlDUSRqlWy9An2AY6v9rYWTj825B6zsj5pLLEToYO1tfwWq0ab183pXg==", "license": "MIT", "dependencies": { "@types/node": "^20.1.0" @@ -216,14 +426,14 @@ } }, "node_modules/@wdio/utils": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.24.0.tgz", - "integrity": "sha512-6WhtzC5SNCGRBTkaObX6A07Ofnnyyf+TQH/d/fuhZRqvBknrP4AMMZF+PFxGl1fwdySWdBn+gV2QLE+52Byowg==", + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.27.0.tgz", + "integrity": "sha512-fUasd5OKJTy2seJfWnYZ9xlxTtY0p/Kyeuh7Tbb8kcofBqmBi2fTvM3sfZlo1tGQX9yCh+IS2N7hlfyFMmuZ+w==", "license": "MIT", "dependencies": { "@puppeteer/browsers": "^2.2.0", "@wdio/logger": "9.18.0", - "@wdio/types": "9.24.0", + "@wdio/types": "9.27.0", "decamelize": "^6.0.0", "deepmerge-ts": "^7.0.3", "edgedriver": "^6.1.2", @@ -241,9 +451,9 @@ } }, "node_modules/@zip.js/zip.js": { - "version": "2.8.21", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.21.tgz", - "integrity": "sha512-fkyzXISE3IMrstDO1AgPkJCx14MYHP/suIGiAovEYEuBjq3mffsuL6aMV7ohOSjW4rXtuACuUfpA3GtITgdtYg==", + "version": "2.8.26", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", + "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", "license": "BSD-3-Clause", "engines": { "bun": ">=0.7.0", @@ -263,6 +473,28 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -302,12 +534,15 @@ } }, "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=12" + "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -349,55 +584,229 @@ "node": ">= 14" } }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } + "node_modules/archiver-utils/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" }, - "node_modules/ast-types": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", - "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" + "balanced-match": "^1.0.0" } }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/asyncbox": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-3.0.0.tgz", - "integrity": "sha512-X7U0nedUMKV3nn9c4R0Zgvdvv6cw97tbDlHSZicq1snGPi/oX9DgGmFSURWtxDdnBWd3V0YviKhqAYAVvoWQ/A==", - "license": "Apache-2.0", + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", "dependencies": { - "bluebird": "^3.5.1", - "lodash": "^4.17.4", - "source-map-support": "^0.x" - }, - "engines": { - "node": ">=16" + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, - "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asyncbox": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-3.0.0.tgz", + "integrity": "sha512-X7U0nedUMKV3nn9c4R0Zgvdvv6cw97tbDlHSZicq1snGPi/oX9DgGmFSURWtxDdnBWd3V0YviKhqAYAVvoWQ/A==", + "license": "Apache-2.0", + "dependencies": { + "bluebird": "^3.5.1", + "lodash": "^4.17.4", + "source-map-support": "^0.x" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, "peerDependenciesMeta": { "react-native-b4a": { "optional": true @@ -405,10 +814,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bare-events": { "version": "2.8.2", @@ -425,11 +837,10 @@ } }, "node_modules/bare-fs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", - "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz", + "integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -450,11 +861,10 @@ } }, "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", + "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } @@ -464,26 +874,28 @@ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", - "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", "license": "Apache-2.0", - "optional": true, "dependencies": { - "streamx": "^2.21.0", + "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { + "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, "bare-buffer": { "optional": true }, @@ -493,11 +905,10 @@ } }, "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-path": "^3.0.0" } @@ -522,21 +933,91 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", + "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.2.tgz", + "integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==", "license": "MIT", "engines": { "node": ">=10.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -544,18 +1025,54 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "funding": [ { "type": "github", @@ -573,7 +1090,7 @@ "license": "MIT", "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "ieee754": "^1.1.13" } }, "node_modules/buffer-crc32": { @@ -591,26 +1108,121 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">= 0.8" } }, - "node_modules/cheerio": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", - "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camoufox-js": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/camoufox-js/-/camoufox-js-0.8.5.tgz", + "integrity": "sha512-20ihPbspAcOVSUTX9Drxxp0C116DON1n8OVA1eUDglWZiHwiHwFVFOMrIEBwAHMZpU11mIEH/kawJtstRIrDPA==", + "license": "MPL-2.0", + "dependencies": { + "adm-zip": "^0.5.16", + "better-sqlite3": "^12.2.0", + "commander": "^14.0.0", + "fingerprint-generator": "^2.1.66", + "glob": "^13.0.0", + "impit": "^0.7.0", + "language-tags": "^2.0.1", + "maxmind": "^5.0.0", + "progress": "^2.0.3", + "ua-parser-js": "^2.0.2", + "xml2js": "^0.6.2" + }, + "bin": { + "camoufox-js": "dist/__main__.js" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "playwright-core": "*" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", @@ -645,6 +1257,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -668,41 +1286,6 @@ "node": ">=8" } }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cliui/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -715,21 +1298,20 @@ "node": ">=8" } }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=0.10.0" } }, "node_modules/color-convert": { @@ -751,12 +1333,12 @@ "license": "MIT" }, "node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { - "node": "^12.20.0 || >=14" + "node": ">=20" } }, "node_modules/compress-commons": { @@ -775,12 +1357,94 @@ "node": ">= 14" } }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "license": "ISC" }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -812,6 +1476,46 @@ "node": ">= 14" } }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -896,20 +1600,12 @@ } }, "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "ms": "2.0.0" } }, "node_modules/decamelize": { @@ -924,6 +1620,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -947,24 +1676,72 @@ "node": ">= 14" } }, - "node_modules/dom-serializer": { + "node_modules/depd": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "engines": { + "node": ">= 0.8" } }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "funding": [ { "type": "github", @@ -1002,6 +1779,47 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1092,12 +1910,33 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "license": "ISC" + }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -1111,6 +1950,18 @@ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1132,6 +1983,36 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1141,6 +2022,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -1193,6 +2080,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -1220,6 +2116,61 @@ "bare-events": "^2.7.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -1240,6 +2191,29 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/extract-zip/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/extract-zip/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", @@ -1268,9 +2242,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.9", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", - "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", + "version": "5.5.11", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.11.tgz", + "integrity": "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA==", "funding": [ { "type": "github", @@ -1280,8 +2254,8 @@ "license": "MIT", "dependencies": { "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.2" + "path-expression-matcher": "^1.4.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -1296,6 +2270,65 @@ "pend": "~1.2.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fingerprint-generator": { + "version": "2.1.82", + "resolved": "https://registry.npmjs.org/fingerprint-generator/-/fingerprint-generator-2.1.82.tgz", + "integrity": "sha512-5Z/yCKW324pMyMarpIKe/QPdkrFWKNJv3ktdU+fXHri80+HAwNE6QhMvEvsMkK9Q8DeCXZlpPHV77UBa1nFb4A==", + "license": "Apache-2.0", + "dependencies": { + "generative-bayesian-network": "^2.1.82", + "header-generator": "^2.1.82", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -1312,6 +2345,73 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/geckodriver": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.0.tgz", @@ -1333,6 +2433,16 @@ "node": ">=20.0.0" } }, + "node_modules/generative-bayesian-network": { + "version": "2.1.82", + "resolved": "https://registry.npmjs.org/generative-bayesian-network/-/generative-bayesian-network-2.1.82.tgz", + "integrity": "sha512-DH4NrmQheoMaJErdVv2IzaqkbOYSDQZmiZTV6UPDJYRDK2EyPpIQ88XRcYdPeFrUjS1N0Jj25H3HUywoJ1dbow==", + "license": "Apache-2.0", + "dependencies": { + "adm-zip": "^0.5.9", + "tslib": "^2.4.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1342,10 +2452,34 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-port": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", - "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", "license": "MIT", "engines": { "node": ">=16" @@ -1354,6 +2488,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -1383,27 +2530,64 @@ "node": ">= 14" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", + "node_modules/get-uri/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "ms": "^2.1.3" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/get-uri/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1425,6 +2609,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/header-generator": { + "version": "2.1.82", + "resolved": "https://registry.npmjs.org/header-generator/-/header-generator-2.1.82.tgz", + "integrity": "sha512-4NjPB0+bAKjPoponSmTOkK58IEF2W22sOJA5O48k/MxbCZgOm+jrU4WVR53Z2I6xFgIPkVrQmKtt1LAbWtfqXw==", + "license": "Apache-2.0", + "dependencies": { + "browserslist": "^4.21.1", + "generative-bayesian-network": "^2.1.82", + "ow": "^0.28.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/htmlfy": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/htmlfy/-/htmlfy-0.8.1.tgz", @@ -1462,6 +2685,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1475,6 +2718,29 @@ "node": ">= 14" } }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -1488,13 +2754,36 @@ "node": ">= 14" } }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "safer-buffer": ">= 2.1.2 < 3" }, "engines": { "node": ">=0.10.0" @@ -1526,6 +2815,153 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/impit": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit/-/impit-0.7.6.tgz", + "integrity": "sha512-AkS6Gv63+E6GMvBrcRhMmOREKpq5oJ0J5m3xwfkHiEs97UIsbpEqFmW3sFw/sdyOTDGRF5q4EjaLxtb922Ta8g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "impit-darwin-arm64": "0.7.6", + "impit-darwin-x64": "0.7.6", + "impit-linux-arm64-gnu": "0.7.6", + "impit-linux-arm64-musl": "0.7.6", + "impit-linux-x64-gnu": "0.7.6", + "impit-linux-x64-musl": "0.7.6", + "impit-win32-arm64-msvc": "0.7.6", + "impit-win32-x64-msvc": "0.7.6" + } + }, + "node_modules/impit-darwin-arm64": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-darwin-arm64/-/impit-darwin-arm64-0.7.6.tgz", + "integrity": "sha512-M7NQXkttyzqilWfzVkNCp7hApT69m0etyJkVpHze4bR5z1kJnHhdsb8BSdDv2dzvZL4u1JyqZNxq+qoMn84eUw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-darwin-x64": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-darwin-x64/-/impit-darwin-x64-0.7.6.tgz", + "integrity": "sha512-kikTesWirAwJp9JPxzGLoGVc+heBlEabWS5AhTkQedACU153vmuL90OBQikVr3ul2N0LPImvnuB+51wV0zDE6g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-gnu": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-gnu/-/impit-linux-arm64-gnu-0.7.6.tgz", + "integrity": "sha512-H6GHjVr/0lG9VEJr6IHF8YLq+YkSIOF4k7Dfue2ygzUAj1+jZ5ZwnouhG/XrZHYW6EWsZmEAjjRfWE56Q0wDRQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-musl": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-musl/-/impit-linux-arm64-musl-0.7.6.tgz", + "integrity": "sha512-1sCB/UBVXLZTpGJsXRdNNSvhN9xmmQcYLMWAAB4Itb7w684RHX1pLoCb6ichv7bfAf6tgaupcFIFZNBp3ghmQA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-gnu": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-x64-gnu/-/impit-linux-x64-gnu-0.7.6.tgz", + "integrity": "sha512-yYhlRnZ4fhKt8kuGe0JK2WSHc8TkR6BEH0wn+guevmu8EOn9Xu43OuRvkeOyVAkRqvFnlZtMyySUo/GuSLz9Gw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-musl": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-x64-musl/-/impit-linux-x64-musl-0.7.6.tgz", + "integrity": "sha512-sdGWyu+PCLmaOXy7Mzo4WP61ZLl5qpZ1L+VeXW+Ycazgu0e7ox0NZLdiLRunIrEzD+h0S+e4CyzNwaiP3yIolg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-arm64-msvc": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-win32-arm64-msvc/-/impit-win32-arm64-msvc-0.7.6.tgz", + "integrity": "sha512-sM5deBqo0EuXg5GACBUMKEua9jIau/i34bwNlfrf/Amnw1n0GB4/RkuUh+sKiUcbNAntrRq+YhCq8qDP8IW19w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-x64-msvc": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-win32-x64-msvc/-/impit-win32-x64-msvc-0.7.6.tgz", + "integrity": "sha512-ry63ADGLCB/PU/vNB1VioRt2V+klDJ34frJUXUZBEv1kA96HEAg9AxUk+604o+UHS3ttGH2rkLmrbwHOdAct5Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -1536,12 +2972,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -1551,6 +3004,30 @@ "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1560,6 +3037,15 @@ "node": ">=8" } }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -1572,6 +3058,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -1599,6 +3117,15 @@ "node": ">=18" } }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -1623,6 +3150,18 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -1665,6 +3204,45 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-2.1.0.tgz", + "integrity": "sha512-D4CgpyCt+61f6z2jHjJS1OmZPviAWM57iJ9OKdFFWSNgS7Udj9QVWqyGs/cveVNF57XpZmhSvMdVIV5mjLA7Vg==", + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -1738,9 +3316,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.clonedeep": { @@ -1749,6 +3327,13 @@ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.zip": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", @@ -1780,21 +3365,139 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/maxmind": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.6.tgz", + "integrity": "sha512-5bvd/u+kIaTqaGM+xkXjatzQw1dQfSmlLggr2W1EKMyMxSgx2woZyusLpNpZ4DdPmL+1bbJWeo4LXsi6bC0Iew==", + "license": "MIT", + "dependencies": { + "mmdb-lib": "3.0.2", + "tiny-lru": "13.0.0" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -1810,30 +3513,101 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "license": "MIT", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mmdb-lib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz", + "integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==", + "license": "MIT", + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/modern-tar": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.4.tgz", - "integrity": "sha512-5ixBi7pY+H8z3MKExsipXPq6S/Q27KpSY0K+NnIyLQLr58mNeZVhT9TkYcqa74H52DabOyrmGLhT5D7TZ/x26Q==", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", "license": "MIT", "engines": { "node": ">=18.0.0" } }, "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/netmask": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", - "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", "license": "MIT", "engines": { "node": ">= 0.4.0" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "license": "MIT" + }, "node_modules/node-simctl": { "version": "7.7.5", "resolved": "https://registry.npmjs.org/node-simctl/-/node-simctl-7.7.5.tgz", @@ -1877,6 +3651,30 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1886,6 +3684,25 @@ "wrappy": "1" } }, + "node_modules/ow": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/ow/-/ow-0.28.2.tgz", + "integrity": "sha512-dD4UpyBh/9m4X2NVjA+73/ZPBRF+uF4zIMFvvQsabMiEK8x41L3rQ8EENOi35kyyoaJwNxEeJcP6Fj1H4U409Q==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.2.0", + "callsites": "^3.1.0", + "dot-prop": "^6.0.1", + "lodash.isequal": "^4.5.0", + "vali-date": "^1.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -1905,6 +3722,29 @@ "node": ">= 14" } }, + "node_modules/pac-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pac-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/pac-resolver": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", @@ -1979,10 +3819,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-expression-matcher": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", - "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "funding": [ { "type": "github", @@ -1990,119 +3839,555 @@ } ], "license": "MIT", - "engines": { - "node": ">=14.0.0" + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright-extra": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/playwright-extra/-/playwright-extra-4.3.6.tgz", + "integrity": "sha512-q2rVtcE8V8K3vPVF1zny4pvwZveHLH8KBuVU2MoE3Jw4OKVoBWsHI9CH9zPydovHHOCDxjGN2Vg+2m644q3ijA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "playwright": "*", + "playwright-core": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "playwright-core": { + "optional": true + } + } + }, + "node_modules/playwright-extra/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/playwright-extra/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, "engines": { - "node": ">=8" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/playwright-core": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", - "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", - "license": "Apache-2.0", + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, "bin": { - "playwright-core": "cli.js" + "rimraf": "bin.js" }, - "engines": { - "node": ">=18" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" + }, "engines": { - "node": ">= 0.6.0" + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "node_modules/puppeteer-extra-plugin-user-preferences/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, "engines": { - "node": ">=0.4.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", - "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "node_modules/puppeteer-extra-plugin-user-preferences/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" + "ms": "^2.1.3" }, "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "license": "ISC", - "engines": { - "node": ">=12" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "node_modules/puppeteer-extra-plugin/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/query-selector-shadow-dom": { @@ -2111,20 +4396,57 @@ "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 6" } }, "node_modules/readdir-glob": { @@ -2136,6 +4458,21 @@ "minimatch": "^5.1.0" } }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/readdir-glob/node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -2196,6 +4533,73 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safaridriver": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/safaridriver/-/safaridriver-1.0.1.tgz", @@ -2226,9 +4630,9 @@ "license": "MIT" }, "node_modules/safe-regex2": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", - "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", + "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==", "funding": [ { "type": "github", @@ -2242,6 +4646,9 @@ "license": "MIT", "dependencies": { "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" } }, "node_modules/safer-buffer": { @@ -2250,6 +4657,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2262,6 +4678,36 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/serialize-error": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-12.0.0.tgz", @@ -2289,6 +4735,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -2301,6 +4762,48 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2334,6 +4837,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2346,6 +4921,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -2384,6 +5004,29 @@ "node": ">= 14" } }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -2428,10 +5071,19 @@ "node": ">= 10.x" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", "license": "MIT", "dependencies": { "events-universal": "^1.0.0", @@ -2449,20 +5101,17 @@ } }, "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/string-width-cjs": { @@ -2489,12 +5138,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2507,13 +5150,34 @@ "node": ">=8" } }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -2544,10 +5208,19 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strnum": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", - "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "funding": [ { "type": "github", @@ -2569,28 +5242,31 @@ } }, "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" + "tar-stream": "^2.1.4" } }, "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "license": "MIT", "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" } }, "node_modules/teen_process": { @@ -2614,7 +5290,6 @@ "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", "license": "MIT", - "optional": true, "dependencies": { "streamx": "^2.12.5" } @@ -2628,28 +5303,122 @@ "b4a": "^1.6.4" } }, + "node_modules/tiny-lru": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-13.0.0.tgz", + "integrity": "sha512-xDHxKKS1FdF0Tv2P+QT7IeSEg74K/8cEDzbv3Tv6UyHHUgBOjOiQiBp818MGj66dhurQus/IBcoAbwIKtSGc6Q==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/type-fest": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.0.tgz", - "integrity": "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.0.tgz", + "integrity": "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.9.tgz", + "integrity": "sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "AGPL-3.0-or-later", + "dependencies": { + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "ua-is-frozen": "^0.1.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" } }, "node_modules/undici": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", - "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -2661,6 +5430,54 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/urlpattern-polyfill": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", @@ -2682,6 +5499,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -2695,6 +5521,24 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/vali-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", + "integrity": "sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/wait-port": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz", @@ -2712,21 +5556,6 @@ "node": ">=10" } }, - "node_modules/wait-port/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wait-port/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2743,19 +5572,51 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/wait-port/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/wait-port/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/wait-port/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/webdriver": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.24.0.tgz", - "integrity": "sha512-2R31Ey83NzMsafkl4hdFq6GlIBvOODQMkueLjeRqYAITu3QCYiq9oqBdnWA6CdePuV4dbKlYsKRX0mwMiPclDA==", + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.27.0.tgz", + "integrity": "sha512-w07ThZND48SIr0b4S7eFougYUyclmoUwdmju8yXvEJiXYjDjeYUpl8wZrYPEYRBylxpSx+sBHfEUBrPQkcTTRQ==", "license": "MIT", "dependencies": { "@types/node": "^20.1.0", "@types/ws": "^8.5.3", - "@wdio/config": "9.24.0", + "@wdio/config": "9.27.0", "@wdio/logger": "9.18.0", - "@wdio/protocols": "9.24.0", - "@wdio/types": "9.24.0", - "@wdio/utils": "9.24.0", + "@wdio/protocols": "9.27.0", + "@wdio/types": "9.27.0", + "@wdio/utils": "9.27.0", "deepmerge-ts": "^7.0.3", "https-proxy-agent": "^7.0.6", "undici": "^6.21.3", @@ -2775,19 +5636,19 @@ } }, "node_modules/webdriverio": { - "version": "9.24.0", - "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.24.0.tgz", - "integrity": "sha512-LTJt6Z/iDM0ne/4ytd3BykoPv9CuJ+CAILOzlwFeMGn4Mj02i4Bk2Rg9o/jeJ89f52hnv4OPmNjD0e8nzWAy5g==", + "version": "9.27.0", + "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.27.0.tgz", + "integrity": "sha512-Y4FbMf4bKBXpPB0lYpglzQ2GfDDe6uojmMZl85uPyrDx18NW7mqN84ZawGoIg/FRvcLaVhcOzc98WOPo725Rag==", "license": "MIT", "dependencies": { "@types/node": "^20.11.30", "@types/sinonjs__fake-timers": "^8.1.5", - "@wdio/config": "9.24.0", + "@wdio/config": "9.27.0", "@wdio/logger": "9.18.0", - "@wdio/protocols": "9.24.0", + "@wdio/protocols": "9.27.0", "@wdio/repl": "9.16.2", - "@wdio/types": "9.24.0", - "@wdio/utils": "9.24.0", + "@wdio/types": "9.27.0", + "@wdio/utils": "9.27.0", "archiver": "^7.0.1", "aria-query": "^5.3.0", "cheerio": "^1.0.0-rc.12", @@ -2804,7 +5665,7 @@ "rgb2hex": "0.2.5", "serialize-error": "^12.0.0", "urlpattern-polyfill": "^10.0.0", - "webdriver": "9.24.0" + "webdriver": "9.27.0" }, "engines": { "node": ">=18.20.0" @@ -2831,6 +5692,18 @@ "node": ">=18" } }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -2856,17 +5729,17 @@ } }, "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -2899,42 +5772,28 @@ "node": ">=8" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, "engines": { "node": ">=8" } }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "node_modules/wrap-ansi/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -2953,9 +5812,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2973,6 +5832,28 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -3009,47 +5890,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", @@ -3083,6 +5923,46 @@ "node": ">= 14" } }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 309217c8220d..8d738c36e305 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "agent-browser": "^0.13.0", "@askjo/camoufox-browser": "^1.0.0" }, + "overrides": { + "lodash": "4.18.1" + }, "engines": { "node": ">=18.0.0" } diff --git a/scripts/whatsapp-bridge/package-lock.json b/scripts/whatsapp-bridge/package-lock.json index 23ea30a09245..570d8a735b28 100644 --- a/scripts/whatsapp-bridge/package-lock.json +++ b/scripts/whatsapp-bridge/package-lock.json @@ -15,9 +15,9 @@ } }, "node_modules/@borewit/text-codec": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", - "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", "license": "MIT", "funding": { "type": "github", @@ -1088,9 +1088,9 @@ } }, "node_modules/file-type": { - "version": "21.3.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz", - "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==", + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", "license": "MIT", "dependencies": { "@tokenizer/inflate": "^0.4.1", @@ -1456,9 +1456,9 @@ "license": "MIT" }, "node_modules/music-metadata": { - "version": "11.12.1", - "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.1.tgz", - "integrity": "sha512-j++ltLxHDb5VCXET9FzQ8bnueiLHwQKgCO7vcbkRH/3F7fRjPkv6qncGEJ47yFhmemcYtgvsOAlcQ1dRBTkDjg==", + "version": "11.12.3", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.3.tgz", + "integrity": "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==", "funding": [ { "type": "github", @@ -1471,11 +1471,11 @@ ], "license": "MIT", "dependencies": { - "@borewit/text-codec": "^0.2.1", + "@borewit/text-codec": "^0.2.2", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", - "file-type": "^21.3.0", + "file-type": "^21.3.1", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.2", @@ -1589,9 +1589,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/pino": { @@ -2002,9 +2002,9 @@ } }, "node_modules/strtok3": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", - "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", "license": "MIT", "dependencies": { "@tokenizer/token": "^0.3.0" From a0cd2c5338cc091f3df12182eac31a31afe3d102 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:05:12 -0700 Subject: [PATCH 069/288] fix(gateway): verbose tool progress no longer truncates args when tool_preview_length is 0 (#8735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When tool_preview_length is 0 (default for platforms without a tier default, like Session), verbose mode was truncating args JSON to 200 characters. Since the user explicitly opted into verbose mode, they expect full tool call detail — the 200-char cap defeated the purpose. Now: tool_preview_length=0 means no truncation in verbose mode. Positive values still cap as before. Platform message-length limits handle overflow naturally. --- gateway/run.py | 8 +-- tests/gateway/test_run_progress_topics.py | 63 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 372cd474bbb7..31fe724f2177 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7411,9 +7411,11 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non _pl = get_tool_preview_max_len() import json as _json args_str = _json.dumps(args, ensure_ascii=False, default=str) - _cap = _pl if _pl > 0 else 200 - if len(args_str) > _cap: - args_str = args_str[:_cap - 3] + "..." + # When tool_preview_length is 0 (default), don't truncate + # in verbose mode — the user explicitly asked for full + # detail. Platform message-length limits handle the rest. + if _pl > 0 and len(args_str) > _pl: + args_str = args_str[:_pl - 3] + "..." msg = f"{emoji} {tool_name}({list(args.keys())})\n{args_str}" elif preview: msg = f"{emoji} {tool_name}: \"{preview}\"" diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 6b1d46567d7c..c1dda60b56e0 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -396,6 +396,27 @@ def run_conversation(self, message, conversation_history=None, task_id=None): } +class VerboseAgent: + """Agent that emits a tool call with args whose JSON exceeds 200 chars.""" + LONG_CODE = "x" * 300 + + def __init__(self, **kwargs): + self.tool_progress_callback = kwargs.get("tool_progress_callback") + self.tools = [] + + def run_conversation(self, message, conversation_history=None, task_id=None): + self.tool_progress_callback( + "tool.started", "execute_code", None, + {"code": self.LONG_CODE}, + ) + time.sleep(0.35) + return { + "final_response": "done", + "messages": [], + "api_calls": 1, + } + + async def _run_with_agent( monkeypatch, tmp_path, @@ -575,3 +596,45 @@ async def test_run_agent_queued_message_does_not_treat_commentary_as_final(monke assert result["final_response"] == "final response 2" assert "I'll inspect the repo first." in sent_texts assert "final response 1" in sent_texts + + +@pytest.mark.asyncio +async def test_verbose_mode_does_not_truncate_args_by_default(monkeypatch, tmp_path): + """Verbose mode with default tool_preview_length (0) should NOT truncate args. + + Previously, verbose mode capped args at 200 chars when tool_preview_length + was 0 (default). The user explicitly opted into verbose — show full detail. + """ + adapter, result = await _run_with_agent( + monkeypatch, + tmp_path, + VerboseAgent, + session_id="sess-verbose-no-truncate", + config_data={"display": {"tool_progress": "verbose", "tool_preview_length": 0}}, + ) + + assert result["final_response"] == "done" + # The full 300-char 'x' string should be present, not truncated to 200 + all_content = " ".join(call["content"] for call in adapter.sent) + all_content += " ".join(call["content"] for call in adapter.edits) + assert VerboseAgent.LONG_CODE in all_content + + +@pytest.mark.asyncio +async def test_verbose_mode_respects_explicit_tool_preview_length(monkeypatch, tmp_path): + """When tool_preview_length is set to a positive value, verbose truncates to that.""" + adapter, result = await _run_with_agent( + monkeypatch, + tmp_path, + VerboseAgent, + session_id="sess-verbose-explicit-cap", + config_data={"display": {"tool_progress": "verbose", "tool_preview_length": 50}}, + ) + + assert result["final_response"] == "done" + all_content = " ".join(call["content"] for call in adapter.sent) + all_content += " ".join(call["content"] for call in adapter.edits) + # Should be truncated — full 300-char string NOT present + assert VerboseAgent.LONG_CODE not in all_content + # But should still contain the truncated portion with "..." + assert "..." in all_content From 83ca0844f7b185bde3db77971982b29975f9e56f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:22:59 -0700 Subject: [PATCH 070/288] fix: preserve dots in model names for OpenCode Zen and ZAI providers (#8794) OpenCode Zen was in _DOT_TO_HYPHEN_PROVIDERS, causing all dotted model names (minimax-m2.5-free, gpt-5.4, glm-5.1) to be mangled. The fix: Layer 1 (model_normalize.py): Remove opencode-zen from the blanket dot-to-hyphen set. Add an explicit block that preserves dots for non-Claude models while keeping Claude hyphenated (Zen's Claude endpoint uses anthropic_messages mode which expects hyphens). Layer 2 (run_agent.py _anthropic_preserve_dots): Add opencode-zen and zai to the provider allowlist. Broaden URL check from opencode.ai/zen/go to opencode.ai/zen/ to cover both Go and Zen endpoints. Add bigmodel.cn for ZAI URL detection. Also adds glm-5.1 to ZAI model lists in models.py and setup.py. Closes #7710 Salvaged from contributions by: - konsisumer (PR #7739, #7719) - DomGrieco (PR #8708) - Esashiero (PR #7296) - sharziki (PR #7497) - XiaoYingGee (PR #8750) - APTX4869-maker (PR #8752) - kagura-agent (PR #7157) --- hermes_cli/model_normalize.py | 20 +++++++++++++---- hermes_cli/models.py | 1 + hermes_cli/setup.py | 2 +- run_agent.py | 7 +++--- tests/agent/test_minimax_provider.py | 28 ++++++++++++++++++++++++ tests/hermes_cli/test_model_normalize.py | 17 ++++++++++---- 6 files changed, 63 insertions(+), 12 deletions(-) diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 68e8dc898edc..8f4ee670cd88 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -8,8 +8,9 @@ hyphens: ``claude-sonnet-4-6``. - **Copilot** expects bare names *with* dots preserved: ``claude-sonnet-4.6``. -- **OpenCode Zen** follows the same dot-to-hyphen convention as - Anthropic: ``claude-sonnet-4-6``. +- **OpenCode Zen** preserves dots for GPT/GLM/Gemini/Kimi/MiniMax-style + model IDs, but Claude still uses hyphenated native names like + ``claude-sonnet-4-6``. - **OpenCode Go** preserves dots in model names: ``minimax-m2.7``. - **DeepSeek** only accepts two model identifiers: ``deepseek-chat`` and ``deepseek-reasoner``. @@ -67,7 +68,6 @@ # Providers that want bare names with dots replaced by hyphens. _DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({ "anthropic", - "opencode-zen", }) # Providers that want bare names with dots preserved. @@ -329,6 +329,9 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: >>> normalize_model_for_provider("claude-sonnet-4.6", "opencode-zen") 'claude-sonnet-4-6' + >>> normalize_model_for_provider("minimax-m2.5-free", "opencode-zen") + 'minimax-m2.5-free' + >>> normalize_model_for_provider("deepseek-v3", "deepseek") 'deepseek-chat' @@ -351,7 +354,16 @@ def normalize_model_for_provider(model_input: str, target_provider: str) -> str: if provider in _AGGREGATOR_PROVIDERS: return _prepend_vendor(name) - # --- Anthropic / OpenCode: strip matching provider prefix, dots -> hyphens --- + # --- OpenCode Zen: Claude stays hyphenated; other models keep dots --- + if provider == "opencode-zen": + bare = _strip_matching_provider_prefix(name, provider) + if "/" in bare: + return bare + if bare.lower().startswith("claude-"): + return _dots_to_hyphens(bare) + return bare + + # --- Anthropic: strip matching provider prefix, dots -> hyphens --- if provider in _DOT_TO_HYPHEN_PROVIDERS: bare = _strip_matching_provider_prefix(name, provider) if "/" in bare: diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 26edd8c30127..8308b102e571 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -130,6 +130,7 @@ def _codex_curated_models() -> list[str]: "gemma-4-26b-it", ], "zai": [ + "glm-5.1", "glm-5", "glm-5-turbo", "glm-4.7", diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 5fa22afe9a29..1fabec847268 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -104,7 +104,7 @@ def _supports_same_provider_pool_setup(provider: str) -> bool: "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite", "gemma-4-31b-it", "gemma-4-26b-it", ], - "zai": ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"], + "zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"], "kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], "minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"], "minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"], diff --git a/run_agent.py b/run_agent.py index 36452bc682ff..37572db5e1f1 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5856,11 +5856,12 @@ def _anthropic_preserve_dots(self) -> bool: """True when using an anthropic-compatible endpoint that preserves dots in model names. Alibaba/DashScope keeps dots (e.g. qwen3.5-plus). MiniMax keeps dots (e.g. MiniMax-M2.7). - OpenCode Go keeps dots (e.g. minimax-m2.7).""" - if (getattr(self, "provider", "") or "").lower() in {"alibaba", "minimax", "minimax-cn", "opencode-go"}: + OpenCode Go/Zen keeps dots for non-Claude models (e.g. minimax-m2.5-free). + ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1).""" + if (getattr(self, "provider", "") or "").lower() in {"alibaba", "minimax", "minimax-cn", "opencode-go", "opencode-zen", "zai"}: return True base = (getattr(self, "base_url", "") or "").lower() - return "dashscope" in base or "aliyuncs" in base or "minimax" in base or "opencode.ai/zen/go" in base + return "dashscope" in base or "aliyuncs" in base or "minimax" in base or "opencode.ai/zen/" in base or "bigmodel.cn" in base def _is_qwen_portal(self) -> bool: """Return True when the base URL targets Qwen Portal.""" diff --git a/tests/agent/test_minimax_provider.py b/tests/agent/test_minimax_provider.py index 1673bfd9445a..85c9c9520647 100644 --- a/tests/agent/test_minimax_provider.py +++ b/tests/agent/test_minimax_provider.py @@ -308,6 +308,34 @@ def test_anthropic_does_not_preserve_dots(self): from run_agent import AIAgent assert AIAgent._anthropic_preserve_dots(agent) is False + def test_opencode_zen_provider_preserves_dots(self): + from types import SimpleNamespace + agent = SimpleNamespace(provider="opencode-zen", base_url="") + from run_agent import AIAgent + assert AIAgent._anthropic_preserve_dots(agent) is True + + def test_opencode_zen_url_preserves_dots(self): + from types import SimpleNamespace + agent = SimpleNamespace(provider="custom", base_url="https://opencode.ai/zen/v1") + from run_agent import AIAgent + assert AIAgent._anthropic_preserve_dots(agent) is True + + def test_zai_provider_preserves_dots(self): + from types import SimpleNamespace + agent = SimpleNamespace(provider="zai", base_url="") + from run_agent import AIAgent + assert AIAgent._anthropic_preserve_dots(agent) is True + + def test_bigmodel_cn_url_preserves_dots(self): + from types import SimpleNamespace + agent = SimpleNamespace(provider="custom", base_url="https://open.bigmodel.cn/api/paas/v4") + from run_agent import AIAgent + assert AIAgent._anthropic_preserve_dots(agent) is True + + def test_normalize_preserves_m25_free_dot(self): + from agent.anthropic_adapter import normalize_model_name + assert normalize_model_name("minimax-m2.5-free", preserve_dots=True) == "minimax-m2.5-free" + def test_normalize_preserves_m27_dot(self): from agent.anthropic_adapter import normalize_model_name assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7" diff --git a/tests/hermes_cli/test_model_normalize.py b/tests/hermes_cli/test_model_normalize.py index 0bca8d52e3aa..14861c37a1d1 100644 --- a/tests/hermes_cli/test_model_normalize.py +++ b/tests/hermes_cli/test_model_normalize.py @@ -54,14 +54,19 @@ def test_anthropic_strips_vendor_prefix(self): # ── OpenCode Zen regression ──────────────────────────────────────────── -class TestOpenCodeZenDotToHyphen: - """OpenCode Zen follows Anthropic convention (dots→hyphens).""" +class TestOpenCodeZenModelNormalization: + """OpenCode Zen preserves dots for most models, but Claude stays hyphenated.""" @pytest.mark.parametrize("model,expected", [ ("claude-sonnet-4.6", "claude-sonnet-4-6"), - ("glm-4.5", "glm-4-5"), + ("opencode-zen/claude-opus-4.5", "claude-opus-4-5"), + ("glm-4.5", "glm-4.5"), + ("glm-5.1", "glm-5.1"), + ("gpt-5.4", "gpt-5.4"), + ("minimax-m2.5-free", "minimax-m2.5-free"), + ("kimi-k2.5", "kimi-k2.5"), ]) - def test_zen_converts_dots(self, model, expected): + def test_zen_normalizes_models(self, model, expected): result = normalize_model_for_provider(model, "opencode-zen") assert result == expected @@ -69,6 +74,10 @@ def test_zen_strips_vendor_prefix(self): result = normalize_model_for_provider("opencode-zen/claude-sonnet-4.6", "opencode-zen") assert result == "claude-sonnet-4-6" + def test_zen_strips_vendor_prefix_for_non_claude(self): + result = normalize_model_for_provider("opencode-zen/glm-5.1", "opencode-zen") + assert result == "glm-5.1" + # ── Copilot dot preservation (regression) ────────────────────────────── From b22663ea6981c5e75f83cd8771ba48f40328bc35 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:03:18 -0700 Subject: [PATCH 071/288] docs: restore Orchestra Research attribution in research-paper-writing skill (#8800) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #4654 replaced ml-paper-writing with research-paper-writing, preserving the writing philosophy and reference files but dropping the dedicated 'Sources Behind This Guidance' attribution table from the SKILL.md body. Re-adds: - The researcher attribution table (Nanda, Farquhar, Gopen & Swan, Lipton, Steinhardt, Perez, Karpathy) with affiliations and links to SKILL.md - Orchestra Research credit as original compiler of the writing philosophy - 'Origin & Attribution' section in sources.md documenting the full chain: Nanda blog → Orchestra skill → teknium integration → SHL0MS expansion --- .../research/research-paper-writing/SKILL.md | 18 ++++++++++++++++++ .../references/sources.md | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/skills/research/research-paper-writing/SKILL.md b/skills/research/research-paper-writing/SKILL.md index e773e098706f..f45ce7e2fa2e 100644 --- a/skills/research/research-paper-writing/SKILL.md +++ b/skills/research/research-paper-writing/SKILL.md @@ -820,6 +820,24 @@ Every successful ML paper centers on what Neel Nanda calls "the narrative": a sh **If you cannot state your contribution in one sentence, you don't yet have a paper.** +### The Sources Behind This Guidance + +This skill synthesizes writing philosophy from researchers who have published extensively at top venues. The writing philosophy layer was originally compiled by [Orchestra Research](https://github.com/orchestra-research) as the `ml-paper-writing` skill. + +| Source | Key Contribution | Link | +|--------|-----------------|------| +| **Neel Nanda** (Google DeepMind) | The Narrative Principle, What/Why/So What framework | [How to Write ML Papers](https://www.alignmentforum.org/posts/eJGptPbbFPZGLpjsp/highly-opinionated-advice-on-how-to-write-ml-papers) | +| **Sebastian Farquhar** (DeepMind) | 5-sentence abstract formula | [How to Write ML Papers](https://sebastianfarquhar.com/on-research/2024/11/04/how_to_write_ml_papers/) | +| **Gopen & Swan** | 7 principles of reader expectations | [Science of Scientific Writing](https://cseweb.ucsd.edu/~swanson/papers/science-of-writing.pdf) | +| **Zachary Lipton** | Word choice, eliminating hedging | [Heuristics for Scientific Writing](https://www.approximatelycorrect.com/2018/01/29/heuristics-technical-scientific-writing-machine-learning-perspective/) | +| **Jacob Steinhardt** (UC Berkeley) | Precision, consistent terminology | [Writing Tips](https://bounded-regret.ghost.io/) | +| **Ethan Perez** (Anthropic) | Micro-level clarity tips | [Easy Paper Writing Tips](https://ethanperez.net/easy-paper-writing-tips/) | +| **Andrej Karpathy** | Single contribution focus | Various lectures | + +**For deeper dives into any of these, see:** +- [references/writing-guide.md](references/writing-guide.md) — Full explanations with examples +- [references/sources.md](references/sources.md) — Complete bibliography + ### Time Allocation Spend approximately **equal time** on each of: diff --git a/skills/research/research-paper-writing/references/sources.md b/skills/research/research-paper-writing/references/sources.md index 47d7273537fa..9ffa95428720 100644 --- a/skills/research/research-paper-writing/references/sources.md +++ b/skills/research/research-paper-writing/references/sources.md @@ -4,6 +4,12 @@ This document lists all authoritative sources used to build this skill, organize --- +## Origin & Attribution + +The writing philosophy, citation verification workflow, and conference reference materials in this skill were originally compiled by **[Orchestra Research](https://github.com/orchestra-research)** as the `ml-paper-writing` skill (January 2026), drawing on Neel Nanda's blog post and other researcher guides listed below. The skill was integrated into hermes-agent by teknium (January 2026), then expanded into the current `research-paper-writing` pipeline by SHL0MS (April 2026, PR #4654), which added experiment design, execution monitoring, iterative refinement, and submission phases while preserving the original writing philosophy and reference files. + +--- + ## Writing Philosophy & Guides ### Primary Sources (Must-Read) From 8a64f3e3681924f1059edf9a01d95d565f9a001d Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 12 Apr 2026 21:19:44 -0700 Subject: [PATCH 072/288] feat(gateway): notify /restart requester when gateway comes back online MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user sends /restart, the gateway now persists their routing info (platform, chat_id, thread_id) to .restart_notify.json. After the new gateway process starts and adapters connect, it reads the file, sends a 'Gateway restarted successfully' message to that specific chat, and cleans up the file. This follows the same pattern as _send_update_notification (used by /update). Thread IDs are preserved so the notification lands in the correct Telegram topic or Discord thread. Previously, after /restart the user had no feedback that the gateway was back — they had to send a message to find out. Now they get a proactive notification and know their session continues. --- gateway/run.py | 61 ++++++++ tests/gateway/test_restart_notification.py | 173 +++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 tests/gateway/test_restart_notification.py diff --git a/gateway/run.py b/gateway/run.py index 31fe724f2177..bf493846bed7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1717,6 +1717,9 @@ async def start(self) -> bool: ): self._schedule_update_notification_watch() + # Notify the chat that initiated /restart that the gateway is back. + await self._send_restart_notification() + # Drain any recovered process watchers (from crash recovery checkpoint) try: from tools.process_registry import process_registry @@ -4147,6 +4150,22 @@ async def _handle_restart_command(self, event: MessageEvent) -> str: return f"⏳ Draining {count} active agent(s) before restart..." return "⏳ Gateway restart already in progress..." + # Save the requester's routing info so the new gateway process can + # notify them once it comes back online. + try: + import json as _json + notify_data = { + "platform": event.source.platform.value if event.source.platform else None, + "chat_id": event.source.chat_id, + } + if event.source.thread_id: + notify_data["thread_id"] = event.source.thread_id + (_hermes_home / ".restart_notify.json").write_text( + _json.dumps(notify_data) + ) + except Exception as e: + logger.debug("Failed to write restart notify file: %s", e) + active_agents = self._running_agent_count() self.request_restart(detached=True, via_service=False) if active_agents: @@ -6880,6 +6899,48 @@ async def _send_update_notification(self) -> bool: return True + async def _send_restart_notification(self) -> None: + """Notify the chat that initiated /restart that the gateway is back.""" + import json as _json + + notify_path = _hermes_home / ".restart_notify.json" + if not notify_path.exists(): + return + + try: + data = _json.loads(notify_path.read_text()) + platform_str = data.get("platform") + chat_id = data.get("chat_id") + thread_id = data.get("thread_id") + + if not platform_str or not chat_id: + return + + platform = Platform(platform_str) + adapter = self.adapters.get(platform) + if not adapter: + logger.debug( + "Restart notification skipped: %s adapter not connected", + platform_str, + ) + return + + metadata = {"thread_id": thread_id} if thread_id else None + await adapter.send( + chat_id, + "♻ Gateway restarted successfully. Your session continues.", + metadata=metadata, + ) + logger.info( + "Sent restart notification to %s:%s", + platform_str, + chat_id, + ) + except Exception as e: + logger.warning("Restart notification failed: %s", e) + finally: + notify_path.unlink(missing_ok=True) + def _set_session_env(self, context: SessionContext) -> list: """Set session context variables for the current async task. diff --git a/tests/gateway/test_restart_notification.py b/tests/gateway/test_restart_notification.py new file mode 100644 index 000000000000..ac7a89f27349 --- /dev/null +++ b/tests/gateway/test_restart_notification.py @@ -0,0 +1,173 @@ +"""Tests for /restart notification — the gateway notifies the requester on comeback.""" + +import asyncio +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import gateway.run as gateway_run +from gateway.config import Platform +from gateway.platforms.base import MessageEvent, MessageType +from gateway.session import build_session_key +from tests.gateway.restart_test_helpers import ( + make_restart_runner, + make_restart_source, +) + + +# ── _handle_restart_command writes .restart_notify.json ────────────────── + + +@pytest.mark.asyncio +async def test_restart_command_writes_notify_file(tmp_path, monkeypatch): + """When /restart fires, the requester's routing info is persisted to disk.""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + runner, _adapter = make_restart_runner() + runner.request_restart = MagicMock(return_value=True) + + source = make_restart_source(chat_id="42") + event = MessageEvent( + text="/restart", + message_type=MessageType.TEXT, + source=source, + message_id="m1", + ) + + result = await runner._handle_restart_command(event) + assert "Restarting" in result + + notify_path = tmp_path / ".restart_notify.json" + assert notify_path.exists() + data = json.loads(notify_path.read_text()) + assert data["platform"] == "telegram" + assert data["chat_id"] == "42" + assert "thread_id" not in data # no thread → omitted + + +@pytest.mark.asyncio +async def test_restart_command_preserves_thread_id(tmp_path, monkeypatch): + """Thread ID is saved when the requester is in a threaded chat.""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + runner, _adapter = make_restart_runner() + runner.request_restart = MagicMock(return_value=True) + + source = make_restart_source(chat_id="99") + source.thread_id = "topic_7" + + event = MessageEvent( + text="/restart", + message_type=MessageType.TEXT, + source=source, + message_id="m2", + ) + + await runner._handle_restart_command(event) + + data = json.loads((tmp_path / ".restart_notify.json").read_text()) + assert data["thread_id"] == "topic_7" + + +# ── _send_restart_notification ─────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_send_restart_notification_delivers_and_cleans_up(tmp_path, monkeypatch): + """On startup, the notification is sent and the file is removed.""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + notify_path = tmp_path / ".restart_notify.json" + notify_path.write_text(json.dumps({ + "platform": "telegram", + "chat_id": "42", + })) + + runner, adapter = make_restart_runner() + adapter.send = AsyncMock() + + await runner._send_restart_notification() + + adapter.send.assert_called_once() + call_args = adapter.send.call_args + assert call_args[0][0] == "42" # chat_id + assert "restarted" in call_args[0][1].lower() + assert call_args[1].get("metadata") is None # no thread + assert not notify_path.exists() + + +@pytest.mark.asyncio +async def test_send_restart_notification_with_thread(tmp_path, monkeypatch): + """Thread ID is passed as metadata so the message lands in the right topic.""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + notify_path = tmp_path / ".restart_notify.json" + notify_path.write_text(json.dumps({ + "platform": "telegram", + "chat_id": "99", + "thread_id": "topic_7", + })) + + runner, adapter = make_restart_runner() + adapter.send = AsyncMock() + + await runner._send_restart_notification() + + call_args = adapter.send.call_args + assert call_args[1]["metadata"] == {"thread_id": "topic_7"} + assert not notify_path.exists() + + +@pytest.mark.asyncio +async def test_send_restart_notification_noop_when_no_file(tmp_path, monkeypatch): + """Nothing happens if there's no pending restart notification.""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + runner, adapter = make_restart_runner() + adapter.send = AsyncMock() + + await runner._send_restart_notification() + + adapter.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_restart_notification_skips_when_adapter_missing(tmp_path, monkeypatch): + """If the requester's platform isn't connected, clean up without crashing.""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + notify_path = tmp_path / ".restart_notify.json" + notify_path.write_text(json.dumps({ + "platform": "discord", # runner only has telegram adapter + "chat_id": "42", + })) + + runner, _adapter = make_restart_runner() + + await runner._send_restart_notification() + + # File cleaned up even though we couldn't send + assert not notify_path.exists() + + +@pytest.mark.asyncio +async def test_send_restart_notification_cleans_up_on_send_failure( + tmp_path, monkeypatch +): + """If the adapter.send() raises, the file is still cleaned up.""" + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + notify_path = tmp_path / ".restart_notify.json" + notify_path.write_text(json.dumps({ + "platform": "telegram", + "chat_id": "42", + })) + + runner, adapter = make_restart_runner() + adapter.send = AsyncMock(side_effect=RuntimeError("network down")) + + await runner._send_restart_notification() + + assert not notify_path.exists() # cleaned up despite error From c052cf0eea054920619c3690123310abb6443a86 Mon Sep 17 00:00:00 2001 From: Dusk1e Date: Sun, 12 Apr 2026 14:13:13 +0300 Subject: [PATCH 073/288] fix(security): validate domain/service params in ha_call_service to prevent path traversal --- tests/tools/test_homeassistant_tool.py | 88 ++++++++++++++++++++++++++ tools/homeassistant_tool.py | 17 +++++ 2 files changed, 105 insertions(+) diff --git a/tests/tools/test_homeassistant_tool.py b/tests/tools/test_homeassistant_tool.py index b136b56534a0..e18dcb385c92 100644 --- a/tests/tools/test_homeassistant_tool.py +++ b/tests/tools/test_homeassistant_tool.py @@ -18,6 +18,7 @@ _handle_call_service, _BLOCKED_DOMAINS, _ENTITY_ID_RE, + _SERVICE_NAME_RE, ) @@ -303,6 +304,93 @@ def test_call_service_allows_no_entity_id(self): assert "Invalid entity_id" not in result["error"] +# --------------------------------------------------------------------------- +# Security: domain/service name format validation +# --------------------------------------------------------------------------- + + +class TestServiceNameValidation: + """Verify domain/service format validation prevents path traversal in URL. + + The domain and service parameters are interpolated into + /api/services/{domain}/{service}, so allowing arbitrary strings would + enable SSRF via path traversal or blocked-domain bypass. + """ + + def test_valid_domain_names(self): + assert _SERVICE_NAME_RE.match("light") + assert _SERVICE_NAME_RE.match("switch") + assert _SERVICE_NAME_RE.match("climate") + assert _SERVICE_NAME_RE.match("shell_command") + assert _SERVICE_NAME_RE.match("media_player") + + def test_valid_service_names(self): + assert _SERVICE_NAME_RE.match("turn_on") + assert _SERVICE_NAME_RE.match("turn_off") + assert _SERVICE_NAME_RE.match("set_temperature") + assert _SERVICE_NAME_RE.match("toggle") + + def test_path_traversal_in_domain_rejected(self): + assert _SERVICE_NAME_RE.match("../../api/config") is None + assert _SERVICE_NAME_RE.match("light/../../../etc") is None + assert _SERVICE_NAME_RE.match("../config") is None + + def test_path_traversal_in_service_rejected(self): + assert _SERVICE_NAME_RE.match("../../api/config") is None + assert _SERVICE_NAME_RE.match("turn_on/../../config") is None + + def test_blocked_domain_bypass_via_traversal_rejected(self): + """Ensure shell_command/../light is rejected, not just checked against blocklist.""" + assert _SERVICE_NAME_RE.match("shell_command/../light") is None + assert _SERVICE_NAME_RE.match("python_script/../scene") is None + assert _SERVICE_NAME_RE.match("hassio/../automation") is None + + def test_slashes_rejected(self): + assert _SERVICE_NAME_RE.match("light/turn_on") is None + assert _SERVICE_NAME_RE.match("a/b/c") is None + + def test_dots_rejected(self): + assert _SERVICE_NAME_RE.match("light.turn_on") is None + assert _SERVICE_NAME_RE.match("..") is None + + def test_uppercase_rejected(self): + assert _SERVICE_NAME_RE.match("LIGHT") is None + assert _SERVICE_NAME_RE.match("Turn_On") is None + + def test_special_chars_rejected(self): + assert _SERVICE_NAME_RE.match("light;rm") is None + assert _SERVICE_NAME_RE.match("light&cmd") is None + assert _SERVICE_NAME_RE.match("light cmd") is None + + def test_handler_rejects_traversal_domain(self): + """_handle_call_service must reject domain with path traversal.""" + result = json.loads(_handle_call_service({ + "domain": "../../api/config", + "service": "turn_on", + })) + assert "error" in result + assert "Invalid domain" in result["error"] + + def test_handler_rejects_traversal_service(self): + """_handle_call_service must reject service with path traversal.""" + result = json.loads(_handle_call_service({ + "domain": "light", + "service": "../../api/config", + })) + assert "error" in result + assert "Invalid service" in result["error"] + + def test_handler_rejects_blocklist_bypass_traversal(self): + """Blocklist bypass via shell_command/../light must be caught by format validation.""" + result = json.loads(_handle_call_service({ + "domain": "shell_command/../light", + "service": "turn_on", + })) + assert "error" in result + # Must be rejected as "Invalid domain", not slip through the blocklist + assert "Invalid domain" in result["error"] + + # --------------------------------------------------------------------------- # Availability check # --------------------------------------------------------------------------- diff --git a/tools/homeassistant_tool.py b/tools/homeassistant_tool.py index 0ab99b4bfab4..bf5514de1232 100644 --- a/tools/homeassistant_tool.py +++ b/tools/homeassistant_tool.py @@ -38,6 +38,15 @@ def _get_config(): # Regex for valid HA entity_id format (e.g. "light.living_room", "sensor.temperature_1") _ENTITY_ID_RE = re.compile(r"^[a-z_][a-z0-9_]*\.[a-z0-9_]+$") +# Regex for valid HA service/domain names (e.g. "light", "turn_on", "shell_command"). +# Only lowercase ASCII letters, digits, and underscores — no slashes, dots, or +# other characters that could allow path traversal in URL construction. +# The domain and service are interpolated into /api/services/{domain}/{service}, +# so allowing arbitrary strings would enable SSRF via path traversal +# (e.g. domain="../../api/config") or blocked-domain bypass +# (e.g. domain="shell_command/../light"). +_SERVICE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") + # Service domains blocked for security -- these allow arbitrary code/command # execution on the HA host or enable SSRF attacks on the local network. # HA provides zero service-level access control; all safety must be in our layer. @@ -246,6 +255,14 @@ def _handle_call_service(args: dict, **kw) -> str: if not domain or not service: return tool_error("Missing required parameters: domain and service") + # Validate domain/service format BEFORE the blocklist check — prevents + # path traversal in /api/services/{domain}/{service} and blocklist bypass + # via payloads like "shell_command/../light". + if not _SERVICE_NAME_RE.match(domain): + return tool_error(f"Invalid domain format: {domain!r}") + if not _SERVICE_NAME_RE.match(service): + return tool_error(f"Invalid service format: {service!r}") + if domain in _BLOCKED_DOMAINS: return json.dumps({ "error": f"Service domain '{domain}' is blocked for security. " From e2a9b5369f60ca9f7e15052481cf160d4b19e66b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:26:28 -0700 Subject: [PATCH 074/288] feat: web UI dashboard for managing Hermes Agent (#8756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621) Adds an embedded web UI dashboard accessible via `hermes web`: - Status page: agent version, active sessions, gateway status, connected platforms - Config editor: schema-driven form with tabbed categories, import/export, reset - API Keys page: set, clear, and view redacted values with category grouping - Sessions, Skills, Cron, Logs, and Analytics pages Backend: - hermes_cli/web_server.py: FastAPI server with REST endpoints - hermes_cli/config.py: reload_env() utility for hot-reloading .env - hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open) - cli.py / commands.py: /reload slash command for .env hot-reload - pyproject.toml: [web] optional dependency extra (fastapi + uvicorn) - Both update paths (git + zip) auto-build web frontend when npm available Frontend: - Vite + React + TypeScript + Tailwind v4 SPA in web/ - shadcn/ui-style components, Nous design language - Auto-refresh status page, toast notifications, masked password inputs Security: - Path traversal guard (resolve().is_relative_to()) on SPA file serving - CORS localhost-only via allow_origin_regex - Generic error messages (no internal leak), SessionDB handles closed properly Tests: 47 tests covering reload_env, redact_key, API endpoints, schema generation, path traversal, category merging, internal key stripping, and full config round-trip. Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor (PR #7621 → #8204), re-salvaged onto current main with stale-branch regressions removed. * fix(web): clean up status page cards, always rebuild on `hermes web` - Remove config version migration alert banner from status page - Remove config version card (internal noise, not surfaced in TUI) - Reorder status cards: Agent → Gateway → Active Sessions (3-col grid) - `hermes web` now always rebuilds from source before serving, preventing stale web_dist when editing frontend files * feat(web): full-text search across session messages - Add GET /api/sessions/search endpoint backed by FTS5 - Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby') - Debounced search (300ms) with spinner in the search icon slot - Search results show FTS5 snippets with highlighted match delimiters - Expanding a search hit auto-scrolls to the first matching message - Matching messages get a warning ring + 'match' badge - Inline term highlighting within Markdown (text, bold, italic, headings, lists) - Clear button (x) on search input for quick reset --------- Co-authored-by: emozilla --- .gitattributes | 2 + .gitignore | 3 + cli.py | 4 + hermes_cli/commands.py | 1 + hermes_cli/config.py | 22 + hermes_cli/main.py | 84 +- hermes_cli/web_server.py | 929 ++++ pyproject.toml | 5 + tests/hermes_cli/test_web_server.py | 675 +++ web/README.md | 48 + web/eslint.config.js | 23 + web/index.html | 13 + web/package-lock.json | 3835 +++++++++++++++++ web/package.json | 36 + web/public/favicon.ico | Bin 0 -> 8482 bytes web/public/fonts/Collapse-Bold.woff2 | Bin 0 -> 59144 bytes web/public/fonts/Collapse-Regular.woff2 | Bin 0 -> 62816 bytes web/public/fonts/CourierPrime-Bold.woff2 | Bin 0 -> 11588 bytes web/public/fonts/CourierPrime-Regular.woff2 | Bin 0 -> 11192 bytes web/public/fonts/Mondwest-Regular.woff2 | Bin 0 -> 23828 bytes web/public/fonts/RulesCompressed-Medium.woff2 | Bin 0 -> 38848 bytes .../fonts/RulesCompressed-Regular.woff2 | Bin 0 -> 37764 bytes web/public/fonts/RulesExpanded-Bold.woff2 | Bin 0 -> 35012 bytes web/public/fonts/RulesExpanded-Regular.woff2 | Bin 0 -> 33820 bytes web/src/App.tsx | 117 + web/src/components/AutoField.tsx | 151 + web/src/components/Markdown.tsx | 279 ++ web/src/components/Toast.tsx | 36 + web/src/components/ui/badge.tsx | 29 + web/src/components/ui/button.tsx | 38 + web/src/components/ui/card.tsx | 29 + web/src/components/ui/input.tsx | 16 + web/src/components/ui/label.tsx | 13 + web/src/components/ui/select.tsx | 15 + web/src/components/ui/separator.tsx | 19 + web/src/components/ui/switch.tsx | 37 + web/src/components/ui/tabs.tsx | 51 + web/src/hooks/useToast.ts | 15 + web/src/index.css | 197 + web/src/lib/api.ts | 260 ++ web/src/lib/nested.ts | 23 + web/src/lib/utils.ts | 26 + web/src/main.tsx | 10 + web/src/pages/AnalyticsPage.tsx | 370 ++ web/src/pages/ConfigPage.tsx | 451 ++ web/src/pages/CronPage.tsx | 279 ++ web/src/pages/EnvPage.tsx | 614 +++ web/src/pages/LogsPage.tsx | 175 + web/src/pages/SessionsPage.tsx | 429 ++ web/src/pages/SkillsPage.tsx | 439 ++ web/src/pages/StatusPage.tsx | 303 ++ web/tsconfig.app.json | 34 + web/tsconfig.json | 7 + web/tsconfig.node.json | 26 + web/vite.config.ts | 22 + 55 files changed, 10187 insertions(+), 3 deletions(-) create mode 100644 .gitattributes create mode 100644 hermes_cli/web_server.py create mode 100644 tests/hermes_cli/test_web_server.py create mode 100644 web/README.md create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.ico create mode 100644 web/public/fonts/Collapse-Bold.woff2 create mode 100644 web/public/fonts/Collapse-Regular.woff2 create mode 100644 web/public/fonts/CourierPrime-Bold.woff2 create mode 100644 web/public/fonts/CourierPrime-Regular.woff2 create mode 100644 web/public/fonts/Mondwest-Regular.woff2 create mode 100644 web/public/fonts/RulesCompressed-Medium.woff2 create mode 100644 web/public/fonts/RulesCompressed-Regular.woff2 create mode 100644 web/public/fonts/RulesExpanded-Bold.woff2 create mode 100644 web/public/fonts/RulesExpanded-Regular.woff2 create mode 100644 web/src/App.tsx create mode 100644 web/src/components/AutoField.tsx create mode 100644 web/src/components/Markdown.tsx create mode 100644 web/src/components/Toast.tsx create mode 100644 web/src/components/ui/badge.tsx create mode 100644 web/src/components/ui/button.tsx create mode 100644 web/src/components/ui/card.tsx create mode 100644 web/src/components/ui/input.tsx create mode 100644 web/src/components/ui/label.tsx create mode 100644 web/src/components/ui/select.tsx create mode 100644 web/src/components/ui/separator.tsx create mode 100644 web/src/components/ui/switch.tsx create mode 100644 web/src/components/ui/tabs.tsx create mode 100644 web/src/hooks/useToast.ts create mode 100644 web/src/index.css create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/nested.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/AnalyticsPage.tsx create mode 100644 web/src/pages/ConfigPage.tsx create mode 100644 web/src/pages/CronPage.tsx create mode 100644 web/src/pages/EnvPage.tsx create mode 100644 web/src/pages/LogsPage.tsx create mode 100644 web/src/pages/SessionsPage.tsx create mode 100644 web/src/pages/SkillsPage.tsx create mode 100644 web/src/pages/StatusPage.tsx create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000000..8726216891f0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto-generated files — collapse diffs and exclude from language stats +web/package-lock.json linguist-generated=true diff --git a/.gitignore b/.gitignore index 73132e4f4a49..137793bb1d9d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ ignored/ .worktrees/ environments/benchmarks/evals/ +# Web UI build output +hermes_cli/web_dist/ + # Release script temp files .release_notes.md mini-swe-agent/ diff --git a/cli.py b/cli.py index c76ec217de38..5951327d0d30 100644 --- a/cli.py +++ b/cli.py @@ -5419,6 +5419,10 @@ def process_command(self, command: str) -> bool: self._handle_paste_command() elif canonical == "image": self._handle_image_command(cmd_original) + elif canonical == "reload": + from hermes_cli.config import reload_env + count = reload_env() + print(f" Reloaded .env ({count} var(s) updated)") elif canonical == "reload-mcp": with self._busy_command(self._slow_command_status(cmd_original)): self._reload_mcp() diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index b44a8aa8f01e..66b770f2a90f 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -129,6 +129,7 @@ class CommandDef: CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]", subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), + CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills"), CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills", aliases=("reload_mcp",)), CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index b9c8106be7f3..fc5bc929d329 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2636,6 +2636,28 @@ def save_env_value_secure(key: str, value: str) -> Dict[str, Any]: +def reload_env() -> int: + """Re-read ~/.hermes/.env into os.environ. Returns count of vars updated. + + Adds/updates vars that changed and removes vars that were deleted from + the .env file (but only vars known to Hermes — OPTIONAL_ENV_VARS and + _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment). + """ + env_vars = load_env() + known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS + count = 0 + for key, value in env_vars.items(): + if os.environ.get(key) != value: + os.environ[key] = value + count += 1 + # Remove known Hermes vars that are no longer in .env + for key in known_keys: + if key not in env_vars and key in os.environ: + del os.environ[key] + count += 1 + return count + + def get_env_value(key: str) -> Optional[str]: """Get a value from ~/.hermes/.env or environment.""" # Check environment first diff --git a/hermes_cli/main.py b/hermes_cli/main.py index aacd8efad16f..ad2a667104bf 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2976,6 +2976,44 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) return default +def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: + """Build the web UI frontend if npm is available. + + Args: + web_dir: Path to the ``web/`` source directory. + fatal: If True, print error guidance and return False on failure + instead of a soft warning (used by ``hermes web``). + + Returns True if the build succeeded or was skipped (no package.json). + """ + if not (web_dir / "package.json").exists(): + return True + import shutil + npm = shutil.which("npm") + if not npm: + if fatal: + print("Web UI frontend not built and npm is not available.") + print("Install Node.js, then run: cd web && npm install && npm run build") + return not fatal + print("→ Building web UI...") + r1 = subprocess.run([npm, "install", "--silent"], cwd=web_dir, capture_output=True) + if r1.returncode != 0: + print(f" {'✗' if fatal else '⚠'} Web UI npm install failed" + + ("" if fatal else " (hermes web will not be available)")) + if fatal: + print(" Run manually: cd web && npm install && npm run build") + return False + r2 = subprocess.run([npm, "run", "build"], cwd=web_dir, capture_output=True) + if r2.returncode != 0: + print(f" {'✗' if fatal else '⚠'} Web UI build failed" + + ("" if fatal else " (hermes web will not be available)")) + if fatal: + print(" Run manually: cd web && npm install && npm run build") + return False + print(" ✓ Web UI built") + return True + + def _update_via_zip(args): """Update Hermes Agent by downloading a ZIP archive. @@ -3070,7 +3108,10 @@ def _update_via_zip(args): check=True, ) _install_python_dependencies_with_optional_fallback(pip_cmd) - + + # Build web UI frontend (optional — requires npm) + _build_web_ui(PROJECT_ROOT / "web") + # Sync skills try: from tools.skills_sync import sync_skills @@ -3817,7 +3858,10 @@ def cmd_update(args): if shutil.which("npm"): print("→ Updating Node.js dependencies...") subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False) - + + # Build web UI frontend (optional — requires npm) + _build_web_ui(PROJECT_ROOT / "web") + print() print("✓ Code updated!") @@ -4099,7 +4143,7 @@ def _coalesce_session_name_args(argv: list) -> list: "chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "auth", "status", "cron", "doctor", "config", "pairing", "skills", "tools", "mcp", "sessions", "insights", "version", "update", "uninstall", - "profile", + "profile", "dashboard", } _SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"} @@ -4377,6 +4421,27 @@ def cmd_profile(args): sys.exit(1) +def cmd_dashboard(args): + """Start the web UI server.""" + try: + import fastapi # noqa: F401 + import uvicorn # noqa: F401 + except ImportError: + print("Web UI dependencies not installed.") + print("Install them with: pip install hermes-agent[web]") + sys.exit(1) + + if not _build_web_ui(PROJECT_ROOT / "web", fatal=True): + sys.exit(1) + + from hermes_cli.web_server import start_server + start_server( + host=args.host, + port=args.port, + open_browser=not args.no_open, + ) + + def cmd_completion(args): """Print shell completion script.""" from hermes_cli.profiles import generate_bash_completion, generate_zsh_completion @@ -5862,6 +5927,19 @@ def cmd_acp(args): ) completion_parser.set_defaults(func=cmd_completion) + # ========================================================================= + # dashboard command + # ========================================================================= + dashboard_parser = subparsers.add_parser( + "dashboard", + help="Start the web UI dashboard", + description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions", + ) + dashboard_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)") + dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)") + dashboard_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically") + dashboard_parser.set_defaults(func=cmd_dashboard) + # ========================================================================= # logs command # ========================================================================= diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py new file mode 100644 index 000000000000..bd77798ca458 --- /dev/null +++ b/hermes_cli/web_server.py @@ -0,0 +1,929 @@ +""" +Hermes Agent — Web UI server. + +Provides a FastAPI backend serving the Vite/React frontend and REST API +endpoints for managing configuration, environment variables, and sessions. + +Usage: + python -m hermes_cli.main web # Start on http://127.0.0.1:9119 + python -m hermes_cli.main web --port 8080 +""" + +import logging +import os +import secrets +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +PROJECT_ROOT = Path(__file__).parent.parent.resolve() +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from hermes_cli import __version__, __release_date__ +from hermes_cli.config import ( + DEFAULT_CONFIG, + OPTIONAL_ENV_VARS, + get_config_path, + get_env_path, + get_hermes_home, + load_config, + load_env, + save_config, + save_env_value, + remove_env_value, + check_config_version, + redact_key, +) +from gateway.status import get_running_pid, read_runtime_status + +try: + from fastapi import FastAPI, HTTPException, Request + from fastapi.middleware.cors import CORSMiddleware + from fastapi.responses import FileResponse, JSONResponse + from fastapi.staticfiles import StaticFiles + from pydantic import BaseModel +except ImportError: + raise SystemExit( + "Web UI requires fastapi and uvicorn.\n" + "Run 'hermes web' to auto-install, or: pip install hermes-agent[web]" + ) + +WEB_DIST = Path(__file__).parent / "web_dist" +_log = logging.getLogger(__name__) + +app = FastAPI(title="Hermes Agent", version=__version__) + +# --------------------------------------------------------------------------- +# Session token for protecting sensitive endpoints (reveal). +# Generated fresh on every server start — dies when the process exits. +# Injected into the SPA HTML so only the legitimate web UI can use it. +# --------------------------------------------------------------------------- +_SESSION_TOKEN = secrets.token_urlsafe(32) + +# Simple rate limiter for the reveal endpoint +_reveal_timestamps: List[float] = [] +_REVEAL_MAX_PER_WINDOW = 5 +_REVEAL_WINDOW_SECONDS = 30 + +# CORS: restrict to localhost origins only. The web UI is intended to run +# locally; binding to 0.0.0.0 with allow_origins=["*"] would let any website +# read/modify config and secrets. + +app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", + allow_methods=["*"], + allow_headers=["*"], +) + + +# --------------------------------------------------------------------------- +# Config schema — auto-generated from DEFAULT_CONFIG +# --------------------------------------------------------------------------- + +# Manual overrides for fields that need select options or custom types +_SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { + "model": { + "type": "string", + "description": "Default model (e.g. anthropic/claude-sonnet-4.6)", + "category": "general", + }, + "terminal.backend": { + "type": "select", + "description": "Terminal execution backend", + "options": ["local", "docker", "ssh", "modal", "daytona", "singularity"], + }, + "terminal.modal_mode": { + "type": "select", + "description": "Modal sandbox mode", + "options": ["sandbox", "function"], + }, + "tts.provider": { + "type": "select", + "description": "Text-to-speech provider", + "options": ["edge", "elevenlabs", "openai", "neutts"], + }, + "stt.provider": { + "type": "select", + "description": "Speech-to-text provider", + "options": ["local", "openai", "mistral"], + }, + "display.skin": { + "type": "select", + "description": "CLI visual theme", + "options": ["default", "ares", "mono", "slate"], + }, + "display.resume_display": { + "type": "select", + "description": "How resumed sessions display history", + "options": ["minimal", "full", "off"], + }, + "display.busy_input_mode": { + "type": "select", + "description": "Input behavior while agent is running", + "options": ["queue", "interrupt", "block"], + }, + "memory.provider": { + "type": "select", + "description": "Memory provider plugin", + "options": ["builtin", "honcho"], + }, + "approvals.mode": { + "type": "select", + "description": "Dangerous command approval mode", + "options": ["ask", "yolo", "deny"], + }, + "context.engine": { + "type": "select", + "description": "Context management engine", + "options": ["default", "custom"], + }, + "human_delay.mode": { + "type": "select", + "description": "Simulated typing delay mode", + "options": ["off", "typing", "fixed"], + }, + "logging.level": { + "type": "select", + "description": "Log level for agent.log", + "options": ["DEBUG", "INFO", "WARNING", "ERROR"], + }, + "agent.service_tier": { + "type": "select", + "description": "API service tier (OpenAI/Anthropic)", + "options": ["", "auto", "default", "flex"], + }, + "delegation.reasoning_effort": { + "type": "select", + "description": "Reasoning effort for delegated subagents", + "options": ["", "low", "medium", "high"], + }, +} + +# Categories with fewer fields get merged into "general" to avoid tab sprawl. +_CATEGORY_MERGE: Dict[str, str] = { + "privacy": "security", + "context": "agent", + "skills": "agent", + "cron": "agent", + "network": "agent", + "checkpoints": "agent", + "approvals": "security", + "human_delay": "display", + "smart_model_routing": "agent", +} + +# Display order for tabs — unlisted categories sort alphabetically after these. +_CATEGORY_ORDER = [ + "general", "agent", "terminal", "display", "delegation", + "memory", "compression", "security", "browser", "voice", + "tts", "stt", "logging", "discord", "auxiliary", +] + + +def _infer_type(value: Any) -> str: + """Infer a UI field type from a Python value.""" + if isinstance(value, bool): + return "boolean" + if isinstance(value, int): + return "number" + if isinstance(value, float): + return "number" + if isinstance(value, list): + return "list" + if isinstance(value, dict): + return "object" + return "string" + + +def _build_schema_from_config( + config: Dict[str, Any], + prefix: str = "", +) -> Dict[str, Dict[str, Any]]: + """Walk DEFAULT_CONFIG and produce a flat dot-path → field schema dict.""" + schema: Dict[str, Dict[str, Any]] = {} + for key, value in config.items(): + full_key = f"{prefix}.{key}" if prefix else key + + # Skip internal / version keys + if full_key in ("_config_version",): + continue + + # Category is the first path component for nested keys, or "general" + # for top-level scalar fields (model, toolsets, timezone, etc.). + if prefix: + category = prefix.split(".")[0] + elif isinstance(value, dict): + category = key + else: + category = "general" + + if isinstance(value, dict): + # Recurse into nested dicts + schema.update(_build_schema_from_config(value, full_key)) + else: + entry: Dict[str, Any] = { + "type": _infer_type(value), + "description": full_key.replace(".", " → ").replace("_", " ").title(), + "category": category, + } + # Apply manual overrides + if full_key in _SCHEMA_OVERRIDES: + entry.update(_SCHEMA_OVERRIDES[full_key]) + # Merge small categories + entry["category"] = _CATEGORY_MERGE.get(entry["category"], entry["category"]) + schema[full_key] = entry + return schema + + +CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG) + + +class ConfigUpdate(BaseModel): + config: dict + + +class EnvVarUpdate(BaseModel): + key: str + value: str + + +class EnvVarDelete(BaseModel): + key: str + + +class EnvVarReveal(BaseModel): + key: str + + +@app.get("/api/status") +async def get_status(): + current_ver, latest_ver = check_config_version() + + gateway_pid = get_running_pid() + gateway_running = gateway_pid is not None + + gateway_state = None + gateway_platforms: dict = {} + gateway_exit_reason = None + gateway_updated_at = None + configured_gateway_platforms: set[str] | None = None + try: + from gateway.config import load_gateway_config + + gateway_config = load_gateway_config() + configured_gateway_platforms = { + platform.value for platform in gateway_config.get_connected_platforms() + } + except Exception: + configured_gateway_platforms = None + + runtime = read_runtime_status() + if runtime: + gateway_state = runtime.get("gateway_state") + gateway_platforms = runtime.get("platforms") or {} + if configured_gateway_platforms is not None: + gateway_platforms = { + key: value + for key, value in gateway_platforms.items() + if key in configured_gateway_platforms + } + gateway_exit_reason = runtime.get("exit_reason") + gateway_updated_at = runtime.get("updated_at") + if not gateway_running: + gateway_state = gateway_state if gateway_state in ("stopped", "startup_failed") else "stopped" + gateway_platforms = {} + + active_sessions = 0 + try: + from hermes_state import SessionDB + db = SessionDB() + try: + sessions = db.list_sessions_rich(limit=50) + now = time.time() + active_sessions = sum( + 1 for s in sessions + if s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + ) + finally: + db.close() + except Exception: + pass + + return { + "version": __version__, + "release_date": __release_date__, + "hermes_home": str(get_hermes_home()), + "config_path": str(get_config_path()), + "env_path": str(get_env_path()), + "config_version": current_ver, + "latest_config_version": latest_ver, + "gateway_running": gateway_running, + "gateway_pid": gateway_pid, + "gateway_state": gateway_state, + "gateway_platforms": gateway_platforms, + "gateway_exit_reason": gateway_exit_reason, + "gateway_updated_at": gateway_updated_at, + "active_sessions": active_sessions, + } + + +@app.get("/api/sessions") +async def get_sessions(): + try: + from hermes_state import SessionDB + db = SessionDB() + try: + sessions = db.list_sessions_rich(limit=20) + now = time.time() + for s in sessions: + s["is_active"] = ( + s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + ) + return sessions + finally: + db.close() + except Exception as e: + _log.exception("GET /api/sessions failed") + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.get("/api/sessions/search") +async def search_sessions(q: str = "", limit: int = 20): + """Full-text search across session message content using FTS5.""" + if not q or not q.strip(): + return {"results": []} + try: + from hermes_state import SessionDB + db = SessionDB() + try: + # Auto-add prefix wildcards so partial words match + # e.g. "nimb" → "nimb*" matches "nimby" + # Preserve quoted phrases and existing wildcards as-is + import re + terms = [] + for token in re.findall(r'"[^"]*"|\S+', q.strip()): + if token.startswith('"') or token.endswith("*"): + terms.append(token) + else: + terms.append(token + "*") + prefix_query = " ".join(terms) + matches = db.search_messages(query=prefix_query, limit=limit) + # Group by session_id — return unique sessions with their best snippet + seen: dict = {} + for m in matches: + sid = m["session_id"] + if sid not in seen: + seen[sid] = { + "session_id": sid, + "snippet": m.get("snippet", ""), + "role": m.get("role"), + "source": m.get("source"), + "model": m.get("model"), + "session_started": m.get("session_started"), + } + return {"results": list(seen.values())} + finally: + db.close() + except Exception: + _log.exception("GET /api/sessions/search failed") + raise HTTPException(status_code=500, detail="Search failed") + + +def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]: + """Normalize config for the web UI. + + Hermes supports ``model`` as either a bare string (``"anthropic/claude-sonnet-4"``) + or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built + from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the + dict form. Normalize to the string form so the frontend schema matches. + """ + config = dict(config) # shallow copy + model_val = config.get("model") + if isinstance(model_val, dict): + config["model"] = model_val.get("default", model_val.get("name", "")) + return config + + +@app.get("/api/config") +async def get_config(): + config = _normalize_config_for_web(load_config()) + # Strip internal keys that the frontend shouldn't see or send back + return {k: v for k, v in config.items() if not k.startswith("_")} + + +@app.get("/api/config/defaults") +async def get_defaults(): + return DEFAULT_CONFIG + + +@app.get("/api/config/schema") +async def get_schema(): + return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER} + + +def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: + """Reverse _normalize_config_for_web before saving. + + Reconstructs ``model`` as a dict by reading the current on-disk config + to recover model subkeys (provider, base_url, api_mode, etc.) that were + stripped from the GET response. The frontend only sees model as a flat + string; the rest is preserved transparently. + """ + config = dict(config) + # Remove any _model_meta that might have leaked in (shouldn't happen + # with the stripped GET response, but be defensive) + config.pop("_model_meta", None) + + model_val = config.get("model") + if isinstance(model_val, str) and model_val: + # Read the current disk config to recover model subkeys + try: + disk_config = load_config() + disk_model = disk_config.get("model") + if isinstance(disk_model, dict): + # Preserve all subkeys, update default with the new value + disk_model["default"] = model_val + config["model"] = disk_model + except Exception: + pass # can't read disk config — just use the string form + return config + + +@app.put("/api/config") +async def update_config(body: ConfigUpdate): + try: + save_config(_denormalize_config_from_web(body.config)) + return {"ok": True} + except Exception as e: + _log.exception("PUT /api/config failed") + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.get("/api/auth/session-token") +async def get_session_token(): + """Return the ephemeral session token for this server instance. + + The token protects sensitive endpoints (reveal). It's served to the SPA + which stores it in memory — it's never persisted and dies when the server + process exits. CORS already restricts this to localhost origins. + """ + return {"token": _SESSION_TOKEN} + + +@app.get("/api/env") +async def get_env_vars(): + env_on_disk = load_env() + result = {} + for var_name, info in OPTIONAL_ENV_VARS.items(): + value = env_on_disk.get(var_name) + result[var_name] = { + "is_set": bool(value), + "redacted_value": redact_key(value) if value else None, + "description": info.get("description", ""), + "url": info.get("url"), + "category": info.get("category", ""), + "is_password": info.get("password", False), + "tools": info.get("tools", []), + "advanced": info.get("advanced", False), + } + return result + + +@app.put("/api/env") +async def set_env_var(body: EnvVarUpdate): + try: + save_env_value(body.key, body.value) + return {"ok": True, "key": body.key} + except Exception as e: + _log.exception("PUT /api/env failed") + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.delete("/api/env") +async def remove_env_var(body: EnvVarDelete): + try: + removed = remove_env_value(body.key) + if not removed: + raise HTTPException(status_code=404, detail=f"{body.key} not found in .env") + return {"ok": True, "key": body.key} + except HTTPException: + raise + except Exception as e: + _log.exception("DELETE /api/env failed") + raise HTTPException(status_code=500, detail="Internal server error") + + +@app.post("/api/env/reveal") +async def reveal_env_var(body: EnvVarReveal, request: Request): + """Return the real (unredacted) value of a single env var. + + Protected by: + - Ephemeral session token (generated per server start, injected into SPA) + - Rate limiting (max 5 reveals per 30s window) + - Audit logging + """ + # --- Token check --- + auth = request.headers.get("authorization", "") + if auth != f"Bearer {_SESSION_TOKEN}": + raise HTTPException(status_code=401, detail="Unauthorized") + + # --- Rate limit --- + now = time.time() + cutoff = now - _REVEAL_WINDOW_SECONDS + _reveal_timestamps[:] = [t for t in _reveal_timestamps if t > cutoff] + if len(_reveal_timestamps) >= _REVEAL_MAX_PER_WINDOW: + raise HTTPException(status_code=429, detail="Too many reveal requests. Try again shortly.") + _reveal_timestamps.append(now) + + # --- Reveal --- + env_on_disk = load_env() + value = env_on_disk.get(body.key) + if value is None: + raise HTTPException(status_code=404, detail=f"{body.key} not found in .env") + + _log.info("env/reveal: %s", body.key) + return {"key": body.key, "value": value} + + +# --------------------------------------------------------------------------- +# Session detail endpoints +# --------------------------------------------------------------------------- + + +@app.get("/api/sessions/{session_id}") +async def get_session_detail(session_id: str): + from hermes_state import SessionDB + db = SessionDB() + try: + sid = db.resolve_session_id(session_id) + session = db.get_session(sid) if sid else None + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return session + finally: + db.close() + + +@app.get("/api/sessions/{session_id}/messages") +async def get_session_messages(session_id: str): + from hermes_state import SessionDB + db = SessionDB() + try: + sid = db.resolve_session_id(session_id) + if not sid: + raise HTTPException(status_code=404, detail="Session not found") + messages = db.get_messages(sid) + return {"session_id": sid, "messages": messages} + finally: + db.close() + + +@app.delete("/api/sessions/{session_id}") +async def delete_session_endpoint(session_id: str): + from hermes_state import SessionDB + db = SessionDB() + try: + if not db.delete_session(session_id): + raise HTTPException(status_code=404, detail="Session not found") + return {"ok": True} + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Log viewer endpoint +# --------------------------------------------------------------------------- + + +@app.get("/api/logs") +async def get_logs( + file: str = "agent", + lines: int = 100, + level: Optional[str] = None, + component: Optional[str] = None, +): + from hermes_cli.logs import _read_tail, LOG_FILES + + log_name = LOG_FILES.get(file) + if not log_name: + raise HTTPException(status_code=400, detail=f"Unknown log file: {file}") + log_path = get_hermes_home() / "logs" / log_name + if not log_path.exists(): + return {"file": file, "lines": []} + + try: + from hermes_logging import COMPONENT_PREFIXES + except ImportError: + COMPONENT_PREFIXES = {} + + has_filters = bool(level or component) + comp_prefixes = COMPONENT_PREFIXES.get(component, ()) if component else () + result = _read_tail( + log_path, min(lines, 500), + has_filters=has_filters, + min_level=level, + component_prefixes=comp_prefixes, + ) + return {"file": file, "lines": result} + + +# --------------------------------------------------------------------------- +# Cron job management endpoints +# --------------------------------------------------------------------------- + + +class CronJobCreate(BaseModel): + prompt: str + schedule: str + name: str = "" + deliver: str = "local" + + +class CronJobUpdate(BaseModel): + updates: dict + + +@app.get("/api/cron/jobs") +async def list_cron_jobs(): + from cron.jobs import list_jobs + return list_jobs(include_disabled=True) + + +@app.get("/api/cron/jobs/{job_id}") +async def get_cron_job(job_id: str): + from cron.jobs import get_job + job = get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return job + + +@app.post("/api/cron/jobs") +async def create_cron_job(body: CronJobCreate): + from cron.jobs import create_job + try: + job = create_job(prompt=body.prompt, schedule=body.schedule, + name=body.name, deliver=body.deliver) + return job + except Exception as e: + _log.exception("POST /api/cron/jobs failed") + raise HTTPException(status_code=400, detail=str(e)) + + +@app.put("/api/cron/jobs/{job_id}") +async def update_cron_job(job_id: str, body: CronJobUpdate): + from cron.jobs import update_job + job = update_job(job_id, body.updates) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return job + + +@app.post("/api/cron/jobs/{job_id}/pause") +async def pause_cron_job(job_id: str): + from cron.jobs import pause_job + job = pause_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return job + + +@app.post("/api/cron/jobs/{job_id}/resume") +async def resume_cron_job(job_id: str): + from cron.jobs import resume_job + job = resume_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return job + + +@app.post("/api/cron/jobs/{job_id}/trigger") +async def trigger_cron_job(job_id: str): + from cron.jobs import trigger_job + job = trigger_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + return job + + +@app.delete("/api/cron/jobs/{job_id}") +async def delete_cron_job(job_id: str): + from cron.jobs import remove_job + if not remove_job(job_id): + raise HTTPException(status_code=404, detail="Job not found") + return {"ok": True} + + +# --------------------------------------------------------------------------- +# Skills & Tools endpoints +# --------------------------------------------------------------------------- + + +class SkillToggle(BaseModel): + name: str + enabled: bool + + +@app.get("/api/skills") +async def get_skills(): + from tools.skills_tool import _find_all_skills + from hermes_cli.skills_config import get_disabled_skills + config = load_config() + disabled = get_disabled_skills(config) + skills = _find_all_skills(skip_disabled=True) + for s in skills: + s["enabled"] = s["name"] not in disabled + return skills + + +@app.put("/api/skills/toggle") +async def toggle_skill(body: SkillToggle): + from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills + config = load_config() + disabled = get_disabled_skills(config) + if body.enabled: + disabled.discard(body.name) + else: + disabled.add(body.name) + save_disabled_skills(config, disabled) + return {"ok": True, "name": body.name, "enabled": body.enabled} + + +@app.get("/api/tools/toolsets") +async def get_toolsets(): + from hermes_cli.tools_config import ( + _get_effective_configurable_toolsets, + _get_platform_tools, + _toolset_has_keys, + ) + from toolsets import resolve_toolset + + config = load_config() + enabled_toolsets = _get_platform_tools( + config, + "cli", + include_default_mcp_servers=False, + ) + result = [] + for name, label, desc in _get_effective_configurable_toolsets(): + try: + tools = sorted(set(resolve_toolset(name))) + except Exception: + tools = [] + is_enabled = name in enabled_toolsets + result.append({ + "name": name, "label": label, "description": desc, + "enabled": is_enabled, + "available": is_enabled, + "configured": _toolset_has_keys(name, config), + "tools": tools, + }) + return result + + +# --------------------------------------------------------------------------- +# Raw YAML config endpoint +# --------------------------------------------------------------------------- + + +class RawConfigUpdate(BaseModel): + yaml_text: str + + +@app.get("/api/config/raw") +async def get_config_raw(): + path = get_config_path() + if not path.exists(): + return {"yaml": ""} + return {"yaml": path.read_text(encoding="utf-8")} + + +@app.put("/api/config/raw") +async def update_config_raw(body: RawConfigUpdate): + try: + parsed = yaml.safe_load(body.yaml_text) + if not isinstance(parsed, dict): + raise HTTPException(status_code=400, detail="YAML must be a mapping") + save_config(parsed) + return {"ok": True} + except yaml.YAMLError as e: + raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}") + + +# --------------------------------------------------------------------------- +# Token / cost analytics endpoint +# --------------------------------------------------------------------------- + + +@app.get("/api/analytics/usage") +async def get_usage_analytics(days: int = 30): + from hermes_state import SessionDB + db = SessionDB() + try: + cutoff = time.time() - (days * 86400) + cur = db._conn.execute(""" + SELECT date(started_at, 'unixepoch') as day, + SUM(input_tokens) as input_tokens, + SUM(output_tokens) as output_tokens, + SUM(cache_read_tokens) as cache_read_tokens, + SUM(reasoning_tokens) as reasoning_tokens, + COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost, + COALESCE(SUM(actual_cost_usd), 0) as actual_cost, + COUNT(*) as sessions + FROM sessions WHERE started_at > ? + GROUP BY day ORDER BY day + """, (cutoff,)) + daily = [dict(r) for r in cur.fetchall()] + + cur2 = db._conn.execute(""" + SELECT model, + SUM(input_tokens) as input_tokens, + SUM(output_tokens) as output_tokens, + COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost, + COUNT(*) as sessions + FROM sessions WHERE started_at > ? AND model IS NOT NULL + GROUP BY model ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC + """, (cutoff,)) + by_model = [dict(r) for r in cur2.fetchall()] + + cur3 = db._conn.execute(""" + SELECT SUM(input_tokens) as total_input, + SUM(output_tokens) as total_output, + SUM(cache_read_tokens) as total_cache_read, + SUM(reasoning_tokens) as total_reasoning, + COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost, + COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost, + COUNT(*) as total_sessions + FROM sessions WHERE started_at > ? + """, (cutoff,)) + totals = dict(cur3.fetchone()) + + return {"daily": daily, "by_model": by_model, "totals": totals, "period_days": days} + finally: + db.close() + + +def mount_spa(application: FastAPI): + """Mount the built SPA. Falls back to index.html for client-side routing.""" + if not WEB_DIST.exists(): + @application.get("/{full_path:path}") + async def no_frontend(full_path: str): + return JSONResponse( + {"error": "Frontend not built. Run: cd web && npm run build"}, + status_code=404, + ) + return + + application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets") + + @application.get("/{full_path:path}") + async def serve_spa(full_path: str): + file_path = WEB_DIST / full_path + # Prevent path traversal via url-encoded sequences (%2e%2e/) + if ( + full_path + and file_path.resolve().is_relative_to(WEB_DIST.resolve()) + and file_path.exists() + and file_path.is_file() + ): + return FileResponse(file_path) + return FileResponse( + WEB_DIST / "index.html", + headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, + ) + + +mount_spa(app) + + +def start_server(host: str = "127.0.0.1", port: int = 9119, open_browser: bool = True): + """Start the web UI server.""" + import uvicorn + + if host not in ("127.0.0.1", "localhost", "::1"): + import logging + logging.warning( + "Binding to %s — the web UI exposes config and API keys. " + "Only bind to non-localhost if you trust all users on the network.", host, + ) + + if open_browser: + import threading + import webbrowser + + def _open(): + import time as _t + _t.sleep(1.0) + webbrowser.open(f"http://{host}:{port}") + + threading.Thread(target=_open, daemon=True).start() + + print(f" Hermes Web UI → http://{host}:{port}") + uvicorn.run(app, host=host, port=port, log_level="warning") diff --git a/pyproject.toml b/pyproject.toml index 95a1dfddd74b..a8d479391055 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,7 @@ termux = [ ] dingtalk = ["dingtalk-stream>=0.1.0,<1"] feishu = ["lark-oapi>=1.5.3,<2"] +web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"] rl = [ "atroposlib @ git+https://github.com/NousResearch/atropos.git", "tinker @ git+https://github.com/thinking-machines-lab/tinker.git", @@ -107,6 +108,7 @@ all = [ "hermes-agent[dingtalk]", "hermes-agent[feishu]", "hermes-agent[mistral]", + "hermes-agent[web]", ] [project.scripts] @@ -117,6 +119,9 @@ hermes-acp = "acp_adapter.entry:main" [tool.setuptools] py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"] +[tool.setuptools.package-data] +hermes_cli = ["web_dist/**/*"] + [tool.setuptools.packages.find] include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"] diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py new file mode 100644 index 000000000000..ffa614cd9064 --- /dev/null +++ b/tests/hermes_cli/test_web_server.py @@ -0,0 +1,675 @@ +"""Tests for hermes_cli.web_server and related config utilities.""" + +import os +import json +import tempfile +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + +from hermes_cli.config import ( + DEFAULT_CONFIG, + reload_env, + redact_key, + _EXTRA_ENV_KEYS, + OPTIONAL_ENV_VARS, +) + + +# --------------------------------------------------------------------------- +# reload_env tests +# --------------------------------------------------------------------------- + + +class TestReloadEnv: + """Tests for reload_env() — re-reads .env into os.environ.""" + + def test_adds_new_vars(self, tmp_path): + """reload_env() adds vars from .env that are not in os.environ.""" + env_file = tmp_path / ".env" + env_file.write_text("TEST_RELOAD_VAR=hello123\n") + with patch("hermes_cli.config.get_env_path", return_value=env_file): + os.environ.pop("TEST_RELOAD_VAR", None) + count = reload_env() + assert count >= 1 + assert os.environ.get("TEST_RELOAD_VAR") == "hello123" + os.environ.pop("TEST_RELOAD_VAR", None) + + def test_updates_changed_vars(self, tmp_path): + """reload_env() updates vars whose value changed on disk.""" + env_file = tmp_path / ".env" + env_file.write_text("TEST_RELOAD_VAR=old_value\n") + with patch("hermes_cli.config.get_env_path", return_value=env_file): + os.environ["TEST_RELOAD_VAR"] = "old_value" + # Now change the file + env_file.write_text("TEST_RELOAD_VAR=new_value\n") + count = reload_env() + assert count >= 1 + assert os.environ.get("TEST_RELOAD_VAR") == "new_value" + os.environ.pop("TEST_RELOAD_VAR", None) + + def test_removes_deleted_known_vars(self, tmp_path): + """reload_env() removes known Hermes vars not present in .env.""" + env_file = tmp_path / ".env" + env_file.write_text("") # empty .env + # Pick a known key from OPTIONAL_ENV_VARS + known_key = next(iter(OPTIONAL_ENV_VARS.keys())) + with patch("hermes_cli.config.get_env_path", return_value=env_file): + os.environ[known_key] = "stale_value" + count = reload_env() + assert known_key not in os.environ + assert count >= 1 + + def test_does_not_remove_unknown_vars(self, tmp_path): + """reload_env() preserves non-Hermes env vars even when absent from .env.""" + env_file = tmp_path / ".env" + env_file.write_text("") + with patch("hermes_cli.config.get_env_path", return_value=env_file): + os.environ["MY_CUSTOM_UNRELATED_VAR"] = "keep_me" + reload_env() + assert os.environ.get("MY_CUSTOM_UNRELATED_VAR") == "keep_me" + os.environ.pop("MY_CUSTOM_UNRELATED_VAR", None) + + +# --------------------------------------------------------------------------- +# redact_key tests +# --------------------------------------------------------------------------- + + +class TestRedactKey: + def test_long_key_shows_prefix_suffix(self): + result = redact_key("sk-1234567890abcdef") + assert result.startswith("sk-1") + assert result.endswith("cdef") + assert "..." in result + + def test_short_key_fully_masked(self): + assert redact_key("short") == "***" + + def test_empty_key(self): + result = redact_key("") + assert "not set" in result.lower() or result == "***" or "\x1b" in result + + +# --------------------------------------------------------------------------- +# web_server tests (FastAPI endpoints) +# --------------------------------------------------------------------------- + + +class TestWebServerEndpoints: + """Test the FastAPI REST endpoints using Starlette TestClient.""" + + @pytest.fixture(autouse=True) + def _setup_test_client(self): + """Create a TestClient — import is deferred to avoid requiring fastapi.""" + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + from hermes_cli.web_server import app + self.client = TestClient(app) + + def test_get_status(self): + resp = self.client.get("/api/status") + assert resp.status_code == 200 + data = resp.json() + assert "version" in data + assert "hermes_home" in data + assert "active_sessions" in data + + def test_get_status_filters_unconfigured_gateway_platforms(self, monkeypatch): + import gateway.config as gateway_config + import hermes_cli.web_server as web_server + + class _Platform: + def __init__(self, value): + self.value = value + + class _GatewayConfig: + def get_connected_platforms(self): + return [_Platform("telegram")] + + monkeypatch.setattr(web_server, "get_running_pid", lambda: 1234) + monkeypatch.setattr( + web_server, + "read_runtime_status", + lambda: { + "gateway_state": "running", + "updated_at": "2026-04-12T00:00:00+00:00", + "platforms": { + "telegram": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"}, + "whatsapp": {"state": "retrying", "updated_at": "2026-04-12T00:00:00+00:00"}, + "feishu": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"}, + }, + }, + ) + monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1)) + monkeypatch.setattr(gateway_config, "load_gateway_config", lambda: _GatewayConfig()) + + resp = self.client.get("/api/status") + + assert resp.status_code == 200 + assert resp.json()["gateway_platforms"] == { + "telegram": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"}, + } + + def test_get_status_hides_stale_platforms_when_gateway_not_running(self, monkeypatch): + import gateway.config as gateway_config + import hermes_cli.web_server as web_server + + class _GatewayConfig: + def get_connected_platforms(self): + return [] + + monkeypatch.setattr(web_server, "get_running_pid", lambda: None) + monkeypatch.setattr( + web_server, + "read_runtime_status", + lambda: { + "gateway_state": "startup_failed", + "updated_at": "2026-04-12T00:00:00+00:00", + "platforms": { + "whatsapp": {"state": "retrying", "updated_at": "2026-04-12T00:00:00+00:00"}, + "feishu": {"state": "connected", "updated_at": "2026-04-12T00:00:00+00:00"}, + }, + }, + ) + monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1)) + monkeypatch.setattr(gateway_config, "load_gateway_config", lambda: _GatewayConfig()) + + resp = self.client.get("/api/status") + + assert resp.status_code == 200 + assert resp.json()["gateway_state"] == "startup_failed" + assert resp.json()["gateway_platforms"] == {} + + def test_get_config_schema(self): + resp = self.client.get("/api/config/schema") + assert resp.status_code == 200 + data = resp.json() + assert "fields" in data + assert "category_order" in data + schema = data["fields"] + assert len(schema) > 100 # Should have 150+ fields + assert "model" in schema + # Verify category_order is a non-empty list + assert isinstance(data["category_order"], list) + assert len(data["category_order"]) > 0 + assert "general" in data["category_order"] + + def test_get_config_defaults(self): + resp = self.client.get("/api/config/defaults") + assert resp.status_code == 200 + defaults = resp.json() + assert "model" in defaults + + def test_get_env_vars(self): + resp = self.client.get("/api/env") + assert resp.status_code == 200 + data = resp.json() + # Should contain known env var names + assert any(k.endswith("_API_KEY") or k.endswith("_TOKEN") for k in data.keys()) + + def test_reveal_env_var(self, tmp_path): + """POST /api/env/reveal should return the real unredacted value.""" + from hermes_cli.config import save_env_value + from hermes_cli.web_server import _SESSION_TOKEN + save_env_value("TEST_REVEAL_KEY", "super-secret-value-12345") + resp = self.client.post( + "/api/env/reveal", + json={"key": "TEST_REVEAL_KEY"}, + headers={"Authorization": f"Bearer {_SESSION_TOKEN}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["key"] == "TEST_REVEAL_KEY" + assert data["value"] == "super-secret-value-12345" + + def test_reveal_env_var_not_found(self): + """POST /api/env/reveal should 404 for unknown keys.""" + from hermes_cli.web_server import _SESSION_TOKEN + resp = self.client.post( + "/api/env/reveal", + json={"key": "NONEXISTENT_KEY_XYZ"}, + headers={"Authorization": f"Bearer {_SESSION_TOKEN}"}, + ) + assert resp.status_code == 404 + + def test_reveal_env_var_no_token(self, tmp_path): + """POST /api/env/reveal without token should return 401.""" + from hermes_cli.config import save_env_value + save_env_value("TEST_REVEAL_NOAUTH", "secret-value") + resp = self.client.post( + "/api/env/reveal", + json={"key": "TEST_REVEAL_NOAUTH"}, + ) + assert resp.status_code == 401 + + def test_reveal_env_var_bad_token(self, tmp_path): + """POST /api/env/reveal with wrong token should return 401.""" + from hermes_cli.config import save_env_value + save_env_value("TEST_REVEAL_BADAUTH", "secret-value") + resp = self.client.post( + "/api/env/reveal", + json={"key": "TEST_REVEAL_BADAUTH"}, + headers={"Authorization": "Bearer wrong-token-here"}, + ) + assert resp.status_code == 401 + + def test_session_token_endpoint(self): + """GET /api/auth/session-token should return a token.""" + from hermes_cli.web_server import _SESSION_TOKEN + resp = self.client.get("/api/auth/session-token") + assert resp.status_code == 200 + assert resp.json()["token"] == _SESSION_TOKEN + + def test_path_traversal_blocked(self): + """Verify URL-encoded path traversal is blocked.""" + # %2e%2e = .. + resp = self.client.get("/%2e%2e/%2e%2e/etc/passwd") + # Should return 200 with index.html (SPA fallback), not the actual file + assert resp.status_code in (200, 404) + if resp.status_code == 200: + # Should be the SPA fallback, not the system file + assert "root:" not in resp.text + + def test_path_traversal_dotdot_blocked(self): + """Direct .. path traversal via encoded sequences.""" + resp = self.client.get("/%2e%2e/hermes_cli/web_server.py") + assert resp.status_code in (200, 404) + if resp.status_code == 200: + assert "FastAPI" not in resp.text # Should not serve the actual source + + +# --------------------------------------------------------------------------- +# _build_schema_from_config tests +# --------------------------------------------------------------------------- + + +class TestBuildSchemaFromConfig: + def test_produces_expected_field_count(self): + from hermes_cli.web_server import CONFIG_SCHEMA + # DEFAULT_CONFIG has ~150+ leaf fields + assert len(CONFIG_SCHEMA) > 100 + + def test_schema_entries_have_required_fields(self): + from hermes_cli.web_server import CONFIG_SCHEMA + for key, entry in list(CONFIG_SCHEMA.items())[:10]: + assert "type" in entry, f"Missing type for {key}" + assert "category" in entry, f"Missing category for {key}" + + def test_overrides_applied(self): + from hermes_cli.web_server import CONFIG_SCHEMA + # terminal.backend should be a select with options + if "terminal.backend" in CONFIG_SCHEMA: + entry = CONFIG_SCHEMA["terminal.backend"] + assert entry["type"] == "select" + assert "options" in entry + assert "local" in entry["options"] + + def test_empty_prefix_produces_correct_keys(self): + from hermes_cli.web_server import _build_schema_from_config + test_config = {"model": "test", "nested": {"key": "val"}} + schema = _build_schema_from_config(test_config) + assert "model" in schema + assert "nested.key" in schema + + def test_top_level_scalars_get_general_category(self): + """Top-level scalar fields should be in 'general' category.""" + from hermes_cli.web_server import CONFIG_SCHEMA + assert CONFIG_SCHEMA["model"]["category"] == "general" + + def test_nested_keys_get_parent_category(self): + """Nested fields should use the top-level parent as their category.""" + from hermes_cli.web_server import CONFIG_SCHEMA + if "agent.max_turns" in CONFIG_SCHEMA: + assert CONFIG_SCHEMA["agent.max_turns"]["category"] == "agent" + + def test_category_merge_applied(self): + """Small categories should be merged into larger ones.""" + from hermes_cli.web_server import CONFIG_SCHEMA + categories = {e["category"] for e in CONFIG_SCHEMA.values()} + # These should be merged away + assert "privacy" not in categories # merged into security + assert "context" not in categories # merged into agent + + def test_no_single_field_categories(self): + """After merging, no category should have just 1 field.""" + from hermes_cli.web_server import CONFIG_SCHEMA + from collections import Counter + cats = Counter(e["category"] for e in CONFIG_SCHEMA.values()) + for cat, count in cats.items(): + assert count >= 2, f"Category '{cat}' has only {count} field(s) — should be merged" + + +# --------------------------------------------------------------------------- +# Config round-trip tests +# --------------------------------------------------------------------------- + + +class TestConfigRoundTrip: + """Verify config survives GET → edit → PUT without data loss.""" + + @pytest.fixture(autouse=True) + def _setup(self): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + from hermes_cli.web_server import app + self.client = TestClient(app) + + def test_get_config_no_internal_keys(self): + """GET /api/config should not expose _config_version or _model_meta.""" + config = self.client.get("/api/config").json() + internal = [k for k in config if k.startswith("_")] + assert not internal, f"Internal keys leaked to frontend: {internal}" + + def test_get_config_model_is_string(self): + """GET /api/config should normalize model dict to a string.""" + config = self.client.get("/api/config").json() + assert isinstance(config.get("model"), str), \ + f"model should be string, got {type(config.get('model'))}" + + def test_round_trip_preserves_model_subkeys(self): + """Save and reload should not lose model.provider, model.base_url, etc.""" + from hermes_cli.config import load_config, save_config + + # Set up a config with model as a dict (the common user config form) + save_config({ + "model": { + "default": "anthropic/claude-sonnet-4", + "provider": "openrouter", + "base_url": "https://openrouter.ai/api/v1", + "api_mode": "openai", + } + }) + + before = load_config() + assert isinstance(before.get("model"), dict) + original_keys = set(before["model"].keys()) + + # GET → PUT unchanged + web_config = self.client.get("/api/config").json() + assert isinstance(web_config.get("model"), str), "GET should normalize model to string" + + self.client.put("/api/config", json={"config": web_config}) + + after = load_config() + assert isinstance(after.get("model"), dict), "model should still be a dict after save" + assert set(after["model"].keys()) >= original_keys, \ + f"Lost model subkeys: {original_keys - set(after['model'].keys())}" + + def test_edit_model_name_preserved(self): + """Changing the model string should update model.default on disk.""" + from hermes_cli.config import load_config + + web_config = self.client.get("/api/config").json() + original_model = web_config["model"] + + # Change model + web_config["model"] = "test/editing-model" + self.client.put("/api/config", json={"config": web_config}) + + after = load_config() + if isinstance(after.get("model"), dict): + assert after["model"]["default"] == "test/editing-model" + else: + assert after["model"] == "test/editing-model" + + # Restore + web_config["model"] = original_model + self.client.put("/api/config", json={"config": web_config}) + + def test_edit_nested_value(self): + """Editing a nested config value should persist correctly.""" + from hermes_cli.config import load_config + + web_config = self.client.get("/api/config").json() + original_turns = web_config.get("agent", {}).get("max_turns") + + # Change max_turns + if "agent" not in web_config: + web_config["agent"] = {} + web_config["agent"]["max_turns"] = 42 + + self.client.put("/api/config", json={"config": web_config}) + + after = load_config() + assert after.get("agent", {}).get("max_turns") == 42 + + # Restore + web_config["agent"]["max_turns"] = original_turns + self.client.put("/api/config", json={"config": web_config}) + + def test_schema_types_match_config_values(self): + """Every schema field should have a matching-type value in the config.""" + config = self.client.get("/api/config").json() + schema_resp = self.client.get("/api/config/schema").json() + schema = schema_resp["fields"] + + def get_nested(obj, path): + parts = path.split(".") + cur = obj + for p in parts: + if cur is None or not isinstance(cur, dict): + return None + cur = cur.get(p) + return cur + + mismatches = [] + for key, entry in schema.items(): + val = get_nested(config, key) + if val is None: + continue # not set in user config — fine + expected = entry["type"] + if expected in ("string", "select") and not isinstance(val, str): + mismatches.append(f"{key}: expected str, got {type(val).__name__}") + elif expected == "number" and not isinstance(val, (int, float)): + mismatches.append(f"{key}: expected number, got {type(val).__name__}") + elif expected == "boolean" and not isinstance(val, bool): + mismatches.append(f"{key}: expected bool, got {type(val).__name__}") + elif expected == "list" and not isinstance(val, list): + mismatches.append(f"{key}: expected list, got {type(val).__name__}") + assert not mismatches, f"Type mismatches:\n" + "\n".join(mismatches) + + +# --------------------------------------------------------------------------- +# New feature endpoint tests +# --------------------------------------------------------------------------- + + +class TestNewEndpoints: + """Tests for session detail, logs, cron, skills, tools, raw config, analytics.""" + + @pytest.fixture(autouse=True) + def _setup(self): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + from hermes_cli.web_server import app + self.client = TestClient(app) + + def test_get_logs_default(self): + resp = self.client.get("/api/logs") + assert resp.status_code == 200 + data = resp.json() + assert "file" in data + assert "lines" in data + assert isinstance(data["lines"], list) + + def test_get_logs_invalid_file(self): + resp = self.client.get("/api/logs?file=nonexistent") + assert resp.status_code == 400 + + def test_cron_list(self): + resp = self.client.get("/api/cron/jobs") + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + def test_cron_job_not_found(self): + resp = self.client.get("/api/cron/jobs/nonexistent-id") + assert resp.status_code == 404 + + def test_skills_list(self): + resp = self.client.get("/api/skills") + assert resp.status_code == 200 + skills = resp.json() + assert isinstance(skills, list) + if skills: + assert "name" in skills[0] + assert "enabled" in skills[0] + + def test_skills_list_includes_disabled_skills(self, monkeypatch): + import tools.skills_tool as skills_tool + import hermes_cli.skills_config as skills_config + import hermes_cli.web_server as web_server + + def _fake_find_all_skills(*, skip_disabled=False): + if skip_disabled: + return [ + {"name": "active-skill", "description": "active", "category": "demo"}, + {"name": "disabled-skill", "description": "disabled", "category": "demo"}, + ] + return [ + {"name": "active-skill", "description": "active", "category": "demo"}, + ] + + monkeypatch.setattr(skills_tool, "_find_all_skills", _fake_find_all_skills) + monkeypatch.setattr(skills_config, "get_disabled_skills", lambda config: {"disabled-skill"}) + monkeypatch.setattr(web_server, "load_config", lambda: {"skills": {"disabled": ["disabled-skill"]}}) + + resp = self.client.get("/api/skills") + + assert resp.status_code == 200 + assert resp.json() == [ + { + "name": "active-skill", + "description": "active", + "category": "demo", + "enabled": True, + }, + { + "name": "disabled-skill", + "description": "disabled", + "category": "demo", + "enabled": False, + }, + ] + + def test_toolsets_list(self): + resp = self.client.get("/api/tools/toolsets") + assert resp.status_code == 200 + toolsets = resp.json() + assert isinstance(toolsets, list) + if toolsets: + assert "name" in toolsets[0] + assert "label" in toolsets[0] + assert "enabled" in toolsets[0] + + def test_toolsets_list_matches_cli_enabled_state(self, monkeypatch): + import hermes_cli.tools_config as tools_config + import toolsets as toolsets_module + import hermes_cli.web_server as web_server + + monkeypatch.setattr( + tools_config, + "_get_effective_configurable_toolsets", + lambda: [ + ("web", "🔍 Web Search & Scraping", "web_search, web_extract"), + ("skills", "📚 Skills", "list, view, manage"), + ("memory", "💾 Memory", "persistent memory across sessions"), + ], + ) + monkeypatch.setattr( + tools_config, + "_get_platform_tools", + lambda config, platform, include_default_mcp_servers=False: {"web", "skills"}, + ) + monkeypatch.setattr( + tools_config, + "_toolset_has_keys", + lambda ts_key, config=None: ts_key != "web", + ) + monkeypatch.setattr( + toolsets_module, + "resolve_toolset", + lambda name: { + "web": ["web_search", "web_extract"], + "skills": ["skills_list", "skill_view"], + "memory": ["memory_read"], + }[name], + ) + monkeypatch.setattr(web_server, "load_config", lambda: {"platform_toolsets": {"cli": ["web", "skills"]}}) + + resp = self.client.get("/api/tools/toolsets") + + assert resp.status_code == 200 + assert resp.json() == [ + { + "name": "web", + "label": "🔍 Web Search & Scraping", + "description": "web_search, web_extract", + "enabled": True, + "available": True, + "configured": False, + "tools": ["web_extract", "web_search"], + }, + { + "name": "skills", + "label": "📚 Skills", + "description": "list, view, manage", + "enabled": True, + "available": True, + "configured": True, + "tools": ["skill_view", "skills_list"], + }, + { + "name": "memory", + "label": "💾 Memory", + "description": "persistent memory across sessions", + "enabled": False, + "available": False, + "configured": True, + "tools": ["memory_read"], + }, + ] + + def test_config_raw_get(self): + resp = self.client.get("/api/config/raw") + assert resp.status_code == 200 + assert "yaml" in resp.json() + + def test_config_raw_put_valid(self): + resp = self.client.put( + "/api/config/raw", + json={"yaml_text": "model: test\ntoolsets:\n - all\n"}, + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + def test_config_raw_put_invalid(self): + resp = self.client.put( + "/api/config/raw", + json={"yaml_text": "- this is a list not a dict"}, + ) + assert resp.status_code == 400 + + def test_analytics_usage(self): + resp = self.client.get("/api/analytics/usage?days=7") + assert resp.status_code == 200 + data = resp.json() + assert "daily" in data + assert "by_model" in data + assert "totals" in data + assert isinstance(data["daily"], list) + assert "total_sessions" in data["totals"] + + def test_session_token_endpoint(self): + from hermes_cli.web_server import _SESSION_TOKEN + resp = self.client.get("/api/auth/session-token") + assert resp.status_code == 200 + assert resp.json()["token"] == _SESSION_TOKEN diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000000..d8127f96e03f --- /dev/null +++ b/web/README.md @@ -0,0 +1,48 @@ +# Hermes Agent — Web UI + +Browser-based dashboard for managing Hermes Agent configuration, API keys, and monitoring active sessions. + +## Stack + +- **Vite** + **React 19** + **TypeScript** +- **Tailwind CSS v4** with custom dark theme +- **shadcn/ui**-style components (hand-rolled, no CLI dependency) + +## Development + +```bash +# Start the backend API server +cd ../ +python -m hermes_cli.main web --no-open + +# In another terminal, start the Vite dev server (with HMR + API proxy) +cd web/ +npm run dev +``` + +The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend). + +## Build + +```bash +npm run build +``` + +This outputs to `../hermes_cli/web_dist/`, which the FastAPI server serves as a static SPA. The built assets are included in the Python package via `pyproject.toml` package-data. + +## Structure + +``` +src/ +├── components/ui/ # Reusable UI primitives (Card, Badge, Button, Input, etc.) +├── lib/ +│ ├── api.ts # API client — typed fetch wrappers for all backend endpoints +│ └── utils.ts # cn() helper for Tailwind class merging +├── pages/ +│ ├── StatusPage # Agent status, active/recent sessions +│ ├── ConfigPage # Dynamic config editor (reads schema from backend) +│ └── EnvPage # API key management with save/clear +├── App.tsx # Main layout and navigation +├── main.tsx # React entry point +└── index.css # Tailwind imports and theme variables +``` diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 000000000000..5e6b472f583e --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000000..c9f0d18e1a14 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Hermes Agent + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 000000000000..d9aa7a9513eb --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3835 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.2.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.56.1", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/type-utils": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", + "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001778", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", + "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", + "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.0", + "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000000..87dbfdb79c93 --- /dev/null +++ b/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.2.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.56.1", + "vite": "^7.3.1" + } +} diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7a949324da9d03b448247867439b632a216d884d GIT binary patch literal 8482 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 literal 0 HcmV?d00001 diff --git a/web/public/fonts/Collapse-Regular.woff2 b/web/public/fonts/Collapse-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0d2e477cc1e2618ddcd7e6eedc06b40a2f837c95 GIT binary patch literal 62816 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 ZjHNx0dVHpbV)n^3kG*g44fWp4>>qBYn704` literal 0 HcmV?d00001 diff --git a/web/public/fonts/CourierPrime-Bold.woff2 b/web/public/fonts/CourierPrime-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4f6d5e9c863cad49d54112e119f708ed9f644d74 GIT binary patch literal 11588 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 W$IF>aZ(pA4YyQ(diTN+Q1ONc)ajHWA literal 0 HcmV?d00001 diff --git a/web/public/fonts/Mondwest-Regular.woff2 b/web/public/fonts/Mondwest-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..02a3658cf7da2e9e6f9cd9bbb2d9bff7d66d50fa GIT binary patch literal 23828 zcmV)0K+eB+Pew8T0RR9109_OS4*&oF0kH4@09>*F0RR9100000000000000000000 z0000#Mn+Uk92#;Py&N2fat2@kh++{43WdZ-gsngei8cTMHUcCAl4t}V1&j;_p%+`K zUvWmfeToX8J3ypSf61B^(`_Co&>fJSN|n;g4#co=0PxQAOZNZ&|0gDuF*J=Mg%(is zciZ+e8TV>hWb`q6HxYXrF_gImn$b!()%~Y5PCbK1iUI*wM&2S@X$mzc!vFh&z>3$ReKP~5%{nJI zryO8^ni}r?{p50^*S@lb#9Y)&dX46I$PmtPAl){jp zbg@v0Vn?)TTT>wyB341}>Oe%Ra>Rs)T|yM1#!kefu2=P=^+@gCp#9npDk1U?jV1{~ zB_z=<8WItj>Q_xC=KD@ebyl?1(={n=ftkneV*ue~gkob|N{#bRaKrcSq$tiJolHEK zh%Epv*KCiHmW7Ql&+aWLK;PM3=%%5`<)~6U6XZ5`#oR6Jf$;Ap7yzn?Rv!v642>9! zHeiG?hgiho3n6|SwhgC~Xrtp@r-Sc$mr0v6y?G~=+q^gM{LF$_3Lb1$WERAVSc_Qr zd_gRTHHei@4zb=Z;^lKitOdXR(h6Lz{}+!LEuZc3VcYgLFr(HN1vgwJFJSQLZ>0f7 z_1-_;L`{10W!(a`#0!|nlJ6|(nFDJ1i#aQS9k-GdC(ZkN&{-bHz+$^_XITgE|NsA% zwci+F0#jm;7-2$cgo(teb+QM-+#C!II`#(IMPgihlrVvDiE$A>xBwsLcKgV7RkessWeZD4@04B`I-k$T8?fvjI}l zi0(*g!~r$VYqZXjYmBqmU6N@wx^OO25KeqSdKcuurRRUCR;Ix6s2y$1YIjaPGd|J< z;msMB>9UA;{ky-2W)s>WIeX3+{O(2rBr*2R$MTs{WyU6?t-g>0eu8lq6u<~Ze+obdfQK45Dyv9N>J5Dq|5Nb=T1s;h1{CRlVEN$VxOGSEe zRuLGs^-1Yfn3bH1l8F*0MPB<}8z6zc+=jA@V~uf%G4}30@2fi#E95}(X_p35%n(d4 zMi?Q45JLDu$;`~_c4CE*q$B_{rPE;vWe7ts!U!XRFs>0kVT8|^^gGtnf>pW}A&X2< z5fO1j>3n>-n9h%-_0MEBnWLF@&TN}z66*yj@d6bQ0R@$2_aChQ$i-XcXJ5;jjwyF& zwmE>YU;NBxb4a0x6qh8GQ;t+kHBt>VNbR&ErKL%obtYwGNDD2-?JpB|v?4s2vBJ}5 zY%vL700ETRTI2KIhY#=`yvac>3I9bs3n+B%^T9x!2GjjtbYPBSmvzx_laTMFe(lG5 zO6z1DSKZxUbpUdh1So|{s;;5d(uSI@&_*QjKM2|Xhg{b3Im5D{A&L9Q0OJfAQp-Xk z`E-JOwq*a4S{}(d6VPRk%xZjcQVz4h+h#&c7W9!%8lkR*RThl09!3OAr>fKw-5Hip zndKr{S_(`uCmK6Abn_`?F{$Ha{htboF0mL*Wuj!_%EXshAPb?)n#*jwOeV>~RGG|? zh50dRLcF#%F~{4ToD*(JuJZKUGIR2F=U_RUAQ$DKs00v)1hie(-wmmT_uoZ=_VO4j zp&0x-yUHp-WmqmN6RXoKyhmMOncNFrqZQH(^9teDs5Ng8Mh}CNP|<5V|CW=s1UfNN zF=w%d`8mN38o`ElG0dA3y`lE=qoHgwsfN>!6*XwDnW)5@HBX_LU)UN19;P-4HY4O_ zQxnU*i^&fJ70EVQBug4bK`?0BD^+PBDUtl-82CoWw$=7=aYlWy-MmUCspSnICG(aq zmTZJ;em2{rLe(N2krtpcvSPx@P2qV?nwv;r)sIxivQ(>9?lqn6!K=^lx@r`@x9G(g5N1Ev>ApZ?sn?sE6MyY}yy z0ELm*nmG8Iuh=bOln(LFk+`fgXb&paCio7B2rKkMm-m6~+GKc(;k`2niUTCMP>~Ly zKq#}AS_G}IM!78PC~oAD0J&?j*n3-+Y?esA;oeoU<+C$P1+1Asj_rQIBjU+Q>TbP= z@|;ISI~DE^%lSqC)Ba^cjKy4TM=f}iH8hy+FPVS^Ya-^l%)V!1>#%H)5d<>1G;m0= z4u)V0-TY*DyZZ{xvPN=`UE|YB4q#25g3)ln0gPQ^bc86u)5wbyJ?mUKaT3Yn8wT0H zNM;mIa|vDnilDWh6y={hUR755{{q2hE(9bNHP-nMQ!cgjG1or#{g6;5EL<5885JE9 zt5V0sCnP2%r=+H5WM*aOpb9}Xgh>MBBa6TH)*)lmiy!4d&*>x- zN|C7y@SKY{*y1B8xz%4ruU7hR#bs@Sz!uDeaStIFN(e&l}UeieRi_!E7j^pEnN#5)z5i-{#k z9_wIOnGK}KmP_uj4~8lr=2fYI zsijfHO?T$=@LON9kX|e@g%FAQ(o} zs+d@Ugd|C6q|NH2!^JVnvhW5?)}vPvuF-t8s@$gcDZ|6Z z$Hzw?5JW^oL_|bLBmuz~+-FwMTvpIr*3cTdM(}|erACc4YW#r(D;$>)MTkNrY>NCx zM-Txhz;J@3a%*I>Y8b2_<(T`cnr>tb=FeTn{WTc!3kr*hOG?YiD=Mq1YijH28ycHh zTHD$?I=h%`E?+2?%9U!Z-e|VkX{X!k4~C=hWICHDA6untod13g4N20;Btfo}+eG6xuhaLuK|j*BfQ)KF_m*V^$&!r9s!w;T9UIY? z(~SVaLOrT8(Mx!3sj=vNPw8R85Z4-pRq%O)*#3`NbG~Qz27;slM!^BpEC?X9S=Ry@ zbbyskj>QC$WQuPfg)q;Qg?ie&W%r#^ZY{FEMw5U-r4a*KUX4fV@77<{Qc^`Kg-dg! zNSulGYcIP<1lm-$)M`{BBa0&vfh_4E6+9w%Y(x=UM?xqne^KF!N?ug#qB0j1xTv&6 zMW@Vfy2ErsWHy%~yO#q55{Zl~1Q8`5Li`bEXlQ8Y=#P4B)TquzWsK^xg4!=)0}WMN z<4rYRrgzFYmt0$vR}3h_J=K<8UOJIErgqb?7WJ+R9q=73Lu>~(@4hBM_5aM^;!yuV zDTJmEd~M)Bu`Qz4^hg)wtZi5-P zjRoK~6^Pqh5N=DsxUGfYwiSxoSvV%y9Dv8eV)6+oDJ}tEak)qe0Te2F{lC*Gd$xhRfWl>wAB5X4CmpB1Shu!oN0@k5HkSUpUVjQ3mlRzjJ)vtMNS_ETMado<8ZFqR$SS! za@*-VmU}5Z7P!fnV@5K$9TN5|3h^4^yc03!k0@VKRz>Grc>ZF?r_D5ScjTD{bZS4i zw&(b2=zk}q!)>ch^ZchF^=I1U5@SuJeotlD{{Wf&S9J8`Xd3{=u4)8GFI;2pFa&=*FDEk*7(1VMc)Lo**!S08VLm-l` zazmiZ2sA!pH8ng#i5ZOyW!f-ut}l?G?whT&%2mneZ0ve}L5nc2u#_Pa*Ln4EVr>>>!)SG}6HP+Gn-E`|kGLwyvkcEl-v; z|0Cg}c5|sY6?ogpY-QdKymj%W3;+QDU^rkb02u#;ruzNH+zifs?mi+tTY&k}!W5mG z*V6g668lv@`la3D;rDaTBhS317xbcRpjXucy{Q%Ayp7$(`!(`G`q)jMU|F0mu+782 zfq;es(L?Qb&F<)RsUY;QW? zBz_L6PYGT#H-@SIk8?BMX_T)Uog0nQFI;Q$k4V}Wm?wp+u$G&F>c$~-&FzxIXU3|? zMz!Im-PO2=jAC_rqW}UVU2RPul7^B}BqV`RzzF9uYy@`bD-HG|OJvqvc-|YCxVZ(w z$442@W->4-!+W1!lL%!bzyMebD?nV<&*8j+%&Cw4^dO_GqX0Z{j!GCpIMyi|$px&h z$3f_r9*WRuCPj`f}ibdp)%>Bi;X5!l-rOzhVo|0#gm1J6S)Ab z)8jH3ot!#?(gIVJQvJGltRqH13Prae8ZX^KB#cGJ9%V!ZK))WRDyT@^(Yld>&jC8R zIqY1n1OY1D?rXi#2Xvf=<{c%xOn&S`Q_e#19YKVB&l-b;{JB1N4)co=lT%% z^)(EaF&1&dq#fBgKa3X4z^$0fjJK-`WY*NvMn{o!#+OVjtEdqu4We2K@TK>O4|3)f zfDq6?GM5wwqfBxV*9RM-L4=pg_}ONpL}R7*$_z39EpEQR*0mo0WC2QS*-}d;r+7vu z+_y$bIV2v2V0Cv+JixRPuj_P-AVEBv1F*H3s#IGwDZ6f*7)w*t@ki8p{5LFX%Zc6L zkZ+Ih1v6q}mFQm_+=?)|cOs5jK4^XkZ7AAih9D#M26>2RpGz(4x(vzNKo;s7T8!Di zG_dRLT}5PD({j{O?X^3@JN641ovlUqtVfeR?ewbm3J-8Xlz2BJCBv|r(#4KDbay9MLx=3gx561RuJoKN)_5n ze6=6>FasKqf(X$+dKW2O#|*WCNN2M(J6XzR-dA6fT|b;!+Jke0C~9ZRG)+Pgqysw$ zPkTddU0Kk&qQ=0InxtJ6{L+7LRORk6gC%txEJ$J!sDmKKm4vOYmo&|Ekf2Mt#jjd} z3iIHJLX=6*7I0A@AqiY}CrB8o%ENZMG1jIglQY5k9pwe_n6N(rCPK~eW-s}zo z+#+{z#sBExF^FTiG1fIGeN2LC6iog*Wz243yta@GZ*w@U1foTyqaLA#4F6PaNajB|0!P?N~A`_ z996}WkXyB#(&t=W%;lCY+of6rPlB66N0_lx6m=v%iIr#Us8oZ!b=c}aEiidAV^78DPrb?aDQ|tPE4)E>zv@1q*UEb3pJ*>4z%H2G# z$35Rx)xk_;8yu_X%kr3VG!Kc|Ai6{{(iumW6PdQ}&*J0D4lrMU^>=M%owfp48V8}nFI@lsYI*|m=jVlCj-y&c( zR4!r#$Ssm?i+NOO);08H1?QpMBty0C#go%M2ALPta~_=ig;B*k-~k%o?g4-Zq4JRS zx>2}2rGn#T3YEv_xDvQ{BLgP$<}GpfcGF1&ZJc?7oky$pOtgnwG`0wrViPKg+YJV` zWJo3vU{-`vXf{SDFBnK4Di%Y1Zk`x#$<*PU9%v{)U@&(haIs| z97NOu0_qseo}-DH4wk|+6@qNP1+;gL;*F3B=fICLva*mDWDBHfy6d6b;qspM=4{2nhKkxz(o?7gK3E^+x37ToWD#lpsdri2V8 zsbPhckrvC_99P{=N&q91ih)2tI+Dgf|pxdXCi;eijyWCajE9X;YzQ(NER1e->+2s}?(fT4PL>xM4`Y z-xe>UT`!R9y(tiK8NkBy2V&UmNBNcpS5A@F7ZtmcZSvz_$JD^SG;ECC9|=GQMevL6 z$Y7(`legJ&$3kxutn3SW{#7)$K#B# z0#miJ3f`${R@xRGelNK!5tU)}AHi0f2VWo*PmXtIP6Uq9Do6X=K>^c<4GYfhF%M8zpra1u>|i;9F!S_f z!3GfSVHg?XmEi&TS{1@IgsE;g^3yI;mm2khj!a8!+r+$o(r2;(VtDDh$dkKjMyan! z1TLz*nMuiEAI6BbPs(rla~G3h^$GssxDlSvV^=*T)?{sq{wv5 zcH&Hx|LIbRKs=Ca<*W!NnGxG0d@OlVU`I8zBEf|8{=zEVGtWmep)8bnTtU?nf#UGP zkl<7x@=g``t$||=tRkaJa(`Zzdl}!(#f;u86>jj@|Ej)sQDk$}8LThTpfI~8=$;odX8|Pr4pXPxpKcY0VjAR09DzG| z|I}BVel5Skv@dhQ8^+d)ILk%N;{1aB18Ifwt6`Da_13*uVx+66D9q($R+SpfGpg)7 z$gEjjJ`5aN>F2g4{=pIWCKnl4;DT-Zj_5ZYJp; zsCZ2&<@iInQZ;T_cyEXJ?%4Aiq#8p8jdu=EXVXLrQ1u~JwXenR{IbtGNr;U!h)9ul zk{a7mP-Y(Ww69)WirA<3jZ${$Nf`U%z0!g}X_VXxvEO3dV?4ieL7VDydjuEFtQA|c zLCtKuJ8uxu)XL?RCq_Az=FF1`(X@V-DbO4oj(Q3|j!<)$yhgq9mvrT66s>M|b9nKM z_}yF4iYWpMu>Zjr38w2l_DWw~>>zLIeh(W5xg$j}c#vnzFk|iFQ6yW%ID8KSkzQvN zhFBM1n$7|T0D}eBiK2jDJi+Okz z)LvYEp;D83uc%{;XV{*x@TfI0I^H_kV>OL#2maW4_cEN$BI1-t#Csk|RREZbIPsf5yN&B?vQpG*b&^!;R!^3vbM(Rmsgd>My=+1wO7M}`Gm^2S z81mBT{W&hleXLi~CFgCIUQP?;BKd(U^F%2aZLq}6tbxz{KUAYRD3>G93>H-crD+;`ve{af8?E zou>5>m+I*W#N|yYDoEG#Vd0Z3gr;@onRC=Ayz79lQ8?{WNzu9wwypMoEaBx+m{UaTO(MU6@?|U7_Luf(RL#c!!AitiztKDQ96AI$slpI4dl{l5AHa(Siswn`HsfB&)IAq*{@=R>NUKjV zhC1Nl>-Z3~PrnkMg*~pyxEk?cWLt_3pm7Ehq4xm}V0~p1Brl$fuEHsqNp18?&KemD zDZ2(`)ZcGCzG5f2?#)zz9S%cKV*=c)Vm?1F;xM+auieP8r%?@WO){*Btxt(sxw9$81Cx zC4KlP*=?OtJaT%Pa9Nh`u6=%_J1dPt))z6m2NOfg>AN7McM|)Xq0v@Y7_#hVt&XsH z@kcbtn+ij~W0Tq_xc>23DY&CYZc7w0+w&k5vT;~#+w&k5>XM_AIK&wkqC`KUQUsog zcZJoty~apz<9N58LGiLw6VLv%XZ#Q z+j&4Ib7<30QGF^TJoczACWlECYzhyZf8yjT*!#@iDS2-#_2!+rDt>?*BPruypR*(s zre{-KK0ICRbmw3+m+BT|_r5qkkd5TNjq)~{3TF48;B6;s?!RFzB2GIM9$;mnLXp9( zLdL`+0Ah`gb$Sw(#!nuVAe5#0U_OjsJBu*hN$yFBzAI(bm*cwO$5?leZ8+G)?q0^M z;3|J9R=4$iWfg56|L72BRMZd+z~dAw)0mUSD3JOz%PE{Ofjes|S6mSeukQJyfcltu z!GLOHyYxL(wNp-+60j{V^%O$MF7;gLV0YCfEw`?ljYrpFme7i*nZdc^oK$L44S_-Tbj zs;6H)HG4j*r$CE9hzH8MYZmQe5P%PcoPuO;ZyR56X3tZOTM>TxPG=e85i|pZ+i@Sp zhwpGy9mD3%xbk)otm)fDZ?ApevEjVez;iEs!oiIlu>Kh4OWTtK=6o>}1`;Wej{8vad?1&ZP<#JlVpE_esgW<`NCF3*6A zW4@nEcZiCjSAYN0)Q^AkUEP}=zfW`SBuu6)QOhSGPdxi2s>=EA-z!$vQ&Xo(OBd#c z)iI95_>}nCY7jhAQ_DidP7tzD9Jnl=a}_o&et|s%$|aCd=tk2P#NuGwtQ)Bi8NWNv zgW`f+sgKWWRLV!ZFc}|cRDPY7so9hj-V4d|>2L&2>gnIe6HaqRjsEKH;;e6Iyjx;v z?x#6`BqOr?vPWH|_S$KKrG#d^m8wh`l(h;F1tmI>)UA%*62>!~p;n4dDjIuQ;1(yp zHH}`5wLVTSE~R{KhOl|l71{mZdsQyR1v-=Ktw#ktT8xz6c2XYqeT3=+MPhTa^EpXR zt5>UO0&3)ai=)D3T!fj_2LL6c-$MFBE1a@VCpQXarG^&a{GpWZ2nb!}B6U+xkRwW% zzKkB;ni%2D`R1uZV=%Ua32s`~o>& zD?g~V`BS&ODH!!0v#U%6HHE|b3G}-l)S4qN*yzmSfz!cUl~p<`C6(-@?{7zuBj#JV zABP0T?F;rgr@*h*gf!_;8UN=&t2hc*`wL_9ge&Q)?QeDClDRvtAj4~{X#obN2&H0chvlCag=@g3fM{LF=l;mS&5-ui<4F0U?a z-LnXrg>@HRTs(Nf(j8)avkPU4cdgSXHXWmcx|D`ZpRW_8*?`^K!V;|rF8^VjYsMQv zB}&IVH+{);ys@B~e(DY+cx{C>VYBM0eEK{6X-e4Z-R}%8~F2 zQudD``@jMd6dT3v7ql?OWADL>g3qFMHP%XYUInjnsJEVQk#B8aTg3DVVFKn+)n3I4#ZYJ|$a4PpQfO_guelczwwN^1t&BZX& zWgE84e7>VVOsXvAuli0&bpVdR-sa`u7E3ifS)3b1Dm|jiZF=Cr2*}X`f^2Uc_ZFjx z5nqAwY~$ve8@Acc#xu!SP01!fSs4o=zdY$q#o7C0u2(!CspS}oNP~6l04Irj`vHuI z<=t%bwF~1PMPjcX{LZ%;8*RetqeKMF_rPgX2seRO`m}S4cRdhGvfk~Q7PPLxWaLC) zt%4J{^~W7nd?H%zD?&SEmdK_+@lh&wv1cbMUZfmriWt-ml zZr(gkkyIH|9I6mojLqFDXK*ywysUI%pwSe|s_u&vpD7>gw&Bo$@uY@h?%KtRIzqudU}VSEM=v zL|^xUj=p=?@TaVU$;OM}#(BZi)MPlr`j9;#lT|-`F4fMycguo3^Hq6iWCbrb3iGH( zcdK4$z>^hjUeU!S3G))(JPHhr3!5TRalXkSsQ)GiZzS=K!RC*ISt97aRcEdL?K++H z@7O8fY*yaF^0*6N2TnF#cpF44li4C(=YMOD3A%bi^J9jvnQ|aRJoU-{eLLI_I!Ft& zfg*zyKG)`>aO-~CM^Zyg`w<<+tL4Dxv>By^D{#g$A*6Z#`F~KkDH_}8>(lYHe=BTX z#BaZ}2XhVdLQ&r5Ude6o`tM)*Y;H}k_j~eF`*_2p-{!Nbf5regv56(0n9~n@Po7*^ zG!73VjTgAW%LXUnZ~zr50*p7!2sTdOzjH!$N(pLtH472o_NtSn?!)YWd4Q9rIL50B zrFDe>h(jO%H5jNt6bJxcivZXGD&up%6T5&d^fvijhhvZ%^WIkKY!?ch{_J%alr?ZT zFk?kt>c;sDM;iiVg2p*2_4HZ#{5DgZ$)QM=JAqB_*TbR7) zyWSeb8lgSGXxk~lUgnHc2r7mIBSC^d1+YM|mLw?3!9JCs@Px}AWw~c0WMMA`CthuL z+5JHU@fbseFT?2OxAT0gZ9P#;hL#L7wHS6TaDgkssw9$Bcos2JfYnVLJuVUrD zmT_kPZv+)VAy-i;1ZaOl0_|NseQ79sn#q=wuB+-n^rX(MPJ;Bw6x&Br=n`dhbbvF` z%DDde&v&~d`~sgnHgJf=sYReoyT80YgJa^uwot|w&jKnS*PYJj%MJAY-~_acMA!Ge z%Nmy!Nc?e9pjILSWaHHyAf%wubrN2+>M`ghs&=hJcjS#i8tE=2jT^qUQjOF<@Nk3J z-Pa}{(b5+&(&1oo1Et~&gQ)HV;%MUVy0i7N}b@Gx!HNH5LW+e*BMAQn#(#BvwF`UU{`UcuQu z>p1$Ki&Js<{1#N|0dt+aauos(sydEf{*u!f=oYdnZgkRePqj zI0EHxwAih>V7P^>+hes_#X)uFNsMbU)(bbjTX-Ly_Ye_NKo#g_pMMcHYo*{8L3EJ} zPC%z9#@1?VHsmU={xa8Gt4isl>LT4GijrX%^kaD`-pnF;eC3pOH}S7i@rGef8Gt?% zuD~1UHFSCq#_>$KU>@N5b!yjM+$KoGXPo8seEmLT;QBP-R9K936f%`b(3*f$^?16+ zwJ+rKTT*j+BnX0%?C7YfW5}ZGAavDm@IqdEwZ2>;^~%N*J-Wsfiu*yf5cr{=r8lfn zg}j0-Ajwt5`UV0jPRaJWs>&(>fk8aw1;QXEAbl({h=T+%GC1LUit$+FRZo5p-^$ry zIvg~j1POfWM*tJg{u;*3pJSHWYqB!eWCC=5PJgp`_Ab)9B_~w1>PlcaS(n%0Q2jA zo!qp<)L7KuYW4o+15u!$uEkp@_K2RcMn?B4`o0e1yd>S0${4RuZ zzVV_-p4(0%Q;(`FCVy0xsPMF&@FWqe8fco6 zVd7;@z>p3qoAO@{xnA;m_QL$P76f!c3bDgDz)J`Gm8=EJX)NQtNAr;8xBIexP^;&y zG_b>E5exgq5i#1_gjwPAB{ISU_jJ+UE>6g= z^^;Xqh^@593tp*>6(E)}#lSP79K6+Fo7Ss3u!3st1+;*r)Rr^0I_tR&!x>v>8*!5I z?TiEoZ#5#k{<@a`V0*E3!Intp9gD9H**tsK27iuPx6JLM3#U`0cu@L1ZRJxl4 zoRP~Vh8UlD2ZP83Xbwt^+D_l}XFt~y;G)@H=MwFU+cNq2(f+4neD(o>=V|Q0E(x@q z>y3BCX=r{TYVTN~h-l_;@xXY?E{KD$ucJtvk@)MltQMC$Bk`h{#z)U?5rhRH9X8{C zWZ|DUJ`$6mOszX`#1k*I$^Bgx1T7+5!2HD&C<#vQF;bF|vkxM|Fbi|YNhY6I1|Oaa1aDY$#vWnlOuvNsOjkGQSUBrDI{v&ZJuedj<@^g=`A(LI6-9U(P@=}fAAve z>=FgxBjO0K!1F@V{1!&c-synf2N(%gUDqWd<(x-?&JB+C(0xGUPUoZ^fXRs%+p2^Y3EGo<_Kd> z{b7^=BTc=BXO9pM?d79b5gcJj@FC$lml|#>wQM!$a6rW|hynj>itq-XsWe)u3oy!mpqO-$PWuv5 z$jqC^BFAA*XTf zy^5rTitc^!gWS2jn{S>c_xxOG*8vO1@r(P-sIX7op;n+J$qTq~PxFaa#DJ zik1x>zZf;vSJ8e6=8`e!L3BOCf)!!CP`rGhM$V3@2-~bdXkR-K8#({7Y0^!~P$s!T z(;c5#slyQ7)mT7nOW^)Mfx25V!Sf=%36IUx-4j=?bft}LAV0{ULS(&IPz*Dtt8qeP zwHxOG;0<33Fh1Q?K@L%a03a72@oKp6;I~$4p-Oaf_Rd&je=g#+p{DOsaby7USe?bU z`w>YA#*gG6YNFXe5UEki6Y2qg$_n}9o3b6Y-^8G8*?2KDswk8?tl8BqH>A*ci+8#Q*bdB41pPk8H2tP;SnO7X2?s4{0oMZy#T!A0$ zt@@S{r-xc4V05Y6G5MRxud)c-PXUH>Qv~aeJH1ik`my*3E!#IS9ZiD>GbyUC3vB)m zDzdvtJhvx@bPUI6#^GI^4o4_e9YpkE+D!THJJVfnk6SZ!5Cx@^DJs5B<0}uNG}0C8 z#gO1iH1$>e0bGip9q!)ue|&lU%G;fL35ZM#tWTyw&hjT883*kmt!u`7de-~MgDF_I zt{m_7m!d0_)joUeRfV*#bsO(F}E6AcnF#9UXzx3jGVa6f?80ujF)ACkQ zIyMn|zlR?zhZp{-{Ry`8GLw1%>ml9+hcA`Jt2nI9?Pj-=%>yDOw*miUb za9XFohz`6o%`S_eI0vs$jw}oVT-a}L4=_1cbKWaU=F7)#ajH(8Nqcgv-)PHPRZ1gc zWb|qVCx13jJwa$z=%`qNqg#v)gcXf-^qy>lt`_xkgFA!KK#QkObcAePwwQx{)r`;fUzP z6lg51_6~rR23tv{9Y4^Y40UMMR@8SjJTujSGVl|L6!p`XW|xxt%Z_8&rPM0jl|4OV z!bD~pu+O6EMuSC>+9Wf!)@FqW z#+N8VjcAHDLuxAtL;`HM;FglYxTr9!s5@vy(DPaUt*?8a#K%rR5?($7R7qPU+cLBY zWdR}35LEQuiX)Nkb+3_B*9?p=K=9;kT@E4}bp5)^g&A%BYjh^&G!=>+!DgC~v+;K<+w5h;MW0_sUr4{D`MeO>%OfkjPf5&T0fKUHvC@Lq`O4j zi}P2hD~r?1I*cl&5?JijC}O04kv2B2QI%Bnnf&;M;2aYiL96kWMov_|N{#f2F*q5Y zp*!|#1LOG}IP)Q)!h}Ep>lZW4qct5MalsynH4D}yC#P$gjkJjI>@4qs00^#D8ET1|eAJ}nW z=A=97cQx-KtLjrHBF69EP}x6;C(w7E>dI0Wo$F;3dyH+=PMy(Z^T45`{#CuQYFs%V z&OtqlP7OXyYTUdgKZq`lUC@Nb%DnqeejJMR!b(fHmU*NgSM>&;fW!+ApZr#d6vnkL z=uO#zW1SZ^yt`Y*rRF>NFEDEluD03l;mFWP@$k}y|5h>D+(0ONBskK7Tu0=&SEkNP zpOjLOMp8a)GoT6+gY%nz7Gt^(tl3XpHu;r@I+?-s|M&l?3zvDpB2E|y+Q8s&K)X%2 zKa{*1|6c9LoQo555fMFe5V|^K(d-sz8N&@@6}c;kt{14C1l^6HZ_-D&Z2YJo==C|E zfDr;FD%q5dGAR`Pl&8NC2_znp60j<>D>`r9F11kgY4`cm1ux+96zd&?GsXeV#?40s z5}X^Hv1_S?!)Dro6lWVc98&n$Q$iNCEDNCu=1@24ssmWw zXdv-*-H}ETGixa2Zu0|HjO8H7vf6Xlp???>JrW5nuEH$@F>0?3(MVFmWlCt{?na<= zs@anj;6K|MR%qE4(0sLw$$y%3{v)b4d#~oqDHxV|I<4TzMCsc6<{SLOLjVUFP&;AvuB<>U_#&kly|MsJ zI-$E)+O(XN%K3hD*SkEUFm8NbIE^d~X$^XD;=Er+oax?tOmMSyt+W6GKY$t}+)dNY zwR^OkS$M@d)4qhviFt~&OXjEGa;WFPNN`(Y@ZZp!kg5h>;HVDl2)A;!2R^%+Y<5Jx z-&k$gl1Z@|RS0m<*w|XdJev!4Lb2x&MmZenqji-Cv#psMZPmF6I38Mu@x|?gn|bS> z9z`9mtehlC`wla5t!OH8j4M5c8GPzQQvQ8|nYgkv(MhC(39YNW<5&@%iIsV&fj|vY zhzrJ^~DZWxW!3VNSSuT0obB^B?sQDx7o@uk4+NRFY3(*)WWHR!+;!reFZi`If`tV4N+ zZyGmUx|HF~&gA!axyNL^(R!hy<bWjdOgRu=2dG1^Mp3nwdBp5;?Psh*<5{{rF5#L}a1?$Ehl3wC77d zT-t>ym%Jli?m>OS#`n$acmHV!uMuyd19?PIv9H8gP6R)Q&JVngw4w7hC{nTuKA@%Z z1Eo52 zO^|h8NLhUO1p;}Oam@NELZ$=CqwCQj?@Tv>!aV8Zj(Ht>aVzqswc4uZaDYkj? zoxSXzS~7HyQFq5;X>CPK>hMy}GdxKY{Fut?j@ceb3zs-@rCrO0${$U_idPDIRsGmH zfTs>D$*_WqR`))4xx5_WL4~Uo=_#q|+rr6jTO$QH^3`d?YvqV|I>3vglPxcAiN1^# zODlkD$}v|FuEIyMtIzxf*s5tv)QpB%a8R68>SCP)4gsMSLXKc{%#}x-1>v|Qn{4A8 zU?{UoPyw|14Ca{7-Vr={JOCdL<27g~>QYA~jIu`|Rr-!v^&}WpK#)a^D1?CM!qN}h z1Zly(Sw>LvJ%*tz7JX|ydT7DNaL*LYmk}%|^W!o@udb?-`#0dtSGTFsE8$_KFL3+L zUeGw)KrPOZsqRiW_`gt|(KougmBc-6_=fXrD?5uy@K1wqAK zoHMd-AK(EE9#6mKDbP_-uJf1<2APCQ{mUn)s}rm)ircEH zWuqzVs=OY#aI zAczuwa@}PS#gz~J&C$>UG z)qm5%Nxk$xzs|M^EuN>J_}43RP=mA%24s+2KBMjVG2SYz2W(mNpHAhP?Ls;J+!=*k zv~ot;a3++!=xpXb4!K02ScZrWU=Z*)MXG9%Lz(wGW#i58cPR5)CSidWa9#{dmENKd#~k1g!Yf30(>5UFkJ`_n&%(c8|e7@NQ&Lu`04Mh*QsBtfjK} zj%??I*47bO6j53tU~m+gXH^+%@3G5r9bG8<$R@Jm&oh~$A!#_*yIWOdkKa*gP8>y~+mXH&pEE^mEX2kI4k?~H)Nkj*Z@A=@GH^%gRa=n_> z<*%;jV}n5is>*j2w$E5@1lfbyJz!|OqKpH4%3A&=0t+f^RCPnVE!6$+MxhB=`RPQ) zpXV4Yi%$(o=cEq?h*=|NTaZyJ5IYZtynN*rge}M|AzYn4nQHZ_6Z}1%rK!b-6e!43 zj=}S@(hBFyIluzt4DiU}wf0$@a6;3N^HxixTec;glxQOvk?43G+iyNWB%H|SHZ=vc zCT8sTucx<f5RSrL(jd*I!1VF~u0tU0t1%51)Nx<@YHzDj;oZg!1cZ)(80P(@ zA9~o5KCB98bkSmj2s{_Z+wR+*!JXFKHz)%m=Z{6(^r{*W(xgq}^CNPz8K|&M_3NF` z{I)94a8vWR3iyZd!AVNFy7=~?usSl>XZa|r=!pykQfmg)!*yo6;Q6;O4_ewkhn_wg zz~U6fJGD!8`zbRP)2z}B1DLt@)lgG>mY+6&I;T7E-b$Ry5}TjgNs$t+I;*rJ``p+;`Kz*A zLaeeTTCq&==2OvctW~gc@)mHLuvNn{NB}w5c2{a=?Y$z!&O`L4<8oO5yQLUFHmnLg zS7bo-s?AQ!5rFB)=g;f6SS@(W@%Y7f55w?S*-y9<{ism zKM`Er`qRzi^$logm;asuveIchim*4Cts)oQ!MATCctDN@N(Go>xIk?yZcbfx@!x zf?9-ql^5UHDdE#!PmtiwUKgrDm*veqn$8}ZEFdjsS7GukPl-36^6b<5202W|;e9-i zT(D?#E1?rxm8e5_tZ|_RTmw%2I#R586Qppet}788htxdvMByP=i4IV6(P@<}#@M>P zifYxQU%ZB9{dF>%4O!HF!O_M1+uQq^Zs?-+^}7~LSmfWi_tjJ# z0nv!6p5T>WSS{#Ue0ynI$i(0oPrRrVbY9et37iZ?LAV2Z32C489=MO8v)pOXE#Xmc zxMNfNb?o=61)G6xVyf5Ff-!|A$^|$L<@OE-5p^1$Za!cd3Ucc2tsxI$GtB}KMj94$ zHHZ>K?O=Wj{kWMz)KG!>rw?x-L7n5^RZ=B~k2^I1OR7{KY3dBd2~Q`wF%7FP9^HZKyIT3?3>?{HADiF>&FPMqc4e#;WY6?hkBn;9>Uf+63(R|z zKjs39SWR(B=DB(NoB(H9LssqwR&&ZqfyI1SuAID&r)!_CSfUUzt|}sPa)n z?*zv4Nje#4_9$lpDIP)4=pgRubt)$(B2fp%gij|5RRCyi&vU(5JKAp0p;y=Z#UH8h z4p}*^l{oSg(W%S-2S`O??bx*L)vqduNOwtz0H|B321NR+k}4Xvru}eckCAyE{wcei zI}(69QklE2#tu&GW&c>mM;Neq%kuC~)yg2^-dO5(#moLN$cmcwn)haf)%2xT!Q)Dm zsL^N!Ik_#RKh&EXx0d+mM`|IHN_mSe{kP`3@r$d|2`)OOu}_19e9pd89=^i za*~Q5h_3t}gaagsz+)i>oxF2i0Q+Hj0eyobP!R19|CodDks{iaAaOqQM^q zq}d29-@EJYSJ=TiJ%_P6e&DL*{$+xgzvMIp-&2_Fii$ov>znMzB;$+tX4AX)n$;1! zUEVE7g~`CEJ*6ufC_{ld8=`#(__dFEKop+sy z($%ua_M{i9@I3F_Dae%aq6#BoJ7cX(!n^q4z|8ULa#5kFl6B<*1|86;!sG$rsW7UY zowjj-V#;QsiqNTO+bxSc#_0kEUZiF1M;0Yr0j{H@3ZU6}6 zg}9Kck~-F7>6-_#Y50kdUEcUyk@3y59-sRE7~UMfgjlZJ0srqCuT9uFB)hO3^4nj zix3bB3df+j#t8ys`MpKJ(5p$~4v?h-=0S9vB{uI3`HB>Z6;rz-fMMV`9sI_oAxAk2 zLozt2%^4m2*$-s-4QoJKFitXi6__@Y zGre2kNneYEkbqj0Jw3xp-+O7*(m-?Q6d@nHmrP5nxod&Oj)>xx)bgR!G4?S$;~?CBR`_u^{mV@fJ-u1Bw8s)++#aE_U`d@)$%iB>;JZg6F~A$ ze%@J;UfWdXCVky%13Lyo9|RG*b>LWwyQhpo+0u9%RBzAXt8%5-=JwTZ5P{QN->j;V zTu~Mmv+Dhqf*9B$NvDxWny)rGX%y>3)_&v~)a(yViaLUha3W zdW*3r@bx0bp)P4yoB>_FLQuYWjWWzgVBUu7NNcr3{!+Rs>)E9#YF!ad?4VwdtN7U?{@nMVRw1vVU+2qv=v@f+uSCUDk*hz)}xQ^*j zl>vIhF@*T}JX?I@4)$B+cO)jAEXQE(1+9cr+kr-1sRl93Z=v;FdE^U^_T@$PgWgmq zosfO>aDq#E^sJ^Yl=%v%!#oyrnIaE24tqkeI=V;IL79f#(_~b3!C2HIt*To2mPQU* zlvRc8X?<8Or)nI*I8fxbPeegehvtWkTvp{IW^A%zqKdG^@AIICR9WJ)s9qWy7K zj0|RcR`?<3zU{AOH)a09WA&r-x^9qaIFdXTyHeC{oK)2JksSo1m}yS3d9o8`oa{H0 zS|Up9l{pCMs;ccIFVG1BsLTs+^3>tcZjCAl0(IRn?^r(}0ik{V=H2JZI4Fi6d0+a$ zY<$_F?RI?sRKfZNTKHTRccgRWy0}`%(ds~JesYI16dpkMi**7;<*wwR|yPGp5#GeKwqR;n%f7;;RcSvxUe;j4;Fv| znkLtPJZ~n?Z!t5=lKdmt>C7y480J%VW%qWSjAdpKM4Af(FFJk|^z^G$_Kr7BIIhzO zoJ{*Ou<~yIX+%k6@_@7qdIxr*!K62wbFR?(NwyYM20g7vki$q}AwU4v_s*0g^Cm8Z(4&GJ`yj2u>8h6FuFqg08x&#nkfx^am=Jw#|95UDPWz< zS1{~i=9L^}BM9?5h<+II@xUh>Q-n%npkC~TCFvINJ=bfw-$8+% zvv)p!z4A9t!e-Wo>8Fda&mZOOOuFmhH0+X|_WP4BnXR`4K<4HTn?B-rFBI?yxckNt ze6HUm1~Thedtl-3zVQMw5%b6}2*DWD);j1|)z)NV{ozm)8Gf!R7||SEp4bw}3bCKB z(tvaNToo$w`7OSA5)U(bE+>iwx%lv&SSf*=k@+WIi4Ek09=i~;^E+P_UoM`<2_XKn zFV!~rY~J=K7FAacPE0YGfgMy5SgH4()6pL{UY3@jGsK+w;WnPhvnl_}zNboR1d-Qd z)0?NQ+QqQMF0eW?vJeAJ7CL{LiF|Gv(*(K7Bk=TPA|mLse#|?kBiKaONOb)l($Lko zEw^bO2uX^%D# z*AF~!>d8Pv7Ll4$$)!tc`E2z)gDmTH+XpV_luyrV;MjEaBOydkx!{dhA!;=q)P6g6 z$vtw6fS8LKVRo4w9Y&}TlpmY1b?P^L@p+S&QA5c2*TfnO7Lpsp!gIkhp`ctjwx&>t zTuOA4X+@>j-6WCw^$3=U6nd0fjCu%^cdX1Q;X!2lWU=jO@CygeAd^j7J`g7pk*S~j z96z0}`vKMAmLy(W+)$hqI)mqcZ3tkkZ>j|FWrapJ49T0i{GB5+0a-s4%(9wRC7K)jsI53zumWtL zgb^tXW+)LJnHJ1c$!@FTuiH2M2le@3K zUil-r&9E^;wMk(cc$gR1Hi7jA$GE}_$AMhV%63{`NUv6kVajRoPnDV(xH0|m7r^YsbVIXZ-_JZ_u#CTkideSZyU&gA!Ddb&ls z^{ct3@nLt~5@8sS_xpn3iLV^NLqAk0f74G-P^%oH7tL|s{Wr0DpzZIQ-n>pnU0(MC z%KnSprTS-}On&VBl=*x65cwurNXuz@{*L4gk2vG#T$ydUXiu#^Esxqn$@9zOhQe#xB7k4^=-O&9H%-FZ0nt~?mmonZU?o%6&A>SM!0IpM<%G{t=$ z)zd~3o>zU=KbOy^Znsb4#QInF=wr0ogk%1%r@S)AAIUBEQV$jALr#w%zdMeAo!>I| zAxiBJ9?bsdK~IUAA7Aywxvg%ycZ4)an_u`p<@d}NeskG7n`RqLgbn+z1Z#oErVal$ z*cUbE>3NRbuEuF>Si-1(DEd5Qn|1oe`uJI0BXi?1!A5n@p%${t6Jv8-DADGH>(bzK z7abO4^cXM-o_oe4)nC{8lU~*+Yf?zq@ct}@z4Hd&S%ag1Oko5CH8_6jyi4z^O<>PK ze^%vZv?*A{hXVOpc9y*=V~+(~kvsrf{6h<1>(SI>b%l=ZvT0tRHH&}_GKuEl^43zE z{C^;!Jx5zGW^4x+^rArG3~dh>SVP*aOel1ACB>@FpNR$KXEvA_ZDE8h2R$r+J;6LX z+tG&{nKr$g$6k1wUBj$^C-6chB%*@E@B(kctwTz6ESy;HZ1Lt!*OTA`SRn(HfpT?N zR-YLSXjn(1#MpQ^ZDBF#oPOwaL6?wCr8BFjDH?;<46v+CWd^tKqzwD3OJ0V>h>o5> z>c%-{M^Xd41-N*a&AVg}ER{7WJo@bCTsKLcUj(%w zqh2WM_0E!@=Bp#^7+|)CcA>yJ)(2g?liI+AEmEHZyG{d_&ErboMBdETH`lte^X>s< z+vEt%1*C-Yynw_*JMcjEY?oAjF`BC?#};#emJ3~D3|o|0JdQpfCUe~Q0Po`bQBsGE z&yGHzZ=~?dE?SRehi*&hH(3aXar{c3~(=e(UZ?O=KEx0Z%M-a!xVYGQ@IIe?-cg-B% zz|GBOb>&!`S@S((3UHQ_yM>OUn^uOT?aNlE12e19*F5}%Afdrn!Jf9G1`-hZDLNmM& zy41{MsyaJZs!uv4nD=ZomjN6N7YnZ5OZ89edX%jp;02SYp=b#!w)t<-SzDUB&m>=c z3FIgdvbFXvB{8oP#50pq$ZTM=meGo~(nLl=>sjMdHR!AmSDM8*8_YQgy8n2AipA&D z8m8lP5jEk-WcwdEa}nCc7c7RnwRB@Yc04eb8336)Bc)@Y2qyhxAOLUm+E>|Fg4o!` zxr}*|mGjQHCV=;pJk=W~>PD8ag1E4%38&Sh8_mk39qFB<-fQ$kTb(m&4MziM=s-x^ ztR##C(n0);9v_jRj>4HrOTO>t)3jv**W2{neg-Kse)~gQicv6~W^R^w-WNN(16$MMs4z|A4U3f`^k?#KeR0$&WZWwNMjD}Y$jE@EGvCFZOnr*^Ovw!juA(4GZ~bC2n-$ZTLVeMhMp11(`;pSNjFl+;J*AKzEAu1 zs}EeZc7ceTgp`cm`fUnIpAUb<$vqlco1C!YV@HqEC*-+~&Q30_?rt8QAMN{iEBt($ z@&fz=gR~-O*3wYJv}~tSsnhkqs&Zh2;bS;KQZ$Rr;quDKM+t-?u|z79E0ij=Mysnp z--y9zGFz-RyTj>nd%QWkY7PX3KmiyW0ev7qI#m(=z$cT6N?hK+AoxmZb^XuLU^M0N zt}P3US6{Ky<@R`efPT4*FyVlIRRjynO_U@VOt7WXq|1;gOLpG@fpa+BfCINUS1^|F za(j<=^;udT4GiQfP^d_;5~a$Nt5B&*wHmeR)N9bFNwXHM+O+G?sS6XU=uYF{;^7kz z5)qS-l95wTQc=^;($O<8GQo~@BG&SqdJ#i~{;$Ky#m!T5@A&uy1cih}M8(7OY-#@>8{{ev^Pyhx;AW>)x7KbMg;neyBqBEE*HiygO3xp!EL@JXjlq$7GtJ52< zE}t`+%ogjt&gOi00sB$JDK}yIG@I6Ufx=v-;avi?3$0;G!s~i4S)pM)I3#>}iJVci z!I|6dE#uXv3uO3&^%Qa~&*nO$vae~O8M!lVI;w6_4coJ;HZt_+dZkd#f+ zS$~O_sG3*sb9r0oyCX#GPk#QJu~K|o5)ETnY`+t7H--yRl1xd{l|jzR+CoP8*PMLv z@?BEuD^=2;+1PD=`5)xf?h+)mJq5xABsb$JnO&@&GKUnkGjqA04bk<;();+f)lGf^!)!4`f z2^Z6j$a2NpU&?iJ;+&1x9lraQa<t7)6$7;dNR*hSoN1WhIOWWn6qZj~JsLAf=sBa=Qx))c%!D z2byrxrl;7l14Nim#zm*w zwr$(Cl~PJ6rIb=iDWwiiiy*>;GA^WLD@S)oYpu1`T5GMf*4h|jj4{R-V~jB$&lGYe bE8FfG@{?I8GX;IV?cwqjUBA5C0{{R3GfF_6 literal 0 HcmV?d00001 diff --git a/web/public/fonts/RulesCompressed-Medium.woff2 b/web/public/fonts/RulesCompressed-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..1a352536b2a85813cb456a2e550dca7f97214fa0 GIT binary patch literal 38848 zcmV)AK*YayPew8T0RR910GGf34FCWD0j>Z50GDI{0U4P900000000000000000000 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 literal 0 HcmV?d00001 diff --git a/web/public/fonts/RulesExpanded-Bold.woff2 b/web/public/fonts/RulesExpanded-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d85515dbd6a3c2e49a696d4723d5d14d8e7972c3 GIT binary patch literal 35012 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 literal 0 HcmV?d00001 diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 000000000000..6a3073224d1f --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,117 @@ +import { useState, useEffect } from "react"; +import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react"; +import StatusPage from "@/pages/StatusPage"; +import ConfigPage from "@/pages/ConfigPage"; +import EnvPage from "@/pages/EnvPage"; +import SessionsPage from "@/pages/SessionsPage"; +import LogsPage from "@/pages/LogsPage"; +import AnalyticsPage from "@/pages/AnalyticsPage"; +import CronPage from "@/pages/CronPage"; +import SkillsPage from "@/pages/SkillsPage"; + +const NAV_ITEMS = [ + { id: "status", label: "Status", icon: Activity }, + { id: "sessions", label: "Sessions", icon: MessageSquare }, + { id: "analytics", label: "Analytics", icon: BarChart3 }, + { id: "logs", label: "Logs", icon: FileText }, + { id: "cron", label: "Cron", icon: Clock }, + { id: "skills", label: "Skills", icon: Package }, + { id: "config", label: "Config", icon: Settings }, + { id: "env", label: "Keys", icon: KeyRound }, +] as const; + +type PageId = (typeof NAV_ITEMS)[number]["id"]; + +const PAGE_COMPONENTS: Record = { + status: StatusPage, + sessions: SessionsPage, + analytics: AnalyticsPage, + logs: LogsPage, + cron: CronPage, + skills: SkillsPage, + config: ConfigPage, + env: EnvPage, +}; + +export default function App() { + const [page, setPage] = useState("status"); + const [animKey, setAnimKey] = useState(0); + + useEffect(() => { + setAnimKey((k) => k + 1); + }, [page]); + + const PageComponent = PAGE_COMPONENTS[page]; + + return ( +

+ {/* Global grain + warm glow (matches landing page) */} +
+
+ + {/* ---- Header with grid-border nav ---- */} +
+
+ {/* Brand */} +
+ + Hermes
Agent +
+
+ + {/* Nav grid — Mondwest labels like the landing page nav */} + + + {/* Version badge */} +
+ + Web UI + +
+
+
+ +
+ +
+ + {/* ---- Footer ---- */} +
+
+ + Hermes Agent + + + Nous Research + +
+
+
+ ); +} diff --git a/web/src/components/AutoField.tsx b/web/src/components/AutoField.tsx new file mode 100644 index 000000000000..67f6739e9224 --- /dev/null +++ b/web/src/components/AutoField.tsx @@ -0,0 +1,151 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; + +function FieldHint({ schema, schemaKey }: { schema: Record; schemaKey: string }) { + const keyPath = schemaKey.includes(".") ? schemaKey : ""; + const description = schema.description ? String(schema.description) : ""; + + if (!keyPath && !description) return null; + + return ( +
+ {keyPath && {keyPath}} + {description && {description}} +
+ ); +} + +export function AutoField({ + schemaKey, + schema, + value, + onChange, +}: AutoFieldProps) { + const rawLabel = schemaKey.split(".").pop() ?? schemaKey; + const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + + if (schema.type === "boolean") { + return ( +
+
+ + +
+ +
+ ); + } + + if (schema.type === "select") { + const options = (schema.options as string[]) ?? []; + return ( +
+ + + +
+ ); + } + + if (schema.type === "number") { + return ( +
+ + + { + const raw = e.target.value; + if (raw === "") { + onChange(0); + return; + } + const n = Number(raw); + if (!Number.isNaN(n)) { + onChange(n); + } + }} + /> +
+ ); + } + + if (schema.type === "text") { + return ( +
+ + +