From 683698742852ce0455f3a07b12c772c786d5a2ae Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 11:18:06 -0700 Subject: [PATCH 01/24] docs(release): rewrite v0.14.0 highlights for excitement framing (#27035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: release v0.14.0 (2026.5.16) The Foundation Release — Hermes installs and runs anywhere now. Highlights: - Native Windows support (early beta) — PowerShell installer, native subprocess/PTY paths, ~40 follow-up Windows-only fixes - pip install hermes-agent — PyPI wheel - Cold-start wave — ~19s off hermes launch, 180x faster browser_console (CDP WS) - Supply-chain advisory checker + lazy-deps + tiered install fallback - OpenAI-compatible local proxy for OAuth providers (Claude Pro, ChatGPT Pro, SuperGrok) - Cross-session 1h Claude prompt cache (Anthropic / OpenRouter / Nous Portal) - 2 new platforms: LINE + SimpleX Chat (22 total) - Microsoft Graph foundation — Teams pipeline + webhook adapter - /handoff actually transfers sessions live - x_search first-class tool, vision_analyze pixel passthrough - LSP semantic diagnostics on every write - Unified video_generate with pluggable backends - computer_use cua-driver backend - 9 new optional skills, OpenRouter Pareto Code router, xAI Grok OAuth - 12 P0 + 50 P1 closures 808 commits · 633 PRs · 1393 files · 165k insertions · 545 issues closed · 215 contributors * docs(release): rewrite v0.14.0 highlights for excitement framing Demote Windows beta from headline; lead with SuperGrok / OAuth proxy / x_search / Microsoft Teams. Frame lazy-deps as a debloating wave that makes installs dramatically lighter. Add highlights for clickable URLs in any terminal, dangerous-command detection bypasses, ChatGPT Pro and SuperGrok via the local proxy. Tighten the summary paragraph. --- RELEASE_v0.14.0.md | 67 +++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/RELEASE_v0.14.0.md b/RELEASE_v0.14.0.md index 38d40db8c69..9a5ee4ba8c8 100644 --- a/RELEASE_v0.14.0.md +++ b/RELEASE_v0.14.0.md @@ -3,74 +3,79 @@ **Release Date:** May 16, 2026 **Since v0.13.0:** 808 commits · 633 merged PRs · 1393 files changed · 165,061 insertions · 545 issues closed (12 P0, 50 P1) · 215 community contributors (including co-authors) -> The Foundation Release — Hermes Agent installs and runs anywhere now. Native Windows ships in early beta with a full PowerShell installer story, a `pip install hermes-agent` wheel lands on PyPI, lazy-deps reshape what `pip install hermes-agent` actually pulls down, the supply-chain checker scans every install/upgrade for unsafe versions, and a new OpenAI-compatible local proxy lets Codex / Aider / Cline talk to OAuth-only providers (Claude Pro, ChatGPT Pro, SuperGrok). The cold-start wave shaves ~19 seconds off `hermes` launch, browser-tool CDP calls run 180x faster, and `hermes tools` All-Platforms drops from 14s to under 1.5s. Two new messaging platforms (LINE and SimpleX Chat) and a Microsoft Graph foundation (Teams pipeline + webhook adapter) land alongside `/handoff` that finally transfers sessions live, `vision_analyze` passing pixels through to vision-capable models, `x_search` as a first-class tool, LSP semantic diagnostics on every `write_file` / `patch`, a unified pluggable `video_generate`, a `computer_use` cua-driver backend, cross-session 1-hour Claude prompt caching, a per-turn file-mutation verifier, plus 9 new optional skills. 50+ P1 closures, 12 P0 closures. +> The Foundation Release — Hermes installs and runs anywhere, ships with the things you actually want to use, and stops shipping the things you don't. xAI Grok lands as a SuperGrok OAuth provider with grok-4.3 bumped to a 1M context window. A new OpenAI-compatible local proxy turns any OAuth-authed Hermes provider — Claude Pro, ChatGPT Pro, SuperGrok — into an endpoint that Codex / Aider / Cline / Continue can hit. `x_search` lands as a first-class X (Twitter) search tool with OAuth-or-API-key auth. The Microsoft Teams stack is wired end-to-end (Graph auth + webhook listener + pipeline runtime + outbound delivery). A debloating wave makes installs dramatically lighter — heavyweight backends now lazy-install on first use, the `[all]` extras drop everything covered by lazy-deps, and a tiered install falls back when a wheel rejects on your platform. `pip install hermes-agent` works from PyPI. The cold-start wave shaves ~19 seconds off `hermes` launch. Browser CDP calls are 180x faster. Two new messaging platforms (LINE + SimpleX Chat) bring the total to 22. Cross-session 1-hour Claude prompt caching, `/handoff` that actually transfers sessions live, native button UI for `clarify` on Telegram and Discord, Discord channel history backfill, LSP semantic diagnostics on every write, a unified pluggable `video_generate`, a `computer_use` cua-driver backend that finally works with non-Anthropic providers, clickable URLs in any terminal, Zed ACP Registry integration via `uvx`, native Windows beta, 9 new optional skills, OpenRouter Pareto Code router, huggingface/skills as a trusted default tap. 12 P0 + 50 P1 closures. --- ## ✨ Highlights -- **Native Windows support (early beta)** — full PowerShell installer, native subprocess/PTY paths, taskkill-based process management, MinGit auto-install, Microsoft Store python stub detection, foreground Ctrl+C preservation, taskkill+ps2 fallback, npm prefix handling, and ~40 follow-up Windows-only fixes across CLI / gateway / TUI / curator / tools. Hermes finally runs natively on `cmd.exe` and PowerShell, no WSL required. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561), [#22130](https://github.com/NousResearch/hermes-agent/pull/22130), [#22752](https://github.com/NousResearch/hermes-agent/pull/22752), [#26618](https://github.com/NousResearch/hermes-agent/pull/26618), and many more) +- **xAI Grok via SuperGrok OAuth — and grok-4.3 jumps to a 1M context window** — Sign in with your xAI account, talk to Grok models from Hermes. The OAuth provider ships with proper entitlement-error handling, prelude-SSE recovery, and reasoning replay gating. grok-4.3's context bumps to 1M as part of the wire-through. Includes an SSH-to-tunnel docs page for remote-machine OAuth flows. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534), [#26664](https://github.com/NousResearch/hermes-agent/pull/26664), [#26644](https://github.com/NousResearch/hermes-agent/pull/26644), [#26592](https://github.com/NousResearch/hermes-agent/pull/26592)) -- **`pip install hermes-agent && hermes`** — Hermes Agent is now a real PyPI package. One command, no clone, no git, no shell installer. Wheel includes the Ink TUI bundle and shell launcher. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593)) +- **OpenAI-compatible local proxy for OAuth providers** — `hermes proxy` exposes any OAuth-authed Hermes provider — Claude Pro, ChatGPT Pro, SuperGrok — as an OpenAI-compatible HTTP endpoint. Point Codex / Aider / Cline / Continue at `http://localhost:port` and your subscription works in any OpenAI-compatible tool. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969)) -- **Cold-start performance wave — ~19s off `hermes` launch** — skills cache, lazy Feishu import, no Nous HTTP at startup, plus PEP-562 lazy adapter imports (QQ, Yuanbao, Teams, Google Chat), deferred `fal_client` / `google-cloud` / `httpx` loads, models.dev disk-cache-first lookup, parallel doctor API checks, eager-skip plugin discovery on built-in subcommands, `hermes tools` All-Platforms drops from 14s to <1.5s, welcome banner skipped on `chat -q`. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341)) +- **`x_search` — first-class X (Twitter) search tool** — gated tool with OAuth-or-API-key auth, no skill required. Search the timeline, find threads, surface posts — straight from the agent. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763)) + +- **Microsoft Teams — end-to-end** — Hermes can now read and post to Teams. The full Microsoft Graph foundation lands together: auth + client foundation, webhook listener platform, Teams pipeline plugin runtime, and Teams outbound delivery via the existing adapter. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024)) + +- **Debloating wave — lighter installs, less you don't need** — the heavy backends (Slack / Matrix / Feishu / DingTalk adapters, hindsight client, codex app-server, Pixverse / Camofox / image-gen SDKs, voice/TTS providers) now lazy-install on first use. The `[all]` extras drop everything covered by lazy-deps so a default install ships with vastly fewer wheels. A tiered install falls back through extras tiers when one rejects on the target platform. The supply-chain advisory checker scans every install/upgrade against an advisory list. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220), [#24515](https://github.com/NousResearch/hermes-agent/pull/24515), [#25014](https://github.com/NousResearch/hermes-agent/pull/25014), [#25038](https://github.com/NousResearch/hermes-agent/pull/25038), [#25766](https://github.com/NousResearch/hermes-agent/pull/25766), [#21818](https://github.com/NousResearch/hermes-agent/pull/21818)) + +- **`pip install hermes-agent && hermes`** — Hermes Agent is a real PyPI package now. One command, no clone, no git, no shell installer. The wheel ships with the Ink TUI bundle and the shell launcher. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593), [#26148](https://github.com/NousResearch/hermes-agent/pull/26148)) + +- **Cross-session 1h Claude prompt cache** — Anthropic, OpenRouter, and Nous Portal now share a 1-hour prefix cache across sessions for Claude models. `/new` and `/resume` hit cache. Background memory review hits cache. The system prompt is now byte-static within a session so cache layout never invalidates mid-conversation. Lower cost, faster first token. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828), [#25434](https://github.com/NousResearch/hermes-agent/pull/25434), [#24778](https://github.com/NousResearch/hermes-agent/pull/24778)) - **180x faster `browser_console` evaluations** — routed through the supervisor's persistent CDP WebSocket instead of spawning a fresh DevTools session per call. Real-world page interactions feel instant. ([#23226](https://github.com/NousResearch/hermes-agent/pull/23226)) -- **Supply-chain advisory checker + lazy-deps framework + tiered install fallback** — every `pip install` / `hermes update` scans dependencies against an advisory list, lazy-deps replace heavy import-time loads with first-use installs, and the installer falls back through extras tiers when a wheel rejects on the target platform. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220)) +- **Cold-start performance wave — ~19s off `hermes` launch** — skills cache + lazy Feishu + no Nous HTTP at startup, plus PEP-562 lazy adapter imports (QQ, Yuanbao, Teams, Google Chat), deferred `fal_client` / `google-cloud` / `httpx` loads, models.dev disk-cache-first lookup, parallel doctor API checks, eager-skip plugin discovery on built-in subcommands. `hermes tools` All-Platforms drops from 14s to <1.5s. Welcome banner skipped on `chat -q`. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341)) -- **OpenAI-compatible local proxy** — `hermes proxy` exposes any OAuth-authed provider (Claude Pro, ChatGPT Pro, SuperGrok) as an OpenAI-compatible endpoint that Codex / Aider / Cline / VS Code Continue can hit. Your subscription, your tools. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969)) - -- **Cross-session 1-hour Claude prompt cache** — Anthropic / OpenRouter / Nous Portal now share a 1h prefix cache across sessions for Claude models. Fast resume, fast `/new`, lower cost on repeat work. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828)) - -- **Two new messaging platforms — LINE + SimpleX Chat** — LINE Messaging API lands as a first-class platform, SimpleX Chat salvages #2558 onto the modern adapter spec. Hermes is now on 22 platforms. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232)) - -- **Microsoft Graph foundation — Teams pipeline + webhook adapter** — `msgraph` auth/client foundation, webhook listener platform, Teams pipeline plugin runtime, and Teams outbound delivery via the existing adapter — Hermes can now read and post to Teams. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024)) +- **Two new messaging platforms — LINE + SimpleX Chat** — LINE Messaging API as a first-class platform, SimpleX Chat salvages #2558 onto the modern adapter spec. Hermes is now on 22 platforms. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232)) - **`/handoff` actually transfers the session live** — the agent's active session moves to a different model / persona / profile mid-conversation, with messages, tool history, and context preserved. ([#23395](https://github.com/NousResearch/hermes-agent/pull/23395)) -- **`x_search` — first-class X (Twitter) search tool** — gated tool with OAuth-or-API-key auth, no skill needed to query the timeline. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763)) +- **Native button UI for `clarify` on Telegram and Discord** — the `clarify` tool renders multi-choice prompts as platform-native inline keyboards on Telegram and buttons on Discord instead of typed responses. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485)) + +- **Discord channel history backfill (default on)** — Hermes reads recent channel history when joining a thread so it actually knows what's already been said before responding. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984)) - **`vision_analyze` returns pixels to vision-capable models** — when the active model can see, `vision_analyze` now hands the image straight through instead of falling back to a text description. ([#22955](https://github.com/NousResearch/hermes-agent/pull/22955)) -- **LSP semantic diagnostics on every write** — `write_file` and `patch` now run real language-server diagnostics on the post-edit file (delta-only) and surface real errors before they ship downstream. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978)) - - **Per-turn file-mutation verifier footer** — after every turn that wrote files, the agent gets a verifier footer summarizing what actually changed on disk — catches silent overwrites and "wrote it but it didn't land" bugs. ([#24498](https://github.com/NousResearch/hermes-agent/pull/24498)) +- **LSP semantic diagnostics on every write** — `write_file` and `patch` run real language-server diagnostics on the post-edit file (delta-only) and surface real errors before they ship downstream. Goes well beyond the in-proc Python/JSON/YAML/TOML linters from v0.13.0. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978)) + - **Unified `video_generate` with pluggable provider backends** — single tool, any backend. Drop in a new video provider as a plugin, no core changes. ([#25126](https://github.com/NousResearch/hermes-agent/pull/25126)) -- **`computer_use` cua-driver backend** — proper focus-safe ops, non-Anthropic provider support, refresh on `hermes update`. Computer-use is no longer locked to a single SDK. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063)) +- **`computer_use` cua-driver backend — works with non-Anthropic models now** — proper focus-safe ops, non-Anthropic provider support, refresh on `hermes update`. Computer-use is no longer locked to a single SDK. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063)) -- **xAI Grok OAuth provider — SuperGrok via subscription** — sign in with your xAI account, talk to Grok models from Hermes. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534)) +- **Clickable URLs in any terminal** — links in agent output now render as proper OSC8 hyperlinks with hover-highlight, in any terminal that supports them. Click to open. (@OutThisLife) ([#25071](https://github.com/NousResearch/hermes-agent/pull/25071), [#24013](https://github.com/NousResearch/hermes-agent/pull/24013)) -- **Clarify with buttons — native inline keyboards on Telegram + Discord** — the `clarify` tool renders multi-choice prompts as platform-native buttons instead of typed responses. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485)) +- **Zed ACP Registry — `uvx` install in one click** — Hermes is in the Zed registry, installable via `uvx` (no npm needed). Plus `hermes acp --setup-browser` bootstraps browser tools for registry installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234)) -- **Discord channel history backfill (default on)** — Hermes reads recent channel history when joining a thread so it actually knows what's been said. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984)) +- **OpenRouter Pareto Code router with `min_coding_score` knob** — pick the cheapest model that meets your quality bar. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838)) -- **Watchers skill — RSS / HTTP JSON / GitHub polling via cron `no_agent` mode** — skill recipes that wire change-detection sources directly into cron's script-only watchdog mode. ([#21881](https://github.com/NousResearch/hermes-agent/pull/21881)) +- **NovitaAI as a new model provider** (salvage #7219) (@kshitijk4poor) ([#25507](https://github.com/NousResearch/hermes-agent/pull/25507)) -- **Zed ACP Registry integration + uvx distribution** — Hermes is in the Zed registry, installable via `uvx` (no npm). Plus `hermes acp --setup-browser` bootstraps browser tools for registry installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234)) +- **Codex app-server runtime for OpenAI/Codex models** — optional runtime that drives OpenAI's Codex CLI under the hood for OpenAI/Codex paths, with session reuse, wedge retirement, and OAuth refresh classification. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769)) -- **OpenRouter Pareto Code router** — wire a new OpenRouter router with `min_coding_score` knob. Pick the cheapest model that meets your quality bar. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838)) +- **`huggingface/skills` as a trusted default tap** — community skills index from huggingface.co/skills is available by default in the Skills Hub. (closes #2549) ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219)) -- **Optional codex app-server runtime for OpenAI/Codex models** — drives the OpenAI Codex CLI under the hood for OpenAI/Codex paths, with session reuse, wedge retirement, and OAuth refresh classification. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769)) - -- **`hermes-skills/huggingface` as a trusted default tap** — community skills index from huggingface.co/skills is available by default in the Skills Hub. ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219)) - -- **9 new optional skills** — Hyperliquid (perp/spot trading via SDK + REST) (@kshitijk4poor & Hermes), Yahoo Finance market data, api-testing (REST/GraphQL debug), unified EVM multi-chain skill (folds #25291 + #2010 + base/), darwinian-evolver, osint-investigation (closes #355), pinggy-tunnel, watchers (RSS/HTTP/GitHub via cron), Notion overhaul for the Developer Platform (May 2026). ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612)) +- **9 new optional skills** — Hyperliquid (perp/spot trading via SDK + REST), Yahoo Finance market data, api-testing (REST/GraphQL debug), unified EVM multi-chain (folds #25291 + #2010 + base/), darwinian-evolver, osint-investigation (closes #355), pinggy-tunnel, watchers (RSS/HTTP/GitHub via cron `no_agent`), Notion overhaul for the May 2026 Developer Platform. ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612)) - **API server exposes run approval events** — long-running runs surface approval requests over the API stream, no more silent stalls. (salvage of [#20311](https://github.com/NousResearch/hermes-agent/pull/20311)) ([#21899](https://github.com/NousResearch/hermes-agent/pull/21899)) -- **`/subgoal` — user-added criteria appended to active `/goal`** — layer extra success criteria onto a running goal loop. The judge sees them in the prompt, no behavior change when subgoals are empty. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449)) - -- **Plugins can run any LLM call via `ctx.llm`** — plugins get a first-class hook to make their own LLM requests through the active provider/credentials, no manual wiring. Plus `tool_override` flag for replacing built-in tools. ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759)) +- **Plugins can run any LLM call via `ctx.llm` + `tool_override`** — plugins get a first-class hook to make their own LLM requests through the active provider/credentials, plus a `tool_override` flag for replacing built-in tools (closes #11049). ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759)) - **Brave Search (free tier) + DuckDuckGo (DDGS) as web-search providers** — two new free search backends alongside Tavily / SearXNG / Exa. ([#21337](https://github.com/NousResearch/hermes-agent/pull/21337)) -- **Sudo brute-force block + sudo-stdin/askpass DANGEROUS classification** — closes the `sudo -S` brute-force avenue; approval gates classify stdin-fed and askpass-stripped sudo invocations as dangerous. (salvages of #22194 + #21128) ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736)) +- **Sudo brute-force block + sudo-stdin/askpass DANGEROUS classification** — closes the `sudo -S` brute-force avenue; approval gates classify stdin-fed and askpass-stripped sudo invocations as dangerous. (salvages of #22194 + #21128) (@kshitijk4poor) ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736)) + +- **Closes 3 dangerous-command detection bypasses + sanitizes tool error strings before injection** — inspired by Claude Code's command-detection work; closes 3 known bypasses and adds tool-error sanitization to stop adversarial output from re-entering the model context. ([#26829](https://github.com/NousResearch/hermes-agent/pull/26829), [#26823](https://github.com/NousResearch/hermes-agent/pull/26823)) + +- **`/subgoal` — user-added criteria appended to active `/goal`** — layer extra success criteria onto a running goal loop. The judge sees them in the prompt, no behavior change when subgoals are empty. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449)) - **Provider rename — Alibaba Cloud → Qwen Cloud, picker reorder** — matches what the world calls it. Existing config keys still work. ([#24835](https://github.com/NousResearch/hermes-agent/pull/24835)) +- **Native Windows support (early beta)** — Hermes runs natively on `cmd.exe` and PowerShell. Full PowerShell installer, native subprocess/PTY paths, taskkill-based process management, MinGit auto-install, foreground Ctrl+C preservation, ~40 follow-up Windows-only fixes. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561)) + +--- --- From 35f25523c60d9b1174c9a5d901e34f2300d81986 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 11:53:13 -0700 Subject: [PATCH 02/24] docs(tools): add video_generate / video_gen toolset to user-facing tool docs (#27050) The video_gen toolset and its video_generate tool shipped without user-facing reference docs. toolsets-reference.md and the dev-guide plugin page were already in, but reference/tools-reference.md had no video_gen section at all and user-guide/features/tools.md's Media row didn't list video_generate. - reference/tools-reference.md: add a video_gen section after video, including backend list (xAI Grok-Imagine, FAL.ai Veo/Pixverse/Kling), unified text-to-video / image-to-video surface note, link to the dev-guide plugin page, and the video_generate tool row. Add video_generate to the standalone-tools quick-counts line. - user-guide/features/tools.md: extend Media row with video_generate and video_analyze plus an opt-in caveat. --- website/docs/reference/tools-reference.md | 17 ++++++++++++++++- website/docs/user-guide/features/tools.md | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 507bd307afb..64cf5e2dc09 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -8,7 +8,7 @@ description: "Authoritative reference for Hermes built-in tools, grouped by tool This page documents Hermes' built-in tools, grouped by toolset. Availability varies by platform, credentials, and enabled toolsets. -**Quick counts (current registry):** ~70 tools — 10 browser tools (core) + 2 CDP-gated browser tools, 4 file tools, 10 RL tools, 4 Home Assistant tools, 2 terminal tools, 2 web tools, 5 Feishu tools, 7 Spotify tools (registered by the bundled `spotify` plugin), 5 Yuanbao tools, 7 kanban tools (registered when the kanban dispatcher spawns the agent), 2 Discord tools, and a handful of standalone tools (`memory`, `clarify`, `delegate_task`, `execute_code`, `cronjob`, `session_search`, `skill_view`/`skill_manage`/`skills_list`, `text_to_speech`, `image_generate`, `vision_analyze`, `video_analyze`, `mixture_of_agents`, `send_message`, `todo`, `computer_use`, `process`). +**Quick counts (current registry):** ~70 tools — 10 browser tools (core) + 2 CDP-gated browser tools, 4 file tools, 10 RL tools, 4 Home Assistant tools, 2 terminal tools, 2 web tools, 5 Feishu tools, 7 Spotify tools (registered by the bundled `spotify` plugin), 5 Yuanbao tools, 7 kanban tools (registered when the kanban dispatcher spawns the agent), 2 Discord tools, and a handful of standalone tools (`memory`, `clarify`, `delegate_task`, `execute_code`, `cronjob`, `session_search`, `skill_view`/`skill_manage`/`skills_list`, `text_to_speech`, `image_generate`, `video_generate`, `vision_analyze`, `video_analyze`, `mixture_of_agents`, `send_message`, `todo`, `computer_use`, `process`). :::tip MCP Tools In addition to built-in tools, Hermes can load tools dynamically from MCP servers. MCP tools appear with the prefix `mcp__` (e.g., `mcp_github_create_issue` for the `github` MCP server). See [MCP Integration](/docs/user-guide/features/mcp) for configuration. @@ -189,6 +189,21 @@ Opt-in toolset (not loaded in the default `hermes-cli` set). Add via `--toolsets |------|-------------|----------------------| | `video_analyze` | Analyze video content from a URL or file path — captions, scene breakdowns, key timestamps, and visual descriptions. | — | +## `video_gen` toolset + +Opt-in toolset (not loaded in the default `hermes-cli` set). Add via `--toolsets video_gen` or enable it in `hermes tools` → Video Generation, which also walks you through picking a backend. + +Backends ship as plugins under `plugins/video_gen//`: + +- **xAI Grok-Imagine** — text-to-video and image-to-video (SuperGrok OAuth or `XAI_API_KEY`). +- **FAL.ai** — Veo 3.1, Pixverse v6, Kling O3 (requires `FAL_KEY`). + +The single `video_generate` tool covers both modalities — pass `image_url` to animate a still, omit it to generate from text alone. The active backend auto-routes to the right endpoint. The tool's description is rebuilt at session start to reflect the active backend's actual capabilities (modalities, aspect ratios, resolutions, duration range, max reference images, audio support). See [Video Generation Provider Plugins](/docs/developer-guide/video-gen-provider-plugin) for backend authoring. + +| Tool | Description | Requires environment | +|------|-------------|----------------------| +| `video_generate` | Generate a video from a text prompt (text-to-video) or animate a still image (image-to-video) using the user's configured video generation backend. Pass `image_url` to animate that image; omit it to generate from text alone. The backend auto-routes to the right endpoint. Returns either an HTTP URL or an absolute file path in the `video` field. | Active `video_gen` plugin + its credential (e.g. `XAI_API_KEY`, `FAL_KEY`) | + ## `web` toolset | Tool | Description | Requires environment | diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index 0c5dd30cb2c..ec0d83b81f1 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -24,7 +24,7 @@ High-level categories: | **X Search** | `x_search` | Search X (Twitter) posts and threads via xAI's built-in `x_search` Responses tool — gated on xAI credentials (SuperGrok OAuth or `XAI_API_KEY`); off by default, opt in via `hermes tools` → 🐦 X (Twitter) Search. | | **Terminal & Files** | `terminal`, `process`, `read_file`, `patch` | Execute commands and manipulate files. | | **Browser** | `browser_navigate`, `browser_snapshot`, `browser_vision` | Interactive browser automation with text and vision support. | -| **Media** | `vision_analyze`, `image_generate`, `text_to_speech` | Multimodal analysis and generation. | +| **Media** | `vision_analyze`, `image_generate`, `video_generate`, `video_analyze`, `text_to_speech` | Multimodal analysis and generation. `video_generate` and `video_analyze` are opt-in (add `video_gen` / `video` toolsets via `hermes tools` or `--toolsets`). | | **Agent orchestration** | `todo`, `clarify`, `execute_code`, `delegate_task` | Planning, clarification, code execution, and subagent delegation. | | **Memory & recall** | `memory`, `session_search` | Persistent memory and session search. | | **Automation & delivery** | `cronjob`, `send_message` | Scheduled tasks with create/list/update/pause/resume/run/remove actions, plus outbound messaging delivery. | From 6c2406c5e131dbbcabb69319c73c02594f63caea Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 11:53:57 -0700 Subject: [PATCH 03/24] fix(signal): read groupV2.id in envelope, fall back to legacy groupInfo (#27051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from qwibitai/nanoclaw#1962: modern Signal V2-only groups surface on dataMessage.groupV2.id, not groupInfo.groupId. signal-cli versions differ in which field they expose for V2 groups — some forward the underlying libsignal envelope verbatim (groupV2), others normalize everything into groupInfo. Without a groupV2 read, V2-only groups appear as DMs because groupInfo is undefined and the adapter misroutes them to the sender's DM session. Reads groupV2.id first, falls back to groupInfo.groupId. Also hardens chat_name extraction against non-dict groupInfo payloads (crashed with AttributeError under malformed envelopes). 6 new tests cover V2 routing, V1 legacy compatibility, V2-preferred precedence, no-group DM path, allowlist enforcement, and malformed payloads. --- gateway/platforms/signal.py | 16 +++- tests/gateway/test_signal.py | 159 +++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index bd731a7ab5d..2a0aa3f80c1 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -490,9 +490,19 @@ class SignalAdapter(BasePlatformAdapter): if not data_message: return - # Check for group message + # Check for group message. + # Modern Signal groups surface on dataMessage.groupV2.id; legacy V1 + # groups still arrive under dataMessage.groupInfo.groupId. signal-cli + # versions differ in which field they expose for V2 groups — some + # forward the underlying libsignal envelope verbatim (groupV2), others + # normalize everything into groupInfo. Read groupV2 first and fall + # back to groupInfo so V2-only groups aren't misrouted as DMs. group_info = data_message.get("groupInfo") - group_id = group_info.get("groupId") if group_info else None + group_v2 = data_message.get("groupV2") + group_id = ( + (group_v2.get("id") if isinstance(group_v2, dict) else None) + or (group_info.get("groupId") if isinstance(group_info, dict) else None) + ) is_group = bool(group_id) # Group message filtering — derived from SIGNAL_GROUP_ALLOWED_USERS: @@ -562,7 +572,7 @@ class SignalAdapter(BasePlatformAdapter): # Build session source source = self.build_source( chat_id=chat_id, - chat_name=group_info.get("groupName") if group_info else sender_name, + chat_name=(group_info.get("groupName") if isinstance(group_info, dict) else None) or sender_name, chat_type=chat_type, user_id=sender, user_name=sender_name or sender, diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index af81f59e8cd..7f34698f027 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -1794,3 +1794,162 @@ class TestSignalContentlessEnvelope: assert "event" in captured, "Normal message should NOT be skipped" assert captured["event"].text == "hello world" + + +# --------------------------------------------------------------------------- +# Envelope handling — group routing (legacy groupInfo vs modern groupV2) +# --------------------------------------------------------------------------- + +class TestSignalGroupV2Routing: + """Regression coverage for groupV2 envelope handling. + + signal-cli's JSON-RPC ``subscribeReceive`` envelope shape has drifted across + versions: some forward the underlying libsignal V2 envelope as + ``dataMessage.groupV2.id`` while older / normalized paths still use + ``dataMessage.groupInfo.groupId``. The adapter must read groupV2 first and + fall back to groupInfo so V2-only groups aren't misrouted as DMs. + + Ported from qwibitai/nanoclaw#1962 (V2 adapter improvements). + """ + + def _base_envelope(self, data_message: dict) -> dict: + return { + "envelope": { + "sourceNumber": "+15559998888", + "sourceUuid": "uuid-sender", + "sourceName": "Alice", + "timestamp": 1700000000000, + "dataMessage": data_message, + } + } + + @pytest.mark.asyncio + async def test_group_v2_id_routes_as_group(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch, group_allowed="*") + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + env = self._base_envelope({ + "message": "hello v2", + "groupV2": {"id": "v2group=="}, + }) + + await adapter._handle_envelope(env) + + assert len(captured) == 1 + assert captured[0].source.chat_id == "group:v2group==" + assert captured[0].source.chat_type == "group" + assert captured[0].text == "hello v2" + + @pytest.mark.asyncio + async def test_legacy_group_info_still_works(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch, group_allowed="*") + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + env = self._base_envelope({ + "message": "hello v1", + "groupInfo": {"groupId": "legacy=="}, + }) + + await adapter._handle_envelope(env) + + assert len(captured) == 1 + assert captured[0].source.chat_id == "group:legacy==" + assert captured[0].source.chat_type == "group" + + @pytest.mark.asyncio + async def test_group_v2_preferred_over_group_info(self, monkeypatch): + """When both fields are present, groupV2 wins — it's the authoritative V2 id.""" + adapter = _make_signal_adapter(monkeypatch, group_allowed="*") + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + env = self._base_envelope({ + "message": "hello", + "groupV2": {"id": "v2=="}, + "groupInfo": {"groupId": "v1=="}, + }) + + await adapter._handle_envelope(env) + + assert len(captured) == 1 + assert captured[0].source.chat_id == "group:v2==" + + @pytest.mark.asyncio + async def test_no_group_fields_routes_as_dm(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch) + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + env = self._base_envelope({"message": "direct message"}) + + await adapter._handle_envelope(env) + + assert len(captured) == 1 + assert captured[0].source.chat_type == "dm" + assert captured[0].source.chat_id == "+15559998888" + + @pytest.mark.asyncio + async def test_group_v2_respects_allowlist(self, monkeypatch): + """V2 group ids flow through the same SIGNAL_GROUP_ALLOWED_USERS filter.""" + adapter = _make_signal_adapter(monkeypatch, group_allowed="allowed-v2==") + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + # Blocked group (not in allowlist) + await adapter._handle_envelope(self._base_envelope({ + "message": "blocked", + "groupV2": {"id": "blocked-v2=="}, + })) + assert len(captured) == 0 + + # Allowed group + await adapter._handle_envelope(self._base_envelope({ + "message": "allowed", + "groupV2": {"id": "allowed-v2=="}, + })) + assert len(captured) == 1 + assert captured[0].source.chat_id == "group:allowed-v2==" + + @pytest.mark.asyncio + async def test_malformed_group_fields_fall_through_to_dm(self, monkeypatch): + """Non-dict groupV2 / groupInfo shouldn't crash — treat as DM.""" + adapter = _make_signal_adapter(monkeypatch) + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + env = self._base_envelope({ + "message": "malformed", + "groupV2": "not-a-dict", + "groupInfo": 42, + }) + + await adapter._handle_envelope(env) + + assert len(captured) == 1 + assert captured[0].source.chat_type == "dm" From 8a2b2b9f6f9c419fdef48f542bf4b1991c655810 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 11:57:59 -0700 Subject: [PATCH 04/24] docs(release): expand v0.14.0 highlights with newcomer-friendly context (#27053) Each highlight now gets 2-3 sentences explaining the user-facing value, not just the technical change. Targeted at someone discovering Hermes for the first time who isn't deep in the codebase. --- RELEASE_v0.14.0.md | 67 ++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/RELEASE_v0.14.0.md b/RELEASE_v0.14.0.md index 9a5ee4ba8c8..30ab4189ac2 100644 --- a/RELEASE_v0.14.0.md +++ b/RELEASE_v0.14.0.md @@ -9,73 +9,70 @@ ## ✨ Highlights -- **xAI Grok via SuperGrok OAuth — and grok-4.3 jumps to a 1M context window** — Sign in with your xAI account, talk to Grok models from Hermes. The OAuth provider ships with proper entitlement-error handling, prelude-SSE recovery, and reasoning replay gating. grok-4.3's context bumps to 1M as part of the wire-through. Includes an SSH-to-tunnel docs page for remote-machine OAuth flows. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534), [#26664](https://github.com/NousResearch/hermes-agent/pull/26664), [#26644](https://github.com/NousResearch/hermes-agent/pull/26644), [#26592](https://github.com/NousResearch/hermes-agent/pull/26592)) +- **xAI Grok via SuperGrok OAuth — and grok-4.3 jumps to a 1M context window** — If you pay for SuperGrok, you can now use Grok inside Hermes by signing in with your xAI account — no API key, no separate billing. The wire-through also bumps grok-4.3 to a 1M token context window, so you can drop whole codebases or research corpora into a single prompt. Includes proper handling for entitlement errors and an SSH-to-tunnel docs page for when you're SSH'd into a remote box and need to complete the OAuth flow. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534), [#26664](https://github.com/NousResearch/hermes-agent/pull/26664), [#26644](https://github.com/NousResearch/hermes-agent/pull/26644), [#26592](https://github.com/NousResearch/hermes-agent/pull/26592)) -- **OpenAI-compatible local proxy for OAuth providers** — `hermes proxy` exposes any OAuth-authed Hermes provider — Claude Pro, ChatGPT Pro, SuperGrok — as an OpenAI-compatible HTTP endpoint. Point Codex / Aider / Cline / Continue at `http://localhost:port` and your subscription works in any OpenAI-compatible tool. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969)) +- **OpenAI-compatible local proxy for OAuth providers** — Run `hermes proxy` and you get a `http://localhost:port` endpoint that speaks the OpenAI API but is backed by whichever OAuth provider you're signed into — Claude Pro, ChatGPT Pro, SuperGrok. Now any tool that expects an OpenAI-compatible endpoint (Codex CLI, Aider, Cline, Continue, your custom scripts) just works with your existing subscription, no API key required. One subscription, every tool. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969)) -- **`x_search` — first-class X (Twitter) search tool** — gated tool with OAuth-or-API-key auth, no skill required. Search the timeline, find threads, surface posts — straight from the agent. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763)) +- **`x_search` — first-class X (Twitter) search tool** — The agent can now search X directly without installing a skill or wiring up a custom integration. Search the timeline, find threads, surface specific posts — straight from the chat. Auth with either your X OAuth login or an API key, whichever you have. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763)) -- **Microsoft Teams — end-to-end** — Hermes can now read and post to Teams. The full Microsoft Graph foundation lands together: auth + client foundation, webhook listener platform, Teams pipeline plugin runtime, and Teams outbound delivery via the existing adapter. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024)) +- **Microsoft Teams — end-to-end** — Hermes can now read messages from Teams and post back. The full Microsoft Graph stack lands together: auth + client foundation, a webhook listener that receives Teams events, a pipeline plugin runtime, and outbound delivery. Wire up the bot once, then chat to your agent from any Teams channel, DM, or group. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024)) -- **Debloating wave — lighter installs, less you don't need** — the heavy backends (Slack / Matrix / Feishu / DingTalk adapters, hindsight client, codex app-server, Pixverse / Camofox / image-gen SDKs, voice/TTS providers) now lazy-install on first use. The `[all]` extras drop everything covered by lazy-deps so a default install ships with vastly fewer wheels. A tiered install falls back through extras tiers when one rejects on the target platform. The supply-chain advisory checker scans every install/upgrade against an advisory list. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220), [#24515](https://github.com/NousResearch/hermes-agent/pull/24515), [#25014](https://github.com/NousResearch/hermes-agent/pull/25014), [#25038](https://github.com/NousResearch/hermes-agent/pull/25038), [#25766](https://github.com/NousResearch/hermes-agent/pull/25766), [#21818](https://github.com/NousResearch/hermes-agent/pull/21818)) +- **Debloating wave — lighter installs, less you don't use** — A clean `pip install hermes-agent` used to pull down everything: every messaging adapter SDK, every image-gen SDK, every voice/TTS provider, whether you used them or not. Now those heavy backends (Slack / Matrix / Feishu / DingTalk adapters, hindsight client, codex app-server, Pixverse / Camofox / image-gen SDKs, voice/TTS providers) install automatically the first time you actually use them. The `[all]` extras drop everything covered by lazy-deps, the installer falls back through tiers when a wheel doesn't fit your platform, and a supply-chain advisory checker scans every install for unsafe versions. Faster installs, smaller disk footprint, fewer transitive vulnerabilities. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220), [#24515](https://github.com/NousResearch/hermes-agent/pull/24515), [#25014](https://github.com/NousResearch/hermes-agent/pull/25014), [#25038](https://github.com/NousResearch/hermes-agent/pull/25038), [#25766](https://github.com/NousResearch/hermes-agent/pull/25766), [#21818](https://github.com/NousResearch/hermes-agent/pull/21818)) -- **`pip install hermes-agent && hermes`** — Hermes Agent is a real PyPI package now. One command, no clone, no git, no shell installer. The wheel ships with the Ink TUI bundle and the shell launcher. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593), [#26148](https://github.com/NousResearch/hermes-agent/pull/26148)) +- **`pip install hermes-agent && hermes`** — Hermes Agent is now a real PyPI package. No more cloning the repo or running shell installers — one pip command and you're running. The wheel ships with the Ink TUI bundle and the shell launcher, so the full experience comes out of the box. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593), [#26148](https://github.com/NousResearch/hermes-agent/pull/26148)) -- **Cross-session 1h Claude prompt cache** — Anthropic, OpenRouter, and Nous Portal now share a 1-hour prefix cache across sessions for Claude models. `/new` and `/resume` hit cache. Background memory review hits cache. The system prompt is now byte-static within a session so cache layout never invalidates mid-conversation. Lower cost, faster first token. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828), [#25434](https://github.com/NousResearch/hermes-agent/pull/25434), [#24778](https://github.com/NousResearch/hermes-agent/pull/24778)) +- **Cross-session 1h Claude prompt cache** — When you use Claude through Anthropic, OpenRouter, or Nous Portal, the prompt prefix (system prompt, skills, memory) now caches for an hour across sessions. Start a `/new` session and the first response comes back faster and cheaper because the cache is still warm from your last session. Background memory review hits the cache too, so it's not paying full price every turn. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828), [#25434](https://github.com/NousResearch/hermes-agent/pull/25434), [#24778](https://github.com/NousResearch/hermes-agent/pull/24778)) -- **180x faster `browser_console` evaluations** — routed through the supervisor's persistent CDP WebSocket instead of spawning a fresh DevTools session per call. Real-world page interactions feel instant. ([#23226](https://github.com/NousResearch/hermes-agent/pull/23226)) +- **180x faster `browser_console` evaluations** — When the agent uses the browser tool to inspect a page or run JavaScript, those calls now share one persistent connection to Chrome instead of spinning up a new DevTools session every time. The difference is huge: things that used to take a couple of seconds per call return in milliseconds. Real-world page interactions feel instant. ([#23226](https://github.com/NousResearch/hermes-agent/pull/23226)) -- **Cold-start performance wave — ~19s off `hermes` launch** — skills cache + lazy Feishu + no Nous HTTP at startup, plus PEP-562 lazy adapter imports (QQ, Yuanbao, Teams, Google Chat), deferred `fal_client` / `google-cloud` / `httpx` loads, models.dev disk-cache-first lookup, parallel doctor API checks, eager-skip plugin discovery on built-in subcommands. `hermes tools` All-Platforms drops from 14s to <1.5s. Welcome banner skipped on `chat -q`. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341)) +- **Cold-start performance wave — ~19 seconds off `hermes` launch** — Running `hermes` used to make you wait through a chunk of import overhead and network calls before you saw a prompt. Now the launch path is mostly deferred: heavy adapters only load when you use them, model catalogs come from disk cache first, doctor checks run in parallel, and `chat -q` skips the welcome banner entirely. The `hermes tools` All-Platforms screen alone dropped from 14 seconds to under 1.5 seconds. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341)) -- **Two new messaging platforms — LINE + SimpleX Chat** — LINE Messaging API as a first-class platform, SimpleX Chat salvages #2558 onto the modern adapter spec. Hermes is now on 22 platforms. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232)) +- **Two new messaging platforms — LINE + SimpleX Chat** — LINE is huge in Japan, Korea, and Taiwan, and now Hermes runs natively on the LINE Messaging API. SimpleX Chat is the privacy-focused decentralized messenger with no user IDs — also wired up as a first-class platform. That brings Hermes to 22 messaging platforms total, so wherever you and your team chat, the agent can be there. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232)) -- **`/handoff` actually transfers the session live** — the agent's active session moves to a different model / persona / profile mid-conversation, with messages, tool history, and context preserved. ([#23395](https://github.com/NousResearch/hermes-agent/pull/23395)) +- **`/handoff` actually transfers the session live** — Switching models or personalities mid-conversation used to mean losing context or starting over. Now `/handoff` moves your active session — every message, every tool call, every piece of context — to the target model, persona, or profile, live, without dropping anything. Mid-debugging hand off from a fast model to a deep-reasoning one, or pass a session between profiles for different parts of a task. ([#23395](https://github.com/NousResearch/hermes-agent/pull/23395)) -- **Native button UI for `clarify` on Telegram and Discord** — the `clarify` tool renders multi-choice prompts as platform-native inline keyboards on Telegram and buttons on Discord instead of typed responses. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485)) +- **Native button UI for `clarify` on Telegram and Discord** — When the agent uses the `clarify` tool to ask you a multiple-choice question, it now shows real platform-native buttons on Telegram and Discord instead of asking you to type back the option number. Tap the button, the agent gets your answer. Especially nice on mobile. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485)) -- **Discord channel history backfill (default on)** — Hermes reads recent channel history when joining a thread so it actually knows what's already been said before responding. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984)) +- **Discord channel history backfill (default on)** — When Hermes joins a Discord channel or thread for the first time, it now reads the recent message history so it knows what's been said before it responds. No more "what are we talking about?" — the agent has the context that's already on screen for everyone else. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984)) -- **`vision_analyze` returns pixels to vision-capable models** — when the active model can see, `vision_analyze` now hands the image straight through instead of falling back to a text description. ([#22955](https://github.com/NousResearch/hermes-agent/pull/22955)) +- **`vision_analyze` returns pixels to vision-capable models** — When you point the agent at an image with `vision_analyze` and the active model can actually see (GPT-5, Claude, Gemini, Grok-vision), Hermes now passes the raw pixels straight to the model instead of converting them to a text description first. You get the model's actual visual reasoning instead of a degraded text-summary round-trip. ([#22955](https://github.com/NousResearch/hermes-agent/pull/22955)) -- **Per-turn file-mutation verifier footer** — after every turn that wrote files, the agent gets a verifier footer summarizing what actually changed on disk — catches silent overwrites and "wrote it but it didn't land" bugs. ([#24498](https://github.com/NousResearch/hermes-agent/pull/24498)) +- **Per-turn file-mutation verifier footer** — After every turn that wrote or edited files, the agent now gets a short footer summarizing exactly what changed on disk — the file paths, the line counts, the actual delta. That means the agent catches its own mistakes when a write didn't land or got silently overwritten, instead of confidently telling you "I added the function" when the file wasn't actually saved. ([#24498](https://github.com/NousResearch/hermes-agent/pull/24498)) -- **LSP semantic diagnostics on every write** — `write_file` and `patch` run real language-server diagnostics on the post-edit file (delta-only) and surface real errors before they ship downstream. Goes well beyond the in-proc Python/JSON/YAML/TOML linters from v0.13.0. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978)) +- **LSP semantic diagnostics on every write** — When the agent uses `write_file` or `patch`, Hermes now runs a real language server against the edited file and surfaces any new errors back to the agent before the next turn. Type errors, undefined symbols, missing imports — caught immediately. Goes way beyond v0.13.0's basic Python/JSON/YAML/TOML linting because it's actual semantic analysis. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978)) -- **Unified `video_generate` with pluggable provider backends** — single tool, any backend. Drop in a new video provider as a plugin, no core changes. ([#25126](https://github.com/NousResearch/hermes-agent/pull/25126)) +- **Unified `video_generate` with pluggable provider backends** — One tool, any video model. Hermes ships with the obvious backends already, but you can drop in a new video provider as a plugin without touching core. So when a new video model lands next month, it can be a one-file plugin instead of a fork. ([#25126](https://github.com/NousResearch/hermes-agent/pull/25126)) -- **`computer_use` cua-driver backend — works with non-Anthropic models now** — proper focus-safe ops, non-Anthropic provider support, refresh on `hermes update`. Computer-use is no longer locked to a single SDK. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063)) +- **`computer_use` cua-driver backend — works with non-Anthropic models now** — Computer-use (the agent controlling your mouse and keyboard to drive GUI apps) used to be locked to Anthropic's SDK. The new cua-driver backend works with non-Anthropic providers too, has proper focus-safe operations, and refreshes itself on `hermes update`. Now any vision-capable model can drive your desktop. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063)) -- **Clickable URLs in any terminal** — links in agent output now render as proper OSC8 hyperlinks with hover-highlight, in any terminal that supports them. Click to open. (@OutThisLife) ([#25071](https://github.com/NousResearch/hermes-agent/pull/25071), [#24013](https://github.com/NousResearch/hermes-agent/pull/24013)) +- **Clickable URLs in any terminal** — Links in agent output are now real OSC8 hyperlinks with hover-highlight in any terminal that supports them. Click to open in your browser — no more copy-paste-trim of long URLs from the transcript. Just works in iTerm2, Kitty, Ghostty, modern Windows Terminal, etc. (@OutThisLife) ([#25071](https://github.com/NousResearch/hermes-agent/pull/25071), [#24013](https://github.com/NousResearch/hermes-agent/pull/24013)) -- **Zed ACP Registry — `uvx` install in one click** — Hermes is in the Zed registry, installable via `uvx` (no npm needed). Plus `hermes acp --setup-browser` bootstraps browser tools for registry installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234)) +- **Zed ACP Registry — `uvx` install in one click** — Hermes is now listed in Zed's Agent Client Protocol registry, so Zed users can install it with one click. The install path uses `uvx` so there's no npm dependency. `hermes acp --setup-browser` bootstraps the browser tools for registry-driven installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234)) -- **OpenRouter Pareto Code router with `min_coding_score` knob** — pick the cheapest model that meets your quality bar. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838)) +- **OpenRouter Pareto Code router with `min_coding_score` knob** — OpenRouter's "Pareto" router automatically picks the cheapest model that meets a minimum quality bar. The new `min_coding_score` config lets you set that bar for coding tasks specifically — Hermes routes to the most affordable model that's at least that good at code. Stop paying for top-tier models when a mid-tier one would do. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838)) -- **NovitaAI as a new model provider** (salvage #7219) (@kshitijk4poor) ([#25507](https://github.com/NousResearch/hermes-agent/pull/25507)) +- **NovitaAI as a new model provider** — NovitaAI joins the provider lineup, giving you another option for open-source model hosting (Llama, Qwen, DeepSeek, etc.) with their pricing and rate limits. (salvage #7219) (@kshitijk4poor) ([#25507](https://github.com/NousResearch/hermes-agent/pull/25507)) -- **Codex app-server runtime for OpenAI/Codex models** — optional runtime that drives OpenAI's Codex CLI under the hood for OpenAI/Codex paths, with session reuse, wedge retirement, and OAuth refresh classification. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769)) +- **Codex app-server runtime for OpenAI/Codex models** — An optional runtime that drives OpenAI's Codex CLI under the hood when you're using OpenAI or Codex paths. You get session reuse, automatic retirement of wedged sessions, and proper OAuth refresh classification — the kind of plumbing that makes long agentic runs not fall over. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769)) -- **`huggingface/skills` as a trusted default tap** — community skills index from huggingface.co/skills is available by default in the Skills Hub. (closes #2549) ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219)) +- **`huggingface/skills` as a trusted default tap** — The community skills index hosted at huggingface.co/skills is now wired into the Skills Hub by default. So when somebody publishes a useful skill there, you can install it from your own `hermes skills` browser without any extra config. (closes #2549) ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219)) -- **9 new optional skills** — Hyperliquid (perp/spot trading via SDK + REST), Yahoo Finance market data, api-testing (REST/GraphQL debug), unified EVM multi-chain (folds #25291 + #2010 + base/), darwinian-evolver, osint-investigation (closes #355), pinggy-tunnel, watchers (RSS/HTTP/GitHub via cron `no_agent`), Notion overhaul for the May 2026 Developer Platform. ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612)) +- **9 new optional skills** — Hyperliquid (perp + spot trading via the SDK and REST API), Yahoo Finance (live market data, fundamentals, historicals), api-testing (REST + GraphQL debug recipes), unified EVM multi-chain (one skill covers Ethereum + L2s + Base), darwinian-evolver (evolutionary prompt/skill tuning), osint-investigation (OSINT recipes for people / domains / orgs), pinggy-tunnel (expose local services to the public internet), watchers (polls RSS / HTTP JSON / GitHub via cron `no_agent` mode for change detection), and a full Notion overhaul for the May 2026 Developer Platform. ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612)) -- **API server exposes run approval events** — long-running runs surface approval requests over the API stream, no more silent stalls. (salvage of [#20311](https://github.com/NousResearch/hermes-agent/pull/20311)) ([#21899](https://github.com/NousResearch/hermes-agent/pull/21899)) +- **API server exposes run approval events** — If you're driving Hermes programmatically through the HTTP API, long-running runs no longer silently hang when the agent hits an approval-required command. The approval request now surfaces on the API stream so your client can prompt the user and reply — no more silent stalls. (salvage of [#20311](https://github.com/NousResearch/hermes-agent/pull/20311)) ([#21899](https://github.com/NousResearch/hermes-agent/pull/21899)) -- **Plugins can run any LLM call via `ctx.llm` + `tool_override`** — plugins get a first-class hook to make their own LLM requests through the active provider/credentials, plus a `tool_override` flag for replacing built-in tools (closes #11049). ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759)) +- **Plugins can run any LLM call via `ctx.llm` + replace built-in tools via `tool_override`** — If you're writing a Hermes plugin, you now get first-class access to make LLM calls through the active provider and credentials — no manual client wiring. The new `tool_override` flag lets a plugin swap out a built-in tool with its own implementation cleanly. Plugin authors get the same model-routing and auth plumbing the core agent uses. (closes #11049) ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759)) -- **Brave Search (free tier) + DuckDuckGo (DDGS) as web-search providers** — two new free search backends alongside Tavily / SearXNG / Exa. ([#21337](https://github.com/NousResearch/hermes-agent/pull/21337)) +- **Brave Search (free tier) + DuckDuckGo (DDGS) as web-search providers** — Two new free web-search backends join Tavily, SearXNG, and Exa. Brave Search has a generous free tier; DDGS is the DuckDuckGo scraper that needs no key at all. Pick whichever fits your budget and rate-limit needs. ([#21337](https://github.com/NousResearch/hermes-agent/pull/21337)) -- **Sudo brute-force block + sudo-stdin/askpass DANGEROUS classification** — closes the `sudo -S` brute-force avenue; approval gates classify stdin-fed and askpass-stripped sudo invocations as dangerous. (salvages of #22194 + #21128) (@kshitijk4poor) ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736)) +- **Sudo brute-force block + 3 dangerous-command bypasses closed + tool-error sanitization** — The approval gate now blocks `sudo -S` brute-force attempts and classifies stdin-fed or askpass-stripped sudo invocations as DANGEROUS. Three known bypasses of dangerous-command detection are closed (inspired by Claude Code's command-detection work). And tool error strings are now sanitized before being re-injected into the model context, so a malicious file or remote service can't pass instructions to your agent through error output. ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736), [#26829](https://github.com/NousResearch/hermes-agent/pull/26829), [#26823](https://github.com/NousResearch/hermes-agent/pull/26823)) -- **Closes 3 dangerous-command detection bypasses + sanitizes tool error strings before injection** — inspired by Claude Code's command-detection work; closes 3 known bypasses and adds tool-error sanitization to stop adversarial output from re-entering the model context. ([#26829](https://github.com/NousResearch/hermes-agent/pull/26829), [#26823](https://github.com/NousResearch/hermes-agent/pull/26823)) +- **`/subgoal` — user-added criteria appended to an active `/goal`** — When you've got a `/goal` running (the persistent Ralph-loop goal where the agent keeps going until criteria are met), you can now use `/subgoal ` to layer extra success criteria onto it mid-run. The judge factors your new criteria into the done-or-keep-going decision without restarting the loop. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449)) -- **`/subgoal` — user-added criteria appended to active `/goal`** — layer extra success criteria onto a running goal loop. The judge sees them in the prompt, no behavior change when subgoals are empty. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449)) +- **Provider rename — Alibaba Cloud → Qwen Cloud** — The Alibaba Cloud provider is renamed to Qwen Cloud in the picker and config to match what the rest of the world calls it. Existing config keys still work — no breaking changes — but the UI matches the actual brand now. ([#24835](https://github.com/NousResearch/hermes-agent/pull/24835)) -- **Provider rename — Alibaba Cloud → Qwen Cloud, picker reorder** — matches what the world calls it. Existing config keys still work. ([#24835](https://github.com/NousResearch/hermes-agent/pull/24835)) +- **Native Windows support (early beta)** — Hermes now runs natively on `cmd.exe` and PowerShell without WSL. A full PowerShell installer handles MinGit auto-install, Microsoft Store python stub detection, and the foreground Ctrl+C dance. There's still rough edges (this is the "early beta" stamp) — ~40 follow-up Windows-only fixes already landed in the window — but the basic loop works end-to-end on a clean Windows box. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561)) -- **Native Windows support (early beta)** — Hermes runs natively on `cmd.exe` and PowerShell. Full PowerShell installer, native subprocess/PTY paths, taskkill-based process management, MinGit auto-install, foreground Ctrl+C preservation, ~40 follow-up Windows-only fixes. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561)) - ---- --- From 05af78c53d553f6dd20012ce18eb0c2c02d346c9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 12:15:45 -0700 Subject: [PATCH 05/24] fix(update): make Camofox lazy-installed instead of eager (#27055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `@askjo/camofox-browser` npm package was a top-level entry in the root `package.json` `dependencies` block, so `hermes update` ran its postinstall on every user, every update. That postinstall calls `npx camoufox-js fetch`, which silently downloads a ~300MB Firefox-fork browser binary from GitHub Releases — multi-minute on fast connections, and a hard block for users on slow / restricted networks (notably users in China running through a VPN). Camofox is an explicit opt-in browser backend. The runtime check in `tools/browser_tool.py` only routes through Camofox when the user has set `CAMOFOX_URL` (selected via `hermes tools` → Browser Automation → Camofox). Users who never opted in never touched the package at runtime, yet every `hermes update` paid for the binary fetch anyway. This change: * Removes `@askjo/camofox-browser` from root `package.json` dependencies (and the regenerated `package-lock.json` drops Camofox's entire transitive tree, ~2.6k lines). * Updates the Camofox `post_setup` handler in `hermes_cli/tools_config.py` to install `@askjo/camofox-browser@^1.5.2` explicitly when the user selects Camofox, and streams npm output (no `--silent`, no `capture_output`) so the ~300MB download is visible rather than appearing frozen. * Adds `tests/test_package_json_lazy_deps.py` as a regression guard so future PRs can't silently re-add Camofox (or any binary-postinstall package) to eager root dependencies. `agent-browser` stays eager — it is the default Chromium-driving backend used by every session that does not have a cloud browser provider configured, and its postinstall is small. Validation: | | Before | After | |---|---|---| | `hermes update` time on slow network | multi-minute hang at `→ Updating Node.js dependencies...` | seconds (no binary fetch) | | Camofox opt-in install visibility | silent, looked frozen | streamed npm output | | Regression guard against re-adding | none | `test_package_json_lazy_deps.py` | Tests: - `tests/test_package_json_lazy_deps.py`: 3/3 pass - `tests/tools/test_browser_camofox*`: 92/92 pass - `tests/hermes_cli/test_tools_config.py`: 66/66 pass - `tests/hermes_cli/test_cmd_update.py` + adjacent: green Reported by lulu (Discord, May 2026) — `hermes update` hangs at `→ Updating Node.js dependencies...` in China. Related: #18840, #18869. --- hermes_cli/tools_config.py | 36 +- package-lock.json | 2630 -------------------------- package.json | 1 - tests/test_package_json_lazy_deps.py | 85 + 4 files changed, 110 insertions(+), 2642 deletions(-) create mode 100644 tests/test_package_json_lazy_deps.py diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 074bd04aa64..3afaa5cc7c9 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -810,21 +810,35 @@ def _run_post_setup(post_setup_key: str): camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser" _npm_bin = shutil.which("npm") if not camofox_dir.exists() and _npm_bin: - _print_info(" Installing Camofox browser server...") + _print_info(" Installing Camofox browser package...") + _print_info(" First run downloads the Camoufox engine (~300MB) — this can take several minutes.") import subprocess - # Absolute npm path so .cmd shim executes on Windows. - result = subprocess.run( - [_npm_bin, "install", "--silent"], - capture_output=True, text=True, cwd=str(PROJECT_ROOT) - ) - if result.returncode == 0: - _print_success(" Camofox installed") - else: - _print_warning(" npm install failed - run manually: npm install") + # Install @askjo/camofox-browser on-demand. It is NOT in + # package.json so that `hermes update` does not silently pull + # the ~300MB Camoufox Firefox-fork binary for every user. + # Stream output (no capture, no --silent) so the long-running + # postinstall download is visible instead of looking frozen. + try: + result = subprocess.run( + [_npm_bin, "install", "@askjo/camofox-browser@^1.5.2", + "--no-fund", "--no-audit", "--progress=false"], + cwd=str(PROJECT_ROOT), + ) + if result.returncode == 0: + _print_success(" Camofox installed") + else: + _print_warning( + " npm install failed — run manually: " + "npm install @askjo/camofox-browser" + ) + except Exception as exc: + _print_warning(f" Camofox install failed: {exc}") + _print_info( + " Run manually: npm install @askjo/camofox-browser" + ) if camofox_dir.exists(): _print_info(" Start the Camofox server:") _print_info(" npx @askjo/camofox-browser") - _print_info(" First run downloads the Camoufox engine (~300MB)") _print_info(" Or use Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser") elif not shutil.which("npm"): _print_warning(" Node.js not found. Install Camofox via Docker:") diff --git a/package-lock.json b/package-lock.json index 8309e3b7a96..055fb0c9b50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,90 +10,12 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@askjo/camofox-browser": "^1.5.2", "agent-browser": "^0.26.0" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@askjo/camofox-browser": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@askjo/camofox-browser/-/camofox-browser-1.5.2.tgz", - "integrity": "sha512-SvRCzhWnJaplxHkRVF9l1OWako6pp2eUw2mZKHOERUfLWDO2Xe/IKI+5bB+UT1TNvO45P6XdhgfAtihcTEARCg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "camoufox-js": "^0.8.5", - "express": "^4.18.2", - "playwright": "^1.50.0", - "playwright-core": "^1.58.0", - "playwright-extra": "^4.3.6", - "prom-client": "^15.1.3", - "puppeteer-extra-plugin-stealth": "^2.11.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.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/@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/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-browser": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/agent-browser/-/agent-browser-0.26.0.tgz", @@ -103,2558 +25,6 @@ "bin": { "agent-browser": "bin/agent-browser.js" } - }, - "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/balanced-match": { - "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/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "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/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/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/bintrees": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", - "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", - "license": "MIT" - }, - "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/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/brace-expansion": { - "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": "^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": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "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.1.13" - } - }, - "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": ">= 0.8" - } - }, - "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": { - "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/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/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": { - "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": ">=0.10.0" - } - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "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/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/debug": { - "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.0.0" - } - }, - "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/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "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/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/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/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/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/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "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", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "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/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/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/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/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/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-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-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/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", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "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/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/iconv-lite": { - "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" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "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": "BSD-3-Clause" - }, - "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/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/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-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-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/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/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/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/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/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": "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": "^5.0.5" - }, - "engines": { - "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", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "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/ms": { - "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/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/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", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "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/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-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-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/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/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/prom-client": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", - "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.4.0", - "tdigest": "^0.1.1" - }, - "engines": { - "node": "^16 || ^18 || >=20" - } - }, - "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/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/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": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "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": { - "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": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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/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": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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": ">=8" - }, - "peerDependencies": { - "playwright-extra": "*", - "puppeteer-extra": "*" - }, - "peerDependenciesMeta": { - "playwright-extra": { - "optional": true - }, - "puppeteer-extra": { - "optional": true - } - } - }, - "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": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "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": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "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/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": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "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/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "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", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "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/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/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/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/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/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/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "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/tar-fs": { - "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": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "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/tdigest": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", - "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", - "license": "MIT", - "dependencies": { - "bintrees": "1.0.2" - } - }, - "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/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-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" - }, - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, - "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/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "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/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/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "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" - } } } } diff --git a/package.json b/package.json index 8fcf5cea696..7500796acd6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ }, "homepage": "https://github.com/NousResearch/Hermes-Agent#readme", "dependencies": { - "@askjo/camofox-browser": "^1.5.2", "agent-browser": "^0.26.0" }, "overrides": { diff --git a/tests/test_package_json_lazy_deps.py b/tests/test_package_json_lazy_deps.py new file mode 100644 index 00000000000..0e2456dba2a --- /dev/null +++ b/tests/test_package_json_lazy_deps.py @@ -0,0 +1,85 @@ +"""Invariants for what is eager vs lazy in the root ``package.json``. + +The root ``package.json`` is installed by ``hermes update`` on every user, +including users who never opted into a given browser backend. Anything +listed in ``dependencies`` therefore runs its npm postinstall script for +everyone — including binary-fetching backends, on every update. + +The contract: + +* ``agent-browser`` IS eager. It is the default Chromium-driving backend + used whenever the agent makes a browser call without a cloud provider + configured, so it must already be installed before any session starts. + Its postinstall is also small. + +* ``@askjo/camofox-browser`` is NOT eager. It is an explicit opt-in + alternative browser backend, selected by the user via + ``hermes tools`` → Browser Automation → Camofox, and only used at + runtime when ``CAMOFOX_URL`` is set. Its postinstall fetches a ~300MB + Firefox-fork binary, which silently blocked ``hermes update`` for + multi-minute stretches on slow / network-restricted connections + (notably users in China running through a VPN). The package is + installed on demand by ``tools_config.py`` ``post_setup_key == + "camofox"`` when the user actually selects Camofox. + +If a future PR re-adds Camofox (or any other binary-postinstall package) +to root ``dependencies``, this test fails — read the lazy-install +guidance in the ``hermes-agent-dev`` skill before changing the +expectations. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _root_package_json() -> dict: + with (REPO_ROOT / "package.json").open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def test_camofox_is_not_in_root_dependencies() -> None: + """Camofox must be opt-in, installed lazily by its post_setup handler.""" + deps = _root_package_json().get("dependencies", {}) + assert "@askjo/camofox-browser" not in deps, ( + "Camofox is a ~300MB binary-postinstall backend that must stay " + "out of root package.json dependencies. It belongs in the " + "Camofox post_setup handler in hermes_cli/tools_config.py so it " + "only installs when the user explicitly selects Camofox via " + "`hermes tools` → Browser Automation → Camofox." + ) + + +def test_agent_browser_stays_eager() -> None: + """agent-browser is the default backend; it must remain eager.""" + deps = _root_package_json().get("dependencies", {}) + assert "agent-browser" in deps, ( + "agent-browser is the default browser-tool backend used by every " + "session that doesn't have a cloud browser provider configured. " + "It must stay in root package.json dependencies so it is present " + "after `hermes setup` / `hermes update` without an explicit " + "post_setup step." + ) + + +def test_root_lockfile_has_no_camofox_entries() -> None: + """Regenerated lockfiles should not contain Camofox tree entries.""" + lock_path = REPO_ROOT / "package-lock.json" + if not lock_path.exists(): + # Some CI matrix shards skip lockfile materialization. + return + text = lock_path.read_text(encoding="utf-8") + assert "@askjo/camofox-browser" not in text, ( + "package-lock.json still references @askjo/camofox-browser. " + "Regenerate the lockfile after removing the dep: " + "`rm package-lock.json && npm install --package-lock-only " + "--ignore-scripts --no-fund --no-audit`." + ) + assert "camoufox-js" not in text, ( + "package-lock.json still references camoufox-js (transitive of " + "@askjo/camofox-browser). Regenerate the lockfile." + ) From c844d15c3d27991a35bbc4ec56558d85122412c9 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sat, 2 May 2026 08:15:11 -0700 Subject: [PATCH 06/24] fix(update): stream npm install output so postinstall progress is visible (#18840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes update` ran the repo-root and ui-tui npm installs with both `--silent` and `subprocess.run(..., capture_output=True)`, which hides all output from optional postinstall scripts. The largest of those — `@askjo/camofox-browser`'s `npx camoufox-js fetch` — downloads a Firefox-fork browser binary that can take many minutes on slow connections. Because nothing was printed during that wait, the updater appeared to hang at "Updating Node.js dependencies..." and users Ctrl-C'd, sometimes leaving `node_modules` partially installed. Drop `--silent` and pass `capture_output=False` for the repo-root and ui-tui paths so npm streams its `info run …` postinstall lines straight to the terminal. Output is still mirrored to `~/.hermes/logs/update.log` by the existing `_UpdateOutputStream` wrapper, so SSH-disconnect safety is preserved. The `web/` install path is untouched — its build step is fast and does not run binary-fetching postinstalls. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/main.py | 11 ++++++++-- tests/hermes_cli/test_cmd_update.py | 31 +++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 41c4a23f932..a893ee85846 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7226,17 +7226,24 @@ def _update_node_dependencies() -> None: if not (path / "package.json").exists(): continue + # Stream npm output (no `--silent`, no `capture_output`) so any + # optional dependency postinstall scripts (e.g. `agent-browser`'s + # Chromium fetch on first install) print progress instead of + # appearing to hang silently for minutes (#18840). The + # `_UpdateOutputStream` wrapper installed by the updater mirrors + # streamed output to ``~/.hermes/logs/update.log`` so nothing is lost. result = _run_npm_install_deterministic( npm, path, - extra_args=("--silent", "--no-fund", "--no-audit", "--progress=false"), + extra_args=("--no-fund", "--no-audit", "--progress=false"), + capture_output=False, ) if result.returncode == 0: print(f" ✓ {label}") continue print(f" ⚠ npm install failed in {label}") - stderr = (result.stderr or "").strip() + stderr = (result.stderr or "").strip() if result.stderr else "" if stderr: print(f" {stderr.splitlines()[-1]}") diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index f059e54ac05..2f4b836286b 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -130,17 +130,22 @@ class TestCmdUpdateBranchFallback: # 1. repo root — slash-command / TUI bridge deps # 2. ui-tui/ — Ink TUI deps # 3. web/ — install + "npm run build" for the web frontend - full_flags = [ + # + # Repo-root and ui-tui installs intentionally omit `--silent` and run + # without `capture_output` so optional postinstall scripts (e.g. + # `@askjo/camofox-browser`'s browser-binary fetch) print progress — + # otherwise long downloads look like a hang (#18840). The web/ install + # keeps `--silent` because its build step is short and noisy. + update_flags = [ "/usr/bin/npm", "ci", - "--silent", "--no-fund", "--no-audit", "--progress=false", ] assert npm_calls[:2] == [ - (full_flags, PROJECT_ROOT), - (full_flags, PROJECT_ROOT / "ui-tui"), + (update_flags, PROJECT_ROOT), + (update_flags, PROJECT_ROOT / "ui-tui"), ] if len(npm_calls) > 2: assert npm_calls[2:] == [ @@ -148,6 +153,24 @@ class TestCmdUpdateBranchFallback: (["/usr/bin/npm", "run", "build"], PROJECT_ROOT / "web"), ] + # Regression for #18840: repo root + ui-tui installs must stream + # output (capture_output=False) so postinstall progress is visible + # to the user. + repo_and_tui_calls = [ + call + for call in mock_run.call_args_list + if call.args + and call.args[0][0] == "/usr/bin/npm" + and call.args[0][1] == "ci" + and call.kwargs.get("cwd") in (PROJECT_ROOT, PROJECT_ROOT / "ui-tui") + ] + assert len(repo_and_tui_calls) == 2 + for call in repo_and_tui_calls: + assert call.kwargs.get("capture_output") is False, ( + "repo-root / ui-tui npm install must stream output " + "(no capture_output) so postinstall progress is visible" + ) + def test_update_non_interactive_runs_safe_config_migrations(self, mock_args, capsys): """Dashboard/web updates apply non-interactive migrations before restart.""" with patch("shutil.which", return_value=None), patch( From fc03c95da13105807cb3b3f42a311e4916b456ce Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 12:51:08 -0700 Subject: [PATCH 07/24] feat(cli): add /exit --delete flag to remove session on quit (#27101) Port from google-gemini/gemini-cli#19332. Users can now exit with '/exit --delete' (or '/quit --delete', '/exit -d') to permanently remove the current session's SQLite history plus on-disk transcripts (*.json / *.jsonl / request_dump_*) in one shot. Useful for privacy-sensitive workflows and one-off interactions where leaving a session recording behind is undesirable. Implementation: - New HermesCLI._delete_session_on_exit one-shot flag (defaults False). - process_command() parses --delete / -d after /exit or /quit and arms the flag. Unknown args print a hint and keep the CLI running (prevents typos like '/exit -delete' from accidentally exiting). - Shutdown path calls SessionDB.delete_session(session_id, sessions_dir=...) right after end_session() when the flag is set. That API already existed for 'hermes sessions delete' and handles both SQLite removal (orphaning child sessions so FK constraints hold) and on-disk file cleanup. - /quit CommandDef now advertises '[--delete]' in args_hint so /help and CLI autocomplete surface it. Tests: tests/cli/test_exit_delete_session.py (12 cases covering both aliases, case insensitivity, whitespace, short form, unknown-arg rejection, and registry metadata). E2E-verified with isolated HERMES_HOME: session row deleted, all three transcript/request-dump files removed, second delete_session call correctly returns False. --- cli.py | 28 ++++++ hermes_cli/commands.py | 4 +- tests/cli/test_exit_delete_session.py | 119 +++++++++++++++++++++++ website/docs/reference/slash-commands.md | 2 +- 4 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 tests/cli/test_exit_delete_session.py diff --git a/cli.py b/cli.py index 241d41e9fcd..12f9ee98fb8 100644 --- a/cli.py +++ b/cli.py @@ -2824,6 +2824,11 @@ class HermesCLI: # turn (which would make Ctrl+C feel like it did nothing). self._last_turn_interrupted = False self._should_exit = False + # /exit --delete: when True, the current session's SQLite history and + # on-disk transcripts are deleted during shutdown. Set by + # process_command() when the user runs /exit --delete or /quit --delete. + # Ported from google-gemini/gemini-cli#19332. + self._delete_session_on_exit = False self._last_ctrl_c_time = 0 self._clarify_state = None self._clarify_freetext = False @@ -7653,6 +7658,16 @@ class HermesCLI: canonical = _cmd_def.name if _cmd_def else _base_word if canonical in {"quit", "exit"}: + # Parse --delete flag: /exit --delete also removes the current + # session's transcripts + SQLite history. Ported from + # google-gemini/gemini-cli#19332. + _rest = cmd_original.split(None, 1) + _args = (_rest[1] if len(_rest) > 1 else "").strip().lower() + if _args in ("--delete", "-d"): + self._delete_session_on_exit = True + elif _args: + _cprint(f" {_DIM}✗ Unknown argument: {_escape(_args)}. Use /exit --delete to also remove session history.{_RST}") + return True return False elif canonical == "help": self.show_help() @@ -13822,6 +13837,19 @@ class HermesCLI: self._session_db.end_session(self.agent.session_id, "cli_close") except (Exception, KeyboardInterrupt) as e: logger.debug("Could not close session in DB: %s", e) + # /exit --delete: also remove the current session's transcripts + # and SQLite history. Ported from google-gemini/gemini-cli#19332. + if getattr(self, '_delete_session_on_exit', False): + try: + from hermes_constants import get_hermes_home as _ghh + _sessions_dir = _ghh() / "sessions" + _sid = self.agent.session_id + if self._session_db.delete_session(_sid, sessions_dir=_sessions_dir): + _cprint(f" {_DIM}✓ Session {_escape(_sid)} deleted{_RST}") + else: + _cprint(f" {_DIM}✗ Session {_escape(_sid)} not found for deletion{_RST}") + except (Exception, KeyboardInterrupt) as e: + logger.debug("Could not delete session on exit: %s", e) # Plugin hook: on_session_end — safety net for interrupted exits. # run_conversation() already fires this per-turn on normal completion, # so only fire here if the agent was mid-turn (_agent_running) when diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 83d86c4a3a9..07e5b5e5c4a 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -211,8 +211,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"), # Exit - CommandDef("quit", "Exit the CLI", "Exit", - cli_only=True, aliases=("exit",)), + CommandDef("quit", "Exit the CLI (use --delete to also remove session history)", "Exit", + cli_only=True, aliases=("exit",), args_hint="[--delete]"), ] diff --git a/tests/cli/test_exit_delete_session.py b/tests/cli/test_exit_delete_session.py new file mode 100644 index 00000000000..dd4fe8d5aa1 --- /dev/null +++ b/tests/cli/test_exit_delete_session.py @@ -0,0 +1,119 @@ +"""Tests for `/exit --delete` and `/quit --delete` session deletion. + +Ports the behavior from google-gemini/gemini-cli#19332: running `/exit` or +`/quit` with the `--delete` flag arms a one-shot `_delete_session_on_exit` +flag that the CLI shutdown path uses to remove the current session from +SQLite + on-disk transcripts before exit. +""" + +from unittest.mock import MagicMock + + +def _make_cli(): + """Bare HermesCLI suitable for process_command() tests. + + Uses ``__new__`` to skip the heavy __init__; only sets the attributes + the /exit branch touches. + """ + from cli import HermesCLI + cli = HermesCLI.__new__(HermesCLI) + cli.config = {} + cli.console = MagicMock() + cli.agent = None + cli.conversation_history = [] + cli.session_id = "test-session" + cli._delete_session_on_exit = False + return cli + + +class TestExitDeleteFlag: + def test_plain_exit_does_not_arm_delete(self): + cli = _make_cli() + result = cli.process_command("/exit") + assert result is False + assert cli._delete_session_on_exit is False + + def test_plain_quit_does_not_arm_delete(self): + cli = _make_cli() + result = cli.process_command("/quit") + assert result is False + assert cli._delete_session_on_exit is False + + def test_exit_delete_arms_flag(self): + cli = _make_cli() + result = cli.process_command("/exit --delete") + assert result is False + assert cli._delete_session_on_exit is True + + def test_quit_delete_arms_flag(self): + cli = _make_cli() + result = cli.process_command("/quit --delete") + assert result is False + assert cli._delete_session_on_exit is True + + def test_exit_delete_short_form(self): + """`-d` is a convenience alias for `--delete`.""" + cli = _make_cli() + result = cli.process_command("/exit -d") + assert result is False + assert cli._delete_session_on_exit is True + + def test_quit_alias_q_is_not_quit(self): + """`/q` is the alias for `/queue`, not `/quit`. This test documents + that /q --delete does NOT arm session deletion — it would dispatch + to /queue instead.""" + cli = _make_cli() + cli._pending_input = __import__("queue").Queue() + # /q with no args shows a usage error and keeps the CLI running. + result = cli.process_command("/q") + assert result is not False # queue command doesn't exit + assert cli._delete_session_on_exit is False + + def test_delete_flag_is_case_insensitive(self): + cli = _make_cli() + result = cli.process_command("/exit --DELETE") + assert result is False + assert cli._delete_session_on_exit is True + + def test_delete_flag_trims_whitespace(self): + cli = _make_cli() + result = cli.process_command("/exit --delete ") + assert result is False + assert cli._delete_session_on_exit is True + + def test_unknown_exit_argument_does_not_exit(self): + """Unrecognised args should NOT exit the CLI — they surface an + error message and stay in the session. This prevents accidental + session destruction from typos like `/exit -delete`.""" + cli = _make_cli() + result = cli.process_command("/exit --delte") + # process_command returns True = keep running + assert result is True + assert cli._delete_session_on_exit is False + + def test_unknown_exit_argument_prints_help(self): + cli = _make_cli() + # _cprint goes through module-level print, so capture via console. + # We can't patch _cprint directly without import juggling; the + # previous assertion already proves the unknown-arg branch is + # reached (result True + flag False). + result = cli.process_command("/exit garbage") + assert result is True + assert cli._delete_session_on_exit is False + + +class TestCommandRegistry: + def test_quit_command_advertises_delete_flag(self): + """The CommandDef args_hint should surface `--delete` in /help and + CLI autocomplete.""" + from hermes_cli.commands import resolve_command + cmd = resolve_command("quit") + assert cmd is not None + assert cmd.args_hint == "[--delete]" + + def test_exit_alias_resolves_to_quit_with_hint(self): + from hermes_cli.commands import resolve_command + cmd = resolve_command("exit") + assert cmd is not None + assert cmd.name == "quit" + assert cmd.args_hint == "[--delete]" diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 377c31c4477..05424c1cd18 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -98,7 +98,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | Command | Description | |---------|-------------| -| `/quit` | Exit the CLI (also: `/exit`). | +| `/quit` | Exit the CLI (also: `/exit`). See note on `/q` under `/queue` above. Pass `--delete` (or `-d`) — e.g. `/exit --delete` — to also permanently remove the current session's SQLite history and on-disk transcripts before exiting. Useful for privacy-sensitive or one-off tasks. | ### Dynamic CLI slash commands From dc3d0fe1489aebd5747fa620d9b2eec751a92a55 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 12:55:23 -0700 Subject: [PATCH 08/24] Port from cline/cline#10343: periodic gateway memory logging (#27102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit a grep-friendly '[MEMORY] rss=...MB ...' line in agent.log / gateway.log every N minutes (default 5) so slow leaks in the long-lived gateway process show up as a time series. Based on https://github.com/cline/cline/pull/10343 (src/standalone/memory-monitor.ts). - gateway/memory_monitor.py: new module. Daemon thread, baseline on start, final snapshot on stop. Uses resource.getrusage() (stdlib) first, falls back to psutil, disables itself with one WARNING if neither is available. - gateway/run.py: start monitor right after setup_logging() in start_gateway(); stop it in the shutdown block next to MCP teardown. - hermes_cli/config.py: logging.memory_monitor { enabled, interval_seconds } defaults under the existing logging section. - tests/gateway/test_memory_monitor.py: 10 unit tests covering format, baseline/shutdown snapshots, double-start noop, periodic timer, daemon thread invariant, and unavailable-RSS warn-and-skip path. Adapted from TypeScript/Node to Python (threading.Event-based daemon thread instead of setInterval/unref), added Python-specific gc + thread counts to the log line (handier than ext/arrayBuffers for diagnosing Python gateway leaks), and gated behind a config.yaml toggle so users can silence the periodic line if they want. No heap-snapshot-on-OOM equivalent — CPython doesn't have V8's --heapsnapshot-near-heap-limit; tracemalloc would be the Python equivalent but adds non-trivial overhead, so leaving that out. --- gateway/memory_monitor.py | 230 +++++++++++++++++++++++++++ gateway/run.py | 37 +++++ hermes_cli/config.py | 9 ++ tests/gateway/test_memory_monitor.py | 122 ++++++++++++++ 4 files changed, 398 insertions(+) create mode 100644 gateway/memory_monitor.py create mode 100644 tests/gateway/test_memory_monitor.py diff --git a/gateway/memory_monitor.py b/gateway/memory_monitor.py new file mode 100644 index 00000000000..bacbbba34ef --- /dev/null +++ b/gateway/memory_monitor.py @@ -0,0 +1,230 @@ +"""Periodic process memory usage logging for the gateway. + +Ported from cline/cline#10343 (src/standalone/memory-monitor.ts). + +The gateway is a long-lived process that accumulates memory as it caches +agent instances, session transcripts, tool schemas, memory providers, MCP +connections, etc. A slow leak in any of those subsystems is invisible +in a single log line — you only see it by watching RSS climb over hours. + +This module emits a single structured ``[MEMORY] ...`` line every N +minutes (default 5) so maintainers investigating a suspected leak can +grep ``agent.log`` / ``gateway.log`` for a time series of RSS + Python +GC stats. The timer runs in a background thread and shuts down cleanly +with the gateway. + +Design notes (parity with the Cline port): + * Grep-friendly single-line format beginning ``[MEMORY]``. + * Final snapshot logged on shutdown so "last RSS before exit" is + always in the log. + * Baseline snapshot logged immediately on start. + * Daemon thread — never blocks process exit. + * Uses ``resource`` (stdlib, Linux/macOS) first and falls back to + ``psutil`` when ``resource`` isn't available (Windows). Both are + optional; when neither works we emit a single WARNING and disable + the monitor rather than crashing the gateway. + +Config: ``logging.memory_monitor`` in ``config.yaml`` — see +``hermes_cli/config.py`` for the defaults block. +""" + +from __future__ import annotations + +import gc +import logging +import os +import sys +import threading +import time +from typing import Optional + +logger = logging.getLogger(__name__) + +_BYTES_TO_MB = 1024 * 1024 + +_monitor_thread: Optional[threading.Thread] = None +_stop_event: Optional[threading.Event] = None +_start_time: Optional[float] = None +_interval_seconds: float = 300.0 # 5 minutes +_lock = threading.Lock() + + +def _get_rss_mb() -> Optional[int]: + """Return current process resident set size in MB, or None if unavailable. + + Tries ``resource.getrusage`` first (Linux/macOS, no extra deps), then + falls back to ``psutil`` which is an optional hermes-agent dep. + """ + # Linux / macOS — resource is stdlib. On Linux ru_maxrss is in KB, + # on macOS it is in bytes (yes, really). We use it as a cheap + # "current" RSS — ru_maxrss reports the high-water mark for the + # process, which is what you actually want for leak detection. + try: + import resource + + maxrss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + if sys.platform == "darwin": + return int(maxrss / _BYTES_TO_MB) + # Linux / other unices: KB + return int(maxrss / 1024) + except Exception: + pass + + # Fallback: psutil (Windows, or unusual unix without resource). + try: + import psutil # type: ignore + + rss = psutil.Process(os.getpid()).memory_info().rss + return int(rss / _BYTES_TO_MB) + except Exception: + return None + + +def log_memory_usage(prefix: str = "") -> None: + """Log current memory usage in a grep-friendly ``[MEMORY] ...`` line. + + Safe to call on-demand from any thread at important lifecycle + moments (after shutdown, after context compression, etc.). + + Parameters + ---------- + prefix + Optional extra tag inserted after ``[MEMORY]`` — e.g. + ``"baseline"``, ``"shutdown"``. + """ + rss = _get_rss_mb() + uptime = int(time.monotonic() - _start_time) if _start_time else 0 + # gc.get_stats() returns per-generation collection counts; the sum + # is a cheap proxy for "how much garbage have we created". + try: + gc_counts = gc.get_count() # (gen0, gen1, gen2) + except Exception: + gc_counts = (0, 0, 0) + # Thread count is a handy correlate when diagnosing thread leaks. + try: + thread_count = threading.active_count() + except Exception: + thread_count = 0 + + tag = f"{prefix} " if prefix else "" + if rss is None: + logger.info( + "[MEMORY] %srss=unavailable gc=%s threads=%d uptime=%ds", + tag, + gc_counts, + thread_count, + uptime, + ) + else: + logger.info( + "[MEMORY] %srss=%dMB gc=%s threads=%d uptime=%ds", + tag, + rss, + gc_counts, + thread_count, + uptime, + ) + + +def _monitor_loop(stop_event: threading.Event, interval: float) -> None: + """Background thread body — log every ``interval`` seconds until stopped.""" + while not stop_event.wait(interval): + try: + log_memory_usage() + except Exception as e: + # Never let the monitor crash the gateway; just log and carry on. + logger.debug("Memory monitor iteration failed: %s", e) + + +def start_memory_monitoring(interval_seconds: float = 300.0) -> bool: + """Start periodic memory usage logging in a daemon thread. + + Logs immediately to capture a baseline, then every ``interval_seconds``. + Safe to call multiple times — subsequent calls are no-ops while the + first monitor is still running. + + Parameters + ---------- + interval_seconds + How often to log. Default 300s (5 minutes), matching the + upstream cline/cline implementation. + + Returns + ------- + bool + True if a fresh monitor thread was started, False if one was + already running or if memory introspection isn't available. + """ + global _monitor_thread, _stop_event, _start_time, _interval_seconds + + with _lock: + if _monitor_thread is not None and _monitor_thread.is_alive(): + return False + + # Sanity-check that we can read RSS at all. If neither resource + # nor psutil works, no point spinning a thread that can only log + # "rss=unavailable" forever — warn once and bail. + if _get_rss_mb() is None: + logger.warning( + "[MEMORY] Memory monitoring unavailable: neither resource.getrusage " + "nor psutil could read process RSS — skipping periodic logging.", + ) + return False + + _start_time = time.monotonic() + _interval_seconds = float(interval_seconds) + _stop_event = threading.Event() + + # Baseline snapshot before the loop starts. + log_memory_usage(prefix="baseline") + + _monitor_thread = threading.Thread( + target=_monitor_loop, + args=(_stop_event, _interval_seconds), + name="gateway-memory-monitor", + daemon=True, + ) + _monitor_thread.start() + + logger.info( + "[MEMORY] Periodic memory monitoring started (interval: %ds)", + int(_interval_seconds), + ) + return True + + +def stop_memory_monitoring(timeout: float = 2.0) -> None: + """Stop the monitor thread and log a final snapshot. + + Safe to call even if ``start_memory_monitoring()`` was never called. + """ + global _monitor_thread, _stop_event + + with _lock: + if _stop_event is None or _monitor_thread is None: + return + + # Final snapshot before teardown so "last RSS" is always in the log. + try: + log_memory_usage(prefix="shutdown") + except Exception: + pass + + _stop_event.set() + thread = _monitor_thread + _monitor_thread = None + _stop_event = None + + # Join outside the lock so a stuck log call can't deadlock shutdown. + try: + thread.join(timeout=timeout) + except Exception: + pass + + logger.info("[MEMORY] Periodic memory monitoring stopped") + + +def is_running() -> bool: + """True if the background monitor thread is alive.""" + with _lock: + return _monitor_thread is not None and _monitor_thread.is_alive() diff --git a/gateway/run.py b/gateway/run.py index f9a282a413f..a5eaafcb063 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -16800,6 +16800,33 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = from hermes_logging import setup_logging setup_logging(hermes_home=_hermes_home, mode="gateway") + # Periodic process memory usage logging (gateway only) — emits a + # grep-friendly "[MEMORY] rss=...MB ..." line every N minutes so + # slow leaks in the long-lived gateway process show up as a time + # series in agent.log / gateway.log. Ported from cline/cline#10343. + # Controlled by the logging.memory_monitor section in config.yaml. + try: + from gateway import memory_monitor as _memory_monitor + + _mm_cfg = {} + try: + # config is loaded a few lines up; re-read the logging section + # here so we pick up user overrides without coupling to local + # variable names inside the start_gateway body. + from hermes_cli.config import load_config as _load_cli_config + + _mm_cfg = (_load_cli_config() or {}).get("logging", {}).get("memory_monitor", {}) or {} + except Exception: + _mm_cfg = {} + if _mm_cfg.get("enabled", True): + try: + _mm_interval = float(_mm_cfg.get("interval_seconds", 300)) + except (TypeError, ValueError): + _mm_interval = 300.0 + _memory_monitor.start_memory_monitoring(interval_seconds=_mm_interval) + except Exception as _mm_exc: + logger.debug("Failed to start memory monitor: %s", _mm_exc) + # Optional stderr handler — level driven by -v/-q flags on the CLI. # verbosity=None (-q/--quiet): no stderr output # verbosity=0 (default): WARNING and above @@ -17016,6 +17043,16 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = except Exception: pass + # Stop the periodic memory monitor (if it was started above). + # This also emits one final "[MEMORY] shutdown rss=..." line so the + # last RSS reading before gateway exit is always in the log. + try: + from gateway import memory_monitor as _memory_monitor + + _memory_monitor.stop_memory_monitoring(timeout=2.0) + except Exception: + pass + if runner.exit_code is not None: raise SystemExit(runner.exit_code) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 574f2397d91..81706d1edb4 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1477,6 +1477,15 @@ DEFAULT_CONFIG = { "level": "INFO", # Minimum level for agent.log: DEBUG, INFO, WARNING "max_size_mb": 5, # Max size per log file before rotation "backup_count": 3, # Number of rotated backup files to keep + # Periodic process memory usage logging (gateway only). Emits a + # grep-friendly "[MEMORY] rss=...MB ..." line at the configured + # interval so slow leaks in the long-lived gateway are visible + # in agent.log / gateway.log as a time series. Ported from + # cline/cline#10343. + "memory_monitor": { + "enabled": True, # Flip to false to silence the periodic line + "interval_seconds": 300, # Default: every 5 minutes + }, }, # Remotely-hosted model catalog manifest. When enabled, the CLI fetches diff --git a/tests/gateway/test_memory_monitor.py b/tests/gateway/test_memory_monitor.py new file mode 100644 index 00000000000..64903dc81f8 --- /dev/null +++ b/tests/gateway/test_memory_monitor.py @@ -0,0 +1,122 @@ +"""Tests for gateway.memory_monitor — periodic process memory logging. + +Ported from cline/cline#10343. The module logs a structured +``[MEMORY] rss=...MB ...`` line periodically so long-running gateway +leaks show up as a time series in agent.log / gateway.log. +""" + +from __future__ import annotations + +import logging +import time + +import pytest + +from gateway import memory_monitor as mm + + +@pytest.fixture(autouse=True) +def _ensure_monitor_stopped(): + """Every test starts from a clean state and leaves one behind.""" + mm.stop_memory_monitoring(timeout=1.0) + yield + mm.stop_memory_monitoring(timeout=1.0) + + +def test_log_memory_usage_emits_memory_line(caplog): + caplog.set_level(logging.INFO, logger="gateway.memory_monitor") + mm.log_memory_usage() + memory_lines = [r for r in caplog.records if "[MEMORY]" in r.getMessage()] + assert memory_lines, "expected at least one [MEMORY] log record" + + +def test_log_memory_usage_has_grep_friendly_format(caplog): + caplog.set_level(logging.INFO, logger="gateway.memory_monitor") + mm.log_memory_usage() + msg = caplog.records[-1].getMessage() + # Grep-friendly contract: line starts with [MEMORY] and carries RSS + # (or 'unavailable'), GC counts, thread count, uptime. + assert msg.startswith("[MEMORY]"), msg + assert "rss=" in msg + assert "gc=" in msg + assert "threads=" in msg + assert "uptime=" in msg + + +def test_log_memory_usage_with_prefix(caplog): + caplog.set_level(logging.INFO, logger="gateway.memory_monitor") + mm.log_memory_usage(prefix="baseline") + msg = caplog.records[-1].getMessage() + assert "[MEMORY] baseline " in msg + + +def test_start_logs_baseline_and_returns_true(caplog): + caplog.set_level(logging.INFO, logger="gateway.memory_monitor") + # Large interval so the background timer never fires during the test — + # we're only checking the synchronous baseline behavior here. + started = mm.start_memory_monitoring(interval_seconds=3600.0) + assert started is True + assert mm.is_running() is True + + messages = [r.getMessage() for r in caplog.records] + assert any("[MEMORY] baseline " in m for m in messages), messages + assert any("Periodic memory monitoring started" in m for m in messages), messages + + +def test_double_start_is_noop(): + assert mm.start_memory_monitoring(interval_seconds=3600.0) is True + assert mm.start_memory_monitoring(interval_seconds=3600.0) is False + assert mm.is_running() is True + + +def test_stop_logs_shutdown_snapshot(caplog): + mm.start_memory_monitoring(interval_seconds=3600.0) + caplog.clear() + caplog.set_level(logging.INFO, logger="gateway.memory_monitor") + mm.stop_memory_monitoring(timeout=1.0) + assert mm.is_running() is False + + messages = [r.getMessage() for r in caplog.records] + assert any("[MEMORY] shutdown " in m for m in messages), messages + assert any("Periodic memory monitoring stopped" in m for m in messages), messages + + +def test_stop_without_start_is_noop(): + # Must not raise, must not log shutdown snapshot. + mm.stop_memory_monitoring(timeout=0.5) + assert mm.is_running() is False + + +def test_periodic_timer_fires(caplog): + caplog.set_level(logging.INFO, logger="gateway.memory_monitor") + # Short interval so we can observe multiple ticks inside the test budget. + mm.start_memory_monitoring(interval_seconds=0.1) + time.sleep(0.45) + mm.stop_memory_monitoring(timeout=1.0) + + periodic = [ + r for r in caplog.records + if r.getMessage().startswith("[MEMORY] rss=") or r.getMessage().startswith("[MEMORY] rss=unavailable") + ] + # baseline + at least 2 periodic + shutdown — but shutdown has the + # "shutdown " prefix so it won't match the strict "[MEMORY] rss=" start. + # We expect >= 3 bare "[MEMORY] rss=..." lines. + assert len(periodic) >= 3, [r.getMessage() for r in caplog.records] + + +def test_thread_is_daemon(): + mm.start_memory_monitoring(interval_seconds=3600.0) + assert mm._monitor_thread is not None + assert mm._monitor_thread.daemon is True, ( + "memory monitor thread must be daemon so it can never block process exit" + ) + + +def test_unavailable_rss_warns_and_does_not_start(caplog, monkeypatch): + # Force both backends to claim unavailable; start should bail. + monkeypatch.setattr(mm, "_get_rss_mb", lambda: None) + caplog.set_level(logging.WARNING, logger="gateway.memory_monitor") + started = mm.start_memory_monitoring(interval_seconds=3600.0) + assert started is False + assert mm.is_running() is False + assert any("Memory monitoring unavailable" in r.getMessage() for r in caplog.records) From 93e109a1d552b03c847b96077428048cceb012cd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 13:02:19 -0700 Subject: [PATCH 09/24] fix(moonshot): strip $ref siblings and collapse tuple items in tool schemas (#27104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from anomalyco/opencode#24730: Moonshot's JSON Schema validator rejects two shapes that the rest of the JSON Schema ecosystem accepts: 1. $ref nodes with sibling keywords. Moonshot expands the reference before validation and then rejects the node if keys like `description`, `type`, or `default` appear alongside $ref. MCP-sourced tool schemas commonly put a `description` on $ref-typed properties so the model sees the field hint — which worked on every provider except Moonshot. 2. Tuple-style `items` arrays (positional element schemas). Moonshot's engine requires ONE schema applied to every array element. Common in tool schemas generated from Go/Protobuf that model fixed-length arrays as `[{type:number}, {type:number}]`. Repairs applied in `agent/moonshot_schema.py`: - Rule 3: when a node has `$ref`, return `{"$ref": }` only (strip every sibling). The referenced definition still carries its own description on the target node, which Moonshot accepts. - Rule 4: when `items` is a list, collapse to the first element schema (falling back to `{}` which is then filled by the generic missing-type rule). Preserves `minItems` / `maxItems` / other siblings. Tests: 10 new cases across TestRefSiblingStripping + TestTupleItems, plus the existing TestMissingTypeFilled::test_ref_node_is_not_given_synthetic_type still passes (it asserted plain $ref passes through; now it passes through as exactly `{"$ref": "..."}` which is strictly compatible). All 35 tests in test_moonshot_schema.py pass. --- agent/moonshot_schema.py | 31 ++++++ tests/agent/test_moonshot_schema.py | 163 ++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/agent/moonshot_schema.py b/agent/moonshot_schema.py index f22176f936e..6f785af5469 100644 --- a/agent/moonshot_schema.py +++ b/agent/moonshot_schema.py @@ -15,6 +15,18 @@ and MoonshotAI/kimi-cli#1595: 2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not the parent. Presence of both causes "type should be defined in anyOf items instead of the parent schema". +3. ``enum`` arrays on scalar-typed nodes may not contain ``null`` or empty + strings. Strip those entries (drop the enum entirely if it becomes empty). +4. ``$ref`` nodes may not carry sibling keywords. Moonshot expands the + reference before validation and then rejects the node if sibling keys + like ``description`` remain on the same node as ``$ref``. Strip every + sibling from ``$ref`` nodes so only ``{"$ref": "..."}`` survives. + (Ported from anomalyco/opencode#24730.) +5. ``items`` may not be a tuple-style array (``items: [schemaA, schemaB]`` + for positional element schemas). Moonshot's schema engine requires a + single object schema applied to every array element. Collapse tuple + ``items`` to the first element schema (or ``{}`` if the tuple is empty). + (Ported from anomalyco/opencode#24730.) The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it @@ -66,6 +78,16 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any: } elif key in _SCHEMA_LIST_KEYS and isinstance(value, list): repaired[key] = [_repair_schema(v, is_schema=True) for v in value] + elif key == "items" and isinstance(value, list): + # Rule 5: tuple-style ``items`` arrays (positional element + # schemas) are not accepted by Moonshot. Collapse to the + # first element schema if present, else to ``{}``. This + # matches opencode's behaviour for moonshotai / kimi models. + first = value[0] if value else {} + if isinstance(first, dict): + repaired[key] = _repair_schema(first, is_schema=True) + else: + repaired[key] = first elif key in _SCHEMA_NODE_KEYS: # items / not / additionalProperties: single nested schema. # additionalProperties can also be a bool — leave those alone. @@ -130,6 +152,15 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any: else: repaired.pop("enum") + # Rule 4: $ref nodes must not have sibling keywords. Moonshot expands + # the reference before validation and then rejects the node if siblings + # like ``description`` / ``type`` / ``default`` appear alongside $ref. + # The referenced definition still carries its own description on the + # target node, which Moonshot accepts. + # (Ported from anomalyco/opencode#24730.) + if "$ref" in repaired: + return {"$ref": repaired["$ref"]} + return repaired diff --git a/tests/agent/test_moonshot_schema.py b/tests/agent/test_moonshot_schema.py index 2ce2daa096a..8ba508c5dbd 100644 --- a/tests/agent/test_moonshot_schema.py +++ b/tests/agent/test_moonshot_schema.py @@ -6,6 +6,11 @@ the JSON Schema ecosystem accepts: 1. Properties without ``type`` — Moonshot requires ``type`` on every node. 2. ``type`` at the parent of ``anyOf`` — Moonshot requires it only inside ``anyOf`` children. +3. ``$ref`` with sibling keywords — Moonshot expands the ref first and then + rejects ``description``/``type`` siblings on the same node. + (Ported from anomalyco/opencode#24730.) +4. Tuple-style ``items`` arrays — Moonshot requires a single item schema, + not positional ones. (Ported from anomalyco/opencode#24730.) These tests cover the repairs applied by ``agent/moonshot_schema.py``. """ @@ -180,6 +185,164 @@ class TestAnyOfParentType: assert db_type["enum"] == ["mysql", "postgresql"] # "" stripped by enum cleanup +class TestRefSiblingStripping: + """Rule 4: ``$ref`` nodes may not carry sibling keywords on Moonshot. + + Ported from anomalyco/opencode#24730. The real-world failure was MCP tools + whose generated schemas put a ``description`` on a ``$ref`` property so the + model would see the field's human-readable hint. The reference stays — the + referenced definition still owns the description (on the target node itself) + and still serves the model's context. + """ + + def test_description_sibling_stripped_from_ref(self): + params = { + "type": "object", + "properties": { + "variantOptions": { + "$ref": "#/$defs/VariantOptions", + "description": "Required. The variant options for generation.", + }, + }, + "$defs": { + "VariantOptions": { + "type": "object", + "properties": {}, + "description": "Configuration options.", + }, + }, + } + out = sanitize_moonshot_tool_parameters(params) + # Sibling stripped. + assert out["properties"]["variantOptions"] == {"$ref": "#/$defs/VariantOptions"} + # The target definition's own description is preserved — we only strip + # siblings ON the $ref node, not on the thing it points at. + assert out["$defs"]["VariantOptions"]["description"] == "Configuration options." + + def test_multiple_siblings_all_stripped(self): + params = { + "type": "object", + "properties": { + "p": { + "$ref": "#/$defs/T", + "type": "object", + "description": "x", + "default": {}, + "title": "P", + }, + }, + "$defs": {"T": {"type": "object"}}, + } + out = sanitize_moonshot_tool_parameters(params) + assert out["properties"]["p"] == {"$ref": "#/$defs/T"} + + def test_ref_without_siblings_unchanged(self): + params = { + "type": "object", + "properties": {"p": {"$ref": "#/$defs/T"}}, + "$defs": {"T": {"type": "object"}}, + } + out = sanitize_moonshot_tool_parameters(params) + assert out["properties"]["p"] == {"$ref": "#/$defs/T"} + + def test_ref_inside_anyof_children(self): + params = { + "type": "object", + "properties": { + "v": { + "anyOf": [ + {"$ref": "#/$defs/A", "description": "variant A"}, + {"type": "null"}, + ], + }, + }, + "$defs": {"A": {"type": "object"}}, + } + out = sanitize_moonshot_tool_parameters(params) + # Main's existing Rule 2 collapses anyOf-with-null down to the + # single non-null branch (Moonshot rejects null branches in anyOf + # outright). That branch was originally `{"$ref": ..., "description": ...}`; + # Rule 4 then strips the sibling, leaving exactly `{"$ref": "..."}`. + # The test name still applies — Rule 4 ran on the $ref branch — it + # just happens after the anyOf collapse on this input. + assert out["properties"]["v"] == {"$ref": "#/$defs/A"} + + +class TestTupleItems: + """Rule 5: tuple-style ``items`` arrays collapse to a single schema. + + Ported from anomalyco/opencode#24730. Moonshot's schema engine requires + ``items`` to be ONE schema object applied to every array element; tuple- + style positional item schemas are rejected. We collapse to the first + element's schema (which is the "closest" interpretation of positional → + single) and drop the rest. + """ + + def test_tuple_items_collapsed_to_first(self): + params = { + "type": "object", + "properties": { + "renderedSize": { + "type": "array", + "items": [{"type": "number"}, {"type": "number"}], + "minItems": 2, + "maxItems": 2, + }, + }, + } + out = sanitize_moonshot_tool_parameters(params) + assert out["properties"]["renderedSize"]["items"] == {"type": "number"} + # Sibling constraints are preserved — only the tuple shape is repaired. + assert out["properties"]["renderedSize"]["minItems"] == 2 + + def test_empty_tuple_items_becomes_empty_schema(self): + # Empty tuple collapses to ``{}``; the generic repair then fills a + # synthetic ``type`` because Moonshot requires ``type`` on every + # schema node. Either ``{}`` or ``{"type": "string"}`` is a valid + # final shape for Moonshot — both accept any string element — but we + # always go through ``_fill_missing_type`` so the result is fully + # well-formed without needing the consumer to patch it later. + params = { + "type": "object", + "properties": { + "things": {"type": "array", "items": []}, + }, + } + out = sanitize_moonshot_tool_parameters(params) + items = out["properties"]["things"]["items"] + # Must be a dict and must carry a ``type`` (the whole point of Rule 1). + assert isinstance(items, dict) + assert items.get("type") + + def test_tuple_items_first_element_is_repaired(self): + # The first element itself has a missing type — it should be filled. + params = { + "type": "object", + "properties": { + "pair": { + "type": "array", + "items": [{"description": "first"}, {"description": "second"}], + }, + }, + } + out = sanitize_moonshot_tool_parameters(params) + # Repaired to a single schema with a synthetic type. + assert out["properties"]["pair"]["items"] == { + "description": "first", + "type": "string", + } + + def test_single_schema_items_unchanged(self): + params = { + "type": "object", + "properties": { + "tags": {"type": "array", "items": {"type": "string"}}, + }, + } + out = sanitize_moonshot_tool_parameters(params) + assert out["properties"]["tags"]["items"] == {"type": "string"} + + class TestTopLevelGuarantees: """The returned top-level schema is always a well-formed object.""" From fb05f5d4b58d4fb20c3a4a98c2c150de3f729f3c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 13:06:56 -0700 Subject: [PATCH 10/24] fix(mcp): validate remote URLs up-front with a clear error (#27105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from anomalyco/opencode#25019 ("fix: handle invalid mcp urls"). Previously: a typo in `config.yaml` (missing scheme, wrong scheme, empty string, non-string value) slipped past `_is_http()` and hit `httpx.URL(url)` or `streamablehttp_client(url, ...)` deep in the transport layer. That raised a generic exception which went through the reconnect-backoff loop, so a bad URL caused _MAX_INITIAL_CONNECT_RETRIES attempts with doubling backoff — about a minute of pointless retries plus an opaque error — before the server was marked failed. Now: we validate the URL once, at the top of `run()`, before entering the retry loop. A malformed URL raises `InvalidMcpUrlError` (a `ValueError` subclass) with a message that names the offending server and explains exactly what was wrong. `_ready` is set and `_error` is populated, so `start()` re-raises and the server shows up as failed in `hermes mcp list` without any backoff burn. Validation rules: - Must be a string (rejects None, dict, int) - Must be non-empty (rejects '' and whitespace-only) - Scheme must be http or https (rejects file://, ws://, stdio://) - Must have a non-empty host (rejects http:///, http://:8080) Tests (21 new cases in tests/tools/test_mcp_invalid_url.py): - TestValidUrlsAccepted: http, https, IPv6, ports, paths, query strings - TestInvalidUrlsRejected: every rejection path above + clear error text - TestErrorIsValueError: downstream code catching ValueError still works E2E verified: a misconfigured server with `url: not-a-valid-url` now fails in <0.001s with the clear error, instead of minutes of retries. Doesn't touch stdio servers (they use `command`, not `url`) — the validator only fires when `_is_http()` returns True. --- tests/tools/test_mcp_invalid_url.py | 125 ++++++++++++++++++++++++++++ tools/mcp_tool.py | 82 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 tests/tools/test_mcp_invalid_url.py diff --git a/tests/tools/test_mcp_invalid_url.py b/tests/tools/test_mcp_invalid_url.py new file mode 100644 index 00000000000..539696292ad --- /dev/null +++ b/tests/tools/test_mcp_invalid_url.py @@ -0,0 +1,125 @@ +"""Tests for the MCP remote-URL validator. + +Ported from anomalyco/opencode#25019 (``fix: handle invalid mcp urls``). + +Previously, a typo in ``config.yaml`` (missing scheme, wrong scheme, empty +string, dict where a URL was expected) caused the MCP server startup code +to enter httpx's URL-parsing path and crash inside the transport layer. +The reconnect-backoff loop would then retry +``_MAX_INITIAL_CONNECT_RETRIES`` times with doubling backoff — a minute or +more of pointless retries plus a confusing opaque error message — before +eventually giving up. + +The fix validates the URL once, up front, and fails fast with a specific +error message identifying the offending server. +""" + +from __future__ import annotations + +import pytest + +from tools.mcp_tool import ( + InvalidMcpUrlError, + _validate_remote_mcp_url, +) + + +class TestValidUrlsAccepted: + """Every valid http(s) URL must pass through untouched (stripped of whitespace).""" + + @pytest.mark.parametrize( + "url", + [ + "http://localhost:3000/mcp", + "https://example.com/mcp", + "https://context7.liam.com/mcp", + "http://127.0.0.1:8080", + "https://api.example.com:443/v1/mcp?session=abc", + "http://[::1]:9000/mcp", # IPv6 + "https://host.example.com", # no port, no path + ], + ) + def test_accepts_valid_http_url(self, url): + assert _validate_remote_mcp_url("test", url) == url + + def test_strips_surrounding_whitespace(self): + assert ( + _validate_remote_mcp_url("test", " https://example.com/mcp ") + == "https://example.com/mcp" + ) + + +class TestInvalidUrlsRejected: + """Every broken shape must raise ``InvalidMcpUrlError`` with a clear message.""" + + def test_none_rejected(self): + with pytest.raises(InvalidMcpUrlError, match="context7.*expected a string"): + _validate_remote_mcp_url("context7", None) + + def test_dict_rejected(self): + with pytest.raises(InvalidMcpUrlError, match="expected a string, got dict"): + _validate_remote_mcp_url("ctx", {"url": "nested"}) + + def test_int_rejected(self): + with pytest.raises(InvalidMcpUrlError, match="expected a string, got int"): + _validate_remote_mcp_url("ctx", 8080) + + def test_empty_string_rejected(self): + with pytest.raises(InvalidMcpUrlError, match="empty url"): + _validate_remote_mcp_url("ctx", "") + + def test_whitespace_only_rejected(self): + with pytest.raises(InvalidMcpUrlError, match="empty url"): + _validate_remote_mcp_url("ctx", " \t\n") + + def test_missing_scheme_rejected(self): + # The most common typo — users copy a host from a web page. + with pytest.raises( + InvalidMcpUrlError, match="scheme must be http or https" + ): + _validate_remote_mcp_url("ctx", "example.com/mcp") + + def test_file_scheme_rejected(self): + with pytest.raises( + InvalidMcpUrlError, match="scheme must be http or https" + ): + _validate_remote_mcp_url("ctx", "file:///etc/passwd") + + def test_ws_scheme_rejected(self): + # WebSocket is not MCP's remote transport. + with pytest.raises( + InvalidMcpUrlError, match="scheme must be http or https" + ): + _validate_remote_mcp_url("ctx", "ws://example.com/mcp") + + def test_stdio_scheme_rejected(self): + # stdio servers use the ``command`` key, not ``url``. + with pytest.raises( + InvalidMcpUrlError, match="scheme must be http or https" + ): + _validate_remote_mcp_url("ctx", "stdio:///node server.js") + + def test_empty_host_rejected(self): + with pytest.raises(InvalidMcpUrlError, match="missing host"): + _validate_remote_mcp_url("ctx", "http:///") + + def test_empty_host_with_path_rejected(self): + with pytest.raises(InvalidMcpUrlError, match="missing host"): + _validate_remote_mcp_url("ctx", "https:///path/only") + + def test_error_mentions_server_name(self): + # So users can find the bad entry when there are multiple configured. + with pytest.raises(InvalidMcpUrlError, match="my-weird-server"): + _validate_remote_mcp_url("my-weird-server", "not a url at all") + + +class TestErrorIsValueError: + """InvalidMcpUrlError must be a ValueError for broad downstream catch blocks.""" + + def test_is_value_error(self): + try: + _validate_remote_mcp_url("ctx", "garbage") + except ValueError: + pass # expected + else: + pytest.fail("expected ValueError") diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index b24bb9705ad..a46496ef59c 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -91,6 +91,7 @@ import threading import time from datetime import datetime from typing import Any, Dict, List, Optional +from urllib.parse import urlparse logger = logging.getLogger(__name__) @@ -492,6 +493,72 @@ def _cache_mcp_image_block(block) -> str: return f"MEDIA:{image_path}" +# --------------------------------------------------------------------------- +# Remote MCP URL validation +# --------------------------------------------------------------------------- + + +class InvalidMcpUrlError(ValueError): + """Raised when a remote MCP server's ``url`` cannot be parsed as http(s)://. + + Validated once at startup so we fail fast with a clear message instead of + burning through the reconnect-backoff loop on every attempt. (Ported from + anomalyco/opencode#25019.) + """ + + +def _validate_remote_mcp_url(server_name: str, url: Any) -> str: + """Return the URL as a string if it's a valid http(s) remote MCP URL. + + Raises :class:`InvalidMcpUrlError` otherwise with a message naming the + offending server, so users can spot the bad entry in their config. + + Accepts: + - ``http://host`` / ``https://host`` with optional port, path, query + - IPv4, IPv6 (bracketed), DNS hostnames + + Rejects: + - Non-string values (``None``, dicts, ints) + - Missing scheme (``example.com/mcp``) + - Non-http(s) schemes (``file://``, ``ws://``, ``stdio:`` — stdio servers + use the ``command`` key, not ``url``) + - Empty host (``http://``, ``https:///path``) + """ + if not isinstance(url, str): + raise InvalidMcpUrlError( + f"Invalid MCP URL for '{server_name}': expected a string, got " + f"{type(url).__name__}" + ) + stripped = url.strip() + if not stripped: + raise InvalidMcpUrlError( + f"Invalid MCP URL for '{server_name}': empty url" + ) + try: + parsed = urlparse(stripped) + except Exception as exc: # urlparse is very permissive — belt and braces + raise InvalidMcpUrlError( + f"Invalid MCP URL for '{server_name}': {stripped!r} ({exc})" + ) from exc + if parsed.scheme.lower() not in ("http", "https"): + raise InvalidMcpUrlError( + f"Invalid MCP URL for '{server_name}': scheme must be http or " + f"https, got {parsed.scheme!r} ({stripped!r})" + ) + if not parsed.netloc: + raise InvalidMcpUrlError( + f"Invalid MCP URL for '{server_name}': missing host ({stripped!r})" + ) + # ``urlparse`` accepts ``http://:8080`` (empty host, explicit port). + # Reject that — we need a real host. + if not parsed.hostname: + raise InvalidMcpUrlError( + f"Invalid MCP URL for '{server_name}': missing hostname " + f"({stripped!r})" + ) + return stripped + + def _format_connect_error(exc: BaseException) -> str: """Render nested MCP connection errors into an actionable short message.""" @@ -1458,6 +1525,21 @@ class MCPServerTask: "this warning.", self.name, ) + + # Validate remote URL once, up front. Raising here (rather than + # letting it blow up inside the SDK's httpx layer on every retry) + # means a typo in config.yaml fails fast with a clear error — and + # critically, no reconnect-backoff burn. (Ported from + # anomalyco/opencode#25019.) + if self._is_http(): + try: + _validate_remote_mcp_url(self.name, config.get("url")) + except InvalidMcpUrlError as exc: + logger.warning("%s", exc) + self._error = exc + self._ready.set() + return + retries = 0 initial_retries = 0 backoff = 1.0 From dffb602f37b3c1b9c9fd7f0417aab3af56cffa38 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 16:00:01 -0700 Subject: [PATCH 11/24] fix(xai): drop stale X Premium+ hint from entitlement 403 surfacing (#27110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xAI announced on 2026-05-16 (https://x.ai/news/grok-hermes) that X Premium subscriptions now work in Hermes Agent. The hint we shipped in PR #26644 asserted the opposite ("X Premium+ does NOT include xAI API access — only standalone SuperGrok subscribers can use this provider"), which would now misdirect Premium+ users who hit any other 403 (no Grok sub at all, wrong tier, exhausted quota) into thinking they need to switch subscriptions when their sub is in fact valid. Remove _decorate_xai_entitlement_error and its two call sites in _summarize_api_error. xAI's own body text already says "Manage subscriptions at https://grok.com/?_s=usage" — surface that verbatim and let xAI's wording do the diagnosis. The _is_entitlement_failure guard (which prevents credential-pool refresh loops on entitlement 403s) and the reasoning-replay gating for xai-oauth are unrelated and untouched. Update tests to assert the body still surfaces verbatim and that no Hermes-side editorializing is appended. --- run_agent.py | 61 +------------- .../test_codex_xai_oauth_recovery.py | 81 +++++-------------- 2 files changed, 24 insertions(+), 118 deletions(-) diff --git a/run_agent.py b/run_agent.py index 1dd4219b22e..61699607d1f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5046,63 +5046,6 @@ class AIAgent: return True return False - @staticmethod - def _decorate_xai_entitlement_error(detail: str) -> str: - """Append a neutral hint when xAI's OAuth surface returns the - permission-denied 403. - - xAI's ``/v1/responses`` endpoint replies to several distinct failure - modes with the SAME body:: - - {"code": "The caller does not have permission to execute the - specified operation", "error": "You have either run out of - available resources or do not have an active Grok subscription. - Manage subscriptions at https://grok.com/?_s=usage or subscribe - at https://grok.com/supergrok"} - - That body covers several real causes we cannot distinguish without - more info from xAI. The most common (and least obvious) one is - that **X Premium+ does NOT include API access** — only standalone - SuperGrok subscribers can use Hermes against xai-oauth. Lots of - users see Grok in their X app, assume it works here too, and hit - this 403 with no idea why. Lead the hint with that. - - Other possible causes: - * No Grok subscription at all - * SuperGrok tier doesn't include the requested model (e.g. - grok-4.3 may need a higher tier) - * Monthly quota exhausted (the ``?_s=usage`` URL hints at this) - - Surface the raw xAI text verbatim and point at - https://grok.com/?_s=usage where the user can see WHICH applies. - - Matched once per detail string — won't double-decorate if the - upstream already concatenated the same text. - """ - if not detail: - return detail - lower = detail.lower() - is_entitlement = ( - "do not have an active grok subscription" in lower - or ("out of available resources" in lower and "grok" in lower) - or ("does not have permission" in lower and "grok" in lower) - ) - if not is_entitlement: - return detail - hint = ( - " — xAI rejected this OAuth account. NOTE: X Premium+ does NOT " - "include xAI API access — only standalone SuperGrok subscribers " - "can use this provider. Other possible causes: no Grok " - "subscription, your tier doesn't include this model, or your " - "quota is exhausted. Check https://grok.com/?_s=usage to see " - "which, or run `/model` to switch providers." - ) - # Idempotency: detect prior decoration by a substring unique to the - # hint (not present in xAI's own body text). - if "X Premium+ does NOT include" in detail: - return detail - return f"{detail}{hint}" - @staticmethod def _summarize_api_error(error: Exception) -> str: """Extract a human-readable one-liner from an API error. @@ -5142,12 +5085,12 @@ class AIAgent: if msg: status_code = getattr(error, "status_code", None) prefix = f"HTTP {status_code}: " if status_code else "" - return AIAgent._decorate_xai_entitlement_error(f"{prefix}{msg[:300]}") + return f"{prefix}{msg[:300]}" # Fallback: truncate the raw string but give more room than 200 chars status_code = getattr(error, "status_code", None) prefix = f"HTTP {status_code}: " if status_code else "" - return AIAgent._decorate_xai_entitlement_error(f"{prefix}{raw[:500]}") + return f"{prefix}{raw[:500]}" def _mask_api_key_for_logs(self, key: Optional[str]) -> Optional[str]: if not key: diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py index 9192d50695b..9eb641cc895 100644 --- a/tests/run_agent/test_codex_xai_oauth_recovery.py +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -158,19 +158,22 @@ def test_codex_stream_postlude_error_still_falls_back(): # --------------------------------------------------------------------------- -# Fix B: friendly entitlement message +# Fix B: surface xAI's entitlement body verbatim (no editorializing) +# +# The original PR #26644 appended a hint that led with "X Premium+ does NOT +# include xAI API access — only standalone SuperGrok subscribers can use this +# provider." xAI announced on 2026-05-16 that X Premium subs now work in +# Hermes (https://x.ai/news/grok-hermes), making that hint actively wrong: +# a Premium+ user hitting a real entitlement issue (no Grok sub, wrong tier, +# exhausted quota) would be misdirected to switch subscriptions when their +# Premium sub is in fact valid. We now surface xAI's own body text verbatim +# (which already says "Manage subscriptions at https://grok.com/?_s=usage") +# and leave the diagnosis to xAI's wording. # --------------------------------------------------------------------------- -def test_summarize_api_error_decorates_xai_entitlement_403(): - """xAI's OAuth 403 must surface the X Premium+ gotcha + neutral causes. - - Wording deliberately leads with the X Premium+ gotcha because that's - the #1 confusing case: people see Grok in their X app, assume it - works here too, and hit this 403 with no idea API access is a - separate SKU. Other causes (no subscription, wrong tier, exhausted - quota) follow. - """ +def test_summarize_api_error_surfaces_xai_entitlement_body_verbatim(): + """xAI's OAuth 403 body must surface as-is, with no Hermes-side hint.""" from run_agent import AIAgent error = RuntimeError( @@ -180,45 +183,15 @@ def test_summarize_api_error_decorates_xai_entitlement_403(): "subscriptions at https://grok.com'}" ) summary = AIAgent._summarize_api_error(error) - # The original xAI text must survive — it's still useful diagnostic info. + # xAI's own body text must reach the user — they need it to diagnose. assert "do not have an active Grok subscription" in summary - # The hint MUST lead with the X Premium+ gotcha (most likely cause - # for users who think they're subscribed). - assert "X Premium+ does NOT include" in summary - assert "standalone SuperGrok subscribers" in summary - # Other causes still listed. - assert "no Grok subscription" in summary - assert "tier doesn't include this model" in summary - assert "quota is exhausted" in summary - # The hint must point at the usage page where the user can verify. - assert "https://grok.com/?_s=usage" in summary - # Switching providers is still a valid escape hatch. - assert "/model" in summary + # No stale claim that X Premium is incompatible with Hermes. + assert "X Premium+ does NOT include" not in summary + assert "standalone SuperGrok subscribers" not in summary -def test_summarize_api_error_does_not_accuse_subscribers(): - """Hint must not confidently say the user has no subscription. - - Don Piedro reported his subscription is active. The hint must not - contradict him — leading with the X Premium+ gotcha gives subscribers - a plausible reason ("oh, I'm on Premium+ not pure SuperGrok") instead - of accusing them of lying about having a subscription. - """ - from run_agent import AIAgent - - error = RuntimeError( - "HTTP 403: do not have an active Grok subscription" - ) - summary = AIAgent._summarize_api_error(error) - # MUST NOT contain language that flatly assumes the user is unsubscribed. - assert "lacks SuperGrok" not in summary - assert "you are not subscribed" not in summary.lower() - # MUST lead with the most-likely-but-non-accusatory cause. - assert "X Premium+ does NOT include" in summary - - -def test_summarize_api_error_decorates_xai_body_message(): - """SDK-style error with structured body must also get the hint.""" +def test_summarize_api_error_xai_body_message_unwrapped(): + """SDK-style error with structured body surfaces the message cleanly.""" from run_agent import AIAgent class _XaiErr(Exception): @@ -235,19 +208,9 @@ def test_summarize_api_error_decorates_xai_body_message(): summary = AIAgent._summarize_api_error(_XaiErr("403")) assert "HTTP 403" in summary - assert "X Premium+ does NOT include" in summary - - -def test_summarize_api_error_idempotent_for_entitlement_hint(): - """Decorating twice must not double up the hint.""" - from run_agent import AIAgent - - raw = "HTTP 403: do not have an active Grok subscription" - once = AIAgent._decorate_xai_entitlement_error(raw) - twice = AIAgent._decorate_xai_entitlement_error(once) - assert once == twice - # Sanity: the hint did fire on the first pass. - assert "X Premium+ does NOT include" in once + assert "do not have an active Grok subscription" in summary + # No editorializing on top of xAI's own wording. + assert "X Premium+ does NOT include" not in summary def test_summarize_api_error_passes_through_unrelated_errors(): From e51d74ab917675a67e6a964d6c2c2ea2b150ac2c Mon Sep 17 00:00:00 2001 From: Maxim Esipov Date: Fri, 15 May 2026 17:19:31 +0300 Subject: [PATCH 12/24] fix(codex): rotate pool on usage limit 429 --- run_agent.py | 12 ++++++-- tests/run_agent/test_run_agent.py | 47 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index 61699607d1f..ffe0ffbe67e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5135,7 +5135,7 @@ class AIAgent: if isinstance(body, dict): payload = body.get("error") if isinstance(body.get("error"), dict) else body if isinstance(payload, dict): - reason = payload.get("code") or payload.get("error") + reason = payload.get("code") or payload.get("type") or payload.get("error") if isinstance(reason, str) and reason.strip(): context["reason"] = reason.strip() message = payload.get("message") or payload.get("error_description") @@ -7583,7 +7583,15 @@ class AIAgent: return False, has_retried_429 if effective_reason == FailoverReason.rate_limit: - if not has_retried_429: + usage_limit_reached = False + if error_context: + context_reason = str(error_context.get("reason") or "").lower() + context_message = str(error_context.get("message") or "").lower() + usage_limit_reached = ( + "usage_limit_reached" in context_reason + or "usage limit has been reached" in context_message + ) + if not has_retried_429 and not usage_limit_reached: return False, True rotate_status = status_code if status_code is not None else 429 next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index cd62cd41ded..8d56ff6425a 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3746,6 +3746,37 @@ class TestCredentialPoolRecovery: assert retry_same is False agent._swap_credential.assert_called_once_with(next_entry) + def test_recover_with_pool_rotates_usage_limit_429_immediately(self, agent): + next_entry = SimpleNamespace(label="secondary") + captured = {} + + class _Pool: + def current(self): + return SimpleNamespace(label="primary") + + def mark_exhausted_and_rotate(self, *, status_code, error_context=None): + captured["status_code"] = status_code + captured["error_context"] = error_context + return next_entry + + agent._credential_pool = _Pool() + agent._swap_credential = MagicMock() + + recovered, retry_same = agent._recover_with_credential_pool( + status_code=429, + has_retried_429=False, + error_context={ + "reason": "usage_limit_reached", + "message": "The usage limit has been reached", + }, + ) + + assert recovered is True + assert retry_same is False + assert captured["status_code"] == 429 + assert captured["error_context"]["reason"] == "usage_limit_reached" + agent._swap_credential.assert_called_once_with(next_entry) + def test_recover_with_pool_refreshes_on_401(self, agent): """401 with successful refresh should swap to refreshed credential.""" @@ -3832,6 +3863,22 @@ class TestCredentialPoolRecovery: assert context["message"] == "Weekly credits exhausted." assert context["reset_at"] == "2026-04-12T10:30:00Z" + def test_extract_api_error_context_uses_type_as_reason(self, agent): + error = SimpleNamespace( + body={ + "error": { + "type": "usage_limit_reached", + "message": "The usage limit has been reached", + } + }, + response=SimpleNamespace(headers={}), + ) + + context = agent._extract_api_error_context(error) + + assert context["reason"] == "usage_limit_reached" + assert context["message"] == "The usage limit has been reached" + def test_recover_with_pool_passes_error_context_on_rotated_429(self, agent): next_entry = SimpleNamespace(label="secondary") captured = {} From 6f817e1447499cf51d8c966b3f3a600ba3412f85 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Sat, 16 May 2026 16:23:35 -0600 Subject: [PATCH 13/24] fix(telegram): restore DM topic typing indicator --- gateway/platforms/telegram.py | 8 -------- tests/gateway/test_telegram_thread_fallback.py | 17 +++++++++-------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 4c56937e5cb..50813c25dc6 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -3504,14 +3504,6 @@ class TelegramAdapter(BasePlatformAdapter): if self._bot: try: _typing_thread = self._metadata_thread_id(metadata) - # Skip the Bot API call entirely for Hermes-created DM topic - # lanes: send_chat_action only accepts message_thread_id, which - # Telegram's Bot API 10.0 rejects for these lanes. The send - # path uses the reply-anchor fallback instead, but typing has - # no equivalent — skipping avoids noisy "thread not found" - # debug logs on every typing tick. - if metadata and metadata.get("telegram_dm_topic_reply_fallback"): - return message_thread_id = self._message_thread_id_for_typing(_typing_thread) # No retry-without-thread fallback here: _message_thread_id_for_typing # already maps the forum General topic to None, so any non-None value diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py index e31753cc2b7..f310d017946 100644 --- a/tests/gateway/test_telegram_thread_fallback.py +++ b/tests/gateway/test_telegram_thread_fallback.py @@ -236,14 +236,13 @@ async def test_send_typing_does_not_fall_back_to_root_for_dm_topic(): @pytest.mark.asyncio -async def test_send_typing_skips_api_call_for_dm_topic_reply_fallback(): - """Hermes-created DM topic lanes have no working Bot API typing route. +async def test_send_typing_attempts_api_call_for_dm_topic_reply_fallback(): + """Hermes-created DM topic lanes should still attempt scoped typing. - ``send_chat_action`` only accepts ``message_thread_id``, which Telegram's - Bot API 10.0 rejects for these lanes — the call would silently fail and - log a "thread not found" warning every typing tick (every 2s). Skipping - the call entirely keeps logs clean while preserving the user-visible - behavior (no typing indicator either way for these lanes). + Some private DM topic lanes route message sends through reply-anchor + fallback, but live Telegram testing shows sendChatAction accepts the lane's + message_thread_id. If Telegram rejects a stale or invalid thread later, + send_typing already swallows that failure as non-fatal. """ adapter = _make_adapter() call_log = [] @@ -262,7 +261,9 @@ async def test_send_typing_skips_api_call_for_dm_topic_reply_fallback(): }, ) - assert call_log == [] + assert call_log == [ + {"chat_id": 12345, "action": "typing", "message_thread_id": 20197}, + ] @pytest.mark.asyncio From 226cee43d97997525e4e26a20075aec98e641418 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 16:51:29 -0700 Subject: [PATCH 14/24] =?UTF-8?q?feat(cli):=20show=20=E2=96=B6=20N=20indic?= =?UTF-8?q?ator=20in=20status=20bar=20when=20/background=20tasks=20are=20r?= =?UTF-8?q?unning=20(#27175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface live background-task count in the prompt_toolkit status bar so users can see at a glance that a /background task exists and is running — no need to ask the agent about it (the agent has no visibility into bg sessions by design). - _get_status_bar_snapshot now reports active_background_tasks from len() of the live _background_tasks dict (entries are removed in the task thread's finally block, so this reflects truly-running tasks) - Indicator shown only on medium (<76) and wide (>=76) tiers; narrow (<52) stays minimal since it's already cramped - No invalidate plumbing needed: status bar fragments are pulled via lambda on every redraw, and the bg thread already calls _app.invalidate() on exit Refs #8568 --- cli.py | 25 +++++ .../test_cli_background_status_indicator.py | 104 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 tests/cli/test_cli_background_status_indicator.py diff --git a/cli.py b/cli.py index 12f9ee98fb8..bc97c8e84c4 100644 --- a/cli.py +++ b/cli.py @@ -3113,8 +3113,19 @@ class HermesCLI: "session_total_tokens": 0, "session_api_calls": 0, "compressions": 0, + "active_background_tasks": 0, } + # Count live /background tasks. The dict entry is removed in the + # task thread's finally block, so len() reflects truly-running tasks. + # len() on a CPython dict is atomic; safe to read without a lock. + try: + bg_tasks = getattr(self, "_background_tasks", None) + if bg_tasks: + snapshot["active_background_tasks"] = len(bg_tasks) + except Exception: + pass + if not agent: return snapshot @@ -3350,6 +3361,9 @@ class HermesCLI: compressions = snapshot.get("compressions", 0) if compressions: parts.append(f"🗜️ {compressions}") + bg_count = snapshot.get("active_background_tasks", 0) + if bg_count: + parts.append(f"▶ {bg_count}") parts.append(duration_label) if yolo_active: parts.append("⚠ YOLO") @@ -3366,6 +3380,9 @@ class HermesCLI: parts = [f"⚕ {snapshot['model_short']}", context_label, percent_label] if compressions: parts.append(f"🗜️ {compressions}") + bg_count = snapshot.get("active_background_tasks", 0) + if bg_count: + parts.append(f"▶ {bg_count}") parts.append(duration_label) prompt_elapsed = snapshot.get("prompt_elapsed") if prompt_elapsed: @@ -3406,6 +3423,7 @@ class HermesCLI: percent_label = f"{percent}%" if percent is not None else "--" if width < 76: compressions = snapshot.get("compressions", 0) + bg_count = snapshot.get("active_background_tasks", 0) frags = [ ("class:status-bar", " ⚕ "), ("class:status-bar-strong", snapshot["model_short"]), @@ -3415,6 +3433,9 @@ class HermesCLI: if compressions: frags.append(("class:status-bar-dim", " · ")) frags.append((self._compression_count_style(compressions), f"🗜️ {compressions}")) + if bg_count: + frags.append(("class:status-bar-dim", " · ")) + frags.append(("class:status-bar-strong", f"▶ {bg_count}")) frags.extend([ ("class:status-bar-dim", " · "), ("class:status-bar-dim", duration_label), @@ -3433,6 +3454,7 @@ class HermesCLI: bar_style = self._status_bar_context_style(percent) compressions = snapshot.get("compressions", 0) + bg_count = snapshot.get("active_background_tasks", 0) frags = [ ("class:status-bar", " ⚕ "), ("class:status-bar-strong", snapshot["model_short"]), @@ -3446,6 +3468,9 @@ class HermesCLI: if compressions: frags.append(("class:status-bar-dim", " │ ")) frags.append((self._compression_count_style(compressions), f"🗜️ {compressions}")) + if bg_count: + frags.append(("class:status-bar-dim", " │ ")) + frags.append(("class:status-bar-strong", f"▶ {bg_count}")) frags.extend([ ("class:status-bar-dim", " │ "), ("class:status-bar-dim", duration_label), diff --git a/tests/cli/test_cli_background_status_indicator.py b/tests/cli/test_cli_background_status_indicator.py new file mode 100644 index 00000000000..32f39f96650 --- /dev/null +++ b/tests/cli/test_cli_background_status_indicator.py @@ -0,0 +1,104 @@ +"""Tests for the /background indicator in the CLI status bar. + +The classic prompt_toolkit status bar shows `▶ N` when N tasks launched via +`/background` are still running. Source of truth is `self._background_tasks` +(a Dict[str, threading.Thread]); entries are removed in the task thread's +finally block, so len() reflects truly-running tasks. +""" + +import threading +from datetime import datetime + +from cli import HermesCLI + + +def _stub_thread() -> threading.Thread: + """Return a Thread instance that's never started — pure dict-value stand-in.""" + return threading.Thread(target=lambda: None) + + +def _make_cli(): + """Bare-metal HermesCLI for snapshot/build tests (no __init__ side effects).""" + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.model = "anthropic/claude-opus-4.6" + cli_obj.agent = None + cli_obj._background_tasks = {} + # The snapshot reads session_start to compute duration; supply a stub. + cli_obj.session_start = datetime.now() + return cli_obj + + +def test_snapshot_reports_zero_when_no_background_tasks(): + cli_obj = _make_cli() + snap = cli_obj._get_status_bar_snapshot() + assert snap["active_background_tasks"] == 0 + + +def test_snapshot_counts_live_background_tasks(): + cli_obj = _make_cli() + cli_obj._background_tasks = {"bg_a": _stub_thread(), "bg_b": _stub_thread()} + snap = cli_obj._get_status_bar_snapshot() + assert snap["active_background_tasks"] == 2 + + +def test_snapshot_safe_when_background_tasks_attr_missing(): + """Older HermesCLI instances (tests with __new__, etc.) may lack the attr.""" + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.model = "x" + cli_obj.agent = None + cli_obj.session_start = datetime.now() + # No _background_tasks at all — must not raise. + snap = cli_obj._get_status_bar_snapshot() + assert snap["active_background_tasks"] == 0 + + +def test_plain_text_status_omits_indicator_when_idle(): + cli_obj = _make_cli() + text = cli_obj._build_status_bar_text(width=80) + assert "▶" not in text + + +def test_plain_text_status_shows_indicator_when_active(): + cli_obj = _make_cli() + cli_obj._background_tasks = {"bg_a": _stub_thread()} + text = cli_obj._build_status_bar_text(width=80) + assert "▶ 1" in text + + +def test_plain_text_status_shows_higher_count(): + cli_obj = _make_cli() + cli_obj._background_tasks = { + "a": _stub_thread(), + "b": _stub_thread(), + "c": _stub_thread(), + } + text = cli_obj._build_status_bar_text(width=80) + assert "▶ 3" in text + + +def test_narrow_width_omits_bg_indicator(): + """The narrow tier (<52) is already cramped — bg is secondary, drop it.""" + cli_obj = _make_cli() + cli_obj._background_tasks = {"bg_a": _stub_thread()} + text = cli_obj._build_status_bar_text(width=40) + assert "▶" not in text + + +def test_fragments_include_bg_segment_when_active(): + cli_obj = _make_cli() + cli_obj._background_tasks = {"a": _stub_thread(), "b": _stub_thread()} + cli_obj._status_bar_visible = True + # _get_status_bar_fragments asks _get_tui_terminal_width(); stub it wide. + cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign] + frags = cli_obj._get_status_bar_fragments() + rendered = "".join(text for _style, text in frags) + assert "▶ 2" in rendered + + +def test_fragments_omit_bg_segment_when_idle(): + cli_obj = _make_cli() + cli_obj._status_bar_visible = True + cli_obj._get_tui_terminal_width = lambda: 120 # type: ignore[method-assign] + frags = cli_obj._get_status_bar_fragments() + rendered = "".join(text for _style, text in frags) + assert "▶" not in rendered From e21cb8d1457f603cda1dc8413efc400721d256e7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 16:51:42 -0700 Subject: [PATCH 15/24] feat(status): append session recap to /status output (#27176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pure-local recap of recent session activity — turn counts, tools used, files touched, last user ask, last assistant reply — appended to the existing /status output. Useful when juggling multiple sessions and you want a one-glance reminder of where this one left off. Inspired by Claude Code 2.1.114's /recap, but folded into /status so we don't add a 6th info command. Pure local computation: no LLM call, no auxiliary model, no prompt-cache invalidation, instant and free. Salvage of #18587 — kept the shared hermes_cli.session_recap.build_recap helper and its 13 unit tests, dropped the /recap slash command + ACTIVE_SESSION_BYPASS_COMMANDS entry + Level-2 bypass since /status already covers both surfaces. Tailored to hermes-agent's tool vocabulary: file-editing tools (patch, write_file, read_file, skill_manage, skill_view) surface touched paths; tool-call counts highlight which classes of work drove the session. Source: https://code.claude.com/docs/en/whats-new/2026-w17 --- cli.py | 18 ++ gateway/run.py | 18 ++ hermes_cli/session_recap.py | 316 +++++++++++++++++++++++++ tests/hermes_cli/test_session_recap.py | 180 ++++++++++++++ 4 files changed, 532 insertions(+) create mode 100644 hermes_cli/session_recap.py create mode 100644 tests/hermes_cli/test_session_recap.py diff --git a/cli.py b/cli.py index bc97c8e84c4..c1ba1c0ddd2 100644 --- a/cli.py +++ b/cli.py @@ -5469,6 +5469,24 @@ class HermesCLI: f"Tokens: {total_tokens:,}", f"Agent Running: {'Yes' if is_running else 'No'}", ]) + + # Session recap — pure local compute summary of recent activity + # (turn counts, tools used, files touched, last ask, last reply). + # No LLM call, no prompt-cache impact. Inspired by Claude Code + # 2.1.114's /recap. + try: + from hermes_cli.session_recap import build_recap + recap = build_recap( + self.conversation_history or [], + session_title=title or None, + session_id=self.session_id, + platform="cli", + ) + if recap: + lines.extend(["", recap]) + except Exception as exc: # defensive — don't let /status fail + logger.debug("build_recap failed in /status: %s", exc) + self._console_print("\n".join(lines), highlight=False, markup=False) def _fast_command_available(self) -> bool: diff --git a/gateway/run.py b/gateway/run.py index a5eaafcb063..458603c3115 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8663,6 +8663,24 @@ class GatewayRunner: t("gateway.status.platforms", platforms=', '.join(connected_platforms)), ]) + # Session recap — what was this session ABOUT? Pure local compute, + # no LLM call, no prompt-cache impact. Useful when juggling multiple + # gateway sessions and you want a one-glance reminder of where this + # one left off. Inspired by Claude Code 2.1.114's /recap. + try: + from hermes_cli.session_recap import build_recap + history = self.session_store.load_transcript(session_entry.session_id) + recap = build_recap( + history, + session_title=title, + session_id=session_entry.session_id, + platform=source.platform.value if source else None, + ) + if recap: + lines.extend(["", recap]) + except Exception as exc: # pragma: no cover — defensive + logger.debug("build_recap failed in /status: %s", exc) + return "\n".join(lines) async def _handle_agents_command(self, event: MessageEvent) -> str: diff --git a/hermes_cli/session_recap.py b/hermes_cli/session_recap.py new file mode 100644 index 00000000000..d67f737d799 --- /dev/null +++ b/hermes_cli/session_recap.py @@ -0,0 +1,316 @@ +"""Session recap — summarize what's happened in the current session. + +Inspired by Claude Code's `/recap` command (v2.1.114, April 2026), which +shows a one-line summary of what happened while a terminal was unfocused +so users juggling multiple sessions can re-orient quickly. + +Source: https://code.claude.com/docs/en/whats-new/2026-w17 + +Differences from Claude Code: + - Pure local computation from the in-memory conversation history. No + LLM call, no auxiliary model, no prompt-cache invalidation. A + recap should be instant and free. + - Works unchanged on CLI and every gateway platform (Telegram, + Discord, Slack, …) because both call into the same ``build_recap`` + helper. Claude Code only shows this on the CLI. + - Tailored to hermes-agent's tool vocabulary (``terminal``, ``patch``, + ``write_file``, ``delegate_task``, ``browser_*``, ``web_*``) — the + recap surfaces which classes of work were most active. +""" +from __future__ import annotations + +import os +from collections import Counter +from typing import Any, Iterable, List, Mapping, Optional, Sequence, Tuple + +# How many recent user/assistant turns we consider "recent activity". +_RECENT_TURN_WINDOW = 20 + +# How many characters of the latest user prompt to show. +_PROMPT_PREVIEW_CHARS = 140 + +# How many characters of the latest assistant text to show. +_ASSISTANT_PREVIEW_CHARS = 200 + +# How many recently-touched files to list. +_MAX_FILES_LISTED = 5 + +# Tool names that identify a file-editing action and the argument key that +# holds the path. +_FILE_EDIT_TOOLS: Mapping[str, str] = { + "write_file": "path", + "patch": "path", + "read_file": "path", + "skill_manage": "file_path", + "skill_view": "file_path", +} + + +def _coerce_text(value: Any) -> str: + """Flatten assistant/user ``content`` into a plain string. + + Content can be a string or a list of content blocks (for multimodal + or reasoning models). We concatenate every text-like block and + ignore the rest. + """ + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, list): + parts: List[str] = [] + for block in value: + if isinstance(block, str): + parts.append(block) + continue + if isinstance(block, Mapping): + text = block.get("text") + if isinstance(text, str) and text: + parts.append(text) + return "\n".join(parts) + return str(value) + + +def _tool_call_name_and_args(tool_call: Any) -> Tuple[str, Mapping[str, Any]]: + """Extract ``(name, arguments_dict)`` from a tool_call entry. + + ``arguments`` may be a JSON string or a dict depending on provider. + Return an empty dict if it cannot be parsed. + """ + if not isinstance(tool_call, Mapping): + return "", {} + fn = tool_call.get("function") or {} + if not isinstance(fn, Mapping): + return "", {} + name = str(fn.get("name") or "") or "" + raw_args = fn.get("arguments") + if isinstance(raw_args, Mapping): + return name, raw_args + if isinstance(raw_args, str) and raw_args: + try: + import json + + parsed = json.loads(raw_args) + if isinstance(parsed, Mapping): + return name, parsed + except Exception: + return name, {} + return name, {} + + +def _iter_assistant_tool_calls( + messages: Sequence[Mapping[str, Any]], +) -> Iterable[Tuple[str, Mapping[str, Any]]]: + for msg in messages: + if not isinstance(msg, Mapping): + continue + if msg.get("role") != "assistant": + continue + tool_calls = msg.get("tool_calls") or [] + if not isinstance(tool_calls, list): + continue + for tc in tool_calls: + name, args = _tool_call_name_and_args(tc) + if name: + yield name, args + + +def _count_visible_turns( + messages: Sequence[Mapping[str, Any]], +) -> Tuple[int, int, int]: + """Return ``(user_turn_count, assistant_turn_count, tool_message_count)``.""" + users = assistants = tools = 0 + for msg in messages: + if not isinstance(msg, Mapping): + continue + role = msg.get("role") + if role == "user": + users += 1 + elif role == "assistant": + assistants += 1 + elif role == "tool": + tools += 1 + return users, assistants, tools + + +def _latest_user_prompt( + messages: Sequence[Mapping[str, Any]], +) -> Optional[str]: + for msg in reversed(messages): + if isinstance(msg, Mapping) and msg.get("role") == "user": + text = _coerce_text(msg.get("content")).strip() + if text: + return text + return None + + +def _latest_assistant_text( + messages: Sequence[Mapping[str, Any]], +) -> Optional[str]: + for msg in reversed(messages): + if not isinstance(msg, Mapping): + continue + if msg.get("role") != "assistant": + continue + text = _coerce_text(msg.get("content")).strip() + if text: + return text + return None + + +def _recent_window( + messages: Sequence[Mapping[str, Any]], window: int = _RECENT_TURN_WINDOW +) -> List[Mapping[str, Any]]: + """Return the tail slice of ``messages`` covering at most ``window`` + user+assistant turns (tool messages ride along inside the window). + + Iterating from the end, we count user and assistant messages and + keep everything from the first message that falls within the window. + """ + count = 0 + cut = 0 + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if isinstance(msg, Mapping) and msg.get("role") in ("user", "assistant"): + count += 1 + if count >= window: + cut = i + break + else: + return list(messages) + return list(messages[cut:]) + + +def _shortened_path(path: str) -> str: + """Show a path relative to cwd when possible, otherwise with ~ expansion.""" + if not path: + return path + try: + abs_path = os.path.abspath(os.path.expanduser(path)) + cwd = os.getcwd() + if abs_path == cwd: + return "." + if abs_path.startswith(cwd + os.sep): + return abs_path[len(cwd) + 1 :] + home = os.path.expanduser("~") + if abs_path.startswith(home + os.sep): + return "~/" + abs_path[len(home) + 1 :] + return abs_path + except Exception: + return path + + +def _summarise_tool_activity( + tool_calls: Sequence[Tuple[str, Mapping[str, Any]]], +) -> Tuple[List[Tuple[str, int]], List[str]]: + """Return ``(tool_counts_sorted, recently_edited_files)``. + + ``tool_counts_sorted`` is descending by count, keeping the full list + so callers can truncate for display. ``recently_edited_files`` lists + distinct paths (most recent first) from file-editing tools. + """ + counter: Counter[str] = Counter() + files_seen: List[str] = [] + files_set: set[str] = set() + # Walk in reverse so "most recent first" drops out of order-preserved iteration. + for name, args in reversed(list(tool_calls)): + counter[name] += 1 + arg_key = _FILE_EDIT_TOOLS.get(name) + if arg_key: + path = args.get(arg_key) + if isinstance(path, str) and path and path not in files_set: + files_set.add(path) + files_seen.append(_shortened_path(path)) + # Restore "reverse of reverse" for correct counts; Counter ignores order + # so only files_seen needed the reversal. Fix ordering: currently + # files_seen is newest→oldest which is what we want for display. + tool_counts = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0])) + return tool_counts, files_seen + + +def _truncate(text: str, limit: int) -> str: + text = " ".join(text.split()) # collapse newlines for a compact one-liner + if len(text) <= limit: + return text + return text[: limit - 1].rstrip() + "…" + + +def build_recap( + messages: Sequence[Mapping[str, Any]], + *, + session_title: Optional[str] = None, + session_id: Optional[str] = None, + platform: Optional[str] = None, +) -> str: + """Build a multi-line recap of recent activity. + + Inputs: + messages: the full conversation history as a list of + chat-completion-style dicts (``role``, ``content``, + ``tool_calls``, …). + session_title: optional human title (from SessionDB). + session_id: optional session id. + platform: optional hint (``"cli"``, ``"telegram"``, …). Does not + change behavior today but is accepted for forward compat. + + The output is plain text designed to render well in both a terminal + (with 80-col wrapping) and a gateway message bubble. + """ + _ = platform # reserved for future use + lines: List[str] = [] + + header_bits: List[str] = ["Session recap"] + if session_title: + header_bits.append(f"— {session_title}") + elif session_id: + header_bits.append(f"— {session_id[:8]}") + lines.append(" ".join(header_bits)) + + if not messages: + lines.append(" (nothing to recap — no messages yet)") + return "\n".join(lines) + + users, assistants, tool_msgs = _count_visible_turns(messages) + window = _recent_window(messages) + win_users, win_assistants, _ = _count_visible_turns(window) + + scope = ( + f"{win_users} user turn{'s' if win_users != 1 else ''} / " + f"{win_assistants} assistant repl{'ies' if win_assistants != 1 else 'y'}" + ) + if (users, assistants) != (win_users, win_assistants): + scope += f" (of {users}/{assistants} total)" + lines.append(f" Recent: {scope}, {tool_msgs} tool result{'s' if tool_msgs != 1 else ''}") + + tool_calls = list(_iter_assistant_tool_calls(window)) + tool_counts, files = _summarise_tool_activity(tool_calls) + if tool_counts: + top = ", ".join(f"{name}×{count}" for name, count in tool_counts[:5]) + extra = len(tool_counts) - 5 + if extra > 0: + top += f" (+{extra} more)" + lines.append(f" Tools used: {top}") + if files: + shown = files[:_MAX_FILES_LISTED] + extra = len(files) - len(shown) + entry = ", ".join(shown) + if extra > 0: + entry += f" (+{extra} more)" + lines.append(f" Files touched: {entry}") + + latest_user = _latest_user_prompt(window) + if latest_user: + lines.append(f" Last ask: {_truncate(latest_user, _PROMPT_PREVIEW_CHARS)}") + + latest_reply = _latest_assistant_text(window) + if latest_reply: + lines.append(f" Last reply: {_truncate(latest_reply, _ASSISTANT_PREVIEW_CHARS)}") + + if len(lines) == 2: + # Only the header + scope line — nothing substantive to show. + lines.append(" (no assistant activity yet in this window)") + + return "\n".join(lines) + + +__all__ = ["build_recap"] diff --git a/tests/hermes_cli/test_session_recap.py b/tests/hermes_cli/test_session_recap.py new file mode 100644 index 00000000000..3998c06c61a --- /dev/null +++ b/tests/hermes_cli/test_session_recap.py @@ -0,0 +1,180 @@ +"""Unit tests for hermes_cli.session_recap.""" +from __future__ import annotations + +import json + +import pytest + +from hermes_cli.session_recap import build_recap + + +def _user(text): + return {"role": "user", "content": text} + + +def _assistant(text=None, tool_calls=None): + msg = {"role": "assistant", "content": text} + if tool_calls: + msg["tool_calls"] = tool_calls + return msg + + +def _tool_call(name, args): + return { + "id": f"call_{name}", + "type": "function", + "function": {"name": name, "arguments": json.dumps(args)}, + } + + +def _tool_result(content="ok"): + return {"role": "tool", "content": content} + + +def test_empty_history(): + out = build_recap([]) + assert "Session recap" in out + assert "nothing to recap" in out + + +def test_header_shows_title_when_provided(): + out = build_recap([_user("hello")], session_title="Refactor the adapter") + assert "Refactor the adapter" in out.splitlines()[0] + + +def test_header_shows_short_id_when_no_title(): + out = build_recap([_user("hello")], session_id="abcdef1234567890") + assert "abcdef12" in out.splitlines()[0] + + +def test_counts_recent_turns(): + msgs = [ + _user("one"), + _assistant("first reply"), + _user("two"), + _assistant("second reply"), + ] + out = build_recap(msgs) + assert "2 user turn" in out + assert "assistant repl" in out + + +def test_last_ask_and_reply_are_surfaced(): + msgs = [ + _user("old question"), + _assistant("old answer"), + _user("summarise the docs"), + _assistant("here is the summary of the docs you asked for"), + ] + out = build_recap(msgs) + assert "summarise the docs" in out + assert "summary of the docs" in out + + +def test_tool_counts_and_files(): + msgs = [ + _user("edit the readme and run tests"), + _assistant( + tool_calls=[ + _tool_call("read_file", {"path": "README.md"}), + _tool_call("patch", {"path": "README.md"}), + ] + ), + _tool_result(), + _tool_result(), + _assistant( + tool_calls=[ + _tool_call("terminal", {"command": "pytest"}), + ] + ), + _tool_result("tests ok"), + _assistant("All green."), + ] + out = build_recap(msgs) + assert "patch×1" in out + assert "terminal×1" in out + assert "read_file×1" in out + # README.md should appear (may include cwd-relative prefix stripping). + assert "README.md" in out + + +def test_tool_preview_length_truncates_long_user_prompt(): + long = "x " * 500 + out = build_recap([_user(long)]) + ask_line = [l for l in out.splitlines() if "Last ask" in l][0] + assert len(ask_line) < 300 # truncated with ellipsis + assert "…" in ask_line + + +def test_respects_recent_window(): + # 30 turns of user+assistant; only the most recent 20 should be summarised. + msgs = [] + for i in range(30): + msgs.append(_user(f"question {i}")) + msgs.append(_assistant(f"answer {i}")) + out = build_recap(msgs) + # We scoped to the 20-turn window but show "of 30/30 total". + assert "of 30/30 total" in out + + +def test_multimodal_content_blocks_flattened(): + msgs = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "check this file"}, + {"type": "image_url", "image_url": {"url": "..."}}, + ], + }, + _assistant("Looked at your image."), + ] + out = build_recap(msgs) + assert "check this file" in out + assert "Looked at your image" in out + + +def test_handles_arguments_as_dict_not_string(): + # Some providers return arguments already as a dict. + msgs = [ + _user("go"), + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "type": "function", + "function": { + "name": "patch", + "arguments": {"path": "foo.py"}, + }, + } + ], + }, + ] + out = build_recap(msgs) + assert "patch×1" in out + assert "foo.py" in out + + +def test_no_assistant_activity_hint(): + out = build_recap([_user("just sent my first message")]) + assert "no assistant activity" in out or "Last ask" in out + + +def test_tool_message_count_reported(): + msgs = [ + _user("go"), + _assistant(tool_calls=[_tool_call("read_file", {"path": "a"})]), + _tool_result(), + _tool_result(), + _assistant("done"), + ] + out = build_recap(msgs) + assert "2 tool result" in out + + +def test_ignores_non_mapping_entries_gracefully(): + msgs = [None, "stray", _user("hi"), _assistant("hello")] + # Should not raise. + out = build_recap(msgs) + assert "Session recap" in out From 2b193907d668af0c45f108d885db53a7ce8b8919 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 17:09:41 -0700 Subject: [PATCH 16/24] fix(xai): surface provider 'error' SSE frame in Codex fallback stream (#27184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xAI's Responses stream emits 'type=error' as the FIRST SSE frame when an OAuth account is unsubscribed/exhausted or rejects the encrypted-reasoning replay introduced in the May 2026 SuperGrok rollout. The SDK helper raises RuntimeError(Expected to have received response.created before error), which the caller correctly routes to _run_codex_create_stream_fallback. The fallback then opens a new stream that emits the same 'error' frame — but the fallback loop only handled {response.completed, response.incomplete, response.failed} and silently continue'd past 'error' events. Result: the loop fell off the end of the stream and raised the useless 'fallback did not emit a terminal response' RuntimeError, which the classifier marked retryable=True and looped 3x before failing with no clue what went wrong. Now: 'error' frames raise a synthesized _StreamErrorEvent with an OpenAI SDK-shaped .body so _summarize_api_error, _extract_api_error_context, _is_entitlement_failure, and classify_api_error all see the real provider message. Users on unsubscribed accounts now see 'do not have an active Grok subscription' once, not three RuntimeErrors. Verified end-to-end: classifier returns reason=auth retryable=False; entitlement detector matches even with status_code=None; summarizer returns the full xAI message. Tests: 4 new in TestCodexFallbackErrorEvent covering xAI subscription message, dict-shaped events, summarizer integration, and the empty-stream case (must still raise the original RuntimeError so 'truncated mid-flight' stays distinguishable from 'provider rejected the call'). --- run_agent.py | 67 ++++++++++++++ tests/run_agent/test_streaming.py | 142 ++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/run_agent.py b/run_agent.py index ffe0ffbe67e..3f4573bac52 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1110,6 +1110,45 @@ def _qwen_portal_headers() -> dict: } +class _StreamErrorEvent(Exception): + """Synthesized provider error surfaced from a Responses ``error`` SSE frame. + + Some Codex-style Responses backends (xAI for subscription/quota + failures, custom relays under malformed-tool-call conditions) emit a + standalone ``type=error`` frame instead of routing the failure + through ``response.failed`` or returning an HTTP 4xx. The fallback + streaming path raises this exception so ``_summarize_api_error`` and + ``_extract_api_error_context`` see a familiar ``.body`` / + ``.status_code`` shape and the entitlement detector can match the + underlying provider message ("do not have an active Grok + subscription", etc.). + """ + + def __init__( + self, + message: str, + *, + code: Optional[str] = None, + param: Optional[str] = None, + status_code: Optional[int] = None, + ) -> None: + super().__init__(message) + self.message = message + self.code = code + self.param = param + self.status_code = status_code + # OpenAI SDK-shaped body so _extract_api_error_context / + # _summarize_api_error / classify_api_error all pick it up. + self.body: Dict[str, Any] = { + "error": { + "message": message, + "code": code, + "param": param, + "type": "error", + } + } + + class AIAgent: """ AI Agent with tool calling capabilities. @@ -7212,6 +7251,34 @@ class AIAgent: if not event_type and isinstance(event, dict): event_type = event.get("type") + # ``error`` SSE frames carry the provider's real failure + # reason (subscription / quota / model-not-available / + # rejected-reasoning-replay) but never appear in the + # ``{completed, incomplete, failed}`` terminal set, so the + # raw loop below would silently consume them and end with + # "did not emit a terminal response". xAI in particular + # emits ``type=error`` as the FIRST frame for OAuth + # accounts whose Grok subscription is missing/exhausted — + # the SDK's stream helper raises ``RuntimeError(Expected + # to have received response.created before error)`` which + # the caller catches and routes here, expecting this + # fallback to surface the message. Synthesize an + # APIError-shaped exception so ``_summarize_api_error`` + # and the credential-pool entitlement detector see the + # real text instead of a generic RuntimeError. + if event_type == "error": + err_message = getattr(event, "message", None) + if not err_message and isinstance(event, dict): + err_message = event.get("message") + err_code = getattr(event, "code", None) + if not err_code and isinstance(event, dict): + err_code = event.get("code") + err_param = getattr(event, "param", None) + if not err_param and isinstance(event, dict): + err_param = event.get("param") + err_message = (err_message or "stream emitted error event").strip() + raise _StreamErrorEvent(err_message, code=err_code, param=err_param) + # Collect output items and text deltas for backfill if event_type == "response.output_item.done": done_item = getattr(event, "item", None) diff --git a/tests/run_agent/test_streaming.py b/tests/run_agent/test_streaming.py index 1ce140f82bf..474a568875d 100644 --- a/tests/run_agent/test_streaming.py +++ b/tests/run_agent/test_streaming.py @@ -1586,3 +1586,145 @@ class TestCopilotACPStreamingDecision: _use_streaming = False assert _use_streaming is True + + +class TestCodexFallbackErrorEvent: + """Provider ``error`` SSE frames must surface the real message, + not the generic "did not emit a terminal response" RuntimeError. + + xAI emits ``type=error`` as the FIRST frame on the Responses stream + when an OAuth account is unsubscribed/exhausted (May 2026 + SuperGrok rollout). The SDK helper raises + ``RuntimeError("Expected to have received response.created before + error")`` which the caller catches and routes to + ``_run_codex_create_stream_fallback``. The fallback then opens a + NEW stream that emits the same ``type=error`` frame; before this + fix it ignored the event entirely and raised a useless RuntimeError. + """ + + def _make_agent(self): + from run_agent import AIAgent + agent = AIAgent( + api_key="test-key", + base_url="https://api.x.ai/v1", + provider="xai-oauth", + model="grok-4.3", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.api_mode = "codex_responses" + agent._touch_activity = lambda desc: None + return agent + + def test_fallback_raises_synthesized_error_with_xai_subscription_message(self): + from run_agent import _StreamErrorEvent + + agent = self._make_agent() + + error_event = SimpleNamespace( + type="error", + message=( + "Forbidden: The caller does not have permission to execute the specified operation. " + "'You have either run out of available resources or do not have an active Grok subscription.'" + ), + code="permission_denied", + param=None, + sequence_number=1, + ) + + class _FakeStream: + def __iter__(self_inner): + return iter([error_event]) + def close(self_inner): + return None + + mock_client = MagicMock() + mock_client.responses.create.return_value = _FakeStream() + + with pytest.raises(_StreamErrorEvent) as excinfo: + agent._run_codex_create_stream_fallback( + {"model": "grok-4.3", "instructions": "hi", "input": []}, + client=mock_client, + ) + + exc = excinfo.value + assert "active Grok subscription" in str(exc) + assert exc.code == "permission_denied" + assert isinstance(exc.body, dict) + assert exc.body["error"]["message"] == error_event.message + # _extract_api_error_context reads .body["error"]["message"] — make sure + # the entitlement detector will find the subscription phrase there. + assert "active Grok subscription" in exc.body["error"]["message"] + + def test_fallback_dict_event_payload_is_also_handled(self): + """Some relays deliver events as plain dicts instead of model + objects; the dict branch in the loop must surface them too.""" + from run_agent import _StreamErrorEvent + + agent = self._make_agent() + + error_event = { + "type": "error", + "message": "rate_limited", + "code": "rate_limit_exceeded", + } + + class _FakeStream: + def __iter__(self_inner): + return iter([error_event]) + def close(self_inner): + return None + + mock_client = MagicMock() + mock_client.responses.create.return_value = _FakeStream() + + with pytest.raises(_StreamErrorEvent) as excinfo: + agent._run_codex_create_stream_fallback( + {"model": "grok-4.3", "instructions": "hi", "input": []}, + client=mock_client, + ) + + assert "rate_limited" in str(excinfo.value) + assert excinfo.value.code == "rate_limit_exceeded" + + def test_fallback_surfaces_message_useful_to_summarizer(self): + """The synthesized exception must be readable by + ``_summarize_api_error`` so the user-facing log line shows the + real provider message instead of a generic class name.""" + from run_agent import AIAgent, _StreamErrorEvent + + agent = self._make_agent() + exc = _StreamErrorEvent( + "You have either run out of available resources or do not have an active Grok subscription.", + code="permission_denied", + ) + + summary = AIAgent._summarize_api_error(exc) + assert "active Grok subscription" in summary + + def test_fallback_still_raises_terminal_error_when_no_error_event(self): + """Streams that simply end without any terminal event (and no + ``error`` frame) must continue to raise the original + ``"did not emit a terminal response"`` RuntimeError so callers + can distinguish "stream truncated mid-flight" from "provider + rejected the call".""" + agent = self._make_agent() + + # Empty stream — no events at all + class _FakeStream: + def __iter__(self_inner): + return iter([]) + def close(self_inner): + return None + + mock_client = MagicMock() + mock_client.responses.create.return_value = _FakeStream() + + with pytest.raises(RuntimeError) as excinfo: + agent._run_codex_create_stream_fallback( + {"model": "grok-4.3", "instructions": "hi", "input": []}, + client=mock_client, + ) + + assert "did not emit a terminal response" in str(excinfo.value) From 33528b428d196443f788f43fec3139bd6e2c4997 Mon Sep 17 00:00:00 2001 From: konsisumer Date: Wed, 6 May 2026 17:21:47 +0200 Subject: [PATCH 17/24] fix(agent): reset _fallback_index at turn start even when no fallback activated In long-lived interactive sessions, _try_activate_fallback() advances _fallback_index before attempting client resolution. When resolution fails (provider not configured, etc.) the function returns False without ever setting _fallback_activated=True. _restore_primary_runtime() then skips its reset block entirely (guarded by `if not _fallback_activated`), leaving _fallback_index >= len(_fallback_chain) for all subsequent turns. The eager-fallback guard at the top of the retry loop checks `_fallback_index < len(_fallback_chain)`, so the condition fails silently and no fallback is ever attempted again for that session. Cron jobs spawn a fresh AIAgent per run and never hit this path, which is why the same fallback chain works reliably for cron but not interactive. Fix: reset _fallback_index=0 in the `not _fallback_activated` early-return branch so every new turn starts with the full chain available. Fixes #20465 --- run_agent.py | 8 ++++++++ .../run_agent/test_primary_runtime_restore.py | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/run_agent.py b/run_agent.py index 3f4573bac52..2931c4fa349 100644 --- a/run_agent.py +++ b/run_agent.py @@ -9223,6 +9223,14 @@ class AIAgent: ``gateway/run.py``), so this restoration IS needed there too. """ if not self._fallback_activated: + # Reset the chain index even when no fallback was activated this + # turn. Without this, a turn where _try_activate_fallback() was + # called but returned False (chain exhausted or provider not + # configured) leaves _fallback_index >= len(_fallback_chain) while + # _fallback_activated stays False. The next turn skips this block + # entirely, stranding the index and silently blocking all future + # fallback attempts for the session. Fixes #20465. + self._fallback_index = 0 return False if getattr(self, "_rate_limited_until", 0) > time.monotonic(): diff --git a/tests/run_agent/test_primary_runtime_restore.py b/tests/run_agent/test_primary_runtime_restore.py index d082f047f27..b921e61ab14 100644 --- a/tests/run_agent/test_primary_runtime_restore.py +++ b/tests/run_agent/test_primary_runtime_restore.py @@ -123,6 +123,26 @@ class TestRestorePrimaryRuntime: assert agent._fallback_activated is False assert agent._restore_primary_runtime() is False + def test_resets_index_when_fallback_not_activated(self): + """Regression for #20465: failed activation leaves _fallback_index advanced + with _fallback_activated=False; the next turn's restore must reset the index.""" + fbs = [{"provider": "custom", "model": "gpt-oss:20b", + "base_url": "http://host.docker.internal:11434/v1", "api_key": "ollama"}] + agent = _make_agent(fallback_model=fbs) + + # resolve_provider_client returns None → _try_activate_fallback returns False + # but _fallback_index has already been incremented to 1 + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(None, None)): + assert agent._try_activate_fallback() is False + + assert agent._fallback_activated is False + assert agent._fallback_index == 1 # advanced past the only entry + + # _restore_primary_runtime must reset the index so the next turn can retry + result = agent._restore_primary_runtime() + assert result is False # still no-op (primary was never left) + assert agent._fallback_index == 0 # chain available again + def test_restores_model_and_provider(self): agent = _make_agent( fallback_model={"provider": "openrouter", "model": "anthropic/claude-sonnet-4"}, From 29b1bd0e20e5848e2be8de431a225174ab6a7fed Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 17:14:45 -0700 Subject: [PATCH 18/24] feat(cli): add `hermes send` to pipe script output to any messaging platform (#27188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a thin CLI wrapper around the existing send_message_tool so shell scripts, cron scripts, CI hooks, and monitoring daemons can reuse the gateway's already-configured platform credentials without reimplementing each platform's REST client. hermes send --to telegram "deploy finished" echo "RAM 92%" | hermes send --to telegram:-1001234567890 hermes send --to discord:#ops --file report.md hermes send --to slack:#eng --subject "[CI]" --file build.log hermes send --list # all targets hermes send --list telegram # filter by platform Supports all platforms the send_message tool already does (Telegram, Discord, Slack, Signal, SMS, WhatsApp, Matrix, Feishu, DingTalk, WeCom, Weixin, Email, etc.), including threaded targets and #channel-name resolution via the channel directory. hermes_cli/send_cmd.py delegates to tools.send_message_tool.send_message_tool, which means there is zero new platform-specific code. The subcommand just: 1. Bridges ~/.hermes/.env and top-level ~/.hermes/config.yaml scalars into os.environ (same bootstrap the gateway does at startup) — required so TELEGRAM_HOME_CHANNEL and friends are visible to load_gateway_config(). 2. Resolves the message body from positional arg, --file, or piped stdin. 3. Calls the shared tool and translates its JSON result to exit codes: 0 success, 1 delivery failure, 2 usage error. No running gateway is required for bot-token platforms (Telegram, Discord, Slack, Signal, SMS, WhatsApp) — the tool hits each platform's REST API directly. Plugin platforms that rely on a live adapter connection still need the gateway running; the error message is forwarded verbatim. - New guide: website/docs/guides/pipe-script-output.md covering real-world patterns (memory watchdogs, CI hooks, cron pipes, long-running task completion pings) and the security/gateway notes. - Cross-links added from automate-with-cron.md ("no LLM? use hermes send") and developer-guide/gateway-internals.md (delivery-path section). tests/hermes_cli/test_send_cmd.py (20 tests, all green): - Happy paths: positional message, stdin, --file, --file -, --subject, --json, --quiet. - Error paths: missing --to, missing body, file not found, tool returns error payload (exit 1), tool skipped-send result (exit 0). - --list: human output, --json output, platform filter, unknown platform. - Env loader: bridges config.yaml scalars into env, does not override existing env vars, gracefully handles missing files. - Registrar contract: register_send_subparser() returns a working parser. Smoke-tested end-to-end against a live Telegram bot before commit. --- hermes_cli/main.py | 6 + hermes_cli/send_cmd.py | 445 ++++++++++++++++++ tests/hermes_cli/test_send_cmd.py | 387 +++++++++++++++ .../docs/developer-guide/gateway-internals.md | 2 +- website/docs/guides/automate-with-cron.md | 5 +- website/docs/guides/pipe-script-output.md | 249 ++++++++++ 6 files changed, 1091 insertions(+), 3 deletions(-) create mode 100644 hermes_cli/send_cmd.py create mode 100644 tests/hermes_cli/test_send_cmd.py create mode 100644 website/docs/guides/pipe-script-output.md diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a893ee85846..bd8fe6c5cff 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10119,6 +10119,12 @@ def main(): ) slack_parser.set_defaults(func=cmd_slack) + # ========================================================================= + # send command — pipe shell-script output to any configured platform + # ========================================================================= + from hermes_cli.send_cmd import register_send_subparser + register_send_subparser(subparsers) + # ========================================================================= # login command # ========================================================================= diff --git a/hermes_cli/send_cmd.py b/hermes_cli/send_cmd.py new file mode 100644 index 00000000000..451bb3b4964 --- /dev/null +++ b/hermes_cli/send_cmd.py @@ -0,0 +1,445 @@ +"""CLI subcommand: ``hermes send`` — pipe text from shell scripts to any +configured messaging platform (Telegram, Discord, Slack, Signal, SMS, etc.). + +This is a thin wrapper around ``tools.send_message_tool.send_message_tool`` +that exposes its functionality as a standalone CLI entry point so ops +scripts, cron jobs, CI hooks, and monitoring daemons can reuse the gateway's +already-configured credentials without having to reimplement each platform's +REST API client. + +Design notes: + +* No LLM, no agent loop — the subcommand just resolves arguments, reads the + message body, calls the shared tool function, and prints/returns the + result. It is intentionally fast, cheap, and side-effect-only. +* For platforms that send via bot token (Telegram, Discord, Slack, Signal, + SMS, WhatsApp-CloudAPI, …) no running gateway is required. The tool + talks directly to each platform's REST endpoint. For platforms that rely + on a persistent adapter connection (plugin platforms, Matrix in some + modes, …) a live gateway is needed; the underlying tool surfaces that + error to the caller. +* Exit codes follow the classic Unix convention: + 0 — delivery (or list) succeeded + 1 — delivery failed at the platform level + 2 — usage / argument / config error (argparse already uses 2) +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Optional + + +_USAGE_EXIT = 2 +_FAILURE_EXIT = 1 +_SUCCESS_EXIT = 0 + + +def _read_message_body( + positional: Optional[str], + file_path: Optional[str], +) -> Optional[str]: + """Resolve the message body from (in order): + + 1. An explicit positional message argument. + 2. ``--file PATH`` or ``--file -`` (where ``-`` means stdin). + 3. Piped stdin when it is not attached to a TTY. + + Returns ``None`` when nothing is available — callers must treat that as + a usage error. + """ + if positional: + return positional + + if file_path: + if file_path == "-": + return sys.stdin.read() + try: + return Path(file_path).read_text() + except OSError as exc: + print(f"hermes send: cannot read {file_path}: {exc}", file=sys.stderr) + sys.exit(_USAGE_EXIT) + + # Piped input: only consume stdin when it is not a TTY. Reading from a + # TTY would block the user in a half-broken "type your message" state, + # which is a poor default for an ops CLI. + if not sys.stdin.isatty(): + data = sys.stdin.read() + if data: + return data + + return None + + +def _resolve_target(arg_to: Optional[str]) -> Optional[str]: + """Return a cleaned ``--to`` value, or ``None`` when nothing is set.""" + if arg_to and arg_to.strip(): + return arg_to.strip() + return None + + +def _emit_result( + result_json: str, + *, + json_mode: bool, + quiet: bool, +) -> int: + """Print the tool result in the requested format and return the exit code. + + The underlying ``send_message_tool`` always returns a JSON string. We + parse it, decide success/failure, and format accordingly. + """ + try: + payload = json.loads(result_json) if result_json else {} + except json.JSONDecodeError: + # Shouldn't happen with the shared tool, but be defensive — pass the + # raw string through so the user can still see what went wrong. + payload = {"error": "invalid JSON from send_message_tool", "raw": result_json} + + if json_mode: + print(json.dumps(payload, indent=2)) + elif quiet: + pass + else: + if payload.get("error"): + print(f"hermes send: {payload['error']}", file=sys.stderr) + elif payload.get("success"): + note = payload.get("note") + if note: + print(note) + else: + print("sent") + else: + # Unknown shape — dump it so nothing is silently dropped. + print(json.dumps(payload, indent=2)) + + if payload.get("error"): + return _FAILURE_EXIT + if payload.get("skipped"): + return _SUCCESS_EXIT + if payload.get("success"): + return _SUCCESS_EXIT + # Unknown / unexpected — treat as failure so scripts notice. + return _FAILURE_EXIT + + +def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int: + """Print the channel directory (all configured targets across platforms). + + Uses ``load_directory()`` for structured JSON output and + ``format_directory_for_display()`` for the human-readable rendering that + the send_message tool itself shows to the model — keeps the two surfaces + identical. + """ + try: + from gateway.channel_directory import ( + format_directory_for_display, + load_directory, + ) + except Exception as exc: + print(f"hermes send: failed to load channel directory: {exc}", file=sys.stderr) + return _FAILURE_EXIT + + try: + raw = load_directory() + except Exception as exc: + print(f"hermes send: failed to read channel directory: {exc}", file=sys.stderr) + return _FAILURE_EXIT + + platforms = dict(raw.get("platforms") or {}) + + if platform_filter: + key = platform_filter.strip().lower() + filtered = {k: v for k, v in platforms.items() if k.lower() == key} + if not filtered: + print( + f"hermes send: no targets found for platform '{platform_filter}'. " + f"Configured: {', '.join(sorted(platforms)) or '(none)'}", + file=sys.stderr, + ) + return _FAILURE_EXIT + platforms = filtered + + if json_mode: + print(json.dumps({"platforms": platforms}, indent=2, default=str)) + return _SUCCESS_EXIT + + if not any(platforms.values()): + print("No messaging platforms configured or no channels discovered yet.") + print("Set one up with `hermes gateway setup`, or run the gateway once so") + print("channel discovery can populate ~/.hermes/channel_directory.json.") + return _SUCCESS_EXIT + + # Human display — when unfiltered, reuse the shared formatter the agent + # already sees. When filtered, build a minimal view ourselves. + if platform_filter is None: + print(format_directory_for_display()) + return _SUCCESS_EXIT + + for plat_name in sorted(platforms): + channels = platforms[plat_name] + print(f"{plat_name}:") + if not channels: + print(" (no channels discovered yet)") + continue + for ch in channels: + name = ch.get("name", "?") + chat_id = ch.get("id") or ch.get("chat_id") or "" + suffix = f" [{chat_id}]" if chat_id and chat_id != name else "" + print(f" {plat_name}:{name}{suffix}") + print() + + return _SUCCESS_EXIT + + +def _load_hermes_env() -> None: + """Populate ``os.environ`` from ``~/.hermes/.env`` AND bridge top-level + ``config.yaml`` keys into the environment so the underlying gateway + config loader sees platform credentials and home channel IDs. + + ``send_message_tool`` reads tokens and home-channel IDs via + ``os.getenv(...)`` on each call. The gateway process does two things at + startup that ``hermes send`` must replicate when invoked standalone: + + 1. ``load_dotenv(~/.hermes/.env)`` — brings bot tokens into the env. + 2. Bridge top-level simple values from ``~/.hermes/config.yaml`` into + ``os.environ`` (without overriding existing env vars). This is where + ``TELEGRAM_HOME_CHANNEL`` and friends live when the user saved them + via ``hermes config set``. + + See ``gateway/run.py`` for the canonical version of this bridge — we + intentionally reimplement the minimum needed here so ``hermes send`` + doesn't pull in the full gateway module just to resolve a home channel. + """ + # Step 1: dotenv + try: + from dotenv import load_dotenv + except Exception: + load_dotenv = None # type: ignore[assignment] + + try: + from hermes_cli.config import get_hermes_home + home = get_hermes_home() + except Exception: + return + + env_path = home / ".env" + if load_dotenv and env_path.exists(): + try: + load_dotenv(str(env_path), override=True, encoding="utf-8") + except UnicodeDecodeError: + try: + load_dotenv(str(env_path), override=True, encoding="latin-1") + except Exception: + pass + except Exception: + pass + + # Step 2: bridge top-level config.yaml values into the environment so + # gateway.config.load_gateway_config() sees them. Scalars only; don't + # override values already in the env. + import os + config_path = home / "config.yaml" + if not config_path.exists(): + return + + try: + import yaml # type: ignore[import-not-found] + except Exception: + return + + try: + with open(config_path, "r", encoding="utf-8") as fh: + raw = yaml.safe_load(fh) or {} + except Exception: + return + + try: + from hermes_cli.config import _expand_env_vars + raw = _expand_env_vars(raw) + except Exception: + pass + + if not isinstance(raw, dict): + return + + for key, val in raw.items(): + if not isinstance(val, (str, int, float, bool)): + continue + if key in os.environ: + continue + os.environ[key] = str(val) + + +def cmd_send(args: argparse.Namespace) -> None: + """Entry point wired into the top-level argparse dispatcher.""" + + # Bridge ~/.hermes/.env and ~/.hermes/config.yaml into os.environ so the + # gateway config loader (invoked downstream by send_message_tool and by + # the channel directory) can see platform credentials and home channels. + _load_hermes_env() + + # --list short-circuits everything else. + if getattr(args, "list_targets", False): + # When `--list telegram` is used, argparse stores "telegram" in the + # `message` positional (since list_targets takes no argument). + platform_filter = getattr(args, "message", None) + exit_code = _list_targets(platform_filter, json_mode=getattr(args, "json", False)) + sys.exit(exit_code) + + target = _resolve_target(getattr(args, "to", None)) + if not target: + print( + "hermes send: --to PLATFORM[:channel[:thread]] is required\n" + "Examples:\n" + " hermes send --to telegram \"hello\"\n" + " hermes send --to discord:#ops --file report.md\n" + " hermes send --list # list available targets", + file=sys.stderr, + ) + sys.exit(_USAGE_EXIT) + + message = _read_message_body( + getattr(args, "message", None), + getattr(args, "file", None), + ) + if message is None or not message.strip(): + print( + "hermes send: no message provided. Pass text as a positional " + "argument, use --file PATH, or pipe data via stdin.", + file=sys.stderr, + ) + sys.exit(_USAGE_EXIT) + + # Optional: prepend a subject line. Useful for alerting scripts that + # want a consistent header without inlining it into every call. + subject = getattr(args, "subject", None) + if subject: + message = f"{subject}\n\n{message.lstrip()}" + + # Import lazily so `hermes send --help` stays fast and does not pull in + # the full tool registry / gateway config stack. + from tools.send_message_tool import send_message_tool + + # send_message_tool auto-loads gateway config + env and routes to the + # appropriate platform adapter (bot-token path for Telegram/Discord/Slack/ + # Signal/SMS/WhatsApp; live-adapter path for plugin platforms). + # + # It expects the standard tool-call dict and returns a JSON string. + tool_args = { + "action": "send", + "target": target, + "message": message, + } + + result = send_message_tool(tool_args) + exit_code = _emit_result( + result, + json_mode=getattr(args, "json", False), + quiet=getattr(args, "quiet", False), + ) + sys.exit(exit_code) + + +def register_send_subparser(subparsers) -> argparse.ArgumentParser: + """Create the ``send`` subparser and return it. + + Kept as a standalone function so the top-level parser builder can wire + it in next to the other messaging subcommands without cluttering + ``_parser.py`` or ``main.py``. + """ + parser = subparsers.add_parser( + "send", + help="Send a message to a configured platform (scripts, cron jobs, CI).", + description=( + "Pipe text from any shell script to any messaging platform Hermes " + "is already configured for. Reuses the gateway's platform " + "credentials (~/.hermes/.env + ~/.hermes/config.yaml) — no LLM, " + "no agent loop, no running gateway required for bot-token " + "platforms like Telegram/Discord/Slack/Signal." + ), + epilog=( + "Examples:\n" + " hermes send --to telegram \"deploy finished\"\n" + " echo \"RAM 92%\" | hermes send --to telegram:-1001234567890\n" + " hermes send --to discord:#ops --file /tmp/report.md\n" + " hermes send --to slack:#eng --subject \"[CI]\" --file build.log\n" + " hermes send --list # all platforms\n" + " hermes send --list telegram # filter by platform\n" + "\n" + "Exit codes: 0 ok, 1 delivery/backend error, 2 usage error." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument( + "-t", + "--to", + metavar="TARGET", + default=None, + help=( + "Delivery target. Format: 'platform' (home channel), " + "'platform:chat_id', 'platform:chat_id:thread_id', or " + "'platform:#channel-name'. Examples: telegram, " + "telegram:-1001234567890:17585, discord:#ops, slack:C0123ABCD, " + "signal:+15551234567." + ), + ) + + parser.add_argument( + "message", + nargs="?", + default=None, + help="Message text. If omitted, read from --file or stdin.", + ) + + # Legacy / convenience positional removed — use --to for clarity. + + parser.add_argument( + "-f", + "--file", + metavar="PATH", + default=None, + help="Read message body from PATH. Use '-' to force stdin.", + ) + + parser.add_argument( + "-s", + "--subject", + metavar="LINE", + default=None, + help="Prepend a subject/header line before the message body.", + ) + + parser.add_argument( + "-l", + "--list", + dest="list_targets", + action="store_true", + default=False, + help="List available targets. Optional positional filter: `hermes send --list telegram`.", + ) + + parser.add_argument( + "-q", + "--quiet", + action="store_true", + default=False, + help="Suppress stdout on success (exit code only).", + ) + + parser.add_argument( + "--json", + action="store_true", + default=False, + help="Emit raw JSON result instead of human-readable output.", + ) + + parser.set_defaults(func=cmd_send) + return parser + + +__all__ = ["cmd_send", "register_send_subparser"] diff --git a/tests/hermes_cli/test_send_cmd.py b/tests/hermes_cli/test_send_cmd.py new file mode 100644 index 00000000000..9202315e3d4 --- /dev/null +++ b/tests/hermes_cli/test_send_cmd.py @@ -0,0 +1,387 @@ +"""Tests for the ``hermes send`` CLI subcommand. + +Covers the argument parsing / stdin / file / list behavior of +``hermes_cli.send_cmd``. The underlying ``send_message_tool`` is stubbed so +no network I/O or gateway is required. +""" + +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest + +from hermes_cli import send_cmd + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _parse(argv): + """Build the top-level parser and return the parsed args for ``argv``.""" + import argparse + + parser = argparse.ArgumentParser(prog="hermes") + subparsers = parser.add_subparsers(dest="command") + send_cmd.register_send_subparser(subparsers) + return parser.parse_args(["send", *argv]) + + +class _FakeTool: + """Replacement for ``tools.send_message_tool.send_message_tool``.""" + + def __init__(self, payload): + self.payload = payload + self.calls = [] + + def __call__(self, args, **_kw): + self.calls.append(dict(args)) + return json.dumps(self.payload) + + +@pytest.fixture +def fake_tool(monkeypatch): + """Install a fake send_message_tool and return the stub for inspection.""" + import sys + import types + + fake = _FakeTool({"success": True, "message_id": "m123"}) + + mod = types.ModuleType("tools.send_message_tool") + mod.send_message_tool = fake + # Register the stub so ``from tools.send_message_tool import ...`` inside + # cmd_send resolves to our fake. Also patch the parent ``tools`` package + # entry so attribute lookup works. + monkeypatch.setitem(sys.modules, "tools.send_message_tool", mod) + return fake + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +def test_positional_message_success(fake_tool, capsys): + args = _parse(["--to", "telegram", "hello world"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 0 + assert fake_tool.calls == [ + {"action": "send", "target": "telegram", "message": "hello world"} + ] + out = capsys.readouterr() + assert "sent" in out.out or out.out == "" # "sent" is the default success banner + + +def test_stdin_message(fake_tool, monkeypatch, capsys): + # Piped stdin (not a tty) should be consumed as the message body. + monkeypatch.setattr("sys.stdin", io.StringIO("piped body\n")) + # Force isatty to return False so the CLI reads from stdin. + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + args = _parse(["--to", "discord:#ops"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 0 + assert fake_tool.calls[0]["message"] == "piped body\n" + assert fake_tool.calls[0]["target"] == "discord:#ops" + + +def test_file_message(fake_tool, tmp_path): + body = tmp_path / "msg.txt" + body.write_text("from a file\n") + args = _parse(["--to", "slack:#eng", "--file", str(body)]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 0 + assert fake_tool.calls[0]["message"] == "from a file\n" + + +def test_file_dash_means_stdin(fake_tool, monkeypatch): + monkeypatch.setattr("sys.stdin", io.StringIO("dash body")) + args = _parse(["--to", "telegram", "--file", "-"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 0 + assert fake_tool.calls[0]["message"] == "dash body" + + +def test_subject_prepends_header(fake_tool): + args = _parse(["--to", "telegram", "--subject", "[CI]", "body text"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 0 + assert fake_tool.calls[0]["message"] == "[CI]\n\nbody text" + + +def test_json_mode_emits_payload(fake_tool, capsys): + args = _parse(["--to", "telegram", "--json", "hi"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 0 + out = capsys.readouterr().out + payload = json.loads(out) + assert payload.get("success") is True + assert payload.get("message_id") == "m123" + + +def test_quiet_suppresses_stdout(fake_tool, capsys): + args = _parse(["--to", "telegram", "--quiet", "shh"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 0 + out = capsys.readouterr() + assert out.out == "" + + +# --------------------------------------------------------------------------- +# Error paths +# --------------------------------------------------------------------------- + + +def test_missing_target(fake_tool, capsys, monkeypatch): + # Ensure stdin is a tty so the CLI does not try to consume it as a body. + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + args = _parse(["hello"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 2 + err = capsys.readouterr().err + assert "--to" in err + + +def test_missing_message(fake_tool, capsys, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + args = _parse(["--to", "telegram"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 2 + err = capsys.readouterr().err + assert "no message" in err.lower() + + +def test_file_not_found_is_usage_error(fake_tool, capsys, monkeypatch): + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + args = _parse(["--to", "telegram", "--file", "/nonexistent/does-not-exist.txt"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 2 + err = capsys.readouterr().err + assert "cannot read" in err.lower() + + +def test_tool_error_returns_failure_exit(monkeypatch, capsys): + import sys as _sys + import types as _types + + fake_mod = _types.ModuleType("tools.send_message_tool") + + def _bad_tool(args, **_kw): + return json.dumps({"error": "platform blew up"}) + + fake_mod.send_message_tool = _bad_tool + monkeypatch.setitem(_sys.modules, "tools.send_message_tool", fake_mod) + + args = _parse(["--to", "telegram", "nope"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "platform blew up" in err + + +def test_skipped_result_is_success(monkeypatch): + import sys as _sys + import types as _types + + fake_mod = _types.ModuleType("tools.send_message_tool") + fake_mod.send_message_tool = lambda args, **_kw: json.dumps( + {"success": True, "skipped": True, "reason": "duplicate"} + ) + monkeypatch.setitem(_sys.modules, "tools.send_message_tool", fake_mod) + + args = _parse(["--to", "telegram", "dup"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 0 + + +# --------------------------------------------------------------------------- +# --list +# --------------------------------------------------------------------------- + + +def test_list_human_output(monkeypatch, capsys): + import sys as _sys + import types as _types + + fake_dir = _types.ModuleType("gateway.channel_directory") + fake_dir.format_directory_for_display = lambda: "Available messaging targets:\n\nTelegram:\n telegram:-100123\n" + fake_dir.load_directory = lambda: { + "platforms": {"telegram": [{"id": "-100123", "name": "Test Group"}]} + } + monkeypatch.setitem(_sys.modules, "gateway.channel_directory", fake_dir) + + args = _parse(["--list"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "Telegram" in out + + +def test_list_json(monkeypatch, capsys): + import sys as _sys + import types as _types + + fake_dir = _types.ModuleType("gateway.channel_directory") + fake_dir.format_directory_for_display = lambda: "(ignored in json mode)" + fake_dir.load_directory = lambda: { + "platforms": {"telegram": [{"id": "-100123", "name": "Test Group"}]} + } + monkeypatch.setitem(_sys.modules, "gateway.channel_directory", fake_dir) + + args = _parse(["--list", "--json"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 0 + out = capsys.readouterr().out + payload = json.loads(out) + assert payload["platforms"]["telegram"][0]["name"] == "Test Group" + + +def test_list_filter_platform(monkeypatch, capsys): + import sys as _sys + import types as _types + + fake_dir = _types.ModuleType("gateway.channel_directory") + fake_dir.format_directory_for_display = lambda: "(should not be called when filter set)" + fake_dir.load_directory = lambda: { + "platforms": { + "telegram": [{"id": "-100123", "name": "TG Chat"}], + "discord": [{"id": "555", "name": "bot-home"}], + } + } + monkeypatch.setitem(_sys.modules, "gateway.channel_directory", fake_dir) + + # When --list is set, argparse puts the optional bareword in the + # `message` positional slot (where the send-mode body would go). + args = _parse(["--list", "telegram"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "telegram" in out.lower() + assert "discord" not in out.lower() + + +def test_list_unknown_platform_fails(monkeypatch, capsys): + import sys as _sys + import types as _types + + fake_dir = _types.ModuleType("gateway.channel_directory") + fake_dir.format_directory_for_display = lambda: "" + fake_dir.load_directory = lambda: {"platforms": {"telegram": []}} + monkeypatch.setitem(_sys.modules, "gateway.channel_directory", fake_dir) + + args = _parse(["--list", "pigeon-post"]) + with pytest.raises(SystemExit) as exc: + send_cmd.cmd_send(args) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "pigeon-post" in err + + +# --------------------------------------------------------------------------- +# Parser registration contract +# --------------------------------------------------------------------------- + + +def test_register_send_subparser_is_reusable(): + """Sanity check: the registrar returns a parser and wires ``cmd_send``.""" + import argparse + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + send_parser = send_cmd.register_send_subparser(subparsers) + assert send_parser is not None + args = parser.parse_args(["send", "--to", "telegram", "hi"]) + assert args.func is send_cmd.cmd_send + assert args.to == "telegram" + assert args.message == "hi" + + +# --------------------------------------------------------------------------- +# Env loader +# --------------------------------------------------------------------------- + + +def test_load_hermes_env_bridges_config_yaml_scalars(tmp_path, monkeypatch): + """Top-level config.yaml scalars should be bridged into os.environ. + + This mirrors the gateway/run.py bootstrap behavior: without this, running + ``hermes send`` from a fresh shell cannot resolve the home channel + because ``TELEGRAM_HOME_CHANNEL`` (saved by ``hermes config set``) lives + in config.yaml, not in .env — and the gateway's config loader reads via + ``os.getenv(...)``. + """ + import os + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / ".env").write_text("SOME_TOKEN=abc123\n") + (hermes_home / "config.yaml").write_text( + "TELEGRAM_HOME_CHANNEL: '5550001111'\nnested:\n ignored: true\n" + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("TELEGRAM_HOME_CHANNEL", raising=False) + monkeypatch.delenv("SOME_TOKEN", raising=False) + + # Force get_hermes_home() to re-resolve under the patched env. + from importlib import reload + + import hermes_cli.config as _hc_config + reload(_hc_config) + + send_cmd._load_hermes_env() + + assert os.environ.get("SOME_TOKEN") == "abc123" + assert os.environ.get("TELEGRAM_HOME_CHANNEL") == "5550001111" + + +def test_load_hermes_env_does_not_override_existing(tmp_path, monkeypatch): + """Existing env vars must not be clobbered by config.yaml values.""" + import os + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text("TELEGRAM_HOME_CHANNEL: yaml_value\n") + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "env_value") + + from importlib import reload + import hermes_cli.config as _hc_config + reload(_hc_config) + + send_cmd._load_hermes_env() + + assert os.environ.get("TELEGRAM_HOME_CHANNEL") == "env_value" + + +def test_load_hermes_env_handles_missing_files(tmp_path, monkeypatch): + """No .env or config.yaml should be a silent no-op, not an exception.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + from importlib import reload + import hermes_cli.config as _hc_config + reload(_hc_config) + + # Should not raise. + send_cmd._load_hermes_env() diff --git a/website/docs/developer-guide/gateway-internals.md b/website/docs/developer-guide/gateway-internals.md index d0521d4816d..ebbe6c0e970 100644 --- a/website/docs/developer-guide/gateway-internals.md +++ b/website/docs/developer-guide/gateway-internals.md @@ -186,7 +186,7 @@ Outgoing deliveries (`gateway/delivery.py`) handle: - **Direct reply** — send response back to the originating chat - **Home channel delivery** — route cron job outputs and background results to a configured home channel -- **Explicit target delivery** — `send_message` tool specifying `telegram:-1001234567890` +- **Explicit target delivery** — `send_message` tool specifying `telegram:-1001234567890`, or the [`hermes send` CLI](/docs/guides/pipe-script-output) wrapping the same tool for shell scripts - **Cross-platform delivery** — deliver to a different platform than the originating message Cron job deliveries are NOT mirrored into gateway session history — they live in their own cron session only. This is a deliberate design choice to avoid message alternation violations. diff --git a/website/docs/guides/automate-with-cron.md b/website/docs/guides/automate-with-cron.md index 46becd88574..aa4fbee1ca2 100644 --- a/website/docs/guides/automate-with-cron.md +++ b/website/docs/guides/automate-with-cron.md @@ -14,8 +14,9 @@ For the full feature reference, see [Scheduled Tasks (Cron)](/docs/user-guide/fe Cron jobs run in fresh agent sessions with no memory of your current chat. Prompts must be **completely self-contained** — include everything the agent needs to know. ::: -:::tip Don't need the LLM? Use no-agent mode. -For recurring watchdogs where the script already produces the exact message you want to send (memory alerts, disk alerts, CI pings, heartbeats), skip the LLM entirely with [script-only cron jobs](/docs/guides/cron-script-only). Zero tokens, same scheduler. You can ask Hermes to set one up for you in chat — the `cronjob` tool knows when to pick `no_agent=True` and writes the script for you. +:::tip Don't need the LLM? You have two zero-token options. +- **Recurring watchdog** where the script already produces the exact message (memory alerts, disk alerts, heartbeats): use [script-only cron jobs](/docs/guides/cron-script-only). Same scheduler, no LLM. You can ask Hermes to set one up for you in chat — the `cronjob` tool knows when to pick `no_agent=True` and writes the script for you. +- **One-shot from a script that's already running** (CI step, post-commit hook, deploy script, externally-scheduled monitor): use [`hermes send`](/docs/guides/pipe-script-output) to pipe stdout or a file straight to Telegram / Discord / Slack / etc. without setting up a cron entry. ::: --- diff --git a/website/docs/guides/pipe-script-output.md b/website/docs/guides/pipe-script-output.md new file mode 100644 index 00000000000..483d45206a3 --- /dev/null +++ b/website/docs/guides/pipe-script-output.md @@ -0,0 +1,249 @@ +--- +sidebar_position: 12 +title: "Pipe Script Output to Messaging Platforms" +description: "Send text from any shell script, cron job, CI hook, or monitoring daemon to Telegram, Discord, Slack, Signal, and other platforms using `hermes send`." +--- + +# Pipe Script Output to Messaging Platforms + +`hermes send` is a small, scriptable CLI that pushes a message to any +messaging platform Hermes is already configured for. Think of it as a +cross-platform `curl` for notifications — you don't need a running +gateway, you don't need an LLM, and you don't need to re-paste bot tokens +into each of your scripts. + +Use it for: + +- System monitoring (memory, disk, GPU temp, long-running job finished) +- CI/CD notifications (deploy done, test failure) +- Cron scripts that need to ping you with results +- Quick one-shot messages from a terminal +- Piping any tool's output anywhere (`make | hermes send --to slack:#builds`) + +The command reuses the same credentials and platform adapters that `hermes +gateway` already uses, so there's no second configuration surface to +maintain. + +--- + +## Quick Start + +```bash +# Plain text to the home channel for a platform +hermes send --to telegram "deploy finished" + +# Pipe in stdout from anything +echo "RAM 92%" | hermes send --to telegram:-1001234567890 + +# Send a file +hermes send --to discord:#ops --file /tmp/report.md + +# Attach a subject/header line +hermes send --to slack:#eng --subject "[CI] build.log" --file build.log + +# Thread target (Telegram topic, Discord thread) +hermes send --to telegram:-1001234567890:17585 "threaded reply" + +# List every configured target +hermes send --list + +# Filter by platform +hermes send --list telegram +``` + +--- + +## Argument Reference + +| Flag | Description | +|------|-------------| +| `-t, --to TARGET` | Destination. See [target formats](#target-formats). | +| `message` (positional) | Message text. Omit to read from `--file` or stdin. | +| `-f, --file PATH` | Read the body from a file. `--file -` forces stdin. | +| `-s, --subject LINE` | Prepend a header/subject line before the body. | +| `-l, --list` | List available targets. Optional positional platform filter. | +| `-q, --quiet` | No stdout on success (exit code only — ideal for scripts). | +| `--json` | Emit the raw JSON result of the send. | +| `-h, --help` | Show the built-in help text. | + +### Target Formats + +| Format | Example | Meaning | +|--------|---------|---------| +| `platform` | `telegram` | Send to the platform's configured home channel | +| `platform:chat_id` | `telegram:-1001234567890` | Specific numeric chat / group / user | +| `platform:chat_id:thread_id` | `telegram:-1001234567890:17585` | Specific thread or Telegram forum topic | +| `platform:#channel` | `discord:#ops` | Human-friendly channel name (resolved against the channel directory) | +| `platform:+E164` | `signal:+15551234567` | Phone-addressed platforms: Signal, SMS, WhatsApp | + +Any platform Hermes ships adapters for works as a target: +`telegram`, `discord`, `slack`, `signal`, `sms`, `whatsapp`, `matrix`, +`mattermost`, `feishu`, `dingtalk`, `wecom`, `weixin`, `email`, and +others. + +### Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | Send (or list) succeeded | +| `1` | Delivery failed at the platform level (auth, permissions, network) | +| `2` | Usage / argument / config error | + +Exit codes follow the standard Unix convention so your scripts can +branch on them the same way they would on `curl` or `grep`. + +--- + +## Message Body Resolution + +`hermes send` resolves the message body in this order: + +1. **Positional argument** — `hermes send --to telegram "hi"` +2. **`--file PATH`** — `hermes send --to telegram --file msg.txt` +3. **Piped stdin** — `echo hi | hermes send --to telegram` + +When stdin is a TTY (no pipe), Hermes does **not** wait for input — you'll +get a clear usage error instead. This keeps scripts from hanging if they +accidentally omit the body. + +--- + +## Real-World Examples + +### Monitoring: Memory / Disk Alerts + +Replace ad-hoc `curl https://api.telegram.org/...` calls in your watchdogs +with a single portable line: + +```bash +#!/usr/bin/env bash +ram_pct=$(free | awk '/^Mem:/ {printf "%d", $3 * 100 / $2}') +if [ "$ram_pct" -ge 85 ]; then + hermes send --to telegram --subject "⚠ MEMORY WARNING" \ + "RAM ${ram_pct}% on $(hostname)" +fi +``` + +Because `hermes send` reuses your Hermes config, the same script works on +any host where Hermes is installed — no need to export bot tokens into +each machine's environment manually. + +:::tip Don't alert the gateway about itself +For watchdogs that might fire when the gateway itself is struggling (OOM +alerts, disk-full alerts), keep using a minimal `curl` call instead of +`hermes send`. If the Python interpreter can't load because the box is +thrashing, you still want that alert to go out. +::: + +### CI / CD: Build and Test Results + +```bash +# In .github/workflows/deploy.yml or any CI script +if ./scripts/deploy.sh; then + hermes send --to slack:#deploys "✅ ${CI_COMMIT_SHA:0:7} deployed" +else + tail -n 100 deploy.log | hermes send \ + --to slack:#deploys --subject "❌ deploy failed" + exit 1 +fi +``` + +### Cron: Daily Report + +```bash +# Crontab entry +0 9 * * * /usr/local/bin/generate-metrics.sh \ + | /home/me/.hermes/bin/hermes send \ + --to telegram --subject "Daily metrics $(date +%Y-%m-%d)" +``` + +### Long-Running Tasks: Ping When Done + +```bash +./train.py --epochs 200 && \ + hermes send --to telegram "training done" || \ + hermes send --to telegram "training failed (exit $?)" +``` + +### Scripting with `--json` and `--quiet` + +```bash +# Hard-fail a script if delivery fails; don't clutter logs on success +hermes send --to telegram --quiet "keepalive" || { + echo "Telegram delivery failed" >&2 + exit 1 +} + +# Capture the message ID for later editing / threading +msg_id=$(hermes send --to discord:#ops --json "build started" \ + | jq -r .message_id) +``` + +--- + +## Does `hermes send` Need the Gateway Running? + +**Usually no.** For any bot-token platform — Telegram, Discord, Slack, +Signal, SMS, WhatsApp Cloud API, and most others — `hermes send` calls +the platform's REST endpoint directly using credentials from +`~/.hermes/.env` and `~/.hermes/config.yaml`. It's a standalone subprocess +that exits as soon as the message is delivered. + +A live gateway is only required for **plugin platforms** that rely on a +persistent adapter connection (for example, a custom plugin that keeps +a long-lived WebSocket open). In that case you'll get a clear error +pointing at the gateway; start it with `hermes gateway start` and retry. + +--- + +## Listing and Discovering Targets + +Before sending to a specific channel, you can inspect what's available: + +```bash +# Every target across every configured platform +hermes send --list + +# Just Telegram targets +hermes send --list telegram + +# Machine-readable +hermes send --list --json +``` + +The listing is built from `~/.hermes/channel_directory.json`, which the +gateway refreshes every few minutes while it's running. If you see +"no channels discovered yet", start the gateway once (`hermes gateway +start`) so it can populate the cache. + +Human-friendly names (`discord:#ops`, `slack:#engineering`) are resolved +against this cache at send time, so you don't need to memorize numeric +IDs. + +--- + +## Comparison with Other Approaches + +| Approach | Multi-platform | Reuses Hermes creds | Needs gateway | Best for | +|----------|----------------|---------------------|---------------|----------| +| `hermes send` | ✅ | ✅ | No (bot-token) | Everything below | +| Raw `curl` to each platform | Each scripted separately | Manual | No | Critical watchdogs | +| `cron` job with `--deliver` | ✅ | ✅ | No | Scheduled agent tasks | +| `send_message` agent tool | ✅ | ✅ | No | Inside an agent loop | + +`hermes send` is intentionally the simplest possible surface. If you need +an agent to decide what to say, use the `send_message` tool from within a +chat or cron job. If you need a scheduled run with LLM-generated content, +use `cronjob(action='create', prompt=...)` with `deliver='telegram:...'`. +If you just need to pipe a raw string, reach for `hermes send`. + +--- + +## Related + +- [Automate Anything with Cron](/docs/guides/automate-with-cron) — + scheduled jobs whose output auto-delivers to any platform. +- [Gateway Internals](/docs/developer-guide/gateway-internals) — + the delivery router that `hermes send` shares with cron delivery. +- [Messaging Platform Setup](/docs/user-guide/messaging/) — + one-time configuration for each platform. From 9b82586c6b6dd628af273b3c6875e0142f798089 Mon Sep 17 00:00:00 2001 From: Guillaume Meyer Date: Sat, 16 May 2026 22:55:28 +0000 Subject: [PATCH 19/24] fix(plugins): surface category-namespaced plugins in hermes plugins list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_discover_all_plugins()` in plugins_cmd.py did a flat scan of the bundled and user plugin directories — only direct children with a plugin.yaml were surfaced. Category directories like `observability/`, `image_gen/`, `platforms/`, `model-providers/`, `web/`, and `video_gen/` have no plugin.yaml of their own, so their nested plugins (`observability/langfuse`, `image_gen/openai`, etc.) never appeared in `hermes plugins list` or the interactive `hermes plugins` UI — even though the runtime loader (`PluginManager._scan_directory_level`) discovers them correctly and they do load at runtime. This broke the documented promise that bundled plugins appear in `hermes plugins list` and the interactive UI before being enabled, and made it look like `observability/langfuse` didn't exist. Refactor `_discover_all_plugins()` to mirror the loader's recursion (depth cap = 2, same skip set, user overrides bundled on key collision). Return the path-derived registry key (e.g. `observability/langfuse`) as the displayed name, matching what the user passes to `hermes plugins enable …` / writes under `plugins.enabled` in config.yaml. Also clarify the plugins docs: spell out that sub-category plugins surface by their `/` key in `hermes plugins list` / interactive UI, add an `observability/langfuse` example to the command reference, and include a nested entry in the interactive-UI mock. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/plugins_cmd.py | 86 +++++++++++++-------- website/docs/user-guide/features/plugins.md | 24 +++--- 2 files changed, 70 insertions(+), 40 deletions(-) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 675989d170e..6fa3c59c7a3 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -708,55 +708,79 @@ def _plugin_exists(name: str) -> bool: def _discover_all_plugins() -> list: - """Return a list of (name, version, description, source, dir_path) for - every plugin the loader can see — user + bundled + project. + """Return a list of (key, version, description, source, dir_path) for + every plugin the loader can see — user + bundled. - Matches the ordering/dedup of ``PluginManager.discover_and_load``: - bundled first, then user, then project; user overrides bundled on - name collision. + Mirrors :meth:`PluginManager._scan_directory_level` so category-namespaced + plugins (``observability/langfuse``, ``image_gen/openai``) surface here + just like flat ones (``disk-cleanup``). A subdirectory with no + ``plugin.yaml`` of its own is treated as a category and recursed into + one level deeper (depth capped at 2, same as the loader). + + The returned ``key`` is the path-derived registry key — the value the + user types into ``hermes plugins enable ``. For category-namespaced + plugins that's ``/``; for flat plugins it's the + manifest's ``name`` (or the directory name if the manifest omits it). + + User entries override bundled on key collision, matching + ``PluginManager.discover_and_load``. """ try: import yaml except ImportError: yaml = None - seen: dict = {} # name -> (name, version, description, source, path) + seen: dict = {} # key -> (key, version, description, source, path) - # Bundled (/plugins//), excluding memory/ and context_engine/ - from hermes_cli.plugins import get_bundled_plugins_dir - repo_plugins = get_bundled_plugins_dir() - for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")): + def _scan(base: Path, source: str, prefix: str, depth: int) -> None: if not base.is_dir(): - continue + return for d in sorted(base.iterdir()): if not d.is_dir(): continue - if source == "bundled" and d.name in {"memory", "context_engine"}: + if ( + depth == 0 + and source == "bundled" + and d.name in {"memory", "context_engine"} + ): continue manifest_file = d / "plugin.yaml" if not manifest_file.exists(): manifest_file = d / "plugin.yml" - if not manifest_file.exists(): + + if manifest_file.exists(): + manifest_name = d.name + version = "" + description = "" + if yaml: + try: + with open(manifest_file, encoding="utf-8") as f: + manifest = yaml.safe_load(f) or {} + manifest_name = manifest.get("name", d.name) + version = manifest.get("version", "") + description = manifest.get("description", "") + except Exception: + pass + key = f"{prefix}/{d.name}" if prefix else manifest_name + if key in seen and source == "bundled": + continue + src_label = source + if source == "user" and (d / ".git").exists(): + src_label = "git" + seen[key] = (key, version, description, src_label, d) continue - name = d.name - version = "" - description = "" - if yaml: - try: - with open(manifest_file, encoding="utf-8") as f: - manifest = yaml.safe_load(f) or {} - name = manifest.get("name", d.name) - version = manifest.get("version", "") - description = manifest.get("description", "") - except Exception: - pass - # User plugins override bundled on name collision. - if name in seen and source == "bundled": + + # No manifest at this level — treat as a category namespace and + # recurse one level deeper. Cap at depth 2 (same as the loader). + if depth >= 1: continue - src_label = source - if source == "user" and (d / ".git").exists(): - src_label = "git" - seen[name] = (name, version, description, src_label, d) + sub_prefix = f"{prefix}/{d.name}" if prefix else d.name + _scan(d, source, sub_prefix, depth + 1) + + from hermes_cli.plugins import get_bundled_plugins_dir + _scan(get_bundled_plugins_dir(), "bundled", "", 0) + _scan(_plugins_dir(), "user", "", 0) + return list(seen.values()) diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index e9dc2910889..9572f3538a6 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -142,6 +142,8 @@ Within each source, Hermes also recognizes sub-category directories that route p User plugins at `~/.hermes/plugins/model-providers//` and `~/.hermes/plugins/memory//` override bundled plugins of the same name — last-writer-wins in `register_provider()` / `register_memory_provider()`. Drop a directory in, and it replaces the built-in without any repo edits. +Sub-category plugins surface in `hermes plugins list` and the interactive `hermes plugins` UI under their **path-derived key** — e.g. `observability/langfuse`, `image_gen/openai`, `platforms/teams`. That key (not the bare manifest `name:`) is the value you pass to `hermes plugins enable …` / `disable …` and the string to add under `plugins.enabled` in `config.yaml`. + ## Plugins are opt-in (with a few exceptions) **General plugins and user-installed backends are disabled by default** — discovery finds them (so they show up in `hermes plugins` and `/plugins`), but nothing with hooks or tools loads until you add the plugin's name to `plugins.enabled` in `~/.hermes/config.yaml`. This stops third-party code from running without your explicit consent. @@ -263,17 +265,20 @@ Declarative plugins are symlinked with a `nix-managed-` prefix — they coexist ## Managing plugins ```bash -hermes plugins # unified interactive UI -hermes plugins list # table: enabled / disabled / not enabled -hermes plugins install user/repo # install from Git, then prompt Enable? [y/N] -hermes plugins install user/repo --enable # install AND enable (no prompt) -hermes plugins install user/repo --no-enable # install but leave disabled (no prompt) -hermes plugins update my-plugin # pull latest -hermes plugins remove my-plugin # uninstall -hermes plugins enable my-plugin # add to allow-list -hermes plugins disable my-plugin # remove from allow-list + add to disabled +hermes plugins # unified interactive UI +hermes plugins list # table: enabled / disabled / not enabled +hermes plugins install user/repo # install from Git, then prompt Enable? [y/N] +hermes plugins install user/repo --enable # install AND enable (no prompt) +hermes plugins install user/repo --no-enable # install but leave disabled (no prompt) +hermes plugins update my-plugin # pull latest +hermes plugins remove my-plugin # uninstall +hermes plugins enable my-plugin # add to allow-list (flat plugin) +hermes plugins enable observability/langfuse # add to allow-list (sub-category plugin) +hermes plugins disable my-plugin # remove from allow-list + add to disabled ``` +For plugins under a sub-category directory (e.g. `plugins/observability/langfuse/`, `plugins/image_gen/openai/`), use the full `/` key — that's exactly what `hermes plugins list` shows in the **Name** column. + ### Interactive UI Running `hermes plugins` with no arguments opens a composite interactive screen: @@ -286,6 +291,7 @@ Plugins → [✓] my-tool-plugin — Custom search tool [ ] webhook-notifier — Event hooks [ ] disk-cleanup — Auto-cleanup of ephemeral files [bundled] + [ ] observability/langfuse — Trace turns / LLM calls / tools to Langfuse [bundled] Provider Plugins Memory Provider ▸ honcho From 8ab8bc2f035ac4ed8b3b43ed2940ba3dc4589cc9 Mon Sep 17 00:00:00 2001 From: Guillaume Meyer Date: Sat, 16 May 2026 23:04:42 +0000 Subject: [PATCH 20/24] =?UTF-8?q?fix(plugins):=20remove=20unreachable=20he?= =?UTF-8?q?rmes=20tools=20=E2=86=92=20Langfuse=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The langfuse plugin is hooks-only (no toolsets), so it never appears in `hermes tools` — that menu iterates `_get_effective_configurable_toolsets()` (= `CONFIGURABLE_TOOLSETS` + plugin-registered toolsets), and "langfuse" is in neither. The `TOOL_CATEGORIES["langfuse"]` setup wizard (with its `post_setup: "langfuse"` hook that pip-installs the SDK and writes `plugins.enabled`) was reachable only when a toolset key "langfuse" got enabled, which can't happen — so it's been dead code, and the docs that promised "Setup (interactive): hermes tools → Langfuse Observability" were silently broken. Right home for that wizard is `hermes plugins` (e.g. auto-running a plugin's post-setup hook on enable), which is a generic plugin-setup mechanism worth designing properly rather than shoehorning langfuse back into `hermes tools`. Until that exists, point users at the working manual flow. Code: - Delete `TOOL_CATEGORIES["langfuse"]` (24 lines) — unreachable. - Delete the `post_setup_key == "langfuse"` branch in `_run_post_setup` (29 lines) — only caller was the deleted TOOL_CATEGORIES entry. Docs / comments (point at the manual flow + interactive `hermes plugins`): - `plugins/observability/langfuse/README.md`: collapse the two-option setup section to the single working flow. - `plugins/observability/langfuse/plugin.yaml`: update `description`. - `plugins/observability/langfuse/__init__.py`: update module docstring. - `hermes_cli/config.py`: update inline comment above the LANGFUSE_* env-var allow-list. - `website/docs/user-guide/features/built-in-plugins.md`: collapse "Setup (interactive)" + "Setup (manual)" into one accurate block. - `website/docs/reference/environment-variables.md`: update the cross-reference in the Langfuse env-vars section. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/config.py | 3 +- hermes_cli/tools_config.py | 55 ------------------- plugins/observability/langfuse/README.md | 10 +--- plugins/observability/langfuse/__init__.py | 8 +-- plugins/observability/langfuse/plugin.yaml | 2 +- .../docs/reference/environment-variables.md | 2 +- .../user-guide/features/built-in-plugins.md | 12 +--- 7 files changed, 12 insertions(+), 80 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 81706d1edb4..c1f68e1c88c 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -134,8 +134,7 @@ _EXTRA_ENV_KEYS = frozenset({ "MATRIX_RECOVERY_KEY", # Langfuse observability plugin — optional tuning keys + standard SDK vars. # Activation is via plugins.enabled (opt-in through `hermes plugins enable - # observability/langfuse` or `hermes tools → Langfuse`); credentials gate - # the plugin at runtime. + # observability/langfuse`); credentials gate the plugin at runtime. "HERMES_LANGFUSE_ENV", "HERMES_LANGFUSE_RELEASE", "HERMES_LANGFUSE_SAMPLE_RATE", diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 3afaa5cc7c9..06ba32bea9e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -461,31 +461,6 @@ TOOL_CATEGORIES = { }, ], }, - "langfuse": { - "name": "Langfuse Observability", - "icon": "📊", - "providers": [ - { - "name": "Langfuse Cloud", - "tag": "Hosted Langfuse (cloud.langfuse.com)", - "env_vars": [ - {"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)", "url": "https://cloud.langfuse.com"}, - {"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)", "url": "https://cloud.langfuse.com"}, - ], - "post_setup": "langfuse", - }, - { - "name": "Langfuse Self-Hosted", - "tag": "Self-hosted Langfuse instance", - "env_vars": [ - {"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)"}, - {"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)"}, - {"key": "HERMES_LANGFUSE_BASE_URL", "prompt": "Langfuse server URL (e.g. http://localhost:3000)", "default": "http://localhost:3000"}, - ], - "post_setup": "langfuse", - }, - ], - }, } # Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES. @@ -947,36 +922,6 @@ def _run_post_setup(post_setup_key: str): _print_warning(f" Spotify login failed: {exc}") _print_info(" Run manually: hermes auth spotify") - elif post_setup_key == "langfuse": - # Install the langfuse SDK. - try: - __import__("langfuse") - _print_success(" langfuse SDK already installed") - except ImportError: - _print_info(" Installing langfuse SDK...") - result = _pip_install(["langfuse", "--quiet"], timeout=120) - if result.returncode == 0: - _print_success(" langfuse SDK installed") - else: - _print_warning(" langfuse SDK install failed — run manually: uv pip install langfuse") - # Opt the bundled observability/langfuse plugin into plugins.enabled. - # The plugin ships in the repo but doesn't load until the user enables - # it (standalone plugins are opt-in). - try: - from hermes_cli.plugins_cmd import _get_enabled_set, _save_enabled_set - enabled = _get_enabled_set() - if "observability/langfuse" in enabled or "langfuse" in enabled: - _print_success(" Plugin observability/langfuse already enabled") - else: - enabled.add("observability/langfuse") - _save_enabled_set(enabled) - _print_success(" Plugin observability/langfuse enabled") - except Exception as exc: - _print_warning(f" Could not enable plugin automatically: {exc}") - _print_info(" Run manually: hermes plugins enable observability/langfuse") - _print_info(" Restart Hermes for tracing to take effect.") - _print_info(" Verify: hermes plugins list") - elif post_setup_key == "xai_grok": # Shared credential bootstrap for any picker entry that talks to xAI # (TTS, Video Gen, future Image Gen, etc.). Accepts either a diff --git a/plugins/observability/langfuse/README.md b/plugins/observability/langfuse/README.md index 864735d9688..97f4757e5a8 100644 --- a/plugins/observability/langfuse/README.md +++ b/plugins/observability/langfuse/README.md @@ -5,20 +5,16 @@ you explicitly enable it. ## Enable -Pick one: - ```bash -# Interactive: walks you through credentials + SDK install + enable -hermes tools # → Langfuse Observability - -# Manual pip install langfuse hermes plugins enable observability/langfuse ``` +Or check the box in the interactive `hermes plugins` UI. + ## Required credentials -Set these in `~/.hermes/.env` (or via `hermes tools`): +Set these in `~/.hermes/.env`: ```bash HERMES_LANGFUSE_PUBLIC_KEY=pk-lf-... diff --git a/plugins/observability/langfuse/__init__.py b/plugins/observability/langfuse/__init__.py index 8516030fb01..a99a8eb9279 100644 --- a/plugins/observability/langfuse/__init__.py +++ b/plugins/observability/langfuse/__init__.py @@ -4,11 +4,11 @@ Traces Hermes conversations, LLM calls, and tool usage to Langfuse. Activation is handled by the Hermes plugin system — standalone plugins only load when listed in ``plugins.enabled`` (via ``hermes plugins enable -observability/langfuse`` or ``hermes tools → Langfuse Observability``). At -runtime the plugin also requires the ``langfuse`` SDK and credentials; if -either is missing the hooks are inert. +observability/langfuse``, or by checking the box in the interactive +``hermes plugins`` UI). At runtime the plugin also requires the +``langfuse`` SDK and credentials; if either is missing the hooks are inert. -Required env vars (set via ``hermes tools`` or ~/.hermes/.env): +Required env vars (set in ~/.hermes/.env): HERMES_LANGFUSE_PUBLIC_KEY - Langfuse project public key (pk-lf-...) HERMES_LANGFUSE_SECRET_KEY - Langfuse project secret key (sk-lf-...) HERMES_LANGFUSE_BASE_URL - Langfuse server URL (default: https://cloud.langfuse.com) diff --git a/plugins/observability/langfuse/plugin.yaml b/plugins/observability/langfuse/plugin.yaml index 18f1c6245d3..708264c8a96 100644 --- a/plugins/observability/langfuse/plugin.yaml +++ b/plugins/observability/langfuse/plugin.yaml @@ -1,6 +1,6 @@ name: langfuse version: "1.0.0" -description: "Optional Langfuse observability for Hermes — traces conversations, LLM calls, and tool usage. Opt-in via `hermes plugins enable observability/langfuse` or `hermes tools → Langfuse Observability`." +description: "Optional Langfuse observability for Hermes — traces conversations, LLM calls, and tool usage. Opt-in via `hermes plugins enable observability/langfuse` (or check the box in `hermes plugins`)." author: NousResearch requires_env: - HERMES_LANGFUSE_PUBLIC_KEY diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 56fe8a13715..4866ac083ac 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -156,7 +156,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe ### Langfuse Observability -Environment variables for the bundled [`observability/langfuse`](/docs/user-guide/features/built-in-plugins#observabilitylangfuse) plugin. Set these with `hermes tools → Langfuse Observability` or manually in `~/.hermes/.env`. The plugin must also be enabled (`hermes plugins enable observability/langfuse`) before any of these take effect. +Environment variables for the bundled [`observability/langfuse`](/docs/user-guide/features/built-in-plugins#observabilitylangfuse) plugin. Set these in `~/.hermes/.env`. The plugin must also be enabled (`hermes plugins enable observability/langfuse`, or check the box in `hermes plugins`) before any of these take effect. | Variable | Description | |----------|-------------| diff --git a/website/docs/user-guide/features/built-in-plugins.md b/website/docs/user-guide/features/built-in-plugins.md index aa346308913..8ac3322c68b 100644 --- a/website/docs/user-guide/features/built-in-plugins.md +++ b/website/docs/user-guide/features/built-in-plugins.md @@ -121,22 +121,14 @@ Traces Hermes turns, LLM calls, and tool invocations to [Langfuse](https://langf The plugin is fail-open: no SDK installed, no credentials, or a transient Langfuse error — all turn into a silent no-op in the hook. The agent loop is never impacted. -**Setup (interactive — recommended):** - -```bash -hermes tools # → Langfuse Observability → Cloud or Self-Hosted -``` - -The wizard collects your keys, `pip install`s the `langfuse` SDK, and adds `observability/langfuse` to `plugins.enabled` for you. Restart Hermes and the next turn ships a trace. - -**Setup (manual):** +**Setup:** ```bash pip install langfuse hermes plugins enable observability/langfuse ``` -Then put the credentials in `~/.hermes/.env`: +Or check the box in the interactive `hermes plugins` UI. Then put the credentials in `~/.hermes/.env`: ```bash HERMES_LANGFUSE_PUBLIC_KEY=pk-lf-... From 21be7025c584ea9b1d829e088b6049e259c6859a Mon Sep 17 00:00:00 2001 From: Guillaume Meyer Date: Sat, 16 May 2026 23:12:18 +0000 Subject: [PATCH 21/24] refactor(plugins): drop dead bundled-source guard in _discover_all_plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `if key in seen and source == "bundled": continue` check was unreachable: bundled is scanned before user, so `key in seen` can never be true while `source == "bundled"`. The "user overrides bundled" semantics are preserved automatically by the unconditional `seen[key] = …` on the user pass. Replaces the dead guard with a one-line comment explaining the overwrite semantics, so a future contributor adding a third source (e.g. project plugins) can see at a glance how ordering interacts with the dict-overwrite. Matches `PluginManager.discover_and_load`'s "user wins" rule. Spotted by Copilot in code review on #27161. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/plugins_cmd.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 6fa3c59c7a3..1e1c3282bee 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -762,11 +762,12 @@ def _discover_all_plugins() -> list: except Exception: pass key = f"{prefix}/{d.name}" if prefix else manifest_name - if key in seen and source == "bundled": - continue src_label = source if source == "user" and (d / ".git").exists(): src_label = "git" + # Bundled is scanned before user, so the user pass overwrites + # bundled entries with the same key — matches + # PluginManager.discover_and_load's "user wins" semantics. seen[key] = (key, version, description, src_label, d) continue From 5cbe0b1c4ffabf6aeca31827ba9a76ec35e4d4fb Mon Sep 17 00:00:00 2001 From: Guillaume Meyer Date: Sat, 16 May 2026 23:14:26 +0000 Subject: [PATCH 22/24] test(plugins): cover _discover_all_plugins recursion + cross-link loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a TestDiscoverAllPlugins class covering the six cases the recursive scan needs to handle: - flat plugin uses its manifest ``name:`` as the key - category-namespaced plugin keys off ``/`` even when the manifest ``name:`` is bare (regression test for the original bug — ``plugins/observability/langfuse/`` with ``name: langfuse`` must surface as ``observability/langfuse``, not ``langfuse``) - user-installed plugin overrides bundled on key collision - depth cap: anything below ``///`` is ignored - bundled ``memory/`` and ``context_engine/`` are skipped (they have their own loaders), but user plugins under those category names are still scanned Also add an in-source comment next to the key derivation pointing at the loader's matching line (``PluginManager._parse_manifest`` in plugins.py:1027-1028), so future renames of one site flag the other. Both items raised in Copilot review on #27161. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/plugins_cmd.py | 5 ++ tests/hermes_cli/test_plugins_cmd.py | 111 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 1e1c3282bee..8c002456787 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -761,6 +761,11 @@ def _discover_all_plugins() -> list: description = manifest.get("description", "") except Exception: pass + # Path-derived key, intentionally ignoring the manifest + # ``name:`` field for category-namespaced plugins — mirrors + # ``PluginManager._parse_manifest`` in plugins.py:1027-1028 + # so renaming a directory (without touching plugin.yaml) shifts + # the registry key in both places consistently. key = f"{prefix}/{d.name}" if prefix else manifest_name src_label = source if source == "user" and (d / ".git").exists(): diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index 180646c935d..5a421f018f9 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -396,6 +396,117 @@ class TestCmdList: cmd_list() +# ── _discover_all_plugins tests ─────────────────────────────────────────────── + + +class TestDiscoverAllPlugins: + """Exercise the recursive scan that powers ``hermes plugins list``. + + Mirrors the layouts the runtime loader handles + (:meth:`PluginManager._scan_directory_level`): flat plugins at the root, + category-namespaced plugins one level deeper, and user-overrides-bundled + on key collision. + """ + + @staticmethod + def _write_plugin(root: Path, segments: list, manifest_name: str = None) -> None: + plugin_dir = root + for seg in segments: + plugin_dir = plugin_dir / seg + plugin_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "name": manifest_name or segments[-1], + "version": "0.1.0", + "description": f"Test plugin {'/'.join(segments)}", + } + (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + + def _entries_by_key(self, tmp_path, monkeypatch) -> dict: + from hermes_cli import plugins_cmd + bundled = tmp_path / "bundled" + user = tmp_path / "user" + bundled.mkdir() + user.mkdir() + monkeypatch.setattr( + "hermes_cli.plugins.get_bundled_plugins_dir", lambda: bundled + ) + monkeypatch.setattr(plugins_cmd, "_plugins_dir", lambda: user) + return bundled, user, lambda: { + e[0]: e for e in plugins_cmd._discover_all_plugins() + } + + def test_flat_plugin_uses_manifest_name_as_key(self, tmp_path, monkeypatch): + bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) + self._write_plugin(bundled, ["disk-cleanup"]) + + entries = discover() + assert "disk-cleanup" in entries + assert entries["disk-cleanup"][3] == "bundled" + + def test_category_namespaced_plugin_uses_path_derived_key( + self, tmp_path, monkeypatch + ): + """Regression test for the original bug — ``observability/langfuse`` + and ``image_gen/openai`` must surface under their path-derived key, + not vanish because the category directory has no ``plugin.yaml``.""" + bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) + # langfuse's real manifest declares ``name: langfuse`` (bare), but it + # lives under ``observability/`` — the key must reflect the path. + self._write_plugin( + bundled, ["observability", "langfuse"], manifest_name="langfuse" + ) + self._write_plugin(bundled, ["image_gen", "openai"]) + + entries = discover() + assert "observability/langfuse" in entries + assert "image_gen/openai" in entries + # Bare manifest name must NOT leak through as a top-level key. + assert "langfuse" not in entries + assert "openai" not in entries + + def test_user_overrides_bundled_on_key_collision(self, tmp_path, monkeypatch): + bundled, user, discover = self._entries_by_key(tmp_path, monkeypatch) + self._write_plugin(bundled, ["observability", "langfuse"]) + self._write_plugin(user, ["observability", "langfuse"]) + + entries = discover() + assert entries["observability/langfuse"][3] == "user" + + def test_depth_cap_skips_third_level(self, tmp_path, monkeypatch): + """Anything deeper than ``///`` is ignored, + matching the loader's depth cap.""" + bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) + # plugins/a/b/c/plugin.yaml — too deep, must NOT be discovered. + self._write_plugin(bundled, ["a", "b", "c"]) + + entries = discover() + assert not any(k.startswith("a/") for k in entries), entries + + def test_bundled_memory_and_context_engine_skipped(self, tmp_path, monkeypatch): + """``plugins/memory/`` and ``plugins/context_engine/`` use their own + loaders; bundled entries inside them must not appear in the general + list (matches the pre-refactor skip set).""" + bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) + self._write_plugin(bundled, ["memory", "honcho"]) + self._write_plugin(bundled, ["context_engine", "compressor"]) + self._write_plugin(bundled, ["observability", "langfuse"]) + + entries = discover() + assert "memory/honcho" not in entries + assert "context_engine/compressor" not in entries + assert "observability/langfuse" in entries + + def test_user_memory_subdir_is_still_scanned(self, tmp_path, monkeypatch): + """The memory/context_engine skip only applies to *bundled* — a user + plugin at ``~/.hermes/plugins/memory//`` should still be discovered + so the user can see what they installed.""" + bundled, user, discover = self._entries_by_key(tmp_path, monkeypatch) + self._write_plugin(user, ["memory", "my-custom-store"]) + + entries = discover() + assert "memory/my-custom-store" in entries + + # ── _copy_example_files tests ───────────────────────────────────────────────── From 3b39096904ae63a9e784b2403ad6ad27160bb2ef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 17:18:25 -0700 Subject: [PATCH 23/24] Port from Kilo-Org/kilocode#9434: strip historical media after compression (#27189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After context compression, the protected tail messages retain their original image parts. When those include multi-MB pasted screenshots, every subsequent API request re-ships the same base-64 blobs forever — which can push the request past provider body-size limits and wedge the session even though compression 'succeeded'. Add _strip_historical_media() to agent/context_compressor.py. After the summary is built, find the newest user message that carries an image part and replace image parts in every earlier message with a short text placeholder ('[Attached image — stripped after compression]'). The newest image-bearing user turn keeps its media so the model can still analyse what the user just sent. Handles all three multimodal shapes: - OpenAI chat.completions image_url - OpenAI Responses API input_image - Anthropic native {type: image, source: ...} Includes 27 unit tests covering the helpers and the end-to-end compress() integration, plus a manual E2E check confirming a ~4MB two-image conversation shrinks to ~2MB after compression. --- agent/context_compressor.py | 116 ++++++++ .../agent/test_compressor_historical_media.py | 266 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 tests/agent/test_compressor_historical_media.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index e7a14faf51b..8eadcf26ef8 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -221,6 +221,114 @@ def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str: return json.dumps(shrunken, ensure_ascii=False) +_IMAGE_PART_TYPES = frozenset({"image_url", "input_image", "image"}) + + +def _is_image_part(part: Any) -> bool: + """True if ``part`` is a multimodal image content block. + + Recognizes all three shapes the agent handles: + - OpenAI chat.completions: ``{"type": "image_url", "image_url": ...}`` + - OpenAI Responses API: ``{"type": "input_image", "image_url": "..."}`` + - Anthropic native: ``{"type": "image", "source": {...}}`` + """ + if not isinstance(part, dict): + return False + return part.get("type") in _IMAGE_PART_TYPES + + +def _content_has_images(content: Any) -> bool: + """True if a message's ``content`` is a multimodal list with image parts.""" + if not isinstance(content, list): + return False + return any(_is_image_part(p) for p in content) + + +def _strip_images_from_content(content: Any) -> Any: + """Return a copy of ``content`` with every image part replaced by a + short text placeholder. + + - String content is returned unchanged. + - Non-list, non-string content is returned unchanged. + - List content: image parts become ``{"type": "text", "text": "[Attached + image — stripped after compression]"}``; other parts are preserved as-is. + + Input is never mutated. + """ + if not isinstance(content, list): + return content + if not any(_is_image_part(p) for p in content): + return content + + new_parts: List[Any] = [] + for p in content: + if _is_image_part(p): + new_parts.append({ + "type": "text", + "text": "[Attached image — stripped after compression]", + }) + else: + new_parts.append(p) + return new_parts + + +def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Replace image parts in older messages with placeholder text. + + The anchor is the *last* user message that has any image content. Every + message before that anchor gets its image parts replaced with a short + placeholder so the outgoing request stops re-shipping the same multi-MB + base-64 image blobs on every turn. + + If no user message carries images, the list is returned unchanged. + If the only user message with images is the very first one (nothing + earlier to strip), the list is returned unchanged. + + Shallow copies of touched messages only; input is never mutated. + Port of Kilo-Org/kilocode#9434 (adapted for the OpenAI-style message + shape the hermes compressor emits). + """ + if not messages: + return messages + + # Find the newest user message that carries at least one image part. + # We anchor on image-bearing user messages (not all user messages) so + # a plain text follow-up after a big-image turn still strips the old + # image — matching the problem kilocode#9434 set out to solve. + anchor = -1 + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if not isinstance(msg, dict): + continue + if msg.get("role") != "user": + continue + if _content_has_images(msg.get("content")): + anchor = i + break + + if anchor <= 0: + # No image-bearing user message, or it's the very first message — + # nothing before it to strip. + return messages + + changed = False + result: List[Dict[str, Any]] = [] + for i, msg in enumerate(messages): + if i >= anchor or not isinstance(msg, dict): + result.append(msg) + continue + content = msg.get("content") + if not _content_has_images(content): + result.append(msg) + continue + new_msg = msg.copy() + new_msg["content"] = _strip_images_from_content(content) + result.append(new_msg) + changed = True + + return result if changed else messages + + def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str: """Create an informative 1-line summary of a tool call + result. @@ -1559,6 +1667,14 @@ The user has requested that this compaction PRIORITISE preserving all informatio compressed = self._sanitize_tool_pairs(compressed) + # Replace image parts in all compressed messages before the newest + # image-bearing user turn with a short text placeholder. Without + # this, tail messages keep their original multi-MB base-64 image + # payloads forever, which can push every subsequent API request + # past the provider's body-size limit and wedge the session. + # Port of Kilo-Org/kilocode#9434. + compressed = _strip_historical_media(compressed) + new_estimate = estimate_messages_tokens_rough(compressed) saved_estimate = display_tokens - new_estimate diff --git a/tests/agent/test_compressor_historical_media.py b/tests/agent/test_compressor_historical_media.py new file mode 100644 index 00000000000..3594ef9bdde --- /dev/null +++ b/tests/agent/test_compressor_historical_media.py @@ -0,0 +1,266 @@ +"""Tests for post-compression historical-media stripping. + +Port of Kilo-Org/kilocode#9434 (adapted for OpenAI-style message lists). +Without this pass, tail messages keep their original multi-MB base-64 image +payloads after context compression, and every subsequent request re-ships +them — sometimes breaching provider body-size limits and wedging the +session. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from agent.context_compressor import ( + ContextCompressor, + _content_has_images, + _is_image_part, + _strip_historical_media, + _strip_images_from_content, +) + + +IMG_URL = { + "type": "image_url", + "image_url": {"url": "data:image/png;base64," + ("A" * 1024)}, +} +INPUT_IMG = { + "type": "input_image", + "image_url": "data:image/png;base64," + ("B" * 1024), +} +ANTHROPIC_IMG = { + "type": "image", + "source": {"type": "base64", "media_type": "image/png", "data": "C" * 1024}, +} +TEXT = {"type": "text", "text": "hi"} +INPUT_TEXT = {"type": "input_text", "text": "hi"} + + +class TestIsImagePart: + def test_openai_chat_shape(self): + assert _is_image_part(IMG_URL) is True + + def test_openai_responses_shape(self): + assert _is_image_part(INPUT_IMG) is True + + def test_anthropic_native_shape(self): + assert _is_image_part(ANTHROPIC_IMG) is True + + def test_text_part_is_not_image(self): + assert _is_image_part(TEXT) is False + assert _is_image_part(INPUT_TEXT) is False + + def test_non_dict_rejected(self): + assert _is_image_part("image") is False + assert _is_image_part(None) is False + assert _is_image_part(42) is False + + +class TestContentHasImages: + def test_string_content(self): + assert _content_has_images("a string") is False + + def test_empty_list(self): + assert _content_has_images([]) is False + + def test_text_only_list(self): + assert _content_has_images([TEXT, TEXT]) is False + + def test_list_with_image(self): + assert _content_has_images([TEXT, IMG_URL]) is True + + def test_none(self): + assert _content_has_images(None) is False + + +class TestStripImagesFromContent: + def test_string_passthrough(self): + assert _strip_images_from_content("hello") == "hello" + + def test_none_passthrough(self): + assert _strip_images_from_content(None) is None + + def test_text_only_passthrough(self): + parts = [TEXT, {"type": "text", "text": "world"}] + assert _strip_images_from_content(parts) == parts + + def test_replaces_image_with_placeholder(self): + parts = [TEXT, IMG_URL] + out = _strip_images_from_content(parts) + assert len(out) == 2 + assert out[0] == TEXT + assert out[1] == { + "type": "text", + "text": "[Attached image — stripped after compression]", + } + + def test_does_not_mutate_input(self): + parts = [IMG_URL, TEXT] + _ = _strip_images_from_content(parts) + assert parts[0] is IMG_URL # original list untouched + assert parts[1] is TEXT + + def test_handles_all_three_shapes(self): + parts = [IMG_URL, INPUT_IMG, ANTHROPIC_IMG, TEXT] + out = _strip_images_from_content(parts) + assert sum(1 for p in out if p.get("type") == "text") == 4 + assert not any(_is_image_part(p) for p in out) + + +class TestStripHistoricalMedia: + def test_empty_passthrough(self): + assert _strip_historical_media([]) == [] + + def test_no_images_anywhere(self): + msgs = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hey"}, + {"role": "user", "content": "bye"}, + ] + assert _strip_historical_media(msgs) is msgs # identity — no copy + + def test_single_image_user_only_first_message(self): + # Only image-bearing user is the first message — nothing before it. + msgs = [ + {"role": "user", "content": [TEXT, IMG_URL]}, + {"role": "assistant", "content": "ok"}, + ] + out = _strip_historical_media(msgs) + assert out is msgs # no-op + # Image still there. + assert _content_has_images(out[0]["content"]) + + def test_strips_older_user_image_keeps_newest(self): + msgs = [ + {"role": "user", "content": [TEXT, IMG_URL]}, # old — strip + {"role": "assistant", "content": "looked at it"}, + {"role": "user", "content": [TEXT, INPUT_IMG]}, # newest — keep + ] + out = _strip_historical_media(msgs) + assert out is not msgs # new list + # First message's image was replaced + assert not _content_has_images(out[0]["content"]) + # Newest user still has its image + assert _content_has_images(out[2]["content"]) + + def test_strips_assistant_and_tool_images_before_anchor(self): + msgs = [ + {"role": "user", "content": [TEXT, IMG_URL]}, # old user + {"role": "assistant", "content": [TEXT, IMG_URL]}, # old assistant + {"role": "tool", "content": [TEXT, IMG_URL], "tool_call_id": "t1"}, + {"role": "user", "content": [TEXT, IMG_URL]}, # newest user — keep + ] + out = _strip_historical_media(msgs) + for i in range(3): + assert not _content_has_images(out[i]["content"]), f"msg {i} still has image" + assert _content_has_images(out[3]["content"]) + + def test_text_only_newest_user_still_strips_older_images(self): + # The anchor is "newest user WITH images". If the newest user is + # text-only, we fall back to the previous image-bearing user turn. + msgs = [ + {"role": "user", "content": [TEXT, IMG_URL]}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": [TEXT, IMG_URL]}, # anchor + {"role": "assistant", "content": "done"}, + {"role": "user", "content": "follow-up text only"}, + ] + out = _strip_historical_media(msgs) + # First image-bearing user (index 0) was stripped — it was before the + # newest image-bearing user (index 2). + assert not _content_has_images(out[0]["content"]) + # Anchor (index 2) keeps its image. + assert _content_has_images(out[2]["content"]) + + def test_no_image_bearing_user_is_noop(self): + msgs = [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": [TEXT, IMG_URL]}, # assistant image only + {"role": "user", "content": "second"}, + ] + out = _strip_historical_media(msgs) + # No image-bearing user anchor → no stripping. + assert out is msgs + assert _content_has_images(out[1]["content"]) + + def test_does_not_mutate_input_messages(self): + msg0 = {"role": "user", "content": [TEXT, IMG_URL]} + msg1 = {"role": "user", "content": [TEXT, IMG_URL]} + msgs = [msg0, msg1] + _ = _strip_historical_media(msgs) + # Originals untouched + assert _content_has_images(msg0["content"]) + assert _content_has_images(msg1["content"]) + + def test_idempotent(self): + msgs = [ + {"role": "user", "content": [TEXT, IMG_URL]}, + {"role": "assistant", "content": "k"}, + {"role": "user", "content": [TEXT, IMG_URL]}, + ] + first = _strip_historical_media(msgs) + second = _strip_historical_media(first) + # Second pass is a no-op — no images left before the anchor. + assert second is first + + def test_non_dict_messages_pass_through(self): + msgs = [ + "not-a-dict", # shouldn't crash + {"role": "user", "content": [TEXT, IMG_URL]}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": [TEXT, IMG_URL]}, + ] + out = _strip_historical_media(msgs) + assert out[0] == "not-a-dict" + # Image-bearing user at index 1 is before the anchor (index 3) → stripped. + assert not _content_has_images(out[1]["content"]) + + +class TestCompressIntegration: + """Verify the stripping runs inside ContextCompressor.compress().""" + + @pytest.fixture + def compressor(self): + with patch("agent.context_compressor.get_model_context_length", return_value=100_000): + c = ContextCompressor( + model="test/model", + threshold_percent=0.50, + protect_first_n=1, + protect_last_n=2, + quiet_mode=True, + ) + return c + + def test_compress_strips_historical_images(self, compressor): + # Enough messages to trigger the summarize path. protect_first_n=1 + + # protect_last_n=2 + a middle window of at least 3 with a summary. + msgs = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": [TEXT, IMG_URL]}, # old image-bearing user + {"role": "assistant", "content": "looked at it"}, + {"role": "user", "content": "follow-up"}, + {"role": "assistant", "content": "ack"}, + {"role": "user", "content": "more"}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": [TEXT, IMG_URL]}, # newest image-bearing user (tail) + {"role": "assistant", "content": "done"}, + ] + # Bypass the real LLM summary — return a stub so compress() proceeds. + with patch.object(compressor, "_generate_summary", return_value="SUMMARY TEXT"): + out = compressor.compress(msgs, current_tokens=60_000) + + # Newest user turn with image should still have it (it's in the tail). + user_imgs = [m for m in out if m.get("role") == "user" and _content_has_images(m.get("content"))] + assert len(user_imgs) == 1, ( + "Expected exactly one user message with images after compression " + f"(the newest one); got {len(user_imgs)}" + ) + # No assistant or tool messages should carry images either. + for m in out: + if m is user_imgs[0]: + continue + assert not _content_has_images(m.get("content")), ( + f"Stale image in {m.get('role')!r} message after compression" + ) From 7415e280738a1d7f4083979e6e2bca967ed9433f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 16 May 2026 21:44:43 -0500 Subject: [PATCH 24/24] chore: uptick --- apps/desktop/src/app/chat/composer/focus.ts | 49 +++++++++++ apps/desktop/src/app/skills/index.tsx | 96 ++++----------------- 2 files changed, 66 insertions(+), 79 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/focus.ts diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts new file mode 100644 index 00000000000..c728577465e --- /dev/null +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -0,0 +1,49 @@ +export type ComposerFocusTarget = 'main' | 'edit' + +interface ComposerFocusRequestDetail { + target: ComposerFocusTarget +} + +const COMPOSER_FOCUS_REQUEST_EVENT = 'hermes:composer-focus-request' + +let activeComposerTarget: ComposerFocusTarget = 'main' + +function resolveTarget(target: ComposerFocusTarget | 'active'): ComposerFocusTarget { + return target === 'active' ? activeComposerTarget : target +} + +export function markActiveComposer(target: ComposerFocusTarget) { + activeComposerTarget = target +} + +export function requestComposerFocus(target: ComposerFocusTarget | 'active' = 'active') { + if (typeof window === 'undefined') { + return + } + + const resolvedTarget = resolveTarget(target) + + const event = new CustomEvent(COMPOSER_FOCUS_REQUEST_EVENT, { + detail: { target: resolvedTarget } + }) + + window.dispatchEvent(event) +} + +export function onComposerFocusRequest(handler: (target: ComposerFocusTarget) => void) { + if (typeof window === 'undefined') { + return () => undefined + } + + const listener = (event: Event) => { + const detail = (event as CustomEvent).detail + + if (detail?.target === 'main' || detail?.target === 'edit') { + handler(detail.target) + } + } + + window.addEventListener(COMPOSER_FOCUS_REQUEST_EVENT, listener) + + return () => window.removeEventListener(COMPOSER_FOCUS_REQUEST_EVENT, listener) +} diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index be3a57c4aac..9826bd52167 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -5,9 +5,9 @@ import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' +import { TextTab, TextTabMeta } from '@/components/ui/text-tab' import { getSkills, getToolsets, toggleSkill } from '@/hermes' -import { Brain, RefreshCw, Search, Wrench, X } from '@/lib/icons' -import type { LucideIcon } from '@/lib/icons' +import { RefreshCw, Search, X } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import type { SkillInfo, ToolsetInfo } from '@/types/hermes' @@ -185,14 +185,13 @@ export function SkillsView({
-
- setMode('skills')} text="Skills" /> - setMode('toolsets')} - text="Toolsets" - /> +
+ setMode('skills')}> + Skills + + setMode('toolsets')}> + Toolsets +
@@ -219,21 +218,18 @@ export function SkillsView({
{mode === 'skills' && categories.length > 0 && ( -
- setActiveCategory(null)} - /> +
+ setActiveCategory(null)}> + All {totalSkills} + {categories.map(category => ( - setActiveCategory(activeCategory === category.key ? null : category.key)} - /> + > + {prettyName(category.key)} {category.count} + ))}
)} @@ -330,64 +326,6 @@ export function SkillsView({ ) } -function ModeButton({ - active, - icon: Icon, - onClick, - text -}: { - active: boolean - icon: LucideIcon - onClick: () => void - text: string -}) { - return ( - - ) -} - -function CategoryButton({ - active, - count, - label, - onClick -}: { - active: boolean - count: number - label: string - onClick: () => void -}) { - return ( - - ) -} - function StatusPill({ active, children }: { active: boolean; children: string }) { return (