diff --git a/.env.example b/.env.example index f66b358b888..ec0c2d81d3a 100644 --- a/.env.example +++ b/.env.example @@ -143,6 +143,18 @@ # Also requires ~/.honcho/config.json with enabled=true (see README). # HONCHO_API_KEY= +# ============================================================================= +# HYPERLIQUID OPTIONAL SKILL +# ============================================================================= +# Optional defaults for the Hyperliquid skill in optional-skills/blockchain/hyperliquid +# +# Hyperliquid API base URL override +# Default: https://api.hyperliquid.xyz +# HYPERLIQUID_API_URL=https://api.hyperliquid-testnet.xyz +# +# Default address for account-level commands like state, fills, orders, and review +# HYPERLIQUID_USER_ADDRESS=0x0000000000000000000000000000000000000000 + # ============================================================================= # TERMINAL TOOL CONFIGURATION # ============================================================================= diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a2a7b2e8d36..807d5b6b69a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -122,7 +122,8 @@ jobs: retention-days: 14 - name: Post / update PR comment - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 with: script: | diff --git a/AGENTS.md b/AGENTS.md index d3ac9d60a79..1aee027386d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -565,10 +565,14 @@ Full authoring guide: `website/docs/developer-guide/model-provider-plugin.md`. ### Dashboard / context-engine / image-gen plugin directories -`plugins/context_engine/`, `plugins/image_gen/`, `plugins/example-dashboard/`, -etc. follow the same pattern (ABC + orchestrator + per-plugin directory). -Context engines plug into `agent/context_engine.py`; image-gen providers -into `agent/image_gen_provider.py`. +`plugins/context_engine/`, `plugins/image_gen/`, etc. follow the same +pattern (ABC + orchestrator + per-plugin directory). Context engines +plug into `agent/context_engine.py`; image-gen providers into +`agent/image_gen_provider.py`. Reference / docs-companion plugins +(`example-dashboard`, `strike-freedom-cockpit`, `plugin-llm-example`, +`plugin-llm-async-example`) live in the +[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins) +companion repo, not in this tree. --- diff --git a/SECURITY.md b/SECURITY.md index 3cede2885e6..c58e348b579 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,84 +1,331 @@ # Hermes Agent Security Policy -This document outlines the security protocols, trust model, and deployment hardening guidelines for the **Hermes Agent** project. +This document describes Hermes Agent's trust model, names the one +security boundary the project treats as load-bearing, and defines the +scope for vulnerability reports. -## 1. Vulnerability Reporting +## 1. Reporting a Vulnerability -Hermes Agent does **not** operate a bug bounty program. Security issues should be reported via [GitHub Security Advisories (GHSA)](https://github.com/NousResearch/hermes-agent/security/advisories/new) or by emailing **security@nousresearch.com**. Do not open public issues for security vulnerabilities. +Report privately via [GitHub Security Advisories](https://github.com/NousResearch/hermes-agent/security/advisories/new) +or **security@nousresearch.com**. Do not open public issues for +security vulnerabilities. **Hermes Agent does not operate a bug +bounty program.** -### Required Submission Details -- **Title & Severity:** Concise description and CVSS score/rating. -- **Affected Component:** Exact file path and line range (e.g., `tools/approval.py:120-145`). -- **Environment:** Output of `hermes version`, commit SHA, OS, and Python version. -- **Reproduction:** Step-by-step Proof-of-Concept (PoC) against `main` or the latest release. -- **Impact:** Explanation of what trust boundary was crossed. +A useful report includes: + +- A concise description and severity assessment. +- The affected component, identified by file path and line range + (e.g. `path/to/file.py:120-145`). +- Environment details (`hermes version`, commit SHA, OS, Python + version). +- A reproduction against `main` or the latest release. +- A statement of which trust boundary in §2 is crossed. + +Please read §2 and §3 before submitting. Reports that demonstrate +limits of an in-process heuristic this policy does not treat as a +boundary will be closed as out-of-scope under §3 — but see §3.2: +they are still welcome as regular issues or pull requests, just not +through the private security channel. --- ## 2. Trust Model -The core assumption is that Hermes is a **personal agent** with one trusted operator. +Hermes Agent is a single-tenant personal agent. Its posture is +layered, and the layers are not equally load-bearing. Reporters and +operators should reason about them in the same terms. -### Operator & Session Trust -- **Single Tenant:** The system protects the operator from LLM actions, not from malicious co-tenants. Multi-user isolation must happen at the OS/host level. -- **Gateway Security:** Authorized callers (Telegram, Discord, Slack, etc.) receive equal trust. Session keys are used for routing, not as authorization boundaries. -- **Execution:** Defaults to `terminal.backend: local` (direct host execution). Container isolation (Docker, Modal, Daytona) is opt-in for sandboxing. +### 2.1 Definitions -### Dangerous Command Approval -The approval system (`tools/approval.py`) is a core security boundary. Terminal commands, file operations, and other potentially destructive actions are gated behind explicit user confirmation before execution. The approval mode is configurable via `approvals.mode` in `config.yaml`: -- `"on"` (default) — prompts the user to approve dangerous commands. -- `"auto"` — auto-approves after a configurable delay. -- `"off"` — disables the gate entirely (break-glass; see Section 3). +- **Agent process.** The Python interpreter running Hermes Agent, + including any Python modules it has loaded (skills, plugins, + hook handlers). +- **Terminal backend.** A pluggable execution target for the + `terminal()` tool. The default runs commands directly on the host. + Other backends run commands inside a container, cloud sandbox, or + remote host. +- **Input surface.** Any channel through which content enters the + agent's context: operator input, web fetches, email, gateway + messages, file reads, MCP server responses, tool results. +- **Trust envelope.** The set of resources an operator has implicitly + granted Hermes Agent access to by running it — typically, whatever + the operator's own user account can reach on the host. +- **Stance.** An explicit statement in Hermes Agent's documentation + or code about how a consuming layer (adapter, UI, file writer, + shell) should treat agent output — e.g. "the dashboard renders + agent output as inert HTML." -### Output Redaction -`agent/redact.py` strips secret-like patterns (API keys, tokens, credentials) from all display output before it reaches the terminal or gateway platform. This prevents accidental credential leakage in chat logs, tool previews, and response text. Redaction operates on the display layer only — underlying values remain intact for internal agent operations. +### 2.2 The Boundary: OS-Level Isolation -### Skills vs. MCP Servers -- **Installed Skills:** High trust. Equivalent to local host code; skills can read environment variables and run arbitrary commands. -- **MCP Servers:** Lower trust. MCP subprocesses receive a filtered environment (`_build_safe_env()` in `tools/mcp_tool.py`) — only safe baseline variables (`PATH`, `HOME`, `XDG_*`) plus variables explicitly declared in the server's `env` config block are passed through. Host credentials are stripped by default. Additionally, packages invoked via `npx`/`uvx` are checked against the OSV malware database before spawning. +**The only security boundary against an adversarial LLM is the +operating system.** Nothing inside the agent process constitutes +containment — not the approval gate, not output redaction, not any +pattern scanner, not any tool allowlist. Any in-process component +that screens LLM output is a heuristic operating on an +attacker-influenced string, and this policy treats it as such. -### Code Execution Sandbox -The `execute_code` tool (`tools/code_execution_tool.py`) runs LLM-generated Python scripts in a child process with API keys and tokens stripped from the environment to prevent credential exfiltration. Only environment variables explicitly declared by loaded skills (via `env_passthrough`) or by the user in `config.yaml` (`terminal.env_passthrough`) are passed through. The child accesses Hermes tools via RPC, not direct API calls. +Hermes Agent supports two OS-level isolation postures. They address +different threats and an operator should choose deliberately. -### Subagents -- **No recursive delegation:** The `delegate_task` tool is disabled for child agents. -- **Depth limit:** `MAX_DEPTH = 2` — parent (depth 0) can spawn a child (depth 1); grandchildren are rejected. -- **Memory isolation:** Subagents run with `skip_memory=True` and do not have access to the parent's persistent memory provider. The parent receives only the task prompt and final response as an observation. +#### Terminal-backend isolation + +A non-default terminal backend runs LLM-emitted shell commands +inside a container, remote host, or cloud sandbox. The file tools +(`read_file`, `write_file`, `patch`) also run through this backend, +since they are implemented on top of the shell contract — they +cannot reach paths the backend doesn't expose. + +What this confines: anything the agent does by issuing shell or +file operations. What this does **not** confine: everything the +agent does in its own Python process. That includes the +code-execution tool (spawned as a host subprocess), MCP subprocesses +(spawned from the agent's environment), plugin loading, hook +dispatch, and skill loading (all imported into the agent +interpreter). + +Terminal-backend isolation is the right posture when the concern is +LLM-emitted destructive shell or unwanted file-tool writes, and the +operator is otherwise trusted. + +#### Whole-process wrapping + +Whole-process wrapping runs the entire agent process tree inside a +sandbox. Every code path — shell, code-execution, MCP, file tools, +plugins, hooks, skill loading — is subject to the same filesystem, +network, process, and (where applicable) inference policy. + +Hermes Agent supports this in two ways: + +- **Hermes Agent's own Docker image and Compose setup.** Lighter- + weight; the agent runs in a standard container with operator- + configured mounts and network policy. +- **[NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell)**. + OpenShell provides per-session sandboxes with declarative policy + across filesystem, network (L7 egress), process/syscall, and + inference-routing layers. Network and inference policies are + hot-reloadable. Credentials are injected from a Provider store + and never touch the sandbox filesystem. + +Under a whole-process wrapper, Hermes Agent's in-process heuristics +(§2.4) function as accident-prevention layered on top of a real +boundary. This is the supported posture when the agent ingests +content from surfaces the operator does not control — the open web, +inbound email, multi-user channels, untrusted MCP servers — and for +production or shared deployments. + +Operators running the default local backend with untrusted input +surfaces, or running a terminal-backend sandbox and expecting it to +contain code paths that don't go through the shell, are operating +outside the supported security posture. + +### 2.3 Credential Scoping + +Hermes Agent filters the environment it passes to its lower-trust +in-process components: shell subprocesses, MCP subprocesses, and +the code-execution child. Credentials like provider API keys and +gateway tokens are stripped by default; variables explicitly +declared by the operator or by a loaded skill are passed through. + +This reduces casual exfiltration. It is not containment. Any +component running inside the agent process (skills, plugins, hook +handlers) can read whatever the agent itself can read, including +in-memory credentials. The mitigation against a compromised +in-process component is operator review before install (§2.4, +§2.5), not environment scrubbing. + +### 2.4 In-Process Heuristics + +The following components screen or warn about LLM behavior. They +are useful. They are not boundaries. + +- The **approval gate** detects common destructive shell patterns + and prompts the operator before execution. Shell is Turing- + complete; a denylist over shell strings is structurally + incomplete. The gate catches cooperative-mode mistakes, not + adversarial output. +- **Output redaction** strips secret-like patterns from display. + A motivated output producer will defeat it. +- **Skills Guard** scans installable skill content for injection + patterns. It is a review aid; the boundary for third-party skills + is operator review before install. Reviewing a skill means + reading its Python code and scripts, not just its SKILL.md + description — skills execute arbitrary Python at import time. + +### 2.5 Plugin Trust Model + +Plugins load into the agent process and run with full agent +privileges: they can read the same credentials, call the same +tools, register the same hooks, and import the same modules as +anything shipped in-tree. The boundary for third-party plugins is +operator review before install — the same rule as skills (§2.4), +called out separately because plugins are architecturally heavier +and often ship their own background services, network listeners, +and dependencies. + +A malicious or buggy plugin is not a vulnerability in Hermes Agent +itself. Bugs in Hermes Agent's plugin-install or plugin-discovery +path that prevent the operator from seeing what they're installing +are in scope under §3.1. + +### 2.6 External Surfaces + +An **external surface** is any channel outside the local agent +process through which a caller can dispatch agent work, resolve +approvals, or receive agent output. Each surface has its own +authorization model, but the rules below apply uniformly. + +**Surfaces in Hermes Agent:** + +- **Gateway platform adapters.** Messaging integrations in + `gateway/platforms/` (Telegram, Discord, Slack, email, SMS, etc.) + and analogous adapters shipped as plugins. +- **Network-exposed HTTP surfaces.** The API server adapter, the + dashboard plugin, the kanban plugin's HTTP endpoints, and any + other plugin that binds a listening socket. +- **Editor / IDE adapters.** The ACP adapter (`acp_adapter/`) and + equivalent integrations that accept requests from a local client + process. +- **The TUI gateway (`tui_gateway/`).** JSON-RPC backend for the + Ink terminal UI, reached over local IPC. + +**Uniform rules:** + +1. **Authorization is required at every surface that crosses a + trust boundary.** For messaging and network HTTP surfaces, the + boundary is the network: authorization means an operator- + configured caller allowlist. For editor and local-IPC surfaces + (ACP, TUI gateway), the boundary is the host's user account: + authorization means relying on OS-level access control (file + permissions, loopback-only binds) and not exposing the surface + beyond the local user without an explicit network auth layer. +2. **An allowlist is required for every enabled network-exposed + adapter.** Adapters must refuse to dispatch agent work, resolve + approvals, or relay output until an allowlist is set. Code paths + that fail open when no allowlist is configured are code bugs in + scope under §3.1. +3. **Session identifiers are routing handles, not authorization + boundaries.** Knowing another caller's session ID does not grant + access to their approvals or output; authorization is always + re-checked against the allowlist (or OS-level equivalent). +4. **Within the authorized set, all callers are equally trusted.** + Hermes Agent does not model per-caller capabilities inside a + single adapter. Operators who need capability separation should + run separate agent instances with separate allowlists. +5. **Binding a local-only surface to a non-loopback interface is a + break-glass operator decision (§3.2).** The dashboard and other + plugin HTTP servers default to loopback; exposing them via + `--host 0.0.0.0` or equivalent makes public-exposure hardening + (§4) the operator's responsibility. --- -## 3. Out of Scope (Non-Vulnerabilities) +## 3. Scope -The following scenarios are **not** considered security breaches: -- **Prompt Injection:** Unless it results in a concrete bypass of the approval system, toolset restrictions, or container sandbox. -- **Public Exposure:** Deploying the gateway to the public internet without external authentication or network protection. -- **Trusted State Access:** Reports that require pre-existing write access to `~/.hermes/`, `.env`, or `config.yaml` (these are operator-owned files). -- **Default Behavior:** Host-level command execution when `terminal.backend` is set to `local` — this is the documented default, not a vulnerability. -- **Configuration Trade-offs:** Intentional break-glass settings such as `approvals.mode: "off"` or `terminal.backend: local` in production. -- **Tool-level read/access restrictions:** The agent has unrestricted shell access via the `terminal` tool by design. Reports that a specific tool (e.g., `read_file`) can access a resource are not vulnerabilities if the same access is available through `terminal`. Tool-level deny lists only constitute a meaningful security boundary when paired with equivalent restrictions on the terminal side (as with write operations, where `WRITE_DENIED_PATHS` is paired with the dangerous command approval system). +### 3.1 In Scope + +- Escape from a declared OS-level isolation posture (§2.2): an + attacker-controlled code path reaching state that the posture + claimed to confine. +- Unauthorized external-surface access: a caller outside the + configured authorization set (allowlist, or OS-level equivalent + for local-IPC surfaces) dispatching work, receiving output, or + resolving approvals (§2.6). +- Credential exfiltration: leakage of operator credentials or + session authorization material to a destination outside the + trust envelope, via a mechanism that should have prevented it + (environment scrubbing bug, adapter logging, transport error + that flushes credentials to an upstream, etc.). +- Trust-model documentation violations: code behaving contrary to + what this policy, Hermes Agent's own documentation, or reasonable + operator expectations would predict — including cases where + Hermes Agent has documented a stance about how its output should + be rendered by a consuming layer (dashboard, gateway adapter, + file writer, shell) and a code path breaks that stance. + +### 3.2 Out of Scope + +"Out of scope" here means "not a security vulnerability under this +policy." It does not mean "not worth reporting." Improvements to the +in-process heuristics, hardening ideas, and UX fixes are welcome as +regular issues or pull requests — the approval gate can always catch +more patterns, redaction can always get smarter, adapter behavior +can always be tightened. These items just don't go through the +private-disclosure channel and don't receive advisories. + +- **Bypasses of in-process heuristics (§2.4)** — approval-gate regex + bypasses, redaction bypasses, Skills Guard pattern bypasses, and + analogous reports against future heuristics. These components are + not boundaries; defeating them is not a vulnerability under this + policy. +- **Prompt injection per se.** Getting the LLM to emit unusual + output — via injected content, hallucination, training artifacts, + or any other cause — is not itself a vulnerability. "I achieved + prompt injection" without a chained §3.1 outcome is not an + actionable report under this policy. +- **Consequences of a chosen isolation posture.** Reports that a + code path operating within its posture's scope can do what that + posture permits are not vulnerabilities. Examples: shell or file + tools reaching host state under the local backend; code-execution + or MCP subprocesses reaching host state under terminal-backend + isolation that only sandboxes shell; reports whose preconditions + require pre-existing write access to operator-owned configuration + or credential files (those are already inside the trust envelope). +- **Documented break-glass settings.** Operator-selected trade-offs + that explicitly disable protections: `--insecure` and equivalent + flags on the dashboard or other components, disabled approvals, + local backend in production, development profiles that bypass + hermes-home security, and similar. Reports against those + configurations are not vulnerabilities — that's the flag's job. +- **Community-contributed skills and plugins.** Third-party skills + (including the community skills repository) and third-party + plugins are in the operator's review surface, not Hermes Agent's + trust surface (§2.4, §2.5). A skill or plugin doing something + malicious is the expected failure mode of one that wasn't + reviewed, not a vulnerability in Hermes Agent. Bugs in Hermes + Agent's skill-install or plugin-install path that prevent the + operator from seeing what they're installing are in scope under + §3.1. +- **Public exposure without external controls.** Exposing the + gateway or API to the public internet without authentication, + VPN, or firewall. +- **Tool-level read/write restrictions on a posture where shell is + permitted.** If a path is reachable via the terminal tool, reports + that other file tools can reach it add nothing. --- -## 4. Deployment Hardening & Best Practices +## 4. Deployment Hardening -### Filesystem & Network -- **Production sandboxing:** Use container backends (`docker`, `modal`, `daytona`) instead of `local` for untrusted workloads. -- **File permissions:** Run as non-root (the Docker image uses UID 10000); protect credentials with `chmod 600 ~/.hermes/.env` on local installs. -- **Network exposure:** Do not expose the gateway or API server to the public internet without VPN, Tailscale, or firewall protection. SSRF protection is enabled by default across all gateway platform adapters (Telegram, Discord, Slack, Matrix, Mattermost, etc.) with redirect validation. Note: the local terminal backend does not apply SSRF filtering, as it operates within the trusted operator's environment. +The single most important hardening decision is matching isolation +(§2.2) to the trust of the content the agent will ingest. Beyond +that: -### Skills & Supply Chain -- **Skill installation:** Review Skills Guard reports (`tools/skills_guard.py`) before installing third-party skills. The audit log at `~/.hermes/skills/.hub/audit.log` tracks every install and removal. -- **MCP safety:** OSV malware checking runs automatically for `npx`/`uvx` packages before MCP server processes are spawned. -- **CI/CD:** GitHub Actions are pinned to full commit SHAs. The `supply-chain-audit.yml` workflow blocks PRs containing `.pth` files or suspicious `base64`+`exec` patterns. - -### Credential Storage -- API keys and tokens belong exclusively in `~/.hermes/.env` — never in `config.yaml` or checked into version control. -- The credential pool system (`agent/credential_pool.py`) handles key rotation and fallback. Credentials are resolved from environment variables, not stored in plaintext databases. +- Run the agent as a non-root user. The supplied container image + does this by default. +- Keep credentials in the operator credential file with tight + permissions, never in the main config, never in version control. + Under OpenShell, use the Provider store rather than an on-disk + credential file. +- Do not expose the gateway or API to the public internet without + VPN, Tailscale, or firewall protection. Under OpenShell, use the + network policy layer to restrict egress. +- Configure a caller allowlist for every network-exposed adapter + you enable (§2.6). +- Review third-party skills and plugins before install (§2.4, + §2.5). For skills, this means reading the Python and scripts, + not just SKILL.md. Skills Guard reports and the install audit + log are the review surface. +- Hermes Agent includes supply-chain guards for MCP server + launches and for dependency / bundled-package changes in CI; see + `CONTRIBUTING.md` for specifics. --- -## 5. Disclosure Process +## 5. Disclosure -- **Coordinated Disclosure:** 90-day window or until a fix is released, whichever comes first. -- **Communication:** All updates occur via the GHSA thread or email correspondence with security@nousresearch.com. -- **Credits:** Reporters are credited in release notes unless anonymity is requested. +- **Coordinated disclosure window:** 90 days from report, or until a + fix is released, whichever comes first. +- **Channel:** the GHSA thread or email correspondence with + security@nousresearch.com. +- **Credit:** reporters are credited in release notes unless + anonymity is requested. diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index e7e53a6277b..31ae943a056 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -769,8 +769,8 @@ def _build_patch_mode_content(patch_text: str) -> List[Any]: old_chunks: list[str] = [] new_chunks: list[str] = [] for hunk in op.hunks: - old_lines = [line.content for line in hunk.lines if line.prefix in (" ", "-")] - new_lines = [line.content for line in hunk.lines if line.prefix in (" ", "+")] + old_lines = [line.content for line in hunk.lines if line.prefix in {" ", "-"}] + new_lines = [line.content for line in hunk.lines if line.prefix in {" ", "+"}] if old_lines or new_lines: old_chunks.append("\n".join(old_lines)) new_chunks.append("\n".join(new_lines)) diff --git a/agent/account_usage.py b/agent/account_usage.py index 0e9562dcc9e..be03646021e 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -47,7 +47,7 @@ def _title_case_slug(value: Optional[str]) -> Optional[str]: def _parse_dt(value: Any) -> Optional[datetime]: - if value in (None, ""): + if value in {None, ""}: return None if isinstance(value, (int, float)): return datetime.fromtimestamp(float(value), tz=timezone.utc) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index d9429c659f2..b4ce2da99d1 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1289,13 +1289,21 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: continue if name: seen_names.add(name) - result.append({ + anthropic_tool: Dict[str, Any] = { "name": name, "description": fn.get("description", ""), "input_schema": _normalize_tool_input_schema( fn.get("parameters", {"type": "object", "properties": {}}) ), - }) + } + # Forward cache_control marker when present on the OpenAI-format + # tool dict (set by ``mark_tools_for_long_lived_cache``). Anthropic's + # tools array supports cache_control on the last tool to cache the + # entire schema cross-session. + cache_control = t.get("cache_control") + if isinstance(cache_control, dict): + anthropic_tool["cache_control"] = dict(cache_control) + result.append(anthropic_tool) return result @@ -1537,7 +1545,7 @@ def convert_messages_to_anthropic( # downgraded to a spurious text block on the last assistant message. reasoning_content = m.get("reasoning_content") _already_has_thinking = any( - isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking") + isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"} for b in blocks ) if isinstance(reasoning_content, str) and not _already_has_thinking: @@ -1688,7 +1696,7 @@ def convert_messages_to_anthropic( if isinstance(m["content"], list): m["content"] = [ b for b in m["content"] - if not (isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking")) + if not (isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}) ] prev_blocks = fixed[-1]["content"] curr_blocks = m["content"] diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index fbd79989209..7b53566a927 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -175,7 +175,7 @@ def _normalize_aux_provider(provider: Optional[str]) -> str: # Resolve to the user's actual main provider so named custom providers # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly. main_prov = (_read_main_provider() or "").strip().lower() - if main_prov and main_prov not in ("auto", "main", ""): + if main_prov and main_prov not in {"auto", "main", ""}: normalized = main_prov else: return "custom" @@ -578,7 +578,7 @@ def _convert_content_for_responses(content: Any) -> Any: if detail: entry["detail"] = detail converted.append(entry) - elif ptype in ("input_text", "input_image"): + elif ptype in {"input_text", "input_image"}: # Already in Responses format — pass through converted.append(part) else: @@ -706,6 +706,16 @@ class _CodexCompletionsAdapter: close() except Exception: logger.debug("Codex auxiliary: client close during timeout failed", exc_info=True) + # The cached auxiliary client wraps this same ``self._client`` + # (or *is* a ``CodexAuxiliaryClient`` whose ``_real_client`` is + # this instance). After we close the httpx transport above, the + # cache must drop that entry — otherwise the next auxiliary call + # (compression retry, memory flush, etc.) reuses the dead client + # and fails fast with a connection error. See issue #23432. + try: + _evict_cached_client_instance(self._client) + except Exception: + logger.debug("Codex auxiliary: cache eviction on timeout failed", exc_info=True) def _check_cancelled() -> None: if deadline is not None and time.monotonic() >= deadline: @@ -788,7 +798,7 @@ class _CodexCompletionsAdapter: if item_type == "message": for part in (_item_get(item, "content") or []): ptype = _item_get(part, "type") - if ptype in ("output_text", "text"): + if ptype in {"output_text", "text"}: text_parts.append(_item_get(part, "text", "")) elif item_type == "function_call": tool_calls_raw.append(SimpleNamespace( @@ -890,6 +900,14 @@ class AsyncCodexAuxiliaryClient: self.chat = _AsyncCodexChatShim(async_adapter) self.api_key = sync_wrapper.api_key self.base_url = sync_wrapper.base_url + # Mirror the sync wrapper's _real_client so cache eviction by leaf + # OpenAI client (e.g. _close_client_on_timeout in #23482) drops + # this async entry too. Without this, sync and async cache entries + # diverge on poisoning: the sync entry is evicted but the async + # entry keeps reusing the closed transport, failing every + # subsequent async aux call with 'Connection error' until the + # gateway restarts. + self._real_client = sync_wrapper._real_client class _AnthropicCompletionsAdapter: @@ -1025,6 +1043,9 @@ class AsyncAnthropicAuxiliaryClient: self.chat = _AsyncAnthropicChatShim(async_adapter) self.api_key = sync_wrapper.api_key self.base_url = sync_wrapper.base_url + # See AsyncCodexAuxiliaryClient: mirror _real_client so cache + # eviction on a poisoned underlying client also drops this entry. + self._real_client = sync_wrapper._real_client def _endpoint_speaks_anthropic_messages(base_url: str) -> bool: @@ -1820,6 +1841,113 @@ def _get_provider_chain() -> List[tuple]: ] +# ── Auxiliary "recently 402'd" unhealthy-provider cache ──────────────────── +# +# When an auxiliary provider returns HTTP 402 (Payment Required / credit +# exhaustion), retrying it on every subsequent aux call is wasteful — the +# provider stays depleted for hours or days, but the chain re-tries it as +# the FIRST entry on every compression/title-gen/session-search call, +# burns ~1 RTT, gets 402 again, then falls back. On a long Discord/LCM +# session that adds up to dozens of doomed 402s. +# +# Solution: when ANY caller observes a payment error against a provider, +# mark it unhealthy for ``_AUX_UNHEALTHY_TTL_SECONDS``. ``_resolve_auto`` +# Step-2 and ``_try_payment_fallback`` both consult this cache and skip +# unhealthy entries (logging once per skip-reason so the user sees what +# happened). Entries auto-expire so a topped-up account recovers without +# manual intervention. +# +# Failure isolation: the cache is in-process only. A second hermes +# process won't inherit the unhealthy mark — that's intentional, since +# the user might be running two profiles with different OpenRouter keys. + +_AUX_UNHEALTHY_TTL_SECONDS = 600 # 10 minutes +_aux_unhealthy_until: Dict[str, float] = {} +_aux_unhealthy_logged_at: Dict[str, float] = {} + +# Map provider names that show up in resolved_provider / explicit-config +# back to the chain labels used by _get_provider_chain(). Keep in sync +# with the alias map in _try_payment_fallback below. +_AUX_UNHEALTHY_LABEL_ALIASES = { + "openrouter": "openrouter", + "nous": "nous", + "custom": "local/custom", + "local/custom": "local/custom", + "openai-codex": "openai-codex", + "codex": "openai-codex", +} + + +def _normalize_chain_label(provider: str) -> str: + """Normalize a resolved_provider value to a chain label used by + ``_get_provider_chain()``. Falls back to the lowercased input for + direct API-key providers (deepseek, alibaba, minimax, etc.) which + each report their own provider name from the api-key chain. + """ + if not provider: + return "" + p = str(provider).strip().lower() + return _AUX_UNHEALTHY_LABEL_ALIASES.get(p, p) + + +def _mark_provider_unhealthy(provider: str, ttl: Optional[float] = None) -> None: + """Mark ``provider`` as recently-402'd, hidden from chain iteration + until the TTL expires. Called from the payment-fallback branches in + ``call_llm`` and ``acall_llm`` after a confirmed payment error. + """ + label = _normalize_chain_label(provider) + if not label: + return + expires_at = time.time() + (ttl if ttl is not None else _AUX_UNHEALTHY_TTL_SECONDS) + _aux_unhealthy_until[label] = expires_at + logger.warning( + "Auxiliary: marking %s unhealthy for %ds (payment / credit error). " + "Subsequent auxiliary calls will skip it until %s.", + label, + int(ttl if ttl is not None else _AUX_UNHEALTHY_TTL_SECONDS), + time.strftime("%H:%M:%S", time.localtime(expires_at)), + ) + + +def _is_provider_unhealthy(label: str) -> bool: + """True iff ``label`` is in the unhealthy cache and the TTL hasn't expired. + Lazily evicts expired entries so the cache stays small. + """ + if not label: + return False + expires_at = _aux_unhealthy_until.get(label) + if expires_at is None: + return False + if time.time() >= expires_at: + _aux_unhealthy_until.pop(label, None) + _aux_unhealthy_logged_at.pop(label, None) + return False + return True + + +def _log_skip_unhealthy(label: str, task: Optional[str] = None) -> None: + """Emit a single info-level log per minute when we skip an unhealthy + provider. Avoids spamming the log on bursty sessions while still + giving the user a trail. + """ + now = time.time() + last = _aux_unhealthy_logged_at.get(label, 0.0) + if now - last >= 60: + _aux_unhealthy_logged_at[label] = now + expires_at = _aux_unhealthy_until.get(label, now) + logger.info( + "Auxiliary %s: skipping %s (recently returned payment error, retry in %ds)", + task or "call", label, max(0, int(expires_at - now)), + ) + + +def _reset_aux_unhealthy_cache() -> None: + """Clear the unhealthy cache. Used by tests and by a future explicit + user trigger (e.g. ``hermes config aux reset``).""" + _aux_unhealthy_until.clear() + _aux_unhealthy_logged_at.clear() + + def _is_payment_error(exc: Exception) -> bool: """Detect payment/credit/quota exhaustion errors. @@ -1832,7 +1960,7 @@ def _is_payment_error(exc: Exception) -> bool: err_lower = str(exc).lower() # OpenRouter and other providers include "credits" or "afford" in 402 bodies, # but sometimes wrap them in 429 or other codes. - if status in (402, 429, None): + if status in {402, 429, None}: if any(kw in err_lower for kw in ("credits", "insufficient funds", "can only afford", "billing", "payment required")): @@ -1984,6 +2112,41 @@ def _evict_cached_clients(provider: str) -> None: _client_cache.pop(key, None) +def _evict_cached_client_instance(target: Any) -> bool: + """Drop the cache entry whose stored client is *target*. + + Used when a specific cached client has been poisoned (closed httpx + transport after a timeout, broken streaming session, etc.) so the next + auxiliary call rebuilds rather than reusing the dead instance. + + Walks both sync and async wrappers (``CodexAuxiliaryClient``, + ``AnthropicAuxiliaryClient``, ``AsyncCodexAuxiliaryClient``, etc.) via + their ``_real_client`` attribute so a timeout that closes the underlying + ``OpenAI`` (or native provider) client evicts every cached shim that + exposed it. Async wrappers must mirror their sync sibling's + ``_real_client`` for this to work — otherwise the sync entry is evicted + but the async entry survives and keeps reusing the dead transport. + + Returns True when at least one entry was evicted. + """ + if target is None: + return False + evicted = False + with _client_cache_lock: + for key in list(_client_cache.keys()): + entry = _client_cache.get(key) + if entry is None: + continue + cached = entry[0] + if cached is None: + continue + real = getattr(cached, "_real_client", None) + if cached is target or real is target: + del _client_cache[key] + evicted = True + return evicted + + def _pool_cache_hint( provider: str, *, @@ -1994,7 +2157,7 @@ def _pool_cache_hint( if normalized == "auto": runtime = _normalize_main_runtime(main_runtime) normalized = _normalize_aux_provider(runtime.get("provider") or _read_main_provider()) - if normalized in ("", "auto", "custom"): + if normalized in {"", "auto", "custom"}: return "" entry = _peek_pool_entry(normalized) if entry is None: @@ -2016,7 +2179,7 @@ def _pool_error_context(exc: Exception) -> Dict[str, Any]: def _recoverable_pool_provider(resolved_provider: str, client: Any) -> Optional[str]: """Infer which provider pool can recover the current auxiliary client.""" normalized = _normalize_aux_provider(resolved_provider) - if normalized not in ("", "auto", "custom"): + if normalized not in {"", "auto", "custom"}: return normalized base = str(getattr(client, "base_url", "") or "") if base_url_host_matches(base, "chatgpt.com"): @@ -2261,6 +2424,10 @@ def _try_payment_fallback( for label, try_fn in _get_provider_chain(): if label in skip_chain_labels: continue + if _is_provider_unhealthy(label): + _log_skip_unhealthy(label, task) + tried.append(f"{label} (unhealthy)") + continue client, model = try_fn() if client is not None: logger.info( @@ -2329,7 +2496,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option main_provider = runtime_provider or _read_main_provider() main_model = runtime_model or _read_main_model() if (main_provider and main_model - and main_provider not in ("auto", "")): + and main_provider not in {"auto", ""}): resolved_provider = main_provider explicit_base_url = None explicit_api_key = None @@ -2337,21 +2504,34 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option resolved_provider = "custom" explicit_base_url = runtime_base_url explicit_api_key = runtime_api_key or None - client, resolved = resolve_provider_client( - resolved_provider, - main_model, - explicit_base_url=explicit_base_url, - explicit_api_key=explicit_api_key, - api_mode=runtime_api_mode or None, - ) - if client is not None: - logger.info("Auxiliary auto-detect: using main provider %s (%s)", - main_provider, resolved or main_model) - return client, resolved or main_model + # Skip Step-1 if the main provider was recently 402'd. The unhealthy + # cache TTL bounds how long we bypass it, so a topped-up account + # recovers automatically. If we tried Step-1 anyway, every aux call + # on a depleted main provider would pay one doomed 402 RTT before + # falling to Step-2. + main_chain_label = _normalize_chain_label(resolved_provider) + if main_chain_label and _is_provider_unhealthy(main_chain_label): + _log_skip_unhealthy(main_chain_label) + else: + client, resolved = resolve_provider_client( + resolved_provider, + main_model, + explicit_base_url=explicit_base_url, + explicit_api_key=explicit_api_key, + api_mode=runtime_api_mode or None, + ) + if client is not None: + logger.info("Auxiliary auto-detect: using main provider %s (%s)", + main_provider, resolved or main_model) + return client, resolved or main_model # ── Step 2: aggregator / fallback chain ────────────────────────────── tried = [] for label, try_fn in _get_provider_chain(): + if _is_provider_unhealthy(label): + _log_skip_unhealthy(label) + tried.append(f"{label} (unhealthy)") + continue client, model = try_fn() if client is not None: if tried: @@ -2977,7 +3157,7 @@ def resolve_provider_client( return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) - elif pconfig.auth_type in ("oauth_device_code", "oauth_external"): + elif pconfig.auth_type in {"oauth_device_code", "oauth_external"}: # OAuth providers — route through their specific try functions if provider == "nous": return resolve_provider_client("nous", model, async_mode) @@ -3086,7 +3266,7 @@ def get_available_vision_backends() -> List[str]: available: List[str] = [] # 1. Active provider — if the user configured a provider, try it first. main_provider = _read_main_provider() - if main_provider and main_provider not in ("auto", ""): + if main_provider and main_provider not in {"auto", ""}: if main_provider in _VISION_AUTO_PROVIDER_ORDER: if _strict_vision_backend_available(main_provider): available.append(main_provider) @@ -3132,7 +3312,7 @@ def resolve_vision_provider_client( if resolved_base_url: provider_for_base_override = ( - requested if requested and requested not in ("", "auto") else "custom" + requested if requested and requested not in {"", "auto"} else "custom" ) client, final_model = resolve_provider_client( provider_for_base_override, @@ -3160,7 +3340,7 @@ def resolve_vision_provider_client( # 4. Stop main_provider = _read_main_provider() main_model = _read_main_model() - if main_provider and main_provider not in ("auto", ""): + if main_provider and main_provider not in {"auto", ""}: vision_model = _PROVIDER_VISION_MODELS.get(main_provider, main_model) if main_provider == "nous": sync_client, default_model = _resolve_strict_vision_backend( @@ -3966,7 +4146,7 @@ def call_llm( # credentials were found, fail fast instead of silently routing # through OpenRouter (which causes confusing 404s). _explicit = (resolved_provider or "").strip().lower() - if _explicit and _explicit not in ("auto", "openrouter", "custom"): + if _explicit and _explicit not in {"auto", "openrouter", "custom"}: raise RuntimeError( f"Provider '{_explicit}' is set in config.yaml but no API key " f"was found. Set the {_explicit.upper()}_API_KEY environment " @@ -4096,7 +4276,7 @@ def call_llm( # ── Auth refresh retry ─────────────────────────────────────── if (_is_auth_error(first_err) - and resolved_provider not in ("auto", "", None) + and resolved_provider not in {"auto", "", None} and not client_is_nous): if _refresh_provider_credentials(resolved_provider): logger.info( @@ -4179,10 +4359,17 @@ def call_llm( # Only try alternative providers when the user didn't explicitly # configure this task's provider. Explicit provider = hard constraint; # auto (the default) = best-effort fallback chain. (#7559) - is_auto = resolved_provider in ("auto", "", None) + is_auto = resolved_provider in {"auto", "", None} if should_fallback and is_auto: if _is_payment_error(first_err): reason = "payment error" + # Resolve the actual provider label (resolved_provider may be + # "auto"; the client's base_url tells us which backend got the + # 402). Mark THAT label unhealthy so subsequent aux calls + # skip it instead of paying another doomed RTT. + _mark_provider_unhealthy( + _recoverable_pool_provider(resolved_provider, client) or resolved_provider + ) elif _is_rate_limit_error(first_err): reason = "rate limit" else: @@ -4200,6 +4387,17 @@ def call_llm( base_url=str(getattr(fb_client, "base_url", "") or "")) return _validate_llm_response( fb_client.chat.completions.create(**fb_kwargs), task) + # Connection/timeout errors leave the cached client poisoned (closed + # httpx transport, half-read stream, dead async loop). Drop it from + # the cache regardless of whether we found a fallback above so the + # next auxiliary call rebuilds a fresh client instead of reusing the + # dead one. See issue #23432. + if _is_connection_error(first_err): + try: + _evict_cached_client_instance(client) + except Exception: + logger.debug("Auxiliary: cache eviction after connection error failed", + exc_info=True) raise @@ -4317,7 +4515,7 @@ async def async_call_llm( ) if client is None: _explicit = (resolved_provider or "").strip().lower() - if _explicit and _explicit not in ("auto", "openrouter", "custom"): + if _explicit and _explicit not in {"auto", "openrouter", "custom"}: raise RuntimeError( f"Provider '{_explicit}' is set in config.yaml but no API key " f"was found. Set the {_explicit.upper()}_API_KEY environment " @@ -4428,7 +4626,7 @@ async def async_call_llm( # ── Auth refresh retry (mirrors sync call_llm) ─────────────── if (_is_auth_error(first_err) - and resolved_provider not in ("auto", "", None) + and resolved_provider not in {"auto", "", None} and not client_is_nous): if _refresh_provider_credentials(resolved_provider): logger.info( @@ -4490,10 +4688,13 @@ async def async_call_llm( or _is_connection_error(first_err) or _is_rate_limit_error(first_err) ) - is_auto = resolved_provider in ("auto", "", None) + is_auto = resolved_provider in {"auto", "", None} if should_fallback and is_auto: if _is_payment_error(first_err): reason = "payment error" + _mark_provider_unhealthy( + _recoverable_pool_provider(resolved_provider, client) or resolved_provider + ) elif _is_rate_limit_error(first_err): reason = "rate limit" else: @@ -4517,4 +4718,12 @@ async def async_call_llm( fb_kwargs["model"] = async_fb_model return _validate_llm_response( await async_fb.chat.completions.create(**fb_kwargs), task) + # Mirror the sync path: drop poisoned clients on connection/timeout + # so the next aux call rebuilds. See issue #23432. + if _is_connection_error(first_err): + try: + _evict_cached_client_instance(client) + except Exception: + logger.debug("Auxiliary (async): cache eviction after connection error failed", + exc_info=True) raise diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 885b0ca7895..d16236737c4 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -167,7 +167,7 @@ def _strip_image_parts_from_parts(parts: Any) -> Any: out.append(part) continue ptype = part.get("type") - if ptype in ("image", "image_url", "input_image"): + if ptype in {"image", "image_url", "input_image"}: had_image = True out.append({"type": "text", "text": "[screenshot removed to save context]"}) else: @@ -274,8 +274,8 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> mode = args.get("mode", "replace") return f"[patch] {mode} in {path} ({content_len:,} chars result)" - if tool_name in ("browser_navigate", "browser_click", "browser_snapshot", - "browser_type", "browser_scroll", "browser_vision"): + if tool_name in {"browser_navigate", "browser_click", "browser_snapshot", + "browser_type", "browser_scroll", "browser_vision"}: url = args.get("url", "") ref = args.get("ref", "") detail = f" {url}" if url else (f" ref={ref}" if ref else "") @@ -304,7 +304,7 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> code_preview += "..." return f"[execute_code] `{code_preview}` ({line_count} lines output)" - if tool_name in ("skill_view", "skills_list", "skill_manage"): + if tool_name in {"skill_view", "skills_list", "skill_manage"}: name = args.get("name", "?") return f"[{tool_name}] name={name} ({content_len:,} chars)" @@ -979,13 +979,13 @@ The user has requested that this compaction PRIORITISE preserving all informatio _status = getattr(e, "status_code", None) or getattr(getattr(e, "response", None), "status_code", None) _err_str = str(e).lower() _is_model_not_found = ( - _status in (404, 503) + _status in {404, 503} or "model_not_found" in _err_str or "does not exist" in _err_str or "no available channel" in _err_str ) _is_timeout = ( - _status in (408, 429, 502, 504) + _status in {408, 429, 502, 504} or "timeout" in _err_str ) # Non-JSON / malformed-body responses from misconfigured providers @@ -1316,8 +1316,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio # Ensure we protect at least min_tail messages fallback_cut = n - min_tail - if cut_idx > fallback_cut: - cut_idx = fallback_cut + cut_idx = min(cut_idx, fallback_cut) # If the token budget would protect everything (small conversations), # force a cut after the head so compression can still remove middle turns. @@ -1480,7 +1479,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio first_tail_role = messages[compress_end].get("role", "user") if compress_end < n_messages else "user" # Pick a role that avoids consecutive same-role with both neighbors. # Priority: avoid colliding with head (already committed), then tail. - if last_head_role in ("assistant", "tool"): + if last_head_role in {"assistant", "tool"}: summary_role = "user" else: summary_role = "assistant" diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 0043c70ca29..aeda76225c8 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -149,7 +149,7 @@ class PooledCredential: } result: Dict[str, Any] = {} for field_def in fields(self): - if field_def.name in ("provider", "extra"): + if field_def.name in {"provider", "extra"}: continue value = getattr(self, field_def.name) if value is not None or field_def.name in _ALWAYS_EMIT: diff --git a/agent/curator.py b/agent/curator.py index f9c10d05656..d0147d4c4fb 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -899,9 +899,12 @@ def _build_rename_summary( • flaky-thing — pruned (stale) • old-utility → spreadsheet-ops full report: hermes curator status + keep an umbrella stable: hermes curator pin document-tools Cap is 10 entries so a 50-skill consolidation doesn't blow up - agent.log; the full list is always in REPORT.md. + agent.log; the full list is always in REPORT.md. The pin hint only + appears when at least one consolidation produced an umbrella worth + pinning (pruned-only runs skip it). """ after_by_name = {r.get("name"): r for r in after_report if isinstance(r, dict)} after_names = set(after_by_name.keys()) @@ -950,6 +953,17 @@ def _build_rename_summary( if total > SHOW: lines.append(f" … and {total - SHOW} more") lines.append("full report: hermes curator status") + # Pin hint — only surface it when there's actually a destination skill + # worth pinning. The umbrella skills that absorbed content are the natural + # candidates: pinning one tells future curator runs to leave it alone. + # Pruned-only runs don't get this hint (nothing surviving to pin). + if consolidated: + umbrellas = sorted({e.get("into") for e in consolidated if e.get("into")}) + if umbrellas: + example = umbrellas[0] + lines.append( + f"keep an umbrella stable: hermes curator pin {example}" + ) return "\n".join(lines) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 1a42a9589ee..d29a2e34ac6 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -83,7 +83,7 @@ class ClassifiedError: @property def is_auth(self) -> bool: - return self.reason in (FailoverReason.auth, FailoverReason.auth_permanent) + return self.reason in {FailoverReason.auth, FailoverReason.auth_permanent} @@ -688,10 +688,10 @@ def _classify_by_status( result_fn=result_fn, ) - if status_code in (500, 502): + if status_code in {500, 502}: return result_fn(FailoverReason.server_error, retryable=True) - if status_code in (503, 529): + if status_code in {503, 529}: return result_fn(FailoverReason.overloaded, retryable=True) # Other 4xx — non-retryable @@ -810,7 +810,7 @@ def _classify_400( # Responses API (and some providers) use flat body: {"message": "..."} if not err_body_msg: err_body_msg = str(body.get("message") or "").strip().lower() - is_generic = len(err_body_msg) < 30 or err_body_msg in ("error", "") + is_generic = len(err_body_msg) < 30 or err_body_msg in {"error", ""} # Absolute token/message-count thresholds are only a proxy for smaller # context windows. Large-context sessions can have many messages while # still being far below their actual token budget. @@ -841,14 +841,14 @@ def _classify_by_error_code( """Classify by structured error codes from the response body.""" code_lower = error_code.lower() - if code_lower in ("resource_exhausted", "throttled", "rate_limit_exceeded"): + if code_lower in {"resource_exhausted", "throttled", "rate_limit_exceeded"}: return result_fn( FailoverReason.rate_limit, retryable=True, should_rotate_credential=True, ) - if code_lower in ("insufficient_quota", "billing_not_active", "payment_required"): + if code_lower in {"insufficient_quota", "billing_not_active", "payment_required"}: return result_fn( FailoverReason.billing, retryable=False, @@ -856,14 +856,14 @@ def _classify_by_error_code( should_fallback=True, ) - if code_lower in ("model_not_found", "model_not_available", "invalid_model"): + if code_lower in {"model_not_found", "model_not_available", "invalid_model"}: return result_fn( FailoverReason.model_not_found, retryable=False, should_fallback=True, ) - if code_lower in ("context_length_exceeded", "max_tokens_exceeded"): + if code_lower in {"context_length_exceeded", "max_tokens_exceeded"}: return result_fn( FailoverReason.context_overflow, retryable=True, diff --git a/agent/gemini_cloudcode_adapter.py b/agent/gemini_cloudcode_adapter.py index 64c51cf9d81..5bc42e3aad7 100644 --- a/agent/gemini_cloudcode_adapter.py +++ b/agent/gemini_cloudcode_adapter.py @@ -77,7 +77,7 @@ def _coerce_content_to_text(content: Any) -> str: if p.get("type") == "text" and isinstance(p.get("text"), str): pieces.append(p["text"]) # Multimodal (image_url, etc.) — stub for now; log and skip - elif p.get("type") in ("image_url", "input_audio"): + elif p.get("type") in {"image_url", "input_audio"}: logger.debug("Dropping multimodal part (not yet supported): %s", p.get("type")) return "\n".join(pieces) return str(content) diff --git a/agent/gemini_native_adapter.py b/agent/gemini_native_adapter.py index 2416a6bc891..b0d903372cd 100644 --- a/agent/gemini_native_adapter.py +++ b/agent/gemini_native_adapter.py @@ -945,6 +945,12 @@ class AsyncGeminiNativeClient: self.api_key = sync_client.api_key self.base_url = sync_client.base_url self.chat = _AsyncGeminiChatNamespace(self) + # Expose the underlying sync client as _real_client so the auxiliary + # cache's eviction-by-leaf-client helper (#23482) can find and drop + # this async entry when the sync GeminiNativeClient is poisoned. + # GeminiNativeClient is itself the leaf (no OpenAI client beneath + # it), so we point at the sync_client directly. + self._real_client = sync_client async def _create_chat_completion(self, **kwargs: Any) -> Any: stream = bool(kwargs.get("stream")) diff --git a/agent/i18n.py b/agent/i18n.py index 0196439bb4e..034fb747b6b 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -39,20 +39,45 @@ from typing import Any logger = logging.getLogger(__name__) -SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es", "fr", "tr", "uk") +SUPPORTED_LANGUAGES: tuple[str, ...] = ( + "en", "zh", "zh-hant", "ja", "de", "es", "fr", "tr", "uk", + "af", "ko", "it", "ga", "pt", "ru", "hu", +) DEFAULT_LANGUAGE = "en" # Accept a few natural aliases so users who type "chinese" / "zh-CN" / "jp" # get the right catalog instead of silently falling back to English. _LANGUAGE_ALIASES: dict[str, str] = { "english": "en", "en-us": "en", "en-gb": "en", - "chinese": "zh", "mandarin": "zh", "zh-cn": "zh", "zh-tw": "zh", "zh-hans": "zh", "zh-hant": "zh", + # Simplified Chinese — explicit codes route here; bare "chinese" / "mandarin" + # also default to Simplified since that's the larger user base. + "chinese": "zh", "mandarin": "zh", "zh-cn": "zh", "zh-hans": "zh", "zh-sg": "zh", + # Traditional Chinese — distinct catalog. Cover Taiwan / Hong Kong / Macau + # locale tags plus the common "traditional" alias. + "traditional-chinese": "zh-hant", "traditional_chinese": "zh-hant", + "zh-tw": "zh-hant", "zh-hk": "zh-hant", "zh-mo": "zh-hant", "japanese": "ja", "jp": "ja", "ja-jp": "ja", - "german": "de", "deutsch": "de", "de-de": "de", - "spanish": "es", "español": "es", "espanol": "es", "es-es": "es", "es-mx": "es", + "german": "de", "deutsch": "de", "de-de": "de", "de-at": "de", "de-ch": "de", + "spanish": "es", "español": "es", "espanol": "es", "es-es": "es", "es-mx": "es", "es-ar": "es", "french": "fr", "français": "fr", "france": "fr", "fr-fr": "fr", "fr-be": "fr", "fr-ca": "fr", "fr-ch": "fr", "ukrainian": "uk", "ukrainisch": "uk", "українська": "uk", "uk-ua": "uk", "ua": "uk", "turkish": "tr", "türkçe": "tr", "tr-tr": "tr", + # Afrikaans — South African Dutch-derived language; "af-ZA" is the common BCP-47 tag. + "afrikaans": "af", "af-za": "af", + # Korean + "korean": "ko", "한국어": "ko", "ko-kr": "ko", + # Italian + "italian": "it", "italiano": "it", "it-it": "it", "it-ch": "it", + # Irish (Gaeilge) — ga is the BCP-47 code + "irish": "ga", "gaeilge": "ga", "ga-ie": "ga", + # Portuguese — bare "portuguese" routes to European Portuguese; pt-br + # is in the same family but rendered identically here (no separate br catalog). + "portuguese": "pt", "português": "pt", "portugues": "pt", + "pt-pt": "pt", "pt-br": "pt", "brazilian": "pt", "brasileiro": "pt", + # Russian + "russian": "ru", "русский": "ru", "ru-ru": "ru", + # Hungarian + "hungarian": "hu", "magyar": "hu", "hu-hu": "hu", } _catalog_cache: dict[str, dict[str, str]] = {} diff --git a/agent/image_routing.py b/agent/image_routing.py index 0b6687787a0..d5247ab222f 100644 --- a/agent/image_routing.py +++ b/agent/image_routing.py @@ -76,7 +76,7 @@ def _explicit_aux_vision_override(cfg: Optional[Dict[str, Any]]) -> bool: base_url = str(vision.get("base_url") or "").strip() # "auto" / "" / blank = not explicit - if provider in ("", "auto") and not model and not base_url: + if provider in {"", "auto"} and not model and not base_url: return False return True @@ -163,7 +163,7 @@ def _sniff_mime_from_bytes(raw: bytes) -> Optional[str]: if raw.startswith(b"\xff\xd8\xff"): return "image/jpeg" # GIF87a / GIF89a - if raw[:6] in (b"GIF87a", b"GIF89a"): + if raw[:6] in {b"GIF87a", b"GIF89a"}: return "image/gif" # WEBP: "RIFF" .... "WEBP" if len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP": @@ -172,9 +172,9 @@ def _sniff_mime_from_bytes(raw: bytes) -> Optional[str]: if raw.startswith(b"BM"): return "image/bmp" # HEIC/HEIF: ftypheic / ftypheix / ftypmif1 / ftypmsf1 etc. - if len(raw) >= 12 and raw[4:8] == b"ftyp" and raw[8:12] in ( + if len(raw) >= 12 and raw[4:8] == b"ftyp" and raw[8:12] in { b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim", b"heis", - ): + }: return "image/heic" return None diff --git a/agent/markdown_tables.py b/agent/markdown_tables.py new file mode 100644 index 00000000000..13c7cd1df0c --- /dev/null +++ b/agent/markdown_tables.py @@ -0,0 +1,170 @@ +"""CJK/wide-character-aware re-alignment of model-emitted markdown tables. + +Models pad markdown tables assuming each character occupies one terminal +cell. CJK glyphs and most emoji render as two cells, so the model's +spacing collapses into drift the moment a table reaches a real terminal — +header pipes line up, every body row drifts right by N cells per CJK +char. + +This module rebuilds row padding using ``wcwidth.wcswidth`` (display +columns), preserving the table's pipes and dashes so it still reads as a +plain-text table in ``strip`` / unrendered display modes. Standard Rich +markdown rendering already aligns CJK correctly inside a wide enough +panel; this helper is for the paths that print the model's text more or +less verbatim. + +The helper is deliberately conservative: + +* Only contiguous ``| ... |`` blocks with a divider line are rewritten. +* Anything that does not look like a table is passed through unchanged. +* Single-line / mid-stream fragments are left alone — callers buffer + table rows and flush them once the block is complete. + +There is a small, intentional caveat: ``wcwidth`` returns ``-1`` for some +emoji-with-variation-selector sequences (e.g. ``⚠️``); we clamp those to +0 so they do not corrupt the column width math. The 1-cell drift on +those specific glyphs is preferable to silently widening every table +that contains one. +""" + +from __future__ import annotations + +import re +from typing import List + +from wcwidth import wcswidth + +__all__ = [ + "is_table_divider", + "looks_like_table_row", + "realign_markdown_tables", + "split_table_row", +] + + +_DIVIDER_CELL_RE = re.compile(r"^\s*:?-{3,}:?\s*$") +_MIN_COL_WIDTH = 3 # matches the divider's minimum dash run. + + +def _disp_width(s: str) -> int: + """``wcswidth`` clamped to a non-negative integer. + + ``wcswidth`` returns ``-1`` when it encounters a control char or an + unknown sequence; treat those as zero-width rather than letting a + negative number flow into ``max`` and break the column-width math. + """ + + w = wcswidth(s) + return w if w > 0 else 0 + + +def _pad_to_width(s: str, target: int) -> str: + return s + " " * max(0, target - _disp_width(s)) + + +def split_table_row(row: str) -> List[str]: + """Split ``| a | b | c |`` into ``["a", "b", "c"]`` with trims.""" + + s = row.strip() + if s.startswith("|"): + s = s[1:] + if s.endswith("|"): + s = s[:-1] + return [c.strip() for c in s.split("|")] + + +def is_table_divider(row: str) -> bool: + """True when ``row`` is a markdown table separator line.""" + + cells = split_table_row(row) + return len(cells) > 1 and all(_DIVIDER_CELL_RE.match(c) for c in cells) + + +def looks_like_table_row(row: str) -> bool: + """True when ``row`` could plausibly be a markdown table row. + + Used by streaming callers to decide whether to buffer an in-flight + line. We are intentionally permissive here — the realigner itself + only rewrites blocks that are accompanied by a divider, so a false + positive here at most delays the print of one line. + """ + + if "|" not in row: + return False + stripped = row.strip() + if not stripped: + return False + # A leading pipe is the strongest signal; without it we still allow + # rows with at least two pipes so models that omit the leading pipe + # don't slip past us. + if stripped.startswith("|"): + return True + return stripped.count("|") >= 2 + + +def _render_block(rows: List[List[str]]) -> List[str]: + """Render ``rows`` (header + body, divider implied) at uniform widths.""" + + ncols = max(len(r) for r in rows) + rows = [r + [""] * (ncols - len(r)) for r in rows] + + widths = [ + max(_MIN_COL_WIDTH, *(_disp_width(r[c]) for r in rows)) + for c in range(ncols) + ] + + def _row(cells: List[str]) -> str: + return ( + "| " + + " | ".join(_pad_to_width(c, widths[k]) for k, c in enumerate(cells)) + + " |" + ) + + out = [_row(rows[0])] + out.append("|" + "|".join("-" * (w + 2) for w in widths) + "|") + for r in rows[1:]: + out.append(_row(r)) + return out + + +def realign_markdown_tables(text: str) -> str: + """Rewrite every ``| ... |`` + divider block with wcwidth-aware padding. + + Lines that are not part of a recognised table are returned verbatim, + so this is safe to apply to arbitrary assistant prose. + """ + + if "|" not in text: + return text + + lines = text.split("\n") + out: List[str] = [] + i = 0 + n = len(lines) + + while i < n: + line = lines[i] + # A table starts with a header row whose next line is a divider. + if ( + "|" in line + and i + 1 < n + and is_table_divider(lines[i + 1]) + ): + header = split_table_row(line) + body: List[List[str]] = [] + j = i + 2 + while j < n and "|" in lines[j] and lines[j].strip(): + if is_table_divider(lines[j]): + j += 1 + continue + body.append(split_table_row(lines[j])) + j += 1 + + if any(c for c in header) or body: + out.extend(_render_block([header] + body)) + i = j + continue + out.append(line) + i += 1 + + return "\n".join(out) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 1319681d3b1..7eda64fba4d 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -470,11 +470,11 @@ class MemoryManager: accepted = [ p for p in params - if p.kind in ( + if p.kind in { inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, - ) + } ] if len(accepted) >= 4: return "positional" diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 956d6b93095..12d5f4170bf 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -244,6 +244,44 @@ DEFAULT_CONTEXT_LENGTHS = { "zai-org/GLM-5": 202752, } +# xAI Grok models that ACCEPT the `reasoning.effort` parameter on +# api.x.ai. Verified live against /v1/responses 2026-05-10: +# +# ACCEPTS effort: grok-3-mini, grok-3-mini-fast, grok-4.20-multi-agent-0309, +# grok-4.3 +# REJECTS effort: grok-3, grok-4, grok-4-0709, grok-4-fast-(non-)reasoning, +# grok-4-1-fast-(non-)reasoning, grok-4.20-0309-(non-)reasoning, +# grok-code-fast-1 +# +# REJECTS-side models still reason natively — they just don't expose an +# effort dial — so callers should send no `reasoning` key at all rather +# than a default `medium` (which 400s with "Model X does not support +# parameter reasoningEffort"). +_GROK_EFFORT_CAPABLE_PREFIXES = ( + "grok-3-mini", + "grok-4.20-multi-agent", + "grok-4.3", +) + + +def grok_supports_reasoning_effort(model: str) -> bool: + """Return True when an xAI Grok model accepts ``reasoning.effort``. + + Allowlist by substring (matches both bare ``grok-3-mini`` and + aggregator-prefixed ``x-ai/grok-3-mini``). Conservative by design: + if a future Grok model isn't listed, we send no effort dial rather + than 400. + """ + name = (model or "").strip().lower() + if not name: + return False + # Strip common aggregator prefixes (x-ai/, openrouter/x-ai/, xai/, ...) + for sep in ("/",): + if sep in name: + name = name.rsplit(sep, 1)[-1] + return any(name.startswith(prefix) for prefix in _GROK_EFFORT_CAPABLE_PREFIXES) + + _CONTEXT_LENGTH_KEYS = ( "context_length", "context_window", @@ -533,7 +571,7 @@ def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]: pricing: Dict[str, Any] = {} for target, aliases in alias_map.items(): for alias in aliases: - if alias in normalized and normalized[alias] not in (None, ""): + if alias in normalized and normalized[alias] not in {None, ""}: pricing[target] = normalized[alias] break if pricing: @@ -968,6 +1006,79 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option return None +def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Optional[int]: + """Query an Ollama server's native ``/api/show`` for context length. + + Provider-agnostic: works against ANY Ollama-compatible server regardless + of hostname — local Ollama, Ollama Cloud (``ollama.com``), custom Ollama + hosting behind a reverse proxy, etc. For non-Ollama servers the POST + returns 404/405 quickly; the function handles errors gracefully. + + For hosted servers the GGUF ``model_info.*.context_length`` is the + authoritative source: the user can't set their own ``num_ctx``, and the + OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length`` + per the OpenAI schema. + + Resolution order for hosted Ollama: + 1. ``model_info.*.context_length`` — GGUF training max (authoritative) + 2. ``parameters`` → ``num_ctx`` — server-side Modelfile override + The order is flipped vs ``query_ollama_num_ctx()`` because local users + control ``num_ctx`` themselves; hosted users can't. + """ + import httpx + + server_url = base_url.rstrip("/") + if server_url.endswith("/v1"): + server_url = server_url[:-3] + + headers = _auth_headers(api_key) + + try: + with httpx.Client(timeout=5.0, headers=headers) as client: + resp = client.post(f"{server_url}/api/show", json={"name": model}) + if resp.status_code != 200: + return None + data = resp.json() + + # Hosted Ollama: GGUF model_info is the real max — prefer it over + # num_ctx which the Cloud operator may have capped arbitrarily. + model_info = data.get("model_info", {}) + for key, value in model_info.items(): + if "context_length" in key and isinstance(value, (int, float)): + ctx = int(value) + if ctx >= 1024: + return ctx + + # Fall back to num_ctx from Modelfile parameters (rare on Cloud) + params = data.get("parameters", "") + if "num_ctx" in params: + for line in params.split("\n"): + if "num_ctx" in line: + parts = line.strip().split() + if len(parts) >= 2: + try: + ctx = int(parts[-1]) + if ctx >= 1024: + return ctx + except ValueError: + pass + except Exception: + pass + return None + + +def _model_name_suggests_kimi(model: str) -> bool: + """Return True if the model name looks like a Kimi-family model. + + Catches ``kimi-k2.6``, ``kimi-k2.5``, ``kimi-k2-thinking``, + ``moonshotai/Kimi-K2.6``, and similar variants. Used as a guard + against stale OpenRouter metadata that underreports these models + as 32K context when they actually support 262K+. + """ + lower = model.lower() + return lower.startswith("kimi") or "moonshot" in lower + + def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]: """Query a local server for the model's context length.""" import httpx @@ -1269,12 +1380,17 @@ def get_model_context_length( 2. Active endpoint metadata (/models for explicit custom endpoints) 3. Local server query (for local endpoints) 4. Anthropic /v1/models API (API-key users only, not OAuth) - 5. OpenRouter live API metadata - 6. Nous suffix-match via OpenRouter cache - 7. models.dev registry lookup (provider-aware) - 8. Thin hardcoded defaults (broad family patterns) - 9. Default fallback (256K) - """ + 5. Provider-aware lookups (before generic OpenRouter cache): + a. Copilot live /models API + b. Nous suffix-match via OpenRouter cache + c. Codex OAuth /models probe + d. GMI /models endpoint + e. Ollama native /api/show probe (any base_url, provider-agnostic) + f. models.dev registry lookup (with :cloud/-cloud suffix fallback) + 6. OpenRouter live API metadata (Kimi-family 32k guard) + 7. Hardcoded defaults (broad family patterns, longest-key-first) + 8. Local server query (last resort) + 9. Default fallback (256K)""" # 0. Explicit config override — user knows best if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0: return config_context_length @@ -1354,6 +1470,13 @@ def get_model_context_length( if context_length is not None: return context_length if not _is_known_provider_base_url(base_url): + # 2b. Ollama native /api/show — any URL might be an Ollama server + # (local, cloud, or custom hosting). Non-Ollama servers return + # 404/405 quickly. Fall through on failure. + ctx = _query_ollama_api_show(model, base_url, api_key=api_key) + if ctx is not None: + save_context_length(model, base_url, ctx) + return ctx # 3. Try querying local server directly if is_local_endpoint(base_url): local_ctx = _query_local_context_length(model, base_url, api_key=api_key) @@ -1385,7 +1508,7 @@ def get_model_context_length( # (e.g. claude-opus-4.6 is 1M on Anthropic but 128K on GitHub Copilot). # If provider is generic (openrouter/custom/empty), try to infer from URL. effective_provider = provider - if not effective_provider or effective_provider in ("openrouter", "custom"): + if not effective_provider or effective_provider in {"openrouter", "custom"}: if base_url: inferred = _infer_provider_from_url(base_url) if inferred: @@ -1395,7 +1518,7 @@ def get_model_context_length( # This catches account-specific models (e.g. claude-opus-4.6-1m) that # don't exist in models.dev. For models that ARE in models.dev, this # returns the provider-enforced limit which is what users can actually use. - if effective_provider in ("copilot", "copilot-acp", "github-copilot"): + if effective_provider in {"copilot", "copilot-acp", "github-copilot"}: try: from hermes_cli.models import get_copilot_model_context ctx = get_copilot_model_context(model, api_key=api_key) @@ -1423,16 +1546,53 @@ def get_model_context_length( ctx = _resolve_endpoint_context_length(model, base_url, api_key=api_key) if ctx is not None: return ctx + # 5e. Ollama native /api/show probe — runs for ANY provider with a + # base_url, not just ollama-cloud. Ollama-compatible servers expose + # this endpoint regardless of hostname (local Ollama, Ollama Cloud, + # custom Ollama hosting). The OpenAI-compat /v1/models endpoint + # correctly omits context_length per the OpenAI schema, but /api/show + # returns the authoritative GGUF model_info.context_length. + # For non-Ollama servers (OpenAI, Anthropic, etc.), the POST returns + # 404/405 quickly. Results are cached, so the hit is per-model+URL, + # once per hour. + if base_url: + ctx = _query_ollama_api_show(model, base_url, api_key=api_key) + if ctx is not None: + save_context_length(model, base_url, ctx) + return ctx if effective_provider: from agent.models_dev import lookup_models_dev_context ctx = lookup_models_dev_context(effective_provider, model) if ctx: return ctx - # 6. OpenRouter live API metadata (provider-unaware fallback) - metadata = fetch_model_metadata() - if model in metadata: - return metadata[model].get("context_length", DEFAULT_FALLBACK_CONTEXT) + # 6. OpenRouter live API metadata — provider-unaware fallback. + # Only consulted when the provider is unknown (no effective_provider), + # because OpenRouter data is community-maintained and can be incorrect + # for models that belong to known providers with curated defaults. + if not effective_provider: + metadata = fetch_model_metadata() + if model in metadata: + or_ctx = metadata[model].get("context_length", DEFAULT_FALLBACK_CONTEXT) + # Guard against stale OpenRouter metadata for Kimi-family models. + # OpenRouter reports 32768 for moonshotai/kimi-k2.6, but the model + # actually supports 262144 (models.dev + official Kimi docs agree). + # Providers that host their own Kimi endpoints (Ollama Cloud, Kimi + # Coding, Moonshot) would otherwise trip the 64k minimum-context + # guard and reject a perfectly capable model. + # The filter is narrow: only reject exactly 32768 for Kimi-named + # models. If OpenRouter ever updates its data, the stale path + # becomes dead code with no impact. + if or_ctx == 32768 and _model_name_suggests_kimi(model): + logger.info( + "Rejecting OpenRouter metadata context=%s for %r " + "(Kimi-family underreport); falling through to hardcoded defaults", + or_ctx, model, + ) + else: + return or_ctx + + # 7. (reserved) # 8. Hardcoded defaults (fuzzy match — longest key first for specificity) # Only check `default_model in model` (is the key a substring of the input). @@ -1495,7 +1655,7 @@ def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int: if not isinstance(part, dict): continue ptype = part.get("type") - if ptype in ("image", "image_url", "input_image"): + if ptype in {"image", "image_url", "input_image"}: count += 1 stashed = msg.get("_anthropic_content_blocks") if isinstance(msg, dict) else None if isinstance(stashed, list): @@ -1507,7 +1667,7 @@ def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int: inner = content.get("content") if isinstance(inner, list): for part in inner: - if isinstance(part, dict) and part.get("type") in ("image", "image_url"): + if isinstance(part, dict) and part.get("type") in {"image", "image_url"}: count += 1 return count * cost_per_image @@ -1529,7 +1689,7 @@ def _estimate_message_chars(msg: Dict[str, Any]) -> int: cleaned = [] for part in v: if isinstance(part, dict): - if part.get("type") in ("image", "image_url", "input_image"): + if part.get("type") in {"image", "image_url", "input_image"}: cleaned.append({"type": part.get("type"), "image": "[stripped]"}) else: cleaned.append(part) diff --git a/agent/models_dev.py b/agent/models_dev.py index fbb3153829b..d709d7176d4 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -145,7 +145,9 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "openai": "openai", "openai-codex": "openai", "zai": "zai", + "kimi": "kimi-for-coding", "kimi-coding": "kimi-for-coding", + "moonshot": "kimi-for-coding", "stepfun": "stepfun", "kimi-coding-cn": "kimi-for-coding", "minimax": "minimax", @@ -347,6 +349,28 @@ def lookup_models_dev_context(provider: str, model: str) -> Optional[int]: if ctx: return ctx + # Suffix-aware fallback: some providers (e.g. ollama-cloud) store + # model IDs with :cloud / -cloud suffixes in models.dev while the + # live API returns bare names. Without this, kimi-k2.6 misses the + # kimi-k2.6:cloud entry and falls through to stale OpenRouter metadata + # reporting 32768 — tripping the 64k minimum-context guard. + # The suffix-stripping in fetch_ollama_cloud_models() handles the + # model-picker UX; this handles the context-length lookup path. + for suffix in (":cloud", "-cloud"): + suffixed_key = model + suffix + entry = models.get(suffixed_key) + if entry: + ctx = _extract_context(entry) + if ctx: + return ctx + # Also try case-insensitive + suffixed_lower = model_lower + suffix + for mid, mdata in models.items(): + if mid.lower() == suffixed_lower: + ctx = _extract_context(mdata) + if ctx: + return ctx + return None diff --git a/agent/moonshot_schema.py b/agent/moonshot_schema.py index aeefd4a0cee..f22176f936e 100644 --- a/agent/moonshot_schema.py +++ b/agent/moonshot_schema.py @@ -122,7 +122,7 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any: # empty, drop it entirely. if "enum" in repaired and isinstance(repaired["enum"], list): node_type = repaired.get("type") - if node_type in ("string", "integer", "number", "boolean"): + if node_type in {"string", "integer", "number", "boolean"}: cleaned = [v for v in repaired["enum"] if v is not None and v != ""] if cleaned: @@ -135,7 +135,7 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any: def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]: """Infer a reasonable ``type`` if this schema node has none.""" - if "type" in node and node["type"] not in (None, ""): + if "type" in node and node["type"] not in {None, ""}: return node # Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum`` diff --git a/agent/plugin_llm.py b/agent/plugin_llm.py new file mode 100644 index 00000000000..e9c2a869dd7 --- /dev/null +++ b/agent/plugin_llm.py @@ -0,0 +1,1046 @@ +""" +Plugin LLM facade — host-owned LLM access for trusted plugins. +============================================================== + +Plugins built on Hermes Agent often need to make their own LLM calls +out-of-band — a hook that rewrites a tool error before the user sees +it, a gateway adapter that translates inbound text, a slash command +that summarises a paste, a scheduled job that scores yesterday's +activity into a single line on a status board. + +Today the only stable plugin surfaces extend an existing Hermes +subsystem: ``register_tool``, ``register_platform``, +``register_memory_provider``, etc. None of those help when the +plugin's job is to make its own model call. This module is the +supported lane for that case. + +The plugin gets ``ctx.llm`` exposed on its +:class:`~hermes_cli.plugins.PluginContext`: + +* ``complete(messages, ...)`` — chat completion against the user's + active model + auth. +* ``complete_structured(instructions=..., input=[...], json_schema=...)`` + — bounded structured inference with optional image inputs, JSON + schema validation, and parsed JSON output. +* async siblings ``acomplete()`` / ``acomplete_structured()`` for + plugins running on asyncio loops (gateway adapters, hooks). + +Provider/model/agent_id/profile are explicit keyword arguments — no +embedded slugs, no shorthands. This mirrors Hermes' main config +shape (``model.provider`` + ``model.model``) so plugin authors who +already understand the host config don't have to learn anything new. + +The host owns provider routing, auth resolution, timeouts, and +fallback. The plugin never sees raw OAuth tokens or API keys. All +override knobs (``provider=``, ``model=``, ``agent_id=``, +``profile=``) are gated behind explicit per-plugin trust flags in +``config.yaml``:: + + plugins: + entries: + my-plugin: + llm: + allow_provider_override: true + allow_model_override: true + allowed_providers: [openrouter, anthropic] # optional + allowed_models: [openai/gpt-4o-mini] # optional + allow_agent_id_override: false + allow_profile_override: false + +Untrusted plugins still get the default surface — they just can't +steer provider, model, agent, or auth-profile selection. The trust +gate is fail-closed: a missing config block means "no overrides," +not "anything goes." + +Backed by :func:`agent.auxiliary_client.call_llm`, which already +handles every provider, fallback chain, and per-task override Hermes +supports. +""" + +from __future__ import annotations + +import base64 +import json +import logging +import re +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Union + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Public dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class PluginLlmTextInput: + """Text block in a structured input list.""" + + text: str + type: str = "text" + + +@dataclass +class PluginLlmImageInput: + """Image block in a structured input list. + + Either ``data`` (raw bytes) or ``url`` (http(s) or data: URL) must be + provided. ``mime_type`` defaults to ``image/png`` when ``data`` is + used and is required for non-PNG bytes to render correctly across + providers. + """ + + data: Optional[bytes] = None + url: Optional[str] = None + mime_type: str = "image/png" + file_name: str = "" + type: str = "image" + + +PluginLlmInput = Union[PluginLlmTextInput, PluginLlmImageInput, Dict[str, Any]] +"""A single structured input block. + +Plugins may pass either the dataclasses above or plain dicts with the +same shape — dicts are normalized internally. Dict shape:: + + {"type": "text", "text": "..."} + {"type": "image", "data": , "mime_type": "image/png", "file_name": "receipt.png"} + {"type": "image", "url": "https://..."} +""" + + +@dataclass +class PluginLlmUsage: + """Token + cost usage for a completion. All fields optional — providers + differ on what they return. ``cost_usd`` is the host's best estimate.""" + + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + cache_read_tokens: int = 0 + cache_write_tokens: int = 0 + cost_usd: Optional[float] = None + + +@dataclass +class PluginLlmCompleteResult: + """Result of :meth:`PluginLlm.complete`.""" + + text: str + provider: str + model: str + agent_id: str + usage: PluginLlmUsage = field(default_factory=PluginLlmUsage) + audit: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class PluginLlmStructuredResult: + """Result of :meth:`PluginLlm.complete_structured`. + + ``parsed`` is set only when ``json_mode=True`` or ``json_schema`` is + provided AND the response was valid JSON. ``content_type`` is + ``"json"`` in that case, ``"text"`` otherwise (e.g. the model + refused or the response wasn't requested as JSON).""" + + text: str + provider: str + model: str + agent_id: str + usage: PluginLlmUsage = field(default_factory=PluginLlmUsage) + parsed: Optional[Any] = None + content_type: str = "text" + audit: Dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Trust gate +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _TrustPolicy: + """Resolved trust gate for one plugin's LLM access.""" + + plugin_id: str + allow_provider_override: bool = False + allowed_providers: Optional[frozenset] = None # None = no allowlist + allow_any_provider: bool = False # True when allowed_providers == ["*"] + allow_model_override: bool = False + allowed_models: Optional[frozenset] = None # None = no allowlist + allow_any_model: bool = False # True when allowed_models == ["*"] + allow_agent_id_override: bool = False + allow_profile_override: bool = False + + +def _normalize_ref(raw: str) -> str: + """Lower-case + strip whitespace. Used for allowlist matching.""" + return (raw or "").strip().lower() + + +def _coerce_allowlist(raw: Any) -> tuple[Optional[frozenset], bool]: + """Coerce a YAML list into ``(frozenset_or_None, allow_any)``. + + ``["*"]`` (or any list containing ``"*"``) → ``(frozenset(), True)``. + Any other list → ``(frozenset({...}), False)``. + Missing / non-list → ``(None, False)`` meaning "no allowlist." + """ + if not isinstance(raw, list): + return None, False + normalized = [_normalize_ref(item) for item in raw if isinstance(item, str)] + allow_any = "*" in normalized + cleaned = {item for item in normalized if item and item != "*"} + if allow_any and not cleaned: + return frozenset(), True + if cleaned: + return frozenset(cleaned), allow_any + return frozenset(), allow_any + + +def _resolve_trust_policy(plugin_id: str) -> _TrustPolicy: + """Read ``plugins.entries..llm`` from config.yaml. + + Missing config → fully restrictive policy (default deny on every + override). The policy is resolved per-call rather than cached so + config edits take effect without restarting the agent. + """ + if not plugin_id: + return _TrustPolicy(plugin_id="") + + try: + from hermes_cli.config import load_config + config = load_config() or {} + except Exception: # pragma: no cover — config IO failure + return _TrustPolicy(plugin_id=plugin_id) + + plugins_cfg = config.get("plugins") + if not isinstance(plugins_cfg, dict): + return _TrustPolicy(plugin_id=plugin_id) + entries = plugins_cfg.get("entries") + if not isinstance(entries, dict): + return _TrustPolicy(plugin_id=plugin_id) + entry = entries.get(plugin_id) + if not isinstance(entry, dict): + return _TrustPolicy(plugin_id=plugin_id) + llm_cfg = entry.get("llm") + if not isinstance(llm_cfg, dict): + return _TrustPolicy(plugin_id=plugin_id) + + allowed_models, allow_any_model = _coerce_allowlist(llm_cfg.get("allowed_models")) + allowed_providers, allow_any_provider = _coerce_allowlist( + llm_cfg.get("allowed_providers") + ) + + return _TrustPolicy( + plugin_id=plugin_id, + allow_provider_override=bool(llm_cfg.get("allow_provider_override", False)), + allowed_providers=allowed_providers, + allow_any_provider=allow_any_provider, + allow_model_override=bool(llm_cfg.get("allow_model_override", False)), + allowed_models=allowed_models, + allow_any_model=allow_any_model, + allow_agent_id_override=bool(llm_cfg.get("allow_agent_id_override", False)), + allow_profile_override=bool(llm_cfg.get("allow_profile_override", False)), + ) + + +class PluginLlmTrustError(PermissionError): + """Raised when a plugin attempts an LLM override without trust.""" + + +def _check_overrides( + policy: _TrustPolicy, + *, + requested_provider: Optional[str], + requested_model: Optional[str], + requested_agent_id: Optional[str], + requested_profile: Optional[str], +) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]: + """Apply the trust gate. Returns the validated overrides as + ``(provider, model, agent_id, profile)`` or raises + :class:`PluginLlmTrustError`. + + Each override (``provider``, ``model``, ``agent_id``, ``profile``) + is independently gated. ``provider`` and ``model`` each have an + optional allowlist via ``allowed_providers`` / ``allowed_models``. + """ + final_provider: Optional[str] = None + final_model: Optional[str] = None + final_profile: Optional[str] = None + + if requested_provider: + if not policy.allow_provider_override: + raise PluginLlmTrustError( + f"Plugin {policy.plugin_id!r} cannot override the provider " + f"(set plugins.entries.{policy.plugin_id}.llm.allow_provider_override " + f"to true to allow)." + ) + normalized = _normalize_ref(requested_provider) + if ( + not policy.allow_any_provider + and policy.allowed_providers is not None + and normalized not in policy.allowed_providers + ): + raise PluginLlmTrustError( + f"Plugin {policy.plugin_id!r} provider override " + f"{requested_provider!r} is not in plugins.entries." + f"{policy.plugin_id}.llm.allowed_providers." + ) + final_provider = requested_provider.strip() + + if requested_model: + if not policy.allow_model_override: + raise PluginLlmTrustError( + f"Plugin {policy.plugin_id!r} cannot override the model " + f"(set plugins.entries.{policy.plugin_id}.llm.allow_model_override " + f"to true to allow)." + ) + normalized = _normalize_ref(requested_model) + if ( + not policy.allow_any_model + and policy.allowed_models is not None + and normalized not in policy.allowed_models + ): + raise PluginLlmTrustError( + f"Plugin {policy.plugin_id!r} model override " + f"{requested_model!r} is not in plugins.entries." + f"{policy.plugin_id}.llm.allowed_models." + ) + final_model = requested_model.strip() + + if requested_agent_id and not policy.allow_agent_id_override: + raise PluginLlmTrustError( + f"Plugin {policy.plugin_id!r} cannot run completions against a " + f"non-default agent id (set plugins.entries.{policy.plugin_id}." + f"llm.allow_agent_id_override to true to allow)." + ) + + if requested_profile: + if not policy.allow_profile_override: + raise PluginLlmTrustError( + f"Plugin {policy.plugin_id!r} cannot override the auth profile " + f"(set plugins.entries.{policy.plugin_id}.llm.allow_profile_override " + f"to true to allow)." + ) + final_profile = requested_profile.strip() + + return final_provider, final_model, requested_agent_id, final_profile + + +# --------------------------------------------------------------------------- +# Input normalization +# --------------------------------------------------------------------------- + + +def _normalize_input_block(block: PluginLlmInput) -> Dict[str, Any]: + """Coerce a structured input block to a plain dict the message + builder understands. Unknown shapes raise ``ValueError``.""" + if isinstance(block, PluginLlmTextInput): + return {"type": "text", "text": block.text} + if isinstance(block, PluginLlmImageInput): + d: Dict[str, Any] = { + "type": "image", + "mime_type": block.mime_type, + "file_name": block.file_name, + } + if block.data is not None: + d["data"] = block.data + if block.url: + d["url"] = block.url + return d + if isinstance(block, dict): + kind = block.get("type") + if kind == "text": + text = block.get("text") + if not isinstance(text, str): + raise ValueError("text input block requires 'text' string") + return {"type": "text", "text": text} + if kind == "image": + if "data" not in block and not block.get("url"): + raise ValueError("image input block requires 'data' bytes or 'url'") + return { + "type": "image", + "data": block.get("data"), + "url": block.get("url"), + "mime_type": block.get("mime_type") or "image/png", + "file_name": block.get("file_name") or "", + } + raise ValueError(f"Unknown input block type: {kind!r}") + raise ValueError(f"Unsupported input block: {type(block).__name__}") + + +def _build_structured_messages( + *, + instructions: str, + inputs: Sequence[PluginLlmInput], + json_mode: bool, + json_schema: Optional[Any], + schema_name: Optional[str], + system_prompt: Optional[str], +) -> List[Dict[str, Any]]: + """Build the OpenAI-style messages list for a structured call. + + The instructions become the first text part of the user message, + followed by an optional ``Schema name: `` hint and an optional + JSON-only directive when JSON output is requested. Image inputs are + encoded as ``image_url`` parts. + """ + messages: List[Dict[str, Any]] = [] + sys_parts: List[str] = [] + if system_prompt: + sys_parts.append(system_prompt.strip()) + if json_mode or json_schema is not None: + sys_parts.append( + "Respond with a single JSON object that matches the requested shape. " + "Do not include prose or markdown fences." + ) + if sys_parts: + messages.append({"role": "system", "content": "\n\n".join(sys_parts)}) + + user_parts: List[Dict[str, Any]] = [] + header = instructions.strip() + if schema_name: + header = f"{header}\n\nSchema name: {schema_name}" + if json_schema is not None: + try: + schema_text = json.dumps(json_schema, ensure_ascii=False, sort_keys=True) + except (TypeError, ValueError): + schema_text = str(json_schema) + header = f"{header}\n\nJSON schema:\n{schema_text}" + user_parts.append({"type": "text", "text": header}) + + for block in inputs: + norm = _normalize_input_block(block) + if norm["type"] == "text": + user_parts.append({"type": "text", "text": norm["text"]}) + elif norm["type"] == "image": + if norm.get("url"): + user_parts.append({ + "type": "image_url", + "image_url": {"url": norm["url"]}, + }) + else: + data = norm.get("data") or b"" + if not isinstance(data, (bytes, bytearray)): + raise ValueError("image input 'data' must be bytes") + b64 = base64.b64encode(data).decode("ascii") + mime = norm.get("mime_type") or "image/png" + user_parts.append({ + "type": "image_url", + "image_url": {"url": f"data:{mime};base64,{b64}"}, + }) + + messages.append({"role": "user", "content": user_parts}) + return messages + + +# --------------------------------------------------------------------------- +# JSON parsing +# --------------------------------------------------------------------------- + + +_FENCE_RE = re.compile(r"```(?:json)?\s*(.+?)```", re.DOTALL | re.IGNORECASE) + + +def _strip_code_fences(text: str) -> str: + """Pull the first fenced code block out of ``text`` if any. Returns + ``text`` unchanged when no fence is present.""" + match = _FENCE_RE.search(text) + if match: + return match.group(1).strip() + return text.strip() + + +def _parse_structured_text( + *, text: str, json_mode: bool, json_schema: Optional[Any] +) -> tuple[Optional[Any], str]: + """Return ``(parsed, content_type)``. ``content_type`` is ``"json"`` + when parsing succeeded and (when a schema was given) validation + passed; ``"text"`` otherwise.""" + if not (json_mode or json_schema is not None): + return None, "text" + if not text: + return None, "text" + + try: + parsed = json.loads(_strip_code_fences(text)) + except (json.JSONDecodeError, ValueError): + return None, "text" + + if json_schema is not None: + try: + import jsonschema # type: ignore[import-untyped] + jsonschema.validate(parsed, json_schema) + except ImportError: + # jsonschema is optional; skip strict validation when absent. + logger.debug("jsonschema unavailable; skipping schema validation") + except jsonschema.ValidationError as exc: # type: ignore[attr-defined] + raise ValueError( + f"Plugin LLM structured output did not match schema: {exc.message}" + ) from exc + + return parsed, "json" + + +# --------------------------------------------------------------------------- +# Usage extraction +# --------------------------------------------------------------------------- + + +def _extract_usage(response: Any) -> PluginLlmUsage: + """Pull token usage out of an OpenAI-shaped response object. + + Tolerant of provider differences — Anthropic via the auxiliary + adapter exposes ``usage.prompt_tokens`` / ``usage.completion_tokens``; + direct OpenAI also exposes ``cache_read_input_tokens``.""" + usage = PluginLlmUsage() + raw = getattr(response, "usage", None) + if raw is None: + return usage + + def _g(name: str) -> int: + v = getattr(raw, name, None) + if v is None and isinstance(raw, dict): + v = raw.get(name) + try: + return int(v) if v is not None else 0 + except (TypeError, ValueError): + return 0 + + usage.input_tokens = _g("prompt_tokens") or _g("input_tokens") + usage.output_tokens = _g("completion_tokens") or _g("output_tokens") + usage.total_tokens = _g("total_tokens") or (usage.input_tokens + usage.output_tokens) + usage.cache_read_tokens = _g("cache_read_input_tokens") or _g("cache_read_tokens") + usage.cache_write_tokens = _g("cache_creation_input_tokens") or _g("cache_write_tokens") + return usage + + +def _extract_text(response: Any) -> str: + """Pull the assistant text out of an OpenAI-shaped response object.""" + try: + msg = response.choices[0].message + content = getattr(msg, "content", None) + if isinstance(content, str): + return content + if isinstance(content, list): + parts: List[str] = [] + for part in content: + if isinstance(part, dict): + if part.get("type") == "text" and isinstance(part.get("text"), str): + parts.append(part["text"]) + else: + txt = getattr(part, "text", None) + if isinstance(txt, str): + parts.append(txt) + return "".join(parts) + except (AttributeError, IndexError, TypeError): + pass + return "" + + +def _resolve_attribution( + *, + provider_override: Optional[str], + model_override: Optional[str], + response: Any, +) -> tuple[str, str]: + """Decide what to record as ``result.provider`` / ``result.model``. + + Precedence: + + 1. Explicit overrides win — if the plugin asked for ``provider="x"`` + or ``model="y"``, that's what we record (it's what the call + actually targeted). + 2. Otherwise we ask the host for the current main provider/model + via :func:`_read_main_provider` / :func:`_read_main_model`, since + those are what ``call_llm`` resolves to when ``provider=None`` + and ``model=None`` are passed through. They reflect runtime + overrides set by ``set_runtime_main()``. + 3. ``response.model`` (if present) overrides the recorded model + string. Providers post-resolution often return a slightly + different model id than the request (e.g. ``gpt-4o`` → + ``gpt-4o-2024-08-06``); the plugin's audit log should reflect + what actually ran. + 4. If everything above is empty, fall back to ``"auto"`` / + ``"default"`` so the result object has non-empty strings. + """ + if provider_override: + provider = provider_override + else: + try: + from agent.auxiliary_client import _read_main_provider + provider = (_read_main_provider() or "").strip() or "auto" + except Exception: # pragma: no cover — defensive + provider = "auto" + + response_model = getattr(response, "model", None) + if isinstance(response_model, str) and response_model.strip(): + model = response_model.strip() + elif model_override: + model = model_override + else: + try: + from agent.auxiliary_client import _read_main_model + model = (_read_main_model() or "").strip() or "default" + except Exception: # pragma: no cover — defensive + model = "default" + + return provider, model + + +# --------------------------------------------------------------------------- +# PluginLlm facade +# --------------------------------------------------------------------------- + + +class PluginLlm: + """Host-owned LLM access for one trusted plugin. + + Instances are constructed by :class:`hermes_cli.plugins.PluginContext` + and exposed as ``ctx.llm``. Plugins should not instantiate this + directly — the constructor binds plugin identity for trust-gate + enforcement. + """ + + def __init__( + self, + *, + plugin_id: str, + policy_loader: Optional[Callable[[str], _TrustPolicy]] = None, + sync_caller: Optional[Callable[..., Any]] = None, + async_caller: Optional[Callable[..., Awaitable[Any]]] = None, + ) -> None: + self._plugin_id = plugin_id + self._policy_loader = policy_loader or _resolve_trust_policy + self._sync_caller = sync_caller + self._async_caller = async_caller + + # -- public sync API ---------------------------------------------------- + + def complete( + self, + messages: List[Dict[str, Any]], + *, + provider: Optional[str] = None, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + timeout: Optional[float] = None, + agent_id: Optional[str] = None, + profile: Optional[str] = None, + purpose: Optional[str] = None, + ) -> PluginLlmCompleteResult: + """Run a host-owned chat completion against the user's active model. + + ``messages`` is the standard OpenAI shape. ``provider``, + ``model``, ``agent_id``, and ``profile`` follow the same + explicit shape as the host's main config (``model.provider`` + + ``model.model``). Each is independently gated by + ``plugins.entries..llm.allow_*_override`` (see module + docstring). + """ + policy = self._policy_loader(self._plugin_id) + eff_provider, eff_model, eff_agent, eff_profile = _check_overrides( + policy, + requested_provider=provider, + requested_model=model, + requested_agent_id=agent_id, + requested_profile=profile, + ) + real_provider, real_model, response = self._invoke_sync( + messages=messages, + provider_override=eff_provider, + model_override=eff_model, + profile_override=eff_profile, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + ) + text = _extract_text(response) + usage = _extract_usage(response) + result = PluginLlmCompleteResult( + text=text, + provider=real_provider, + model=real_model, + agent_id=eff_agent or "default", + usage=usage, + audit={ + "plugin_id": self._plugin_id, + "purpose": purpose or "", + "profile": eff_profile or "", + }, + ) + logger.info( + "plugin_llm.complete plugin=%s provider=%s model=%s purpose=%s " + "tokens=%d", + self._plugin_id, real_provider, real_model, purpose or "", + usage.total_tokens, + ) + return result + + def complete_structured( + self, + *, + instructions: str, + input: Sequence[PluginLlmInput], + json_schema: Optional[Any] = None, + json_mode: bool = False, + schema_name: Optional[str] = None, + system_prompt: Optional[str] = None, + provider: Optional[str] = None, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + timeout: Optional[float] = None, + agent_id: Optional[str] = None, + profile: Optional[str] = None, + purpose: Optional[str] = None, + ) -> PluginLlmStructuredResult: + """Run a bounded host-owned structured completion. + + ``input`` accepts text and image blocks (see + :class:`PluginLlmTextInput` / :class:`PluginLlmImageInput`). When + ``json_mode=True`` or ``json_schema`` is provided, the response + is parsed and (if a schema is given) validated; the parsed value + is returned in :attr:`PluginLlmStructuredResult.parsed`. + + Validation requires the optional ``jsonschema`` package. When it + isn't installed, JSON mode still works but schema enforcement is + skipped with a debug log. + """ + if not instructions or not instructions.strip(): + raise ValueError("complete_structured requires non-empty instructions") + if not input: + raise ValueError("complete_structured requires at least one input block") + + policy = self._policy_loader(self._plugin_id) + eff_provider, eff_model, eff_agent, eff_profile = _check_overrides( + policy, + requested_provider=provider, + requested_model=model, + requested_agent_id=agent_id, + requested_profile=profile, + ) + + messages = _build_structured_messages( + instructions=instructions, + inputs=list(input), + json_mode=json_mode, + json_schema=json_schema, + schema_name=schema_name, + system_prompt=system_prompt, + ) + extra_body = self._json_response_format(json_mode=json_mode, json_schema=json_schema) + + real_provider, real_model, response = self._invoke_sync( + messages=messages, + provider_override=eff_provider, + model_override=eff_model, + profile_override=eff_profile, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + extra_body=extra_body, + ) + text = _extract_text(response) + usage = _extract_usage(response) + parsed, content_type = _parse_structured_text( + text=text, json_mode=json_mode, json_schema=json_schema + ) + result = PluginLlmStructuredResult( + text=text, + provider=real_provider, + model=real_model, + agent_id=eff_agent or "default", + usage=usage, + parsed=parsed, + content_type=content_type, + audit={ + "plugin_id": self._plugin_id, + "purpose": purpose or "", + "profile": eff_profile or "", + "schema_name": schema_name or "", + }, + ) + logger.info( + "plugin_llm.complete_structured plugin=%s provider=%s model=%s " + "purpose=%s content_type=%s tokens=%d", + self._plugin_id, real_provider, real_model, purpose or "", + content_type, usage.total_tokens, + ) + return result + + # -- public async API --------------------------------------------------- + + async def acomplete( + self, + messages: List[Dict[str, Any]], + *, + provider: Optional[str] = None, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + timeout: Optional[float] = None, + agent_id: Optional[str] = None, + profile: Optional[str] = None, + purpose: Optional[str] = None, + ) -> PluginLlmCompleteResult: + """Async sibling of :meth:`complete`.""" + policy = self._policy_loader(self._plugin_id) + eff_provider, eff_model, eff_agent, eff_profile = _check_overrides( + policy, + requested_provider=provider, + requested_model=model, + requested_agent_id=agent_id, + requested_profile=profile, + ) + real_provider, real_model, response = await self._invoke_async( + messages=messages, + provider_override=eff_provider, + model_override=eff_model, + profile_override=eff_profile, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + ) + text = _extract_text(response) + usage = _extract_usage(response) + return PluginLlmCompleteResult( + text=text, + provider=real_provider, + model=real_model, + agent_id=eff_agent or "default", + usage=usage, + audit={ + "plugin_id": self._plugin_id, + "purpose": purpose or "", + "profile": eff_profile or "", + }, + ) + + async def acomplete_structured( + self, + *, + instructions: str, + input: Sequence[PluginLlmInput], + json_schema: Optional[Any] = None, + json_mode: bool = False, + schema_name: Optional[str] = None, + system_prompt: Optional[str] = None, + provider: Optional[str] = None, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + timeout: Optional[float] = None, + agent_id: Optional[str] = None, + profile: Optional[str] = None, + purpose: Optional[str] = None, + ) -> PluginLlmStructuredResult: + """Async sibling of :meth:`complete_structured`.""" + if not instructions or not instructions.strip(): + raise ValueError("acomplete_structured requires non-empty instructions") + if not input: + raise ValueError("acomplete_structured requires at least one input block") + + policy = self._policy_loader(self._plugin_id) + eff_provider, eff_model, eff_agent, eff_profile = _check_overrides( + policy, + requested_provider=provider, + requested_model=model, + requested_agent_id=agent_id, + requested_profile=profile, + ) + messages = _build_structured_messages( + instructions=instructions, + inputs=list(input), + json_mode=json_mode, + json_schema=json_schema, + schema_name=schema_name, + system_prompt=system_prompt, + ) + extra_body = self._json_response_format(json_mode=json_mode, json_schema=json_schema) + real_provider, real_model, response = await self._invoke_async( + messages=messages, + provider_override=eff_provider, + model_override=eff_model, + profile_override=eff_profile, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + extra_body=extra_body, + ) + text = _extract_text(response) + usage = _extract_usage(response) + parsed, content_type = _parse_structured_text( + text=text, json_mode=json_mode, json_schema=json_schema + ) + return PluginLlmStructuredResult( + text=text, + provider=real_provider, + model=real_model, + agent_id=eff_agent or "default", + usage=usage, + parsed=parsed, + content_type=content_type, + audit={ + "plugin_id": self._plugin_id, + "purpose": purpose or "", + "profile": eff_profile or "", + "schema_name": schema_name or "", + }, + ) + + # -- internals --------------------------------------------------------- + + @staticmethod + def _json_response_format( + *, json_mode: bool, json_schema: Optional[Any] + ) -> Optional[Dict[str, Any]]: + """Build the ``extra_body.response_format`` payload for the + provider request. Falls back to ``json_object`` when no schema + is given so providers that ignore json_schema still get a hint.""" + if json_schema is not None: + return { + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "plugin_structured_output", + "schema": json_schema, + "strict": False, + }, + } + } + if json_mode: + return {"response_format": {"type": "json_object"}} + return None + + def _invoke_sync( + self, + *, + messages: List[Dict[str, Any]], + provider_override: Optional[str], + model_override: Optional[str], + profile_override: Optional[str], + temperature: Optional[float], + max_tokens: Optional[int], + timeout: Optional[float], + extra_body: Optional[Dict[str, Any]] = None, + ) -> tuple[str, str, Any]: + """Invoke the host's ``call_llm``. Lazy-imports + ``agent.auxiliary_client`` to avoid circular deps at plugin + discovery time.""" + if self._sync_caller is not None: + return self._sync_caller( + messages=messages, + provider_override=provider_override, + model_override=model_override, + profile_override=profile_override, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + extra_body=extra_body, + ) + from agent.auxiliary_client import call_llm + merged_extra = dict(extra_body or {}) + if profile_override: + merged_extra.setdefault("metadata", {})["auth_profile"] = profile_override + response = call_llm( + task=None, + provider=provider_override, + model=model_override, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + extra_body=merged_extra or None, + ) + provider, model = _resolve_attribution( + provider_override=provider_override, + model_override=model_override, + response=response, + ) + return provider, model, response + + async def _invoke_async( + self, + *, + messages: List[Dict[str, Any]], + provider_override: Optional[str], + model_override: Optional[str], + profile_override: Optional[str], + temperature: Optional[float], + max_tokens: Optional[int], + timeout: Optional[float], + extra_body: Optional[Dict[str, Any]] = None, + ) -> tuple[str, str, Any]: + if self._async_caller is not None: + return await self._async_caller( + messages=messages, + provider_override=provider_override, + model_override=model_override, + profile_override=profile_override, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + extra_body=extra_body, + ) + from agent.auxiliary_client import async_call_llm + merged_extra = dict(extra_body or {}) + if profile_override: + merged_extra.setdefault("metadata", {})["auth_profile"] = profile_override + response = await async_call_llm( + task=None, + provider=provider_override, + model=model_override, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + timeout=timeout, + extra_body=merged_extra or None, + ) + provider, model = _resolve_attribution( + provider_override=provider_override, + model_override=model_override, + response=response, + ) + return provider, model, response + + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + + +def make_plugin_llm_for_test( + *, + plugin_id: str, + policy: _TrustPolicy, + sync_caller: Optional[Callable[..., Any]] = None, + async_caller: Optional[Callable[..., Awaitable[Any]]] = None, +) -> PluginLlm: + """Construct a :class:`PluginLlm` with an injected policy and caller. + + Used by unit tests that don't want to round-trip through config.yaml + or hit a real provider. Not part of the public plugin API. + """ + return PluginLlm( + plugin_id=plugin_id, + policy_loader=lambda _pid: policy, + sync_caller=sync_caller, + async_caller=async_caller, + ) + + +__all__ = [ + "PluginLlm", + "PluginLlmTextInput", + "PluginLlmImageInput", + "PluginLlmInput", + "PluginLlmUsage", + "PluginLlmCompleteResult", + "PluginLlmStructuredResult", + "PluginLlmTrustError", + "make_plugin_llm_for_test", +] diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 456cd099ea1..025ea8ab654 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -216,7 +216,15 @@ KANBAN_GUIDANCE = ( "artifacts. `metadata` is machine-readable facts " "(`{changed_files: [...], tests_run: N, decisions: [...]}`). Downstream " "workers read both via their own `kanban_show`. Never put secrets / " - "tokens / raw PII in either field — run rows are durable forever.\n" + "tokens / raw PII in either field — run rows are durable forever. " + "Exception: if your output is a code change that needs human review " + "before counting as merged/done (most coding tasks), drop the " + "structured metadata (changed_files / tests_run / diff_path) into a " + "`kanban_comment` first, then end with " + "`kanban_block(reason=\"review-required: \")` so a " + "reviewer can approve+unblock or request changes. Reviewing-then-" + "completing is more honest than auto-completing work that still needs " + "eyes on it.\n" "6. **If follow-up work appears, create it; don't do it.** Use " "`kanban_create(title=..., assignee=, parents=[your-task-id])` " "to spawn a child task for the appropriate specialist profile instead of " diff --git a/agent/prompt_caching.py b/agent/prompt_caching.py index d80f58ea40a..4829c96b332 100644 --- a/agent/prompt_caching.py +++ b/agent/prompt_caching.py @@ -1,15 +1,25 @@ -"""Anthropic prompt caching (system_and_3 strategy). +"""Anthropic prompt caching strategies. -Reduces input token costs by ~75% on multi-turn conversations by caching -the conversation prefix. Uses 4 cache_control breakpoints (Anthropic max): - 1. System prompt (stable across all turns) - 2-4. Last 3 non-system messages (rolling window) +Two layouts: + +* ``system_and_3`` (default, used everywhere except the long-lived path): + 4 cache_control breakpoints — system prompt + last 3 non-system messages. + All at the same TTL (5m or 1h). Reduces input token costs by ~75% on + multi-turn conversations within a single session. + +* ``prefix_and_2`` (Claude on Anthropic / OpenRouter / Nous Portal): + 4 breakpoints split across two TTL tiers — tools[-1] (1h) + + stable system prefix (1h) + last 2 non-system messages (5m). The + long-lived prefix is byte-stable across sessions for a given user + config, so every fresh session reads the cached system+tools instead + of re-paying for them. Within-session rolling window shrinks from 3 + messages to 2 to free the breakpoint budget. Pure functions -- no class state, no AIAgent dependency. """ import copy -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional def _apply_cache_marker(msg: dict, cache_marker: dict, native_anthropic: bool = False) -> None: @@ -38,6 +48,14 @@ def _apply_cache_marker(msg: dict, cache_marker: dict, native_anthropic: bool = last["cache_control"] = cache_marker +def _build_marker(ttl: str) -> Dict[str, str]: + """Build a cache_control marker dict for the given TTL ('5m' or '1h').""" + marker: Dict[str, str] = {"type": "ephemeral"} + if ttl == "1h": + marker["ttl"] = "1h" + return marker + + def apply_anthropic_cache_control( api_messages: List[Dict[str, Any]], cache_ttl: str = "5m", @@ -45,7 +63,8 @@ def apply_anthropic_cache_control( ) -> List[Dict[str, Any]]: """Apply system_and_3 caching strategy to messages for Anthropic models. - Places up to 4 cache_control breakpoints: system prompt + last 3 non-system messages. + Places up to 4 cache_control breakpoints: system prompt + last 3 non-system + messages, all at the same TTL. Returns: Deep copy of messages with cache_control breakpoints injected. @@ -54,9 +73,7 @@ def apply_anthropic_cache_control( if not messages: return messages - marker = {"type": "ephemeral"} - if cache_ttl == "1h": - marker["ttl"] = "1h" + marker = _build_marker(cache_ttl) breakpoints_used = 0 @@ -70,3 +87,115 @@ def apply_anthropic_cache_control( _apply_cache_marker(messages[idx], marker, native_anthropic=native_anthropic) return messages + + +def _mark_system_stable_block( + messages: List[Dict[str, Any]], + long_lived_marker: Dict[str, str], +) -> bool: + """Mark the *first* content block of the system message with the 1h marker. + + The system message is expected to have been split into multiple content + blocks beforehand by the caller — block[0] is the cross-session-stable + prefix, subsequent blocks carry context files + volatile suffix. + Falls back to marking the whole system message as a single block when + the message hasn't been split (preserves correctness on the fallback path). + + Returns True when a marker was placed. + """ + if not messages or messages[0].get("role") != "system": + return False + + sys_msg = messages[0] + content = sys_msg.get("content") + + # Already a list of blocks → mark the first block. + if isinstance(content, list) and content: + first = content[0] + if isinstance(first, dict): + first["cache_control"] = long_lived_marker + return True + return False + + # String content (no split) → cannot place a stable-prefix breakpoint + # without changing the byte content. Caller is responsible for + # splitting; if they didn't, fall through to envelope marker so we still + # cache *something* for this turn. + if isinstance(content, str) and content: + sys_msg["content"] = [ + {"type": "text", "text": content, "cache_control": long_lived_marker} + ] + return True + + return False + + +def apply_anthropic_cache_control_long_lived( + api_messages: List[Dict[str, Any]], + long_lived_ttl: str = "1h", + rolling_ttl: str = "5m", + native_anthropic: bool = False, +) -> List[Dict[str, Any]]: + """Apply prefix_and_2 caching: long-lived stable prefix + rolling window. + + Layout (4 breakpoints total): + * Stable system prefix (block[0]) → ``long_lived_ttl`` TTL + * Last 2 non-system messages → ``rolling_ttl`` TTL each + + NOTE: this function does NOT mark the tools array. Tools cache_control + is attached separately (see ``mark_tools_for_long_lived_cache``) because + tools live outside the messages list in the API payload. + + The caller MUST have split the system message into ordered content + blocks where block[0] is the cross-session-stable portion. If the system + message is still a single string, it is wrapped into a single block and + marked — this is correct, just less effective (the volatile suffix is + not isolated, so the prefix invalidates per-session). + + Returns: + Deep copy of messages with cache_control breakpoints injected. + """ + messages = copy.deepcopy(api_messages) + if not messages: + return messages + + long_marker = _build_marker(long_lived_ttl) + rolling_marker = _build_marker(rolling_ttl) + + placed_prefix = _mark_system_stable_block(messages, long_marker) + + # Reserve 1 breakpoint for the system prefix (when placed); spend the + # remaining 3 on the rolling tail. Anthropic max is 4 total — + # tools[-1] (when marked) consumes the 4th, so we cap rolling at 2 here. + rolling_budget = 2 if placed_prefix else 3 + non_sys = [i for i in range(len(messages)) if messages[i].get("role") != "system"] + for idx in non_sys[-rolling_budget:]: + _apply_cache_marker(messages[idx], rolling_marker, native_anthropic=native_anthropic) + + return messages + + +def mark_tools_for_long_lived_cache( + tools: Optional[List[Dict[str, Any]]], + long_lived_ttl: str = "1h", +) -> Optional[List[Dict[str, Any]]]: + """Attach cache_control to the last tool in the OpenAI-format tools list. + + Anthropic prefix-cache order is ``tools → system → messages``. Marking + the last tool dict caches the entire tools array (Anthropic's docs: + "the marker is placed on the last block you want included in the cached + prefix"). Marker is preserved across the OpenAI-wire boundary on + OpenRouter and Nous Portal (which proxies to OpenRouter); on native + Anthropic the marker is forwarded by ``convert_tools_to_anthropic``. + + Returns a deep copy of the tools list with the marker attached, or the + input unchanged when tools is empty/None. Pure function — does not + mutate the input. + """ + if not tools: + return tools + out = copy.deepcopy(tools) + last = out[-1] + if isinstance(last, dict): + last["cache_control"] = _build_marker(long_lived_ttl) + return out diff --git a/agent/redact.py b/agent/redact.py index 1ac284cffd4..c6643304a9d 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -64,7 +64,7 @@ _SENSITIVE_BODY_KEYS = frozenset({ # cli.py) or `HERMES_REDACT_SECRETS=false` in ~/.hermes/.env. An opt-out # warning is logged at gateway and CLI startup so operators see the # downgrade — see `_log_redaction_status()` in gateway/run.py and cli.py. -_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "true").lower() in ("1", "true", "yes", "on") +_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "true").lower() in {"1", "true", "yes", "on"} # Known API key prefixes -- match the prefix + contiguous token chars _PREFIX_PATTERNS = [ diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index d45851fea6c..bad5388f88b 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -312,7 +312,7 @@ def _parse_single_entry( ) matcher = None - if matcher is not None and event not in ("pre_tool_call", "post_tool_call"): + if matcher is not None and event not in {"pre_tool_call", "post_tool_call"}: logger.warning( "hooks.%s[%d].matcher=%r will be ignored at runtime — the " "matcher field is only honored for pre_tool_call / " @@ -423,7 +423,7 @@ def _make_callback(spec: ShellHookSpec) -> Callable[..., Optional[Dict[str, Any] def _callback(**kwargs: Any) -> Optional[Dict[str, Any]]: # Matcher gate — only meaningful for tool-scoped events. - if spec.event in ("pre_tool_call", "post_tool_call"): + if spec.event in {"pre_tool_call", "post_tool_call"}: if not spec.matches_tool(kwargs.get("tool_name")): return None @@ -658,7 +658,7 @@ def _prompt_and_record( print() # keep the terminal tidy after ^C return False - if answer in ("y", "yes"): + if answer in {"y", "yes"}: _record_approval(event, command) return True @@ -752,13 +752,13 @@ def _resolve_effective_accept( if accept_hooks_arg: return True env = os.environ.get("HERMES_ACCEPT_HOOKS", "").strip().lower() - if env in ("1", "true", "yes", "on"): + if env in {"1", "true", "yes", "on"}: return True cfg_val = cfg.get("hooks_auto_accept", False) if isinstance(cfg_val, bool): return cfg_val if isinstance(cfg_val, str): - return cfg_val.strip().lower() in ("1", "true", "yes", "on") + return cfg_val.strip().lower() in {"1", "true", "yes", "on"} return False diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 0276d5fc9ac..c8b7d039c46 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -261,7 +261,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: for scan_dir in dirs_to_scan: for skill_md in iter_skill_index_files(scan_dir, "SKILL.md"): - if any(part in ('.git', '.github', '.hub', '.archive') for part in skill_md.parts): + if any(part in {'.git', '.github', '.hub', '.archive'} for part in skill_md.parts): continue try: content = skill_md.read_text(encoding='utf-8') diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 9b0dc32e5cc..7edb69e42c7 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -279,7 +279,7 @@ class ChatCompletionsTransport(ProviderTransport): _kimi_effort = "medium" if reasoning_config and isinstance(reasoning_config, dict): _e = (reasoning_config.get("effort") or "").strip().lower() - if _e in ("low", "medium", "high"): + if _e in {"low", "medium", "high"}: _kimi_effort = _e api_kwargs["reasoning_effort"] = _kimi_effort @@ -294,7 +294,7 @@ class ChatCompletionsTransport(ProviderTransport): _tokenhub_effort = "high" if reasoning_config and isinstance(reasoning_config, dict): _e = (reasoning_config.get("effort") or "").strip().lower() - if _e in ("low", "medium", "high"): + if _e in {"low", "medium", "high"}: _tokenhub_effort = _e api_kwargs["reasoning_effort"] = _tokenhub_effort diff --git a/agent/transports/codex.py b/agent/transports/codex.py index f011034dae8..6738ed3220c 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -104,8 +104,16 @@ class ResponsesApiTransport(ProviderTransport): kwargs["prompt_cache_key"] = session_id if reasoning_enabled and is_xai_responses: + from agent.model_metadata import grok_supports_reasoning_effort + kwargs["include"] = ["reasoning.encrypted_content"] - kwargs["reasoning"] = {"effort": reasoning_effort} + # xAI rejects `reasoning.effort` on grok-4 / grok-4-fast / grok-3 + # / grok-code-fast / grok-4.20-0309-* with HTTP 400 even though + # those models reason natively. Only send the effort dial when + # the target model is on the allowlist; otherwise send no + # `reasoning` key at all and let the model reason on its own. + if grok_supports_reasoning_effort(model): + kwargs["reasoning"] = {"effort": reasoning_effort} elif reasoning_enabled: if is_github_responses: github_reasoning = params.get("github_reasoning_extra") diff --git a/apps/dashboard/src/components/LanguageSwitcher.tsx b/apps/dashboard/src/components/LanguageSwitcher.tsx index dc477021ee8..74a16b1068f 100644 --- a/apps/dashboard/src/components/LanguageSwitcher.tsx +++ b/apps/dashboard/src/components/LanguageSwitcher.tsx @@ -1,36 +1,100 @@ +import { useState, useRef, useEffect } from "react"; import { Button } from "@nous-research/ui/ui/components/button"; import { Typography } from "@/components/NouiTypography"; import { useI18n } from "@/i18n/context"; +import { LOCALE_META } from "@/i18n"; +import type { Locale } from "@/i18n"; /** - * Compact language toggle — shows a clickable flag that switches between - * English and Chinese. Persists choice to localStorage. + * Language picker — shows the current language's flag + endonym, opens a + * dropdown of all supported locales when clicked. Persists choice to + * localStorage via the I18n context. + * + * Replaces the older two-state EN↔ZH toggle now that we ship 16 locales + * (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu). */ export function LanguageSwitcher() { const { locale, setLocale, t } = useI18n(); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); - const toggle = () => setLocale(locale === "en" ? "zh" : "en"); + // Close on outside click / Escape so the dropdown doesn't trap the user. + useEffect(() => { + if (!open) return; + + function onPointerDown(e: PointerEvent) { + if (!containerRef.current) return; + if (!containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const current = LOCALE_META[locale]; + const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>; return ( - - - {locale === "en" ? "EN" : "中文"} - - - + {allLocales.map(([code, meta]) => { + const selected = code === locale; + return ( + + ); + })} + + )} + ); } diff --git a/apps/dashboard/src/i18n/af.ts b/apps/dashboard/src/i18n/af.ts new file mode 100644 index 00000000000..4f49eb12227 --- /dev/null +++ b/apps/dashboard/src/i18n/af.ts @@ -0,0 +1,696 @@ +import type { Translations } from "./types"; + +export const af: Translations = { + common: { + save: "Stoor", + saving: "Besig om te stoor...", + cancel: "Kanselleer", + close: "Maak toe", + confirm: "Bevestig", + delete: "Skrap", + refresh: "Herlaai", + retry: "Probeer weer", + search: "Soek...", + loading: "Besig om te laai...", + create: "Skep", + creating: "Besig om te skep...", + set: "Stel", + replace: "Vervang", + clear: "Vee uit", + live: "Lewendig", + off: "Af", + enabled: "geaktiveer", + disabled: "gedeaktiveer", + active: "aktief", + inactive: "onaktief", + unknown: "onbekend", + untitled: "Sonder titel", + none: "Geen", + form: "Vorm", + noResults: "Geen resultate", + of: "van", + page: "Bladsy", + msgs: "boodskappe", + tools: "gereedskap", + match: "passing", + other: "Ander", + configured: "gekonfigureer", + removed: "verwyder", + failedToToggle: "Kon nie wissel nie", + failedToRemove: "Kon nie verwyder nie", + failedToReveal: "Kon nie openbaar nie", + collapse: "Vou in", + expand: "Vou uit", + general: "Algemeen", + messaging: "Boodskappe", + pluginLoadFailed: + "Kon nie hierdie inprop se skrip laai nie. Kontroleer die Netwerk-oortjie (dashboard-plugins/…) en die bediener se inprop-pad.", + pluginNotRegistered: + "Die inprop se skrip het nie register() geroep nie, of die skrip het 'n fout gegee. Maak die blaaier-konsole oop vir besonderhede.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "Maak navigasie toe", + closeModelTools: "Maak model en gereedskap toe", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "Aktiewe Sessies:", + gatewayStatusLabel: "Gateway-status:", + gatewayStrip: { + failed: "Begin het misluk", + off: "Af", + running: "Loop", + starting: "Begin", + stopped: "Gestop", + }, + nav: { + analytics: "Analise", + chat: "Klets", + config: "Konfigurasie", + cron: "Cron", + documentation: "Dokumentasie", + keys: "Sleutels", + logs: "Logs", + models: "Modelle", + profiles: "profiele : multi-agente", + plugins: "Inproppe", + sessions: "Sessies", + skills: "Vaardighede", + }, + modelToolsSheetSubtitle: "& gereedskap", + modelToolsSheetTitle: "Model", + navigation: "Navigasie", + openDocumentation: "Maak dokumentasie in 'n nuwe oortjie oop", + openNavigation: "Maak navigasie oop", + pluginNavSection: "Inproppe", + sessionsActiveCount: "{count} aktief", + statusOverview: "Statusoorsig", + system: "Stelsel", + webUi: "Web UI", + }, + + status: { + actionFailed: "Aksie het misluk", + actionFinished: "Voltooi", + actions: "Aksies", + agent: "Agent", + activeSessions: "Aktiewe Sessies", + connected: "Gekoppel", + connectedPlatforms: "Gekoppelde Platforms", + disconnected: "Ontkoppel", + error: "Fout", + failed: "Misluk", + gateway: "Gateway", + gatewayFailedToStart: "Gateway kon nie begin nie", + lastUpdate: "Laaste opdatering", + noneRunning: "Geen", + notRunning: "Loop nie", + pid: "PID", + platformDisconnected: "ontkoppel", + platformError: "fout", + recentSessions: "Onlangse Sessies", + restartGateway: "Herbegin Gateway", + restartingGateway: "Besig om gateway te herbegin…", + running: "Loop", + runningRemote: "Loop (afgeleë)", + startFailed: "Begin het misluk", + starting: "Begin", + startedInBackground: "Begin in agtergrond — kyk logs vir vordering", + stopped: "Gestop", + updateHermes: "Werk Hermes op", + updatingHermes: "Besig om Hermes op te werk…", + waitingForOutput: "Wag vir uitset…", + }, + + sessions: { + title: "Sessies", + searchPlaceholder: "Soek boodskap-inhoud...", + noSessions: "Nog geen sessies nie", + noMatch: "Geen sessies stem ooreen met jou soektog nie", + startConversation: "Begin 'n gesprek om dit hier te sien", + noMessages: "Geen boodskappe", + untitledSession: "Sessie sonder titel", + deleteSession: "Skrap sessie", + confirmDeleteTitle: "Skrap sessie?", + confirmDeleteMessage: + "Dit verwyder die gesprek en al sy boodskappe permanent. Dit kan nie ongedaan gemaak word nie.", + sessionDeleted: "Sessie geskrap", + failedToDelete: "Kon nie sessie skrap nie", + resumeInChat: "Hervat in Klets", + previousPage: "Vorige bladsy", + nextPage: "Volgende bladsy", + roles: { + user: "Gebruiker", + assistant: "Assistent", + system: "Stelsel", + tool: "Gereedskap", + }, + }, + + analytics: { + period: "Tydperk:", + totalTokens: "Totale Tokens", + totalSessions: "Totale Sessies", + apiCalls: "API-oproepe", + dailyTokenUsage: "Daaglikse Tokengebruik", + dailyBreakdown: "Daaglikse Uiteensetting", + perModelBreakdown: "Per-Model Uiteensetting", + topSkills: "Top Vaardighede", + skill: "Vaardigheid", + loads: "Agent Gelaai", + edits: "Agent Bestuur", + lastUsed: "Laas Gebruik", + input: "Inset", + output: "Uitset", + total: "Totaal", + noUsageData: "Geen gebruiksdata vir hierdie tydperk nie", + startSession: "Begin 'n sessie om analise hier te sien", + date: "Datum", + model: "Model", + tokens: "Tokens", + perDayAvg: "/dag gem.", + acrossModels: "oor {count} modelle", + inOut: "{input} in / {output} uit", + }, + + models: { + modelsUsed: "Modelle Gebruik", + estimatedCost: "Geskatte Koste", + tokens: "tokens", + sessions: "sessies", + avgPerSession: "gem./sessie", + apiCalls: "API-oproepe", + toolCalls: "gereedskap-oproepe", + noModelsData: "Geen modelgebruiksdata vir hierdie tydperk nie", + startSession: "Begin 'n sessie om modeldata hier te sien", + }, + + logs: { + title: "Logs", + autoRefresh: "Outo-herlaai", + file: "Lêer", + level: "Vlak", + component: "Komponent", + lines: "Reëls", + noLogLines: "Geen logreëls gevind nie", + }, + + cron: { + confirmDeleteMessage: + "Dit verwyder die taak van die skedule. Dit kan nie ongedaan gemaak word nie.", + confirmDeleteTitle: "Skrap geskeduleerde taak?", + newJob: "Nuwe Cron-taak", + nameOptional: "Naam (opsioneel)", + namePlaceholder: "bv. Daaglikse opsomming", + prompt: "Opdrag", + promptPlaceholder: "Wat moet die agent met elke uitvoering doen?", + schedule: "Skedule (cron-uitdrukking)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Lewer aan", + scheduledJobs: "Geskeduleerde Take", + noJobs: "Geen cron-take gekonfigureer nie. Skep een hierbo.", + last: "Laaste", + next: "Volgende", + pause: "Pouse", + resume: "Hervat", + triggerNow: "Voer nou uit", + delivery: { + local: "Plaaslik", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "Nuwe Profiel", + name: "Naam", + namePlaceholder: "bv. coder, writer, ens.", + nameRequired: "Naam word vereis", + nameRule: + "Slegs kleinletters, syfers, _ en -; moet met 'n letter of syfer begin; tot 64 karakters.", + invalidName: "Ongeldige profielnaam", + cloneFromDefault: "Kloon konfigurasie vanaf verstekprofiel", + allProfiles: "Profiele", + noProfiles: "Geen profiele gevind nie.", + defaultBadge: "verstek", + hasEnv: "env", + model: "Model", + skills: "Vaardighede", + rename: "Hernoem", + editSoul: "Wysig SOUL.md", + soulSection: "SOUL.md (persoonlikheid / stelselopdrag)", + soulPlaceholder: "# Hoe hierdie agent moet optree…", + saveSoul: "Stoor SOUL", + soulSaved: "SOUL.md gestoor", + openInTerminal: "Kopieer CLI-opdrag", + commandCopied: "Na knipbord gekopieer", + copyFailed: "Kon nie kopieer nie", + confirmDeleteTitle: "Skrap profiel?", + confirmDeleteMessage: + "Dit skrap profiel '{name}' permanent — konfigurasie, sleutels, geheue, sessies, vaardighede, cron-take. Kan nie ongedaan gemaak word nie.", + created: "Geskep", + deleted: "Geskrap", + renamed: "Hernoem", + }, + + pluginsPage: { + contextEngineLabel: "Konteks-enjin", + dashboardSlots: "Dashboard-gleuwe", + disableRuntime: "Deaktiveer", + enableAfterInstall: "Aktiveer ná installasie", + enableRuntime: "Aktiveer", + forceReinstall: "Forseer herinstallasie (skrap eers bestaande gids)", + headline: + "Ontdek, installeer, aktiveer en werk Hermes-inproppe op (`hermes plugins` ekwivalent).", + identifierLabel: "Git-URL of owner/repo", + inactive: "onaktief", + installBtn: "Installeer vanaf Git", + installHeading: "Installeer vanaf GitHub / Git-URL", + installHint: "Gebruik owner/repo-kortvorm of 'n volledige https:// of git@ kloon-URL.", + memoryProviderLabel: "Geheueverskaffer", + missingEnvWarn: "Stel hierdie in Sleutels voordat die inprop kan loop:", + noDashboardTab: "Geen dashboard-oortjie", + openTab: "Maak oop", + orphanHeading: "Slegs-dashboard-uitbreidings (geen ooreenstemmende agent plugin.yaml nie)", + pluginListHeading: "Geïnstalleerde inproppe", + providerDefaults: "ingebou / verstek", + providersHeading: "Looptyd-verskafferinproppe", + providersHint: + "Skryf memory.provider (leeg = ingebou) en context.engine na config.yaml. Tree volgende sessie in werking.", + refreshDashboard: "Herskandeer dashboard-uitbreidings", + removeConfirm: "Verwyder hierdie inprop uit ~/.hermes/plugins/?", + removeHint: "Slegs gebruiker-geïnstalleerde inproppe onder ~/.hermes/plugins kan verwyder word.", + rescanHeading: "SPA-inprop-register", + rescanHint: "Herskandeer ná die byvoeg van lêers op skyf sodat die dashboard-sybalk nuwe manifeste optel.", + runtimeHeading: "Gateway-looptyd (YAML-inproppe)", + saveProviders: "Stoor verskaffer-instellings", + savedProviders: "Verskaffer-instellings gestoor.", + sourceBadge: "Bron", + authRequired: "Verifikasie vereis", + authRequiredHint: "Voer hierdie opdrag uit om te verifieer:", + updateGit: "Git pull", + versionBadge: "Weergawe", + showInSidebar: "Wys in sybalk", + hideFromSidebar: "Versteek van sybalk", + }, + + skills: { + title: "Vaardighede", + searchPlaceholder: "Soek vaardighede en gereedskapstelle...", + enabledOf: "{enabled}/{total} geaktiveer", + all: "Alles", + categories: "Kategorieë", + filters: "Filters", + noSkills: "Geen vaardighede gevind nie. Vaardighede word gelaai uit ~/.hermes/skills/", + noSkillsMatch: "Geen vaardighede stem ooreen met jou soektog of filter nie.", + skillCount: "{count} vaardighe{s}id", + resultCount: "{count} resulta{s}at", + noDescription: "Geen beskrywing beskikbaar nie.", + toolsets: "Gereedskapstelle", + toolsetLabel: "{name} gereedskapstel", + noToolsetsMatch: "Geen gereedskapstelle stem ooreen met die soektog nie.", + setupNeeded: "Opstelling nodig", + disabledForCli: "Gedeaktiveer vir CLI", + more: "+{count} meer", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "Filters", + sections: "Afdelings", + exportConfig: "Voer konfigurasie uit as JSON", + importConfig: "Voer konfigurasie in vanaf JSON", + resetDefaults: "Stel terug na verstek", + resetScopeTooltip: "Stel {scope} terug na verstek", + confirmResetScope: "Stel alle {scope}-instellings terug na hul verstek? Dit werk slegs die vorm op — veranderinge word nie na config.yaml geskryf voordat jy Stoor druk nie.", + resetScopeToast: "{scope} teruggestel na verstek — kontroleer en Stoor om te behou", + rawYaml: "Rou YAML-konfigurasie", + searchResults: "Soekresultate", + fields: "veld{s}", + noFieldsMatch: 'Geen velde stem ooreen met "{query}" nie', + configSaved: "Konfigurasie gestoor", + yamlConfigSaved: "YAML-konfigurasie gestoor", + failedToSave: "Kon nie stoor nie", + failedToSaveYaml: "Kon nie YAML stoor nie", + failedToLoadRaw: "Kon nie rou konfigurasie laai nie", + configImported: "Konfigurasie ingevoer — kontroleer en stoor", + invalidJson: "Ongeldige JSON-lêer", + categories: { + general: "Algemeen", + agent: "Agent", + terminal: "Terminaal", + display: "Vertoon", + delegation: "Delegasie", + memory: "Geheue", + compression: "Kompressie", + security: "Sekuriteit", + browser: "Blaaier", + voice: "Stem", + tts: "Teks-na-Spraak", + stt: "Spraak-na-Teks", + logging: "Aantekening", + discord: "Discord", + auxiliary: "Hulpmiddels", + }, + }, + + env: { + changesNote: "Veranderinge word onmiddellik na skyf gestoor. Aktiewe sessies tel nuwe sleutels outomaties op.", + confirmClearMessage: + "Die gestoorde waarde vir hierdie veranderlike sal uit jou .env-lêer verwyder word. Dit kan nie vanaf die UI ongedaan gemaak word nie.", + confirmClearTitle: "Vee hierdie sleutel uit?", + description: "Bestuur API-sleutels en geheime gestoor in", + hideAdvanced: "Versteek Gevorderd", + showAdvanced: "Wys Gevorderd", + llmProviders: "LLM-verskaffers", + providersConfigured: "{configured} van {total} verskaffers gekonfigureer", + getKey: "Kry sleutel", + notConfigured: "{count} nie gekonfigureer nie", + notSet: "Nie gestel nie", + keysCount: "{count} sleutel{s}", + enterValue: "Voer waarde in...", + replaceCurrentValue: "Vervang huidige waarde ({preview})", + showValue: "Wys werklike waarde", + hideValue: "Versteek waarde", + }, + + oauth: { + title: "Verskaffer-aanmeldings (OAuth)", + providerLogins: "Verskaffer-aanmeldings (OAuth)", + description: "{connected} van {total} OAuth-verskaffers gekoppel. Aanmeldvloei loop tans via die CLI; klik Kopieer opdrag en plak in 'n terminaal om op te stel.", + connected: "Gekoppel", + expired: "Verval", + notConnected: "Nie gekoppel nie. Voer {command} uit in 'n terminaal.", + runInTerminal: "in 'n terminaal.", + noProviders: "Geen OAuth-bekwame verskaffers opgespoor nie.", + login: "Meld aan", + disconnect: "Ontkoppel", + managedExternally: "Ekstern bestuur", + copied: "Gekopieer ✓", + cli: "CLI", + copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)", + connect: "Koppel", + sessionExpires: "Sessie verval oor {time}", + initiatingLogin: "Aanmeldvloei word begin…", + exchangingCode: "Kode word vir tokens omgeruil…", + connectedClosing: "Gekoppel! Besig om toe te maak…", + loginFailed: "Aanmelding het misluk.", + sessionExpired: "Sessie het verval. Klik Probeer weer om 'n nuwe aanmelding te begin.", + reOpenAuth: "Heropen verifikasiebladsy", + reOpenVerification: "Heropen verifikasiebladsy", + submitCode: "Dien kode in", + pasteCode: "Plak magtigingskode (met #state agtervoegsel is in die haak)", + waitingAuth: "Wag vir jou om in die blaaier te magtig…", + enterCodePrompt: "'n Nuwe oortjie het oopgegaan. Voer hierdie kode in indien gevra:", + pkceStep1: "'n Nuwe oortjie het na claude.ai oopgegaan. Meld aan en klik Magtig.", + pkceStep2: "Kopieer die magtigingskode wat ná magtiging vertoon word.", + pkceStep3: "Plak dit hieronder en dien in.", + flowLabels: { + pkce: "Blaaier-aanmelding (PKCE)", + device_code: "Toestel-kode", + external: "Eksterne CLI", + }, + expiresIn: "verval oor {time}", + }, + + language: { + switchTo: "Skakel oor na Engels", + }, + + theme: { + title: "Tema", + switchTheme: "Wissel tema", + }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Versamelbare Hermes-kentekens wat verdien word uit werklike sessiegeskiedenis. Bekende, onvoltooide prestasies word as Ontdek vertoon; Geheime prestasies bly verborge totdat die eerste ooreenstemmende gedrag verskyn.", + scan_subtitle: + "Hermes-sessiegeskiedenis word geskandeer. Die eerste skandering kan 5–10 sekondes neem op groot geskiedenisse.", + }, + actions: { + rescan: "Herskandeer", + }, + stats: { + unlocked: "Ontsluit", + unlocked_hint: "verdiende kentekens", + discovered: "Ontdek", + discovered_hint: "bekend, nog nie verdien nie", + secrets: "Geheime", + secrets_hint: "verborge tot eerste sein", + highest_tier: "Hoogste vlak", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "Jongste", + latest_hint_empty: "gebruik Hermes meer", + none_yet: "Nog geen", + }, + state: { + unlocked: "Ontsluit", + discovered: "Ontdek", + secret: "Geheim", + }, + tier: { + target: "Teiken {tier}", + hidden: "Verborge", + complete: "Voltooi", + objective: "Doelwit", + }, + progress: { + hidden: "verborge", + }, + scan: { + building_headline: "Prestasieprofiel word gebou…", + building_detail: + "Sessies, gereedskaproepe, modelmetadata en ontsluitstatus word gelees.", + starting_headline: "Prestasieskandering begin…", + progress_detail: + "{scanned} van {total} sessies geskandeer · {pct}%. Kentekens ontsluit soos meer geskiedenis instroom.", + idle_detail: + "Sessies, gereedskaproepe, modelmetadata en ontsluitstatus word gelees. Kentekens verskyn hier soos hulle ontsluit.", + }, + guide: { + tiers_header: "Vlakke", + secret_header: "Geheime prestasies", + secret_body: + "Geheime hou hul presiese sneller verborge. Sodra Hermes 'n verwante sein sien, word die kaart Ontdek en wys sy vereiste.", + scan_status_header: "Skanderingstatus", + scan_status_body: + "Hermes skandeer plaaslike geskiedenis een keer, daarna verskyn kaarte outomaties. Niks is vasgevang as dit 'n paar sekondes neem nie.", + what_scanned_header: "Wat geskandeer word", + what_scanned_body: + "Sessies, gereedskaproepe, modelmetadata, foute, prestasies en plaaslike ontsluitstatus.", + }, + card: { + share_title: "Deel hierdie prestasie", + share_label: "Deel {name}", + share_text: "Deel", + how_to_reveal: "Hoe om te onthul", + what_counts: "Wat tel", + evidence_label: "Bewys", + evidence_session_fallback: "sessie", + no_evidence: "Nog geen bewys nie", + }, + latest: { + header: "Onlangse ontsluitings", + }, + empty: { + no_secrets_header: "Geen verborge geheime in hierdie skandering oor nie.", + no_secrets_body: + "Wenk: geheime begin gewoonlik by ongewone mislukkings of magsgebruikerspatrone — poortbotsings, toestemmingsmure, ontbrekende env-veranderlikes, YAML-foute, Docker-botsings, terugrol/kontrolepunt-gebruik, kasterugslae of klein regstellings na baie rooi teks.", + }, + filters: { + all_categories: "Alles", + visibility_all: "alles", + visibility_unlocked: "ontsluit", + visibility_discovered: "ontdek", + visibility_secret: "geheim", + }, + share: { + dialog_label: "Deel prestasie", + header: "Deel: {name}", + close: "Maak toe", + rendering: "Lewer tans…", + card_alt: "{name} deelkaart", + error_generic: "Iets het verkeerd geloop.", + x_title: "Maak X oop met 'n vooraf-ingevulde plasing", + x_button: "Deel op X", + copy_title: "Kopieer die beeld om in jou plasing te plak", + copy_button: "Kopieer beeld", + copied: "Gekopieer ✓", + download_button: "Laai PNG af", + hint: + "Deel op X maak 'n vooraf-ingevulde plasing in 'n nuwe oortjie oop. Klik eers op Kopieer beeld as jy die 1200×630-kenteken aangeheg wil hê — X laat jou dit direk in die tweet-skrywer plak. Laai PNG af stoor die lêer om enige plek te gebruik.", + clipboard_unsupported: + "Beeldkopiëring na knipbord word nie in hierdie blaaier ondersteun nie — gebruik eerder Aflaai.", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Kanban-bord word gelaai…", + loadFailed: "Kon nie Kanban-bord laai nie: ", + loadFailedHint: + "Die agterkant skep kanban.db outomaties met die eerste lees. Indien hierdie probleem aanhou, raadpleeg die paneellogboeke.", + board: "Bord", + newBoard: "+ Nuwe bord", + newBoardTitle: "Nuwe bord", + newBoardDescription: + "Borde laat u toe om onverwante werkstrome te skei — een per projek, repositorium of domein. Werkers op een bord sien nooit 'n ander bord se take nie.", + slug: "Slug", + slugHint: "— kleinletters, koppeltekens, bv. atm10-server", + displayName: "Vertoonnaam", + displayNameHint: "(opsioneel)", + description: "Beskrywing", + descriptionHint: "(opsioneel)", + icon: "Ikoon", + iconHint: "(enkele karakter of emoji)", + switchAfterCreate: "Skakel oor na hierdie bord nadat dit geskep is", + cancel: "Kanselleer", + creating: "Word geskep…", + createBoard: "Skep bord", + search: "Soek", + filterCards: "Filter kaarte…", + tenant: "Huurder", + allTenants: "Alle huurders", + assignee: "Toegewysde", + allProfiles: "Alle profiele", + showArchived: "Wys gearchiveerde", + lanesByProfile: "Bane per profiel", + nudgeDispatcher: "Por versender aan", + refresh: "Verfris", + selected: "gekies", + complete: "Voltooi", + archive: "Argiveer", + apply: "Pas toe", + clear: "Maak skoon", + createTask: "Skep taak in hierdie kolom", + noTasks: "— geen take —", + unassigned: "nie toegewys nie", + untitled: "(sonder titel)", + loadingDetail: "Word gelaai…", + addComment: "Voeg 'n opmerking by… (Enter om in te dien)", + comment: "Opmerking", + status: "Status", + workspace: "Werkruimte", + skills: "Vaardighede", + createdBy: "Geskep deur", + result: "Resultaat", + comments: "Opmerkings", + events: "Gebeurtenisse", + runHistory: "Uitvoergeskiedenis", + workerLog: "Werker-log", + loadingLog: "Log word gelaai…", + noWorkerLog: + "— nog geen werker-log nie (taak is nog nie ontketen nie of die log is geroteer) —", + noDescription: "— geen beskrywing —", + noComments: "— geen opmerkings —", + edit: "redigeer", + save: "Stoor", + dependencies: "Afhanklikhede", + parents: "Ouers:", + children: "Kinders:", + none: "geen", + addParent: "— voeg ouer by —", + addChild: "— voeg kind by —", + removeDependency: "Verwyder afhanklikheid", + block: "Blokkeer", + unblock: "Deblokkeer", + notifyHomeChannels: "Stel tuiskanale in kennis", + diagnostics: "Diagnostiek", + hide: "Versteek", + show: "Wys", + attention: "Aandag", + tasksNeedAttention: "take benodig aandag", + taskNeedsAttention: "1 taak benodig aandag", + diagnostic: "diagnose", + open: "Maak oop", + close: "Sluit (Esc)", + reassignTo: "Hertoeken aan:", + copied: "Gekopieer", + copyCommand: "Kopieer opdrag na knipbord", + reclaim: "Heroor", + reassign: "Hertoeken", + renderingError: "Kanban-oortjie het 'n weergawefout teëgekom", + reloadView: "Herlaai aansig", + wsAuthFailed: + "WebSocket-verifikasie het misluk — herlaai die bladsy om die sessietoken te verfris.", + markDone: "Merk {n} take as klaar?", + markArchived: "Argiveer {n} take?", + warning: "Waarskuwing", + phantomIds: "Spook-ID's:", + active: "aktief", + ended: "geëindig", + noProfile: "(geen profiel)", + showAllAttempts: "Wys alle pogings", + sendingUpdates: "Stuur opdaterings na", + sendNotifications: "Stuur completed / blocked / gave_up kennisgewings na", + archiveBoardConfirm: + "Argiveer bord '{name}'? Dit sal na boards/_archived/ geskuif word sodat u dit later kan herstel. Take op hierdie bord sal nie meer in die UI verskyn nie.", + archiveBoardTitle: "Argiveer hierdie bord", + boardSwitcherHint: "Borde laat u toe om onverwante werkstrome te skei", + taskCreatedWarning: "Taak geskep, maar: ", + moveFailed: "Skuif het misluk: ", + bulkFailed: "Grootmaat: ", + completionBlockedHallucination: "⚠ Voltooiing geblokkeer — spook-kaart-ID's", + suspectedHallucinatedReferences: "⚠ Teks het na spook-kaart-ID's verwys", + pickProfileFirst: "Kies eers 'n profiel.", + unblockedMessage: "{id} gedeblokkeer. Taak is gereed vir die volgende tik.", + unblockFailed: "Deblokkering het misluk: ", + reclaimedMessage: "{id} heroor. Taak is terug op gereed.", + reclaimFailed: "Heroornaming het misluk: ", + reassignedMessage: "{id} hertoegeken aan {profile}.", + reassignFailed: "Hertoekenning het misluk: ", + selectForBulk: "Kies vir grootmaataksies", + clickToEdit: "Klik om te redigeer", + clickToEditAssignee: "Klik om toegewysde te redigeer", + emptyAssignee: "(leeg = ontbind toekenning)", + columnLabels: { + triage: "Triage", + todo: "Te doen", + ready: "Gereed", + running: "Aan die gang", + blocked: "Geblokkeer", + done: "Klaar", + archived: "Gearchiveer", + }, + columnHelp: { + triage: "Rou idees — 'n spesifiseerder sal die spesifikasie uitwerk", + todo: "Wag op afhanklikhede of nie toegewys nie", + ready: "Toegewys en wag vir 'n versender-tik", + running: "Deur 'n werker geëis — in vlug", + blocked: "Werker het mensinvoer aangevra", + done: "Voltooi", + archived: "Gearchiveer", + }, + confirmDone: + "Merk hierdie taak as klaar? Die werker se eis word vrygestel en afhanklike kinders word gereed.", + confirmArchive: + "Argiveer hierdie taak? Dit verdwyn uit die verstek-bordaansig.", + confirmBlocked: + "Merk hierdie taak as geblokkeer? Die werker se eis word vrygestel.", + completionSummary: + "Voltooiingsopsomming vir {label}. Dit word as die taak se result gestoor.", + completionSummaryRequired: + "'n Voltooiingsopsomming is verpligtend voordat 'n taak as klaar gemerk word.", + triagePlaceholder: "Rowwe idee — KI sal dit spesifiseer…", + taskTitlePlaceholder: "Nuwe taaktitel…", + specifier: "spesifiseerder", + assigneePlaceholder: "toegewysde", + priority: "Prioriteit", + skillsPlaceholder: + "vaardighede (opsioneel, kommageskei): translation, github-code-review", + noParent: "— geen ouer —", + workspacePathDir: "werkruimtepad (verpligtend, bv. ~/projects/my-app)", + workspacePathOptional: + "werkruimtepad (opsioneel, afgelei van toegewysde indien leeg)", + logTruncated: "(toon laaste 100 KB — volledige log by ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/context.tsx b/apps/dashboard/src/i18n/context.tsx index 6fc6f6e56a0..7d6fecf5c9b 100644 --- a/apps/dashboard/src/i18n/context.tsx +++ b/apps/dashboard/src/i18n/context.tsx @@ -2,14 +2,74 @@ import { createContext, useContext, useState, useCallback, type ReactNode } from import type { Locale, Translations } from "./types"; import { en } from "./en"; import { zh } from "./zh"; +import { zhHant } from "./zh-hant"; +import { ja } from "./ja"; +import { de } from "./de"; +import { es } from "./es"; +import { fr } from "./fr"; +import { tr } from "./tr"; +import { uk } from "./uk"; +import { af } from "./af"; +import { ko } from "./ko"; +import { it } from "./it"; +import { ga } from "./ga"; +import { pt } from "./pt"; +import { ru } from "./ru"; +import { hu } from "./hu"; -const TRANSLATIONS: Record = { en, zh }; +const TRANSLATIONS: Record = { + en, + zh, + "zh-hant": zhHant, + ja, + de, + es, + fr, + tr, + uk, + af, + ko, + it, + ga, + pt, + ru, + hu, +}; + +// Display metadata for the language picker — endonym (native name) so users +// recognize their language even if they don't speak the current UI language, +// plus a flag emoji for visual scanning. Exposed as a constant so the +// LanguageSwitcher and any future settings page can share the same list. +export const LOCALE_META: Record = { + en: { name: "English", flag: "🇬🇧" }, + zh: { name: "简体中文", flag: "🇨🇳" }, + "zh-hant": { name: "繁體中文", flag: "🇹🇼" }, + ja: { name: "日本語", flag: "🇯🇵" }, + de: { name: "Deutsch", flag: "🇩🇪" }, + es: { name: "Español", flag: "🇪🇸" }, + fr: { name: "Français", flag: "🇫🇷" }, + tr: { name: "Türkçe", flag: "🇹🇷" }, + uk: { name: "Українська", flag: "🇺🇦" }, + af: { name: "Afrikaans", flag: "🇿🇦" }, + ko: { name: "한국어", flag: "🇰🇷" }, + it: { name: "Italiano", flag: "🇮🇹" }, + ga: { name: "Gaeilge", flag: "🇮🇪" }, + pt: { name: "Português", flag: "🇵🇹" }, + ru: { name: "Русский", flag: "🇷🇺" }, + hu: { name: "Magyar", flag: "🇭🇺" }, +}; + +const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[]; const STORAGE_KEY = "hermes-locale"; +function isLocale(value: string): value is Locale { + return (SUPPORTED_LOCALES as string[]).includes(value); +} + function getInitialLocale(): Locale { try { const stored = localStorage.getItem(STORAGE_KEY); - if (stored === "en" || stored === "zh") return stored; + if (stored && isLocale(stored)) return stored; } catch { // SSR or privacy mode } diff --git a/apps/dashboard/src/i18n/de.ts b/apps/dashboard/src/i18n/de.ts new file mode 100644 index 00000000000..c70ccfe8701 --- /dev/null +++ b/apps/dashboard/src/i18n/de.ts @@ -0,0 +1,695 @@ +import type { Translations } from "./types"; + +export const de: Translations = { + common: { + save: "Speichern", + saving: "Speichern...", + cancel: "Abbrechen", + close: "Schließen", + confirm: "Bestätigen", + delete: "Löschen", + refresh: "Aktualisieren", + retry: "Erneut versuchen", + search: "Suchen...", + loading: "Lädt...", + create: "Erstellen", + creating: "Erstellen...", + set: "Festlegen", + replace: "Ersetzen", + clear: "Leeren", + live: "Live", + off: "Aus", + enabled: "aktiviert", + disabled: "deaktiviert", + active: "aktiv", + inactive: "inaktiv", + unknown: "unbekannt", + untitled: "Ohne Titel", + none: "Keine", + form: "Formular", + noResults: "Keine Ergebnisse", + of: "von", + page: "Seite", + msgs: "Nachr.", + tools: "Werkzeuge", + match: "Treffer", + other: "Sonstige", + configured: "konfiguriert", + removed: "entfernt", + failedToToggle: "Umschalten fehlgeschlagen", + failedToRemove: "Entfernen fehlgeschlagen", + failedToReveal: "Anzeigen fehlgeschlagen", + collapse: "Einklappen", + expand: "Ausklappen", + general: "Allgemein", + messaging: "Messaging", + pluginLoadFailed: + "Das Skript dieses Plugins konnte nicht geladen werden. Prüfe den Netzwerk-Tab (dashboard-plugins/…) und den Plugin-Pfad des Servers.", + pluginNotRegistered: + "Das Skript des Plugins hat register() nicht aufgerufen oder ist fehlgeschlagen. Öffne die Browser-Konsole für Details.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "Navigation schließen", + closeModelTools: "Modell und Werkzeuge schließen", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "Aktive Sitzungen:", + gatewayStatusLabel: "Gateway-Status:", + gatewayStrip: { + failed: "Start fehlgeschlagen", + off: "Aus", + running: "Läuft", + starting: "Startet", + stopped: "Gestoppt", + }, + nav: { + analytics: "Analyse", + chat: "Chat", + config: "Konfiguration", + cron: "Cron", + documentation: "Dokumentation", + keys: "Schlüssel", + logs: "Protokolle", + models: "Modelle", + profiles: "Profile : Multi-Agenten", + plugins: "Plugins", + sessions: "Sitzungen", + skills: "Skills", + }, + modelToolsSheetSubtitle: "& Werkzeuge", + modelToolsSheetTitle: "Modell", + navigation: "Navigation", + openDocumentation: "Dokumentation in neuem Tab öffnen", + openNavigation: "Navigation öffnen", + pluginNavSection: "Plugins", + sessionsActiveCount: "{count} aktiv", + statusOverview: "Statusübersicht", + system: "System", + webUi: "Web UI", + }, + + status: { + actionFailed: "Aktion fehlgeschlagen", + actionFinished: "Abgeschlossen", + actions: "Aktionen", + agent: "Agent", + activeSessions: "Aktive Sitzungen", + connected: "Verbunden", + connectedPlatforms: "Verbundene Plattformen", + disconnected: "Getrennt", + error: "Fehler", + failed: "Fehlgeschlagen", + gateway: "Gateway", + gatewayFailedToStart: "Gateway konnte nicht gestartet werden", + lastUpdate: "Letzte Aktualisierung", + noneRunning: "Keine", + notRunning: "Läuft nicht", + pid: "PID", + platformDisconnected: "getrennt", + platformError: "Fehler", + recentSessions: "Letzte Sitzungen", + restartGateway: "Gateway neu starten", + restartingGateway: "Gateway wird neu gestartet…", + running: "Läuft", + runningRemote: "Läuft (remote)", + startFailed: "Start fehlgeschlagen", + starting: "Startet", + startedInBackground: "Im Hintergrund gestartet — siehe Protokolle für den Fortschritt", + stopped: "Gestoppt", + updateHermes: "Hermes aktualisieren", + updatingHermes: "Hermes wird aktualisiert…", + waitingForOutput: "Warte auf Ausgabe…", + }, + + sessions: { + title: "Sitzungen", + searchPlaceholder: "Nachrichteninhalt suchen...", + noSessions: "Noch keine Sitzungen", + noMatch: "Keine Sitzungen entsprechen deiner Suche", + startConversation: "Starte eine Unterhaltung, um sie hier zu sehen", + noMessages: "Keine Nachrichten", + untitledSession: "Sitzung ohne Titel", + deleteSession: "Sitzung löschen", + confirmDeleteTitle: "Sitzung löschen?", + confirmDeleteMessage: + "Dies entfernt die Unterhaltung und alle Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.", + sessionDeleted: "Sitzung gelöscht", + failedToDelete: "Sitzung konnte nicht gelöscht werden", + resumeInChat: "Im Chat fortsetzen", + previousPage: "Vorherige Seite", + nextPage: "Nächste Seite", + roles: { + user: "Benutzer", + assistant: "Assistent", + system: "System", + tool: "Werkzeug", + }, + }, + + analytics: { + period: "Zeitraum:", + totalTokens: "Tokens gesamt", + totalSessions: "Sitzungen gesamt", + apiCalls: "API-Aufrufe", + dailyTokenUsage: "Tägliche Token-Nutzung", + dailyBreakdown: "Tagesaufschlüsselung", + perModelBreakdown: "Aufschlüsselung pro Modell", + topSkills: "Top-Skills", + skill: "Skill", + loads: "Agent geladen", + edits: "Agent verwaltet", + lastUsed: "Zuletzt verwendet", + input: "Eingabe", + output: "Ausgabe", + total: "Gesamt", + noUsageData: "Keine Nutzungsdaten für diesen Zeitraum", + startSession: "Starte eine Sitzung, um hier Analysen zu sehen", + date: "Datum", + model: "Modell", + tokens: "Tokens", + perDayAvg: "/Tag Ø", + acrossModels: "über {count} Modelle", + inOut: "{input} ein / {output} aus", + }, + + models: { + modelsUsed: "Verwendete Modelle", + estimatedCost: "Gesch. Kosten", + tokens: "Tokens", + sessions: "Sitzungen", + avgPerSession: "Ø/Sitzung", + apiCalls: "API-Aufrufe", + toolCalls: "Werkzeug-Aufrufe", + noModelsData: "Keine Modellnutzungsdaten für diesen Zeitraum", + startSession: "Starte eine Sitzung, um hier Modelldaten zu sehen", + }, + + logs: { + title: "Protokolle", + autoRefresh: "Auto-Aktualisierung", + file: "Datei", + level: "Stufe", + component: "Komponente", + lines: "Zeilen", + noLogLines: "Keine Protokollzeilen gefunden", + }, + + cron: { + confirmDeleteMessage: + "Damit wird die Aufgabe aus dem Zeitplan entfernt. Dies kann nicht rückgängig gemacht werden.", + confirmDeleteTitle: "Geplante Aufgabe löschen?", + newJob: "Neue Cron-Aufgabe", + nameOptional: "Name (optional)", + namePlaceholder: "z. B. Tägliche Zusammenfassung", + prompt: "Prompt", + promptPlaceholder: "Was soll der Agent bei jedem Lauf tun?", + schedule: "Zeitplan (Cron-Ausdruck)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Zustellen an", + scheduledJobs: "Geplante Aufgaben", + noJobs: "Keine Cron-Aufgaben konfiguriert. Erstelle oben eine.", + last: "Zuletzt", + next: "Nächste", + pause: "Pausieren", + resume: "Fortsetzen", + triggerNow: "Jetzt auslösen", + delivery: { + local: "Lokal", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "Neues Profil", + name: "Name", + namePlaceholder: "z. B. coder, writer usw.", + nameRequired: "Name ist erforderlich", + nameRule: + "Nur Kleinbuchstaben, Ziffern, _ und -; muss mit einem Buchstaben oder einer Ziffer beginnen; maximal 64 Zeichen.", + invalidName: "Ungültiger Profilname", + cloneFromDefault: "Konfiguration vom Standardprofil klonen", + allProfiles: "Profile", + noProfiles: "Keine Profile gefunden.", + defaultBadge: "Standard", + hasEnv: "env", + model: "Modell", + skills: "Skills", + rename: "Umbenennen", + editSoul: "SOUL.md bearbeiten", + soulSection: "SOUL.md (Persönlichkeit / System-Prompt)", + soulPlaceholder: "# Wie sich dieser Agent verhalten soll…", + saveSoul: "SOUL speichern", + soulSaved: "SOUL.md gespeichert", + openInTerminal: "CLI-Befehl kopieren", + commandCopied: "In Zwischenablage kopiert", + copyFailed: "Kopieren fehlgeschlagen", + confirmDeleteTitle: "Profil löschen?", + confirmDeleteMessage: + "Damit wird das Profil '{name}' dauerhaft gelöscht — Konfiguration, Schlüssel, Erinnerungen, Sitzungen, Skills, Cron-Aufgaben. Kann nicht rückgängig gemacht werden.", + created: "Erstellt", + deleted: "Gelöscht", + renamed: "Umbenannt", + }, + + pluginsPage: { + contextEngineLabel: "Kontext-Engine", + dashboardSlots: "Dashboard-Slots", + disableRuntime: "Deaktivieren", + enableAfterInstall: "Nach Installation aktivieren", + enableRuntime: "Aktivieren", + forceReinstall: "Neuinstallation erzwingen (bestehenden Ordner zuerst löschen)", + headline: + "Hermes-Plugins entdecken, installieren, aktivieren und aktualisieren (entspricht `hermes plugins`).", + identifierLabel: "Git-URL oder owner/repo", + inactive: "inaktiv", + installBtn: "Aus Git installieren", + installHeading: "Aus GitHub / Git-URL installieren", + installHint: "Verwende owner/repo-Kurzform oder eine vollständige https:// oder git@ Klon-URL.", + memoryProviderLabel: "Speicheranbieter", + missingEnvWarn: "Setze diese unter Schlüssel, bevor das Plugin laufen kann:", + noDashboardTab: "Kein Dashboard-Tab", + openTab: "Öffnen", + orphanHeading: "Nur-Dashboard-Erweiterungen (keine Übereinstimmung mit Agent plugin.yaml)", + pluginListHeading: "Installierte Plugins", + providerDefaults: "eingebaut / Standard", + providersHeading: "Laufzeit-Anbieter-Plugins", + providersHint: + "Schreibt memory.provider (leer = eingebaut) und context.engine in config.yaml. Wirkt sich auf die nächste Sitzung aus.", + refreshDashboard: "Dashboard-Erweiterungen erneut scannen", + removeConfirm: "Dieses Plugin aus ~/.hermes/plugins/ entfernen?", + removeHint: "Nur vom Benutzer installierte Plugins unter ~/.hermes/plugins können entfernt werden.", + rescanHeading: "SPA-Plugin-Registry", + rescanHint: "Nach dem Hinzufügen von Dateien auf dem Datenträger erneut scannen, damit die Sidebar neue Manifeste erkennt.", + runtimeHeading: "Gateway-Laufzeit (YAML-Plugins)", + saveProviders: "Anbieter-Einstellungen speichern", + savedProviders: "Anbieter-Einstellungen gespeichert.", + sourceBadge: "Quelle", + authRequired: "Authentifizierung erforderlich", + authRequiredHint: "Führe diesen Befehl aus, um dich zu authentifizieren:", + updateGit: "Git pull", + versionBadge: "Version", + showInSidebar: "In Sidebar anzeigen", + hideFromSidebar: "Aus Sidebar ausblenden", + }, + + skills: { + title: "Skills", + searchPlaceholder: "Skills und Toolsets suchen...", + enabledOf: "{enabled}/{total} aktiviert", + all: "Alle", + categories: "Kategorien", + filters: "Filter", + noSkills: "Keine Skills gefunden. Skills werden aus ~/.hermes/skills/ geladen", + noSkillsMatch: "Keine Skills entsprechen deiner Suche oder deinem Filter.", + skillCount: "{count} Skill{s}", + resultCount: "{count} Ergebnis{s}", + noDescription: "Keine Beschreibung verfügbar.", + toolsets: "Toolsets", + toolsetLabel: "{name} Toolset", + noToolsetsMatch: "Keine Toolsets entsprechen der Suche.", + setupNeeded: "Einrichtung erforderlich", + disabledForCli: "Für CLI deaktiviert", + more: "+{count} weitere", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "Filter", + sections: "Bereiche", + exportConfig: "Konfiguration als JSON exportieren", + importConfig: "Konfiguration aus JSON importieren", + resetDefaults: "Auf Standardwerte zurücksetzen", + resetScopeTooltip: "{scope} auf Standardwerte zurücksetzen", + confirmResetScope: "Alle {scope}-Einstellungen auf ihre Standardwerte zurücksetzen? Dies aktualisiert nur das Formular — Änderungen werden erst in config.yaml geschrieben, wenn du auf Speichern drückst.", + resetScopeToast: "{scope} auf Standardwerte zurückgesetzt — überprüfen und Speichern, um zu übernehmen", + rawYaml: "Rohe YAML-Konfiguration", + searchResults: "Suchergebnisse", + fields: "Feld{s}", + noFieldsMatch: 'Keine Felder entsprechen "{query}"', + configSaved: "Konfiguration gespeichert", + yamlConfigSaved: "YAML-Konfiguration gespeichert", + failedToSave: "Speichern fehlgeschlagen", + failedToSaveYaml: "YAML konnte nicht gespeichert werden", + failedToLoadRaw: "Rohe Konfiguration konnte nicht geladen werden", + configImported: "Konfiguration importiert — überprüfen und speichern", + invalidJson: "Ungültige JSON-Datei", + categories: { + general: "Allgemein", + agent: "Agent", + terminal: "Terminal", + display: "Anzeige", + delegation: "Delegation", + memory: "Speicher", + compression: "Komprimierung", + security: "Sicherheit", + browser: "Browser", + voice: "Stimme", + tts: "Text-zu-Sprache", + stt: "Sprache-zu-Text", + logging: "Protokollierung", + discord: "Discord", + auxiliary: "Hilfs", + }, + }, + + env: { + changesNote: "Änderungen werden sofort auf der Festplatte gespeichert. Aktive Sitzungen übernehmen neue Schlüssel automatisch.", + confirmClearMessage: + "Der gespeicherte Wert für diese Variable wird aus deiner .env-Datei entfernt. Dies kann über die UI nicht rückgängig gemacht werden.", + confirmClearTitle: "Diesen Schlüssel löschen?", + description: "Verwalte API-Schlüssel und Geheimnisse, die hier gespeichert sind", + hideAdvanced: "Erweitert ausblenden", + showAdvanced: "Erweitert anzeigen", + llmProviders: "LLM-Anbieter", + providersConfigured: "{configured} von {total} Anbietern konfiguriert", + getKey: "Schlüssel holen", + notConfigured: "{count} nicht konfiguriert", + notSet: "Nicht gesetzt", + keysCount: "{count} Schlüssel", + enterValue: "Wert eingeben...", + replaceCurrentValue: "Aktuellen Wert ersetzen ({preview})", + showValue: "Echten Wert anzeigen", + hideValue: "Wert ausblenden", + }, + + oauth: { + title: "Anbieter-Logins (OAuth)", + providerLogins: "Anbieter-Logins (OAuth)", + description: "{connected} von {total} OAuth-Anbietern verbunden. Login-Abläufe laufen derzeit über die CLI; klicke auf Befehl kopieren und füge ihn in ein Terminal ein, um einzurichten.", + connected: "Verbunden", + expired: "Abgelaufen", + notConnected: "Nicht verbunden. Führe {command} in einem Terminal aus.", + runInTerminal: "in einem Terminal.", + noProviders: "Keine OAuth-fähigen Anbieter erkannt.", + login: "Anmelden", + disconnect: "Trennen", + managedExternally: "Extern verwaltet", + copied: "Kopiert ✓", + cli: "CLI", + copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)", + connect: "Verbinden", + sessionExpires: "Sitzung läuft in {time} ab", + initiatingLogin: "Login-Ablauf wird gestartet…", + exchangingCode: "Code wird gegen Tokens getauscht…", + connectedClosing: "Verbunden! Wird geschlossen…", + loginFailed: "Anmeldung fehlgeschlagen.", + sessionExpired: "Sitzung abgelaufen. Klicke auf Erneut versuchen, um eine neue Anmeldung zu starten.", + reOpenAuth: "Authentifizierungsseite erneut öffnen", + reOpenVerification: "Verifizierungsseite erneut öffnen", + submitCode: "Code einreichen", + pasteCode: "Autorisierungscode einfügen (mit #state-Suffix ist okay)", + waitingAuth: "Warte, bis du im Browser autorisierst…", + enterCodePrompt: "Ein neuer Tab wurde geöffnet. Gib bei Aufforderung diesen Code ein:", + pkceStep1: "Ein neuer Tab wurde zu claude.ai geöffnet. Melde dich an und klicke auf Autorisieren.", + pkceStep2: "Kopiere den Autorisierungscode, der nach der Autorisierung angezeigt wird.", + pkceStep3: "Füge ihn unten ein und sende ab.", + flowLabels: { + pkce: "Browser-Login (PKCE)", + device_code: "Gerätecode", + external: "Externe CLI", + }, + expiresIn: "läuft in {time} ab", + }, + + language: { + switchTo: "Zu Englisch wechseln", + }, + + theme: { + title: "Design", + switchTheme: "Design wechseln", + }, + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Sammelbare Hermes-Abzeichen, verdient durch echten Sitzungsverlauf. Bekannte, noch nicht abgeschlossene Achievements werden als Entdeckt angezeigt; geheime Achievements bleiben verborgen, bis das erste passende Verhalten auftritt.", + scan_subtitle: + "Hermes-Sitzungsverlauf wird gescannt. Der erste Scan kann bei umfangreichem Verlauf 5–10 Sekunden dauern.", + }, + actions: { + rescan: "Neu scannen", + }, + stats: { + unlocked: "Freigeschaltet", + unlocked_hint: "verdiente Abzeichen", + discovered: "Entdeckt", + discovered_hint: "bekannt, noch nicht verdient", + secrets: "Geheimnisse", + secrets_hint: "verborgen bis zum ersten Signal", + highest_tier: "Höchste Stufe", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "Neueste", + latest_hint_empty: "nutze Hermes mehr", + none_yet: "Noch keine", + }, + state: { + unlocked: "Freigeschaltet", + discovered: "Entdeckt", + secret: "Geheim", + }, + tier: { + target: "Ziel {tier}", + hidden: "Verborgen", + complete: "Abgeschlossen", + objective: "Ziel", + }, + progress: { + hidden: "verborgen", + }, + scan: { + building_headline: "Achievement-Profil wird erstellt…", + building_detail: + "Sitzungen, Tool-Aufrufe, Modell-Metadaten und Freischaltstatus werden gelesen.", + starting_headline: "Achievement-Scan wird gestartet…", + progress_detail: + "{scanned} von {total} Sitzungen gescannt · {pct}%. Abzeichen werden freigeschaltet, sobald mehr Verlauf eingelesen wird.", + idle_detail: + "Sitzungen, Tool-Aufrufe, Modell-Metadaten und Freischaltstatus werden gelesen. Abzeichen erscheinen hier, sobald sie freigeschaltet werden.", + }, + guide: { + tiers_header: "Stufen", + secret_header: "Geheime Achievements", + secret_body: + "Geheimnisse verbergen ihren genauen Auslöser. Sobald Hermes ein verwandtes Signal erkennt, wird die Karte zu Entdeckt und zeigt ihre Anforderung an.", + scan_status_header: "Scan-Status", + scan_status_body: + "Hermes scannt den lokalen Verlauf einmalig, danach erscheinen die Karten automatisch. Es ist nichts hängengeblieben, wenn dies ein paar Sekunden dauert.", + what_scanned_header: "Was gescannt wird", + what_scanned_body: + "Sitzungen, Tool-Aufrufe, Modell-Metadaten, Fehler, Achievements und lokaler Freischaltstatus.", + }, + card: { + share_title: "Dieses Achievement teilen", + share_label: "{name} teilen", + share_text: "Teilen", + how_to_reveal: "Wie aufdecken", + what_counts: "Was zählt", + evidence_label: "Beleg", + evidence_session_fallback: "Sitzung", + no_evidence: "Noch kein Beleg", + }, + latest: { + header: "Letzte Freischaltungen", + }, + empty: { + no_secrets_header: "Keine verborgenen Geheimnisse mehr in diesem Scan.", + no_secrets_body: + "Hinweis: Geheimnisse beginnen meist bei ungewöhnlichen Fehlern oder Power-User-Mustern – Port-Konflikten, Berechtigungswänden, fehlenden Umgebungsvariablen, YAML-Fehlern, Docker-Kollisionen, Rollback-/Checkpoint-Nutzung, Cache-Treffern oder kleinen Fixes nach viel rotem Text.", + }, + filters: { + all_categories: "Alle", + visibility_all: "alle", + visibility_unlocked: "freigeschaltet", + visibility_discovered: "entdeckt", + visibility_secret: "geheim", + }, + share: { + dialog_label: "Achievement teilen", + header: "Teilen: {name}", + close: "Schließen", + rendering: "Wird gerendert…", + card_alt: "{name} Share-Karte", + error_generic: "Etwas ist schiefgelaufen.", + x_title: "Öffnet X mit einem vorgefertigten Post", + x_button: "Auf X teilen", + copy_title: "Bild kopieren, um es in deinen Post einzufügen", + copy_button: "Bild kopieren", + copied: "Kopiert ✓", + download_button: "PNG herunterladen", + hint: + "Auf X teilen öffnet einen vorgefertigten Post in einem neuen Tab. Klicke zuerst auf Bild kopieren, wenn du das 1200×630-Abzeichen anhängen möchtest – X lässt dich es direkt in den Tweet-Editor einfügen. PNG herunterladen speichert die Datei zur Nutzung an beliebiger Stelle.", + clipboard_unsupported: + "Bildkopie über die Zwischenablage wird in diesem Browser nicht unterstützt – nutze stattdessen Herunterladen.", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Kanban-Board wird geladen…", + loadFailed: "Laden des Kanban-Boards fehlgeschlagen: ", + loadFailedHint: + "Das Backend erstellt kanban.db beim ersten Lesen automatisch. Wenn das Problem bestehen bleibt, prüfe die Dashboard-Logs.", + board: "Board", + newBoard: "+ Neues Board", + newBoardTitle: "Neues Board", + newBoardDescription: + "Mit Boards kannst du voneinander unabhängige Arbeitsabläufe trennen — eines pro Projekt, Repository oder Domäne. Worker auf einem Board sehen niemals die Aufgaben eines anderen Boards.", + slug: "Slug", + slugHint: "— Kleinbuchstaben, Bindestriche, z. B. atm10-server", + displayName: "Anzeigename", + displayNameHint: "(optional)", + description: "Beschreibung", + descriptionHint: "(optional)", + icon: "Symbol", + iconHint: "(einzelnes Zeichen oder Emoji)", + switchAfterCreate: "Nach dem Erstellen zu diesem Board wechseln", + cancel: "Abbrechen", + creating: "Wird erstellt…", + createBoard: "Board erstellen", + search: "Suchen", + filterCards: "Karten filtern…", + tenant: "Tenant", + allTenants: "Alle Tenants", + assignee: "Zuständige Person", + allProfiles: "Alle Profile", + showArchived: "Archivierte anzeigen", + lanesByProfile: "Spuren nach Profil", + nudgeDispatcher: "Dispatcher anstoßen", + refresh: "Aktualisieren", + selected: "ausgewählt", + complete: "Abschließen", + archive: "Archivieren", + apply: "Anwenden", + clear: "Zurücksetzen", + createTask: "Aufgabe in dieser Spalte erstellen", + noTasks: "— keine Aufgaben —", + unassigned: "nicht zugewiesen", + untitled: "(ohne Titel)", + loadingDetail: "Wird geladen…", + addComment: "Kommentar hinzufügen… (Enter zum Senden)", + comment: "Kommentar", + status: "Status", + workspace: "Arbeitsbereich", + skills: "Fähigkeiten", + createdBy: "Erstellt von", + result: "Ergebnis", + comments: "Kommentare", + events: "Ereignisse", + runHistory: "Ausführungsverlauf", + workerLog: "Worker-Log", + loadingLog: "Log wird geladen…", + noWorkerLog: + "— noch kein Worker-Log (Aufgabe wurde nicht gestartet oder Log wurde rotiert) —", + noDescription: "— keine Beschreibung —", + noComments: "— keine Kommentare —", + edit: "bearbeiten", + save: "Speichern", + dependencies: "Abhängigkeiten", + parents: "Übergeordnet:", + children: "Untergeordnet:", + none: "keine", + addParent: "— übergeordnete Aufgabe hinzufügen —", + addChild: "— untergeordnete Aufgabe hinzufügen —", + removeDependency: "Abhängigkeit entfernen", + block: "Blockieren", + unblock: "Freigeben", + notifyHomeChannels: "Home-Kanäle benachrichtigen", + diagnostics: "Diagnose", + hide: "Ausblenden", + show: "Anzeigen", + attention: "Achtung", + tasksNeedAttention: "Aufgaben benötigen Aufmerksamkeit", + taskNeedsAttention: "1 Aufgabe benötigt Aufmerksamkeit", + diagnostic: "Diagnose", + open: "Öffnen", + close: "Schließen (Esc)", + reassignTo: "Neu zuweisen an:", + copied: "Kopiert", + copyCommand: "Befehl in die Zwischenablage kopieren", + reclaim: "Zurückholen", + reassign: "Neu zuweisen", + renderingError: "Im Kanban-Tab ist ein Renderfehler aufgetreten", + reloadView: "Ansicht neu laden", + wsAuthFailed: + "WebSocket-Authentifizierung fehlgeschlagen — lade die Seite neu, um das Sitzungs-Token zu aktualisieren.", + markDone: "{n} Aufgabe(n) als erledigt markieren?", + markArchived: "{n} Aufgabe(n) archivieren?", + warning: "Warnung", + phantomIds: "Phantom-IDs:", + active: "aktiv", + ended: "beendet", + noProfile: "(kein Profil)", + showAllAttempts: "Alle Versuche anzeigen", + sendingUpdates: "Aktualisierungen werden gesendet an ", + sendNotifications: "Benachrichtigungen für Abgeschlossen / Blockiert / Aufgegeben senden an", + archiveBoardConfirm: + "Board „{name}“ archivieren? Es wird nach boards/_archived/ verschoben, sodass du es später wiederherstellen kannst. Aufgaben auf diesem Board erscheinen nirgendwo mehr in der UI.", + archiveBoardTitle: "Dieses Board archivieren", + boardSwitcherHint: "Mit Boards kannst du voneinander unabhängige Arbeitsabläufe trennen", + taskCreatedWarning: "Aufgabe erstellt, aber: ", + moveFailed: "Verschieben fehlgeschlagen: ", + bulkFailed: "Bulk: ", + completionBlockedHallucination: "⚠ Abschluss blockiert — Phantom-Karten-IDs", + suspectedHallucinatedReferences: "⚠ Text verweist auf Phantom-Karten-IDs", + pickProfileFirst: "Wähle zuerst ein Profil aus.", + unblockedMessage: "{id} freigegeben. Aufgabe ist bereit für den nächsten Tick.", + unblockFailed: "Freigeben fehlgeschlagen: ", + reclaimedMessage: "{id} zurückgeholt. Aufgabe ist wieder auf ready.", + reclaimFailed: "Zurückholen fehlgeschlagen: ", + reassignedMessage: "{id} an {profile} neu zugewiesen.", + reassignFailed: "Neu zuweisen fehlgeschlagen: ", + selectForBulk: "Für Bulk-Aktionen auswählen", + clickToEdit: "Zum Bearbeiten klicken", + clickToEditAssignee: "Klicken, um zuständige Person zu bearbeiten", + emptyAssignee: "(leer = Zuweisung aufheben)", + columnLabels: { + triage: "Triage", + todo: "Zu erledigen", + ready: "Bereit", + running: "In Bearbeitung", + blocked: "Blockiert", + done: "Erledigt", + archived: "Archiviert", + }, + columnHelp: { + triage: "Rohe Ideen — ein Specifier wird die Spezifikation ausarbeiten", + todo: "Wartet auf Abhängigkeiten oder ist nicht zugewiesen", + ready: "Zugewiesen und wartet auf einen Dispatcher-Tick", + running: "Von einem Worker übernommen — in Bearbeitung", + blocked: "Worker hat um menschliche Eingabe gebeten", + done: "Abgeschlossen", + archived: "Archiviert", + }, + confirmDone: + "Diese Aufgabe als erledigt markieren? Der Anspruch des Workers wird freigegeben und abhängige untergeordnete Aufgaben werden bereit.", + confirmArchive: + "Diese Aufgabe archivieren? Sie verschwindet aus der Standard-Board-Ansicht.", + confirmBlocked: + "Diese Aufgabe als blockiert markieren? Der Anspruch des Workers wird freigegeben.", + completionSummary: + "Abschluss-Zusammenfassung für {label}. Diese wird als Ergebnis der Aufgabe gespeichert.", + completionSummaryRequired: + "Eine Abschluss-Zusammenfassung ist erforderlich, bevor eine Aufgabe als erledigt markiert werden kann.", + triagePlaceholder: "Grobe Idee — die KI wird die Spezifikation erstellen…", + taskTitlePlaceholder: "Titel der neuen Aufgabe…", + specifier: "Specifier", + assigneePlaceholder: "Zuständige Person", + priority: "Priorität", + skillsPlaceholder: + "Fähigkeiten (optional, kommagetrennt): translation, github-code-review", + noParent: "— keine übergeordnete Aufgabe —", + workspacePathDir: "Arbeitsbereichs-Pfad (erforderlich, z. B. ~/projects/my-app)", + workspacePathOptional: + "Arbeitsbereichs-Pfad (optional, wird aus zuständiger Person abgeleitet, wenn leer)", + logTruncated: "(zeige die letzten 100 KB — vollständiges Log unter ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/en.ts b/apps/dashboard/src/i18n/en.ts index 55e3267b1ba..cec4dc2ff98 100644 --- a/apps/dashboard/src/i18n/en.ts +++ b/apps/dashboard/src/i18n/en.ts @@ -426,4 +426,272 @@ export const en: Translations = { title: "Theme", switchTheme: "Switch theme", }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Collectible Hermes badges earned from real session history. Known unfinished achievements are shown as Discovered; Secret achievements stay hidden until the first matching behavior appears.", + scan_subtitle: + "Scanning Hermes session history. First scan can take 5–10 seconds on large histories.", + }, + actions: { + rescan: "Rescan", + }, + stats: { + unlocked: "Unlocked", + unlocked_hint: "earned badges", + discovered: "Discovered", + discovered_hint: "known, not earned yet", + secrets: "Secrets", + secrets_hint: "hidden until first signal", + highest_tier: "Highest tier", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "Latest", + latest_hint_empty: "run Hermes more", + none_yet: "None yet", + }, + state: { + unlocked: "Unlocked", + discovered: "Discovered", + secret: "Secret", + }, + tier: { + target: "Target {tier}", + hidden: "Hidden", + complete: "Complete", + objective: "Objective", + }, + progress: { + hidden: "hidden", + }, + scan: { + building_headline: "Building achievement profile…", + building_detail: + "Reading sessions, tool calls, model metadata, and unlock state.", + starting_headline: "Starting achievement scan…", + progress_detail: + "Scanned {scanned} of {total} sessions · {pct}%. Badges unlock as more history streams in.", + idle_detail: + "Reading sessions, tool calls, model metadata, and unlock state. Badges appear here as they unlock.", + }, + guide: { + tiers_header: "Tiers", + secret_header: "Secret achievements", + secret_body: + "Secrets hide their exact trigger. Once Hermes sees a related signal, the card becomes Discovered and shows its requirement.", + scan_status_header: "Scan status", + scan_status_body: + "Hermes is scanning local history once, then cards will appear automatically. Nothing is stuck if this takes a few seconds.", + what_scanned_header: "What is scanned", + what_scanned_body: + "Sessions, tool calls, model metadata, errors, achievements, and local unlock state.", + }, + card: { + share_title: "Share this achievement", + share_label: "Share {name}", + share_text: "Share", + how_to_reveal: "How to reveal", + what_counts: "What counts", + evidence_label: "Evidence", + evidence_session_fallback: "session", + no_evidence: "No evidence yet", + }, + latest: { + header: "Recent unlocks", + }, + empty: { + no_secrets_header: "No hidden secrets left in this scan.", + no_secrets_body: + "Clue: secrets usually start from unusual failure or power-user patterns — port conflicts, permission walls, missing env vars, YAML mistakes, Docker collisions, rollback/checkpoint use, cache hits, or tiny fixes after lots of red text.", + }, + filters: { + all_categories: "All", + visibility_all: "all", + visibility_unlocked: "unlocked", + visibility_discovered: "discovered", + visibility_secret: "secret", + }, + share: { + dialog_label: "Share achievement", + header: "Share: {name}", + close: "Close", + rendering: "Rendering…", + card_alt: "{name} share card", + error_generic: "Something went wrong.", + x_title: "Opens X with a pre-filled post", + x_button: "Share on X", + copy_title: "Copy the image to paste into your post", + copy_button: "Copy image", + copied: "Copied ✓", + download_button: "Download PNG", + hint: + "Share on X opens a pre-filled post in a new tab. Click Copy image first if you want the 1200×630 badge attached — X lets you paste it right into the tweet composer. Download PNG saves the file for use anywhere.", + clipboard_unsupported: + "Clipboard image copy not supported in this browser — use Download instead.", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + + kanban: { + loading: "Loading Kanban board…", + loadFailed: "Failed to load Kanban board: ", + loadFailedHint: + "The backend auto-creates kanban.db on first read. If this persists, check the dashboard logs.", + board: "Board", + newBoard: "+ New board", + newBoardTitle: "New board", + newBoardDescription: + "Boards let you separate unrelated streams of work — one per project, repo, or domain. Workers on one board never see another board's tasks.", + slug: "Slug", + slugHint: "— lowercase, hyphens, e.g. atm10-server", + displayName: "Display name", + displayNameHint: "(optional)", + description: "Description", + descriptionHint: "(optional)", + icon: "Icon", + iconHint: "(single character or emoji)", + switchAfterCreate: "Switch to this board after creating it", + cancel: "Cancel", + creating: "Creating…", + createBoard: "Create board", + search: "Search", + filterCards: "Filter cards…", + tenant: "Tenant", + allTenants: "All tenants", + assignee: "Assignee", + allProfiles: "All profiles", + showArchived: "Show archived", + lanesByProfile: "Lanes by profile", + nudgeDispatcher: "Nudge dispatcher", + refresh: "Refresh", + selected: "selected", + complete: "Complete", + archive: "Archive", + apply: "Apply", + clear: "Clear", + createTask: "Create task in this column", + noTasks: "— no tasks —", + unassigned: "unassigned", + untitled: "(untitled)", + loadingDetail: "Loading…", + addComment: "Add a comment… (Enter to submit)", + comment: "Comment", + status: "Status", + workspace: "Workspace", + skills: "Skills", + createdBy: "Created by", + result: "Result", + comments: "Comments", + events: "Events", + runHistory: "Run history", + workerLog: "Worker log", + loadingLog: "Loading log…", + noWorkerLog: + "— no worker log yet (task hasn't spawned or log was rotated away) —", + noDescription: "— no description —", + noComments: "— no comments —", + edit: "edit", + save: "Save", + dependencies: "Dependencies", + parents: "Parents:", + children: "Children:", + none: "none", + addParent: "— add parent —", + addChild: "— add child —", + removeDependency: "Remove dependency", + block: "Block", + unblock: "Unblock", + notifyHomeChannels: "Notify home channels", + diagnostics: "Diagnostics", + hide: "Hide", + show: "Show", + attention: "Attention", + tasksNeedAttention: "tasks need attention", + taskNeedsAttention: "1 task needs attention", + diagnostic: "diagnostic", + open: "Open", + close: "Close (Esc)", + reassignTo: "Reassign to:", + copied: "Copied", + copyCommand: "Copy command to clipboard", + reclaim: "Reclaim", + reassign: "Reassign", + renderingError: "Kanban tab hit a rendering error", + reloadView: "Reload view", + wsAuthFailed: + "WebSocket auth failed — reload the page to refresh the session token.", + markDone: "Mark {n} task(s) as done?", + markArchived: "Archive {n} task(s)?", + warning: "Warning", + phantomIds: "Phantom ids:", + active: "active", + ended: "ended", + noProfile: "(no profile)", + showAllAttempts: "Show all attempts", + sendingUpdates: "Sending updates to", + sendNotifications: "Send completed / blocked / gave_up notifications to", + archiveBoardConfirm: + "Archive board '{name}'? It will be moved to boards/_archived/ so you can recover it later. Tasks on this board will no longer appear anywhere in the UI.", + archiveBoardTitle: "Archive this board", + boardSwitcherHint: "Boards let you separate unrelated streams of work", + taskCreatedWarning: "Task created, but: ", + moveFailed: "Move failed: ", + bulkFailed: "Bulk: ", + completionBlockedHallucination: "⚠ Completion blocked — phantom card ids", + suspectedHallucinatedReferences: "⚠ Prose referenced phantom card ids", + pickProfileFirst: "Pick a profile first.", + unblockedMessage: "Unblocked {id}. Task is ready for the next tick.", + unblockFailed: "Unblock failed: ", + reclaimedMessage: "Reclaimed {id}. Task is back to ready.", + reclaimFailed: "Reclaim failed: ", + reassignedMessage: "Reassigned {id} to {profile}.", + reassignFailed: "Reassign failed: ", + selectForBulk: "Select for bulk actions", + clickToEdit: "Click to edit", + clickToEditAssignee: "Click to edit assignee", + emptyAssignee: "(empty = unassign)", + columnLabels: { + triage: "Triage", + todo: "Todo", + ready: "Ready", + running: "In Progress", + blocked: "Blocked", + done: "Done", + archived: "Archived", + }, + columnHelp: { + triage: "Raw ideas — a specifier will flesh out the spec", + todo: "Waiting on dependencies or unassigned", + ready: "Assigned and waiting for a dispatcher tick", + running: "Claimed by a worker — in-flight", + blocked: "Worker asked for human input", + done: "Completed", + archived: "Archived", + }, + confirmDone: + "Mark this task as done? The worker's claim is released and dependent children become ready.", + confirmArchive: + "Archive this task? It disappears from the default board view.", + confirmBlocked: + "Mark this task as blocked? The worker's claim is released.", + completionSummary: + "Completion summary for {label}. This is stored as the task result.", + completionSummaryRequired: + "Completion summary is required before marking a task done.", + triagePlaceholder: "Rough idea — AI will spec it…", + taskTitlePlaceholder: "New task title…", + specifier: "specifier", + assigneePlaceholder: "assignee", + priority: "Priority", + skillsPlaceholder: + "skills (optional, comma-separated): translation, github-code-review", + noParent: "— no parent —", + workspacePathDir: "workspace path (required, e.g. ~/projects/my-app)", + workspacePathOptional: + "workspace path (optional, derived from assignee if blank)", + logTruncated: "(showing last 100 KB — full log at ", + logAt: ")", + }, }; diff --git a/apps/dashboard/src/i18n/es.ts b/apps/dashboard/src/i18n/es.ts new file mode 100644 index 00000000000..19088de12c8 --- /dev/null +++ b/apps/dashboard/src/i18n/es.ts @@ -0,0 +1,695 @@ +import type { Translations } from "./types"; + +export const es: Translations = { + common: { + save: "Guardar", + saving: "Guardando...", + cancel: "Cancelar", + close: "Cerrar", + confirm: "Confirmar", + delete: "Eliminar", + refresh: "Actualizar", + retry: "Reintentar", + search: "Buscar...", + loading: "Cargando...", + create: "Crear", + creating: "Creando...", + set: "Establecer", + replace: "Reemplazar", + clear: "Limpiar", + live: "En vivo", + off: "Apagado", + enabled: "habilitado", + disabled: "deshabilitado", + active: "activo", + inactive: "inactivo", + unknown: "desconocido", + untitled: "Sin título", + none: "Ninguno", + form: "Formulario", + noResults: "Sin resultados", + of: "de", + page: "Página", + msgs: "msjs", + tools: "herramientas", + match: "coincidencia", + other: "Otros", + configured: "configurado", + removed: "eliminado", + failedToToggle: "No se pudo alternar", + failedToRemove: "No se pudo eliminar", + failedToReveal: "No se pudo mostrar", + collapse: "Contraer", + expand: "Expandir", + general: "General", + messaging: "Mensajería", + pluginLoadFailed: + "No se pudo cargar el script de este complemento. Revisa la pestaña Network (dashboard-plugins/…) y la ruta del complemento del servidor.", + pluginNotRegistered: + "El script del complemento no llamó a register(), o falló. Abre la consola del navegador para más detalles.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "Cerrar navegación", + closeModelTools: "Cerrar modelo y herramientas", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "Sesiones activas:", + gatewayStatusLabel: "Estado del Gateway:", + gatewayStrip: { + failed: "Inicio fallido", + off: "Apagado", + running: "En ejecución", + starting: "Iniciando", + stopped: "Detenido", + }, + nav: { + analytics: "Analíticas", + chat: "Chat", + config: "Configuración", + cron: "Cron", + documentation: "Documentación", + keys: "Claves", + logs: "Registros", + models: "Modelos", + profiles: "perfiles : multi agentes", + plugins: "Complementos", + sessions: "Sesiones", + skills: "Habilidades", + }, + modelToolsSheetSubtitle: "y herramientas", + modelToolsSheetTitle: "Modelo", + navigation: "Navegación", + openDocumentation: "Abrir documentación en una nueva pestaña", + openNavigation: "Abrir navegación", + pluginNavSection: "Complementos", + sessionsActiveCount: "{count} activas", + statusOverview: "Resumen de estado", + system: "Sistema", + webUi: "Web UI", + }, + + status: { + actionFailed: "Acción fallida", + actionFinished: "Finalizado", + actions: "Acciones", + agent: "Agente", + activeSessions: "Sesiones activas", + connected: "Conectado", + connectedPlatforms: "Plataformas conectadas", + disconnected: "Desconectado", + error: "Error", + failed: "Fallido", + gateway: "Gateway", + gatewayFailedToStart: "El Gateway no pudo iniciarse", + lastUpdate: "Última actualización", + noneRunning: "Ninguno", + notRunning: "No en ejecución", + pid: "PID", + platformDisconnected: "desconectado", + platformError: "error", + recentSessions: "Sesiones recientes", + restartGateway: "Reiniciar Gateway", + restartingGateway: "Reiniciando gateway…", + running: "En ejecución", + runningRemote: "En ejecución (remoto)", + startFailed: "Inicio fallido", + starting: "Iniciando", + startedInBackground: "Iniciado en segundo plano — revisa los registros para ver el progreso", + stopped: "Detenido", + updateHermes: "Actualizar Hermes", + updatingHermes: "Actualizando Hermes…", + waitingForOutput: "Esperando salida…", + }, + + sessions: { + title: "Sesiones", + searchPlaceholder: "Buscar contenido de mensajes...", + noSessions: "Aún no hay sesiones", + noMatch: "Ninguna sesión coincide con tu búsqueda", + startConversation: "Inicia una conversación para verla aquí", + noMessages: "Sin mensajes", + untitledSession: "Sesión sin título", + deleteSession: "Eliminar sesión", + confirmDeleteTitle: "¿Eliminar sesión?", + confirmDeleteMessage: + "Esto elimina permanentemente la conversación y todos sus mensajes. No se puede deshacer.", + sessionDeleted: "Sesión eliminada", + failedToDelete: "No se pudo eliminar la sesión", + resumeInChat: "Reanudar en el chat", + previousPage: "Página anterior", + nextPage: "Página siguiente", + roles: { + user: "Usuario", + assistant: "Asistente", + system: "Sistema", + tool: "Herramienta", + }, + }, + + analytics: { + period: "Período:", + totalTokens: "Tokens totales", + totalSessions: "Sesiones totales", + apiCalls: "Llamadas API", + dailyTokenUsage: "Uso diario de tokens", + dailyBreakdown: "Desglose diario", + perModelBreakdown: "Desglose por modelo", + topSkills: "Habilidades principales", + skill: "Habilidad", + loads: "Agente cargó", + edits: "Agente gestionó", + lastUsed: "Último uso", + input: "Entrada", + output: "Salida", + total: "Total", + noUsageData: "No hay datos de uso para este período", + startSession: "Inicia una sesión para ver analíticas aquí", + date: "Fecha", + model: "Modelo", + tokens: "Tokens", + perDayAvg: "/día prom.", + acrossModels: "en {count} modelos", + inOut: "{input} entrada / {output} salida", + }, + + models: { + modelsUsed: "Modelos utilizados", + estimatedCost: "Coste est.", + tokens: "tokens", + sessions: "sesiones", + avgPerSession: "prom./sesión", + apiCalls: "llamadas API", + toolCalls: "llamadas de herramientas", + noModelsData: "No hay datos de uso de modelos para este período", + startSession: "Inicia una sesión para ver datos de modelos aquí", + }, + + logs: { + title: "Registros", + autoRefresh: "Actualización automática", + file: "Archivo", + level: "Nivel", + component: "Componente", + lines: "Líneas", + noLogLines: "No se encontraron líneas de registro", + }, + + cron: { + confirmDeleteMessage: + "Esto elimina la tarea de la programación. No se puede deshacer.", + confirmDeleteTitle: "¿Eliminar tarea programada?", + newJob: "Nueva tarea Cron", + nameOptional: "Nombre (opcional)", + namePlaceholder: "p. ej. Resumen diario", + prompt: "Prompt", + promptPlaceholder: "¿Qué debe hacer el agente en cada ejecución?", + schedule: "Programación (expresión cron)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Entregar a", + scheduledJobs: "Tareas programadas", + noJobs: "No hay tareas cron configuradas. Crea una arriba.", + last: "Última", + next: "Próxima", + pause: "Pausar", + resume: "Reanudar", + triggerNow: "Ejecutar ahora", + delivery: { + local: "Local", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "Nuevo perfil", + name: "Nombre", + namePlaceholder: "p. ej. coder, writer, etc.", + nameRequired: "El nombre es obligatorio", + nameRule: + "Solo letras minúsculas, dígitos, _ y -; debe comenzar con una letra o dígito; hasta 64 caracteres.", + invalidName: "Nombre de perfil no válido", + cloneFromDefault: "Clonar configuración del perfil predeterminado", + allProfiles: "Perfiles", + noProfiles: "No se encontraron perfiles.", + defaultBadge: "predeterminado", + hasEnv: "env", + model: "Modelo", + skills: "Habilidades", + rename: "Renombrar", + editSoul: "Editar SOUL.md", + soulSection: "SOUL.md (personalidad / prompt del sistema)", + soulPlaceholder: "# Cómo debe comportarse este agente…", + saveSoul: "Guardar SOUL", + soulSaved: "SOUL.md guardado", + openInTerminal: "Copiar comando CLI", + commandCopied: "Copiado al portapapeles", + copyFailed: "No se pudo copiar", + confirmDeleteTitle: "¿Eliminar perfil?", + confirmDeleteMessage: + "Esto elimina permanentemente el perfil '{name}' — configuración, claves, memorias, sesiones, habilidades, tareas cron. No se puede deshacer.", + created: "Creado", + deleted: "Eliminado", + renamed: "Renombrado", + }, + + pluginsPage: { + contextEngineLabel: "Motor de contexto", + dashboardSlots: "Slots del panel", + disableRuntime: "Deshabilitar", + enableAfterInstall: "Habilitar tras instalar", + enableRuntime: "Habilitar", + forceReinstall: "Forzar reinstalación (eliminar carpeta existente primero)", + headline: + "Descubre, instala, habilita y actualiza complementos de Hermes (equivalente a `hermes plugins`).", + identifierLabel: "URL de Git u owner/repo", + inactive: "inactivo", + installBtn: "Instalar desde Git", + installHeading: "Instalar desde GitHub / URL de Git", + installHint: "Usa la forma corta owner/repo o una URL de clonación https:// o git@ completa.", + memoryProviderLabel: "Proveedor de memoria", + missingEnvWarn: "Configura estos en Claves antes de que el complemento pueda ejecutarse:", + noDashboardTab: "Sin pestaña de panel", + openTab: "Abrir", + orphanHeading: "Extensiones solo del panel (sin coincidencia de plugin.yaml del agente)", + pluginListHeading: "Complementos instalados", + providerDefaults: "incorporado / predeterminado", + providersHeading: "Complementos de proveedor en tiempo de ejecución", + providersHint: + "Escribe memory.provider (vacío = incorporado) y context.engine en config.yaml. Surte efecto en la próxima sesión.", + refreshDashboard: "Volver a escanear extensiones del panel", + removeConfirm: "¿Eliminar este complemento de ~/.hermes/plugins/?", + removeHint: "Solo se pueden eliminar complementos instalados por el usuario en ~/.hermes/plugins.", + rescanHeading: "Registro de complementos SPA", + rescanHint: "Vuelve a escanear tras añadir archivos en disco para que la barra lateral del panel detecte nuevos manifiestos.", + runtimeHeading: "Tiempo de ejecución del Gateway (complementos YAML)", + saveProviders: "Guardar configuración del proveedor", + savedProviders: "Configuración del proveedor guardada.", + sourceBadge: "Fuente", + authRequired: "Autenticación requerida", + authRequiredHint: "Ejecuta este comando para autenticarte:", + updateGit: "Git pull", + versionBadge: "Versión", + showInSidebar: "Mostrar en barra lateral", + hideFromSidebar: "Ocultar de la barra lateral", + }, + + skills: { + title: "Habilidades", + searchPlaceholder: "Buscar habilidades y conjuntos de herramientas...", + enabledOf: "{enabled}/{total} habilitados", + all: "Todas", + categories: "Categorías", + filters: "Filtros", + noSkills: "No se encontraron habilidades. Las habilidades se cargan desde ~/.hermes/skills/", + noSkillsMatch: "Ninguna habilidad coincide con tu búsqueda o filtro.", + skillCount: "{count} habilidad{s}", + resultCount: "{count} resultado{s}", + noDescription: "No hay descripción disponible.", + toolsets: "Conjuntos de herramientas", + toolsetLabel: "conjunto de herramientas {name}", + noToolsetsMatch: "Ningún conjunto de herramientas coincide con la búsqueda.", + setupNeeded: "Configuración necesaria", + disabledForCli: "Deshabilitado para CLI", + more: "+{count} más", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "Filtros", + sections: "Secciones", + exportConfig: "Exportar configuración como JSON", + importConfig: "Importar configuración desde JSON", + resetDefaults: "Restablecer valores predeterminados", + resetScopeTooltip: "Restablecer {scope} a los valores predeterminados", + confirmResetScope: "¿Restablecer todos los ajustes de {scope} a sus valores predeterminados? Esto solo actualiza el formulario — los cambios no se escriben en config.yaml hasta que pulses Guardar.", + resetScopeToast: "{scope} restablecido a los valores predeterminados — revisa y guarda para que persista", + rawYaml: "Configuración YAML en bruto", + searchResults: "Resultados de búsqueda", + fields: "campo{s}", + noFieldsMatch: 'Ningún campo coincide con "{query}"', + configSaved: "Configuración guardada", + yamlConfigSaved: "Configuración YAML guardada", + failedToSave: "No se pudo guardar", + failedToSaveYaml: "No se pudo guardar YAML", + failedToLoadRaw: "No se pudo cargar la configuración en bruto", + configImported: "Configuración importada — revisa y guarda", + invalidJson: "Archivo JSON no válido", + categories: { + general: "General", + agent: "Agente", + terminal: "Terminal", + display: "Pantalla", + delegation: "Delegación", + memory: "Memoria", + compression: "Compresión", + security: "Seguridad", + browser: "Navegador", + voice: "Voz", + tts: "Texto a voz", + stt: "Voz a texto", + logging: "Registro", + discord: "Discord", + auxiliary: "Auxiliar", + }, + }, + + env: { + changesNote: "Los cambios se guardan en disco inmediatamente. Las sesiones activas adoptan las nuevas claves automáticamente.", + confirmClearMessage: + "El valor almacenado para esta variable se eliminará de tu archivo .env. Esto no se puede deshacer desde la UI.", + confirmClearTitle: "¿Limpiar esta clave?", + description: "Gestiona claves API y secretos almacenados en", + hideAdvanced: "Ocultar avanzado", + showAdvanced: "Mostrar avanzado", + llmProviders: "Proveedores LLM", + providersConfigured: "{configured} de {total} proveedores configurados", + getKey: "Obtener clave", + notConfigured: "{count} no configurados", + notSet: "No establecido", + keysCount: "{count} clave{s}", + enterValue: "Introduce un valor...", + replaceCurrentValue: "Reemplazar valor actual ({preview})", + showValue: "Mostrar valor real", + hideValue: "Ocultar valor", + }, + + oauth: { + title: "Inicios de sesión de proveedores (OAuth)", + providerLogins: "Inicios de sesión de proveedores (OAuth)", + description: "{connected} de {total} proveedores OAuth conectados. Los flujos de inicio de sesión actualmente se ejecutan a través de la CLI; haz clic en Copiar comando y pégalo en una terminal para configurar.", + connected: "Conectado", + expired: "Caducado", + notConnected: "No conectado. Ejecuta {command} en una terminal.", + runInTerminal: "en una terminal.", + noProviders: "No se han detectado proveedores compatibles con OAuth.", + login: "Iniciar sesión", + disconnect: "Desconectar", + managedExternally: "Gestionado externamente", + copied: "Copiado ✓", + cli: "CLI", + copyCliCommand: "Copiar comando CLI (para externo / alternativa)", + connect: "Conectar", + sessionExpires: "La sesión caduca en {time}", + initiatingLogin: "Iniciando flujo de inicio de sesión…", + exchangingCode: "Intercambiando código por tokens…", + connectedClosing: "¡Conectado! Cerrando…", + loginFailed: "Inicio de sesión fallido.", + sessionExpired: "Sesión caducada. Haz clic en Reintentar para iniciar un nuevo inicio de sesión.", + reOpenAuth: "Reabrir página de autenticación", + reOpenVerification: "Reabrir página de verificación", + submitCode: "Enviar código", + pasteCode: "Pega el código de autorización (con el sufijo #state está bien)", + waitingAuth: "Esperando que autorices en el navegador…", + enterCodePrompt: "Se abrió una nueva pestaña. Introduce este código si se solicita:", + pkceStep1: "Se abrió una nueva pestaña en claude.ai. Inicia sesión y haz clic en Autorizar.", + pkceStep2: "Copia el código de autorización mostrado tras autorizar.", + pkceStep3: "Pégalo abajo y envía.", + flowLabels: { + pkce: "Inicio de sesión por navegador (PKCE)", + device_code: "Código de dispositivo", + external: "CLI externa", + }, + expiresIn: "caduca en {time}", + }, + + language: { + switchTo: "Cambiar a inglés", + }, + + theme: { + title: "Tema", + switchTheme: "Cambiar tema", + }, + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Insignias coleccionables de Hermes ganadas a partir del historial real de sesiones. Los logros conocidos no completados se muestran como Descubiertos; los logros secretos permanecen ocultos hasta que aparece el primer comportamiento coincidente.", + scan_subtitle: + "Escaneando el historial de sesiones de Hermes. El primer escaneo puede tardar 5–10 segundos en historiales grandes.", + }, + actions: { + rescan: "Volver a escanear", + }, + stats: { + unlocked: "Desbloqueados", + unlocked_hint: "insignias ganadas", + discovered: "Descubiertos", + discovered_hint: "conocidos, aún no ganados", + secrets: "Secretos", + secrets_hint: "ocultos hasta la primera señal", + highest_tier: "Nivel más alto", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "Más reciente", + latest_hint_empty: "usa Hermes más", + none_yet: "Ninguno aún", + }, + state: { + unlocked: "Desbloqueado", + discovered: "Descubierto", + secret: "Secreto", + }, + tier: { + target: "Objetivo {tier}", + hidden: "Oculto", + complete: "Completo", + objective: "Objetivo", + }, + progress: { + hidden: "oculto", + }, + scan: { + building_headline: "Construyendo perfil de logros…", + building_detail: + "Leyendo sesiones, llamadas a herramientas, metadatos del modelo y estado de desbloqueo.", + starting_headline: "Iniciando escaneo de logros…", + progress_detail: + "Escaneadas {scanned} de {total} sesiones · {pct}%. Las insignias se desbloquean a medida que se procesa más historial.", + idle_detail: + "Leyendo sesiones, llamadas a herramientas, metadatos del modelo y estado de desbloqueo. Las insignias aparecerán aquí a medida que se desbloqueen.", + }, + guide: { + tiers_header: "Niveles", + secret_header: "Logros secretos", + secret_body: + "Los secretos ocultan su disparador exacto. Una vez que Hermes detecta una señal relacionada, la tarjeta pasa a Descubierto y muestra su requisito.", + scan_status_header: "Estado del escaneo", + scan_status_body: + "Hermes está escaneando el historial local una vez, después las tarjetas aparecerán automáticamente. No hay nada bloqueado si tarda unos segundos.", + what_scanned_header: "Qué se escanea", + what_scanned_body: + "Sesiones, llamadas a herramientas, metadatos del modelo, errores, logros y estado de desbloqueo local.", + }, + card: { + share_title: "Compartir este logro", + share_label: "Compartir {name}", + share_text: "Compartir", + how_to_reveal: "Cómo revelarlo", + what_counts: "Qué cuenta", + evidence_label: "Evidencia", + evidence_session_fallback: "sesión", + no_evidence: "Aún sin evidencia", + }, + latest: { + header: "Desbloqueos recientes", + }, + empty: { + no_secrets_header: "No quedan secretos ocultos en este escaneo.", + no_secrets_body: + "Pista: los secretos suelen comenzar a partir de fallos inusuales o patrones de usuario avanzado: conflictos de puertos, muros de permisos, variables de entorno faltantes, errores de YAML, colisiones de Docker, uso de rollback/checkpoint, aciertos de caché o pequeñas correcciones tras mucho texto rojo.", + }, + filters: { + all_categories: "Todos", + visibility_all: "todos", + visibility_unlocked: "desbloqueados", + visibility_discovered: "descubiertos", + visibility_secret: "secretos", + }, + share: { + dialog_label: "Compartir logro", + header: "Compartir: {name}", + close: "Cerrar", + rendering: "Renderizando…", + card_alt: "Tarjeta para compartir de {name}", + error_generic: "Algo salió mal.", + x_title: "Abre X con una publicación predefinida", + x_button: "Compartir en X", + copy_title: "Copia la imagen para pegarla en tu publicación", + copy_button: "Copiar imagen", + copied: "Copiado ✓", + download_button: "Descargar PNG", + hint: + "Compartir en X abre una publicación predefinida en una nueva pestaña. Haz clic primero en Copiar imagen si quieres adjuntar la insignia 1200×630: X te permite pegarla directamente en el redactor del tuit. Descargar PNG guarda el archivo para usarlo en cualquier lugar.", + clipboard_unsupported: + "Este navegador no admite copiar imágenes al portapapeles: usa Descargar en su lugar.", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Cargando tablero Kanban…", + loadFailed: "Error al cargar el tablero Kanban: ", + loadFailedHint: + "El backend crea automáticamente kanban.db en la primera lectura. Si el problema persiste, revisa los registros del panel.", + board: "Tablero", + newBoard: "+ Nuevo tablero", + newBoardTitle: "Nuevo tablero", + newBoardDescription: + "Los tableros te permiten separar flujos de trabajo no relacionados — uno por proyecto, repositorio o dominio. Los workers de un tablero nunca ven las tareas de otro.", + slug: "Slug", + slugHint: "— minúsculas, guiones, p. ej. atm10-server", + displayName: "Nombre visible", + displayNameHint: "(opcional)", + description: "Descripción", + descriptionHint: "(opcional)", + icon: "Icono", + iconHint: "(un solo carácter o emoji)", + switchAfterCreate: "Cambiar a este tablero tras crearlo", + cancel: "Cancelar", + creating: "Creando…", + createBoard: "Crear tablero", + search: "Buscar", + filterCards: "Filtrar tarjetas…", + tenant: "Tenant", + allTenants: "Todos los tenants", + assignee: "Asignado a", + allProfiles: "Todos los perfiles", + showArchived: "Mostrar archivados", + lanesByProfile: "Carriles por perfil", + nudgeDispatcher: "Avisar al dispatcher", + refresh: "Actualizar", + selected: "seleccionado(s)", + complete: "Completar", + archive: "Archivar", + apply: "Aplicar", + clear: "Limpiar", + createTask: "Crear tarea en esta columna", + noTasks: "— sin tareas —", + unassigned: "sin asignar", + untitled: "(sin título)", + loadingDetail: "Cargando…", + addComment: "Añadir un comentario… (Enter para enviar)", + comment: "Comentario", + status: "Estado", + workspace: "Workspace", + skills: "Habilidades", + createdBy: "Creado por", + result: "Result", + comments: "Comentarios", + events: "Eventos", + runHistory: "Historial de ejecuciones", + workerLog: "Registro del worker", + loadingLog: "Cargando registro…", + noWorkerLog: + "— aún no hay registro del worker (la tarea no se ha lanzado o el registro fue rotado) —", + noDescription: "— sin descripción —", + noComments: "— sin comentarios —", + edit: "editar", + save: "Guardar", + dependencies: "Dependencias", + parents: "Padres:", + children: "Hijos:", + none: "ninguno", + addParent: "— añadir padre —", + addChild: "— añadir hijo —", + removeDependency: "Eliminar dependencia", + block: "Bloquear", + unblock: "Desbloquear", + notifyHomeChannels: "Notificar a los canales de inicio", + diagnostics: "Diagnósticos", + hide: "Ocultar", + show: "Mostrar", + attention: "Atención", + tasksNeedAttention: "tareas requieren atención", + taskNeedsAttention: "1 tarea requiere atención", + diagnostic: "diagnóstico", + open: "Abrir", + close: "Cerrar (Esc)", + reassignTo: "Reasignar a:", + copied: "Copiado", + copyCommand: "Copiar comando al portapapeles", + reclaim: "Recuperar", + reassign: "Reasignar", + renderingError: "La pestaña Kanban tuvo un error de renderizado", + reloadView: "Recargar vista", + wsAuthFailed: + "Error de autenticación de WebSocket — recarga la página para refrescar el token de sesión.", + markDone: "¿Marcar {n} tarea(s) como hechas?", + markArchived: "¿Archivar {n} tarea(s)?", + warning: "Advertencia", + phantomIds: "IDs fantasma:", + active: "activo", + ended: "finalizado", + noProfile: "(sin perfil)", + showAllAttempts: "Mostrar todos los intentos", + sendingUpdates: "Enviando actualizaciones a", + sendNotifications: "Enviar notificaciones de completed / blocked / gave_up a", + archiveBoardConfirm: + "¿Archivar el tablero '{name}'? Se moverá a boards/_archived/ para que puedas recuperarlo más tarde. Las tareas de este tablero ya no aparecerán en ninguna parte de la UI.", + archiveBoardTitle: "Archivar este tablero", + boardSwitcherHint: "Los tableros te permiten separar flujos de trabajo no relacionados", + taskCreatedWarning: "Tarea creada, pero: ", + moveFailed: "Error al mover: ", + bulkFailed: "Lote: ", + completionBlockedHallucination: "⚠ Completado bloqueado — IDs de tarjeta fantasma", + suspectedHallucinatedReferences: "⚠ El texto referenció IDs de tarjeta fantasma", + pickProfileFirst: "Elige primero un perfil.", + unblockedMessage: "Desbloqueado {id}. La tarea está lista para el próximo tick.", + unblockFailed: "Error al desbloquear: ", + reclaimedMessage: "Recuperado {id}. La tarea vuelve a estar lista.", + reclaimFailed: "Error al recuperar: ", + reassignedMessage: "Reasignado {id} a {profile}.", + reassignFailed: "Error al reasignar: ", + selectForBulk: "Seleccionar para acciones por lotes", + clickToEdit: "Haz clic para editar", + clickToEditAssignee: "Haz clic para editar el asignado", + emptyAssignee: "(vacío = sin asignar)", + columnLabels: { + triage: "Clasificación", + todo: "Por hacer", + ready: "Listo", + running: "En curso", + blocked: "Bloqueado", + done: "Hecho", + archived: "Archivado", + }, + columnHelp: { + triage: "Ideas en bruto — un specifier desarrollará la especificación", + todo: "Esperando dependencias o sin asignar", + ready: "Asignado y esperando un tick del dispatcher", + running: "Reclamado por un worker — en ejecución", + blocked: "El worker pidió intervención humana", + done: "Completado", + archived: "Archivado", + }, + confirmDone: + "¿Marcar esta tarea como hecha? Se libera el reclamo del worker y los hijos dependientes pasan a estar listos.", + confirmArchive: + "¿Archivar esta tarea? Desaparecerá de la vista por defecto del tablero.", + confirmBlocked: + "¿Marcar esta tarea como bloqueada? Se libera el reclamo del worker.", + completionSummary: + "Resumen de finalización para {label}. Se almacena como el result de la tarea.", + completionSummaryRequired: + "El resumen de finalización es obligatorio antes de marcar una tarea como hecha.", + triagePlaceholder: "Idea aproximada — la IA la especificará…", + taskTitlePlaceholder: "Título de la nueva tarea…", + specifier: "specifier", + assigneePlaceholder: "asignado", + priority: "Prioridad", + skillsPlaceholder: + "habilidades (opcional, separadas por comas): translation, github-code-review", + noParent: "— sin padre —", + workspacePathDir: "ruta del workspace (obligatoria, p. ej. ~/projects/my-app)", + workspacePathOptional: + "ruta del workspace (opcional, derivada del asignado si está vacía)", + logTruncated: "(mostrando los últimos 100 KB — registro completo en ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/fr.ts b/apps/dashboard/src/i18n/fr.ts new file mode 100644 index 00000000000..4532cab3ee0 --- /dev/null +++ b/apps/dashboard/src/i18n/fr.ts @@ -0,0 +1,695 @@ +import type { Translations } from "./types"; + +export const fr: Translations = { + common: { + save: "Enregistrer", + saving: "Enregistrement...", + cancel: "Annuler", + close: "Fermer", + confirm: "Confirmer", + delete: "Supprimer", + refresh: "Actualiser", + retry: "Réessayer", + search: "Rechercher...", + loading: "Chargement...", + create: "Créer", + creating: "Création...", + set: "Définir", + replace: "Remplacer", + clear: "Effacer", + live: "En direct", + off: "Désactivé", + enabled: "activé", + disabled: "désactivé", + active: "actif", + inactive: "inactif", + unknown: "inconnu", + untitled: "Sans titre", + none: "Aucun", + form: "Formulaire", + noResults: "Aucun résultat", + of: "sur", + page: "Page", + msgs: "msgs", + tools: "outils", + match: "correspondance", + other: "Autre", + configured: "configuré", + removed: "supprimé", + failedToToggle: "Échec du basculement", + failedToRemove: "Échec de la suppression", + failedToReveal: "Échec de l'affichage", + collapse: "Réduire", + expand: "Développer", + general: "Général", + messaging: "Messagerie", + pluginLoadFailed: + "Impossible de charger le script de ce plugin. Vérifiez l'onglet Réseau (dashboard-plugins/…) et le chemin des plugins du serveur.", + pluginNotRegistered: + "Le script du plugin n'a pas appelé register(), ou le script a échoué. Ouvrez la console du navigateur pour plus de détails.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "Fermer la navigation", + closeModelTools: "Fermer modèle et outils", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "Sessions actives:", + gatewayStatusLabel: "État de la passerelle:", + gatewayStrip: { + failed: "Échec du démarrage", + off: "Désactivé", + running: "En cours", + starting: "Démarrage", + stopped: "Arrêté", + }, + nav: { + analytics: "Analyses", + chat: "Chat", + config: "Configuration", + cron: "Cron", + documentation: "Documentation", + keys: "Clés", + logs: "Journaux", + models: "Modèles", + profiles: "profils : multi agents", + plugins: "Plugins", + sessions: "Sessions", + skills: "Compétences", + }, + modelToolsSheetSubtitle: "& outils", + modelToolsSheetTitle: "Modèle", + navigation: "Navigation", + openDocumentation: "Ouvrir la documentation dans un nouvel onglet", + openNavigation: "Ouvrir la navigation", + pluginNavSection: "Plugins", + sessionsActiveCount: "{count} actives", + statusOverview: "Aperçu de l'état", + system: "Système", + webUi: "Web UI", + }, + + status: { + actionFailed: "Action échouée", + actionFinished: "Terminé", + actions: "Actions", + agent: "Agent", + activeSessions: "Sessions actives", + connected: "Connecté", + connectedPlatforms: "Plateformes connectées", + disconnected: "Déconnecté", + error: "Erreur", + failed: "Échec", + gateway: "Passerelle", + gatewayFailedToStart: "Le démarrage de la passerelle a échoué", + lastUpdate: "Dernière mise à jour", + noneRunning: "Aucun", + notRunning: "Non lancé", + pid: "PID", + platformDisconnected: "déconnecté", + platformError: "erreur", + recentSessions: "Sessions récentes", + restartGateway: "Redémarrer la passerelle", + restartingGateway: "Redémarrage de la passerelle…", + running: "En cours", + runningRemote: "En cours (distant)", + startFailed: "Échec du démarrage", + starting: "Démarrage", + startedInBackground: "Démarré en arrière-plan — consultez les journaux pour la progression", + stopped: "Arrêté", + updateHermes: "Mettre à jour Hermes", + updatingHermes: "Mise à jour de Hermes…", + waitingForOutput: "En attente de la sortie…", + }, + + sessions: { + title: "Sessions", + searchPlaceholder: "Rechercher dans les messages...", + noSessions: "Aucune session pour l'instant", + noMatch: "Aucune session ne correspond à votre recherche", + startConversation: "Démarrez une conversation pour la voir ici", + noMessages: "Aucun message", + untitledSession: "Session sans titre", + deleteSession: "Supprimer la session", + confirmDeleteTitle: "Supprimer la session ?", + confirmDeleteMessage: + "Cela supprime définitivement la conversation et tous ses messages. Cette action est irréversible.", + sessionDeleted: "Session supprimée", + failedToDelete: "Échec de la suppression de la session", + resumeInChat: "Reprendre dans le chat", + previousPage: "Page précédente", + nextPage: "Page suivante", + roles: { + user: "Utilisateur", + assistant: "Assistant", + system: "Système", + tool: "Outil", + }, + }, + + analytics: { + period: "Période:", + totalTokens: "Tokens totaux", + totalSessions: "Sessions totales", + apiCalls: "Appels API", + dailyTokenUsage: "Utilisation quotidienne des tokens", + dailyBreakdown: "Détail quotidien", + perModelBreakdown: "Détail par modèle", + topSkills: "Compétences les plus utilisées", + skill: "Compétence", + loads: "Agent chargé", + edits: "Agent géré", + lastUsed: "Dernière utilisation", + input: "Entrée", + output: "Sortie", + total: "Total", + noUsageData: "Aucune donnée d'utilisation pour cette période", + startSession: "Démarrez une session pour voir les analyses ici", + date: "Date", + model: "Modèle", + tokens: "Tokens", + perDayAvg: "/jour moy", + acrossModels: "sur {count} modèles", + inOut: "{input} entrée / {output} sortie", + }, + + models: { + modelsUsed: "Modèles utilisés", + estimatedCost: "Coût est.", + tokens: "tokens", + sessions: "sessions", + avgPerSession: "moy/session", + apiCalls: "appels API", + toolCalls: "appels d'outil", + noModelsData: "Aucune donnée de modèle pour cette période", + startSession: "Démarrez une session pour voir les données de modèle ici", + }, + + logs: { + title: "Journaux", + autoRefresh: "Actualisation auto", + file: "Fichier", + level: "Niveau", + component: "Composant", + lines: "Lignes", + noLogLines: "Aucune ligne de journal trouvée", + }, + + cron: { + confirmDeleteMessage: + "Cela supprime la tâche du planning. Cette action est irréversible.", + confirmDeleteTitle: "Supprimer la tâche planifiée ?", + newJob: "Nouvelle tâche cron", + nameOptional: "Nom (facultatif)", + namePlaceholder: "ex. Résumé quotidien", + prompt: "Invite", + promptPlaceholder: "Que doit faire l'agent à chaque exécution ?", + schedule: "Planning (expression cron)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Livrer à", + scheduledJobs: "Tâches planifiées", + noJobs: "Aucune tâche cron configurée. Créez-en une ci-dessus.", + last: "Dernière", + next: "Prochaine", + pause: "Pause", + resume: "Reprendre", + triggerNow: "Déclencher maintenant", + delivery: { + local: "Local", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "Nouveau profil", + name: "Nom", + namePlaceholder: "ex. coder, writer, etc.", + nameRequired: "Le nom est requis", + nameRule: + "Lettres minuscules, chiffres, _ et - uniquement ; doit commencer par une lettre ou un chiffre ; jusqu'à 64 caractères.", + invalidName: "Nom de profil invalide", + cloneFromDefault: "Cloner la configuration du profil par défaut", + allProfiles: "Profils", + noProfiles: "Aucun profil trouvé.", + defaultBadge: "défaut", + hasEnv: "env", + model: "Modèle", + skills: "Compétences", + rename: "Renommer", + editSoul: "Modifier SOUL.md", + soulSection: "SOUL.md (personnalité / invite système)", + soulPlaceholder: "# Comment cet agent doit se comporter…", + saveSoul: "Enregistrer SOUL", + soulSaved: "SOUL.md enregistré", + openInTerminal: "Copier la commande CLI", + commandCopied: "Copié dans le presse-papiers", + copyFailed: "Impossible de copier", + confirmDeleteTitle: "Supprimer le profil ?", + confirmDeleteMessage: + "Cela supprime définitivement le profil '{name}' — configuration, clés, mémoires, sessions, compétences, tâches cron. Action irréversible.", + created: "Créé", + deleted: "Supprimé", + renamed: "Renommé", + }, + + pluginsPage: { + contextEngineLabel: "Moteur de contexte", + dashboardSlots: "Emplacements du tableau de bord", + disableRuntime: "Désactiver", + enableAfterInstall: "Activer après l'installation", + enableRuntime: "Activer", + forceReinstall: "Forcer la réinstallation (supprimer d'abord le dossier existant)", + headline: + "Découvrez, installez, activez et mettez à jour les plugins Hermes (parité avec `hermes plugins`).", + identifierLabel: "URL Git ou owner/repo", + inactive: "inactif", + installBtn: "Installer depuis Git", + installHeading: "Installer depuis GitHub / URL Git", + installHint: "Utilisez le raccourci owner/repo ou une URL de clonage complète https:// ou git@.", + memoryProviderLabel: "Fournisseur de mémoire", + missingEnvWarn: "Définissez ces variables dans Clés avant que le plugin puisse s'exécuter:", + noDashboardTab: "Aucun onglet de tableau de bord", + openTab: "Ouvrir", + orphanHeading: "Extensions du tableau de bord uniquement (aucune correspondance plugin.yaml d'agent)", + pluginListHeading: "Plugins installés", + providerDefaults: "intégré / par défaut", + providersHeading: "Plugins fournisseurs d'exécution", + providersHint: + "Écrit memory.provider (vide = intégré) et context.engine dans config.yaml. Prend effet à la prochaine session.", + refreshDashboard: "Re-scanner les extensions du tableau de bord", + removeConfirm: "Retirer ce plugin de ~/.hermes/plugins/ ?", + removeHint: "Seuls les plugins installés par l'utilisateur sous ~/.hermes/plugins peuvent être supprimés.", + rescanHeading: "Registre des plugins SPA", + rescanHint: "Re-scannez après avoir ajouté des fichiers sur le disque pour que la barre latérale prenne en compte les nouveaux manifestes.", + runtimeHeading: "Exécution de la passerelle (plugins YAML)", + saveProviders: "Enregistrer les paramètres de fournisseur", + savedProviders: "Paramètres de fournisseur enregistrés.", + sourceBadge: "Source", + authRequired: "Authentification requise", + authRequiredHint: "Exécutez cette commande pour vous authentifier:", + updateGit: "Git pull", + versionBadge: "Version", + showInSidebar: "Afficher dans la barre latérale", + hideFromSidebar: "Masquer de la barre latérale", + }, + + skills: { + title: "Compétences", + searchPlaceholder: "Rechercher des compétences et des outils...", + enabledOf: "{enabled}/{total} activées", + all: "Toutes", + categories: "Catégories", + filters: "Filtres", + noSkills: "Aucune compétence trouvée. Les compétences sont chargées depuis ~/.hermes/skills/", + noSkillsMatch: "Aucune compétence ne correspond à votre recherche ou filtre.", + skillCount: "{count} compétence{s}", + resultCount: "{count} résultat{s}", + noDescription: "Aucune description disponible.", + toolsets: "Ensembles d'outils", + toolsetLabel: "Ensemble d'outils {name}", + noToolsetsMatch: "Aucun ensemble d'outils ne correspond à la recherche.", + setupNeeded: "Configuration nécessaire", + disabledForCli: "Désactivé pour CLI", + more: "+{count} de plus", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "Filtres", + sections: "Sections", + exportConfig: "Exporter la configuration en JSON", + importConfig: "Importer la configuration depuis JSON", + resetDefaults: "Réinitialiser aux valeurs par défaut", + resetScopeTooltip: "Réinitialiser {scope} aux valeurs par défaut", + confirmResetScope: "Réinitialiser tous les paramètres de {scope} aux valeurs par défaut ? Cela ne met à jour que le formulaire — les modifications ne sont écrites dans config.yaml qu'après avoir appuyé sur Enregistrer.", + resetScopeToast: "{scope} réinitialisé aux valeurs par défaut — vérifiez et enregistrez pour conserver", + rawYaml: "Configuration YAML brute", + searchResults: "Résultats de recherche", + fields: "champ{s}", + noFieldsMatch: 'Aucun champ ne correspond à "{query}"', + configSaved: "Configuration enregistrée", + yamlConfigSaved: "Configuration YAML enregistrée", + failedToSave: "Échec de l'enregistrement", + failedToSaveYaml: "Échec de l'enregistrement YAML", + failedToLoadRaw: "Échec du chargement de la configuration brute", + configImported: "Configuration importée — vérifiez et enregistrez", + invalidJson: "Fichier JSON invalide", + categories: { + general: "Général", + agent: "Agent", + terminal: "Terminal", + display: "Affichage", + delegation: "Délégation", + memory: "Mémoire", + compression: "Compression", + security: "Sécurité", + browser: "Navigateur", + voice: "Voix", + tts: "Synthèse vocale", + stt: "Reconnaissance vocale", + logging: "Journalisation", + discord: "Discord", + auxiliary: "Auxiliaire", + }, + }, + + env: { + changesNote: "Les modifications sont enregistrées sur le disque immédiatement. Les sessions actives récupèrent les nouvelles clés automatiquement.", + confirmClearMessage: + "La valeur stockée pour cette variable sera supprimée de votre fichier .env. Cette action ne peut pas être annulée depuis l'interface.", + confirmClearTitle: "Effacer cette clé ?", + description: "Gérer les clés API et les secrets stockés dans", + hideAdvanced: "Masquer les options avancées", + showAdvanced: "Afficher les options avancées", + llmProviders: "Fournisseurs LLM", + providersConfigured: "{configured} sur {total} fournisseurs configurés", + getKey: "Obtenir la clé", + notConfigured: "{count} non configuré", + notSet: "Non défini", + keysCount: "{count} clé{s}", + enterValue: "Saisir une valeur...", + replaceCurrentValue: "Remplacer la valeur actuelle ({preview})", + showValue: "Afficher la valeur réelle", + hideValue: "Masquer la valeur", + }, + + oauth: { + title: "Connexions fournisseurs (OAuth)", + providerLogins: "Connexions fournisseurs (OAuth)", + description: "{connected} sur {total} fournisseurs OAuth connectés. Les flux de connexion s'exécutent actuellement via le CLI ; cliquez sur Copier la commande et collez-la dans un terminal pour configurer.", + connected: "Connecté", + expired: "Expiré", + notConnected: "Non connecté. Exécutez {command} dans un terminal.", + runInTerminal: "dans un terminal.", + noProviders: "Aucun fournisseur compatible OAuth détecté.", + login: "Connexion", + disconnect: "Déconnecter", + managedExternally: "Géré en externe", + copied: "Copié ✓", + cli: "CLI", + copyCliCommand: "Copier la commande CLI (pour externe / repli)", + connect: "Connecter", + sessionExpires: "La session expire dans {time}", + initiatingLogin: "Lancement du flux de connexion…", + exchangingCode: "Échange du code contre des jetons…", + connectedClosing: "Connecté ! Fermeture…", + loginFailed: "Échec de la connexion.", + sessionExpired: "Session expirée. Cliquez sur Réessayer pour démarrer une nouvelle connexion.", + reOpenAuth: "Rouvrir la page d'authentification", + reOpenVerification: "Rouvrir la page de vérification", + submitCode: "Soumettre le code", + pasteCode: "Collez le code d'autorisation (avec suffixe #state accepté)", + waitingAuth: "En attente de votre autorisation dans le navigateur…", + enterCodePrompt: "Un nouvel onglet s'est ouvert. Saisissez ce code si demandé:", + pkceStep1: "Un nouvel onglet s'est ouvert vers claude.ai. Connectez-vous et cliquez sur Autoriser.", + pkceStep2: "Copiez le code d'autorisation affiché après autorisation.", + pkceStep3: "Collez-le ci-dessous et soumettez.", + flowLabels: { + pkce: "Connexion navigateur (PKCE)", + device_code: "Code d'appareil", + external: "CLI externe", + }, + expiresIn: "expire dans {time}", + }, + + language: { + switchTo: "Passer à l'anglais", + }, + + theme: { + title: "Thème", + switchTheme: "Changer de thème", + }, + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Badges Hermes à collectionner, gagnés à partir de l'historique réel des sessions. Les succès connus non terminés sont affichés comme Découverts ; les succès secrets restent cachés jusqu'à l'apparition du premier comportement correspondant.", + scan_subtitle: + "Analyse de l'historique des sessions Hermes en cours. Le premier scan peut prendre 5 à 10 secondes sur les historiques volumineux.", + }, + actions: { + rescan: "Relancer le scan", + }, + stats: { + unlocked: "Débloqués", + unlocked_hint: "badges obtenus", + discovered: "Découverts", + discovered_hint: "connus, pas encore obtenus", + secrets: "Secrets", + secrets_hint: "cachés jusqu'au premier signal", + highest_tier: "Niveau le plus élevé", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "Dernier", + latest_hint_empty: "utilisez Hermes davantage", + none_yet: "Aucun pour l'instant", + }, + state: { + unlocked: "Débloqué", + discovered: "Découvert", + secret: "Secret", + }, + tier: { + target: "Cible {tier}", + hidden: "Caché", + complete: "Terminé", + objective: "Objectif", + }, + progress: { + hidden: "caché", + }, + scan: { + building_headline: "Création du profil de succès…", + building_detail: + "Lecture des sessions, des appels d'outils, des métadonnées du modèle et de l'état de déblocage.", + starting_headline: "Démarrage du scan des succès…", + progress_detail: + "{scanned} sessions analysées sur {total} · {pct}%. Les badges se débloquent à mesure que l'historique est traité.", + idle_detail: + "Lecture des sessions, des appels d'outils, des métadonnées du modèle et de l'état de déblocage. Les badges apparaissent ici à mesure qu'ils se débloquent.", + }, + guide: { + tiers_header: "Niveaux", + secret_header: "Succès secrets", + secret_body: + "Les secrets cachent leur déclencheur exact. Dès qu'Hermes détecte un signal lié, la carte passe à Découvert et affiche son exigence.", + scan_status_header: "État du scan", + scan_status_body: + "Hermes analyse l'historique local une seule fois, puis les cartes apparaîtront automatiquement. Rien n'est bloqué si cela prend quelques secondes.", + what_scanned_header: "Ce qui est analysé", + what_scanned_body: + "Sessions, appels d'outils, métadonnées du modèle, erreurs, succès et état de déblocage local.", + }, + card: { + share_title: "Partager ce succès", + share_label: "Partager {name}", + share_text: "Partager", + how_to_reveal: "Comment le révéler", + what_counts: "Ce qui compte", + evidence_label: "Preuve", + evidence_session_fallback: "session", + no_evidence: "Pas encore de preuve", + }, + latest: { + header: "Déblocages récents", + }, + empty: { + no_secrets_header: "Plus aucun secret caché dans ce scan.", + no_secrets_body: + "Indice: les secrets démarrent généralement à partir d'échecs inhabituels ou de schémas d'utilisateurs avancés — conflits de ports, murs de permissions, variables d'environnement manquantes, erreurs YAML, collisions Docker, utilisation de rollback/checkpoint, succès de cache ou petits correctifs après beaucoup de texte rouge.", + }, + filters: { + all_categories: "Tous", + visibility_all: "tous", + visibility_unlocked: "débloqués", + visibility_discovered: "découverts", + visibility_secret: "secrets", + }, + share: { + dialog_label: "Partager le succès", + header: "Partager: {name}", + close: "Fermer", + rendering: "Rendu en cours…", + card_alt: "Carte de partage {name}", + error_generic: "Une erreur s'est produite.", + x_title: "Ouvre X avec une publication préremplie", + x_button: "Partager sur X", + copy_title: "Copiez l'image pour la coller dans votre publication", + copy_button: "Copier l'image", + copied: "Copié ✓", + download_button: "Télécharger le PNG", + hint: + "Partager sur X ouvre une publication préremplie dans un nouvel onglet. Cliquez d'abord sur Copier l'image si vous voulez joindre le badge 1200×630 — X vous laisse le coller directement dans l'éditeur de tweet. Télécharger le PNG enregistre le fichier pour l'utiliser n'importe où.", + clipboard_unsupported: + "La copie d'image dans le presse-papiers n'est pas prise en charge par ce navigateur — utilisez Télécharger à la place.", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Chargement du tableau Kanban…", + loadFailed: "Échec du chargement du tableau Kanban: ", + loadFailedHint: + "Le backend crée automatiquement kanban.db à la première lecture. Si le problème persiste, consultez les logs du dashboard.", + board: "Tableau", + newBoard: "+ Nouveau tableau", + newBoardTitle: "Nouveau tableau", + newBoardDescription: + "Les tableaux vous permettent de séparer des flux de travail indépendants — un par projet, dépôt ou domaine. Les workers d'un tableau ne voient jamais les tâches d'un autre.", + slug: "Slug", + slugHint: "— minuscules, tirets, par ex. atm10-server", + displayName: "Nom affiché", + displayNameHint: "(facultatif)", + description: "Description", + descriptionHint: "(facultatif)", + icon: "Icône", + iconHint: "(un seul caractère ou emoji)", + switchAfterCreate: "Basculer sur ce tableau après l'avoir créé", + cancel: "Annuler", + creating: "Création…", + createBoard: "Créer le tableau", + search: "Rechercher", + filterCards: "Filtrer les cartes…", + tenant: "Tenant", + allTenants: "Tous les tenants", + assignee: "Assigné à", + allProfiles: "Tous les profils", + showArchived: "Afficher les archivés", + lanesByProfile: "Couloirs par profil", + nudgeDispatcher: "Solliciter le dispatcher", + refresh: "Rafraîchir", + selected: "sélectionné(s)", + complete: "Terminer", + archive: "Archiver", + apply: "Appliquer", + clear: "Effacer", + createTask: "Créer une tâche dans cette colonne", + noTasks: "— aucune tâche —", + unassigned: "non assigné", + untitled: "(sans titre)", + loadingDetail: "Chargement…", + addComment: "Ajouter un commentaire… (Enter pour envoyer)", + comment: "Commentaire", + status: "Statut", + workspace: "Workspace", + skills: "Compétences", + createdBy: "Créé par", + result: "Result", + comments: "Commentaires", + events: "Événements", + runHistory: "Historique d'exécution", + workerLog: "Log du worker", + loadingLog: "Chargement du log…", + noWorkerLog: + "— pas encore de log du worker (la tâche n'a pas démarré ou le log a été effacé par rotation) —", + noDescription: "— aucune description —", + noComments: "— aucun commentaire —", + edit: "modifier", + save: "Enregistrer", + dependencies: "Dépendances", + parents: "Parents:", + children: "Enfants:", + none: "aucun", + addParent: "— ajouter un parent —", + addChild: "— ajouter un enfant —", + removeDependency: "Supprimer la dépendance", + block: "Bloquer", + unblock: "Débloquer", + notifyHomeChannels: "Notifier les canaux home", + diagnostics: "Diagnostics", + hide: "Masquer", + show: "Afficher", + attention: "Attention", + tasksNeedAttention: "tâches nécessitent une attention", + taskNeedsAttention: "1 tâche nécessite une attention", + diagnostic: "diagnostic", + open: "Ouvrir", + close: "Fermer (Esc)", + reassignTo: "Réassigner à:", + copied: "Copié", + copyCommand: "Copier la commande dans le presse-papiers", + reclaim: "Récupérer", + reassign: "Réassigner", + renderingError: "L'onglet Kanban a rencontré une erreur de rendu", + reloadView: "Recharger la vue", + wsAuthFailed: + "Échec d'authentification WebSocket — rechargez la page pour rafraîchir le jeton de session.", + markDone: "Marquer {n} tâche(s) comme terminée(s) ?", + markArchived: "Archiver {n} tâche(s) ?", + warning: "Avertissement", + phantomIds: "IDs fantômes:", + active: "actif", + ended: "terminé", + noProfile: "(aucun profil)", + showAllAttempts: "Afficher toutes les tentatives", + sendingUpdates: "Envoi des mises à jour à", + sendNotifications: "Envoyer les notifications completed / blocked / gave_up à", + archiveBoardConfirm: + "Archiver le tableau '{name}' ? Il sera déplacé vers boards/_archived/ pour pouvoir être récupéré plus tard. Les tâches de ce tableau n'apparaîtront plus nulle part dans l'UI.", + archiveBoardTitle: "Archiver ce tableau", + boardSwitcherHint: "Les tableaux vous permettent de séparer des flux de travail indépendants", + taskCreatedWarning: "Tâche créée, mais: ", + moveFailed: "Échec du déplacement: ", + bulkFailed: "Lot: ", + completionBlockedHallucination: "⚠ Achèvement bloqué — IDs de carte fantômes", + suspectedHallucinatedReferences: "⚠ Le texte a référencé des IDs de carte fantômes", + pickProfileFirst: "Choisissez d'abord un profil.", + unblockedMessage: "Débloqué {id}. La tâche est prête pour le prochain tick.", + unblockFailed: "Échec du déblocage: ", + reclaimedMessage: "Récupéré {id}. La tâche est de nouveau prête.", + reclaimFailed: "Échec de la récupération: ", + reassignedMessage: "Réassigné {id} à {profile}.", + reassignFailed: "Échec de la réassignation: ", + selectForBulk: "Sélectionner pour des actions groupées", + clickToEdit: "Cliquez pour modifier", + clickToEditAssignee: "Cliquez pour modifier l'assigné", + emptyAssignee: "(vide = désassigner)", + columnLabels: { + triage: "Triage", + todo: "À faire", + ready: "Prêt", + running: "En cours", + blocked: "Bloqué", + done: "Terminé", + archived: "Archivé", + }, + columnHelp: { + triage: "Idées brutes — un specifier rédigera la spécification", + todo: "En attente de dépendances ou non assigné", + ready: "Assigné et en attente d'un tick du dispatcher", + running: "Réclamé par un worker — en cours d'exécution", + blocked: "Le worker a demandé une intervention humaine", + done: "Terminé", + archived: "Archivé", + }, + confirmDone: + "Marquer cette tâche comme terminée ? La revendication du worker est libérée et les enfants dépendants deviennent prêts.", + confirmArchive: + "Archiver cette tâche ? Elle disparaîtra de la vue par défaut du tableau.", + confirmBlocked: + "Marquer cette tâche comme bloquée ? La revendication du worker est libérée.", + completionSummary: + "Résumé d'achèvement pour {label}. Stocké comme result de la tâche.", + completionSummaryRequired: + "Un résumé d'achèvement est requis avant de marquer une tâche comme terminée.", + triagePlaceholder: "Idée approximative — l'IA la spécifiera…", + taskTitlePlaceholder: "Titre de la nouvelle tâche…", + specifier: "specifier", + assigneePlaceholder: "assigné", + priority: "Priorité", + skillsPlaceholder: + "compétences (facultatif, séparées par virgules): translation, github-code-review", + noParent: "— aucun parent —", + workspacePathDir: "chemin du workspace (requis, par ex. ~/projects/my-app)", + workspacePathOptional: + "chemin du workspace (facultatif, dérivé de l'assigné si vide)", + logTruncated: "(affichage des derniers 100 KB — log complet à ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/ga.ts b/apps/dashboard/src/i18n/ga.ts new file mode 100644 index 00000000000..d75ec061b8b --- /dev/null +++ b/apps/dashboard/src/i18n/ga.ts @@ -0,0 +1,696 @@ +import type { Translations } from "./types"; + +export const ga: Translations = { + common: { + save: "Sábháil", + saving: "Á shábháil...", + cancel: "Cealaigh", + close: "Dún", + confirm: "Deimhnigh", + delete: "Scrios", + refresh: "Athnuaigh", + retry: "Bain triail eile as", + search: "Cuardaigh...", + loading: "Á luchtú...", + create: "Cruthaigh", + creating: "Á chruthú...", + set: "Socraigh", + replace: "Athchuir", + clear: "Glan", + live: "Beo", + off: "As", + enabled: "cumasaithe", + disabled: "díchumasaithe", + active: "gníomhach", + inactive: "neamhghníomhach", + unknown: "anaithnid", + untitled: "Gan teideal", + none: "Aon cheann", + form: "Foirm", + noResults: "Aon toradh", + of: "as", + page: "Leathanach", + msgs: "tcht", + tools: "uirlisí", + match: "meaitseáil", + other: "Eile", + configured: "cumraithe", + removed: "bainte", + failedToToggle: "Theip ar an scoránú", + failedToRemove: "Theip ar an mbaint", + failedToReveal: "Theip ar an taispeáint", + collapse: "Laghdaigh", + expand: "Leathnaigh", + general: "Ginearálta", + messaging: "Teachtaireachtaí", + pluginLoadFailed: + "Níorbh fhéidir script an plugin seo a luchtú. Seiceáil an cluaisín Network (dashboard-plugins/…) agus conair plugin an fhreastalaí.", + pluginNotRegistered: + "Níor ghlaoigh script an plugin ar register(), nó tharla earráid sa script. Oscail consól an bhrabhsálaí le haghaidh sonraí.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "Dún an nascleanúint", + closeModelTools: "Dún an samhail agus na huirlisí", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "Seisiúin gníomhacha:", + gatewayStatusLabel: "Stádas an gateway:", + gatewayStrip: { + failed: "Theip ar an tús", + off: "As", + running: "Ag rith", + starting: "Ag tosú", + stopped: "Stoptha", + }, + nav: { + analytics: "Anailís", + chat: "Comhrá", + config: "Cumraíocht", + cron: "Cron", + documentation: "Doiciméadú", + keys: "Eochracha", + logs: "Logaí", + models: "Samhlacha", + profiles: "próifílí : il-agents", + plugins: "Plugins", + sessions: "Seisiúin", + skills: "Scileanna", + }, + modelToolsSheetSubtitle: "agus uirlisí", + modelToolsSheetTitle: "Samhail", + navigation: "Nascleanúint", + openDocumentation: "Oscail an doiciméadú i gcluaisín nua", + openNavigation: "Oscail an nascleanúint", + pluginNavSection: "Plugins", + sessionsActiveCount: "{count} gníomhach", + statusOverview: "Forbhreathnú stádais", + system: "Córas", + webUi: "Web UI", + }, + + status: { + actionFailed: "Theip ar an ngníomh", + actionFinished: "Críochnaithe", + actions: "Gníomhartha", + agent: "Agent", + activeSessions: "Seisiúin ghníomhacha", + connected: "Ceangailte", + connectedPlatforms: "Ardáin cheangailte", + disconnected: "Dícheangailte", + error: "Earráid", + failed: "Theip", + gateway: "Gateway", + gatewayFailedToStart: "Theip ar an gateway tosú", + lastUpdate: "Nuashonrú deireanach", + noneRunning: "Aon cheann", + notRunning: "Níl ag rith", + pid: "PID", + platformDisconnected: "dícheangailte", + platformError: "earráid", + recentSessions: "Seisiúin le déanaí", + restartGateway: "Atosaigh an gateway", + restartingGateway: "Ag atosú an gateway…", + running: "Ag rith", + runningRemote: "Ag rith (cianda)", + startFailed: "Theip ar an tús", + starting: "Ag tosú", + startedInBackground: "Tosaithe sa chúlra — seiceáil na logaí le haghaidh dul chun cinn", + stopped: "Stoptha", + updateHermes: "Nuashonraigh Hermes", + updatingHermes: "Ag nuashonrú Hermes…", + waitingForOutput: "Ag fanacht le haschur…", + }, + + sessions: { + title: "Seisiúin", + searchPlaceholder: "Cuardaigh ábhar teachtaireachta...", + noSessions: "Gan seisiúin go fóill", + noMatch: "Níl seisiún ar bith ag teacht le do chuardach", + startConversation: "Tosaigh comhrá chun é a fheiceáil anseo", + noMessages: "Gan teachtaireachtaí", + untitledSession: "Seisiún gan teideal", + deleteSession: "Scrios an seisiún", + confirmDeleteTitle: "Scrios an seisiún?", + confirmDeleteMessage: + "Baineann sé seo an comhrá agus a chuid teachtaireachtaí ar fad go buan. Ní féidir é seo a chealú.", + sessionDeleted: "Seisiún scriosta", + failedToDelete: "Theip ar scriosadh an tseisiúin", + resumeInChat: "Lean ar aghaidh sa chomhrá", + previousPage: "Leathanach roimhe seo", + nextPage: "An chéad leathanach eile", + roles: { + user: "Úsáideoir", + assistant: "Cúntóir", + system: "Córas", + tool: "Uirlis", + }, + }, + + analytics: { + period: "Tréimhse:", + totalTokens: "Tokens iomlána", + totalSessions: "Seisiúin iomlána", + apiCalls: "Glaonna API", + dailyTokenUsage: "Úsáid laethúil tokens", + dailyBreakdown: "Miondealú laethúil", + perModelBreakdown: "Miondealú de réir samhla", + topSkills: "Príomhscileanna", + skill: "Scil", + loads: "Luchtaithe ag an Agent", + edits: "Bainistithe ag an Agent", + lastUsed: "Úsáidte go deireanach", + input: "Ionchur", + output: "Aschur", + total: "Iomlán", + noUsageData: "Gan sonraí úsáide don tréimhse seo", + startSession: "Tosaigh seisiún chun anailís a fheiceáil anseo", + date: "Dáta", + model: "Samhail", + tokens: "Tokens", + perDayAvg: "/lá meán", + acrossModels: "thar {count} samhail", + inOut: "{input} isteach / {output} amach", + }, + + models: { + modelsUsed: "Samhlacha úsáidte", + estimatedCost: "Costas measta", + tokens: "tokens", + sessions: "seisiúin", + avgPerSession: "meán/seisiún", + apiCalls: "glaonna API", + toolCalls: "glaonna uirlise", + noModelsData: "Gan sonraí úsáide samhla don tréimhse seo", + startSession: "Tosaigh seisiún chun sonraí samhla a fheiceáil anseo", + }, + + logs: { + title: "Logaí", + autoRefresh: "Athnuachan uathoibríoch", + file: "Comhad", + level: "Leibhéal", + component: "Comhpháirt", + lines: "Línte", + noLogLines: "Níor aimsíodh línte loga", + }, + + cron: { + confirmDeleteMessage: + "Baineann sé seo an post ón sceideal. Ní féidir é seo a chealú.", + confirmDeleteTitle: "Scrios an post sceidealta?", + newJob: "Post Cron Nua", + nameOptional: "Ainm (roghnach)", + namePlaceholder: "m.sh. Achoimre laethúil", + prompt: "Prompt", + promptPlaceholder: "Cad ba chóir don agent a dhéanamh ag gach rith?", + schedule: "Sceideal (slonn cron)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Seachadadh chuig", + scheduledJobs: "Poist sceidealta", + noJobs: "Níl poist cron cumraithe. Cruthaigh ceann thuas.", + last: "Deireanach", + next: "Ar aghaidh", + pause: "Sos", + resume: "Lean ar aghaidh", + triggerNow: "Spreag anois", + delivery: { + local: "Áitiúil", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "Próifíl Nua", + name: "Ainm", + namePlaceholder: "m.sh. coder, writer, srl.", + nameRequired: "Tá ainm riachtanach", + nameRule: + "Litreacha cás íochtair, digití, _ agus - amháin; caithfidh tús a chur le litir nó digit; suas le 64 carachtar.", + invalidName: "Ainm próifíle neamhbhailí", + cloneFromDefault: "Clónáil cumraíocht ón bpróifíl réamhshocraithe", + allProfiles: "Próifílí", + noProfiles: "Níor aimsíodh próifílí.", + defaultBadge: "réamhshocraithe", + hasEnv: "env", + model: "Samhail", + skills: "Scileanna", + rename: "Athainmnigh", + editSoul: "Cuir SOUL.md in eagar", + soulSection: "SOUL.md (pearsantacht / prompt córais)", + soulPlaceholder: "# Conas ba chóir don agent seo iompar…", + saveSoul: "Sábháil SOUL", + soulSaved: "SOUL.md sábháilte", + openInTerminal: "Cóipeáil ordú CLI", + commandCopied: "Cóipeáilte chuig an ngearrthaisce", + copyFailed: "Níorbh fhéidir cóipeáil", + confirmDeleteTitle: "Scrios an phróifíl?", + confirmDeleteMessage: + "Scriosann sé seo an phróifíl '{name}' go buan — cumraíocht, eochracha, cuimhní, seisiúin, scileanna, poist cron. Ní féidir é a chealú.", + created: "Cruthaithe", + deleted: "Scriosta", + renamed: "Athainmnithe", + }, + + pluginsPage: { + contextEngineLabel: "Inneall comhthéacs", + dashboardSlots: "Slots an dashboard", + disableRuntime: "Díchumasaigh", + enableAfterInstall: "Cumasaigh tar éis suiteála", + enableRuntime: "Cumasaigh", + forceReinstall: "Cuir iallach ar athshuiteáil (scrios an fillteán atá ann ar dtús)", + headline: + "Faigh, suiteáil, cumasaigh agus nuashonraigh plugins Hermes (paireacht le `hermes plugins`).", + identifierLabel: "URL Git nó owner/repo", + inactive: "neamhghníomhach", + installBtn: "Suiteáil ó Git", + installHeading: "Suiteáil ó GitHub / URL Git", + installHint: "Úsáid an gearrshamhail owner/repo nó URL clóin iomlán https:// nó git@.", + memoryProviderLabel: "Soláthraí cuimhne", + missingEnvWarn: "Socraigh iad seo in Eochracha sular féidir leis an plugin rith:", + noDashboardTab: "Gan cluaisín dashboard", + openTab: "Oscail", + orphanHeading: "Síntí dashboard amháin (gan meaitseáil le agent plugin.yaml)", + pluginListHeading: "Plugins suiteáilte", + providerDefaults: "ionsuite / réamhshocraithe", + providersHeading: "Plugins soláthraí runtime", + providersHint: + "Scríobhann memory.provider (folamh = ionsuite) agus context.engine chuig config.yaml. Beidh éifeacht aige sa chéad seisiún eile.", + refreshDashboard: "Athscan síntí an dashboard", + removeConfirm: "Bain an plugin seo ó ~/.hermes/plugins/?", + removeHint: "Ní féidir ach plugins atá suiteáilte ag an úsáideoir faoi ~/.hermes/plugins a bhaint.", + rescanHeading: "Clár plugin SPA", + rescanHint: "Athscan tar éis comhaid a chur leis an diosca ionas go n-aimseoidh barra taoibh an dashboard manifests nua.", + runtimeHeading: "Runtime gateway (plugins YAML)", + saveProviders: "Sábháil socruithe an tsoláthraí", + savedProviders: "Socruithe an tsoláthraí sábháilte.", + sourceBadge: "Foinse", + authRequired: "Fíordheimhniú riachtanach", + authRequiredHint: "Rith an t-ordú seo chun fíordheimhniú a dhéanamh:", + updateGit: "Git pull", + versionBadge: "Leagan", + showInSidebar: "Taispeáin sa bharra taoibh", + hideFromSidebar: "Folaigh ón mbarra taoibh", + }, + + skills: { + title: "Scileanna", + searchPlaceholder: "Cuardaigh scileanna agus toolsets...", + enabledOf: "{enabled}/{total} cumasaithe", + all: "Gach ceann", + categories: "Catagóirí", + filters: "Scagairí", + noSkills: "Níor aimsíodh scileanna. Luchtaítear scileanna ó ~/.hermes/skills/", + noSkillsMatch: "Níl scil ar bith ag teacht le do chuardach nó scagaire.", + skillCount: "{count} scil{s}", + resultCount: "{count} torad{s}", + noDescription: "Gan cur síos ar fáil.", + toolsets: "Toolsets", + toolsetLabel: "toolset {name}", + noToolsetsMatch: "Níl toolset ar bith ag teacht leis an gcuardach.", + setupNeeded: "Socrú ag teastáil", + disabledForCli: "Díchumasaithe don CLI", + more: "+{count} eile", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "Scagairí", + sections: "Ranna", + exportConfig: "Easpórtáil cumraíocht mar JSON", + importConfig: "Iompórtáil cumraíocht ó JSON", + resetDefaults: "Athshocraigh chuig réamhshocruithe", + resetScopeTooltip: "Athshocraigh {scope} chuig réamhshocruithe", + confirmResetScope: "Athshocraigh socruithe uile {scope} chuig a réamhshocruithe? Nuashonraíonn sé seo an fhoirm amháin — ní scríobhfar athruithe chuig config.yaml go dtí go mbrúnn tú Sábháil.", + resetScopeToast: "{scope} athshocraithe chuig réamhshocruithe — athbhreithnigh agus Sábháil chun é a choinneáil", + rawYaml: "Cumraíocht YAML amh", + searchResults: "Torthaí cuardaigh", + fields: "réims{s}", + noFieldsMatch: 'Níl aon réimsí ag teacht le "{query}"', + configSaved: "Cumraíocht sábháilte", + yamlConfigSaved: "Cumraíocht YAML sábháilte", + failedToSave: "Theip ar shábháil", + failedToSaveYaml: "Theip ar shábháil an YAML", + failedToLoadRaw: "Theip ar luchtú na cumraíochta amh", + configImported: "Cumraíocht iompórtáilte — athbhreithnigh agus sábháil", + invalidJson: "Comhad JSON neamhbhailí", + categories: { + general: "Ginearálta", + agent: "Agent", + terminal: "Teirminéal", + display: "Taispeáint", + delegation: "Tarmligean", + memory: "Cuimhne", + compression: "Comhbhrú", + security: "Slándáil", + browser: "Brabhsálaí", + voice: "Guth", + tts: "Téacs go Caint", + stt: "Caint go Téacs", + logging: "Logáil", + discord: "Discord", + auxiliary: "Cúntach", + }, + }, + + env: { + changesNote: "Sábháiltear athruithe chuig an diosca láithreach. Aimsíonn seisiúin ghníomhacha eochracha nua go huathoibríoch.", + confirmClearMessage: + "Bainfear an luach stóráilte don athróg seo ó do chomhad .env. Ní féidir é seo a chealú ón UI.", + confirmClearTitle: "Glan an eochair seo?", + description: "Bainistigh eochracha API agus rúin atá stóráilte i", + hideAdvanced: "Folaigh Ardroghanna", + showAdvanced: "Taispeáin Ardroghanna", + llmProviders: "Soláthraithe LLM", + providersConfigured: "{configured} as {total} soláthraí cumraithe", + getKey: "Faigh eochair", + notConfigured: "{count} gan cumrú", + notSet: "Gan socrú", + keysCount: "{count} eochai{s}", + enterValue: "Cuir luach isteach...", + replaceCurrentValue: "Athchuir an luach reatha ({preview})", + showValue: "Taispeáin an fíorluach", + hideValue: "Folaigh an luach", + }, + + oauth: { + title: "Logálacha isteach soláthraí (OAuth)", + providerLogins: "Logálacha isteach soláthraí (OAuth)", + description: "{connected} as {total} soláthraí OAuth ceangailte. Reáchtáiltear sreabha logála isteach faoi láthair tríd an CLI; cliceáil Cóipeáil ordú agus greamaigh i dteirminéal chun é a shocrú.", + connected: "Ceangailte", + expired: "As feidhm", + notConnected: "Gan cheangal. Rith {command} i dteirminéal.", + runInTerminal: "i dteirminéal.", + noProviders: "Níor aimsíodh soláthraithe a thacaíonn le OAuth.", + login: "Logáil isteach", + disconnect: "Dícheangail", + managedExternally: "Bainistithe go seachtrach", + copied: "Cóipeáilte ✓", + cli: "CLI", + copyCliCommand: "Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)", + connect: "Ceangail", + sessionExpires: "Téann an seisiún as feidhm i {time}", + initiatingLogin: "Ag tosú an tsreabha logála isteach…", + exchangingCode: "Ag malartú an chóid ar tokens…", + connectedClosing: "Ceangailte! Á dhúnadh…", + loginFailed: "Theip ar an logáil isteach.", + sessionExpired: "Seisiún as feidhm. Cliceáil Bain triail eile as chun logáil isteach nua a thosú.", + reOpenAuth: "Athoscail an leathanach údaraithe", + reOpenVerification: "Athoscail an leathanach fíoraithe", + submitCode: "Cuir an cód isteach", + pasteCode: "Greamaigh an cód údaraithe (tá iarmhír #state ceart go leor)", + waitingAuth: "Ag fanacht leat údarú a dhéanamh sa bhrabhsálaí…", + enterCodePrompt: "D'oscail cluaisín nua. Cuir an cód seo isteach má iarrtar ort:", + pkceStep1: "D'oscail cluaisín nua chuig claude.ai. Logáil isteach agus cliceáil Údaraigh.", + pkceStep2: "Cóipeáil an cód údaraithe a thaispeántar tar éis údaraithe.", + pkceStep3: "Greamaigh thíos é agus cuir isteach é.", + flowLabels: { + pkce: "Logáil isteach brabhsálaí (PKCE)", + device_code: "Cód gléis", + external: "CLI seachtrach", + }, + expiresIn: "as feidhm i {time}", + }, + + language: { + switchTo: "Athraigh go Béarla", + }, + + theme: { + title: "Téama", + switchTheme: "Athraigh téama", + }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Suaitheantais Hermes inbhailithe a thuilltear ó stair fíor-session. Léirítear gnóthachtálacha aitheanta neamhchríochnaithe mar Discovered; fanann gnóthachtálacha Secret i bhfolach go dtí go bhfeictear an chéad iompar comhoiriúnach.", + scan_subtitle: + "Stair session Hermes á scanadh. Is féidir leis an gcéad scan 5–10 soicind a thógáil ar staireanna móra.", + }, + actions: { + rescan: "Athscan", + }, + stats: { + unlocked: "Díghlasáilte", + unlocked_hint: "suaitheantais tuillte", + discovered: "Aimsithe", + discovered_hint: "ar eolas, gan tuilleamh fós", + secrets: "Rúin", + secrets_hint: "i bhfolach go dtí an chéad chomhartha", + highest_tier: "An leibhéal is airde", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "An ceann is déanaí", + latest_hint_empty: "rith Hermes níos mó", + none_yet: "Aon cheann fós", + }, + state: { + unlocked: "Díghlasáilte", + discovered: "Aimsithe", + secret: "Rún", + }, + tier: { + target: "Sprioc {tier}", + hidden: "I bhfolach", + complete: "Críochnaithe", + objective: "Cuspóir", + }, + progress: { + hidden: "i bhfolach", + }, + scan: { + building_headline: "Próifíl ghnóthachtála á tógáil…", + building_detail: + "Sessions, glaonna ar uirlisí, meiteashonraí samhla agus staid díghlasála á léamh.", + starting_headline: "Scan ghnóthachtála á thosú…", + progress_detail: + "{scanned} as {total} session scanta · {pct}%. Díghlasáiltear suaitheantais de réir mar a shníonn níos mó staire isteach.", + idle_detail: + "Sessions, glaonna ar uirlisí, meiteashonraí samhla agus staid díghlasála á léamh. Feicfear suaitheantais anseo de réir mar a dhíghlasáiltear iad.", + }, + guide: { + tiers_header: "Leibhéil", + secret_header: "Gnóthachtálacha rúnda", + secret_body: + "Coinníonn rúin a dtruicear cruinn faoi cheilt. Nuair a fheiceann Hermes comhartha gaolmhar, athraíonn an cárta go Aimsithe agus taispeánann sé a riachtanas.", + scan_status_header: "Stádas an scanta", + scan_status_body: + "Scanann Hermes an stair logánta uair amháin, ansin feicfear cártaí go huathoibríoch. Níl aon rud sáinnithe má thógann sé cúpla soicind.", + what_scanned_header: "Cad a scantar", + what_scanned_body: + "Sessions, glaonna ar uirlisí, meiteashonraí samhla, earráidí, gnóthachtálacha agus staid díghlasála logánta.", + }, + card: { + share_title: "Comhroinn an gnóthachtáil seo", + share_label: "Comhroinn {name}", + share_text: "Comhroinn", + how_to_reveal: "Conas é a nochtadh", + what_counts: "Cad a chomhairtear", + evidence_label: "Fianaise", + evidence_session_fallback: "session", + no_evidence: "Níl fianaise ann fós", + }, + latest: { + header: "Díghlasálacha le déanaí", + }, + empty: { + no_secrets_header: "Níl aon rúin fhalaithe fágtha sa scan seo.", + no_secrets_body: + "Leid: tosaíonn rúin de ghnáth le patrúin teipe neamhghnácha nó patrúin power-user — coinbhleachtaí poirt, ballaí ceadanna, athróga env in easnamh, botúin YAML, imbhuailtí Docker, úsáid rollback/checkpoint, amais cache, nó mionchóirithe tar éis go leor téacs dheirg.", + }, + filters: { + all_categories: "Gach rud", + visibility_all: "uile", + visibility_unlocked: "díghlasáilte", + visibility_discovered: "aimsithe", + visibility_secret: "rún", + }, + share: { + dialog_label: "Comhroinn gnóthachtáil", + header: "Comhroinn: {name}", + close: "Dún", + rendering: "Á rindreáil…", + card_alt: "Cárta comhroinnte {name}", + error_generic: "Chuaigh rud éigin amú.", + x_title: "Osclaíonn X le post réamhlíonta", + x_button: "Comhroinn ar X", + copy_title: "Cóipeáil an íomhá le greamú isteach i do phost", + copy_button: "Cóipeáil íomhá", + copied: "Cóipeáilte ✓", + download_button: "Íoslódáil PNG", + hint: + "Osclaíonn Comhroinn ar X post réamhlíonta i gcluaisín nua. Cliceáil Cóipeáil íomhá ar dtús más mian leat an suaitheantas 1200×630 a bheith ceangailte — ligeann X duit é a ghreamú díreach isteach i scríbhneoir an tweet. Sábhálann Íoslódáil PNG an comhad le húsáid áit ar bith.", + clipboard_unsupported: + "Ní thacaítear le cóipeáil íomhá chuig an ngearrthaisce sa bhrabhsálaí seo — úsáid Íoslódáil ina ionad sin.", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Clár Kanban á luchtú…", + loadFailed: "Theip ar luchtú an chláir Kanban: ", + loadFailedHint: + "Cruthaíonn an cúl-inneall kanban.db go huathoibríoch ar an gcéad léamh. Má leanann sé seo, féach logaí an dashboard.", + board: "Clár", + newBoard: "+ Clár nua", + newBoardTitle: "Clár nua", + newBoardDescription: + "Ligeann boards duit sruthanna oibre neamhghaolmhara a scaradh — ceann amháin in aghaidh an tionscadail, an repo nó an fhearainn. Ní fheiceann workers ar bhord amháin tascanna board eile riamh.", + slug: "Slug", + slugHint: "— litreacha beaga, fleiscíní, m.sh. atm10-server", + displayName: "Ainm taispeána", + displayNameHint: "(roghnach)", + description: "Cur síos", + descriptionHint: "(roghnach)", + icon: "Deilbhín", + iconHint: "(carachtar amháin nó emoji)", + switchAfterCreate: "Athraigh chuig an gclár seo tar éis a chruthaithe", + cancel: "Cealaigh", + creating: "Á chruthú…", + createBoard: "Cruthaigh clár", + search: "Cuardaigh", + filterCards: "Scag cártaí…", + tenant: "Tenant", + allTenants: "Gach tenant", + assignee: "Sannaí", + allProfiles: "Gach profile", + showArchived: "Taispeáin cinn cartlannaithe", + lanesByProfile: "Lánaí de réir profile", + nudgeDispatcher: "Spreag an dispatcher", + refresh: "Athnuaigh", + selected: "roghnaithe", + complete: "Cuir i gcrích", + archive: "Cartlannaigh", + apply: "Cuir i bhfeidhm", + clear: "Glan", + createTask: "Cruthaigh tasc sa cholún seo", + noTasks: "— gan tascanna —", + unassigned: "gan sannadh", + untitled: "(gan teideal)", + loadingDetail: "Á luchtú…", + addComment: "Cuir nóta tráchta… (Enter chun seoladh)", + comment: "Nóta tráchta", + status: "Stádas", + workspace: "Workspace", + skills: "Scileanna", + createdBy: "Cruthaithe ag", + result: "Toradh", + comments: "Nótaí tráchta", + events: "Imeachtaí", + runHistory: "Stair na rití", + workerLog: "Loga an worker", + loadingLog: "Loga á luchtú…", + noWorkerLog: + "— níl loga worker ann fós (níor sheol an tasc nó rinneadh an loga a rothlú) —", + noDescription: "— gan cur síos —", + noComments: "— gan nótaí tráchta —", + edit: "cuir in eagar", + save: "Sábháil", + dependencies: "Spleáchais", + parents: "Tuismitheoirí:", + children: "Leanaí:", + none: "ceann ar bith", + addParent: "— cuir tuismitheoir leis —", + addChild: "— cuir leanbh leis —", + removeDependency: "Bain spleáchas", + block: "Bac", + unblock: "Díbhac", + notifyHomeChannels: "Cuir cainéil bhaile ar an eolas", + diagnostics: "Diagnóisic", + hide: "Folaigh", + show: "Taispeáin", + attention: "Aird", + tasksNeedAttention: "tasc ag teastáil aird", + taskNeedsAttention: "Tá aird ag teastáil ó 1 thasc", + diagnostic: "diagnóis", + open: "Oscail", + close: "Dún (Esc)", + reassignTo: "Athshann chuig:", + copied: "Cóipeáilte", + copyCommand: "Cóipeáil ordú chuig an ngearrthaisce", + reclaim: "Athéiligh", + reassign: "Athshann", + renderingError: "Bhuail earráid rindreála an chluaisín Kanban", + reloadView: "Athluchtaigh an radharc", + wsAuthFailed: + "Theip ar fhíordheimhniú WebSocket — athluchtaigh an leathanach chun an comhartha seisiúin a athnuachan.", + markDone: "Marcáil {n} tasc mar críochnaithe?", + markArchived: "Cartlannaigh {n} tasc?", + warning: "Rabhadh", + phantomIds: "ID-anna taibhse:", + active: "gníomhach", + ended: "críochnaithe", + noProfile: "(gan profile)", + showAllAttempts: "Taispeáin gach iarracht", + sendingUpdates: "Nuashonruithe á seoladh chuig", + sendNotifications: "Seol fógraí completed / blocked / gave_up chuig", + archiveBoardConfirm: + "Cartlannaigh an clár '{name}'? Bogfar é go boards/_archived/ ionas gur féidir é a aisghabháil níos déanaí. Ní bheidh tascanna an chláir seo le feiceáil aon áit san UI a thuilleadh.", + archiveBoardTitle: "Cartlannaigh an clár seo", + boardSwitcherHint: "Ligeann boards duit sruthanna oibre neamhghaolmhara a scaradh", + taskCreatedWarning: "Cruthaíodh an tasc, ach: ", + moveFailed: "Theip ar an mbogadh: ", + bulkFailed: "Cnuasach: ", + completionBlockedHallucination: "⚠ Cuireadh bac ar chríochnú — ID-anna taibhse na gcártaí", + suspectedHallucinatedReferences: "⚠ Tagairt sa téacs do ID-anna taibhse na gcártaí", + pickProfileFirst: "Roghnaigh profile ar dtús.", + unblockedMessage: "Díbhacadh {id}. Tá an tasc réidh don chéad tic eile.", + unblockFailed: "Theip ar an díbhacadh: ", + reclaimedMessage: "Athéilíodh {id}. Tá an tasc ar ais ag ready.", + reclaimFailed: "Theip ar an athéileamh: ", + reassignedMessage: "Athshannadh {id} chuig {profile}.", + reassignFailed: "Theip ar an athshannadh: ", + selectForBulk: "Roghnaigh do ghníomhartha cnuasaigh", + clickToEdit: "Cliceáil chun eagarthóireacht a dhéanamh", + clickToEditAssignee: "Cliceáil chun an sannaí a chur in eagar", + emptyAssignee: "(folamh = bain an sannadh)", + columnLabels: { + triage: "Triáiseáil", + todo: "Le déanamh", + ready: "Réidh", + running: "Ar siúl", + blocked: "Bactha", + done: "Críochnaithe", + archived: "Cartlannaithe", + }, + columnHelp: { + triage: "Smaointe amha — déanfaidh specifier an spec a chur i bhfeidhm", + todo: "Ag fanacht ar spleáchais nó gan sannadh", + ready: "Sannta agus ag fanacht ar thic an dispatcher", + running: "Éilithe ag worker — ar siúl", + blocked: "D'iarr an worker ionchur duine", + done: "Críochnaithe", + archived: "Cartlannaithe", + }, + confirmDone: + "Marcáil an tasc seo mar críochnaithe? Scaoiltear éileamh an worker agus éiríonn leanaí spleácha ready.", + confirmArchive: + "Cartlannaigh an tasc seo? Imíonn sé as an réamhradharc cláir.", + confirmBlocked: + "Marcáil an tasc seo mar bactha? Scaoiltear éileamh an worker.", + completionSummary: + "Achoimre chríochnaithe ar {label}. Stóráiltear é seo mar result an taisc.", + completionSummaryRequired: + "Tá achoimre chríochnaithe riachtanach sula marcáiltear tasc mar críochnaithe.", + triagePlaceholder: "Smaoineamh garbh — déanfaidh AI an spec…", + taskTitlePlaceholder: "Teideal taisc nua…", + specifier: "specifier", + assigneePlaceholder: "sannaí", + priority: "Tosaíocht", + skillsPlaceholder: + "scileanna (roghnach, scartha le camóga): translation, github-code-review", + noParent: "— gan tuismitheoir —", + workspacePathDir: "conair workspace (riachtanach, m.sh. ~/projects/my-app)", + workspacePathOptional: + "conair workspace (roghnach, díorthaithe ón sannaí má tá sé folamh)", + logTruncated: "(taispeántar an 100 KB deireanach — loga iomlán ag ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/hu.ts b/apps/dashboard/src/i18n/hu.ts new file mode 100644 index 00000000000..f563c1dacc4 --- /dev/null +++ b/apps/dashboard/src/i18n/hu.ts @@ -0,0 +1,696 @@ +import type { Translations } from "./types"; + +export const hu: Translations = { + common: { + save: "Mentés", + saving: "Mentés...", + cancel: "Mégse", + close: "Bezárás", + confirm: "Megerősítés", + delete: "Törlés", + refresh: "Frissítés", + retry: "Újra", + search: "Keresés...", + loading: "Betöltés...", + create: "Létrehozás", + creating: "Létrehozás...", + set: "Beállítás", + replace: "Csere", + clear: "Törlés", + live: "Élő", + off: "Ki", + enabled: "engedélyezve", + disabled: "letiltva", + active: "aktív", + inactive: "inaktív", + unknown: "ismeretlen", + untitled: "Névtelen", + none: "Nincs", + form: "Űrlap", + noResults: "Nincs találat", + of: "/", + page: "Oldal", + msgs: "üzenet", + tools: "eszközök", + match: "egyezés", + other: "Egyéb", + configured: "beállítva", + removed: "eltávolítva", + failedToToggle: "Nem sikerült átváltani", + failedToRemove: "Nem sikerült eltávolítani", + failedToReveal: "Nem sikerült megjeleníteni", + collapse: "Összecsukás", + expand: "Kibontás", + general: "Általános", + messaging: "Üzenetküldés", + pluginLoadFailed: + "Nem sikerült betölteni a bővítmény szkriptjét. Ellenőrizze a Network fület (dashboard-plugins/…) és a kiszolgáló bővítmény-elérési útját.", + pluginNotRegistered: + "A bővítmény szkriptje nem hívta meg a register() függvényt, vagy hibára futott. A részletekért nyissa meg a böngésző konzolját.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "Navigáció bezárása", + closeModelTools: "Modell és eszközök bezárása", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "Aktív munkamenetek:", + gatewayStatusLabel: "Átjáró állapota:", + gatewayStrip: { + failed: "Indítás sikertelen", + off: "Ki", + running: "Fut", + starting: "Indul", + stopped: "Leállítva", + }, + nav: { + analytics: "Analitika", + chat: "Csevegés", + config: "Beállítások", + cron: "Cron", + documentation: "Dokumentáció", + keys: "Kulcsok", + logs: "Naplók", + models: "Modellek", + profiles: "profilok: több ügynök", + plugins: "Bővítmények", + sessions: "Munkamenetek", + skills: "Készségek", + }, + modelToolsSheetSubtitle: "és eszközök", + modelToolsSheetTitle: "Modell", + navigation: "Navigáció", + openDocumentation: "Dokumentáció megnyitása új lapon", + openNavigation: "Navigáció megnyitása", + pluginNavSection: "Bővítmények", + sessionsActiveCount: "{count} aktív", + statusOverview: "Állapot áttekintése", + system: "Rendszer", + webUi: "Web UI", + }, + + status: { + actionFailed: "Művelet sikertelen", + actionFinished: "Befejezve", + actions: "Műveletek", + agent: "Ügynök", + activeSessions: "Aktív munkamenetek", + connected: "Csatlakoztatva", + connectedPlatforms: "Csatlakoztatott platformok", + disconnected: "Lekapcsolva", + error: "Hiba", + failed: "Sikertelen", + gateway: "Átjáró", + gatewayFailedToStart: "Az átjáró nem indult el", + lastUpdate: "Utolsó frissítés", + noneRunning: "Nincs", + notRunning: "Nem fut", + pid: "PID", + platformDisconnected: "lekapcsolva", + platformError: "hiba", + recentSessions: "Legutóbbi munkamenetek", + restartGateway: "Átjáró újraindítása", + restartingGateway: "Átjáró újraindítása…", + running: "Fut", + runningRemote: "Fut (távoli)", + startFailed: "Indítás sikertelen", + starting: "Indul", + startedInBackground: "Háttérben elindítva — kövesse a naplókat a folyamathoz", + stopped: "Leállítva", + updateHermes: "Hermes frissítése", + updatingHermes: "Hermes frissítése…", + waitingForOutput: "Várakozás a kimenetre…", + }, + + sessions: { + title: "Munkamenetek", + searchPlaceholder: "Keresés üzenettartalomban...", + noSessions: "Még nincsenek munkamenetek", + noMatch: "Nincs a keresésnek megfelelő munkamenet", + startConversation: "Indítson egy beszélgetést, hogy itt megjelenjen", + noMessages: "Nincsenek üzenetek", + untitledSession: "Névtelen munkamenet", + deleteSession: "Munkamenet törlése", + confirmDeleteTitle: "Törli a munkamenetet?", + confirmDeleteMessage: + "Ez véglegesen eltávolítja a beszélgetést és minden üzenetét. A művelet nem vonható vissza.", + sessionDeleted: "Munkamenet törölve", + failedToDelete: "Nem sikerült törölni a munkamenetet", + resumeInChat: "Folytatás a csevegésben", + previousPage: "Előző oldal", + nextPage: "Következő oldal", + roles: { + user: "Felhasználó", + assistant: "Asszisztens", + system: "Rendszer", + tool: "Eszköz", + }, + }, + + analytics: { + period: "Időszak:", + totalTokens: "Összes token", + totalSessions: "Összes munkamenet", + apiCalls: "API-hívások", + dailyTokenUsage: "Napi tokenhasználat", + dailyBreakdown: "Napi bontás", + perModelBreakdown: "Modellek szerinti bontás", + topSkills: "Legnépszerűbb készségek", + skill: "Készség", + loads: "Ügynök által betöltve", + edits: "Ügynök által kezelve", + lastUsed: "Utoljára használva", + input: "Bemenet", + output: "Kimenet", + total: "Összesen", + noUsageData: "Nincs használati adat erre az időszakra", + startSession: "Indítson munkamenetet az analitika megtekintéséhez", + date: "Dátum", + model: "Modell", + tokens: "Tokenek", + perDayAvg: "/nap átlag", + acrossModels: "{count} modellen át", + inOut: "{input} be / {output} ki", + }, + + models: { + modelsUsed: "Használt modellek", + estimatedCost: "Becsült költség", + tokens: "tokenek", + sessions: "munkamenetek", + avgPerSession: "átlag/munkamenet", + apiCalls: "API-hívások", + toolCalls: "eszközhívások", + noModelsData: "Nincs modellhasználati adat erre az időszakra", + startSession: "Indítson munkamenetet a modelladatok megtekintéséhez", + }, + + logs: { + title: "Naplók", + autoRefresh: "Automatikus frissítés", + file: "Fájl", + level: "Szint", + component: "Komponens", + lines: "Sorok", + noLogLines: "Nem található naplóbejegyzés", + }, + + cron: { + confirmDeleteMessage: + "Ez eltávolítja a feladatot az ütemezésből. A művelet nem vonható vissza.", + confirmDeleteTitle: "Törli az ütemezett feladatot?", + newJob: "Új Cron-feladat", + nameOptional: "Név (opcionális)", + namePlaceholder: "pl. Napi összegzés", + prompt: "Prompt", + promptPlaceholder: "Mit tegyen az ügynök minden futtatáskor?", + schedule: "Ütemezés (cron-kifejezés)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Kézbesítés ide", + scheduledJobs: "Ütemezett feladatok", + noJobs: "Nincs beállított cron-feladat. Hozzon létre egyet fent.", + last: "Utolsó", + next: "Következő", + pause: "Szüneteltetés", + resume: "Folytatás", + triggerNow: "Indítás most", + delivery: { + local: "Helyi", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "Új profil", + name: "Név", + namePlaceholder: "pl. coder, writer stb.", + nameRequired: "A név kötelező", + nameRule: + "Csak kisbetűk, számjegyek, _ és - karakterek; betűvel vagy számjeggyel kell kezdődnie; legfeljebb 64 karakter.", + invalidName: "Érvénytelen profilnév", + cloneFromDefault: "Konfiguráció klónozása az alapértelmezett profilból", + allProfiles: "Profilok", + noProfiles: "Nem található profil.", + defaultBadge: "alapértelmezett", + hasEnv: "env", + model: "Modell", + skills: "Készségek", + rename: "Átnevezés", + editSoul: "SOUL.md szerkesztése", + soulSection: "SOUL.md (személyiség / rendszerprompt)", + soulPlaceholder: "# Hogyan viselkedjen ez az ügynök…", + saveSoul: "SOUL mentése", + soulSaved: "SOUL.md mentve", + openInTerminal: "CLI-parancs másolása", + commandCopied: "Vágólapra másolva", + copyFailed: "Nem sikerült másolni", + confirmDeleteTitle: "Törli a profilt?", + confirmDeleteMessage: + "Ez véglegesen törli a(z) '{name}' profilt — konfigurációt, kulcsokat, emlékeket, munkameneteket, készségeket, cron-feladatokat. A művelet nem vonható vissza.", + created: "Létrehozva", + deleted: "Törölve", + renamed: "Átnevezve", + }, + + pluginsPage: { + contextEngineLabel: "Kontextusmotor", + dashboardSlots: "Vezérlőpult-slotok", + disableRuntime: "Letiltás", + enableAfterInstall: "Engedélyezés a telepítés után", + enableRuntime: "Engedélyezés", + forceReinstall: "Kényszerített újratelepítés (a meglévő mappa előbb törlődik)", + headline: + "Hermes-bővítmények felfedezése, telepítése, engedélyezése és frissítése (a `hermes plugins` paritás).", + identifierLabel: "Git URL vagy owner/repo", + inactive: "inaktív", + installBtn: "Telepítés Gitből", + installHeading: "Telepítés GitHubról / Git URL-ről", + installHint: "Használjon owner/repo rövidítést vagy teljes https:// vagy git@ klónozási URL-t.", + memoryProviderLabel: "Memória-szolgáltató", + missingEnvWarn: "Állítsa be ezeket a Kulcsok között, mielőtt a bővítmény futhatna:", + noDashboardTab: "Nincs vezérlőpult-fül", + openTab: "Megnyitás", + orphanHeading: "Csak vezérlőpult-bővítmények (nincs egyező agent plugin.yaml)", + pluginListHeading: "Telepített bővítmények", + providerDefaults: "beépített / alapértelmezett", + providersHeading: "Futási idejű szolgáltató-bővítmények", + providersHint: + "A memory.provider (üres = beépített) és a context.engine értékét írja a config.yaml fájlba. A következő munkamenetben lép életbe.", + refreshDashboard: "Vezérlőpult-bővítmények újraolvasása", + removeConfirm: "Eltávolítja ezt a bővítményt a ~/.hermes/plugins/ mappából?", + removeHint: "Csak a felhasználó által a ~/.hermes/plugins alá telepített bővítmények távolíthatók el.", + rescanHeading: "SPA-bővítményregiszter", + rescanHint: "Olvassa újra a fájlokat a lemezen történő hozzáadás után, hogy az oldalsáv felvegye az új manifesteket.", + runtimeHeading: "Átjáró-futási idő (YAML-bővítmények)", + saveProviders: "Szolgáltatóbeállítások mentése", + savedProviders: "Szolgáltatóbeállítások mentve.", + sourceBadge: "Forrás", + authRequired: "Hitelesítés szükséges", + authRequiredHint: "Futtassa ezt a parancsot a hitelesítéshez:", + updateGit: "Git pull", + versionBadge: "Verzió", + showInSidebar: "Megjelenítés az oldalsávon", + hideFromSidebar: "Elrejtés az oldalsávról", + }, + + skills: { + title: "Készségek", + searchPlaceholder: "Készségek és eszközkészletek keresése...", + enabledOf: "{enabled}/{total} engedélyezve", + all: "Összes", + categories: "Kategóriák", + filters: "Szűrők", + noSkills: "Nem található készség. A készségek a ~/.hermes/skills/ mappából töltődnek be", + noSkillsMatch: "Nincs a keresésnek vagy szűrőnek megfelelő készség.", + skillCount: "{count} készség{s}", + resultCount: "{count} találat{s}", + noDescription: "Nincs elérhető leírás.", + toolsets: "Eszközkészletek", + toolsetLabel: "{name} eszközkészlet", + noToolsetsMatch: "Nincs a keresésnek megfelelő eszközkészlet.", + setupNeeded: "Beállítás szükséges", + disabledForCli: "CLI-hez letiltva", + more: "+{count} további", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "Szűrők", + sections: "Szakaszok", + exportConfig: "Konfiguráció exportálása JSON-ba", + importConfig: "Konfiguráció importálása JSON-ból", + resetDefaults: "Visszaállítás alapértelmezettre", + resetScopeTooltip: "{scope} visszaállítása alapértelmezettre", + confirmResetScope: "Visszaállítja az összes {scope} beállítást alapértelmezettre? Ez csak az űrlapot frissíti — a változások nem íródnak be a config.yaml fájlba, amíg meg nem nyomja a Mentés gombot.", + resetScopeToast: "{scope} visszaállítva alapértelmezettre — ellenőrizze és mentse a megőrzéshez", + rawYaml: "Nyers YAML-konfiguráció", + searchResults: "Keresési eredmények", + fields: "mező{s}", + noFieldsMatch: 'Nincs a(z) "{query}" keresésnek megfelelő mező', + configSaved: "Konfiguráció mentve", + yamlConfigSaved: "YAML-konfiguráció mentve", + failedToSave: "Mentés sikertelen", + failedToSaveYaml: "YAML mentése sikertelen", + failedToLoadRaw: "Nem sikerült betölteni a nyers konfigurációt", + configImported: "Konfiguráció importálva — ellenőrizze és mentse", + invalidJson: "Érvénytelen JSON-fájl", + categories: { + general: "Általános", + agent: "Ügynök", + terminal: "Terminál", + display: "Megjelenítés", + delegation: "Delegálás", + memory: "Memória", + compression: "Tömörítés", + security: "Biztonság", + browser: "Böngésző", + voice: "Hang", + tts: "Szövegfelolvasás", + stt: "Beszédfelismerés", + logging: "Naplózás", + discord: "Discord", + auxiliary: "Kiegészítő", + }, + }, + + env: { + changesNote: "A változások azonnal mentésre kerülnek a lemezre. Az aktív munkamenetek automatikusan átveszik az új kulcsokat.", + confirmClearMessage: + "A változó tárolt értéke törlődik a .env fájlból. Ez a felületről nem vonható vissza.", + confirmClearTitle: "Törli ezt a kulcsot?", + description: "API-kulcsok és titkok kezelése a következő helyen:", + hideAdvanced: "Speciális elrejtése", + showAdvanced: "Speciális megjelenítése", + llmProviders: "LLM-szolgáltatók", + providersConfigured: "{configured} / {total} szolgáltató beállítva", + getKey: "Kulcs lekérése", + notConfigured: "{count} nincs beállítva", + notSet: "Nincs beállítva", + keysCount: "{count} kulcs{s}", + enterValue: "Adjon meg értéket...", + replaceCurrentValue: "Jelenlegi érték cseréje ({preview})", + showValue: "Tényleges érték megjelenítése", + hideValue: "Érték elrejtése", + }, + + oauth: { + title: "Szolgáltatói bejelentkezések (OAuth)", + providerLogins: "Szolgáltatói bejelentkezések (OAuth)", + description: "{connected} / {total} OAuth-szolgáltató csatlakoztatva. A bejelentkezési folyamat jelenleg a CLI-n keresztül fut; kattintson a Parancs másolása gombra, és illessze be egy terminálba a beállításhoz.", + connected: "Csatlakoztatva", + expired: "Lejárt", + notConnected: "Nincs csatlakoztatva. Futtassa a {command} parancsot egy terminálban.", + runInTerminal: "egy terminálban.", + noProviders: "Nem észlelhető OAuth-képes szolgáltató.", + login: "Bejelentkezés", + disconnect: "Lecsatlakozás", + managedExternally: "Külsőleg kezelt", + copied: "Másolva ✓", + cli: "CLI", + copyCliCommand: "CLI-parancs másolása (külső / tartalék)", + connect: "Csatlakozás", + sessionExpires: "A munkamenet {time} múlva lejár", + initiatingLogin: "Bejelentkezési folyamat indítása…", + exchangingCode: "Kód cseréje tokenekre…", + connectedClosing: "Csatlakoztatva! Bezárás…", + loginFailed: "A bejelentkezés sikertelen.", + sessionExpired: "A munkamenet lejárt. Kattintson az Újra gombra új bejelentkezéshez.", + reOpenAuth: "Hitelesítési oldal újranyitása", + reOpenVerification: "Ellenőrzési oldal újranyitása", + submitCode: "Kód beküldése", + pasteCode: "Illessze be a hitelesítési kódot (a #state utótaggal együtt is megfelel)", + waitingAuth: "Várakozás a böngészőben történő engedélyezésre…", + enterCodePrompt: "Új lap nyílt meg. Adja meg ezt a kódot, ha kéri:", + pkceStep1: "Új lap nyílt meg a claude.ai oldalra. Jelentkezzen be, és kattintson az Authorize gombra.", + pkceStep2: "Másolja ki az engedélyezés után megjelenő hitelesítési kódot.", + pkceStep3: "Illessze be alább, és küldje be.", + flowLabels: { + pkce: "Bejelentkezés böngészőből (PKCE)", + device_code: "Eszközkód", + external: "Külső CLI", + }, + expiresIn: "lejár {time} múlva", + }, + + language: { + switchTo: "Váltás angolra", + }, + + theme: { + title: "Téma", + switchTheme: "Téma váltása", + }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Gyűjthető Hermes-jelvények, valós munkamenet-előzmények alapján szerezve. Az ismert, de még nem szerzett teljesítmények Felfedezettként jelennek meg; a Titkos teljesítmények rejtve maradnak az első egyező viselkedésig.", + scan_subtitle: + "Hermes munkamenet-előzmények vizsgálata. Az első vizsgálat 5–10 másodpercig is eltarthat nagy előzmények esetén.", + }, + actions: { + rescan: "Újravizsgálat", + }, + stats: { + unlocked: "Feloldva", + unlocked_hint: "megszerzett jelvények", + discovered: "Felfedezve", + discovered_hint: "ismert, még nem szerzett", + secrets: "Titkok", + secrets_hint: "rejtve az első jelzésig", + highest_tier: "Legmagasabb szint", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "Legutóbbi", + latest_hint_empty: "futtasd többet a Hermest", + none_yet: "Még semmi", + }, + state: { + unlocked: "Feloldva", + discovered: "Felfedezve", + secret: "Titkos", + }, + tier: { + target: "Cél: {tier}", + hidden: "Rejtett", + complete: "Kész", + objective: "Cél", + }, + progress: { + hidden: "rejtett", + }, + scan: { + building_headline: "Teljesítményprofil építése…", + building_detail: + "Munkamenetek, eszközhívások, modell-metaadatok és feloldási állapot olvasása.", + starting_headline: "Teljesítmény-vizsgálat indítása…", + progress_detail: + "{scanned} / {total} munkamenet vizsgálva · {pct}%. A jelvények a további előzmények beolvasásával oldódnak fel.", + idle_detail: + "Munkamenetek, eszközhívások, modell-metaadatok és feloldási állapot olvasása. A jelvények itt jelennek meg, ahogy feloldódnak.", + }, + guide: { + tiers_header: "Szintek", + secret_header: "Titkos teljesítmények", + secret_body: + "A titkos teljesítmények elrejtik a pontos kiváltó eseményt. Amint a Hermes kapcsolódó jelet észlel, a kártya Felfedezettre vált, és megjeleníti a követelményt.", + scan_status_header: "Vizsgálat állapota", + scan_status_body: + "A Hermes egyszer átvizsgálja a helyi előzményeket, majd a kártyák automatikusan megjelennek. Semmi sem akadt el, ha ez néhány másodpercig tart.", + what_scanned_header: "Mit vizsgálunk", + what_scanned_body: + "Munkamenetek, eszközhívások, modell-metaadatok, hibák, teljesítmények és helyi feloldási állapot.", + }, + card: { + share_title: "Teljesítmény megosztása", + share_label: "{name} megosztása", + share_text: "Megosztás", + how_to_reveal: "Hogyan fedhető fel", + what_counts: "Mi számít", + evidence_label: "Bizonyíték", + evidence_session_fallback: "munkamenet", + no_evidence: "Még nincs bizonyíték", + }, + latest: { + header: "Legutóbbi feloldások", + }, + empty: { + no_secrets_header: "Ebben a vizsgálatban nem maradt rejtett titok.", + no_secrets_body: + "Tipp: a titkok általában szokatlan hibákból vagy haladó felhasználói mintákból indulnak — portütközések, jogosultsági falak, hiányzó környezeti változók, YAML-hibák, Docker-ütközések, rollback/checkpoint használata, gyorsítótár-találatok vagy apró javítások sok piros szöveg után.", + }, + filters: { + all_categories: "Összes", + visibility_all: "összes", + visibility_unlocked: "feloldott", + visibility_discovered: "felfedezett", + visibility_secret: "titkos", + }, + share: { + dialog_label: "Teljesítmény megosztása", + header: "Megosztás: {name}", + close: "Bezárás", + rendering: "Renderelés…", + card_alt: "{name} megosztókártya", + error_generic: "Valami hiba történt.", + x_title: "Megnyitja az X-et előre kitöltött bejegyzéssel", + x_button: "Megosztás az X-en", + copy_title: "Kép másolása a bejegyzésbe való beillesztéshez", + copy_button: "Kép másolása", + copied: "Másolva ✓", + download_button: "PNG letöltése", + hint: + "A „Megosztás az X-en” új lapon nyit meg egy előre kitöltött bejegyzést. Először kattints a „Kép másolása” gombra, ha az 1200×630-as jelvényt is csatolnád — az X engedi, hogy közvetlenül beillesszd a bejegyzésszerkesztőbe. A „PNG letöltése” bárhol felhasználható fájlként menti.", + clipboard_unsupported: + "A kép vágólapra másolása nem támogatott ebben a böngészőben — használd inkább a Letöltést.", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Kanban tábla betöltése…", + loadFailed: "Nem sikerült betölteni a Kanban táblát: ", + loadFailedHint: + "A backend első olvasáskor automatikusan létrehozza a kanban.db fájlt. Ha továbbra is fennáll, ellenőrizd a dashboard naplóit.", + board: "Tábla", + newBoard: "+ Új tábla", + newBoardTitle: "Új tábla", + newBoardDescription: + "A táblákkal külön tudod választani az egymással nem összefüggő munkafolyamokat — egyet projektenként, repónként vagy területenként. Az egyik tábla workerei sosem látják a másik tábla feladatait.", + slug: "Slug", + slugHint: "— kisbetűk, kötőjelek, pl. atm10-server", + displayName: "Megjelenítendő név", + displayNameHint: "(opcionális)", + description: "Leírás", + descriptionHint: "(opcionális)", + icon: "Ikon", + iconHint: "(egyetlen karakter vagy emodzsi)", + switchAfterCreate: "Váltás erre a táblára létrehozás után", + cancel: "Mégse", + creating: "Létrehozás…", + createBoard: "Tábla létrehozása", + search: "Keresés", + filterCards: "Kártyák szűrése…", + tenant: "Tenant", + allTenants: "Összes tenant", + assignee: "Felelős", + allProfiles: "Összes profil", + showArchived: "Archiváltak megjelenítése", + lanesByProfile: "Sávok profil szerint", + nudgeDispatcher: "Dispatcher noszogatása", + refresh: "Frissítés", + selected: "kiválasztva", + complete: "Befejezés", + archive: "Archiválás", + apply: "Alkalmaz", + clear: "Törlés", + createTask: "Feladat létrehozása ebben az oszlopban", + noTasks: "— nincsenek feladatok —", + unassigned: "nincs felelős", + untitled: "(névtelen)", + loadingDetail: "Betöltés…", + addComment: "Hozzászólás hozzáadása… (Enter a beküldéshez)", + comment: "Hozzászólás", + status: "Állapot", + workspace: "Munkaterület", + skills: "Készségek", + createdBy: "Létrehozta", + result: "Eredmény", + comments: "Hozzászólások", + events: "Események", + runHistory: "Futási előzmények", + workerLog: "Worker napló", + loadingLog: "Napló betöltése…", + noWorkerLog: + "— még nincs worker napló (a feladat nem indult el, vagy a napló rotálódott) —", + noDescription: "— nincs leírás —", + noComments: "— nincsenek hozzászólások —", + edit: "szerkesztés", + save: "Mentés", + dependencies: "Függőségek", + parents: "Szülők:", + children: "Gyermekek:", + none: "nincs", + addParent: "— szülő hozzáadása —", + addChild: "— gyermek hozzáadása —", + removeDependency: "Függőség eltávolítása", + block: "Blokkolás", + unblock: "Feloldás", + notifyHomeChannels: "Otthoni csatornák értesítése", + diagnostics: "Diagnosztika", + hide: "Elrejtés", + show: "Megjelenítés", + attention: "Figyelem", + tasksNeedAttention: "feladat figyelmet igényel", + taskNeedsAttention: "1 feladat figyelmet igényel", + diagnostic: "diagnosztika", + open: "Megnyitás", + close: "Bezárás (Esc)", + reassignTo: "Új felelős:", + copied: "Másolva", + copyCommand: "Parancs másolása a vágólapra", + reclaim: "Visszavétel", + reassign: "Újrakiosztás", + renderingError: "A Kanban fülön renderelési hiba lépett fel", + reloadView: "Nézet újratöltése", + wsAuthFailed: + "WebSocket-hitelesítés sikertelen — töltsd újra az oldalt a munkamenet-token frissítéséhez.", + markDone: "Megjelölöd {n} feladatot késznek?", + markArchived: "Archiválsz {n} feladatot?", + warning: "Figyelmeztetés", + phantomIds: "Fantom id-k:", + active: "aktív", + ended: "befejeződött", + noProfile: "(nincs profil)", + showAllAttempts: "Összes próbálkozás megjelenítése", + sendingUpdates: "Frissítések küldése ide:", + sendNotifications: "completed / blocked / gave_up értesítések küldése ide:", + archiveBoardConfirm: + "Archiválod a(z) '{name}' táblát? Áthelyezzük a boards/_archived/ mappába, hogy később visszaállíthasd. A táblán lévő feladatok többé nem jelennek meg sehol az UI-ban.", + archiveBoardTitle: "Tábla archiválása", + boardSwitcherHint: "A táblákkal külön tudod választani az egymással nem összefüggő munkafolyamokat", + taskCreatedWarning: "Feladat létrehozva, de: ", + moveFailed: "Áthelyezés sikertelen: ", + bulkFailed: "Tömeges: ", + completionBlockedHallucination: "⚠ Befejezés blokkolva — fantom kártya id-k", + suspectedHallucinatedReferences: "⚠ A szöveg fantom kártya id-kre hivatkozott", + pickProfileFirst: "Először válassz profilt.", + unblockedMessage: "{id} feloldva. A feladat készen áll a következő tickre.", + unblockFailed: "Feloldás sikertelen: ", + reclaimedMessage: "{id} visszavéve. A feladat újra ready állapotban van.", + reclaimFailed: "Visszavétel sikertelen: ", + reassignedMessage: "{id} újrakiosztva neki: {profile}.", + reassignFailed: "Újrakiosztás sikertelen: ", + selectForBulk: "Kijelölés tömeges műveletekhez", + clickToEdit: "Kattints a szerkesztéshez", + clickToEditAssignee: "Kattints a felelős szerkesztéséhez", + emptyAssignee: "(üres = felelős eltávolítása)", + columnLabels: { + triage: "Triázs", + todo: "Tennivaló", + ready: "Indulásra kész", + running: "Folyamatban", + blocked: "Blokkolva", + done: "Kész", + archived: "Archivált", + }, + columnHelp: { + triage: "Nyers ötletek — egy specifier kidolgozza a specifikációt", + todo: "Függőségekre vár vagy nincs felelőse", + ready: "Kiosztva, dispatcher tickre vár", + running: "Worker felvette — folyamatban", + blocked: "A worker emberi beavatkozást kért", + done: "Befejezve", + archived: "Archiválva", + }, + confirmDone: + "Megjelölöd ezt a feladatot késznek? A worker foglalása felszabadul, és a függő gyermekek ready állapotba kerülnek.", + confirmArchive: + "Archiválod ezt a feladatot? Eltűnik az alapértelmezett tábla nézetből.", + confirmBlocked: + "Megjelölöd ezt a feladatot blokkoltként? A worker foglalása felszabadul.", + completionSummary: + "Befejezési összefoglaló a következőhöz: {label}. Ez a feladat eredményeként kerül tárolásra.", + completionSummaryRequired: + "A feladat késznek jelölése előtt kötelező megadni a befejezési összefoglalót.", + triagePlaceholder: "Nyers ötlet — az AI specifikálja…", + taskTitlePlaceholder: "Új feladat címe…", + specifier: "specifier", + assigneePlaceholder: "felelős", + priority: "Prioritás", + skillsPlaceholder: + "készségek (opcionális, vesszővel elválasztva): translation, github-code-review", + noParent: "— nincs szülő —", + workspacePathDir: "munkaterület útvonala (kötelező, pl. ~/projects/my-app)", + workspacePathOptional: + "munkaterület útvonala (opcionális, üresen a felelősből származtatva)", + logTruncated: "(az utolsó 100 KB látható — teljes napló: ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/index.ts b/apps/dashboard/src/i18n/index.ts index 7a9a9471ea9..fe0e779ae29 100644 --- a/apps/dashboard/src/i18n/index.ts +++ b/apps/dashboard/src/i18n/index.ts @@ -1,2 +1,2 @@ -export { I18nProvider, useI18n } from "./context"; +export { I18nProvider, useI18n, LOCALE_META } from "./context"; export type { Locale, Translations } from "./types"; diff --git a/apps/dashboard/src/i18n/it.ts b/apps/dashboard/src/i18n/it.ts new file mode 100644 index 00000000000..5e79d3115c3 --- /dev/null +++ b/apps/dashboard/src/i18n/it.ts @@ -0,0 +1,695 @@ +import type { Translations } from "./types"; + +export const it: Translations = { + common: { + save: "Salva", + saving: "Salvataggio...", + cancel: "Annulla", + close: "Chiudi", + confirm: "Conferma", + delete: "Elimina", + refresh: "Aggiorna", + retry: "Riprova", + search: "Cerca...", + loading: "Caricamento...", + create: "Crea", + creating: "Creazione...", + set: "Imposta", + replace: "Sostituisci", + clear: "Cancella", + live: "In tempo reale", + off: "Spento", + enabled: "abilitato", + disabled: "disabilitato", + active: "attivo", + inactive: "inattivo", + unknown: "sconosciuto", + untitled: "Senza titolo", + none: "Nessuno", + form: "Modulo", + noResults: "Nessun risultato", + of: "di", + page: "Pagina", + msgs: "msg", + tools: "strumenti", + match: "corrispondenza", + other: "Altro", + configured: "configurato", + removed: "rimosso", + failedToToggle: "Commutazione non riuscita", + failedToRemove: "Rimozione non riuscita", + failedToReveal: "Visualizzazione non riuscita", + collapse: "Comprimi", + expand: "Espandi", + general: "Generale", + messaging: "Messaggistica", + pluginLoadFailed: + "Impossibile caricare lo script di questo plugin. Controlla la scheda Network (dashboard-plugins/…) e il percorso dei plugin del server.", + pluginNotRegistered: + "Lo script del plugin non ha chiamato register(), oppure ha generato un errore. Apri la console del browser per i dettagli.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "Chiudi navigazione", + closeModelTools: "Chiudi modello e strumenti", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "Sessioni attive:", + gatewayStatusLabel: "Stato gateway:", + gatewayStrip: { + failed: "Avvio non riuscito", + off: "Spento", + running: "In esecuzione", + starting: "Avvio in corso", + stopped: "Arrestato", + }, + nav: { + analytics: "Analisi", + chat: "Chat", + config: "Configurazione", + cron: "Cron", + documentation: "Documentazione", + keys: "Chiavi", + logs: "Log", + models: "Modelli", + profiles: "profili : multi agent", + plugins: "Plugin", + sessions: "Sessioni", + skills: "Competenze", + }, + modelToolsSheetSubtitle: "e strumenti", + modelToolsSheetTitle: "Modello", + navigation: "Navigazione", + openDocumentation: "Apri la documentazione in una nuova scheda", + openNavigation: "Apri navigazione", + pluginNavSection: "Plugin", + sessionsActiveCount: "{count} attive", + statusOverview: "Panoramica dello stato", + system: "Sistema", + webUi: "Web UI", + }, + + status: { + actionFailed: "Azione non riuscita", + actionFinished: "Completata", + actions: "Azioni", + agent: "Agente", + activeSessions: "Sessioni attive", + connected: "Connesso", + connectedPlatforms: "Piattaforme connesse", + disconnected: "Disconnesso", + error: "Errore", + failed: "Non riuscito", + gateway: "Gateway", + gatewayFailedToStart: "Avvio del gateway non riuscito", + lastUpdate: "Ultimo aggiornamento", + noneRunning: "Nessuno", + notRunning: "Non in esecuzione", + pid: "PID", + platformDisconnected: "disconnesso", + platformError: "errore", + recentSessions: "Sessioni recenti", + restartGateway: "Riavvia gateway", + restartingGateway: "Riavvio del gateway…", + running: "In esecuzione", + runningRemote: "In esecuzione (remoto)", + startFailed: "Avvio non riuscito", + starting: "Avvio in corso", + startedInBackground: "Avviato in background — controlla i log per i progressi", + stopped: "Arrestato", + updateHermes: "Aggiorna Hermes", + updatingHermes: "Aggiornamento di Hermes…", + waitingForOutput: "In attesa di output…", + }, + + sessions: { + title: "Sessioni", + searchPlaceholder: "Cerca nel contenuto dei messaggi...", + noSessions: "Nessuna sessione", + noMatch: "Nessuna sessione corrisponde alla ricerca", + startConversation: "Avvia una conversazione per vederla qui", + noMessages: "Nessun messaggio", + untitledSession: "Sessione senza titolo", + deleteSession: "Elimina sessione", + confirmDeleteTitle: "Eliminare la sessione?", + confirmDeleteMessage: + "Questa operazione rimuove definitivamente la conversazione e tutti i suoi messaggi. Non può essere annullata.", + sessionDeleted: "Sessione eliminata", + failedToDelete: "Eliminazione della sessione non riuscita", + resumeInChat: "Riprendi nella chat", + previousPage: "Pagina precedente", + nextPage: "Pagina successiva", + roles: { + user: "Utente", + assistant: "Assistente", + system: "Sistema", + tool: "Strumento", + }, + }, + + analytics: { + period: "Periodo:", + totalTokens: "Token totali", + totalSessions: "Sessioni totali", + apiCalls: "Chiamate API", + dailyTokenUsage: "Utilizzo giornaliero token", + dailyBreakdown: "Dettaglio giornaliero", + perModelBreakdown: "Dettaglio per modello", + topSkills: "Competenze più usate", + skill: "Competenza", + loads: "Caricato dall'agente", + edits: "Gestito dall'agente", + lastUsed: "Ultimo uso", + input: "Input", + output: "Output", + total: "Totale", + noUsageData: "Nessun dato di utilizzo per questo periodo", + startSession: "Avvia una sessione per vedere le analisi qui", + date: "Data", + model: "Modello", + tokens: "Token", + perDayAvg: "/giorno medio", + acrossModels: "su {count} modelli", + inOut: "{input} in / {output} out", + }, + + models: { + modelsUsed: "Modelli utilizzati", + estimatedCost: "Costo stim.", + tokens: "token", + sessions: "sessioni", + avgPerSession: "media/sessione", + apiCalls: "chiamate API", + toolCalls: "chiamate strumenti", + noModelsData: "Nessun dato sull'uso dei modelli per questo periodo", + startSession: "Avvia una sessione per vedere i dati dei modelli qui", + }, + + logs: { + title: "Log", + autoRefresh: "Aggiornamento automatico", + file: "File", + level: "Livello", + component: "Componente", + lines: "Righe", + noLogLines: "Nessuna riga di log trovata", + }, + + cron: { + confirmDeleteMessage: + "Questa operazione rimuove l'attività dalla pianificazione. Non può essere annullata.", + confirmDeleteTitle: "Eliminare l'attività pianificata?", + newJob: "Nuova attività cron", + nameOptional: "Nome (facoltativo)", + namePlaceholder: "es. Riepilogo giornaliero", + prompt: "Prompt", + promptPlaceholder: "Cosa deve fare l'agente a ogni esecuzione?", + schedule: "Pianificazione (espressione cron)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Consegna a", + scheduledJobs: "Attività pianificate", + noJobs: "Nessuna attività cron configurata. Creane una sopra.", + last: "Ultima", + next: "Prossima", + pause: "Pausa", + resume: "Riprendi", + triggerNow: "Esegui ora", + delivery: { + local: "Locale", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "Nuovo profilo", + name: "Nome", + namePlaceholder: "es. coder, writer, ecc.", + nameRequired: "Il nome è obbligatorio", + nameRule: + "Solo lettere minuscole, cifre, _ e -; deve iniziare con una lettera o cifra; fino a 64 caratteri.", + invalidName: "Nome del profilo non valido", + cloneFromDefault: "Clona la configurazione dal profilo predefinito", + allProfiles: "Profili", + noProfiles: "Nessun profilo trovato.", + defaultBadge: "predefinito", + hasEnv: "env", + model: "Modello", + skills: "Competenze", + rename: "Rinomina", + editSoul: "Modifica SOUL.md", + soulSection: "SOUL.md (personalità / prompt di sistema)", + soulPlaceholder: "# Come dovrebbe comportarsi questo agente…", + saveSoul: "Salva SOUL", + soulSaved: "SOUL.md salvato", + openInTerminal: "Copia comando CLI", + commandCopied: "Copiato negli appunti", + copyFailed: "Impossibile copiare", + confirmDeleteTitle: "Eliminare il profilo?", + confirmDeleteMessage: + "Questa operazione elimina definitivamente il profilo '{name}' — configurazione, chiavi, memorie, sessioni, competenze, attività cron. Non può essere annullata.", + created: "Creato", + deleted: "Eliminato", + renamed: "Rinominato", + }, + + pluginsPage: { + contextEngineLabel: "Motore di contesto", + dashboardSlots: "Slot del dashboard", + disableRuntime: "Disabilita", + enableAfterInstall: "Abilita dopo l'installazione", + enableRuntime: "Abilita", + forceReinstall: "Forza reinstallazione (elimina prima la cartella esistente)", + headline: + "Scopri, installa, abilita e aggiorna i plugin Hermes (parità con `hermes plugins`).", + identifierLabel: "URL Git o owner/repo", + inactive: "inattivo", + installBtn: "Installa da Git", + installHeading: "Installa da GitHub / URL Git", + installHint: "Usa la forma breve owner/repo o un URL clone https:// o git@ completo.", + memoryProviderLabel: "Provider di memoria", + missingEnvWarn: "Imposta queste variabili in Chiavi prima di eseguire il plugin:", + noDashboardTab: "Nessuna scheda nel dashboard", + openTab: "Apri", + orphanHeading: "Estensioni solo dashboard (nessuna corrispondenza con plugin.yaml)", + pluginListHeading: "Plugin installati", + providerDefaults: "integrato / predefinito", + providersHeading: "Plugin provider runtime", + providersHint: + "Scrive memory.provider (vuoto = integrato) e context.engine in config.yaml. Effetto dalla prossima sessione.", + refreshDashboard: "Riscansiona estensioni dashboard", + removeConfirm: "Rimuovere questo plugin da ~/.hermes/plugins/?", + removeHint: "Solo i plugin installati dall'utente in ~/.hermes/plugins possono essere rimossi.", + rescanHeading: "Registro plugin SPA", + rescanHint: "Riscansiona dopo aver aggiunto file su disco affinché la barra laterale rilevi i nuovi manifest.", + runtimeHeading: "Runtime gateway (plugin YAML)", + saveProviders: "Salva impostazioni provider", + savedProviders: "Impostazioni provider salvate.", + sourceBadge: "Origine", + authRequired: "Autenticazione richiesta", + authRequiredHint: "Esegui questo comando per autenticarti:", + updateGit: "Git pull", + versionBadge: "Versione", + showInSidebar: "Mostra nella barra laterale", + hideFromSidebar: "Nascondi dalla barra laterale", + }, + + skills: { + title: "Competenze", + searchPlaceholder: "Cerca competenze e toolset...", + enabledOf: "{enabled}/{total} abilitati", + all: "Tutti", + categories: "Categorie", + filters: "Filtri", + noSkills: "Nessuna competenza trovata. Le competenze vengono caricate da ~/.hermes/skills/", + noSkillsMatch: "Nessuna competenza corrisponde alla ricerca o al filtro.", + skillCount: "{count} competenz{s}", + resultCount: "{count} risultat{s}", + noDescription: "Nessuna descrizione disponibile.", + toolsets: "Toolset", + toolsetLabel: "Toolset {name}", + noToolsetsMatch: "Nessun toolset corrisponde alla ricerca.", + setupNeeded: "Configurazione necessaria", + disabledForCli: "Disabilitato per CLI", + more: "+{count} in più", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "Filtri", + sections: "Sezioni", + exportConfig: "Esporta configurazione come JSON", + importConfig: "Importa configurazione da JSON", + resetDefaults: "Ripristina predefiniti", + resetScopeTooltip: "Ripristina {scope} ai valori predefiniti", + confirmResetScope: "Ripristinare tutte le impostazioni di {scope} ai valori predefiniti? Questa operazione aggiorna solo il modulo — le modifiche non vengono scritte in config.yaml finché non premi Salva.", + resetScopeToast: "{scope} ripristinato ai valori predefiniti — controlla e Salva per rendere persistente", + rawYaml: "Configurazione YAML grezza", + searchResults: "Risultati della ricerca", + fields: "camp{s}", + noFieldsMatch: 'Nessun campo corrisponde a "{query}"', + configSaved: "Configurazione salvata", + yamlConfigSaved: "Configurazione YAML salvata", + failedToSave: "Salvataggio non riuscito", + failedToSaveYaml: "Salvataggio YAML non riuscito", + failedToLoadRaw: "Caricamento configurazione grezza non riuscito", + configImported: "Configurazione importata — controlla e salva", + invalidJson: "File JSON non valido", + categories: { + general: "Generale", + agent: "Agente", + terminal: "Terminale", + display: "Visualizzazione", + delegation: "Delega", + memory: "Memoria", + compression: "Compressione", + security: "Sicurezza", + browser: "Browser", + voice: "Voce", + tts: "Sintesi vocale", + stt: "Riconoscimento vocale", + logging: "Log", + discord: "Discord", + auxiliary: "Ausiliario", + }, + }, + + env: { + changesNote: "Le modifiche vengono salvate immediatamente su disco. Le sessioni attive rilevano automaticamente le nuove chiavi.", + confirmClearMessage: + "Il valore memorizzato per questa variabile sarà rimosso dal tuo file .env. Non può essere annullato dall'interfaccia.", + confirmClearTitle: "Cancellare questa chiave?", + description: "Gestisci chiavi API e segreti memorizzati in", + hideAdvanced: "Nascondi avanzate", + showAdvanced: "Mostra avanzate", + llmProviders: "Provider LLM", + providersConfigured: "{configured} di {total} provider configurati", + getKey: "Ottieni chiave", + notConfigured: "{count} non configurat{s}", + notSet: "Non impostato", + keysCount: "{count} chiav{s}", + enterValue: "Inserisci valore...", + replaceCurrentValue: "Sostituisci valore corrente ({preview})", + showValue: "Mostra valore reale", + hideValue: "Nascondi valore", + }, + + oauth: { + title: "Accessi provider (OAuth)", + providerLogins: "Accessi provider (OAuth)", + description: "{connected} di {total} provider OAuth connessi. I flussi di accesso vengono attualmente eseguiti tramite la CLI; clicca Copia comando e incolla in un terminale per configurare.", + connected: "Connesso", + expired: "Scaduto", + notConnected: "Non connesso. Esegui {command} in un terminale.", + runInTerminal: "in un terminale.", + noProviders: "Nessun provider compatibile con OAuth rilevato.", + login: "Accedi", + disconnect: "Disconnetti", + managedExternally: "Gestito esternamente", + copied: "Copiato ✓", + cli: "CLI", + copyCliCommand: "Copia comando CLI (per uso esterno / fallback)", + connect: "Connetti", + sessionExpires: "La sessione scade tra {time}", + initiatingLogin: "Avvio del flusso di accesso…", + exchangingCode: "Scambio del codice per i token…", + connectedClosing: "Connesso! Chiusura…", + loginFailed: "Accesso non riuscito.", + sessionExpired: "Sessione scaduta. Clicca Riprova per iniziare un nuovo accesso.", + reOpenAuth: "Riapri pagina di autenticazione", + reOpenVerification: "Riapri pagina di verifica", + submitCode: "Invia codice", + pasteCode: "Incolla codice di autorizzazione (con suffisso #state va bene)", + waitingAuth: "In attesa che tu autorizzi nel browser…", + enterCodePrompt: "È stata aperta una nuova scheda. Inserisci questo codice se richiesto:", + pkceStep1: "È stata aperta una nuova scheda su claude.ai. Accedi e clicca Autorizza.", + pkceStep2: "Copia il codice di autorizzazione mostrato dopo l'autorizzazione.", + pkceStep3: "Incollalo qui sotto e invia.", + flowLabels: { + pkce: "Accesso browser (PKCE)", + device_code: "Codice dispositivo", + external: "CLI esterna", + }, + expiresIn: "scade tra {time}", + }, + + language: { + switchTo: "Passa all'inglese", + }, + + theme: { + title: "Tema", + switchTheme: "Cambia tema", + }, + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Badge Hermes da collezione, ottenuti dalla cronologia reale delle sessioni. Gli achievement noti non completati vengono mostrati come Scoperti; gli achievement segreti restano nascosti finché non compare il primo comportamento corrispondente.", + scan_subtitle: + "Scansione della cronologia delle sessioni Hermes in corso. La prima scansione può richiedere 5–10 secondi su cronologie ampie.", + }, + actions: { + rescan: "Riscansiona", + }, + stats: { + unlocked: "Sbloccati", + unlocked_hint: "badge ottenuti", + discovered: "Scoperti", + discovered_hint: "noti, non ancora ottenuti", + secrets: "Segreti", + secrets_hint: "nascosti fino al primo segnale", + highest_tier: "Livello più alto", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "Più recente", + latest_hint_empty: "usa Hermes di più", + none_yet: "Nessuno ancora", + }, + state: { + unlocked: "Sbloccato", + discovered: "Scoperto", + secret: "Segreto", + }, + tier: { + target: "Obiettivo {tier}", + hidden: "Nascosto", + complete: "Completato", + objective: "Obiettivo", + }, + progress: { + hidden: "nascosto", + }, + scan: { + building_headline: "Costruzione del profilo achievement…", + building_detail: + "Lettura di sessioni, chiamate agli strumenti, metadati del modello e stato di sblocco.", + starting_headline: "Avvio della scansione achievement…", + progress_detail: + "Scansionate {scanned} di {total} sessioni · {pct}%. I badge si sbloccano man mano che viene elaborata altra cronologia.", + idle_detail: + "Lettura di sessioni, chiamate agli strumenti, metadati del modello e stato di sblocco. I badge appaiono qui non appena vengono sbloccati.", + }, + guide: { + tiers_header: "Livelli", + secret_header: "Achievement segreti", + secret_body: + "I segreti nascondono il loro trigger esatto. Quando Hermes rileva un segnale correlato, la carta passa a Scoperto e mostra il requisito.", + scan_status_header: "Stato della scansione", + scan_status_body: + "Hermes sta scansionando la cronologia locale una sola volta, poi le carte appariranno automaticamente. Non è bloccato nulla se richiede qualche secondo.", + what_scanned_header: "Cosa viene scansionato", + what_scanned_body: + "Sessioni, chiamate agli strumenti, metadati del modello, errori, achievement e stato di sblocco locale.", + }, + card: { + share_title: "Condividi questo achievement", + share_label: "Condividi {name}", + share_text: "Condividi", + how_to_reveal: "Come rivelarlo", + what_counts: "Cosa conta", + evidence_label: "Prova", + evidence_session_fallback: "sessione", + no_evidence: "Nessuna prova ancora", + }, + latest: { + header: "Sblocchi recenti", + }, + empty: { + no_secrets_header: "Nessun segreto nascosto rimasto in questa scansione.", + no_secrets_body: + "Indizio: i segreti di solito partono da fallimenti inusuali o pattern da utente esperto — conflitti di porte, muri di permessi, variabili d'ambiente mancanti, errori YAML, collisioni Docker, uso di rollback/checkpoint, cache hit o piccole correzioni dopo molto testo rosso.", + }, + filters: { + all_categories: "Tutti", + visibility_all: "tutti", + visibility_unlocked: "sbloccati", + visibility_discovered: "scoperti", + visibility_secret: "segreti", + }, + share: { + dialog_label: "Condividi achievement", + header: "Condividi: {name}", + close: "Chiudi", + rendering: "Rendering…", + card_alt: "Carta di condivisione {name}", + error_generic: "Qualcosa è andato storto.", + x_title: "Apre X con un post precompilato", + x_button: "Condividi su X", + copy_title: "Copia l'immagine per incollarla nel tuo post", + copy_button: "Copia immagine", + copied: "Copiato ✓", + download_button: "Scarica PNG", + hint: + "Condividi su X apre un post precompilato in una nuova scheda. Clicca prima su Copia immagine se vuoi allegare il badge 1200×630 — X ti permette di incollarlo direttamente nell'editor del tweet. Scarica PNG salva il file per l'uso ovunque.", + clipboard_unsupported: + "La copia delle immagini negli appunti non è supportata in questo browser — usa Scarica invece.", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Caricamento bacheca Kanban…", + loadFailed: "Caricamento della bacheca Kanban non riuscito: ", + loadFailedHint: + "Il backend crea automaticamente kanban.db alla prima lettura. Se il problema persiste, controlla i log del dashboard.", + board: "Bacheca", + newBoard: "+ Nuova bacheca", + newBoardTitle: "Nuova bacheca", + newBoardDescription: + "Le bacheche ti permettono di separare flussi di lavoro non correlati — una per progetto, repository o dominio. I worker su una bacheca non vedono mai le attività di un'altra.", + slug: "Slug", + slugHint: "— minuscolo, trattini, ad es. atm10-server", + displayName: "Nome visualizzato", + displayNameHint: "(facoltativo)", + description: "Descrizione", + descriptionHint: "(facoltativo)", + icon: "Icona", + iconHint: "(un singolo carattere o emoji)", + switchAfterCreate: "Passa a questa bacheca dopo la creazione", + cancel: "Annulla", + creating: "Creazione…", + createBoard: "Crea bacheca", + search: "Cerca", + filterCards: "Filtra schede…", + tenant: "Tenant", + allTenants: "Tutti i tenant", + assignee: "Assegnatario", + allProfiles: "Tutti i profili", + showArchived: "Mostra archiviati", + lanesByProfile: "Corsie per profilo", + nudgeDispatcher: "Sollecita dispatcher", + refresh: "Aggiorna", + selected: "selezionato/i", + complete: "Completa", + archive: "Archivia", + apply: "Applica", + clear: "Cancella", + createTask: "Crea attività in questa colonna", + noTasks: "— nessuna attività —", + unassigned: "non assegnato", + untitled: "(senza titolo)", + loadingDetail: "Caricamento…", + addComment: "Aggiungi un commento… (Enter per inviare)", + comment: "Commento", + status: "Stato", + workspace: "Workspace", + skills: "Competenze", + createdBy: "Creato da", + result: "Result", + comments: "Commenti", + events: "Eventi", + runHistory: "Cronologia esecuzioni", + workerLog: "Log del worker", + loadingLog: "Caricamento log…", + noWorkerLog: + "— nessun log del worker ancora (l'attività non è stata avviata o il log è stato ruotato) —", + noDescription: "— nessuna descrizione —", + noComments: "— nessun commento —", + edit: "modifica", + save: "Salva", + dependencies: "Dipendenze", + parents: "Padri:", + children: "Figli:", + none: "nessuno", + addParent: "— aggiungi padre —", + addChild: "— aggiungi figlio —", + removeDependency: "Rimuovi dipendenza", + block: "Blocca", + unblock: "Sblocca", + notifyHomeChannels: "Notifica i canali home", + diagnostics: "Diagnostica", + hide: "Nascondi", + show: "Mostra", + attention: "Attenzione", + tasksNeedAttention: "attività richiedono attenzione", + taskNeedsAttention: "1 attività richiede attenzione", + diagnostic: "diagnostica", + open: "Apri", + close: "Chiudi (Esc)", + reassignTo: "Riassegna a:", + copied: "Copiato", + copyCommand: "Copia comando negli appunti", + reclaim: "Recupera", + reassign: "Riassegna", + renderingError: "La scheda Kanban ha avuto un errore di rendering", + reloadView: "Ricarica vista", + wsAuthFailed: + "Autenticazione WebSocket non riuscita — ricarica la pagina per aggiornare il token di sessione.", + markDone: "Contrassegnare {n} attività come completate?", + markArchived: "Archiviare {n} attività?", + warning: "Avviso", + phantomIds: "ID fantasma:", + active: "attivo", + ended: "terminato", + noProfile: "(nessun profilo)", + showAllAttempts: "Mostra tutti i tentativi", + sendingUpdates: "Invio aggiornamenti a", + sendNotifications: "Invia notifiche di completed / blocked / gave_up a", + archiveBoardConfirm: + "Archiviare la bacheca '{name}'? Verrà spostata in boards/_archived/ in modo da poterla recuperare in seguito. Le attività di questa bacheca non appariranno più da nessuna parte nell'UI.", + archiveBoardTitle: "Archivia questa bacheca", + boardSwitcherHint: "Le bacheche ti permettono di separare flussi di lavoro non correlati", + taskCreatedWarning: "Attività creata, ma: ", + moveFailed: "Spostamento non riuscito: ", + bulkFailed: "Massivo: ", + completionBlockedHallucination: "⚠ Completamento bloccato — ID schede fantasma", + suspectedHallucinatedReferences: "⚠ Il testo ha fatto riferimento a ID schede fantasma", + pickProfileFirst: "Scegli prima un profilo.", + unblockedMessage: "Sbloccato {id}. L'attività è pronta per il prossimo tick.", + unblockFailed: "Sblocco non riuscito: ", + reclaimedMessage: "Recuperato {id}. L'attività è di nuovo pronta.", + reclaimFailed: "Recupero non riuscito: ", + reassignedMessage: "Riassegnato {id} a {profile}.", + reassignFailed: "Riassegnazione non riuscita: ", + selectForBulk: "Seleziona per azioni massive", + clickToEdit: "Clicca per modificare", + clickToEditAssignee: "Clicca per modificare l'assegnatario", + emptyAssignee: "(vuoto = rimuovi assegnazione)", + columnLabels: { + triage: "Triage", + todo: "Da fare", + ready: "Pronto", + running: "In corso", + blocked: "Bloccato", + done: "Fatto", + archived: "Archiviato", + }, + columnHelp: { + triage: "Idee grezze — un specifier elaborerà la specifica", + todo: "In attesa di dipendenze o non assegnato", + ready: "Assegnato e in attesa di un tick del dispatcher", + running: "Preso in carico da un worker — in esecuzione", + blocked: "Il worker ha richiesto input umano", + done: "Completato", + archived: "Archiviato", + }, + confirmDone: + "Contrassegnare questa attività come completata? La presa in carico del worker viene rilasciata e i figli dipendenti diventano pronti.", + confirmArchive: + "Archiviare questa attività? Sparirà dalla vista predefinita della bacheca.", + confirmBlocked: + "Contrassegnare questa attività come bloccata? La presa in carico del worker viene rilasciata.", + completionSummary: + "Riepilogo di completamento per {label}. Memorizzato come result dell'attività.", + completionSummaryRequired: + "Il riepilogo di completamento è obbligatorio prima di contrassegnare un'attività come completata.", + triagePlaceholder: "Idea approssimativa — l'IA la specificherà…", + taskTitlePlaceholder: "Titolo della nuova attività…", + specifier: "specifier", + assigneePlaceholder: "assegnatario", + priority: "Priorità", + skillsPlaceholder: + "competenze (facoltative, separate da virgole): translation, github-code-review", + noParent: "— nessun padre —", + workspacePathDir: "percorso del workspace (richiesto, ad es. ~/projects/my-app)", + workspacePathOptional: + "percorso del workspace (facoltativo, derivato dall'assegnatario se vuoto)", + logTruncated: "(mostrando ultimi 100 KB — log completo in ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/ja.ts b/apps/dashboard/src/i18n/ja.ts new file mode 100644 index 00000000000..175468e4d8b --- /dev/null +++ b/apps/dashboard/src/i18n/ja.ts @@ -0,0 +1,696 @@ +import type { Translations } from "./types"; + +export const ja: Translations = { + common: { + save: "保存", + saving: "保存中...", + cancel: "キャンセル", + close: "閉じる", + confirm: "確認", + delete: "削除", + refresh: "更新", + retry: "再試行", + search: "検索...", + loading: "読み込み中...", + create: "作成", + creating: "作成中...", + set: "設定", + replace: "置換", + clear: "クリア", + live: "ライブ", + off: "オフ", + enabled: "有効", + disabled: "無効", + active: "アクティブ", + inactive: "非アクティブ", + unknown: "不明", + untitled: "無題", + none: "なし", + form: "フォーム", + noResults: "結果がありません", + of: "/", + page: "ページ", + msgs: "メッセージ", + tools: "ツール", + match: "一致", + other: "その他", + configured: "設定済み", + removed: "削除されました", + failedToToggle: "切り替えに失敗しました", + failedToRemove: "削除に失敗しました", + failedToReveal: "表示に失敗しました", + collapse: "折りたたむ", + expand: "展開", + general: "一般", + messaging: "メッセージング", + pluginLoadFailed: + "このプラグインのスクリプトを読み込めませんでした。Network タブ(dashboard-plugins/…)とサーバーのプラグインパスをご確認ください。", + pluginNotRegistered: + "プラグインのスクリプトが register() を呼び出していないか、スクリプトでエラーが発生しました。詳細はブラウザのコンソールをご確認ください。", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "ナビゲーションを閉じる", + closeModelTools: "モデルとツールを閉じる", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "アクティブなセッション:", + gatewayStatusLabel: "ゲートウェイの状態:", + gatewayStrip: { + failed: "起動に失敗しました", + off: "オフ", + running: "実行中", + starting: "起動中", + stopped: "停止", + }, + nav: { + analytics: "分析", + chat: "チャット", + config: "設定", + cron: "Cron", + documentation: "ドキュメント", + keys: "キー", + logs: "ログ", + models: "モデル", + profiles: "プロファイル : マルチエージェント", + plugins: "プラグイン", + sessions: "セッション", + skills: "スキル", + }, + modelToolsSheetSubtitle: "とツール", + modelToolsSheetTitle: "モデル", + navigation: "ナビゲーション", + openDocumentation: "ドキュメントを新しいタブで開く", + openNavigation: "ナビゲーションを開く", + pluginNavSection: "プラグイン", + sessionsActiveCount: "{count} 件アクティブ", + statusOverview: "ステータス概要", + system: "システム", + webUi: "Web UI", + }, + + status: { + actionFailed: "アクションが失敗しました", + actionFinished: "完了", + actions: "アクション", + agent: "エージェント", + activeSessions: "アクティブなセッション", + connected: "接続済み", + connectedPlatforms: "接続済みプラットフォーム", + disconnected: "切断", + error: "エラー", + failed: "失敗", + gateway: "ゲートウェイ", + gatewayFailedToStart: "ゲートウェイの起動に失敗しました", + lastUpdate: "最終更新", + noneRunning: "なし", + notRunning: "実行されていません", + pid: "PID", + platformDisconnected: "切断", + platformError: "エラー", + recentSessions: "最近のセッション", + restartGateway: "ゲートウェイを再起動", + restartingGateway: "ゲートウェイを再起動しています…", + running: "実行中", + runningRemote: "実行中 (リモート)", + startFailed: "起動に失敗しました", + starting: "起動中", + startedInBackground: "バックグラウンドで起動しました — 進行状況はログをご確認ください", + stopped: "停止", + updateHermes: "Hermes を更新", + updatingHermes: "Hermes を更新しています…", + waitingForOutput: "出力を待機しています…", + }, + + sessions: { + title: "セッション", + searchPlaceholder: "メッセージ内容を検索...", + noSessions: "まだセッションがありません", + noMatch: "検索条件に一致するセッションはありません", + startConversation: "会話を開始するとここに表示されます", + noMessages: "メッセージがありません", + untitledSession: "無題のセッション", + deleteSession: "セッションを削除", + confirmDeleteTitle: "セッションを削除しますか?", + confirmDeleteMessage: + "会話とそのすべてのメッセージが完全に削除されます。この操作は取り消せません。", + sessionDeleted: "セッションを削除しました", + failedToDelete: "セッションの削除に失敗しました", + resumeInChat: "チャットで再開", + previousPage: "前のページ", + nextPage: "次のページ", + roles: { + user: "ユーザー", + assistant: "アシスタント", + system: "システム", + tool: "ツール", + }, + }, + + analytics: { + period: "期間:", + totalTokens: "合計トークン数", + totalSessions: "合計セッション数", + apiCalls: "API 呼び出し", + dailyTokenUsage: "日次トークン使用量", + dailyBreakdown: "日次内訳", + perModelBreakdown: "モデル別内訳", + topSkills: "トップスキル", + skill: "スキル", + loads: "エージェント読み込み", + edits: "エージェント管理", + lastUsed: "最終使用", + input: "入力", + output: "出力", + total: "合計", + noUsageData: "この期間の使用データはありません", + startSession: "セッションを開始すると分析がここに表示されます", + date: "日付", + model: "モデル", + tokens: "トークン", + perDayAvg: "/日 平均", + acrossModels: "{count} モデル全体", + inOut: "{input} 入力 / {output} 出力", + }, + + models: { + modelsUsed: "使用モデル", + estimatedCost: "推定コスト", + tokens: "トークン", + sessions: "セッション", + avgPerSession: "平均/セッション", + apiCalls: "API 呼び出し", + toolCalls: "ツール呼び出し", + noModelsData: "この期間のモデル使用データはありません", + startSession: "セッションを開始するとモデルデータがここに表示されます", + }, + + logs: { + title: "ログ", + autoRefresh: "自動更新", + file: "ファイル", + level: "レベル", + component: "コンポーネント", + lines: "行数", + noLogLines: "ログ行が見つかりません", + }, + + cron: { + confirmDeleteMessage: + "ジョブをスケジュールから削除します。この操作は取り消せません。", + confirmDeleteTitle: "スケジュールされたジョブを削除しますか?", + newJob: "新しい Cron ジョブ", + nameOptional: "名前 (任意)", + namePlaceholder: "例: 日次サマリー", + prompt: "プロンプト", + promptPlaceholder: "実行ごとにエージェントが行う内容は?", + schedule: "スケジュール (cron 式)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "配信先", + scheduledJobs: "スケジュール済みジョブ", + noJobs: "Cron ジョブが設定されていません。上で作成してください。", + last: "前回", + next: "次回", + pause: "一時停止", + resume: "再開", + triggerNow: "今すぐ実行", + delivery: { + local: "ローカル", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "新しいプロファイル", + name: "名前", + namePlaceholder: "例: coder, writer など", + nameRequired: "名前は必須です", + nameRule: + "小文字、数字、_ および - のみ使用可能。最初は文字または数字で始める必要があります。最大 64 文字。", + invalidName: "無効なプロファイル名", + cloneFromDefault: "デフォルトプロファイルから設定を複製", + allProfiles: "プロファイル", + noProfiles: "プロファイルが見つかりません。", + defaultBadge: "デフォルト", + hasEnv: "env", + model: "モデル", + skills: "スキル", + rename: "名前を変更", + editSoul: "SOUL.md を編集", + soulSection: "SOUL.md (パーソナリティ / システムプロンプト)", + soulPlaceholder: "# このエージェントの振る舞い…", + saveSoul: "SOUL を保存", + soulSaved: "SOUL.md を保存しました", + openInTerminal: "CLI コマンドをコピー", + commandCopied: "クリップボードにコピーしました", + copyFailed: "コピーできませんでした", + confirmDeleteTitle: "プロファイルを削除しますか?", + confirmDeleteMessage: + "プロファイル '{name}' を完全に削除します — 設定、キー、メモリ、セッション、スキル、cron ジョブ。この操作は取り消せません。", + created: "作成しました", + deleted: "削除しました", + renamed: "名前を変更しました", + }, + + pluginsPage: { + contextEngineLabel: "コンテキストエンジン", + dashboardSlots: "ダッシュボードスロット", + disableRuntime: "無効化", + enableAfterInstall: "インストール後に有効化", + enableRuntime: "有効化", + forceReinstall: "強制再インストール (既存のフォルダを先に削除)", + headline: + "Hermes プラグインを発見、インストール、有効化、更新します (`hermes plugins` 相当)。", + identifierLabel: "Git URL または owner/repo", + inactive: "非アクティブ", + installBtn: "Git からインストール", + installHeading: "GitHub / Git URL からインストール", + installHint: "owner/repo の短縮形、または完全な https:// もしくは git@ クローン URL を使用してください。", + memoryProviderLabel: "メモリプロバイダー", + missingEnvWarn: "プラグインを実行する前にこれらをキーに設定してください:", + noDashboardTab: "ダッシュボードタブなし", + openTab: "開く", + orphanHeading: "ダッシュボード専用拡張 (該当する agent plugin.yaml なし)", + pluginListHeading: "インストール済みプラグイン", + providerDefaults: "組み込み / デフォルト", + providersHeading: "ランタイムプロバイダープラグイン", + providersHint: + "memory.provider (空 = 組み込み) と context.engine を config.yaml に書き込みます。次のセッションで有効になります。", + refreshDashboard: "ダッシュボード拡張を再スキャン", + removeConfirm: "このプラグインを ~/.hermes/plugins/ から削除しますか?", + removeHint: "削除できるのは ~/.hermes/plugins 配下のユーザーがインストールしたプラグインのみです。", + rescanHeading: "SPA プラグインレジストリ", + rescanHint: "ディスクにファイルを追加した後に再スキャンすると、ダッシュボードのサイドバーが新しいマニフェストを認識します。", + runtimeHeading: "ゲートウェイランタイム (YAML プラグイン)", + saveProviders: "プロバイダー設定を保存", + savedProviders: "プロバイダー設定を保存しました。", + sourceBadge: "ソース", + authRequired: "認証が必要", + authRequiredHint: "認証するには次のコマンドを実行してください:", + updateGit: "Git pull", + versionBadge: "バージョン", + showInSidebar: "サイドバーに表示", + hideFromSidebar: "サイドバーから非表示", + }, + + skills: { + title: "スキル", + searchPlaceholder: "スキルとツールセットを検索...", + enabledOf: "{enabled}/{total} 有効", + all: "すべて", + categories: "カテゴリ", + filters: "フィルター", + noSkills: "スキルが見つかりません。スキルは ~/.hermes/skills/ から読み込まれます", + noSkillsMatch: "検索またはフィルターに一致するスキルはありません。", + skillCount: "{count} スキル{s}", + resultCount: "{count} 件の結果{s}", + noDescription: "説明はありません。", + toolsets: "ツールセット", + toolsetLabel: "{name} ツールセット", + noToolsetsMatch: "検索に一致するツールセットはありません。", + setupNeeded: "セットアップが必要", + disabledForCli: "CLI では無効", + more: "+{count} 件", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "フィルター", + sections: "セクション", + exportConfig: "設定を JSON としてエクスポート", + importConfig: "JSON から設定をインポート", + resetDefaults: "デフォルトにリセット", + resetScopeTooltip: "{scope} をデフォルトにリセット", + confirmResetScope: "すべての {scope} 設定をデフォルトにリセットしますか?フォームのみ更新されます — 保存を押すまで config.yaml には書き込まれません。", + resetScopeToast: "{scope} をデフォルトにリセットしました — 確認して保存してください", + rawYaml: "生の YAML 設定", + searchResults: "検索結果", + fields: "フィールド{s}", + noFieldsMatch: '"{query}" に一致するフィールドはありません', + configSaved: "設定を保存しました", + yamlConfigSaved: "YAML 設定を保存しました", + failedToSave: "保存に失敗しました", + failedToSaveYaml: "YAML の保存に失敗しました", + failedToLoadRaw: "生の設定の読み込みに失敗しました", + configImported: "設定をインポートしました — 確認して保存してください", + invalidJson: "無効な JSON ファイル", + categories: { + general: "一般", + agent: "エージェント", + terminal: "ターミナル", + display: "表示", + delegation: "委任", + memory: "メモリ", + compression: "圧縮", + security: "セキュリティ", + browser: "ブラウザ", + voice: "音声", + tts: "音声合成", + stt: "音声認識", + logging: "ロギング", + discord: "Discord", + auxiliary: "補助", + }, + }, + + env: { + changesNote: "変更は即座にディスクへ保存されます。アクティブなセッションは新しいキーを自動的に取得します。", + confirmClearMessage: + "この変数の保存値が .env ファイルから削除されます。この操作は UI から取り消せません。", + confirmClearTitle: "このキーをクリアしますか?", + description: "API キーとシークレットを管理します。保存先:", + hideAdvanced: "詳細設定を隠す", + showAdvanced: "詳細設定を表示", + llmProviders: "LLM プロバイダー", + providersConfigured: "{configured} / {total} プロバイダーが設定済み", + getKey: "キーを取得", + notConfigured: "{count} 件未設定", + notSet: "未設定", + keysCount: "{count} キー{s}", + enterValue: "値を入力...", + replaceCurrentValue: "現在の値を置き換える ({preview})", + showValue: "実際の値を表示", + hideValue: "値を非表示", + }, + + oauth: { + title: "プロバイダーログイン (OAuth)", + providerLogins: "プロバイダーログイン (OAuth)", + description: "{connected} / {total} OAuth プロバイダーが接続されています。ログインフローは現在 CLI 経由で実行されます。「コマンドをコピー」をクリックして、ターミナルに貼り付けてセットアップしてください。", + connected: "接続済み", + expired: "期限切れ", + notConnected: "未接続です。ターミナルで {command} を実行してください。", + runInTerminal: "ターミナルで実行してください。", + noProviders: "OAuth 対応プロバイダーは検出されませんでした。", + login: "ログイン", + disconnect: "切断", + managedExternally: "外部で管理", + copied: "コピーしました ✓", + cli: "CLI", + copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)", + connect: "接続", + sessionExpires: "セッションは {time} 後に期限切れになります", + initiatingLogin: "ログインフローを開始しています…", + exchangingCode: "コードをトークンと交換しています…", + connectedClosing: "接続しました!閉じています…", + loginFailed: "ログインに失敗しました。", + sessionExpired: "セッションの有効期限が切れました。再試行をクリックして新しいログインを開始してください。", + reOpenAuth: "認証ページを再度開く", + reOpenVerification: "確認ページを再度開く", + submitCode: "コードを送信", + pasteCode: "認可コードを貼り付け (#state サフィックス付きでも問題ありません)", + waitingAuth: "ブラウザでの認可をお待ちしています…", + enterCodePrompt: "新しいタブが開きました。プロンプトが表示されたらこのコードを入力してください:", + pkceStep1: "claude.ai への新しいタブが開きました。サインインして「Authorize」をクリックしてください。", + pkceStep2: "認可後に表示される認可コードをコピーしてください。", + pkceStep3: "下に貼り付けて送信してください。", + flowLabels: { + pkce: "ブラウザログイン (PKCE)", + device_code: "デバイスコード", + external: "外部 CLI", + }, + expiresIn: "{time} 後に期限切れ", + }, + + language: { + switchTo: "英語に切り替え", + }, + + theme: { + title: "テーマ", + switchTheme: "テーマを切り替え", + }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "実際のセッション履歴から獲得できる Hermes のコレクタブル バッジです。既知の未達成の実績は「Discovered」として表示され、Secret 実績は最初の該当する挙動が検出されるまで非表示のままです。", + scan_subtitle: + "Hermes のセッション履歴をスキャンしています。履歴が大きい場合、初回スキャンには 5~10 秒かかることがあります。", + }, + actions: { + rescan: "再スキャン", + }, + stats: { + unlocked: "解除済み", + unlocked_hint: "獲得したバッジ", + discovered: "発見済み", + discovered_hint: "判明していますが未獲得", + secrets: "シークレット", + secrets_hint: "最初のシグナルまで非表示", + highest_tier: "最高ティア", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "最新", + latest_hint_empty: "Hermes をもっと使ってみてください", + none_yet: "まだありません", + }, + state: { + unlocked: "解除済み", + discovered: "発見済み", + secret: "シークレット", + }, + tier: { + target: "目標 {tier}", + hidden: "非表示", + complete: "達成", + objective: "目的", + }, + progress: { + hidden: "非表示", + }, + scan: { + building_headline: "実績プロファイルを構築中…", + building_detail: + "セッション、ツール呼び出し、モデルのメタデータ、解除状態を読み込んでいます。", + starting_headline: "実績スキャンを開始しています…", + progress_detail: + "{total} 件中 {scanned} 件のセッションをスキャンしました · {pct}%。履歴が読み込まれるにつれてバッジが解除されます。", + idle_detail: + "セッション、ツール呼び出し、モデルのメタデータ、解除状態を読み込んでいます。バッジは解除され次第ここに表示されます。", + }, + guide: { + tiers_header: "ティア", + secret_header: "シークレット実績", + secret_body: + "シークレットはトリガー条件を隠しています。Hermes が関連するシグナルを検出すると、カードは「Discovered」になり、要件が表示されます。", + scan_status_header: "スキャン状況", + scan_status_body: + "Hermes はローカル履歴を一度スキャンし、その後カードが自動的に表示されます。数秒かかってもスタックしているわけではありません。", + what_scanned_header: "スキャン対象", + what_scanned_body: + "セッション、ツール呼び出し、モデルのメタデータ、エラー、実績、ローカルの解除状態。", + }, + card: { + share_title: "この実績を共有", + share_label: "{name} を共有", + share_text: "共有", + how_to_reveal: "解除する方法", + what_counts: "対象となる条件", + evidence_label: "エビデンス", + evidence_session_fallback: "セッション", + no_evidence: "エビデンスはまだありません", + }, + latest: { + header: "最近の解除", + }, + empty: { + no_secrets_header: "このスキャンに残っている隠しシークレットはありません。", + no_secrets_body: + "ヒント: シークレットは通常、想定外の失敗やパワーユーザー的なパターンから生まれます — ポート競合、権限の壁、環境変数の不足、YAML のミス、Docker の衝突、ロールバックやチェックポイントの利用、キャッシュヒット、あるいは大量の赤いエラーの後の小さな修正など。", + }, + filters: { + all_categories: "すべて", + visibility_all: "すべて", + visibility_unlocked: "解除済み", + visibility_discovered: "発見済み", + visibility_secret: "シークレット", + }, + share: { + dialog_label: "実績を共有", + header: "共有: {name}", + close: "閉じる", + rendering: "描画中…", + card_alt: "{name} の共有カード", + error_generic: "問題が発生しました。", + x_title: "事前入力された投稿で X を開きます", + x_button: "X で共有", + copy_title: "投稿に貼り付けるために画像をコピーします", + copy_button: "画像をコピー", + copied: "コピーしました ✓", + download_button: "PNG をダウンロード", + hint: + "「X で共有」は事前入力された投稿を新しいタブで開きます。1200×630 のバッジを添付したい場合は、先に「画像をコピー」を押してください — X では投稿エディタに直接貼り付けられます。「PNG をダウンロード」はファイルとして保存し、どこでも使えるようにします。", + clipboard_unsupported: + "このブラウザではクリップボードへの画像コピーがサポートされていません — 代わりに「ダウンロード」をご利用ください。", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Kanban ボードを読み込んでいます…", + loadFailed: "Kanban ボードの読み込みに失敗しました: ", + loadFailedHint: + "バックエンドは初回読み込み時に kanban.db を自動作成します。問題が続く場合は、ダッシュボードのログをご確認ください。", + board: "ボード", + newBoard: "+ 新しいボード", + newBoardTitle: "新しいボード", + newBoardDescription: + "ボードを使うと、関連のない作業の流れを分けられます — プロジェクト、リポジトリ、ドメインごとに 1 つずつ。あるボードのワーカーは、別のボードのタスクを見ることはありません。", + slug: "スラッグ", + slugHint: "— 小文字とハイフン、例: atm10-server", + displayName: "表示名", + displayNameHint: "(任意)", + description: "説明", + descriptionHint: "(任意)", + icon: "アイコン", + iconHint: "(1 文字または絵文字)", + switchAfterCreate: "作成後にこのボードへ切り替える", + cancel: "キャンセル", + creating: "作成中…", + createBoard: "ボードを作成", + search: "検索", + filterCards: "カードを絞り込む…", + tenant: "テナント", + allTenants: "すべてのテナント", + assignee: "担当者", + allProfiles: "すべてのプロファイル", + showArchived: "アーカイブ済みを表示", + lanesByProfile: "プロファイル別レーン", + nudgeDispatcher: "ディスパッチャーを起動", + refresh: "更新", + selected: "選択中", + complete: "完了", + archive: "アーカイブ", + apply: "適用", + clear: "クリア", + createTask: "この列にタスクを作成", + noTasks: "— タスクはありません —", + unassigned: "未割り当て", + untitled: "(タイトルなし)", + loadingDetail: "読み込み中…", + addComment: "コメントを追加…(Enter で送信)", + comment: "コメント", + status: "ステータス", + workspace: "ワークスペース", + skills: "スキル", + createdBy: "作成者", + result: "結果", + comments: "コメント", + events: "イベント", + runHistory: "実行履歴", + workerLog: "ワーカーログ", + loadingLog: "ログを読み込んでいます…", + noWorkerLog: + "— ワーカーログはまだありません(タスクが起動していないか、ログがローテーションされました)—", + noDescription: "— 説明はありません —", + noComments: "— コメントはありません —", + edit: "編集", + save: "保存", + dependencies: "依存関係", + parents: "親タスク:", + children: "子タスク:", + none: "なし", + addParent: "— 親タスクを追加 —", + addChild: "— 子タスクを追加 —", + removeDependency: "依存関係を削除", + block: "ブロック", + unblock: "ブロック解除", + notifyHomeChannels: "ホームチャンネルに通知する", + diagnostics: "診断情報", + hide: "非表示", + show: "表示", + attention: "注意", + tasksNeedAttention: "件のタスクが対応を必要としています", + taskNeedsAttention: "1 件のタスクが対応を必要としています", + diagnostic: "診断", + open: "開く", + close: "閉じる (Esc)", + reassignTo: "再割り当て先:", + copied: "コピーしました", + copyCommand: "コマンドをクリップボードにコピー", + reclaim: "回収", + reassign: "再割り当て", + renderingError: "Kanban タブで描画エラーが発生しました", + reloadView: "ビューを再読み込み", + wsAuthFailed: + "WebSocket 認証に失敗しました — ページを再読み込みしてセッショントークンを更新してください。", + markDone: "{n} 件のタスクを完了にしますか?", + markArchived: "{n} 件のタスクをアーカイブしますか?", + warning: "警告", + phantomIds: "ファントム ID:", + active: "実行中", + ended: "終了", + noProfile: "(プロファイルなし)", + showAllAttempts: "すべての試行を表示", + sendingUpdates: "更新の送信先: ", + sendNotifications: "完了 / ブロック / 諦めの通知の送信先", + archiveBoardConfirm: + "ボード「{name}」をアーカイブしますか?ボードは boards/_archived/ に移動され、後で復元できます。このボード上のタスクは UI のどこにも表示されなくなります。", + archiveBoardTitle: "このボードをアーカイブ", + boardSwitcherHint: "ボードを使うと、関連のない作業の流れを分けられます", + taskCreatedWarning: "タスクは作成されましたが: ", + moveFailed: "移動に失敗しました: ", + bulkFailed: "一括処理: ", + completionBlockedHallucination: "⚠ 完了がブロックされました — ファントムカード ID", + suspectedHallucinatedReferences: "⚠ 本文がファントムカード ID を参照しています", + pickProfileFirst: "まずプロファイルを選択してください。", + unblockedMessage: "{id} のブロックを解除しました。タスクは次のティックの準備ができています。", + unblockFailed: "ブロック解除に失敗しました: ", + reclaimedMessage: "{id} を回収しました。タスクは ready に戻りました。", + reclaimFailed: "回収に失敗しました: ", + reassignedMessage: "{id} を {profile} に再割り当てしました。", + reassignFailed: "再割り当てに失敗しました: ", + selectForBulk: "一括操作のために選択", + clickToEdit: "クリックして編集", + clickToEditAssignee: "クリックして担当者を編集", + emptyAssignee: "(空 = 割り当て解除)", + columnLabels: { + triage: "トリアージ", + todo: "ToDo", + ready: "準備完了", + running: "進行中", + blocked: "ブロック中", + done: "完了", + archived: "アーカイブ済み", + }, + columnHelp: { + triage: "未整理のアイデア — スペシファイアが仕様を肉付けします", + todo: "依存関係の待機中、または未割り当て", + ready: "割り当て済み、ディスパッチャーのティック待ち", + running: "ワーカーが取得中 — 実行中", + blocked: "ワーカーが人間の入力を求めています", + done: "完了", + archived: "アーカイブ済み", + }, + confirmDone: + "このタスクを完了にしますか?ワーカーの取得は解放され、依存している子タスクが ready になります。", + confirmArchive: + "このタスクをアーカイブしますか?既定のボードビューから消えます。", + confirmBlocked: + "このタスクをブロック中にしますか?ワーカーの取得は解放されます。", + completionSummary: + "{label} の完了サマリ。これはタスクの結果として保存されます。", + completionSummaryRequired: + "タスクを完了にする前に、完了サマリの入力が必要です。", + triagePlaceholder: "おおまかなアイデア — AI が仕様化します…", + taskTitlePlaceholder: "新しいタスクのタイトル…", + specifier: "スペシファイア", + assigneePlaceholder: "担当者", + priority: "優先度", + skillsPlaceholder: + "スキル(任意、カンマ区切り): translation, github-code-review", + noParent: "— 親タスクなし —", + workspacePathDir: "ワークスペースのパス(必須、例: ~/projects/my-app)", + workspacePathOptional: + "ワークスペースのパス(任意、空の場合は担当者から導出)", + logTruncated: "(最後の 100 KB を表示中 — 完全なログは ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/ko.ts b/apps/dashboard/src/i18n/ko.ts new file mode 100644 index 00000000000..cfc40d63df7 --- /dev/null +++ b/apps/dashboard/src/i18n/ko.ts @@ -0,0 +1,696 @@ +import type { Translations } from "./types"; + +export const ko: Translations = { + common: { + save: "저장", + saving: "저장 중...", + cancel: "취소", + close: "닫기", + confirm: "확인", + delete: "삭제", + refresh: "새로고침", + retry: "다시 시도", + search: "검색...", + loading: "로딩 중...", + create: "생성", + creating: "생성 중...", + set: "설정", + replace: "교체", + clear: "지우기", + live: "라이브", + off: "꺼짐", + enabled: "활성화됨", + disabled: "비활성화됨", + active: "활성", + inactive: "비활성", + unknown: "알 수 없음", + untitled: "제목 없음", + none: "없음", + form: "양식", + noResults: "결과 없음", + of: "/", + page: "페이지", + msgs: "메시지", + tools: "도구", + match: "일치", + other: "기타", + configured: "구성됨", + removed: "제거됨", + failedToToggle: "전환에 실패했습니다", + failedToRemove: "제거에 실패했습니다", + failedToReveal: "표시에 실패했습니다", + collapse: "접기", + expand: "펼치기", + general: "일반", + messaging: "메시징", + pluginLoadFailed: + "이 플러그인의 스크립트를 로드할 수 없습니다. Network 탭(dashboard-plugins/…)과 서버의 플러그인 경로를 확인하세요.", + pluginNotRegistered: + "플러그인 스크립트가 register()를 호출하지 않았거나 스크립트에 오류가 발생했습니다. 자세한 내용은 브라우저 콘솔을 열어 확인하세요.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "내비게이션 닫기", + closeModelTools: "모델 및 도구 닫기", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "활성 세션:", + gatewayStatusLabel: "게이트웨이 상태:", + gatewayStrip: { + failed: "시작 실패", + off: "꺼짐", + running: "실행 중", + starting: "시작 중", + stopped: "중지됨", + }, + nav: { + analytics: "분석", + chat: "채팅", + config: "설정", + cron: "Cron", + documentation: "문서", + keys: "키", + logs: "로그", + models: "모델", + profiles: "프로필: 멀티 에이전트", + plugins: "플러그인", + sessions: "세션", + skills: "스킬", + }, + modelToolsSheetSubtitle: "및 도구", + modelToolsSheetTitle: "모델", + navigation: "내비게이션", + openDocumentation: "새 탭에서 문서 열기", + openNavigation: "내비게이션 열기", + pluginNavSection: "플러그인", + sessionsActiveCount: "{count}개 활성", + statusOverview: "상태 개요", + system: "시스템", + webUi: "Web UI", + }, + + status: { + actionFailed: "작업 실패", + actionFinished: "완료됨", + actions: "작업", + agent: "에이전트", + activeSessions: "활성 세션", + connected: "연결됨", + connectedPlatforms: "연결된 플랫폼", + disconnected: "연결 끊김", + error: "오류", + failed: "실패", + gateway: "게이트웨이", + gatewayFailedToStart: "게이트웨이 시작 실패", + lastUpdate: "마지막 업데이트", + noneRunning: "없음", + notRunning: "실행 중이 아님", + pid: "PID", + platformDisconnected: "연결 끊김", + platformError: "오류", + recentSessions: "최근 세션", + restartGateway: "게이트웨이 재시작", + restartingGateway: "게이트웨이 재시작 중…", + running: "실행 중", + runningRemote: "실행 중 (원격)", + startFailed: "시작 실패", + starting: "시작 중", + startedInBackground: "백그라운드에서 시작됨 — 진행 상황은 로그를 확인하세요", + stopped: "중지됨", + updateHermes: "Hermes 업데이트", + updatingHermes: "Hermes 업데이트 중…", + waitingForOutput: "출력 대기 중…", + }, + + sessions: { + title: "세션", + searchPlaceholder: "메시지 내용 검색...", + noSessions: "아직 세션이 없습니다", + noMatch: "검색과 일치하는 세션이 없습니다", + startConversation: "대화를 시작하면 여기에 표시됩니다", + noMessages: "메시지가 없습니다", + untitledSession: "제목 없는 세션", + deleteSession: "세션 삭제", + confirmDeleteTitle: "세션을 삭제하시겠습니까?", + confirmDeleteMessage: + "이 작업은 대화와 모든 메시지를 영구적으로 제거합니다. 되돌릴 수 없습니다.", + sessionDeleted: "세션이 삭제되었습니다", + failedToDelete: "세션 삭제에 실패했습니다", + resumeInChat: "채팅에서 다시 시작", + previousPage: "이전 페이지", + nextPage: "다음 페이지", + roles: { + user: "사용자", + assistant: "어시스턴트", + system: "시스템", + tool: "도구", + }, + }, + + analytics: { + period: "기간:", + totalTokens: "총 토큰", + totalSessions: "총 세션", + apiCalls: "API 호출", + dailyTokenUsage: "일일 토큰 사용량", + dailyBreakdown: "일별 내역", + perModelBreakdown: "모델별 내역", + topSkills: "주요 스킬", + skill: "스킬", + loads: "에이전트 로드됨", + edits: "에이전트 관리", + lastUsed: "마지막 사용", + input: "입력", + output: "출력", + total: "합계", + noUsageData: "이 기간에 대한 사용 데이터가 없습니다", + startSession: "세션을 시작하면 여기에 분석이 표시됩니다", + date: "날짜", + model: "모델", + tokens: "토큰", + perDayAvg: "/일 평균", + acrossModels: "{count}개 모델 전반", + inOut: "입력 {input} / 출력 {output}", + }, + + models: { + modelsUsed: "사용된 모델", + estimatedCost: "예상 비용", + tokens: "토큰", + sessions: "세션", + avgPerSession: "세션당 평균", + apiCalls: "API 호출", + toolCalls: "도구 호출", + noModelsData: "이 기간에 대한 모델 사용 데이터가 없습니다", + startSession: "세션을 시작하면 여기에 모델 데이터가 표시됩니다", + }, + + logs: { + title: "로그", + autoRefresh: "자동 새로고침", + file: "파일", + level: "레벨", + component: "구성 요소", + lines: "줄 수", + noLogLines: "로그 줄을 찾을 수 없습니다", + }, + + cron: { + confirmDeleteMessage: + "이 작업은 일정에서 작업을 제거합니다. 되돌릴 수 없습니다.", + confirmDeleteTitle: "예약된 작업을 삭제하시겠습니까?", + newJob: "새 Cron 작업", + nameOptional: "이름 (선택 사항)", + namePlaceholder: "예: 일일 요약", + prompt: "프롬프트", + promptPlaceholder: "에이전트가 매 실행 시 무엇을 해야 합니까?", + schedule: "스케줄 (cron 표현식)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "전달 대상", + scheduledJobs: "예약된 작업", + noJobs: "구성된 cron 작업이 없습니다. 위에서 하나 만드세요.", + last: "마지막", + next: "다음", + pause: "일시 정지", + resume: "재개", + triggerNow: "지금 실행", + delivery: { + local: "로컬", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "새 프로필", + name: "이름", + namePlaceholder: "예: coder, writer 등.", + nameRequired: "이름은 필수입니다", + nameRule: + "소문자, 숫자, _ 및 - 만 사용 가능합니다. 문자나 숫자로 시작해야 하며 최대 64자입니다.", + invalidName: "잘못된 프로필 이름입니다", + cloneFromDefault: "기본 프로필에서 설정 복제", + allProfiles: "프로필", + noProfiles: "프로필을 찾을 수 없습니다.", + defaultBadge: "기본", + hasEnv: "env", + model: "모델", + skills: "스킬", + rename: "이름 변경", + editSoul: "SOUL.md 편집", + soulSection: "SOUL.md (개성 / 시스템 프롬프트)", + soulPlaceholder: "# 이 에이전트가 어떻게 동작해야 하는지…", + saveSoul: "SOUL 저장", + soulSaved: "SOUL.md가 저장되었습니다", + openInTerminal: "CLI 명령 복사", + commandCopied: "클립보드에 복사되었습니다", + copyFailed: "복사할 수 없습니다", + confirmDeleteTitle: "프로필을 삭제하시겠습니까?", + confirmDeleteMessage: + "이 작업은 '{name}' 프로필 — 설정, 키, 메모리, 세션, 스킬, cron 작업 — 을 영구적으로 삭제합니다. 되돌릴 수 없습니다.", + created: "생성됨", + deleted: "삭제됨", + renamed: "이름 변경됨", + }, + + pluginsPage: { + contextEngineLabel: "컨텍스트 엔진", + dashboardSlots: "대시보드 슬롯", + disableRuntime: "비활성화", + enableAfterInstall: "설치 후 활성화", + enableRuntime: "활성화", + forceReinstall: "강제 재설치 (기존 폴더를 먼저 삭제)", + headline: + "Hermes 플러그인을 검색, 설치, 활성화 및 업데이트합니다 (`hermes plugins` 동등).", + identifierLabel: "Git URL 또는 owner/repo", + inactive: "비활성", + installBtn: "Git에서 설치", + installHeading: "GitHub / Git URL에서 설치", + installHint: "owner/repo 약어 또는 전체 https:// 또는 git@ 클론 URL을 사용하세요.", + memoryProviderLabel: "메모리 제공자", + missingEnvWarn: "플러그인을 실행하기 전에 Keys에서 다음 항목을 설정하세요:", + noDashboardTab: "대시보드 탭 없음", + openTab: "열기", + orphanHeading: "대시보드 전용 확장 (일치하는 agent plugin.yaml 없음)", + pluginListHeading: "설치된 플러그인", + providerDefaults: "내장 / 기본", + providersHeading: "런타임 제공자 플러그인", + providersHint: + "memory.provider (비어 있으면 = 내장)와 context.engine을 config.yaml에 기록합니다. 다음 세션부터 적용됩니다.", + refreshDashboard: "대시보드 확장 재스캔", + removeConfirm: "~/.hermes/plugins/에서 이 플러그인을 제거하시겠습니까?", + removeHint: "~/.hermes/plugins 아래에 사용자가 설치한 플러그인만 제거할 수 있습니다.", + rescanHeading: "SPA 플러그인 레지스트리", + rescanHint: "디스크에 파일을 추가한 후 재스캔하여 대시보드 사이드바가 새 매니페스트를 인식하도록 합니다.", + runtimeHeading: "게이트웨이 런타임 (YAML 플러그인)", + saveProviders: "제공자 설정 저장", + savedProviders: "제공자 설정이 저장되었습니다.", + sourceBadge: "소스", + authRequired: "인증 필요", + authRequiredHint: "이 명령을 실행하여 인증하세요:", + updateGit: "Git pull", + versionBadge: "버전", + showInSidebar: "사이드바에 표시", + hideFromSidebar: "사이드바에서 숨기기", + }, + + skills: { + title: "스킬", + searchPlaceholder: "스킬 및 도구 세트 검색...", + enabledOf: "{enabled}/{total} 활성화됨", + all: "전체", + categories: "카테고리", + filters: "필터", + noSkills: "스킬을 찾을 수 없습니다. 스킬은 ~/.hermes/skills/ 에서 로드됩니다", + noSkillsMatch: "검색이나 필터와 일치하는 스킬이 없습니다.", + skillCount: "{count}개 스킬", + resultCount: "{count}개 결과", + noDescription: "사용 가능한 설명이 없습니다.", + toolsets: "도구 세트", + toolsetLabel: "{name} 도구 세트", + noToolsetsMatch: "검색과 일치하는 도구 세트가 없습니다.", + setupNeeded: "설정 필요", + disabledForCli: "CLI에서 비활성화됨", + more: "+{count}개 더", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "필터", + sections: "섹션", + exportConfig: "설정을 JSON으로 내보내기", + importConfig: "JSON에서 설정 가져오기", + resetDefaults: "기본값으로 재설정", + resetScopeTooltip: "{scope}을(를) 기본값으로 재설정", + confirmResetScope: "모든 {scope} 설정을 기본값으로 재설정하시겠습니까? 이 작업은 양식만 업데이트하며, 저장을 누르기 전까지는 변경 사항이 config.yaml에 기록되지 않습니다.", + resetScopeToast: "{scope}이(가) 기본값으로 재설정되었습니다 — 검토 후 저장하여 적용하세요", + rawYaml: "원본 YAML 설정", + searchResults: "검색 결과", + fields: "개 필드", + noFieldsMatch: '\"{query}\"와(과) 일치하는 필드가 없습니다', + configSaved: "설정이 저장되었습니다", + yamlConfigSaved: "YAML 설정이 저장되었습니다", + failedToSave: "저장에 실패했습니다", + failedToSaveYaml: "YAML 저장에 실패했습니다", + failedToLoadRaw: "원본 설정 로드에 실패했습니다", + configImported: "설정을 가져왔습니다 — 검토 후 저장하세요", + invalidJson: "잘못된 JSON 파일입니다", + categories: { + general: "일반", + agent: "에이전트", + terminal: "터미널", + display: "디스플레이", + delegation: "위임", + memory: "메모리", + compression: "압축", + security: "보안", + browser: "브라우저", + voice: "음성", + tts: "텍스트 음성 변환", + stt: "음성 텍스트 변환", + logging: "로깅", + discord: "Discord", + auxiliary: "보조", + }, + }, + + env: { + changesNote: "변경 사항은 즉시 디스크에 저장됩니다. 활성 세션은 자동으로 새 키를 가져옵니다.", + confirmClearMessage: + "이 변수에 대해 저장된 값이 .env 파일에서 제거됩니다. UI에서는 이 작업을 되돌릴 수 없습니다.", + confirmClearTitle: "이 키를 지우시겠습니까?", + description: "다음 위치에 저장된 API 키와 비밀을 관리합니다", + hideAdvanced: "고급 숨기기", + showAdvanced: "고급 표시", + llmProviders: "LLM 제공자", + providersConfigured: "{configured}/{total} 제공자가 구성됨", + getKey: "키 받기", + notConfigured: "{count}개 구성되지 않음", + notSet: "설정되지 않음", + keysCount: "{count}개 키", + enterValue: "값 입력...", + replaceCurrentValue: "현재 값 교체 ({preview})", + showValue: "실제 값 표시", + hideValue: "값 숨기기", + }, + + oauth: { + title: "제공자 로그인 (OAuth)", + providerLogins: "제공자 로그인 (OAuth)", + description: "{connected}/{total} OAuth 제공자가 연결되었습니다. 로그인 흐름은 현재 CLI를 통해 실행됩니다. 명령 복사를 클릭하고 터미널에 붙여넣어 설정하세요.", + connected: "연결됨", + expired: "만료됨", + notConnected: "연결되지 않음. 터미널에서 {command}을(를) 실행하세요.", + runInTerminal: "터미널에서.", + noProviders: "OAuth를 지원하는 제공자가 감지되지 않았습니다.", + login: "로그인", + disconnect: "연결 해제", + managedExternally: "외부에서 관리됨", + copied: "복사됨 ✓", + cli: "CLI", + copyCliCommand: "CLI 명령 복사 (외부 / 대체용)", + connect: "연결", + sessionExpires: "세션이 {time} 후 만료됩니다", + initiatingLogin: "로그인 흐름 시작 중…", + exchangingCode: "코드를 토큰으로 교환 중…", + connectedClosing: "연결되었습니다! 닫는 중…", + loginFailed: "로그인 실패.", + sessionExpired: "세션이 만료되었습니다. 다시 시도를 클릭하여 새 로그인을 시작하세요.", + reOpenAuth: "인증 페이지 다시 열기", + reOpenVerification: "확인 페이지 다시 열기", + submitCode: "코드 제출", + pasteCode: "인증 코드 붙여넣기 (#state 접미사 포함도 가능)", + waitingAuth: "브라우저에서 인증을 기다리는 중…", + enterCodePrompt: "새 탭이 열렸습니다. 메시지가 표시되면 이 코드를 입력하세요:", + pkceStep1: "claude.ai로 새 탭이 열렸습니다. 로그인하고 Authorize를 클릭하세요.", + pkceStep2: "인증 후 표시된 인증 코드를 복사하세요.", + pkceStep3: "아래에 붙여넣고 제출하세요.", + flowLabels: { + pkce: "브라우저 로그인 (PKCE)", + device_code: "디바이스 코드", + external: "외부 CLI", + }, + expiresIn: "{time} 후 만료", + }, + + language: { + switchTo: "영어로 전환", + }, + + theme: { + title: "테마", + switchTheme: "테마 전환", + }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "실제 세션 기록에서 획득하는 Hermes 컬렉터블 배지입니다. 알려져 있지만 아직 달성되지 않은 업적은 Discovered로 표시되며, Secret 업적은 일치하는 동작이 처음 나타날 때까지 숨겨집니다.", + scan_subtitle: + "Hermes 세션 기록을 스캔하고 있습니다. 기록이 많으면 첫 스캔에 5~10초가 걸릴 수 있습니다.", + }, + actions: { + rescan: "다시 스캔", + }, + stats: { + unlocked: "해제됨", + unlocked_hint: "획득한 배지", + discovered: "발견됨", + discovered_hint: "알려져 있으나 아직 획득하지 못함", + secrets: "시크릿", + secrets_hint: "첫 신호가 있을 때까지 숨겨짐", + highest_tier: "최고 등급", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "최근", + latest_hint_empty: "Hermes를 더 사용해 보세요", + none_yet: "아직 없음", + }, + state: { + unlocked: "해제됨", + discovered: "발견됨", + secret: "시크릿", + }, + tier: { + target: "목표 {tier}", + hidden: "숨김", + complete: "완료", + objective: "목표", + }, + progress: { + hidden: "숨김", + }, + scan: { + building_headline: "업적 프로필을 구성하고 있습니다…", + building_detail: + "세션, 도구 호출, 모델 메타데이터, 해제 상태를 읽고 있습니다.", + starting_headline: "업적 스캔을 시작합니다…", + progress_detail: + "{total}개 중 {scanned}개의 세션을 스캔했습니다 · {pct}%. 더 많은 기록이 들어오면 배지가 해제됩니다.", + idle_detail: + "세션, 도구 호출, 모델 메타데이터, 해제 상태를 읽고 있습니다. 배지가 해제되면 여기에 표시됩니다.", + }, + guide: { + tiers_header: "등급", + secret_header: "시크릿 업적", + secret_body: + "시크릿은 정확한 트리거 조건을 숨깁니다. Hermes가 관련 신호를 감지하면 카드가 Discovered로 바뀌고 요건이 표시됩니다.", + scan_status_header: "스캔 상태", + scan_status_body: + "Hermes는 로컬 기록을 한 번 스캔한 뒤 카드를 자동으로 표시합니다. 몇 초 걸리더라도 멈춘 것이 아닙니다.", + what_scanned_header: "스캔 대상", + what_scanned_body: + "세션, 도구 호출, 모델 메타데이터, 오류, 업적 및 로컬 해제 상태입니다.", + }, + card: { + share_title: "이 업적 공유", + share_label: "{name} 공유", + share_text: "공유", + how_to_reveal: "공개하는 방법", + what_counts: "인정되는 조건", + evidence_label: "근거", + evidence_session_fallback: "세션", + no_evidence: "아직 근거가 없습니다", + }, + latest: { + header: "최근 해제", + }, + empty: { + no_secrets_header: "이번 스캔에 남은 숨겨진 시크릿이 없습니다.", + no_secrets_body: + "힌트: 시크릿은 보통 비정상적인 실패나 파워 유저 패턴에서 시작됩니다 — 포트 충돌, 권한 차단, 누락된 환경 변수, YAML 실수, Docker 충돌, 롤백/체크포인트 사용, 캐시 적중, 또는 많은 오류 메시지 뒤의 작은 수정 등입니다.", + }, + filters: { + all_categories: "전체", + visibility_all: "전체", + visibility_unlocked: "해제됨", + visibility_discovered: "발견됨", + visibility_secret: "시크릿", + }, + share: { + dialog_label: "업적 공유", + header: "공유: {name}", + close: "닫기", + rendering: "렌더링 중…", + card_alt: "{name} 공유 카드", + error_generic: "문제가 발생했습니다.", + x_title: "미리 작성된 게시물로 X를 엽니다", + x_button: "X에 공유", + copy_title: "게시물에 붙여넣을 수 있도록 이미지를 복사합니다", + copy_button: "이미지 복사", + copied: "복사됨 ✓", + download_button: "PNG 다운로드", + hint: + "X에 공유를 누르면 새 탭에서 미리 작성된 게시물이 열립니다. 1200×630 배지를 첨부하려면 먼저 이미지 복사를 누르세요 — X 작성기에서 바로 붙여넣을 수 있습니다. PNG 다운로드는 파일을 저장하여 어디서나 사용할 수 있게 합니다.", + clipboard_unsupported: + "이 브라우저에서는 클립보드 이미지 복사를 지원하지 않습니다 — 대신 다운로드를 이용하세요.", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Kanban 보드를 불러오는 중입니다…", + loadFailed: "Kanban 보드를 불러오지 못했습니다: ", + loadFailedHint: + "백엔드는 처음 읽을 때 kanban.db를 자동으로 생성합니다. 문제가 계속되면 대시보드 로그를 확인하십시오.", + board: "보드", + newBoard: "+ 새 보드", + newBoardTitle: "새 보드", + newBoardDescription: + "보드를 사용하면 관련 없는 작업 흐름을 분리할 수 있습니다 — 프로젝트, 저장소, 도메인마다 하나씩. 한 보드의 워커는 다른 보드의 작업을 절대 보지 않습니다.", + slug: "슬러그", + slugHint: "— 소문자, 하이픈, 예: atm10-server", + displayName: "표시 이름", + displayNameHint: "(선택)", + description: "설명", + descriptionHint: "(선택)", + icon: "아이콘", + iconHint: "(한 글자 또는 이모지)", + switchAfterCreate: "생성 후 이 보드로 전환", + cancel: "취소", + creating: "생성 중…", + createBoard: "보드 생성", + search: "검색", + filterCards: "카드 필터링…", + tenant: "테넌트", + allTenants: "모든 테넌트", + assignee: "담당자", + allProfiles: "모든 프로필", + showArchived: "보관된 항목 표시", + lanesByProfile: "프로필별 레인", + nudgeDispatcher: "디스패처 깨우기", + refresh: "새로 고침", + selected: "선택됨", + complete: "완료", + archive: "보관", + apply: "적용", + clear: "지우기", + createTask: "이 열에 작업 만들기", + noTasks: "— 작업 없음 —", + unassigned: "미지정", + untitled: "(제목 없음)", + loadingDetail: "불러오는 중…", + addComment: "댓글 추가… (Enter로 전송)", + comment: "댓글", + status: "상태", + workspace: "작업 공간", + skills: "스킬", + createdBy: "작성자", + result: "결과", + comments: "댓글", + events: "이벤트", + runHistory: "실행 기록", + workerLog: "워커 로그", + loadingLog: "로그를 불러오는 중…", + noWorkerLog: + "— 아직 워커 로그가 없습니다 (작업이 시작되지 않았거나 로그가 순환되었습니다) —", + noDescription: "— 설명 없음 —", + noComments: "— 댓글 없음 —", + edit: "편집", + save: "저장", + dependencies: "종속성", + parents: "상위 작업:", + children: "하위 작업:", + none: "없음", + addParent: "— 상위 작업 추가 —", + addChild: "— 하위 작업 추가 —", + removeDependency: "종속성 제거", + block: "차단", + unblock: "차단 해제", + notifyHomeChannels: "홈 채널에 알림", + diagnostics: "진단", + hide: "숨기기", + show: "표시", + attention: "주의", + tasksNeedAttention: "개의 작업이 주의를 필요로 합니다", + taskNeedsAttention: "작업 1개가 주의를 필요로 합니다", + diagnostic: "진단", + open: "열기", + close: "닫기 (Esc)", + reassignTo: "다음으로 재지정:", + copied: "복사됨", + copyCommand: "명령을 클립보드로 복사", + reclaim: "회수", + reassign: "재지정", + renderingError: "Kanban 탭에서 렌더링 오류가 발생했습니다", + reloadView: "뷰 다시 불러오기", + wsAuthFailed: + "WebSocket 인증 실패 — 페이지를 다시 불러와 세션 토큰을 갱신하십시오.", + markDone: "{n}개의 작업을 완료로 표시하시겠습니까?", + markArchived: "{n}개의 작업을 보관하시겠습니까?", + warning: "경고", + phantomIds: "팬텀 ID:", + active: "활성", + ended: "종료됨", + noProfile: "(프로필 없음)", + showAllAttempts: "모든 시도 표시", + sendingUpdates: "업데이트 전송 대상: ", + sendNotifications: "완료 / 차단됨 / 포기 알림 전송 대상", + archiveBoardConfirm: + "보드 '{name}'을(를) 보관하시겠습니까? 보드는 boards/_archived/로 이동되어 나중에 복구할 수 있습니다. 이 보드의 작업은 더 이상 UI 어디에도 나타나지 않습니다.", + archiveBoardTitle: "이 보드 보관", + boardSwitcherHint: "보드를 사용하면 관련 없는 작업 흐름을 분리할 수 있습니다", + taskCreatedWarning: "작업이 생성되었지만: ", + moveFailed: "이동 실패: ", + bulkFailed: "일괄 처리: ", + completionBlockedHallucination: "⚠ 완료가 차단됨 — 팬텀 카드 ID", + suspectedHallucinatedReferences: "⚠ 본문이 팬텀 카드 ID를 참조함", + pickProfileFirst: "먼저 프로필을 선택하십시오.", + unblockedMessage: "{id}의 차단을 해제했습니다. 작업이 다음 틱을 위해 준비되었습니다.", + unblockFailed: "차단 해제 실패: ", + reclaimedMessage: "{id}을(를) 회수했습니다. 작업이 ready 상태로 돌아갔습니다.", + reclaimFailed: "회수 실패: ", + reassignedMessage: "{id}을(를) {profile}(으)로 재지정했습니다.", + reassignFailed: "재지정 실패: ", + selectForBulk: "일괄 작업을 위해 선택", + clickToEdit: "클릭하여 편집", + clickToEditAssignee: "클릭하여 담당자 편집", + emptyAssignee: "(비우면 = 지정 해제)", + columnLabels: { + triage: "분류", + todo: "할 일", + ready: "준비됨", + running: "진행 중", + blocked: "차단됨", + done: "완료", + archived: "보관됨", + }, + columnHelp: { + triage: "원시 아이디어 — 스페시파이어가 사양을 구체화합니다", + todo: "종속성 대기 중 또는 미지정", + ready: "지정되었으며 디스패처 틱 대기 중", + running: "워커가 점유 중 — 실행 중", + blocked: "워커가 사람의 입력을 요청함", + done: "완료됨", + archived: "보관됨", + }, + confirmDone: + "이 작업을 완료로 표시하시겠습니까? 워커의 점유가 해제되고 종속된 하위 작업이 ready 상태가 됩니다.", + confirmArchive: + "이 작업을 보관하시겠습니까? 기본 보드 보기에서 사라집니다.", + confirmBlocked: + "이 작업을 차단됨으로 표시하시겠습니까? 워커의 점유가 해제됩니다.", + completionSummary: + "{label}의 완료 요약입니다. 이는 작업 결과로 저장됩니다.", + completionSummaryRequired: + "작업을 완료로 표시하기 전에 완료 요약이 필요합니다.", + triagePlaceholder: "대략적인 아이디어 — AI가 사양을 작성합니다…", + taskTitlePlaceholder: "새 작업 제목…", + specifier: "스페시파이어", + assigneePlaceholder: "담당자", + priority: "우선순위", + skillsPlaceholder: + "스킬 (선택, 쉼표로 구분): translation, github-code-review", + noParent: "— 상위 작업 없음 —", + workspacePathDir: "작업 공간 경로 (필수, 예: ~/projects/my-app)", + workspacePathOptional: + "작업 공간 경로 (선택, 비어 있으면 담당자에서 파생됨)", + logTruncated: "(마지막 100 KB 표시 중 — 전체 로그 위치: ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/pt.ts b/apps/dashboard/src/i18n/pt.ts new file mode 100644 index 00000000000..6cdd40b8fe5 --- /dev/null +++ b/apps/dashboard/src/i18n/pt.ts @@ -0,0 +1,696 @@ +import type { Translations } from "./types"; + +export const pt: Translations = { + common: { + save: "Guardar", + saving: "A guardar...", + cancel: "Cancelar", + close: "Fechar", + confirm: "Confirmar", + delete: "Eliminar", + refresh: "Atualizar", + retry: "Tentar novamente", + search: "Pesquisar...", + loading: "A carregar...", + create: "Criar", + creating: "A criar...", + set: "Definir", + replace: "Substituir", + clear: "Limpar", + live: "Ativo", + off: "Desligado", + enabled: "ativado", + disabled: "desativado", + active: "ativo", + inactive: "inativo", + unknown: "desconhecido", + untitled: "Sem título", + none: "Nenhum", + form: "Formulário", + noResults: "Sem resultados", + of: "de", + page: "Página", + msgs: "msgs", + tools: "ferramentas", + match: "correspondência", + other: "Outro", + configured: "configurado", + removed: "removido", + failedToToggle: "Falha ao alternar", + failedToRemove: "Falha ao remover", + failedToReveal: "Falha ao revelar", + collapse: "Recolher", + expand: "Expandir", + general: "Geral", + messaging: "Mensagens", + pluginLoadFailed: + "Não foi possível carregar o script deste plugin. Verifique o separador Network (dashboard-plugins/…) e o caminho do plugin no servidor.", + pluginNotRegistered: + "O script do plugin não chamou register(), ou o script falhou. Abra a consola do browser para mais detalhes.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "Fechar navegação", + closeModelTools: "Fechar modelo e ferramentas", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "Sessões ativas:", + gatewayStatusLabel: "Estado do gateway:", + gatewayStrip: { + failed: "Falha ao iniciar", + off: "Desligado", + running: "A executar", + starting: "A iniciar", + stopped: "Parado", + }, + nav: { + analytics: "Análise", + chat: "Chat", + config: "Configuração", + cron: "Cron", + documentation: "Documentação", + keys: "Chaves", + logs: "Registos", + models: "Modelos", + profiles: "perfis: multiagentes", + plugins: "Plugins", + sessions: "Sessões", + skills: "Competências", + }, + modelToolsSheetSubtitle: "e ferramentas", + modelToolsSheetTitle: "Modelo", + navigation: "Navegação", + openDocumentation: "Abrir documentação num novo separador", + openNavigation: "Abrir navegação", + pluginNavSection: "Plugins", + sessionsActiveCount: "{count} ativa(s)", + statusOverview: "Visão geral do estado", + system: "Sistema", + webUi: "Web UI", + }, + + status: { + actionFailed: "Ação falhou", + actionFinished: "Concluído", + actions: "Ações", + agent: "Agente", + activeSessions: "Sessões ativas", + connected: "Ligado", + connectedPlatforms: "Plataformas ligadas", + disconnected: "Desligado", + error: "Erro", + failed: "Falhou", + gateway: "Gateway", + gatewayFailedToStart: "O gateway falhou ao iniciar", + lastUpdate: "Última atualização", + noneRunning: "Nenhum", + notRunning: "Não está a executar", + pid: "PID", + platformDisconnected: "desligado", + platformError: "erro", + recentSessions: "Sessões recentes", + restartGateway: "Reiniciar gateway", + restartingGateway: "A reiniciar gateway…", + running: "A executar", + runningRemote: "A executar (remoto)", + startFailed: "Falha ao iniciar", + starting: "A iniciar", + startedInBackground: "Iniciado em segundo plano — verifique os registos para acompanhar", + stopped: "Parado", + updateHermes: "Atualizar Hermes", + updatingHermes: "A atualizar Hermes…", + waitingForOutput: "À espera de saída…", + }, + + sessions: { + title: "Sessões", + searchPlaceholder: "Pesquisar conteúdo das mensagens...", + noSessions: "Ainda não há sessões", + noMatch: "Nenhuma sessão corresponde à pesquisa", + startConversation: "Inicie uma conversa para a ver aqui", + noMessages: "Sem mensagens", + untitledSession: "Sessão sem título", + deleteSession: "Eliminar sessão", + confirmDeleteTitle: "Eliminar sessão?", + confirmDeleteMessage: + "Esta ação remove permanentemente a conversa e todas as suas mensagens. Não é possível anular.", + sessionDeleted: "Sessão eliminada", + failedToDelete: "Falha ao eliminar a sessão", + resumeInChat: "Retomar no Chat", + previousPage: "Página anterior", + nextPage: "Página seguinte", + roles: { + user: "Utilizador", + assistant: "Assistente", + system: "Sistema", + tool: "Ferramenta", + }, + }, + + analytics: { + period: "Período:", + totalTokens: "Tokens totais", + totalSessions: "Sessões totais", + apiCalls: "Chamadas à API", + dailyTokenUsage: "Utilização diária de tokens", + dailyBreakdown: "Detalhe diário", + perModelBreakdown: "Detalhe por modelo", + topSkills: "Competências principais", + skill: "Competência", + loads: "Carregadas pelo agente", + edits: "Geridas pelo agente", + lastUsed: "Última utilização", + input: "Entrada", + output: "Saída", + total: "Total", + noUsageData: "Sem dados de utilização para este período", + startSession: "Inicie uma sessão para ver as análises aqui", + date: "Data", + model: "Modelo", + tokens: "Tokens", + perDayAvg: "/dia (média)", + acrossModels: "em {count} modelos", + inOut: "{input} entrada / {output} saída", + }, + + models: { + modelsUsed: "Modelos utilizados", + estimatedCost: "Custo est.", + tokens: "tokens", + sessions: "sessões", + avgPerSession: "média/sessão", + apiCalls: "chamadas à API", + toolCalls: "chamadas a ferramentas", + noModelsData: "Sem dados de utilização de modelos para este período", + startSession: "Inicie uma sessão para ver os dados de modelos aqui", + }, + + logs: { + title: "Registos", + autoRefresh: "Atualização automática", + file: "Ficheiro", + level: "Nível", + component: "Componente", + lines: "Linhas", + noLogLines: "Não foram encontradas linhas de registo", + }, + + cron: { + confirmDeleteMessage: + "Esta ação remove a tarefa do agendamento. Não é possível anular.", + confirmDeleteTitle: "Eliminar tarefa agendada?", + newJob: "Nova tarefa cron", + nameOptional: "Nome (opcional)", + namePlaceholder: "ex: Resumo diário", + prompt: "Prompt", + promptPlaceholder: "O que deve o agente fazer em cada execução?", + schedule: "Agendamento (expressão cron)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Entregar a", + scheduledJobs: "Tarefas agendadas", + noJobs: "Sem tarefas cron configuradas. Crie uma acima.", + last: "Última", + next: "Próxima", + pause: "Pausar", + resume: "Retomar", + triggerNow: "Acionar agora", + delivery: { + local: "Local", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "Novo perfil", + name: "Nome", + namePlaceholder: "ex: coder, writer, etc.", + nameRequired: "O nome é obrigatório", + nameRule: + "Apenas letras minúsculas, dígitos, _ e -; deve começar com letra ou dígito; até 64 caracteres.", + invalidName: "Nome de perfil inválido", + cloneFromDefault: "Clonar configuração do perfil predefinido", + allProfiles: "Perfis", + noProfiles: "Não foram encontrados perfis.", + defaultBadge: "predefinido", + hasEnv: "env", + model: "Modelo", + skills: "Competências", + rename: "Renomear", + editSoul: "Editar SOUL.md", + soulSection: "SOUL.md (personalidade / prompt do sistema)", + soulPlaceholder: "# Como este agente se deve comportar…", + saveSoul: "Guardar SOUL", + soulSaved: "SOUL.md guardado", + openInTerminal: "Copiar comando da CLI", + commandCopied: "Copiado para a área de transferência", + copyFailed: "Não foi possível copiar", + confirmDeleteTitle: "Eliminar perfil?", + confirmDeleteMessage: + "Esta ação elimina permanentemente o perfil '{name}' — configuração, chaves, memórias, sessões, competências, tarefas cron. Não é possível anular.", + created: "Criado", + deleted: "Eliminado", + renamed: "Renomeado", + }, + + pluginsPage: { + contextEngineLabel: "Motor de contexto", + dashboardSlots: "Slots do dashboard", + disableRuntime: "Desativar", + enableAfterInstall: "Ativar após instalação", + enableRuntime: "Ativar", + forceReinstall: "Forçar reinstalação (eliminar pasta existente primeiro)", + headline: + "Descobrir, instalar, ativar e atualizar plugins Hermes (paridade com `hermes plugins`).", + identifierLabel: "URL Git ou owner/repo", + inactive: "inativo", + installBtn: "Instalar a partir do Git", + installHeading: "Instalar a partir de GitHub / URL Git", + installHint: "Use a forma curta owner/repo ou um URL completo de clone https:// ou git@.", + memoryProviderLabel: "Fornecedor de memória", + missingEnvWarn: "Defina os seguintes em Chaves antes de o plugin poder executar:", + noDashboardTab: "Sem separador no dashboard", + openTab: "Abrir", + orphanHeading: "Extensões só de dashboard (sem plugin.yaml de agente correspondente)", + pluginListHeading: "Plugins instalados", + providerDefaults: "incorporado / predefinido", + providersHeading: "Plugins de fornecedor em runtime", + providersHint: + "Escreve memory.provider (vazio = incorporado) e context.engine no config.yaml. Aplicado na próxima sessão.", + refreshDashboard: "Re-analisar extensões do dashboard", + removeConfirm: "Remover este plugin de ~/.hermes/plugins/?", + removeHint: "Apenas plugins instalados pelo utilizador em ~/.hermes/plugins podem ser removidos.", + rescanHeading: "Registo de plugins SPA", + rescanHint: "Re-analise depois de adicionar ficheiros em disco para que a barra lateral detete novos manifestos.", + runtimeHeading: "Runtime do gateway (plugins YAML)", + saveProviders: "Guardar definições do fornecedor", + savedProviders: "Definições do fornecedor guardadas.", + sourceBadge: "Fonte", + authRequired: "Autenticação necessária", + authRequiredHint: "Execute este comando para autenticar:", + updateGit: "Git pull", + versionBadge: "Versão", + showInSidebar: "Mostrar na barra lateral", + hideFromSidebar: "Ocultar da barra lateral", + }, + + skills: { + title: "Competências", + searchPlaceholder: "Pesquisar competências e conjuntos de ferramentas...", + enabledOf: "{enabled}/{total} ativadas", + all: "Todas", + categories: "Categorias", + filters: "Filtros", + noSkills: "Nenhuma competência encontrada. As competências são carregadas de ~/.hermes/skills/", + noSkillsMatch: "Nenhuma competência corresponde à pesquisa ou filtro.", + skillCount: "{count} competência{s}", + resultCount: "{count} resultado{s}", + noDescription: "Sem descrição disponível.", + toolsets: "Conjuntos de ferramentas", + toolsetLabel: "conjunto {name}", + noToolsetsMatch: "Nenhum conjunto de ferramentas corresponde à pesquisa.", + setupNeeded: "Configuração necessária", + disabledForCli: "Desativado para CLI", + more: "+{count} mais", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "Filtros", + sections: "Secções", + exportConfig: "Exportar configuração como JSON", + importConfig: "Importar configuração de JSON", + resetDefaults: "Repor predefinições", + resetScopeTooltip: "Repor {scope} para predefinições", + confirmResetScope: "Repor todas as definições de {scope} para os valores predefinidos? Isto apenas atualiza o formulário — as alterações só são escritas em config.yaml quando premir Guardar.", + resetScopeToast: "{scope} reposto para predefinições — reveja e Guarde para persistir", + rawYaml: "Configuração YAML em bruto", + searchResults: "Resultados da pesquisa", + fields: "campo{s}", + noFieldsMatch: 'Nenhum campo corresponde a "{query}"', + configSaved: "Configuração guardada", + yamlConfigSaved: "Configuração YAML guardada", + failedToSave: "Falha ao guardar", + failedToSaveYaml: "Falha ao guardar YAML", + failedToLoadRaw: "Falha ao carregar configuração em bruto", + configImported: "Configuração importada — reveja e guarde", + invalidJson: "Ficheiro JSON inválido", + categories: { + general: "Geral", + agent: "Agente", + terminal: "Terminal", + display: "Visualização", + delegation: "Delegação", + memory: "Memória", + compression: "Compressão", + security: "Segurança", + browser: "Browser", + voice: "Voz", + tts: "Texto para fala", + stt: "Fala para texto", + logging: "Registo", + discord: "Discord", + auxiliary: "Auxiliar", + }, + }, + + env: { + changesNote: "As alterações são guardadas em disco imediatamente. As sessões ativas detetam novas chaves automaticamente.", + confirmClearMessage: + "O valor armazenado para esta variável será removido do seu ficheiro .env. Esta ação não pode ser anulada a partir da UI.", + confirmClearTitle: "Limpar esta chave?", + description: "Gerir chaves de API e segredos armazenados em", + hideAdvanced: "Ocultar avançadas", + showAdvanced: "Mostrar avançadas", + llmProviders: "Fornecedores LLM", + providersConfigured: "{configured} de {total} fornecedores configurados", + getKey: "Obter chave", + notConfigured: "{count} não configurado(s)", + notSet: "Não definido", + keysCount: "{count} chave{s}", + enterValue: "Introduzir valor...", + replaceCurrentValue: "Substituir valor atual ({preview})", + showValue: "Mostrar valor real", + hideValue: "Ocultar valor", + }, + + oauth: { + title: "Inícios de sessão de fornecedor (OAuth)", + providerLogins: "Inícios de sessão de fornecedor (OAuth)", + description: "{connected} de {total} fornecedores OAuth ligados. Os fluxos de início de sessão são executados via CLI; clique em Copiar comando e cole num terminal para configurar.", + connected: "Ligado", + expired: "Expirado", + notConnected: "Não ligado. Execute {command} num terminal.", + runInTerminal: "num terminal.", + noProviders: "Não foram detetados fornecedores compatíveis com OAuth.", + login: "Iniciar sessão", + disconnect: "Desligar", + managedExternally: "Gerido externamente", + copied: "Copiado ✓", + cli: "CLI", + copyCliCommand: "Copiar comando CLI (para externo / fallback)", + connect: "Ligar", + sessionExpires: "A sessão expira em {time}", + initiatingLogin: "A iniciar fluxo de início de sessão…", + exchangingCode: "A trocar código por tokens…", + connectedClosing: "Ligado! A fechar…", + loginFailed: "Início de sessão falhou.", + sessionExpired: "Sessão expirada. Clique em Tentar novamente para iniciar um novo início de sessão.", + reOpenAuth: "Reabrir página de autenticação", + reOpenVerification: "Reabrir página de verificação", + submitCode: "Submeter código", + pasteCode: "Cole o código de autorização (com sufixo #state também é válido)", + waitingAuth: "À espera que autorize no browser…", + enterCodePrompt: "Foi aberto um novo separador. Introduza este código se for solicitado:", + pkceStep1: "Foi aberto um novo separador para claude.ai. Inicie sessão e clique em Authorize.", + pkceStep2: "Copie o código de autorização mostrado após autorizar.", + pkceStep3: "Cole-o abaixo e submeta.", + flowLabels: { + pkce: "Início de sessão pelo browser (PKCE)", + device_code: "Código de dispositivo", + external: "CLI externa", + }, + expiresIn: "expira em {time}", + }, + + language: { + switchTo: "Mudar para inglês", + }, + + theme: { + title: "Tema", + switchTheme: "Mudar tema", + }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Distintivos colecionáveis do Hermes obtidos a partir do histórico real de sessões. Conquistas conhecidas mas ainda não obtidas aparecem como Descobertas; conquistas Secretas permanecem ocultas até surgir o primeiro comportamento correspondente.", + scan_subtitle: + "A analisar o histórico de sessões do Hermes. A primeira análise pode demorar 5–10 segundos em históricos extensos.", + }, + actions: { + rescan: "Voltar a analisar", + }, + stats: { + unlocked: "Desbloqueadas", + unlocked_hint: "distintivos obtidos", + discovered: "Descobertas", + discovered_hint: "conhecidas, ainda não obtidas", + secrets: "Secretas", + secrets_hint: "ocultas até ao primeiro sinal", + highest_tier: "Nível mais alto", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "Mais recente", + latest_hint_empty: "execute mais o Hermes", + none_yet: "Ainda nenhuma", + }, + state: { + unlocked: "Desbloqueada", + discovered: "Descoberta", + secret: "Secreta", + }, + tier: { + target: "Objetivo {tier}", + hidden: "Oculto", + complete: "Completo", + objective: "Objetivo", + }, + progress: { + hidden: "oculto", + }, + scan: { + building_headline: "A construir perfil de conquistas…", + building_detail: + "A ler sessões, chamadas de ferramentas, metadados de modelos e estado de desbloqueio.", + starting_headline: "A iniciar análise de conquistas…", + progress_detail: + "Analisadas {scanned} de {total} sessões · {pct}%. Os distintivos são desbloqueados à medida que mais histórico é processado.", + idle_detail: + "A ler sessões, chamadas de ferramentas, metadados de modelos e estado de desbloqueio. Os distintivos aparecem aqui à medida que são desbloqueados.", + }, + guide: { + tiers_header: "Níveis", + secret_header: "Conquistas secretas", + secret_body: + "As secretas escondem o seu acionador exato. Assim que o Hermes detetar um sinal relacionado, o cartão passa a Descoberta e mostra o requisito.", + scan_status_header: "Estado da análise", + scan_status_body: + "O Hermes analisa o histórico local uma vez e depois os cartões aparecem automaticamente. Nada está bloqueado se isto demorar alguns segundos.", + what_scanned_header: "O que é analisado", + what_scanned_body: + "Sessões, chamadas de ferramentas, metadados de modelos, erros, conquistas e estado de desbloqueio local.", + }, + card: { + share_title: "Partilhar esta conquista", + share_label: "Partilhar {name}", + share_text: "Partilhar", + how_to_reveal: "Como revelar", + what_counts: "O que conta", + evidence_label: "Evidência", + evidence_session_fallback: "sessão", + no_evidence: "Ainda sem evidência", + }, + latest: { + header: "Desbloqueios recentes", + }, + empty: { + no_secrets_header: "Não restam segredos ocultos nesta análise.", + no_secrets_body: + "Pista: as secretas começam normalmente em padrões pouco comuns de falha ou de utilizador avançado — conflitos de portas, barreiras de permissões, variáveis de ambiente em falta, erros de YAML, colisões de Docker, uso de rollback/checkpoint, acertos de cache ou pequenas correções após muito texto a vermelho.", + }, + filters: { + all_categories: "Todas", + visibility_all: "todas", + visibility_unlocked: "desbloqueadas", + visibility_discovered: "descobertas", + visibility_secret: "secretas", + }, + share: { + dialog_label: "Partilhar conquista", + header: "Partilhar: {name}", + close: "Fechar", + rendering: "A renderizar…", + card_alt: "Cartão de partilha de {name}", + error_generic: "Algo correu mal.", + x_title: "Abre o X com uma publicação pré-preenchida", + x_button: "Partilhar no X", + copy_title: "Copiar a imagem para colar na sua publicação", + copy_button: "Copiar imagem", + copied: "Copiado ✓", + download_button: "Transferir PNG", + hint: + "Partilhar no X abre uma publicação pré-preenchida num novo separador. Clique primeiro em Copiar imagem se quiser anexar o distintivo 1200×630 — o X permite colá-lo diretamente no compositor da publicação. Transferir PNG guarda o ficheiro para utilização em qualquer lado.", + clipboard_unsupported: + "A cópia de imagens para a área de transferência não é suportada neste navegador — utilize Transferir.", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "A carregar o quadro Kanban…", + loadFailed: "Falha ao carregar o quadro Kanban: ", + loadFailedHint: + "O backend cria automaticamente kanban.db na primeira leitura. Se persistir, consulte os registos do dashboard.", + board: "Quadro", + newBoard: "+ Novo quadro", + newBoardTitle: "Novo quadro", + newBoardDescription: + "Os quadros permitem-lhe separar fluxos de trabalho não relacionados — um por projeto, repositório ou domínio. Os workers de um quadro nunca veem as tarefas de outro quadro.", + slug: "Slug", + slugHint: "— minúsculas, hífenes, p. ex. atm10-server", + displayName: "Nome a apresentar", + displayNameHint: "(opcional)", + description: "Descrição", + descriptionHint: "(opcional)", + icon: "Ícone", + iconHint: "(carácter único ou emoji)", + switchAfterCreate: "Mudar para este quadro após o criar", + cancel: "Cancelar", + creating: "A criar…", + createBoard: "Criar quadro", + search: "Pesquisar", + filterCards: "Filtrar cartões…", + tenant: "Tenant", + allTenants: "Todos os tenants", + assignee: "Responsável", + allProfiles: "Todos os perfis", + showArchived: "Mostrar arquivados", + lanesByProfile: "Faixas por perfil", + nudgeDispatcher: "Despertar o dispatcher", + refresh: "Atualizar", + selected: "selecionado(s)", + complete: "Concluir", + archive: "Arquivar", + apply: "Aplicar", + clear: "Limpar", + createTask: "Criar tarefa nesta coluna", + noTasks: "— sem tarefas —", + unassigned: "sem atribuição", + untitled: "(sem título)", + loadingDetail: "A carregar…", + addComment: "Adicionar um comentário… (Enter para submeter)", + comment: "Comentário", + status: "Estado", + workspace: "Espaço de trabalho", + skills: "Competências", + createdBy: "Criado por", + result: "Resultado", + comments: "Comentários", + events: "Eventos", + runHistory: "Histórico de execuções", + workerLog: "Registo do worker", + loadingLog: "A carregar registo…", + noWorkerLog: + "— ainda não há registo do worker (a tarefa não foi iniciada ou o registo foi rotacionado) —", + noDescription: "— sem descrição —", + noComments: "— sem comentários —", + edit: "editar", + save: "Guardar", + dependencies: "Dependências", + parents: "Pais:", + children: "Filhos:", + none: "nenhum", + addParent: "— adicionar pai —", + addChild: "— adicionar filho —", + removeDependency: "Remover dependência", + block: "Bloquear", + unblock: "Desbloquear", + notifyHomeChannels: "Notificar canais principais", + diagnostics: "Diagnósticos", + hide: "Ocultar", + show: "Mostrar", + attention: "Atenção", + tasksNeedAttention: "tarefas precisam de atenção", + taskNeedsAttention: "1 tarefa precisa de atenção", + diagnostic: "diagnóstico", + open: "Abrir", + close: "Fechar (Esc)", + reassignTo: "Reatribuir a:", + copied: "Copiado", + copyCommand: "Copiar comando para a área de transferência", + reclaim: "Reivindicar", + reassign: "Reatribuir", + renderingError: "O separador Kanban encontrou um erro de renderização", + reloadView: "Recarregar vista", + wsAuthFailed: + "Falha de autenticação WebSocket — recarregue a página para atualizar o token de sessão.", + markDone: "Marcar {n} tarefa(s) como concluídas?", + markArchived: "Arquivar {n} tarefa(s)?", + warning: "Aviso", + phantomIds: "Ids fantasma:", + active: "ativo", + ended: "terminado", + noProfile: "(sem perfil)", + showAllAttempts: "Mostrar todas as tentativas", + sendingUpdates: "A enviar atualizações para", + sendNotifications: "Enviar notificações de completed / blocked / gave_up para", + archiveBoardConfirm: + "Arquivar o quadro '{name}'? Será movido para boards/_archived/ para que possa recuperá-lo mais tarde. As tarefas deste quadro deixarão de aparecer em qualquer parte da interface.", + archiveBoardTitle: "Arquivar este quadro", + boardSwitcherHint: "Os quadros permitem-lhe separar fluxos de trabalho não relacionados", + taskCreatedWarning: "Tarefa criada, mas: ", + moveFailed: "Falha ao mover: ", + bulkFailed: "Em lote: ", + completionBlockedHallucination: "⚠ Conclusão bloqueada — ids de cartões fantasma", + suspectedHallucinatedReferences: "⚠ O texto referenciou ids de cartões fantasma", + pickProfileFirst: "Escolha primeiro um perfil.", + unblockedMessage: "{id} desbloqueado. A tarefa está pronta para o próximo tick.", + unblockFailed: "Falha ao desbloquear: ", + reclaimedMessage: "{id} reivindicado. A tarefa voltou a ready.", + reclaimFailed: "Falha ao reivindicar: ", + reassignedMessage: "{id} reatribuído a {profile}.", + reassignFailed: "Falha ao reatribuir: ", + selectForBulk: "Selecionar para ações em lote", + clickToEdit: "Clique para editar", + clickToEditAssignee: "Clique para editar responsável", + emptyAssignee: "(vazio = remover atribuição)", + columnLabels: { + triage: "Triagem", + todo: "A fazer", + ready: "Pronto", + running: "Em curso", + blocked: "Bloqueado", + done: "Concluído", + archived: "Arquivado", + }, + columnHelp: { + triage: "Ideias em bruto — um specifier vai detalhar a especificação", + todo: "À espera de dependências ou sem atribuição", + ready: "Atribuído e à espera de um tick do dispatcher", + running: "Reivindicado por um worker — em execução", + blocked: "O worker pediu intervenção humana", + done: "Concluído", + archived: "Arquivado", + }, + confirmDone: + "Marcar esta tarefa como concluída? A reivindicação do worker é libertada e os filhos dependentes ficam prontos.", + confirmArchive: + "Arquivar esta tarefa? Desaparece da vista padrão do quadro.", + confirmBlocked: + "Marcar esta tarefa como bloqueada? A reivindicação do worker é libertada.", + completionSummary: + "Resumo de conclusão para {label}. Será guardado como o resultado da tarefa.", + completionSummaryRequired: + "É necessário um resumo de conclusão antes de marcar uma tarefa como concluída.", + triagePlaceholder: "Ideia aproximada — a IA irá especificá-la…", + taskTitlePlaceholder: "Título da nova tarefa…", + specifier: "specifier", + assigneePlaceholder: "responsável", + priority: "Prioridade", + skillsPlaceholder: + "competências (opcional, separadas por vírgulas): translation, github-code-review", + noParent: "— sem pai —", + workspacePathDir: "caminho do espaço de trabalho (obrigatório, p. ex. ~/projects/my-app)", + workspacePathOptional: + "caminho do espaço de trabalho (opcional, derivado do responsável se vazio)", + logTruncated: "(a mostrar os últimos 100 KB — registo completo em ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/ru.ts b/apps/dashboard/src/i18n/ru.ts new file mode 100644 index 00000000000..c5b9a5b5038 --- /dev/null +++ b/apps/dashboard/src/i18n/ru.ts @@ -0,0 +1,696 @@ +import type { Translations } from "./types"; + +export const ru: Translations = { + common: { + save: "Сохранить", + saving: "Сохранение...", + cancel: "Отмена", + close: "Закрыть", + confirm: "Подтвердить", + delete: "Удалить", + refresh: "Обновить", + retry: "Повторить", + search: "Поиск...", + loading: "Загрузка...", + create: "Создать", + creating: "Создание...", + set: "Задать", + replace: "Заменить", + clear: "Очистить", + live: "В сети", + off: "Отключено", + enabled: "включено", + disabled: "отключено", + active: "активно", + inactive: "неактивно", + unknown: "неизвестно", + untitled: "Без названия", + none: "Нет", + form: "Форма", + noResults: "Нет результатов", + of: "из", + page: "Страница", + msgs: "сообщ.", + tools: "инструменты", + match: "совпадение", + other: "Прочее", + configured: "настроено", + removed: "удалено", + failedToToggle: "Не удалось переключить", + failedToRemove: "Не удалось удалить", + failedToReveal: "Не удалось показать", + collapse: "Свернуть", + expand: "Развернуть", + general: "Общие", + messaging: "Мессенджеры", + pluginLoadFailed: + "Не удалось загрузить скрипт этого плагина. Проверьте вкладку «Сеть» (dashboard-plugins/…) и путь к плагинам на сервере.", + pluginNotRegistered: + "Скрипт плагина не вызвал register() или завершился с ошибкой. Откройте консоль браузера для подробностей.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "Закрыть навигацию", + closeModelTools: "Закрыть модель и инструменты", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "Активные сессии:", + gatewayStatusLabel: "Статус шлюза:", + gatewayStrip: { + failed: "Ошибка запуска", + off: "Отключён", + running: "Работает", + starting: "Запуск", + stopped: "Остановлен", + }, + nav: { + analytics: "Аналитика", + chat: "Чат", + config: "Конфигурация", + cron: "Cron", + documentation: "Документация", + keys: "Ключи", + logs: "Журналы", + models: "Модели", + profiles: "профили: мульти-агенты", + plugins: "Плагины", + sessions: "Сессии", + skills: "Навыки", + }, + modelToolsSheetSubtitle: "и инструменты", + modelToolsSheetTitle: "Модель", + navigation: "Навигация", + openDocumentation: "Открыть документацию в новой вкладке", + openNavigation: "Открыть навигацию", + pluginNavSection: "Плагины", + sessionsActiveCount: "{count} активн.", + statusOverview: "Обзор статуса", + system: "Система", + webUi: "Web UI", + }, + + status: { + actionFailed: "Ошибка действия", + actionFinished: "Завершено", + actions: "Действия", + agent: "Агент", + activeSessions: "Активные сессии", + connected: "Подключено", + connectedPlatforms: "Подключённые платформы", + disconnected: "Отключено", + error: "Ошибка", + failed: "Сбой", + gateway: "Шлюз", + gatewayFailedToStart: "Шлюзу не удалось запуститься", + lastUpdate: "Последнее обновление", + noneRunning: "Нет", + notRunning: "Не запущено", + pid: "PID", + platformDisconnected: "отключено", + platformError: "ошибка", + recentSessions: "Недавние сессии", + restartGateway: "Перезапустить шлюз", + restartingGateway: "Перезапуск шлюза…", + running: "Работает", + runningRemote: "Работает (удалённо)", + startFailed: "Ошибка запуска", + starting: "Запуск", + startedInBackground: "Запущено в фоне — следите за журналами", + stopped: "Остановлено", + updateHermes: "Обновить Hermes", + updatingHermes: "Обновление Hermes…", + waitingForOutput: "Ожидание вывода…", + }, + + sessions: { + title: "Сессии", + searchPlaceholder: "Поиск по содержимому сообщений...", + noSessions: "Сессий пока нет", + noMatch: "Нет сессий, соответствующих запросу", + startConversation: "Начните разговор, чтобы увидеть его здесь", + noMessages: "Нет сообщений", + untitledSession: "Сессия без названия", + deleteSession: "Удалить сессию", + confirmDeleteTitle: "Удалить сессию?", + confirmDeleteMessage: + "Это безвозвратно удалит разговор и все его сообщения. Действие нельзя отменить.", + sessionDeleted: "Сессия удалена", + failedToDelete: "Не удалось удалить сессию", + resumeInChat: "Продолжить в чате", + previousPage: "Предыдущая страница", + nextPage: "Следующая страница", + roles: { + user: "Пользователь", + assistant: "Ассистент", + system: "Система", + tool: "Инструмент", + }, + }, + + analytics: { + period: "Период:", + totalTokens: "Всего токенов", + totalSessions: "Всего сессий", + apiCalls: "Вызовы API", + dailyTokenUsage: "Расход токенов по дням", + dailyBreakdown: "Разбивка по дням", + perModelBreakdown: "Разбивка по моделям", + topSkills: "Популярные навыки", + skill: "Навык", + loads: "Загружено агентом", + edits: "Управляется агентом", + lastUsed: "Последнее использование", + input: "Ввод", + output: "Вывод", + total: "Итого", + noUsageData: "Нет данных об использовании за этот период", + startSession: "Начните сессию, чтобы увидеть аналитику", + date: "Дата", + model: "Модель", + tokens: "Токены", + perDayAvg: "/день в среднем", + acrossModels: "по {count} моделям", + inOut: "{input} вход / {output} выход", + }, + + models: { + modelsUsed: "Использовано моделей", + estimatedCost: "Оценка стоимости", + tokens: "токены", + sessions: "сессии", + avgPerSession: "ср./сессию", + apiCalls: "вызовы API", + toolCalls: "вызовы инструментов", + noModelsData: "Нет данных по моделям за этот период", + startSession: "Начните сессию, чтобы увидеть данные по моделям", + }, + + logs: { + title: "Журналы", + autoRefresh: "Автообновление", + file: "Файл", + level: "Уровень", + component: "Компонент", + lines: "Строк", + noLogLines: "Записи журнала не найдены", + }, + + cron: { + confirmDeleteMessage: + "Это удалит задачу из расписания. Действие нельзя отменить.", + confirmDeleteTitle: "Удалить запланированную задачу?", + newJob: "Новая Cron-задача", + nameOptional: "Имя (необязательно)", + namePlaceholder: "напр. Ежедневная сводка", + prompt: "Запрос", + promptPlaceholder: "Что должен делать агент при каждом запуске?", + schedule: "Расписание (cron-выражение)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Доставить в", + scheduledJobs: "Запланированные задачи", + noJobs: "Cron-задачи не настроены. Создайте задачу выше.", + last: "Последний", + next: "Следующий", + pause: "Пауза", + resume: "Возобновить", + triggerNow: "Запустить сейчас", + delivery: { + local: "Локально", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "Новый профиль", + name: "Имя", + namePlaceholder: "напр. coder, writer и т.п.", + nameRequired: "Имя обязательно", + nameRule: + "Только строчные буквы, цифры, _ и -; должно начинаться с буквы или цифры; до 64 символов.", + invalidName: "Недопустимое имя профиля", + cloneFromDefault: "Клонировать конфигурацию из профиля по умолчанию", + allProfiles: "Профили", + noProfiles: "Профили не найдены.", + defaultBadge: "по умолчанию", + hasEnv: "env", + model: "Модель", + skills: "Навыки", + rename: "Переименовать", + editSoul: "Редактировать SOUL.md", + soulSection: "SOUL.md (личность / системный промпт)", + soulPlaceholder: "# Как должен вести себя этот агент…", + saveSoul: "Сохранить SOUL", + soulSaved: "SOUL.md сохранён", + openInTerminal: "Скопировать команду CLI", + commandCopied: "Скопировано в буфер обмена", + copyFailed: "Не удалось скопировать", + confirmDeleteTitle: "Удалить профиль?", + confirmDeleteMessage: + "Это безвозвратно удалит профиль '{name}' — конфигурацию, ключи, память, сессии, навыки, cron-задачи. Отменить нельзя.", + created: "Создан", + deleted: "Удалён", + renamed: "Переименован", + }, + + pluginsPage: { + contextEngineLabel: "Движок контекста", + dashboardSlots: "Слоты панели", + disableRuntime: "Отключить", + enableAfterInstall: "Включить после установки", + enableRuntime: "Включить", + forceReinstall: "Принудительная переустановка (сначала удалить существующую папку)", + headline: + "Поиск, установка, включение и обновление плагинов Hermes (аналог `hermes plugins`).", + identifierLabel: "Git URL или owner/repo", + inactive: "неактивно", + installBtn: "Установить из Git", + installHeading: "Установка из GitHub / Git URL", + installHint: "Используйте сокращение owner/repo или полный https:// или git@ URL для клонирования.", + memoryProviderLabel: "Провайдер памяти", + missingEnvWarn: "Задайте эти переменные в разделе «Ключи», прежде чем плагин сможет работать:", + noDashboardTab: "Нет вкладки в панели", + openTab: "Открыть", + orphanHeading: "Расширения только для панели (без соответствующего plugin.yaml агента)", + pluginListHeading: "Установленные плагины", + providerDefaults: "встроенный / по умолчанию", + providersHeading: "Плагины-провайдеры рантайма", + providersHint: + "Записывает memory.provider (пусто = встроенный) и context.engine в config.yaml. Применяется со следующей сессии.", + refreshDashboard: "Пересканировать расширения панели", + removeConfirm: "Удалить этот плагин из ~/.hermes/plugins/?", + removeHint: "Удалять можно только плагины, установленные пользователем в ~/.hermes/plugins.", + rescanHeading: "Реестр SPA-плагинов", + rescanHint: "Пересканируйте после добавления файлов на диск, чтобы боковая панель подхватила новые манифесты.", + runtimeHeading: "Рантайм шлюза (YAML-плагины)", + saveProviders: "Сохранить настройки провайдеров", + savedProviders: "Настройки провайдеров сохранены.", + sourceBadge: "Источник", + authRequired: "Требуется аутентификация", + authRequiredHint: "Выполните эту команду для аутентификации:", + updateGit: "Git pull", + versionBadge: "Версия", + showInSidebar: "Показывать в боковой панели", + hideFromSidebar: "Скрыть из боковой панели", + }, + + skills: { + title: "Навыки", + searchPlaceholder: "Поиск навыков и наборов инструментов...", + enabledOf: "{enabled}/{total} включено", + all: "Все", + categories: "Категории", + filters: "Фильтры", + noSkills: "Навыки не найдены. Навыки загружаются из ~/.hermes/skills/", + noSkillsMatch: "Нет навыков, соответствующих запросу или фильтру.", + skillCount: "{count} навык{s}", + resultCount: "{count} результат{s}", + noDescription: "Описание отсутствует.", + toolsets: "Наборы инструментов", + toolsetLabel: "Набор инструментов {name}", + noToolsetsMatch: "Нет наборов инструментов, соответствующих запросу.", + setupNeeded: "Требуется настройка", + disabledForCli: "Отключено для CLI", + more: "+{count} ещё", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "Фильтры", + sections: "Разделы", + exportConfig: "Экспортировать конфигурацию в JSON", + importConfig: "Импортировать конфигурацию из JSON", + resetDefaults: "Сбросить к значениям по умолчанию", + resetScopeTooltip: "Сбросить {scope} к значениям по умолчанию", + confirmResetScope: "Сбросить все настройки {scope} к значениям по умолчанию? Это обновит только форму — изменения не будут записаны в config.yaml, пока вы не нажмёте «Сохранить».", + resetScopeToast: "{scope} сброшено к значениям по умолчанию — проверьте и сохраните", + rawYaml: "Исходная YAML-конфигурация", + searchResults: "Результаты поиска", + fields: "пол{s}", + noFieldsMatch: 'Нет полей, соответствующих "{query}"', + configSaved: "Конфигурация сохранена", + yamlConfigSaved: "YAML-конфигурация сохранена", + failedToSave: "Не удалось сохранить", + failedToSaveYaml: "Не удалось сохранить YAML", + failedToLoadRaw: "Не удалось загрузить исходную конфигурацию", + configImported: "Конфигурация импортирована — проверьте и сохраните", + invalidJson: "Некорректный JSON-файл", + categories: { + general: "Общие", + agent: "Агент", + terminal: "Терминал", + display: "Отображение", + delegation: "Делегирование", + memory: "Память", + compression: "Сжатие", + security: "Безопасность", + browser: "Браузер", + voice: "Голос", + tts: "Синтез речи", + stt: "Распознавание речи", + logging: "Журналирование", + discord: "Discord", + auxiliary: "Вспомогательные", + }, + }, + + env: { + changesNote: "Изменения сохраняются на диск немедленно. Активные сессии автоматически подхватывают новые ключи.", + confirmClearMessage: + "Сохранённое значение этой переменной будет удалено из вашего файла .env. Это нельзя отменить из интерфейса.", + confirmClearTitle: "Очистить этот ключ?", + description: "Управление API-ключами и секретами, хранящимися в", + hideAdvanced: "Скрыть расширенные", + showAdvanced: "Показать расширенные", + llmProviders: "Провайдеры LLM", + providersConfigured: "Настроено {configured} из {total} провайдеров", + getKey: "Получить ключ", + notConfigured: "{count} не настроено", + notSet: "Не задано", + keysCount: "{count} ключ{s}", + enterValue: "Введите значение...", + replaceCurrentValue: "Заменить текущее значение ({preview})", + showValue: "Показать реальное значение", + hideValue: "Скрыть значение", + }, + + oauth: { + title: "Входы провайдеров (OAuth)", + providerLogins: "Входы провайдеров (OAuth)", + description: "Подключено {connected} из {total} OAuth-провайдеров. Процесс входа в настоящее время выполняется через CLI; нажмите «Скопировать команду» и вставьте в терминал для настройки.", + connected: "Подключено", + expired: "Срок истёк", + notConnected: "Не подключено. Выполните {command} в терминале.", + runInTerminal: "в терминале.", + noProviders: "OAuth-совместимые провайдеры не обнаружены.", + login: "Войти", + disconnect: "Отключить", + managedExternally: "Управляется извне", + copied: "Скопировано ✓", + cli: "CLI", + copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)", + connect: "Подключить", + sessionExpires: "Сессия истечёт через {time}", + initiatingLogin: "Запуск процесса входа…", + exchangingCode: "Обмен кода на токены…", + connectedClosing: "Подключено! Закрытие…", + loginFailed: "Ошибка входа.", + sessionExpired: "Сессия истекла. Нажмите «Повторить» для нового входа.", + reOpenAuth: "Снова открыть страницу авторизации", + reOpenVerification: "Снова открыть страницу подтверждения", + submitCode: "Отправить код", + pasteCode: "Вставьте код авторизации (с суффиксом #state — допустимо)", + waitingAuth: "Ожидание авторизации в браузере…", + enterCodePrompt: "Открыта новая вкладка. Введите этот код, если будет запрошено:", + pkceStep1: "В новой вкладке открыт claude.ai. Войдите и нажмите «Authorize».", + pkceStep2: "Скопируйте код авторизации, отображённый после авторизации.", + pkceStep3: "Вставьте его ниже и отправьте.", + flowLabels: { + pkce: "Вход через браузер (PKCE)", + device_code: "Код устройства", + external: "Внешний CLI", + }, + expiresIn: "истекает через {time}", + }, + + language: { + switchTo: "Переключиться на английский", + }, + + theme: { + title: "Тема", + switchTheme: "Сменить тему", + }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Коллекционные значки Hermes, полученные на основе реальной истории сессий. Известные, но ещё не полученные достижения отображаются как «Обнаруженные»; «Секретные» достижения остаются скрытыми до появления первого подходящего поведения.", + scan_subtitle: + "Анализ истории сессий Hermes. Первое сканирование может занять 5–10 секунд при большой истории.", + }, + actions: { + rescan: "Пересканировать", + }, + stats: { + unlocked: "Разблокировано", + unlocked_hint: "полученные значки", + discovered: "Обнаружено", + discovered_hint: "известные, ещё не получены", + secrets: "Секреты", + secrets_hint: "скрыты до первого сигнала", + highest_tier: "Высший уровень", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "Последнее", + latest_hint_empty: "запускайте Hermes чаще", + none_yet: "Пока нет", + }, + state: { + unlocked: "Разблокировано", + discovered: "Обнаружено", + secret: "Секрет", + }, + tier: { + target: "Цель: {tier}", + hidden: "Скрыто", + complete: "Завершено", + objective: "Задача", + }, + progress: { + hidden: "скрыто", + }, + scan: { + building_headline: "Создание профиля достижений…", + building_detail: + "Чтение сессий, вызовов инструментов, метаданных моделей и состояния разблокировки.", + starting_headline: "Запуск сканирования достижений…", + progress_detail: + "Просканировано {scanned} из {total} сессий · {pct}%. Значки разблокируются по мере поступления истории.", + idle_detail: + "Чтение сессий, вызовов инструментов, метаданных моделей и состояния разблокировки. Значки появляются здесь по мере разблокировки.", + }, + guide: { + tiers_header: "Уровни", + secret_header: "Секретные достижения", + secret_body: + "Секретные достижения скрывают свой точный триггер. Как только Hermes обнаруживает связанный сигнал, карточка становится «Обнаруженной» и показывает требование.", + scan_status_header: "Статус сканирования", + scan_status_body: + "Hermes сканирует локальную историю один раз, затем карточки появятся автоматически. Если это занимает несколько секунд — ничего не зависло.", + what_scanned_header: "Что сканируется", + what_scanned_body: + "Сессии, вызовы инструментов, метаданные моделей, ошибки, достижения и локальное состояние разблокировки.", + }, + card: { + share_title: "Поделиться этим достижением", + share_label: "Поделиться: {name}", + share_text: "Поделиться", + how_to_reveal: "Как открыть", + what_counts: "Что засчитывается", + evidence_label: "Подтверждение", + evidence_session_fallback: "сессия", + no_evidence: "Подтверждений пока нет", + }, + latest: { + header: "Недавние разблокировки", + }, + empty: { + no_secrets_header: "В этом сканировании больше не осталось скрытых секретов.", + no_secrets_body: + "Подсказка: секреты обычно начинаются с необычных ошибок или паттернов опытных пользователей — конфликты портов, ограничения прав, отсутствующие переменные окружения, ошибки YAML, коллизии Docker, использование rollback/checkpoint, попадания в кеш или мелкие исправления после большого количества красного текста.", + }, + filters: { + all_categories: "Все", + visibility_all: "все", + visibility_unlocked: "разблокированные", + visibility_discovered: "обнаруженные", + visibility_secret: "секретные", + }, + share: { + dialog_label: "Поделиться достижением", + header: "Поделиться: {name}", + close: "Закрыть", + rendering: "Отрисовка…", + card_alt: "Карточка для публикации {name}", + error_generic: "Что-то пошло не так.", + x_title: "Открывает X с заранее заполненным постом", + x_button: "Поделиться в X", + copy_title: "Скопировать изображение для вставки в публикацию", + copy_button: "Скопировать изображение", + copied: "Скопировано ✓", + download_button: "Скачать PNG", + hint: + "«Поделиться в X» открывает пост с заранее заполненным текстом в новой вкладке. Сначала нажмите «Скопировать изображение», если хотите прикрепить значок 1200×630 — X позволяет вставить его прямо в редактор твита. «Скачать PNG» сохраняет файл для использования где угодно.", + clipboard_unsupported: + "Копирование изображений в буфер обмена не поддерживается в этом браузере — используйте «Скачать».", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Загрузка доски Kanban…", + loadFailed: "Не удалось загрузить доску Kanban: ", + loadFailedHint: + "Бэкенд автоматически создаёт kanban.db при первом чтении. Если ошибка повторяется, проверьте логи панели.", + board: "Доска", + newBoard: "+ Новая доска", + newBoardTitle: "Новая доска", + newBoardDescription: + "Доски позволяют разделять не связанные между собой потоки работы — по одной на проект, репозиторий или область. Воркеры одной доски никогда не видят задачи другой.", + slug: "Slug", + slugHint: "— строчные буквы, дефисы, например atm10-server", + displayName: "Отображаемое имя", + displayNameHint: "(необязательно)", + description: "Описание", + descriptionHint: "(необязательно)", + icon: "Значок", + iconHint: "(один символ или эмодзи)", + switchAfterCreate: "Переключиться на эту доску после создания", + cancel: "Отмена", + creating: "Создание…", + createBoard: "Создать доску", + search: "Поиск", + filterCards: "Фильтр карточек…", + tenant: "Tenant", + allTenants: "Все tenant'ы", + assignee: "Исполнитель", + allProfiles: "Все профили", + showArchived: "Показать архив", + lanesByProfile: "Дорожки по профилю", + nudgeDispatcher: "Подтолкнуть диспетчер", + refresh: "Обновить", + selected: "выбрано", + complete: "Завершить", + archive: "В архив", + apply: "Применить", + clear: "Очистить", + createTask: "Создать задачу в этой колонке", + noTasks: "— нет задач —", + unassigned: "без исполнителя", + untitled: "(без названия)", + loadingDetail: "Загрузка…", + addComment: "Добавить комментарий… (Enter — отправить)", + comment: "Комментарий", + status: "Статус", + workspace: "Рабочая область", + skills: "Навыки", + createdBy: "Создал", + result: "Результат", + comments: "Комментарии", + events: "События", + runHistory: "История запусков", + workerLog: "Журнал воркера", + loadingLog: "Загрузка журнала…", + noWorkerLog: + "— журнала воркера ещё нет (задача не запускалась или журнал был ротирован) —", + noDescription: "— нет описания —", + noComments: "— нет комментариев —", + edit: "изменить", + save: "Сохранить", + dependencies: "Зависимости", + parents: "Родители:", + children: "Потомки:", + none: "нет", + addParent: "— добавить родителя —", + addChild: "— добавить потомка —", + removeDependency: "Удалить зависимость", + block: "Заблокировать", + unblock: "Разблокировать", + notifyHomeChannels: "Уведомить домашние каналы", + diagnostics: "Диагностика", + hide: "Скрыть", + show: "Показать", + attention: "Внимание", + tasksNeedAttention: "задач(и) требуют внимания", + taskNeedsAttention: "1 задача требует внимания", + diagnostic: "диагностика", + open: "Открыть", + close: "Закрыть (Esc)", + reassignTo: "Переназначить на:", + copied: "Скопировано", + copyCommand: "Скопировать команду в буфер обмена", + reclaim: "Вернуть", + reassign: "Переназначить", + renderingError: "Во вкладке Kanban произошла ошибка отрисовки", + reloadView: "Перезагрузить вид", + wsAuthFailed: + "Сбой аутентификации WebSocket — перезагрузите страницу, чтобы обновить токен сессии.", + markDone: "Отметить {n} задач(и) как выполненные?", + markArchived: "Архивировать {n} задач(и)?", + warning: "Предупреждение", + phantomIds: "Фантомные id:", + active: "активно", + ended: "завершено", + noProfile: "(нет профиля)", + showAllAttempts: "Показать все попытки", + sendingUpdates: "Отправка обновлений в", + sendNotifications: "Отправлять уведомления completed / blocked / gave_up в", + archiveBoardConfirm: + "Архивировать доску '{name}'? Она будет перемещена в boards/_archived/, чтобы её можно было восстановить позже. Задачи этой доски больше не будут отображаться нигде в интерфейсе.", + archiveBoardTitle: "Архивировать эту доску", + boardSwitcherHint: "Доски позволяют разделять не связанные между собой потоки работы", + taskCreatedWarning: "Задача создана, но: ", + moveFailed: "Не удалось переместить: ", + bulkFailed: "Массовая операция: ", + completionBlockedHallucination: "⚠ Завершение заблокировано — фантомные id карточек", + suspectedHallucinatedReferences: "⚠ В тексте упомянуты фантомные id карточек", + pickProfileFirst: "Сначала выберите профиль.", + unblockedMessage: "{id} разблокирована. Задача готова к следующему тику.", + unblockFailed: "Не удалось разблокировать: ", + reclaimedMessage: "{id} возвращена. Задача снова в состоянии ready.", + reclaimFailed: "Не удалось вернуть: ", + reassignedMessage: "{id} переназначена на {profile}.", + reassignFailed: "Не удалось переназначить: ", + selectForBulk: "Выбрать для массовых действий", + clickToEdit: "Нажмите, чтобы изменить", + clickToEditAssignee: "Нажмите, чтобы изменить исполнителя", + emptyAssignee: "(пусто = снять назначение)", + columnLabels: { + triage: "Сортировка", + todo: "К выполнению", + ready: "Готово к работе", + running: "В работе", + blocked: "Заблокировано", + done: "Готово", + archived: "В архиве", + }, + columnHelp: { + triage: "Сырые идеи — specifier подготовит спецификацию", + todo: "Ожидает зависимостей или без исполнителя", + ready: "Назначено и ждёт тика диспетчера", + running: "Взято воркером — выполняется", + blocked: "Воркер запросил вмешательство человека", + done: "Завершено", + archived: "В архиве", + }, + confirmDone: + "Отметить эту задачу как выполненную? Захват воркера будет освобождён, а зависимые потомки станут готовыми.", + confirmArchive: + "Архивировать эту задачу? Она исчезнет из стандартного вида доски.", + confirmBlocked: + "Отметить эту задачу как заблокированную? Захват воркера будет освобождён.", + completionSummary: + "Сводка завершения для {label}. Сохраняется как результат задачи.", + completionSummaryRequired: + "Перед отметкой задачи как выполненной требуется сводка завершения.", + triagePlaceholder: "Черновая идея — ИИ её проспецифицирует…", + taskTitlePlaceholder: "Название новой задачи…", + specifier: "specifier", + assigneePlaceholder: "исполнитель", + priority: "Приоритет", + skillsPlaceholder: + "навыки (необязательно, через запятую): translation, github-code-review", + noParent: "— без родителя —", + workspacePathDir: "путь к рабочей области (обязательно, например ~/projects/my-app)", + workspacePathOptional: + "путь к рабочей области (необязательно, выводится из исполнителя, если не указан)", + logTruncated: "(показаны последние 100 KB — полный журнал в ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/tr.ts b/apps/dashboard/src/i18n/tr.ts new file mode 100644 index 00000000000..7de6ea1df7d --- /dev/null +++ b/apps/dashboard/src/i18n/tr.ts @@ -0,0 +1,696 @@ +import type { Translations } from "./types"; + +export const tr: Translations = { + common: { + save: "Kaydet", + saving: "Kaydediliyor...", + cancel: "İptal", + close: "Kapat", + confirm: "Onayla", + delete: "Sil", + refresh: "Yenile", + retry: "Yeniden dene", + search: "Ara...", + loading: "Yükleniyor...", + create: "Oluştur", + creating: "Oluşturuluyor...", + set: "Ayarla", + replace: "Değiştir", + clear: "Temizle", + live: "Canlı", + off: "Kapalı", + enabled: "etkin", + disabled: "devre dışı", + active: "aktif", + inactive: "pasif", + unknown: "bilinmiyor", + untitled: "Başlıksız", + none: "Yok", + form: "Form", + noResults: "Sonuç yok", + of: "/", + page: "Sayfa", + msgs: "mesaj", + tools: "araçlar", + match: "eşleşme", + other: "Diğer", + configured: "yapılandırıldı", + removed: "kaldırıldı", + failedToToggle: "Değiştirilemedi", + failedToRemove: "Kaldırılamadı", + failedToReveal: "Gösterilemedi", + collapse: "Daralt", + expand: "Genişlet", + general: "Genel", + messaging: "Mesajlaşma", + pluginLoadFailed: + "Bu eklentinin betiği yüklenemedi. Ağ sekmesini (dashboard-plugins/…) ve sunucunun eklenti yolunu kontrol edin.", + pluginNotRegistered: + "Eklenti betiği register() çağırmadı veya betik hata verdi. Ayrıntılar için tarayıcı konsolunu açın.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "Gezintiyi kapat", + closeModelTools: "Modeli ve araçları kapat", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "Aktif Oturumlar:", + gatewayStatusLabel: "Ağ Geçidi Durumu:", + gatewayStrip: { + failed: "Başlatma başarısız", + off: "Kapalı", + running: "Çalışıyor", + starting: "Başlatılıyor", + stopped: "Durduruldu", + }, + nav: { + analytics: "Analiz", + chat: "Sohbet", + config: "Yapılandırma", + cron: "Cron", + documentation: "Dokümantasyon", + keys: "Anahtarlar", + logs: "Günlükler", + models: "Modeller", + profiles: "profiller : çoklu agent", + plugins: "Eklentiler", + sessions: "Oturumlar", + skills: "Yetenekler", + }, + modelToolsSheetSubtitle: "& araçlar", + modelToolsSheetTitle: "Model", + navigation: "Gezinti", + openDocumentation: "Dokümantasyonu yeni sekmede aç", + openNavigation: "Gezintiyi aç", + pluginNavSection: "Eklentiler", + sessionsActiveCount: "{count} aktif", + statusOverview: "Durum özeti", + system: "Sistem", + webUi: "Web UI", + }, + + status: { + actionFailed: "İşlem başarısız", + actionFinished: "Tamamlandı", + actions: "İşlemler", + agent: "Agent", + activeSessions: "Aktif Oturumlar", + connected: "Bağlandı", + connectedPlatforms: "Bağlı Platformlar", + disconnected: "Bağlantı kesildi", + error: "Hata", + failed: "Başarısız", + gateway: "Ağ Geçidi", + gatewayFailedToStart: "Ağ geçidi başlatılamadı", + lastUpdate: "Son güncelleme", + noneRunning: "Yok", + notRunning: "Çalışmıyor", + pid: "PID", + platformDisconnected: "bağlantı kesildi", + platformError: "hata", + recentSessions: "Son Oturumlar", + restartGateway: "Ağ Geçidini Yeniden Başlat", + restartingGateway: "Ağ geçidi yeniden başlatılıyor…", + running: "Çalışıyor", + runningRemote: "Çalışıyor (uzak)", + startFailed: "Başlatma başarısız", + starting: "Başlatılıyor", + startedInBackground: "Arka planda başlatıldı — ilerleme için günlüklere bakın", + stopped: "Durduruldu", + updateHermes: "Hermes'i Güncelle", + updatingHermes: "Hermes güncelleniyor…", + waitingForOutput: "Çıktı bekleniyor…", + }, + + sessions: { + title: "Oturumlar", + searchPlaceholder: "Mesaj içeriğinde ara...", + noSessions: "Henüz oturum yok", + noMatch: "Aramanızla eşleşen oturum yok", + startConversation: "Burada görmek için bir konuşma başlatın", + noMessages: "Mesaj yok", + untitledSession: "Başlıksız oturum", + deleteSession: "Oturumu sil", + confirmDeleteTitle: "Oturum silinsin mi?", + confirmDeleteMessage: + "Bu, konuşmayı ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.", + sessionDeleted: "Oturum silindi", + failedToDelete: "Oturum silinemedi", + resumeInChat: "Sohbette Devam Et", + previousPage: "Önceki sayfa", + nextPage: "Sonraki sayfa", + roles: { + user: "Kullanıcı", + assistant: "Asistan", + system: "Sistem", + tool: "Araç", + }, + }, + + analytics: { + period: "Dönem:", + totalTokens: "Toplam Token", + totalSessions: "Toplam Oturum", + apiCalls: "API Çağrıları", + dailyTokenUsage: "Günlük Token Kullanımı", + dailyBreakdown: "Günlük Dağılım", + perModelBreakdown: "Model Bazında Dağılım", + topSkills: "En Çok Kullanılan Yetenekler", + skill: "Yetenek", + loads: "Agent Yüklendi", + edits: "Agent Yönetildi", + lastUsed: "Son Kullanım", + input: "Giriş", + output: "Çıkış", + total: "Toplam", + noUsageData: "Bu dönem için kullanım verisi yok", + startSession: "Burada analizleri görmek için bir oturum başlatın", + date: "Tarih", + model: "Model", + tokens: "Token", + perDayAvg: "/gün ort", + acrossModels: "{count} model üzerinden", + inOut: "{input} giriş / {output} çıkış", + }, + + models: { + modelsUsed: "Kullanılan Modeller", + estimatedCost: "Tahmini Maliyet", + tokens: "token", + sessions: "oturum", + avgPerSession: "ort/oturum", + apiCalls: "API çağrıları", + toolCalls: "araç çağrıları", + noModelsData: "Bu dönem için model kullanım verisi yok", + startSession: "Burada model verilerini görmek için bir oturum başlatın", + }, + + logs: { + title: "Günlükler", + autoRefresh: "Otomatik yenile", + file: "Dosya", + level: "Seviye", + component: "Bileşen", + lines: "Satırlar", + noLogLines: "Günlük satırı bulunamadı", + }, + + cron: { + confirmDeleteMessage: + "Bu, görevi zamanlamadan kaldırır. Bu işlem geri alınamaz.", + confirmDeleteTitle: "Zamanlanmış görev silinsin mi?", + newJob: "Yeni Cron Görevi", + nameOptional: "Ad (isteğe bağlı)", + namePlaceholder: "örn. Günlük özet", + prompt: "İstem", + promptPlaceholder: "Agent her çalıştırmada ne yapmalı?", + schedule: "Zamanlama (cron ifadesi)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Şuraya teslim et", + scheduledJobs: "Zamanlanmış Görevler", + noJobs: "Yapılandırılmış cron görevi yok. Yukarıdan bir tane oluşturun.", + last: "Son", + next: "Sonraki", + pause: "Duraklat", + resume: "Devam ettir", + triggerNow: "Şimdi tetikle", + delivery: { + local: "Yerel", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "Yeni Profil", + name: "Ad", + namePlaceholder: "örn. coder, writer, vb.", + nameRequired: "Ad gereklidir", + nameRule: + "Yalnızca küçük harfler, rakamlar, _ ve - kullanılabilir; harf veya rakamla başlamalı; en fazla 64 karakter.", + invalidName: "Geçersiz profil adı", + cloneFromDefault: "Varsayılan profilden yapılandırmayı klonla", + allProfiles: "Profiller", + noProfiles: "Profil bulunamadı.", + defaultBadge: "varsayılan", + hasEnv: "env", + model: "Model", + skills: "Yetenekler", + rename: "Yeniden adlandır", + editSoul: "SOUL.md'yi düzenle", + soulSection: "SOUL.md (kişilik / sistem istemi)", + soulPlaceholder: "# Bu agent nasıl davranmalı…", + saveSoul: "SOUL'u kaydet", + soulSaved: "SOUL.md kaydedildi", + openInTerminal: "CLI komutunu kopyala", + commandCopied: "Panoya kopyalandı", + copyFailed: "Kopyalanamadı", + confirmDeleteTitle: "Profil silinsin mi?", + confirmDeleteMessage: + "Bu, '{name}' profilini kalıcı olarak siler — yapılandırma, anahtarlar, hatıralar, oturumlar, yetenekler, cron görevleri. Geri alınamaz.", + created: "Oluşturuldu", + deleted: "Silindi", + renamed: "Yeniden adlandırıldı", + }, + + pluginsPage: { + contextEngineLabel: "Bağlam motoru", + dashboardSlots: "Pano yuvaları", + disableRuntime: "Devre dışı bırak", + enableAfterInstall: "Yüklemeden sonra etkinleştir", + enableRuntime: "Etkinleştir", + forceReinstall: "Yeniden yüklemeyi zorla (önce mevcut klasörü sil)", + headline: + "Hermes eklentilerini keşfedin, yükleyin, etkinleştirin ve güncelleyin (`hermes plugins` ile eşdeğer).", + identifierLabel: "Git URL veya owner/repo", + inactive: "pasif", + installBtn: "Git'ten yükle", + installHeading: "GitHub / Git URL'sinden yükle", + installHint: "owner/repo kısayolunu veya tam https:// ya da git@ klon URL'sini kullanın.", + memoryProviderLabel: "Bellek sağlayıcısı", + missingEnvWarn: "Eklenti çalışmadan önce bunları Anahtarlar bölümünde ayarlayın:", + noDashboardTab: "Pano sekmesi yok", + openTab: "Aç", + orphanHeading: "Yalnızca pano uzantıları (eşleşen agent plugin.yaml yok)", + pluginListHeading: "Yüklü eklentiler", + providerDefaults: "yerleşik / varsayılan", + providersHeading: "Çalışma zamanı sağlayıcı eklentileri", + providersHint: + "config.yaml'a memory.provider (boş = yerleşik) ve context.engine yazar. Bir sonraki oturumda etkili olur.", + refreshDashboard: "Pano uzantılarını yeniden tara", + removeConfirm: "Bu eklenti ~/.hermes/plugins/ içinden kaldırılsın mı?", + removeHint: "Yalnızca ~/.hermes/plugins altındaki kullanıcı tarafından yüklenmiş eklentiler kaldırılabilir.", + rescanHeading: "SPA eklenti kayıt defteri", + rescanHint: "Diske dosya ekledikten sonra yeniden tarayın, böylece pano kenar çubuğu yeni manifestleri algılar.", + runtimeHeading: "Ağ geçidi çalışma zamanı (YAML eklentileri)", + saveProviders: "Sağlayıcı ayarlarını kaydet", + savedProviders: "Sağlayıcı ayarları kaydedildi.", + sourceBadge: "Kaynak", + authRequired: "Kimlik doğrulama gerekli", + authRequiredHint: "Kimlik doğrulamak için bu komutu çalıştırın:", + updateGit: "Git pull", + versionBadge: "Sürüm", + showInSidebar: "Kenar çubuğunda göster", + hideFromSidebar: "Kenar çubuğundan gizle", + }, + + skills: { + title: "Yetenekler", + searchPlaceholder: "Yetenek ve araç setlerinde ara...", + enabledOf: "{enabled}/{total} etkin", + all: "Tümü", + categories: "Kategoriler", + filters: "Filtreler", + noSkills: "Yetenek bulunamadı. Yetenekler ~/.hermes/skills/ adresinden yüklenir", + noSkillsMatch: "Aramanız veya filtrenizle eşleşen yetenek yok.", + skillCount: "{count} yetenek{s}", + resultCount: "{count} sonuç{s}", + noDescription: "Açıklama mevcut değil.", + toolsets: "Araç setleri", + toolsetLabel: "{name} araç seti", + noToolsetsMatch: "Aramayla eşleşen araç seti yok.", + setupNeeded: "Kurulum gerekli", + disabledForCli: "CLI için devre dışı", + more: "+{count} daha", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "Filtreler", + sections: "Bölümler", + exportConfig: "Yapılandırmayı JSON olarak dışa aktar", + importConfig: "Yapılandırmayı JSON'dan içe aktar", + resetDefaults: "Varsayılanlara sıfırla", + resetScopeTooltip: "{scope} varsayılanlara sıfırla", + confirmResetScope: "{scope} ayarlarının tümü varsayılanlara sıfırlansın mı? Bu yalnızca formu günceller — değişiklikler Kaydet'e basılana kadar config.yaml'a yazılmaz.", + resetScopeToast: "{scope} varsayılanlara sıfırlandı — gözden geçirip kalıcı kılmak için Kaydet'e basın", + rawYaml: "Ham YAML Yapılandırması", + searchResults: "Arama Sonuçları", + fields: "alan{s}", + noFieldsMatch: '"{query}" ile eşleşen alan yok', + configSaved: "Yapılandırma kaydedildi", + yamlConfigSaved: "YAML yapılandırması kaydedildi", + failedToSave: "Kaydedilemedi", + failedToSaveYaml: "YAML kaydedilemedi", + failedToLoadRaw: "Ham yapılandırma yüklenemedi", + configImported: "Yapılandırma içe aktarıldı — gözden geçirip kaydedin", + invalidJson: "Geçersiz JSON dosyası", + categories: { + general: "Genel", + agent: "Agent", + terminal: "Terminal", + display: "Görüntü", + delegation: "Yetkilendirme", + memory: "Bellek", + compression: "Sıkıştırma", + security: "Güvenlik", + browser: "Tarayıcı", + voice: "Ses", + tts: "Metinden Konuşmaya", + stt: "Konuşmadan Metne", + logging: "Günlükleme", + discord: "Discord", + auxiliary: "Yardımcı", + }, + }, + + env: { + changesNote: "Değişiklikler diske hemen kaydedilir. Aktif oturumlar yeni anahtarları otomatik olarak alır.", + confirmClearMessage: + "Bu değişken için saklanan değer .env dosyanızdan kaldırılacak. Bu işlem arayüzden geri alınamaz.", + confirmClearTitle: "Bu anahtar temizlensin mi?", + description: "Şurada saklanan API anahtarlarını ve sırları yönetin", + hideAdvanced: "Gelişmişi Gizle", + showAdvanced: "Gelişmişi Göster", + llmProviders: "LLM Sağlayıcıları", + providersConfigured: "{configured}/{total} sağlayıcı yapılandırıldı", + getKey: "Anahtar al", + notConfigured: "{count} yapılandırılmamış", + notSet: "Ayarlanmadı", + keysCount: "{count} anahtar", + enterValue: "Değer girin...", + replaceCurrentValue: "Mevcut değeri değiştir ({preview})", + showValue: "Gerçek değeri göster", + hideValue: "Değeri gizle", + }, + + oauth: { + title: "Sağlayıcı Girişleri (OAuth)", + providerLogins: "Sağlayıcı Girişleri (OAuth)", + description: "{connected}/{total} OAuth sağlayıcısı bağlandı. Giriş akışları şu anda CLI üzerinden çalışır; Komutu kopyala'ya tıklayın ve kurmak için bir terminale yapıştırın.", + connected: "Bağlandı", + expired: "Süresi doldu", + notConnected: "Bağlı değil. Bir terminalde {command} komutunu çalıştırın.", + runInTerminal: "bir terminalde.", + noProviders: "OAuth uyumlu sağlayıcı algılanmadı.", + login: "Giriş", + disconnect: "Bağlantıyı kes", + managedExternally: "Harici olarak yönetiliyor", + copied: "Kopyalandı ✓", + cli: "CLI", + copyCliCommand: "CLI komutunu kopyala (harici / yedek için)", + connect: "Bağlan", + sessionExpires: "Oturumun süresi {time} sonra dolacak", + initiatingLogin: "Giriş akışı başlatılıyor…", + exchangingCode: "Kod, jetonlarla değiştiriliyor…", + connectedClosing: "Bağlandı! Kapatılıyor…", + loginFailed: "Giriş başarısız.", + sessionExpired: "Oturum süresi doldu. Yeni bir giriş başlatmak için Yeniden Dene'ye tıklayın.", + reOpenAuth: "Kimlik doğrulama sayfasını yeniden aç", + reOpenVerification: "Doğrulama sayfasını yeniden aç", + submitCode: "Kodu gönder", + pasteCode: "Yetkilendirme kodunu yapıştırın (#state ekiyle de olabilir)", + waitingAuth: "Tarayıcıda yetkilendirmeniz bekleniyor…", + enterCodePrompt: "Yeni bir sekme açıldı. İstenirse bu kodu girin:", + pkceStep1: "claude.ai için yeni bir sekme açıldı. Giriş yapın ve Yetkilendir'e tıklayın.", + pkceStep2: "Yetkilendirmeden sonra gösterilen yetkilendirme kodunu kopyalayın.", + pkceStep3: "Aşağıya yapıştırıp gönderin.", + flowLabels: { + pkce: "Tarayıcı girişi (PKCE)", + device_code: "Cihaz kodu", + external: "Harici CLI", + }, + expiresIn: "{time} sonra sona erer", + }, + + language: { + switchTo: "İngilizce'ye geç", + }, + + theme: { + title: "Tema", + switchTheme: "Temayı değiştir", + }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Gerçek oturum geçmişinden kazanılan, koleksiyonluk Hermes rozetleri. Bilinen ama henüz tamamlanmamış başarılar Keşfedildi olarak gösterilir; Gizli başarılar ilk eşleşen davranış görünene kadar saklı kalır.", + scan_subtitle: + "Hermes oturum geçmişi taranıyor. Büyük geçmişlerde ilk tarama 5–10 saniye sürebilir.", + }, + actions: { + rescan: "Yeniden tara", + }, + stats: { + unlocked: "Açıldı", + unlocked_hint: "kazanılan rozetler", + discovered: "Keşfedildi", + discovered_hint: "biliniyor, henüz kazanılmadı", + secrets: "Sırlar", + secrets_hint: "ilk sinyale kadar gizli", + highest_tier: "En yüksek kademe", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "En son", + latest_hint_empty: "Hermes'i daha çok çalıştır", + none_yet: "Henüz yok", + }, + state: { + unlocked: "Açıldı", + discovered: "Keşfedildi", + secret: "Gizli", + }, + tier: { + target: "Hedef {tier}", + hidden: "Gizli", + complete: "Tamamlandı", + objective: "Amaç", + }, + progress: { + hidden: "gizli", + }, + scan: { + building_headline: "Başarı profili oluşturuluyor…", + building_detail: + "Oturumlar, araç çağrıları, model meta verileri ve açılma durumu okunuyor.", + starting_headline: "Başarı taraması başlatılıyor…", + progress_detail: + "{total} oturumun {scanned} tanesi tarandı · %{pct}. Daha fazla geçmiş aktıkça rozetler açılır.", + idle_detail: + "Oturumlar, araç çağrıları, model meta verileri ve açılma durumu okunuyor. Rozetler açıldıkça burada görünür.", + }, + guide: { + tiers_header: "Kademeler", + secret_header: "Gizli başarılar", + secret_body: + "Sırlar, tetikleyicilerini saklı tutar. Hermes ilgili bir sinyal gördüğünde kart Keşfedildi durumuna geçer ve gereksinimini gösterir.", + scan_status_header: "Tarama durumu", + scan_status_body: + "Hermes yerel geçmişi bir kez tarıyor; sonra kartlar otomatik olarak görünür. Birkaç saniye sürmesi normaldir, hiçbir şey takılmadı.", + what_scanned_header: "Neler taranır", + what_scanned_body: + "Oturumlar, araç çağrıları, model meta verileri, hatalar, başarılar ve yerel açılma durumu.", + }, + card: { + share_title: "Bu başarıyı paylaş", + share_label: "{name} paylaş", + share_text: "Paylaş", + how_to_reveal: "Nasıl ortaya çıkarılır", + what_counts: "Neler sayılır", + evidence_label: "Kanıt", + evidence_session_fallback: "oturum", + no_evidence: "Henüz kanıt yok", + }, + latest: { + header: "Son açılanlar", + }, + empty: { + no_secrets_header: "Bu taramada gizli sır kalmadı.", + no_secrets_body: + "İpucu: sırlar genellikle alışılmadık hata veya ileri kullanıcı kalıplarıyla başlar — port çakışmaları, izin duvarları, eksik ortam değişkenleri, YAML hataları, Docker çakışmaları, geri alma/checkpoint kullanımı, önbellek isabetleri ya da çokça kırmızı yazıdan sonra yapılan ufak düzeltmeler.", + }, + filters: { + all_categories: "Tümü", + visibility_all: "tümü", + visibility_unlocked: "açıldı", + visibility_discovered: "keşfedildi", + visibility_secret: "gizli", + }, + share: { + dialog_label: "Başarıyı paylaş", + header: "Paylaş: {name}", + close: "Kapat", + rendering: "Oluşturuluyor…", + card_alt: "{name} paylaşım kartı", + error_generic: "Bir şeyler ters gitti.", + x_title: "X'i önceden doldurulmuş bir gönderiyle açar", + x_button: "X'te paylaş", + copy_title: "Görseli kopyalayıp gönderine yapıştır", + copy_button: "Görseli kopyala", + copied: "Kopyalandı ✓", + download_button: "PNG indir", + hint: + "X'te paylaş, yeni sekmede önceden doldurulmuş bir gönderi açar. 1200×630 rozetin eklenmesini istiyorsan önce Görseli kopyala'ya tıkla — X, görseli doğrudan tweet düzenleyiciye yapıştırmana izin verir. PNG indir, dosyayı her yerde kullanmak üzere kaydeder.", + clipboard_unsupported: + "Bu tarayıcıda panoya görsel kopyalama desteklenmiyor — bunun yerine İndir'i kullanın.", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Kanban panosu yükleniyor…", + loadFailed: "Kanban panosu yüklenemedi: ", + loadFailedHint: + "Backend, ilk okumada kanban.db'yi otomatik olarak oluşturur. Sorun devam ederse panel günlüklerini kontrol edin.", + board: "Pano", + newBoard: "+ Yeni pano", + newBoardTitle: "Yeni pano", + newBoardDescription: + "Panolar, ilgisiz iş akışlarını ayırmanızı sağlar — proje, depo veya alan başına bir pano. Bir panodaki worker'lar başka bir panonun görevlerini asla görmez.", + slug: "Slug", + slugHint: "— küçük harf, tire, ör. atm10-server", + displayName: "Görünen ad", + displayNameHint: "(isteğe bağlı)", + description: "Açıklama", + descriptionHint: "(isteğe bağlı)", + icon: "Simge", + iconHint: "(tek karakter veya emoji)", + switchAfterCreate: "Oluşturduktan sonra bu panoya geç", + cancel: "İptal", + creating: "Oluşturuluyor…", + createBoard: "Pano oluştur", + search: "Ara", + filterCards: "Kartları filtrele…", + tenant: "Tenant", + allTenants: "Tüm tenant'lar", + assignee: "Atanan kişi", + allProfiles: "Tüm profiller", + showArchived: "Arşivlenenleri göster", + lanesByProfile: "Profile göre şeritler", + nudgeDispatcher: "Dispatcher'ı dürt", + refresh: "Yenile", + selected: "seçili", + complete: "Tamamla", + archive: "Arşivle", + apply: "Uygula", + clear: "Temizle", + createTask: "Bu sütunda görev oluştur", + noTasks: "— görev yok —", + unassigned: "atanmamış", + untitled: "(başlıksız)", + loadingDetail: "Yükleniyor…", + addComment: "Yorum ekle… (göndermek için Enter)", + comment: "Yorum", + status: "Durum", + workspace: "Workspace", + skills: "Beceriler", + createdBy: "Oluşturan", + result: "Result", + comments: "Yorumlar", + events: "Olaylar", + runHistory: "Çalıştırma geçmişi", + workerLog: "Worker günlüğü", + loadingLog: "Günlük yükleniyor…", + noWorkerLog: + "— henüz worker günlüğü yok (görev başlatılmadı veya günlük döndürüldü) —", + noDescription: "— açıklama yok —", + noComments: "— yorum yok —", + edit: "düzenle", + save: "Kaydet", + dependencies: "Bağımlılıklar", + parents: "Üstler:", + children: "Altlar:", + none: "yok", + addParent: "— üst ekle —", + addChild: "— alt ekle —", + removeDependency: "Bağımlılığı kaldır", + block: "Engelle", + unblock: "Engeli kaldır", + notifyHomeChannels: "Ana kanalları bilgilendir", + diagnostics: "Tanılama", + hide: "Gizle", + show: "Göster", + attention: "Dikkat", + tasksNeedAttention: "görev dikkat gerektiriyor", + taskNeedsAttention: "1 görev dikkat gerektiriyor", + diagnostic: "tanılama", + open: "Aç", + close: "Kapat (Esc)", + reassignTo: "Yeniden ata:", + copied: "Kopyalandı", + copyCommand: "Komutu panoya kopyala", + reclaim: "Geri al", + reassign: "Yeniden ata", + renderingError: "Kanban sekmesinde bir oluşturma hatası oluştu", + reloadView: "Görünümü yeniden yükle", + wsAuthFailed: + "WebSocket kimlik doğrulaması başarısız — oturum jetonunu yenilemek için sayfayı yeniden yükleyin.", + markDone: "{n} görev tamamlandı olarak işaretlensin mi?", + markArchived: "{n} görev arşivlensin mi?", + warning: "Uyarı", + phantomIds: "Hayalet ID'ler:", + active: "etkin", + ended: "sona erdi", + noProfile: "(profil yok)", + showAllAttempts: "Tüm denemeleri göster", + sendingUpdates: "Güncellemeler şuraya gönderiliyor", + sendNotifications: "completed / blocked / gave_up bildirimlerini şuraya gönder", + archiveBoardConfirm: + "'{name}' panosu arşivlensin mi? boards/_archived/ dizinine taşınacak, böylece daha sonra kurtarabilirsiniz. Bu panodaki görevler artık UI'nin hiçbir yerinde görünmeyecek.", + archiveBoardTitle: "Bu panoyu arşivle", + boardSwitcherHint: "Panolar, ilgisiz iş akışlarını ayırmanızı sağlar", + taskCreatedWarning: "Görev oluşturuldu, ancak: ", + moveFailed: "Taşıma başarısız: ", + bulkFailed: "Toplu: ", + completionBlockedHallucination: "⚠ Tamamlanma engellendi — hayalet kart ID'leri", + suspectedHallucinatedReferences: "⚠ Metin hayalet kart ID'lerine atıfta bulundu", + pickProfileFirst: "Önce bir profil seçin.", + unblockedMessage: "{id} engeli kaldırıldı. Görev sonraki tick için hazır.", + unblockFailed: "Engel kaldırma başarısız: ", + reclaimedMessage: "{id} geri alındı. Görev tekrar hazır.", + reclaimFailed: "Geri alma başarısız: ", + reassignedMessage: "{id}, {profile} kişisine yeniden atandı.", + reassignFailed: "Yeniden atama başarısız: ", + selectForBulk: "Toplu işlemler için seç", + clickToEdit: "Düzenlemek için tıklayın", + clickToEditAssignee: "Atanan kişiyi düzenlemek için tıklayın", + emptyAssignee: "(boş = atamayı kaldır)", + columnLabels: { + triage: "Triyaj", + todo: "Yapılacak", + ready: "Hazır", + running: "Sürüyor", + blocked: "Engellendi", + done: "Bitti", + archived: "Arşivlendi", + }, + columnHelp: { + triage: "Ham fikirler — bir specifier şartnameyi detaylandıracak", + todo: "Bağımlılıklar bekleniyor veya atanmamış", + ready: "Atanmış ve dispatcher tick'i bekleniyor", + running: "Bir worker tarafından alındı — yürütülüyor", + blocked: "Worker insan girdisi istedi", + done: "Tamamlandı", + archived: "Arşivlendi", + }, + confirmDone: + "Bu görev tamamlandı olarak işaretlensin mi? Worker'ın sahiplenmesi serbest bırakılır ve bağımlı altlar hazır hale gelir.", + confirmArchive: + "Bu görev arşivlensin mi? Varsayılan pano görünümünden kaybolur.", + confirmBlocked: + "Bu görev engellendi olarak işaretlensin mi? Worker'ın sahiplenmesi serbest bırakılır.", + completionSummary: + "{label} için tamamlanma özeti. Görev result'ı olarak saklanır.", + completionSummaryRequired: + "Bir görevi tamamlandı olarak işaretlemeden önce tamamlanma özeti gereklidir.", + triagePlaceholder: "Kabataslak fikir — yapay zeka şartnameyi yazacak…", + taskTitlePlaceholder: "Yeni görev başlığı…", + specifier: "specifier", + assigneePlaceholder: "atanan", + priority: "Öncelik", + skillsPlaceholder: + "beceriler (isteğe bağlı, virgülle ayrılmış): translation, github-code-review", + noParent: "— üst yok —", + workspacePathDir: "workspace yolu (zorunlu, ör. ~/projects/my-app)", + workspacePathOptional: + "workspace yolu (isteğe bağlı, boşsa atanan kişiden türetilir)", + logTruncated: "(son 100 KB gösteriliyor — tam günlük şurada: ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/types.ts b/apps/dashboard/src/i18n/types.ts index d93260d26d7..ca40b4a381f 100644 --- a/apps/dashboard/src/i18n/types.ts +++ b/apps/dashboard/src/i18n/types.ts @@ -1,4 +1,20 @@ -export type Locale = "en" | "zh"; +export type Locale = + | "en" + | "zh" + | "zh-hant" + | "ja" + | "de" + | "es" + | "fr" + | "tr" + | "uk" + | "af" + | "ko" + | "it" + | "ga" + | "pt" + | "ru" + | "hu"; export interface Translations { // ── Common ── @@ -433,4 +449,251 @@ export interface Translations { title: string; switchTheme: string; }; + + // ── Achievements plugin (plugins/hermes-achievements) ── + achievements: { + hero: { + kicker: string; + title: string; + subtitle: string; + scan_subtitle: string; + }; + actions: { + rescan: string; + }; + stats: { + unlocked: string; + unlocked_hint: string; + discovered: string; + discovered_hint: string; + secrets: string; + secrets_hint: string; + highest_tier: string; + highest_tier_hint: string; + latest: string; + latest_hint_empty: string; + none_yet: string; + }; + state: { + unlocked: string; + discovered: string; + secret: string; + }; + tier: { + target: string; + hidden: string; + complete: string; + objective: string; + }; + progress: { + hidden: string; + }; + scan: { + building_headline: string; + building_detail: string; + starting_headline: string; + progress_detail: string; + idle_detail: string; + }; + guide: { + tiers_header: string; + secret_header: string; + secret_body: string; + scan_status_header: string; + scan_status_body: string; + what_scanned_header: string; + what_scanned_body: string; + }; + card: { + share_title: string; + share_label: string; + share_text: string; + how_to_reveal: string; + what_counts: string; + evidence_label: string; + evidence_session_fallback: string; + no_evidence: string; + }; + latest: { + header: string; + }; + empty: { + no_secrets_header: string; + no_secrets_body: string; + }; + filters: { + all_categories: string; + visibility_all: string; + visibility_unlocked: string; + visibility_discovered: string; + visibility_secret: string; + }; + share: { + dialog_label: string; + header: string; + close: string; + rendering: string; + card_alt: string; + error_generic: string; + x_title: string; + x_button: string; + copy_title: string; + copy_button: string; + copied: string; + download_button: string; + hint: string; + clipboard_unsupported: string; + tweet_text: string; + }; + }; + + // ── Kanban ── + kanban: { + loading: string; + loadFailed: string; + loadFailedHint: string; + board: string; + newBoard: string; + newBoardTitle: string; + newBoardDescription: string; + slug: string; + slugHint: string; + displayName: string; + displayNameHint: string; + description: string; + descriptionHint: string; + icon: string; + iconHint: string; + switchAfterCreate: string; + cancel: string; + creating: string; + createBoard: string; + search: string; + filterCards: string; + tenant: string; + allTenants: string; + assignee: string; + allProfiles: string; + showArchived: string; + lanesByProfile: string; + nudgeDispatcher: string; + refresh: string; + selected: string; + complete: string; + archive: string; + apply: string; + clear: string; + createTask: string; + noTasks: string; + unassigned: string; + untitled: string; + loadingDetail: string; + addComment: string; + comment: string; + status: string; + workspace: string; + skills: string; + createdBy: string; + result: string; + comments: string; + events: string; + runHistory: string; + workerLog: string; + loadingLog: string; + noWorkerLog: string; + noDescription: string; + noComments: string; + edit: string; + save: string; + dependencies: string; + parents: string; + children: string; + none: string; + addParent: string; + addChild: string; + removeDependency: string; + block: string; + unblock: string; + notifyHomeChannels: string; + diagnostics: string; + hide: string; + show: string; + attention: string; + tasksNeedAttention: string; + taskNeedsAttention: string; + diagnostic: string; + open: string; + close: string; + reassignTo: string; + copied: string; + copyCommand: string; + reclaim: string; + reassign: string; + renderingError: string; + reloadView: string; + wsAuthFailed: string; + markDone: string; + markArchived: string; + warning: string; + phantomIds: string; + active: string; + ended: string; + noProfile: string; + showAllAttempts: string; + sendingUpdates: string; + sendNotifications: string; + archiveBoardConfirm: string; + archiveBoardTitle: string; + boardSwitcherHint: string; + taskCreatedWarning: string; + moveFailed: string; + bulkFailed: string; + completionBlockedHallucination: string; + suspectedHallucinatedReferences: string; + pickProfileFirst: string; + unblockedMessage: string; + unblockFailed: string; + reclaimedMessage: string; + reclaimFailed: string; + reassignedMessage: string; + reassignFailed: string; + selectForBulk: string; + clickToEdit: string; + clickToEditAssignee: string; + emptyAssignee: string; + columnLabels: { + triage: string; + todo: string; + ready: string; + running: string; + blocked: string; + done: string; + archived: string; + }; + columnHelp: { + triage: string; + todo: string; + ready: string; + running: string; + blocked: string; + done: string; + archived: string; + }; + confirmDone: string; + confirmArchive: string; + confirmBlocked: string; + completionSummary: string; + completionSummaryRequired: string; + triagePlaceholder: string; + taskTitlePlaceholder: string; + specifier: string; + assigneePlaceholder: string; + priority: string; + skillsPlaceholder: string; + noParent: string; + workspacePathDir: string; + workspacePathOptional: string; + logTruncated: string; + logAt: string; + }; } diff --git a/apps/dashboard/src/i18n/uk.ts b/apps/dashboard/src/i18n/uk.ts new file mode 100644 index 00000000000..72726aabe5f --- /dev/null +++ b/apps/dashboard/src/i18n/uk.ts @@ -0,0 +1,696 @@ +import type { Translations } from "./types"; + +export const uk: Translations = { + common: { + save: "Зберегти", + saving: "Збереження...", + cancel: "Скасувати", + close: "Закрити", + confirm: "Підтвердити", + delete: "Видалити", + refresh: "Оновити", + retry: "Повторити", + search: "Пошук...", + loading: "Завантаження...", + create: "Створити", + creating: "Створення...", + set: "Встановити", + replace: "Замінити", + clear: "Очистити", + live: "Наживо", + off: "Вимкнено", + enabled: "увімкнено", + disabled: "вимкнено", + active: "активний", + inactive: "неактивний", + unknown: "невідомо", + untitled: "Без назви", + none: "Немає", + form: "Форма", + noResults: "Немає результатів", + of: "з", + page: "Сторінка", + msgs: "повідомл.", + tools: "інструменти", + match: "збіг", + other: "Інше", + configured: "налаштовано", + removed: "видалено", + failedToToggle: "Не вдалося перемкнути", + failedToRemove: "Не вдалося видалити", + failedToReveal: "Не вдалося показати", + collapse: "Згорнути", + expand: "Розгорнути", + general: "Загальне", + messaging: "Обмін повідомленнями", + pluginLoadFailed: + "Не вдалося завантажити скрипт цього плагіна. Перевірте вкладку Network (dashboard-plugins/…) та шлях до плагінів на сервері.", + pluginNotRegistered: + "Скрипт плагіна не викликав register(), або у скрипті сталася помилка. Відкрийте консоль браузера, щоб побачити деталі.", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "Закрити навігацію", + closeModelTools: "Закрити модель та інструменти", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "Активні сесії:", + gatewayStatusLabel: "Стан шлюзу:", + gatewayStrip: { + failed: "Помилка запуску", + off: "Вимкнено", + running: "Працює", + starting: "Запускається", + stopped: "Зупинено", + }, + nav: { + analytics: "Аналітика", + chat: "Чат", + config: "Конфігурація", + cron: "Cron", + documentation: "Документація", + keys: "Ключі", + logs: "Журнали", + models: "Моделі", + profiles: "профілі: мульти-агенти", + plugins: "Плагіни", + sessions: "Сесії", + skills: "Навички", + }, + modelToolsSheetSubtitle: "та інструменти", + modelToolsSheetTitle: "Модель", + navigation: "Навігація", + openDocumentation: "Відкрити документацію в новій вкладці", + openNavigation: "Відкрити навігацію", + pluginNavSection: "Плагіни", + sessionsActiveCount: "{count} активних", + statusOverview: "Огляд стану", + system: "Система", + webUi: "Web UI", + }, + + status: { + actionFailed: "Дія не вдалася", + actionFinished: "Завершено", + actions: "Дії", + agent: "Агент", + activeSessions: "Активні сесії", + connected: "Підключено", + connectedPlatforms: "Підключені платформи", + disconnected: "Відключено", + error: "Помилка", + failed: "Не вдалося", + gateway: "Шлюз", + gatewayFailedToStart: "Не вдалося запустити шлюз", + lastUpdate: "Останнє оновлення", + noneRunning: "Немає", + notRunning: "Не запущено", + pid: "PID", + platformDisconnected: "відключено", + platformError: "помилка", + recentSessions: "Останні сесії", + restartGateway: "Перезапустити шлюз", + restartingGateway: "Перезапуск шлюзу…", + running: "Працює", + runningRemote: "Працює (віддалено)", + startFailed: "Помилка запуску", + starting: "Запускається", + startedInBackground: "Запущено у фоні — перевірте журнали для прогресу", + stopped: "Зупинено", + updateHermes: "Оновити Hermes", + updatingHermes: "Оновлення Hermes…", + waitingForOutput: "Очікування виводу…", + }, + + sessions: { + title: "Сесії", + searchPlaceholder: "Пошук у вмісті повідомлень...", + noSessions: "Поки немає сесій", + noMatch: "Жодна сесія не відповідає вашому пошуку", + startConversation: "Почніть розмову, щоб побачити її тут", + noMessages: "Немає повідомлень", + untitledSession: "Сесія без назви", + deleteSession: "Видалити сесію", + confirmDeleteTitle: "Видалити сесію?", + confirmDeleteMessage: + "Це назавжди видалить розмову та всі її повідомлення. Цю дію не можна скасувати.", + sessionDeleted: "Сесію видалено", + failedToDelete: "Не вдалося видалити сесію", + resumeInChat: "Продовжити в чаті", + previousPage: "Попередня сторінка", + nextPage: "Наступна сторінка", + roles: { + user: "Користувач", + assistant: "Асистент", + system: "Система", + tool: "Інструмент", + }, + }, + + analytics: { + period: "Період:", + totalTokens: "Усього токенів", + totalSessions: "Усього сесій", + apiCalls: "Виклики API", + dailyTokenUsage: "Щоденне використання токенів", + dailyBreakdown: "Щоденна розбивка", + perModelBreakdown: "Розбивка за моделями", + topSkills: "Топ навичок", + skill: "Навичка", + loads: "Агент завантажив", + edits: "Агент керує", + lastUsed: "Останнє використання", + input: "Вхід", + output: "Вихід", + total: "Усього", + noUsageData: "Немає даних про використання за цей період", + startSession: "Почніть сесію, щоб побачити аналітику тут", + date: "Дата", + model: "Модель", + tokens: "Токени", + perDayAvg: "/день у сер.", + acrossModels: "по {count} моделях", + inOut: "{input} вх. / {output} вих.", + }, + + models: { + modelsUsed: "Використано моделей", + estimatedCost: "Орієнт. вартість", + tokens: "токени", + sessions: "сесії", + avgPerSession: "сер./сесію", + apiCalls: "виклики API", + toolCalls: "виклики інструментів", + noModelsData: "Немає даних про використання моделей за цей період", + startSession: "Почніть сесію, щоб побачити дані моделей тут", + }, + + logs: { + title: "Журнали", + autoRefresh: "Автооновлення", + file: "Файл", + level: "Рівень", + component: "Компонент", + lines: "Рядки", + noLogLines: "Записів журналу не знайдено", + }, + + cron: { + confirmDeleteMessage: + "Це видаляє завдання з розкладу. Цю дію не можна скасувати.", + confirmDeleteTitle: "Видалити заплановане завдання?", + newJob: "Нове Cron-завдання", + nameOptional: "Назва (необов'язково)", + namePlaceholder: "напр. Щоденне зведення", + prompt: "Запит", + promptPlaceholder: "Що агент має робити при кожному запуску?", + schedule: "Розклад (cron-вираз)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "Надіслати на", + scheduledJobs: "Заплановані завдання", + noJobs: "Cron-завдань не налаштовано. Створіть одне вище.", + last: "Останнє", + next: "Наступне", + pause: "Призупинити", + resume: "Відновити", + triggerNow: "Запустити зараз", + delivery: { + local: "Локально", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "Новий профіль", + name: "Назва", + namePlaceholder: "напр. coder, writer тощо.", + nameRequired: "Назва обов'язкова", + nameRule: + "Лише малі літери, цифри, _ та -; має починатися з літери або цифри; до 64 символів.", + invalidName: "Недопустима назва профілю", + cloneFromDefault: "Клонувати конфігурацію з профілю за замовчуванням", + allProfiles: "Профілі", + noProfiles: "Профілів не знайдено.", + defaultBadge: "за замовчуванням", + hasEnv: "env", + model: "Модель", + skills: "Навички", + rename: "Перейменувати", + editSoul: "Редагувати SOUL.md", + soulSection: "SOUL.md (особистість / системний запит)", + soulPlaceholder: "# Як цей агент має поводитися…", + saveSoul: "Зберегти SOUL", + soulSaved: "SOUL.md збережено", + openInTerminal: "Скопіювати CLI-команду", + commandCopied: "Скопійовано в буфер обміну", + copyFailed: "Не вдалося скопіювати", + confirmDeleteTitle: "Видалити профіль?", + confirmDeleteMessage: + "Це назавжди видаляє профіль '{name}' — конфігурацію, ключі, спогади, сесії, навички, cron-завдання. Не можна скасувати.", + created: "Створено", + deleted: "Видалено", + renamed: "Перейменовано", + }, + + pluginsPage: { + contextEngineLabel: "Контекстний рушій", + dashboardSlots: "Слоти панелі", + disableRuntime: "Вимкнути", + enableAfterInstall: "Увімкнути після встановлення", + enableRuntime: "Увімкнути", + forceReinstall: "Примусово перевстановити (спершу видалити наявну теку)", + headline: + "Знаходьте, встановлюйте, вмикайте та оновлюйте плагіни Hermes (паритет з `hermes plugins`).", + identifierLabel: "Git URL або owner/repo", + inactive: "неактивний", + installBtn: "Встановити з Git", + installHeading: "Встановити з GitHub / Git URL", + installHint: "Використовуйте скорочення owner/repo або повну https:// чи git@ URL для клонування.", + memoryProviderLabel: "Постачальник пам'яті", + missingEnvWarn: "Встановіть їх у Keys, перш ніж плагін зможе працювати:", + noDashboardTab: "Немає вкладки панелі", + openTab: "Відкрити", + orphanHeading: "Розширення лише для панелі (без відповідного agent plugin.yaml)", + pluginListHeading: "Встановлені плагіни", + providerDefaults: "вбудований / за замовчуванням", + providersHeading: "Плагіни постачальників часу виконання", + providersHint: + "Записує memory.provider (порожньо = вбудований) та context.engine у config.yaml. Набуває чинності в наступній сесії.", + refreshDashboard: "Перескан розширень панелі", + removeConfirm: "Видалити цей плагін з ~/.hermes/plugins/?", + removeHint: "Видаляти можна лише плагіни, встановлені користувачем у ~/.hermes/plugins.", + rescanHeading: "Реєстр SPA-плагінів", + rescanHint: "Скануйте після додавання файлів на диск, щоб бічна панель підхопила нові маніфести.", + runtimeHeading: "Час виконання шлюзу (YAML-плагіни)", + saveProviders: "Зберегти налаштування постачальників", + savedProviders: "Налаштування постачальників збережено.", + sourceBadge: "Джерело", + authRequired: "Потрібна автентифікація", + authRequiredHint: "Виконайте цю команду, щоб автентифікуватися:", + updateGit: "Git pull", + versionBadge: "Версія", + showInSidebar: "Показати у бічній панелі", + hideFromSidebar: "Сховати з бічної панелі", + }, + + skills: { + title: "Навички", + searchPlaceholder: "Пошук навичок та наборів інструментів...", + enabledOf: "{enabled}/{total} увімкнено", + all: "Усі", + categories: "Категорії", + filters: "Фільтри", + noSkills: "Навичок не знайдено. Навички завантажуються з ~/.hermes/skills/", + noSkillsMatch: "Жодна навичка не відповідає вашому пошуку чи фільтру.", + skillCount: "{count} навичок", + resultCount: "{count} результатів", + noDescription: "Опис відсутній.", + toolsets: "Набори інструментів", + toolsetLabel: "Набір {name}", + noToolsetsMatch: "Жоден набір інструментів не відповідає пошуку.", + setupNeeded: "Потрібне налаштування", + disabledForCli: "Вимкнено для CLI", + more: "+ще {count}", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "Фільтри", + sections: "Розділи", + exportConfig: "Експортувати конфігурацію як JSON", + importConfig: "Імпортувати конфігурацію з JSON", + resetDefaults: "Скинути до значень за замовчуванням", + resetScopeTooltip: "Скинути {scope} до значень за замовчуванням", + confirmResetScope: "Скинути всі налаштування {scope} до значень за замовчуванням? Це лише оновлює форму — зміни не записуються до config.yaml, доки ви не натиснете «Зберегти».", + resetScopeToast: "{scope} скинуто до значень за замовчуванням — перегляньте та збережіть, щоб застосувати", + rawYaml: "Сирий YAML-конфіг", + searchResults: "Результати пошуку", + fields: "поле(ів)", + noFieldsMatch: 'Немає полів, що відповідають \"{query}\"', + configSaved: "Конфігурацію збережено", + yamlConfigSaved: "YAML-конфігурацію збережено", + failedToSave: "Не вдалося зберегти", + failedToSaveYaml: "Не вдалося зберегти YAML", + failedToLoadRaw: "Не вдалося завантажити сирий конфіг", + configImported: "Конфігурацію імпортовано — перегляньте та збережіть", + invalidJson: "Недійсний файл JSON", + categories: { + general: "Загальне", + agent: "Агент", + terminal: "Термінал", + display: "Відображення", + delegation: "Делегування", + memory: "Пам'ять", + compression: "Стиснення", + security: "Безпека", + browser: "Браузер", + voice: "Голос", + tts: "Синтез мовлення", + stt: "Розпізнавання мовлення", + logging: "Журналювання", + discord: "Discord", + auxiliary: "Додатково", + }, + }, + + env: { + changesNote: "Зміни одразу зберігаються на диск. Активні сесії автоматично підхоплюють нові ключі.", + confirmClearMessage: + "Збережене значення цієї змінної буде видалено з вашого .env-файлу. Цю дію не можна скасувати з UI.", + confirmClearTitle: "Очистити цей ключ?", + description: "Керуйте API-ключами та секретами, що зберігаються в", + hideAdvanced: "Сховати розширене", + showAdvanced: "Показати розширене", + llmProviders: "Постачальники LLM", + providersConfigured: "Налаштовано {configured} з {total} постачальників", + getKey: "Отримати ключ", + notConfigured: "{count} не налаштовано", + notSet: "Не задано", + keysCount: "{count} ключ(ів)", + enterValue: "Введіть значення...", + replaceCurrentValue: "Замінити поточне значення ({preview})", + showValue: "Показати справжнє значення", + hideValue: "Сховати значення", + }, + + oauth: { + title: "Входи постачальників (OAuth)", + providerLogins: "Входи постачальників (OAuth)", + description: "Підключено {connected} з {total} постачальників OAuth. Процеси входу наразі виконуються через CLI; натисніть «Скопіювати команду» та вставте у термінал, щоб налаштувати.", + connected: "Підключено", + expired: "Прострочено", + notConnected: "Не підключено. Виконайте {command} у терміналі.", + runInTerminal: "у терміналі.", + noProviders: "Не виявлено постачальників із підтримкою OAuth.", + login: "Увійти", + disconnect: "Відключити", + managedExternally: "Керується ззовні", + copied: "Скопійовано ✓", + cli: "CLI", + copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)", + connect: "Підключити", + sessionExpires: "Сесія завершиться через {time}", + initiatingLogin: "Запуск процесу входу…", + exchangingCode: "Обмін коду на токени…", + connectedClosing: "Підключено! Закриття…", + loginFailed: "Помилка входу.", + sessionExpired: "Сесія прострочена. Натисніть «Повторити», щоб розпочати новий вхід.", + reOpenAuth: "Знову відкрити сторінку авторизації", + reOpenVerification: "Знову відкрити сторінку перевірки", + submitCode: "Надіслати код", + pasteCode: "Вставте код авторизації (з суфіксом #state теж нормально)", + waitingAuth: "Очікування на вашу авторизацію в браузері…", + enterCodePrompt: "Відкрилася нова вкладка. Якщо буде запит, введіть цей код:", + pkceStep1: "Відкрилася нова вкладка з claude.ai. Увійдіть та натисніть Authorize.", + pkceStep2: "Скопіюйте код авторизації, що відображається після авторизації.", + pkceStep3: "Вставте його нижче та надішліть.", + flowLabels: { + pkce: "Вхід через браузер (PKCE)", + device_code: "Код пристрою", + external: "Зовнішній CLI", + }, + expiresIn: "завершується через {time}", + }, + + language: { + switchTo: "Перемкнути на англійську", + }, + + theme: { + title: "Тема", + switchTheme: "Змінити тему", + }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "Колекційні значки Hermes, отримані з реальної історії сеансів. Відомі, але ще не виконані досягнення показані як Виявлені; Секретні досягнення залишаються прихованими, доки не з'явиться перший відповідний сигнал.", + scan_subtitle: + "Сканування історії сеансів Hermes. Перше сканування на великих історіях може тривати 5–10 секунд.", + }, + actions: { + rescan: "Повторне сканування", + }, + stats: { + unlocked: "Розблоковано", + unlocked_hint: "отримані значки", + discovered: "Виявлено", + discovered_hint: "відомі, ще не отримані", + secrets: "Секрети", + secrets_hint: "приховані до першого сигналу", + highest_tier: "Найвищий рівень", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "Останнє", + latest_hint_empty: "запускайте Hermes частіше", + none_yet: "Поки немає", + }, + state: { + unlocked: "Розблоковано", + discovered: "Виявлено", + secret: "Секрет", + }, + tier: { + target: "Ціль {tier}", + hidden: "Приховано", + complete: "Завершено", + objective: "Завдання", + }, + progress: { + hidden: "приховано", + }, + scan: { + building_headline: "Побудова профілю досягнень…", + building_detail: + "Читання сеансів, викликів інструментів, метаданих моделей і стану розблокування.", + starting_headline: "Запуск сканування досягнень…", + progress_detail: + "Проскановано {scanned} з {total} сеансів · {pct}%. Значки розблоковуються в міру надходження історії.", + idle_detail: + "Читання сеансів, викликів інструментів, метаданих моделей і стану розблокування. Значки з'являються тут у міру розблокування.", + }, + guide: { + tiers_header: "Рівні", + secret_header: "Секретні досягнення", + secret_body: + "Секрети приховують свій точний тригер. Щойно Hermes побачить пов'язаний сигнал, картка стає Виявленою та показує свою умову.", + scan_status_header: "Стан сканування", + scan_status_body: + "Hermes одноразово сканує локальну історію, а потім картки з'являться автоматично. Якщо це триває кілька секунд — нічого не зависло.", + what_scanned_header: "Що сканується", + what_scanned_body: + "Сеанси, виклики інструментів, метадані моделей, помилки, досягнення та локальний стан розблокування.", + }, + card: { + share_title: "Поділитися цим досягненням", + share_label: "Поділитися {name}", + share_text: "Поділитися", + how_to_reveal: "Як розкрити", + what_counts: "Що зараховується", + evidence_label: "Доказ", + evidence_session_fallback: "сеанс", + no_evidence: "Доказів поки немає", + }, + latest: { + header: "Нещодавні розблокування", + }, + empty: { + no_secrets_header: "У цьому скануванні не залишилося прихованих секретів.", + no_secrets_body: + "Підказка: секрети зазвичай починаються з незвичних збоїв або шаблонів досвідчених користувачів — конфлікти портів, стіни дозволів, відсутні змінні середовища, помилки YAML, колізії Docker, відкат/контрольні точки, влучання в кеш або дрібні виправлення після купи червоного тексту.", + }, + filters: { + all_categories: "Усі", + visibility_all: "усі", + visibility_unlocked: "розблоковано", + visibility_discovered: "виявлено", + visibility_secret: "секрет", + }, + share: { + dialog_label: "Поділитися досягненням", + header: "Поділитися: {name}", + close: "Закрити", + rendering: "Рендеринг…", + card_alt: "Картка для поширення {name}", + error_generic: "Щось пішло не так.", + x_title: "Відкриває X із попередньо заповненим дописом", + x_button: "Поділитися в X", + copy_title: "Скопіюйте зображення, щоб вставити у свій допис", + copy_button: "Копіювати зображення", + copied: "Скопійовано ✓", + download_button: "Завантажити PNG", + hint: + "«Поділитися в X» відкриває попередньо заповнений допис у новій вкладці. Якщо хочете прикріпити значок 1200×630 — спочатку натисніть «Копіювати зображення»: X дозволить вставити його прямо в редактор твіта. «Завантажити PNG» збереже файл для використання будь-де.", + clipboard_unsupported: + "Цей браузер не підтримує копіювання зображень у буфер обміну — використайте «Завантажити».", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "Завантаження дошки Kanban…", + loadFailed: "Не вдалося завантажити дошку Kanban: ", + loadFailedHint: + "Бекенд автоматично створює kanban.db під час першого читання. Якщо помилка не зникає, перевірте журнали панелі.", + board: "Дошка", + newBoard: "+ Нова дошка", + newBoardTitle: "Нова дошка", + newBoardDescription: + "Дошки дозволяють розділяти непов'язані потоки роботи — по одній на проєкт, репозиторій або домен. Воркери на одній дошці ніколи не бачать задач іншої дошки.", + slug: "Slug", + slugHint: "— рядкові літери, дефіси, напр. atm10-server", + displayName: "Відображувана назва", + displayNameHint: "(необов'язково)", + description: "Опис", + descriptionHint: "(необов'язково)", + icon: "Іконка", + iconHint: "(один символ або емодзі)", + switchAfterCreate: "Перейти на цю дошку після створення", + cancel: "Скасувати", + creating: "Створення…", + createBoard: "Створити дошку", + search: "Пошук", + filterCards: "Фільтрувати картки…", + tenant: "Орендар", + allTenants: "Усі орендарі", + assignee: "Виконавець", + allProfiles: "Усі профілі", + showArchived: "Показати архівовані", + lanesByProfile: "Доріжки за профілем", + nudgeDispatcher: "Підштовхнути диспетчер", + refresh: "Оновити", + selected: "вибрано", + complete: "Завершити", + archive: "Архівувати", + apply: "Застосувати", + clear: "Очистити", + createTask: "Створити задачу в цьому стовпці", + noTasks: "— немає задач —", + unassigned: "не призначено", + untitled: "(без назви)", + loadingDetail: "Завантаження…", + addComment: "Додати коментар… (Enter для надсилання)", + comment: "Коментар", + status: "Статус", + workspace: "Робоча область", + skills: "Навички", + createdBy: "Створив", + result: "Результат", + comments: "Коментарі", + events: "Події", + runHistory: "Історія запусків", + workerLog: "Журнал воркера", + loadingLog: "Завантаження журналу…", + noWorkerLog: + "— журналу воркера ще немає (задачу не запущено або журнал ротаційно видалено) —", + noDescription: "— немає опису —", + noComments: "— немає коментарів —", + edit: "редагувати", + save: "Зберегти", + dependencies: "Залежності", + parents: "Батьки:", + children: "Нащадки:", + none: "немає", + addParent: "— додати батька —", + addChild: "— додати нащадка —", + removeDependency: "Видалити залежність", + block: "Заблокувати", + unblock: "Розблокувати", + notifyHomeChannels: "Повідомити домашні канали", + diagnostics: "Діагностика", + hide: "Приховати", + show: "Показати", + attention: "Увага", + tasksNeedAttention: "задач потребують уваги", + taskNeedsAttention: "1 задача потребує уваги", + diagnostic: "діагностика", + open: "Відкрити", + close: "Закрити (Esc)", + reassignTo: "Перепризначити на:", + copied: "Скопійовано", + copyCommand: "Скопіювати команду в буфер обміну", + reclaim: "Повернути", + reassign: "Перепризначити", + renderingError: "На вкладці Kanban сталася помилка рендерингу", + reloadView: "Перезавантажити вигляд", + wsAuthFailed: + "Помилка автентифікації WebSocket — перезавантажте сторінку, щоб оновити токен сесії.", + markDone: "Позначити {n} задач(у) як виконані?", + markArchived: "Архівувати {n} задач(у)?", + warning: "Попередження", + phantomIds: "Фантомні id:", + active: "активна", + ended: "завершена", + noProfile: "(немає профілю)", + showAllAttempts: "Показати всі спроби", + sendingUpdates: "Надсилання оновлень до", + sendNotifications: "Надсилати сповіщення completed / blocked / gave_up до", + archiveBoardConfirm: + "Архівувати дошку «{name}»? Її буде переміщено до boards/_archived/, тож пізніше її можна відновити. Задачі цієї дошки більше не з'являтимуться в інтерфейсі.", + archiveBoardTitle: "Архівувати цю дошку", + boardSwitcherHint: "Дошки дозволяють розділяти непов'язані потоки роботи", + taskCreatedWarning: "Задачу створено, але: ", + moveFailed: "Переміщення не вдалося: ", + bulkFailed: "Масова дія: ", + completionBlockedHallucination: "⚠ Завершення заблоковано — фантомні id карток", + suspectedHallucinatedReferences: "⚠ Текст посилався на фантомні id карток", + pickProfileFirst: "Спочатку виберіть профіль.", + unblockedMessage: "{id} розблоковано. Задача готова до наступного тіку.", + unblockFailed: "Розблокування не вдалося: ", + reclaimedMessage: "{id} повернуто. Задача знову готова.", + reclaimFailed: "Повернення не вдалося: ", + reassignedMessage: "{id} перепризначено на {profile}.", + reassignFailed: "Перепризначення не вдалося: ", + selectForBulk: "Вибрати для масових дій", + clickToEdit: "Клікніть, щоб редагувати", + clickToEditAssignee: "Клікніть, щоб редагувати виконавця", + emptyAssignee: "(порожньо = зняти призначення)", + columnLabels: { + triage: "Сортування", + todo: "До виконання", + ready: "Готово", + running: "У роботі", + blocked: "Заблоковано", + done: "Виконано", + archived: "Архів", + }, + columnHelp: { + triage: "Сирі ідеї — специфікатор деталізує специфікацію", + todo: "Очікує на залежності або не призначено", + ready: "Призначено, очікує тіку диспетчера", + running: "Захоплено воркером — у роботі", + blocked: "Воркер запитав втручання людини", + done: "Завершено", + archived: "Архівовано", + }, + confirmDone: + "Позначити цю задачу як виконану? Захоплення воркера буде звільнено, а залежні нащадки стануть готовими.", + confirmArchive: + "Архівувати цю задачу? Вона зникне з типового вигляду дошки.", + confirmBlocked: + "Позначити цю задачу як заблоковану? Захоплення воркера буде звільнено.", + completionSummary: + "Підсумок завершення для {label}. Зберігається як result задачі.", + completionSummaryRequired: + "Підсумок завершення обов'язковий перед позначенням задачі виконаною.", + triagePlaceholder: "Чорнова ідея — ШІ її специфікує…", + taskTitlePlaceholder: "Назва нової задачі…", + specifier: "специфікатор", + assigneePlaceholder: "виконавець", + priority: "Пріоритет", + skillsPlaceholder: + "навички (необов'язково, через кому): translation, github-code-review", + noParent: "— без батька —", + workspacePathDir: "шлях робочої області (обов'язково, напр. ~/projects/my-app)", + workspacePathOptional: + "шлях робочої області (необов'язково, виводиться з виконавця, якщо порожньо)", + logTruncated: "(показано останні 100 KB — повний журнал у ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/zh-hant.ts b/apps/dashboard/src/i18n/zh-hant.ts new file mode 100644 index 00000000000..c79222cfe91 --- /dev/null +++ b/apps/dashboard/src/i18n/zh-hant.ts @@ -0,0 +1,696 @@ +import type { Translations } from "./types"; + +export const zhHant: Translations = { + common: { + save: "儲存", + saving: "儲存中...", + cancel: "取消", + close: "關閉", + confirm: "確認", + delete: "刪除", + refresh: "重新整理", + retry: "重試", + search: "搜尋...", + loading: "載入中...", + create: "建立", + creating: "建立中...", + set: "設定", + replace: "取代", + clear: "清除", + live: "線上", + off: "離線", + enabled: "已啟用", + disabled: "已停用", + active: "使用中", + inactive: "未啟用", + unknown: "未知", + untitled: "未命名", + none: "無", + form: "表單", + noResults: "無結果", + of: "/", + page: "頁", + msgs: "訊息", + tools: "工具", + match: "符合", + other: "其他", + configured: "已設定", + removed: "已移除", + failedToToggle: "切換失敗", + failedToRemove: "移除失敗", + failedToReveal: "顯示失敗", + collapse: "收合", + expand: "展開", + general: "一般", + messaging: "訊息平台", + pluginLoadFailed: + "無法載入此外掛的指令碼。請檢查網路請求(dashboard-plugins/…)以及伺服器上的外掛路徑。", + pluginNotRegistered: + "外掛指令碼未呼叫 register(),或執行時發生錯誤。請開啟瀏覽器主控台查看詳細資訊。", + }, + + app: { + brand: "Hermes Agent", + brandShort: "HA", + closeNavigation: "關閉導覽", + closeModelTools: "關閉模型與工具", + footer: { + org: "Nous Research", + }, + activeSessionsLabel: "使用中工作階段:", + gatewayStatusLabel: "閘道狀態:", + gatewayStrip: { + failed: "啟動失敗", + off: "關閉", + running: "執行中", + starting: "啟動中", + stopped: "已停止", + }, + nav: { + analytics: "分析", + chat: "對話", + config: "設定", + cron: "排程任務", + documentation: "文件", + keys: "金鑰", + logs: "日誌", + models: "模型", + profiles: "多代理設定檔", + plugins: "外掛管理", + sessions: "工作階段", + skills: "技能", + }, + modelToolsSheetSubtitle: "與工具", + modelToolsSheetTitle: "模型", + navigation: "導覽", + openDocumentation: "在新分頁開啟文件", + openNavigation: "開啟導覽", + pluginNavSection: "外掛", + sessionsActiveCount: "{count} 個使用中", + statusOverview: "狀態總覽", + system: "系統", + webUi: "管理面板", + }, + + status: { + actionFailed: "動作失敗", + actionFinished: "已完成", + actions: "動作", + agent: "代理", + activeSessions: "使用中工作階段", + connected: "已連線", + connectedPlatforms: "已連線平台", + disconnected: "已中斷連線", + error: "錯誤", + failed: "失敗", + gateway: "閘道", + gatewayFailedToStart: "閘道啟動失敗", + lastUpdate: "最後更新", + noneRunning: "無", + notRunning: "未執行", + pid: "PID", + platformDisconnected: "已中斷", + platformError: "錯誤", + recentSessions: "近期工作階段", + restartGateway: "重新啟動閘道", + restartingGateway: "正在重新啟動閘道…", + running: "執行中", + runningRemote: "執行中(遠端)", + startFailed: "啟動失敗", + starting: "啟動中", + startedInBackground: "已於背景啟動 — 請查看日誌以取得進度", + stopped: "已停止", + updateHermes: "更新 Hermes", + updatingHermes: "正在更新 Hermes…", + waitingForOutput: "等待輸出…", + }, + + sessions: { + title: "工作階段", + searchPlaceholder: "搜尋訊息內容...", + noSessions: "尚無工作階段", + noMatch: "沒有符合的工作階段", + startConversation: "開始對話後將顯示於此", + noMessages: "尚無訊息", + untitledSession: "未命名工作階段", + deleteSession: "刪除工作階段", + confirmDeleteTitle: "刪除工作階段?", + confirmDeleteMessage: + "此操作將永久移除對話及其所有訊息,無法復原。", + sessionDeleted: "工作階段已刪除", + failedToDelete: "刪除工作階段失敗", + resumeInChat: "在對話中繼續", + previousPage: "上一頁", + nextPage: "下一頁", + roles: { + user: "使用者", + assistant: "助理", + system: "系統", + tool: "工具", + }, + }, + + analytics: { + period: "時間範圍:", + totalTokens: "Token 總數", + totalSessions: "工作階段總數", + apiCalls: "API 呼叫", + dailyTokenUsage: "每日 Token 用量", + dailyBreakdown: "每日明細", + perModelBreakdown: "各模型用量明細", + topSkills: "常用技能", + skill: "技能", + loads: "代理載入", + edits: "代理管理", + lastUsed: "最近使用", + input: "輸入", + output: "輸出", + total: "總計", + noUsageData: "此時間範圍內無使用資料", + startSession: "開始工作階段後將於此處顯示分析資料", + date: "日期", + model: "模型", + tokens: "Token", + perDayAvg: "/日 平均", + acrossModels: "共 {count} 個模型", + inOut: "輸入 {input} / 輸出 {output}", + }, + + models: { + modelsUsed: "使用模型數", + estimatedCost: "預估費用", + tokens: "Token", + sessions: "工作階段", + avgPerSession: "平均/工作階段", + apiCalls: "API 呼叫", + toolCalls: "工具呼叫", + noModelsData: "此時間範圍內無模型使用資料", + startSession: "開始工作階段後將於此處顯示模型資料", + }, + + logs: { + title: "日誌", + autoRefresh: "自動重新整理", + file: "檔案", + level: "層級", + component: "元件", + lines: "行數", + noLogLines: "找不到日誌記錄", + }, + + cron: { + confirmDeleteMessage: + "將從排程移除此任務,此操作無法復原。", + confirmDeleteTitle: "刪除排程任務?", + newJob: "新增排程任務", + nameOptional: "名稱(選填)", + namePlaceholder: "例如:每日摘要", + prompt: "提示詞", + promptPlaceholder: "代理每次執行時應做什麼?", + schedule: "排程(cron 運算式)", + schedulePlaceholder: "0 9 * * *", + deliverTo: "傳送至", + scheduledJobs: "已排程任務", + noJobs: "尚未設定排程任務。請於上方建立。", + last: "上次", + next: "下次", + pause: "暫停", + resume: "繼續", + triggerNow: "立即觸發", + delivery: { + local: "本機", + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + email: "Email", + }, + }, + + profiles: { + newProfile: "新增設定檔", + name: "名稱", + namePlaceholder: "例如:coder、writer 等", + nameRequired: "名稱為必填", + nameRule: + "僅允許小寫字母、數字、底線及連字號;首字必須為字母或數字;最多 64 個字元。", + invalidName: "設定檔名稱無效", + cloneFromDefault: "從預設設定檔複製設定", + allProfiles: "設定檔", + noProfiles: "找不到設定檔。", + defaultBadge: "預設", + hasEnv: "env", + model: "模型", + skills: "技能", + rename: "重新命名", + editSoul: "編輯 SOUL.md", + soulSection: "SOUL.md(人格 / 系統提示詞)", + soulPlaceholder: "# 此代理應如何運作…", + saveSoul: "儲存 SOUL", + soulSaved: "SOUL.md 已儲存", + openInTerminal: "複製 CLI 指令", + commandCopied: "已複製到剪貼簿", + copyFailed: "複製失敗", + confirmDeleteTitle: "刪除設定檔?", + confirmDeleteMessage: + "將永久刪除設定檔「{name}」 — 包括設定、金鑰、記憶、工作階段、技能、排程任務。無法復原。", + created: "已建立", + deleted: "已刪除", + renamed: "已重新命名", + }, + + pluginsPage: { + contextEngineLabel: "上下文引擎", + dashboardSlots: "面板插槽", + disableRuntime: "停用", + enableAfterInstall: "安裝後啟用", + enableRuntime: "啟用", + forceReinstall: "強制重新安裝(先刪除既有資料夾)", + headline: + "探索、安裝、啟用並更新 Hermes 外掛(對齊 `hermes plugins` CLI)。", + identifierLabel: "Git 網址或 owner/repo", + inactive: "未啟用", + installBtn: "從 Git 安裝", + installHeading: "從 GitHub / Git URL 安裝", + installHint: "可使用 owner/repo 簡寫或完整的 https:// 或 git@ 複製網址。", + memoryProviderLabel: "記憶提供者", + missingEnvWarn: "請先在「金鑰」頁面設定下列項目,外掛才能執行:", + noDashboardTab: "無儀表板分頁", + openTab: "開啟", + orphanHeading: "僅儀表板擴充功能(無對應的 agent plugin.yaml)", + pluginListHeading: "已安裝的外掛", + providerDefaults: "內建 / 預設", + providersHeading: "執行階段提供者外掛", + providersHint: + "會寫入 config.yaml:memory.provider(留空為內建)與 context.engine。下一個工作階段生效。", + refreshDashboard: "重新掃描儀表板擴充功能", + removeConfirm: "從 ~/.hermes/plugins/ 移除此外掛?", + removeHint: "僅可移除位於 ~/.hermes/plugins 下使用者安裝的外掛。", + rescanHeading: "SPA 外掛註冊表", + rescanHint: "在磁碟新增檔案後重新掃描,使儀表板側邊欄載入新的 manifest。", + runtimeHeading: "閘道執行階段(YAML 外掛)", + saveProviders: "儲存提供者設定", + savedProviders: "提供者設定已儲存。", + sourceBadge: "來源", + authRequired: "需要驗證", + authRequiredHint: "執行此指令以完成驗證:", + updateGit: "Git pull", + versionBadge: "版本", + showInSidebar: "顯示於側邊欄", + hideFromSidebar: "從側邊欄隱藏", + }, + + skills: { + title: "技能", + searchPlaceholder: "搜尋技能與工具集...", + enabledOf: "已啟用 {enabled}/{total}", + all: "全部", + categories: "分類", + filters: "篩選", + noSkills: "找不到技能。技能由 ~/.hermes/skills/ 載入", + noSkillsMatch: "沒有符合搜尋或篩選條件的技能。", + skillCount: "{count} 個技能", + resultCount: "{count} 個結果", + noDescription: "無可用描述。", + toolsets: "工具集", + toolsetLabel: "{name} 工具集", + noToolsetsMatch: "沒有符合搜尋條件的工具集。", + setupNeeded: "需要設定", + disabledForCli: "CLI 已停用", + more: "還有 {count} 個", + }, + + config: { + configPath: "~/.hermes/config.yaml", + filters: "篩選", + sections: "分類", + exportConfig: "匯出設定為 JSON", + importConfig: "從 JSON 匯入設定", + resetDefaults: "重設為預設值", + resetScopeTooltip: "將{scope}重設為預設值", + confirmResetScope: "要將{scope}的所有設定重設為預設值嗎?此操作只更新表單,在按下「儲存」前不會寫入 config.yaml。", + resetScopeToast: "{scope}已重設為預設值 — 請檢視並儲存以套用", + rawYaml: "原始 YAML 設定", + searchResults: "搜尋結果", + fields: "個欄位", + noFieldsMatch: '沒有符合「{query}」的欄位', + configSaved: "設定已儲存", + yamlConfigSaved: "YAML 設定已儲存", + failedToSave: "儲存失敗", + failedToSaveYaml: "YAML 儲存失敗", + failedToLoadRaw: "載入原始設定失敗", + configImported: "設定已匯入 — 請檢視後儲存", + invalidJson: "無效的 JSON 檔案", + categories: { + general: "一般", + agent: "代理", + terminal: "終端機", + display: "顯示", + delegation: "委派", + memory: "記憶", + compression: "壓縮", + security: "安全性", + browser: "瀏覽器", + voice: "語音", + tts: "文字轉語音", + stt: "語音轉文字", + logging: "日誌", + discord: "Discord", + auxiliary: "輔助", + }, + }, + + env: { + changesNote: "變更會立即儲存到磁碟。使用中的工作階段將自動取得新金鑰。", + confirmClearMessage: + "此變數已儲存的值將從 .env 檔案中移除。無法從介面復原。", + confirmClearTitle: "清除此金鑰?", + description: "管理儲存於下列位置的 API 金鑰與密鑰", + hideAdvanced: "隱藏進階選項", + showAdvanced: "顯示進階選項", + llmProviders: "LLM 提供者", + providersConfigured: "已設定 {configured}/{total} 個提供者", + getKey: "取得金鑰", + notConfigured: "{count} 個未設定", + notSet: "未設定", + keysCount: "{count} 個金鑰", + enterValue: "輸入值...", + replaceCurrentValue: "取代目前值({preview})", + showValue: "顯示實際值", + hideValue: "隱藏值", + }, + + oauth: { + title: "提供者登入(OAuth)", + providerLogins: "提供者登入(OAuth)", + description: "已連線 {connected}/{total} 個 OAuth 提供者。登入流程目前透過 CLI 執行;請點擊「複製指令」並貼到終端機完成設定。", + connected: "已連線", + expired: "已過期", + notConnected: "未連線。請在終端機執行 {command}。", + runInTerminal: "於終端機。", + noProviders: "未偵測到支援 OAuth 的提供者。", + login: "登入", + disconnect: "中斷連線", + managedExternally: "由外部管理", + copied: "已複製 ✓", + cli: "CLI", + copyCliCommand: "複製 CLI 指令(外部 / 備援用)", + connect: "連線", + sessionExpires: "工作階段將於 {time} 後過期", + initiatingLogin: "正在啟動登入流程…", + exchangingCode: "正在交換權杖…", + connectedClosing: "已連線!正在關閉…", + loginFailed: "登入失敗。", + sessionExpired: "工作階段已過期。請點擊「重試」開始新的登入。", + reOpenAuth: "重新開啟授權頁面", + reOpenVerification: "重新開啟驗證頁面", + submitCode: "提交代碼", + pasteCode: "貼上授權代碼(包含 #state 後綴亦可)", + waitingAuth: "等待您於瀏覽器中完成授權…", + enterCodePrompt: "已開啟新分頁。如有提示,請輸入此代碼:", + pkceStep1: "已於新分頁開啟 claude.ai。請登入並點擊「Authorize」。", + pkceStep2: "複製授權後顯示的授權代碼。", + pkceStep3: "將其貼到下方並提交。", + flowLabels: { + pkce: "瀏覽器登入(PKCE)", + device_code: "裝置代碼", + external: "外部 CLI", + }, + expiresIn: "{time}後過期", + }, + + language: { + switchTo: "切換為英文", + }, + + theme: { + title: "主題", + switchTheme: "切換主題", + }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "從真實工作階段歷史中獲得的 Hermes 可收集徽章。已知尚未達成的成就會顯示為「已發現」;秘密成就在首次出現相符行為之前保持隱藏。", + scan_subtitle: + "正在掃描 Hermes 工作階段歷史。在歷史紀錄較多時,首次掃描可能需要 5–10 秒。", + }, + actions: { + rescan: "重新掃描", + }, + stats: { + unlocked: "已解鎖", + unlocked_hint: "獲得的徽章", + discovered: "已發現", + discovered_hint: "已知,但尚未獲得", + secrets: "秘密", + secrets_hint: "在首次訊號出現前保持隱藏", + highest_tier: "最高等級", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "最新", + latest_hint_empty: "多多執行 Hermes", + none_yet: "尚無", + }, + state: { + unlocked: "已解鎖", + discovered: "已發現", + secret: "秘密", + }, + tier: { + target: "目標 {tier}", + hidden: "隱藏", + complete: "已完成", + objective: "目標", + }, + progress: { + hidden: "隱藏", + }, + scan: { + building_headline: "正在建立成就檔案…", + building_detail: + "正在讀取工作階段、工具呼叫、模型中繼資料以及解鎖狀態。", + starting_headline: "正在開始成就掃描…", + progress_detail: + "已掃描 {scanned} / {total} 個工作階段 · {pct}%。隨著更多歷史串入,徽章會陸續解鎖。", + idle_detail: + "正在讀取工作階段、工具呼叫、模型中繼資料以及解鎖狀態。徽章解鎖後會顯示在這裡。", + }, + guide: { + tiers_header: "等級", + secret_header: "秘密成就", + secret_body: + "秘密成就會隱藏其確切觸發條件。一旦 Hermes 偵測到相關訊號,卡片便會變為「已發現」並顯示其需求。", + scan_status_header: "掃描狀態", + scan_status_body: + "Hermes 正在對本機歷史進行一次掃描,之後卡片會自動出現。即使需要幾秒鐘,也並未卡住。", + what_scanned_header: "掃描內容", + what_scanned_body: + "工作階段、工具呼叫、模型中繼資料、錯誤、成就以及本機解鎖狀態。", + }, + card: { + share_title: "分享此成就", + share_label: "分享 {name}", + share_text: "分享", + how_to_reveal: "如何揭示", + what_counts: "計入條件", + evidence_label: "證據", + evidence_session_fallback: "工作階段", + no_evidence: "尚無證據", + }, + latest: { + header: "最近解鎖", + }, + empty: { + no_secrets_header: "本次掃描已沒有隱藏的秘密。", + no_secrets_body: + "提示:秘密通常源自異常失敗或進階使用者的行為模式 —— 連接埠衝突、權限阻擋、缺少環境變數、YAML 錯誤、Docker 衝突、回復或檢查點的使用、快取命中,或在大量紅色錯誤後做出的小小修正。", + }, + filters: { + all_categories: "全部", + visibility_all: "全部", + visibility_unlocked: "已解鎖", + visibility_discovered: "已發現", + visibility_secret: "秘密", + }, + share: { + dialog_label: "分享成就", + header: "分享:{name}", + close: "關閉", + rendering: "繪製中…", + card_alt: "{name} 分享卡片", + error_generic: "發生錯誤。", + x_title: "在 X 中開啟預先填寫的貼文", + x_button: "在 X 上分享", + copy_title: "複製圖片以貼上到你的貼文", + copy_button: "複製圖片", + copied: "已複製 ✓", + download_button: "下載 PNG", + hint: + "「在 X 上分享」會在新分頁中開啟預先填寫的貼文。若想附上 1200×630 的徽章,請先點擊「複製圖片」—— X 允許你直接貼到推文編輯器中。「下載 PNG」會將檔案儲存下來,可在任何地方使用。", + clipboard_unsupported: + "此瀏覽器不支援剪貼簿圖片複製 —— 請改用「下載」。", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + kanban: { + loading: "正在載入看板…", + loadFailed: "載入看板失敗:", + loadFailedHint: + "後端會在首次讀取時自動建立 kanban.db。如果問題持續,請檢查儀表板日誌。", + board: "看板", + newBoard: "+ 新增看板", + newBoardTitle: "新增看板", + newBoardDescription: + "看板可將不相關的工作流分開——每個專案、程式碼庫或網域一個看板。一個看板上的工作者不會看到另一個看板的任務。", + slug: "識別碼", + slugHint: "— 小寫字母、連字號,例如 atm10-server", + displayName: "顯示名稱", + displayNameHint: "(選填)", + description: "描述", + descriptionHint: "(選填)", + icon: "圖示", + iconHint: "(單一字元或表情符號)", + switchAfterCreate: "建立後切換到此看板", + cancel: "取消", + creating: "建立中…", + createBoard: "建立看板", + search: "搜尋", + filterCards: "篩選卡片…", + tenant: "租戶", + allTenants: "全部租戶", + assignee: "負責人", + allProfiles: "全部設定檔", + showArchived: "顯示已封存", + lanesByProfile: "依設定檔分組", + nudgeDispatcher: "觸發排程器", + refresh: "重新整理", + selected: "已選取", + complete: "完成", + archive: "封存", + apply: "套用", + clear: "清除", + createTask: "在此欄建立任務", + noTasks: "— 沒有任務 —", + unassigned: "未指派", + untitled: "(無標題)", + loadingDetail: "載入中…", + addComment: "新增留言…(按 Enter 送出)", + comment: "留言", + status: "狀態", + workspace: "工作區", + skills: "技能", + createdBy: "建立者", + result: "結果", + comments: "留言", + events: "事件", + runHistory: "執行紀錄", + workerLog: "工作者日誌", + loadingLog: "正在載入日誌…", + noWorkerLog: + "— 尚無工作者日誌(任務尚未啟動或日誌已被輪替)—", + noDescription: "— 沒有描述 —", + noComments: "— 沒有留言 —", + edit: "編輯", + save: "儲存", + dependencies: "相依項目", + parents: "上層任務:", + children: "下層任務:", + none: "無", + addParent: "— 新增上層任務 —", + addChild: "— 新增下層任務 —", + removeDependency: "移除相依項目", + block: "封鎖", + unblock: "解除封鎖", + notifyHomeChannels: "通知主要頻道", + diagnostics: "診斷", + hide: "隱藏", + show: "顯示", + attention: "注意", + tasksNeedAttention: "個任務需要關注", + taskNeedsAttention: "1 個任務需要關注", + diagnostic: "診斷", + open: "開啟", + close: "關閉 (Esc)", + reassignTo: "重新指派給:", + copied: "已複製", + copyCommand: "複製指令到剪貼簿", + reclaim: "收回", + reassign: "重新指派", + renderingError: "看板分頁發生繪製錯誤", + reloadView: "重新載入檢視", + wsAuthFailed: + "WebSocket 驗證失敗 — 請重新載入頁面以更新工作階段權杖。", + markDone: "將 {n} 個任務標記為完成?", + markArchived: "封存 {n} 個任務?", + warning: "警告", + phantomIds: "幽靈 ID:", + active: "進行中", + ended: "已結束", + noProfile: "(無設定檔)", + showAllAttempts: "顯示所有嘗試", + sendingUpdates: "正在傳送更新到", + sendNotifications: "傳送完成 / 封鎖 / 放棄通知到", + archiveBoardConfirm: + "封存看板「{name}」?看板將會移至 boards/_archived/,以便日後復原。此看板上的任務將不再出現在 UI 中的任何位置。", + archiveBoardTitle: "封存此看板", + boardSwitcherHint: "看板可將不相關的工作流分開", + taskCreatedWarning: "任務已建立,但:", + moveFailed: "移動失敗:", + bulkFailed: "批次操作:", + completionBlockedHallucination: "⚠ 完成被封鎖 — 幽靈卡片 ID", + suspectedHallucinatedReferences: "⚠ 文字內容引用了幽靈卡片 ID", + pickProfileFirst: "請先選擇一個設定檔。", + unblockedMessage: "已解除封鎖 {id}。任務已準備好進入下一輪排程。", + unblockFailed: "解除封鎖失敗:", + reclaimedMessage: "已收回 {id}。任務已回到就緒狀態。", + reclaimFailed: "收回失敗:", + reassignedMessage: "已將 {id} 重新指派給 {profile}。", + reassignFailed: "重新指派失敗:", + selectForBulk: "選取以進行批次操作", + clickToEdit: "點擊以編輯", + clickToEditAssignee: "點擊以編輯負責人", + emptyAssignee: "(留空 = 取消指派)", + columnLabels: { + triage: "待分類", + todo: "待辦", + ready: "就緒", + running: "進行中", + blocked: "已封鎖", + done: "已完成", + archived: "已封存", + }, + columnHelp: { + triage: "原始想法 — 規格制定者將完善規格", + todo: "等待相依項目或尚未指派", + ready: "已指派,等待排程器輪詢", + running: "已被工作者領取 — 執行中", + blocked: "工作者請求人工輸入", + done: "已完成", + archived: "已封存", + }, + confirmDone: + "將此任務標記為完成?工作者的領取將被釋放,下層相依任務將變為就緒。", + confirmArchive: + "封存此任務?它將從預設看板檢視中消失。", + confirmBlocked: + "將此任務標記為已封鎖?工作者的領取將被釋放。", + completionSummary: + "{label} 的完成摘要。這將作為任務結果儲存。", + completionSummaryRequired: + "在將任務標記為完成之前,必須提供完成摘要。", + triagePlaceholder: "粗略的想法 — AI 將完善規格…", + taskTitlePlaceholder: "新任務標題…", + specifier: "規格制定者", + assigneePlaceholder: "負責人", + priority: "優先順序", + skillsPlaceholder: + "技能(選填,以逗號分隔):translation、github-code-review", + noParent: "— 無上層任務 —", + workspacePathDir: "工作區路徑(必填,例如 ~/projects/my-app)", + workspacePathOptional: + "工作區路徑(選填,留空則依負責人推導)", + logTruncated: "(顯示最後 100 KB — 完整日誌位於 ", + logAt: ")", + }, +}; diff --git a/apps/dashboard/src/i18n/zh.ts b/apps/dashboard/src/i18n/zh.ts index b64de0661f3..0a8ceb7962a 100644 --- a/apps/dashboard/src/i18n/zh.ts +++ b/apps/dashboard/src/i18n/zh.ts @@ -421,4 +421,272 @@ export const zh: Translations = { title: "主题", switchTheme: "切换主题", }, + + achievements: { + hero: { + kicker: "Agentic Gamerscore", + title: "Hermes Achievements", + subtitle: + "从真实会话历史中获得的 Hermes 可收集徽章。已知尚未达成的成就显示为「已发现」;秘密成就在首次出现匹配行为之前保持隐藏。", + scan_subtitle: + "正在扫描 Hermes 会话历史。在历史记录较多时,首次扫描可能需要 5–10 秒。", + }, + actions: { + rescan: "重新扫描", + }, + stats: { + unlocked: "已解锁", + unlocked_hint: "获得的徽章", + discovered: "已发现", + discovered_hint: "已知,但尚未获得", + secrets: "秘密", + secrets_hint: "在首次信号出现前保持隐藏", + highest_tier: "最高等级", + highest_tier_hint: "Copper → Silver → Gold → Diamond → Olympian", + latest: "最新", + latest_hint_empty: "多多运行 Hermes", + none_yet: "暂无", + }, + state: { + unlocked: "已解锁", + discovered: "已发现", + secret: "秘密", + }, + tier: { + target: "目标 {tier}", + hidden: "隐藏", + complete: "已完成", + objective: "目标", + }, + progress: { + hidden: "隐藏", + }, + scan: { + building_headline: "正在构建成就档案…", + building_detail: + "正在读取会话、工具调用、模型元数据和解锁状态。", + starting_headline: "正在开始成就扫描…", + progress_detail: + "已扫描 {scanned} / {total} 个会话 · {pct}%。随着更多历史流入,徽章会陆续解锁。", + idle_detail: + "正在读取会话、工具调用、模型元数据和解锁状态。徽章解锁后将在此显示。", + }, + guide: { + tiers_header: "等级", + secret_header: "秘密成就", + secret_body: + "秘密成就会隐藏其确切触发条件。一旦 Hermes 检测到相关信号,卡片将变为「已发现」并显示其要求。", + scan_status_header: "扫描状态", + scan_status_body: + "Hermes 正在对本地历史进行一次扫描,之后卡片会自动出现。即使这需要几秒钟,也没有卡住。", + what_scanned_header: "扫描内容", + what_scanned_body: + "会话、工具调用、模型元数据、错误、成就和本地解锁状态。", + }, + card: { + share_title: "分享此成就", + share_label: "分享 {name}", + share_text: "分享", + how_to_reveal: "如何揭示", + what_counts: "计入条件", + evidence_label: "证据", + evidence_session_fallback: "会话", + no_evidence: "暂无证据", + }, + latest: { + header: "最近解锁", + }, + empty: { + no_secrets_header: "本次扫描中已没有隐藏的秘密。", + no_secrets_body: + "提示:秘密通常源于异常失败或高级用户行为模式 —— 端口冲突、权限阻拦、缺少环境变量、YAML 错误、Docker 冲突、回滚或检查点使用、缓存命中,或在大量红色错误后做出的小小修复。", + }, + filters: { + all_categories: "全部", + visibility_all: "全部", + visibility_unlocked: "已解锁", + visibility_discovered: "已发现", + visibility_secret: "秘密", + }, + share: { + dialog_label: "分享成就", + header: "分享:{name}", + close: "关闭", + rendering: "渲染中…", + card_alt: "{name} 分享卡片", + error_generic: "发生错误。", + x_title: "在 X 中打开预填好的帖子", + x_button: "在 X 上分享", + copy_title: "复制图片以粘贴到你的帖子中", + copy_button: "复制图片", + copied: "已复制 ✓", + download_button: "下载 PNG", + hint: + "「在 X 上分享」会在新标签页中打开预填好的帖子。如果想附上 1200×630 的徽章,请先点击「复制图片」—— X 允许你直接粘贴到推文编辑器中。「下载 PNG」会将文件保存下来,可在任意位置使用。", + clipboard_unsupported: + "此浏览器不支持复制剪贴板图片 —— 请改用「下载」。", + tweet_text: "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", + }, + }, + + kanban: { + loading: "正在加载看板…", + loadFailed: "加载看板失败:", + loadFailedHint: + "后端会在首次读取时自动创建 kanban.db。如果问题持续,请检查仪表盘日志。", + board: "看板", + newBoard: "+ 新建看板", + newBoardTitle: "新建看板", + newBoardDescription: + "看板可以将不相关的工作流分开——每个项目、代码库或域一个看板。一个看板上的工作者不会看到另一个看板的任务。", + slug: "标识", + slugHint: "— 小写字母、连字符,例如 atm10-server", + displayName: "显示名称", + displayNameHint: "(可选)", + description: "描述", + descriptionHint: "(可选)", + icon: "图标", + iconHint: "(单个字符或表情)", + switchAfterCreate: "创建后切换到此看板", + cancel: "取消", + creating: "创建中…", + createBoard: "创建看板", + search: "搜索", + filterCards: "筛选卡片…", + tenant: "租户", + allTenants: "全部租户", + assignee: "负责人", + allProfiles: "全部配置", + showArchived: "显示已归档", + lanesByProfile: "按配置分组", + nudgeDispatcher: "触发调度器", + refresh: "刷新", + selected: "已选中", + complete: "完成", + archive: "归档", + apply: "应用", + clear: "清除", + createTask: "在此列创建任务", + noTasks: "— 无任务 —", + unassigned: "未分配", + untitled: "(无标题)", + loadingDetail: "加载中…", + addComment: "添加评论…(按回车提交)", + comment: "评论", + status: "状态", + workspace: "工作区", + skills: "技能", + createdBy: "创建者", + result: "结果", + comments: "评论", + events: "事件", + runHistory: "运行历史", + workerLog: "工作日志", + loadingLog: "正在加载日志…", + noWorkerLog: + "— 暂无工作日志(任务尚未启动或日志已被轮转)—", + noDescription: "— 无描述 —", + noComments: "— 无评论 —", + edit: "编辑", + save: "保存", + dependencies: "依赖", + parents: "父任务:", + children: "子任务:", + none: "无", + addParent: "— 添加父任务 —", + addChild: "— 添加子任务 —", + removeDependency: "移除依赖", + block: "阻塞", + unblock: "解除阻塞", + notifyHomeChannels: "通知主页频道", + diagnostics: "诊断", + hide: "隐藏", + show: "显示", + attention: "注意", + tasksNeedAttention: "个任务需要关注", + taskNeedsAttention: "1 个任务需要关注", + diagnostic: "诊断", + open: "打开", + close: "关闭 (Esc)", + reassignTo: "重新分配给:", + copied: "已复制", + copyCommand: "复制命令到剪贴板", + reclaim: "收回", + reassign: "重新分配", + renderingError: "看板标签页发生渲染错误", + reloadView: "重新加载视图", + wsAuthFailed: + "WebSocket 认证失败 — 请刷新页面以更新会话令牌。", + markDone: "将 {n} 个任务标记为完成?", + markArchived: "归档 {n} 个任务?", + warning: "警告", + phantomIds: "幽灵 ID:", + active: "运行中", + ended: "已结束", + noProfile: "(无配置)", + showAllAttempts: "显示所有尝试", + sendingUpdates: "正在发送更新到", + sendNotifications: "发送完成 / 阻塞 / 放弃通知到", + archiveBoardConfirm: + "归档看板 '{name}'?它将被移动到 boards/_archived/ 以便稍后恢复。此看板上的任务将不再出现在 UI 中的任何地方。", + archiveBoardTitle: "归档此看板", + boardSwitcherHint: "看板可以将不相关的工作流分开", + taskCreatedWarning: "任务已创建,但:", + moveFailed: "移动失败:", + bulkFailed: "批量操作:", + completionBlockedHallucination: "⚠ 完成被阻塞 — 幽灵卡片 ID", + suspectedHallucinatedReferences: "⚠ 文本引用了幽灵卡片 ID", + pickProfileFirst: "请先选择一个配置。", + unblockedMessage: "已解除阻塞 {id}。任务已准备好进入下一轮调度。", + unblockFailed: "解除阻塞失败:", + reclaimedMessage: "已收回 {id}。任务已回到就绪状态。", + reclaimFailed: "收回失败:", + reassignedMessage: "已将 {id} 重新分配给 {profile}。", + reassignFailed: "重新分配失败:", + selectForBulk: "选择以进行批量操作", + clickToEdit: "点击编辑", + clickToEditAssignee: "点击编辑负责人", + emptyAssignee: "(留空 = 取消分配)", + columnLabels: { + triage: "待分类", + todo: "待办", + ready: "就绪", + running: "进行中", + blocked: "阻塞", + done: "已完成", + archived: "已归档", + }, + columnHelp: { + triage: "原始想法 — 规范制定者将完善规格", + todo: "等待依赖项或未分配", + ready: "已分配,等待调度器轮询", + running: "已被工作者认领 — 执行中", + blocked: "工作者请求人工输入", + done: "已完成", + archived: "已归档", + }, + confirmDone: + "将此任务标记为完成?工作者将被释放,依赖的子任务将变为就绪。", + confirmArchive: + "归档此任务?它将从默认看板视图中消失。", + confirmBlocked: + "将此任务标记为阻塞?工作者将被释放。", + completionSummary: + "{label} 的完成摘要。这将作为任务结果存储。", + completionSummaryRequired: + "在将任务标记为完成之前,必须提供完成摘要。", + triagePlaceholder: "粗略想法 — AI 将完善规格…", + taskTitlePlaceholder: "新任务标题…", + specifier: "规范制定者", + assigneePlaceholder: "负责人", + priority: "优先级", + skillsPlaceholder: + "技能(可选,逗号分隔):翻译、github-code-review", + noParent: "— 无父任务 —", + workspacePathDir: "工作区路径(必填,例如 ~/projects/my-app)", + workspacePathOptional: + "工作区路径(可选,留空则根据负责人推导)", + logTruncated: "(显示最后 100 KB — 完整日志位于 ", + logAt: ")", + }, }; diff --git a/batch_runner.py b/batch_runner.py index 9d6838288d4..a67037171bf 100644 --- a/batch_runner.py +++ b/batch_runner.py @@ -795,7 +795,7 @@ class BatchRunner: conversations = entry.get("conversations", []) for msg in conversations: role = msg.get("role") or msg.get("from") - if role in ("user", "human"): + if role in {"user", "human"}: prompt_text = (msg.get("content") or msg.get("value", "")).strip() break diff --git a/cli-config.yaml.example b/cli-config.yaml.example index b611b395755..6daceba04a9 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -203,6 +203,12 @@ terminal: # docker_forward_env: # - "GITHUB_TOKEN" # - "NPM_TOKEN" +# # Optional: extra flags passed verbatim to docker run (appended after security defaults). +# # Useful for adding capabilities (e.g. apt installs needing SETUID) or custom options. +# # Example: add a Linux capability not included by default +# # docker_extra_args: +# # - "--cap-add" +# # - "SETUID" # ----------------------------------------------------------------------------- # OPTION 4: Singularity/Apptainer container @@ -947,6 +953,9 @@ display: # false: Wait for the full response before rendering streaming: true + # Show [HH:MM] timestamps on user input and assistant response labels. + # timestamps: false + # ─────────────────────────────────────────────────────────────────────────── # Skin / Theme # ─────────────────────────────────────────────────────────────────────────── diff --git a/cli.py b/cli.py index 488d505b70b..7843882c2c4 100644 --- a/cli.py +++ b/cli.py @@ -87,6 +87,11 @@ from agent.usage_pricing import ( format_duration_compact, format_token_count_compact, ) +from agent.markdown_tables import ( + is_table_divider, + looks_like_table_row, + realign_markdown_tables, +) # NOTE: `from agent.account_usage import ...` is deliberately NOT at module # top — it transitively pulls the OpenAI SDK chain (~230 ms cold) and is only # needed when the user runs `/limits`. Lazy-imported inside the handler below. @@ -1355,12 +1360,21 @@ def _render_final_assistant_content(text: str, mode: str = "render"): normalized_mode = str(mode or "render").strip().lower() if normalized_mode == "strip": - return _RichText(_strip_markdown_syntax(text)) + # Strip first — inline markdown inside cells (`code`, **bold**, ~~strike~~) + # changes cell display width — then re-align so the column padding + # reflects the final visible text, not the marker-decorated source. + return _RichText(realign_markdown_tables(_strip_markdown_syntax(text))) if normalized_mode == "raw": return _rich_text_from_ansi(text or "") + # `render` mode: Rich's Markdown renderer handles CJK width via wcwidth + # internally, so a pre-pass through realign_markdown_tables would just + # rewrite already-correct padding. But on the way in we still want to + # normalise model-emitted under-padded tables so that mid-render fallbacks + # (narrow panels, etc.) at least see consistent input. plain = _rich_text_from_ansi(text or "").plain plain = _preserve_windows_dot_segments_for_markdown(plain) + plain = realign_markdown_tables(plain) return Markdown(plain) @@ -1727,7 +1741,7 @@ def _detect_file_drop(user_input: str) -> "dict | None": or stripped.startswith("./") or stripped.startswith("../") or stripped.startswith("file://") - or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in ("\\", "/") and stripped[0].isalpha()) + or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in {"\\", "/"} and stripped[0].isalpha()) or stripped.startswith('"/') or stripped.startswith('"~') or stripped.startswith("'/") @@ -1736,7 +1750,7 @@ def _detect_file_drop(user_input: str) -> "dict | None": or stripped.startswith('"../') or stripped.startswith("'./") or stripped.startswith("'../") - or (len(stripped) >= 4 and stripped[0] in ("'", '"') and stripped[2] == ":" and stripped[3] in ("\\", "/") and stripped[1].isalpha()) + or (len(stripped) >= 4 and stripped[0] in {"'", '"'} and stripped[2] == ":" and stripped[3] in {"\\", "/"} and stripped[1].isalpha()) ) if not starts_like_path: return None @@ -2300,6 +2314,8 @@ class HermesCLI: # streaming: stream tokens to the terminal as they arrive (display.streaming in config.yaml) self.streaming_enabled = CLI_CONFIG["display"].get("streaming", False) + # show_timestamps: prefix user and assistant labels with [HH:MM] + self.show_timestamps = CLI_CONFIG["display"].get("timestamps", False) self.final_response_markdown = str( CLI_CONFIG["display"].get("final_response_markdown", "strip") ).strip().lower() or "strip" @@ -2329,6 +2345,12 @@ class HermesCLI: self._stream_started = False # True once first delta arrives self._stream_box_opened = False # True once the response box header is printed self._reasoning_preview_buf = "" # Coalesce tiny reasoning chunks for [thinking] output + # Table-row buffer. When a streamed line looks like it could be + # part of a markdown table, hold it here until the block ends so + # we can re-pad with wcwidth-aware widths. Empty by default; + # populated only while `_in_stream_table` is True. + self._stream_table_buf: list[str] = [] + self._in_stream_table = False self._pending_edit_snapshots = {} self._last_input_mode_recovery = 0.0 self._input_mode_recovery_notice_shown = False @@ -2465,7 +2487,7 @@ class HermesCLI: _or_cfg = CLI_CONFIG.get("openrouter", {}) or {} _raw_score = _or_cfg.get("min_coding_score") self._openrouter_min_coding_score: Optional[float] = None - if _raw_score not in (None, ""): + if _raw_score not in {None, ""}: try: _f = float(_raw_score) if 0.0 <= _f <= 1.0: @@ -2556,6 +2578,8 @@ class HermesCLI: self._approval_state = None self._approval_deadline = 0 self._approval_lock = threading.Lock() + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 self._model_picker_state = None self._secret_state = None self._secret_deadline = 0 @@ -3315,9 +3339,13 @@ class HermesCLI: def _format_submitted_user_message_preview(self, user_input: str) -> str: """Format the submitted user-message scrollback preview.""" + ts_suffix = ( + f" [dim]{datetime.now().strftime('%H:%M')}[/]" + if getattr(self, "show_timestamps", False) else "" + ) lines = user_input.split("\n") if len(lines) <= 1: - return f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]" + return f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]{ts_suffix}" first_lines = int(getattr(self, "user_message_preview_first_lines", 2)) last_lines = int(getattr(self, "user_message_preview_last_lines", 2)) @@ -3334,7 +3362,7 @@ class HermesCLI: tail = [] preview_lines = [ - f"[bold {_accent_hex()}]●[/] [bold]{_escape(head[0])}[/]" + f"[bold {_accent_hex()}]●[/] [bold]{_escape(head[0])}[/]{ts_suffix}" ] preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in head[1:]) @@ -3606,6 +3634,8 @@ class HermesCLI: self._stream_text_ansi = f"\033[38;2;{_r};{_g};{_b}m" except (ValueError, IndexError): self._stream_text_ansi = "" + if self.show_timestamps: + label = f"{label} {datetime.now().strftime('%H:%M')}" w = shutil.get_terminal_size().columns fill = w - 2 - len(label) _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") @@ -3614,11 +3644,51 @@ class HermesCLI: # Emit complete lines, keep partial remainder in buffer _tc = getattr(self, "_stream_text_ansi", "") + + def _emit_one(printed_line: str) -> None: + _cprint(f"{_STREAM_PAD}{_tc}{printed_line}{_RST}" if _tc else f"{_STREAM_PAD}{printed_line}") + + def _flush_table_buf() -> None: + buf = self._stream_table_buf + self._stream_table_buf = [] + self._in_stream_table = False + if not buf: + return + # Strip cell-level markdown (`code`, **bold**, ~~strike~~) FIRST + # so the realigner pads to the final visible cell width, not + # the marker-decorated source width. Otherwise a body row + # like `` | Bold | `**bold**` | `` lands narrower than its + # header column once the markers are removed. + joined = "\n".join(buf) + if self.final_response_markdown == "strip": + joined = _strip_markdown_syntax(joined) + block = realign_markdown_tables(joined) + for ln in block.split("\n"): + _emit_one(ln) + while "\n" in self._stream_buf: line, self._stream_buf = self._stream_buf.split("\n", 1) + + # Hold table-shaped lines in a side-buffer so we can re-pad + # the whole block once it ends. Streaming line-by-line, we + # cannot re-align mid-table without reflowing already-printed + # rows; the cost is that the user sees the table appear in a + # single batch when the block closes instead of row-by-row. + if self._in_stream_table: + if looks_like_table_row(line) or is_table_divider(line): + self._stream_table_buf.append(line) + continue + # Block ended — flush the realigned table, then fall + # through to print the current (non-table) line. + _flush_table_buf() + elif looks_like_table_row(line): + self._stream_table_buf.append(line) + self._in_stream_table = True + continue + if self.final_response_markdown == "strip": line = _strip_markdown_syntax(line) - _cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}") + _emit_one(line) def _flush_stream(self) -> None: """Emit any remaining partial line from the stream buffer and close the box.""" @@ -3633,8 +3703,34 @@ class HermesCLI: # Close reasoning box if still open (in case no content tokens arrived) self._close_reasoning_box() + _tc = getattr(self, "_stream_text_ansi", "") + + # If the stream buffer has a trailing partial line that looks like + # a table row, fold it into the table buffer so the whole block + # gets re-aligned together. Otherwise the final row prints raw + # (with the model's original under-padded spacing) while the rows + # above it are aligned. + if ( + self._stream_buf + and getattr(self, "_in_stream_table", False) + and (looks_like_table_row(self._stream_buf) or is_table_divider(self._stream_buf)) + ): + self._stream_table_buf.append(self._stream_buf) + self._stream_buf = "" + + # Flush any buffered table rows first so their padding is + # finalised before the stream remainder lands. + if getattr(self, "_stream_table_buf", None): + joined = "\n".join(self._stream_table_buf) + self._stream_table_buf = [] + self._in_stream_table = False + if self.final_response_markdown == "strip": + joined = _strip_markdown_syntax(joined) + block = realign_markdown_tables(joined) + for ln in block.split("\n"): + _cprint(f"{_STREAM_PAD}{_tc}{ln}{_RST}" if _tc else f"{_STREAM_PAD}{ln}") + if self._stream_buf: - _tc = getattr(self, "_stream_text_ansi", "") line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == "strip" else self._stream_buf _cprint(f"{_STREAM_PAD}{_tc}{line}{_RST}" if _tc else f"{_STREAM_PAD}{line}") self._stream_buf = "" @@ -3657,6 +3753,8 @@ class HermesCLI: self._reasoning_buf = "" self._reasoning_preview_buf = "" self._deferred_content = "" + self._stream_table_buf = [] + self._in_stream_table = False def _slow_command_status(self, command: str) -> str: """Return a user-facing status message for slower slash commands.""" @@ -3707,7 +3805,7 @@ class HermesCLI: if self._command_running: _cprint(f"{_DIM}Wait for the current command to finish before opening the editor.{_RST}") return False - if self._sudo_state or self._secret_state or self._approval_state or self._clarify_state: + if self._sudo_state or self._secret_state or self._approval_state or getattr(self, "_slash_confirm_state", None) or self._clarify_state: _cprint(f"{_DIM}Finish the active prompt before opening the editor.{_RST}") return False target_buffer = buffer or getattr(app, "current_buffer", None) @@ -4565,7 +4663,7 @@ class HermesCLI: parts = command.split() subcmd = parts[1].lower() if len(parts) > 1 else "list" - if subcmd in ("list", "ls"): + if subcmd in {"list", "ls"}: snaps = list_quick_snapshots() if not snaps: print(" No state snapshots yet.") @@ -4593,7 +4691,7 @@ class HermesCLI: else: print(" No state files found to snapshot.") - elif subcmd in ("restore", "rewind"): + elif subcmd in {"restore", "rewind"}: if len(parts) < 3: print(" Usage: /snapshot restore ") # Show hint with most recent snapshot @@ -5132,7 +5230,7 @@ class HermesCLI: parts = cmd.split() subcommand = parts[1] if len(parts) > 1 else "" - if subcommand not in ("list", "disable", "enable"): + if subcommand not in {"list", "disable", "enable"}: self.show_tools() return @@ -5484,6 +5582,156 @@ class HermesCLI: else: print("(^_^)v New session started!") + def _handle_handoff_command(self, cmd_original: str) -> bool: + """Handle ``/handoff `` — transfer this CLI session to a gateway platform. + + Flow: + 1. Validate platform name + the gateway has a home channel for it. + 2. Reject if the agent is currently running (the in-flight turn + would race with the gateway's switch_session). + 3. Write ``handoff_state='pending'`` on this session row. + 4. Block-poll ``state.db`` for terminal state (timeout 60s). + 5. On ``completed`` → print resume hint and signal CLI exit by + returning False (the caller honors that like ``/quit``). + 6. On ``failed`` / timeout → print error and return True so the + user keeps their CLI session. + + Returns: + False to signal CLI exit, True to keep going. + """ + from hermes_state import format_session_db_unavailable + + parts = cmd_original.split(maxsplit=1) + if len(parts) < 2 or not parts[1].strip(): + _cprint(" Usage: /handoff ") + _cprint(" Hands the current session off to that platform's home channel.") + _cprint(" The CLI session ends here; resume it later with /resume.") + return True + + platform_name = parts[1].strip().lower() + + # Validate platform name + home channel via the live gateway config. + try: + from gateway.config import load_gateway_config, Platform + except Exception as exc: # pragma: no cover — gateway pkg always shipped + _cprint(f" Could not load gateway config: {exc}") + return True + + try: + platform = Platform(platform_name) + except (ValueError, KeyError): + _cprint(f" Unknown platform '{platform_name}'.") + return True + + try: + gw_config = load_gateway_config() + except Exception as exc: + _cprint(f" Could not load gateway config: {exc}") + return True + + pcfg = gw_config.platforms.get(platform) + if not pcfg or not pcfg.enabled: + _cprint(f" Platform '{platform_name}' is not configured/enabled in the gateway.") + return True + + home = gw_config.get_home_channel(platform) + if not home or not home.chat_id: + _cprint(f" No home channel configured for {platform_name}.") + _cprint(f" Set one with /sethome on the destination chat first.") + return True + + # Refuse mid-turn: an in-flight agent run would race with the + # gateway's switch_session and the synthetic turn dispatch. + if getattr(self, "_agent_running", False): + _cprint(" Agent is busy. Wait for the current turn to finish, then retry /handoff.") + return True + + # Make sure we have a SessionDB handle. + if not self._session_db: + try: + from hermes_state import SessionDB + self._session_db = SessionDB() + except Exception: + pass + if not self._session_db: + _cprint(f" {format_session_db_unavailable()}") + return True + + # Make sure the session row exists in state.db. Most CLI sessions + # are written via _flush_messages_to_session_db on the first turn + # already, but if the user tries to hand off an empty session we + # still want a row to mark. + try: + row = self._session_db.get_session(self.session_id) + if not row: + # Nothing has flushed yet. Create a stub so the gateway has + # something to switch_session onto. Inserting via title-set + # is the simplest path because set_session_title's INSERT OR + # IGNORE creates the row. + placeholder_title = f"handoff-{self.session_id[:8]}" + self._session_db.set_session_title(self.session_id, placeholder_title) + except Exception as exc: + _cprint(f" Could not ensure session row in state.db: {exc}") + return True + + # Display title for messaging. + session_title = "" + try: + row = self._session_db.get_session(self.session_id) + if row: + session_title = row.get("title") or "" + except Exception: + pass + if not session_title: + session_title = self.session_id[:8] + + # Mark pending — gateway watcher will pick this up. + ok = self._session_db.request_handoff(self.session_id, platform_name) + if not ok: + _cprint(" Session is already in flight for handoff. Wait for it to settle, then retry.") + return True + + _cprint(f" Queued handoff of '{session_title}' → {platform_name} (home: {home.name}).") + _cprint(f" Waiting for the gateway to pick it up...") + + # Poll-block on terminal state. Tick every 0.5s; bail at ~60s. + import time as _time + deadline = _time.time() + 60.0 + last_state = "pending" + while _time.time() < deadline: + try: + state_row = self._session_db.get_handoff_state(self.session_id) + except Exception: + state_row = None + current = (state_row or {}).get("state") or "pending" + if current != last_state: + if current == "running": + _cprint(" Gateway picked it up; transferring...") + last_state = current + if current == "completed": + _cprint("") + _cprint(f" ↻ Handoff complete. The session is now active on {platform_name}.") + _cprint(f" Resume it on this CLI later with: /resume {session_title}") + _cprint("") + # End the CLI cleanly — same exit semantics as /quit. + self._should_exit = True + return False + if current == "failed": + err = (state_row or {}).get("error") or "unknown error" + _cprint(f" Handoff failed: {err}") + _cprint(" Your CLI session is intact. Try /handoff again, or /resume on the platform manually.") + return True + _time.sleep(0.5) + + # Timed out. Clear the pending flag so the user can retry. + try: + self._session_db.fail_handoff(self.session_id, "timed out waiting for gateway") + except Exception: + pass + _cprint(" Timed out waiting for the gateway. Is `hermes gateway` running?") + _cprint(" Your CLI session is intact.") + return True + def _handle_resume_command(self, cmd_original: str) -> None: """Handle /resume — switch to a previous session mid-conversation.""" parts = cmd_original.split(None, 1) @@ -5858,7 +6106,17 @@ class HermesCLI: return result[0] def _prompt_text_input(self, prompt_text: str) -> str | None: - """Prompt for free-text input safely inside or outside prompt_toolkit.""" + """Prompt for free-text input safely inside or outside prompt_toolkit. + + Mirrors the thread-aware guard in ``_run_curses_picker``: ``run_in_terminal`` + returns a coroutine that must be awaited by the prompt_toolkit event loop, + which only exists on the main thread. Slash commands are dispatched from + the ``process_loop`` daemon thread (see issue #23185), so calling + ``run_in_terminal`` from there orphans the coroutine — ``_ask`` never runs, + and user keystrokes leak into the composer instead. Fall back to a direct + ``input()`` when we're off the main thread. + """ + import threading result = [None] def _ask(): @@ -5867,13 +6125,23 @@ class HermesCLI: except (KeyboardInterrupt, EOFError): pass - if self._app: + in_main_thread = threading.current_thread() is threading.main_thread() + + if self._app and in_main_thread: from prompt_toolkit.application import run_in_terminal was_visible = self._status_bar_visible self._status_bar_visible = False self._app.invalidate() try: run_in_terminal(_ask) + except Exception: + # WSL / Warp / certain terminal emulators silently drop the + # scheduled coroutine. Fall back to a direct input() so the + # user's keystrokes don't leak into the agent buffer. + try: + _ask() + except Exception: + pass finally: self._status_bar_visible = was_visible self._app.invalidate() @@ -5881,6 +6149,194 @@ class HermesCLI: _ask() return result[0] + def _prompt_text_input_modal( + self, + *, + title: str, + detail: str, + choices: list[tuple[str, str, str]], + timeout: float = 120, + ) -> str | None: + """Prompt through the prompt_toolkit composer instead of raw input(). + + This is for CLI slash-command confirmations. The old raw input() path + fought prompt_toolkit's active stdin ownership: in some terminals the + prompt appeared above the TUI, choices were redrawn later, and Enter + could be interpreted as EOF/exit. A first-class modal state keeps the + choices visible and lets the normal Enter key binding submit the typed + or highlighted choice. + """ + import time as _time + + if not choices: + return None + + # If prompt_toolkit is not running (unit tests / non-interactive calls), + # keep the simple stdin fallback. + if not getattr(self, "_app", None): + return self._prompt_text_input("Choice [1/2/3]: ") + + response_queue = queue.Queue() + self._capture_modal_input_snapshot() + self._slash_confirm_state = { + "title": title, + "detail": detail, + "choices": choices, + "selected": 0, + "response_queue": response_queue, + } + self._slash_confirm_deadline = _time.monotonic() + timeout + self._invalidate() + + _last_countdown_refresh = _time.monotonic() + try: + while True: + try: + result = response_queue.get(timeout=1) + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 + self._restore_modal_input_snapshot() + self._invalidate() + return result + except queue.Empty: + remaining = self._slash_confirm_deadline - _time.monotonic() + if remaining <= 0: + break + now = _time.monotonic() + if now - _last_countdown_refresh >= 5.0: + _last_countdown_refresh = now + self._invalidate() + finally: + if self._slash_confirm_state is not None: + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 + self._restore_modal_input_snapshot() + self._invalidate() + return None + + def _submit_slash_confirm_response(self, value: str | None) -> None: + state = self._slash_confirm_state + if not state: + return + state["response_queue"].put(value) + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 + self._invalidate() + + def _normalize_slash_confirm_choice( + self, + raw: str | None, + choices: list[tuple[str, str, str]], + ) -> str | None: + if raw is None: + return None + choice_raw = raw.strip().lower() + if not choice_raw: + return None + aliases = { + "1": "once", + "once": "once", + "approve": "once", + "yes": "once", + "y": "once", + "ok": "once", + "2": "always", + "always": "always", + "remember": "always", + "3": "cancel", + "cancel": "cancel", + "nevermind": "cancel", + "no": "cancel", + "n": "cancel", + } + allowed = {choice[0] for choice in choices} + normalized = aliases.get(choice_raw) + if normalized in allowed: + return normalized + if choice_raw in allowed: + return choice_raw + return None + + def _get_slash_confirm_display_fragments(self): + """Render the /new-/clear-style confirmation panel.""" + state = self._slash_confirm_state + if not state: + return [] + + title = state.get("title") or "Confirm action" + detail = state.get("detail") or "" + choices = state.get("choices") or [] + selected = state.get("selected", 0) + + def _panel_box_width(title_text: str, content_lines: list[str], min_width: int = 56, max_width: int = 86) -> int: + term_cols = shutil.get_terminal_size((100, 20)).columns + longest = max([len(title_text)] + [len(line) for line in content_lines] + [min_width - 4]) + inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6)) + return inner + 2 + + def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "") -> list[str]: + wrapped = textwrap.wrap( + text, + width=max(8, width), + replace_whitespace=False, + drop_whitespace=False, + subsequent_indent=subsequent_indent, + ) + return wrapped or [""] + + def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None: + inner_width = max(0, box_width - 2) + lines.append((border_style, "│ ")) + lines.append((content_style, text.ljust(inner_width))) + lines.append((border_style, " │\n")) + + def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None: + lines.append((border_style, "│" + (" " * box_width) + "│\n")) + + preview_lines = [] + for line in detail.splitlines(): + preview_lines.extend(_wrap_panel_text(line, 72)) + for idx, (_value, label, desc) in enumerate(choices): + marker = "❯" if idx == selected else " " + preview_lines.extend(_wrap_panel_text(f"{marker} [{idx + 1}] {label} — {desc}", 72, subsequent_indent=" ")) + preview_lines.append("Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.") + + box_width = _panel_box_width(title, preview_lines) + inner_text_width = max(8, box_width - 2) + detail_wrapped = [] + for line in detail.splitlines(): + detail_wrapped.extend(_wrap_panel_text(line, inner_text_width)) + choice_wrapped: list[tuple[int, str]] = [] + for idx, (_value, label, desc) in enumerate(choices): + marker = "❯" if idx == selected else " " + for wrapped in _wrap_panel_text(f"{marker} [{idx + 1}] {label} — {desc}", inner_text_width, subsequent_indent=" "): + choice_wrapped.append((idx, wrapped)) + + term_rows = shutil.get_terminal_size((100, 24)).lines + reserved_below = 6 + chrome_full = 6 + available = max(0, term_rows - reserved_below) + max_detail_rows = max(1, available - chrome_full - len(choice_wrapped)) + max_detail_rows = min(max_detail_rows, 8) + if len(detail_wrapped) > max_detail_rows: + keep = max(1, max_detail_rows - 1) + detail_wrapped = detail_wrapped[:keep] + ["… (detail truncated)"] + + lines = [] + lines.append(('class:approval-border', '╭' + ('─' * box_width) + '╮\n')) + _append_panel_line(lines, 'class:approval-border', 'class:approval-title', title, box_width) + _append_blank_panel_line(lines, 'class:approval-border', box_width) + for wrapped in detail_wrapped: + _append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width) + _append_blank_panel_line(lines, 'class:approval-border', box_width) + for idx, wrapped in choice_wrapped: + style = 'class:approval-selected' if idx == selected else 'class:approval-choice' + _append_panel_line(lines, 'class:approval-border', style, wrapped, box_width) + _append_blank_panel_line(lines, 'class:approval-border', box_width) + _append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', 'Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels.', box_width) + lines.append(('class:approval-border', '╰' + ('─' * box_width) + '╯\n')) + return lines + def _open_model_picker(self, providers: list, current_model: str, current_provider: str, user_provs=None, custom_provs=None) -> None: """Open prompt_toolkit-native /model picker modal.""" self._capture_modal_input_snapshot() @@ -6358,7 +6814,7 @@ class HermesCLI: # Set personality personality_name = parts[1].strip().lower() - if personality_name in ("none", "default", "neutral"): + if personality_name in {"none", "default", "neutral"}: self.system_prompt = "" self.agent = None # Force re-init if save_config_value("agent.system_prompt", ""): @@ -6766,7 +7222,7 @@ class HermesCLI: _cmd_def = _resolve_cmd(_base_word) canonical = _cmd_def.name if _cmd_def else _base_word - if canonical in ("quit", "exit"): + if canonical in {"quit", "exit"}: return False elif canonical == "help": self.show_help() @@ -6896,20 +7352,22 @@ class HermesCLI: _cprint(f" {format_session_db_unavailable()}") else: _cprint(" Usage: /title ") - else: - # Show current title and session ID if no argument given - if self._session_db: - _cprint(f" Session ID: {self.session_id}") - session = self._session_db.get_session(self.session_id) - if session and session.get("title"): - _cprint(f" Title: {session['title']}") - elif self._pending_title: - _cprint(f" Title (pending): {self._pending_title}") - else: - _cprint(" No title set. Usage: /title ") + # Show current title and session ID if no argument given + elif self._session_db: + _cprint(f" Session ID: {self.session_id}") + session = self._session_db.get_session(self.session_id) + if session and session.get("title"): + _cprint(f" Title: {session['title']}") + elif self._pending_title: + _cprint(f" Title (pending): {self._pending_title}") else: - from hermes_state import format_session_db_unavailable - _cprint(f" {format_session_db_unavailable()}") + _cprint(" No title set. Usage: /title ") + else: + from hermes_state import format_session_db_unavailable + _cprint(f" {format_session_db_unavailable()}") + elif canonical == "handoff": + if not self._handle_handoff_command(cmd_original): + return False elif canonical == "new": parts = cmd_original.split(maxsplit=1) title = parts[1].strip() if len(parts) > 1 else None @@ -7638,7 +8096,7 @@ class HermesCLI: ) return - if lower in ("clear", "stop", "done"): + if lower in {"clear", "stop", "done"}: had = mgr.has_goal() mgr.clear() if had: @@ -7728,7 +8186,7 @@ class HermesCLI: parts = [ p.get("text", "") for p in content - if isinstance(p, dict) and p.get("type") in ("text", "output_text") + if isinstance(p, dict) and p.get("type") in {"text", "output_text"} ] last_response = "\n".join(t for t in parts if t) else: @@ -7823,7 +8281,7 @@ class HermesCLI: current = bool(footer_cfg.get("enabled", False)) fields = footer_cfg.get("fields") or ["model", "context_pct", "cwd"] - if arg in ("status", "?"): + if arg in {"status", "?"}: state = "ON" if current else "OFF" _cprint( f" {_Colors.BOLD}Runtime footer:{_Colors.RESET} {state}\n" @@ -7831,9 +8289,9 @@ class HermesCLI: ) return - if arg in ("on", "enable", "true", "1"): + if arg in {"on", "enable", "true", "1"}: new_state = True - elif arg in ("off", "disable", "false", "0"): + elif arg in {"off", "disable", "false", "0"}: new_state = False elif arg == "": new_state = not current @@ -7926,7 +8384,7 @@ class HermesCLI: arg = parts[1].strip().lower() # Display toggle - if arg in ("show", "on"): + if arg in {"show", "on"}: self.show_reasoning = True if self.agent: self.agent.reasoning_callback = self._current_reasoning_callback() @@ -7934,7 +8392,7 @@ class HermesCLI: _cprint(f" {_ACCENT}✓ Reasoning display: ON (saved){_RST}") _cprint(f" {_DIM} Model thinking will be shown during and after each response.{_RST}") return - if arg in ("hide", "off"): + if arg in {"hide", "off"}: self.show_reasoning = False if self.agent: self.agent.reasoning_callback = self._current_reasoning_callback() @@ -8396,30 +8854,24 @@ class HermesCLI: if not confirm_required: return "once" - # Render warning + prompt — single-line composer prompt, mirrors - # ``_confirm_and_reload_mcp``. - print() - print(f"⚠️ /{command} — destroys conversation state") - print() - for line in detail.splitlines(): - print(f" {line}") - print() - print(" [1] Approve Once — proceed this time only") - print(" [2] Always Approve — proceed and silence this prompt permanently") - print(" [3] Cancel — keep current conversation") - print() - raw = self._prompt_text_input("Choice [1/2/3]: ") + # Render a prompt_toolkit-native confirmation panel. This keeps option + # labels visible above the composer and avoids raw input()/EOF races with + # the running TUI. + choices = [ + ("once", "Approve Once", "proceed this time only"), + ("always", "Always Approve", "proceed and silence this prompt permanently"), + ("cancel", "Cancel", "keep current conversation"), + ] + raw = self._prompt_text_input_modal( + title=f"⚠️ /{command} — destroys conversation state", + detail=detail, + choices=choices, + ) if raw is None: print(f"🟡 /{command} cancelled (no input).") return None - choice_raw = raw.strip().lower() - if choice_raw in ("1", "once", "approve", "yes", "y", "ok"): - choice = "once" - elif choice_raw in ("2", "always", "remember"): - choice = "always" - elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""): - choice = "cancel" - else: + choice = self._normalize_slash_confirm_choice(raw, choices) + if choice is None: print(f"🟡 Unrecognized choice '{raw}'. /{command} cancelled.") return None @@ -8464,32 +8916,28 @@ class HermesCLI: self._reload_mcp() return - # Render warning + prompt. Use a single-line prompt so the user - # sees the warning as output and types a response into the composer. - print() - print("⚠️ /reload-mcp — Prompt cache invalidation warning") - print() - print(" Reloading MCP servers rebuilds the tool set for this session and") - print(" invalidates the provider prompt cache. The next message will") - print(" re-send full input tokens (can be expensive on long-context or") - print(" high-reasoning models).") - print() - print(" [1] Approve Once — reload now") - print(" [2] Always Approve — reload now and silence this prompt permanently") - print(" [3] Cancel — leave MCP tools unchanged") - print() - raw = self._prompt_text_input("Choice [1/2/3]: ") + # Render warning + prompt. Use the same prompt_toolkit-native composer + # modal as destructive slash confirmations so choices stay visible. + choices = [ + ("once", "Approve Once", "reload now"), + ("always", "Always Approve", "reload now and silence this prompt permanently"), + ("cancel", "Cancel", "leave MCP tools unchanged"), + ] + raw = self._prompt_text_input_modal( + title="⚠️ /reload-mcp — Prompt cache invalidation warning", + detail=( + "Reloading MCP servers rebuilds the tool set for this session and\n" + "invalidates the provider prompt cache. The next message will\n" + "re-send full input tokens (can be expensive on long-context or\n" + "high-reasoning models)." + ), + choices=choices, + ) if raw is None: print("🟡 /reload-mcp cancelled (no input).") return - choice_raw = raw.strip().lower() - if choice_raw in ("1", "once", "approve", "yes", "y", "ok"): - choice = "once" - elif choice_raw in ("2", "always", "remember"): - choice = "always" - elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""): - choice = "cancel" - else: + choice = self._normalize_slash_confirm_choice(raw, choices) + if choice is None: print(f"🟡 Unrecognized choice '{raw}'. /reload-mcp cancelled.") return @@ -8706,7 +9154,7 @@ class HermesCLI: if event_type == "tool.completed": self._tool_start_time = 0.0 # Print stacked scrollback line for "all" / "new" modes - if function_name and self.tool_progress_mode in ("all", "new"): + if function_name and self.tool_progress_mode in {"all", "new"}: duration = kwargs.get("duration", 0.0) is_error = kwargs.get("is_error", False) # Pop stored args from tool.started for this function @@ -9886,6 +10334,8 @@ class HermesCLI: _streaming_box_opened = True w = self.console.width label = " ⚕ Hermes " + if self.show_timestamps: + label = f"{label}{datetime.now().strftime('%H:%M')} " fill = w - 2 - len(label) _cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}") _cprint(f"{_STREAM_PAD}{sentence.rstrip()}") @@ -10356,7 +10806,7 @@ class HermesCLI: try: from hermes_cli.profiles import get_active_profile_name profile = get_active_profile_name() - if profile not in ("default", "custom"): + if profile not in {"default", "custom"}: symbol = f"{profile} {symbol}" except Exception: pass @@ -10411,6 +10861,8 @@ class HermesCLI: return _state_fragment("class:sudo-prompt", "🔑") if self._approval_state: return _state_fragment("class:prompt-working", "⚠") + if getattr(self, "_slash_confirm_state", None): + return _state_fragment("class:prompt-working", "⚠") if self._clarify_freetext: return _state_fragment("class:clarify-selected", "✎") if self._clarify_state: @@ -10477,6 +10929,7 @@ class HermesCLI: sudo_widget, secret_widget, approval_widget, + slash_confirm_widget=None, clarify_widget, model_picker_widget=None, spinner_widget=None, @@ -10501,6 +10954,7 @@ class HermesCLI: sudo_widget, secret_widget, approval_widget, + slash_confirm_widget, clarify_widget, model_picker_widget, spinner_widget, @@ -10556,7 +11010,7 @@ class HermesCLI: # see that they're running without the safety net. try: _redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true") - if _redact_raw.lower() not in ("1", "true", "yes", "on"): + if _redact_raw.lower() not in {"1", "true", "yes", "on"}: self._console_print( "[bold red]⚠ Secret redaction is DISABLED[/] " f"(HERMES_REDACT_SECRETS={_redact_raw}). " @@ -10663,6 +11117,13 @@ class HermesCLI: self._approval_deadline = 0 self._approval_lock = threading.Lock() # serialize concurrent approval prompts (delegation race fix) + # Destructive slash-command confirmation state (/new, /clear, /undo). + # These prompts are answered through the prompt_toolkit composer, not + # raw input(), so the option labels stay visible and Enter does not EOF + # the whole app. + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 + # Slash command loading state self._command_running = False self._command_status = "" @@ -10754,6 +11215,20 @@ class HermesCLI: event.app.invalidate() return + # --- Slash-command confirmation: submit typed or highlighted choice --- + if self._slash_confirm_state: + text = event.app.current_buffer.text.strip() + choices = self._slash_confirm_state.get("choices") or [] + choice = self._normalize_slash_confirm_choice(text, choices) if text else None + if choice is None: + selected = self._slash_confirm_state.get("selected", 0) + if 0 <= selected < len(choices): + choice = choices[selected][0] + self._submit_slash_confirm_response(choice or "cancel") + event.app.current_buffer.reset() + event.app.invalidate() + return + # --- /model picker modal --- if self._model_picker_state: try: @@ -11014,6 +11489,20 @@ class HermesCLI: self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1) event.app.invalidate() + # --- Slash-command confirmation: arrow-key navigation --- + @kb.add('up', filter=Condition(lambda: bool(self._slash_confirm_state))) + def slash_confirm_up(event): + if self._slash_confirm_state: + self._slash_confirm_state["selected"] = max(0, self._slash_confirm_state.get("selected", 0) - 1) + event.app.invalidate() + + @kb.add('down', filter=Condition(lambda: bool(self._slash_confirm_state))) + def slash_confirm_down(event): + if self._slash_confirm_state: + max_idx = len(self._slash_confirm_state.get("choices") or []) - 1 + self._slash_confirm_state["selected"] = min(max_idx, self._slash_confirm_state.get("selected", 0) + 1) + event.app.invalidate() + # --- /model picker: arrow-key navigation --- @kb.add('up', filter=Condition(lambda: bool(self._model_picker_state))) def model_picker_up(event): @@ -11054,12 +11543,26 @@ class HermesCLI: _idx = 9 if _num == 0 else _num - 1 kb.add(str(_num), filter=Condition(lambda: bool(self._approval_state)))(_make_approval_number_handler(_idx)) + # Number keys for quick slash-confirm selection (1-9, 0 for 10th item) + def _make_slash_confirm_number_handler(idx): + def handler(event): + if self._slash_confirm_state and idx < len(self._slash_confirm_state.get("choices") or []): + choice = self._slash_confirm_state["choices"][idx][0] + self._submit_slash_confirm_response(choice) + event.app.current_buffer.reset() + event.app.invalidate() + return handler + + for _num in range(10): + _idx = 9 if _num == 0 else _num - 1 + kb.add(str(_num), filter=Condition(lambda: bool(self._slash_confirm_state)))(_make_slash_confirm_number_handler(_idx)) + # --- History navigation: up/down browse history in normal input mode --- # The TextArea is multiline, so by default up/down only move the cursor. # Buffer.auto_up/auto_down handle both: cursor movement when multi-line, # history browsing when on the first/last line (or single-line input). _normal_input = Condition( - lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state and not self._model_picker_state + lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state ) @kb.add('up', filter=_normal_input) @@ -11135,6 +11638,13 @@ class HermesCLI: event.app.invalidate() return + # Cancel slash confirmation prompt + if self._slash_confirm_state: + self._submit_slash_confirm_response("cancel") + event.app.current_buffer.reset() + event.app.invalidate() + return + # Cancel /model picker if self._model_picker_state: self._close_model_picker() @@ -11163,16 +11673,15 @@ class HermesCLI: self._last_ctrl_c_time = now print("\n⚡ Interrupting agent... (press Ctrl+C again to force exit)") self.agent.interrupt() + # If there's text or images, clear them (like bash). + # If everything is already empty, exit. + elif event.app.current_buffer.text or self._attached_images: + event.app.current_buffer.reset() + self._attached_images.clear() + event.app.invalidate() else: - # If there's text or images, clear them (like bash). - # If everything is already empty, exit. - if event.app.current_buffer.text or self._attached_images: - event.app.current_buffer.reset() - self._attached_images.clear() - event.app.invalidate() - else: - self._should_exit = True - event.app.exit() + self._should_exit = True + event.app.exit() # Ctrl+Shift+C: no binding needed. Terminal emulators (GNOME Terminal, # iTerm2, kitty, Windows Terminal, etc.) intercept Ctrl+Shift+C before @@ -11229,6 +11738,13 @@ class HermesCLI: event.app.invalidate() return + # Cancel slash confirmation prompt + if self._slash_confirm_state: + self._submit_slash_confirm_response("cancel") + event.app.current_buffer.reset() + event.app.invalidate() + return + # Cancel /model picker if self._model_picker_state: self._close_model_picker() @@ -11250,14 +11766,13 @@ class HermesCLI: if self._agent_running and self.agent: print("\n⚡ Interrupting agent...") self.agent.interrupt() + elif event.app.current_buffer.text or self._attached_images: + event.app.current_buffer.reset() + self._attached_images.clear() + event.app.invalidate() else: - if event.app.current_buffer.text or self._attached_images: - event.app.current_buffer.reset() - self._attached_images.clear() - event.app.invalidate() - else: - self._should_exit = True - event.app.exit() + self._should_exit = True + event.app.exit() @kb.add('c-d') def handle_ctrl_d(event): @@ -11277,7 +11792,7 @@ class HermesCLI: event.app.exit() _modal_prompt_active = Condition( - lambda: bool(self._secret_state or self._sudo_state) + lambda: bool(self._secret_state or self._sudo_state or self._slash_confirm_state) ) @kb.add('escape', filter=_modal_prompt_active, eager=True) @@ -11293,6 +11808,11 @@ class HermesCLI: self._sudo_state = None event.app.invalidate() return + if self._slash_confirm_state: + self._submit_slash_confirm_response("cancel") + event.app.current_buffer.reset() + event.app.invalidate() + return @kb.add('c-z') def handle_ctrl_z(event): @@ -11375,7 +11895,7 @@ class HermesCLI: # Guard: don't START recording during agent run or interactive prompts if cli_ref._agent_running: return - if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state: + if cli_ref._clarify_state or cli_ref._sudo_state or cli_ref._approval_state or cli_ref._slash_confirm_state: return # Guard: don't start while a previous stop/transcribe cycle is # still running — recorder.stop() holds AudioRecorder._lock and @@ -11662,6 +12182,8 @@ class HermesCLI: return "type secret (hidden), Enter to submit · ESC to skip" if cli_ref._approval_state: return "" + if cli_ref._slash_confirm_state: + return "type 1/2/3, or use ↑/↓ then Enter" if cli_ref._clarify_freetext: return "type your answer here and press Enter" if cli_ref._clarify_state: @@ -11704,6 +12226,13 @@ class HermesCLI: ('class:clarify-countdown', f' ({remaining}s)'), ] + if cli_ref._slash_confirm_state: + remaining = max(0, int(cli_ref._slash_confirm_deadline - time.monotonic())) + return [ + ('class:hint', ' type 1/2/3, or ↑/↓ to select, Enter to confirm'), + ('class:clarify-countdown', f' ({remaining}s)'), + ] + if cli_ref._clarify_state: remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic())) countdown = f' ({remaining}s)' if cli_ref._clarify_deadline else '' @@ -11726,7 +12255,7 @@ class HermesCLI: return [] def get_hint_height(): - if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running: + if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._slash_confirm_state or cli_ref._clarify_state or cli_ref._command_running: return 1 # Keep a spacer while the agent runs on roomy terminals, but reclaim # the row on narrow/mobile screens where every line matters. @@ -12030,6 +12559,17 @@ class HermesCLI: filter=Condition(lambda: cli_ref._approval_state is not None), ) + def _get_slash_confirm_display(): + return cli_ref._get_slash_confirm_display_fragments() + + slash_confirm_widget = ConditionalContainer( + Window( + FormattedTextControl(_get_slash_confirm_display), + wrap_lines=True, + ), + filter=Condition(lambda: cli_ref._slash_confirm_state is not None), + ) + # --- /model picker: display widget --- def _get_model_picker_display(): state = cli_ref._model_picker_state @@ -12175,6 +12715,7 @@ class HermesCLI: sudo_widget=sudo_widget, secret_widget=secret_widget, approval_widget=approval_widget, + slash_confirm_widget=slash_confirm_widget, clarify_widget=clarify_widget, model_picker_widget=model_picker_widget, spinner_widget=spinner_widget, diff --git a/cron/jobs.py b/cron/jobs.py index a7c87d223e1..6b3bc0e66f9 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -664,7 +664,7 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] # None both mean "clear the field" (restore old behaviour). if "workdir" in updates: _wd = updates["workdir"] - if _wd in (None, "", False): + if _wd in {None, "", False}: updates["workdir"] = None else: updates["workdir"] = _normalize_workdir(_wd) @@ -811,7 +811,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None, # schedule quietly goes off. See issue #16265. if job["next_run_at"] is None: kind = job.get("schedule", {}).get("kind") - if kind in ("cron", "interval"): + if kind in {"cron", "interval"}: job["state"] = "error" if not job.get("last_error"): job["last_error"] = ( @@ -855,7 +855,7 @@ def advance_next_run(job_id: str) -> bool: for job in jobs: if job["id"] == job_id: kind = job.get("schedule", {}).get("kind") - if kind not in ("cron", "interval"): + if kind not in {"cron", "interval"}: return False now = _hermes_now().isoformat() new_next = compute_next_run(job["schedule"], now) @@ -909,7 +909,7 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: # next_run_at unset. Without this branch, such jobs are # silently skipped forever; recompute next_run_at from the # schedule so they pick up at their next scheduled tick. - if not recovered_next and kind in ("cron", "interval"): + if not recovered_next and kind in {"cron", "interval"}: recovered_next = compute_next_run(schedule, now.isoformat()) if recovered_next: recovery_kind = kind @@ -940,7 +940,7 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]: # (gateway was down and missed the window). Fast-forward to # the next future occurrence instead of firing a stale run. grace = _compute_grace_seconds(schedule) - if kind in ("cron", "interval") and (now - next_run_dt).total_seconds() > grace: + if kind in {"cron", "interval"} and (now - next_run_dt).total_seconds() > grace: # Job is past its catch-up grace window — this is a stale missed run. # Grace scales with schedule period: daily=2h, hourly=30m, 10min=5m. new_next = compute_next_run(schedule, now.isoformat()) @@ -1082,9 +1082,8 @@ def rewrite_skill_refs( new_skills.append(target) elif name in pruned_set: dropped.append(name) - else: - if name not in new_skills: - new_skills.append(name) + elif name not in new_skills: + new_skills.append(name) if not mapped and not dropped: continue diff --git a/cron/scheduler.py b/cron/scheduler.py index 90683b6cc1c..7e39df578bb 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -754,7 +754,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: # shebang: the scripts dir is trusted, but keeping the interpreter # choice explicit here keeps the allowed surface small and auditable. suffix = path.suffix.lower() - if suffix in (".sh", ".bash"): + if suffix in {".sh", ".bash"}: # Resolve bash dynamically so Windows (Git Bash) and Linux/macOS # all work. On native Windows without Git for Windows installed # shutil.which returns None — fall back to a clear error rather diff --git a/environments/agentic_opd_env.py b/environments/agentic_opd_env.py index 44311f55144..c6ed88756bf 100644 --- a/environments/agentic_opd_env.py +++ b/environments/agentic_opd_env.py @@ -264,7 +264,7 @@ def _parse_hint_result(text: str) -> tuple[int | None, str]: """Parse the judge's boxed decision and hint text.""" boxed = _BOXED_RE.findall(text) score = int(boxed[-1]) if boxed else None - if score not in (1, -1): + if score not in {1, -1}: score = None hint_matches = _HINT_RE.findall(text) hint = hint_matches[-1].strip() if hint_matches else "" diff --git a/environments/benchmarks/terminalbench_2/terminalbench2_env.py b/environments/benchmarks/terminalbench_2/terminalbench2_env.py index 0e88ac347fa..1a76b8da61e 100644 --- a/environments/benchmarks/terminalbench_2/terminalbench2_env.py +++ b/environments/benchmarks/terminalbench_2/terminalbench2_env.py @@ -162,7 +162,7 @@ def _normalize_tar_member_parts(member_name: str) -> list: ): raise ValueError(f"Unsafe archive member path: {member_name}") - parts = [part for part in posix_path.parts if part not in ("", ".")] + parts = [part for part in posix_path.parts if part not in {"", "."}] if not parts or any(part == ".." for part in parts): raise ValueError(f"Unsafe archive member path: {member_name}") return parts @@ -561,7 +561,7 @@ class TerminalBench2EvalEnv(HermesAgentBaseEnv): # --- 5. Verify -- run test suite in the agent's sandbox --- # Skip verification if the agent produced no meaningful output only_system_and_user = all( - msg.get("role") in ("system", "user") for msg in result.messages + msg.get("role") in {"system", "user"} for msg in result.messages ) if result.turns_used == 0 or only_system_and_user: logger.warning( @@ -919,7 +919,7 @@ class TerminalBench2EvalEnv(HermesAgentBaseEnv): eval_metrics[f"eval/pass_rate_{cat_key}"] = cat_pass_rate # Store metrics for wandb_log - self.eval_metrics = [(k, v) for k, v in eval_metrics.items()] + self.eval_metrics = list(eval_metrics.items()) # ---- Print summary ---- print(f"\n{'='*60}") diff --git a/environments/benchmarks/yc_bench/yc_bench_env.py b/environments/benchmarks/yc_bench/yc_bench_env.py index 4fd22495440..6e7be2c899b 100644 --- a/environments/benchmarks/yc_bench/yc_bench_env.py +++ b/environments/benchmarks/yc_bench/yc_bench_env.py @@ -759,7 +759,7 @@ class YCBenchEvalEnv(HermesAgentBaseEnv): eval_metrics[f"eval/survival_rate_{key}"] = ps / pt if pt else 0 eval_metrics[f"eval/avg_score_{key}"] = pa - self.eval_metrics = [(k, v) for k, v in eval_metrics.items()] + self.eval_metrics = list(eval_metrics.items()) # --- Print summary --- print(f"\n{'='*60}") diff --git a/environments/hermes_base_env.py b/environments/hermes_base_env.py index ededab355f0..adefa9b7c3c 100644 --- a/environments/hermes_base_env.py +++ b/environments/hermes_base_env.py @@ -571,7 +571,7 @@ class HermesAgentBaseEnv(BaseEnv): # (e.g., API call failed on turn 1). No point spinning up a Modal sandbox # just to verify files that were never created. only_system_and_user = all( - msg.get("role") in ("system", "user") for msg in result.messages + msg.get("role") in {"system", "user"} for msg in result.messages ) if result.turns_used == 0 or only_system_and_user: logger.warning( diff --git a/environments/tool_context.py b/environments/tool_context.py index 550c5e851c1..9756dadaf7c 100644 --- a/environments/tool_context.py +++ b/environments/tool_context.py @@ -179,7 +179,7 @@ class ToolContext: # Ensure parent directory exists in the sandbox parent = str(_Path(remote_path).parent) - if parent not in (".", "/"): + if parent not in {".", "/"}: self.terminal(f"mkdir -p {parent}", timeout=10) # For small files, single command is fine diff --git a/gateway/config.py b/gateway/config.py index b3d9ca65437..8edfc6e123e 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -28,9 +28,9 @@ def _coerce_bool(value: Any, default: bool = True) -> bool: return default if isinstance(value, str): lowered = value.strip().lower() - if lowered in ("true", "1", "yes", "on"): + if lowered in {"true", "1", "yes", "on"}: return True - if lowered in ("false", "0", "no", "off"): + if lowered in {"false", "0", "no", "off"}: return False return default return is_truthy_value(value, default=default) @@ -317,14 +317,32 @@ class PlatformConfig: ) +# Streaming defaults — single source of truth so both StreamingConfig and +# StreamConsumerConfig agree on the out-of-the-box edit rhythm. Tuned for +# Telegram's ~1 edit/s flood envelope: a touch under 1s lets the cadence +# breathe without bumping into rate limits, and a smaller buffer threshold +# makes short replies feel near-instant in DMs. +DEFAULT_STREAMING_EDIT_INTERVAL: float = 0.8 +DEFAULT_STREAMING_BUFFER_THRESHOLD: int = 24 +DEFAULT_STREAMING_CURSOR: str = " ▉" + + @dataclass class StreamingConfig: """Configuration for real-time token streaming to messaging platforms.""" enabled: bool = False - transport: str = "edit" # "edit" (progressive editMessageText) or "off" - edit_interval: float = 1.0 # Seconds between message edits (Telegram rate-limits at ~1/s) - buffer_threshold: int = 40 # Chars before forcing an edit - cursor: str = " ▉" # Cursor shown during streaming + # Transport selection: + # "auto" — prefer native streaming-draft updates when the platform + # supports them (Telegram sendMessageDraft, Bot API 9.5+); + # fall back to edit-based when not. Recommended. + # "draft" — explicitly request native drafts; falls back to edit when + # the platform/chat doesn't support them. + # "edit" — progressive editMessageText only (legacy behaviour). + # "off" — disable streaming entirely. + transport: str = "auto" + edit_interval: float = DEFAULT_STREAMING_EDIT_INTERVAL + buffer_threshold: int = DEFAULT_STREAMING_BUFFER_THRESHOLD + cursor: str = DEFAULT_STREAMING_CURSOR # Ported from openclaw/openclaw#72038. When >0, the final edit for # a long-running streamed response is delivered as a fresh message # if the original preview has been visible for at least this many @@ -350,10 +368,14 @@ class StreamingConfig: return cls() return cls( enabled=_coerce_bool(data.get("enabled"), False), - transport=data.get("transport", "edit"), - edit_interval=_coerce_float(data.get("edit_interval"), 1.0), - buffer_threshold=_coerce_int(data.get("buffer_threshold"), 40), - cursor=data.get("cursor", " ▉"), + transport=data.get("transport", "auto"), + edit_interval=_coerce_float( + data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL, + ), + buffer_threshold=_coerce_int( + data.get("buffer_threshold"), DEFAULT_STREAMING_BUFFER_THRESHOLD, + ), + cursor=data.get("cursor", DEFAULT_STREAMING_CURSOR), fresh_final_after_seconds=_coerce_float( data.get("fresh_final_after_seconds"), 60.0 ), @@ -588,8 +610,7 @@ class GatewayConfig: try: session_store_max_age_days = int(data.get("session_store_max_age_days", 90)) - if session_store_max_age_days < 0: - session_store_max_age_days = 0 + session_store_max_age_days = max(session_store_max_age_days, 0) except (TypeError, ValueError): session_store_max_age_days = 90 @@ -766,11 +787,19 @@ def load_gateway_config() -> GatewayConfig: bridged["dm_policy"] = platform_cfg["dm_policy"] if "allow_from" in platform_cfg: bridged["allow_from"] = platform_cfg["allow_from"] + if "allow_admin_from" in platform_cfg: + bridged["allow_admin_from"] = platform_cfg["allow_admin_from"] + if "user_allowed_commands" in platform_cfg: + bridged["user_allowed_commands"] = platform_cfg["user_allowed_commands"] if "group_policy" in platform_cfg: bridged["group_policy"] = platform_cfg["group_policy"] if "group_allow_from" in platform_cfg: bridged["group_allow_from"] = platform_cfg["group_allow_from"] - if plat in (Platform.DISCORD, Platform.SLACK) and "channel_skill_bindings" in platform_cfg: + if "group_allow_admin_from" in platform_cfg: + bridged["group_allow_admin_from"] = platform_cfg["group_allow_admin_from"] + if "group_user_allowed_commands" in platform_cfg: + bridged["group_user_allowed_commands"] = platform_cfg["group_user_allowed_commands"] + if plat in {Platform.DISCORD, Platform.SLACK} and "channel_skill_bindings" in platform_cfg: bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] if "channel_prompts" in platform_cfg: channel_prompts = platform_cfg["channel_prompts"] @@ -1159,7 +1188,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: # Reply threading mode for Telegram (off/first/all) telegram_reply_mode = os.getenv("TELEGRAM_REPLY_TO_MODE", "").lower() - if telegram_reply_mode in ("off", "first", "all"): + if telegram_reply_mode in {"off", "first", "all"}: if Platform.TELEGRAM not in config.platforms: config.platforms[Platform.TELEGRAM] = PlatformConfig() config.platforms[Platform.TELEGRAM].reply_to_mode = telegram_reply_mode @@ -1198,14 +1227,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None: # Reply threading mode for Discord (off/first/all) discord_reply_mode = os.getenv("DISCORD_REPLY_TO_MODE", "").lower() - if discord_reply_mode in ("off", "first", "all"): + if discord_reply_mode in {"off", "first", "all"}: if Platform.DISCORD not in config.platforms: config.platforms[Platform.DISCORD] = PlatformConfig() config.platforms[Platform.DISCORD].reply_to_mode = discord_reply_mode # WhatsApp (typically uses different auth mechanism) - whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes") - whatsapp_disabled_explicitly = os.getenv("WHATSAPP_ENABLED", "").lower() in ("false", "0", "no") + whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in {"true", "1", "yes"} + whatsapp_disabled_explicitly = os.getenv("WHATSAPP_ENABLED", "").lower() in {"false", "0", "no"} if Platform.WHATSAPP in config.platforms: # YAML config exists — respect explicit disable wa_cfg = config.platforms[Platform.WHATSAPP] @@ -1261,7 +1290,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: signal_config.extra.update({ "http_url": signal_url, "account": signal_account, - "ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"), + "ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in {"true", "1", "yes"}, }) signal_home = os.getenv("SIGNAL_HOME_CHANNEL") if signal_home and Platform.SIGNAL in config.platforms: @@ -1306,7 +1335,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: matrix_password = os.getenv("MATRIX_PASSWORD", "") if matrix_password: matrix_config.extra["password"] = matrix_password - matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes") + matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"} matrix_config.extra["encryption"] = matrix_e2ee matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "") if matrix_device_id: @@ -1371,7 +1400,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: ) # API Server - api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in ("true", "1", "yes") + api_server_enabled = os.getenv("API_SERVER_ENABLED", "").lower() in {"true", "1", "yes"} api_server_key = os.getenv("API_SERVER_KEY", "") api_server_cors_origins = os.getenv("API_SERVER_CORS_ORIGINS", "") api_server_port = os.getenv("API_SERVER_PORT") @@ -1398,7 +1427,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.API_SERVER].extra["model_name"] = api_server_model_name # Webhook platform - webhook_enabled = os.getenv("WEBHOOK_ENABLED", "").lower() in ("true", "1", "yes") + webhook_enabled = os.getenv("WEBHOOK_ENABLED", "").lower() in {"true", "1", "yes"} webhook_port = os.getenv("WEBHOOK_PORT") webhook_secret = os.getenv("WEBHOOK_SECRET", "") if webhook_enabled: @@ -1414,11 +1443,11 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret # Microsoft Graph webhook platform - msgraph_webhook_enabled = os.getenv("MSGRAPH_WEBHOOK_ENABLED", "").lower() in ( + msgraph_webhook_enabled = os.getenv("MSGRAPH_WEBHOOK_ENABLED", "").lower() in { "true", "1", "yes", - ) + } msgraph_webhook_port = os.getenv("MSGRAPH_WEBHOOK_PORT") msgraph_webhook_client_state = os.getenv("MSGRAPH_WEBHOOK_CLIENT_STATE", "") msgraph_webhook_resources = os.getenv("MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES", "") @@ -1612,7 +1641,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: "webhook_host": os.getenv("BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"), "webhook_port": int(os.getenv("BLUEBUBBLES_WEBHOOK_PORT", "8645")), "webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"), - "send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in ("true", "1", "yes"), + "send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in {"true", "1", "yes"}, }) bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL") if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms: diff --git a/gateway/display_config.py b/gateway/display_config.py index 55cc344677e..eab6bebc783 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -81,7 +81,7 @@ _TIER_MINIMAL = { _PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = { # Tier 1 — full edit support, personal/team use - "telegram": _TIER_HIGH, + "telegram": {**_TIER_HIGH, "tool_progress": "new"}, "discord": _TIER_HIGH, # Tier 2 — edit support, often customer/workspace channels @@ -190,13 +190,13 @@ def _normalise(setting: str, value: Any) -> Any: if value is True: return "all" return str(value).lower() - if setting in ("show_reasoning", "streaming"): + if setting in {"show_reasoning", "streaming"}: if isinstance(value, str): - return value.lower() in ("true", "1", "yes", "on") + return value.lower() in {"true", "1", "yes", "on"} return bool(value) if setting == "cleanup_progress": if isinstance(value, str): - return value.lower() in ("true", "1", "yes", "on") + return value.lower() in {"true", "1", "yes", "on"} return bool(value) if setting == "tool_preview_length": try: diff --git a/gateway/platforms/ADDING_A_PLATFORM.md b/gateway/platforms/ADDING_A_PLATFORM.md index 80ebd27c5da..ffe67e046b1 100644 --- a/gateway/platforms/ADDING_A_PLATFORM.md +++ b/gateway/platforms/ADDING_A_PLATFORM.md @@ -33,6 +33,17 @@ status display, gateway setup, and more. auto-populate `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` so the setup wizard surfaces proper descriptions, prompts, password flags, and URLs. +**Subclassing for platform-specific UX.** When a platform has a hard +time-window constraint that the base adapter can't anticipate (LINE's +60s single-use reply token, WhatsApp's 24h session window, etc.), an +adapter can override `_keep_typing` to layer a mid-flight bubble at a +threshold without expanding the kwarg surface. Always +`await super()._keep_typing(...)` so the typing heartbeat keeps running, +and tear down your side task in `finally`. See `plugins/platforms/line/` +for the full pattern (Template Buttons postback at 45s, `RequestCache` +state machine, `interrupt_session_activity` override for `/stop` +orphans) and the developer-guide page for the prose walkthrough. + See `plugins/platforms/irc/`, `plugins/platforms/teams/`, and `plugins/platforms/google_chat/` for complete working examples, and `website/docs/developer-guide/adding-platform-adapters.md` for the full diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 357ecbd4785..497adbd19c6 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -449,7 +449,7 @@ if AIOHTTP_AVAILABLE: @web.middleware async def body_limit_middleware(request, handler): """Reject overly large request bodies early based on Content-Length.""" - if request.method in ("POST", "PUT", "PATCH"): + if request.method in {"POST", "PUT", "PATCH"}: cl = request.headers.get("Content-Length") if cl is not None: try: @@ -646,7 +646,7 @@ class APIServerAdapter(BasePlatformAdapter): try: from hermes_cli.profiles import get_active_profile_name profile = get_active_profile_name() - if profile and profile not in ("default", "custom"): + if profile and profile not in {"default", "custom"}: return profile except Exception: pass @@ -1003,7 +1003,7 @@ class APIServerAdapter(BasePlatformAdapter): system_prompt = content else: system_prompt = system_prompt + "\n" + content - elif role in ("user", "assistant"): + elif role in {"user", "assistant"}: try: content = _normalize_multimodal_content(raw_content) except ValueError as exc: @@ -2381,7 +2381,7 @@ class APIServerAdapter(BasePlatformAdapter): if cron_err: return cron_err try: - include_disabled = request.query.get("include_disabled", "").lower() in ("true", "1") + include_disabled = request.query.get("include_disabled", "").lower() in {"true", "1"} jobs = _cron_list(include_disabled=include_disabled) return web.json_response({"jobs": jobs}) except Exception as e: diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 413cebfbe87..ec0323d4738 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -560,7 +560,7 @@ def _looks_like_image(data: bytes) -> bool: return True if data[:3] == b"\xff\xd8\xff": return True - if data[:6] in (b"GIF87a", b"GIF89a"): + if data[:6] in {b"GIF87a", b"GIF89a"}: return True if data[:2] == b"BM": return True @@ -859,7 +859,7 @@ def cache_document_from_bytes(data: bytes, filename: str) -> str: # Sanitize: strip directory components, null bytes, and control characters safe_name = Path(filename).name if filename else "document" safe_name = safe_name.replace("\x00", "").strip() - if not safe_name or safe_name in (".", ".."): + if not safe_name or safe_name in {".", ".."}: safe_name = "document" cached_name = f"doc_{uuid.uuid4().hex[:12]}_{safe_name}" filepath = cache_dir / cached_name @@ -1035,6 +1035,13 @@ class SendResult: error: Optional[str] = None raw_response: Any = None retryable: bool = False # True for transient connection errors — base will retry automatically + # When the adapter had to split an oversized payload across multiple + # platform messages (e.g. Telegram edit_message overflow split-and-deliver), + # ``message_id`` is the LAST visible message id (so subsequent edits target + # the most recent chunk) and these are the additional message ids that + # made up the full payload, in send order. Empty tuple for the common + # single-message case. + continuation_message_ids: tuple = () class EphemeralReply(str): @@ -1311,6 +1318,61 @@ class BasePlatformAdapter(ABC): # _keep_typing skips send_typing when the chat_id is in this set. self._typing_paused: set = set() + @property + def message_len_fn(self) -> Callable[[str], int]: + """Return the length function for measuring message size on this platform. + + Override in adapters whose platform counts characters differently from + Python ``len`` (e.g. Telegram counts UTF-16 code units). + """ + return len + + def supports_draft_streaming( + self, + chat_type: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> bool: + """Whether this adapter supports native streaming-draft updates. + + Telegram Bot API 9.5 introduced ``sendMessageDraft``, which renders an + animated streaming preview as the bot calls it repeatedly with the + same ``draft_id`` and growing text. Adapters that implement + ``send_draft`` should return True here for the chat types where the + platform supports it (Telegram restricts drafts to private DMs). + + Default implementation returns False. Stream consumers fall back to + the edit-based path (``send`` + ``edit_message``) when this returns + False or when ``send_draft`` raises. + """ + return False + + async def send_draft( + self, + chat_id: str, + draft_id: int, + content: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send or update an animated streaming-draft preview. + + Reuse the same ``draft_id`` (any non-zero int) across consecutive + calls within a single response so the platform animates the preview + rather than re-creating it. Different responses must use different + ``draft_id`` values within the same chat to avoid animating over a + prior bubble. + + Drafts have no message_id and cannot be edited, replied to, or + deleted via normal message APIs. When the response finishes, the + caller delivers the final answer as a regular ``send`` and the + draft preview clears naturally on the client. + + Default implementation raises NotImplementedError; adapters that + also return True from :meth:`supports_draft_streaming` must override. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement send_draft" + ) + @property def has_fatal_error(self) -> bool: return self._fatal_error_message is not None @@ -1511,6 +1573,33 @@ class BasePlatformAdapter(ABC): # property) so the stream consumer knows not to short-circuit. REQUIRES_EDIT_FINALIZE: bool = False + async def create_handoff_thread( + self, + parent_chat_id: str, + name: str, + ) -> Optional[str]: + """Create a fresh thread under ``parent_chat_id`` for a session handoff. + + Used by the gateway's handoff watcher when transferring a CLI + session to a thread-capable platform — the new thread isolates the + handed-off conversation from any pre-existing chat in the home + channel and gives users a clean per-handoff scrollback. + + Returns the new thread/topic id (as a string) on success, or + ``None`` if the platform doesn't support threading or the + attempt failed (permissions, topics-mode off, etc.). When ``None`` + is returned the watcher falls back to using ``parent_chat_id`` + directly. + + Default implementation returns ``None`` — adapters that support + threads override this. See: + - Telegram: forum topics in groups, DM topics with bot API 9.4+ + - Discord: text-channel threads (1440-min auto-archive) + - Slack: seed-message thread anchoring + """ + return None + + async def edit_message( self, chat_id: str, @@ -2704,7 +2793,7 @@ class BasePlatformAdapter(ABC): # and preserve ordering of queued follow-ups. Route those # through the dedicated handoff path that serializes # cancellation + runner response + pending drain. - if cmd in ("stop", "new", "reset"): + if cmd in {"stop", "new", "reset"}: try: await self._dispatch_active_session_command(event, session_key, cmd) except Exception as e: diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index 31120785c09..7a4af3ad685 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -223,7 +223,7 @@ class BlueBubblesAdapter(BasePlatformAdapter): def _webhook_url(self) -> str: """Compute the external webhook URL for BlueBubbles registration.""" host = self.webhook_host - if host in ("0.0.0.0", "127.0.0.1", "localhost", "::"): + if host in {"0.0.0.0", "127.0.0.1", "localhost", "::"}: host = "localhost" return f"http://{host}:{self.webhook_port}{self.webhook_path}" diff --git a/gateway/platforms/dingtalk.py b/gateway/platforms/dingtalk.py index 5c2285f24bb..579c382c704 100644 --- a/gateway/platforms/dingtalk.py +++ b/gateway/platforms/dingtalk.py @@ -353,9 +353,9 @@ class DingTalkAdapter(BasePlatformAdapter): configured = self.config.extra.get("require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() in ("true", "1", "yes", "on") + return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("DINGTALK_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + return os.getenv("DINGTALK_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"} def _dingtalk_free_response_chats(self) -> Set[str]: raw = self.config.extra.get("free_response_chats") diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index ae107cdfb2b..5113f49f179 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -115,7 +115,7 @@ def _build_allowed_mentions(): raw = os.getenv(name, "").strip().lower() if not raw: return default - return raw in ("true", "1", "yes", "on") + return raw in {"true", "1", "yes", "on"} return discord.AllowedMentions( everyone=_b("DISCORD_ALLOW_MENTION_EVERYONE", False), @@ -708,7 +708,7 @@ class DiscordAdapter(BasePlatformAdapter): # Ignore Discord system messages (thread renames, pins, member joins, etc.) # Allow both default and reply types — replies have a distinct MessageType. - if message.type not in (discord.MessageType.default, discord.MessageType.reply): + if message.type not in {discord.MessageType.default, discord.MessageType.reply}: return # Bot message filtering (DISCORD_ALLOW_BOTS): @@ -769,7 +769,7 @@ class DiscordAdapter(BasePlatformAdapter): # answer regardless of who is mentioned. _ignore_no_mention = os.getenv( "DISCORD_IGNORE_NO_MENTION", "true" - ).lower() in ("true", "1", "yes") + ).lower() in {"true", "1", "yes"} if _ignore_no_mention and not _self_mentioned and not _other_bots_mentioned: _channel_id = str(message.channel.id) _parent_id = None @@ -1317,7 +1317,7 @@ class DiscordAdapter(BasePlatformAdapter): def _reactions_enabled(self) -> bool: """Check if message reactions are enabled via config/env.""" - return os.getenv("DISCORD_REACTIONS", "true").lower() not in ("false", "0", "no") + return os.getenv("DISCORD_REACTIONS", "true").lower() not in {"false", "0", "no"} async def on_processing_start(self, event: MessageEvent) -> None: """Add an in-progress reaction for normal Discord message events.""" @@ -2697,6 +2697,8 @@ class DiscordAdapter(BasePlatformAdapter): await asyncio.sleep(8) except asyncio.CancelledError: pass + finally: + self._typing_tasks.pop(chat_id, None) self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop()) @@ -3135,9 +3137,9 @@ class DiscordAdapter(BasePlatformAdapter): # UX so users don't see commands they can't invoke. Off by default # to preserve the slash UX for deployments that intentionally allow # everyone in the guild. - if os.getenv("DISCORD_HIDE_SLASH_COMMANDS", "false").strip().lower() in ( + if os.getenv("DISCORD_HIDE_SLASH_COMMANDS", "false").strip().lower() in { "true", "1", "yes", "on", - ): + }: self._apply_owner_only_visibility(tree) def _apply_owner_only_visibility(self, tree) -> None: @@ -3524,9 +3526,9 @@ class DiscordAdapter(BasePlatformAdapter): configured = self.config.extra.get("require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() not in ("false", "0", "no", "off") + return configured.lower() not in {"false", "0", "no", "off"} return bool(configured) - return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"} def _discord_free_response_channels(self) -> set: """Return Discord channel IDs where no bot mention is required. @@ -3689,6 +3691,84 @@ class DiscordAdapter(BasePlatformAdapter): ) return None + async def create_handoff_thread( + self, + parent_chat_id: str, + name: str, + ) -> Optional[str]: + """Create a Discord thread under a text channel for a handoff. + + Falls back to a seed-message + ``message.create_thread`` path if + ``parent.create_thread`` is rejected (some channel types or + permission setups). Returns the new thread id as a string, or + ``None`` on failure or when the parent isn't a text channel + (DMs, voice channels, threads themselves can't host threads). + """ + if not self._client or not DISCORD_AVAILABLE: + return None + + try: + parent_id = int(parent_chat_id) + except (TypeError, ValueError): + return None + + try: + parent = self._client.get_channel(parent_id) + if parent is None: + parent = await self._client.fetch_channel(parent_id) + except Exception as exc: + logger.warning( + "[%s] Handoff thread: cannot resolve parent %s: %s", + self.name, parent_chat_id, exc, + ) + return None + + # DMs, voice channels, and existing threads can't host child threads. + if isinstance(parent, getattr(discord, "DMChannel", ())): + logger.info( + "[%s] Handoff thread: parent %s is a DM; threads not supported here", + self.name, parent_chat_id, + ) + return None + + thread_name = (name or "handoff").strip()[:80] or "handoff" + reason = "Hermes session handoff" + + # First try: create a thread directly on the channel. + try: + create = getattr(parent, "create_thread", None) + if create is not None: + thread = await create( + name=thread_name, + auto_archive_duration=1440, + reason=reason, + ) + return str(thread.id) + except Exception as direct_error: + logger.debug( + "[%s] Handoff thread: direct create failed (%s); trying seed-message fallback", + self.name, direct_error, + ) + + # Fallback: post a seed message and create the thread from it. + try: + send = getattr(parent, "send", None) + if send is None: + return None + seed_msg = await send(f"\U0001f9f5 Hermes handoff: **{thread_name}**") + thread = await seed_msg.create_thread( + name=thread_name, + auto_archive_duration=1440, + reason=reason, + ) + return str(thread.id) + except Exception as fallback_error: + logger.warning( + "[%s] Handoff thread: both create paths failed for parent %s: %s", + self.name, parent_chat_id, fallback_error, + ) + return None + async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", @@ -4120,7 +4200,7 @@ class DiscordAdapter(BasePlatformAdapter): no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "") no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()} skip_thread = bool(channel_ids & no_thread_channels) - auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes") + auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in {"true", "1", "yes"} is_reply_message = getattr(message, "type", None) == discord.MessageType.reply if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message: thread = await self._auto_create_thread(message) @@ -4202,7 +4282,7 @@ class DiscordAdapter(BasePlatformAdapter): try: # Determine extension from content type (image/png -> .png) ext = "." + content_type.split("/")[-1].split(";")[0] - if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"): + if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: ext = ".jpg" cached_path = await self._cache_discord_image(att, ext) media_urls.append(cached_path) @@ -4216,7 +4296,7 @@ class DiscordAdapter(BasePlatformAdapter): elif content_type.startswith("audio/"): try: ext = "." + content_type.split("/")[-1].split(";")[0] - if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"): + if ext not in {".ogg", ".mp3", ".wav", ".webm", ".m4a"}: ext = ".ogg" cached_path = await self._cache_discord_audio(att, ext) media_urls.append(cached_path) @@ -4259,7 +4339,7 @@ class DiscordAdapter(BasePlatformAdapter): logger.info("[Discord] Cached user document: %s", cached_path) # Inject text content for plain-text documents (capped at 100 KB) MAX_TEXT_INJECT_BYTES = 100 * 1024 - if ext in (".md", ".txt", ".log") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + if ext in {".md", ".txt", ".log"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: try: text_content = raw_bytes.decode("utf-8") display_name = att.filename or f"document{ext}" diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py index fb44ad308e7..0fffb82d0b9 100644 --- a/gateway/platforms/email.py +++ b/gateway/platforms/email.py @@ -54,7 +54,7 @@ _NOREPLY_PATTERNS = ( # RFC headers that indicate bulk/automated mail _AUTOMATED_HEADERS = { "Auto-Submitted": lambda v: v.lower() != "no", - "Precedence": lambda v: v.lower() in ("bulk", "list", "junk"), + "Precedence": lambda v: v.lower() in {"bulk", "list", "junk"}, "X-Auto-Response-Suppress": lambda v: bool(v), "List-Unsubscribe": lambda v: bool(v), } @@ -203,7 +203,7 @@ def _extract_attachments( continue # Skip text/plain and text/html body parts content_type = part.get_content_type() - if content_type in ("text/plain", "text/html") and "attachment" not in disposition: + if content_type in {"text/plain", "text/html"} and "attachment" not in disposition: continue filename = part.get_filename() diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py index 0470aaa2665..ae3f7075104 100644 --- a/gateway/platforms/feishu.py +++ b/gateway/platforms/feishu.py @@ -428,7 +428,7 @@ RejectReason = Literal[ def _is_bot_sender(sender: Any) -> bool: # receive_v1 docs say {user, bot}; accept "app" defensively. - return getattr(sender, "sender_type", "") in ("bot", "app") + return getattr(sender, "sender_type", "") in {"bot", "app"} def _sender_identity(sender: Any) -> frozenset: @@ -1428,8 +1428,8 @@ class FeishuAdapter(BasePlatformAdapter): per_chat_require_mention = _to_boolean(rule_cfg.get("require_mention")) group_rules[str(chat_id)] = FeishuGroupRule( policy=str(rule_cfg.get("policy", "open")).strip().lower(), - allowlist=set(str(u).strip() for u in rule_cfg.get("allowlist", []) if str(u).strip()), - blacklist=set(str(u).strip() for u in rule_cfg.get("blacklist", []) if str(u).strip()), + allowlist={str(u).strip() for u in rule_cfg.get("allowlist", []) if str(u).strip()}, + blacklist={str(u).strip() for u in rule_cfg.get("blacklist", []) if str(u).strip()}, require_mention=per_chat_require_mention, ) @@ -1443,7 +1443,7 @@ class FeishuAdapter(BasePlatformAdapter): # Env-only so adapter and gateway auth bypass share one source; yaml # feishu.allow_bots is bridged to this env var at config load. allow_bots = os.getenv("FEISHU_ALLOW_BOTS", "none").strip().lower() - if allow_bots not in ("none", "mentions", "all"): + if allow_bots not in {"none", "mentions", "all"}: logger.warning( "[Feishu] Unknown allow_bots=%r, falling back to 'none'. Valid: none, mentions, all.", allow_bots, @@ -2752,7 +2752,7 @@ class FeishuAdapter(BasePlatformAdapter): # ========================================================================= def _reactions_enabled(self) -> bool: - return os.getenv("FEISHU_REACTIONS", "true").strip().lower() not in ("false", "0", "no") + return os.getenv("FEISHU_REACTIONS", "true").strip().lower() not in {"false", "0", "no"} async def _add_reaction(self, message_id: str, emoji_type: str) -> Optional[str]: """Return the reaction_id on success, else None. The id is needed later for deletion.""" @@ -3219,7 +3219,7 @@ class FeishuAdapter(BasePlatformAdapter): self._on_bot_added_to_chat(data) elif event_type == "im.chat.member.bot.deleted_v1": self._on_bot_removed_from_chat(data) - elif event_type in ("im.message.reaction.created_v1", "im.message.reaction.deleted_v1"): + elif event_type in {"im.message.reaction.created_v1", "im.message.reaction.deleted_v1"}: self._on_reaction_event(event_type, data) elif event_type == "card.action.trigger": self._on_card_action_trigger(data) @@ -4273,21 +4273,31 @@ class FeishuAdapter(BasePlatformAdapter): request = self._build_reply_message_request(effective_reply_to, body) return await asyncio.to_thread(self._client.im.v1.message.reply, request) - body = self._build_create_message_body( - receive_id=chat_id, - msg_type=msg_type, - content=payload, - uuid_value=str(uuid.uuid4()), - ) - # Detect whether chat_id is a user open_id (DM) or a chat_id (group). - # Feishu API expects receive_id_type="open_id" for user DMs (ou_ prefix) - # and receive_id_type="chat_id" for group chats (oc_ prefix, which IS - # the chat_id format — see https://open.feishu.cn/document/). - if chat_id.startswith("ou_"): - receive_id_type = "open_id" + # For topic/thread messages that fell back from reply→create, use + # thread_id as receive_id so the message lands in the topic instead of + # the main chat. + _thread_id = (metadata or {}).get("thread_id") + if _thread_id: + body = self._build_create_message_body( + receive_id=_thread_id, + msg_type=msg_type, + content=payload, + uuid_value=str(uuid.uuid4()), + ) + request = self._build_create_message_request("thread_id", body) else: - receive_id_type = "chat_id" - request = self._build_create_message_request(receive_id_type, body) + body = self._build_create_message_body( + receive_id=chat_id, + msg_type=msg_type, + content=payload, + uuid_value=str(uuid.uuid4()), + ) + # Detect whether chat_id is a user open_id (DM) or a chat_id (group). + if chat_id.startswith("ou_"): + receive_id_type = "open_id" + else: + receive_id_type = "chat_id" + request = self._build_create_message_request(receive_id_type, body) return await asyncio.to_thread(self._client.im.v1.message.create, request) @staticmethod @@ -4805,7 +4815,7 @@ def _poll_registration( # Terminal errors error = res.get("error", "") - if error in ("access_denied", "expired_token"): + if error in {"access_denied", "expired_token"}: if poll_count > 0: print() logger.warning("[Feishu onboard] Registration %s", error) diff --git a/gateway/platforms/feishu_comment.py b/gateway/platforms/feishu_comment.py index 08cd35185c6..4d757cc7646 100644 --- a/gateway/platforms/feishu_comment.py +++ b/gateway/platforms/feishu_comment.py @@ -690,7 +690,7 @@ def _extract_docs_links(replies: List[Dict[str, Any]]) -> List[Dict[str, str]]: except (json.JSONDecodeError, TypeError): continue for elem in content.get("elements", []): - if elem.get("type") not in ("docs_link", "link"): + if elem.get("type") not in {"docs_link", "link"}: continue link_data = elem.get("docs_link") or elem.get("link") or {} url = link_data.get("url", "") @@ -1031,7 +1031,7 @@ def _save_session_history(key: str, messages: List[Dict[str, Any]]) -> None: # Only keep user/assistant messages (strip system messages and tool internals) cleaned = [ m for m in messages - if m.get("role") in ("user", "assistant") and m.get("content") + if m.get("role") in {"user", "assistant"} and m.get("content") ] # Keep last N if len(cleaned) > _SESSION_MAX_MESSAGES: @@ -1170,7 +1170,7 @@ async def handle_drive_comment_event( rule = resolve_rule(comments_cfg, file_type, file_token) # If no exact match and config has wiki keys, try reverse-lookup - if rule.match_source in ("wildcard", "top") and has_wiki_keys(comments_cfg): + if rule.match_source in {"wildcard", "top"} and has_wiki_keys(comments_cfg): wiki_token = await _reverse_lookup_wiki_token(client, file_type, file_token) if wiki_token: rule = resolve_rule(comments_cfg, file_type, file_token, wiki_token=wiki_token) diff --git a/gateway/platforms/feishu_comment_rules.py b/gateway/platforms/feishu_comment_rules.py index 054ef956989..25927bafb0a 100644 --- a/gateway/platforms/feishu_comment_rules.py +++ b/gateway/platforms/feishu_comment_rules.py @@ -228,7 +228,7 @@ def _load_pairing_approved() -> set: if isinstance(approved, dict): return set(approved.keys()) if isinstance(approved, list): - return set(str(u) for u in approved if u) + return {str(u) for u in approved if u} return set() diff --git a/gateway/platforms/helpers.py b/gateway/platforms/helpers.py index 673beeac9b4..1c4f451585a 100644 --- a/gateway/platforms/helpers.py +++ b/gateway/platforms/helpers.py @@ -246,7 +246,7 @@ class ThreadParticipationTracker: thread_list = list(self._threads) if len(thread_list) > self._max_tracked: thread_list = thread_list[-self._max_tracked:] - self._threads = {thread_id: None for thread_id in thread_list} + self._threads = dict.fromkeys(thread_list) atomic_json_write(path, thread_list, indent=None) def mark(self, thread_id: str) -> None: diff --git a/gateway/platforms/homeassistant.py b/gateway/platforms/homeassistant.py index 6bc9ae6eb61..e7ea762e2e7 100644 --- a/gateway/platforms/homeassistant.py +++ b/gateway/platforms/homeassistant.py @@ -256,7 +256,7 @@ class HomeAssistantAdapter(BasePlatformAdapter): await self._handle_ha_event(data.get("event", {})) except json.JSONDecodeError: logger.debug("Invalid JSON from HA WS: %s", ws_msg.data[:200]) - elif ws_msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + elif ws_msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR}: break async def _handle_ha_event(self, event: Dict[str, Any]) -> None: @@ -361,7 +361,7 @@ class HomeAssistantAdapter(BasePlatformAdapter): f"(was {'triggered' if old_val == 'on' else 'cleared'})" ) - if domain in ("light", "switch", "fan"): + if domain in {"light", "switch", "fan"}: return ( f"[Home Assistant] {friendly_name}: turned " f"{'on' if new_val == 'on' else 'off'}" diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 12e840b69c4..0133dc2dac7 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -245,11 +245,11 @@ def check_matrix_requirements() -> bool: # If encryption is requested, verify E2EE deps are available at startup # rather than silently degrading to plaintext-only at connect time. - encryption_requested = os.getenv("MATRIX_ENCRYPTION", "").lower() in ( + encryption_requested = os.getenv("MATRIX_ENCRYPTION", "").lower() in { "true", "1", "yes", - ) + } if encryption_requested and not _check_e2ee_deps(): logger.error( "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. " @@ -312,7 +312,7 @@ class MatrixAdapter(BasePlatformAdapter): ) self._encryption: bool = config.extra.get( "encryption", - os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes"), + os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"}, ) self._device_id: str = config.extra.get("device_id", "") or os.getenv( "MATRIX_DEVICE_ID", "" @@ -343,7 +343,7 @@ class MatrixAdapter(BasePlatformAdapter): # Mention/thread gating — parsed once from env vars. self._require_mention: bool = os.getenv( "MATRIX_REQUIRE_MENTION", "true" - ).lower() not in ("false", "0", "no") + ).lower() not in {"false", "0", "no"} free_rooms_raw = config.extra.get("free_response_rooms") if free_rooms_raw is None: free_rooms_raw = os.getenv("MATRIX_FREE_RESPONSE_ROOMS", "") @@ -367,22 +367,22 @@ class MatrixAdapter(BasePlatformAdapter): self._allowed_rooms: Set[str] = { r.strip() for r in str(allowed_rooms_raw).split(",") if r.strip() } - self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in ( + self._auto_thread: bool = os.getenv("MATRIX_AUTO_THREAD", "true").lower() in { "true", "1", "yes", - ) + } self._dm_auto_thread: bool = os.getenv( "MATRIX_DM_AUTO_THREAD", "false" - ).lower() in ("true", "1", "yes") + ).lower() in {"true", "1", "yes"} self._dm_mention_threads: bool = os.getenv( "MATRIX_DM_MENTION_THREADS", "false" - ).lower() in ("true", "1", "yes") + ).lower() in {"true", "1", "yes"} # Reactions: configurable via MATRIX_REACTIONS (default: true). self._reactions_enabled: bool = os.getenv( "MATRIX_REACTIONS", "true" - ).lower() not in ("false", "0", "no") + ).lower() not in {"false", "0", "no"} self._pending_reactions: dict[tuple[str, str], str] = {} # Delay before redacting reactions so Matrix homeservers have time to # deliver the final message event without tripping "missing event" @@ -1771,9 +1771,9 @@ class MatrixAdapter(BasePlatformAdapter): # Cache media locally when downstream tools need a real file path. cached_path = None - should_cache_locally = msg_type in ( + should_cache_locally = msg_type in { MessageType.PHOTO, MessageType.AUDIO, MessageType.VIDEO, MessageType.DOCUMENT, - ) or is_voice_message or is_encrypted_media + } or is_voice_message or is_encrypted_media if should_cache_locally and url: try: file_bytes = await self._client.download_media(ContentURI(url)) @@ -1834,7 +1834,7 @@ class MatrixAdapter(BasePlatformAdapter): ext = ext_map.get(media_type, ".jpg") cached_path = cache_image_from_bytes(file_bytes, ext=ext) logger.info("[Matrix] Cached user image at %s", cached_path) - elif msg_type in (MessageType.AUDIO, MessageType.VOICE): + elif msg_type in {MessageType.AUDIO, MessageType.VOICE}: ext = ( Path( body @@ -2602,7 +2602,7 @@ class MatrixAdapter(BasePlatformAdapter): """Sanitize a URL for use in an href attribute.""" stripped = url.strip() scheme = stripped.split(":", 1)[0].lower().strip() if ":" in stripped else "" - if scheme in ("javascript", "data", "vbscript"): + if scheme in {"javascript", "data", "vbscript"}: return "" return stripped.replace('"', """) diff --git a/gateway/platforms/mattermost.py b/gateway/platforms/mattermost.py index 3ffd74326d3..9487f8a1edf 100644 --- a/gateway/platforms/mattermost.py +++ b/gateway/platforms/mattermost.py @@ -611,7 +611,7 @@ class MattermostAdapter(BasePlatformAdapter): # succeed on retry — stop reconnecting instead of looping forever. import aiohttp err_str = str(exc).lower() - if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in (401, 403): + if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in {401, 403}: logger.error("Mattermost WS auth failed (HTTP %d) — stopping reconnect", exc.status) return if "401" in err_str or "403" in err_str or "unauthorized" in err_str: @@ -649,21 +649,21 @@ class MattermostAdapter(BasePlatformAdapter): if self._closing: return - if raw_msg.type in ( + if raw_msg.type in { raw_msg.type.TEXT, raw_msg.type.BINARY, - ): + }: try: event = json.loads(raw_msg.data) except (json.JSONDecodeError, TypeError): continue await self._handle_ws_event(event) - elif raw_msg.type in ( + elif raw_msg.type in { raw_msg.type.ERROR, raw_msg.type.CLOSE, raw_msg.type.CLOSING, raw_msg.type.CLOSED, - ): + }: logger.info("Mattermost: WebSocket closed (%s)", raw_msg.type) break @@ -732,7 +732,7 @@ class MattermostAdapter(BasePlatformAdapter): require_mention = os.getenv( "MATTERMOST_REQUIRE_MENTION", "true" - ).lower() not in ("false", "0", "no") + ).lower() not in {"false", "0", "no"} free_channels_raw = os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS", "") free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()} diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 12caef0f144..b7a306f9b69 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -513,7 +513,7 @@ class QQAdapter(BasePlatformAdapter): self._fail_pending("Connection closed") # Stop reconnecting for fatal codes - if code in (4914, 4915): + if code in {4914, 4915}: desc = "offline/sandbox-only" if code == 4914 else "banned" logger.error( "[%s] Bot is %s. Check QQ Open Platform.", self._log_tag, desc @@ -550,7 +550,7 @@ class QQAdapter(BasePlatformAdapter): self._token_expires_at = 0.0 # Session invalid → clear session, will re-identify on next Hello - if code in ( + if code in { 4006, 4007, 4009, @@ -568,7 +568,7 @@ class QQAdapter(BasePlatformAdapter): 4911, 4912, 4913, - ): + }: logger.info( "[%s] Session error (%d), clearing session for re-identify", self._log_tag, @@ -637,12 +637,12 @@ class QQAdapter(BasePlatformAdapter): payload = self._parse_json(msg.data) if payload: self._dispatch_payload(payload) - elif msg.type in (aiohttp.WSMsgType.PING,): + elif msg.type in {aiohttp.WSMsgType.PING,}: # aiohttp auto-replies with PONG pass elif msg.type == aiohttp.WSMsgType.CLOSE: raise QQCloseError(msg.data, msg.extra) - elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + elif msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR}: raise RuntimeError("WebSocket closed") async def _heartbeat_loop(self) -> None: @@ -783,13 +783,13 @@ class QQAdapter(BasePlatformAdapter): self._handle_ready(d) elif t == "RESUMED": logger.info("[%s] Session resumed", self._log_tag) - elif t in ( + elif t in { "C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE", "DIRECT_MESSAGE_CREATE", "GUILD_MESSAGE_CREATE", "GUILD_AT_MESSAGE_CREATE", - ): + }: asyncio.create_task(self._on_message(t, d)) elif t == "INTERACTION_CREATE": self._create_task(self._on_interaction(d)) @@ -859,9 +859,9 @@ class QQAdapter(BasePlatformAdapter): # Route by event type if event_type == "C2C_MESSAGE_CREATE": await self._handle_c2c_message(d, msg_id, content, author, timestamp) - elif event_type in ("GROUP_AT_MESSAGE_CREATE",): + elif event_type in {"GROUP_AT_MESSAGE_CREATE",}: await self._handle_group_message(d, msg_id, content, author, timestamp) - elif event_type in ("GUILD_MESSAGE_CREATE", "GUILD_AT_MESSAGE_CREATE"): + elif event_type in {"GUILD_MESSAGE_CREATE", "GUILD_AT_MESSAGE_CREATE"}: await self._handle_guild_message(d, msg_id, content, author, timestamp) elif event_type == "DIRECT_MESSAGE_CREATE": await self._handle_dm_message(d, msg_id, content, author, timestamp) @@ -1864,7 +1864,7 @@ class QQAdapter(BasePlatformAdapter): return ".wav" if data[:4] == b"fLaC": return ".flac" - if data[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"): + if data[:2] in {b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"}: return ".mp3" if data[:4] == b"\x30\x26\xb2\x75" or data[:4] == b"\x4f\x67\x67\x53": return ".ogg" @@ -2033,7 +2033,7 @@ class QQAdapter(BasePlatformAdapter): "base_url": base_url, "api_key": api_key, "model": model - or ("glm-asr" if provider in ("zai", "glm") else "whisper-1"), + or ("glm-asr" if provider in {"zai", "glm"} else "whisper-1"), } # 2. QQ-specific env vars (set by `hermes setup gateway` / `hermes gateway`) @@ -2115,7 +2115,7 @@ class QQAdapter(BasePlatformAdapter): if urlparse(source_url).path else "" ) - if not ext or ext not in ( + if not ext or ext not in { ".silk", ".amr", ".mp3", @@ -2124,7 +2124,7 @@ class QQAdapter(BasePlatformAdapter): ".m4a", ".aac", ".flac", - ): + }: ext = self._guess_ext_from_data(audio_data) with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp_src: @@ -2870,7 +2870,7 @@ class QQAdapter(BasePlatformAdapter): raise ValueError("Media source is required") parsed = urlparse(source) - if parsed.scheme in ("http", "https"): + if parsed.scheme in {"http", "https"}: # For URLs, pass through directly to the upload API content_type = mimetypes.guess_type(source)[0] or "application/octet-stream" resolved_name = file_name or Path(parsed.path).name or "media" @@ -2966,7 +2966,7 @@ class QQAdapter(BasePlatformAdapter): chat_type = self._guess_chat_type(chat_id) return { "name": chat_id, - "type": "group" if chat_type in ("group", "guild") else "dm", + "type": "group" if chat_type in {"group", "guild"} else "dm", } # ------------------------------------------------------------------ @@ -2975,7 +2975,7 @@ class QQAdapter(BasePlatformAdapter): @staticmethod def _is_url(source: str) -> bool: - return urlparse(str(source)).scheme in ("http", "https") + return urlparse(str(source)).scheme in {"http", "https"} def _guess_chat_type(self, chat_id: str) -> str: """Determine chat type from stored inbound metadata, fallback to 'c2c'.""" diff --git a/gateway/platforms/qqbot/chunked_upload.py b/gateway/platforms/qqbot/chunked_upload.py index d0a6e5d226b..416dfc52a98 100644 --- a/gateway/platforms/qqbot/chunked_upload.py +++ b/gateway/platforms/qqbot/chunked_upload.py @@ -239,7 +239,7 @@ class ChunkedUploader: :raises UploadFileTooLargeError: When the file exceeds the platform limit. :raises RuntimeError: On other API or I/O failures. """ - if chat_type not in ("c2c", "group"): + if chat_type not in {"c2c", "group"}: raise ValueError( f"ChunkedUploader: unsupported chat_type {chat_type!r}" ) @@ -592,8 +592,7 @@ async def _run_with_concurrency( concurrency: int, ) -> None: """Run a list of thunks with a bounded number in flight at once.""" - if concurrency < 1: - concurrency = 1 + concurrency = max(concurrency, 1) sem = asyncio.Semaphore(concurrency) async def _wrap(thunk: Callable[[], Awaitable[None]]) -> None: diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index a0053317f7e..118eb688cc9 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -99,11 +99,11 @@ def _guess_extension(data: bytes) -> str: def _is_image_ext(ext: str) -> bool: - return ext.lower() in (".jpg", ".jpeg", ".png", ".gif", ".webp") + return ext.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"} def _is_audio_ext(ext: str) -> bool: - return ext.lower() in (".mp3", ".wav", ".ogg", ".m4a", ".aac") + return ext.lower() in {".mp3", ".wav", ".ogg", ".m4a", ".aac"} _EXT_TO_MIME = { @@ -1449,7 +1449,7 @@ class SignalAdapter(BasePlatformAdapter): contacts from seeing the 👀 reaction (which fires before run.py's auth gate and would otherwise reveal that a bot is listening). """ - if os.getenv("SIGNAL_REACTIONS", "true").lower() in ("false", "0", "no"): + if os.getenv("SIGNAL_REACTIONS", "true").lower() in {"false", "0", "no"}: return False if event is not None: sender = getattr(getattr(event, "source", None), "user_id", None) diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 843fb78959c..7fbefd446ca 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -679,6 +679,41 @@ class SlackAdapter(BasePlatformAdapter): if lock_acquired and not self._running: self._release_platform_lock() + async def create_handoff_thread( + self, + parent_chat_id: str, + name: str, + ) -> Optional[str]: + """Create a Slack thread anchor for a session handoff. + + Slack threads are anchored to a parent message (``thread_ts``), not + a channel-level construct. So we post a seed message into the home + channel and return its ``ts`` — the watcher uses that as the + ``thread_id`` for subsequent sends. + + Returns the seed message ts as a string, or ``None`` on failure. + """ + if not self._app: + return None + try: + client = self._get_client(parent_chat_id) + if client is None: + return None + seed_text = f":thread: Hermes handoff — *{(name or 'session').strip()[:80]}*" + result = await client.chat_postMessage( + channel=parent_chat_id, + text=seed_text, + ) + ts = result.get("ts") if isinstance(result, dict) else getattr(result, "get", lambda _k, _d=None: None)("ts") + if ts: + return str(ts) + except Exception as exc: + logger.warning( + "[%s] Handoff thread: seed-post failed for channel %s: %s", + self.name, parent_chat_id, exc, + ) + return None + async def disconnect(self) -> None: """Disconnect from Slack.""" if self._handler: @@ -900,7 +935,7 @@ class SlackAdapter(BasePlatformAdapter): raw = self.config.extra.get("dm_top_level_threads_as_sessions") if raw is None: return True # default: each DM thread is its own session - return str(raw).strip().lower() in ("1", "true", "yes", "on") + return str(raw).strip().lower() in {"1", "true", "yes", "on"} def _resolve_thread_ts( self, @@ -1265,7 +1300,7 @@ class SlackAdapter(BasePlatformAdapter): def _reactions_enabled(self) -> bool: """Check if message reactions are enabled via config/env.""" - return os.getenv("SLACK_REACTIONS", "true").lower() not in ("false", "0", "no") + return os.getenv("SLACK_REACTIONS", "true").lower() not in {"false", "0", "no"} async def on_processing_start(self, event: MessageEvent) -> None: """Add an in-progress reaction when message processing begins.""" @@ -1738,7 +1773,7 @@ class SlackAdapter(BasePlatformAdapter): # Ignore message edits and deletions subtype = event.get("subtype") - if subtype in ("message_changed", "message_deleted"): + if subtype in {"message_changed", "message_deleted"}: return original_text = event.get("text", "") @@ -1857,7 +1892,7 @@ class SlackAdapter(BasePlatformAdapter): channel_type = event.get("channel_type", "") if not channel_type and channel_id.startswith("D"): channel_type = "im" - is_dm = channel_type in ("im", "mpim") # Both 1:1 and group DMs + is_dm = channel_type in {"im", "mpim"} # Both 1:1 and group DMs # Build thread_ts for session keying. # In channels: fall back to ts so each top-level @mention starts a @@ -1998,7 +2033,7 @@ class SlackAdapter(BasePlatformAdapter): if mimetype.startswith("image/") and url: try: ext = "." + mimetype.split("/")[-1].split(";")[0] - if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"): + if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: ext = ".jpg" # Slack private URLs require the bot token as auth header cached = await self._download_slack_file(url, ext, team_id=team_id) @@ -2014,7 +2049,7 @@ class SlackAdapter(BasePlatformAdapter): elif mimetype.startswith("audio/") and url: try: ext = "." + mimetype.split("/")[-1].split(";")[0] - if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"): + if ext not in {".ogg", ".mp3", ".wav", ".webm", ".m4a"}: ext = ".ogg" cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id) media_urls.append(cached) @@ -2702,7 +2737,7 @@ class SlackAdapter(BasePlatformAdapter): if team_id and channel_id: self._channel_team[channel_id] = team_id - if slash_name in ("hermes", ""): + if slash_name in {"hermes", ""}: # Legacy /hermes [args] routing + free-form questions. # Empty slash_name falls into this branch for backward compat # with any caller that didn't populate command["command"]. @@ -2897,9 +2932,9 @@ class SlackAdapter(BasePlatformAdapter): configured = self.config.extra.get("require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() not in ("false", "0", "no", "off") + return configured.lower() not in {"false", "0", "no", "off"} return bool(configured) - return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off") + return os.getenv("SLACK_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"} def _slack_strict_mention(self) -> bool: """When true, channel threads require an explicit @-mention on every @@ -2909,9 +2944,9 @@ class SlackAdapter(BasePlatformAdapter): configured = self.config.extra.get("strict_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() in ("true", "1", "yes", "on") + return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("SLACK_STRICT_MENTION", "false").lower() in ("true", "1", "yes", "on") + return os.getenv("SLACK_STRICT_MENTION", "false").lower() in {"true", "1", "yes", "on"} def _slack_free_response_channels(self) -> set: """Return channel IDs where no @mention is required.""" diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 9bae59a3497..8e937d7573f 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -77,7 +77,6 @@ from gateway.platforms.base import ( SUPPORTED_VIDEO_TYPES, SUPPORTED_DOCUMENT_TYPES, utf16_len, - _prefix_within_utf16_limit, ) from gateway.platforms.telegram_network import ( TelegramFallbackTransport, @@ -283,6 +282,50 @@ class TelegramAdapter(BasePlatformAdapter): MEDIA_GROUP_WAIT_SECONDS = 0.8 _GENERAL_TOPIC_THREAD_ID = "1" + # Adaptive text-batch ingress: short messages need a tighter delay so the + # first token reaches the agent fast. Numbers tuned for "feels instant": + # ≤320 codepoints (one short paragraph) settles in ~180ms; ≤1024 + # (a normal paragraph) in ~240ms; longer waits the configured cap. + # Always clamped to ``_text_batch_delay_seconds`` so an operator can lower + # the cap further via env var. + _TEXT_BATCH_FAST_LEN = 320 + _TEXT_BATCH_FAST_DELAY_S = 0.18 + _TEXT_BATCH_SHORT_LEN = 1024 + _TEXT_BATCH_SHORT_DELAY_S = 0.24 + + @staticmethod + def _env_float_clamped( + name: str, + default: float, + *, + min_value: Optional[float] = None, + max_value: Optional[float] = None, + ) -> float: + """Read a float env var, reject non-finite values, and clamp to bounds. + + Guarantees the returned value is a finite number usable directly in + ``asyncio.sleep()`` and similar APIs that reject NaN / Inf. + """ + import math + + raw = os.getenv(name) + try: + value = float(raw) if raw is not None else float(default) + except (TypeError, ValueError): + value = float(default) + if not math.isfinite(value): + value = float(default) + if min_value is not None: + value = max(value, min_value) + if max_value is not None: + value = min(value, max_value) + return value + + @property + def message_len_fn(self): + """Telegram measures message length in UTF-16 code units.""" + return utf16_len + def __init__(self, config: PlatformConfig): super().__init__(config, Platform.TELEGRAM) self._app: Optional[Application] = None @@ -299,9 +342,24 @@ class TelegramAdapter(BasePlatformAdapter): self._media_group_events: Dict[str, MessageEvent] = {} self._media_group_tasks: Dict[str, asyncio.Task] = {} # Buffer rapid text messages so Telegram client-side splits of long - # messages are aggregated into a single MessageEvent. - self._text_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS", "0.6")) - self._text_batch_split_delay_seconds = float(os.getenv("HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS", "2.0")) + # messages are aggregated into a single MessageEvent. Lower defaults + # (0.3s / 1.0s instead of 0.6s / 2.0s) let short replies stream + # without a noticeable wait — combined with the adaptive fast-path + # in ``_calc_text_batch_delay`` below, ≤320-codepoint replies settle + # in ~180ms. All bounds are conservative for Telegram's + # ~1 edit/s flood envelope. + self._text_batch_delay_seconds = self._env_float_clamped( + "HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS", + 0.3, + min_value=0.08, + max_value=2.0, + ) + self._text_batch_split_delay_seconds = self._env_float_clamped( + "HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS", + 1.0, + min_value=self._text_batch_delay_seconds, + max_value=4.0, + ) self._pending_text_batches: Dict[str, MessageEvent] = {} self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {} self._polling_error_task: Optional[asyncio.Task] = None @@ -558,7 +616,7 @@ class TelegramAdapter(BasePlatformAdapter): def _looks_like_network_error(error: Exception) -> bool: """Return True for transient network errors that warrant a reconnect attempt.""" name = error.__class__.__name__.lower() - if name in ("networkerror", "timedout", "connectionerror"): + if name in {"networkerror", "timedout", "connectionerror"}: return True try: from telegram.error import NetworkError, TimedOut @@ -574,9 +632,9 @@ class TelegramAdapter(BasePlatformAdapter): return default if isinstance(value, str): lowered = value.strip().lower() - if lowered in ("true", "1", "yes", "on"): + if lowered in {"true", "1", "yes", "on"}: return True - if lowered in ("false", "0", "no", "off"): + if lowered in {"false", "0", "no", "off"}: return False return default return bool(value) @@ -865,6 +923,24 @@ class TelegramAdapter(BasePlatformAdapter): ) return None + async def create_handoff_thread( + self, + parent_chat_id: str, + name: str, + ) -> Optional[str]: + """Create a forum topic for a session handoff. + + Works for DM topics (Bot API 9.4+, requires user to enable Topics + in their chat with the bot) and forum supergroups. Returns the + ``message_thread_id`` as a string, or ``None`` on failure. + """ + try: + chat_id_int = int(parent_chat_id) + except (TypeError, ValueError): + return None + thread_id = await self._create_dm_topic(chat_id_int, name=name) + return str(thread_id) if thread_id else None + async def rename_dm_topic( self, chat_id: int, @@ -1095,7 +1171,7 @@ class TelegramAdapter(BasePlatformAdapter): "write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0), } - disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in ("1", "true", "yes", "on")) + disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in {"1", "true", "yes", "on"}) fallback_ips = self._fallback_ips() if not fallback_ips: fallback_ips = await discover_fallback_ips() @@ -1536,10 +1612,18 @@ class TelegramAdapter(BasePlatformAdapter): except Exception as e: logger.error("[%s] Failed to send Telegram message: %s", self.name, e, exc_info=True) + err_str = str(e).lower() + # Message too long — content exceeded 4096 chars. Return failure so + # stream consumer enters fallback mode and sends the remainder. + if "message_too_long" in err_str or "too long" in err_str: + logger.debug( + "[%s] send() content too long, falling back to new-message continuation", + self.name, + ) + return SendResult(success=False, error="message_too_long") # TimedOut means the request may have reached Telegram — # mark as non-retryable so _send_with_retry() doesn't re-send. _to = locals().get("_TimedOut") - err_str = str(e).lower() is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str return SendResult(success=False, error=str(e), retryable=not is_timeout) @@ -1551,9 +1635,26 @@ class TelegramAdapter(BasePlatformAdapter): *, finalize: bool = False, ) -> SendResult: - """Edit a previously sent Telegram message.""" + """Edit a previously sent Telegram message. + + Telegram caps single-message text at 4096 UTF-16 codeunits. Streaming + replies that grow past this limit must NOT be silently truncated and + must NOT return failure (the consumer would re-send and create a + duplicate). Instead this method split-and-delivers: edit the + existing message with the first chunk and send the rest as + continuation messages, returning the final chunk's id so subsequent + edits target the most recent visible message. + """ if not self._bot: return SendResult(success=False, error="Not connected") + + # Pre-flight: if content already exceeds the limit, split-and-deliver + # without round-tripping a doomed edit. + if utf16_len(content) > self.MAX_MESSAGE_LENGTH: + return await self._edit_overflow_split( + chat_id, message_id, content, finalize=finalize, + ) + try: if not finalize: await self._bot.edit_message_text( @@ -1587,22 +1688,17 @@ class TelegramAdapter(BasePlatformAdapter): # "Message is not modified" — content identical, treat as success if "not modified" in err_str: return SendResult(success=True, message_id=message_id) - # Message too long — content exceeded 4096 chars (e.g. during - # streaming). Truncate and succeed so the stream consumer can - # split the overflow into a new message instead of dying. + # Reactive split-and-deliver: parse_mode formatting can inflate + # the payload past the limit even when the raw text was under + # (e.g. MarkdownV2 escapes). Same fix as the pre-flight path. if "message_too_long" in err_str or "too long" in err_str: - truncated = _prefix_within_utf16_limit( - content, self.MAX_MESSAGE_LENGTH - 20 - ) + "…" - try: - await self._bot.edit_message_text( - chat_id=int(chat_id), - message_id=int(message_id), - text=truncated, - ) - except Exception: - pass # best-effort truncation - return SendResult(success=True, message_id=message_id) + logger.debug( + "[%s] edit_message overflow (%d UTF-16 > %d), splitting", + self.name, utf16_len(content), self.MAX_MESSAGE_LENGTH, + ) + return await self._edit_overflow_split( + chat_id, message_id, content, finalize=finalize, + ) # Flood control / RetryAfter — short waits are retried inline, # long waits return a failure immediately so streaming can fall back # to a normal final send instead of leaving a truncated partial. @@ -1638,6 +1734,147 @@ class TelegramAdapter(BasePlatformAdapter): ) return SendResult(success=False, error=str(e)) + async def _edit_overflow_split( + self, + chat_id: str, + message_id: str, + content: str, + *, + finalize: bool, + ) -> SendResult: + """Split an oversized edit across the existing message + continuations. + + Edit the original ``message_id`` with chunk 1 (with the platform's + usual ``(1/N)`` suffix preserved), then send the remaining chunks as + new messages threaded as replies to the previous chunk so the user + sees them grouped. Returns ``SendResult(success=True, + message_id=, continuation_message_ids=(...))`` so the + stream consumer can keep editing the most recent visible message + and the gateway has full visibility into every message id we put on + screen. + + Falls back to ``SendResult(success=False)`` only if even the first- + chunk edit fails — that's a real adapter problem, not an overflow. + """ + chunks = self.truncate_message( + content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len, + ) + if len(chunks) <= 1: + # Defensive: shouldn't happen given the caller's pre-flight, but + # if truncate_message returned a single chunk just edit normally. + chunks = [content] + + # Step 1 — edit the existing message with the first chunk. + first_chunk = chunks[0] + try: + if finalize: + # Use format_message + parse_mode for the final chunk; + # mirror edit_message's main happy-path. + formatted = self.format_message(first_chunk) + try: + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=int(message_id), + text=formatted, + parse_mode=ParseMode.MARKDOWN_V2, + ) + except Exception as fmt_err: + if "not modified" not in str(fmt_err).lower(): + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=int(message_id), + text=first_chunk, + ) + else: + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=int(message_id), + text=first_chunk, + ) + except Exception as e: + err_str = str(e).lower() + if "not modified" in err_str: + # First chunk identical to current text — fall through to + # send continuations. + pass + else: + logger.error( + "[%s] Overflow split: first-chunk edit failed: %s", + self.name, e, exc_info=True, + ) + return SendResult(success=False, error=str(e)) + + # Step 2 — send each remaining chunk as a continuation message, + # threaded as a reply to the previous so the user sees them as a + # contiguous block. We call self._bot.send_message directly so the + # continuation skips ``self.send``'s own pre-chunking pass (chunks + # are already correctly sized). Best-effort MarkdownV2 with plain + # fallback, mirroring send(). + continuation_ids: list[str] = [] + prev_id = message_id + for chunk in chunks[1:]: + sent_msg = None + for use_markdown in (True, False) if finalize else (False,): + try: + text = self.format_message(chunk) if use_markdown else chunk + sent_msg = await self._bot.send_message( + chat_id=int(chat_id), + text=text, + parse_mode=ParseMode.MARKDOWN_V2 if use_markdown else None, + reply_to_message_id=int(prev_id) if prev_id else None, + ) + break + except Exception as send_err: + if "reply message not found" in str(send_err).lower(): + # Drop the reply anchor and try again. + try: + sent_msg = await self._bot.send_message( + chat_id=int(chat_id), + text=chunk, + ) + break + except Exception as _retry_err: + logger.warning( + "[%s] Overflow continuation no-reply retry failed: %s", + self.name, _retry_err, + ) + sent_msg = None + break + if use_markdown: + # try plain text on next loop iteration + continue + logger.warning( + "[%s] Overflow continuation send failed: %s", + self.name, send_err, + ) + sent_msg = None + break + if sent_msg is None: + # Continuation failed — the user has chunk 1 + however many + # continuations succeeded. Report success with what we got + # so the stream consumer knows the edit landed; the + # remaining tail is lost on this attempt and the next + # streaming tick may retry. + logger.warning( + "[%s] Overflow split: stopped at %d/%d chunks delivered", + self.name, 1 + len(continuation_ids), len(chunks), + ) + break + new_id = str(getattr(sent_msg, "message_id", "")) or prev_id + continuation_ids.append(new_id) + prev_id = new_id + + last_id = continuation_ids[-1] if continuation_ids else message_id + logger.debug( + "[%s] Overflow split delivered %d chunks; last_id=%s", + self.name, 1 + len(continuation_ids), last_id, + ) + return SendResult( + success=True, + message_id=last_id, + continuation_message_ids=tuple(continuation_ids), + ) + async def delete_message(self, chat_id: str, message_id: str) -> bool: """Delete a previously sent Telegram message. @@ -1663,6 +1900,109 @@ class TelegramAdapter(BasePlatformAdapter): ) return False + def supports_draft_streaming( + self, + chat_type: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> bool: + """Telegram supports sendMessageDraft for private chats only. + + Bot API 9.5 (March 2026) opened ``sendMessageDraft`` to all bots + unconditionally for private (DM) chats. Groups, supergroups, and + channels still rely on the edit-based path. + + We additionally require ``self._bot`` to expose ``send_message_draft`` + (added to python-telegram-bot in 22.6); older PTB installs gracefully + fall back to the edit path even on DMs. + """ + if not self._bot or not hasattr(self._bot, "send_message_draft"): + return False + return (chat_type or "").lower() in {"dm", "private"} + + async def send_draft( + self, + chat_id: str, + draft_id: int, + content: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Stream a partial message via Telegram's native sendMessageDraft. + + The Bot API animates the preview when the same ``draft_id`` is reused + across consecutive calls in the same chat. When the response + finishes, the caller sends the final text via the normal ``send`` + path; the draft preview clears naturally on the client (Telegram has + no Bot API to "promote" a draft to a real message — the final + ``sendMessage`` is what the user receives in their history). + """ + if not self._bot: + return SendResult(success=False, error="not_connected") + if not hasattr(self._bot, "send_message_draft"): + return SendResult(success=False, error="api_unavailable") + + # Trim to the same UTF-16 budget the platform enforces on regular + # sends. Drafts have the same length contract as messages. + text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \ + self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0] + + kwargs: Dict[str, Any] = { + "chat_id": int(chat_id), + "draft_id": int(draft_id), + "text": text, + } + thread_id = self._metadata_thread_id(metadata) + if thread_id is not None: + kwargs["message_thread_id"] = thread_id + + try: + ok = await self._bot.send_message_draft(**kwargs) + if ok: + # Drafts have no message_id; we report success without one + # so the caller knows the animation frame landed. + return SendResult(success=True, message_id=None) + return SendResult(success=False, error="draft_rejected") + except Exception as e: + # Most likely: BadRequest because this bot/chat doesn't allow + # drafts, or a transient server hiccup. The caller treats any + # failure as "fall back to edit-based for this response". + logger.debug( + "[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s", + self.name, chat_id, draft_id, e, + ) + return SendResult(success=False, error=str(e)) + + async def _send_message_with_thread_fallback(self, **kwargs): + """Send a Telegram message, retrying once without message_thread_id + if Telegram returns 'Message thread not found'. + + Used for control-style sends (approval prompts, model picker, + update prompts) that can carry a stale thread_id from a DM + reply chain. The streaming send loop has its own equivalent + (PR #3390) at the body of ``send``; this helper applies the + same retry pattern to the non-streaming control paths. + """ + if not self._bot: + raise RuntimeError("Not connected") + + message_thread_id = kwargs.get("message_thread_id") + try: + return await self._bot.send_message(**kwargs) + except Exception as send_err: + if ( + message_thread_id is not None + and self._is_bad_request_error(send_err) + and self._is_thread_not_found_error(send_err) + ): + logger.warning( + "[%s] Thread %s not found for control message, retrying without message_thread_id", + self.name, + message_thread_id, + ) + retry_kwargs = dict(kwargs) + retry_kwargs.pop("message_thread_id", None) + return await self._bot.send_message(**retry_kwargs) + raise + async def send_update_prompt( self, chat_id: str, prompt: str, default: str = "", session_key: str = "", @@ -1686,7 +2026,7 @@ class TelegramAdapter(BasePlatformAdapter): ]) thread_id = self._metadata_thread_id(metadata) reply_to_id = self._reply_to_message_id_for_send(None, metadata) - msg = await self._bot.send_message( + msg = await self._send_message_with_thread_fallback( chat_id=int(chat_id), text=text, parse_mode=ParseMode.MARKDOWN, @@ -1766,7 +2106,7 @@ class TelegramAdapter(BasePlatformAdapter): ) ) - msg = await self._bot.send_message(**kwargs) + msg = await self._send_message_with_thread_fallback(**kwargs) # Store session_key keyed by approval_id for the callback handler self._approval_state[approval_id] = session_key @@ -1818,7 +2158,7 @@ class TelegramAdapter(BasePlatformAdapter): ) ) - msg = await self._bot.send_message(**kwargs) + msg = await self._send_message_with_thread_fallback(**kwargs) self._slash_confirm_state[confirm_id] = session_key return SendResult(success=True, message_id=str(msg.message_id)) except Exception as e: @@ -1876,7 +2216,7 @@ class TelegramAdapter(BasePlatformAdapter): thread_id = metadata.get("thread_id") if metadata else None reply_to_id = self._reply_to_message_id_for_send(None, metadata) - msg = await self._bot.send_message( + msg = await self._send_message_with_thread_fallback( chat_id=int(chat_id), text=text, parse_mode=ParseMode.MARKDOWN, @@ -2383,7 +2723,7 @@ class TelegramAdapter(BasePlatformAdapter): with open(audio_path, "rb") as audio_file: ext = os.path.splitext(audio_path)[1].lower() # .ogg / .opus files -> send as voice (round playable bubble) - if ext in (".ogg", ".opus"): + if ext in {".ogg", ".opus"}: _voice_thread = self._metadata_thread_id(metadata) reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata) voice_thread_kwargs = self._thread_kwargs_for_send( @@ -2407,7 +2747,7 @@ class TelegramAdapter(BasePlatformAdapter): "voice", reset_media=lambda: audio_file.seek(0), ) - elif ext in (".mp3", ".m4a"): + elif ext in {".mp3", ".m4a"}: # Telegram's Bot API sendAudio only accepts MP3 / M4A. _audio_thread = self._metadata_thread_id(metadata) reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata) @@ -3158,18 +3498,18 @@ class TelegramAdapter(BasePlatformAdapter): configured = self.config.extra.get("require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() in ("true", "1", "yes", "on") + return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"} def _telegram_guest_mode(self) -> bool: """Return whether non-allowlisted groups may trigger via direct @mention.""" configured = self.config.extra.get("guest_mode") if configured is not None: if isinstance(configured, str): - return configured.lower() in ("true", "1", "yes", "on") + return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("TELEGRAM_GUEST_MODE", "false").lower() in ("true", "1", "yes", "on") + return os.getenv("TELEGRAM_GUEST_MODE", "false").lower() in {"true", "1", "yes", "on"} def _telegram_free_response_chats(self) -> set[str]: raw = self.config.extra.get("free_response_chats") @@ -3258,7 +3598,7 @@ class TelegramAdapter(BasePlatformAdapter): if not chat: return False chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower() - return chat_type in ("group", "supergroup") + return chat_type in {"group", "supergroup"} def _is_reply_to_bot(self, message: Message) -> bool: if not self._bot or not getattr(message, "reply_to_message", None): @@ -3522,12 +3862,27 @@ class TelegramAdapter(BasePlatformAdapter): """ current_task = asyncio.current_task() try: - # Adaptive delay: if the latest chunk is near Telegram's 4096-char - # split point, a continuation is almost certain — wait longer. + # Adaptive delay tiers: + # - last chunk ≥ _SPLIT_THRESHOLD: a continuation is almost + # certain → wait the longer split delay. + # - total accumulated text ≤ _TEXT_BATCH_FAST_LEN (~320 cp): + # short message → cap delay at _TEXT_BATCH_FAST_DELAY_S + # so the agent sees the text near-instantly. + # - total ≤ _TEXT_BATCH_SHORT_LEN (~1024 cp): + # medium → cap at _TEXT_BATCH_SHORT_DELAY_S. + # - otherwise: use the configured cap. + # Tiers compose with operator overrides via the env-var-driven + # ``_text_batch_delay_seconds`` (e.g. an operator who sets the + # cap below 0.18s gets that lower number on every tier). pending = self._pending_text_batches.get(key) last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 + total_len = len(getattr(pending, "text", "") or "") if pending else 0 if last_len >= self._SPLIT_THRESHOLD: delay = self._text_batch_split_delay_seconds + elif total_len <= self._TEXT_BATCH_FAST_LEN: + delay = min(self._text_batch_delay_seconds, self._TEXT_BATCH_FAST_DELAY_S) + elif total_len <= self._TEXT_BATCH_SHORT_LEN: + delay = min(self._text_batch_delay_seconds, self._TEXT_BATCH_SHORT_DELAY_S) else: delay = self._text_batch_delay_seconds await asyncio.sleep(delay) @@ -3802,7 +4157,7 @@ class TelegramAdapter(BasePlatformAdapter): # For text files, inject content into event.text (capped at 100 KB) MAX_TEXT_INJECT_BYTES = 100 * 1024 - if ext in (".md", ".txt") and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + if ext in {".md", ".txt"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: try: text_content = raw_bytes.decode("utf-8") display_name = original_filename or f"document{ext}" @@ -4041,14 +4396,29 @@ class TelegramAdapter(BasePlatformAdapter): # Determine chat type chat_type = "dm" - if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP): + if chat.type in {ChatType.GROUP, ChatType.SUPERGROUP}: chat_type = "group" elif chat.type == ChatType.CHANNEL: chat_type = "channel" - # Resolve DM topic name and skill binding + # Resolve DM topic name and skill binding. + # In private chats, only preserve thread ids for real topic messages + # (is_topic_message=True). Telegram puts message_thread_id on every + # DM that is a reply, even when the user is just replying to a + # previous message in the same DM — that bogus id then routes to a + # nonexistent thread and Telegram returns 'Message thread not found' + # on send (#3206). thread_id_raw = message.message_thread_id - thread_id_str = str(thread_id_raw) if thread_id_raw is not None else None + is_topic_message = bool(getattr(message, "is_topic_message", False)) + thread_id_str = None + if thread_id_raw is not None: + if chat_type == "group": + thread_id_str = str(thread_id_raw) + elif chat_type == "dm" and is_topic_message: + thread_id_str = str(thread_id_raw) + # For forum groups without an explicit topic, default to the + # General-topic id so the gateway routes back to the General topic + # rather than dropping into the bot's main channel (#22423). if chat_type == "group" and thread_id_str is None and getattr(chat, "is_forum", False): thread_id_str = self._GENERAL_TOPIC_THREAD_ID chat_topic = None @@ -4142,7 +4512,7 @@ class TelegramAdapter(BasePlatformAdapter): def _reactions_enabled(self) -> bool: """Check if message reactions are enabled via config/env.""" - return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in ("false", "0", "no") + return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in {"false", "0", "no"} async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool: """Set a single emoji reaction on a Telegram message.""" diff --git a/gateway/platforms/telegram_network.py b/gateway/platforms/telegram_network.py index 8fe4c280934..2975c6f029c 100644 --- a/gateway/platforms/telegram_network.py +++ b/gateway/platforms/telegram_network.py @@ -59,7 +59,7 @@ class TelegramFallbackTransport(httpx.AsyncBaseTransport): """ def __init__(self, fallback_ips: Iterable[str], **transport_kwargs): - self._fallback_ips = [ip for ip in dict.fromkeys(_normalize_fallback_ips(fallback_ips))] + self._fallback_ips = list(dict.fromkeys(_normalize_fallback_ips(fallback_ips))) proxy_url = _resolve_proxy_url(target_hosts=[_TELEGRAM_API_HOST, *self._fallback_ips]) if proxy_url and "proxy" not in transport_kwargs: transport_kwargs["proxy"] = proxy_url diff --git a/gateway/platforms/wecom.py b/gateway/platforms/wecom.py index 769743794df..d7a5c1d9a49 100644 --- a/gateway/platforms/wecom.py +++ b/gateway/platforms/wecom.py @@ -295,7 +295,7 @@ class WeComAdapter(BasePlatformAdapter): auth_payload = await self._wait_for_handshake(req_id) errcode = auth_payload.get("errcode", 0) - if errcode not in (0, None): + if errcode not in {0, None}: errmsg = auth_payload.get("errmsg", "authentication failed") raise RuntimeError(f"{errmsg} (errcode={errcode})") @@ -320,7 +320,7 @@ class WeComAdapter(BasePlatformAdapter): if self._payload_req_id(payload) == req_id: return payload logger.debug("[%s] Ignoring pre-auth payload: %s", self.name, payload.get("cmd")) - elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR): + elif msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.ERROR}: raise RuntimeError("WeCom websocket closed during authentication") async def _listen_loop(self) -> None: @@ -360,7 +360,7 @@ class WeComAdapter(BasePlatformAdapter): payload = self._parse_json(msg.data) if payload: await self._dispatch_payload(payload) - elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + elif msg.type in {aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR}: raise RuntimeError("WeCom websocket closed") async def _heartbeat_loop(self) -> None: @@ -998,7 +998,7 @@ class WeComAdapter(BasePlatformAdapter): @staticmethod def _response_error(response: Dict[str, Any]) -> Optional[str]: errcode = response.get("errcode", 0) - if errcode in (0, None): + if errcode in {0, None}: return None errmsg = str(response.get("errmsg") or "unknown error") return f"WeCom errcode {errcode}: {errmsg}" diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 1c20b3f2902..1c9fec0af7f 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -605,7 +605,7 @@ def _assert_weixin_cdn_url(url: str) -> None: except Exception as exc: # noqa: BLE001 raise ValueError(f"Unparseable media URL: {url!r}") from exc - if scheme not in ("http", "https"): + if scheme not in {"http", "https"}: raise ValueError( f"Media URL has disallowed scheme {scheme!r}; only http/https are permitted." ) @@ -983,7 +983,7 @@ def _extract_text(item_list: List[Dict[str, Any]]) -> str: ref = item.get("ref_msg") or {} ref_item = ref.get("message_item") or {} ref_type = ref_item.get("type") - if ref_type in (ITEM_IMAGE, ITEM_VIDEO, ITEM_FILE, ITEM_VOICE): + if ref_type in {ITEM_IMAGE, ITEM_VIDEO, ITEM_FILE, ITEM_VOICE}: title = ref.get("title") or "" prefix = f"[引用媒体: {title}]\n" if title else "[引用媒体]\n" return f"{prefix}{text}".strip() @@ -1331,7 +1331,7 @@ class WeixinAdapter(BasePlatformAdapter): ret = response.get("ret", 0) errcode = response.get("errcode", 0) - if ret not in (0, None) or errcode not in (0, None): + if ret not in {0, None} or errcode not in {0, None}: if (ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE or _is_stale_session_ret(ret, errcode, response.get("errmsg"))): logger.error("[%s] Session expired; pausing for 10 minutes", self.name) @@ -1601,7 +1601,7 @@ class WeixinAdapter(BasePlatformAdapter): if resp and isinstance(resp, dict): ret = resp.get("ret") errcode = resp.get("errcode") - if (ret is not None and ret not in (0,)) or (errcode is not None and errcode not in (0,)): + if (ret is not None and ret not in {0,}) or (errcode is not None and errcode not in {0,}): is_session_expired = ( ret == SESSION_EXPIRED_ERRCODE or errcode == SESSION_EXPIRED_ERRCODE diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 8e21736441c..2fb6fc13329 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -301,9 +301,9 @@ class WhatsAppAdapter(BasePlatformAdapter): configured = self.config.extra.get("require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() in ("true", "1", "yes", "on") + return configured.lower() in {"true", "1", "yes", "on"} return bool(configured) - return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + return os.getenv("WHATSAPP_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"} def _whatsapp_free_response_chats(self) -> set[str]: raw = self.config.extra.get("free_response_chats") @@ -679,7 +679,7 @@ class WhatsAppAdapter(BasePlatformAdapter): # getattr-with-default keeps tests that construct the adapter via # ``WhatsAppAdapter.__new__`` (bypassing __init__) working without # every _make_adapter() helper having to seed the attribute. - if getattr(self, "_shutting_down", False) and returncode in (0, -2, -15): + if getattr(self, "_shutting_down", False) and returncode in {0, -2, -15}: logger.info( "[%s] Bridge exited during shutdown (code %d).", self.name, @@ -1183,7 +1183,7 @@ class WhatsAppAdapter(BasePlatformAdapter): if msg_type == MessageType.DOCUMENT and cached_urls: for doc_path in cached_urls: ext = Path(doc_path).suffix.lower() - if ext in (".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log", ".py", ".js", ".ts", ".html", ".css"): + if ext in {".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log", ".py", ".js", ".ts", ".html", ".css"}: try: file_size = Path(doc_path).stat().st_size if file_size > MAX_TEXT_INJECT_BYTES: diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index f08f7266e19..d79da7856ae 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -2228,7 +2228,7 @@ class MediaResolveMiddleware(InboundMiddleware): resp.raise_for_status() payload = resp.json() code = payload.get("code") - if code not in (None, 0): + if code not in {None, 0}: raise RuntimeError( f"resource/v1/download failed: code={code}, msg={payload.get('msg', '')}" ) @@ -2391,7 +2391,7 @@ class MediaResolveMiddleware(InboundMiddleware): rid = m.group(2) kind, _, filename = head.partition(":") kind = kind.strip() - if kind not in ("image", "file"): + if kind not in {"image", "file"}: continue if rid in seen: continue @@ -2993,10 +2993,10 @@ class ConnectionManager: # Fire-and-forget heartbeat ACKs — server always responds but callers don't # wait on these; silently discard to avoid "Unmatched Response" noise. - if cmd_type == CMD_TYPE["Response"] and cmd in ( + if cmd_type == CMD_TYPE["Response"] and cmd in { "send_group_heartbeat", "send_private_heartbeat", - ): + }: logger.debug("[%s] Heartbeat ACK received: cmd=%s msg_id=%s", adapter.name, cmd, msg_id) return @@ -3369,7 +3369,7 @@ class MediaSendHandler(ABC): # Remove keys already passed explicitly to avoid "multiple values" TypeError fwd_kwargs = { k: v for k, v in kwargs.items() - if k not in ("file_uuid", "filename", "content_type") + if k not in {"file_uuid", "filename", "content_type"} } msg_body = self.build_msg_body( upload_result, diff --git a/gateway/platforms/yuanbao_media.py b/gateway/platforms/yuanbao_media.py index 39f8d88d8a3..87eefcddae2 100644 --- a/gateway/platforms/yuanbao_media.py +++ b/gateway/platforms/yuanbao_media.py @@ -150,7 +150,7 @@ def _parse_jpeg_size(buf: bytes) -> Optional[dict[str, int]]: i += 1 continue marker = buf[i + 1] - if marker in (0xC0, 0xC2): + if marker in {0xC0, 0xC2}: h = struct.unpack(">H", buf[i + 5: i + 7])[0] w = struct.unpack(">H", buf[i + 7: i + 9])[0] return {"width": w, "height": h} @@ -165,7 +165,7 @@ def _parse_gif_size(buf: bytes) -> Optional[dict[str, int]]: if len(buf) < 10: return None sig = buf[:6].decode("ascii", errors="replace") - if sig not in ("GIF87a", "GIF89a"): + if sig not in {"GIF87a", "GIF89a"}: return None w = struct.unpack(" Optional[dict]: "trace_id": trace_id, } # 过滤空值(保持 API 整洁) - return {k: v for k, v in result.items() if v or k in ("msg_body", "msg_seq")} + return {k: v for k, v in result.items() if v or k in {"msg_body", "msg_seq"}} except Exception as e: if DEBUG_MODE: logger.debug("[yuanbao_proto] decode_inbound_push failed: %s", e) diff --git a/gateway/run.py b/gateway/run.py index 1b741b6a81a..1da45e3f03f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -268,9 +268,8 @@ def _build_replay_entry(role: str, content: Any, msg: Dict[str, Any]) -> Dict[st # Preserve empty-string sentinel for thinking-mode replay. if _rval is None: continue - else: - if not _rval: - continue + elif not _rval: + continue entry[_rkey] = _rval return entry @@ -289,7 +288,7 @@ def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any: if not isinstance(msg, dict): continue role = msg.get("role") - if not role or role in ("session_meta", "system"): + if not role or role in {"session_meta", "system"}: continue ts = msg.get("timestamp") if ts is not None: @@ -473,7 +472,7 @@ if _config_path.exists(): # gateway resolves these to Path.home() later (line ~255). # Writing the raw placeholder here would just be noise. # Only bridge explicit absolute paths from config.yaml. - if _cfg_key == "cwd" and str(_val) in (".", "auto", "cwd"): + if _cfg_key == "cwd" and str(_val) in {".", "auto", "cwd"}: continue # Expand shell tilde in cwd so subprocess.Popen never # receives a literal "~/" which the kernel rejects. @@ -617,7 +616,7 @@ os.environ["HERMES_EXEC_ASK"] = "1" # to home directory. MESSAGING_CWD is accepted as a backward-compat # fallback (deprecated — the warning above tells users to migrate). _configured_cwd = os.environ.get("TERMINAL_CWD", "") -if not _configured_cwd or _configured_cwd in (".", "auto", "cwd"): +if not _configured_cwd or _configured_cwd in {".", "auto", "cwd"}: _fallback = os.getenv("MESSAGING_CWD") or str(Path.home()) os.environ["TERMINAL_CWD"] = _fallback @@ -850,7 +849,7 @@ def _skill_slug_from_frontmatter(skill_md: Path) -> tuple[str | None, str | None if line.startswith("name:"): raw = line.split(":", 1)[1].strip() # Strip YAML quote wrappers if present - if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ('"', "'"): + if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {'"', "'"}: raw = raw[1:-1] declared_name = raw.strip() break @@ -892,7 +891,7 @@ def _check_unavailable_skill(command_name: str) -> str | None: if not skills_dir.exists(): continue for skill_md in skills_dir.rglob("SKILL.md"): - if any(part in ('.git', '.github', '.hub', '.archive') for part in skill_md.parts): + if any(part in {'.git', '.github', '.hub', '.archive'} for part in skill_md.parts): continue slug, declared_name = _skill_slug_from_frontmatter(skill_md) if not slug or not declared_name: @@ -1034,7 +1033,7 @@ def _parse_session_key(session_key: str) -> "dict | None": "chat_type": parts[3], "chat_id": parts[4], } - if len(parts) > 5 and parts[3] in ("dm", "thread"): + if len(parts) > 5 and parts[3] in {"dm", "thread"}: result["thread_id"] = parts[5] return result return None @@ -1249,6 +1248,7 @@ class GatewayRunner: # Per-session reasoning effort overrides from /reasoning. # Key: session_key, Value: parsed reasoning config dict. self._session_reasoning_overrides: Dict[str, Dict[str, Any]] = {} + self._kanban_notifier_profile = self._active_profile_name() # Teams meeting pipeline runtime (bound later when msgraph_webhook adapter exists). self._teams_pipeline_runtime = None self._teams_pipeline_runtime_error: Optional[str] = None @@ -1561,7 +1561,7 @@ class GatewayRunner: enabled_chats.clear() enabled_chats.update( key[len(prefix):] for key, mode in self._voice_mode.items() - if mode in ("voice_only", "all") and key.startswith(prefix) + if mode in {"voice_only", "all"} and key.startswith(prefix) ) async def _safe_adapter_disconnect(self, adapter, platform) -> None: @@ -1991,7 +1991,7 @@ class GatewayRunner: # Both "queue" and "steer" modes imply the user doesn't want messages # to be lost during restart — queue them for the newly-spawned gateway # process to pick up. "interrupt" mode drops them (current behaviour). - return self._restart_requested and self._busy_input_mode in ("queue", "steer") + return self._restart_requested and self._busy_input_mode in {"queue", "steer"} # -------- /queue FIFO helpers -------------------------------------- # /queue must produce one full agent turn per invocation, in FIFO @@ -2401,7 +2401,7 @@ class GatewayRunner: raw = cfg_get(cfg, "display", "background_process_notifications") if raw is False: mode = "off" - elif raw not in (None, ""): + elif raw not in {None, ""}: mode = str(raw) except Exception: pass @@ -3206,6 +3206,28 @@ class GatewayRunner: except RuntimeError: self._gateway_loop = None logger.info("Session storage: %s", self.config.sessions_dir) + + # Sanity-check that systemd's TimeoutStopSec covers our drain + # window. When the user upgraded hermes-agent without re-running + # ``hermes setup``, their unit file may still encode the old + # default — in which case SIGKILL hits mid-drain and looks like + # a phantom kill in the journal. Best-effort, never raises. + try: + from gateway.shutdown_forensics import check_systemd_timing_alignment + _alignment = check_systemd_timing_alignment(self._restart_drain_timeout) + if _alignment is not None and _alignment.get("mismatch"): + logger.warning( + "Stale systemd unit detected: %s has TimeoutStopSec=%.0fs but " + "drain_timeout=%.0fs (expected >=%.0fs). systemd may SIGKILL the " + "gateway mid-drain. Run `hermes gateway service install --replace` " + "to regenerate the unit, or shorten agent.restart_drain_timeout.", + _alignment.get("unit", "(unknown)"), + _alignment["timeout_stop_sec"], + _alignment["drain_timeout"], + _alignment["expected_min"], + ) + except Exception as _e: + logger.debug("check_systemd_timing_alignment failed: %s", _e) # Log the resolved max_iterations budget so operators can verify the # config.yaml → env bridge did the right thing at a glance (instead # of silently running at a stale .env value for weeks). @@ -3225,7 +3247,7 @@ class GatewayRunner: # for this process's lifetime. try: _redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true") - _redact_on = _redact_raw.lower() in ("1", "true", "yes", "on") + _redact_on = _redact_raw.lower() in {"1", "true", "yes", "on"} if _redact_on: logger.info( "Secret redaction: ENABLED (tool output, logs, and chat " @@ -3307,8 +3329,8 @@ class GatewayRunner: _any_allowlist = any( os.getenv(v) for v in _builtin_allowed_vars + _plugin_allowed_vars ) - _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any( - os.getenv(v, "").lower() in ("true", "1", "yes") + _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} or any( + os.getenv(v, "").lower() in {"true", "1", "yes"} for v in _builtin_allow_all_vars + _plugin_allow_all_vars ) if not _any_allowlist and not _allow_all: @@ -3659,10 +3681,234 @@ class GatewayRunner: ) asyncio.create_task(self._platform_reconnect_watcher()) + # Start background handoff watcher — picks up CLI sessions marked + # handoff_state='pending' in state.db and re-binds them to the + # destination platform's home channel, then forges a synthetic user + # turn so the agent kicks off the new chat. + asyncio.create_task(self._handoff_watcher()) + logger.info("Press Ctrl+C to stop") return True + async def _handoff_watcher(self, interval: float = 2.0) -> None: + """Background task that processes pending CLI→gateway session handoffs. + + Polls ``state.db`` for sessions in ``handoff_state='pending'`` and, + for each one: + + 1. Atomically claims it (pending → running). + 2. Resolves the destination platform's configured home channel. + 3. Re-binds the gateway's session_key for that home channel to the + CLI's existing session_id via ``session_store.switch_session`` so + the full role-aware transcript replays on the next agent turn. + 4. Forges a synthetic ``MessageEvent`` (``internal=True``) with a + handoff-notice text and dispatches through the normal gateway + message pipeline so the agent runs and replies on the platform. + 5. Marks the row ``completed`` (or ``failed`` with ``handoff_error``). + + The CLI process is poll-blocked on the row's terminal state and + prints the result to the user. + """ + # Initial delay so the gateway is fully connected to its platforms + # before we try to dispatch handoffs through them. + await asyncio.sleep(5) + while self._running: + try: + if self._session_db is None: + await asyncio.sleep(interval) + continue + pending = self._session_db.list_pending_handoffs() + for row in pending: + session_id = row.get("id") + if not session_id: + continue + if not self._session_db.claim_handoff(session_id): + # Another tick or another gateway already claimed it. + continue + try: + await self._process_handoff(row) + self._session_db.complete_handoff(session_id) + except Exception as exc: + logger.warning( + "Handoff for session %s failed: %s", + session_id, exc, exc_info=True, + ) + self._session_db.fail_handoff(session_id, str(exc)) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.debug("Handoff watcher tick error: %s", exc, exc_info=True) + await asyncio.sleep(interval) + + async def _process_handoff(self, row: Dict[str, Any]) -> None: + """Execute one handoff row. Raises on failure (caller marks failed).""" + from gateway.config import Platform + from gateway.session import SessionSource, build_session_key + from gateway.platforms.base import MessageEvent + + cli_session_id = row["id"] + platform_name = (row.get("handoff_platform") or "").strip().lower() + if not platform_name: + raise RuntimeError("handoff_platform is empty") + + # Resolve platform enum + try: + platform = Platform(platform_name) + except (ValueError, KeyError): + raise RuntimeError(f"unknown platform '{platform_name}'") + + # Adapter must be live + adapter = self.adapters.get(platform) + if not adapter: + raise RuntimeError( + f"platform '{platform_name}' is not active in this gateway" + ) + + # Home channel must be configured + home = self.config.get_home_channel(platform) + if not home or not home.chat_id: + raise RuntimeError( + f"no home channel configured for {platform_name}; " + f"run /sethome on the desired chat first" + ) + + cli_title = row.get("title") or cli_session_id[:8] + + # Try to create a fresh thread on the destination so the handoff + # has its own scrollback. Adapter returns None if threading isn't + # supported (Matrix/WhatsApp/Signal/SMS) or if creation failed + # (no permission, topics-mode off, parent is a DM, etc.). When + # None we fall through to using the home channel directly — the + # synthetic turn still lands; just without thread isolation. + thread_name = f"Hermes — {cli_title}" + try: + new_thread_id = await adapter.create_handoff_thread( + str(home.chat_id), thread_name, + ) + except Exception as exc: + logger.debug( + "Handoff: create_handoff_thread raised on %s: %s", + platform_name, exc, exc_info=True, + ) + new_thread_id = None + + # Use the new thread if the adapter created one; otherwise fall + # back to whatever thread (if any) the home channel was configured + # with. + effective_thread_id = new_thread_id or ( + str(home.thread_id) if home.thread_id else None + ) + + # Determine chat_type for the destination source. If we created a + # thread, key the session_key as a thread (build_session_key sets + # thread sessions to user-shared by default, which is what we + # want — the synthetic turn and any later real-user message both + # land on the same key without needing a user_id). + if new_thread_id: + dest_chat_type = "thread" + else: + # No thread — assume DM-style for the home channel. For + # group/channel home channels without thread support + # (Matrix/WhatsApp/Signal), the platform's own keying makes + # the synthetic turn shared anyway (single-DM platforms). + dest_chat_type = "dm" + + dest_source = SessionSource( + platform=platform, + chat_id=str(home.chat_id), + chat_name=home.name, + chat_type=dest_chat_type, + user_id="system:handoff", + user_name="Handoff", + thread_id=effective_thread_id, + ) + + # Compute the gateway's session_key for that destination using the + # same rules its adapters use, so switch_session targets the right + # entry. For thread destinations build_session_key keys without + # user_id (thread_sessions_per_user defaults to False) — so the + # next real user message in the thread shares this same session. + platform_cfg = self.config.platforms.get(platform) + extra = platform_cfg.extra if platform_cfg else {} + session_key = build_session_key( + dest_source, + group_sessions_per_user=extra.get("group_sessions_per_user", True), + thread_sessions_per_user=extra.get("thread_sessions_per_user", False), + ) + + # Make sure there's an entry in the session_store for this key. If + # the home channel has never been used, get_or_create_session + # creates one; switch_session then re-points it. + self.session_store.get_or_create_session(dest_source) + + # Re-bind the destination key to the CLI session_id. switch_session + # ends the prior session in SQLite and reopens the CLI session under + # the new key. The CLI's transcript becomes the active one for the + # gateway from this moment on. + switched = self.session_store.switch_session(session_key, cli_session_id) + if switched is None: + raise RuntimeError( + f"could not switch session key {session_key} → {cli_session_id}" + ) + + # Evict any cached AIAgent for this session_key so the next dispatch + # rebuilds it against the CLI session_id (mirrors /resume / /branch). + self._evict_cached_agent(session_key) + + # Cancel any in-flight running-agent state for the destination key + # so the synthetic turn isn't queued behind a stale running flag. + self._release_running_agent_state(session_key) + + synthetic_text = ( + f"[Session was just handed off from CLI (\"{cli_title}\") to this " + f"channel. The full prior conversation history is loaded above. " + f"Briefly confirm you're working here and summarize what we were " + f"working on, so the user can continue from this device.]" + ) + + synthetic_event = MessageEvent( + text=synthetic_text, + source=dest_source, + internal=True, + ) + + logger.info( + "Handoff: dispatching synthetic turn for CLI session %s → %s " + "(home=%s, thread=%s, session_key=%s)", + cli_session_id, platform_name, home.chat_id, effective_thread_id, + session_key, + ) + + # Dispatch through the runner directly. Going through + # adapter.handle_message would spawn a background task and we'd + # lose synchronous error visibility; calling _handle_message inline + # keeps the success/failure path observable for the watcher. + response_text = await self._handle_message(synthetic_event) + if not response_text: + # Streaming may have already delivered the response inline. + # Either way, agent ran without raising — count as success. + return + + # Send the agent's reply to the destination. Route to the new + # thread if we created one; otherwise the configured home channel + # (which may itself carry a thread_id). + send_metadata: Dict[str, Any] = {} + if effective_thread_id: + send_metadata["thread_id"] = effective_thread_id + try: + result = await adapter.send( + chat_id=str(home.chat_id), + content=response_text, + metadata=send_metadata or None, + ) + except Exception as exc: + raise RuntimeError(f"adapter.send failed: {exc}") from exc + + if not getattr(result, "success", True): + err = getattr(result, "error", "send returned success=False") + raise RuntimeError(f"adapter.send failed: {err}") + async def _session_expiry_watcher(self, interval: int = 300): """Background task that finalizes expired sessions. @@ -3825,6 +4071,14 @@ class GatewayRunner: break await asyncio.sleep(1) + def _active_profile_name(self) -> str: + """Return the profile name this gateway represents.""" + try: + from hermes_cli.profiles import get_active_profile_name + return get_active_profile_name() or "default" + except Exception: + return "default" + async def _kanban_notifier_watcher(self, interval: float = 5.0) -> None: """Poll ``kanban_notify_subs`` and deliver terminal events to users. @@ -3852,10 +4106,18 @@ class GatewayRunner: return TERMINAL_KINDS = ("completed", "blocked", "gave_up", "crashed", "timed_out") - # Terminal event kinds trigger automatic unsubscription — the task - # is done or in a retry-needed state that the human - # shouldn't keep pinging a stale chat for. - TERMINAL_EVENT_KINDS = ("completed", "gave_up", "crashed", "timed_out") + # Subscriptions are removed only when the task reaches a truly final + # status (done / archived). We used to also unsub on any terminal + # event kind (gave_up / crashed / timed_out / blocked), but that + # silently dropped the user out of the loop whenever the dispatcher + # respawned the task: a worker that crashes, gets reclaimed, runs + # again, and crashes a second time would only notify on the first + # crash because the subscription was deleted after the first event. + # Same shape as the reblock-after-unblock cycle that PR #22941 + # fixed for `blocked`. Keeping the subscription alive until the + # task is genuinely done lets the cursor (advanced atomically by + # claim_unseen_events_for_sub) handle dedup, and any retry-loop + # event reaches the user. # Per-subscription send-failure counter. Adapter.send raising # means the chat is dead (deleted, bot kicked, etc.) — after N # consecutive send failures the sub is dropped so we don't spin @@ -3865,6 +4127,10 @@ class GatewayRunner: self, "_kanban_sub_fail_counts", {} ) self._kanban_sub_fail_counts = sub_fail_counts + notifier_profile = getattr(self, "_kanban_notifier_profile", None) + if not notifier_profile: + notifier_profile = self._active_profile_name() + self._kanban_notifier_profile = notifier_profile # Initial delay so the gateway can finish wiring adapters. await asyncio.sleep(5) @@ -3873,18 +4139,42 @@ class GatewayRunner: try: def _collect(): deliveries: list[dict] = [] - # Enumerate every board on disk. Cheap: a few - # directory stat calls per tick. Missing/empty - # boards are silently skipped. + active_platforms = { + getattr(platform, "value", str(platform)).lower() + for platform in self.adapters.keys() + } + if not active_platforms: + logger.debug("kanban notifier: no connected adapters; skipping tick") + return deliveries + + # Enumerate every board on disk, but poll each resolved DB + # path once. Multiple slugs can point at the same DB when + # HERMES_KANBAN_DB pins the board path; without this guard + # one gateway could collect the same subscription/event + # more than once before advancing the cursor. try: boards = _kb.list_boards(include_archived=False) except Exception: boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)] + seen_db_paths: set[str] = set() for board_meta in boards: slug = board_meta.get("slug") or _kb.DEFAULT_BOARD + db_path = board_meta.get("db_path") + try: + resolved_db_path = str(Path(db_path).expanduser().resolve()) if db_path else str(_kb.kanban_db_path(slug).resolve()) + except Exception: + resolved_db_path = f"slug:{slug}" + if resolved_db_path in seen_db_paths: + logger.debug( + "kanban notifier: skipping duplicate board slug %s for DB %s", + slug, resolved_db_path, + ) + continue + seen_db_paths.add(resolved_db_path) try: conn = _kb.connect(board=slug) - except Exception: + except Exception as exc: + logger.debug("kanban notifier: cannot open board %s: %s", slug, exc) continue try: # `connect()` runs the schema + idempotent migration @@ -3900,8 +4190,24 @@ class GatewayRunner: # tolerates that race, but we still skip the # redundant call to avoid the wasted work. subs = _kb.list_notify_subs(conn) + if not subs: + logger.debug("kanban notifier: board %s has no subscriptions", slug) for sub in subs: - cursor, events = _kb.unseen_events_for_sub( + owner_profile = sub.get("notifier_profile") or None + if owner_profile and owner_profile != notifier_profile: + logger.debug( + "kanban notifier: subscription for %s owned by profile %s; current profile %s skipping", + sub.get("task_id"), owner_profile, notifier_profile, + ) + continue + platform = (sub.get("platform") or "").lower() + if platform not in active_platforms: + logger.debug( + "kanban notifier: subscription for %s on %s skipped; adapter not connected", + sub.get("task_id"), platform or "", + ) + continue + old_cursor, cursor, events = _kb.claim_unseen_events_for_sub( conn, task_id=sub["task_id"], platform=sub["platform"], @@ -3912,8 +4218,13 @@ class GatewayRunner: if not events: continue task = _kb.get_task(conn, sub["task_id"]) + logger.debug( + "kanban notifier: claimed %d event(s) for %s on board %s cursor %s→%s", + len(events), sub["task_id"], slug, old_cursor, cursor, + ) deliveries.append({ "sub": sub, + "old_cursor": old_cursor, "cursor": cursor, "events": events, "task": task, @@ -3940,7 +4251,18 @@ class GatewayRunner: continue adapter = self.adapters.get(plat) if adapter is None: - continue # platform not currently connected + logger.debug( + "kanban notifier: adapter %s disconnected before delivery for %s; rewinding claim", + platform_str, sub["task_id"], + ) + await asyncio.to_thread( + self._kanban_rewind, + sub, + d["cursor"], + d.get("old_cursor", 0), + board_slug, + ) + continue title = (task.title if task else sub["task_id"])[:120] for ev in d["events"]: kind = ev.kind @@ -4008,6 +4330,10 @@ class GatewayRunner: await adapter.send( sub["chat_id"], msg, metadata=metadata, ) + logger.debug( + "kanban notifier: delivered %s event for %s to %s/%s on board %s", + kind, sub["task_id"], platform_str, sub["chat_id"], board_slug, + ) # Reset the failure counter on success. sub_fail_counts.pop(sub_key, None) except Exception as exc: @@ -4027,22 +4353,34 @@ class GatewayRunner: ) await asyncio.to_thread(self._kanban_unsub, sub, board_slug) sub_fail_counts.pop(sub_key, None) - # Don't advance cursor on send failure — retry next tick. + else: + await asyncio.to_thread( + self._kanban_rewind, + sub, + d["cursor"], + d.get("old_cursor", 0), + board_slug, + ) + # Rewind the pre-send claim on transient failure so + # a later tick can retry. After too many failures, + # dropping the subscription is the terminal action. break else: - # All events delivered; advance cursor + maybe unsub. + # All events delivered; advance cursor. The cursor + # is the dedup mechanism — it prevents re-delivery + # of the same event on subsequent ticks. await asyncio.to_thread( self._kanban_advance, sub, d["cursor"], board_slug, ) - # Unsubscribe when the LAST delivered event is a - # terminal kind (the task hit a "no further updates" - # state), not just on task.status in {done, archived}. - # Covers blocked / gave_up / crashed / timed_out which - # used to leak subs forever. - last_kind = d["events"][-1].kind if d["events"] else None - task_terminal = task and task.status in ("done", "archived") - event_terminal = last_kind in TERMINAL_EVENT_KINDS - if task_terminal or event_terminal: + # Unsubscribe only when the task has reached a truly + # final status (done / archived). For blocked / + # gave_up / crashed / timed_out the subscription is + # kept alive so the user gets notified again if the + # dispatcher respawns the task and it cycles into the + # same state. See the longer comment on TERMINAL_KINDS + # above for the failure mode this prevents. + task_terminal = task and task.status in {"done", "archived"} + if task_terminal: await asyncio.to_thread( self._kanban_unsub, sub, board_slug, ) @@ -4090,6 +4428,29 @@ class GatewayRunner: finally: conn.close() + def _kanban_rewind( + self, + sub: dict, + claimed_cursor: int, + old_cursor: int, + board: Optional[str] = None, + ) -> None: + """Sync helper: undo a claimed notification cursor after send failure.""" + from hermes_cli import kanban_db as _kb + conn = _kb.connect(board=board) + try: + _kb.rewind_notify_cursor( + conn, + task_id=sub["task_id"], + platform=sub["platform"], + chat_id=sub["chat_id"], + thread_id=sub.get("thread_id") or "", + claimed_cursor=claimed_cursor, + old_cursor=old_cursor, + ) + finally: + conn.close() + async def _kanban_dispatcher_watcher(self) -> None: """Embedded kanban dispatcher — one tick every `dispatch_interval_seconds`. @@ -4118,7 +4479,7 @@ class GatewayRunner: logger.warning("kanban dispatcher: config loader unavailable; disabled") return env_override = os.environ.get("HERMES_KANBAN_DISPATCH_IN_GATEWAY", "").strip().lower() - if env_override in ("0", "false", "no", "off"): + if env_override in {"0", "false", "no", "off"}: logger.info("kanban dispatcher: disabled via HERMES_KANBAN_DISPATCH_IN_GATEWAY env") return @@ -4141,8 +4502,7 @@ class GatewayRunner: return interval = float(kanban_cfg.get("dispatch_interval_seconds", 60) or 60) - if interval < 1.0: - interval = 1.0 # sanity floor — tighter than this is a footgun + interval = max(interval, 1.0) # sanity floor — tighter than this is a footgun # Read max_spawn config to limit concurrent kanban tasks max_spawn = kanban_cfg.get("max_spawn", None) @@ -4394,34 +4754,33 @@ class GatewayRunner: await build_channel_directory(self.adapters) except Exception: pass + # Check if the failure is non-retryable + elif adapter.has_fatal_error and not adapter.fatal_error_retryable: + self._update_platform_runtime_status( + platform.value, + platform_state="fatal", + error_code=adapter.fatal_error_code, + error_message=adapter.fatal_error_message, + ) + logger.warning( + "Reconnect %s: non-retryable error (%s), removing from retry queue", + platform.value, adapter.fatal_error_message, + ) + del self._failed_platforms[platform] else: - # Check if the failure is non-retryable - if adapter.has_fatal_error and not adapter.fatal_error_retryable: - self._update_platform_runtime_status( - platform.value, - platform_state="fatal", - error_code=adapter.fatal_error_code, - error_message=adapter.fatal_error_message, - ) - logger.warning( - "Reconnect %s: non-retryable error (%s), removing from retry queue", - platform.value, adapter.fatal_error_message, - ) - del self._failed_platforms[platform] - else: - self._update_platform_runtime_status( - platform.value, - platform_state="retrying", - error_code=adapter.fatal_error_code, - error_message=adapter.fatal_error_message or "failed to reconnect", - ) - backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP) - info["attempts"] = attempt - info["next_retry"] = time.monotonic() + backoff - logger.info( - "Reconnect %s failed, next retry in %ds", - platform.value, backoff, - ) + self._update_platform_runtime_status( + platform.value, + platform_state="retrying", + error_code=adapter.fatal_error_code, + error_message=adapter.fatal_error_message or "failed to reconnect", + ) + backoff = min(30 * (2 ** (attempt - 1)), _BACKOFF_CAP) + info["attempts"] = attempt + info["next_retry"] = time.monotonic() + backoff + logger.info( + "Reconnect %s failed, next retry in %ds", + platform.value, backoff, + ) except Exception as e: self._update_platform_runtime_status( platform.value, @@ -4498,15 +4857,34 @@ class GatewayRunner: "Stopping gateway%s...", " for restart" if self._restart_requested else "", ) + _stop_started_at = time.monotonic() + + def _phase_elapsed() -> float: + return time.monotonic() - _stop_started_at + self._running = False self._draining = True # Notify all chats with active agents BEFORE draining. # Adapters are still connected here, so messages can be sent. await self._notify_active_sessions_of_shutdown() + logger.info( + "Shutdown phase: notify_active_sessions done at +%.2fs", + _phase_elapsed(), + ) timeout = self._restart_drain_timeout + _drain_started_at = time.monotonic() active_agents, timed_out = await self._drain_active_agents(timeout) + logger.info( + "Shutdown phase: drain done at +%.2fs (drain took %.2fs, " + "timed_out=%s, active_at_start=%d, active_now=%d)", + _phase_elapsed(), + time.monotonic() - _drain_started_at, + timed_out, + len(active_agents), + self._running_agent_count(), + ) if timed_out: logger.warning( "Gateway drain timed out after %.1fs with %d active agent(s); interrupting remaining work.", @@ -4564,6 +4942,10 @@ class GatewayRunner: # killed by systemd instead of us (issue #8202). The final # catch-all cleanup below still runs for the graceful path. _kill_tool_subprocesses("post-interrupt") + logger.info( + "Shutdown phase: post-interrupt tool kill done at +%.2fs", + _phase_elapsed(), + ) if self._restart_requested and self._restart_detached: try: @@ -4591,15 +4973,29 @@ class GatewayRunner: self._cleanup_agent_resources(_agent) for platform, adapter in list(self.adapters.items()): + _adapter_started_at = time.monotonic() try: await adapter.cancel_background_tasks() except Exception as e: logger.debug("✗ %s background-task cancel error: %s", platform.value, e) try: await adapter.disconnect() - logger.info("✓ %s disconnected", platform.value) + logger.info( + "✓ %s disconnected (%.2fs)", + platform.value, + time.monotonic() - _adapter_started_at, + ) except Exception as e: - logger.error("✗ %s disconnect error: %s", platform.value, e) + logger.error( + "✗ %s disconnect error after %.2fs: %s", + platform.value, + time.monotonic() - _adapter_started_at, + e, + ) + logger.info( + "Shutdown phase: all adapters disconnected at +%.2fs", + _phase_elapsed(), + ) for _task in list(self._background_tasks): if _task is self._stop_task: @@ -4624,6 +5020,10 @@ class GatewayRunner: # that got respawned between the earlier call and adapter # disconnect (defense in depth; safe to call repeatedly). _kill_tool_subprocesses("final-cleanup") + logger.info( + "Shutdown phase: final-cleanup tool kill done at +%.2fs", + _phase_elapsed(), + ) # Reap the process-global auxiliary-client cache once at the very # end of teardown. Per-turn cleanup runs in _cleanup_agent_resources @@ -4651,6 +5051,10 @@ class GatewayRunner: _db.close() except Exception as _e: logger.debug("SessionDB close error: %s", _e) + logger.info( + "Shutdown phase: SessionDB close done at +%.2fs", + _phase_elapsed(), + ) from gateway.status import remove_pid_file, release_gateway_runtime_lock remove_pid_file() @@ -4690,7 +5094,7 @@ class GatewayRunner: self._draining = False self._update_runtime_status("stopped", self._exit_reason) - logger.info("Gateway stopped") + logger.info("Gateway stopped (total teardown %.2fs)", _phase_elapsed()) self._stop_task = asyncio.create_task(_stop_impl()) await self._stop_task @@ -4752,12 +5156,12 @@ class GatewayRunner: try: _gw_cfg = _load_gateway_config() _raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications") - if _raw not in (None, ""): + if _raw not in {None, ""}: _notify_mode = str(_raw).strip().lower() except Exception: pass _notify_mode = _notify_mode or "important" - if _notify_mode not in ("all", "important"): + if _notify_mode not in {"all", "important"}: logger.warning( "Unknown telegram notifications mode '%s', " "defaulting to 'important' (valid: all, important)", @@ -4934,7 +5338,7 @@ class GatewayRunner: # connection, so HA events are always authorized. # Webhook events are authenticated via HMAC signature validation in # the adapter itself — no user allowlist applies. - if source.platform in (Platform.HOMEASSISTANT, Platform.WEBHOOK): + if source.platform in {Platform.HOMEASSISTANT, Platform.WEBHOOK}: return True user_id = source.user_id @@ -5007,12 +5411,12 @@ class GatewayRunner: # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) platform_allow_all_var = platform_allow_all_map.get(source.platform, "") - if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in ("true", "1", "yes"): + if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}: return True if getattr(source, "is_bot", False): allow_bots_var = platform_allow_bots_map.get(source.platform) - if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in ("mentions", "all"): + if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}: return True # Discord role-based access (DISCORD_ALLOWED_ROLES): the adapter's @@ -5043,7 +5447,7 @@ class GatewayRunner: if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist: # No allowlists configured -- check global allow-all flag - return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") + return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} # Telegram can optionally authorize group traffic by chat ID. # Keep this separate from TELEGRAM_GROUP_ALLOWED_USERS, which gates @@ -5338,9 +5742,9 @@ class GatewayRunner: raw = (event.text or "").strip() # Accept /approve and /deny as shorthand for yes/no cmd = event.get_command() - if cmd in ("approve", "yes"): + if cmd in {"approve", "yes"}: response_text = "y" - elif cmd in ("deny", "no"): + elif cmd in {"deny", "no"}: response_text = "n" else: _recognized_cmd = None @@ -5422,17 +5826,17 @@ class GatewayRunner: _raw_reply = (event.text or "").strip() _cmd_reply = event.get_command() _confirm_choice = None - if _cmd_reply in ("approve", "yes", "ok", "confirm"): + if _cmd_reply in {"approve", "yes", "ok", "confirm"}: _confirm_choice = "once" - elif _cmd_reply in ("always", "remember"): + elif _cmd_reply in {"always", "remember"}: _confirm_choice = "always" - elif _cmd_reply in ("cancel", "no", "deny", "nevermind"): + elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}: _confirm_choice = "cancel" - elif _raw_reply.lower() in ("approve", "approve once", "once"): + elif _raw_reply.lower() in {"approve", "approve once", "once"}: _confirm_choice = "once" - elif _raw_reply.lower() in ("always", "always approve"): + elif _raw_reply.lower() in {"always", "always approve"}: _confirm_choice = "always" - elif _raw_reply.lower() in ("cancel", "nevermind", "no"): + elif _raw_reply.lower() in {"cancel", "nevermind", "no"}: _confirm_choice = "cancel" if _confirm_choice is not None: _resolved = await _slash_confirm_mod.resolve( @@ -5516,6 +5920,17 @@ class GatewayRunner: _evt_cmd = event.get_command() _cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None + # Slash command access control on the running-agent fast-path. + # Mirrors the cold-path gate further below so non-admin users + # can't bypass gating just because an agent happens to be busy. + # /status above is intentionally pre-gate so users always see + # session state. /help and /whoami fall under the always-allowed + # floor inside _check_slash_access. + if _evt_cmd and _cmd_def_inner is not None: + _denied = self._check_slash_access(source, _cmd_def_inner.name) + if _denied is not None: + return _denied + if _cmd_def_inner and _cmd_def_inner.name == "restart": return await self._handle_restart_command(event) @@ -5532,7 +5947,7 @@ class GatewayRunner: invalidation_reason="stop_command", ) logger.info("STOP for session %s — agent interrupted, session lock released", _quick_key) - return EphemeralReply("⚡ Stopped. You can continue this session.") + return EphemeralReply(t("gateway.stop.stopped")) # /reset and /new must bypass the running-agent guard so they # actually dispatch as commands instead of being queued as user @@ -5557,7 +5972,7 @@ class GatewayRunner: # Semantics: each /queue invocation produces its own full agent # turn, processed in FIFO order after the current run (and any # earlier /queue items) finishes. Messages are NOT merged. - if event.get_command() in ("queue", "q"): + if event.get_command() in {"queue", "q"}: queued_text = event.get_command_args().strip() if not queued_text: return "Usage: /queue " @@ -5630,7 +6045,7 @@ class GatewayRunner: # The agent thread is blocked on a threading.Event inside # tools/approval.py — sending an interrupt won't unblock it. # Route directly to the approval handler so the event is signalled. - if _cmd_def_inner and _cmd_def_inner.name in ("approve", "deny"): + if _cmd_def_inner and _cmd_def_inner.name in {"approve", "deny"}: if _cmd_def_inner.name == "approve": return await self._handle_approve_command(event) return await self._handle_deny_command(event) @@ -5661,7 +6076,7 @@ class GatewayRunner: # continuation prompt against the current turn. if _cmd_def_inner and _cmd_def_inner.name == "goal": _goal_arg = (event.get_command_args() or "").strip().lower() - if not _goal_arg or _goal_arg in ("status", "pause", "resume", "clear", "stop", "done"): + if not _goal_arg or _goal_arg in {"status", "pause", "resume", "clear", "stop", "done"}: return await self._handle_goal_command(event) return "Agent is running — use /goal status / pause / clear mid-run, or /stop before setting a new goal." @@ -5673,7 +6088,7 @@ class GatewayRunner: # /fast and /reasoning are config-only and take effect next # message, so they fall through to the catch-all busy response # below — users should wait and set them between turns. - if _cmd_def_inner and _cmd_def_inner.name in ("yolo", "verbose"): + if _cmd_def_inner and _cmd_def_inner.name in {"yolo", "verbose"}: if _cmd_def_inner.name == "yolo": return await self._handle_yolo_command(event) if _cmd_def_inner.name == "verbose": @@ -5792,10 +6207,9 @@ class GatewayRunner: return None logger.debug("PRIORITY interrupt for session %s", _quick_key) running_agent.interrupt(event.text) - if _quick_key in self._pending_messages: - self._pending_messages[_quick_key] += "\n" + event.text - else: - self._pending_messages[_quick_key] = event.text + # NOTE: self._pending_messages was write-only (never consumed). + # The actual interrupt message is delivered via adapter._pending_messages + # which is read by _run_agent. Removed to prevent unbounded growth. return None # Check for commands @@ -5834,6 +6248,17 @@ class GatewayRunner: _cmd_def = _resolve_cmd(command) if command else None canonical = _cmd_def.name if _cmd_def else command + # Per-platform slash command access control. Only kicks in when the + # operator has set ``allow_admin_from`` for the source's scope (DM + # vs group). When unset → backward-compat: every allowed user can + # run every command. When set → non-admins can run only commands in + # ``user_allowed_commands`` (plus the always-allowed floor: /help, + # /whoami). Plain chat is unaffected — only slash commands gate. + if command and canonical and is_gateway_known_command(canonical): + _denied = self._check_slash_access(source, canonical) + if _denied is not None: + return _denied + # Fire the ``command:`` hook for any recognized slash # command — built-in OR plugin-registered. Handlers can return a # dict with ``{"decision": "deny" | "handled" | "rewrite", ...}`` @@ -5917,6 +6342,9 @@ class GatewayRunner: if canonical == "profile": return await self._handle_profile_command(event) + if canonical == "whoami": + return await self._handle_whoami_command(event) + if canonical == "status": return await self._handle_status_command(event) @@ -6050,13 +6478,23 @@ class GatewayRunner: exec_cmd = qcmd.get("command", "") if exec_cmd: try: + # Sanitize env to prevent credential leakage — + # quick commands run in the gateway process which + # has all API keys in os.environ. + from tools.environments.local import _sanitize_subprocess_env + sanitized_env = _sanitize_subprocess_env(os.environ.copy()) proc = await asyncio.create_subprocess_shell( exec_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=sanitized_env, ) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) output = (stdout or stderr).decode().strip() + # Redact any remaining sensitive patterns in output + if output: + from agent.redact import redact_sensitive_text + output = redact_sensitive_text(output) return output if output else "Command returned no output." except asyncio.TimeoutError: return "Quick command timed out (30s)." @@ -6273,7 +6711,7 @@ class GatewayRunner: mtype = event.media_types[i] if i < len(event.media_types) else "" if mtype.startswith("image/") or event.message_type == MessageType.PHOTO: image_paths.append(path) - if mtype.startswith("audio/") or event.message_type in (MessageType.VOICE, MessageType.AUDIO): + if mtype.startswith("audio/") or event.message_type in {MessageType.VOICE, MessageType.AUDIO}: audio_paths.append(path) if image_paths: @@ -6342,7 +6780,7 @@ class GatewayRunner: _TEXT_EXTENSIONS = {".txt", ".md", ".csv", ".log", ".json", ".xml", ".yaml", ".yml", ".toml", ".ini", ".cfg"} for i, path in enumerate(event.media_urls): mtype = event.media_types[i] if i < len(event.media_types) else "" - if mtype in ("", "application/octet-stream"): + if mtype in {"", "application/octet-stream"}: _ext = os.path.splitext(path)[1].lower() if _ext in _TEXT_EXTENSIONS: mtype = "text/plain" @@ -6613,7 +7051,7 @@ class GatewayRunner: pass await adapter.send( source.chat_id, notice, - metadata=getattr(event, 'metadata', None), + metadata=self._thread_metadata_for_source(source), ) except Exception as e: logger.debug("Auto-reset notification failed (non-fatal): %s", e) @@ -6726,7 +7164,7 @@ class GatewayRunner: if isinstance(_comp_cfg, dict): _hyg_compression_enabled = str( _comp_cfg.get("enabled", True) - ).lower() in ("true", "1", "yes") + ).lower() in {"true", "1", "yes"} _raw_hard_limit = _comp_cfg.get("hygiene_hard_message_limit") if _raw_hard_limit is not None: try: @@ -6849,7 +7287,7 @@ class GatewayRunner: _hyg_msgs = [ {"role": m.get("role"), "content": m.get("content")} for m in history - if m.get("role") in ("user", "assistant") + if m.get("role") in {"user", "assistant"} and m.get("content") ] @@ -7213,7 +7651,7 @@ class GatewayRunner: while not _pr.completion_queue.empty(): evt = _pr.completion_queue.get_nowait() evt_type = evt.get("type", "completion") - if evt_type in ("watch_match", "watch_disabled"): + if evt_type in {"watch_match", "watch_disabled"}: _watch_events.append(evt) # else: completion events are handled by the watcher task for evt in _watch_events: @@ -7455,7 +7893,7 @@ class GatewayRunner: status_hint = " You are being rate-limited. Please wait a moment and try again." elif status_code == 529: status_hint = " The API is temporarily overloaded. Please try again shortly." - elif status_code in (400, 500): + elif status_code in {400, 500}: # 400 with a large session is context overflow. # 500 with a large session often means the payload is too large # for the API to process — treat it the same way. @@ -7684,11 +8122,11 @@ class GatewayRunner: session_info = "" if new_entry: - header = self._telegram_topic_new_header(source) or "✨ Session reset! Starting fresh." + header = self._telegram_topic_new_header(source) or t("gateway.reset.header_default") else: # No existing session, just create one new_entry = self.session_store.get_or_create_session(source, force_new=True) - header = self._telegram_topic_new_header(source) or "✨ New session started!" + header = self._telegram_topic_new_header(source) or t("gateway.reset.header_new") # Set session title if provided with /new _title_arg = event.get_command_args().strip() @@ -7699,18 +8137,18 @@ class GatewayRunner: sanitized = SessionDB.sanitize_title(_title_arg) except ValueError as e: sanitized = None - _title_note = f"\n⚠️ Title rejected: {e}" + _title_note = t("gateway.reset.title_rejected", error=str(e)) if sanitized: try: self._session_db.set_session_title(new_entry.session_id, sanitized) - header = f"✨ New session started: {sanitized}" + header = t("gateway.reset.header_titled", title=sanitized) except ValueError as e: - _title_note = f"\n⚠️ {e} — session started untitled." + _title_note = t("gateway.reset.title_error_untitled", error=str(e)) except Exception: pass elif not _title_note: # sanitize_title returned empty (whitespace-only / unprintable) - _title_note = "\n⚠️ Title is empty after cleanup — session started untitled." + _title_note = t("gateway.reset.title_empty_untitled") header = header + _title_note # When /new runs inside a Telegram DM topic lane, rewrite the @@ -7736,7 +8174,7 @@ class GatewayRunner: # Append a random tip to the reset message try: from hermes_cli.tips import get_random_tip - _tip_line = f"\n✦ Tip: {get_random_tip()}" + _tip_line = t("gateway.reset.tip", tip=get_random_tip()) except Exception: _tip_line = "" @@ -7753,13 +8191,108 @@ class GatewayRunner: profile_name = get_active_profile_name() lines = [ - f"👤 **Profile:** `{profile_name}`", - f"📂 **Home:** `{display}`", + t("gateway.profile.header", profile=profile_name), + t("gateway.profile.home", home=display), ] return "\n".join(lines) + def _check_slash_access( + self, source: SessionSource, canonical_cmd: str + ) -> Optional[str]: + """Return a denial message if ``source`` cannot run ``canonical_cmd``, + else None. Used by both the cold and running-agent dispatch paths + in ``_handle_message`` so admin/user gating can't be bypassed by + an in-flight agent. + + Backward-compat semantics live in + :func:`gateway.slash_access.policy_for_source` — when the operator + hasn't set ``allow_admin_from`` for the scope, the policy returns + ``enabled=False`` and this method always returns None. + """ + from gateway.slash_access import policy_for_source as _policy_for_source + + if not canonical_cmd: + return None + policy = _policy_for_source(self.config, source) + if not policy.enabled or policy.can_run(source.user_id, canonical_cmd): + return None + logger.info( + "Slash command /%s denied for %s:%s (not admin, not in user_allowed_commands)", + canonical_cmd, + source.platform.value if source.platform else "?", + source.user_id, + ) + allowed_preview = sorted(policy.user_allowed_commands) + if allowed_preview: + suffix = ( + "You can run: " + + ", ".join(f"/{c}" for c in allowed_preview[:12]) + + ("…" if len(allowed_preview) > 12 else "") + + ". Use /whoami for the full list." + ) + else: + suffix = ( + "No slash commands are enabled for non-admins on this " + "platform. Ask an admin to add you to allow_admin_from " + "or to set user_allowed_commands." + ) + return f"⛔ /{canonical_cmd} is admin-only here. {suffix}" + + + async def _handle_whoami_command(self, event: MessageEvent) -> str: + """Handle /whoami — show the user's slash command access on this scope. + + Always works (it's in the always-allowed floor of slash_access). + Reports: platform, scope (DM vs group), the user's tier + (admin / user / unrestricted), and the slash commands they can + actually run on this scope. + """ + from gateway.slash_access import policy_for_source as _policy_for_source + + source = event.source + policy = _policy_for_source(self.config, source) + platform = source.platform.value if source and source.platform else "?" + chat_type = (source.chat_type if source else "") or "dm" + scope = "DM" if chat_type.lower() in {"dm", "direct", "private", ""} else "group/channel" + user_id = (source.user_id if source else None) or "?" + + if not policy.enabled: + return ( + f"**You** — {platform} ({scope})\n" + f"User ID: `{user_id}`\n" + f"Tier: unrestricted (no admin list configured for this scope)\n" + f"Slash commands: all available" + ) + + if policy.is_admin(user_id): + return ( + f"**You** — {platform} ({scope})\n" + f"User ID: `{user_id}`\n" + f"Tier: **admin**\n" + f"Slash commands: all available" + ) + + # Non-admin user. Show what's actually reachable. + floor = ["help", "whoami"] # mirrors slash_access._ALWAYS_ALLOWED_FOR_USERS + configured = sorted(policy.user_allowed_commands) + # Combine + dedupe, preserve order: floor first, then operator additions. + seen: set[str] = set() + runnable: list[str] = [] + for c in floor + configured: + if c not in seen: + seen.add(c) + runnable.append(c) + runnable_str = ", ".join(f"/{c}" for c in runnable) if runnable else "(none)" + return ( + f"**You** — {platform} ({scope})\n" + f"User ID: `{user_id}`\n" + f"Tier: user\n" + f"Slash commands you can run: {runnable_str}" + ) + + async def _handle_kanban_command(self, event: MessageEvent) -> str: """Handle /kanban — delegate to the shared kanban CLI. @@ -7776,6 +8309,7 @@ class GatewayRunner: """ import asyncio import re + import shlex from hermes_cli.kanban import run_slash text = (event.text or "").strip() @@ -7785,12 +8319,31 @@ class GatewayRunner: if text.startswith("kanban"): text = text[len("kanban"):].lstrip() - is_create = text.split(None, 1)[:1] == ["create"] + tokens = shlex.split(text) if text else [] + requested_board = None + action = None + i = 0 + while i < len(tokens): + tok = tokens[i] + if tok == "--board": + if i + 1 >= len(tokens): + break + requested_board = tokens[i + 1] + i += 2 + continue + if tok.startswith("--board="): + requested_board = tok.split("=", 1)[1] + i += 1 + continue + action = tok + break + + is_create = action == "create" try: output = await asyncio.to_thread(run_slash, text) except Exception as exc: # pragma: no cover - defensive - return f"⚠ kanban error: {exc}" + return t("gateway.kanban.error_prefix", error=exc) # Auto-subscribe on create. Parse the task id from the CLI's standard # success line ("Created t_abcd (ready, assignee=...)"). If the user @@ -7812,21 +8365,22 @@ class GatewayRunner: if platform_str and chat_id: def _sub(): from hermes_cli import kanban_db as _kb - conn = _kb.connect() + conn = _kb.connect(board=requested_board) try: _kb.add_notify_sub( conn, task_id=task_id, platform=platform_str, chat_id=chat_id, thread_id=thread_id or None, user_id=user_id, + notifier_profile=getattr(self, "_kanban_notifier_profile", None) or self._active_profile_name(), ) finally: conn.close() await asyncio.to_thread(_sub) output = ( output.rstrip() - + f"\n(subscribed — you'll be notified when {task_id} " - f"completes or blocks)" + + "\n" + + t("gateway.kanban.subscribed_suffix", task_id=task_id) ) except Exception as exc: logger.warning("kanban create auto-subscribe failed: %s", exc) @@ -7834,8 +8388,8 @@ class GatewayRunner: # Gateway messages have practical length caps; truncate long # listings to keep the UX reasonable. if len(output) > 3800: - output = output[:3800] + "\n… (truncated; use `hermes kanban …` in your terminal for full output)" - return output or "(no output)" + output = output[:3800] + "\n" + t("gateway.kanban.truncated_suffix") + return output or t("gateway.kanban.no_output") async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" @@ -7879,23 +8433,23 @@ class GatewayRunner: db_total_tokens = 0 lines = [ - "📊 **Hermes Gateway Status**", + t("gateway.status.header"), "", - f"**Session ID:** `{session_entry.session_id}`", + t("gateway.status.session_id", session_id=session_entry.session_id), ] if title: - lines.append(f"**Title:** {title}") + lines.append(t("gateway.status.title", title=title)) lines.extend([ - f"**Created:** {session_entry.created_at.strftime('%Y-%m-%d %H:%M')}", - f"**Last Activity:** {session_entry.updated_at.strftime('%Y-%m-%d %H:%M')}", - f"**Tokens:** {db_total_tokens:,}", - f"**Agent Running:** {'Yes ⚡' if is_running else 'No'}", + t("gateway.status.created", timestamp=session_entry.created_at.strftime('%Y-%m-%d %H:%M')), + t("gateway.status.last_activity", timestamp=session_entry.updated_at.strftime('%Y-%m-%d %H:%M')), + t("gateway.status.tokens", tokens=f"{db_total_tokens:,}"), + t("gateway.status.agent_running", state=t("gateway.status.state_yes") if is_running else t("gateway.status.state_no")), ]) if queue_depth: - lines.append(f"**Queued follow-ups:** {queue_depth}") + lines.append(t("gateway.status.queued", count=queue_depth)) lines.extend([ "", - f"**Connected Platforms:** {', '.join(connected_platforms)}", + t("gateway.status.platforms", platforms=', '.join(connected_platforms)), ]) return "\n".join(lines) @@ -7919,7 +8473,7 @@ class GatewayRunner: { "session_key": session_key, "elapsed": elapsed, - "state": "starting" if is_pending else "running", + "state": t("gateway.agents.state_starting") if is_pending else t("gateway.agents.state_running"), "session_id": "" if is_pending else str(getattr(agent, "session_id", "") or ""), "model": "" if is_pending else str(getattr(agent, "model", "") or ""), } @@ -7942,14 +8496,14 @@ class GatewayRunner: ] lines = [ - "🤖 **Active Agents & Tasks**", + t("gateway.agents.header"), "", - f"**Active agents:** {len(agent_rows)}", + t("gateway.agents.active_agents", count=len(agent_rows)), ] if agent_rows: for idx, row in enumerate(agent_rows[:12], 1): - current = " · this chat" if row["session_key"] == current_session_key else "" + current = t("gateway.agents.this_chat") if row["session_key"] == current_session_key else "" sid = f" · `{row['session_id']}`" if row["session_id"] else "" model = f" · `{row['model']}`" if row["model"] else "" lines.append( @@ -7957,12 +8511,12 @@ class GatewayRunner: f"{format_uptime_short(row['elapsed'])}{sid}{model}{current}" ) if len(agent_rows) > 12: - lines.append(f"... and {len(agent_rows) - 12} more") + lines.append(t("gateway.agents.more", count=len(agent_rows) - 12)) lines.extend( [ "", - f"**Running background processes:** {len(running_processes)}", + t("gateway.agents.running_processes", count=len(running_processes)), ] ) if running_processes: @@ -7975,18 +8529,18 @@ class GatewayRunner: f"{format_uptime_short(int(proc.get('uptime_seconds', 0)))} · `{cmd}`" ) if len(running_processes) > 12: - lines.append(f"... and {len(running_processes) - 12} more") + lines.append(t("gateway.agents.more", count=len(running_processes) - 12)) lines.extend( [ "", - f"**Gateway async jobs:** {len(background_tasks)}", + t("gateway.agents.async_jobs", count=len(background_tasks)), ] ) if not agent_rows and not running_processes and not background_tasks: lines.append("") - lines.append("No active agents or running tasks.") + lines.append(t("gateway.agents.none")) return "\n".join(lines) @@ -8015,7 +8569,7 @@ class GatewayRunner: invalidation_reason="stop_command_pending", ) logger.info("STOP (pending) for session %s — sentinel cleared", session_key) - return EphemeralReply("⚡ Stopped. The agent hadn't started yet — you can continue this session.") + return EphemeralReply(t("gateway.stop.stopped_pending")) if agent: # Force-clean the session lock so a truly hung agent doesn't # keep it locked forever. @@ -8025,9 +8579,9 @@ class GatewayRunner: interrupt_reason=_INTERRUPT_REASON_STOP, invalidation_reason="stop_command_handler", ) - return EphemeralReply("⚡ Stopped. You can continue this session.") + return EphemeralReply(t("gateway.stop.stopped")) else: - return "No active task to stop." + return t("gateway.stop.no_active") async def _handle_restart_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /restart command - drain active work, then restart the gateway.""" @@ -8055,7 +8609,7 @@ class GatewayRunner: count = self._running_agent_count() if count: return t("gateway.draining", count=count) - return EphemeralReply("⏳ Gateway restart already in progress...") + return EphemeralReply(t("gateway.restart.in_progress")) # Save the requester's routing info so the new gateway process can # notify them once it comes back online. @@ -8107,7 +8661,7 @@ class GatewayRunner: self.request_restart(detached=True, via_service=False) if active_agents: return t("gateway.draining", count=active_agents) - return EphemeralReply("♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`.") + return EphemeralReply(t("gateway.restart.restarting")) def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool: """Return True if this /restart is a Telegram re-delivery we already handled. @@ -8163,20 +8717,20 @@ class GatewayRunner: """Handle /help command - list available commands.""" from hermes_cli.commands import gateway_help_lines lines = [ - "📖 **Hermes Commands**\n", + t("gateway.help.header"), *gateway_help_lines(), ] try: from agent.skill_commands import get_skill_commands skill_cmds = get_skill_commands() if skill_cmds: - lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} active):") + lines.append(t("gateway.help.skill_header", count=len(skill_cmds))) # Show first 10, then point to /commands for the rest sorted_cmds = sorted(skill_cmds) for cmd in sorted_cmds[:10]: lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}") if len(sorted_cmds) > 10: - lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.") + lines.append(t("gateway.help.more_use_commands", count=len(sorted_cmds) - 10)) except Exception: pass return _telegramize_command_mentions( @@ -8193,7 +8747,7 @@ class GatewayRunner: try: requested_page = int(raw_args) except ValueError: - return "Usage: `/commands [page]`" + return t("gateway.commands.usage") else: requested_page = 1 @@ -8204,15 +8758,15 @@ class GatewayRunner: skill_cmds = get_skill_commands() if skill_cmds: entries.append("") - entries.append("⚡ **Skill Commands**:") + entries.append(t("gateway.commands.skill_header")) for cmd in sorted(skill_cmds): - desc = skill_cmds[cmd].get("description", "").strip() or "Skill command" + desc = skill_cmds[cmd].get("description", "").strip() or t("gateway.commands.default_desc") entries.append(f"`{cmd}` — {desc}") except Exception: pass if not entries: - return "No commands available." + return t("gateway.commands.none") from gateway.config import Platform page_size = 15 if event.source.platform == Platform.TELEGRAM else 20 @@ -8222,19 +8776,19 @@ class GatewayRunner: page_entries = entries[start:start + page_size] lines = [ - f"📚 **Commands** ({len(entries)} total, page {page}/{total_pages})", + t("gateway.commands.header", total=len(entries), page=page, total_pages=total_pages), "", *page_entries, ] if total_pages > 1: nav_parts = [] if page > 1: - nav_parts.append(f"`/commands {page - 1}` ← prev") + nav_parts.append(t("gateway.commands.nav_prev", page=page - 1)) if page < total_pages: - nav_parts.append(f"next → `/commands {page + 1}`") + nav_parts.append(t("gateway.commands.nav_next", page=page + 1)) lines.extend(["", " | ".join(nav_parts)]) if page != requested_page: - lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_") + lines.append(t("gateway.commands.out_of_range", requested=requested_page, page=page)) return _telegramize_command_mentions( "\n".join(lines), getattr(getattr(event, "source", None), "platform", None), @@ -8346,7 +8900,7 @@ class GatewayRunner: custom_providers=custom_provs, ) if not result.success: - return f"Error: {result.error_message}" + return t("gateway.model.error_prefix", error=result.error_message) # Update cached agent in-place cached_entry = None @@ -8390,8 +8944,8 @@ class GatewayRunner: # Build confirmation text plabel = result.provider_label or result.target_provider - lines = [f"Model switched to `{result.new_model}`"] - lines.append(f"Provider: {plabel}") + lines = [t("gateway.model.switched", model=result.new_model)] + lines.append(t("gateway.model.provider_label", provider=plabel)) mi = result.model_info from hermes_cli.model_switch import resolve_display_context_length _sw_config_ctx = None @@ -8414,14 +8968,14 @@ class GatewayRunner: config_context_length=_sw_config_ctx, ) if ctx: - lines.append(f"Context: {ctx:,} tokens") + lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}")) if mi: if mi.max_output: - lines.append(f"Max output: {mi.max_output:,} tokens") + lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}")) if mi.has_cost_data(): - lines.append(f"Cost: {mi.format_cost()}") - lines.append(f"Capabilities: {mi.format_capabilities()}") - lines.append("_(session only — use `/model <name> --global` to persist)_") + lines.append(t("gateway.model.cost_label", cost=mi.format_cost())) + lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities())) + lines.append(t("gateway.model.session_only_hint")) return "\n".join(lines) metadata = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event)) @@ -8439,7 +8993,7 @@ class GatewayRunner: # Fallback: text list (for platforms without picker or if picker failed) provider_label = get_label(current_provider) - lines = [f"Current: `{current_model or 'unknown'}` on {provider_label}", ""] + lines = [t("gateway.model.current_label", model=current_model or "unknown", provider=provider_label), ""] try: providers = list_authenticated_providers( @@ -8451,11 +9005,11 @@ class GatewayRunner: max_models=5, ) for p in providers: - tag = " (current)" if p["is_current"] else "" + tag = t("gateway.model.current_tag") if p["is_current"] else "" lines.append(f"**{p['name']}** `--provider {p['slug']}`{tag}:") if p["models"]: model_strs = ", ".join(f"`{m}`" for m in p["models"]) - extra = f" (+{p['total_models'] - len(p['models'])} more)" if p["total_models"] > len(p["models"]) else "" + extra = t("gateway.model.more_models_suffix", count=p["total_models"] - len(p["models"])) if p["total_models"] > len(p["models"]) else "" lines.append(f" {model_strs}{extra}") elif p.get("api_url"): lines.append(f" `{p['api_url']}`") @@ -8463,9 +9017,9 @@ class GatewayRunner: except Exception: pass - lines.append("`/model <name>` — switch model") - lines.append("`/model <name> --provider <slug>` — switch provider") - lines.append("`/model <name> --global` — persist") + lines.append(t("gateway.model.usage_switch_model")) + lines.append(t("gateway.model.usage_switch_provider")) + lines.append(t("gateway.model.usage_persist")) return "\n".join(lines) # Perform the switch @@ -8482,7 +9036,7 @@ class GatewayRunner: ) if not result.success: - return f"Error: {result.error_message}" + return t("gateway.model.error_prefix", error=result.error_message) # If there's a cached agent, update it in-place cached_entry = None @@ -8547,8 +9101,8 @@ class GatewayRunner: # Build confirmation message with full metadata provider_label = result.provider_label or result.target_provider - lines = [f"Model switched to `{result.new_model}`"] - lines.append(f"Provider: {provider_label}") + lines = [t("gateway.model.switched", model=result.new_model)] + lines.append(t("gateway.model.provider_label", provider=provider_label)) # Context: always resolve via the provider-aware chain so Codex OAuth, # Copilot, and Nous-enforced caps win over the raw models.dev entry. @@ -8574,13 +9128,13 @@ class GatewayRunner: config_context_length=_sw2_config_ctx, ) if ctx: - lines.append(f"Context: {ctx:,} tokens") + lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}")) if mi: if mi.max_output: - lines.append(f"Max output: {mi.max_output:,} tokens") + lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}")) if mi.has_cost_data(): - lines.append(f"Cost: {mi.format_cost()}") - lines.append(f"Capabilities: {mi.format_capabilities()}") + lines.append(t("gateway.model.cost_label", cost=mi.format_cost())) + lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities())) # Cache notice cache_enabled = ( @@ -8588,15 +9142,15 @@ class GatewayRunner: or result.api_mode == "anthropic_messages" ) if cache_enabled: - lines.append("Prompt caching: enabled") + lines.append(t("gateway.model.prompt_caching_enabled")) if result.warning_message: - lines.append(f"Warning: {result.warning_message}") + lines.append(t("gateway.model.warning_prefix", warning=result.warning_message)) if persist_global: - lines.append("Saved to config.yaml (`--global`)") + lines.append(t("gateway.model.saved_global")) else: - lines.append("_(session only -- add `--global` to persist)_") + lines.append(t("gateway.model.session_only_hint")) return "\n".join(lines) @@ -8615,18 +9169,18 @@ class GatewayRunner: personalities = {} if not personalities: - return f"No personalities configured in `{display_hermes_home()}/config.yaml`" + return t("gateway.personality.none_configured", path=display_hermes_home()) if not args: - lines = ["🎭 **Available Personalities**\n"] - lines.append("• `none` — (no personality overlay)") + lines = [t("gateway.personality.header")] + lines.append(t("gateway.personality.none_option")) for name, prompt in personalities.items(): if isinstance(prompt, dict): preview = prompt.get("description") or prompt.get("system_prompt", "")[:50] else: preview = prompt[:50] + "..." if len(prompt) > 50 else prompt - lines.append(f"• `{name}` — {preview}") - lines.append("\nUsage: `/personality <name>`") + lines.append(t("gateway.personality.item", name=name, preview=preview)) + lines.append(t("gateway.personality.usage")) return "\n".join(lines) def _resolve_prompt(value): @@ -8639,16 +9193,16 @@ class GatewayRunner: return "\n".join(p for p in parts if p) return str(value) - if args in ("none", "default", "neutral"): + if args in {"none", "default", "neutral"}: try: if "agent" not in config or not isinstance(config.get("agent"), dict): config["agent"] = {} config["agent"]["system_prompt"] = "" atomic_yaml_write(config_path, config) except Exception as e: - return f"⚠️ Failed to save personality change: {e}" + return t("gateway.personality.save_failed", error=str(e)) self._ephemeral_system_prompt = "" - return "🎭 Personality cleared — using base agent behavior.\n_(takes effect on next message)_" + return t("gateway.personality.cleared") elif args in personalities: new_prompt = _resolve_prompt(personalities[args]) @@ -8659,15 +9213,15 @@ class GatewayRunner: config["agent"]["system_prompt"] = new_prompt atomic_yaml_write(config_path, config) except Exception as e: - return f"⚠️ Failed to save personality change: {e}" + return t("gateway.personality.save_failed", error=str(e)) # Update in-memory so it takes effect on the very next message. self._ephemeral_system_prompt = new_prompt - return f"🎭 Personality set to **{args}**\n_(takes effect on next message)_" + return t("gateway.personality.set_to", name=args) available = "`none`, " + ", ".join(f"`{n}`" for n in personalities) - return f"Unknown personality: `{args}`\n\nAvailable: {available}" + return t("gateway.personality.unknown", name=args, available=available) async def _handle_retry_command(self, event: MessageEvent) -> str: """Handle /retry command - re-send the last user message.""" @@ -8685,7 +9239,7 @@ class GatewayRunner: break if not last_user_msg: - return "No previous message to retry." + return t("gateway.retry.no_previous") # Truncate history to before the last user message and persist truncated = history[:last_user_idx] @@ -8767,7 +9321,7 @@ class GatewayRunner: mgr, session_entry = self._get_goal_manager_for_event(event) if mgr is None: - return "Goals unavailable on this session." + return t("gateway.goal.unavailable") if not args or lower == "status": return mgr.status_line() @@ -8775,7 +9329,7 @@ class GatewayRunner: if lower == "pause": state = mgr.pause(reason="user-paused") if state is None: - return "No goal set." + return t("gateway.goal.no_goal_set") try: adapter = self.adapters.get(event.source.platform) if event.source else None _quick_key = self._session_key_for_source(event.source) if event.source else None @@ -8783,18 +9337,15 @@ class GatewayRunner: self._clear_goal_pending_continuations(_quick_key, adapter) except Exception as exc: logger.debug("goal pause: pending continuation cleanup failed: %s", exc) - return f"⏸ Goal paused: {state.goal}" + return t("gateway.goal.paused", goal=state.goal) if lower == "resume": state = mgr.resume() if state is None: - return "No goal to resume." - return ( - f"▶ Goal resumed: {state.goal}\n" - "Send any message to continue, or wait — I'll take the next step on the next turn." - ) + return t("gateway.goal.no_resume") + return t("gateway.goal.resumed", goal=state.goal) - if lower in ("clear", "stop", "done"): + if lower in {"clear", "stop", "done"}: had = mgr.has_goal() mgr.clear() try: @@ -8810,7 +9361,7 @@ class GatewayRunner: try: state = mgr.set(args) except ValueError as exc: - return f"Invalid goal: {exc}" + return t("gateway.goal.invalid", error=str(exc)) # Queue the goal text as an immediate first turn so the agent # starts making progress. The post-turn hook takes over after. @@ -8829,11 +9380,7 @@ class GatewayRunner: except Exception as exc: logger.debug("goal kickoff enqueue failed: %s", exc) - return ( - f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n" - "I'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\n" - "Controls: /goal status · /goal pause · /goal resume · /goal clear" - ) + return t("gateway.goal.set", budget=state.max_turns, goal=state.goal) async def _send_goal_status_notice(self, source: Any, message: str) -> None: """Send a /goal judge status line back to the originating chat/thread.""" @@ -8980,7 +9527,7 @@ class GatewayRunner: break if last_user_idx is None: - return "Nothing to undo." + return t("gateway.undo.nothing") removed_msg = history[last_user_idx].get("content", "") removed_count = len(history) - last_user_idx @@ -8989,7 +9536,7 @@ class GatewayRunner: session_entry.last_prompt_tokens = 0 preview = removed_msg[:40] + "..." if len(removed_msg) > 40 else removed_msg - return f"↩️ Undid {removed_count} message(s).\nRemoved: \"{preview}\"" + return t("gateway.undo.removed", count=removed_count, preview=preview) async def _handle_set_home_command(self, event: MessageEvent) -> str: """Handle /sethome command -- set the current chat as the platform's home channel.""" @@ -9010,7 +9557,7 @@ class GatewayRunner: # /sethome is run from the parent chat instead of a thread. save_env_value(thread_env_key, str(thread_id or "")) except Exception as e: - return f"Failed to save home channel: {e}" + return t("gateway.set_home.save_failed", error=e) # Keep the running gateway config in sync too. The pre-restart # notification path reads self.config before the process reloads env. @@ -9026,10 +9573,7 @@ class GatewayRunner: thread_id=str(thread_id) if thread_id else None, ) - return ( - f"✅ Home channel set to **{chat_name}** (ID: {chat_id}).\n" - f"Cron jobs and cross-platform messages will be delivered here." - ) + return t("gateway.set_home.success", name=chat_name, chat_id=chat_id) @staticmethod def _get_guild_id(event: MessageEvent) -> Optional[int]: @@ -9054,41 +9598,34 @@ class GatewayRunner: adapter = self.adapters.get(platform) - if args in ("on", "enable"): + if args in {"on", "enable"}: self._voice_mode[voice_key] = "voice_only" self._save_voice_modes() if adapter: self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) - return ( - "Voice mode enabled.\n" - "I'll reply with voice when you send voice messages.\n" - "Use /voice tts to get voice replies for all messages." - ) - elif args in ("off", "disable"): + return t("gateway.voice.enabled_voice_only") + elif args in {"off", "disable"}: self._voice_mode[voice_key] = "off" self._save_voice_modes() if adapter: self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) - return "Voice mode disabled. Text-only replies." + return t("gateway.voice.disabled_text") elif args == "tts": self._voice_mode[voice_key] = "all" self._save_voice_modes() if adapter: self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) - return ( - "Auto-TTS enabled.\n" - "All replies will include a voice message." - ) - elif args in ("channel", "join"): + return t("gateway.voice.tts_enabled") + elif args in {"channel", "join"}: return await self._handle_voice_channel_join(event) elif args == "leave": return await self._handle_voice_channel_leave(event) elif args == "status": mode = self._voice_mode.get(voice_key, "off") labels = { - "off": "Off (text only)", - "voice_only": "On (voice reply to voice messages)", - "all": "TTS (voice reply to all messages)", + "off": t("gateway.voice.label_off"), + "voice_only": t("gateway.voice.label_voice_only"), + "all": t("gateway.voice.label_all"), } # Append voice channel info if connected adapter = self.adapters.get(event.source.platform) @@ -9097,15 +9634,15 @@ class GatewayRunner: info = adapter.get_voice_channel_info(guild_id) if info: lines = [ - f"Voice mode: {labels.get(mode, mode)}", - f"Voice channel: #{info['channel_name']}", - f"Participants: {info['member_count']}", + t("gateway.voice.status_mode", label=labels.get(mode, mode)), + t("gateway.voice.status_channel", channel=info['channel_name']), + t("gateway.voice.status_participants", count=info['member_count']), ] for m in info["members"]: - status = " (speaking)" if m.get("is_speaking") else "" - lines.append(f" - {m['display_name']}{status}") + status = t("gateway.voice.speaking") if m.get("is_speaking") else "" + lines.append(t("gateway.voice.status_member", name=m['display_name'], status=status)) return "\n".join(lines) - return f"Voice mode: {labels.get(mode, mode)}" + return t("gateway.voice.status_mode", label=labels.get(mode, mode)) else: # Toggle: off → on, on/all → off current = self._voice_mode.get(voice_key, "off") @@ -9114,13 +9651,13 @@ class GatewayRunner: self._save_voice_modes() if adapter: self._set_adapter_auto_tts_enabled(adapter, chat_id, enabled=True) - return "Voice mode enabled." + return t("gateway.voice.enabled_short") else: self._voice_mode[voice_key] = "off" self._save_voice_modes() if adapter: self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True) - return "Voice mode disabled." + return t("gateway.voice.disabled_short") async def _handle_voice_channel_join(self, event: MessageEvent) -> str: """Join the user's current Discord voice channel.""" @@ -9560,10 +10097,7 @@ class GatewayRunner: pass if not cp_cfg.get("enabled", False): - return ( - "Checkpoints are not enabled.\n" - "Enable in config.yaml:\n```\ncheckpoints:\n enabled: true\n```" - ) + return t("gateway.rollback.not_enabled") mgr = CheckpointManager( enabled=True, @@ -9582,7 +10116,7 @@ class GatewayRunner: # Restore by number or hash checkpoints = mgr.list_checkpoints(cwd) if not checkpoints: - return f"No checkpoints found for {cwd}" + return t("gateway.rollback.none_found", cwd=cwd) target_hash = None try: @@ -9590,17 +10124,18 @@ class GatewayRunner: if 0 <= idx < len(checkpoints): target_hash = checkpoints[idx]["hash"] else: - return f"Invalid checkpoint number. Use 1-{len(checkpoints)}." + return t("gateway.rollback.invalid_number", max=len(checkpoints)) except ValueError: target_hash = arg result = mgr.restore(cwd, target_hash) if result["success"]: - return ( - f"✅ Restored to checkpoint {result['restored_to']}: {result['reason']}\n" - f"A pre-rollback snapshot was saved automatically." + return t( + "gateway.rollback.restored", + hash=result["restored_to"], + reason=result["reason"], ) - return f"❌ {result['error']}" + return t("gateway.rollback.restore_failed", error=result["error"]) async def _handle_background_command(self, event: MessageEvent) -> str: """Handle /background <prompt> — run a prompt in a separate background session. @@ -9611,12 +10146,7 @@ class GatewayRunner: """ prompt = event.get_command_args().strip() if not prompt: - return ( - "Usage: /background <prompt>\n" - "Example: /background Summarize the top HN stories today\n\n" - "Runs the prompt in a separate session. " - "You can keep chatting — the result will appear here when done." - ) + return t("gateway.background.usage") source = event.source task_id = f"bg_{datetime.now().strftime('%H%M%S')}_{os.urandom(3).hex()}" @@ -9636,7 +10166,7 @@ class GatewayRunner: _task.add_done_callback(self._background_tasks.discard) preview = prompt[:60] + ("..." if len(prompt) > 60 else "") - return f'🔄 Background task started: "{preview}"\nTask ID: {task_id}\nYou can keep chatting — results will appear when done.' + return t("gateway.background.started", preview=preview, task_id=task_id) async def _run_background_task( self, @@ -9835,56 +10365,58 @@ class GatewayRunner: # Show current state rc = self._reasoning_config if rc is None: - level = "medium (default)" + level = t("gateway.reasoning.level_default") elif rc.get("enabled") is False: - level = "none (disabled)" + level = t("gateway.reasoning.level_disabled") else: level = rc.get("effort", "medium") - display_state = "on ✓" if self._show_reasoning else "off" + display_state = ( + t("gateway.reasoning.display_on") + if self._show_reasoning + else t("gateway.reasoning.display_off") + ) has_session_override = session_key in (getattr(self, "_session_reasoning_overrides", {}) or {}) - scope = "session override" if has_session_override else "global config" - return ( - "🧠 **Reasoning Settings**\n\n" - f"**Effort:** `{level}`\n" - f"**Scope:** {scope}\n" - f"**Display:** {display_state}\n\n" - "_Usage:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + scope = ( + t("gateway.reasoning.scope_session") + if has_session_override + else t("gateway.reasoning.scope_global") + ) + return t( + "gateway.reasoning.status", + level=level, + scope=scope, + display=display_state, ) # Display toggle (per-platform) platform_key = _platform_config_key(event.source.platform) - if args in ("show", "on"): + if args in {"show", "on"}: self._show_reasoning = True _save_config_key(f"display.platforms.{platform_key}.show_reasoning", True) - return ( - "🧠 ✓ Reasoning display: **ON**\n" - f"Model thinking will be shown before each response on **{platform_key}**." - ) + return t("gateway.reasoning.display_set_on", platform=platform_key) - if args in ("hide", "off"): + if args in {"hide", "off"}: self._show_reasoning = False _save_config_key(f"display.platforms.{platform_key}.show_reasoning", False) - return f"🧠 ✓ Reasoning display: **OFF** for **{platform_key}**" + return t("gateway.reasoning.display_set_off", platform=platform_key) # Effort level change effort = args.strip() if effort == "reset": if persist_global: - return "⚠️ `/reasoning reset --global` is not supported. Use `/reasoning <level> --global` to change the global default." + return t("gateway.reasoning.reset_global_unsupported") self._set_session_reasoning_override(session_key, None) self._reasoning_config = self._load_reasoning_config() self._evict_cached_agent(session_key) - return "🧠 ✓ Session reasoning override cleared; falling back to global config." + return t("gateway.reasoning.reset_done") if effort == "none": parsed = {"enabled": False} - elif effort in ("minimal", "low", "medium", "high", "xhigh"): + elif effort in {"minimal", "low", "medium", "high", "xhigh"}: parsed = {"enabled": True, "effort": effort} else: - return ( - f"⚠️ Unknown argument: `{effort or raw_args.lower()}`\n\n" - "**Valid levels:** none, minimal, low, medium, high, xhigh\n" - "**Display:** show, hide\n" - "**Persist:** add `--global` to save beyond this session" + return t( + "gateway.reasoning.unknown_arg", + arg=effort or raw_args.lower(), ) self._reasoning_config = parsed @@ -9892,14 +10424,14 @@ class GatewayRunner: if _save_config_key("agent.reasoning_effort", effort): self._set_session_reasoning_override(session_key, None) self._evict_cached_agent(session_key) - return f"🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_" + return t("gateway.reasoning.set_global", effort=effort) self._set_session_reasoning_override(session_key, parsed) self._evict_cached_agent(session_key) - return f"🧠 ✓ Reasoning effort set to `{effort}` (session only — config save failed)\n_(takes effect on next message)_" + return t("gateway.reasoning.set_global_save_failed", effort=effort) self._set_session_reasoning_override(session_key, parsed) self._evict_cached_agent(session_key) - return f"🧠 ✓ Reasoning effort set to `{effort}` (session only — add `--global` to persist)\n_(takes effect on next message)_" + return t("gateway.reasoning.set_session", effort=effort) async def _handle_fast_command(self, event: MessageEvent) -> str: """Handle /fast — mirror the CLI Priority Processing toggle in gateway chats.""" @@ -9913,7 +10445,7 @@ class GatewayRunner: user_config = _load_gateway_config() model = _resolve_gateway_model(user_config) if not model_supports_fast_mode(model): - return "⚡ /fast is only available for OpenAI models that support Priority Processing." + return t("gateway.fast.not_supported") def _save_config_key(key_path: str, value): """Save a dot-separated key to config.yaml.""" @@ -9936,30 +10468,23 @@ class GatewayRunner: return False if not args or args == "status": - status = "fast" if self._service_tier == "priority" else "normal" - return ( - "⚡ Priority Processing\n\n" - f"Current mode: `{status}`\n\n" - "_Usage:_ `/fast <normal|fast|status>`" - ) + status = t("gateway.fast.status_fast") if self._service_tier == "priority" else t("gateway.fast.status_normal") + return t("gateway.fast.status", mode=status) if args in {"fast", "on"}: self._service_tier = "priority" saved_value = "fast" - label = "FAST" + label = t("gateway.fast.label_fast") elif args in {"normal", "off"}: self._service_tier = None saved_value = "normal" - label = "NORMAL" + label = t("gateway.fast.label_normal") else: - return ( - f"⚠️ Unknown argument: `{args}`\n\n" - "**Valid options:** normal, fast, status" - ) + return t("gateway.fast.unknown_arg", arg=args) if _save_config_key("agent.service_tier", saved_value): - return f"⚡ ✓ Priority Processing: **{label}** (saved to config)\n_(takes effect on next message)_" - return f"⚡ ✓ Priority Processing: **{label}** (this session only)" + return t("gateway.fast.saved", label=label) + return t("gateway.fast.session_only", label=label) async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]: """Handle /yolo — toggle dangerous command approval bypass for this session only.""" @@ -9973,10 +10498,10 @@ class GatewayRunner: current = is_session_yolo_enabled(session_key) if current: disable_session_yolo(session_key) - return EphemeralReply("⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval.") + return EphemeralReply(t("gateway.yolo.disabled")) else: enable_session_yolo(session_key) - return EphemeralReply("⚡ YOLO mode **ON** for this session — all commands auto-approved. Use with caution.") + return EphemeralReply(t("gateway.yolo.enabled")) async def _handle_verbose_command(self, event: MessageEvent) -> str: """Handle /verbose command — cycle tool progress display mode. @@ -10002,19 +10527,15 @@ class GatewayRunner: gate_enabled = False if not gate_enabled: - return ( - "The `/verbose` command is not enabled for messaging platforms.\n\n" - "Enable it in `config.yaml`:\n```yaml\n" - "display:\n tool_progress_command: true\n```" - ) + return t("gateway.verbose.not_enabled") # --- cycle mode (per-platform) ---------------------------------------- cycle = ["off", "new", "all", "verbose"] descriptions = { - "off": "⚙️ Tool progress: **OFF** — no tool activity shown.", - "new": "⚙️ Tool progress: **NEW** — shown when tool changes (preview length: `display.tool_preview_length`, default 40).", - "all": "⚙️ Tool progress: **ALL** — every tool call shown (preview length: `display.tool_preview_length`, default 40).", - "verbose": "⚙️ Tool progress: **VERBOSE** — every tool call with full arguments.", + "off": t("gateway.verbose.mode_off"), + "new": t("gateway.verbose.mode_new"), + "all": t("gateway.verbose.mode_all"), + "verbose": t("gateway.verbose.mode_verbose"), } # Read current effective mode for this platform via the resolver @@ -10038,11 +10559,11 @@ class GatewayRunner: atomic_yaml_write(config_path, user_config) return ( f"{descriptions[new_mode]}\n" - f"_(saved for **{platform_key}** — takes effect on next message)_" + + t("gateway.verbose.saved_suffix", platform=platform_key) ) except Exception as e: logger.warning("Failed to save tool_progress mode: %s", e) - return f"{descriptions[new_mode]}\n_(could not save to config: {e})_" + return f"{descriptions[new_mode]}\n" + t("gateway.verbose.save_failed", error=e) async def _handle_footer_command(self, event: MessageEvent) -> str: """Handle /footer command — toggle the runtime-metadata footer. @@ -10082,23 +10603,24 @@ class GatewayRunner: effective = resolve_footer_config(user_config, platform_key) - if arg in ("status", "?"): - state = "ON" if effective["enabled"] else "OFF" + if arg in {"status", "?"}: + state = t("gateway.footer.state_on") if effective["enabled"] else t("gateway.footer.state_off") fields = ", ".join(effective.get("fields") or []) - return ( - f"📎 Runtime footer: **{state}**\n" - f"Fields: `{fields}`\n" - f"Platform: `{platform_key}`" + return t( + "gateway.footer.status", + state=state, + fields=fields, + platform=platform_key, ) - if arg in ("on", "enable", "true", "1"): + if arg in {"on", "enable", "true", "1"}: new_state = True - elif arg in ("off", "disable", "false", "0"): + elif arg in {"off", "disable", "false", "0"}: new_state = False elif arg == "": new_state = not effective["enabled"] else: - return "Usage: `/footer [on|off|status]`" + return t("gateway.footer.usage") # --- write global flag --------------------------------------------- try: @@ -10113,7 +10635,7 @@ class GatewayRunner: logger.warning("Failed to save runtime_footer.enabled: %s", e) return t("gateway.config_save_failed", error=e) - state = "ON" if new_state else "OFF" + state = t("gateway.footer.state_on") if new_state else t("gateway.footer.state_off") example = "" if new_state: # Show a preview using current agent state if available. @@ -10125,12 +10647,8 @@ class GatewayRunner: fields=effective.get("fields") or ["model", "context_pct", "cwd"], ) if preview: - example = f"\nExample: `{preview}`" - return ( - f"📎 Runtime footer: **{state}**" - f"{example}\n" - f"_(saved globally — takes effect on next message)_" - ) + example = t("gateway.footer.example_line", preview=preview) + return t("gateway.footer.saved", state=state, example=example) async def _handle_compress_command(self, event: MessageEvent) -> str: """Handle /compress command -- manually compress conversation context. @@ -10144,7 +10662,7 @@ class GatewayRunner: history = self.session_store.load_transcript(session_entry.session_id) if not history or len(history) < 4: - return "Not enough conversation to compress (need at least 4 messages)." + return t("gateway.compress.not_enough") # Extract optional focus topic from command args focus_topic = (event.get_command_args() or "").strip() or None @@ -10160,12 +10678,12 @@ class GatewayRunner: session_key=session_key, ) if not runtime_kwargs.get("api_key"): - return "No provider configured -- cannot compress." + return t("gateway.compress.no_provider") msgs = [ {"role": m.get("role"), "content": m.get("content")} for m in history - if m.get("role") in ("user", "assistant") and m.get("content") + if m.get("role") in {"user", "assistant"} and m.get("content") ] tmp_agent = AIAgent( @@ -10192,7 +10710,7 @@ class GatewayRunner: compressor = tmp_agent.context_compressor if not compressor.has_content_to_compress(msgs): - return "Nothing to compress yet (the transcript is still all protected context)." + return t("gateway.compress.nothing_to_do") loop = asyncio.get_running_loop() compressed, _ = await loop.run_in_executor( @@ -10241,28 +10759,30 @@ class GatewayRunner: self._cleanup_agent_resources(tmp_agent) lines = [f"🗜️ {summary['headline']}"] if focus_topic: - lines.append(f"Focus: \"{focus_topic}\"") + lines.append(t("gateway.compress.focus_line", topic=focus_topic)) lines.append(summary["token_line"]) if summary["note"]: lines.append(summary["note"]) if _summary_failed: lines.append( - f"⚠️ Summary generation failed ({_summary_err or 'unknown error'}). " - f"{_dropped_count} historical message(s) were removed and replaced " - "with a placeholder; earlier context is no longer recoverable. " - "Consider checking your auxiliary.compression model configuration." + t( + "gateway.compress.summary_failed", + error=(_summary_err or "unknown error"), + count=_dropped_count, + ) ) elif _aux_fail_model: lines.append( - f"ℹ️ Configured compression model `{_aux_fail_model}` failed " - f"({_aux_fail_err or 'unknown error'}). Recovered using your main " - "model — context is intact — but you may want to check " - "`auxiliary.compression.model` in config.yaml." + t( + "gateway.compress.aux_failed", + model=_aux_fail_model, + error=(_aux_fail_err or "unknown error"), + ) ) return "\n".join(lines) except Exception as e: logger.warning("Manual compress failed: %s", e) - return f"Compression failed: {e}" + return t("gateway.compress.failed", error=e) async def _get_telegram_topic_capabilities(self, source: SessionSource) -> dict: """Read Telegram private-topic capability flags via Bot API getMe.""" @@ -10518,7 +11038,7 @@ class GatewayRunner: """Cleanly disable topic mode for a chat via /topic off.""" if not self._session_db: from hermes_state import format_session_db_unavailable - return format_session_db_unavailable() + return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) chat_id = str(source.chat_id or "") if not chat_id: return "Could not determine chat ID." @@ -10554,10 +11074,10 @@ class GatewayRunner: """Handle /topic for Telegram DM user-managed topic sessions.""" source = event.source if source.platform != Platform.TELEGRAM or source.chat_type != "dm": - return "The /topic command is only available in Telegram private chats." + return t("gateway.topic.not_telegram_dm") if not self._session_db: from hermes_state import format_session_db_unavailable - return format_session_db_unavailable() + return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) # Authorization: /topic activates multi-session mode and mutates # SQLite side tables. Unauthorized senders (not in allowlist) must @@ -10567,7 +11087,7 @@ class GatewayRunner: if callable(auth_fn): try: if not auth_fn(source): - return "You are not authorized to use /topic on this bot." + return t("gateway.topic.unauthorized") except Exception: logger.debug("Topic auth check failed", exc_info=True) @@ -10583,11 +11103,7 @@ class GatewayRunner: if args: if not source.thread_id: - return ( - "To restore a session, first create or open a Telegram topic, " - "then send /topic <session-id> inside that topic. To create a " - "new topic, open All Messages and send any message there." - ) + return t("gateway.topic.restore_needs_topic") return await self._restore_telegram_topic_session(event, args) capabilities = await self._get_telegram_topic_capabilities(source) @@ -10597,24 +11113,11 @@ class GatewayRunner: # /topic while threads are still disabled. if self._should_send_telegram_capability_hint(source): await self._send_telegram_topic_setup_image(source) - return ( - "Telegram topics are not enabled for this bot yet.\n\n" - "How to enable them:\n" - "1. Open @BotFather.\n" - "2. Choose your bot.\n" - "3. Open Bot Settings → Threads Settings.\n" - "4. Turn on Threaded Mode and make sure users are allowed to create new threads.\n\n" - "Then send /topic again." - ) + return t("gateway.topic.topics_disabled") if capabilities.get("allows_users_to_create_topics") is False: if self._should_send_telegram_capability_hint(source): await self._send_telegram_topic_setup_image(source) - return ( - "Telegram topics are enabled, but users are not allowed to create topics.\n\n" - "Open @BotFather → choose your bot → Bot Settings → Threads Settings, " - "then turn off 'Disallow users to create new threads'.\n\n" - "Then send /topic again." - ) + return t("gateway.topic.topics_user_disallowed") try: self._session_db.enable_telegram_topic_mode( @@ -10625,7 +11128,7 @@ class GatewayRunner: ) except Exception as exc: logger.exception("Failed to enable Telegram topic mode") - return f"Failed to enable Telegram topic mode: {exc}" + return t("gateway.topic.enable_failed", error=exc) if not source.thread_id: await self._ensure_telegram_system_topic(source) @@ -10646,21 +11149,13 @@ class GatewayRunner: title = self._session_db.get_session_title(session_id) except Exception: title = None - session_label = title or "Untitled session" - return ( - "This topic is linked to:\n" - f"Session: {session_label}\n" - f"ID: {session_id}\n\n" - "Use /new to replace this topic with a fresh session.\n" - "For parallel work, open All Messages and send a message there " - "to create another topic." + session_label = title or t("gateway.topic.untitled_session") + return t( + "gateway.topic.bound_status", + label=session_label, + session_id=session_id, ) - return ( - "Telegram multi-session topics are enabled.\n\n" - "This topic will be used as an independent Hermes session. " - "Use /new to replace this topic's current session. For parallel " - "work, open All Messages and send a message there to create another topic." - ) + return t("gateway.topic.thread_ready") return self._telegram_topic_root_status_message(source) @@ -10772,7 +11267,7 @@ class GatewayRunner: if not self._session_db: from hermes_state import format_session_db_unavailable - return format_session_db_unavailable() + return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) # Ensure session exists in SQLite DB (it may only exist in session_store # if this is the first command in a new session) @@ -10794,30 +11289,30 @@ class GatewayRunner: try: sanitized = self._session_db.sanitize_title(title_arg) except ValueError as e: - return f"⚠️ {e}" + return t("gateway.shared.warn_passthrough", error=e) if not sanitized: - return "⚠️ Title is empty after cleanup. Please use printable characters." + return t("gateway.title.empty_after_clean") # Set the title try: if self._session_db.set_session_title(session_id, sanitized): - return f"✏️ Session title set: **{sanitized}**" + return t("gateway.title.set_to", title=sanitized) else: - return "Session not found in database." + return t("gateway.title.not_found") except ValueError as e: - return f"⚠️ {e}" + return t("gateway.shared.warn_passthrough", error=e) else: # Show the current title and session ID title = self._session_db.get_session_title(session_id) if title: - return f"📌 Session: `{session_id}`\nTitle: **{title}**" + return t("gateway.title.current_with_title", session_id=session_id, title=title) else: - return f"📌 Session: `{session_id}`\nNo title set. Usage: `/title My Session Name`" + return t("gateway.title.current_no_title", session_id=session_id) async def _handle_resume_command(self, event: MessageEvent) -> str: """Handle /resume command — switch to a previously-named session.""" if not self._session_db: from hermes_state import format_session_db_unavailable - return format_session_db_unavailable() + return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) source = event.source session_key = self._session_key_for_source(source) @@ -10832,30 +11327,23 @@ class GatewayRunner: ) titled = [s for s in sessions if s.get("title")] if not titled: - return ( - "No named sessions found.\n" - "Use `/title My Session` to name your current session, " - "then `/resume My Session` to return to it later." - ) - lines = ["📋 **Named Sessions**\n"] + return t("gateway.resume.no_named_sessions") + lines = [t("gateway.resume.list_header")] for s in titled[:10]: title = s["title"] preview = s.get("preview", "")[:40] - preview_part = f" — _{preview}_" if preview else "" - lines.append(f"• **{title}**{preview_part}") - lines.append("\nUsage: `/resume <session name>`") + preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else "" + lines.append(t("gateway.resume.list_item", title=title, preview_part=preview_part)) + lines.append(t("gateway.resume.list_footer")) return "\n".join(lines) except Exception as e: logger.debug("Failed to list titled sessions: %s", e) - return f"Could not list sessions: {e}" + return t("gateway.resume.list_failed", error=e) # Resolve the name to a session ID. target_id = self._session_db.resolve_session_by_title(name) if not target_id: - return ( - f"No session found matching '**{name}**'.\n" - "Use `/resume` with no arguments to see available sessions." - ) + return t("gateway.resume.not_found", name=name) # Compression creates child continuations that hold the live transcript. # Follow that chain so gateway /resume matches CLI behavior (#15000). try: @@ -10866,7 +11354,7 @@ class GatewayRunner: # Check if already on that session current_entry = self.session_store.get_or_create_session(source) if current_entry.session_id == target_id: - return f"📌 Already on session **{name}**." + return t("gateway.resume.already_on", name=name) # Clear any running agent for this session key self._release_running_agent_state(session_key) @@ -10874,7 +11362,7 @@ class GatewayRunner: # Switch the session entry to point at the old session new_entry = self.session_store.switch_session(session_key, target_id) if not new_entry: - return "Failed to switch session." + return t("gateway.resume.switch_failed") self._clear_session_boundary_security_state(session_key) # Evict any cached agent for this session so the next message @@ -10890,9 +11378,11 @@ class GatewayRunner: # Count messages for context history = self.session_store.load_transcript(target_id) msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0 - msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else "" - - return f"↻ Resumed session **{title}**{msg_part}. Conversation restored." + if not msg_count: + return t("gateway.resume.resumed_no_count", title=title) + if msg_count == 1: + return t("gateway.resume.resumed_one", title=title, count=msg_count) + return t("gateway.resume.resumed_many", title=title, count=msg_count) async def _handle_branch_command(self, event: MessageEvent) -> str: """Handle /branch [name] — fork the current session into a new independent copy. @@ -10905,7 +11395,7 @@ class GatewayRunner: if not self._session_db: from hermes_state import format_session_db_unavailable - return format_session_db_unavailable() + return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) source = event.source session_key = self._session_key_for_source(source) @@ -10914,7 +11404,7 @@ class GatewayRunner: current_entry = self.session_store.get_or_create_session(source) history = self.session_store.load_transcript(current_entry.session_id) if not history: - return "No conversation to branch — send a message first." + return t("gateway.branch.no_conversation") branch_name = event.get_command_args().strip() @@ -10945,7 +11435,7 @@ class GatewayRunner: ) except Exception as e: logger.error("Failed to create branch session: %s", e) - return f"Failed to create branch: {e}" + return t("gateway.branch.create_failed", error=e) # Copy conversation history to the new session for msg in history: @@ -10976,20 +11466,15 @@ class GatewayRunner: # Switch the session store entry to the new session new_entry = self.session_store.switch_session(session_key, new_session_id) if not new_entry: - return "Branch created but failed to switch to it." + return t("gateway.branch.switch_failed") self._clear_session_boundary_security_state(session_key) # Evict any cached agent for this session self._evict_cached_agent(session_key) msg_count = len([m for m in history if m.get("role") == "user"]) - return ( - f"⑂ Branched to **{branch_title}**" - f" ({msg_count} message{'s' if msg_count != 1 else ''} copied)\n" - f"Original: `{parent_session_id}`\n" - f"Branch: `{new_session_id}`\n" - f"Use `/resume` to switch back to the original." - ) + key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many" + return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id) async def _handle_usage_command(self, event: MessageEvent) -> str: """Handle /usage command -- show token usage for the current session. @@ -11051,7 +11536,7 @@ class GatewayRunner: rl_state = agent.get_rate_limit_state() if rl_state and rl_state.has_data: from agent.rate_limit_tracker import format_rate_limit_compact - lines.append(f"⏱️ **Rate Limits:** {format_rate_limit_compact(rl_state)}") + lines.append(t("gateway.usage.rate_limits", state=format_rate_limit_compact(rl_state))) lines.append("") # Session token usage — detailed breakdown matching CLI @@ -11060,16 +11545,16 @@ class GatewayRunner: cache_read = getattr(agent, "session_cache_read_tokens", 0) or 0 cache_write = getattr(agent, "session_cache_write_tokens", 0) or 0 - lines.append("📊 **Session Token Usage**") - lines.append(f"Model: `{agent.model}`") - lines.append(f"Input tokens: {input_tokens:,}") + lines.append(t("gateway.usage.header_session")) + lines.append(t("gateway.usage.label_model", model=agent.model)) + lines.append(t("gateway.usage.label_input_tokens", count=f"{input_tokens:,}")) if cache_read: - lines.append(f"Cache read tokens: {cache_read:,}") + lines.append(t("gateway.usage.label_cache_read", count=f"{cache_read:,}")) if cache_write: - lines.append(f"Cache write tokens: {cache_write:,}") - lines.append(f"Output tokens: {output_tokens:,}") - lines.append(f"Total: {agent.session_total_tokens:,}") - lines.append(f"API calls: {agent.session_api_calls}") + lines.append(t("gateway.usage.label_cache_write", count=f"{cache_write:,}")) + lines.append(t("gateway.usage.label_output_tokens", count=f"{output_tokens:,}")) + lines.append(t("gateway.usage.label_total", count=f"{agent.session_total_tokens:,}")) + lines.append(t("gateway.usage.label_api_calls", count=agent.session_api_calls)) # Cost estimation try: @@ -11087,9 +11572,9 @@ class GatewayRunner: ) if cost_result.amount_usd is not None: prefix = "~" if cost_result.status == "estimated" else "" - lines.append(f"Cost: {prefix}${float(cost_result.amount_usd):.4f}") + lines.append(t("gateway.usage.label_cost", prefix=prefix, amount=f"{float(cost_result.amount_usd):.4f}")) elif cost_result.status == "included": - lines.append("Cost: included") + lines.append(t("gateway.usage.label_cost_included")) except Exception: pass @@ -11097,9 +11582,9 @@ class GatewayRunner: ctx = agent.context_compressor if ctx.last_prompt_tokens: pct = min(100, ctx.last_prompt_tokens / ctx.context_length * 100) if ctx.context_length else 0 - lines.append(f"Context: {ctx.last_prompt_tokens:,} / {ctx.context_length:,} ({pct:.0f}%)") + lines.append(t("gateway.usage.label_context", used=f"{ctx.last_prompt_tokens:,}", total=f"{ctx.context_length:,}", pct=f"{pct:.0f}")) if ctx.compression_count: - lines.append(f"Compressions: {ctx.compression_count}") + lines.append(t("gateway.usage.label_compressions", count=ctx.compression_count)) if account_lines: lines.append("") @@ -11112,13 +11597,13 @@ class GatewayRunner: history = self.session_store.load_transcript(session_entry.session_id) if history: from agent.model_metadata import estimate_messages_tokens_rough - msgs = [m for m in history if m.get("role") in ("user", "assistant") and m.get("content")] + msgs = [m for m in history if m.get("role") in {"user", "assistant"} and m.get("content")] approx = estimate_messages_tokens_rough(msgs) lines = [ - "📊 **Session Info**", - f"Messages: {len(msgs)}", - f"Estimated context: ~{approx:,} tokens", - "_(Detailed usage available after the first agent response)_", + t("gateway.usage.header_session_info"), + t("gateway.usage.label_messages", count=len(msgs)), + t("gateway.usage.label_estimated_context", count=f"{approx:,}"), + t("gateway.usage.detailed_after_first"), ] if account_lines: lines.append("") @@ -11126,7 +11611,7 @@ class GatewayRunner: return "\n".join(lines) if account_lines: return "\n".join(account_lines) - return "No usage data available for this session." + return t("gateway.usage.no_data") async def _handle_insights_command(self, event: MessageEvent) -> str: """Handle /insights command -- show usage insights and analytics.""" @@ -11147,7 +11632,7 @@ class GatewayRunner: try: days = int(parts[i + 1]) except ValueError: - return f"Invalid --days value: {parts[i + 1]}" + return t("gateway.insights.invalid_days", value=parts[i + 1]) i += 2 elif parts[i] == "--source" and i + 1 < len(parts): source = parts[i + 1] @@ -11175,7 +11660,7 @@ class GatewayRunner: return await loop.run_in_executor(None, _run_insights) except Exception as e: logger.error("Insights command error: %s", e, exc_info=True) - return f"Error generating insights: {e}" + return t("gateway.insights.error", error=e) async def _handle_reload_mcp_command(self, event: MessageEvent) -> Optional[str]: """Handle /reload-mcp — reconnect MCP servers and rebuild the cached agent. @@ -11213,7 +11698,7 @@ class GatewayRunner: # chosen outcome. async def _on_confirm(choice: str) -> Optional[str]: if choice == "cancel": - return "🟡 /reload-mcp cancelled. MCP tools unchanged." + return t("gateway.reload_mcp.cancelled") if choice == "always": # Persist the opt-out and run the reload. try: @@ -11228,25 +11713,10 @@ class GatewayRunner: # once / always → run the reload result = await self._execute_mcp_reload(event) if choice == "always": - return ( - f"{result}\n\n" - "ℹ️ Future `/reload-mcp` calls will run without confirmation. " - "Re-enable via `approvals.mcp_reload_confirm: true` in config.yaml." - ) + return f"{result}\n\n" + t("gateway.reload_mcp.always_followup") return result - prompt_message = ( - "⚠️ **Confirm /reload-mcp**\n\n" - "Reloading MCP servers rebuilds the tool set for this session " - "and **invalidates the provider prompt cache** — the next " - "message will re-send full input tokens. On long-context or " - "high-reasoning models this can be expensive.\n\n" - "Choose:\n" - "• **Approve Once** — reload now\n" - "• **Always Approve** — reload now and silence this prompt permanently\n" - "• **Cancel** — leave MCP tools unchanged\n\n" - "_Text fallback: reply `/approve`, `/always`, or `/cancel`._" - ) + prompt_message = t("gateway.reload_mcp.confirm_prompt") return await self._request_slash_confirm( event=event, command="reload-mcp", @@ -11285,17 +11755,17 @@ class GatewayRunner: removed = old_servers - connected_servers reconnected = connected_servers & old_servers - lines = ["🔄 **MCP Servers Reloaded**\n"] + lines = [t("gateway.reload_mcp.header")] if reconnected: - lines.append(f"♻️ Reconnected: {', '.join(sorted(reconnected))}") + lines.append(t("gateway.reload_mcp.reconnected", names=", ".join(sorted(reconnected)))) if added: - lines.append(f"➕ Added: {', '.join(sorted(added))}") + lines.append(t("gateway.reload_mcp.added", names=", ".join(sorted(added)))) if removed: - lines.append(f"➖ Removed: {', '.join(sorted(removed))}") + lines.append(t("gateway.reload_mcp.removed", names=", ".join(sorted(removed)))) if not connected_servers: - lines.append("No MCP servers connected.") + lines.append(t("gateway.reload_mcp.none_connected")) else: - lines.append(f"\n🔧 {len(new_tools)} tool(s) available from {len(connected_servers)} server(s)") + lines.append(t("gateway.reload_mcp.tools_available", tools=len(new_tools), servers=len(connected_servers))) # Inject a message at the END of the session history so the # model knows tools changed on its next turn. Appended after @@ -11325,7 +11795,7 @@ class GatewayRunner: except Exception as e: logger.warning("MCP reload failed: %s", e) - return f"❌ MCP reload failed: {e}" + return t("gateway.reload_mcp.failed", error=e) async def _handle_reload_skills_command(self, event: MessageEvent) -> str: """Handle /reload-skills — rescan skills dir, queue a note for next turn. @@ -11373,26 +11843,28 @@ class GatewayRunner: getattr(adapter, "name", adapter), exc, ) - lines = ["🔄 **Skills Reloaded**\n"] + lines = [t("gateway.reload_skills.header")] if not added and not removed: - lines.append("No new skills detected.") - lines.append(f"\n📚 {total} skill(s) available") + lines.append(t("gateway.reload_skills.no_new")) + lines.append(t("gateway.reload_skills.total", count=total)) return "\n".join(lines) def _fmt_line(item: dict) -> str: nm = item.get("name", "") desc = item.get("description", "") - return f" - {nm}: {desc}" if desc else f" - {nm}" + if desc: + return t("gateway.reload_skills.item_with_desc", name=nm, desc=desc) + return t("gateway.reload_skills.item_no_desc", name=nm) if added: - lines.append("➕ **Added Skills:**") + lines.append(t("gateway.reload_skills.added_header")) for item in added: lines.append(_fmt_line(item)) if removed: - lines.append("➖ **Removed Skills:**") + lines.append(t("gateway.reload_skills.removed_header")) for item in removed: lines.append(_fmt_line(item)) - lines.append(f"\n📚 {total} skill(s) available") + lines.append(t("gateway.reload_skills.total", count=total)) # Queue the one-shot note for the next user turn in this session. # Format matches how the system prompt renders pre-existing @@ -11423,7 +11895,7 @@ class GatewayRunner: except Exception as e: logger.warning("Skills reload failed: %s", e) - return f"❌ Skills reload failed: {e}" + return t("gateway.reload_skills.failed", error=e) # ------------------------------------------------------------------ # Slash-command confirmation primitive (generic) @@ -11672,35 +12144,32 @@ class GatewayRunner: if session_key in self._pending_approvals: self._pending_approvals.pop(session_key) return t("gateway.approval_expired") - return "No pending command to approve." + return t("gateway.approve.no_pending") # Parse args: support "all", "all session", "all always", "session", "always" args = event.get_command_args().strip().lower().split() resolve_all = "all" in args remaining = [a for a in args if a != "all"] - if any(a in ("always", "permanent", "permanently") for a in remaining): + if any(a in {"always", "permanent", "permanently"} for a in remaining): choice = "always" - scope_msg = " (pattern approved permanently)" - elif any(a in ("session", "ses") for a in remaining): + elif any(a in {"session", "ses"} for a in remaining): choice = "session" - scope_msg = " (pattern approved for this session)" else: choice = "once" - scope_msg = "" count = resolve_gateway_approval(session_key, choice, resolve_all=resolve_all) if not count: - return "No pending command to approve." + return t("gateway.approve.no_pending") # Resume typing indicator — agent is about to continue processing. _adapter = self.adapters.get(source.platform) if _adapter: _adapter.resume_typing_for_chat(source.chat_id) - count_msg = f" ({count} commands)" if count > 1 else "" - logger.info("User approved %d dangerous command(s) via /approve%s", count, scope_msg) - return f"✅ Command{'s' if count > 1 else ''} approved{scope_msg}{count_msg}. The agent is resuming..." + logger.info("User approved %d dangerous command(s) via /approve (%s)", count, choice) + plural = "plural" if count > 1 else "singular" + return t(f"gateway.approve.{choice}_{plural}", count=count) async def _handle_deny_command(self, event: MessageEvent) -> str: """Handle /deny command — reject pending dangerous command(s). @@ -11720,24 +12189,25 @@ class GatewayRunner: if not has_blocking_approval(session_key): if session_key in self._pending_approvals: self._pending_approvals.pop(session_key) - return "❌ Command denied (approval was stale)." - return "No pending command to deny." + return t("gateway.deny.stale") + return t("gateway.deny.no_pending") args = event.get_command_args().strip().lower() resolve_all = "all" in args count = resolve_gateway_approval(session_key, "deny", resolve_all=resolve_all) if not count: - return "No pending command to deny." + return t("gateway.deny.no_pending") # Resume typing indicator — agent continues (with BLOCKED result). _adapter = self.adapters.get(source.platform) if _adapter: _adapter.resume_typing_for_chat(source.chat_id) - count_msg = f" ({count} commands)" if count > 1 else "" logger.info("User denied %d dangerous command(s) via /deny", count) - return f"❌ Command{'s' if count > 1 else ''} denied{count_msg}." + if count > 1: + return t("gateway.deny.denied_plural", count=count) + return t("gateway.deny.denied_singular") # Platforms where /update is allowed. ACP, API server, and webhooks are # programmatic interfaces that should not trigger system updates. @@ -11774,20 +12244,20 @@ class GatewayRunner: try: urls["Report"] = upload_to_pastebin(report) except Exception as exc: - return f"✗ Failed to upload debug report: {exc}" + return t("gateway.debug.upload_failed", error=exc) # Schedule auto-deletion after 6 hours _schedule_auto_delete(list(urls.values())) - lines = [_GATEWAY_PRIVACY_NOTICE, "", "**Debug report uploaded:**", ""] + lines = [_GATEWAY_PRIVACY_NOTICE, "", t("gateway.debug.header"), ""] label_width = max(len(k) for k in urls) for label, url in urls.items(): lines.append(f"`{label:<{label_width}}` {url}") lines.append("") - lines.append("⏱ Pastes will auto-delete in 6 hours.") - lines.append("For full log uploads, use `hermes debug share` from the CLI.") - lines.append("Share these links with the Hermes team for support.") + lines.append(t("gateway.debug.auto_delete")) + lines.append(t("gateway.debug.full_logs_hint")) + lines.append(t("gateway.debug.share_hint")) return "\n".join(lines) return await loop.run_in_executor(None, _collect_and_upload) @@ -11815,9 +12285,9 @@ class GatewayRunner: from gateway.platform_registry import platform_registry entry = platform_registry.get(platform.value) if not entry or not entry.allow_update_command: - return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal." + return t("gateway.update.platform_not_messaging") except Exception: - return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal." + return t("gateway.update.platform_not_messaging") if is_managed(): return f"✗ {format_managed_message('update Hermes Agent')}" @@ -11826,16 +12296,11 @@ class GatewayRunner: git_dir = project_root / '.git' if not git_dir.exists(): - return "✗ Not a git repository — cannot update." + return t("gateway.update.not_git_repo") hermes_cmd = _resolve_hermes_bin() if not hermes_cmd: - return ( - "✗ Could not locate the `hermes` command. " - "Hermes is running, but the update command could not find the " - "executable on PATH or via the current Python interpreter. " - "Try running `hermes update` manually in your terminal." - ) + return t("gateway.update.hermes_cmd_not_found") pending_path = _hermes_home / ".update_pending.json" output_path = _hermes_home / ".update_output.txt" @@ -11938,10 +12403,10 @@ class GatewayRunner: except Exception as e: pending_path.unlink(missing_ok=True) exit_code_path.unlink(missing_ok=True) - return f"✗ Failed to start update: {e}" + return t("gateway.update.start_failed", error=e) self._schedule_update_notification_watch() - return "⚕ Starting Hermes update… I'll stream progress here." + return t("gateway.update.starting") def _schedule_update_notification_watch(self) -> None: """Ensure a background task is watching for update completion.""" @@ -12231,11 +12696,10 @@ class GatewayRunner: msg = f"✅ Hermes update finished.\n\n```\n{output}\n```" else: msg = f"❌ Hermes update failed.\n\n```\n{output}\n```" + elif exit_code == 0: + msg = "✅ Hermes update finished successfully." else: - if exit_code == 0: - msg = "✅ Hermes update finished successfully." - else: - msg = "❌ Hermes update failed. Check the gateway logs or run `hermes update` manually for details." + msg = "❌ Hermes update failed. Check the gateway logs or run `hermes update` manually for details." await adapter.send(chat_id, msg, metadata=metadata) logger.info( "Sent post-update notification to %s:%s (exit=%s)", @@ -12806,8 +13270,8 @@ class GatewayRunner: # --- Normal text-only notification --- # Decide whether to notify based on mode should_notify = ( - notify_mode in ("all", "result") - or (notify_mode == "error" and session.exit_code not in (0, None)) + notify_mode in {"all", "result"} + or (notify_mode == "error" and session.exit_code not in {0, None}) ) if should_notify: new_output = session.output_buffer[-1000:] if session.output_buffer else "" @@ -13402,7 +13866,7 @@ class GatewayRunner: for msg in history: role = msg.get("role") content = msg.get("content") - if role in ("user", "assistant") and content: + if role in {"user", "assistant"} and content: api_messages.append({"role": role, "content": content}) api_messages.append({"role": "user", "content": message}) @@ -13467,12 +13931,15 @@ class GatewayRunner: cursor=_effective_cursor, buffer_only=_buffer_only, fresh_final_after_seconds=_fresh_final_secs, + transport=_scfg.transport or "auto", + chat_type=getattr(source, "chat_type", "") or "", ) _stream_consumer = GatewayStreamConsumer( adapter=_adapter, chat_id=source.chat_id, config=_consumer_cfg, metadata=_thread_metadata, + initial_reply_to_id=event_message_id, ) except Exception as _sc_err: logger.debug("Proxy: could not set up stream consumer: %s", _sc_err) @@ -13790,7 +14257,7 @@ class GatewayRunner: # Only act on tool.started events (ignore tool.completed, reasoning.available, etc.) - if event_type not in ("tool.started",): + if event_type not in {"tool.started",}: return # Suppress tool-progress bubbles once the user has sent `stop`. @@ -14287,6 +14754,8 @@ class GatewayRunner: cursor=_effective_cursor, buffer_only=_buffer_only, fresh_final_after_seconds=_fresh_final_secs, + transport=_scfg.transport or "auto", + chat_type=getattr(source, "chat_type", "") or "", ) _stream_consumer = GatewayStreamConsumer( adapter=_adapter, @@ -14298,6 +14767,7 @@ class GatewayRunner: if progress_queue is not None else None ), + initial_reply_to_id=event_message_id, ) if _want_stream_deltas: def _stream_delta_cb(text: str) -> None: @@ -14484,7 +14954,7 @@ class GatewayRunner: # Skip metadata entries (tool definitions, session info) # -- these are for transcript logging, not for the LLM - if role in ("session_meta",): + if role in {"session_meta",}: continue # Skip system messages -- the agent rebuilds its own system prompt @@ -14521,7 +14991,7 @@ class GatewayRunner: # even if the message list shrinks, we know which paths are old. _history_media_paths: set = set() for _hm in agent_history: - if _hm.get("role") in ("tool", "function"): + if _hm.get("role") in {"tool", "function"}: _hc = _hm.get("content", "") if "MEDIA:" in _hc: for _match in re.finditer(r'MEDIA:(\S+)', _hc): @@ -14793,7 +15263,7 @@ class GatewayRunner: media_tags = [] has_voice_directive = False for msg in result.get("messages", []): - if msg.get("role") in ("tool", "function"): + if msg.get("role") in {"tool", "function"}: content = msg.get("content", "") if "MEDIA:" in content: for match in re.finditer(r'MEDIA:(\S+)', content): @@ -15851,40 +16321,62 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = except Exception as e: logger.debug("Planned stop marker check failed: %s", e) + # Fast (<10ms) snapshot of who's asking us to shut down — runs + # synchronously inside the asyncio signal handler, so we keep it + # purely stdlib + /proc reads, no subprocesses. See PR #15826 + # (May 2026): the previous implementation called `ps aux` here + # synchronously, blocking the event loop for up to 3s while + # adapter teardown couldn't begin. + try: + from gateway.shutdown_forensics import ( + format_context_for_log, + snapshot_shutdown_context, + spawn_async_diagnostic, + ) + _shutdown_ctx = snapshot_shutdown_context(received_signal) + except Exception as _e: + _shutdown_ctx = None + logger.debug("snapshot_shutdown_context failed: %s", _e) + if planned_takeover: logger.info( - "Received SIGTERM as a planned --replace takeover — exiting cleanly" + "Received %s as a planned --replace takeover — exiting cleanly", + _shutdown_ctx["signal"] if _shutdown_ctx else "SIGTERM", ) elif planned_stop: logger.info( - "Received SIGTERM/SIGINT as a planned gateway stop — exiting cleanly" + "Received %s as a planned gateway stop — exiting cleanly", + _shutdown_ctx["signal"] if _shutdown_ctx else "SIGTERM/SIGINT", ) else: _signal_initiated_shutdown = True - logger.info("Received SIGTERM/SIGINT — initiating shutdown") - # Diagnostic: log all hermes-related processes so we can identify - # what triggered the signal (hermes update, hermes gateway restart, - # a stale detached subprocess, etc.). - try: - import subprocess as _sp - _ps = _sp.run( - ["ps", "aux"], - capture_output=True, text=True, timeout=3, + logger.info( + "Received %s — initiating shutdown", + _shutdown_ctx["signal"] if _shutdown_ctx else "SIGTERM/SIGINT", ) - _hermes_procs = [ - line for line in _ps.stdout.splitlines() - if ("hermes" in line.lower() or "gateway" in line.lower()) - and str(os.getpid()) not in line.split()[1:2] # exclude self - ] - if _hermes_procs: + + # Always log who/what triggered the signal — most useful single + # line when diagnosing "the gateway keeps dying" tickets. Format + # is one line, key=value, parent_cmdline last (often long). + if _shutdown_ctx is not None: + try: logger.warning( - "Shutdown diagnostic — other hermes processes running:\n %s", - "\n ".join(_hermes_procs), + "Shutdown context: %s", format_context_for_log(_shutdown_ctx) ) - else: - logger.info("Shutdown diagnostic — no other hermes processes found") - except Exception: - pass + except Exception as _e: + logger.debug("format_context_for_log failed: %s", _e) + + # Spawn the heavyweight diagnostic (ps auxf, pstree, dmesg) in + # a detached subprocess so it can finish writing to disk even + # if our cgroup is being torn down. Bounded by an internal + # timeout; never blocks the event loop here. + try: + _diag_log = _hermes_home / "logs" / "gateway-shutdown-diag.log" + spawn_async_diagnostic( + _diag_log, _shutdown_ctx["signal"], timeout_seconds=5.0 + ) + except Exception as _e: + logger.debug("spawn_async_diagnostic failed: %s", _e) asyncio.create_task(runner.stop()) def restart_signal_handler(): diff --git a/gateway/session.py b/gateway/session.py index be393e48e6f..ac6f95eec63 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -764,12 +764,12 @@ class SessionStore: now = _now() - if policy.mode in ("idle", "both"): + if policy.mode in {"idle", "both"}: idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes) if now > idle_deadline: return True - if policy.mode in ("daily", "both"): + if policy.mode in {"daily", "both"}: today_reset = now.replace( hour=policy.at_hour, minute=0, second=0, microsecond=0, @@ -805,12 +805,12 @@ class SessionStore: now = _now() - if policy.mode in ("idle", "both"): + if policy.mode in {"idle", "both"}: idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes) if now > idle_deadline: return "idle" - if policy.mode in ("daily", "both"): + if policy.mode in {"daily", "both"}: today_reset = now.replace( hour=policy.at_hour, minute=0, @@ -1276,9 +1276,14 @@ class SessionStore: # Also write legacy JSONL (keeps existing tooling working during transition) transcript_path = self.get_transcript_path(session_id) - with self._lock: - with open(transcript_path, "a", encoding="utf-8") as f: - f.write(json.dumps(message, ensure_ascii=False) + "\n") + try: + with self._lock: + with open(transcript_path, "a", encoding="utf-8") as f: + f.write(json.dumps(message, ensure_ascii=False) + "\n") + except OSError as e: + # Disk full / read-only fs / permission errors must not crash the + # message handler — the SQLite write above is the primary store. + logger.debug("Failed to write JSONL transcript for %s: %s", session_id, e) def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) -> None: """Replace the entire transcript for a session with new messages. diff --git a/gateway/session_context.py b/gateway/session_context.py index 9dc051e3a2c..b64f31de081 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -55,6 +55,7 @@ _SESSION_THREAD_ID: ContextVar = ContextVar("HERMES_SESSION_THREAD_ID", default= _SESSION_USER_ID: ContextVar = ContextVar("HERMES_SESSION_USER_ID", default=_UNSET) _SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET) _SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET) +_SESSION_ID: ContextVar = ContextVar("HERMES_SESSION_ID", default=_UNSET) # Cron auto-delivery vars — set per-job in run_job() so concurrent jobs # don't clobber each other's delivery targets. @@ -70,6 +71,7 @@ _VAR_MAP = { "HERMES_SESSION_USER_ID": _SESSION_USER_ID, "HERMES_SESSION_USER_NAME": _SESSION_USER_NAME, "HERMES_SESSION_KEY": _SESSION_KEY, + "HERMES_SESSION_ID": _SESSION_ID, "HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM, "HERMES_CRON_AUTO_DELIVER_CHAT_ID": _CRON_AUTO_DELIVER_CHAT_ID, "HERMES_CRON_AUTO_DELIVER_THREAD_ID": _CRON_AUTO_DELIVER_THREAD_ID, diff --git a/gateway/shutdown_forensics.py b/gateway/shutdown_forensics.py new file mode 100644 index 00000000000..0a52ce14f09 --- /dev/null +++ b/gateway/shutdown_forensics.py @@ -0,0 +1,462 @@ +"""Shutdown forensics — capture context when the gateway receives SIGTERM/SIGINT. + +The gateway's ``shutdown_signal_handler`` runs synchronously inside the +asyncio event loop. We can't safely block it for long, but we DO want a +durable record of who/what triggered the shutdown so that "the gateway +keeps dying" incidents can be diagnosed after the fact. + +This module exposes :func:`snapshot_shutdown_context`, a fast (<10ms), +non-blocking probe that returns a structured dict the signal handler can +log immediately, plus :func:`spawn_async_diagnostic`, a fire-and-forget +``ps`` walk that runs as a detached subprocess so it can't block teardown +even if /proc is wedged. + +Anything that needs to wait (e.g. shelling out to ``ps aux``) belongs in +the async helper, never in the synchronous probe. +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + + +_SIGNAL_NAME_BY_NUM: Dict[int, str] = {} +for _name in ("SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT", "SIGUSR1", "SIGUSR2"): + _val = getattr(signal, _name, None) + if _val is not None: + _SIGNAL_NAME_BY_NUM[int(_val)] = _name + + +def _signal_name(sig: Any) -> str: + """Return a human-readable signal name (or ``str(sig)`` as fallback).""" + if sig is None: + return "UNKNOWN" + try: + sig_int = int(sig) + except (TypeError, ValueError): + return str(sig) + return _SIGNAL_NAME_BY_NUM.get(sig_int, f"signal#{sig_int}") + + +def _read_proc_field(pid: int, key: str) -> Optional[str]: + """Read a single field from /proc/<pid>/status. Linux only; None elsewhere.""" + try: + with open(f"/proc/{pid}/status", encoding="utf-8") as fh: + for line in fh: + if line.startswith(key + ":"): + return line.split(":", 1)[1].strip() + except (FileNotFoundError, PermissionError, OSError): + pass + return None + + +def _read_proc_cmdline(pid: int) -> Optional[str]: + """Read /proc/<pid>/cmdline as a printable string. Linux only; None elsewhere.""" + try: + with open(f"/proc/{pid}/cmdline", "rb") as fh: + data = fh.read() + except (FileNotFoundError, PermissionError, OSError): + return None + if not data: + return None + # cmdline uses NUL separators + return data.replace(b"\x00", b" ").decode("utf-8", errors="replace").strip() + + +def _proc_summary(pid: int) -> Dict[str, Any]: + """Compact /proc/<pid> snapshot: pid, ppid, state, uid, cmdline. + + Best-effort. Missing fields are simply omitted rather than raising. + """ + summary: Dict[str, Any] = {"pid": pid} + if pid <= 0: + return summary + name = _read_proc_field(pid, "Name") + if name is not None: + summary["name"] = name + state = _read_proc_field(pid, "State") + if state is not None: + summary["state"] = state + ppid = _read_proc_field(pid, "PPid") + if ppid is not None: + try: + summary["ppid"] = int(ppid) + except ValueError: + pass + uid = _read_proc_field(pid, "Uid") + if uid is not None: + # "real effective saved fs" + summary["uid"] = uid.split()[0] if uid else uid + cmdline = _read_proc_cmdline(pid) + if cmdline: + # Truncate aggressively — these can be 4KB + summary["cmdline"] = cmdline[:300] + return summary + + +def snapshot_shutdown_context(received_signal: Any = None) -> Dict[str, Any]: + """Fast (<10ms) snapshot of who/what is asking us to shut down. + + Captures: + + * The signal number/name (so SIGINT vs SIGTERM is visible) + * Our own PID/ppid + parent process info from /proc (Linux) + * Whether systemd is our parent (``ppid==1`` or ``INVOCATION_ID`` set) + * Whether takeover/planned-stop markers exist (consumed lazily by the caller) + * /proc/self limits + load average (1-min) + * Wall-clock and monotonic timestamps for cross-correlating later phases + + Pure stdlib, never raises, never blocks on subprocesses. + """ + now = time.time() + monotonic = time.monotonic() + pid = os.getpid() + ppid = os.getppid() + + ctx: Dict[str, Any] = { + "ts": now, + "ts_monotonic": monotonic, + "signal": _signal_name(received_signal), + "signal_num": int(received_signal) if received_signal is not None else None, + "pid": pid, + "ppid": ppid, + "parent": _proc_summary(ppid), + "self": _proc_summary(pid), + } + + # systemd context. If we were started by a systemd unit, INVOCATION_ID + # is set in our env. ppid==1 (init) is also a strong signal that + # systemd reaped+forwarded the SIGTERM. + invocation_id = os.environ.get("INVOCATION_ID") + if invocation_id: + ctx["systemd_invocation_id"] = invocation_id + journal_stream = os.environ.get("JOURNAL_STREAM") + if journal_stream: + ctx["systemd_journal_stream"] = journal_stream + ctx["under_systemd"] = bool(invocation_id) or ppid == 1 + + # Load average — high load points the finger at "something else + # crushing the box" rather than "external killer". + try: + ctx["loadavg_1m"] = os.getloadavg()[0] + except (OSError, AttributeError): + pass + + # /proc/self/status TracerPid: nonzero means a debugger / strace is + # attached. Useful when "phantom SIGKILL" turns out to be a manual + # gdb session. + try: + tracer = _read_proc_field(pid, "TracerPid") + if tracer is not None and tracer != "0": + ctx["tracer_pid"] = int(tracer) if tracer.isdigit() else tracer + ctx["tracer"] = _proc_summary(int(tracer)) if tracer.isdigit() else None + except (TypeError, ValueError): + pass + + # Race-detection hint: did somebody recently start a sibling gateway + # with --replace? We can't see the new process directly here, but if + # there's a takeover marker on disk that DOESN'T name us, that's a + # smoking gun for "another --replace instance is killing us". + # Filenames mirror gateway.status (._TAKEOVER_MARKER_FILENAME / + # _PLANNED_STOP_MARKER_FILENAME); we use string literals here so the + # signal-handler path stays import-light. + try: + hermes_home_str = os.environ.get("HERMES_HOME") + if hermes_home_str: + takeover_path = Path(hermes_home_str) / ".gateway-takeover.json" + if takeover_path.exists(): + try: + raw = takeover_path.read_text(encoding="utf-8") + ctx["takeover_marker"] = raw[:300] + ctx["takeover_marker_for_self"] = ( + f'"target_pid": {pid}' in raw + or f"'target_pid': {pid}" in raw + ) + except OSError: + pass + planned_stop_path = Path(hermes_home_str) / ".gateway-planned-stop.json" + if planned_stop_path.exists(): + try: + raw = planned_stop_path.read_text(encoding="utf-8") + ctx["planned_stop_marker"] = raw[:300] + except OSError: + pass + except Exception: # noqa: BLE001 — never raise from a signal handler + pass + + return ctx + + +def spawn_async_diagnostic( + log_path: Path, + signal_name: str, + *, + timeout_seconds: float = 5.0, +) -> Optional[int]: + """Fire-and-forget ``ps``-style snapshot written to ``log_path``. + + Runs as a detached subprocess so it can't block the asyncio event loop + or compete with platform teardown. The subprocess uses its own + ``timeout`` so a wedged ``ps`` still self-cleans within + ``timeout_seconds``. + + Returns the subprocess PID on success, ``None`` on failure. Never + raises. + + We deliberately avoid ``subprocess.run(["ps", "aux"])`` from inside the + signal handler (the pre-existing pattern): on a busy host with hundreds + of processes, ``ps aux`` can take >2s to walk /proc, during which the + asyncio loop is frozen and adapter teardown can't begin. + """ + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + except OSError: + return None + + # Inline shell so we don't have to ship a helper script. bash -c is + # available on every POSIX target we support; on Windows we just skip + # the snapshot (the platform doesn't ship ps anyway). + if sys.platform == "win32": + return None + + script = ( + f"echo '=== shutdown diagnostic @ {signal_name} ==='; " + "echo '--- date ---'; date -u +%Y-%m-%dT%H:%M:%SZ; " + "echo '--- ps auxf (top 60 by cpu) ---'; " + "ps auxf --sort=-pcpu 2>/dev/null | head -60; " + "echo '--- pstree of self ---'; " + f"pstree -plau {os.getpid()} 2>/dev/null | head -40 || true; " + "echo '--- /proc/loadavg ---'; " + "cat /proc/loadavg 2>/dev/null || true; " + "echo '--- recent dmesg (oom/killed) ---'; " + "dmesg -T 2>/dev/null | tail -20 || journalctl --user -n 20 --no-pager 2>/dev/null | tail -20 || true; " + "echo '=== end ==='" + ) + + try: + # Open the log file in append mode and let the subprocess inherit. + # We use os.O_APPEND so concurrent diagnostics from rapid signals + # don't trample each other. + fd = os.open(str(log_path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644) + except OSError: + return None + + try: + # Detach from our process group so the subprocess survives even + # if systemd kills our cgroup with KillMode=control-group (which + # would also reap us anyway, but defense in depth). Without + # start_new_session, a SIGKILL on our cgroup takes the diag down + # before it can flush. + proc = subprocess.Popen( + ["timeout", f"{timeout_seconds:.0f}", "bash", "-c", script], + stdout=fd, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + start_new_session=True, + close_fds=True, + ) + except (FileNotFoundError, OSError): + try: + os.close(fd) + except OSError: + pass + return None + finally: + # Subprocess inherited the fd; we can drop our handle. + try: + os.close(fd) + except OSError: + pass + + return proc.pid + + +def format_context_for_log(ctx: Dict[str, Any]) -> str: + """Render a shutdown context dict as a single, scannable log line.""" + sig = ctx.get("signal", "?") + parent = ctx.get("parent") or {} + parent_cmd = parent.get("cmdline", "(unknown)") + parent_name = parent.get("name") or "?" + parent_pid = parent.get("pid") or "?" + under_systemd = "yes" if ctx.get("under_systemd") else "no" + load = ctx.get("loadavg_1m") + load_str = f"{load:.2f}" if isinstance(load, (int, float)) else "?" + extras: List[str] = [] + if ctx.get("takeover_marker") is not None: + for_self = ctx.get("takeover_marker_for_self") + extras.append( + f"takeover_marker_present={'self' if for_self else 'other'}" + ) + if ctx.get("planned_stop_marker") is not None: + extras.append("planned_stop_marker_present=yes") + if ctx.get("tracer_pid"): + extras.append(f"tracer_pid={ctx['tracer_pid']}") + extras_str = (" " + " ".join(extras)) if extras else "" + # Parent cmdline is the most useful single signal — log it prominently. + return ( + f"signal={sig} " + f"under_systemd={under_systemd} " + f"parent_pid={parent_pid} " + f"parent_name={parent_name} " + f"loadavg_1m={load_str}" + f"{extras_str} " + f"parent_cmdline={parent_cmd!r}" + ) + + +def context_as_json(ctx: Dict[str, Any]) -> str: + """JSON-serialise a context dict for structured ingestion. Never raises.""" + try: + return json.dumps(ctx, default=str, sort_keys=True) + except (TypeError, ValueError): + return "{}" + + +def check_systemd_timing_alignment(drain_timeout: float) -> Optional[Dict[str, Any]]: + """At startup, sanity-check that systemd's TimeoutStopSec >= drain_timeout. + + When the gateway is run under a stale systemd unit file (e.g. the user + upgraded hermes-agent but never re-ran ``hermes setup`` to regenerate + the unit), ``TimeoutStopSec`` can be smaller than the configured + ``restart_drain_timeout``. Result: SIGTERM arrives, the drain starts, + and systemd SIGKILLs the cgroup mid-drain — looks like a phantom kill + in the journal because the journal only logs ``code=killed status=9``. + + Returns ``None`` when the alignment is fine OR we can't determine it + (not running under systemd, ``systemctl`` unavailable, etc.). Returns + a dict with ``timeout_stop_sec`` + ``drain_timeout`` + ``mismatch`` + bool when we have data to report. + + Best-effort. Never raises. + """ + invocation_id = os.environ.get("INVOCATION_ID") + if not invocation_id: + return None # Not running under systemd (or at least not directly) + + # Try to identify our unit name and ask systemctl for its config. + unit_name: Optional[str] = None + try: + # /proc/self/cgroup gives us "0::/user.slice/.../hermes-gateway.service" + with open("/proc/self/cgroup", encoding="utf-8") as fh: + for line in fh: + # systemd cgroup line ends with the unit name + if ".service" in line: + parts = line.strip().split("/") + for p in reversed(parts): + if p.endswith(".service"): + unit_name = p + break + if unit_name: + break + except (OSError, FileNotFoundError): + pass + if not unit_name: + return None + + # Query systemctl for TimeoutStopUSec. Use --user OR system depending + # on which manager actually owns the unit. Try user first since + # that's the common case for hermes. + timeout_us: Optional[int] = None + for flag in (["--user"], []): + try: + result = subprocess.run( + ["systemctl", *flag, "show", unit_name, "--property=TimeoutStopUSec"], + capture_output=True, text=True, timeout=2.0, + ) + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + continue + if result.returncode != 0: + continue + # Output: "TimeoutStopUSec=1min 30s" or "TimeoutStopUSec=90000000" + for line in result.stdout.splitlines(): + if line.startswith("TimeoutStopUSec="): + value = line.split("=", 1)[1].strip() + # Try numeric microseconds first + if value.isdigit(): + timeout_us = int(value) + else: + timeout_us = _parse_systemd_duration_to_us(value) + if timeout_us is not None: + break + if timeout_us is not None: + break + + if timeout_us is None: + return None + + timeout_stop_sec = timeout_us / 1_000_000.0 + # systemd needs headroom for: post-interrupt kill, adapter disconnect, + # SessionDB close, file unlinks, etc. 30s matches the unit-template + # constant in hermes_cli/gateway.py. + headroom = 30.0 + expected = drain_timeout + headroom + return { + "unit": unit_name, + "timeout_stop_sec": timeout_stop_sec, + "drain_timeout": drain_timeout, + "expected_min": expected, + "mismatch": timeout_stop_sec < expected, + } + + +def _parse_systemd_duration_to_us(raw: str) -> Optional[int]: + """Parse 'TimeoutStopUSec=1min 30s' / '90s' style values to microseconds. + + systemd accepts a wide grammar; we cover the common cases (s, ms, min, + h) and return None on anything unexpected. Never raises. + """ + if not raw: + return None + units = { + "us": 1, + "ms": 1_000, + "s": 1_000_000, + "sec": 1_000_000, + "min": 60_000_000, + "h": 3_600_000_000, + "hr": 3_600_000_000, + } + total_us = 0 + token = "" + digits = "" + for ch in raw + " ": + if ch.isdigit() or ch == ".": + if token: + # End previous unit, start new number + multiplier = units.get(token.lower()) + if multiplier is None or not digits: + return None + try: + total_us += int(float(digits) * multiplier) + except ValueError: + return None + digits = "" + token = "" + digits += ch + elif ch.isalpha(): + token += ch + elif digits and token: + multiplier = units.get(token.lower()) + if multiplier is None: + return None + try: + total_us += int(float(digits) * multiplier) + except ValueError: + return None + digits = "" + token = "" + elif digits and not token: + # Bare number = seconds (rare but valid) + try: + total_us += int(float(digits) * 1_000_000) + except ValueError: + return None + digits = "" + return total_us if total_us > 0 else None diff --git a/gateway/slash_access.py b/gateway/slash_access.py new file mode 100644 index 00000000000..e4a398dc14a --- /dev/null +++ b/gateway/slash_access.py @@ -0,0 +1,229 @@ +"""Per-platform slash command access control. + +This module sits beside the existing per-platform allowlist (``allow_from``) +and adds a second axis: of the users who are *allowed to talk to the +gateway*, which ones can run *which slash commands*. + +Two lists per platform scope (DM vs group, mirroring ``allow_from`` vs +``group_allow_from``): + + - ``allow_admin_from`` — user IDs that get every registered slash + command (built-in + plugin-registered). + - ``user_allowed_commands`` — slash command names non-admin users may + run. Empty / unset → non-admins get no + slash commands. + +Backward compatibility: + + If ``allow_admin_from`` is not set for a scope, slash command gating + is disabled entirely for that scope. Every allowed user can run every + slash command, exactly like before. This means existing installs are + unaffected until an operator opts in by listing at least one admin. + +The gate is applied at the slash command dispatch site in +``gateway/run.py`` so it covers BOTH built-in and plugin-registered +commands via the live registry. Gating slash commands does not affect +plain chat — non-admin users can still talk to the agent normally, +they just can't trigger commands outside ``user_allowed_commands``. + +Authored as a slimmed-down salvage of PR #4443's permission tiers +(co-authored by @ReqX). The full tier system, audit log, usage +tracking, rate limiting, and tool filtering from that PR are not +included here — only the slash-command access split. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, FrozenSet, Iterable, Optional, Tuple + + +# Slash commands that MUST stay reachable for any allowed user, even when +# slash gating is enabled and the user has no commands listed. Without this +# carve-out, a non-admin user has no way to discover what they can or +# can't do (``/help``, ``/whoami``) and no way to see what state the agent +# is in (``/status``). These mirror the smallest set of read-only commands +# we'd hand to a guest. Operators can still narrow this further by writing +# their own ``user_allowed_commands`` (this set is only the implicit +# fallback floor — anything in ``user_allowed_commands`` overrides it +# additively, never restrictively). +_ALWAYS_ALLOWED_FOR_USERS: FrozenSet[str] = frozenset({ + "help", + "whoami", +}) + + +@dataclass(frozen=True) +class SlashAccessPolicy: + """Resolved access policy for a single (platform, scope) pair. + + ``scope`` is ``"dm"`` for direct messages and ``"group"`` for groups, + channels, threads, and any other multi-user context. The mapping from + SessionSource.chat_type → scope happens in ``policy_for_source``. + """ + + enabled: bool # gating active for this scope? + admin_user_ids: FrozenSet[str] + user_allowed_commands: FrozenSet[str] + + def is_admin(self, user_id: Optional[str]) -> bool: + if not self.enabled: + # Gating disabled → treat every allowed user as admin so + # downstream code can keep using ``is_admin`` / ``can_run`` + # uniformly. + return True + if not user_id: + return False + return str(user_id) in self.admin_user_ids + + def can_run(self, user_id: Optional[str], canonical_cmd: str) -> bool: + if not self.enabled: + return True + if self.is_admin(user_id): + return True + if not canonical_cmd: + return False + if canonical_cmd in _ALWAYS_ALLOWED_FOR_USERS: + return True + return canonical_cmd in self.user_allowed_commands + + +_DM_CHAT_TYPES = frozenset({"dm", "direct", "private", ""}) + + +def _coerce_id_list(raw: Any) -> FrozenSet[str]: + """Normalize a YAML-loaded admin/user list into a frozenset of strings. + + Accepts ``None``, list, tuple, or comma-separated string. Stringifies + each entry and strips whitespace; empty entries are dropped. + """ + if raw is None: + return frozenset() + if isinstance(raw, (list, tuple, set, frozenset)): + items: Iterable[Any] = raw + elif isinstance(raw, str): + items = (s for s in raw.split(",") if s.strip()) + else: + # single scalar (int user id, etc.) + items = (raw,) + out: list[str] = [] + for it in items: + s = str(it).strip() + if s: + out.append(s) + return frozenset(out) + + +def _coerce_command_list(raw: Any) -> FrozenSet[str]: + """Normalize a slash command allowlist. + + Strips leading slashes so YAML can read either ``["help", "status"]`` + or ``["/help", "/status"]``. Lowercase canonicalization matches how + ``resolve_command()`` stores names. + """ + if raw is None: + return frozenset() + if isinstance(raw, (list, tuple, set, frozenset)): + items: Iterable[Any] = raw + elif isinstance(raw, str): + items = (s for s in raw.split(",") if s.strip()) + else: + items = (raw,) + out: list[str] = [] + for it in items: + s = str(it).strip().lstrip("/").lower() + if s: + out.append(s) + return frozenset(out) + + +def _scope_for_chat_type(chat_type: Optional[str]) -> str: + if chat_type and chat_type.lower() in _DM_CHAT_TYPES: + return "dm" + return "group" + + +def _platform_extra(platform_config: Any) -> dict: + """Return the ``extra`` dict from a PlatformConfig-like object. + + Defensively handles None and non-PlatformConfig shapes so calling + code can stay simple. + """ + if platform_config is None: + return {} + extra = getattr(platform_config, "extra", None) + if isinstance(extra, dict): + return extra + if isinstance(platform_config, dict): + # Some test harnesses pass dicts directly. + return platform_config + return {} + + +def _keys_for_scope(scope: str) -> Tuple[str, str]: + """Return (admin_key, user_cmd_key) names for a scope.""" + if scope == "group": + return ("group_allow_admin_from", "group_user_allowed_commands") + return ("allow_admin_from", "user_allowed_commands") + + +def policy_from_extra(extra: dict, scope: str) -> SlashAccessPolicy: + """Build a policy from a platform's ``extra`` dict for one scope. + + DM scope falls back to group scope keys ONLY for ``user_allowed_commands`` + when the DM scope didn't specify its own. This keeps the common case + (operator wants the same command set DM and group) ergonomic without + forcing duplication. Admin lists are NOT cross-scope: an admin in + DMs is not implicitly an admin in a group. + """ + admin_key, cmd_key = _keys_for_scope(scope) + admin_ids = _coerce_id_list(extra.get(admin_key)) + cmds = _coerce_command_list(extra.get(cmd_key)) + + if scope == "dm" and not cmds: + # DM didn't specify — let group's user_allowed_commands fall through + # so operators only need to list it once if it's the same. + cmds = _coerce_command_list(extra.get("group_user_allowed_commands")) + + enabled = bool(admin_ids) + return SlashAccessPolicy( + enabled=enabled, + admin_user_ids=admin_ids, + user_allowed_commands=cmds, + ) + + +def policy_for_source(gateway_config: Any, source: Any) -> SlashAccessPolicy: + """Resolve the access policy for a SessionSource. + + Returns a "disabled" policy (gating off, allow everything) when: + - gateway_config is None + - the platform has no PlatformConfig + - the platform's PlatformConfig has no admin list set for the scope + + Callers should treat the returned policy as authoritative for slash + command gating only. It does not gate plain chat messages. + """ + if gateway_config is None or source is None: + return SlashAccessPolicy( + enabled=False, + admin_user_ids=frozenset(), + user_allowed_commands=frozenset(), + ) + platforms = getattr(gateway_config, "platforms", None) + platform_config = None + if platforms is not None: + try: + platform_config = platforms.get(source.platform) + except Exception: + platform_config = None + extra = _platform_extra(platform_config) + scope = _scope_for_chat_type(getattr(source, "chat_type", None)) + return policy_from_extra(extra, scope) + + +__all__ = [ + "SlashAccessPolicy", + "policy_from_extra", + "policy_for_source", +] diff --git a/gateway/status.py b/gateway/status.py index 78fec1a98cb..2849e775080 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -218,7 +218,11 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]: if not pid_path.exists(): return None - raw = pid_path.read_text().strip() + try: + raw = pid_path.read_text().strip() + except OSError: + # File was deleted between exists() and read_text(), or permission flipped. + return None if not raw: return None @@ -600,7 +604,7 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str, for _line in _proc_status.read_text(encoding="utf-8").splitlines(): if _line.startswith("State:"): _state = _line.split()[1] - if _state in ("T", "t"): # stopped or tracing stop + if _state in {"T", "t"}: # stopped or tracing stop stale = True break except (OSError, PermissionError): diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index cfd5e9f8d8a..558a86bd295 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -21,7 +21,15 @@ import queue import re import time from dataclasses import dataclass -from typing import Any, Optional +from typing import Any, Callable, Optional + +from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter +from gateway.platforms.base import _custom_unit_to_cp +from gateway.config import ( + DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL, + DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD, + DEFAULT_STREAMING_CURSOR as _DEFAULT_STREAMING_CURSOR, +) logger = logging.getLogger("gateway.stream_consumer") @@ -40,9 +48,9 @@ _COMMENTARY = object() @dataclass class StreamConsumerConfig: """Runtime config for a single stream consumer instance.""" - edit_interval: float = 1.0 - buffer_threshold: int = 40 - cursor: str = " ▉" + edit_interval: float = _DEFAULT_STREAMING_EDIT_INTERVAL + buffer_threshold: int = _DEFAULT_STREAMING_BUFFER_THRESHOLD + cursor: str = _DEFAULT_STREAMING_CURSOR buffer_only: bool = False # When >0, the final edit for a streamed response is delivered as a # fresh message if the original preview has been visible for at least @@ -52,6 +60,18 @@ class StreamConsumerConfig: # openclaw/openclaw#72038. Default 0 = always edit in place (legacy # behavior). The gateway enables this selectively per-platform. fresh_final_after_seconds: float = 0.0 + # Streaming transport selection: + # "auto" — prefer native draft streaming (e.g. Telegram sendMessageDraft) + # when the adapter + chat supports it; fall back to edit. + # "draft" — explicitly request native draft streaming; fall back to + # edit when unsupported. + # "edit" — progressive editMessageText (legacy behavior). + # "off" — handled by the gateway before the consumer is even built. + transport: str = "auto" + # Hint for the consumer about the originating chat type (e.g. "dm", + # "group", "supergroup", "forum"). Used to gate native draft streaming, + # which is platform-specific (Telegram drafts are DM-only). + chat_type: str = "" class GatewayStreamConsumer: @@ -85,6 +105,11 @@ class GatewayStreamConsumer: "</THINKING>", "</thinking>", "</thought>", ) + # Class-wide monotonic counter for native-streaming draft ids. Telegram + # animates a draft when the same draft_id is reused across consecutive + # calls in the same chat, so we need a fresh non-zero id per response. + _draft_id_counter: int = 0 + def __init__( self, adapter: Any, @@ -92,6 +117,7 @@ class GatewayStreamConsumer: config: Optional[StreamConsumerConfig] = None, metadata: Optional[dict] = None, on_new_message: Optional[callable] = None, + initial_reply_to_id: Optional[str] = None, ): self.adapter = adapter self.chat_id = chat_id @@ -105,6 +131,7 @@ class GatewayStreamConsumer: # the content, not edit the old bubble above it. # Called with no arguments. Exceptions are swallowed. self._on_new_message = on_new_message + self._initial_reply_to_id = initial_reply_to_id self._queue: queue.Queue = queue.Queue() self._accumulated = "" self._message_id: Optional[str] = None @@ -136,6 +163,20 @@ class GatewayStreamConsumer: self._in_think_block = False self._think_buffer = "" + # Native draft-streaming state. Resolved at the start of run() based + # on cfg.transport, cfg.chat_type, and the adapter's + # supports_draft_streaming() probe. When True, the consumer emits + # animated draft frames via adapter.send_draft instead of progressive + # edits via adapter.edit_message. The final answer still goes + # through the normal first-send path so the user gets a real message + # in their chat history (drafts have no message_id). + self._use_draft_streaming = False + self._draft_id: Optional[int] = None + # Cumulative draft-frame failure count for this consumer. After the + # first failure we permanently disable drafts for the remainder of + # this response and route through edit-based for graceful degradation. + self._draft_failures = 0 + @property def already_sent(self) -> bool: """True if at least one message was sent or edited during the run.""" @@ -174,6 +215,16 @@ class GatewayStreamConsumer: self._last_sent_text = "" self._fallback_final_send = False self._fallback_prefix = "" + # Native draft streaming: bump the draft_id so the next text segment + # animates as a fresh preview below the tool-progress bubbles, not + # over the prior segment's already-finalized draft. This is how + # we avoid the "inter-tool-call text leak" failure mode openclaw + # documented in their issue #32535 — each text block becomes its + # own visible message via the finalize, then a new draft animates + # for the next one. + if self._use_draft_streaming: + type(self)._draft_id_counter += 1 + self._draft_id = type(self)._draft_id_counter def on_delta(self, text: str) -> None: """Thread-safe callback — called from the agent's worker thread. @@ -299,9 +350,32 @@ class GatewayStreamConsumer: async def run(self) -> None: """Async task that drains the queue and edits the platform message.""" - # Platform message length limit — leave room for cursor + formatting + # Platform message length limit — leave room for cursor + formatting. + # Use the adapter's length function (e.g. utf16_len for Telegram) so + # overflow detection matches what the platform actually enforces. + # Gate on isinstance(BasePlatformAdapter) so test MagicMocks (whose + # auto-attributes return mock objects, not callables) fall back to len. + _len_fn: "Callable[[str], int]" = ( + self.adapter.message_len_fn + if isinstance(self.adapter, _BasePlatformAdapter) + else len + ) _raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096) - _safe_limit = max(500, _raw_limit - len(self.cfg.cursor) - 100) + _safe_limit = max(500, _raw_limit - _len_fn(self.cfg.cursor) - 100) + + # Resolve native draft streaming once per run. When enabled the + # consumer routes mid-stream frames through adapter.send_draft and + # leaves _message_id=None so the existing got_done path delivers the + # final answer as a regular sendMessage (drafts have no message_id + # to edit). + self._use_draft_streaming = self._resolve_draft_streaming() + if self._use_draft_streaming: + type(self)._draft_id_counter += 1 + self._draft_id = type(self)._draft_id_counter + logger.debug( + "Stream consumer using native-draft transport (chat=%s draft_id=%s)", + self.chat_id, self._draft_id, + ) try: while True: @@ -343,6 +417,10 @@ class GatewayStreamConsumer: should_edit = should_edit or ( (elapsed >= self._current_edit_interval and self._accumulated) + # buffer_threshold is intentionally codepoint-based: + # it's a debounce heuristic ("send updates roughly + # every N visible characters"), not a platform-limit + # check. _len_fn is reserved for overflow detection. or len(self._accumulated) >= self.cfg.buffer_threshold ) @@ -351,7 +429,7 @@ class GatewayStreamConsumer: # Split overflow: if accumulated text exceeds the platform # limit, split into properly sized chunks. if ( - len(self._accumulated) > _safe_limit + _len_fn(self._accumulated) > _safe_limit and self._message_id is None ): # No existing message to edit (first message or after a @@ -360,15 +438,23 @@ class GatewayStreamConsumer: # proper word/code-fence boundaries and chunk # indicators like "(1/2)". chunks = self.adapter.truncate_message( - self._accumulated, _safe_limit + self._accumulated, _safe_limit, len_fn=_len_fn, ) + chunks_delivered = False + reply_to = self._message_id or self._initial_reply_to_id for chunk in chunks: - await self._send_new_chunk(chunk, self._message_id) + new_id = await self._send_new_chunk(chunk, reply_to) + if new_id is not None and new_id != reply_to: + chunks_delivered = True self._accumulated = "" self._last_sent_text = "" self._last_edit_time = time.monotonic() if got_done: - self._final_response_sent = self._already_sent + # Only claim final delivery if THESE chunks actually + # landed. ``_already_sent`` may be True from prior + # tool-progress edits or fallback-mode promotion (#10748) + # — that doesn't mean the final answer reached the user. + self._final_response_sent = chunks_delivered return if got_segment_break: self._message_id = None @@ -379,11 +465,14 @@ class GatewayStreamConsumer: # Existing message: edit it with the first chunk, then # start a new message for the overflow remainder. while ( - len(self._accumulated) > _safe_limit + _len_fn(self._accumulated) > _safe_limit and self._message_id is not None and self._edit_supported ): - split_at = self._accumulated.rfind("\n", 0, _safe_limit) + _cp_budget = _custom_unit_to_cp( + self._accumulated, _safe_limit, _len_fn, + ) + split_at = self._accumulated.rfind("\n", 0, _cp_budget) if split_at < _safe_limit // 2: split_at = _safe_limit chunk = self._accumulated[:split_at] @@ -574,14 +663,18 @@ class GatewayStreamConsumer: return final_text @staticmethod - def _split_text_chunks(text: str, limit: int) -> list[str]: + def _split_text_chunks( + text: str, limit: int, + len_fn: "Callable[[str], int]" = len, + ) -> list[str]: """Split text into reasonably sized chunks for fallback sends.""" - if len(text) <= limit: + if len_fn(text) <= limit: return [text] chunks: list[str] = [] remaining = text - while len(remaining) > limit: - split_at = remaining.rfind("\n", 0, limit) + while len_fn(remaining) > limit: + _cp_budget = _custom_unit_to_cp(remaining, limit, len_fn) + split_at = remaining.rfind("\n", 0, _cp_budget) if split_at < limit // 2: split_at = limit chunks.append(remaining[:split_at]) @@ -637,9 +730,15 @@ class GatewayStreamConsumer: return raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096) + _len_fn: "Callable[[str], int]" = ( + self.adapter.message_len_fn + if isinstance(self.adapter, _BasePlatformAdapter) + else len + ) safe_limit = max(500, raw_limit - 100) - chunks = self._split_text_chunks(continuation, safe_limit) + chunks = self._split_text_chunks(continuation, safe_limit, len_fn=_len_fn) + stale_message_id = self._message_id # partial message to clean up last_message_id: Optional[str] = None last_successful_chunk = "" sent_any_chunk = False @@ -687,6 +786,22 @@ class GatewayStreamConsumer: # so any stale tool-progress bubble gets closed off. self._notify_new_message() + # Remove the frozen partial message so the user only sees the + # complete fallback response. Best-effort — if the platform doesn't + # implement ``delete_message``, the delete fails (flood control still + # active, bot lacks permission, message too old to delete), the + # partial remains but at least the full answer was delivered. + if stale_message_id and stale_message_id != last_message_id: + delete_fn = getattr(self.adapter, "delete_message", None) + if delete_fn is not None: + try: + await delete_fn(self.chat_id, stale_message_id) + except Exception as e: + logger.debug( + "Fallback partial cleanup failed (%s): %s", + stale_message_id, e, + ) + self._message_id = last_message_id self._already_sent = True self._final_response_sent = True @@ -699,6 +814,89 @@ class GatewayStreamConsumer: err_lower = err.lower() return "flood" in err_lower or "retry after" in err_lower or "rate" in err_lower + def _resolve_draft_streaming(self) -> bool: + """Decide whether this run should use native draft streaming. + + Honors ``cfg.transport``: + * ``"edit"`` → never use drafts (legacy progressive-edit path). + * ``"draft"`` → require draft support; gracefully fall back to edit + when the adapter declines. Logs the downgrade at debug. + * ``"auto"`` → use drafts when the adapter supports them for this + chat type; otherwise edit. + + Adapter eligibility is checked via + :meth:`BasePlatformAdapter.supports_draft_streaming`, which considers + the chat type (e.g. Telegram drafts are DM-only) and platform-version + gates (e.g. python-telegram-bot 22.6+). + """ + transport = (self.cfg.transport or "auto").lower() + if transport == "edit": + return False + # "off" is filtered upstream by the gateway; treat as edit defensively. + if transport == "off": + return False + # Test adapters are MagicMocks that don't subclass BasePlatformAdapter; + # default them to edit so existing test behaviour is preserved. + if not isinstance(self.adapter, _BasePlatformAdapter): + return False + try: + supported = self.adapter.supports_draft_streaming( + chat_type=self.cfg.chat_type or None, + metadata=self.metadata, + ) + except Exception: + logger.debug("supports_draft_streaming probe raised", exc_info=True) + supported = False + if not supported: + if transport == "draft": + logger.debug( + "Draft streaming requested but unsupported (chat=%s, type=%r) — " + "falling back to edit", + self.chat_id, self.cfg.chat_type, + ) + return False + return True + + async def _send_draft_frame(self, text: str) -> bool: + """Emit a single animated draft frame for the current accumulated text. + + Returns True when the frame landed. On any failure, permanently + disables drafts for the remainder of this run so subsequent frames + flow through the edit-based path (which can adapt with flood-control + backoff, etc.). Drafts have no message_id and clear naturally on + the client when the response finalizes via a regular sendMessage. + """ + if self._draft_id is None: + # Defensive: should never happen — _use_draft_streaming gate is + # set in tandem with _draft_id in run(). Disable to be safe. + self._use_draft_streaming = False + return False + try: + result = await self.adapter.send_draft( + chat_id=self.chat_id, + draft_id=self._draft_id, + content=text, + metadata=self.metadata, + ) + except Exception as e: + logger.debug( + "send_draft raised, disabling draft transport for this run: %s", e, + ) + self._draft_failures += 1 + self._use_draft_streaming = False + return False + if not getattr(result, "success", False): + logger.debug( + "send_draft returned success=False, disabling draft transport: %s", + getattr(result, "error", "unknown"), + ) + self._draft_failures += 1 + self._use_draft_streaming = False + return False + # Frame delivered. Track text for parity with edit-based no-op skip. + self._last_sent_text = text + return True + async def _flush_segment_tail_on_edit_failure(self) -> None: """Deliver un-sent tail content before a segment-break reset. @@ -893,6 +1091,35 @@ class GatewayStreamConsumer: and self.cfg.cursor in text and len(_visible_stripped) < _MIN_NEW_MSG_CHARS): return True # too short for a standalone message — accumulate more + + # Native draft streaming: route mid-stream frames through send_draft. + # The final answer is delivered via the regular sendMessage path + # below — drafts have no message_id so we can't finalize them + # in-place; the regular sendMessage clears the draft naturally on + # the client and gives the user a real message in their history. + # Skip when: + # * finalize=True (this is the final answer; needs to be a real message) + # * an edit path is already established (message_id is set, e.g. after + # a tool-boundary segment break where the prior text was finalized + # as a real sendMessage and the next text segment continues editing + # that one — staying on edit-based for that segment is correct). + if ( + self._use_draft_streaming + and not finalize + and self._message_id is None + ): + # No-op skip: identical to the last frame we sent. + if text == self._last_sent_text: + return True + ok = await self._send_draft_frame(text) + if ok: + # Drafts mark "we put something on screen" but DO NOT set + # _already_sent — that flag gates the gateway's fallback + # final-send path and we still need that to fire so the + # user gets a real message (drafts have no message_id). + return True + # Failure already disabled drafts for this run; fall through to + # the regular edit/send path below. try: if self._message_id is not None: if self._edit_supported: @@ -931,7 +1158,29 @@ class GatewayStreamConsumer: ) if result.success: self._already_sent = True - self._last_sent_text = text + # Adapter may have split-and-delivered an oversized + # edit across the original message + N continuations. + # When that happens, ``message_id`` is the LAST visible + # continuation and ``_last_sent_text`` no longer reflects + # the on-screen content (the new message only holds the + # final chunk's text), so subsequent edits must target + # the new id and skip-if-same comparisons must reset. + # Fire on_new_message so tool-progress bubbles linearize + # below the new continuation, not the original. + # ``getattr`` with default keeps backwards compat with + # SimpleNamespace mocks in tests that pre-date the field. + _continuation_ids = getattr(result, "continuation_message_ids", ()) or () + if ( + _continuation_ids + and result.message_id + and result.message_id != self._message_id + ): + self._message_id = str(result.message_id) + self._message_created_ts = time.monotonic() + self._last_sent_text = "" + self._notify_new_message() + else: + self._last_sent_text = text # Successful edit — reset flood strike counter self._flood_strikes = 0 return True @@ -979,10 +1228,12 @@ class GatewayStreamConsumer: # The final response will be sent by the fallback path. return False else: - # First message — send new + # First message — send new, threaded to the original user message + # so it lands in the correct topic/thread. result = await self.adapter.send( chat_id=self.chat_id, content=text, + reply_to=self._initial_reply_to_id, metadata=self.metadata, ) if result.success: diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 42e2f720874..7db897cb55b 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1450,7 +1450,7 @@ def resolve_provider( # whose availability isn't implied by LM_API_KEY presence (it may be # offline, and the no-auth setup uses a placeholder value), so it # also requires explicit selection. - if pid in ("copilot", "lmstudio"): + if pid in {"copilot", "lmstudio"}: continue for env_var in pconfig.api_key_env_vars: if has_usable_secret(os.getenv(env_var, "")): @@ -2541,7 +2541,7 @@ def refresh_codex_oauth_pure( # A 401/403 from the token endpoint always means the refresh token # is invalid/expired — force relogin even if the body error code # wasn't one of the known strings above. - if response.status_code in (401, 403) and not relogin_required: + if response.status_code in {401, 403} and not relogin_required: relogin_required = True raise AuthError( message, @@ -2947,7 +2947,7 @@ def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool: "expires_at", ): value = shared.get(key) - if value not in (None, ""): + if value not in {None, ""}: state[key] = value return True @@ -3986,7 +3986,7 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if provider_id in ("kimi-coding", "kimi-coding-cn"): + if provider_id in {"kimi-coding", "kimi-coding-cn"}: base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif env_url: base_url = env_url @@ -4090,7 +4090,7 @@ def resolve_api_key_provider_credentials(provider_id: str) -> Dict[str, Any]: if pconfig.base_url_env_var: env_url = os.getenv(pconfig.base_url_env_var, "").strip() - if provider_id in ("kimi-coding", "kimi-coding-cn"): + if provider_id in {"kimi-coding", "kimi-coding-cn"}: base_url = _resolve_kimi_base_url(api_key, pconfig.inference_base_url, env_url) elif provider_id == "zai": base_url = _resolve_zai_base_url(api_key, pconfig.inference_base_url, env_url) @@ -4510,7 +4510,7 @@ def _login_openai_codex( reuse = input("Use existing credentials? [Y/n]: ").strip().lower() except (EOFError, KeyboardInterrupt): reuse = "y" - if reuse in ("", "y", "yes"): + if reuse in {"", "y", "yes"}: config_path = _update_config_for_provider("openai-codex", existing.get("base_url", DEFAULT_CODEX_BASE_URL)) print() print("Login successful!") @@ -4531,7 +4531,7 @@ def _login_openai_codex( do_import = input("Import these credentials? (a separate login is recommended) [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): do_import = "n" - if do_import in ("y", "yes"): + if do_import in {"y", "yes"}: _save_codex_tokens(cli_tokens) base_url = os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") or DEFAULT_CODEX_BASE_URL config_path = _update_config_for_provider("openai-codex", base_url) @@ -4623,7 +4623,7 @@ def _codex_device_code_login() -> Dict[str, Any]: if poll_resp.status_code == 200: code_resp = poll_resp.json() break - elif poll_resp.status_code in (403, 404): + elif poll_resp.status_code in {403, 404}: continue # User hasn't completed login yet else: raise AuthError( @@ -5188,7 +5188,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: do_import = input("Import these credentials? [Y/n]: ").strip().lower() except (EOFError, KeyboardInterrupt): do_import = "y" - if do_import in ("", "y", "yes"): + if do_import in {"", "y", "yes"}: print("Rehydrating Nous session from shared credentials...") auth_state = _try_import_shared_nous_state( timeout_seconds=timeout_seconds, diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 4312f688a3f..b701a54725a 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -266,7 +266,7 @@ def auth_add_command(args) -> None: do_import = input("Import these credentials? [Y/n]: ").strip().lower() except (EOFError, KeyboardInterrupt): do_import = "y" - if do_import in ("", "y", "yes"): + if do_import in {"", "y", "yes"}: print("Rehydrating Nous session from shared credentials...") rehydrated = auth_mod._try_import_shared_nous_state( timeout_seconds=getattr(args, "timeout", None) or 15.0, diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 4237c678b19..a137509d7b1 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -298,7 +298,7 @@ def _detect_prefix(zf: zipfile.ZipFile) -> str: if len(first_parts) == 1: prefix = first_parts.pop() # Only strip if it looks like a hermes dir name - if prefix in (".hermes", "hermes"): + if prefix in {".hermes", "hermes"}: return prefix + "/" return "" @@ -349,7 +349,7 @@ def run_import(args) -> None: except (EOFError, KeyboardInterrupt): print("\nAborted.") sys.exit(1) - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: print("Aborted.") return @@ -802,8 +802,7 @@ def _prune_pre_update_backups(backup_dir: Path, keep: int) -> int: Operators who genuinely don't want a backup should set ``updates.pre_update_backup: false`` in config — that gates creation. """ - if keep < 1: - keep = 1 + keep = max(keep, 1) if not backup_dir.exists(): return 0 @@ -875,8 +874,7 @@ def _prune_pre_migration_backups(backup_dir: Path, keep: int) -> int: Only touches files matching ``pre-migration-*.zip`` so other backups in the same directory are never touched. """ - if keep < 0: - keep = 0 + keep = max(keep, 0) if not backup_dir.exists(): return 0 diff --git a/hermes_cli/checkpoints.py b/hermes_cli/checkpoints.py index cac5cd0979f..2c0d3dd107b 100644 --- a/hermes_cli/checkpoints.py +++ b/hermes_cli/checkpoints.py @@ -139,7 +139,7 @@ def _confirm(prompt: str) -> bool: except (EOFError, KeyboardInterrupt): print() return False - return resp in ("y", "yes") + return resp in {"y", "yes"} def cmd_clear(args: argparse.Namespace) -> int: diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 5455b4355d0..909b046f1f7 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -298,7 +298,7 @@ def claw_command(args): if action == "migrate": _cmd_migrate(args) - elif action in ("cleanup", "clean"): + elif action in {"cleanup", "clean"}: _cmd_cleanup(args) else: print("Usage: hermes claw <command> [options]") @@ -670,17 +670,16 @@ def _cmd_cleanup(args): elif not auto_yes and not sys.stdin.isatty(): print_info(f"Non-interactive session — would archive: {source_dir}") print_info("To execute, re-run with: hermes claw cleanup --yes") + elif auto_yes or prompt_yes_no(f"Archive {source_dir}?", default=True): + try: + archive_path = _archive_directory(source_dir) + print_success(f"Archived: {source_dir} → {archive_path}") + total_archived += 1 + except OSError as e: + print_error(f"Could not archive: {e}") + print_info(f"Try manually: mv {source_dir} {source_dir}.pre-migration") else: - if auto_yes or prompt_yes_no(f"Archive {source_dir}?", default=True): - try: - archive_path = _archive_directory(source_dir) - print_success(f"Archived: {source_dir} → {archive_path}") - total_archived += 1 - except OSError as e: - print_error(f"Could not archive: {e}") - print_info(f"Try manually: mv {source_dir} {source_dir}.pre-migration") - else: - print_info("Skipped.") + print_info("Skipped.") # Summary print() diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py index 8e50004c2d6..e45ba33f8eb 100644 --- a/hermes_cli/codex_models.py +++ b/hermes_cli/codex_models.py @@ -101,7 +101,7 @@ def _fetch_models_from_api(access_token: str) -> List[str]: # Some valid Codex CLI models (for example gpt-5.3-codex-spark) are # marked false here but are still accepted by the Codex route. visibility = item.get("visibility", "") - if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"): + if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}: continue priority = item.get("priority") rank = int(priority) if isinstance(priority, (int, float)) else 10_000 @@ -152,7 +152,7 @@ def _read_cache_models(codex_home: Path) -> List[str]: # public OpenAI API, while Hermes openai-codex talks to the same # OAuth-backed Codex backend as Codex CLI. visibility = item.get("visibility") - if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"): + if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}: continue priority = item.get("priority") rank = int(priority) if isinstance(priority, (int, float)) else 10_000 diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index de41bcfae7e..1478b8b2e44 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -79,6 +79,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("undo", "Remove the last user/assistant exchange", "Session"), CommandDef("title", "Set a title for the current session", "Session", args_hint="[name]"), + CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session", + args_hint="<platform>", cli_only=True), CommandDef("branch", "Branch the current session (explore a different path)", "Session", aliases=("fork",), args_hint="[name]"), CommandDef("compress", "Manually compress conversation context", "Session", @@ -103,6 +105,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("goal", "Set a standing goal Hermes works on across turns until achieved", "Session", args_hint="[text | pause | resume | clear | status]"), CommandDef("status", "Show session info", "Session"), + CommandDef("whoami", "Show your slash command access (admin / user)", "Info"), CommandDef("profile", "Show active profile name and home directory", "Info"), CommandDef("sethome", "Set this chat as the home channel", "Session", gateway_only=True, aliases=("set-home",)), @@ -808,7 +811,7 @@ def discord_skill_commands_by_category( # names are marked with a sentinel so the warning distinguishes # "skill collided with a reserved command" from "two skills collided # on the 32-char clamp" — the latter is the rename-worthy case. - _names_used: dict[str, str] = {n: "<reserved>" for n in reserved_names} + _names_used: dict[str, str] = dict.fromkeys(reserved_names, "<reserved>") hidden = 0 try: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 85ed6544073..e9cd807a495 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -28,6 +28,48 @@ from typing import Dict, Any, Optional, List, Tuple logger = logging.getLogger(__name__) +# Track which (config_path, mtime_ns, size) tuples we've already warned about +# so concurrent CLI/gateway loads of a broken config.yaml don't spam stderr +# every time. Cleared automatically when the file changes (different mtime). +_CONFIG_PARSE_WARNED: set = set() + + +def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None: + """Surface a config.yaml parse failure to user, log, and stderr. + + A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()`` + to silently fall back to ``DEFAULT_CONFIG``, which means every user + override (auxiliary providers, fallback chain, model overrides, etc.) + is dropped. Before this helper that was a one-line ``print(...)`` that + scrolled off-screen on the first invocation and was never seen again. + + Now: warn once per (path, mtime_ns, size) on stderr **and** in + ``agent.log`` / ``errors.log`` at WARNING level so ``hermes logs`` + surfaces it. Re-warns automatically if the file changes (different + mtime/size), so users editing the config see the next failure. + """ + try: + st = config_path.stat() + key = (str(config_path), st.st_mtime_ns, st.st_size) + except OSError: + key = (str(config_path), 0, 0) + if key in _CONFIG_PARSE_WARNED: + return + _CONFIG_PARSE_WARNED.add(key) + + msg = ( + f"Failed to parse {config_path}: {exc}. " + f"Falling back to default config — every user override " + f"(auxiliary providers, fallback chain, model settings) is being IGNORED. " + f"Fix the YAML and restart." + ) + logger.warning(msg) + try: + sys.stderr.write(f"⚠️ hermes config: {msg}\n") + sys.stderr.flush() + except Exception: + pass + _IS_WINDOWS = platform.system() == "Windows" _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {} @@ -537,6 +579,7 @@ DEFAULT_CONFIG = { # Explicit opt-in: mount the host cwd into /workspace for Docker sessions. # Default off because passing host directories into a sandbox weakens isolation. "docker_mount_cwd_to_workspace": False, + "docker_extra_args": [], # Extra flags passed verbatim to docker run # Explicit opt-in: run the Docker container as the host user's uid:gid # (via `--user`). When enabled, files written into bind-mounted dirs # (docker_volumes, the persistent workspace, or the auto-mounted cwd) @@ -680,8 +723,15 @@ DEFAULT_CONFIG = { # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API). # cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored. + # long_lived_prefix: when true (default), Claude on Anthropic / OpenRouter / Nous + # Portal uses a split layout: tools[-1] + stable system prefix at long_lived_ttl + # (cross-session cache), last 2 messages at cache_ttl (within-session rolling). + # Set false to keep the legacy "system + last 3 messages" single-tier layout. + # long_lived_ttl: TTL for the cross-session prefix tier ("5m" or "1h"; default "1h"). "prompt_caching": { "cache_ttl": "5m", + "long_lived_prefix": True, + "long_lived_ttl": "1h", }, # OpenRouter-specific settings. @@ -859,6 +909,7 @@ DEFAULT_CONFIG = { "bell_on_complete": False, "show_reasoning": False, "streaming": False, + "timestamps": False, # Show [HH:MM] on user and assistant labels "final_response_markdown": "strip", # render | strip | raw # Preserve recent classic CLI output across Ctrl+L, /redraw, and # terminal resize full-screen clears. Disable if a terminal emulator @@ -3165,7 +3216,7 @@ def warn_deprecated_cwd_env_vars(config: Optional[Dict[str, Any]] = None) -> Non terminal_cfg = config.get("terminal", {}) config_cwd = terminal_cfg.get("cwd", ".") if isinstance(terminal_cfg, dict) else "." # Only warn if config.yaml doesn't have an explicit path - config_has_explicit_cwd = config_cwd not in (".", "auto", "cwd", "") + config_has_explicit_cwd = config_cwd not in {".", "auto", "cwd", ""} lines: list[str] = [] if messaging_cwd: @@ -3225,10 +3276,10 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A if "tool_progress" not in display: old_enabled = get_env_value("HERMES_TOOL_PROGRESS") old_mode = get_env_value("HERMES_TOOL_PROGRESS_MODE") - if old_enabled and old_enabled.lower() in ("false", "0", "no"): + if old_enabled and old_enabled.lower() in {"false", "0", "no"}: display["tool_progress"] = "off" results["config_added"].append("display.tool_progress=off (from HERMES_TOOL_PROGRESS=false)") - elif old_mode and old_mode.lower() in ("new", "all"): + elif old_mode and old_mode.lower() in {"new", "all"}: display["tool_progress"] = old_mode.lower() results["config_added"].append(f"display.tool_progress={old_mode.lower()} (from HERMES_TOOL_PROGRESS_MODE)") else: @@ -3307,7 +3358,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A new_entry = {"api": old_url} if old_name: new_entry["name"] = old_name - if old_key and old_key not in ("no-key", "no-key-required", ""): + if old_key and old_key not in {"no-key", "no-key-required", ""}: new_entry["api_key"] = old_key # Carry over model and api_mode if present @@ -3365,7 +3416,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A stt.pop("model", None) # Place it in the appropriate provider section only if the # user didn't already set a model there - if provider in ("local", "local_command"): + if provider in {"local", "local_command"}: # Don't migrate an OpenAI model name into the local section _local_models = { "tiny.en", "tiny", "base.en", "base", "small.en", "small", @@ -3449,7 +3500,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A if not aux_comp.get("model"): aux_comp["model"] = str(s_model).strip() migrated_keys.append(f"model={s_model}") - if s_provider and str(s_provider).strip() not in ("", "auto"): + if s_provider and str(s_provider).strip() not in {"", "auto"}: aux = config.setdefault("auxiliary", {}) aux_comp = aux.setdefault("compression", {}) if not aux_comp.get("provider") or aux_comp.get("provider") == "auto": @@ -3680,7 +3731,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A except (EOFError, KeyboardInterrupt): answer = "n" - if answer in ("y", "yes"): + if answer in {"y", "yes"}: print() for name, info in new_and_unset: if info.get("url"): @@ -3741,7 +3792,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A except (EOFError, KeyboardInterrupt): answer = "n" - if answer in ("y", "yes"): + if answer in {"y", "yes"}: print() config = load_config() try: @@ -4011,7 +4062,8 @@ def read_raw_config() -> Dict[str, Any]: try: with open(config_path, encoding="utf-8") as f: data = yaml.safe_load(f) or {} - except Exception: + except Exception as e: + _warn_config_parse_failure(config_path, e) return {} if not isinstance(data, dict): @@ -4061,7 +4113,7 @@ def load_config() -> Dict[str, Any]: config = _deep_merge(config, user_config) except Exception as e: - print(f"Warning: Failed to load config: {e}") + _warn_config_parse_failure(config_path, e) normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) expanded = _expand_env_vars(normalized) @@ -4822,9 +4874,9 @@ def set_config_value(key: str, value: str): # inline navigation here silently overwrote lists with dicts. # Convert value to appropriate type - if value.lower() in ('true', 'yes', 'on'): + if value.lower() in {'true', 'yes', 'on'}: value = True - elif value.lower() in ('false', 'no', 'off'): + elif value.lower() in {'false', 'no', 'off'}: value = False elif value.isdigit(): value = int(value) @@ -5029,7 +5081,7 @@ def _inject_profile_env_vars() -> None: try: from providers import list_providers for _pp in list_providers(): - if _pp.auth_type not in ("api_key",): + if _pp.auth_type not in {"api_key",}: continue for _var in _pp.env_vars: if _var in OPTIONAL_ENV_VARS: diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index 7475f80a2b1..e6f63a1557c 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -128,7 +128,7 @@ def _try_gh_cli_token() -> Optional[str]: # Build a clean env so gh doesn't short-circuit on GITHUB_TOKEN / GH_TOKEN clean_env = {k: v for k, v in os.environ.items() - if k not in ("GITHUB_TOKEN", "GH_TOKEN")} + if k not in {"GITHUB_TOKEN", "GH_TOKEN"}} for gh_path in _gh_cli_candidates(): cmd = [gh_path, "auth", "token"] diff --git a/hermes_cli/curator.py b/hermes_cli/curator.py index 38675b93ab8..190a052b48e 100644 --- a/hermes_cli/curator.py +++ b/hermes_cli/curator.py @@ -347,7 +347,7 @@ def _cmd_prune(args) -> int: except (EOFError, KeyboardInterrupt): print("\ncurator: aborted") return 1 - if reply not in ("y", "yes"): + if reply not in {"y", "yes"}: print("curator: aborted") return 1 @@ -449,7 +449,7 @@ def _cmd_rollback(args) -> int: except (EOFError, KeyboardInterrupt): print("\ncancelled") return 1 - if ans not in ("y", "yes"): + if ans not in {"y", "yes"}: print("cancelled") return 1 diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index 01d759d3872..57607cc31dd 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -139,16 +139,16 @@ def curses_checklist( stdscr.refresh() key = stdscr.getch() - if key in (curses.KEY_UP, ord("k")): + if key in {curses.KEY_UP, ord("k")}: cursor = (cursor - 1) % len(items) - elif key in (curses.KEY_DOWN, ord("j")): + elif key in {curses.KEY_DOWN, ord("j")}: cursor = (cursor + 1) % len(items) elif key == ord(" "): chosen.symmetric_difference_update({cursor}) - elif key in (curses.KEY_ENTER, 10, 13): + elif key in {curses.KEY_ENTER, 10, 13}: result_holder[0] = set(chosen) return - elif key in (27, ord("q")): + elif key in {27, ord("q")}: result_holder[0] = cancel_returns return @@ -265,14 +265,14 @@ def curses_radiolist( stdscr.refresh() key = stdscr.getch() - if key in (curses.KEY_UP, ord("k")): + if key in {curses.KEY_UP, ord("k")}: cursor = (cursor - 1) % len(items) - elif key in (curses.KEY_DOWN, ord("j")): + elif key in {curses.KEY_DOWN, ord("j")}: cursor = (cursor + 1) % len(items) - elif key in (ord(" "), curses.KEY_ENTER, 10, 13): + elif key in {ord(" "), curses.KEY_ENTER, 10, 13}: result_holder[0] = cursor return - elif key in (27, ord("q")): + elif key in {27, ord("q")}: result_holder[0] = cancel_returns return @@ -388,14 +388,14 @@ def curses_single_select( stdscr.refresh() key = stdscr.getch() - if key in (curses.KEY_UP, ord("k")): + if key in {curses.KEY_UP, ord("k")}: cursor = (cursor - 1) % len(all_items) - elif key in (curses.KEY_DOWN, ord("j")): + elif key in {curses.KEY_DOWN, ord("j")}: cursor = (cursor + 1) % len(all_items) - elif key in (curses.KEY_ENTER, 10, 13): + elif key in {curses.KEY_ENTER, 10, 13}: result_holder[0] = cursor return - elif key in (27, ord("q")): + elif key in {27, ord("q")}: result_holder[0] = None return diff --git a/hermes_cli/dingtalk_auth.py b/hermes_cli/dingtalk_auth.py index 798ce46fcb7..50d56e845ea 100644 --- a/hermes_cli/dingtalk_auth.py +++ b/hermes_cli/dingtalk_auth.py @@ -93,7 +93,7 @@ def poll_registration(device_code: str) -> dict: """ data = _api_post("/app/registration/poll", {"device_code": device_code}) status_raw = str(data.get("status", "")).strip().upper() - if status_raw not in ("WAITING", "SUCCESS", "FAIL", "EXPIRED"): + if status_raw not in {"WAITING", "SUCCESS", "FAIL", "EXPIRED"}: status_raw = "UNKNOWN" return { "status": status_raw, diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index aaa490a3372..13f58a8509f 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -473,7 +473,7 @@ def run_doctor(args): if ( provider and _resolve_auth_provider is not None - and provider not in ("auto", "custom") + and provider not in {"auto", "custom"} ): try: runtime_provider = _resolve_auth_provider(provider) @@ -485,7 +485,7 @@ def run_doctor(args): if ( provider and _resolve_provider_full is not None - and provider not in ("auto", "custom") + and provider not in {"auto", "custom"} ): provider_def = _resolve_provider_full(provider, user_providers, custom_providers) catalog_provider = provider_def.id if provider_def is not None else None @@ -542,7 +542,7 @@ def run_doctor(args): # own env-var checks elsewhere in doctor, and get_auth_status() # returns a bare {logged_in: False} for anything it doesn't # explicitly dispatch, which would produce false positives. - if runtime_provider and runtime_provider not in ("auto", "custom", "openrouter"): + if runtime_provider and runtime_provider not in {"auto", "custom", "openrouter"}: try: from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status pconfig = PROVIDER_REGISTRY.get(runtime_provider) @@ -729,13 +729,12 @@ def run_doctor(args): hermes_home = HERMES_HOME if hermes_home.exists(): check_ok(f"{_DHH} directory exists") + elif should_fix: + hermes_home.mkdir(parents=True, exist_ok=True) + check_ok(f"Created {_DHH} directory") + fixed_count += 1 else: - if should_fix: - hermes_home.mkdir(parents=True, exist_ok=True) - check_ok(f"Created {_DHH} directory") - fixed_count += 1 - else: - check_warn(f"{_DHH} not found", "(will be created on first use)") + check_warn(f"{_DHH} not found", "(will be created on first use)") # Check expected subdirectories expected_subdirs = ["cron", "sessions", "logs", "skills", "memories"] @@ -743,13 +742,12 @@ def run_doctor(args): subdir_path = hermes_home / subdir_name if subdir_path.exists(): check_ok(f"{_DHH}/{subdir_name}/ exists") + elif should_fix: + subdir_path.mkdir(parents=True, exist_ok=True) + check_ok(f"Created {_DHH}/{subdir_name}/") + fixed_count += 1 else: - if should_fix: - subdir_path.mkdir(parents=True, exist_ok=True) - check_ok(f"Created {_DHH}/{subdir_name}/") - fixed_count += 1 - else: - check_warn(f"{_DHH}/{subdir_name}/ not found", "(will be created on first use)") + check_warn(f"{_DHH}/{subdir_name}/ not found", "(will be created on first use)") # Check for SOUL.md persona file soul_path = hermes_home / "SOUL.md" @@ -955,14 +953,12 @@ def run_doctor(args): else: check_fail("docker not found", "(required for TERMINAL_ENV=docker)") issues.append("Install Docker or change TERMINAL_ENV") + elif _safe_which("docker"): + check_ok("docker", "(optional)") + elif _is_termux(): + check_info("Docker backend is not available inside Termux (expected on Android)") else: - if _safe_which("docker"): - check_ok("docker", "(optional)") - else: - if _is_termux(): - check_info("Docker backend is not available inside Termux (expected on Android)") - else: - check_warn("docker not found", "(optional)") + check_warn("docker not found", "(optional)") # SSH (if using ssh backend) if terminal_env == "ssh": @@ -1014,7 +1010,7 @@ def run_doctor(args): issues.append(f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}") disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip() - if disk in ("", "0", "51200"): + if disk in {"", "0", "51200"}: check_ok("Vercel disk setting", "(uses platform default)") else: check_fail("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)") @@ -1040,7 +1036,7 @@ def run_doctor(args): for line in auth_status.detail_lines: check_info(f"Vercel auth {line}") - persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("1", "true", "yes", "on") + persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"} if persistent: check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation") else: @@ -1058,15 +1054,14 @@ def run_doctor(args): elif shutil.which("agent-browser"): check_ok("agent-browser", "(browser automation)") agent_browser_ok = True + elif _is_termux(): + check_info("agent-browser is not installed (expected in the tested Termux path)") + check_info("Install it manually later with: npm install -g agent-browser && agent-browser install") + check_info("Termux browser setup:") + for step in _termux_browser_setup_steps(node_installed=True): + check_info(step) else: - if _is_termux(): - check_info("agent-browser is not installed (expected in the tested Termux path)") - check_info("Install it manually later with: npm install -g agent-browser && agent-browser install") - check_info("Termux browser setup:") - for step in _termux_browser_setup_steps(node_installed=True): - check_info(step) - else: - check_warn("agent-browser not installed", "(run: npm install)") + check_warn("agent-browser not installed", "(run: npm install)") # Chromium presence — the browser tools silently fail to register when # agent-browser is found but no Playwright-managed Chromium is on disk @@ -1117,15 +1112,14 @@ def run_doctor(args): f"Install with: cd {PROJECT_ROOT} && " "npx playwright install --with-deps chromium" ) + elif _is_termux(): + check_info("Node.js not found (browser tools are optional in the tested Termux path)") + check_info("Install Node.js on Termux with: pkg install nodejs") + check_info("Termux browser setup:") + for step in _termux_browser_setup_steps(node_installed=False): + check_info(step) else: - if _is_termux(): - check_info("Node.js not found (browser tools are optional in the tested Termux path)") - check_info("Install Node.js on Termux with: pkg install nodejs") - check_info("Termux browser setup:") - for step in _termux_browser_setup_steps(node_installed=False): - check_info(step) - else: - check_warn("Node.js not found", "(optional, needed for browser tools)") + check_warn("Node.js not found", "(optional, needed for browser tools)") # npm audit for all Node.js packages _npm_bin = _safe_which("npm") diff --git a/hermes_cli/fallback_cmd.py b/hermes_cli/fallback_cmd.py index 02c0a01c39d..9f2e6b97d46 100644 --- a/hermes_cli/fallback_cmd.py +++ b/hermes_cli/fallback_cmd.py @@ -307,7 +307,7 @@ def cmd_fallback_clear(args) -> None: # noqa: ARG001 print() print(" Cancelled.") return - if resp not in ("y", "yes"): + if resp not in {"y", "yes"}: print(" Cancelled — no change.") return @@ -347,11 +347,11 @@ def _numbered_pick(question: str, choices: List[str]) -> Optional[int]: def cmd_fallback(args) -> None: """Top-level dispatcher for ``hermes fallback [subcommand]``.""" sub = getattr(args, "fallback_command", None) - if sub in (None, "", "list", "ls"): + if sub in {None, "", "list", "ls"}: cmd_fallback_list(args) elif sub == "add": cmd_fallback_add(args) - elif sub in ("remove", "rm"): + elif sub in {"remove", "rm"}: cmd_fallback_remove(args) elif sub == "clear": cmd_fallback_clear(args) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 46907592d17..c3e1344556e 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1194,7 +1194,7 @@ def _systemd_operational(system: bool = False) -> bool: ) # "running", "degraded", "starting" all mean systemd is PID 1 status = result.stdout.strip().lower() - return status in ("running", "degraded", "starting", "initializing") + return status in {"running", "degraded", "starting", "initializing"} except (RuntimeError, subprocess.TimeoutExpired, OSError): return False @@ -2915,7 +2915,7 @@ def launchd_start(): try: subprocess.run(["launchctl", "kickstart", f"{_launchd_domain()}/{label}"], check=True, timeout=30) except subprocess.CalledProcessError as e: - if e.returncode not in (3, 113): + if e.returncode not in {3, 113}: raise print("↻ launchd job was unloaded; reloading service definition") subprocess.run(["launchctl", "bootstrap", _launchd_domain(), str(plist_path)], check=True, timeout=30) @@ -2939,7 +2939,7 @@ def launchd_stop(): try: subprocess.run(["launchctl", "bootout", target], check=True, timeout=90) except subprocess.CalledProcessError as e: - if e.returncode in (3, 113): + if e.returncode in {3, 113}: pass # Already unloaded — nothing to stop. else: raise @@ -3011,7 +3011,7 @@ def launchd_restart(): subprocess.run(["launchctl", "kickstart", "-k", target], check=True, timeout=90) print("✓ Service restarted") except subprocess.CalledProcessError as e: - if e.returncode not in (3, 113): + if e.returncode not in {3, 113}: raise # Job not loaded — bootstrap and start fresh print("↻ launchd job was unloaded; reloading") @@ -3749,7 +3749,7 @@ def _platform_status(platform: dict) -> str: password = get_env_value("MATRIX_PASSWORD") if (val or password) and homeserver: e2ee = get_env_value("MATRIX_ENCRYPTION") - suffix = " + E2EE" if e2ee and e2ee.lower() in ("true", "1", "yes") else "" + suffix = " + E2EE" if e2ee and e2ee.lower() in {"true", "1", "yes"} else "" return f"configured{suffix}" if val or password or homeserver: return "partially configured" @@ -4947,15 +4947,14 @@ def gateway_setup(): print_info(" Run in foreground: hermes gateway run") print_info(" For persistence: tmux new -s hermes 'hermes gateway run'") print_info(" To enable systemd: add systemd=true to /etc/wsl.conf, then 'wsl --shutdown'") + elif is_termux(): + from hermes_constants import display_hermes_home as _dhh + print_info(" Termux does not use systemd/launchd services.") + print_info(" Run in foreground: hermes gateway run") + print_info(f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &") else: - if is_termux(): - from hermes_constants import display_hermes_home as _dhh - print_info(" Termux does not use systemd/launchd services.") - print_info(" Run in foreground: hermes gateway run") - print_info(f" Or start it manually in the background (best effort): nohup hermes gateway run >{_dhh()}/logs/gateway.log 2>&1 &") - else: - print_info(" Service install not supported on this platform.") - print_info(" Run in foreground: hermes gateway run") + print_info(" Service install not supported on this platform.") + print_info(" Run in foreground: hermes gateway run") else: print() print_info("No platforms configured. Run 'hermes gateway setup' when ready.") diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index 894cdddb01b..9e8742e08ae 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -270,7 +270,7 @@ def _parse_judge_response(raw: str) -> Tuple[bool, str, bool]: done_val = data.get("done") if isinstance(done_val, str): - done = done_val.strip().lower() in ("true", "yes", "1", "done") + done = done_val.strip().lower() in {"true", "yes", "1", "done"} else: done = bool(done_val) reason = str(data.get("reason") or "").strip() @@ -389,11 +389,11 @@ class GoalManager: return self._state is not None and self._state.status == "active" def has_goal(self) -> bool: - return self._state is not None and self._state.status in ("active", "paused") + return self._state is not None and self._state.status in {"active", "paused"} def status_line(self) -> str: s = self._state - if s is None or s.status in ("cleared",): + if s is None or s.status in {"cleared",}: return "No active goal. Set one with /goal <text>." turns = f"{s.turns_used}/{s.max_turns} turns" if s.status == "active": diff --git a/hermes_cli/hooks.py b/hermes_cli/hooks.py index 45b3fc63745..9bbec9997fe 100644 --- a/hermes_cli/hooks.py +++ b/hermes_cli/hooks.py @@ -32,11 +32,11 @@ def hooks_command(args) -> None: print("Run 'hermes hooks --help' for details.") return - if sub in ("list", "ls"): + if sub in {"list", "ls"}: _cmd_list(args) elif sub == "test": _cmd_test(args) - elif sub in ("revoke", "remove", "rm"): + elif sub in {"revoke", "remove", "rm"}: _cmd_revoke(args) elif sub == "doctor": _cmd_doctor(args) @@ -220,7 +220,7 @@ def _cmd_test(args) -> None: if getattr(args, "for_tool", None): specs = [ s for s in specs - if s.event not in ("pre_tool_call", "post_tool_call") + if s.event not in {"pre_tool_call", "post_tool_call"} or s.matches_tool(args.for_tool) ] diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 00a61b41d4d..76f95db4fac 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -82,7 +82,7 @@ def _parse_workspace_flag(value: str) -> tuple[str, Optional[str]]: if not value: return ("scratch", None) v = value.strip() - if v in ("scratch", "worktree"): + if v in {"scratch", "worktree"}: return (v, None) if v.startswith("dir:"): path = v[len("dir:"):].strip() @@ -510,6 +510,10 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu p_nsub.add_argument("--chat-id", required=True) p_nsub.add_argument("--thread-id", default=None) p_nsub.add_argument("--user-id", default=None) + p_nsub.add_argument( + "--notifier-profile", default=None, + help="Profile gateway that owns/delivers this subscription (default: active profile)", + ) p_nlist = sub.add_parser( "notify-list", @@ -648,6 +652,16 @@ def kanban_command(args: argparse.Namespace) -> int: # keeps the patch small and inherits the exact same resolution the # dispatcher uses for workers — consistency is a feature here. board_override = getattr(args, "board", None) + prev_board_env = os.environ.get("HERMES_KANBAN_BOARD") + restore_board_env = False + + def _restore_board_env() -> None: + if not restore_board_env: + return + if prev_board_env is None: + os.environ.pop("HERMES_KANBAN_BOARD", None) + else: + os.environ["HERMES_KANBAN_BOARD"] = prev_board_env if board_override: try: normed = kb._normalize_board_slug(board_override) @@ -667,12 +681,16 @@ def kanban_command(args: argparse.Namespace) -> int: ) return 1 os.environ["HERMES_KANBAN_BOARD"] = normed + restore_board_env = True # Boards management doesn't touch the DB at all — dispatch early so # fresh installs that haven't initialized any DB can still use # `hermes kanban boards create …`. if action == "boards": - return _dispatch_boards(args) + try: + return _dispatch_boards(args) + finally: + _restore_board_env() # Auto-initialize the DB before dispatching any subcommand. init_db # is idempotent, so running it every invocation is cheap (one @@ -685,6 +703,7 @@ def kanban_command(args: argparse.Namespace) -> int: kb.init_db() except Exception as exc: print(f"kanban: could not initialize database: {exc}", file=sys.stderr) + _restore_board_env() return 1 handlers = { @@ -726,12 +745,16 @@ def kanban_command(args: argparse.Namespace) -> int: handler = handlers.get(action) if not handler: print(f"kanban: unknown action {action!r}", file=sys.stderr) + _restore_board_env() return 2 try: return int(handler(args) or 0) except (ValueError, RuntimeError) as exc: print(f"kanban: {exc}", file=sys.stderr) + _restore_board_env() return 1 + finally: + _restore_board_env() # --------------------------------------------------------------------------- @@ -765,15 +788,15 @@ def _dispatch_boards(args: argparse.Namespace) -> int: can still run ``boards create`` / ``boards list``. """ sub = getattr(args, "boards_action", None) or "list" - if sub in ("list", "ls"): + if sub in {"list", "ls"}: return _cmd_boards_list(args) - if sub in ("create", "new"): + if sub in {"create", "new"}: return _cmd_boards_create(args) - if sub in ("rm", "remove", "delete"): + if sub in {"rm", "remove", "delete"}: return _cmd_boards_rm(args) - if sub in ("switch", "use"): + if sub in {"switch", "use"}: return _cmd_boards_switch(args) - if sub in ("show", "current"): + if sub in {"show", "current"}: return _cmd_boards_show(args) if sub == "rename": return _cmd_boards_rename(args) @@ -1278,7 +1301,7 @@ def _cmd_show(args: argparse.Namespace) -> int: def _cmd_assign(args: argparse.Namespace) -> int: - profile = None if args.profile.lower() in ("none", "-", "null") else args.profile + profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile with kb.connect() as conn: ok = kb.assign_task(conn, args.task_id, profile) if not ok: @@ -1305,7 +1328,7 @@ def _cmd_reclaim(args: argparse.Namespace) -> int: def _cmd_reassign(args: argparse.Namespace) -> int: - profile = None if args.profile.lower() in ("none", "-", "null") else args.profile + profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile with kb.connect() as conn: ok = kb.reassign_task( conn, args.task_id, profile, @@ -1921,6 +1944,7 @@ def _cmd_notify_subscribe(args: argparse.Namespace) -> int: conn, task_id=args.task_id, platform=args.platform, chat_id=args.chat_id, thread_id=args.thread_id, user_id=args.user_id, + notifier_profile=args.notifier_profile or _profile_author(), ) print(f"Subscribed {args.platform}:{args.chat_id}" + (f":{args.thread_id}" if args.thread_id else "") @@ -1939,8 +1963,9 @@ def _cmd_notify_list(args: argparse.Namespace) -> int: return 0 for s in subs: thr = f":{s['thread_id']}" if s.get("thread_id") else "" + owner = f" owner={s['notifier_profile']}" if s.get("notifier_profile") else "" print(f" {s['task_id']:10s} {s['platform']}:{s['chat_id']}{thr}" - f" (since event {s['last_event_id']})") + f" (since event {s['last_event_id']}){owner}") return 0 @@ -2071,19 +2096,18 @@ def _cmd_specify(args: argparse.Namespace) -> int: "reason": outcome.reason, "new_title": outcome.new_title, })) + elif outcome.ok: + title_suffix = ( + f" — retitled: {outcome.new_title!r}" + if outcome.new_title + else "" + ) + print(f"Specified {outcome.task_id} → todo{title_suffix}") else: - if outcome.ok: - title_suffix = ( - f" — retitled: {outcome.new_title!r}" - if outcome.new_title - else "" - ) - print(f"Specified {outcome.task_id} → todo{title_suffix}") - else: - print( - f"kanban: specify {outcome.task_id}: {outcome.reason}", - file=sys.stderr, - ) + print( + f"kanban: specify {outcome.task_id}: {outcome.reason}", + file=sys.stderr, + ) if not all_flag: return 0 if ok_count == 1 else 1 # --all: succeed if at least one promotion landed; exit 1 only when @@ -2206,7 +2230,7 @@ def run_slash(rest: str) -> str: out = buf_out.getvalue().rstrip() err = buf_err.getvalue().rstrip() # Help dump (exit 0) → return the captured help text directly. - if exc.code in (0, None) and out: + if exc.code in {0, None} and out: return out body = err or out return f"⚠ /kanban usage error\n{body}" if body else "⚠ /kanban usage error" diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index aa3655b1762..0db694ff5b1 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -83,6 +83,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable, Optional +from toolsets import get_toolset_names + # --------------------------------------------------------------------------- # Constants @@ -90,6 +92,7 @@ from typing import Any, Iterable, Optional VALID_STATUSES = {"triage", "todo", "ready", "running", "blocked", "done", "archived"} VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} +KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names()) # A running task's claim is valid for 15 minutes; after that the next # dispatcher tick reclaims it. Workers that outlive this window should call @@ -858,6 +861,7 @@ CREATE TABLE IF NOT EXISTS kanban_notify_subs ( chat_id TEXT NOT NULL, thread_id TEXT NOT NULL DEFAULT '', user_id TEXT, + notifier_profile TEXT, created_at INTEGER NOT NULL, last_event_id INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (task_id, platform, chat_id, thread_id) @@ -1082,6 +1086,18 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: "ON task_events(run_id, id)" ) + notify_table_exists = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='kanban_notify_subs'" + ).fetchone() is not None + if notify_table_exists: + notify_cols = { + row["name"] for row in conn.execute("PRAGMA table_info(kanban_notify_subs)") + } + if "notifier_profile" not in notify_cols: + _add_column_if_missing( + conn, "kanban_notify_subs", "notifier_profile", "notifier_profile TEXT" + ) + # One-shot backfill: any task that is 'running' before runs existed # had its claim_lock / claim_expires / worker_pid on the task row. # Synthesize a matching task_runs row so subsequent end-run / heartbeat @@ -1272,6 +1288,12 @@ def create_task( if skills is not None: cleaned: list[str] = [] seen: set[str] = set() + # Collect all toolset-name confusions up front so the user sees the + # whole list at once. Raising on the first hit is friendly when the + # input has one mistake, but agents that confuse skills with toolsets + # usually pass several at once (`skills=["web", "browser", "terminal"]`) + # and serial-correcting one per failure round-trips wastes tokens. + toolset_typos: list[str] = [] for s in skills: if not s: continue @@ -1283,10 +1305,23 @@ def create_task( f"skill name cannot contain comma: {name!r} " f"(pass a list of separate names instead of a comma-joined string)" ) + if name.casefold() in KNOWN_TOOLSET_NAMES: + toolset_typos.append(name) + continue if name in seen: continue seen.add(name) cleaned.append(name) + if toolset_typos: + quoted = ", ".join(repr(n) for n in toolset_typos) + noun = "is a toolset name" if len(toolset_typos) == 1 else "are toolset names" + raise ValueError( + f"{quoted} {noun}, not skill name(s). " + "Put toolsets in the assignee profile's `toolsets:` config " + "instead of per-task skills. Skills are named skill bundles " + "(e.g. `kanban-worker`, `blogwatcher`); toolsets are runtime " + "capabilities (e.g. `web`, `browser`, `terminal`)." + ) skills_list = cleaned # Idempotency check — return the existing task instead of creating a @@ -1791,7 +1826,7 @@ def _synthesize_ended_run( # --------------------------------------------------------------------------- def recompute_ready(conn: sqlite3.Connection) -> int: - """Promote ``todo`` tasks to ``ready`` when all parents are ``done``. + """Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``. Returns the number of tasks promoted. Safe to call inside or outside an existing transaction; it opens its own IMMEDIATE txn. @@ -1809,7 +1844,7 @@ def recompute_ready(conn: sqlite3.Connection) -> int: "WHERE l.child_id = ?", (task_id,), ).fetchall() - if all(p["status"] == "done" for p in parents): + if all(p["status"] in {"done", "archived"} for p in parents): conn.execute( "UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'todo'", (task_id,), @@ -1850,7 +1885,7 @@ def claim_task( undone = conn.execute( "SELECT 1 FROM task_links l " "JOIN tasks p ON p.id = l.parent_id " - "WHERE l.child_id = ? AND p.status != 'done' LIMIT 1", + "WHERE l.child_id = ? AND p.status NOT IN ('done', 'archived') LIMIT 1", (task_id,), ).fetchone() if undone: @@ -1975,16 +2010,69 @@ def release_stale_claims( ) -> int: """Reset any ``running`` task whose claim has expired. - Returns the number of stale claims reclaimed. Safe to call often. + A stale-by-TTL claim whose host-local worker PID is still alive is + *extended* (with a ``claim_extended`` event) instead of being + reclaimed. Reclaiming a live worker mid-flight produces the spawn- + then-immediately-reclaim loop seen on slow models that spend longer + than ``DEFAULT_CLAIM_TTL_SECONDS`` inside a single tool-free LLM + call (#23025): no tool calls means no ``kanban_heartbeat``, even + though the subprocess is healthy. ``enforce_max_runtime`` and + ``detect_crashed_workers`` remain the upper bounds for genuinely + wedged or dead workers. + + Returns the number of stale claims actually reclaimed (live-pid + extensions don't count). Safe to call often. """ now = int(time.time()) reclaimed = 0 + host_prefix = f"{_claimer_id().split(':', 1)[0]}:" stale = conn.execute( - "SELECT id, claim_lock, worker_pid FROM tasks " - "WHERE status = 'running' AND claim_expires IS NOT NULL AND claim_expires < ?", + "SELECT id, claim_lock, worker_pid, claim_expires, last_heartbeat_at " + "FROM tasks " + "WHERE status = 'running' AND claim_expires IS NOT NULL " + " AND claim_expires < ?", (now,), ).fetchall() for row in stale: + lock = row["claim_lock"] or "" + host_local = lock.startswith(host_prefix) + if host_local and row["worker_pid"] and _pid_alive(row["worker_pid"]): + new_expires = now + int(DEFAULT_CLAIM_TTL_SECONDS) + with write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET claim_expires = ? " + "WHERE id = ? AND status = 'running' " + " AND claim_lock IS ? " + " AND claim_expires IS NOT NULL " + " AND claim_expires < ?", + (new_expires, row["id"], row["claim_lock"], now), + ) + if cur.rowcount != 1: + continue + run_id = _current_run_id(conn, row["id"]) + if run_id is not None: + conn.execute( + "UPDATE task_runs SET claim_expires = ? WHERE id = ?", + (new_expires, run_id), + ) + _append_event( + conn, row["id"], "claim_extended", + { + "reason": "pid_alive", + "worker_pid": int(row["worker_pid"]), + "claim_lock": row["claim_lock"], + "claim_expires_was": int(row["claim_expires"]), + "claim_expires_now": new_expires, + "last_heartbeat_at": ( + int(row["last_heartbeat_at"]) + if row["last_heartbeat_at"] is not None + else None + ), + }, + run_id=run_id, + ) + continue + termination = _terminate_reclaimed_worker( row["worker_pid"], row["claim_lock"], signal_fn=signal_fn, ) @@ -2004,7 +2092,20 @@ def release_stale_claims( error=f"stale_lock={row['claim_lock']}", metadata=termination, ) - payload = {"stale_lock": row["claim_lock"]} + payload = { + "stale_lock": row["claim_lock"], + "worker_pid": ( + int(row["worker_pid"]) + if row["worker_pid"] is not None else None + ), + "claim_expires": int(row["claim_expires"]), + "last_heartbeat_at": ( + int(row["last_heartbeat_at"]) + if row["last_heartbeat_at"] is not None else None + ), + "now": now, + "host_local": host_local, + } payload.update(termination) _append_event( conn, row["id"], "reclaimed", @@ -3587,6 +3688,14 @@ def dispatch_once( failures the task is auto-blocked with the last error as its reason — prevents the dispatcher from thrashing forever on an unfixable task. + ``max_spawn`` is a **live concurrency cap**, not a per-tick spawn budget: + it counts tasks already in ``status='running'`` plus this tick's spawns + against the limit. So ``max_spawn=4`` means "at most 4 workers running + at any time across the whole board" — matching the gateway's stated + intent ("limit concurrent kanban tasks"). With a per-tick interpretation + a 60-second tick interval could grow concurrency by N every minute on a + busy board and accumulate without bound. + ``spawn_fn`` defaults to ``_default_spawn``. Tests pass a stub. ``board`` pins workspace/log/db resolution for this tick to a specific board. When omitted, the current-board resolution chain is used. @@ -3638,6 +3747,21 @@ def dispatch_once( result.timed_out = enforce_max_runtime(conn) result.promoted = recompute_ready(conn) + # Count tasks already running so max_spawn enforces concurrency rather + # than a per-tick spawn budget. See the docstring above for the full + # rationale; the short version is that a 60-second tick interval with a + # per-tick budget of N would grow concurrency by N every tick on a busy + # board, since "running" tasks aren't reclaimed by completion alone — + # they sit in status='running' until the worker calls + # kanban_complete/kanban_block (or the dispatcher TTL-reclaims them). + running_count = 0 + if max_spawn is not None: + running_count = int( + conn.execute( + "SELECT COUNT(*) FROM tasks WHERE status = 'running'" + ).fetchone()[0] + ) + ready_rows = conn.execute( "SELECT id, assignee FROM tasks " "WHERE status = 'ready' AND claim_lock IS NULL " @@ -3645,7 +3769,7 @@ def dispatch_once( ).fetchall() spawned = 0 for row in ready_rows: - if max_spawn is not None and spawned >= max_spawn: + if max_spawn is not None and running_count + spawned >= max_spawn: break if not row["assignee"]: result.skipped_unassigned.append(row["id"]) @@ -3749,6 +3873,35 @@ def _rotate_worker_log(log_path: Path, max_bytes: int) -> None: pass +def _resolve_hermes_argv() -> list[str]: + """Resolve the ``hermes`` invocation as argv parts for ``Popen``. + + Tries in order: + + 1. ``shutil.which("hermes")`` — the console-script shim, the same form + that shows up in ``ps`` output and existing logs. Preferred so live + systems' diagnostics stay familiar. + 2. ``sys.executable -m hermes_cli.main`` — fallback for setups where + Hermes is launched from a venv and the ``hermes`` shim is not on + the dispatcher's ``$PATH`` (cron, systemd ``User=`` services, + launchd jobs, detached processes, etc.). Goes through the running + interpreter so the result is independent of ``$PATH``. + + Mirrors ``gateway.run._resolve_hermes_bin`` for the same reason. Kept + local (not imported from gateway) because ``hermes_cli`` sits below + ``gateway`` in the dependency order. + """ + import shutil + + hermes_bin = shutil.which("hermes") + if hermes_bin: + return [hermes_bin] + # Fallback to the module form. ``hermes_cli.main`` is the actual + # console-script target declared in pyproject.toml, NOT a top-level + # ``hermes`` package — there is no ``hermes`` package to import. + return [sys.executable, "-m", "hermes_cli.main"] + + def _default_spawn( task: Task, workspace: str, @@ -3777,6 +3930,25 @@ def _default_spawn( prompt = f"work kanban task {task.id}" env = dict(os.environ) + + # Inject HERMES_HOME so the worker reads the profile-scoped config.yaml + # (fallback_providers, toolsets, agent settings, etc.) instead of the root + # config. Without this, `env = dict(os.environ)` copies only the parent's + # env, and when the child process starts `hermes -p <name>` the + # _apply_profile_override() runs *before* hermes_constants is imported. + # If HERMES_HOME is absent from the child's env, get_hermes_home() falls + # back to Path.home() / ".hermes" (the DEFAULT profile root), ignoring the + # profile-specific config entirely. Fixes profile-scoped fallback_providers + # being invisible to kanban workers. + from hermes_cli.profiles import resolve_profile_env + try: + env["HERMES_HOME"] = resolve_profile_env(profile_arg) + except FileNotFoundError: + # Profile dir doesn't exist — defer resolution to the CLI's + # _apply_profile_override() via HERMES_PROFILE (set below). + # This only happens in test fixtures where the isolated + # HERMES_HOME never had profiles created. + pass if task.tenant: env["HERMES_TENANT"] = task.tenant env["HERMES_KANBAN_TASK"] = task.id @@ -3805,7 +3977,7 @@ def _default_spawn( env["HERMES_PROFILE"] = profile_arg cmd = [ - "hermes", + *_resolve_hermes_argv(), "-p", profile_arg, # Auto-load the kanban-worker skill so every dispatched worker # has the pattern library (good summary/metadata shapes, retry @@ -4161,16 +4333,26 @@ def board_stats(conn: sqlite3.Connection) -> dict: } +def _safe_int(val: Optional[str]) -> Optional[int]: + """Parse a timestamp field to int, returning None on garbage like '%s'.""" + if val is None: + return None + try: + return int(val) + except (ValueError, TypeError): + return None + + def task_age(task: Task) -> dict: """Return age metrics for a single task. All values are seconds or None.""" now = int(time.time()) - age_since_created = now - int(task.created_at) if task.created_at else None - age_since_started = ( - now - int(task.started_at) if task.started_at else None - ) + created = _safe_int(task.created_at) + started = _safe_int(task.started_at) + completed = _safe_int(task.completed_at) + age_since_created = now - created if created else None + age_since_started = now - started if started else None time_to_complete = ( - int(task.completed_at) - int(task.started_at or task.created_at) - if task.completed_at else None + completed - (started or created) if completed else None ) return { "created_age_seconds": age_since_created, @@ -4191,6 +4373,7 @@ def add_notify_sub( chat_id: str, thread_id: Optional[str] = None, user_id: Optional[str] = None, + notifier_profile: Optional[str] = None, ) -> None: """Register a gateway source that wants terminal-state notifications for ``task_id``. Idempotent on (task, platform, chat, thread).""" @@ -4199,10 +4382,10 @@ def add_notify_sub( conn.execute( """ INSERT OR IGNORE INTO kanban_notify_subs - (task_id, platform, chat_id, thread_id, user_id, created_at) - VALUES (?, ?, ?, ?, ?, ?) + (task_id, platform, chat_id, thread_id, user_id, notifier_profile, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (task_id, platform, chat_id, thread_id or "", user_id, now), + (task_id, platform, chat_id, thread_id or "", user_id, notifier_profile, now), ) @@ -4284,6 +4467,57 @@ def unseen_events_for_sub( return max_id, out +def claim_unseen_events_for_sub( + conn: sqlite3.Connection, + *, + task_id: str, + platform: str, + chat_id: str, + thread_id: Optional[str] = None, + kinds: Optional[Iterable[str]] = None, +) -> tuple[int, int, list[Event]]: + """Atomically claim unseen notification events for one subscription. + + Returns ``(old_cursor, new_cursor, events)``. When events are returned, + ``kanban_notify_subs.last_event_id`` has already been advanced to + ``new_cursor`` inside a ``BEGIN IMMEDIATE`` transaction. That makes the + notifier's read/claim step single-owner across multiple gateway watcher + processes pointed at the same board DB: concurrent watchers serialize on + SQLite's writer lock, and only the first process sees and claims a given + event range. + + Callers should send the claimed events, then either leave the cursor at + ``new_cursor`` on success or call :func:`rewind_notify_cursor` if delivery + failed before any terminal unsubscribe removed the row. + """ + with write_txn(conn): + row = conn.execute( + "SELECT last_event_id FROM kanban_notify_subs " + "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?", + (task_id, platform, chat_id, thread_id or ""), + ).fetchone() + if row is None: + return 0, 0, [] + old_cursor = int(row["last_event_id"]) + new_cursor, events = unseen_events_for_sub( + conn, + task_id=task_id, + platform=platform, + chat_id=chat_id, + thread_id=thread_id, + kinds=kinds, + ) + if not events: + return old_cursor, old_cursor, [] + conn.execute( + "UPDATE kanban_notify_subs SET last_event_id = ? " + "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? " + "AND last_event_id = ?", + (int(new_cursor), task_id, platform, chat_id, thread_id or "", int(old_cursor)), + ) + return old_cursor, new_cursor, events + + def advance_notify_cursor( conn: sqlite3.Connection, *, @@ -4301,6 +4535,35 @@ def advance_notify_cursor( ) +def rewind_notify_cursor( + conn: sqlite3.Connection, + *, + task_id: str, + platform: str, + chat_id: str, + thread_id: Optional[str] = None, + claimed_cursor: int, + old_cursor: int, +) -> bool: + """Undo a notification claim when delivery fails. + + The CAS guard only rewinds if no later notifier advanced the row after our + claim. This keeps retry behavior for transient send failures without + clobbering newer progress. + """ + with write_txn(conn): + cur = conn.execute( + "UPDATE kanban_notify_subs SET last_event_id = ? " + "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? " + "AND last_event_id = ?", + ( + int(old_cursor), task_id, platform, chat_id, thread_id or "", + int(claimed_cursor), + ), + ) + return cur.rowcount > 0 + + # --------------------------------------------------------------------------- # Retention + garbage collection # --------------------------------------------------------------------------- diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index d2ba26cb835..42c0c2043f2 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -177,7 +177,7 @@ def _active_hallucination_events( active: list[Any] = [] for ev in events: k = _event_kind(ev) - if k in ("completed", "edited"): + if k in {"completed", "edited"}: active.clear() elif k == kind: active.append(ev) @@ -193,10 +193,9 @@ def _latest_clean_event_ts(events: Iterable[Any]) -> int: """ latest = 0 for ev in events: - if _event_kind(ev) in ("completed", "edited"): + if _event_kind(ev) in {"completed", "edited"}: t = _event_ts(ev) - if t > latest: - latest = t + latest = max(latest, t) return latest @@ -356,7 +355,7 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: most_recent_outcome = None for r in reversed(ordered_runs): oc = _task_field(r, "outcome") - if oc in ("spawn_failed", "timed_out", "crashed"): + if oc in {"spawn_failed", "timed_out", "crashed"}: most_recent_outcome = oc break @@ -374,7 +373,7 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: label=f"Fix profile auth: hermes -p {assignee} auth", payload={"command": f"hermes -p {assignee} auth"}, )) - elif most_recent_outcome in ("timed_out", "crashed"): + elif most_recent_outcome in {"timed_out", "crashed"}: # Worker got off the ground but died. Logs are the right place # to diagnose; reclaim/reassign are the recovery levers. task_id = _task_field(task, "id") @@ -467,7 +466,7 @@ def _rule_repeated_crashes(task, events, runs, now, cfg) -> list[Diagnostic]: consecutive += 1 if last_err is None: last_err = _task_field(r, "error") - elif outcome in ("completed", "reclaimed"): + elif outcome in {"completed", "reclaimed"}: # A success (or manual reclaim) breaks the streak. break else: @@ -534,8 +533,7 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: for ev in events: if _event_kind(ev) == "blocked": t = _event_ts(ev) - if t > last_blocked_ts: - last_blocked_ts = t + last_blocked_ts = max(last_blocked_ts, t) if last_blocked_ts == 0: return [] age_hours = (now - last_blocked_ts) / 3600.0 @@ -543,7 +541,7 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: return [] # Any comment / unblock after the block breaks the "stale" signal. for ev in events: - if _event_kind(ev) in ("commented", "unblocked") and _event_ts(ev) > last_blocked_ts: + if _event_kind(ev) in {"commented", "unblocked"} and _event_ts(ev) > last_blocked_ts: return [] actions: list[DiagnosticAction] = [ DiagnosticAction( @@ -570,6 +568,129 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: )] +def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: + """Task has been in ``ready`` status for too long without any worker + claiming it. + + Threshold: cfg["stranded_threshold_seconds"] (default 1800 = 30 min). + + Catches every "task waiting for a worker that never comes" case + without caring WHY: + + * Operator typo'd the assignee — no profile or external worker matches. + * Profile was deleted, leaving its tasks stranded. + * External worker pool (Codex CLI, Claude Code lane, custom daemon) + is down, hung, or wasn't started. + * Dispatcher is misconfigured (wrong board, wrong HERMES_HOME). + + Pre-rule, all of these silently rotted in ``skipped_nonspawnable`` — + the dispatcher correctly skipped them (good — no respawn loop) but + nobody surfaced the fact that operator-actionable work was + accumulating. The rule fires when a ready task's promoted-to-ready + timestamp is older than the threshold AND the assignee is non-empty + (truly unassigned tasks have their own ``skipped_unassigned`` signal + on the dispatcher and a different operator response). + + The signal is age-based on purpose: it's identity-agnostic, so it + works for Hermes profiles, registered lanes, external workers, and + typos uniformly. No registry to curate, no per-board allowlist. + """ + threshold_seconds = float( + cfg.get("stranded_threshold_seconds", 30 * 60) + ) + status = _task_field(task, "status") + if status != "ready": + return [] + # Skip tasks with a live claim — they're being worked on, even if + # the worker hasn't reported progress yet (run-level liveness + # extends the claim TTL; we don't want to second-guess that here). + if _task_field(task, "claim_lock"): + return [] + assignee = _task_field(task, "assignee") or "" + if not assignee.strip(): + # Unassigned tasks: the dispatcher's ``skipped_unassigned`` is + # already the right signal. A separate diagnostic here would + # double-flag the same condition. + return [] + + # Find the most recent event that put this task into ready. + # ``created`` covers tasks born ready; ``promoted`` covers parent- + # done auto-promotion; ``reclaimed`` covers TTL/crash recovery; + # ``unblocked`` covers human-driven resumes. + READY_TRANSITION_KINDS = { + "created", "promoted", "reclaimed", "unblocked", + } + last_ready_ts = 0 + for ev in events: + if _event_kind(ev) in READY_TRANSITION_KINDS: + t = _event_ts(ev) + last_ready_ts = max(last_ready_ts, t) + + # Fallback: if no qualifying event exists (very old task or events + # truncated), fall back to ``created_at`` on the task row. Better + # to occasionally over-flag an ancient task than miss a stranded one. + if last_ready_ts == 0: + last_ready_ts = int(_task_field(task, "created_at", default=0) or 0) + if last_ready_ts == 0: + return [] + + age_seconds = now - last_ready_ts + if age_seconds < threshold_seconds: + return [] + + # Format the age in the largest sensible unit. + if age_seconds >= 3600: + age_str = f"{age_seconds / 3600:.1f}h" + else: + age_str = f"{int(age_seconds / 60)}m" + + # Severity escalates with age. Below 2x threshold = warning; + # 2x – 6x = error; beyond 6x = critical (something is clearly + # broken, not just slow). + if age_seconds >= threshold_seconds * 6: + severity = "critical" + elif age_seconds >= threshold_seconds * 2: + severity = "error" + else: + severity = "warning" + + actions = [ + DiagnosticAction( + kind="reassign", + label="Reassign to a different worker", + payload={"current_assignee": assignee}, + ), + DiagnosticAction( + kind="cli_hint", + label="Check dispatcher status", + payload={"command": "hermes kanban diagnostics"}, + ), + ] + + return [Diagnostic( + kind="stranded_in_ready", + severity=severity, + title=f"Ready for {age_str} with no worker", + detail=( + f"This task has been ready for {age_str} but nothing has " + f"claimed it. Common causes: assignee {assignee!r} is " + f"misspelled, the profile was deleted, or the external " + f"worker pool for this lane is down. Confirm the assignee " + f"is correct and that a worker is actually polling for it." + ), + actions=actions, + first_seen_at=last_ready_ts, + last_seen_at=last_ready_ts, + count=1, + data={ + "ready_since": last_ready_ts, + "age_seconds": int(age_seconds), + "assignee": assignee, + "threshold_seconds": int(threshold_seconds), + }, + )] + + # Registry — order matters: rules higher on the list render first when # severity ties. Add new rules here. _RULES: list[RuleFn] = [ @@ -578,6 +699,7 @@ _RULES: list[RuleFn] = [ _rule_repeated_failures, _rule_repeated_crashes, _rule_stuck_in_blocked, + _rule_stranded_in_ready, ] @@ -589,6 +711,7 @@ DIAGNOSTIC_KINDS = ( "repeated_failures", "repeated_crashes", "stuck_in_blocked", + "stranded_in_ready", ) @@ -598,6 +721,10 @@ DEFAULT_CONFIG = { "spawn_failure_threshold": 3, "crash_threshold": 2, "blocked_stale_hours": 24, + # Stranded-task threshold. 30 min by default — below that, the + # signal is dominated by tasks that are about to be claimed on the + # next dispatcher tick (default 60s) and would just be noise. + "stranded_threshold_seconds": 30 * 60, } diff --git a/hermes_cli/main.py b/hermes_cli/main.py index db260e60bb0..d59064c825d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -124,7 +124,7 @@ def _apply_profile_override() -> None: # 1. Check for explicit -p / --profile flag for i, arg in enumerate(argv): - if arg in ("--profile", "-p") and i + 1 < len(argv): + if arg in {"--profile", "-p"} and i + 1 < len(argv): profile_name = argv[i + 1] consume = 2 break @@ -192,7 +192,7 @@ def _apply_profile_override() -> None: # Strip the flag from argv so argparse doesn't choke if consume > 0: for i, arg in enumerate(argv): - if arg in ("--profile", "-p"): + if arg in {"--profile", "-p"}: start = i + 1 # +1 because argv is sys.argv[1:] sys.argv = sys.argv[:start] + sys.argv[start + consume :] break @@ -505,8 +505,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]: # Compute visible area visible_rows = max_y - 4 # header + col header + blank + footer - if visible_rows < 1: - visible_rows = 1 + visible_rows = max(visible_rows, 1) # Clamp cursor and scroll if not filtered: @@ -518,8 +517,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]: else: if cursor >= len(filtered): cursor = len(filtered) - 1 - if cursor < 0: - cursor = 0 + cursor = max(cursor, 0) if cursor < scroll_offset: scroll_offset = cursor elif cursor >= scroll_offset + visible_rows: @@ -569,13 +567,13 @@ def _session_browse_picker(sessions: list) -> Optional[str]: stdscr.refresh() key = stdscr.getch() - if key in (curses.KEY_UP,): + if key in {curses.KEY_UP,}: if filtered: cursor = (cursor - 1) % len(filtered) - elif key in (curses.KEY_DOWN,): + elif key in {curses.KEY_DOWN,}: if filtered: cursor = (cursor + 1) % len(filtered) - elif key in (curses.KEY_ENTER, 10, 13): + elif key in {curses.KEY_ENTER, 10, 13}: if filtered: result_holder[0] = filtered[cursor]["id"] return @@ -589,7 +587,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]: else: # Second Esc exits return - elif key in (curses.KEY_BACKSPACE, 127, 8): + elif key in {curses.KEY_BACKSPACE, 127, 8}: if search_text: search_text = search_text[:-1] if search_text: @@ -628,7 +626,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]: while True: try: val = input(f"\n Select [1-{len(sessions)}]: ").strip() - if not val or val.lower() in ("q", "quit", "exit"): + if not val or val.lower() in {"q", "quit", "exit"}: return None idx = int(val) - 1 if 0 <= idx < len(sessions): @@ -1305,7 +1303,7 @@ def _launch_tui( except KeyboardInterrupt: code = 130 - if code in (0, 130): + if code in {0, 130}: _print_tui_exit_summary(resume_session_id, active_session_file) finally: try: @@ -1425,7 +1423,7 @@ def cmd_chat(args): reply = input("Run setup now? [Y/n] ").strip().lower() except (EOFError, KeyboardInterrupt): reply = "n" - if reply in ("", "y", "yes"): + if reply in {"", "y", "yes"}: cmd_setup(args) return print() @@ -1602,7 +1600,7 @@ def cmd_whatsapp(args): response = input("\n Update allowed users? [y/N] ").strip() except (EOFError, KeyboardInterrupt): response = "n" - if response.lower() in ("y", "yes"): + if response.lower() in {"y", "yes"}: if wa_mode == "bot": phone = input( " Phone numbers that can message the bot (comma-separated): " @@ -1677,7 +1675,7 @@ def cmd_whatsapp(args): ).strip() except (EOFError, KeyboardInterrupt): response = "n" - if response.lower() in ("y", "yes"): + if response.lower() in {"y", "yes"}: shutil.rmtree(session_dir, ignore_errors=True) session_dir.mkdir(parents=True, exist_ok=True) print(" ✓ Session cleared") @@ -2031,7 +2029,7 @@ def select_provider_and_model(args=None): _model_flow_bedrock(config, current_model) elif selected_provider == "azure-foundry": _model_flow_azure_foundry(config, current_model) - elif selected_provider in ( + elif selected_provider in { "gemini", "deepseek", "xai", @@ -2051,18 +2049,18 @@ def select_provider_and_model(args=None): "ollama-cloud", "tencent-tokenhub", "lmstudio", - ) or _is_profile_api_key_provider(selected_provider): + } or _is_profile_api_key_provider(selected_provider): _model_flow_api_key_provider(config, selected_provider, current_model) # ── Post-switch cleanup: clear stale OPENAI_BASE_URL ────────────── # When the user switches to a named provider (anything except "custom"), # a leftover OPENAI_BASE_URL in ~/.hermes/.env can poison auxiliary # clients that use provider:auto. Clear it proactively. (#5161) - if selected_provider not in ( + if selected_provider not in { "custom", "cancel", "remove-custom", - ) and not selected_provider.startswith("custom:"): + } and not selected_provider.startswith("custom:"): _clear_stale_openai_base_url() @@ -2188,7 +2186,7 @@ def _reset_aux_to_auto() -> int: entry = {} aux[task] = entry changed = False - if entry.get("provider") not in (None, "", "auto"): + if entry.get("provider") not in {None, "", "auto"}: entry["provider"] = "auto" changed = True for field in ("model", "base_url", "api_key"): @@ -3099,7 +3097,7 @@ def _model_flow_custom(config): _add_v1 = input(" Add /v1? [Y/n]: ").strip().lower() except (KeyboardInterrupt, EOFError): _add_v1 = "n" - if _add_v1 in ("", "y", "yes"): + if _add_v1 in {"", "y", "yes"}: effective_url = effective_url.rstrip("/") + "/v1" if base_url: base_url = effective_url @@ -3143,7 +3141,7 @@ def _model_flow_custom(config): if len(detected_models) == 1: print(f" Detected model: {detected_models[0]}") confirm = input(" Use this model? [Y/n]: ").strip().lower() - if confirm in ("", "y", "yes"): + if confirm in {"", "y", "yes"}: model_name = detected_models[0] else: model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip() @@ -3976,7 +3974,7 @@ def _model_flow_copilot(config, current_model=""): api_key = creds.get("api_key", "") source = creds.get("source", "") else: - if source in ("GITHUB_TOKEN", "GH_TOKEN"): + if source in {"GITHUB_TOKEN", "GH_TOKEN"}: print(f" GitHub token: {api_key[:8]}... ✓ ({source})") elif source == "gh auth token": print(" GitHub token: ✓ (from `gh auth token`)") @@ -5296,7 +5294,7 @@ def cmd_slack(args): command registered as a first-class slash. """ sub = getattr(args, "slack_command", None) - if sub in (None, ""): + if sub in {None, ""}: # No subcommand — print usage hint. print( "usage: hermes slack <subcommand>\n" @@ -5443,7 +5441,7 @@ def _clear_bytecode_cache(root: Path) -> int: dirnames[:] = [ d for d in dirnames - if d not in ("venv", ".venv", "node_modules", ".git", ".worktrees") + if d not in {"venv", ".venv", "node_modules", ".git", ".worktrees"} ] if os.path.basename(dirpath) == "__pycache__": try: @@ -5568,6 +5566,8 @@ def _run_npm_install_deterministic( cwd=cwd, capture_output=capture_output, text=True, + encoding="utf-8", + errors="replace", check=False, ) if ci_result.returncode == 0: @@ -5580,6 +5580,8 @@ def _run_npm_install_deterministic( cwd=cwd, capture_output=capture_output, text=True, + encoding="utf-8", + errors="replace", check=False, ) @@ -5616,12 +5618,50 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: if fatal: print(" Run manually: cd apps/dashboard && npm install && npm run build") return False - r2 = subprocess.run([npm, "run", "build"], cwd=web_dir, capture_output=True) + # First attempt + r2 = subprocess.run( + [npm, "run", "build"], + cwd=web_dir, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) if r2.returncode != 0: + # Retry once after a short delay — covers boot-time races on Windows + # (antivirus scanning Node.js binaries, npm cache not ready, transient + # I/O when launched via Scheduled Task at logon). See issue #23817. + _time.sleep(3) + r2 = subprocess.run( + [npm, "run", "build"], + cwd=web_dir, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + if r2.returncode != 0: + stderr_preview = (r2.stderr or "").strip() + stderr_tail = "\n ".join(stderr_preview.splitlines()[-10:]) if stderr_preview else "" + dist_dir = web_dir.parent / "hermes_cli" / "web_dist" + dist_index = dist_dir / "index.html" + + # If a stale dist exists, serve it as a fallback instead of failing. + # A stale UI is far better than no UI for non-interactive callers + # (Windows Scheduled Tasks, CI) — issue #23817. + if dist_index.exists(): + print(" ⚠ Web UI build failed — serving stale dist as fallback") + if stderr_tail: + print(f" Build error:\n {stderr_tail}") + return True + print( f" {'✗' if fatal else '⚠'} Web UI build failed" + ("" if fatal else " (hermes web will not be available)") ) + if stderr_tail: + print(f" Build error:\n {stderr_tail}") if fatal: print(" Run manually: cd apps/dashboard && npm install && npm run build") return False @@ -5940,8 +5980,8 @@ def _kill_stale_dashboard_processes( for pid in killed: print(f" ✓ stopped PID {pid}") - for pid, reason in failed: - print(f" ✗ failed to stop PID {pid}: {reason}") + for pid, err_msg in failed: + print(f" ✗ failed to stop PID {pid}: {err_msg}") if killed: print(" Restart the dashboard when you're ready:") @@ -6198,7 +6238,7 @@ def _restore_stashed_changes( response = input_fn("Restore local changes now? [Y/n]", "y") else: response = input().strip().lower() - if response not in ("", "y", "yes"): + if response not in {"", "y", "yes"}: print("Skipped restoring local changes.") print("Your changes are still preserved in git stash.") print(f"Restore manually with: git stash apply {stash_ref}") @@ -6441,7 +6481,7 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: print() response = "n" - if response in ("", "y", "yes"): + if response in {"", "y", "yes"}: print("→ Adding upstream remote...") if _add_upstream_remote(git_cmd, cwd): print( @@ -6622,6 +6662,103 @@ def _run_install_with_heartbeat( t.join(timeout=0.2) +def _is_windows() -> bool: + return sys.platform == "win32" + + +def _venv_scripts_dir() -> Path | None: + """Return the venv Scripts directory if we're running inside the project venv.""" + venv_dir = PROJECT_ROOT / "venv" + if not venv_dir.is_dir(): + return None + scripts = venv_dir / ("Scripts" if _is_windows() else "bin") + return scripts if scripts.is_dir() else None + + +def _hermes_exe_shims(scripts_dir: Path) -> list[Path]: + """Entry-point shims that uv may try to rewrite during ``pip install -e .``. + + On Windows these are .exe launchers generated by setuptools/uv. On POSIX + they're regular Python scripts which can be replaced atomically — no + self-replacement hazard exists outside Windows. + """ + if not _is_windows(): + return [] + return [ + scripts_dir / "hermes.exe", + scripts_dir / "hermes-gateway.exe", + ] + + +def _quarantine_running_hermes_exe(scripts_dir: Path) -> list[tuple[Path, Path]]: + """Pre-empt Windows file lock on the running ``hermes.exe``. + + Windows allows RENAMING a mapped/running executable (the kernel tracks the + file by handle, not path), but blocks DELETE/REPLACE while it's loaded. uv + needs to overwrite the entry-point shims during ``pip install -e .``; + when ``hermes update`` runs, ``hermes.exe`` IS the live process, and uv + fails with ``Access is denied. (os error 5)``. + + We rename live shims to ``hermes.exe.old.<unix-ms>`` first. uv then writes + fresh shims at the original paths. The ``.old`` files are cleaned up on + the next hermes invocation by ``_cleanup_quarantined_exes``. + + Returns the list of (original, quarantined) pairs so the caller can roll + back if the install itself fails before uv writes a replacement. + """ + moved: list[tuple[Path, Path]] = [] + if not _is_windows(): + return moved + + import time + stamp = int(time.time() * 1000) + for shim in _hermes_exe_shims(scripts_dir): + if not shim.exists(): + continue + target = shim.with_suffix(shim.suffix + f".old.{stamp}") + try: + shim.rename(target) + moved.append((shim, target)) + except OSError as e: + # Best-effort: keep going. uv's failure later will surface the + # real error; this is a heuristic, not a hard guarantee. + print(f" ⚠ Could not quarantine {shim.name}: {e}") + return moved + + +def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None: + """Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements.""" + for original, quarantined in moved: + try: + if not original.exists() and quarantined.exists(): + quarantined.rename(original) + except OSError: + pass + + +def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: + """Sweep ``hermes.exe.old.*`` left by prior updates. + + Called early on every hermes invocation. The .old files are unlocked once + their owning process exited, so deletion succeeds the next run. Silent + no-op when nothing's there or on file-locked / permission errors. + """ + if not _is_windows(): + return + if scripts_dir is None: + scripts_dir = _venv_scripts_dir() + if scripts_dir is None: + return + try: + for stale in scripts_dir.glob("*.exe.old.*"): + try: + stale.unlink() + except OSError: + pass # still locked or in use — try again next run + except OSError: + pass + + def _install_python_dependencies_with_optional_fallback( install_cmd_prefix: list[str], *, @@ -6632,31 +6769,42 @@ def _install_python_dependencies_with_optional_fallback( By default this targets ``.[all]``; Termux callers can pass ``group='termux-all'`` to use the curated Android-compatible profile. + + On Windows, pre-renames live ``hermes.exe`` / ``hermes-gateway.exe`` shims + in the venv Scripts dir before each install attempt so uv can write fresh + copies (Windows blocks REPLACE on a running .exe but allows RENAME). See + ``_quarantine_running_hermes_exe`` for the rationale. """ + scripts_dir = _venv_scripts_dir() if _is_windows() else None + + def _install(args: list[str]) -> None: + moved: list[tuple[Path, Path]] = [] + if scripts_dir is not None: + moved = _quarantine_running_hermes_exe(scripts_dir) + try: + _run_install_with_heartbeat(install_cmd_prefix + args, env=env) + except BaseException: + # Restore shims if uv didn't write replacements (e.g. install + # failed before the entry-points step). Don't swallow the error. + if scripts_dir is not None: + _restore_quarantined_exes(moved) + raise + try: - _run_install_with_heartbeat( - install_cmd_prefix + ["install", "-e", f".[{group}]"], - env=env, - ) + _install(["install", "-e", f".[{group}]"]) return except subprocess.CalledProcessError: print( " ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..." ) - _run_install_with_heartbeat( - install_cmd_prefix + ["install", "-e", "."], - env=env, - ) + _install(["install", "-e", "."]) failed_extras: list[str] = [] installed_extras: list[str] = [] for extra in _load_installable_optional_extras(group=group): try: - _run_install_with_heartbeat( - install_cmd_prefix + ["install", "-e", f".[{extra}]"], - env=env, - ) + _install(["install", "-e", f".[{extra}]"]) installed_extras.append(extra) except subprocess.CalledProcessError: failed_extras.append(extra) @@ -7396,7 +7544,7 @@ def _cmd_update_impl(args, gateway_mode: bool): prompt_user=prompt_for_restore, input_fn=gw_input_fn, ) - if current_branch not in ("main", "HEAD"): + if current_branch not in {"main", "HEAD"}: subprocess.run( git_cmd + ["checkout", current_branch], cwd=PROJECT_ROOT, @@ -7680,7 +7828,7 @@ def _cmd_update_impl(args, gateway_mode: bool): except EOFError: response = "n" - if response in ("", "y", "yes", "auto"): + if response in {"", "y", "yes", "auto"}: print() # Gateway mode, --yes, and non-interactive update contexts # (dashboard / web server actions) cannot prompt for API keys. @@ -8741,7 +8889,7 @@ def cmd_profile(args): answer = input("\nProceed with install? [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "" - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: print("Install cancelled.") return @@ -8800,7 +8948,7 @@ def cmd_profile(args): answer = input("\nProceed? [y/N] ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "" - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: print("Update cancelled.") return @@ -8996,9 +9144,24 @@ def cmd_dashboard(args): # backend is the desktop's primary entrypoint and needs the same. _sync_bundled_skills_quietly() - if "HERMES_WEB_DIST" not in os.environ: + if "HERMES_WEB_DIST" not in os.environ and not getattr(args, "skip_build", False): if not _build_web_ui(PROJECT_ROOT / "apps" / "dashboard", fatal=True): sys.exit(1) + elif getattr(args, "skip_build", False): + # --skip-build trusts the caller to have pre-built the web UI. + # Verify the dist actually exists; otherwise the server will start + # and serve 404s with no obvious cause (issue #23817). + _dist_root = ( + Path(os.environ["HERMES_WEB_DIST"]) + if "HERMES_WEB_DIST" in os.environ + else PROJECT_ROOT / "hermes_cli" / "web_dist" + ) + if not (_dist_root / "index.html").exists(): + print(f"✗ --skip-build was passed but no web dist found at: {_dist_root}") + print(" Pre-build first: cd apps/dashboard && npm install && npm run build") + print(" Or drop --skip-build to build automatically.") + sys.exit(1) + print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}") from hermes_cli.web_server import start_server @@ -9179,6 +9342,14 @@ def main(): except Exception: pass + # Sweep stale ``hermes.exe.old.*`` quarantine files left by previous + # ``hermes update`` runs on Windows. Silent no-op on non-Windows or when + # there's nothing to clean. See ``_quarantine_running_hermes_exe``. + try: + _cleanup_quarantined_exes() + except Exception: + pass + from hermes_cli._parser import build_top_level_parser parser, subparsers, chat_parser = build_top_level_parser() @@ -10571,9 +10742,9 @@ Examples: mem_dir = get_hermes_home() / "memories" target = getattr(args, "target", "all") files_to_reset = [] - if target in ("all", "memory"): + if target in {"all", "memory"}: files_to_reset.append(("MEMORY.md", "agent notes")) - if target in ("all", "user"): + if target in {"all", "user"}: files_to_reset.append(("USER.md", "user profile")) # Check what exists @@ -10684,7 +10855,7 @@ Examples: def cmd_tools(args): action = getattr(args, "tools_action", None) - if action in ("list", "disable", "enable"): + if action in {"list", "disable", "enable"}: from hermes_cli.tools_config import tools_disable_enable_command tools_disable_enable_command(args) @@ -10893,7 +11064,7 @@ Examples: def _confirm_prompt(prompt: str) -> bool: """Prompt for y/N confirmation, safe against non-TTY environments.""" try: - return input(prompt).strip().lower() in ("y", "yes") + return input(prompt).strip().lower() in {"y", "yes"} except (EOFError, KeyboardInterrupt): return False @@ -11467,6 +11638,15 @@ Examples: "Alternatively set HERMES_DASHBOARD_TUI=1." ), ) + dashboard_parser.add_argument( + "--skip-build", + action="store_true", + help=( + "Skip the web UI build step and serve the existing dist directly. " + "Useful for non-interactive contexts (Windows Scheduled Tasks, CI) " + "where npm may not be available. Pre-build with: cd web && npm run build" + ), + ) # Lifecycle flags — mutually exclusive with each other and with the # start-a-server flags above (if both are passed, --stop / --status win # because they exit before the server is started). The dashboard has diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 0e1e6c5a87d..8c12ad70758 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -63,7 +63,7 @@ def _confirm(question: str, default: bool = True) -> bool: return default if not val: return default - return val in ("y", "yes") + return val in {"y", "yes"} def _prompt(question: str, *, password: bool = False, default: str = "") -> str: @@ -375,11 +375,11 @@ def cmd_mcp_add(args): _info("Cancelled.") return - if choice in ("n", "no"): + if choice in {"n", "no"}: _info("Cancelled — server not saved.") return - if choice in ("s", "select"): + if choice in {"s", "select"}: # Interactive tool selection from hermes_cli.curses_ui import curses_checklist @@ -509,7 +509,7 @@ def cmd_mcp_list(args=None): # Enabled status enabled = cfg.get("enabled", True) if isinstance(enabled, str): - enabled = enabled.lower() in ("true", "1", "yes") + enabled = enabled.lower() in {"true", "1", "yes"} status = color("✓ enabled", Colors.GREEN) if enabled else color("✗ disabled", Colors.DIM) print(f" {name:<16} {transport:<30} {tools_str:<12} {status}") diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index d75aca5cd08..fec1f33d092 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -825,7 +825,7 @@ def switch_model( # --- Step e: detect_provider_for_model() as last resort --- _base = current_base_url or "" - is_custom = current_provider in ("custom", "local") or ( + is_custom = current_provider in {"custom", "local"} or ( "localhost" in _base or "127.0.0.1" in _base ) @@ -1079,6 +1079,7 @@ def list_authenticated_providers( from hermes_cli.models import ( OPENROUTER_MODELS, _PROVIDER_MODELS, _MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids, + get_curated_nous_model_ids, ) results: List[dict] = [] @@ -1160,9 +1161,12 @@ def list_authenticated_providers( # Build curated model lists keyed by hermes provider ID curated: dict[str, list[str]] = dict(_PROVIDER_MODELS) curated["openrouter"] = [mid for mid, _ in OPENROUTER_MODELS] - # "nous" shares OpenRouter's curated list if not separately defined - if "nous" not in curated: - curated["nous"] = curated["openrouter"] + # "nous" pulls from the remote model-catalog manifest published at + # https://hermes-agent.nousresearch.com/docs/api/model-catalog.json so + # newly added Portal models surface in the /model picker without + # requiring a Hermes release. Falls back to the in-repo + # _PROVIDER_MODELS["nous"] snapshot when the manifest is unreachable. + curated["nous"] = get_curated_nous_model_ids() # Ollama Cloud uses dynamic discovery (no static curated list) if "ollama-cloud" not in curated: from hermes_cli.models import fetch_ollama_cloud_models @@ -1521,7 +1525,7 @@ def list_authenticated_providers( api_key = os.environ.get(key_env, "").strip() if key_env else "" discover = ep_cfg.get("discover_models", True) if isinstance(discover, str): - discover = discover.lower() not in ("false", "no", "0") + discover = discover.lower() not in {"false", "no", "0"} if api_url and api_key and discover: try: from hermes_cli.models import fetch_api_models diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 1dc8a7aca66..c23bd397e3f 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -110,16 +110,16 @@ def _codex_curated_models() -> list[str]: # $HERMES_HOME/models_dev_cache.json as of 2026-04-28. Whenever xAI renames # or retires a model, the disk cache picks it up on the next refresh and the # fallback here only matters until that refresh lands. +# +# Models retired by xAI on May 15, 2026 are excluded — see +# https://docs.x.ai/developers/migration/may-15-retirement +# (grok-4, grok-4-0709, grok-4-fast{,-reasoning,-non-reasoning}, +# grok-4-1-fast{,-reasoning,-non-reasoning}, grok-code-fast-1 → grok-4.3). _XAI_STATIC_FALLBACK: list[str] = [ "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-4.20-multi-agent-0309", - "grok-4-1-fast", - "grok-4-1-fast-non-reasoning", - "grok-4-fast", - "grok-4-fast-non-reasoning", - "grok-4", - "grok-code-fast-1", + "grok-4.3", ] @@ -210,7 +210,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "gemini-3-pro-preview", "gemini-3-flash-preview", "gemini-2.5-pro", - "grok-code-fast-1", ], "gemini": [ "gemini-3.1-pro-preview", @@ -819,7 +818,7 @@ try: for _pp in _list_providers_for_canonical(): if _pp.name in _canonical_slugs: continue - if _pp.auth_type in ("oauth_device_code", "oauth_external", "external_process", "aws_sdk", "copilot"): + if _pp.auth_type in {"oauth_device_code", "oauth_external", "external_process", "aws_sdk", "copilot"}: continue # non-api-key flows need bespoke picker UX; skip auto-inject _label = _pp.display_name or _pp.name _desc = _pp.description or f"{_label} (direct API)" @@ -2336,7 +2335,7 @@ def _lmstudio_fetch_raw_models( with urllib.request.urlopen(request, timeout=timeout) as resp: payload = json.loads(resp.read().decode()) except urllib.error.HTTPError as exc: - if exc.code in (401, 403): + if exc.code in {401, 403}: from hermes_cli.auth import AuthError raise AuthError( f"LM Studio rejected the request with HTTP {exc.code}.", @@ -3271,7 +3270,7 @@ def validate_requested_model( # MiniMax providers don't expose a /models endpoint — validate against # the static catalog instead, similar to openai-codex. - if normalized in ("minimax", "minimax-cn"): + if normalized in {"minimax", "minimax-cn"}: try: catalog_models = provider_model_ids(normalized) except Exception: diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 15ef7920a15..70b0dc9cd7f 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -86,9 +86,9 @@ logger = logging.getLogger(__name__) # The env var is read once at import time; tests that need to flip it # mid-process can call ``_install_plugin_debug_handler(force=True)``. -_PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in ( +_PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in { "1", "true", "yes", "on", -) +} _DEBUG_HANDLER_INSTALLED = False @@ -100,9 +100,9 @@ def _install_plugin_debug_handler(force: bool = False) -> None: """ global _DEBUG_HANDLER_INSTALLED, _PLUGINS_DEBUG if force: - _PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in ( + _PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in { "1", "true", "yes", "on", - ) + } if not _PLUGINS_DEBUG or _DEBUG_HANDLER_INSTALLED: return handler = logging.StreamHandler(sys.stderr) @@ -290,6 +290,27 @@ class PluginContext: def __init__(self, manifest: PluginManifest, manager: "PluginManager"): self.manifest = manifest self._manager = manager + # Lazy-built host-owned LLM facade — see ctx.llm property below. + self._llm: Any = None + + # -- host-owned LLM access ---------------------------------------------- + + @property + def llm(self) -> Any: + """Return the plugin's :class:`agent.plugin_llm.PluginLlm` facade. + + Lets trusted plugins run host-owned chat or structured completions + against the user's active model and auth without bringing their + own provider keys. Override capability (model, agent id, auth + profile) is fail-closed by default and gated through + ``plugins.entries.<plugin_id>.llm.*`` config keys. + + See :mod:`agent.plugin_llm` for the full surface.""" + if self._llm is None: + from agent.plugin_llm import PluginLlm + plugin_id = self.manifest.key or self.manifest.name + self._llm = PluginLlm(plugin_id=plugin_id) + return self._llm # -- tool registration -------------------------------------------------- @@ -803,7 +824,7 @@ class PluginManager: # Bundled platform plugins (gateway adapters like IRC) auto-load # for the same reason: every platform Hermes ships must be # available out of the box without the user having to opt in. - if manifest.source == "bundled" and manifest.kind in ("backend", "platform"): + if manifest.source == "bundled" and manifest.kind in {"backend", "platform"}: self._load_plugin(manifest) continue @@ -1054,7 +1075,7 @@ class PluginManager: ) try: - if manifest.source in ("user", "project", "bundled"): + if manifest.source in {"user", "project", "bundled"}: module = self._load_directory_module(manifest) else: module = self._load_entrypoint_module(manifest) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index cd3520016aa..675989d170e 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -85,7 +85,7 @@ def _sanitize_plugin_name(name: str, plugins_dir: Path) -> Path: if not name: raise ValueError("Plugin name must not be empty.") - if name in (".", ".."): + if name in {".", ".."}: raise ValueError( f"Invalid plugin name '{name}': must not reference the plugins directory itself." ) @@ -491,7 +491,7 @@ def cmd_install( answer = input( f" Enable '{installed_name}' now? [y/N]: ", ).strip().lower() - should_enable = answer in ("y", "yes") + should_enable = answer in {"y", "yes"} except (EOFError, KeyboardInterrupt): should_enable = False else: @@ -731,7 +731,7 @@ def _discover_all_plugins() -> list: for d in sorted(base.iterdir()): if not d.is_dir(): continue - if source == "bundled" and d.name in ("memory", "context_engine"): + if source == "bundled" and d.name in {"memory", "context_engine"}: continue manifest_file = d / "plugin.yaml" if not manifest_file.exists(): @@ -1129,10 +1129,10 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected, stdscr.refresh() key = stdscr.getch() - if key in (curses.KEY_UP, ord("k")): + if key in {curses.KEY_UP, ord("k")}: if total_items > 0: cursor = (cursor - 1) % total_items - elif key in (curses.KEY_DOWN, ord("j")): + elif key in {curses.KEY_DOWN, ord("j")}: if total_items > 0: cursor = (cursor + 1) % total_items elif key == ord(" "): @@ -1168,7 +1168,7 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected, curses.init_pair(3, curses.COLOR_CYAN, -1) curses.init_pair(4, 8, -1) curses.curs_set(0) - elif key in (curses.KEY_ENTER, 10, 13): + elif key in {curses.KEY_ENTER, 10, 13}: if cursor < n_plugins: # ENTER on a plugin checkbox — confirm and exit result_holder["plugins_changed"] = True @@ -1200,7 +1200,7 @@ def _run_composite_ui(curses, plugin_names, plugin_labels, plugin_selected, curses.init_pair(3, curses.COLOR_CYAN, -1) curses.init_pair(4, 8, -1) curses.curs_set(0) - elif key in (27, ord("q")): + elif key in {27, ord("q")}: # Save plugin changes on exit result_holder["plugins_changed"] = True return @@ -1428,10 +1428,9 @@ def _toggle_plugin_toolset(name: str, *, enable: bool) -> None: if toolset_key not in ts_list: ts_list.append(toolset_key) changed = True - else: - if toolset_key in ts_list: - ts_list.remove(toolset_key) - changed = True + elif toolset_key in ts_list: + ts_list.remove(toolset_key) + changed = True # If enabling and no platforms have toolset lists yet, add to "cli" at minimum if enable and not changed and not platform_toolsets: @@ -1570,13 +1569,13 @@ def plugins_command(args) -> None: ) elif action == "update": cmd_update(args.name) - elif action in ("remove", "rm", "uninstall"): + elif action in {"remove", "rm", "uninstall"}: cmd_remove(args.name) elif action == "enable": cmd_enable(args.name) elif action == "disable": cmd_disable(args.name) - elif action in ("list", "ls"): + elif action in {"list", "ls"}: cmd_list() elif action is None: cmd_toggle() diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index d111159c013..468a4599f84 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -989,7 +989,7 @@ def _default_export_ignore(root_dir: Path): if entry == "__pycache__" or entry.endswith((".sock", ".tmp")): ignored.add(entry) # npm lockfiles can appear at root - elif entry in ("package.json", "package-lock.json"): + elif entry in {"package.json", "package-lock.json"}: ignored.add(entry) # Root-level exclusions if Path(directory) == root_dir: @@ -1057,7 +1057,7 @@ def _normalize_profile_archive_parts(member_name: str) -> List[str]: ): raise ValueError(f"Unsafe archive member path: {member_name}") - parts = [part for part in posix_path.parts if part not in ("", ".")] + parts = [part for part in posix_path.parts if part not in {"", "."}] if not parts or any(part == ".." for part in parts): raise ValueError(f"Unsafe archive member path: {member_name}") return parts diff --git a/hermes_cli/pty_bridge.py b/hermes_cli/pty_bridge.py index f2ef8d0876d..a1779aa1dd2 100644 --- a/hermes_cli/pty_bridge.py +++ b/hermes_cli/pty_bridge.py @@ -164,7 +164,7 @@ class PtyBridge: data = os.read(self._fd, 65536) except OSError as exc: # EIO on Linux = slave side closed. EBADF = already closed. - if exc.errno in (errno.EIO, errno.EBADF): + if exc.errno in {errno.EIO, errno.EBADF}: return None raise if not data: @@ -181,7 +181,7 @@ class PtyBridge: try: n = os.write(self._fd, view) except OSError as exc: - if exc.errno in (errno.EIO, errno.EBADF, errno.EPIPE): + if exc.errno in {errno.EIO, errno.EBADF, errno.EPIPE}: return raise if n <= 0: diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index fe996d1e399..1cc41ceae95 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -260,7 +260,7 @@ def _resolve_runtime_from_pool_entry( if cfg_base_url: base_url = cfg_base_url configured_mode = _parse_api_mode(model_cfg.get("api_mode")) - if provider in ("opencode-zen", "opencode-go"): + if provider in {"opencode-zen", "opencode-go"}: # Re-derive api_mode from the effective model rather than the # persisted api_mode: the opencode providers serve both # anthropic_messages and chat_completions models, so the previous @@ -282,7 +282,7 @@ def _resolve_runtime_from_pool_entry( # Anthropic SDK prepends its own /v1/messages to the base_url. Strip the # trailing /v1 so the SDK constructs the correct path (e.g. # https://opencode.ai/zen/go/v1/messages instead of .../v1/v1/messages). - if api_mode == "anthropic_messages" and provider in ("opencode-zen", "opencode-go"): + if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}: base_url = re.sub(r"/v1/?$", "", base_url) return { @@ -859,7 +859,7 @@ def _resolve_explicit_runtime( base_url = explicit_base_url if not base_url: - if provider in ("kimi-coding", "kimi-coding-cn"): + if provider in {"kimi-coding", "kimi-coding-cn"}: creds = resolve_api_key_provider_credentials(provider) base_url = creds.get("base_url", "").rstrip("/") else: @@ -1223,7 +1223,7 @@ def resolve_runtime_provider( # trust boto3's credential chain — it handles IMDS, ECS task roles, # Lambda execution roles, SSO, and other implicit sources that our # env-var check can't detect. - is_explicit = requested_provider in ("bedrock", "aws", "aws-bedrock", "amazon-bedrock", "amazon") + is_explicit = requested_provider in {"bedrock", "aws", "aws-bedrock", "amazon-bedrock", "amazon"} if not is_explicit and not has_aws_credentials(): raise AuthError( "No AWS credentials found for Bedrock. Configure one of:\n" @@ -1303,7 +1303,7 @@ def resolve_runtime_provider( configured_provider = str(model_cfg.get("provider") or "").strip().lower() # Only honor persisted api_mode when it belongs to the same provider family. configured_mode = _parse_api_mode(model_cfg.get("api_mode")) - if provider in ("opencode-zen", "opencode-go"): + if provider in {"opencode-zen", "opencode-go"}: # opencode-zen/go must always re-derive api_mode from the # target model (not the stale persisted api_mode), because # the same provider serves both anthropic_messages @@ -1325,7 +1325,7 @@ def resolve_runtime_provider( if detected: api_mode = detected # Strip trailing /v1 for OpenCode Anthropic models (see comment above). - if api_mode == "anthropic_messages" and provider in ("opencode-zen", "opencode-go"): + if api_mode == "anthropic_messages" and provider in {"opencode-zen", "opencode-go"}: base_url = re.sub(r"/v1/?$", "", base_url) return { "provider": provider, diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index ad5d80b921f..df4e88e0006 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -89,7 +89,6 @@ _DEFAULT_PROVIDER_MODELS = { "claude-sonnet-4.5", "claude-haiku-4.5", "gemini-2.5-pro", - "grok-code-fast-1", ], "gemini": [ "gemini-3.1-pro-preview", "gemini-3-pro-preview", @@ -293,9 +292,9 @@ def prompt_yes_no(question: str, default: bool = True) -> bool: if not value: return default - if value in ("y", "yes"): + if value in {"y", "yes"}: return True - if value in ("n", "no"): + if value in {"n", "no"}: return False print_error("Please enter 'y' or 'n'") @@ -642,7 +641,7 @@ def _prompt_container_resources(config: dict): persist_str = prompt( " Persist filesystem across sessions? (yes/no)", persist_label ) - terminal["container_persistent"] = persist_str.lower() in ("yes", "true", "y", "1") + terminal["container_persistent"] = persist_str.lower() in {"yes", "true", "y", "1"} # CPU current_cpu = terminal.get("container_cpu", 1) @@ -693,7 +692,7 @@ def _prompt_vercel_sandbox_settings(config: dict): persist_label = "yes" if current_persist else "no" terminal["container_persistent"] = prompt( " Persist filesystem with snapshots? (yes/no)", persist_label - ).lower() in ("yes", "true", "y", "1") + ).lower() in {"yes", "true", "y", "1"} current_cpu = terminal.get("container_cpu", 1) cpu_str = prompt(" CPU cores", str(current_cpu)) @@ -709,7 +708,7 @@ def _prompt_vercel_sandbox_settings(config: dict): except ValueError: pass - if terminal.get("container_disk", 51200) not in (0, 51200): + if terminal.get("container_disk", 51200) not in {0, 51200}: print_warning("Vercel Sandbox does not support custom disk sizing; resetting container_disk to 51200.") terminal["container_disk"] = 51200 @@ -1356,14 +1355,13 @@ def setup_terminal_backend(config: dict): existing_sudo = get_env_value("SUDO_PASSWORD") if existing_sudo: print_info("Sudo password: configured") - else: - if prompt_yes_no( - "Enable sudo support? (stores password for apt install, etc.)", False - ): - sudo_pass = prompt(" Sudo password", password=True) - if sudo_pass: - save_env_value("SUDO_PASSWORD", sudo_pass) - print_success("Sudo password saved") + elif prompt_yes_no( + "Enable sudo support? (stores password for apt install, etc.)", False + ): + sudo_pass = prompt(" Sudo password", password=True) + if sudo_pass: + save_env_value("SUDO_PASSWORD", sudo_pass) + print_success("Sudo password saved") elif selected_backend == "docker": print_success("Terminal backend: Docker") @@ -1731,7 +1729,7 @@ def setup_agent_settings(config: dict): current_mode = cfg_get(config, "display", "tool_progress", default="all") mode = prompt("Tool progress mode", current_mode) - if mode.lower() in ("off", "new", "all", "verbose"): + if mode.lower() in {"off", "new", "all", "verbose"}: if "display" not in config: config["display"] = {} config["display"]["tool_progress"] = mode.lower() diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 3bfb0631cc4..96c02feb732 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -593,7 +593,7 @@ def do_install(identifier: str, category: str = "", force: bool = False, answer = input("Confirm [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "n" - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: c.print("[dim]Installation cancelled.[/]\n") shutil.rmtree(q_path, ignore_errors=True) return @@ -948,7 +948,7 @@ def do_uninstall(name: str, console: Optional[Console] = None, answer = input("Confirm [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "n" - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: c.print("[dim]Cancelled.[/]\n") return @@ -984,7 +984,7 @@ def do_reset(name: str, restore: bool = False, answer = input("Confirm [y/N]: ").strip().lower() except (EOFError, KeyboardInterrupt): answer = "n" - if answer not in ("y", "yes"): + if answer not in {"y", "yes"}: c.print("[dim]Cancelled.[/]\n") return @@ -1138,7 +1138,7 @@ def _github_publish(skill_path: Path, skill_name: str, target_repo: str, f"https://api.github.com/repos/{target_repo}/forks", headers=headers, timeout=30, ) - if resp.status_code in (200, 202): + if resp.status_code in {200, 202}: fork = resp.json() fork_repo = fork["full_name"] elif resp.status_code == 403: @@ -1564,7 +1564,7 @@ def handle_skills_slash(cmd: str, console: Optional[Console] = None) -> None: repo = args[1] if len(args) > 1 else "" do_tap(tap_action, repo=repo, console=c) - elif action in ("help", "--help", "-h"): + elif action in {"help", "--help", "-h"}: _print_skills_help(c) else: diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 9a40c8d9b78..b4417091ca7 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -367,7 +367,7 @@ def show_status(args): if persist is None: persist_enabled = bool(terminal_cfg.get("container_persistent", True)) else: - persist_enabled = persist.lower() in ("1", "true", "yes", "on") + persist_enabled = persist.lower() in {"1", "true", "yes", "on"} auth_status = describe_vercel_auth() sdk_ok = importlib.util.find_spec("vercel") is not None sdk_label = "installed" if sdk_ok else "missing (install: pip install 'hermes-agent[vercel]')" diff --git a/hermes_cli/stdio.py b/hermes_cli/stdio.py index 51c3f7ba530..a1733f0fe0b 100644 --- a/hermes_cli/stdio.py +++ b/hermes_cli/stdio.py @@ -105,7 +105,7 @@ def configure_windows_stdio() -> bool: _CONFIGURED = True return False - if os.environ.get("HERMES_DISABLE_WINDOWS_UTF8") in ("1", "true", "True", "yes"): + if os.environ.get("HERMES_DISABLE_WINDOWS_UTF8") in {"1", "true", "True", "yes"}: _CONFIGURED = True return False diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 74fc29247d2..81e4d327c0b 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -13,6 +13,7 @@ import json as _json import logging import os import shutil +import subprocess import sys from pathlib import Path from typing import Dict, List, Optional, Set @@ -521,10 +522,79 @@ TOOLSET_ENV_REQUIREMENTS = { # ─── Post-Setup Hooks ───────────────────────────────────────────────────────── + +def _pip_install( + args: List[str], + *, + timeout: int = 300, + capture_output: bool = True, +): + """Install Python packages from a post-setup hook. + + Strategy (in order): + 1. ``uv pip install`` if uv is on PATH — fast, doesn't need pip in the venv. + 2. ``python -m pip install`` — works on stdlib venvs. + 3. ``python -m ensurepip --upgrade`` then retry pip — covers ``uv venv`` + which creates a venv WITHOUT pip. + + Why this exists: the Windows installer creates the venv via ``uv venv``, + which doesn't seed pip. Post-setup hooks that shelled out to + ``[sys.executable, '-m', 'pip', 'install', ...]`` failed with + ``No module named pip`` on every fresh install. uv-first sidesteps that. + + Returns the ``subprocess.CompletedProcess`` from whichever tier succeeded + (or the last failure for the caller to inspect). + """ + venv_root = Path(sys.executable).parent.parent + uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)} + + uv_bin = shutil.which("uv") + if uv_bin: + try: + result = subprocess.run( + [uv_bin, "pip", "install", *args], + capture_output=capture_output, text=True, timeout=timeout, + env=uv_env, + ) + if result.returncode == 0: + return result + # Fall through to pip — uv may have failed for an unrelated reason + # (resolution conflict, network), and pip might handle it. + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + pip_cmd = [sys.executable, "-m", "pip"] + try: + # Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it). + probe = subprocess.run( + pip_cmd + ["--version"], + capture_output=True, text=True, timeout=15, + ) + if probe.returncode != 0: + raise FileNotFoundError("pip not in venv") + except (subprocess.TimeoutExpired, FileNotFoundError): + try: + subprocess.run( + [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], + capture_output=True, text=True, timeout=120, check=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + # Synthesize a result so callers see a clean failure path. + return subprocess.CompletedProcess( + pip_cmd, returncode=1, stdout="", + stderr=f"pip not available and ensurepip failed: {e}", + ) + + return subprocess.run( + pip_cmd + ["install", *args], + capture_output=capture_output, text=True, timeout=timeout, + ) + + def _run_post_setup(post_setup_key: str): """Run post-setup hooks for tools that need extra installation steps.""" import shutil - if post_setup_key in ("agent_browser", "browserbase"): + if post_setup_key in {"agent_browser", "browserbase"}: node_modules = PROJECT_ROOT / "node_modules" / "agent-browser" npm_bin = shutil.which("npm") npx_bin = shutil.which("npx") @@ -712,51 +782,43 @@ def _run_post_setup(post_setup_key: str): return except ImportError: pass - import subprocess _print_info(" Installing kittentts (~25-80MB model, CPU-only)...") wheel_url = ( "https://github.com/KittenML/KittenTTS/releases/download/" "0.8.1/kittentts-0.8.1-py3-none-any.whl" ) try: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", wheel_url, "soundfile", "--quiet"], - capture_output=True, text=True, timeout=300, - ) + result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300) if result.returncode == 0: _print_success(" kittentts installed") _print_info(" Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo") _print_info(" Models: KittenML/kitten-tts-nano-0.8-int8 (25MB), micro (41MB), mini (80MB)") else: _print_warning(" kittentts install failed:") - _print_info(f" {result.stderr.strip()[:300]}") - _print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile") + _print_info(f" {(result.stderr or '').strip()[:300]}") + _print_info(f" Run manually: uv pip install -U '{wheel_url}' soundfile") except subprocess.TimeoutExpired: _print_warning(" kittentts install timed out (>5min)") - _print_info(f" Run manually: python -m pip install -U '{wheel_url}' soundfile") + _print_info(f" Run manually: uv pip install -U '{wheel_url}' soundfile") elif post_setup_key == "piper": try: __import__("piper") _print_success(" piper-tts is already installed") except ImportError: - import subprocess _print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...") try: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", "piper-tts", "--quiet"], - capture_output=True, text=True, timeout=300, - ) + result = _pip_install(["-U", "piper-tts", "--quiet"], timeout=300) if result.returncode == 0: _print_success(" piper-tts installed") else: _print_warning(" piper-tts install failed:") - _print_info(f" {result.stderr.strip()[:300]}") - _print_info(" Run manually: python -m pip install -U piper-tts") + _print_info(f" {(result.stderr or '').strip()[:300]}") + _print_info(" Run manually: uv pip install -U piper-tts") return except subprocess.TimeoutExpired: _print_warning(" piper-tts install timed out (>5min)") - _print_info(" Run manually: python -m pip install -U piper-tts") + _print_info(" Run manually: uv pip install -U piper-tts") return _print_info(" Default voice: en_US-lessac-medium (downloaded on first TTS call)") _print_info(" Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md") @@ -767,23 +829,19 @@ def _run_post_setup(post_setup_key: str): __import__("ddgs") _print_success(" ddgs is already installed") except ImportError: - import subprocess _print_info(" Installing ddgs (DuckDuckGo search package)...") try: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-U", "ddgs", "--quiet"], - capture_output=True, text=True, timeout=300, - ) + result = _pip_install(["-U", "ddgs", "--quiet"], timeout=300) if result.returncode == 0: _print_success(" ddgs installed") else: _print_warning(" ddgs install failed:") - _print_info(f" {result.stderr.strip()[:300]}") - _print_info(" Run manually: python -m pip install -U ddgs") + _print_info(f" {(result.stderr or '').strip()[:300]}") + _print_info(" Run manually: uv pip install -U ddgs") return except subprocess.TimeoutExpired: _print_warning(" ddgs install timed out (>5min)") - _print_info(" Run manually: python -m pip install -U ddgs") + _print_info(" Run manually: uv pip install -U ddgs") return _print_info(" No API key required. DuckDuckGo enforces server-side rate limits.") _print_info(" Pair with an extract provider if you also need web_extract.") @@ -824,18 +882,7 @@ def _run_post_setup(post_setup_key: str): tinker_dir = PROJECT_ROOT / "tinker-atropos" if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists(): _print_info(" Installing tinker-atropos submodule...") - import subprocess - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [uv_bin, "pip", "install", "--python", sys.executable, "-e", str(tinker_dir)], - capture_output=True, text=True - ) - else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "-e", str(tinker_dir)], - capture_output=True, text=True - ) + result = _pip_install(["-e", str(tinker_dir)]) if result.returncode == 0: _print_success(" tinker-atropos installed") else: @@ -852,16 +899,12 @@ def _run_post_setup(post_setup_key: str): __import__("langfuse") _print_success(" langfuse SDK already installed") except ImportError: - import subprocess _print_info(" Installing langfuse SDK...") - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "langfuse", "--quiet"], - capture_output=True, text=True, timeout=120, - ) + 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: pip install langfuse") + _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). @@ -1588,7 +1631,7 @@ def _is_provider_active(provider: dict, config: dict) -> bool: image_cfg = config.get("image_gen", {}) if isinstance(image_cfg, dict): configured_provider = image_cfg.get("provider") - if configured_provider not in (None, "", "fal"): + if configured_provider not in {None, "", "fal"}: return False if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False): return False @@ -1621,7 +1664,7 @@ def _is_provider_active(provider: dict, config: dict) -> bool: configured_provider = image_cfg.get("provider") return ( provider["imagegen_backend"] == "fal" - and configured_provider in (None, "", "fal") + and configured_provider in {None, "", "fal"} and not is_truthy_value(image_cfg.get("use_gateway"), default=False) ) return False @@ -1871,7 +1914,7 @@ def _configure_provider(provider: dict, config: dict): # For tools without a specific config key (e.g. image_gen), still # track use_gateway so the runtime knows the user's intent. - if managed_feature and managed_feature not in ("web", "tts", "browser"): + if managed_feature and managed_feature not in {"web", "tts", "browser"}: config.setdefault(managed_feature, {})["use_gateway"] = True elif not managed_feature: # User picked a non-gateway provider — find which category this @@ -1903,7 +1946,7 @@ def _configure_provider(provider: dict, config: dict): # image_gen.provider clear so the dispatch shim falls through # to the legacy FAL path. img_cfg = config.setdefault("image_gen", {}) - if isinstance(img_cfg, dict) and img_cfg.get("provider") not in (None, "", "fal"): + if isinstance(img_cfg, dict) and img_cfg.get("provider") not in {None, "", "fal"}: img_cfg["provider"] = "fal" return @@ -1948,7 +1991,7 @@ def _configure_provider(provider: dict, config: dict): if backend: _configure_imagegen_model(backend, config) img_cfg = config.setdefault("image_gen", {}) - if isinstance(img_cfg, dict) and img_cfg.get("provider") not in (None, "", "fal"): + if isinstance(img_cfg, dict) and img_cfg.get("provider") not in {None, "", "fal"}: img_cfg["provider"] = "fal" @@ -2143,7 +2186,7 @@ def _reconfigure_provider(provider: dict, config: dict): web_cfg["use_gateway"] = bool(managed_feature) _print_success(f" Web backend set to: {provider['web_backend']}") - if managed_feature and managed_feature not in ("web", "tts", "browser"): + if managed_feature and managed_feature not in {"web", "tts", "browser"}: section = config.setdefault(managed_feature, {}) if not isinstance(section, dict): section = {} @@ -2492,7 +2535,7 @@ def _configure_mcp_tools_interactive(config: dict): # Count enabled servers enabled_names = [ k for k, v in mcp_servers.items() - if v.get("enabled", True) not in (False, "false", "0", "no", "off") + if v.get("enabled", True) not in {False, "false", "0", "no", "off"} ] if not enabled_names: _print_info("All MCP servers are disabled.") diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index f14c2358750..2d781e754ae 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -490,7 +490,7 @@ def run_uninstall(args): print("Cancelled.") return - if choice == "3" or choice.lower() in ("c", "cancel", "q", "quit", "n", "no"): + if choice == "3" or choice.lower() in {"c", "cancel", "q", "quit", "n", "no"}: print() print("Uninstall cancelled.") return @@ -517,7 +517,7 @@ def run_uninstall(args): print() print("Cancelled.") return - remove_profiles = resp in ("y", "yes") + remove_profiles = resp in {"y", "yes"} # Final confirmation print() diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 480daf75a8b..dfa4776684b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -186,7 +186,7 @@ def _is_accepted_host(host_header: str, bound_host: str) -> bool: # 0.0.0.0 bind means operator explicitly opted into all-interfaces # (requires --insecure per web_server.start_server). No Host-layer # defence can protect that mode; rely on operator network controls. - if bound_host in ("0.0.0.0", "::"): + if bound_host in {"0.0.0.0", "::"}: return True # Loopback bind: accept the loopback names @@ -232,7 +232,7 @@ async def host_header_middleware(request: Request, call_next): async def auth_middleware(request: Request, call_next): """Require the session token on all /api/ routes except the public list.""" path = request.url.path - if path.startswith("/api/") and path not in _PUBLIC_API_PATHS and not path.startswith("/api/plugins/"): + if path.startswith("/api/") and path not in _PUBLIC_API_PATHS: if not _has_valid_session_token(request): return JSONResponse( status_code=401, @@ -397,7 +397,7 @@ def _build_schema_from_config( full_key = f"{prefix}.{key}" if prefix else key # Skip internal / version keys - if full_key in ("_config_version",): + if full_key in {"_config_version",}: continue # Category is the first path component for nested keys, or "general" @@ -622,13 +622,13 @@ async def get_status(): gateway_exit_reason = runtime.get("exit_reason") gateway_updated_at = runtime.get("updated_at") if not gateway_running: - gateway_state = gateway_state if gateway_state in ("stopped", "startup_failed") else "stopped" + gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped" gateway_platforms = {} elif gateway_running and remote_health_body is not None: # The health probe confirmed the gateway is alive, but the local # runtime status file may be stale (cross-container). Override # stopped/None state so the dashboard shows the correct badge. - if gateway_state in (None, "stopped"): + if gateway_state in {None, "stopped"}: gateway_state = "running" # If there was no runtime info at all but the health probe confirmed alive, @@ -1313,7 +1313,7 @@ async def set_model_assignment(body: ModelAssignment): model = (body.model or "").strip() task = (body.task or "").strip().lower() - if scope not in ("main", "auxiliary"): + if scope not in {"main", "auxiliary"}: raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'") try: @@ -1428,14 +1428,13 @@ def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: else: disk_model.pop("context_length", None) config["model"] = disk_model - else: - # Model was previously a bare string — upgrade to dict if - # user is setting a context_length override - if ctx_override > 0: - config["model"] = { - "default": model_val, - "context_length": ctx_override, - } + # Model was previously a bare string — upgrade to dict if + # user is setting a context_length override + elif ctx_override > 0: + config["model"] = { + "default": model_val, + "context_length": ctx_override, + } except Exception: pass # can't read disk config — just use the string form return config @@ -2324,7 +2323,7 @@ async def disconnect_oauth_provider(provider_id: str, request: Request): # AND forget the Claude Code import. We don't touch ~/.claude/* directly # — that's owned by the Claude Code CLI; users can re-auth there if they # want to undo a disconnect. - if provider_id in ("anthropic", "claude-code"): + if provider_id in {"anthropic", "claude-code"}: try: from agent.anthropic_adapter import _HERMES_OAUTH_FILE if _HERMES_OAUTH_FILE.exists(): @@ -2780,7 +2779,7 @@ def _codex_full_login_worker(session_id: str) -> None: if poll.status_code == 200: code_resp = poll.json() break - if poll.status_code in (403, 404): + if poll.status_code in {403, 404}: continue # user hasn't authorized yet raise RuntimeError(f"deviceauth/token poll returned {poll.status_code}") @@ -3781,7 +3780,7 @@ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"}) def _is_public_bind() -> bool: """True when bound to all-interfaces (operator used --insecure).""" - return getattr(app.state, "bound_host", "") in ("0.0.0.0", "::") + return getattr(app.state, "bound_host", "") in {"0.0.0.0", "::"} def _ws_client_is_allowed(ws: "WebSocket") -> bool: @@ -4384,7 +4383,7 @@ def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any] if isinstance(radius, str) and radius.strip(): layout["radius"] = radius density = layout_src.get("density") - if isinstance(density, str) and density in ("compact", "comfortable", "spacious"): + if isinstance(density, str) and density in {"compact", "comfortable", "spacious"}: layout["density"] = density # Color overrides — keep only valid keys with string values. @@ -4717,7 +4716,7 @@ def _merged_plugins_hub() -> Dict[str, Any]: pass can_remove_update = ( - source in ("user", "git") and under_user_tree and Path(dir_str).is_dir() + source in {"user", "git"} and under_user_tree and Path(dir_str).is_dir() ) # Check if this plugin provides tools that require auth diff --git a/hermes_cli/webhook.py b/hermes_cli/webhook.py index 4b74204bcc4..621acc82e27 100644 --- a/hermes_cli/webhook.py +++ b/hermes_cli/webhook.py @@ -124,11 +124,11 @@ def webhook_command(args): if not _require_webhook_enabled(): return - if sub in ("subscribe", "add"): + if sub in {"subscribe", "add"}: _cmd_subscribe(args) - elif sub in ("list", "ls"): + elif sub in {"list", "ls"}: _cmd_list(args) - elif sub in ("remove", "rm"): + elif sub in {"remove", "rm"}: _cmd_remove(args) elif sub == "test": _cmd_test(args) diff --git a/hermes_state.py b/hermes_state.py index b86f44ca2a6..f2973f1d76e 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -215,6 +215,9 @@ CREATE TABLE IF NOT EXISTS sessions ( pricing_version TEXT, title TEXT, api_call_count INTEGER DEFAULT 0, + handoff_state TEXT, + handoff_platform TEXT, + handoff_error TEXT, FOREIGN KEY (parent_session_id) REFERENCES sessions(id) ); @@ -1968,7 +1971,7 @@ class SessionDB: # Route to LIKE when any non-operator CJK token is <3 CJK chars. _tokens_for_check = [ t for t in raw_query.split() - if t.upper() not in ("AND", "OR", "NOT") and self._contains_cjk(t) + if t.upper() not in {"AND", "OR", "NOT"} and self._contains_cjk(t) ] _any_short_cjk = any( self._count_cjk(t) < 3 for t in _tokens_for_check @@ -1981,7 +1984,7 @@ class SessionDB: tokens = raw_query.split() parts = [] for tok in tokens: - if tok.upper() in ("AND", "OR", "NOT"): + if tok.upper() in {"AND", "OR", "NOT"}: parts.append(tok) else: parts.append('"' + tok.replace('"', '""') + '"') @@ -2032,7 +2035,7 @@ class SessionDB: # is matched independently (#20494). non_op_tokens = [ t for t in raw_query.split() - if t.upper() not in ("AND", "OR", "NOT") + if t.upper() not in {"AND", "OR", "NOT"} ] or [raw_query] token_clauses = [] like_params: list = [] @@ -2345,7 +2348,7 @@ class SessionDB: "SELECT id FROM sessions WHERE started_at < ? AND ended_at IS NOT NULL", (cutoff,), ) - session_ids = set(row["id"] for row in cursor.fetchall()) + session_ids = {row["id"] for row in cursor.fetchall()} if not session_ids: return 0 @@ -2872,3 +2875,103 @@ class SessionDB: return result + # ── Handoff (cross-platform session transfer) ────────────────────────── + # + # State machine: + # None — no handoff in flight + # "pending" — CLI requested handoff, gateway hasn't picked it up yet + # "running" — gateway is processing (session switch + synthetic turn) + # "completed"— gateway successfully delivered the synthetic turn + # "failed" — gateway hit an error; reason in handoff_error + # + # The CLI writes "pending" then poll-waits for terminal state. The gateway + # watcher transitions pending→running→{completed,failed}. + + def request_handoff(self, session_id: str, platform: str) -> bool: + """Mark a session as pending handoff to the given platform. + + Returns True if the row was found and not already in flight; False if + the session is already in a non-terminal handoff state. + """ + def _do(conn): + cur = conn.execute( + "UPDATE sessions " + "SET handoff_state = 'pending', " + " handoff_platform = ?, " + " handoff_error = NULL " + "WHERE id = ? AND (handoff_state IS NULL " + " OR handoff_state IN ('completed', 'failed'))", + (platform, session_id), + ) + return cur.rowcount > 0 + return self._execute_write(_do) + + def get_handoff_state(self, session_id: str) -> Optional[Dict[str, Any]]: + """Read the current handoff state for a session. + + Returns ``{"state", "platform", "error"}`` or None if the session has + no handoff record. + """ + try: + cur = self._conn.execute( + "SELECT handoff_state, handoff_platform, handoff_error " + "FROM sessions WHERE id = ?", + (session_id,), + ) + row = cur.fetchone() + if not row: + return None + return { + "state": row["handoff_state"], + "platform": row["handoff_platform"], + "error": row["handoff_error"], + } + except Exception: + return None + + def list_pending_handoffs(self) -> List[Dict[str, Any]]: + """Return all sessions in handoff_state='pending', oldest first. + + Used by the gateway's handoff watcher. + """ + try: + cur = self._conn.execute( + "SELECT * FROM sessions " + "WHERE handoff_state = 'pending' " + "ORDER BY started_at ASC" + ) + return [dict(r) for r in cur.fetchall()] + except Exception: + return [] + + def claim_handoff(self, session_id: str) -> bool: + """Atomically transition pending → running. Returns True if claimed.""" + def _do(conn): + cur = conn.execute( + "UPDATE sessions SET handoff_state = 'running' " + "WHERE id = ? AND handoff_state = 'pending'", + (session_id,), + ) + return cur.rowcount > 0 + return self._execute_write(_do) + + def complete_handoff(self, session_id: str) -> None: + """Mark a handoff as completed.""" + def _do(conn): + conn.execute( + "UPDATE sessions SET handoff_state = 'completed', " + "handoff_error = NULL WHERE id = ?", + (session_id,), + ) + self._execute_write(_do) + + def fail_handoff(self, session_id: str, error: str) -> None: + """Mark a handoff as failed and record the reason.""" + def _do(conn): + conn.execute( + "UPDATE sessions SET handoff_state = 'failed', " + "handoff_error = ? WHERE id = ?", + (error[:500], session_id), + ) + self._execute_write(_do) + diff --git a/locales/af.yaml b/locales/af.yaml new file mode 100644 index 00000000000..264b4b321a5 --- /dev/null +++ b/locales/af.yaml @@ -0,0 +1,350 @@ +# Hermes statiese boodskap-katalogus -- Afrikaans +# See locales/en.yaml for the source of truth; keep keys in sync. + +approval: + dangerous_header: "⚠️ GEVAARLIKE OPDRAG: {description}" + choose_long: " [o]eenmalig | [s]sessie | [a]altyd | [d]weier" + choose_short: " [o]eenmalig | [s]sessie | [d]weier" + prompt_long: " Keuse [o/s/a/D]: " + prompt_short: " Keuse [o/s/D]: " + timeout: " ⏱ Tyd verstreke - opdrag word geweier" + allowed_once: " ✓ Eenmalig toegelaat" + allowed_session: " ✓ Vir hierdie sessie toegelaat" + allowed_always: " ✓ By permanente toelaatlys gevoeg" + denied: " ✗ Geweier" + cancelled: " ✗ Gekanselleer" + blocklist_message: "Hierdie opdrag is op die onvoorwaardelike blokkeerlys en kan nie goedgekeur word nie." + +gateway: + approval_expired: "⚠️ Goedkeuring het verval (die agent wag nie meer nie). Vra die agent om weer te probeer." + draining: "⏳ Wag vir {count} aktiewe agent(e) voor herbegin..." + goal_cleared: "✓ Doelwit verwyder." + no_active_goal: "Geen aktiewe doelwit nie." + config_read_failed: "⚠️ Kon nie config.yaml lees nie: {error}" + config_save_failed: "⚠️ Kon nie konfigurasie stoor nie: {error}" + + model: + error_prefix: "Fout: {error}" + switched: "Model verander na `{model}`" + provider_label: "Verskaffer: {provider}" + context_label: "Konteks: {tokens} tokens" + max_output_label: "Maks. uitvoer: {tokens} tokens" + cost_label: "Koste: {cost}" + capabilities_label: "Vermoëns: {capabilities}" + prompt_caching_enabled: "Prompt-kasing: geaktiveer" + warning_prefix: "Waarskuwing: {warning}" + saved_global: "Gestoor in config.yaml (`--global`)" + session_only_hint: "_(slegs sessie — voeg `--global` by om permanent te stoor)_" + current_label: "Huidig: `{model}` op {provider}" + current_tag: " (huidig)" + more_models_suffix: " (+{count} meer)" + usage_switch_model: "`/model <name>` — verander model" + usage_switch_provider: "`/model <name> --provider <slug>` — verander verskaffer" + usage_persist: "`/model <name> --global` — stoor permanent" + + agents: + header: "🤖 **Aktiewe Agente & Take**" + active_agents: "**Aktiewe agente:** {count}" + this_chat: " · hierdie geselsie" + more: "... en nog {count}" + running_processes: "**Lopende agtergrondprosesse:** {count}" + async_jobs: "**Asinchrone werke van die gateway:** {count}" + none: "Geen aktiewe agente of lopende take nie." + state_starting: "begin" + state_running: "loop" + + approve: + no_pending: "Geen hangende opdrag om goed te keur nie." + once_singular: "✅ Opdrag goedgekeur. Die agent gaan voort..." + once_plural: "✅ Opdragte goedgekeur ({count} opdragte). Die agent gaan voort..." + session_singular: "✅ Opdrag goedgekeur (patroon goedgekeur vir hierdie sessie). Die agent gaan voort..." + session_plural: "✅ Opdragte goedgekeur (patroon goedgekeur vir hierdie sessie) ({count} opdragte). Die agent gaan voort..." + always_singular: "✅ Opdrag goedgekeur (patroon permanent goedgekeur). Die agent gaan voort..." + always_plural: "✅ Opdragte goedgekeur (patroon permanent goedgekeur) ({count} opdragte). Die agent gaan voort..." + + background: + usage: "Gebruik: /background <prompt>\nVoorbeeld: /background Som vandag se top HN-stories op\n\nVoer die prompt in 'n aparte sessie uit. Jy kan aanhou gesels — die resultaat verskyn hier wanneer dit klaar is." + started: "🔄 Agtergrondtaak begin: \"{preview}\"\nTaak-ID: {task_id}\nJy kan aanhou gesels — resultate verskyn hier wanneer dit klaar is." + + branch: + db_unavailable: "Sessie-databasis is nie beskikbaar nie." + no_conversation: "Geen gesprek om te vertak nie — stuur eers 'n boodskap." + create_failed: "Kon nie tak skep nie: {error}" + switch_failed: "Tak is geskep, maar oorskakeling het misluk." + branched_one: "⑂ Vertak na **{title}** ({count} boodskap gekopieer)\nOorspronklik: `{parent}`\nTak: `{new}`\nGebruik `/resume` om terug te gaan na die oorspronklike." + branched_many: "⑂ Vertak na **{title}** ({count} boodskappe gekopieer)\nOorspronklik: `{parent}`\nTak: `{new}`\nGebruik `/resume` om terug te gaan na die oorspronklike." + + commands: + usage: "Gebruik: `/commands [page]`" + skill_header: "⚡ **Vaardigheidsopdragte**:" + default_desc: "Vaardigheidsopdrag" + none: "Geen opdragte beskikbaar nie." + header: "📚 **Opdragte** ({total} altesaam, bladsy {page}/{total_pages})" + nav_prev: "`/commands {page}` ← vorige" + nav_next: "volgende → `/commands {page}`" + out_of_range: "_(Versoekte bladsy {requested} was buite reikwydte; bladsy {page} word vertoon.)_" + + compress: + not_enough: "Nie genoeg gesprek om saam te pers nie (ten minste 4 boodskappe nodig)." + no_provider: "Geen verskaffer opgestel nie -- kan nie saampers nie." + nothing_to_do: "Niks om saam te pers nie (die transkripsie is steeds heeltemal beskermde konteks)." + focus_line: "Fokus: \"{topic}\"" + summary_failed: "⚠️ Opsomming kon nie gegenereer word nie ({error}). {count} historiese boodskap(pe) is verwyder en met 'n plekhouer vervang; vroeëre konteks kan nie meer herstel word nie. Oorweeg om jou auxiliary.compression-modelopstelling na te gaan." + aux_failed: "ℹ️ Opgestelde saamperseringsmodel `{model}` het misluk ({error}). Herstel met jou hoofmodel — konteks is intakt — maar jy mag dalk `auxiliary.compression.model` in config.yaml wil nagaan." + failed: "Saampersing het misluk: {error}" + + debug: + upload_failed: "✗ Kon nie ontfoutverslag oplaai nie: {error}" + header: "**Ontfoutverslag opgelaai:**" + auto_delete: "⏱ Plakke sal outomaties oor 6 uur uitgevee word." + full_logs_hint: "Vir volledige loglae, gebruik `hermes debug share` vanaf die CLI." + share_hint: "Deel hierdie skakels met die Hermes-span vir ondersteuning." + + deny: + stale: "❌ Opdrag geweier (goedkeuring was verouderd)." + no_pending: "Geen hangende opdrag om te weier nie." + denied_singular: "❌ Opdrag geweier." + denied_plural: "❌ Opdragte geweier ({count} opdragte)." + + fast: + not_supported: "⚡ /fast is slegs beskikbaar vir OpenAI-modelle wat Priority Processing ondersteun." + status: "⚡ Priority Processing\n\nHuidige modus: `{mode}`\n\n_Gebruik:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Onbekende argument: `{arg}`\n\n**Geldige opsies:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (gestoor in konfigurasie)\n_(neem effek by die volgende boodskap)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (slegs hierdie sessie)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Looptyd-voetstuk: **{state}**\nVelde: `{fields}`\nPlatform: `{platform}`" + usage: "Gebruik: `/footer [on|off|status]`" + saved: "📎 Looptyd-voetstuk: **{state}**{example}\n_(globaal gestoor — neem effek by die volgende boodskap)_" + example_line: "\nVoorbeeld: `{preview}`" + state_on: "AAN" + state_off: "AF" + + goal: + unavailable: "Doelwitte is nie beskikbaar in hierdie sessie nie." + no_goal_set: "Geen doelwit gestel nie." + paused: "⏸ Doelwit gepouse: {goal}" + no_resume: "Geen doelwit om voort te sit nie." + resumed: "▶ Doelwit hervat: {goal}\nStuur enige boodskap om voort te gaan, of wag — ek sal die volgende stap met die volgende beurt neem." + invalid: "Ongeldige doelwit: {error}" + set: "⊙ Doelwit gestel ({budget}-beurt-begroting): {goal}\nEk sal aanhou werk totdat die doelwit klaar is, jy dit pouseer/verwyder, of die begroting opgebruik is.\nBeheer: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Hermes-opdragte**\n" + skill_header: "\n⚡ **Vaardigheidsopdragte** ({count} aktief):" + more_use_commands: "\n... en nog {count}. Gebruik `/commands` vir die volledige bladsy-lys." + + insights: + invalid_days: "Ongeldige --days waarde: {value}" + error: "Fout met genereer van insigte: {error}" + + kanban: + error_prefix: "⚠ kanban-fout: {error}" + subscribed_suffix: "(ingeteken — jy sal in kennis gestel word wanneer {task_id} voltooi of vasval)" + truncated_suffix: "… (afgekap; gebruik `hermes kanban …` in jou terminale vir volle uitvoer)" + no_output: "(geen uitvoer)" + + personality: + none_configured: "Geen persoonlikhede opgestel in `{path}/config.yaml` nie" + header: "🎭 **Beskikbare Persoonlikhede**\n" + none_option: "• `none` — (geen persoonlikheidslaag)" + item: "• `{name}` — {preview}" + usage: "\nGebruik: `/personality <name>`" + save_failed: "⚠️ Kon nie persoonlikheidsverandering stoor nie: {error}" + cleared: "🎭 Persoonlikheid verwyder — basis-agentgedrag word gebruik.\n_(neem effek by die volgende boodskap)_" + set_to: "🎭 Persoonlikheid gestel op **{name}**\n_(neem effek by die volgende boodskap)_" + unknown: "Onbekende persoonlikheid: `{name}`\n\nBeskikbaar: {available}" + + profile: + header: "👤 **Profiel:** `{profile}`" + home: "📂 **Tuiste:** `{home}`" + + reasoning: + level_default: "medium (verstek)" + level_disabled: "none (gedeaktiveer)" + scope_session: "sessie-oorskryf" + scope_global: "globale konfigurasie" + status: "🧠 **Redenering-instellings**\n\n**Inspanning:** `{level}`\n**Bereik:** {scope}\n**Vertoon:** {display}\n\n_Gebruik:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "aan ✓" + display_off: "af" + display_set_on: "🧠 ✓ Redenering-vertoon: **AAN**\nDie model se denke sal voor elke antwoord op **{platform}** vertoon word." + display_set_off: "🧠 ✓ Redenering-vertoon: **AF** vir **{platform}**" + reset_global_unsupported: "⚠️ `/reasoning reset --global` word nie ondersteun nie. Gebruik `/reasoning <level> --global` om die globale verstek te verander." + reset_done: "🧠 ✓ Sessie-redenering-oorskryf verwyder; val terug op globale konfigurasie." + unknown_arg: "⚠️ Onbekende argument: `{arg}`\n\n**Geldige vlakke:** none, minimal, low, medium, high, xhigh\n**Vertoon:** show, hide\n**Permanent:** voeg `--global` by om verby hierdie sessie te stoor" + set_global: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (gestoor in konfigurasie)\n_(neem effek by die volgende boodskap)_" + set_global_save_failed: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (slegs sessie — konfigurasie-stoor het misluk)\n_(neem effek by die volgende boodskap)_" + set_session: "🧠 ✓ Redenering-inspanning gestel op `{effort}` (slegs sessie — voeg `--global` by om permanent te stoor)\n_(neem effek by die volgende boodskap)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp gekanselleer. MCP-gereedskap onveranderd." + always_followup: "ℹ️ Toekomstige `/reload-mcp`-oproepe sal sonder bevestiging loop. Heraktiveer via `approvals.mcp_reload_confirm: true` in config.yaml." + confirm_prompt: "⚠️ **Bevestig /reload-mcp**\n\nOm MCP-bedieners te herlaai, herbou die gereedskapsstel vir hierdie sessie en **maak die verskaffer se prompt-kasie ongeldig** — die volgende boodskap sal alle invoertokens herstuur. Op modelle met lang konteks of hoë redenering kan dit duur wees.\n\nKies:\n• **Eenmaal Goedkeur** — herlaai nou\n• **Altyd Goedkeur** — herlaai nou en stop hierdie prompt permanent\n• **Kanselleer** — laat MCP-gereedskap onveranderd\n\n_Teks-alternatief: antwoord `/approve`, `/always`, of `/cancel`._" + header: "🔄 **MCP-bedieners herlaai**\n" + reconnected: "♻️ Herverbind: {names}" + added: "➕ Bygevoeg: {names}" + removed: "➖ Verwyder: {names}" + none_connected: "Geen MCP-bedieners verbind nie." + tools_available: "\n🔧 {tools} gereedskap beskikbaar van {servers} bediener(s)" + failed: "❌ MCP-herlaai het misluk: {error}" + + reload_skills: + header: "🔄 **Vaardighede herlaai**\n" + no_new: "Geen nuwe vaardighede opgespoor nie." + total: "\n📚 {count} vaardigheid(e) beskikbaar" + added_header: "➕ **Bygevoegde Vaardighede:**" + removed_header: "➖ **Verwyderde Vaardighede:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ Vaardigheids-herlaai het misluk: {error}" + + reset: + header_default: "✨ Sessie herstel! Begin van voor." + header_new: "✨ Nuwe sessie begin!" + header_titled: "✨ Nuwe sessie begin: {title}" + title_rejected: "\n⚠️ Titel verwerp: {error}" + title_error_untitled: "\n⚠️ {error} — sessie sonder titel begin." + title_empty_untitled: "\n⚠️ Titel is leeg na opruiming — sessie sonder titel begin." + tip: "\n✦ Wenk: {tip}" + + restart: + in_progress: "⏳ Gateway-herbegin reeds aan die gang..." + restarting: "♻ Herbegin van gateway. As jy nie binne 60 sekondes in kennis gestel word nie, herbegin vanaf die konsole met `hermes gateway restart`." + + resume: + db_unavailable: "Sessie-databasis is nie beskikbaar nie." + no_named_sessions: "Geen benoemde sessies gevind nie.\nGebruik `/title My Sessie` om jou huidige sessie 'n naam te gee, en dan `/resume My Sessie` om later daarheen terug te keer." + list_header: "📋 **Benoemde Sessies**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nGebruik: `/resume <session name>`" + list_failed: "Kon nie sessies lys nie: {error}" + not_found: "Geen sessie gevind wat by '**{name}**' pas nie.\nGebruik `/resume` sonder argumente om beskikbare sessies te sien." + already_on: "📌 Reeds op sessie **{name}**." + switch_failed: "Kon nie sessie verander nie." + resumed_one: "↻ Sessie **{title}** hervat ({count} boodskap). Gesprek herstel." + resumed_many: "↻ Sessie **{title}** hervat ({count} boodskappe). Gesprek herstel." + resumed_no_count: "↻ Sessie **{title}** hervat. Gesprek herstel." + + retry: + no_previous: "Geen vorige boodskap om te herhaal nie." + + rollback: + not_enabled: "Kontrolepunte is nie geaktiveer nie.\nAktiveer in config.yaml:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "Geen kontrolepunte vir {cwd} gevind nie" + invalid_number: "Ongeldige kontrolepunt-nommer. Gebruik 1-{max}." + restored: "✅ Herstel na kontrolepunt {hash}: {reason}\n'n Voor-terugrol-momentopname is outomaties gestoor." + restore_failed: "❌ {error}" + + set_home: + save_failed: "Kon nie tuiste-kanaal stoor nie: {error}" + success: "✅ Tuiste-kanaal gestel op **{name}** (ID: {chat_id}).\nKron-take en kruisplatform-boodskappe sal hier afgelewer word." + + status: + header: "📊 **Hermes Gateway Status**" + session_id: "**Sessie-ID:** `{session_id}`" + title: "**Titel:** {title}" + created: "**Geskep:** {timestamp}" + last_activity: "**Laaste aktiwiteit:** {timestamp}" + tokens: "**Tokens:** {tokens}" + agent_running: "**Agent loop:** {state}" + state_yes: "Ja ⚡" + state_no: "Nee" + queued: "**Opgehoopte opvolge:** {count}" + platforms: "**Verbinde Platforms:** {platforms}" + + stop: + stopped_pending: "⚡ Gestop. Die agent het nog nie begin nie — jy kan met hierdie sessie voortgaan." + stopped: "⚡ Gestop. Jy kan met hierdie sessie voortgaan." + no_active: "Geen aktiewe taak om te stop nie." + + title: + db_unavailable: "Sessie-databasis is nie beskikbaar nie." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ Titel is leeg na opruiming. Gebruik asseblief drukbare karakters." + set_to: "✏️ Sessie-titel gestel: **{title}**" + not_found: "Sessie nie in databasis gevind nie." + current_with_title: "📌 Sessie: `{session_id}`\nTitel: **{title}**" + current_no_title: "📌 Sessie: `{session_id}`\nGeen titel gestel nie. Gebruik: `/title My Sessie Naam`" + + topic: + not_telegram_dm: "Die /topic-opdrag is slegs beskikbaar in Telegram-privaatgesprekke." + no_session_db: "Sessie-databasis is nie beskikbaar nie." + unauthorized: "Jy het nie toestemming om /topic op hierdie bot te gebruik nie." + restore_needs_topic: "Om 'n sessie te herstel, skep of open eers 'n Telegram-onderwerp en stuur dan /topic <session-id> binne daardie onderwerp. Om 'n nuwe onderwerp te skep, open All Messages en stuur enige boodskap daar." + topics_disabled: "Telegram-onderwerpe is nog nie vir hierdie bot geaktiveer nie.\n\nHoe om dit te aktiveer:\n1. Open @BotFather.\n2. Kies jou bot.\n3. Open Bot Settings → Threads Settings.\n4. Skakel Threaded Mode aan en maak seker gebruikers mag nuwe drade skep.\n\nStuur dan weer /topic." + topics_user_disallowed: "Telegram-onderwerpe is geaktiveer, maar gebruikers mag nie onderwerpe skep nie.\n\nOpen @BotFather → kies jou bot → Bot Settings → Threads Settings, en skakel dan 'Disallow users to create new threads' af.\n\nStuur dan weer /topic." + enable_failed: "Kon nie Telegram-onderwerpmodus aktiveer nie: {error}" + bound_status: "Hierdie onderwerp is gekoppel aan:\nSessie: {label}\nID: {session_id}\n\nGebruik /new om hierdie onderwerp met 'n vars sessie te vervang.\nVir parallelle werk, open All Messages en stuur 'n boodskap daar om 'n ander onderwerp te skep." + thread_ready: "Telegram multi-sessie-onderwerpe is geaktiveer.\n\nHierdie onderwerp sal as 'n onafhanklike Hermes-sessie gebruik word. Gebruik /new om hierdie onderwerp se huidige sessie te vervang. Vir parallelle werk, open All Messages en stuur 'n boodskap daar om 'n ander onderwerp te skep." + untitled_session: "Sessie sonder titel" + + undo: + nothing: "Niks om ongedaan te maak nie." + removed: "↩️ {count} boodskap(pe) ongedaan gemaak.\nVerwyder: \"{preview}\"" + + update: + platform_not_messaging: "✗ /update is slegs beskikbaar vanaf boodskapplatforms. Voer `hermes update` vanaf die terminale uit." + not_git_repo: "✗ Nie 'n git-bewaarplek nie — kan nie opdateer nie." + hermes_cmd_not_found: "✗ Kon nie die `hermes`-opdrag vind nie. Hermes loop, maar die opdateeropdrag kon nie die uitvoerbare lêer op PATH of via die huidige Python-vertolker vind nie. Probeer `hermes update` met die hand in jou terminale uitvoer." + start_failed: "✗ Kon nie opdatering begin nie: {error}" + starting: "⚕ Begin Hermes-opdatering… Ek sal vordering hier stroom." + + usage: + rate_limits: "⏱️ **Tariefperke:** {state}" + header_session: "📊 **Sessie-tokengebruik**" + label_model: "Model: `{model}`" + label_input_tokens: "Invoertokens: {count}" + label_cache_read: "Kasie-leestokens: {count}" + label_cache_write: "Kasie-skryftokens: {count}" + label_output_tokens: "Uitvoertokens: {count}" + label_total: "Totaal: {count}" + label_api_calls: "API-oproepe: {count}" + label_cost: "Koste: {prefix}${amount}" + label_cost_included: "Koste: ingesluit" + label_context: "Konteks: {used} / {total} ({pct}%)" + label_compressions: "Saamperserings: {count}" + header_session_info: "📊 **Sessie-inligting**" + label_messages: "Boodskappe: {count}" + label_estimated_context: "Geskatte konteks: ~{count} tokens" + detailed_after_first: "_(Gedetailleerde gebruik beskikbaar na die eerste agent-antwoord)_" + no_data: "Geen gebruiksdata beskikbaar vir hierdie sessie nie." + + verbose: + not_enabled: "Die `/verbose`-opdrag is nie vir boodskapplatforms geaktiveer nie.\n\nAktiveer dit in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Gereedskap-vordering: **AF** — geen gereedskap-aktiwiteit word vertoon nie." + mode_new: "⚙️ Gereedskap-vordering: **NUUT** — vertoon wanneer gereedskap verander (voorskoulengte: `display.tool_preview_length`, verstek 40)." + mode_all: "⚙️ Gereedskap-vordering: **ALMAL** — elke gereedskaps-oproep vertoon (voorskoulengte: `display.tool_preview_length`, verstek 40)." + mode_verbose: "⚙️ Gereedskap-vordering: **OMSLAGTIG** — elke gereedskaps-oproep met volle argumente." + saved_suffix: "_(gestoor vir **{platform}** — neem effek by die volgende boodskap)_" + save_failed: "_(kon nie in konfigurasie stoor nie: {error})_" + + voice: + enabled_voice_only: "Stemmodus geaktiveer.\nEk sal met stem antwoord wanneer jy stemboodskappe stuur.\nGebruik /voice tts om stemantwoorde vir alle boodskappe te kry." + disabled_text: "Stemmodus gedeaktiveer. Slegs teks-antwoorde." + tts_enabled: "Outo-TTS geaktiveer.\nAlle antwoorde sal 'n stemboodskap insluit." + status_mode: "Stemmodus: {label}" + status_channel: "Stemkanaal: #{channel}" + status_participants: "Deelnemers: {count}" + status_member: " - {name}{status}" + speaking: " (praat)" + enabled_short: "Stemmodus geaktiveer." + disabled_short: "Stemmodus gedeaktiveer." + label_off: "Af (slegs teks)" + label_voice_only: "Aan (stemantwoord op stemboodskappe)" + label_all: "TTS (stemantwoord op alle boodskappe)" + + yolo: + disabled: "⚠️ YOLO-modus **AF** vir hierdie sessie — gevaarlike opdragte sal goedkeuring vereis." + enabled: "⚡ YOLO-modus **AAN** vir hierdie sessie — alle opdragte word outomaties goedgekeur. Gebruik versigtig." + + shared: + session_db_unavailable: "Sessie-databasis is nie beskikbaar nie." + session_db_unavailable_prefix: "Sessie-databasis is nie beskikbaar" + session_not_found: "Sessie nie in databasis gevind nie." + warn_passthrough: "⚠️ {error}" diff --git a/locales/de.yaml b/locales/de.yaml index e0087c651f7..86aa0fae9ac 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -22,3 +22,329 @@ gateway: no_active_goal: "Kein aktives Ziel." config_read_failed: "⚠️ config.yaml konnte nicht gelesen werden: {error}" config_save_failed: "⚠️ Konfiguration konnte nicht gespeichert werden: {error}" + + model: + error_prefix: "Fehler: {error}" + switched: "Modell gewechselt zu `{model}`" + provider_label: "Anbieter: {provider}" + context_label: "Kontext: {tokens} Tokens" + max_output_label: "Max. Ausgabe: {tokens} Tokens" + cost_label: "Kosten: {cost}" + capabilities_label: "Fähigkeiten: {capabilities}" + prompt_caching_enabled: "Prompt-Caching: aktiviert" + warning_prefix: "Warnung: {warning}" + saved_global: "In config.yaml gespeichert (`--global`)" + session_only_hint: "_(nur für diese Sitzung — `--global` ergänzen, um zu speichern)_" + current_label: "Aktuell: `{model}` bei {provider}" + current_tag: " (aktuell)" + more_models_suffix: " (+{count} weitere)" + usage_switch_model: "`/model <name>` — Modell wechseln" + usage_switch_provider: "`/model <name> --provider <slug>` — Anbieter wechseln" + usage_persist: "`/model <name> --global` — dauerhaft speichern" + + agents: + header: "🤖 **Aktive Agenten & Aufgaben**" + active_agents: "**Aktive Agenten:** {count}" + this_chat: " · dieser Chat" + more: "... und {count} weitere" + running_processes: "**Laufende Hintergrundprozesse:** {count}" + async_jobs: "**Gateway-Async-Jobs:** {count}" + none: "Keine aktiven Agenten oder laufenden Aufgaben." + state_starting: "startet" + state_running: "läuft" + + approve: + no_pending: "Kein ausstehender Befehl zum Genehmigen." + once_singular: "✅ Befehl genehmigt. Der Agent wird fortgesetzt..." + once_plural: "✅ Befehle genehmigt ({count} Befehle). Der Agent wird fortgesetzt..." + session_singular: "✅ Befehl genehmigt (Muster für diese Sitzung genehmigt). Der Agent wird fortgesetzt..." + session_plural: "✅ Befehle genehmigt (Muster für diese Sitzung genehmigt) ({count} Befehle). Der Agent wird fortgesetzt..." + always_singular: "✅ Befehl genehmigt (Muster dauerhaft genehmigt). Der Agent wird fortgesetzt..." + always_plural: "✅ Befehle genehmigt (Muster dauerhaft genehmigt) ({count} Befehle). Der Agent wird fortgesetzt..." + + background: + usage: "Verwendung: /background <prompt>\nBeispiel: /background Fasse die Top-HN-Storys von heute zusammen\n\nFührt den Prompt in einer separaten Sitzung aus. Sie können weiter chatten — das Ergebnis erscheint hier, wenn es fertig ist." + started: "🔄 Hintergrund-Aufgabe gestartet: \"{preview}\"\nAufgaben-ID: {task_id}\nSie können weiter chatten — die Ergebnisse erscheinen hier, wenn sie fertig sind." + + branch: + db_unavailable: "Sitzungsdatenbank nicht verfügbar." + no_conversation: "Keine Konversation zum Verzweigen — senden Sie zuerst eine Nachricht." + create_failed: "Verzweigung fehlgeschlagen: {error}" + switch_failed: "Verzweigung erstellt, aber Wechsel fehlgeschlagen." + branched_one: "⑂ Verzweigt zu **{title}** ({count} Nachricht kopiert)\nOriginal: `{parent}`\nZweig: `{new}`\nVerwenden Sie `/resume`, um zum Original zurückzukehren." + branched_many: "⑂ Verzweigt zu **{title}** ({count} Nachrichten kopiert)\nOriginal: `{parent}`\nZweig: `{new}`\nVerwenden Sie `/resume`, um zum Original zurückzukehren." + + commands: + usage: "Verwendung: `/commands [page]`" + skill_header: "⚡ **Skill-Befehle**:" + default_desc: "Skill-Befehl" + none: "Keine Befehle verfügbar." + header: "📚 **Befehle** ({total} insgesamt, Seite {page}/{total_pages})" + nav_prev: "`/commands {page}` ← zurück" + nav_next: "weiter → `/commands {page}`" + out_of_range: "_(Angeforderte Seite {requested} liegt außerhalb des Bereichs, Seite {page} wird angezeigt.)_" + + compress: + not_enough: "Nicht genug Konversation zum Komprimieren (mindestens 4 Nachrichten erforderlich)." + no_provider: "Kein Anbieter konfiguriert — Komprimierung nicht möglich." + nothing_to_do: "Noch nichts zu komprimieren (das Transkript ist weiterhin vollständig geschützter Kontext)." + focus_line: "Fokus: \"{topic}\"" + summary_failed: "⚠️ Zusammenfassungsgenerierung fehlgeschlagen ({error}). {count} historische Nachricht(en) wurden entfernt und durch einen Platzhalter ersetzt; früherer Kontext ist nicht mehr wiederherstellbar. Überprüfen Sie die Konfiguration des auxiliary.compression-Modells." + aux_failed: "ℹ️ Das konfigurierte Komprimierungsmodell `{model}` ist fehlgeschlagen ({error}). Wiederherstellung mit Ihrem Hauptmodell — Kontext ist intakt — Sie sollten jedoch `auxiliary.compression.model` in config.yaml überprüfen." + failed: "Komprimierung fehlgeschlagen: {error}" + + debug: + upload_failed: "✗ Debug-Bericht konnte nicht hochgeladen werden: {error}" + header: "**Debug-Bericht hochgeladen:**" + auto_delete: "⏱ Pastes werden in 6 Stunden automatisch gelöscht." + full_logs_hint: "Für vollständige Log-Uploads verwenden Sie `hermes debug share` aus der CLI." + share_hint: "Teilen Sie diese Links mit dem Hermes-Team, um Unterstützung zu erhalten." + + deny: + stale: "❌ Befehl abgelehnt (Genehmigung war veraltet)." + no_pending: "Kein ausstehender Befehl zum Ablehnen." + denied_singular: "❌ Befehl abgelehnt." + denied_plural: "❌ Befehle abgelehnt ({count} Befehle)." + + fast: + not_supported: "⚡ /fast ist nur für OpenAI-Modelle mit Priority Processing verfügbar." + status: "⚡ Priority Processing\n\nAktueller Modus: `{mode}`\n\n_Verwendung:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Unbekanntes Argument: `{arg}`\n\n**Gültige Optionen:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (in Konfiguration gespeichert)\n_(wird ab nächster Nachricht wirksam)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (nur diese Sitzung)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Laufzeit-Fußzeile: **{state}**\nFelder: `{fields}`\nPlattform: `{platform}`" + usage: "Verwendung: `/footer [on|off|status]`" + saved: "📎 Laufzeit-Fußzeile: **{state}**{example}\n_(global gespeichert — wird ab nächster Nachricht wirksam)_" + example_line: "\nBeispiel: `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "Ziele sind in dieser Sitzung nicht verfügbar." + no_goal_set: "Kein Ziel gesetzt." + paused: "⏸ Ziel pausiert: {goal}" + no_resume: "Kein Ziel zum Fortsetzen." + resumed: "▶ Ziel fortgesetzt: {goal}\nSenden Sie eine Nachricht zum Fortfahren oder warten Sie — ich übernehme den nächsten Schritt im nächsten Zug." + invalid: "Ungültiges Ziel: {error}" + set: "⊙ Ziel gesetzt ({budget}-Zug-Budget): {goal}\nIch arbeite weiter, bis das Ziel erreicht ist, Sie es pausieren/löschen oder das Budget aufgebraucht ist.\nSteuerung: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Hermes-Befehle**\n" + skill_header: "\n⚡ **Skill-Befehle** ({count} aktiv):" + more_use_commands: "\n... und {count} weitere. Verwenden Sie `/commands` für die vollständige paginierte Liste." + + insights: + invalid_days: "Ungültiger --days-Wert: {value}" + error: "Fehler beim Erstellen der Auswertung: {error}" + + kanban: + error_prefix: "⚠ Kanban-Fehler: {error}" + subscribed_suffix: "(abonniert — Sie werden benachrichtigt, wenn {task_id} abgeschlossen oder blockiert wird)" + truncated_suffix: "… (gekürzt; verwenden Sie `hermes kanban …` im Terminal für die vollständige Ausgabe)" + no_output: "(keine Ausgabe)" + + personality: + none_configured: "Keine Persönlichkeiten in `{path}/config.yaml` konfiguriert" + header: "🎭 **Verfügbare Persönlichkeiten**\n" + none_option: "• `none` — (kein Persönlichkeits-Overlay)" + item: "• `{name}` — {preview}" + usage: "\nVerwendung: `/personality <name>`" + save_failed: "⚠️ Speichern der Persönlichkeitsänderung fehlgeschlagen: {error}" + cleared: "🎭 Persönlichkeit gelöscht — Basisverhalten des Agenten wird verwendet.\n_(wird mit der nächsten Nachricht wirksam)_" + set_to: "🎭 Persönlichkeit auf **{name}** gesetzt\n_(wird mit der nächsten Nachricht wirksam)_" + unknown: "Unbekannte Persönlichkeit: `{name}`\n\nVerfügbar: {available}" + + profile: + header: "👤 **Profil:** `{profile}`" + home: "📂 **Stammverzeichnis:** `{home}`" + + reasoning: + level_default: "medium (Standard)" + level_disabled: "none (deaktiviert)" + scope_session: "Sitzungs-Override" + scope_global: "Globale Konfiguration" + status: "🧠 **Reasoning-Einstellungen**\n\n**Stärke:** `{level}`\n**Geltungsbereich:** {scope}\n**Anzeige:** {display}\n\n_Verwendung:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "an ✓" + display_off: "aus" + display_set_on: "🧠 ✓ Reasoning-Anzeige: **AN**\nDas Modelldenken wird vor jeder Antwort auf **{platform}** angezeigt." + display_set_off: "🧠 ✓ Reasoning-Anzeige: **AUS** für **{platform}**" + reset_global_unsupported: "⚠️ `/reasoning reset --global` wird nicht unterstützt. Verwenden Sie `/reasoning <level> --global`, um den globalen Standard zu ändern." + reset_done: "🧠 ✓ Sitzungs-Reasoning-Override gelöscht; Rückfall auf globale Konfiguration." + unknown_arg: "⚠️ Unbekanntes Argument: `{arg}`\n\n**Gültige Stärken:** none, minimal, low, medium, high, xhigh\n**Anzeige:** show, hide\n**Speichern:** `--global` hinzufügen, um über die Sitzung hinaus zu speichern" + set_global: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (in Konfiguration gespeichert)\n_(wird mit der nächsten Nachricht wirksam)_" + set_global_save_failed: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (nur Sitzung — Konfiguration konnte nicht gespeichert werden)\n_(wird mit der nächsten Nachricht wirksam)_" + set_session: "🧠 ✓ Reasoning-Stärke auf `{effort}` gesetzt (nur Sitzung — `--global` hinzufügen, um zu speichern)\n_(wird mit der nächsten Nachricht wirksam)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp abgebrochen. MCP-Tools unverändert." + always_followup: "ℹ️ Künftige `/reload-mcp`-Aufrufe laufen ohne Bestätigung. Wieder aktivieren über `approvals.mcp_reload_confirm: true` in `config.yaml`." + confirm_prompt: "⚠️ **/reload-mcp bestätigen**\n\nDas Neuladen der MCP-Server baut das Toolset für diese Sitzung neu auf und **invalidiert den Prompt-Cache des Anbieters** — die nächste Nachricht sendet die vollständigen Eingabetokens erneut. Bei langem Kontext oder Modellen mit hohem Reasoning-Aufwand kann das teuer sein.\n\nWählen Sie:\n• **Einmal genehmigen** — jetzt neu laden\n• **Immer genehmigen** — jetzt neu laden und diese Bestätigung dauerhaft unterdrücken\n• **Abbrechen** — MCP-Tools unverändert lassen\n\n_Text-Alternative: Antworten Sie mit `/approve`, `/always` oder `/cancel`._" + header: "🔄 **MCP-Server neu geladen**\n" + reconnected: "♻️ Wiederverbunden: {names}" + added: "➕ Hinzugefügt: {names}" + removed: "➖ Entfernt: {names}" + none_connected: "Keine MCP-Server verbunden." + tools_available: "\n🔧 {tools} Tool(s) von {servers} Server(n) verfügbar" + failed: "❌ MCP-Neuladen fehlgeschlagen: {error}" + + reload_skills: + header: "🔄 **Skills neu geladen**\n" + no_new: "Keine neuen Skills erkannt." + total: "\n📚 {count} Skill(s) verfügbar" + added_header: "➕ **Hinzugefügte Skills:**" + removed_header: "➖ **Entfernte Skills:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ Skill-Neuladen fehlgeschlagen: {error}" + + reset: + header_default: "✨ Sitzung zurückgesetzt! Neuanfang." + header_new: "✨ Neue Sitzung gestartet!" + header_titled: "✨ Neue Sitzung gestartet: {title}" + title_rejected: "\n⚠️ Titel abgelehnt: {error}" + title_error_untitled: "\n⚠️ {error} — Sitzung ohne Titel gestartet." + title_empty_untitled: "\n⚠️ Titel ist nach Bereinigung leer — Sitzung ohne Titel gestartet." + tip: "\n✦ Tipp: {tip}" + + restart: + in_progress: "⏳ Gateway-Neustart läuft bereits..." + restarting: "♻ Gateway wird neu gestartet. Falls Sie nicht innerhalb von 60 Sekunden benachrichtigt werden, starten Sie über die Konsole mit `hermes gateway restart` neu." + + resume: + db_unavailable: "Sitzungsdatenbank nicht verfügbar." + no_named_sessions: "Keine benannten Sitzungen gefunden.\nVerwenden Sie `/title Meine Sitzung`, um die aktuelle Sitzung zu benennen, dann `/resume Meine Sitzung`, um später dorthin zurückzukehren." + list_header: "📋 **Benannte Sitzungen**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nVerwendung: `/resume <Sitzungsname>`" + list_failed: "Sitzungen konnten nicht aufgelistet werden: {error}" + not_found: "Keine Sitzung passend zu '**{name}**' gefunden.\nVerwenden Sie `/resume` ohne Argumente, um verfügbare Sitzungen zu sehen." + already_on: "📌 Bereits in Sitzung **{name}**." + switch_failed: "Sitzungswechsel fehlgeschlagen." + resumed_one: "↻ Sitzung **{title}** fortgesetzt ({count} Nachricht). Konversation wiederhergestellt." + resumed_many: "↻ Sitzung **{title}** fortgesetzt ({count} Nachrichten). Konversation wiederhergestellt." + resumed_no_count: "↻ Sitzung **{title}** fortgesetzt. Konversation wiederhergestellt." + + retry: + no_previous: "Keine vorherige Nachricht zum Wiederholen." + + rollback: + not_enabled: "Checkpoints sind nicht aktiviert.\nIn config.yaml aktivieren:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "Keine Checkpoints für {cwd} gefunden" + invalid_number: "Ungültige Checkpoint-Nummer. Verwenden Sie 1-{max}." + restored: "✅ Auf Checkpoint {hash} wiederhergestellt: {reason}\nEin Pre-Rollback-Snapshot wurde automatisch gespeichert." + restore_failed: "❌ {error}" + + set_home: + save_failed: "Home-Kanal konnte nicht gespeichert werden: {error}" + success: "✅ Home-Kanal auf **{name}** (ID: {chat_id}) gesetzt.\nCron-Jobs und plattformübergreifende Nachrichten werden hierher geliefert." + + status: + header: "📊 **Hermes-Gateway-Status**" + session_id: "**Sitzungs-ID:** `{session_id}`" + title: "**Titel:** {title}" + created: "**Erstellt:** {timestamp}" + last_activity: "**Letzte Aktivität:** {timestamp}" + tokens: "**Tokens:** {tokens}" + agent_running: "**Agent läuft:** {state}" + state_yes: "Ja ⚡" + state_no: "Nein" + queued: "**Wartende Folgenachrichten:** {count}" + platforms: "**Verbundene Plattformen:** {platforms}" + + stop: + stopped_pending: "⚡ Gestoppt. Der Agent hatte noch nicht begonnen — Sie können diese Sitzung fortsetzen." + stopped: "⚡ Gestoppt. Sie können diese Sitzung fortsetzen." + no_active: "Keine aktive Aufgabe zum Stoppen." + + title: + db_unavailable: "Sitzungsdatenbank nicht verfügbar." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ Titel ist nach der Bereinigung leer. Bitte druckbare Zeichen verwenden." + set_to: "✏️ Sitzungstitel gesetzt: **{title}**" + not_found: "Sitzung nicht in der Datenbank gefunden." + current_with_title: "📌 Sitzung: `{session_id}`\nTitel: **{title}**" + current_no_title: "📌 Sitzung: `{session_id}`\nKein Titel gesetzt. Verwendung: `/title Mein Sitzungsname`" + + topic: + not_telegram_dm: "Der /topic-Befehl ist nur in Telegram-Privatchats verfügbar." + no_session_db: "Sitzungsdatenbank nicht verfügbar." + unauthorized: "Sie sind nicht berechtigt, /topic auf diesem Bot zu verwenden." + restore_needs_topic: "Um eine Sitzung wiederherzustellen, erstellen oder öffnen Sie zuerst ein Telegram-Topic und senden Sie dann /topic <session-id> innerhalb dieses Topics. Um ein neues Topic zu erstellen, öffnen Sie All Messages und senden Sie dort eine beliebige Nachricht." + topics_disabled: "Telegram-Topics sind für diesen Bot noch nicht aktiviert.\n\nSo aktivieren Sie sie:\n1. Öffnen Sie @BotFather.\n2. Wählen Sie Ihren Bot.\n3. Öffnen Sie Bot Settings → Threads Settings.\n4. Aktivieren Sie Threaded Mode und stellen Sie sicher, dass Benutzer neue Threads erstellen dürfen.\n\nDann senden Sie /topic erneut." + topics_user_disallowed: "Telegram-Topics sind aktiviert, aber Benutzer dürfen keine Topics erstellen.\n\nÖffnen Sie @BotFather → wählen Sie Ihren Bot → Bot Settings → Threads Settings, und deaktivieren Sie dann 'Disallow users to create new threads'.\n\nDann senden Sie /topic erneut." + enable_failed: "Telegram-Topic-Modus konnte nicht aktiviert werden: {error}" + bound_status: "Dieses Topic ist verknüpft mit:\nSitzung: {label}\nID: {session_id}\n\nVerwenden Sie /new, um dieses Topic durch eine neue Sitzung zu ersetzen.\nFür parallele Arbeit öffnen Sie All Messages und senden Sie dort eine Nachricht, um ein weiteres Topic zu erstellen." + thread_ready: "Telegram-Multi-Session-Topics sind aktiviert.\n\nDieses Topic wird als unabhängige Hermes-Sitzung verwendet. Verwenden Sie /new, um die aktuelle Sitzung dieses Topics zu ersetzen. Für parallele Arbeit öffnen Sie All Messages und senden Sie dort eine Nachricht, um ein weiteres Topic zu erstellen." + untitled_session: "Unbenannte Sitzung" + + undo: + nothing: "Nichts zum Rückgängigmachen." + removed: "↩️ {count} Nachricht(en) rückgängig gemacht.\nEntfernt: \"{preview}\"" + + update: + platform_not_messaging: "✗ /update ist nur auf Messaging-Plattformen verfügbar. Führen Sie `hermes update` im Terminal aus." + not_git_repo: "✗ Kein Git-Repository — Update nicht möglich." + hermes_cmd_not_found: "✗ Der Befehl `hermes` konnte nicht gefunden werden. Hermes läuft, aber der Update-Befehl konnte das ausführbare Programm weder im PATH noch über den aktuellen Python-Interpreter finden. Versuchen Sie, `hermes update` manuell im Terminal auszuführen." + start_failed: "✗ Update konnte nicht gestartet werden: {error}" + starting: "⚕ Hermes-Update wird gestartet… Ich streame den Fortschritt hier." + + usage: + rate_limits: "⏱️ **Ratenlimits:** {state}" + header_session: "📊 **Sitzungs-Token-Nutzung**" + label_model: "Modell: `{model}`" + label_input_tokens: "Eingabetokens: {count}" + label_cache_read: "Cache-Lesetokens: {count}" + label_cache_write: "Cache-Schreibtokens: {count}" + label_output_tokens: "Ausgabetokens: {count}" + label_total: "Gesamt: {count}" + label_api_calls: "API-Aufrufe: {count}" + label_cost: "Kosten: {prefix}${amount}" + label_cost_included: "Kosten: inbegriffen" + label_context: "Kontext: {used} / {total} ({pct}%)" + label_compressions: "Kompressionen: {count}" + header_session_info: "📊 **Sitzungsinfo**" + label_messages: "Nachrichten: {count}" + label_estimated_context: "Geschätzter Kontext: ~{count} Tokens" + detailed_after_first: "_(Detaillierte Nutzung nach der ersten Agentenantwort verfügbar)_" + no_data: "Keine Nutzungsdaten für diese Sitzung verfügbar." + + verbose: + not_enabled: "Der Befehl `/verbose` ist für Messaging-Plattformen nicht aktiviert.\n\nIn `config.yaml` aktivieren:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Tool-Fortschritt: **OFF** — keine Tool-Aktivität angezeigt." + mode_new: "⚙️ Tool-Fortschritt: **NEW** — angezeigt bei Tool-Wechsel (Vorschaulänge: `display.tool_preview_length`, Standard 40)." + mode_all: "⚙️ Tool-Fortschritt: **ALL** — jeder Tool-Aufruf wird angezeigt (Vorschaulänge: `display.tool_preview_length`, Standard 40)." + mode_verbose: "⚙️ Tool-Fortschritt: **VERBOSE** — jeder Tool-Aufruf mit vollständigen Argumenten." + saved_suffix: "_(für **{platform}** gespeichert — wird ab nächster Nachricht wirksam)_" + save_failed: "_(konnte nicht in der Konfiguration gespeichert werden: {error})_" + + voice: + enabled_voice_only: "Sprachmodus aktiviert.\nIch antworte mit Sprache, wenn Sie Sprachnachrichten senden.\nVerwenden Sie /voice tts für Sprachantworten auf alle Nachrichten." + disabled_text: "Sprachmodus deaktiviert. Nur Textantworten." + tts_enabled: "Auto-TTS aktiviert.\nAlle Antworten enthalten eine Sprachnachricht." + status_mode: "Sprachmodus: {label}" + status_channel: "Sprachkanal: #{channel}" + status_participants: "Teilnehmer: {count}" + status_member: " - {name}{status}" + speaking: " (spricht)" + enabled_short: "Sprachmodus aktiviert." + disabled_short: "Sprachmodus deaktiviert." + label_off: "Aus (nur Text)" + label_voice_only: "An (Sprachantwort auf Sprachnachrichten)" + label_all: "TTS (Sprachantwort auf alle Nachrichten)" + + yolo: + disabled: "⚠️ YOLO-Modus für diese Sitzung **AUS** — gefährliche Befehle benötigen eine Genehmigung." + enabled: "⚡ YOLO-Modus für diese Sitzung **AN** — alle Befehle werden automatisch genehmigt. Mit Vorsicht verwenden." + + shared: + session_db_unavailable: "Session-Datenbank nicht verfügbar." + session_db_unavailable_prefix: "Session-Datenbank nicht verfügbar" + session_not_found: "Session nicht in der Datenbank gefunden." + warn_passthrough: "⚠️ {error}" diff --git a/locales/en.yaml b/locales/en.yaml index 017c73c75e6..d485efe7561 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -33,3 +33,333 @@ gateway: no_active_goal: "No active goal." config_read_failed: "⚠️ Could not read config.yaml: {error}" config_save_failed: "⚠️ Could not save config: {error}" + + # /model command output -- shown after a model switch or when listing models. + # Provider names, model IDs, capability strings, and cost figures are NOT + # translated -- they're identifiers/values, not prose. Only the labels + # ("Provider:", "Context:", etc.) and the help/footer lines are localized. + model: + error_prefix: "Error: {error}" + switched: "Model switched to `{model}`" + provider_label: "Provider: {provider}" + context_label: "Context: {tokens} tokens" + max_output_label: "Max output: {tokens} tokens" + cost_label: "Cost: {cost}" + capabilities_label: "Capabilities: {capabilities}" + prompt_caching_enabled: "Prompt caching: enabled" + warning_prefix: "Warning: {warning}" + saved_global: "Saved to config.yaml (`--global`)" + session_only_hint: "_(session only — add `--global` to persist)_" + current_label: "Current: `{model}` on {provider}" + current_tag: " (current)" + more_models_suffix: " (+{count} more)" + usage_switch_model: "`/model <name>` — switch model" + usage_switch_provider: "`/model <name> --provider <slug>` — switch provider" + usage_persist: "`/model <name> --global` — persist" + + agents: + header: "🤖 **Active Agents & Tasks**" + active_agents: "**Active agents:** {count}" + this_chat: " · this chat" + more: "... and {count} more" + running_processes: "**Running background processes:** {count}" + async_jobs: "**Gateway async jobs:** {count}" + none: "No active agents or running tasks." + state_starting: "starting" + state_running: "running" + + approve: + no_pending: "No pending command to approve." + once_singular: "✅ Command approved. The agent is resuming..." + once_plural: "✅ Commands approved ({count} commands). The agent is resuming..." + session_singular: "✅ Command approved (pattern approved for this session). The agent is resuming..." + session_plural: "✅ Commands approved (pattern approved for this session) ({count} commands). The agent is resuming..." + always_singular: "✅ Command approved (pattern approved permanently). The agent is resuming..." + always_plural: "✅ Commands approved (pattern approved permanently) ({count} commands). The agent is resuming..." + + background: + usage: "Usage: /background <prompt>\nExample: /background Summarize the top HN stories today\n\nRuns the prompt in a separate session. You can keep chatting — the result will appear here when done." + started: "🔄 Background task started: \"{preview}\"\nTask ID: {task_id}\nYou can keep chatting — results will appear when done." + + branch: + db_unavailable: "Session database not available." + no_conversation: "No conversation to branch — send a message first." + create_failed: "Failed to create branch: {error}" + switch_failed: "Branch created but failed to switch to it." + branched_one: "⑂ Branched to **{title}** ({count} message copied)\nOriginal: `{parent}`\nBranch: `{new}`\nUse `/resume` to switch back to the original." + branched_many: "⑂ Branched to **{title}** ({count} messages copied)\nOriginal: `{parent}`\nBranch: `{new}`\nUse `/resume` to switch back to the original." + + commands: + usage: "Usage: `/commands [page]`" + skill_header: "⚡ **Skill Commands**:" + default_desc: "Skill command" + none: "No commands available." + header: "📚 **Commands** ({total} total, page {page}/{total_pages})" + nav_prev: "`/commands {page}` ← prev" + nav_next: "next → `/commands {page}`" + out_of_range: "_(Requested page {requested} was out of range, showing page {page}.)_" + + compress: + not_enough: "Not enough conversation to compress (need at least 4 messages)." + no_provider: "No provider configured -- cannot compress." + nothing_to_do: "Nothing to compress yet (the transcript is still all protected context)." + focus_line: "Focus: \"{topic}\"" + summary_failed: "⚠️ Summary generation failed ({error}). {count} historical message(s) were removed and replaced with a placeholder; earlier context is no longer recoverable. Consider checking your auxiliary.compression model configuration." + aux_failed: "ℹ️ Configured compression model `{model}` failed ({error}). Recovered using your main model — context is intact — but you may want to check `auxiliary.compression.model` in config.yaml." + failed: "Compression failed: {error}" + + debug: + upload_failed: "✗ Failed to upload debug report: {error}" + header: "**Debug report uploaded:**" + auto_delete: "⏱ Pastes will auto-delete in 6 hours." + full_logs_hint: "For full log uploads, use `hermes debug share` from the CLI." + share_hint: "Share these links with the Hermes team for support." + + deny: + stale: "❌ Command denied (approval was stale)." + no_pending: "No pending command to deny." + denied_singular: "❌ Command denied." + denied_plural: "❌ Commands denied ({count} commands)." + + fast: + not_supported: "⚡ /fast is only available for OpenAI models that support Priority Processing." + status: "⚡ Priority Processing\n\nCurrent mode: `{mode}`\n\n_Usage:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Unknown argument: `{arg}`\n\n**Valid options:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (saved to config)\n_(takes effect on next message)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (this session only)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Runtime footer: **{state}**\nFields: `{fields}`\nPlatform: `{platform}`" + usage: "Usage: `/footer [on|off|status]`" + saved: "📎 Runtime footer: **{state}**{example}\n_(saved globally — takes effect on next message)_" + example_line: "\nExample: `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "Goals unavailable on this session." + no_goal_set: "No goal set." + paused: "⏸ Goal paused: {goal}" + no_resume: "No goal to resume." + resumed: "▶ Goal resumed: {goal}\nSend any message to continue, or wait — I'll take the next step on the next turn." + invalid: "Invalid goal: {error}" + set: "⊙ Goal set ({budget}-turn budget): {goal}\nI'll keep working until the goal is done, you pause/clear it, or the budget is exhausted.\nControls: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Hermes Commands**\n" + skill_header: "\n⚡ **Skill Commands** ({count} active):" + more_use_commands: "\n... and {count} more. Use `/commands` for the full paginated list." + + insights: + invalid_days: "Invalid --days value: {value}" + error: "Error generating insights: {error}" + + kanban: + error_prefix: "⚠ kanban error: {error}" + subscribed_suffix: "(subscribed — you'll be notified when {task_id} completes or blocks)" + truncated_suffix: "… (truncated; use `hermes kanban …` in your terminal for full output)" + no_output: "(no output)" + + personality: + none_configured: "No personalities configured in `{path}/config.yaml`" + header: "🎭 **Available Personalities**\n" + none_option: "• `none` — (no personality overlay)" + item: "• `{name}` — {preview}" + usage: "\nUsage: `/personality <name>`" + save_failed: "⚠️ Failed to save personality change: {error}" + cleared: "🎭 Personality cleared — using base agent behavior.\n_(takes effect on next message)_" + set_to: "🎭 Personality set to **{name}**\n_(takes effect on next message)_" + unknown: "Unknown personality: `{name}`\n\nAvailable: {available}" + + profile: + header: "👤 **Profile:** `{profile}`" + home: "📂 **Home:** `{home}`" + + reasoning: + level_default: "medium (default)" + level_disabled: "none (disabled)" + scope_session: "session override" + scope_global: "global config" + status: "🧠 **Reasoning Settings**\n\n**Effort:** `{level}`\n**Scope:** {scope}\n**Display:** {display}\n\n_Usage:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "on ✓" + display_off: "off" + display_set_on: "🧠 ✓ Reasoning display: **ON**\nModel thinking will be shown before each response on **{platform}**." + display_set_off: "🧠 ✓ Reasoning display: **OFF** for **{platform}**" + reset_global_unsupported: "⚠️ `/reasoning reset --global` is not supported. Use `/reasoning <level> --global` to change the global default." + reset_done: "🧠 ✓ Session reasoning override cleared; falling back to global config." + unknown_arg: "⚠️ Unknown argument: `{arg}`\n\n**Valid levels:** none, minimal, low, medium, high, xhigh\n**Display:** show, hide\n**Persist:** add `--global` to save beyond this session" + set_global: "🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_" + set_global_save_failed: "🧠 ✓ Reasoning effort set to `{effort}` (session only — config save failed)\n_(takes effect on next message)_" + set_session: "🧠 ✓ Reasoning effort set to `{effort}` (session only — add `--global` to persist)\n_(takes effect on next message)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp cancelled. MCP tools unchanged." + always_followup: "ℹ️ Future `/reload-mcp` calls will run without confirmation. Re-enable via `approvals.mcp_reload_confirm: true` in config.yaml." + confirm_prompt: "⚠️ **Confirm /reload-mcp**\n\nReloading MCP servers rebuilds the tool set for this session and **invalidates the provider prompt cache** — the next message will re-send full input tokens. On long-context or high-reasoning models this can be expensive.\n\nChoose:\n• **Approve Once** — reload now\n• **Always Approve** — reload now and silence this prompt permanently\n• **Cancel** — leave MCP tools unchanged\n\n_Text fallback: reply `/approve`, `/always`, or `/cancel`._" + header: "🔄 **MCP Servers Reloaded**\n" + reconnected: "♻️ Reconnected: {names}" + added: "➕ Added: {names}" + removed: "➖ Removed: {names}" + none_connected: "No MCP servers connected." + tools_available: "\n🔧 {tools} tool(s) available from {servers} server(s)" + failed: "❌ MCP reload failed: {error}" + + reload_skills: + header: "🔄 **Skills Reloaded**\n" + no_new: "No new skills detected." + total: "\n📚 {count} skill(s) available" + added_header: "➕ **Added Skills:**" + removed_header: "➖ **Removed Skills:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ Skills reload failed: {error}" + + reset: + header_default: "✨ Session reset! Starting fresh." + header_new: "✨ New session started!" + header_titled: "✨ New session started: {title}" + title_rejected: "\n⚠️ Title rejected: {error}" + title_error_untitled: "\n⚠️ {error} — session started untitled." + title_empty_untitled: "\n⚠️ Title is empty after cleanup — session started untitled." + tip: "\n✦ Tip: {tip}" + + restart: + in_progress: "⏳ Gateway restart already in progress..." + restarting: "♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`." + + resume: + db_unavailable: "Session database not available." + no_named_sessions: "No named sessions found.\nUse `/title My Session` to name your current session, then `/resume My Session` to return to it later." + list_header: "📋 **Named Sessions**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nUsage: `/resume <session name>`" + list_failed: "Could not list sessions: {error}" + not_found: "No session found matching '**{name}**'.\nUse `/resume` with no arguments to see available sessions." + already_on: "📌 Already on session **{name}**." + switch_failed: "Failed to switch session." + resumed_one: "↻ Resumed session **{title}** ({count} message). Conversation restored." + resumed_many: "↻ Resumed session **{title}** ({count} messages). Conversation restored." + resumed_no_count: "↻ Resumed session **{title}**. Conversation restored." + + retry: + no_previous: "No previous message to retry." + + rollback: + not_enabled: "Checkpoints are not enabled.\nEnable in config.yaml:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "No checkpoints found for {cwd}" + invalid_number: "Invalid checkpoint number. Use 1-{max}." + restored: "✅ Restored to checkpoint {hash}: {reason}\nA pre-rollback snapshot was saved automatically." + restore_failed: "❌ {error}" + + set_home: + save_failed: "Failed to save home channel: {error}" + success: "✅ Home channel set to **{name}** (ID: {chat_id}).\nCron jobs and cross-platform messages will be delivered here." + + status: + header: "📊 **Hermes Gateway Status**" + session_id: "**Session ID:** `{session_id}`" + title: "**Title:** {title}" + created: "**Created:** {timestamp}" + last_activity: "**Last Activity:** {timestamp}" + tokens: "**Tokens:** {tokens}" + agent_running: "**Agent Running:** {state}" + state_yes: "Yes ⚡" + state_no: "No" + queued: "**Queued follow-ups:** {count}" + platforms: "**Connected Platforms:** {platforms}" + + stop: + stopped_pending: "⚡ Stopped. The agent hadn't started yet — you can continue this session." + stopped: "⚡ Stopped. You can continue this session." + no_active: "No active task to stop." + + title: + db_unavailable: "Session database not available." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ Title is empty after cleanup. Please use printable characters." + set_to: "✏️ Session title set: **{title}**" + not_found: "Session not found in database." + current_with_title: "📌 Session: `{session_id}`\nTitle: **{title}**" + current_no_title: "📌 Session: `{session_id}`\nNo title set. Usage: `/title My Session Name`" + + topic: + not_telegram_dm: "The /topic command is only available in Telegram private chats." + no_session_db: "Session database not available." + unauthorized: "You are not authorized to use /topic on this bot." + restore_needs_topic: "To restore a session, first create or open a Telegram topic, then send /topic <session-id> inside that topic. To create a new topic, open All Messages and send any message there." + topics_disabled: "Telegram topics are not enabled for this bot yet.\n\nHow to enable them:\n1. Open @BotFather.\n2. Choose your bot.\n3. Open Bot Settings → Threads Settings.\n4. Turn on Threaded Mode and make sure users are allowed to create new threads.\n\nThen send /topic again." + topics_user_disallowed: "Telegram topics are enabled, but users are not allowed to create topics.\n\nOpen @BotFather → choose your bot → Bot Settings → Threads Settings, then turn off 'Disallow users to create new threads'.\n\nThen send /topic again." + enable_failed: "Failed to enable Telegram topic mode: {error}" + bound_status: "This topic is linked to:\nSession: {label}\nID: {session_id}\n\nUse /new to replace this topic with a fresh session.\nFor parallel work, open All Messages and send a message there to create another topic." + thread_ready: "Telegram multi-session topics are enabled.\n\nThis topic will be used as an independent Hermes session. Use /new to replace this topic's current session. For parallel work, open All Messages and send a message there to create another topic." + untitled_session: "Untitled session" + + undo: + nothing: "Nothing to undo." + removed: "↩️ Undid {count} message(s).\nRemoved: \"{preview}\"" + + update: + platform_not_messaging: "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal." + not_git_repo: "✗ Not a git repository — cannot update." + hermes_cmd_not_found: "✗ Could not locate the `hermes` command. Hermes is running, but the update command could not find the executable on PATH or via the current Python interpreter. Try running `hermes update` manually in your terminal." + start_failed: "✗ Failed to start update: {error}" + starting: "⚕ Starting Hermes update… I'll stream progress here." + + usage: + rate_limits: "⏱️ **Rate Limits:** {state}" + header_session: "📊 **Session Token Usage**" + label_model: "Model: `{model}`" + label_input_tokens: "Input tokens: {count}" + label_cache_read: "Cache read tokens: {count}" + label_cache_write: "Cache write tokens: {count}" + label_output_tokens: "Output tokens: {count}" + label_total: "Total: {count}" + label_api_calls: "API calls: {count}" + label_cost: "Cost: {prefix}${amount}" + label_cost_included: "Cost: included" + label_context: "Context: {used} / {total} ({pct}%)" + label_compressions: "Compressions: {count}" + header_session_info: "📊 **Session Info**" + label_messages: "Messages: {count}" + label_estimated_context: "Estimated context: ~{count} tokens" + detailed_after_first: "_(Detailed usage available after the first agent response)_" + no_data: "No usage data available for this session." + + verbose: + not_enabled: "The `/verbose` command is not enabled for messaging platforms.\n\nEnable it in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Tool progress: **OFF** — no tool activity shown." + mode_new: "⚙️ Tool progress: **NEW** — shown when tool changes (preview length: `display.tool_preview_length`, default 40)." + mode_all: "⚙️ Tool progress: **ALL** — every tool call shown (preview length: `display.tool_preview_length`, default 40)." + mode_verbose: "⚙️ Tool progress: **VERBOSE** — every tool call with full arguments." + saved_suffix: "_(saved for **{platform}** — takes effect on next message)_" + save_failed: "_(could not save to config: {error})_" + + voice: + enabled_voice_only: "Voice mode enabled.\nI'll reply with voice when you send voice messages.\nUse /voice tts to get voice replies for all messages." + disabled_text: "Voice mode disabled. Text-only replies." + tts_enabled: "Auto-TTS enabled.\nAll replies will include a voice message." + status_mode: "Voice mode: {label}" + status_channel: "Voice channel: #{channel}" + status_participants: "Participants: {count}" + status_member: " - {name}{status}" + speaking: " (speaking)" + enabled_short: "Voice mode enabled." + disabled_short: "Voice mode disabled." + label_off: "Off (text only)" + label_voice_only: "On (voice reply to voice messages)" + label_all: "TTS (voice reply to all messages)" + + yolo: + disabled: "⚠️ YOLO mode **OFF** for this session — dangerous commands will require approval." + enabled: "⚡ YOLO mode **ON** for this session — all commands auto-approved. Use with caution." + + shared: + session_db_unavailable: "Session database not available." + session_db_unavailable_prefix: "Session database not available" + session_not_found: "Session not found in database." + warn_passthrough: "⚠️ {error}" diff --git a/locales/es.yaml b/locales/es.yaml index aa7c2c60941..6e7a8a34cda 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -22,3 +22,329 @@ gateway: no_active_goal: "No hay objetivo activo." config_read_failed: "⚠️ No se pudo leer config.yaml: {error}" config_save_failed: "⚠️ No se pudo guardar la configuración: {error}" + + model: + error_prefix: "Error: {error}" + switched: "Modelo cambiado a `{model}`" + provider_label: "Proveedor: {provider}" + context_label: "Contexto: {tokens} tokens" + max_output_label: "Salida máxima: {tokens} tokens" + cost_label: "Coste: {cost}" + capabilities_label: "Capacidades: {capabilities}" + prompt_caching_enabled: "Caché de prompts: activado" + warning_prefix: "Advertencia: {warning}" + saved_global: "Guardado en config.yaml (`--global`)" + session_only_hint: "_(solo para esta sesión — añade `--global` para guardarlo)_" + current_label: "Actual: `{model}` en {provider}" + current_tag: " (actual)" + more_models_suffix: " (+{count} más)" + usage_switch_model: "`/model <name>` — cambiar modelo" + usage_switch_provider: "`/model <name> --provider <slug>` — cambiar proveedor" + usage_persist: "`/model <name> --global` — guardar de forma permanente" + + agents: + header: "🤖 **Agentes y tareas activos**" + active_agents: "**Agentes activos:** {count}" + this_chat: " · este chat" + more: "... y {count} más" + running_processes: "**Procesos en segundo plano en ejecución:** {count}" + async_jobs: "**Tareas asíncronas del gateway:** {count}" + none: "No hay agentes activos ni tareas en ejecución." + state_starting: "iniciando" + state_running: "en ejecución" + + approve: + no_pending: "No hay ningún comando pendiente que aprobar." + once_singular: "✅ Comando aprobado. El agente se está reanudando..." + once_plural: "✅ Comandos aprobados ({count} comandos). El agente se está reanudando..." + session_singular: "✅ Comando aprobado (patrón aprobado para esta sesión). El agente se está reanudando..." + session_plural: "✅ Comandos aprobados (patrón aprobado para esta sesión) ({count} comandos). El agente se está reanudando..." + always_singular: "✅ Comando aprobado (patrón aprobado permanentemente). El agente se está reanudando..." + always_plural: "✅ Comandos aprobados (patrón aprobado permanentemente) ({count} comandos). El agente se está reanudando..." + + background: + usage: "Uso: /background <prompt>\nEjemplo: /background Resume las principales historias de HN de hoy\n\nEjecuta el prompt en una sesión separada. Puedes seguir chateando — el resultado aparecerá aquí cuando termine." + started: "🔄 Tarea en segundo plano iniciada: \"{preview}\"\nID de tarea: {task_id}\nPuedes seguir chateando — los resultados aparecerán aquí cuando terminen." + + branch: + db_unavailable: "Base de datos de sesiones no disponible." + no_conversation: "No hay conversación para ramificar — envía un mensaje primero." + create_failed: "No se pudo crear la rama: {error}" + switch_failed: "Rama creada pero no se pudo cambiar a ella." + branched_one: "⑂ Ramificado a **{title}** ({count} mensaje copiado)\nOriginal: `{parent}`\nRama: `{new}`\nUsa `/resume` para volver al original." + branched_many: "⑂ Ramificado a **{title}** ({count} mensajes copiados)\nOriginal: `{parent}`\nRama: `{new}`\nUsa `/resume` para volver al original." + + commands: + usage: "Uso: `/commands [page]`" + skill_header: "⚡ **Comandos de skill**:" + default_desc: "Comando de skill" + none: "No hay comandos disponibles." + header: "📚 **Comandos** ({total} en total, página {page}/{total_pages})" + nav_prev: "`/commands {page}` ← anterior" + nav_next: "siguiente → `/commands {page}`" + out_of_range: "_(La página solicitada {requested} estaba fuera de rango, mostrando la página {page}.)_" + + compress: + not_enough: "No hay suficiente conversación para comprimir (se necesitan al menos 4 mensajes)." + no_provider: "No hay proveedor configurado — no se puede comprimir." + nothing_to_do: "Aún no hay nada que comprimir (la transcripción sigue siendo todo contexto protegido)." + focus_line: "Enfoque: \"{topic}\"" + summary_failed: "⚠️ Falló la generación del resumen ({error}). Se eliminaron {count} mensaje(s) históricos y se reemplazaron por un marcador; el contexto anterior ya no se puede recuperar. Considera revisar la configuración del modelo auxiliary.compression." + aux_failed: "ℹ️ El modelo de compresión configurado `{model}` falló ({error}). Recuperado con tu modelo principal — el contexto está intacto — pero quizá quieras revisar `auxiliary.compression.model` en config.yaml." + failed: "Compresión fallida: {error}" + + debug: + upload_failed: "✗ No se pudo subir el informe de depuración: {error}" + header: "**Informe de depuración subido:**" + auto_delete: "⏱ Los pastes se eliminarán automáticamente en 6 horas." + full_logs_hint: "Para subir registros completos, usa `hermes debug share` desde la CLI." + share_hint: "Comparte estos enlaces con el equipo de Hermes para obtener soporte." + + deny: + stale: "❌ Comando denegado (la aprobación había caducado)." + no_pending: "No hay ningún comando pendiente que denegar." + denied_singular: "❌ Comando denegado." + denied_plural: "❌ Comandos denegados ({count} comandos)." + + fast: + not_supported: "⚡ /fast solo está disponible para modelos de OpenAI que admiten Priority Processing." + status: "⚡ Priority Processing\n\nModo actual: `{mode}`\n\n_Uso:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Argumento desconocido: `{arg}`\n\n**Opciones válidas:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (guardado en la configuración)\n_(se aplica en el próximo mensaje)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (solo esta sesión)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Pie de ejecución: **{state}**\nCampos: `{fields}`\nPlataforma: `{platform}`" + usage: "Uso: `/footer [on|off|status]`" + saved: "📎 Pie de ejecución: **{state}**{example}\n_(guardado globalmente — se aplica en el próximo mensaje)_" + example_line: "\nEjemplo: `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "Los objetivos no están disponibles en esta sesión." + no_goal_set: "No hay objetivo establecido." + paused: "⏸ Objetivo pausado: {goal}" + no_resume: "No hay objetivo para reanudar." + resumed: "▶ Objetivo reanudado: {goal}\nEnvía cualquier mensaje para continuar, o espera — daré el siguiente paso en el próximo turno." + invalid: "Objetivo no válido: {error}" + set: "⊙ Objetivo establecido (presupuesto de {budget} turnos): {goal}\nSeguiré trabajando hasta que el objetivo se complete, lo pauses/elimines o se agote el presupuesto.\nControles: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Comandos de Hermes**\n" + skill_header: "\n⚡ **Comandos de skill** ({count} activos):" + more_use_commands: "\n... y {count} más. Usa `/commands` para la lista paginada completa." + + insights: + invalid_days: "Valor --days no válido: {value}" + error: "Error al generar el análisis: {error}" + + kanban: + error_prefix: "⚠ error de kanban: {error}" + subscribed_suffix: "(suscrito — recibirás una notificación cuando {task_id} termine o se bloquee)" + truncated_suffix: "… (truncado; usa `hermes kanban …` en tu terminal para la salida completa)" + no_output: "(sin salida)" + + personality: + none_configured: "No hay personalidades configuradas en `{path}/config.yaml`" + header: "🎭 **Personalidades disponibles**\n" + none_option: "• `none` — (sin superposición de personalidad)" + item: "• `{name}` — {preview}" + usage: "\nUso: `/personality <name>`" + save_failed: "⚠️ No se pudo guardar el cambio de personalidad: {error}" + cleared: "🎭 Personalidad eliminada — usando el comportamiento base del agente.\n_(surte efecto en el siguiente mensaje)_" + set_to: "🎭 Personalidad establecida en **{name}**\n_(surte efecto en el siguiente mensaje)_" + unknown: "Personalidad desconocida: `{name}`\n\nDisponibles: {available}" + + profile: + header: "👤 **Perfil:** `{profile}`" + home: "📂 **Inicio:** `{home}`" + + reasoning: + level_default: "medium (predeterminado)" + level_disabled: "none (deshabilitado)" + scope_session: "anulación de sesión" + scope_global: "configuración global" + status: "🧠 **Ajustes de razonamiento**\n\n**Esfuerzo:** `{level}`\n**Alcance:** {scope}\n**Visualización:** {display}\n\n_Uso:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "activada ✓" + display_off: "desactivada" + display_set_on: "🧠 ✓ Visualización de razonamiento: **ACTIVADA**\nEl pensamiento del modelo se mostrará antes de cada respuesta en **{platform}**." + display_set_off: "🧠 ✓ Visualización de razonamiento: **DESACTIVADA** para **{platform}**" + reset_global_unsupported: "⚠️ `/reasoning reset --global` no es compatible. Usa `/reasoning <level> --global` para cambiar el valor global por defecto." + reset_done: "🧠 ✓ Anulación de razonamiento de la sesión borrada; volviendo a la configuración global." + unknown_arg: "⚠️ Argumento desconocido: `{arg}`\n\n**Niveles válidos:** none, minimal, low, medium, high, xhigh\n**Visualización:** show, hide\n**Persistir:** añade `--global` para guardar más allá de esta sesión" + set_global: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (guardado en la configuración)\n_(se aplica en el próximo mensaje)_" + set_global_save_failed: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (solo en la sesión — error al guardar la configuración)\n_(se aplica en el próximo mensaje)_" + set_session: "🧠 ✓ Esfuerzo de razonamiento ajustado a `{effort}` (solo en la sesión — añade `--global` para persistir)\n_(se aplica en el próximo mensaje)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp cancelado. Las herramientas MCP no han cambiado." + always_followup: "ℹ️ Las próximas llamadas a `/reload-mcp` se ejecutarán sin confirmación. Reactiva mediante `approvals.mcp_reload_confirm: true` en `config.yaml`." + confirm_prompt: "⚠️ **Confirmar /reload-mcp**\n\nRecargar los servidores MCP reconstruye el conjunto de herramientas de esta sesión e **invalida la caché de prompt del proveedor** — el siguiente mensaje reenviará los tokens de entrada completos. En modelos de contexto largo o de razonamiento alto esto puede resultar costoso.\n\nElige:\n• **Aprobar una vez** — recargar ahora\n• **Aprobar siempre** — recargar ahora y silenciar esta confirmación permanentemente\n• **Cancelar** — dejar las herramientas MCP sin cambios\n\n_Alternativa de texto: responde `/approve`, `/always` o `/cancel`._" + header: "🔄 **Servidores MCP recargados**\n" + reconnected: "♻️ Reconectados: {names}" + added: "➕ Añadidos: {names}" + removed: "➖ Eliminados: {names}" + none_connected: "No hay servidores MCP conectados." + tools_available: "\n🔧 {tools} herramienta(s) disponibles de {servers} servidor(es)" + failed: "❌ Falló la recarga de MCP: {error}" + + reload_skills: + header: "🔄 **Skills recargadas**\n" + no_new: "No se detectaron nuevas skills." + total: "\n📚 {count} skill(s) disponibles" + added_header: "➕ **Skills añadidas:**" + removed_header: "➖ **Skills eliminadas:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ Falló la recarga de skills: {error}" + + reset: + header_default: "✨ ¡Sesión reiniciada! Empezando de nuevo." + header_new: "✨ ¡Nueva sesión iniciada!" + header_titled: "✨ Nueva sesión iniciada: {title}" + title_rejected: "\n⚠️ Título rechazado: {error}" + title_error_untitled: "\n⚠️ {error} — sesión iniciada sin título." + title_empty_untitled: "\n⚠️ El título queda vacío tras la limpieza — sesión iniciada sin título." + tip: "\n✦ Consejo: {tip}" + + restart: + in_progress: "⏳ El reinicio del gateway ya está en curso..." + restarting: "♻ Reiniciando el gateway. Si no recibes notificación en 60 segundos, reinicia desde la consola con `hermes gateway restart`." + + resume: + db_unavailable: "Base de datos de sesiones no disponible." + no_named_sessions: "No se encontraron sesiones con nombre.\nUsa `/title Mi sesión` para nombrar la sesión actual y luego `/resume Mi sesión` para volver a ella." + list_header: "📋 **Sesiones con nombre**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nUso: `/resume <nombre de sesión>`" + list_failed: "No se pudieron listar las sesiones: {error}" + not_found: "No se encontró ninguna sesión que coincida con '**{name}**'.\nUsa `/resume` sin argumentos para ver las sesiones disponibles." + already_on: "📌 Ya estás en la sesión **{name}**." + switch_failed: "No se pudo cambiar de sesión." + resumed_one: "↻ Sesión **{title}** reanudada ({count} mensaje). Conversación restaurada." + resumed_many: "↻ Sesión **{title}** reanudada ({count} mensajes). Conversación restaurada." + resumed_no_count: "↻ Sesión **{title}** reanudada. Conversación restaurada." + + retry: + no_previous: "No hay un mensaje anterior para reintentar." + + rollback: + not_enabled: "Los checkpoints no están habilitados.\nHabilítalos en config.yaml:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "No se encontraron checkpoints para {cwd}" + invalid_number: "Número de checkpoint inválido. Usa 1-{max}." + restored: "✅ Restaurado al checkpoint {hash}: {reason}\nSe guardó automáticamente un snapshot previo al rollback." + restore_failed: "❌ {error}" + + set_home: + save_failed: "No se pudo guardar el canal principal: {error}" + success: "✅ Canal principal establecido en **{name}** (ID: {chat_id}).\nLas tareas cron y los mensajes entre plataformas se entregarán aquí." + + status: + header: "📊 **Estado de Hermes Gateway**" + session_id: "**ID de sesión:** `{session_id}`" + title: "**Título:** {title}" + created: "**Creado:** {timestamp}" + last_activity: "**Última actividad:** {timestamp}" + tokens: "**Tokens:** {tokens}" + agent_running: "**Agente activo:** {state}" + state_yes: "Sí ⚡" + state_no: "No" + queued: "**Seguimientos en cola:** {count}" + platforms: "**Plataformas conectadas:** {platforms}" + + stop: + stopped_pending: "⚡ Detenido. El agente aún no había comenzado — puedes continuar esta sesión." + stopped: "⚡ Detenido. Puedes continuar esta sesión." + no_active: "No hay ninguna tarea activa que detener." + + title: + db_unavailable: "Base de datos de sesiones no disponible." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ El título está vacío tras la limpieza. Usa caracteres imprimibles." + set_to: "✏️ Título de sesión establecido: **{title}**" + not_found: "Sesión no encontrada en la base de datos." + current_with_title: "📌 Sesión: `{session_id}`\nTítulo: **{title}**" + current_no_title: "📌 Sesión: `{session_id}`\nSin título. Uso: `/title Mi nombre de sesión`" + + topic: + not_telegram_dm: "El comando /topic solo está disponible en chats privados de Telegram." + no_session_db: "Base de datos de sesiones no disponible." + unauthorized: "No tienes autorización para usar /topic en este bot." + restore_needs_topic: "Para restaurar una sesión, primero crea o abre un topic de Telegram, luego envía /topic <session-id> dentro de ese topic. Para crear un topic nuevo, abre All Messages y envía cualquier mensaje allí." + topics_disabled: "Los topics de Telegram aún no están habilitados para este bot.\n\nCómo habilitarlos:\n1. Abre @BotFather.\n2. Elige tu bot.\n3. Abre Bot Settings → Threads Settings.\n4. Activa Threaded Mode y asegúrate de permitir que los usuarios creen nuevos threads.\n\nLuego envía /topic de nuevo." + topics_user_disallowed: "Los topics de Telegram están habilitados, pero los usuarios no pueden crearlos.\n\nAbre @BotFather → elige tu bot → Bot Settings → Threads Settings, luego desactiva 'Disallow users to create new threads'.\n\nLuego envía /topic de nuevo." + enable_failed: "No se pudo habilitar el modo topic de Telegram: {error}" + bound_status: "Este topic está vinculado a:\nSesión: {label}\nID: {session_id}\n\nUsa /new para reemplazar este topic con una sesión nueva.\nPara trabajo paralelo, abre All Messages y envía un mensaje allí para crear otro topic." + thread_ready: "Los topics multisesión de Telegram están habilitados.\n\nEste topic se usará como una sesión independiente de Hermes. Usa /new para reemplazar la sesión actual de este topic. Para trabajo paralelo, abre All Messages y envía un mensaje allí para crear otro topic." + untitled_session: "Sesión sin título" + + undo: + nothing: "Nada que deshacer." + removed: "↩️ {count} mensaje(s) deshecho(s).\nEliminado: \"{preview}\"" + + update: + platform_not_messaging: "✗ /update solo está disponible en plataformas de mensajería. Ejecuta `hermes update` desde la terminal." + not_git_repo: "✗ No es un repositorio git — no se puede actualizar." + hermes_cmd_not_found: "✗ No se pudo localizar el comando `hermes`. Hermes está en ejecución, pero el comando de actualización no encontró el ejecutable en PATH ni a través del intérprete de Python actual. Intenta ejecutar `hermes update` manualmente en tu terminal." + start_failed: "✗ No se pudo iniciar la actualización: {error}" + starting: "⚕ Iniciando la actualización de Hermes… Transmitiré el progreso aquí." + + usage: + rate_limits: "⏱️ **Límites de tasa:** {state}" + header_session: "📊 **Uso de tokens de la sesión**" + label_model: "Modelo: `{model}`" + label_input_tokens: "Tokens de entrada: {count}" + label_cache_read: "Tokens de lectura de caché: {count}" + label_cache_write: "Tokens de escritura de caché: {count}" + label_output_tokens: "Tokens de salida: {count}" + label_total: "Total: {count}" + label_api_calls: "Llamadas API: {count}" + label_cost: "Costo: {prefix}${amount}" + label_cost_included: "Costo: incluido" + label_context: "Contexto: {used} / {total} ({pct}%)" + label_compressions: "Compresiones: {count}" + header_session_info: "📊 **Información de la sesión**" + label_messages: "Mensajes: {count}" + label_estimated_context: "Contexto estimado: ~{count} tokens" + detailed_after_first: "_(Uso detallado disponible tras la primera respuesta del agente)_" + no_data: "No hay datos de uso disponibles para esta sesión." + + verbose: + not_enabled: "El comando `/verbose` no está habilitado para plataformas de mensajería.\n\nHabilítalo en `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Progreso de herramientas: **OFF** — no se muestra actividad de herramientas." + mode_new: "⚙️ Progreso de herramientas: **NEW** — se muestra al cambiar de herramienta (longitud de vista previa: `display.tool_preview_length`, por defecto 40)." + mode_all: "⚙️ Progreso de herramientas: **ALL** — se muestra cada llamada a herramienta (longitud de vista previa: `display.tool_preview_length`, por defecto 40)." + mode_verbose: "⚙️ Progreso de herramientas: **VERBOSE** — cada llamada a herramienta con sus argumentos completos." + saved_suffix: "_(guardado para **{platform}** — se aplica en el próximo mensaje)_" + save_failed: "_(no se pudo guardar en la configuración: {error})_" + + voice: + enabled_voice_only: "Modo de voz activado.\nResponderé con voz cuando envíes mensajes de voz.\nUsa /voice tts para recibir respuestas de voz en todos los mensajes." + disabled_text: "Modo de voz desactivado. Respuestas solo de texto." + tts_enabled: "Auto-TTS activado.\nTodas las respuestas incluirán un mensaje de voz." + status_mode: "Modo de voz: {label}" + status_channel: "Canal de voz: #{channel}" + status_participants: "Participantes: {count}" + status_member: " - {name}{status}" + speaking: " (hablando)" + enabled_short: "Modo de voz activado." + disabled_short: "Modo de voz desactivado." + label_off: "Desactivado (solo texto)" + label_voice_only: "Activado (responder con voz a mensajes de voz)" + label_all: "TTS (responder con voz a todos los mensajes)" + + yolo: + disabled: "⚠️ Modo YOLO **DESACTIVADO** en esta sesión — los comandos peligrosos requerirán aprobación." + enabled: "⚡ Modo YOLO **ACTIVADO** en esta sesión — todos los comandos se aprueban automáticamente. Úsalo con precaución." + + shared: + session_db_unavailable: "Base de datos de sesiones no disponible." + session_db_unavailable_prefix: "Base de datos de sesiones no disponible" + session_not_found: "Sesión no encontrada en la base de datos." + warn_passthrough: "⚠️ {error}" diff --git a/locales/fr.yaml b/locales/fr.yaml index 2127f7396bb..0a8399f2748 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -22,3 +22,329 @@ gateway: no_active_goal: "Aucun objectif actif." config_read_failed: "⚠️ Impossible de lire config.yaml : {error}" config_save_failed: "⚠️ Impossible de sauvegarder la configuration : {error}" + + model: + error_prefix: "Erreur : {error}" + switched: "Modèle changé pour `{model}`" + provider_label: "Fournisseur : {provider}" + context_label: "Contexte : {tokens} tokens" + max_output_label: "Sortie max. : {tokens} tokens" + cost_label: "Coût : {cost}" + capabilities_label: "Capacités : {capabilities}" + prompt_caching_enabled: "Cache de prompts : activé" + warning_prefix: "Avertissement : {warning}" + saved_global: "Enregistré dans config.yaml (`--global`)" + session_only_hint: "_(session uniquement — ajoutez `--global` pour conserver)_" + current_label: "Actuel : `{model}` chez {provider}" + current_tag: " (actuel)" + more_models_suffix: " (+{count} autres)" + usage_switch_model: "`/model <name>` — changer de modèle" + usage_switch_provider: "`/model <name> --provider <slug>` — changer de fournisseur" + usage_persist: "`/model <name> --global` — conserver" + + agents: + header: "🤖 **Agents et tâches actifs**" + active_agents: "**Agents actifs :** {count}" + this_chat: " · ce chat" + more: "... et {count} de plus" + running_processes: "**Processus d'arrière-plan en cours :** {count}" + async_jobs: "**Tâches asynchrones du gateway :** {count}" + none: "Aucun agent actif ni tâche en cours." + state_starting: "démarrage" + state_running: "en cours" + + approve: + no_pending: "Aucune commande en attente d'approbation." + once_singular: "✅ Commande approuvée. L'agent reprend..." + once_plural: "✅ Commandes approuvées ({count} commandes). L'agent reprend..." + session_singular: "✅ Commande approuvée (modèle approuvé pour cette session). L'agent reprend..." + session_plural: "✅ Commandes approuvées (modèle approuvé pour cette session) ({count} commandes). L'agent reprend..." + always_singular: "✅ Commande approuvée (modèle approuvé de manière permanente). L'agent reprend..." + always_plural: "✅ Commandes approuvées (modèle approuvé de manière permanente) ({count} commandes). L'agent reprend..." + + background: + usage: "Usage : /background <prompt>\nExemple : /background Résume les meilleures histoires HN d'aujourd'hui\n\nExécute le prompt dans une session séparée. Vous pouvez continuer à discuter — le résultat apparaîtra ici une fois terminé." + started: "🔄 Tâche d'arrière-plan démarrée : « {preview} »\nID de tâche : {task_id}\nVous pouvez continuer à discuter — les résultats apparaîtront ici une fois terminés." + + branch: + db_unavailable: "Base de données des sessions indisponible." + no_conversation: "Aucune conversation à brancher — envoyez d'abord un message." + create_failed: "Échec de la création de la branche : {error}" + switch_failed: "Branche créée mais impossible de basculer dessus." + branched_one: "⑂ Branche **{title}** créée ({count} message copié)\nOriginal : `{parent}`\nBranche : `{new}`\nUtilisez `/resume` pour revenir à l'original." + branched_many: "⑂ Branche **{title}** créée ({count} messages copiés)\nOriginal : `{parent}`\nBranche : `{new}`\nUtilisez `/resume` pour revenir à l'original." + + commands: + usage: "Utilisation : `/commands [page]`" + skill_header: "⚡ **Commandes de skill** :" + default_desc: "Commande de skill" + none: "Aucune commande disponible." + header: "📚 **Commandes** ({total} au total, page {page}/{total_pages})" + nav_prev: "`/commands {page}` ← précédent" + nav_next: "suivant → `/commands {page}`" + out_of_range: "_(La page demandée {requested} était hors limites, affichage de la page {page}.)_" + + compress: + not_enough: "Conversation insuffisante pour la compression (au moins 4 messages nécessaires)." + no_provider: "Aucun fournisseur configuré — compression impossible." + nothing_to_do: "Rien à compresser pour l'instant (la transcription est encore entièrement du contexte protégé)." + focus_line: "Focus : \"{topic}\"" + summary_failed: "⚠️ Échec de la génération du résumé ({error}). {count} message(s) historique(s) ont été supprimés et remplacés par un espace réservé ; le contexte antérieur n'est plus récupérable. Vérifiez la configuration du modèle auxiliary.compression." + aux_failed: "ℹ️ Le modèle de compression configuré `{model}` a échoué ({error}). Récupéré avec votre modèle principal — le contexte est intact — mais vous pouvez vérifier `auxiliary.compression.model` dans config.yaml." + failed: "Échec de la compression : {error}" + + debug: + upload_failed: "✗ Échec de l'envoi du rapport de débogage : {error}" + header: "**Rapport de débogage envoyé :**" + auto_delete: "⏱ Les pastes s'effaceront automatiquement dans 6 heures." + full_logs_hint: "Pour envoyer les journaux complets, utilisez `hermes debug share` depuis la CLI." + share_hint: "Partagez ces liens avec l'équipe Hermes pour obtenir de l'aide." + + deny: + stale: "❌ Commande refusée (l'approbation était périmée)." + no_pending: "Aucune commande en attente de refus." + denied_singular: "❌ Commande refusée." + denied_plural: "❌ Commandes refusées ({count} commandes)." + + fast: + not_supported: "⚡ /fast n'est disponible que pour les modèles OpenAI qui prennent en charge Priority Processing." + status: "⚡ Priority Processing\n\nMode actuel : `{mode}`\n\n_Usage :_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Argument inconnu : `{arg}`\n\n**Options valides :** normal, fast, status" + saved: "⚡ ✓ Priority Processing : **{label}** (enregistré dans la configuration)\n_(prend effet au prochain message)_" + session_only: "⚡ ✓ Priority Processing : **{label}** (cette session uniquement)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Pied de page d'exécution : **{state}**\nChamps : `{fields}`\nPlateforme : `{platform}`" + usage: "Usage : `/footer [on|off|status]`" + saved: "📎 Pied de page d'exécution : **{state}**{example}\n_(enregistré globalement — prend effet au prochain message)_" + example_line: "\nExemple : `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "Les objectifs ne sont pas disponibles dans cette session." + no_goal_set: "Aucun objectif défini." + paused: "⏸ Objectif en pause : {goal}" + no_resume: "Aucun objectif à reprendre." + resumed: "▶ Objectif repris : {goal}\nEnvoyez un message pour continuer, ou attendez — je passerai à l'étape suivante au prochain tour." + invalid: "Objectif invalide : {error}" + set: "⊙ Objectif défini (budget de {budget} tours) : {goal}\nJe continuerai jusqu'à ce que l'objectif soit terminé, que vous le mettiez en pause/effaciez, ou que le budget soit épuisé.\nContrôles : /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Commandes Hermes**\n" + skill_header: "\n⚡ **Commandes de skill** ({count} actives) :" + more_use_commands: "\n... et {count} de plus. Utilisez `/commands` pour la liste paginée complète." + + insights: + invalid_days: "Valeur --days invalide : {value}" + error: "Erreur lors de la génération des analyses : {error}" + + kanban: + error_prefix: "⚠ erreur kanban : {error}" + subscribed_suffix: "(abonné — vous serez notifié lorsque {task_id} se terminera ou sera bloqué)" + truncated_suffix: "… (tronqué ; utilisez `hermes kanban …` dans votre terminal pour la sortie complète)" + no_output: "(aucune sortie)" + + personality: + none_configured: "Aucune personnalité configurée dans `{path}/config.yaml`" + header: "🎭 **Personnalités disponibles**\n" + none_option: "• `none` — (aucune superposition de personnalité)" + item: "• `{name}` — {preview}" + usage: "\nUtilisation : `/personality <name>`" + save_failed: "⚠️ Échec de l'enregistrement du changement de personnalité : {error}" + cleared: "🎭 Personnalité effacée — comportement de base de l'agent utilisé.\n_(prend effet au prochain message)_" + set_to: "🎭 Personnalité définie sur **{name}**\n_(prend effet au prochain message)_" + unknown: "Personnalité inconnue : `{name}`\n\nDisponibles : {available}" + + profile: + header: "👤 **Profil :** `{profile}`" + home: "📂 **Dossier personnel :** `{home}`" + + reasoning: + level_default: "medium (par défaut)" + level_disabled: "none (désactivé)" + scope_session: "remplacement de session" + scope_global: "configuration globale" + status: "🧠 **Paramètres de raisonnement**\n\n**Effort :** `{level}`\n**Portée :** {scope}\n**Affichage :** {display}\n\n_Usage :_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "activé ✓" + display_off: "désactivé" + display_set_on: "🧠 ✓ Affichage du raisonnement : **ACTIVÉ**\nLa réflexion du modèle sera affichée avant chaque réponse sur **{platform}**." + display_set_off: "🧠 ✓ Affichage du raisonnement : **DÉSACTIVÉ** pour **{platform}**" + reset_global_unsupported: "⚠️ `/reasoning reset --global` n'est pas pris en charge. Utilisez `/reasoning <level> --global` pour modifier la valeur globale par défaut." + reset_done: "🧠 ✓ Remplacement de raisonnement de la session effacé ; retour à la configuration globale." + unknown_arg: "⚠️ Argument inconnu : `{arg}`\n\n**Niveaux valides :** none, minimal, low, medium, high, xhigh\n**Affichage :** show, hide\n**Persister :** ajoutez `--global` pour enregistrer au-delà de cette session" + set_global: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (enregistré dans la configuration)\n_(prend effet au prochain message)_" + set_global_save_failed: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (session uniquement — échec de l'enregistrement de la configuration)\n_(prend effet au prochain message)_" + set_session: "🧠 ✓ Effort de raisonnement défini sur `{effort}` (session uniquement — ajoutez `--global` pour persister)\n_(prend effet au prochain message)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp annulé. Outils MCP inchangés." + always_followup: "ℹ️ Les prochains appels `/reload-mcp` s'exécuteront sans confirmation. Réactivez via `approvals.mcp_reload_confirm: true` dans `config.yaml`." + confirm_prompt: "⚠️ **Confirmer /reload-mcp**\n\nRecharger les serveurs MCP reconstruit l'ensemble d'outils de cette session et **invalide le cache de prompt du fournisseur** — le prochain message renverra l'intégralité des jetons d'entrée. Sur les modèles à long contexte ou à raisonnement élevé, cela peut être coûteux.\n\nChoisissez :\n• **Approuver une fois** — recharger maintenant\n• **Toujours approuver** — recharger maintenant et masquer cette confirmation définitivement\n• **Annuler** — laisser les outils MCP inchangés\n\n_Alternative texte : répondez `/approve`, `/always` ou `/cancel`._" + header: "🔄 **Serveurs MCP rechargés**\n" + reconnected: "♻️ Reconnectés : {names}" + added: "➕ Ajoutés : {names}" + removed: "➖ Supprimés : {names}" + none_connected: "Aucun serveur MCP connecté." + tools_available: "\n🔧 {tools} outil(s) disponible(s) sur {servers} serveur(s)" + failed: "❌ Échec du rechargement MCP : {error}" + + reload_skills: + header: "🔄 **Skills rechargées**\n" + no_new: "Aucune nouvelle skill détectée." + total: "\n📚 {count} skill(s) disponible(s)" + added_header: "➕ **Skills ajoutées :**" + removed_header: "➖ **Skills supprimées :**" + item_with_desc: " - {name} : {desc}" + item_no_desc: " - {name}" + failed: "❌ Échec du rechargement des skills : {error}" + + reset: + header_default: "✨ Session réinitialisée ! Nouveau départ." + header_new: "✨ Nouvelle session démarrée !" + header_titled: "✨ Nouvelle session démarrée : {title}" + title_rejected: "\n⚠️ Titre refusé : {error}" + title_error_untitled: "\n⚠️ {error} — session démarrée sans titre." + title_empty_untitled: "\n⚠️ Le titre est vide après nettoyage — session démarrée sans titre." + tip: "\n✦ Astuce : {tip}" + + restart: + in_progress: "⏳ Redémarrage du gateway déjà en cours..." + restarting: "♻ Redémarrage du gateway. Si vous n'êtes pas notifié dans les 60 secondes, redémarrez depuis la console avec `hermes gateway restart`." + + resume: + db_unavailable: "Base de données des sessions indisponible." + no_named_sessions: "Aucune session nommée trouvée.\nUtilisez `/title Ma session` pour nommer la session actuelle, puis `/resume Ma session` pour y revenir plus tard." + list_header: "📋 **Sessions nommées**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nUsage : `/resume <nom de session>`" + list_failed: "Impossible de lister les sessions : {error}" + not_found: "Aucune session correspondant à '**{name}**' trouvée.\nUtilisez `/resume` sans argument pour voir les sessions disponibles." + already_on: "📌 Déjà sur la session **{name}**." + switch_failed: "Échec du changement de session." + resumed_one: "↻ Session **{title}** reprise ({count} message). Conversation restaurée." + resumed_many: "↻ Session **{title}** reprise ({count} messages). Conversation restaurée." + resumed_no_count: "↻ Session **{title}** reprise. Conversation restaurée." + + retry: + no_previous: "Aucun message précédent à réessayer." + + rollback: + not_enabled: "Les points de contrôle ne sont pas activés.\nActivez-les dans config.yaml :\n```\ncheckpoints:\n enabled: true\n```" + none_found: "Aucun point de contrôle trouvé pour {cwd}" + invalid_number: "Numéro de point de contrôle invalide. Utilisez 1-{max}." + restored: "✅ Restauré au point de contrôle {hash} : {reason}\nUn instantané pré-rollback a été enregistré automatiquement." + restore_failed: "❌ {error}" + + set_home: + save_failed: "Impossible d'enregistrer le canal principal : {error}" + success: "✅ Canal principal défini sur **{name}** (ID : {chat_id}).\nLes tâches cron et les messages multi-plateformes seront livrés ici." + + status: + header: "📊 **État de Hermes Gateway**" + session_id: "**ID de session :** `{session_id}`" + title: "**Titre :** {title}" + created: "**Créé :** {timestamp}" + last_activity: "**Dernière activité :** {timestamp}" + tokens: "**Jetons :** {tokens}" + agent_running: "**Agent en cours :** {state}" + state_yes: "Oui ⚡" + state_no: "Non" + queued: "**Suivis en file :** {count}" + platforms: "**Plateformes connectées :** {platforms}" + + stop: + stopped_pending: "⚡ Arrêté. L'agent n'avait pas encore commencé — vous pouvez continuer cette session." + stopped: "⚡ Arrêté. Vous pouvez continuer cette session." + no_active: "Aucune tâche active à arrêter." + + title: + db_unavailable: "Base de données des sessions indisponible." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ Le titre est vide après nettoyage. Utilisez des caractères imprimables." + set_to: "✏️ Titre de session défini : **{title}**" + not_found: "Session introuvable dans la base de données." + current_with_title: "📌 Session : `{session_id}`\nTitre : **{title}**" + current_no_title: "📌 Session : `{session_id}`\nAucun titre défini. Usage : `/title Mon nom de session`" + + topic: + not_telegram_dm: "La commande /topic n'est disponible que dans les chats privés Telegram." + no_session_db: "Base de données de sessions non disponible." + unauthorized: "Vous n'êtes pas autorisé à utiliser /topic sur ce bot." + restore_needs_topic: "Pour restaurer une session, créez ou ouvrez d'abord un topic Telegram, puis envoyez /topic <session-id> dans ce topic. Pour créer un nouveau topic, ouvrez All Messages et envoyez-y n'importe quel message." + topics_disabled: "Les topics Telegram ne sont pas encore activés pour ce bot.\n\nComment les activer :\n1. Ouvrez @BotFather.\n2. Choisissez votre bot.\n3. Ouvrez Bot Settings → Threads Settings.\n4. Activez Threaded Mode et assurez-vous que les utilisateurs sont autorisés à créer de nouveaux threads.\n\nPuis envoyez /topic à nouveau." + topics_user_disallowed: "Les topics Telegram sont activés, mais les utilisateurs ne peuvent pas en créer.\n\nOuvrez @BotFather → choisissez votre bot → Bot Settings → Threads Settings, puis désactivez 'Disallow users to create new threads'.\n\nPuis envoyez /topic à nouveau." + enable_failed: "Échec de l'activation du mode topic Telegram : {error}" + bound_status: "Ce topic est lié à :\nSession : {label}\nID : {session_id}\n\nUtilisez /new pour remplacer ce topic par une nouvelle session.\nPour un travail parallèle, ouvrez All Messages et envoyez-y un message pour créer un autre topic." + thread_ready: "Les topics multi-sessions Telegram sont activés.\n\nCe topic sera utilisé comme session Hermes indépendante. Utilisez /new pour remplacer la session actuelle de ce topic. Pour un travail parallèle, ouvrez All Messages et envoyez-y un message pour créer un autre topic." + untitled_session: "Session sans titre" + + undo: + nothing: "Rien à annuler." + removed: "↩️ {count} message(s) annulé(s).\nSupprimé : « {preview} »" + + update: + platform_not_messaging: "✗ /update n'est disponible que depuis les plateformes de messagerie. Exécutez `hermes update` depuis le terminal." + not_git_repo: "✗ Pas un dépôt git — impossible de mettre à jour." + hermes_cmd_not_found: "✗ Impossible de localiser la commande `hermes`. Hermes est en cours d'exécution, mais la commande de mise à jour n'a pas pu trouver l'exécutable dans le PATH ni via l'interpréteur Python actuel. Essayez d'exécuter `hermes update` manuellement dans votre terminal." + start_failed: "✗ Échec du démarrage de la mise à jour : {error}" + starting: "⚕ Démarrage de la mise à jour Hermes… Je diffuserai la progression ici." + + usage: + rate_limits: "⏱️ **Limites de débit :** {state}" + header_session: "📊 **Utilisation des jetons de session**" + label_model: "Modèle : `{model}`" + label_input_tokens: "Jetons d'entrée : {count}" + label_cache_read: "Jetons de lecture du cache : {count}" + label_cache_write: "Jetons d'écriture du cache : {count}" + label_output_tokens: "Jetons de sortie : {count}" + label_total: "Total : {count}" + label_api_calls: "Appels API : {count}" + label_cost: "Coût : {prefix}${amount}" + label_cost_included: "Coût : inclus" + label_context: "Contexte : {used} / {total} ({pct}%)" + label_compressions: "Compressions : {count}" + header_session_info: "📊 **Infos de session**" + label_messages: "Messages : {count}" + label_estimated_context: "Contexte estimé : ~{count} jetons" + detailed_after_first: "_(Utilisation détaillée disponible après la première réponse de l'agent)_" + no_data: "Aucune donnée d'utilisation disponible pour cette session." + + verbose: + not_enabled: "La commande `/verbose` n'est pas activée pour les plateformes de messagerie.\n\nActivez-la dans `config.yaml` :\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Progression des outils : **OFF** — aucune activité d'outil affichée." + mode_new: "⚙️ Progression des outils : **NEW** — affichée lors d'un changement d'outil (longueur d'aperçu : `display.tool_preview_length`, par défaut 40)." + mode_all: "⚙️ Progression des outils : **ALL** — chaque appel d'outil est affiché (longueur d'aperçu : `display.tool_preview_length`, par défaut 40)." + mode_verbose: "⚙️ Progression des outils : **VERBOSE** — chaque appel d'outil avec ses arguments complets." + saved_suffix: "_(enregistré pour **{platform}** — prend effet au prochain message)_" + save_failed: "_(impossible d'enregistrer dans la configuration : {error})_" + + voice: + enabled_voice_only: "Mode vocal activé.\nJe répondrai en vocal quand vous envoyez des messages vocaux.\nUtilisez /voice tts pour obtenir des réponses vocales à tous les messages." + disabled_text: "Mode vocal désactivé. Réponses uniquement textuelles." + tts_enabled: "TTS automatique activé.\nToutes les réponses incluront un message vocal." + status_mode: "Mode vocal : {label}" + status_channel: "Canal vocal : #{channel}" + status_participants: "Participants : {count}" + status_member: " - {name}{status}" + speaking: " (parle)" + enabled_short: "Mode vocal activé." + disabled_short: "Mode vocal désactivé." + label_off: "Désactivé (texte seulement)" + label_voice_only: "Activé (réponse vocale aux messages vocaux)" + label_all: "TTS (réponse vocale à tous les messages)" + + yolo: + disabled: "⚠️ Mode YOLO **DÉSACTIVÉ** pour cette session — les commandes dangereuses nécessiteront une approbation." + enabled: "⚡ Mode YOLO **ACTIVÉ** pour cette session — toutes les commandes sont auto-approuvées. À utiliser avec prudence." + + shared: + session_db_unavailable: "Base de données de sessions indisponible." + session_db_unavailable_prefix: "Base de données de sessions indisponible" + session_not_found: "Session introuvable dans la base de données." + warn_passthrough: "⚠️ {error}" diff --git a/locales/ga.yaml b/locales/ga.yaml new file mode 100644 index 00000000000..551d8d3362d --- /dev/null +++ b/locales/ga.yaml @@ -0,0 +1,354 @@ +# Hermes static-message catalog -- Gaeilge (Irish) +# See locales/en.yaml for the source of truth; keep keys in sync. +# +# Modern Irish technical writing freely uses English loanwords for terms +# without good native equivalents (e.g. "session", "tokens", "API"). +# Where Irish has a settled term we use it; otherwise we keep the English. + +approval: + dangerous_header: "⚠️ ORDÚ CONTÚIRTEACH: {description}" + choose_long: " [o]uair amháin | [s]eisiún | [a]i gcónaí | [d]iúltaigh" + choose_short: " [o]uair amháin | [s]eisiún | [d]iúltaigh" + prompt_long: " Rogha [o/s/a/D]: " + prompt_short: " Rogha [o/s/D]: " + timeout: " ⏱ Am istigh — ag diúltú don ordú" + allowed_once: " ✓ Ceadaithe uair amháin" + allowed_session: " ✓ Ceadaithe don seisiún seo" + allowed_always: " ✓ Curtha leis an liosta ceadaithe buan" + denied: " ✗ Diúltaithe" + cancelled: " ✗ Cealaithe" + blocklist_message: "Tá an t-ordú seo ar an liosta cosc gan choinníoll agus ní féidir é a cheadú." + +gateway: + approval_expired: "⚠️ Tá an cead imithe in éag (níl an gníomhaire ag fanacht níos mó). Iarr ar an ngníomhaire iarracht eile a dhéanamh." + draining: "⏳ Ag fanacht le {count} gníomhaire(í) gníomhach roimh atosú..." + goal_cleared: "✓ Sprioc glanta." + no_active_goal: "Níl aon sprioc ghníomhach ann." + config_read_failed: "⚠️ Níorbh fhéidir config.yaml a léamh: {error}" + config_save_failed: "⚠️ Níorbh fhéidir an chumraíocht a shábháil: {error}" + + model: + error_prefix: "Earráid: {error}" + switched: "Athraíodh an tsamhail go `{model}`" + provider_label: "Soláthraí: {provider}" + context_label: "Comhthéacs: {tokens} comhartha" + max_output_label: "Aschur uasta: {tokens} comhartha" + cost_label: "Costas: {cost}" + capabilities_label: "Cumais: {capabilities}" + prompt_caching_enabled: "Taisceadh leid: cumasaithe" + warning_prefix: "Rabhadh: {warning}" + saved_global: "Sábháilte i config.yaml (`--global`)" + session_only_hint: "_(seisiún amháin — cuir `--global` leis chun é a choinneáil)_" + current_label: "Reatha: `{model}` ar {provider}" + current_tag: " (reatha)" + more_models_suffix: " (+{count} eile)" + usage_switch_model: "`/model <name>` — athraigh an tsamhail" + usage_switch_provider: "`/model <name> --provider <slug>` — athraigh an soláthraí" + usage_persist: "`/model <name> --global` — coinnigh" + + agents: + header: "🤖 **Gníomhairí & Tascanna Gníomhacha**" + active_agents: "**Gníomhairí gníomhacha:** {count}" + this_chat: " · an comhrá seo" + more: "... agus {count} eile" + running_processes: "**Próisis chúlra ag rith:** {count}" + async_jobs: "**Tascanna asincrónacha gateway:** {count}" + none: "Níl aon ghníomhairí gníomhacha ná tascanna ag rith." + state_starting: "ag tosú" + state_running: "ag rith" + + approve: + no_pending: "Níl aon ordú ag fanacht le ceadú." + once_singular: "✅ Ordú ceadaithe. Tá an gníomhaire ag atosú..." + once_plural: "✅ Orduithe ceadaithe ({count} ordú). Tá an gníomhaire ag atosú..." + session_singular: "✅ Ordú ceadaithe (patrún ceadaithe don seisiún seo). Tá an gníomhaire ag atosú..." + session_plural: "✅ Orduithe ceadaithe (patrún ceadaithe don seisiún seo) ({count} ordú). Tá an gníomhaire ag atosú..." + always_singular: "✅ Ordú ceadaithe (patrún ceadaithe go buan). Tá an gníomhaire ag atosú..." + always_plural: "✅ Orduithe ceadaithe (patrún ceadaithe go buan) ({count} ordú). Tá an gníomhaire ag atosú..." + + background: + usage: "Úsáid: /background <leid>\nSampla: /background Déan achoimre ar phríomhscéalta HN inniu\n\nRitheann an leid i seisiún ar leith. Is féidir leat leanúint leis an gcomhrá — taispeánfar an toradh anseo nuair a bheidh sé críochnaithe." + started: "🔄 Tasc cúlra tosaithe: \"{preview}\"\nAitheantas an tasc: {task_id}\nIs féidir leat leanúint leis an gcomhrá — taispeánfar na torthaí nuair a bheidh sé críochnaithe." + + branch: + db_unavailable: "Níl bunachar sonraí na seisiún ar fáil." + no_conversation: "Níl aon chomhrá le brainseáil — seol teachtaireacht ar dtús." + create_failed: "Theip ar an mbrainse a chruthú: {error}" + switch_failed: "Cruthaíodh an brainse ach theip ar athrú chuige." + branched_one: "⑂ Brainseáilte go **{title}** ({count} teachtaireacht cóipeáilte)\nBunaidh: `{parent}`\nBrainse: `{new}`\nÚsáid `/resume` chun filleadh ar an mbunaidh." + branched_many: "⑂ Brainseáilte go **{title}** ({count} teachtaireacht cóipeáilte)\nBunaidh: `{parent}`\nBrainse: `{new}`\nÚsáid `/resume` chun filleadh ar an mbunaidh." + + commands: + usage: "Úsáid: `/commands [page]`" + skill_header: "⚡ **Orduithe Scileanna**:" + default_desc: "Ordú scile" + none: "Níl aon ordú ar fáil." + header: "📚 **Orduithe** ({total} san iomlán, leathanach {page}/{total_pages})" + nav_prev: "`/commands {page}` ← roimhe seo" + nav_next: "ar aghaidh → `/commands {page}`" + out_of_range: "_(Bhí leathanach {requested} a iarradh as raon, ag taispeáint leathanach {page}.)_" + + compress: + not_enough: "Níl go leor comhrá le dlúthú (teastaíonn 4 theachtaireacht ar a laghad)." + no_provider: "Níl aon soláthraí cumraithe — ní féidir dlúthú." + nothing_to_do: "Níl aon rud le dlúthú fós (tá an traschríbhinn fós uile mar chomhthéacs cosanta)." + focus_line: "Fócas: \"{topic}\"" + summary_failed: "⚠️ Theip ar ghiniúint achoimre ({error}). Baineadh {count} teachtaireacht stairiúil agus cuireadh ionadaí ina n-áit; níl an comhthéacs roimhe seo in-aisghabhála a thuilleadh. Smaoinigh ar an gcumraíocht auxiliary.compression a sheiceáil." + aux_failed: "ℹ️ Theip ar an tsamhail dlúthúcháin chumraithe `{model}` ({error}). Aisghafa ag baint úsáide as do phríomhshamhail — tá an comhthéacs slán — ach b'fhéidir gur mhaith leat `auxiliary.compression.model` i config.yaml a sheiceáil." + failed: "Theip ar dhlúthú: {error}" + + debug: + upload_failed: "✗ Theip ar uaslódáil tuairisce dífhabhtaithe: {error}" + header: "**Tuairisc dhífhabhtaithe uaslódáilte:**" + auto_delete: "⏱ Scriosfar na pastes go huathoibríoch i 6 huaire." + full_logs_hint: "Le haghaidh uaslódálacha logála iomlána, úsáid `hermes debug share` ón CLI." + share_hint: "Roinn na naisc seo le foireann Hermes le haghaidh tacaíochta." + + deny: + stale: "❌ Ordú diúltaithe (bhí an cead imithe i léig)." + no_pending: "Níl aon ordú ag fanacht le diúltú." + denied_singular: "❌ Ordú diúltaithe." + denied_plural: "❌ Orduithe diúltaithe ({count} ordú)." + + fast: + not_supported: "⚡ Tá /fast ar fáil amháin do shamhlacha OpenAI a thacaíonn le Priority Processing." + status: "⚡ Priority Processing\n\nMód reatha: `{mode}`\n\n_Úsáid:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Argóint anaithnid: `{arg}`\n\n**Roghanna bailí:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (sábháilte sa chumraíocht)\n_(éifeachtach ón gcéad teachtaireacht eile)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (an seisiún seo amháin)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Buntásc rite: **{state}**\nRéimsí: `{fields}`\nArdán: `{platform}`" + usage: "Úsáid: `/footer [on|off|status]`" + saved: "📎 Buntásc rite: **{state}**{example}\n_(sábháilte go domhanda — éifeachtach ón gcéad teachtaireacht eile)_" + example_line: "\nSampla: `{preview}`" + state_on: "AR" + state_off: "AS" + + goal: + unavailable: "Níl spriocanna ar fáil sa seisiún seo." + no_goal_set: "Níl aon sprioc socraithe." + paused: "⏸ Sprioc curtha ar sos: {goal}" + no_resume: "Níl aon sprioc le hatosú." + resumed: "▶ Sprioc atosaithe: {goal}\nSeol teachtaireacht ar bith chun leanúint, nó fan — déanfaidh mé an chéad chéim eile sa chéad seal eile." + invalid: "Sprioc neamhbhailí: {error}" + set: "⊙ Sprioc socraithe (buiséad {budget} seal): {goal}\nLeanfaidh mé ag obair go dtí go bhfuil an sprioc críochnaithe, go gcuirfidh tú ar sos / go nglanfaidh tú í, nó go n-úsáidfear an buiséad.\nSmacht: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Orduithe Hermes**\n" + skill_header: "\n⚡ **Orduithe Scileanna** ({count} gníomhach):" + more_use_commands: "\n... agus {count} eile. Úsáid `/commands` don liosta iomlán uimhrithe." + + insights: + invalid_days: "Luach --days neamhbhailí: {value}" + error: "Earráid agus léargais á gcruthú: {error}" + + kanban: + error_prefix: "⚠ earráid kanban: {error}" + subscribed_suffix: "(síntiúsaithe — cuirfear in iúl duit nuair a chríochnóidh nó a stopfaidh {task_id})" + truncated_suffix: "… (giorraithe; úsáid `hermes kanban …` i do theirminéal le haghaidh aschur iomláin)" + no_output: "(gan aschur)" + + personality: + none_configured: "Níl aon phearsantachtaí cumraithe in `{path}/config.yaml`" + header: "🎭 **Pearsantachtaí ar fáil**\n" + none_option: "• `none` — (gan forleagan pearsantachta)" + item: "• `{name}` — {preview}" + usage: "\nÚsáid: `/personality <name>`" + save_failed: "⚠️ Theip ar shábháil athraithe pearsantachta: {error}" + cleared: "🎭 Pearsantacht glanta — ag úsáid iompair bunúsaigh an ghníomhaire.\n_(éifeachtach ón gcéad teachtaireacht eile)_" + set_to: "🎭 Pearsantacht socraithe go **{name}**\n_(éifeachtach ón gcéad teachtaireacht eile)_" + unknown: "Pearsantacht anaithnid: `{name}`\n\nAr fáil: {available}" + + profile: + header: "👤 **Próifíl:** `{profile}`" + home: "📂 **Baile:** `{home}`" + + reasoning: + level_default: "medium (réamhshocraithe)" + level_disabled: "none (díchumasaithe)" + scope_session: "sárú seisiúin" + scope_global: "cumraíocht dhomhanda" + status: "🧠 **Socruithe Réasúnaíochta**\n\n**Iarracht:** `{level}`\n**Scóip:** {scope}\n**Taispeáint:** {display}\n\n_Úsáid:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "ar ✓" + display_off: "as" + display_set_on: "🧠 ✓ Taispeáint réasúnaíochta: **AR**\nTaispeánfar smaointeoireacht na samhla roimh gach freagra ar **{platform}**." + display_set_off: "🧠 ✓ Taispeáint réasúnaíochta: **AS** do **{platform}**" + reset_global_unsupported: "⚠️ Ní thacaítear le `/reasoning reset --global`. Úsáid `/reasoning <level> --global` chun an réamhshocrú domhanda a athrú." + reset_done: "🧠 ✓ Sárú réasúnaíochta seisiúin glanta; ag titim siar ar an gcumraíocht dhomhanda." + unknown_arg: "⚠️ Argóint anaithnid: `{arg}`\n\n**Leibhéil bhailí:** none, minimal, low, medium, high, xhigh\n**Taispeáint:** show, hide\n**Coinnigh:** cuir `--global` leis chun sábháil thar an seisiún seo" + set_global: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (sábháilte sa chumraíocht)\n_(éifeachtach ón gcéad teachtaireacht eile)_" + set_global_save_failed: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (seisiún amháin — theip ar shábháil cumraíochta)\n_(éifeachtach ón gcéad teachtaireacht eile)_" + set_session: "🧠 ✓ Iarracht réasúnaíochta socraithe go `{effort}` (seisiún amháin — cuir `--global` leis chun é a choinneáil)\n_(éifeachtach ón gcéad teachtaireacht eile)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp cealaithe. Tá uirlisí MCP gan athrú." + always_followup: "ℹ️ Rithfear glaonna `/reload-mcp` amach anseo gan dearbhú. Athchumasaigh trí `approvals.mcp_reload_confirm: true` a shocrú in config.yaml." + confirm_prompt: "⚠️ **Dearbhaigh /reload-mcp**\n\nAthlódáil freastalaithe MCP a athchruthaíonn an tacar uirlisí don seisiún seo agus **cuireann sé taisce leid an tsoláthraí ar neamhní** — seolfaidh an chéad teachtaireacht eile na comharthaí ionchuir iomlána arís. Ar shamhlacha le comhthéacs fada nó réasúnaíocht ard, is féidir leis seo a bheith costasach.\n\nRoghnaigh:\n• **Approve Once** — athlódáil anois\n• **Always Approve** — athlódáil anois agus an leid seo a chiúnú go buan\n• **Cancel** — fág uirlisí MCP gan athrú\n\n_Cúltaca téacs: freagair `/approve`, `/always`, nó `/cancel`._" + header: "🔄 **Freastalaithe MCP Athlódáilte**\n" + reconnected: "♻️ Athcheanglaithe: {names}" + added: "➕ Curtha leis: {names}" + removed: "➖ Bainte: {names}" + none_connected: "Níl aon fhreastalaí MCP ceangailte." + tools_available: "\n🔧 {tools} uirlis(í) ar fáil ó {servers} freastalaí(thí)" + failed: "❌ Theip ar athlódáil MCP: {error}" + + reload_skills: + header: "🔄 **Scileanna Athlódáilte**\n" + no_new: "Níor braitheadh aon scil nua." + total: "\n📚 {count} scil(eanna) ar fáil" + added_header: "➕ **Scileanna Curtha leis:**" + removed_header: "➖ **Scileanna Bainte:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ Theip ar athlódáil scileanna: {error}" + + reset: + header_default: "✨ Seisiún athshocraithe! Ag tosú as an nua." + header_new: "✨ Seisiún nua tosaithe!" + header_titled: "✨ Seisiún nua tosaithe: {title}" + title_rejected: "\n⚠️ Teideal diúltaithe: {error}" + title_error_untitled: "\n⚠️ {error} — seisiún tosaithe gan teideal." + title_empty_untitled: "\n⚠️ Tá an teideal folamh tar éis glanta — seisiún tosaithe gan teideal." + tip: "\n✦ Leid: {tip}" + + restart: + in_progress: "⏳ Tá atosú gateway ar siúl cheana féin..." + restarting: "♻ Ag atosú gateway. Mura gcuirfear in iúl duit laistigh de 60 soicind, atosaigh ón gconsól le `hermes gateway restart`." + + resume: + db_unavailable: "Níl bunachar sonraí na seisiún ar fáil." + no_named_sessions: "Níor aimsíodh aon seisiún ainmnithe.\nÚsáid `/title M'Ainm Seisiúin` chun do sheisiún reatha a ainmniú, ansin `/resume M'Ainm Seisiúin` chun filleadh air níos déanaí." + list_header: "📋 **Seisiúin Ainmnithe**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nÚsáid: `/resume <session name>`" + list_failed: "Níorbh fhéidir seisiúin a liostáil: {error}" + not_found: "Níor aimsíodh aon seisiún ag teacht le '**{name}**'.\nÚsáid `/resume` gan argóintí chun seisiúin atá ar fáil a fheiceáil." + already_on: "📌 Cheana ar an seisiún **{name}**." + switch_failed: "Theip ar athrú seisiúin." + resumed_one: "↻ Seisiún **{title}** atosaithe ({count} teachtaireacht). Comhrá aischurtha." + resumed_many: "↻ Seisiún **{title}** atosaithe ({count} teachtaireacht). Comhrá aischurtha." + resumed_no_count: "↻ Seisiún **{title}** atosaithe. Comhrá aischurtha." + + retry: + no_previous: "Níl aon teachtaireacht roimhe seo le hath-iarraidh." + + rollback: + not_enabled: "Níl seicphointí cumasaithe.\nCumasaigh in config.yaml:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "Níor aimsíodh aon seicphointe do {cwd}" + invalid_number: "Uimhir seicphointe neamhbhailí. Úsáid 1-{max}." + restored: "✅ Aischurtha go seicphointe {hash}: {reason}\nSábháladh roghchóip réamh-rollback go huathoibríoch." + restore_failed: "❌ {error}" + + set_home: + save_failed: "Theip ar shábháil chainéil bhaile: {error}" + success: "✅ Cainéal baile socraithe go **{name}** (ID: {chat_id}).\nSeachadfar tascanna cron agus teachtaireachtaí trasardáin anseo." + + status: + header: "📊 **Stádas Hermes Gateway**" + session_id: "**ID Seisiúin:** `{session_id}`" + title: "**Teideal:** {title}" + created: "**Cruthaithe:** {timestamp}" + last_activity: "**Gníomhaíocht is déanaí:** {timestamp}" + tokens: "**Comharthaí:** {tokens}" + agent_running: "**Gníomhaire ag rith:** {state}" + state_yes: "Tá ⚡" + state_no: "Níl" + queued: "**Tascanna i scuaine:** {count}" + platforms: "**Ardáin Cheangailte:** {platforms}" + + stop: + stopped_pending: "⚡ Stoptha. Ní raibh an gníomhaire tosaithe fós — is féidir leat leanúint leis an seisiún seo." + stopped: "⚡ Stoptha. Is féidir leat leanúint leis an seisiún seo." + no_active: "Níl aon tasc gníomhach le stopadh." + + title: + db_unavailable: "Níl bunachar sonraí na seisiún ar fáil." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ Tá an teideal folamh tar éis glanta. Bain úsáid as carachtair inphriontáilte le do thoil." + set_to: "✏️ Teideal seisiúin socraithe: **{title}**" + not_found: "Seisiún gan a aimsiú sa bhunachar sonraí." + current_with_title: "📌 Seisiún: `{session_id}`\nTeideal: **{title}**" + current_no_title: "📌 Seisiún: `{session_id}`\nGan teideal socraithe. Úsáid: `/title M'Ainm Seisiúin`" + + topic: + not_telegram_dm: "Tá an t-ordú /topic ar fáil amháin i gcomhráite príobháideacha Telegram." + no_session_db: "Níl bunachar sonraí na seisiún ar fáil." + unauthorized: "Níl tú údaraithe chun /topic a úsáid ar an mbot seo." + restore_needs_topic: "Chun seisiún a athchóiriú, cruthaigh nó oscail topaic Telegram ar dtús, ansin seol /topic <session-id> taobh istigh den topaic sin. Chun topaic nua a chruthú, oscail All Messages agus seol teachtaireacht ar bith ann." + topics_disabled: "Níl topaicí Telegram cumasaithe don bhot seo fós.\n\nConas iad a chumasú:\n1. Oscail @BotFather.\n2. Roghnaigh do bhot.\n3. Oscail Bot Settings → Threads Settings.\n4. Casadh ar Threaded Mode agus déan cinnte go bhfuil cead ag úsáideoirí snáitheanna nua a chruthú.\n\nAnsin seol /topic arís." + topics_user_disallowed: "Tá topaicí Telegram cumasaithe, ach níl cead ag úsáideoirí topaicí a chruthú.\n\nOscail @BotFather → roghnaigh do bhot → Bot Settings → Threads Settings, ansin múchadh 'Disallow users to create new threads'.\n\nAnsin seol /topic arís." + enable_failed: "Theip ar mhodh topaice Telegram a chumasú: {error}" + bound_status: "Tá an topaic seo nasctha le:\nSeisiún: {label}\nID: {session_id}\n\nÚsáid /new chun an topaic seo a athsholáthar le seisiún úr.\nLe haghaidh oibre comhthreomhaire, oscail All Messages agus seol teachtaireacht ann chun topaic eile a chruthú." + thread_ready: "Tá topaicí il-seisiúin Telegram cumasaithe.\n\nÚsáidfear an topaic seo mar sheisiún Hermes neamhspleách. Úsáid /new chun seisiún reatha na topaice seo a athsholáthar. Le haghaidh oibre comhthreomhaire, oscail All Messages agus seol teachtaireacht ann chun topaic eile a chruthú." + untitled_session: "Seisiún gan teideal" + + undo: + nothing: "Níl aon rud le cealú." + removed: "↩️ Cealaíodh {count} teachtaireacht.\nBaineadh: \"{preview}\"" + + update: + platform_not_messaging: "✗ Tá /update ar fáil amháin ó ardáin teachtaireachtaí. Rith `hermes update` ón teirminéal." + not_git_repo: "✗ Ní stór git é seo — ní féidir nuashonrú." + hermes_cmd_not_found: "✗ Níorbh fhéidir an t-ordú `hermes` a aimsiú. Tá Hermes ag rith, ach níorbh fhéidir leis an ordú nuashonraithe an inrite a aimsiú ar PATH ná tríd an léirmhínitheoir Python reatha. Bain triail as `hermes update` a rith de láimh i do theirminéal." + start_failed: "✗ Theip ar nuashonrú a thosú: {error}" + starting: "⚕ Ag tosú nuashonrú Hermes… Cuirfidh mé an dul chun cinn ar shruth anseo." + + usage: + rate_limits: "⏱️ **Teorainneacha Ráta:** {state}" + header_session: "📊 **Úsáid Comharthaí Seisiúin**" + label_model: "Samhail: `{model}`" + label_input_tokens: "Comharthaí ionchuir: {count}" + label_cache_read: "Comharthaí léite ón taisce: {count}" + label_cache_write: "Comharthaí scríofa sa taisce: {count}" + label_output_tokens: "Comharthaí aschuir: {count}" + label_total: "Iomlán: {count}" + label_api_calls: "Glaonna API: {count}" + label_cost: "Costas: {prefix}${amount}" + label_cost_included: "Costas: san áireamh" + label_context: "Comhthéacs: {used} / {total} ({pct}%)" + label_compressions: "Dlúthuithe: {count}" + header_session_info: "📊 **Eolas Seisiúin**" + label_messages: "Teachtaireachtaí: {count}" + label_estimated_context: "Comhthéacs measta: ~{count} comhartha" + detailed_after_first: "_(Úsáid mhionsonraithe ar fáil tar éis chéad fhreagra an ghníomhaire)_" + no_data: "Níl aon sonraí úsáide ar fáil don seisiún seo." + + verbose: + not_enabled: "Níl an t-ordú `/verbose` cumasaithe d'ardáin teachtaireachtaí.\n\nCumasaigh in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Dul chun cinn uirlise: **AS** — gan aon ghníomhaíocht uirlise á thaispeáint." + mode_new: "⚙️ Dul chun cinn uirlise: **NUA** — taispeánta nuair a athraíonn an uirlis (fad réamhamhairc: `display.tool_preview_length`, réamhshocrú 40)." + mode_all: "⚙️ Dul chun cinn uirlise: **GACH CEANN** — taispeántar gach glao uirlise (fad réamhamhairc: `display.tool_preview_length`, réamhshocrú 40)." + mode_verbose: "⚙️ Dul chun cinn uirlise: **BÉALSCAOILTE** — gach glao uirlise le hargóintí iomlána." + saved_suffix: "_(sábháilte do **{platform}** — éifeachtach ón gcéad teachtaireacht eile)_" + save_failed: "_(níorbh fhéidir sábháil sa chumraíocht: {error})_" + + voice: + enabled_voice_only: "Mód gutha cumasaithe.\nFreagróidh mé le guth nuair a sheolann tú teachtaireachtaí gutha.\nÚsáid /voice tts chun freagraí gutha a fháil do gach teachtaireacht." + disabled_text: "Mód gutha díchumasaithe. Freagraí téacs amháin." + tts_enabled: "Auto-TTS cumasaithe.\nBeidh teachtaireacht gutha mar chuid de gach freagra." + status_mode: "Mód gutha: {label}" + status_channel: "Cainéal gutha: #{channel}" + status_participants: "Rannpháirtithe: {count}" + status_member: " - {name}{status}" + speaking: " (ag labhairt)" + enabled_short: "Mód gutha cumasaithe." + disabled_short: "Mód gutha díchumasaithe." + label_off: "As (téacs amháin)" + label_voice_only: "Ar (freagra gutha do theachtaireachtaí gutha)" + label_all: "TTS (freagra gutha do gach teachtaireacht)" + + yolo: + disabled: "⚠️ Mód YOLO **AS** don seisiún seo — beidh cead de dhíth d'orduithe contúirteacha." + enabled: "⚡ Mód YOLO **AR** don seisiún seo — gach ordú ceadaithe go huathoibríoch. Úsáid go cúramach." + + shared: + session_db_unavailable: "Níl bunachar sonraí na seisiún ar fáil." + session_db_unavailable_prefix: "Níl bunachar sonraí na seisiún ar fáil" + session_not_found: "Seisiún gan a aimsiú sa bhunachar sonraí." + warn_passthrough: "⚠️ {error}" diff --git a/locales/hu.yaml b/locales/hu.yaml new file mode 100644 index 00000000000..21fb4c81324 --- /dev/null +++ b/locales/hu.yaml @@ -0,0 +1,350 @@ +# Hermes statikus üzenetkatalógus -- Magyar +# See locales/en.yaml for the source of truth; keep keys in sync. + +approval: + dangerous_header: "⚠️ VESZÉLYES PARANCS: {description}" + choose_long: " [o]egyszer | [s]munkamenet | [a]mindig | [d]elutasít" + choose_short: " [o]egyszer | [s]munkamenet | [d]elutasít" + prompt_long: " Választás [o/s/a/D]: " + prompt_short: " Választás [o/s/D]: " + timeout: " ⏱ Időtúllépés - parancs elutasítva" + allowed_once: " ✓ Egyszer engedélyezve" + allowed_session: " ✓ Engedélyezve ehhez a munkamenethez" + allowed_always: " ✓ Hozzáadva az állandó engedélylistához" + denied: " ✗ Elutasítva" + cancelled: " ✗ Megszakítva" + blocklist_message: "Ez a parancs a feltétel nélküli tiltólistán van, és nem hagyható jóvá." + +gateway: + approval_expired: "⚠️ A jóváhagyás lejárt (az ügynök már nem vár). Kérd meg az ügynököt, hogy próbálja újra." + draining: "⏳ {count} aktív ügynök befejezésére várunk az újraindítás előtt..." + goal_cleared: "✓ A cél törölve." + no_active_goal: "Nincs aktív cél." + config_read_failed: "⚠️ Nem sikerült olvasni a config.yaml fájlt: {error}" + config_save_failed: "⚠️ Nem sikerült menteni a konfigurációt: {error}" + + model: + error_prefix: "Hiba: {error}" + switched: "Modell átváltva: `{model}`" + provider_label: "Szolgáltató: {provider}" + context_label: "Kontextus: {tokens} token" + max_output_label: "Max. kimenet: {tokens} token" + cost_label: "Költség: {cost}" + capabilities_label: "Képességek: {capabilities}" + prompt_caching_enabled: "Prompt-gyorsítótárazás: bekapcsolva" + warning_prefix: "Figyelmeztetés: {warning}" + saved_global: "Mentve a config.yaml fájlba (`--global`)" + session_only_hint: "_(csak ehhez a munkamenethez — add hozzá a `--global` opciót a megőrzéshez)_" + current_label: "Aktuális: `{model}` ezen: {provider}" + current_tag: " (aktuális)" + more_models_suffix: " (+{count} további)" + usage_switch_model: "`/model <name>` — modell váltása" + usage_switch_provider: "`/model <name> --provider <slug>` — szolgáltató váltása" + usage_persist: "`/model <name> --global` — megőrzés" + + agents: + header: "🤖 **Aktív ügynökök és feladatok**" + active_agents: "**Aktív ügynökök:** {count}" + this_chat: " · ez a csevegés" + more: "... és még {count}" + running_processes: "**Futó háttérfolyamatok:** {count}" + async_jobs: "**Átjáró aszinkron feladatai:** {count}" + none: "Nincsenek aktív ügynökök vagy futó feladatok." + state_starting: "indul" + state_running: "fut" + + approve: + no_pending: "Nincs jóváhagyásra váró parancs." + once_singular: "✅ Parancs jóváhagyva. Az ügynök folytatja..." + once_plural: "✅ Parancsok jóváhagyva ({count} parancs). Az ügynök folytatja..." + session_singular: "✅ Parancs jóváhagyva (minta jóváhagyva ehhez a munkamenethez). Az ügynök folytatja..." + session_plural: "✅ Parancsok jóváhagyva (minta jóváhagyva ehhez a munkamenethez) ({count} parancs). Az ügynök folytatja..." + always_singular: "✅ Parancs jóváhagyva (minta véglegesen jóváhagyva). Az ügynök folytatja..." + always_plural: "✅ Parancsok jóváhagyva (minta véglegesen jóváhagyva) ({count} parancs). Az ügynök folytatja..." + + background: + usage: "Használat: /background <prompt>\nPélda: /background Foglald össze a mai legjobb HN sztorikat\n\nKülön munkamenetben futtatja a promptot. Folytathatod a beszélgetést — az eredmény itt jelenik meg, amint elkészül." + started: "🔄 Háttérfeladat elindítva: \"{preview}\"\nFeladatazonosító: {task_id}\nFolytathatod a beszélgetést — az eredmények itt jelennek meg, amint elkészülnek." + + branch: + db_unavailable: "A munkamenet-adatbázis nem érhető el." + no_conversation: "Nincs elágaztatható beszélgetés — küldj előbb egy üzenetet." + create_failed: "Nem sikerült létrehozni az ágat: {error}" + switch_failed: "Az ág létrejött, de nem sikerült rá váltani." + branched_one: "⑂ Új ág: **{title}** ({count} üzenet másolva)\nEredeti: `{parent}`\nÁg: `{new}`\nHasználd a `/resume` parancsot az eredetihez való visszatéréshez." + branched_many: "⑂ Új ág: **{title}** ({count} üzenet másolva)\nEredeti: `{parent}`\nÁg: `{new}`\nHasználd a `/resume` parancsot az eredetihez való visszatéréshez." + + commands: + usage: "Használat: `/commands [page]`" + skill_header: "⚡ **Készségparancsok**:" + default_desc: "Készségparancs" + none: "Nincsenek elérhető parancsok." + header: "📚 **Parancsok** (összesen {total}, {page}/{total_pages}. oldal)" + nav_prev: "`/commands {page}` ← előző" + nav_next: "következő → `/commands {page}`" + out_of_range: "_(A kért {requested}. oldal a tartományon kívül esik, a(z) {page}. oldal jelenik meg.)_" + + compress: + not_enough: "Nincs elég beszélgetés a tömörítéshez (legalább 4 üzenet kell)." + no_provider: "Nincs konfigurált szolgáltató — nem lehet tömöríteni." + nothing_to_do: "Még nincs mit tömöríteni (a teljes átirat még védett kontextus)." + focus_line: "Fókusz: \"{topic}\"" + summary_failed: "⚠️ Az összefoglaló generálása sikertelen ({error}). {count} korábbi üzenet eltávolítva és helykitöltővel helyettesítve; a korábbi kontextus már nem helyreállítható. Érdemes ellenőrizni az auxiliary.compression modell konfigurációját." + aux_failed: "ℹ️ A beállított tömörítőmodell (`{model}`) hibát adott ({error}). A főmodellel helyreállítva — a kontextus érintetlen — de érdemes ellenőrizni az `auxiliary.compression.model` beállítást a config.yaml fájlban." + failed: "Tömörítés sikertelen: {error}" + + debug: + upload_failed: "✗ Nem sikerült feltölteni a hibakeresési jelentést: {error}" + header: "**Hibakeresési jelentés feltöltve:**" + auto_delete: "⏱ A beillesztések 6 óra múlva automatikusan törlődnek." + full_logs_hint: "Teljes naplók feltöltéséhez használd a `hermes debug share` parancsot a CLI-ből." + share_hint: "Oszd meg ezeket a hivatkozásokat a Hermes csapattal támogatásért." + + deny: + stale: "❌ Parancs elutasítva (a jóváhagyás elavult)." + no_pending: "Nincs elutasítható függőben lévő parancs." + denied_singular: "❌ Parancs elutasítva." + denied_plural: "❌ Parancsok elutasítva ({count} parancs)." + + fast: + not_supported: "⚡ A /fast csak olyan OpenAI modelleknél érhető el, amelyek támogatják a Priority Processinget." + status: "⚡ Priority Processing\n\nJelenlegi mód: `{mode}`\n\n_Használat:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Ismeretlen argumentum: `{arg}`\n\n**Érvényes lehetőségek:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (mentve a konfigurációba)\n_(a következő üzenettől lép életbe)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (csak ebben a munkamenetben)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Futási idejű lábléc: **{state}**\nMezők: `{fields}`\nPlatform: `{platform}`" + usage: "Használat: `/footer [on|off|status]`" + saved: "📎 Futási idejű lábléc: **{state}**{example}\n_(globálisan elmentve — a következő üzenettől lép életbe)_" + example_line: "\nPélda: `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "A célok nem érhetők el ebben a munkamenetben." + no_goal_set: "Nincs cél beállítva." + paused: "⏸ Cél szüneteltetve: {goal}" + no_resume: "Nincs folytatható cél." + resumed: "▶ Cél folytatva: {goal}\nKüldj bármilyen üzenetet a folytatáshoz, vagy várj — a következő körben megteszem a következő lépést." + invalid: "Érvénytelen cél: {error}" + set: "⊙ Cél beállítva ({budget} körös keret): {goal}\nDolgozni fogok rajta, amíg a cél el nem készül, te nem szünetelteted/törlöd, vagy a keret ki nem merül.\nVezérlés: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Hermes parancsok**\n" + skill_header: "\n⚡ **Készségparancsok** ({count} aktív):" + more_use_commands: "\n... és még {count}. Használd a `/commands` parancsot a teljes, lapozható listához." + + insights: + invalid_days: "Érvénytelen --days érték: {value}" + error: "Hiba a betekintések generálásakor: {error}" + + kanban: + error_prefix: "⚠ kanban hiba: {error}" + subscribed_suffix: "(feliratkozva — értesítést kapsz, ha a {task_id} befejeződik vagy elakad)" + truncated_suffix: "… (csonkítva; használd a `hermes kanban …` parancsot a terminálban a teljes kimenethez)" + no_output: "(nincs kimenet)" + + personality: + none_configured: "Nincs személyiség beállítva itt: `{path}/config.yaml`" + header: "🎭 **Elérhető személyiségek**\n" + none_option: "• `none` — (nincs személyiségréteg)" + item: "• `{name}` — {preview}" + usage: "\nHasználat: `/personality <name>`" + save_failed: "⚠️ Nem sikerült menteni a személyiség módosítását: {error}" + cleared: "🎭 Személyiség törölve — alap ügynöki viselkedés használatban.\n_(a következő üzenettől lép életbe)_" + set_to: "🎭 Személyiség beállítva: **{name}**\n_(a következő üzenettől lép életbe)_" + unknown: "Ismeretlen személyiség: `{name}`\n\nElérhetők: {available}" + + profile: + header: "👤 **Profil:** `{profile}`" + home: "📂 **Kezdőkönyvtár:** `{home}`" + + reasoning: + level_default: "medium (alapértelmezett)" + level_disabled: "none (kikapcsolva)" + scope_session: "munkamenet-felülbírálás" + scope_global: "globális konfiguráció" + status: "🧠 **Gondolkodási beállítások**\n\n**Erőfeszítés:** `{level}`\n**Hatókör:** {scope}\n**Megjelenítés:** {display}\n\n_Használat:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "be ✓" + display_off: "ki" + display_set_on: "🧠 ✓ Gondolkodás megjelenítése: **BE**\nA modell gondolatai minden válasz előtt megjelennek itt: **{platform}**." + display_set_off: "🧠 ✓ Gondolkodás megjelenítése: **KI** itt: **{platform}**" + reset_global_unsupported: "⚠️ A `/reasoning reset --global` nem támogatott. Használd a `/reasoning <level> --global` parancsot a globális alapérték módosításához." + reset_done: "🧠 ✓ A munkamenet gondolkodási felülbírálása törölve; visszaállás a globális konfigurációra." + unknown_arg: "⚠️ Ismeretlen argumentum: `{arg}`\n\n**Érvényes szintek:** none, minimal, low, medium, high, xhigh\n**Megjelenítés:** show, hide\n**Megőrzés:** add hozzá a `--global` opciót a munkameneten túli mentéshez" + set_global: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (mentve a konfigurációba)\n_(a következő üzenettől lép életbe)_" + set_global_save_failed: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (csak ebben a munkamenetben — a konfiguráció mentése sikertelen)\n_(a következő üzenettől lép életbe)_" + set_session: "🧠 ✓ Gondolkodási erőfeszítés beállítva: `{effort}` (csak ebben a munkamenetben — add hozzá a `--global` opciót a megőrzéshez)\n_(a következő üzenettől lép életbe)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp megszakítva. Az MCP-eszközök változatlanok." + always_followup: "ℹ️ A jövőbeli `/reload-mcp` hívások megerősítés nélkül futnak. Újra engedélyezhető az `approvals.mcp_reload_confirm: true` beállítással a config.yaml fájlban." + confirm_prompt: "⚠️ **A /reload-mcp megerősítése**\n\nAz MCP-szerverek újratöltése újraépíti az eszközkészletet ehhez a munkamenethez, és **érvényteleníti a szolgáltató prompt-gyorsítótárát** — a következő üzenet újraküldi a teljes bemeneti tokent. Hosszú kontextusú vagy magas gondolkodási szintű modelleknél ez költséges lehet.\n\nVálassz:\n• **Egyszeri jóváhagyás** — újratöltés most\n• **Mindig jóváhagy** — újratöltés most, és ennek a kérdésnek a végleges elnémítása\n• **Megszakítás** — az MCP-eszközök változatlanok maradnak\n\n_Szöveges alternatíva: válaszolj `/approve`, `/always` vagy `/cancel` paranccsal._" + header: "🔄 **MCP-szerverek újratöltve**\n" + reconnected: "♻️ Újracsatlakozva: {names}" + added: "➕ Hozzáadva: {names}" + removed: "➖ Eltávolítva: {names}" + none_connected: "Nincsenek csatlakoztatott MCP-szerverek." + tools_available: "\n🔧 {tools} eszköz érhető el {servers} szerverről" + failed: "❌ MCP újratöltés sikertelen: {error}" + + reload_skills: + header: "🔄 **Készségek újratöltve**\n" + no_new: "Nem észleltünk új készséget." + total: "\n📚 {count} készség érhető el" + added_header: "➕ **Hozzáadott készségek:**" + removed_header: "➖ **Eltávolított készségek:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ Készségek újratöltése sikertelen: {error}" + + reset: + header_default: "✨ Munkamenet visszaállítva! Kezdjük tiszta lappal." + header_new: "✨ Új munkamenet elindítva!" + header_titled: "✨ Új munkamenet elindítva: {title}" + title_rejected: "\n⚠️ Cím elutasítva: {error}" + title_error_untitled: "\n⚠️ {error} — a munkamenet cím nélkül indult." + title_empty_untitled: "\n⚠️ Tisztítás után a cím üres — a munkamenet cím nélkül indult." + tip: "\n✦ Tipp: {tip}" + + restart: + in_progress: "⏳ Az átjáró újraindítása már folyamatban van..." + restarting: "♻ Átjáró újraindítása. Ha 60 másodpercen belül nem kapsz értesítést, indítsd újra a konzolból a `hermes gateway restart` paranccsal." + + resume: + db_unavailable: "A munkamenet-adatbázis nem érhető el." + no_named_sessions: "Nem található elnevezett munkamenet.\nHasználd a `/title Saját munkamenet` parancsot a jelenlegi munkamenet elnevezéséhez, majd a `/resume Saját munkamenet` paranccsal térhetsz vissza hozzá." + list_header: "📋 **Elnevezett munkamenetek**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nHasználat: `/resume <munkamenet neve>`" + list_failed: "Nem sikerült listázni a munkameneteket: {error}" + not_found: "Nem található '**{name}**' nevű munkamenet.\nArgumentumok nélkül használd a `/resume` parancsot az elérhető munkamenetek megtekintéséhez." + already_on: "📌 Már a **{name}** munkamenetben vagy." + switch_failed: "Nem sikerült munkamenetet váltani." + resumed_one: "↻ **{title}** munkamenet folytatva ({count} üzenet). Beszélgetés visszaállítva." + resumed_many: "↻ **{title}** munkamenet folytatva ({count} üzenet). Beszélgetés visszaállítva." + resumed_no_count: "↻ **{title}** munkamenet folytatva. Beszélgetés visszaállítva." + + retry: + no_previous: "Nincs előző üzenet az újrapróbáláshoz." + + rollback: + not_enabled: "Az ellenőrzőpontok nincsenek bekapcsolva.\nKapcsold be a config.yaml fájlban:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "Nem található ellenőrzőpont ehhez: {cwd}" + invalid_number: "Érvénytelen ellenőrzőpont-szám. Használj 1-{max} közötti értéket." + restored: "✅ Visszaállítva a(z) {hash} ellenőrzőpontra: {reason}\nA visszaállítás előtti pillanatkép automatikusan elmentve." + restore_failed: "❌ {error}" + + set_home: + save_failed: "Nem sikerült menteni a kezdőcsatornát: {error}" + success: "✅ Kezdőcsatorna beállítva: **{name}** (ID: {chat_id}).\nA cron-feladatok és a platformok közötti üzenetek ide érkeznek." + + status: + header: "📊 **Hermes Gateway állapot**" + session_id: "**Munkamenet-azonosító:** `{session_id}`" + title: "**Cím:** {title}" + created: "**Létrehozva:** {timestamp}" + last_activity: "**Utolsó tevékenység:** {timestamp}" + tokens: "**Tokenek:** {tokens}" + agent_running: "**Ügynök fut:** {state}" + state_yes: "Igen ⚡" + state_no: "Nem" + queued: "**Sorban álló folytatások:** {count}" + platforms: "**Csatlakoztatott platformok:** {platforms}" + + stop: + stopped_pending: "⚡ Leállítva. Az ügynök még el sem kezdte — folytathatod ezt a munkamenetet." + stopped: "⚡ Leállítva. Folytathatod ezt a munkamenetet." + no_active: "Nincs leállítható aktív feladat." + + title: + db_unavailable: "A munkamenet-adatbázis nem érhető el." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ Tisztítás után a cím üres. Használj nyomtatható karaktereket." + set_to: "✏️ Munkamenet címe beállítva: **{title}**" + not_found: "A munkamenet nem található az adatbázisban." + current_with_title: "📌 Munkamenet: `{session_id}`\nCím: **{title}**" + current_no_title: "📌 Munkamenet: `{session_id}`\nNincs cím beállítva. Használat: `/title Saját munkamenet neve`" + + topic: + not_telegram_dm: "A /topic parancs csak Telegram privát csevegésekben érhető el." + no_session_db: "A munkamenet-adatbázis nem érhető el." + unauthorized: "Nincs jogosultságod a /topic használatához ezen a boton." + restore_needs_topic: "Egy munkamenet visszaállításához először hozz létre vagy nyiss meg egy Telegram topicot, majd küldd a /topic <session-id> parancsot abban a topicban. Új topic létrehozásához nyisd meg az All Messagest, és küldj oda bármilyen üzenetet." + topics_disabled: "A Telegram topicok még nincsenek engedélyezve ehhez a bothoz.\n\nHogyan engedélyezd:\n1. Nyisd meg a @BotFathert.\n2. Válaszd ki a botod.\n3. Nyisd meg a Bot Settings → Threads Settings menüt.\n4. Kapcsold be a Threaded Mode-ot, és győződj meg róla, hogy a felhasználók új threadeket hozhatnak létre.\n\nEzután küldd újra a /topic parancsot." + topics_user_disallowed: "A Telegram topicok engedélyezve vannak, de a felhasználók nem hozhatnak létre topicokat.\n\nNyisd meg a @BotFather → válaszd ki a botod → Bot Settings → Threads Settings menüt, majd kapcsold ki a 'Disallow users to create new threads' opciót.\n\nEzután küldd újra a /topic parancsot." + enable_failed: "Nem sikerült engedélyezni a Telegram topic módot: {error}" + bound_status: "Ez a topic ehhez van kapcsolva:\nMunkamenet: {label}\nID: {session_id}\n\nHasználd a /new parancsot, hogy lecseréld ezt a topicot új munkamenetre.\nPárhuzamos munkához nyisd meg az All Messagest, és küldj oda egy üzenetet egy másik topic létrehozásához." + thread_ready: "A többmunkamenetes Telegram topicok engedélyezve vannak.\n\nEz a topic független Hermes-munkamenetként szolgál. Használd a /new parancsot, hogy lecseréld a topic jelenlegi munkamenetét. Párhuzamos munkához nyisd meg az All Messagest, és küldj oda egy üzenetet egy másik topic létrehozásához." + untitled_session: "Cím nélküli munkamenet" + + undo: + nothing: "Nincs mit visszavonni." + removed: "↩️ {count} üzenet visszavonva.\nEltávolítva: \"{preview}\"" + + update: + platform_not_messaging: "✗ A /update csak üzenetküldő platformokról érhető el. Futtasd a `hermes update` parancsot a terminálból." + not_git_repo: "✗ Nem git-tárhely — frissítés nem lehetséges." + hermes_cmd_not_found: "✗ Nem sikerült megtalálni a `hermes` parancsot. A Hermes fut, de a frissítőparancs nem találta a futtatható fájlt a PATH-on vagy a jelenlegi Python interpreteren keresztül. Próbáld futtatni a `hermes update` parancsot manuálisan a terminálban." + start_failed: "✗ Nem sikerült elindítani a frissítést: {error}" + starting: "⚕ Hermes frissítés indítása… A folyamatot itt fogom közvetíteni." + + usage: + rate_limits: "⏱️ **Sebességkorlátok:** {state}" + header_session: "📊 **Munkamenet tokenhasználat**" + label_model: "Modell: `{model}`" + label_input_tokens: "Bemeneti tokenek: {count}" + label_cache_read: "Gyorsítótár-olvasási tokenek: {count}" + label_cache_write: "Gyorsítótár-írási tokenek: {count}" + label_output_tokens: "Kimeneti tokenek: {count}" + label_total: "Összesen: {count}" + label_api_calls: "API-hívások: {count}" + label_cost: "Költség: {prefix}${amount}" + label_cost_included: "Költség: belefoglalva" + label_context: "Kontextus: {used} / {total} ({pct}%)" + label_compressions: "Tömörítések: {count}" + header_session_info: "📊 **Munkamenet-információ**" + label_messages: "Üzenetek: {count}" + label_estimated_context: "Becsült kontextus: ~{count} token" + detailed_after_first: "_(A részletes használat az első ügynökválasz után érhető el)_" + no_data: "Ehhez a munkamenethez nincsenek elérhető használati adatok." + + verbose: + not_enabled: "A `/verbose` parancs nincs engedélyezve az üzenetküldő platformokon.\n\nEngedélyezd a `config.yaml` fájlban:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Eszközfolyamat: **OFF** — nem jelenik meg eszközaktivitás." + mode_new: "⚙️ Eszközfolyamat: **NEW** — eszközváltáskor jelenik meg (előnézet hossza: `display.tool_preview_length`, alapértelmezetten 40)." + mode_all: "⚙️ Eszközfolyamat: **ALL** — minden eszközhívás megjelenik (előnézet hossza: `display.tool_preview_length`, alapértelmezetten 40)." + mode_verbose: "⚙️ Eszközfolyamat: **VERBOSE** — minden eszközhívás teljes argumentumokkal." + saved_suffix: "_(elmentve ehhez: **{platform}** — a következő üzenettől lép életbe)_" + save_failed: "_(nem sikerült menteni a konfigurációba: {error})_" + + voice: + enabled_voice_only: "Hangmód bekapcsolva.\nHanggal válaszolok, ha hangüzenetet küldesz.\nHasználd a /voice tts parancsot, hogy minden üzenetre hangválaszt kapj." + disabled_text: "Hangmód kikapcsolva. Csak szöveges válaszok." + tts_enabled: "Auto-TTS bekapcsolva.\nMinden válasz tartalmaz egy hangüzenetet." + status_mode: "Hangmód: {label}" + status_channel: "Hangcsatorna: #{channel}" + status_participants: "Résztvevők: {count}" + status_member: " - {name}{status}" + speaking: " (beszél)" + enabled_short: "Hangmód bekapcsolva." + disabled_short: "Hangmód kikapcsolva." + label_off: "Ki (csak szöveg)" + label_voice_only: "Be (hangválasz hangüzenetekre)" + label_all: "TTS (hangválasz minden üzenetre)" + + yolo: + disabled: "⚠️ YOLO mód **KI** ebben a munkamenetben — a veszélyes parancsok jóváhagyást igényelnek." + enabled: "⚡ YOLO mód **BE** ebben a munkamenetben — minden parancs automatikusan jóváhagyva. Óvatosan használd." + + shared: + session_db_unavailable: "A munkamenet-adatbázis nem érhető el." + session_db_unavailable_prefix: "A munkamenet-adatbázis nem érhető el" + session_not_found: "A munkamenet nem található az adatbázisban." + warn_passthrough: "⚠️ {error}" diff --git a/locales/it.yaml b/locales/it.yaml new file mode 100644 index 00000000000..2e4d9940194 --- /dev/null +++ b/locales/it.yaml @@ -0,0 +1,350 @@ +# Catalogo dei messaggi statici di Hermes -- Italiano +# See locales/en.yaml for the source of truth; keep keys in sync. + +approval: + dangerous_header: "⚠️ COMANDO PERICOLOSO: {description}" + choose_long: " [o]una volta | [s]essione | [a]sempre | [d]nega" + choose_short: " [o]una volta | [s]essione | [d]nega" + prompt_long: " Scelta [o/s/a/D]: " + prompt_short: " Scelta [o/s/D]: " + timeout: " ⏱ Tempo scaduto — comando negato" + allowed_once: " ✓ Consentito una volta" + allowed_session: " ✓ Consentito per questa sessione" + allowed_always: " ✓ Aggiunto alla lista permessi permanente" + denied: " ✗ Negato" + cancelled: " ✗ Annullato" + blocklist_message: "Questo comando è nella lista di blocco incondizionata e non può essere approvato." + +gateway: + approval_expired: "⚠️ Approvazione scaduta (l'agente non è più in attesa). Chiedi all'agente di riprovare." + draining: "⏳ Attendo il completamento di {count} agente/i attivo/i prima di riavviare..." + goal_cleared: "✓ Obiettivo cancellato." + no_active_goal: "Nessun obiettivo attivo." + config_read_failed: "⚠️ Impossibile leggere config.yaml: {error}" + config_save_failed: "⚠️ Impossibile salvare la configurazione: {error}" + + model: + error_prefix: "Errore: {error}" + switched: "Modello cambiato a `{model}`" + provider_label: "Provider: {provider}" + context_label: "Contesto: {tokens} token" + max_output_label: "Output massimo: {tokens} token" + cost_label: "Costo: {cost}" + capabilities_label: "Capacità: {capabilities}" + prompt_caching_enabled: "Caching dei prompt: attivo" + warning_prefix: "Avviso: {warning}" + saved_global: "Salvato in config.yaml (`--global`)" + session_only_hint: "_(solo per questa sessione — aggiungi `--global` per renderlo permanente)_" + current_label: "Attuale: `{model}` su {provider}" + current_tag: " (attuale)" + more_models_suffix: " (+{count} altri)" + usage_switch_model: "`/model <name>` — cambia modello" + usage_switch_provider: "`/model <name> --provider <slug>` — cambia provider" + usage_persist: "`/model <name> --global` — rendi permanente" + + agents: + header: "🤖 **Agenti e attività attivi**" + active_agents: "**Agenti attivi:** {count}" + this_chat: " · questa chat" + more: "... e {count} altri" + running_processes: "**Processi in background in esecuzione:** {count}" + async_jobs: "**Job asincroni del gateway:** {count}" + none: "Nessun agente attivo o attività in esecuzione." + state_starting: "in avvio" + state_running: "in esecuzione" + + approve: + no_pending: "Nessun comando in attesa di approvazione." + once_singular: "✅ Comando approvato. L'agente sta riprendendo..." + once_plural: "✅ Comandi approvati ({count} comandi). L'agente sta riprendendo..." + session_singular: "✅ Comando approvato (modello approvato per questa sessione). L'agente sta riprendendo..." + session_plural: "✅ Comandi approvati (modello approvato per questa sessione) ({count} comandi). L'agente sta riprendendo..." + always_singular: "✅ Comando approvato (modello approvato in modo permanente). L'agente sta riprendendo..." + always_plural: "✅ Comandi approvati (modello approvato in modo permanente) ({count} comandi). L'agente sta riprendendo..." + + background: + usage: "Uso: /background <prompt>\nEsempio: /background Riassumi le principali notizie di HN di oggi\n\nEsegue il prompt in una sessione separata. Puoi continuare a chattare — il risultato apparirà qui al termine." + started: "🔄 Attività in background avviata: \"{preview}\"\nID attività: {task_id}\nPuoi continuare a chattare — i risultati appariranno al termine." + + branch: + db_unavailable: "Database delle sessioni non disponibile." + no_conversation: "Nessuna conversazione da diramare — invia prima un messaggio." + create_failed: "Creazione del ramo non riuscita: {error}" + switch_failed: "Ramo creato ma il passaggio ad esso non è riuscito." + branched_one: "⑂ Diramato in **{title}** ({count} messaggio copiato)\nOriginale: `{parent}`\nRamo: `{new}`\nUsa `/resume` per tornare all'originale." + branched_many: "⑂ Diramato in **{title}** ({count} messaggi copiati)\nOriginale: `{parent}`\nRamo: `{new}`\nUsa `/resume` per tornare all'originale." + + commands: + usage: "Uso: `/commands [page]`" + skill_header: "⚡ **Comandi skill**:" + default_desc: "Comando skill" + none: "Nessun comando disponibile." + header: "📚 **Comandi** ({total} totali, pagina {page}/{total_pages})" + nav_prev: "`/commands {page}` ← prec" + nav_next: "succ → `/commands {page}`" + out_of_range: "_(La pagina richiesta {requested} è fuori intervallo, mostrando la pagina {page}.)_" + + compress: + not_enough: "Conversazione insufficiente da comprimere (servono almeno 4 messaggi)." + no_provider: "Nessun provider configurato — impossibile comprimere." + nothing_to_do: "Niente da comprimere per ora (la trascrizione è ancora tutta contesto protetto)." + focus_line: "Focus: \"{topic}\"" + summary_failed: "⚠️ Generazione del riepilogo non riuscita ({error}). {count} messaggio/i storico/i sono stati rimossi e sostituiti con un segnaposto; il contesto precedente non è più recuperabile. Considera di controllare la configurazione del modello auxiliary.compression." + aux_failed: "ℹ️ Il modello di compressione configurato `{model}` non è riuscito ({error}). Recupero effettuato usando il modello principale — il contesto è intatto — ma potresti voler controllare `auxiliary.compression.model` in config.yaml." + failed: "Compressione non riuscita: {error}" + + debug: + upload_failed: "✗ Caricamento del report di debug non riuscito: {error}" + header: "**Report di debug caricato:**" + auto_delete: "⏱ I paste verranno eliminati automaticamente tra 6 ore." + full_logs_hint: "Per il caricamento dei log completi, usa `hermes debug share` dalla CLI." + share_hint: "Condividi questi link con il team Hermes per ricevere supporto." + + deny: + stale: "❌ Comando negato (l'approvazione era obsoleta)." + no_pending: "Nessun comando in attesa da negare." + denied_singular: "❌ Comando negato." + denied_plural: "❌ Comandi negati ({count} comandi)." + + fast: + not_supported: "⚡ /fast è disponibile solo per i modelli OpenAI che supportano Priority Processing." + status: "⚡ Priority Processing\n\nModalità attuale: `{mode}`\n\n_Uso:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Argomento sconosciuto: `{arg}`\n\n**Opzioni valide:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (salvato nella configurazione)\n_(verrà applicato al prossimo messaggio)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (solo per questa sessione)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Footer di runtime: **{state}**\nCampi: `{fields}`\nPiattaforma: `{platform}`" + usage: "Uso: `/footer [on|off|status]`" + saved: "📎 Footer di runtime: **{state}**{example}\n_(salvato globalmente — verrà applicato al prossimo messaggio)_" + example_line: "\nEsempio: `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "Gli obiettivi non sono disponibili in questa sessione." + no_goal_set: "Nessun obiettivo impostato." + paused: "⏸ Obiettivo in pausa: {goal}" + no_resume: "Nessun obiettivo da riprendere." + resumed: "▶ Obiettivo ripreso: {goal}\nInvia un messaggio per continuare, oppure aspetta — farò il prossimo passo al turno successivo." + invalid: "Obiettivo non valido: {error}" + set: "⊙ Obiettivo impostato (budget di {budget} turni): {goal}\nContinuerò a lavorare finché l'obiettivo non sarà completato, lo metterai in pausa/lo cancellerai, oppure il budget sarà esaurito.\nControlli: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Comandi Hermes**\n" + skill_header: "\n⚡ **Comandi skill** ({count} attivi):" + more_use_commands: "\n... e altri {count}. Usa `/commands` per la lista paginata completa." + + insights: + invalid_days: "Valore --days non valido: {value}" + error: "Errore nella generazione degli insight: {error}" + + kanban: + error_prefix: "⚠ errore kanban: {error}" + subscribed_suffix: "(iscritto — riceverai notifica quando {task_id} verrà completato o si bloccherà)" + truncated_suffix: "… (troncato; usa `hermes kanban …` nel terminale per l'output completo)" + no_output: "(nessun output)" + + personality: + none_configured: "Nessuna personalità configurata in `{path}/config.yaml`" + header: "🎭 **Personalità disponibili**\n" + none_option: "• `none` — (nessun overlay di personalità)" + item: "• `{name}` — {preview}" + usage: "\nUso: `/personality <name>`" + save_failed: "⚠️ Salvataggio del cambio di personalità non riuscito: {error}" + cleared: "🎭 Personalità cancellata — uso il comportamento base dell'agente.\n_(verrà applicato al prossimo messaggio)_" + set_to: "🎭 Personalità impostata su **{name}**\n_(verrà applicato al prossimo messaggio)_" + unknown: "Personalità sconosciuta: `{name}`\n\nDisponibili: {available}" + + profile: + header: "👤 **Profilo:** `{profile}`" + home: "📂 **Home:** `{home}`" + + reasoning: + level_default: "medio (predefinito)" + level_disabled: "nessuno (disattivato)" + scope_session: "override di sessione" + scope_global: "configurazione globale" + status: "🧠 **Impostazioni di reasoning**\n\n**Sforzo:** `{level}`\n**Ambito:** {scope}\n**Visualizzazione:** {display}\n\n_Uso:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "attivo ✓" + display_off: "disattivato" + display_set_on: "🧠 ✓ Visualizzazione del reasoning: **ATTIVA**\nIl pensiero del modello verrà mostrato prima di ogni risposta su **{platform}**." + display_set_off: "🧠 ✓ Visualizzazione del reasoning: **DISATTIVATA** per **{platform}**" + reset_global_unsupported: "⚠️ `/reasoning reset --global` non è supportato. Usa `/reasoning <level> --global` per cambiare il valore predefinito globale." + reset_done: "🧠 ✓ Override di reasoning della sessione cancellato; ripristino della configurazione globale." + unknown_arg: "⚠️ Argomento sconosciuto: `{arg}`\n\n**Livelli validi:** none, minimal, low, medium, high, xhigh\n**Visualizzazione:** show, hide\n**Persistenza:** aggiungi `--global` per salvare oltre questa sessione" + set_global: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (salvato nella configurazione)\n_(verrà applicato al prossimo messaggio)_" + set_global_save_failed: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (solo per questa sessione — salvataggio della configurazione non riuscito)\n_(verrà applicato al prossimo messaggio)_" + set_session: "🧠 ✓ Sforzo di reasoning impostato su `{effort}` (solo per questa sessione — aggiungi `--global` per renderlo permanente)\n_(verrà applicato al prossimo messaggio)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp annullato. Strumenti MCP invariati." + always_followup: "ℹ️ Le future chiamate a `/reload-mcp` verranno eseguite senza conferma. Riattiva tramite `approvals.mcp_reload_confirm: true` in config.yaml." + confirm_prompt: "⚠️ **Conferma /reload-mcp**\n\nIl ricaricamento dei server MCP ricostruisce il set di strumenti per questa sessione e **invalida la cache dei prompt del provider** — il prossimo messaggio invierà nuovamente tutti i token di input. Sui modelli a contesto lungo o ad alto reasoning questo può essere costoso.\n\nScegli:\n• **Approva una volta** — ricarica ora\n• **Approva sempre** — ricarica ora e silenzia questa richiesta in modo permanente\n• **Annulla** — lascia gli strumenti MCP invariati\n\n_Alternativa testuale: rispondi `/approve`, `/always`, oppure `/cancel`._" + header: "🔄 **Server MCP ricaricati**\n" + reconnected: "♻️ Riconnessi: {names}" + added: "➕ Aggiunti: {names}" + removed: "➖ Rimossi: {names}" + none_connected: "Nessun server MCP connesso." + tools_available: "\n🔧 {tools} strumento/i disponibile/i da {servers} server" + failed: "❌ Ricaricamento MCP non riuscito: {error}" + + reload_skills: + header: "🔄 **Skill ricaricate**\n" + no_new: "Nessuna nuova skill rilevata." + total: "\n📚 {count} skill disponibili" + added_header: "➕ **Skill aggiunte:**" + removed_header: "➖ **Skill rimosse:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ Ricaricamento delle skill non riuscito: {error}" + + reset: + header_default: "✨ Sessione reimpostata! Si ricomincia da zero." + header_new: "✨ Nuova sessione avviata!" + header_titled: "✨ Nuova sessione avviata: {title}" + title_rejected: "\n⚠️ Titolo rifiutato: {error}" + title_error_untitled: "\n⚠️ {error} — sessione avviata senza titolo." + title_empty_untitled: "\n⚠️ Il titolo è vuoto dopo la pulizia — sessione avviata senza titolo." + tip: "\n✦ Suggerimento: {tip}" + + restart: + in_progress: "⏳ Riavvio del gateway già in corso..." + restarting: "♻ Riavvio del gateway. Se non ricevi una notifica entro 60 secondi, riavvia dalla console con `hermes gateway restart`." + + resume: + db_unavailable: "Database delle sessioni non disponibile." + no_named_sessions: "Nessuna sessione con nome trovata.\nUsa `/title My Session` per dare un nome alla sessione attuale, poi `/resume My Session` per tornare a essa in seguito." + list_header: "📋 **Sessioni con nome**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nUso: `/resume <session name>`" + list_failed: "Impossibile elencare le sessioni: {error}" + not_found: "Nessuna sessione trovata corrispondente a '**{name}**'.\nUsa `/resume` senza argomenti per vedere le sessioni disponibili." + already_on: "📌 Già nella sessione **{name}**." + switch_failed: "Cambio di sessione non riuscito." + resumed_one: "↻ Sessione **{title}** ripresa ({count} messaggio). Conversazione ripristinata." + resumed_many: "↻ Sessione **{title}** ripresa ({count} messaggi). Conversazione ripristinata." + resumed_no_count: "↻ Sessione **{title}** ripresa. Conversazione ripristinata." + + retry: + no_previous: "Nessun messaggio precedente da ripetere." + + rollback: + not_enabled: "I checkpoint non sono abilitati.\nAbilitali in config.yaml:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "Nessun checkpoint trovato per {cwd}" + invalid_number: "Numero di checkpoint non valido. Usa 1-{max}." + restored: "✅ Ripristinato al checkpoint {hash}: {reason}\nUno snapshot pre-rollback è stato salvato automaticamente." + restore_failed: "❌ {error}" + + set_home: + save_failed: "Salvataggio del canale home non riuscito: {error}" + success: "✅ Canale home impostato su **{name}** (ID: {chat_id}).\nI cron job e i messaggi cross-platform verranno consegnati qui." + + status: + header: "📊 **Stato del Gateway Hermes**" + session_id: "**ID sessione:** `{session_id}`" + title: "**Titolo:** {title}" + created: "**Creata:** {timestamp}" + last_activity: "**Ultima attività:** {timestamp}" + tokens: "**Token:** {tokens}" + agent_running: "**Agente in esecuzione:** {state}" + state_yes: "Sì ⚡" + state_no: "No" + queued: "**Follow-up in coda:** {count}" + platforms: "**Piattaforme connesse:** {platforms}" + + stop: + stopped_pending: "⚡ Fermato. L'agente non era ancora partito — puoi continuare questa sessione." + stopped: "⚡ Fermato. Puoi continuare questa sessione." + no_active: "Nessuna attività attiva da fermare." + + title: + db_unavailable: "Database delle sessioni non disponibile." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ Il titolo è vuoto dopo la pulizia. Usa caratteri stampabili." + set_to: "✏️ Titolo della sessione impostato: **{title}**" + not_found: "Sessione non trovata nel database." + current_with_title: "📌 Sessione: `{session_id}`\nTitolo: **{title}**" + current_no_title: "📌 Sessione: `{session_id}`\nNessun titolo impostato. Uso: `/title My Session Name`" + + topic: + not_telegram_dm: "Il comando /topic è disponibile solo nelle chat private di Telegram." + no_session_db: "Database delle sessioni non disponibile." + unauthorized: "Non sei autorizzato a usare /topic su questo bot." + restore_needs_topic: "Per ripristinare una sessione, crea o apri prima un topic Telegram, poi invia /topic <session-id> all'interno di quel topic. Per creare un nuovo topic, apri All Messages e invia un messaggio qualsiasi lì." + topics_disabled: "I topic Telegram non sono ancora abilitati per questo bot.\n\nCome abilitarli:\n1. Apri @BotFather.\n2. Scegli il tuo bot.\n3. Apri Bot Settings → Threads Settings.\n4. Attiva la modalità Threaded e assicurati che gli utenti possano creare nuovi thread.\n\nPoi invia di nuovo /topic." + topics_user_disallowed: "I topic Telegram sono abilitati, ma agli utenti non è permesso crearne.\n\nApri @BotFather → scegli il tuo bot → Bot Settings → Threads Settings, poi disattiva 'Disallow users to create new threads'.\n\nPoi invia di nuovo /topic." + enable_failed: "Abilitazione della modalità topic Telegram non riuscita: {error}" + bound_status: "Questo topic è collegato a:\nSessione: {label}\nID: {session_id}\n\nUsa /new per sostituire questo topic con una nuova sessione.\nPer lavorare in parallelo, apri All Messages e invia un messaggio lì per creare un altro topic." + thread_ready: "I topic multi-sessione di Telegram sono abilitati.\n\nQuesto topic verrà usato come una sessione Hermes indipendente. Usa /new per sostituire la sessione corrente di questo topic. Per lavorare in parallelo, apri All Messages e invia un messaggio lì per creare un altro topic." + untitled_session: "Sessione senza titolo" + + undo: + nothing: "Niente da annullare." + removed: "↩️ Annullati {count} messaggio/i.\nRimosso: \"{preview}\"" + + update: + platform_not_messaging: "✗ /update è disponibile solo dalle piattaforme di messaggistica. Esegui `hermes update` dal terminale." + not_git_repo: "✗ Non è un repository git — impossibile aggiornare." + hermes_cmd_not_found: "✗ Impossibile localizzare il comando `hermes`. Hermes è in esecuzione, ma il comando di aggiornamento non ha trovato l'eseguibile nel PATH o tramite l'interprete Python attuale. Prova a eseguire `hermes update` manualmente nel terminale." + start_failed: "✗ Avvio dell'aggiornamento non riuscito: {error}" + starting: "⚕ Avvio dell'aggiornamento di Hermes… mostrerò qui i progressi in streaming." + + usage: + rate_limits: "⏱️ **Limiti di frequenza:** {state}" + header_session: "📊 **Uso dei token della sessione**" + label_model: "Modello: `{model}`" + label_input_tokens: "Token di input: {count}" + label_cache_read: "Token di lettura cache: {count}" + label_cache_write: "Token di scrittura cache: {count}" + label_output_tokens: "Token di output: {count}" + label_total: "Totale: {count}" + label_api_calls: "Chiamate API: {count}" + label_cost: "Costo: {prefix}${amount}" + label_cost_included: "Costo: incluso" + label_context: "Contesto: {used} / {total} ({pct}%)" + label_compressions: "Compressioni: {count}" + header_session_info: "📊 **Info sessione**" + label_messages: "Messaggi: {count}" + label_estimated_context: "Contesto stimato: ~{count} token" + detailed_after_first: "_(L'uso dettagliato sarà disponibile dopo la prima risposta dell'agente)_" + no_data: "Nessun dato di utilizzo disponibile per questa sessione." + + verbose: + not_enabled: "Il comando `/verbose` non è abilitato per le piattaforme di messaggistica.\n\nAbilitalo in `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Progresso strumenti: **OFF** — nessuna attività degli strumenti mostrata." + mode_new: "⚙️ Progresso strumenti: **NEW** — mostrato quando lo strumento cambia (lunghezza anteprima: `display.tool_preview_length`, predefinito 40)." + mode_all: "⚙️ Progresso strumenti: **ALL** — ogni chiamata a uno strumento viene mostrata (lunghezza anteprima: `display.tool_preview_length`, predefinito 40)." + mode_verbose: "⚙️ Progresso strumenti: **VERBOSE** — ogni chiamata a uno strumento con argomenti completi." + saved_suffix: "_(salvato per **{platform}** — verrà applicato al prossimo messaggio)_" + save_failed: "_(impossibile salvare nella configurazione: {error})_" + + voice: + enabled_voice_only: "Modalità vocale attivata.\nRisponderò con la voce quando invii messaggi vocali.\nUsa /voice tts per ricevere risposte vocali per tutti i messaggi." + disabled_text: "Modalità vocale disattivata. Risposte solo testuali." + tts_enabled: "Auto-TTS attivato.\nTutte le risposte includeranno un messaggio vocale." + status_mode: "Modalità vocale: {label}" + status_channel: "Canale vocale: #{channel}" + status_participants: "Partecipanti: {count}" + status_member: " - {name}{status}" + speaking: " (sta parlando)" + enabled_short: "Modalità vocale attivata." + disabled_short: "Modalità vocale disattivata." + label_off: "Off (solo testo)" + label_voice_only: "On (risposta vocale ai messaggi vocali)" + label_all: "TTS (risposta vocale a tutti i messaggi)" + + yolo: + disabled: "⚠️ Modalità YOLO **OFF** per questa sessione — i comandi pericolosi richiederanno approvazione." + enabled: "⚡ Modalità YOLO **ON** per questa sessione — tutti i comandi auto-approvati. Usa con cautela." + + shared: + session_db_unavailable: "Database delle sessioni non disponibile." + session_db_unavailable_prefix: "Database delle sessioni non disponibile" + session_not_found: "Sessione non trovata nel database." + warn_passthrough: "⚠️ {error}" diff --git a/locales/ja.yaml b/locales/ja.yaml index 5cf229a5206..55c42915e65 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -22,3 +22,329 @@ gateway: no_active_goal: "アクティブな目標はありません。" config_read_failed: "⚠️ config.yaml を読み込めませんでした: {error}" config_save_failed: "⚠️ 設定を保存できませんでした: {error}" + + model: + error_prefix: "エラー: {error}" + switched: "モデルを `{model}` に切り替えました" + provider_label: "プロバイダー: {provider}" + context_label: "コンテキスト: {tokens} トークン" + max_output_label: "最大出力: {tokens} トークン" + cost_label: "コスト: {cost}" + capabilities_label: "機能: {capabilities}" + prompt_caching_enabled: "プロンプトキャッシュ: 有効" + warning_prefix: "警告: {warning}" + saved_global: "config.yaml に保存しました (`--global`)" + session_only_hint: "_(このセッションのみ — 永続化するには `--global` を追加)_" + current_label: "現在: `{model}` ({provider})" + current_tag: " (現在)" + more_models_suffix: " (他 {count} 件)" + usage_switch_model: "`/model <name>` — モデルを切り替え" + usage_switch_provider: "`/model <name> --provider <slug>` — プロバイダーを切り替え" + usage_persist: "`/model <name> --global` — 永続化" + + agents: + header: "🤖 **アクティブなエージェントとタスク**" + active_agents: "**アクティブなエージェント:** {count}" + this_chat: " · このチャット" + more: "... 他に {count} 件" + running_processes: "**実行中のバックグラウンドプロセス:** {count}" + async_jobs: "**ゲートウェイ非同期ジョブ:** {count}" + none: "アクティブなエージェントや実行中のタスクはありません。" + state_starting: "起動中" + state_running: "実行中" + + approve: + no_pending: "承認待ちのコマンドはありません。" + once_singular: "✅ コマンドを承認しました。エージェントを再開しています..." + once_plural: "✅ コマンドを承認しました ({count} 件)。エージェントを再開しています..." + session_singular: "✅ コマンドを承認しました (このセッション中はパターンを許可)。エージェントを再開しています..." + session_plural: "✅ コマンドを承認しました (このセッション中はパターンを許可) ({count} 件)。エージェントを再開しています..." + always_singular: "✅ コマンドを承認しました (パターンを永続的に許可)。エージェントを再開しています..." + always_plural: "✅ コマンドを承認しました (パターンを永続的に許可) ({count} 件)。エージェントを再開しています..." + + background: + usage: "使い方: /background <プロンプト>\n例: /background 今日の HN トップ記事を要約して\n\nプロンプトを別のセッションで実行します。チャットを続けられます — 完了したらここに結果が表示されます。" + started: "🔄 バックグラウンドタスクを開始しました: 「{preview}」\nタスク ID: {task_id}\nチャットを続けられます — 完了したらここに結果が表示されます。" + + branch: + db_unavailable: "セッションデータベースは利用できません。" + no_conversation: "分岐する会話がありません — まずメッセージを送信してください。" + create_failed: "ブランチの作成に失敗しました: {error}" + switch_failed: "ブランチは作成されましたが、切り替えに失敗しました。" + branched_one: "⑂ **{title}** に分岐しました ({count} メッセージをコピー)\n元: `{parent}`\nブランチ: `{new}`\n元のセッションに戻るには `/resume` を使用してください。" + branched_many: "⑂ **{title}** に分岐しました ({count} メッセージをコピー)\n元: `{parent}`\nブランチ: `{new}`\n元のセッションに戻るには `/resume` を使用してください。" + + commands: + usage: "使い方: `/commands [page]`" + skill_header: "⚡ **スキルコマンド**:" + default_desc: "スキルコマンド" + none: "利用可能なコマンドはありません。" + header: "📚 **コマンド** (合計 {total}、{page}/{total_pages} ページ)" + nav_prev: "`/commands {page}` ← 前へ" + nav_next: "次へ → `/commands {page}`" + out_of_range: "_(要求されたページ {requested} は範囲外のため、{page} ページを表示しています。)_" + + compress: + not_enough: "圧縮するための会話が不十分です (少なくとも 4 件のメッセージが必要)。" + no_provider: "プロバイダーが構成されていません — 圧縮できません。" + nothing_to_do: "まだ圧縮するものがありません (トランスクリプトはすべて保護されたコンテキストのままです)。" + focus_line: "フォーカス: \"{topic}\"" + summary_failed: "⚠️ 要約の生成に失敗しました ({error})。{count} 件の履歴メッセージが削除され、プレースホルダーに置き換えられました。以前のコンテキストは復元できません。auxiliary.compression モデルの設定を確認してください。" + aux_failed: "ℹ️ 構成された圧縮モデル `{model}` が失敗しました ({error})。メインモデルで復旧しました — コンテキストは無傷です — config.yaml の `auxiliary.compression.model` を確認するとよいでしょう。" + failed: "圧縮に失敗しました: {error}" + + debug: + upload_failed: "✗ デバッグレポートのアップロードに失敗しました: {error}" + header: "**デバッグレポートをアップロードしました:**" + auto_delete: "⏱ ペーストは 6 時間後に自動削除されます。" + full_logs_hint: "完全なログのアップロードには、CLI から `hermes debug share` を使用してください。" + share_hint: "サポートを受けるには、このリンクを Hermes チームに共有してください。" + + deny: + stale: "❌ コマンドを拒否しました (承認は期限切れでした)。" + no_pending: "拒否待ちのコマンドはありません。" + denied_singular: "❌ コマンドを拒否しました。" + denied_plural: "❌ コマンドを拒否しました ({count} 件)。" + + fast: + not_supported: "⚡ /fast は Priority Processing をサポートする OpenAI モデルでのみ利用できます。" + status: "⚡ Priority Processing\n\n現在のモード: `{mode}`\n\n_使い方:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ 不明な引数: `{arg}`\n\n**有効なオプション:** normal、fast、status" + saved: "⚡ ✓ Priority Processing: **{label}** (設定に保存しました)\n_(次のメッセージから有効)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (このセッションのみ)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 ランタイムフッター: **{state}**\nフィールド: `{fields}`\nプラットフォーム: `{platform}`" + usage: "使い方: `/footer [on|off|status]`" + saved: "📎 ランタイムフッター: **{state}**{example}\n_(グローバルに保存しました — 次のメッセージから有効)_" + example_line: "\n例: `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "このセッションでは目標機能を利用できません。" + no_goal_set: "目標が設定されていません。" + paused: "⏸ 目標を一時停止しました: {goal}" + no_resume: "再開する目標がありません。" + resumed: "▶ 目標を再開しました: {goal}\nメッセージを送って続行するか、お待ちください — 次のターンで続きを進めます。" + invalid: "無効な目標: {error}" + set: "⊙ 目標を設定しました ({budget} ターンの予算): {goal}\n目標が完了するか、一時停止/解除されるか、予算が尽きるまで作業を続けます。\nコントロール: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Hermes コマンド**\n" + skill_header: "\n⚡ **スキルコマンド** ({count} 件アクティブ):" + more_use_commands: "\n... 他に {count} 件。完全なページ分けリストは `/commands` で確認してください。" + + insights: + invalid_days: "--days の値が無効です: {value}" + error: "インサイトの生成中にエラーが発生しました: {error}" + + kanban: + error_prefix: "⚠ kanban エラー: {error}" + subscribed_suffix: "(購読しました — {task_id} が完了またはブロックされたときに通知されます)" + truncated_suffix: "… (切り詰めました; 完全な出力にはターミナルで `hermes kanban …` を使用してください)" + no_output: "(出力なし)" + + personality: + none_configured: "`{path}/config.yaml` に人格が設定されていません" + header: "🎭 **利用可能な人格**\n" + none_option: "• `none` — (人格オーバーレイなし)" + item: "• `{name}` — {preview}" + usage: "\n使い方: `/personality <name>`" + save_failed: "⚠️ 人格変更の保存に失敗しました: {error}" + cleared: "🎭 人格をクリアしました — 基本のエージェント動作を使用します。\n_(次のメッセージから有効)_" + set_to: "🎭 人格を **{name}** に設定しました\n_(次のメッセージから有効)_" + unknown: "不明な人格: `{name}`\n\n利用可能: {available}" + + profile: + header: "👤 **プロファイル:** `{profile}`" + home: "📂 **ホーム:** `{home}`" + + reasoning: + level_default: "medium (デフォルト)" + level_disabled: "none (無効)" + scope_session: "セッションのオーバーライド" + scope_global: "グローバル設定" + status: "🧠 **推論設定**\n\n**強度:** `{level}`\n**スコープ:** {scope}\n**表示:** {display}\n\n_使い方:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "オン ✓" + display_off: "オフ" + display_set_on: "🧠 ✓ 推論表示: **オン**\n**{platform}** 上で各応答の前にモデルの思考が表示されます。" + display_set_off: "🧠 ✓ **{platform}** での推論表示: **オフ**" + reset_global_unsupported: "⚠️ `/reasoning reset --global` はサポートされていません。グローバルのデフォルトを変更するには `/reasoning <level> --global` を使用してください。" + reset_done: "🧠 ✓ セッションの推論オーバーライドをクリアしました。グローバル設定にフォールバックします。" + unknown_arg: "⚠️ 不明な引数: `{arg}`\n\n**有効なレベル:** none, minimal, low, medium, high, xhigh\n**表示:** show, hide\n**永続化:** セッションを越えて保存するには `--global` を追加" + set_global: "🧠 ✓ 推論強度を `{effort}` に設定しました (設定に保存)\n_(次のメッセージから有効)_" + set_global_save_failed: "🧠 ✓ 推論強度を `{effort}` に設定しました (セッションのみ — 設定の保存に失敗)\n_(次のメッセージから有効)_" + set_session: "🧠 ✓ 推論強度を `{effort}` に設定しました (セッションのみ — 永続化するには `--global` を追加)\n_(次のメッセージから有効)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp をキャンセルしました。MCP ツールは変更されていません。" + always_followup: "ℹ️ 今後の `/reload-mcp` は確認なしで実行されます。`config.yaml` で `approvals.mcp_reload_confirm: true` を設定すると再有効化できます。" + confirm_prompt: "⚠️ **/reload-mcp の確認**\n\nMCP サーバーを再読み込みすると、このセッションのツールセットが再構築され、**プロバイダーのプロンプトキャッシュが無効化されます** — 次のメッセージで完全な入力トークンが再送信されます。長コンテキストや高推論モデルではコストが高くなる可能性があります。\n\n選択してください:\n• **一度だけ承認** — 今すぐ再読み込み\n• **常に承認** — 今すぐ再読み込みし、このプロンプトを永続的に非表示\n• **キャンセル** — MCP ツールを変更しない\n\n_テキスト代替: `/approve`、`/always`、または `/cancel` と返信してください。_" + header: "🔄 **MCP サーバーを再読み込みしました**\n" + reconnected: "♻️ 再接続: {names}" + added: "➕ 追加: {names}" + removed: "➖ 削除: {names}" + none_connected: "接続中の MCP サーバーはありません。" + tools_available: "\n🔧 {servers} 台のサーバーから {tools} 個のツールが利用可能" + failed: "❌ MCP の再読み込みに失敗しました: {error}" + + reload_skills: + header: "🔄 **スキルを再読み込みしました**\n" + no_new: "新しいスキルは検出されませんでした。" + total: "\n📚 {count} 個のスキルが利用可能" + added_header: "➕ **追加されたスキル:**" + removed_header: "➖ **削除されたスキル:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ スキルの再読み込みに失敗しました: {error}" + + reset: + header_default: "✨ セッションをリセットしました。新たに開始します。" + header_new: "✨ 新しいセッションを開始しました。" + header_titled: "✨ 新しいセッションを開始しました: {title}" + title_rejected: "\n⚠️ タイトルが拒否されました: {error}" + title_error_untitled: "\n⚠️ {error} — タイトルなしでセッションを開始しました。" + title_empty_untitled: "\n⚠️ クリーンアップ後にタイトルが空になりました — タイトルなしでセッションを開始しました。" + tip: "\n✦ ヒント: {tip}" + + restart: + in_progress: "⏳ ゲートウェイの再起動はすでに進行中です..." + restarting: "♻ ゲートウェイを再起動しています。60 秒以内に通知が届かない場合は、コンソールで `hermes gateway restart` を実行してください。" + + resume: + db_unavailable: "セッションデータベースは利用できません。" + no_named_sessions: "名前付きセッションが見つかりません。\n`/title セッション名` で現在のセッションに名前を付けると、後で `/resume セッション名` で戻れます。" + list_header: "📋 **名前付きセッション**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\n使い方: `/resume <セッション名>`" + list_failed: "セッションを一覧表示できませんでした: {error}" + not_found: "'**{name}**' に一致するセッションが見つかりません。\n引数なしで `/resume` を実行すると利用可能なセッションを表示します。" + already_on: "📌 既にセッション **{name}** にいます。" + switch_failed: "セッションの切り替えに失敗しました。" + resumed_one: "↻ セッション **{title}** を再開しました ({count} メッセージ)。会話を復元しました。" + resumed_many: "↻ セッション **{title}** を再開しました ({count} メッセージ)。会話を復元しました。" + resumed_no_count: "↻ セッション **{title}** を再開しました。会話を復元しました。" + + retry: + no_previous: "再試行する前のメッセージがありません。" + + rollback: + not_enabled: "チェックポイントは有効になっていません。\nconfig.yaml で有効にしてください:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "{cwd} のチェックポイントが見つかりません" + invalid_number: "無効なチェックポイント番号です。1-{max} を使用してください。" + restored: "✅ チェックポイント {hash} に復元しました: {reason}\nロールバック前のスナップショットが自動的に保存されました。" + restore_failed: "❌ {error}" + + set_home: + save_failed: "ホームチャンネルを保存できませんでした: {error}" + success: "✅ ホームチャンネルを **{name}** (ID: {chat_id}) に設定しました。\nCron ジョブとプラットフォーム間メッセージはここに配信されます。" + + status: + header: "📊 **Hermes ゲートウェイ状態**" + session_id: "**セッション ID:** `{session_id}`" + title: "**タイトル:** {title}" + created: "**作成日時:** {timestamp}" + last_activity: "**最終アクティビティ:** {timestamp}" + tokens: "**トークン:** {tokens}" + agent_running: "**エージェント実行中:** {state}" + state_yes: "はい ⚡" + state_no: "いいえ" + queued: "**キュー内の後続:** {count}" + platforms: "**接続プラットフォーム:** {platforms}" + + stop: + stopped_pending: "⚡ 停止しました。エージェントはまだ開始していません — このセッションを続行できます。" + stopped: "⚡ 停止しました。このセッションを続行できます。" + no_active: "停止できるアクティブなタスクはありません。" + + title: + db_unavailable: "セッションデータベースは利用できません。" + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ クリーンアップ後にタイトルが空になりました。印字可能な文字を使用してください。" + set_to: "✏️ セッションタイトルを設定しました: **{title}**" + not_found: "データベースにセッションが見つかりません。" + current_with_title: "📌 セッション: `{session_id}`\nタイトル: **{title}**" + current_no_title: "📌 セッション: `{session_id}`\nタイトル未設定。使い方: `/title セッション名`" + + topic: + not_telegram_dm: "/topic コマンドは Telegram のプライベートチャットでのみ利用できます。" + no_session_db: "セッションデータベースを利用できません。" + unauthorized: "この bot で /topic を使用する権限がありません。" + restore_needs_topic: "セッションを復元するには、まず Telegram topic を作成または開いてから、その topic 内で /topic <session-id> を送信してください。新しい topic を作成するには、All Messages を開いて任意のメッセージを送信してください。" + topics_disabled: "この bot ではまだ Telegram topics が有効になっていません。\n\n有効にする方法:\n1. @BotFather を開きます。\n2. 自分の bot を選びます。\n3. Bot Settings → Threads Settings を開きます。\n4. Threaded Mode をオンにし、ユーザーが新しいスレッドを作成できるように設定します。\n\nそして /topic をもう一度送信してください。" + topics_user_disallowed: "Telegram topics は有効ですが、ユーザーは topic を作成できません。\n\n@BotFather → 自分の bot → Bot Settings → Threads Settings を開き、'Disallow users to create new threads' をオフにしてください。\n\nそして /topic をもう一度送信してください。" + enable_failed: "Telegram topic モードの有効化に失敗しました: {error}" + bound_status: "この topic は次にリンクされています:\nセッション: {label}\nID: {session_id}\n\nこの topic を新しいセッションに置き換えるには /new を使用してください。\n並行作業には、All Messages を開いてメッセージを送信し、別の topic を作成してください。" + thread_ready: "Telegram のマルチセッション topics が有効です。\n\nこの topic は独立した Hermes セッションとして使用されます。この topic の現在のセッションを置き換えるには /new を使用してください。並行作業には、All Messages を開いてメッセージを送信し、別の topic を作成してください。" + untitled_session: "無題のセッション" + + undo: + nothing: "元に戻せる操作がありません。" + removed: "↩️ {count} 件のメッセージを取り消しました。\n削除: 「{preview}」" + + update: + platform_not_messaging: "✗ /update はメッセージングプラットフォームでのみ利用可能です。ターミナルで `hermes update` を実行してください。" + not_git_repo: "✗ Git リポジトリではありません — 更新できません。" + hermes_cmd_not_found: "✗ `hermes` コマンドが見つかりません。Hermes は実行中ですが、更新コマンドは PATH 上にも現在の Python インタープリタ経由でも実行可能ファイルを見つけられませんでした。ターミナルで `hermes update` を手動で実行してみてください。" + start_failed: "✗ 更新の開始に失敗しました: {error}" + starting: "⚕ Hermes の更新を開始しています… 進捗をここにストリーミングします。" + + usage: + rate_limits: "⏱️ **レート制限:** {state}" + header_session: "📊 **セッショントークン使用状況**" + label_model: "モデル: `{model}`" + label_input_tokens: "入力トークン: {count}" + label_cache_read: "キャッシュ読み取りトークン: {count}" + label_cache_write: "キャッシュ書き込みトークン: {count}" + label_output_tokens: "出力トークン: {count}" + label_total: "合計: {count}" + label_api_calls: "API 呼び出し: {count}" + label_cost: "コスト: {prefix}${amount}" + label_cost_included: "コスト: 含まれています" + label_context: "コンテキスト: {used} / {total} ({pct}%)" + label_compressions: "圧縮回数: {count}" + header_session_info: "📊 **セッション情報**" + label_messages: "メッセージ数: {count}" + label_estimated_context: "推定コンテキスト: ~{count} トークン" + detailed_after_first: "_(詳細な使用状況は最初のエージェント応答後に利用可能)_" + no_data: "このセッションの使用データはありません。" + + verbose: + not_enabled: "`/verbose` コマンドはメッセージングプラットフォームで有効になっていません。\n\n`config.yaml` で有効にしてください:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ ツール進捗: **OFF** — ツールの動作は表示されません。" + mode_new: "⚙️ ツール進捗: **NEW** — ツールが変わったときに表示 (プレビュー長: `display.tool_preview_length`、デフォルト 40)。" + mode_all: "⚙️ ツール進捗: **ALL** — すべてのツール呼び出しを表示 (プレビュー長: `display.tool_preview_length`、デフォルト 40)。" + mode_verbose: "⚙️ ツール進捗: **VERBOSE** — すべてのツール呼び出しを完全な引数とともに表示。" + saved_suffix: "_(**{platform}** に保存しました — 次のメッセージから有効)_" + save_failed: "_(設定に保存できませんでした: {error})_" + + voice: + enabled_voice_only: "音声モードを有効にしました。\n音声メッセージを送ると音声で返信します。\nすべてのメッセージへの音声返信は /voice tts を使ってください。" + disabled_text: "音声モードを無効にしました。テキストのみで返信します。" + tts_enabled: "自動 TTS を有効にしました。\nすべての返信に音声メッセージが含まれます。" + status_mode: "音声モード: {label}" + status_channel: "音声チャンネル: #{channel}" + status_participants: "参加者: {count}" + status_member: " - {name}{status}" + speaking: " (発話中)" + enabled_short: "音声モードを有効にしました。" + disabled_short: "音声モードを無効にしました。" + label_off: "オフ (テキストのみ)" + label_voice_only: "オン (音声メッセージにのみ音声で返信)" + label_all: "TTS (すべてのメッセージに音声で返信)" + + yolo: + disabled: "⚠️ このセッションの YOLO モードは **OFF** — 危険なコマンドには承認が必要です。" + enabled: "⚡ このセッションの YOLO モードは **ON** — すべてのコマンドが自動承認されます。注意して使用してください。" + + shared: + session_db_unavailable: "セッションデータベースが利用できません。" + session_db_unavailable_prefix: "セッションデータベースが利用できません" + session_not_found: "データベースにセッションが見つかりません。" + warn_passthrough: "⚠️ {error}" diff --git a/locales/ko.yaml b/locales/ko.yaml new file mode 100644 index 00000000000..11f5380e319 --- /dev/null +++ b/locales/ko.yaml @@ -0,0 +1,350 @@ +# Hermes 정적 메시지 카탈로그 -- 한국어 +# See locales/en.yaml for the source of truth; keep keys in sync. + +approval: + dangerous_header: "⚠️ 위험한 명령: {description}" + choose_long: " [o]한 번 | [s]세션 | [a]항상 | [d]거부" + choose_short: " [o]한 번 | [s]세션 | [d]거부" + prompt_long: " 선택 [o/s/a/D]: " + prompt_short: " 선택 [o/s/D]: " + timeout: " ⏱ 시간 초과 - 명령을 거부합니다" + allowed_once: " ✓ 한 번 허용됨" + allowed_session: " ✓ 이 세션에서 허용됨" + allowed_always: " ✓ 영구 허용 목록에 추가됨" + denied: " ✗ 거부됨" + cancelled: " ✗ 취소됨" + blocklist_message: "이 명령은 무조건 차단 목록에 있으며 승인할 수 없습니다." + +gateway: + approval_expired: "⚠️ 승인이 만료되었습니다 (에이전트가 더 이상 대기하지 않습니다). 에이전트에게 다시 시도하도록 요청하세요." + draining: "⏳ 재시작 전에 활성 에이전트 {count}명을 정리하는 중..." + goal_cleared: "✓ 목표가 삭제되었습니다." + no_active_goal: "활성 목표가 없습니다." + config_read_failed: "⚠️ config.yaml을 읽을 수 없습니다: {error}" + config_save_failed: "⚠️ 설정을 저장할 수 없습니다: {error}" + + model: + error_prefix: "오류: {error}" + switched: "모델이 `{model}`(으)로 전환되었습니다" + provider_label: "제공자: {provider}" + context_label: "컨텍스트: {tokens} 토큰" + max_output_label: "최대 출력: {tokens} 토큰" + cost_label: "비용: {cost}" + capabilities_label: "기능: {capabilities}" + prompt_caching_enabled: "프롬프트 캐싱: 활성화됨" + warning_prefix: "경고: {warning}" + saved_global: "config.yaml에 저장됨 (`--global`)" + session_only_hint: "_(세션 한정 — 영구 저장하려면 `--global`을 추가하세요)_" + current_label: "현재: `{model}` ({provider})" + current_tag: " (현재)" + more_models_suffix: " (+{count}개 더 있음)" + usage_switch_model: "`/model <name>` — 모델 전환" + usage_switch_provider: "`/model <name> --provider <slug>` — 제공자 전환" + usage_persist: "`/model <name> --global` — 영구 저장" + + agents: + header: "🤖 **활성 에이전트 및 작업**" + active_agents: "**활성 에이전트:** {count}" + this_chat: " · 이 채팅" + more: "... 외 {count}개 더" + running_processes: "**실행 중인 백그라운드 프로세스:** {count}" + async_jobs: "**게이트웨이 비동기 작업:** {count}" + none: "활성 에이전트나 실행 중인 작업이 없습니다." + state_starting: "시작 중" + state_running: "실행 중" + + approve: + no_pending: "승인 대기 중인 명령이 없습니다." + once_singular: "✅ 명령이 승인되었습니다. 에이전트가 재개됩니다..." + once_plural: "✅ 명령이 승인되었습니다 ({count}개). 에이전트가 재개됩니다..." + session_singular: "✅ 명령이 승인되었습니다 (이 세션 동안 패턴 승인됨). 에이전트가 재개됩니다..." + session_plural: "✅ 명령이 승인되었습니다 (이 세션 동안 패턴 승인됨) ({count}개). 에이전트가 재개됩니다..." + always_singular: "✅ 명령이 승인되었습니다 (패턴 영구 승인됨). 에이전트가 재개됩니다..." + always_plural: "✅ 명령이 승인되었습니다 (패턴 영구 승인됨) ({count}개). 에이전트가 재개됩니다..." + + background: + usage: "사용법: /background <prompt>\n예시: /background 오늘 HN 인기 글을 요약해줘\n\n프롬프트를 별도 세션에서 실행합니다. 계속 대화할 수 있으며, 완료되면 결과가 여기에 표시됩니다." + started: "🔄 백그라운드 작업이 시작되었습니다: \"{preview}\"\n작업 ID: {task_id}\n계속 대화하실 수 있습니다 — 완료되면 결과가 여기에 표시됩니다." + + branch: + db_unavailable: "세션 데이터베이스를 사용할 수 없습니다." + no_conversation: "분기할 대화가 없습니다 — 먼저 메시지를 보내주세요." + create_failed: "분기 생성에 실패했습니다: {error}" + switch_failed: "분기는 생성되었으나 전환에 실패했습니다." + branched_one: "⑂ **{title}**(으)로 분기했습니다 (메시지 {count}개 복사됨)\n원본: `{parent}`\n분기: `{new}`\n원본으로 돌아가려면 `/resume`을 사용하세요." + branched_many: "⑂ **{title}**(으)로 분기했습니다 (메시지 {count}개 복사됨)\n원본: `{parent}`\n분기: `{new}`\n원본으로 돌아가려면 `/resume`을 사용하세요." + + commands: + usage: "사용법: `/commands [page]`" + skill_header: "⚡ **스킬 명령**:" + default_desc: "스킬 명령" + none: "사용 가능한 명령이 없습니다." + header: "📚 **명령 목록** (총 {total}개, {page}/{total_pages} 페이지)" + nav_prev: "`/commands {page}` ← 이전" + nav_next: "다음 → `/commands {page}`" + out_of_range: "_(요청한 페이지 {requested}이(가) 범위를 벗어났습니다. {page} 페이지를 표시합니다.)_" + + compress: + not_enough: "압축할 대화가 충분하지 않습니다 (최소 4개의 메시지가 필요합니다)." + no_provider: "구성된 제공자가 없습니다 -- 압축할 수 없습니다." + nothing_to_do: "아직 압축할 내용이 없습니다 (대화 내용이 모두 보호된 컨텍스트입니다)." + focus_line: "초점: \"{topic}\"" + summary_failed: "⚠️ 요약 생성에 실패했습니다 ({error}). 과거 메시지 {count}개가 제거되어 자리표시자로 대체되었으며, 이전 컨텍스트는 더 이상 복구할 수 없습니다. auxiliary.compression 모델 설정을 확인해 보세요." + aux_failed: "ℹ️ 구성된 압축 모델 `{model}`이(가) 실패했습니다 ({error}). 메인 모델로 복구되어 컨텍스트는 보존되었지만, config.yaml의 `auxiliary.compression.model` 설정을 확인하는 것이 좋습니다." + failed: "압축 실패: {error}" + + debug: + upload_failed: "✗ 디버그 보고서 업로드 실패: {error}" + header: "**디버그 보고서가 업로드되었습니다:**" + auto_delete: "⏱ 페이스트는 6시간 후 자동 삭제됩니다." + full_logs_hint: "전체 로그 업로드는 CLI에서 `hermes debug share`를 사용하세요." + share_hint: "지원을 받으려면 이 링크를 Hermes 팀과 공유하세요." + + deny: + stale: "❌ 명령이 거부되었습니다 (승인이 만료됨)." + no_pending: "거부 대기 중인 명령이 없습니다." + denied_singular: "❌ 명령이 거부되었습니다." + denied_plural: "❌ 명령이 거부되었습니다 ({count}개)." + + fast: + not_supported: "⚡ /fast는 Priority Processing을 지원하는 OpenAI 모델에서만 사용할 수 있습니다." + status: "⚡ Priority Processing\n\n현재 모드: `{mode}`\n\n_사용법:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ 알 수 없는 인수: `{arg}`\n\n**유효한 옵션:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (설정에 저장됨)\n_(다음 메시지부터 적용됩니다)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (이 세션에만 적용)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 런타임 푸터: **{state}**\n필드: `{fields}`\n플랫폼: `{platform}`" + usage: "사용법: `/footer [on|off|status]`" + saved: "📎 런타임 푸터: **{state}**{example}\n_(전역 저장됨 — 다음 메시지부터 적용됩니다)_" + example_line: "\n예시: `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "이 세션에서는 목표 기능을 사용할 수 없습니다." + no_goal_set: "설정된 목표가 없습니다." + paused: "⏸ 목표 일시정지: {goal}" + no_resume: "재개할 목표가 없습니다." + resumed: "▶ 목표 재개: {goal}\n메시지를 보내 계속하거나 기다려 주세요 — 다음 차례에 다음 단계를 진행하겠습니다." + invalid: "잘못된 목표: {error}" + set: "⊙ 목표 설정됨 ({budget}회 예산): {goal}\n목표가 완료되거나, 일시정지/삭제하거나, 예산이 소진될 때까지 계속 작업하겠습니다.\n제어: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Hermes 명령**\n" + skill_header: "\n⚡ **스킬 명령** ({count}개 활성):" + more_use_commands: "\n... 외 {count}개 더. 전체 목록은 `/commands`로 확인하세요." + + insights: + invalid_days: "잘못된 --days 값: {value}" + error: "인사이트 생성 중 오류: {error}" + + kanban: + error_prefix: "⚠ kanban 오류: {error}" + subscribed_suffix: "(구독 중 — {task_id}이(가) 완료되거나 차단되면 알림을 받습니다)" + truncated_suffix: "… (잘림; 전체 출력을 보려면 터미널에서 `hermes kanban …`을 사용하세요)" + no_output: "(출력 없음)" + + personality: + none_configured: "`{path}/config.yaml`에 구성된 성격이 없습니다" + header: "🎭 **사용 가능한 성격**\n" + none_option: "• `none` — (성격 오버레이 없음)" + item: "• `{name}` — {preview}" + usage: "\n사용법: `/personality <name>`" + save_failed: "⚠️ 성격 변경 저장에 실패했습니다: {error}" + cleared: "🎭 성격이 해제되었습니다 — 기본 에이전트 동작을 사용합니다.\n_(다음 메시지부터 적용됩니다)_" + set_to: "🎭 성격이 **{name}**(으)로 설정되었습니다\n_(다음 메시지부터 적용됩니다)_" + unknown: "알 수 없는 성격: `{name}`\n\n사용 가능: {available}" + + profile: + header: "👤 **프로필:** `{profile}`" + home: "📂 **홈:** `{home}`" + + reasoning: + level_default: "medium (기본값)" + level_disabled: "none (비활성화됨)" + scope_session: "세션 재정의" + scope_global: "전역 설정" + status: "🧠 **추론 설정**\n\n**노력:** `{level}`\n**범위:** {scope}\n**표시:** {display}\n\n_사용법:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "켜짐 ✓" + display_off: "꺼짐" + display_set_on: "🧠 ✓ 추론 표시: **켜짐**\n**{platform}**에서 응답 전에 모델의 사고 과정이 표시됩니다." + display_set_off: "🧠 ✓ 추론 표시: **꺼짐** (**{platform}**에서)" + reset_global_unsupported: "⚠️ `/reasoning reset --global`은 지원되지 않습니다. 전역 기본값을 변경하려면 `/reasoning <level> --global`을 사용하세요." + reset_done: "🧠 ✓ 세션 추론 재정의가 해제되었습니다. 전역 설정으로 돌아갑니다." + unknown_arg: "⚠️ 알 수 없는 인수: `{arg}`\n\n**유효한 수준:** none, minimal, low, medium, high, xhigh\n**표시:** show, hide\n**영구화:** 이 세션을 넘어 저장하려면 `--global`을 추가하세요" + set_global: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (설정에 저장됨)\n_(다음 메시지부터 적용됩니다)_" + set_global_save_failed: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (세션 한정 — 설정 저장 실패)\n_(다음 메시지부터 적용됩니다)_" + set_session: "🧠 ✓ 추론 노력이 `{effort}`(으)로 설정되었습니다 (세션 한정 — 영구 저장하려면 `--global` 추가)\n_(다음 메시지부터 적용됩니다)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp가 취소되었습니다. MCP 도구는 변경되지 않았습니다." + always_followup: "ℹ️ 이후 `/reload-mcp` 호출은 확인 없이 실행됩니다. config.yaml의 `approvals.mcp_reload_confirm: true`로 다시 활성화할 수 있습니다." + confirm_prompt: "⚠️ **/reload-mcp 확인**\n\nMCP 서버를 재로드하면 이 세션의 도구 세트가 재구성되며 **제공자 프롬프트 캐시가 무효화됩니다** — 다음 메시지에서 전체 입력 토큰이 다시 전송됩니다. 긴 컨텍스트 또는 고도 추론 모델에서는 비용이 클 수 있습니다.\n\n선택하세요:\n• **한 번 승인** — 지금 재로드\n• **항상 승인** — 지금 재로드하고 이 프롬프트를 영구 비활성화\n• **취소** — MCP 도구를 변경하지 않음\n\n_텍스트 대체: `/approve`, `/always`, `/cancel`로 응답하세요._" + header: "🔄 **MCP 서버가 재로드되었습니다**\n" + reconnected: "♻️ 재연결됨: {names}" + added: "➕ 추가됨: {names}" + removed: "➖ 제거됨: {names}" + none_connected: "연결된 MCP 서버가 없습니다." + tools_available: "\n🔧 {servers}개 서버에서 {tools}개 도구 사용 가능" + failed: "❌ MCP 재로드 실패: {error}" + + reload_skills: + header: "🔄 **스킬이 재로드되었습니다**\n" + no_new: "새로운 스킬이 감지되지 않았습니다." + total: "\n📚 {count}개 스킬 사용 가능" + added_header: "➕ **추가된 스킬:**" + removed_header: "➖ **제거된 스킬:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ 스킬 재로드 실패: {error}" + + reset: + header_default: "✨ 세션이 초기화되었습니다! 새로 시작합니다." + header_new: "✨ 새 세션이 시작되었습니다!" + header_titled: "✨ 새 세션이 시작되었습니다: {title}" + title_rejected: "\n⚠️ 제목이 거부되었습니다: {error}" + title_error_untitled: "\n⚠️ {error} — 제목 없이 세션을 시작했습니다." + title_empty_untitled: "\n⚠️ 정리 후 제목이 비어 있습니다 — 제목 없이 세션을 시작했습니다." + tip: "\n✦ 팁: {tip}" + + restart: + in_progress: "⏳ 게이트웨이 재시작이 이미 진행 중입니다..." + restarting: "♻ 게이트웨이를 재시작 중입니다. 60초 이내에 알림이 오지 않으면 콘솔에서 `hermes gateway restart`로 재시작하세요." + + resume: + db_unavailable: "세션 데이터베이스를 사용할 수 없습니다." + no_named_sessions: "이름이 지정된 세션이 없습니다.\n현재 세션에 이름을 지정하려면 `/title 내 세션`을 사용하고, 나중에 `/resume 내 세션`으로 돌아오세요." + list_header: "📋 **이름이 지정된 세션**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\n사용법: `/resume <session name>`" + list_failed: "세션 목록을 가져올 수 없습니다: {error}" + not_found: "'**{name}**'와 일치하는 세션이 없습니다.\n사용 가능한 세션을 보려면 인수 없이 `/resume`을 사용하세요." + already_on: "📌 이미 **{name}** 세션에 있습니다." + switch_failed: "세션 전환에 실패했습니다." + resumed_one: "↻ **{title}** 세션 재개됨 (메시지 {count}개). 대화가 복원되었습니다." + resumed_many: "↻ **{title}** 세션 재개됨 (메시지 {count}개). 대화가 복원되었습니다." + resumed_no_count: "↻ **{title}** 세션 재개됨. 대화가 복원되었습니다." + + retry: + no_previous: "재시도할 이전 메시지가 없습니다." + + rollback: + not_enabled: "체크포인트가 활성화되어 있지 않습니다.\nconfig.yaml에서 활성화하세요:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "{cwd}에 체크포인트를 찾을 수 없습니다" + invalid_number: "잘못된 체크포인트 번호입니다. 1-{max}을 사용하세요." + restored: "✅ 체크포인트 {hash}(으)로 복원됨: {reason}\n롤백 전 스냅샷이 자동으로 저장되었습니다." + restore_failed: "❌ {error}" + + set_home: + save_failed: "홈 채널 저장에 실패했습니다: {error}" + success: "✅ 홈 채널이 **{name}**(ID: {chat_id})(으)로 설정되었습니다.\n크론 작업과 플랫폼 간 메시지가 여기로 전달됩니다." + + status: + header: "📊 **Hermes 게이트웨이 상태**" + session_id: "**세션 ID:** `{session_id}`" + title: "**제목:** {title}" + created: "**생성됨:** {timestamp}" + last_activity: "**최종 활동:** {timestamp}" + tokens: "**토큰:** {tokens}" + agent_running: "**에이전트 실행 중:** {state}" + state_yes: "예 ⚡" + state_no: "아니오" + queued: "**대기 중인 후속 작업:** {count}" + platforms: "**연결된 플랫폼:** {platforms}" + + stop: + stopped_pending: "⚡ 중지되었습니다. 에이전트가 아직 시작되지 않았습니다 — 이 세션을 계속할 수 있습니다." + stopped: "⚡ 중지되었습니다. 이 세션을 계속할 수 있습니다." + no_active: "중지할 활성 작업이 없습니다." + + title: + db_unavailable: "세션 데이터베이스를 사용할 수 없습니다." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ 정리 후 제목이 비어 있습니다. 인쇄 가능한 문자를 사용해 주세요." + set_to: "✏️ 세션 제목 설정됨: **{title}**" + not_found: "데이터베이스에서 세션을 찾을 수 없습니다." + current_with_title: "📌 세션: `{session_id}`\n제목: **{title}**" + current_no_title: "📌 세션: `{session_id}`\n제목이 설정되지 않았습니다. 사용법: `/title 내 세션 이름`" + + topic: + not_telegram_dm: "/topic 명령은 Telegram 비공개 채팅에서만 사용할 수 있습니다." + no_session_db: "세션 데이터베이스를 사용할 수 없습니다." + unauthorized: "이 봇에서 /topic을 사용할 권한이 없습니다." + restore_needs_topic: "세션을 복원하려면 먼저 Telegram 토픽을 만들거나 열고, 해당 토픽 안에서 /topic <session-id>를 보내세요. 새 토픽을 만들려면 All Messages를 열고 그곳으로 메시지를 보내세요." + topics_disabled: "이 봇에는 아직 Telegram 토픽이 활성화되어 있지 않습니다.\n\n활성화 방법:\n1. @BotFather를 엽니다.\n2. 봇을 선택합니다.\n3. Bot Settings → Threads Settings를 엽니다.\n4. Threaded Mode를 켜고 사용자가 새 스레드를 만들 수 있도록 허용합니다.\n\n그런 다음 다시 /topic을 보내세요." + topics_user_disallowed: "Telegram 토픽이 활성화되어 있지만, 사용자가 토픽을 만들 수 없습니다.\n\n@BotFather → 봇 선택 → Bot Settings → Threads Settings를 열고 'Disallow users to create new threads'를 끄세요.\n\n그런 다음 다시 /topic을 보내세요." + enable_failed: "Telegram 토픽 모드 활성화에 실패했습니다: {error}" + bound_status: "이 토픽은 다음에 연결되어 있습니다:\n세션: {label}\nID: {session_id}\n\n이 토픽을 새 세션으로 교체하려면 /new를 사용하세요.\n병렬 작업을 위해서는 All Messages를 열고 메시지를 보내 다른 토픽을 만드세요." + thread_ready: "Telegram 다중 세션 토픽이 활성화되었습니다.\n\n이 토픽은 독립된 Hermes 세션으로 사용됩니다. 이 토픽의 현재 세션을 교체하려면 /new를 사용하세요. 병렬 작업을 위해서는 All Messages를 열고 메시지를 보내 다른 토픽을 만드세요." + untitled_session: "제목 없는 세션" + + undo: + nothing: "되돌릴 내용이 없습니다." + removed: "↩️ 메시지 {count}개를 되돌렸습니다.\n제거됨: \"{preview}\"" + + update: + platform_not_messaging: "✗ /update는 메시징 플랫폼에서만 사용할 수 있습니다. 터미널에서 `hermes update`를 실행하세요." + not_git_repo: "✗ git 저장소가 아닙니다 — 업데이트할 수 없습니다." + hermes_cmd_not_found: "✗ `hermes` 명령을 찾을 수 없습니다. Hermes는 실행 중이지만 PATH나 현재 Python 인터프리터를 통해 실행 파일을 찾을 수 없습니다. 터미널에서 `hermes update`를 직접 실행해 보세요." + start_failed: "✗ 업데이트 시작 실패: {error}" + starting: "⚕ Hermes 업데이트를 시작합니다… 진행 상황을 여기에 스트리밍하겠습니다." + + usage: + rate_limits: "⏱️ **요청 제한:** {state}" + header_session: "📊 **세션 토큰 사용량**" + label_model: "모델: `{model}`" + label_input_tokens: "입력 토큰: {count}" + label_cache_read: "캐시 읽기 토큰: {count}" + label_cache_write: "캐시 쓰기 토큰: {count}" + label_output_tokens: "출력 토큰: {count}" + label_total: "합계: {count}" + label_api_calls: "API 호출: {count}" + label_cost: "비용: {prefix}${amount}" + label_cost_included: "비용: 포함됨" + label_context: "컨텍스트: {used} / {total} ({pct}%)" + label_compressions: "압축: {count}" + header_session_info: "📊 **세션 정보**" + label_messages: "메시지: {count}" + label_estimated_context: "예상 컨텍스트: 약 {count} 토큰" + detailed_after_first: "_(자세한 사용량은 첫 에이전트 응답 이후 확인할 수 있습니다)_" + no_data: "이 세션에 사용 가능한 사용량 데이터가 없습니다." + + verbose: + not_enabled: "`/verbose` 명령은 메시징 플랫폼에서 활성화되어 있지 않습니다.\n\n`config.yaml`에서 활성화하세요:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ 도구 진행 상황: **OFF** — 도구 활동이 표시되지 않습니다." + mode_new: "⚙️ 도구 진행 상황: **NEW** — 도구가 변경될 때 표시됩니다 (미리보기 길이: `display.tool_preview_length`, 기본 40)." + mode_all: "⚙️ 도구 진행 상황: **ALL** — 모든 도구 호출이 표시됩니다 (미리보기 길이: `display.tool_preview_length`, 기본 40)." + mode_verbose: "⚙️ 도구 진행 상황: **VERBOSE** — 모든 도구 호출이 전체 인수와 함께 표시됩니다." + saved_suffix: "_(**{platform}**에 저장됨 — 다음 메시지부터 적용됩니다)_" + save_failed: "_(설정에 저장할 수 없습니다: {error})_" + + voice: + enabled_voice_only: "음성 모드가 활성화되었습니다.\n음성 메시지를 보내시면 음성으로 답변하겠습니다.\n모든 메시지에 대해 음성으로 응답받으려면 /voice tts를 사용하세요." + disabled_text: "음성 모드가 비활성화되었습니다. 텍스트로만 응답합니다." + tts_enabled: "자동 TTS가 활성화되었습니다.\n모든 응답에 음성 메시지가 포함됩니다." + status_mode: "음성 모드: {label}" + status_channel: "음성 채널: #{channel}" + status_participants: "참가자: {count}" + status_member: " - {name}{status}" + speaking: " (말하는 중)" + enabled_short: "음성 모드가 활성화되었습니다." + disabled_short: "음성 모드가 비활성화되었습니다." + label_off: "꺼짐 (텍스트 전용)" + label_voice_only: "켜짐 (음성 메시지에 음성으로 응답)" + label_all: "TTS (모든 메시지에 음성으로 응답)" + + yolo: + disabled: "⚠️ 이 세션에서 YOLO 모드 **꺼짐** — 위험한 명령은 승인이 필요합니다." + enabled: "⚡ 이 세션에서 YOLO 모드 **켜짐** — 모든 명령이 자동 승인됩니다. 주의해서 사용하세요." + + shared: + session_db_unavailable: "세션 데이터베이스를 사용할 수 없습니다." + session_db_unavailable_prefix: "세션 데이터베이스를 사용할 수 없습니다" + session_not_found: "데이터베이스에서 세션을 찾을 수 없습니다." + warn_passthrough: "⚠️ {error}" diff --git a/locales/pt.yaml b/locales/pt.yaml new file mode 100644 index 00000000000..e74c218d6ba --- /dev/null +++ b/locales/pt.yaml @@ -0,0 +1,350 @@ +# Catálogo de mensagens estáticas do Hermes -- Português +# See locales/en.yaml for the source of truth; keep keys in sync. + +approval: + dangerous_header: "⚠️ COMANDO PERIGOSO: {description}" + choose_long: " [o]uma vez | [s]sessão | [a]sempre | [d]negar" + choose_short: " [o]uma vez | [s]sessão | [d]negar" + prompt_long: " Escolha [o/s/a/D]: " + prompt_short: " Escolha [o/s/D]: " + timeout: " ⏱ Tempo esgotado — comando negado" + allowed_once: " ✓ Permitido uma vez" + allowed_session: " ✓ Permitido nesta sessão" + allowed_always: " ✓ Adicionado à lista de permissões permanente" + denied: " ✗ Negado" + cancelled: " ✗ Cancelado" + blocklist_message: "Este comando está na lista de bloqueio incondicional e não pode ser aprovado." + +gateway: + approval_expired: "⚠️ A aprovação expirou (o agente já não está à espera). Peça ao agente para tentar novamente." + draining: "⏳ A aguardar que {count} agente(s) ativo(s) terminem antes de reiniciar..." + goal_cleared: "✓ Objetivo removido." + no_active_goal: "Não há objetivo ativo." + config_read_failed: "⚠️ Não foi possível ler config.yaml: {error}" + config_save_failed: "⚠️ Não foi possível guardar a configuração: {error}" + + model: + error_prefix: "Erro: {error}" + switched: "Modelo alterado para `{model}`" + provider_label: "Fornecedor: {provider}" + context_label: "Contexto: {tokens} tokens" + max_output_label: "Saída máxima: {tokens} tokens" + cost_label: "Custo: {cost}" + capabilities_label: "Capacidades: {capabilities}" + prompt_caching_enabled: "Cache de prompts: ativado" + warning_prefix: "Aviso: {warning}" + saved_global: "Guardado em config.yaml (`--global`)" + session_only_hint: "_(apenas para esta sessão — adiciona `--global` para tornar permanente)_" + current_label: "Atual: `{model}` em {provider}" + current_tag: " (atual)" + more_models_suffix: " (+{count} mais)" + usage_switch_model: "`/model <name>` — mudar de modelo" + usage_switch_provider: "`/model <name> --provider <slug>` — mudar de fornecedor" + usage_persist: "`/model <name> --global` — guardar permanentemente" + + agents: + header: "🤖 **Agentes e tarefas ativos**" + active_agents: "**Agentes ativos:** {count}" + this_chat: " · este chat" + more: "... e mais {count}" + running_processes: "**Processos em segundo plano em execução:** {count}" + async_jobs: "**Tarefas assíncronas do gateway:** {count}" + none: "Não há agentes ativos nem tarefas em execução." + state_starting: "a iniciar" + state_running: "em execução" + + approve: + no_pending: "Não há nenhum comando pendente para aprovar." + once_singular: "✅ Comando aprovado. O agente está a retomar..." + once_plural: "✅ Comandos aprovados ({count} comandos). O agente está a retomar..." + session_singular: "✅ Comando aprovado (padrão aprovado para esta sessão). O agente está a retomar..." + session_plural: "✅ Comandos aprovados (padrão aprovado para esta sessão) ({count} comandos). O agente está a retomar..." + always_singular: "✅ Comando aprovado (padrão aprovado permanentemente). O agente está a retomar..." + always_plural: "✅ Comandos aprovados (padrão aprovado permanentemente) ({count} comandos). O agente está a retomar..." + + background: + usage: "Uso: /background <prompt>\nExemplo: /background Resume as principais histórias do HN de hoje\n\nExecuta o prompt numa sessão separada. Podes continuar a conversar — o resultado aparecerá aqui quando estiver concluído." + started: "🔄 Tarefa em segundo plano iniciada: \"{preview}\"\nID da tarefa: {task_id}\nPodes continuar a conversar — os resultados aparecerão aqui quando estiverem prontos." + + branch: + db_unavailable: "Base de dados de sessões indisponível." + no_conversation: "Não há conversa para ramificar — envia uma mensagem primeiro." + create_failed: "Falha ao criar ramo: {error}" + switch_failed: "Ramo criado, mas não foi possível mudar para ele." + branched_one: "⑂ Ramificado para **{title}** ({count} mensagem copiada)\nOriginal: `{parent}`\nRamo: `{new}`\nUsa `/resume` para voltar ao original." + branched_many: "⑂ Ramificado para **{title}** ({count} mensagens copiadas)\nOriginal: `{parent}`\nRamo: `{new}`\nUsa `/resume` para voltar ao original." + + commands: + usage: "Uso: `/commands [page]`" + skill_header: "⚡ **Comandos de skill**:" + default_desc: "Comando de skill" + none: "Não há comandos disponíveis." + header: "📚 **Comandos** ({total} no total, página {page}/{total_pages})" + nav_prev: "`/commands {page}` ← anterior" + nav_next: "seguinte → `/commands {page}`" + out_of_range: "_(A página solicitada {requested} estava fora do intervalo, a mostrar a página {page}.)_" + + compress: + not_enough: "Não há conversa suficiente para comprimir (são necessárias pelo menos 4 mensagens)." + no_provider: "Nenhum fornecedor configurado — não é possível comprimir." + nothing_to_do: "Ainda não há nada para comprimir (a transcrição continua a ser todo o contexto protegido)." + focus_line: "Foco: \"{topic}\"" + summary_failed: "⚠️ Falha ao gerar o resumo ({error}). {count} mensagem(ns) histórica(s) foram removidas e substituídas por um marcador; o contexto anterior já não pode ser recuperado. Considera verificar a configuração do modelo auxiliary.compression." + aux_failed: "ℹ️ O modelo de compressão configurado `{model}` falhou ({error}). Recuperado com o teu modelo principal — o contexto está intacto — mas talvez queiras verificar `auxiliary.compression.model` em config.yaml." + failed: "Compressão falhou: {error}" + + debug: + upload_failed: "✗ Falha ao carregar relatório de depuração: {error}" + header: "**Relatório de depuração carregado:**" + auto_delete: "⏱ Os pastes serão eliminados automaticamente em 6 horas." + full_logs_hint: "Para enviar logs completos, usa `hermes debug share` a partir da CLI." + share_hint: "Partilha estes links com a equipa do Hermes para obter suporte." + + deny: + stale: "❌ Comando negado (a aprovação tinha expirado)." + no_pending: "Não há nenhum comando pendente para negar." + denied_singular: "❌ Comando negado." + denied_plural: "❌ Comandos negados ({count} comandos)." + + fast: + not_supported: "⚡ /fast só está disponível para modelos da OpenAI que suportam Priority Processing." + status: "⚡ Priority Processing\n\nModo atual: `{mode}`\n\n_Uso:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Argumento desconhecido: `{arg}`\n\n**Opções válidas:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (guardado na configuração)\n_(produz efeito na próxima mensagem)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (apenas esta sessão)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Rodapé de execução: **{state}**\nCampos: `{fields}`\nPlataforma: `{platform}`" + usage: "Uso: `/footer [on|off|status]`" + saved: "📎 Rodapé de execução: **{state}**{example}\n_(guardado globalmente — produz efeito na próxima mensagem)_" + example_line: "\nExemplo: `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "Os objetivos não estão disponíveis nesta sessão." + no_goal_set: "Nenhum objetivo definido." + paused: "⏸ Objetivo pausado: {goal}" + no_resume: "Nenhum objetivo para retomar." + resumed: "▶ Objetivo retomado: {goal}\nEnvia qualquer mensagem para continuar, ou aguarda — darei o próximo passo no próximo turno." + invalid: "Objetivo inválido: {error}" + set: "⊙ Objetivo definido (orçamento de {budget} turnos): {goal}\nVou continuar a trabalhar até o objetivo estar concluído, pausares/limpares ou o orçamento esgotar.\nControlos: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Comandos do Hermes**\n" + skill_header: "\n⚡ **Comandos de skill** ({count} ativos):" + more_use_commands: "\n... e mais {count}. Usa `/commands` para a lista paginada completa." + + insights: + invalid_days: "Valor --days inválido: {value}" + error: "Erro ao gerar análise: {error}" + + kanban: + error_prefix: "⚠ erro do kanban: {error}" + subscribed_suffix: "(subscrito — receberás uma notificação quando {task_id} terminar ou bloquear)" + truncated_suffix: "… (truncado; usa `hermes kanban …` no teu terminal para a saída completa)" + no_output: "(sem saída)" + + personality: + none_configured: "Nenhuma personalidade configurada em `{path}/config.yaml`" + header: "🎭 **Personalidades disponíveis**\n" + none_option: "• `none` — (sem sobreposição de personalidade)" + item: "• `{name}` — {preview}" + usage: "\nUso: `/personality <name>`" + save_failed: "⚠️ Falha ao guardar a alteração de personalidade: {error}" + cleared: "🎭 Personalidade removida — a usar o comportamento base do agente.\n_(produz efeito na próxima mensagem)_" + set_to: "🎭 Personalidade definida como **{name}**\n_(produz efeito na próxima mensagem)_" + unknown: "Personalidade desconhecida: `{name}`\n\nDisponíveis: {available}" + + profile: + header: "👤 **Perfil:** `{profile}`" + home: "📂 **Início:** `{home}`" + + reasoning: + level_default: "medium (predefinido)" + level_disabled: "none (desativado)" + scope_session: "substituição de sessão" + scope_global: "configuração global" + status: "🧠 **Definições de raciocínio**\n\n**Esforço:** `{level}`\n**Âmbito:** {scope}\n**Visualização:** {display}\n\n_Uso:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "ativada ✓" + display_off: "desativada" + display_set_on: "🧠 ✓ Visualização do raciocínio: **ATIVADA**\nO pensamento do modelo será mostrado antes de cada resposta em **{platform}**." + display_set_off: "🧠 ✓ Visualização do raciocínio: **DESATIVADA** para **{platform}**" + reset_global_unsupported: "⚠️ `/reasoning reset --global` não é suportado. Usa `/reasoning <level> --global` para alterar o predefinido global." + reset_done: "🧠 ✓ Substituição de raciocínio da sessão removida; a regressar à configuração global." + unknown_arg: "⚠️ Argumento desconhecido: `{arg}`\n\n**Níveis válidos:** none, minimal, low, medium, high, xhigh\n**Visualização:** show, hide\n**Persistir:** adiciona `--global` para guardar para além desta sessão" + set_global: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (guardado na configuração)\n_(produz efeito na próxima mensagem)_" + set_global_save_failed: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (apenas sessão — falha ao guardar a configuração)\n_(produz efeito na próxima mensagem)_" + set_session: "🧠 ✓ Esforço de raciocínio definido como `{effort}` (apenas sessão — adiciona `--global` para persistir)\n_(produz efeito na próxima mensagem)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp cancelado. As ferramentas MCP não foram alteradas." + always_followup: "ℹ️ Próximas chamadas a `/reload-mcp` serão executadas sem confirmação. Reativa através de `approvals.mcp_reload_confirm: true` em `config.yaml`." + confirm_prompt: "⚠️ **Confirmar /reload-mcp**\n\nRecarregar os servidores MCP reconstrói o conjunto de ferramentas desta sessão e **invalida a cache de prompt do fornecedor** — a próxima mensagem reenviará os tokens de entrada completos. Em modelos de contexto longo ou de raciocínio elevado isto pode ser dispendioso.\n\nEscolhe:\n• **Aprovar uma vez** — recarregar agora\n• **Aprovar sempre** — recarregar agora e silenciar este pedido permanentemente\n• **Cancelar** — manter as ferramentas MCP inalteradas\n\n_Alternativa em texto: responde `/approve`, `/always` ou `/cancel`._" + header: "🔄 **Servidores MCP recarregados**\n" + reconnected: "♻️ Reconectados: {names}" + added: "➕ Adicionados: {names}" + removed: "➖ Removidos: {names}" + none_connected: "Não há servidores MCP ligados." + tools_available: "\n🔧 {tools} ferramenta(s) disponíveis de {servers} servidor(es)" + failed: "❌ Falha ao recarregar MCP: {error}" + + reload_skills: + header: "🔄 **Skills recarregadas**\n" + no_new: "Não foram detetadas novas skills." + total: "\n📚 {count} skill(s) disponíveis" + added_header: "➕ **Skills adicionadas:**" + removed_header: "➖ **Skills removidas:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ Falha ao recarregar skills: {error}" + + reset: + header_default: "✨ Sessão reiniciada! A começar do zero." + header_new: "✨ Nova sessão iniciada!" + header_titled: "✨ Nova sessão iniciada: {title}" + title_rejected: "\n⚠️ Título rejeitado: {error}" + title_error_untitled: "\n⚠️ {error} — sessão iniciada sem título." + title_empty_untitled: "\n⚠️ O título fica vazio após a limpeza — sessão iniciada sem título." + tip: "\n✦ Dica: {tip}" + + restart: + in_progress: "⏳ O reinício do gateway já está em curso..." + restarting: "♻ A reiniciar o gateway. Se não fores notificado em 60 segundos, reinicia a partir da consola com `hermes gateway restart`." + + resume: + db_unavailable: "Base de dados de sessões indisponível." + no_named_sessions: "Não foram encontradas sessões com nome.\nUsa `/title A minha sessão` para nomear a sessão atual e depois `/resume A minha sessão` para voltar a ela." + list_header: "📋 **Sessões com nome**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nUso: `/resume <nome da sessão>`" + list_failed: "Não foi possível listar as sessões: {error}" + not_found: "Não foi encontrada nenhuma sessão correspondente a '**{name}**'.\nUsa `/resume` sem argumentos para ver as sessões disponíveis." + already_on: "📌 Já estás na sessão **{name}**." + switch_failed: "Falha ao mudar de sessão." + resumed_one: "↻ Sessão **{title}** retomada ({count} mensagem). Conversa restaurada." + resumed_many: "↻ Sessão **{title}** retomada ({count} mensagens). Conversa restaurada." + resumed_no_count: "↻ Sessão **{title}** retomada. Conversa restaurada." + + retry: + no_previous: "Não há mensagem anterior para tentar novamente." + + rollback: + not_enabled: "Os checkpoints não estão ativados.\nAtiva-os em config.yaml:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "Não foram encontrados checkpoints para {cwd}" + invalid_number: "Número de checkpoint inválido. Usa 1-{max}." + restored: "✅ Restaurado para o checkpoint {hash}: {reason}\nFoi guardado automaticamente um snapshot anterior ao rollback." + restore_failed: "❌ {error}" + + set_home: + save_failed: "Falha ao guardar o canal principal: {error}" + success: "✅ Canal principal definido como **{name}** (ID: {chat_id}).\nAs tarefas cron e mensagens entre plataformas serão entregues aqui." + + status: + header: "📊 **Estado do Hermes Gateway**" + session_id: "**ID da sessão:** `{session_id}`" + title: "**Título:** {title}" + created: "**Criada:** {timestamp}" + last_activity: "**Última atividade:** {timestamp}" + tokens: "**Tokens:** {tokens}" + agent_running: "**Agente em execução:** {state}" + state_yes: "Sim ⚡" + state_no: "Não" + queued: "**Seguimentos em fila:** {count}" + platforms: "**Plataformas ligadas:** {platforms}" + + stop: + stopped_pending: "⚡ Parado. O agente ainda não tinha começado — podes continuar esta sessão." + stopped: "⚡ Parado. Podes continuar esta sessão." + no_active: "Não há nenhuma tarefa ativa para parar." + + title: + db_unavailable: "Base de dados de sessões indisponível." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ O título está vazio após a limpeza. Usa caracteres imprimíveis." + set_to: "✏️ Título da sessão definido: **{title}**" + not_found: "Sessão não encontrada na base de dados." + current_with_title: "📌 Sessão: `{session_id}`\nTítulo: **{title}**" + current_no_title: "📌 Sessão: `{session_id}`\nSem título. Uso: `/title O meu nome de sessão`" + + topic: + not_telegram_dm: "O comando /topic só está disponível em chats privados do Telegram." + no_session_db: "Base de dados de sessões indisponível." + unauthorized: "Não tens autorização para usar /topic neste bot." + restore_needs_topic: "Para restaurar uma sessão, cria ou abre primeiro um topic do Telegram, depois envia /topic <session-id> dentro desse topic. Para criar um novo topic, abre All Messages e envia qualquer mensagem aí." + topics_disabled: "Os topics do Telegram ainda não estão ativados para este bot.\n\nComo ativá-los:\n1. Abre @BotFather.\n2. Escolhe o teu bot.\n3. Abre Bot Settings → Threads Settings.\n4. Ativa Threaded Mode e garante que os utilizadores podem criar novas threads.\n\nDepois envia /topic novamente." + topics_user_disallowed: "Os topics do Telegram estão ativados, mas os utilizadores não podem criá-los.\n\nAbre @BotFather → escolhe o teu bot → Bot Settings → Threads Settings, depois desativa 'Disallow users to create new threads'.\n\nDepois envia /topic novamente." + enable_failed: "Falha ao ativar o modo topic do Telegram: {error}" + bound_status: "Este topic está associado a:\nSessão: {label}\nID: {session_id}\n\nUsa /new para substituir este topic por uma sessão nova.\nPara trabalho paralelo, abre All Messages e envia uma mensagem aí para criar outro topic." + thread_ready: "Os topics multi-sessão do Telegram estão ativados.\n\nEste topic será usado como uma sessão independente do Hermes. Usa /new para substituir a sessão atual deste topic. Para trabalho paralelo, abre All Messages e envia uma mensagem aí para criar outro topic." + untitled_session: "Sessão sem título" + + undo: + nothing: "Nada para anular." + removed: "↩️ {count} mensagem(ns) anulada(s).\nRemovido: \"{preview}\"" + + update: + platform_not_messaging: "✗ /update só está disponível em plataformas de mensagens. Executa `hermes update` a partir do terminal." + not_git_repo: "✗ Não é um repositório git — não é possível atualizar." + hermes_cmd_not_found: "✗ Não foi possível localizar o comando `hermes`. O Hermes está em execução, mas o comando de atualização não conseguiu encontrar o executável no PATH nem através do interpretador Python atual. Tenta executar `hermes update` manualmente no teu terminal." + start_failed: "✗ Falha ao iniciar a atualização: {error}" + starting: "⚕ A iniciar a atualização do Hermes… Vou transmitir o progresso aqui." + + usage: + rate_limits: "⏱️ **Limites de taxa:** {state}" + header_session: "📊 **Utilização de tokens da sessão**" + label_model: "Modelo: `{model}`" + label_input_tokens: "Tokens de entrada: {count}" + label_cache_read: "Tokens de leitura de cache: {count}" + label_cache_write: "Tokens de escrita de cache: {count}" + label_output_tokens: "Tokens de saída: {count}" + label_total: "Total: {count}" + label_api_calls: "Chamadas à API: {count}" + label_cost: "Custo: {prefix}${amount}" + label_cost_included: "Custo: incluído" + label_context: "Contexto: {used} / {total} ({pct}%)" + label_compressions: "Compressões: {count}" + header_session_info: "📊 **Informações da sessão**" + label_messages: "Mensagens: {count}" + label_estimated_context: "Contexto estimado: ~{count} tokens" + detailed_after_first: "_(Utilização detalhada disponível após a primeira resposta do agente)_" + no_data: "Não há dados de utilização disponíveis para esta sessão." + + verbose: + not_enabled: "O comando `/verbose` não está ativado para plataformas de mensagens.\n\nAtiva-o em `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Progresso de ferramentas: **OFF** — não é mostrada qualquer atividade de ferramentas." + mode_new: "⚙️ Progresso de ferramentas: **NEW** — mostrado quando a ferramenta muda (comprimento da pré-visualização: `display.tool_preview_length`, predefinição 40)." + mode_all: "⚙️ Progresso de ferramentas: **ALL** — cada chamada de ferramenta é mostrada (comprimento da pré-visualização: `display.tool_preview_length`, predefinição 40)." + mode_verbose: "⚙️ Progresso de ferramentas: **VERBOSE** — cada chamada de ferramenta com os argumentos completos." + saved_suffix: "_(guardado para **{platform}** — produz efeito na próxima mensagem)_" + save_failed: "_(não foi possível guardar na configuração: {error})_" + + voice: + enabled_voice_only: "Modo de voz ativado.\nResponderei com voz quando enviares mensagens de voz.\nUsa /voice tts para receber respostas de voz em todas as mensagens." + disabled_text: "Modo de voz desativado. Respostas apenas em texto." + tts_enabled: "Auto-TTS ativado.\nTodas as respostas incluirão uma mensagem de voz." + status_mode: "Modo de voz: {label}" + status_channel: "Canal de voz: #{channel}" + status_participants: "Participantes: {count}" + status_member: " - {name}{status}" + speaking: " (a falar)" + enabled_short: "Modo de voz ativado." + disabled_short: "Modo de voz desativado." + label_off: "Desativado (apenas texto)" + label_voice_only: "Ativado (resposta de voz a mensagens de voz)" + label_all: "TTS (resposta de voz a todas as mensagens)" + + yolo: + disabled: "⚠️ Modo YOLO **DESATIVADO** nesta sessão — comandos perigosos exigirão aprovação." + enabled: "⚡ Modo YOLO **ATIVADO** nesta sessão — todos os comandos são aprovados automaticamente. Usa com precaução." + + shared: + session_db_unavailable: "Base de dados de sessões indisponível." + session_db_unavailable_prefix: "Base de dados de sessões indisponível" + session_not_found: "Sessão não encontrada na base de dados." + warn_passthrough: "⚠️ {error}" diff --git a/locales/ru.yaml b/locales/ru.yaml new file mode 100644 index 00000000000..c520362675d --- /dev/null +++ b/locales/ru.yaml @@ -0,0 +1,350 @@ +# Каталог статических сообщений Hermes -- Русский +# See locales/en.yaml for the source of truth; keep keys in sync. + +approval: + dangerous_header: "⚠️ ОПАСНАЯ КОМАНДА: {description}" + choose_long: " [o]один раз | [s]сеанс | [a]всегда | [d]отклонить" + choose_short: " [o]один раз | [s]сеанс | [d]отклонить" + prompt_long: " Выбор [o/s/a/D]: " + prompt_short: " Выбор [o/s/D]: " + timeout: " ⏱ Время ожидания истекло — команда отклонена" + allowed_once: " ✓ Разрешено один раз" + allowed_session: " ✓ Разрешено для этого сеанса" + allowed_always: " ✓ Добавлено в постоянный список разрешённых" + denied: " ✗ Отклонено" + cancelled: " ✗ Отменено" + blocklist_message: "Эта команда находится в безусловном списке блокировки и не может быть одобрена." + +gateway: + approval_expired: "⚠️ Срок одобрения истёк (агент больше не ожидает). Попросите агента повторить попытку." + draining: "⏳ Ожидание завершения {count} активных агент(ов) перед перезапуском..." + goal_cleared: "✓ Цель очищена." + no_active_goal: "Нет активной цели." + config_read_failed: "⚠️ Не удалось прочитать config.yaml: {error}" + config_save_failed: "⚠️ Не удалось сохранить конфигурацию: {error}" + + model: + error_prefix: "Ошибка: {error}" + switched: "Модель изменена на `{model}`" + provider_label: "Провайдер: {provider}" + context_label: "Контекст: {tokens} токенов" + max_output_label: "Макс. вывод: {tokens} токенов" + cost_label: "Стоимость: {cost}" + capabilities_label: "Возможности: {capabilities}" + prompt_caching_enabled: "Кеширование промптов: включено" + warning_prefix: "Предупреждение: {warning}" + saved_global: "Сохранено в config.yaml (`--global`)" + session_only_hint: "_(только для этого сеанса — добавьте `--global`, чтобы сохранить)_" + current_label: "Текущая: `{model}` на {provider}" + current_tag: " (текущая)" + more_models_suffix: " (+ещё {count})" + usage_switch_model: "`/model <name>` — сменить модель" + usage_switch_provider: "`/model <name> --provider <slug>` — сменить провайдера" + usage_persist: "`/model <name> --global` — сохранить навсегда" + + agents: + header: "🤖 **Активные агенты и задачи**" + active_agents: "**Активные агенты:** {count}" + this_chat: " · этот чат" + more: "... и ещё {count}" + running_processes: "**Выполняющиеся фоновые процессы:** {count}" + async_jobs: "**Асинхронные задачи шлюза:** {count}" + none: "Нет активных агентов или выполняющихся задач." + state_starting: "запускается" + state_running: "выполняется" + + approve: + no_pending: "Нет команды, ожидающей одобрения." + once_singular: "✅ Команда одобрена. Агент возобновляет работу..." + once_plural: "✅ Команды одобрены ({count} команд). Агент возобновляет работу..." + session_singular: "✅ Команда одобрена (шаблон одобрен для этого сеанса). Агент возобновляет работу..." + session_plural: "✅ Команды одобрены (шаблон одобрен для этого сеанса) ({count} команд). Агент возобновляет работу..." + always_singular: "✅ Команда одобрена (шаблон одобрен навсегда). Агент возобновляет работу..." + always_plural: "✅ Команды одобрены (шаблон одобрен навсегда) ({count} команд). Агент возобновляет работу..." + + background: + usage: "Использование: /background <запрос>\nПример: /background Сделай сводку лучших историй с HN сегодня\n\nЗапускает запрос в отдельном сеансе. Можно продолжить общение — результат появится здесь по завершении." + started: "🔄 Фоновая задача запущена: «{preview}»\nID задачи: {task_id}\nМожно продолжить общение — результаты появятся здесь по завершении." + + branch: + db_unavailable: "База данных сеансов недоступна." + no_conversation: "Нет беседы для ответвления — сначала отправьте сообщение." + create_failed: "Не удалось создать ветку: {error}" + switch_failed: "Ветка создана, но переключиться на неё не удалось." + branched_one: "⑂ Создана ветка **{title}** (скопировано {count} сообщение)\nОригинал: `{parent}`\nВетка: `{new}`\nИспользуйте `/resume`, чтобы вернуться к оригиналу." + branched_many: "⑂ Создана ветка **{title}** (скопировано {count} сообщений)\nОригинал: `{parent}`\nВетка: `{new}`\nИспользуйте `/resume`, чтобы вернуться к оригиналу." + + commands: + usage: "Использование: `/commands [page]`" + skill_header: "⚡ **Команды навыков**:" + default_desc: "Команда навыка" + none: "Нет доступных команд." + header: "📚 **Команды** (всего {total}, страница {page}/{total_pages})" + nav_prev: "`/commands {page}` ← пред." + nav_next: "след. → `/commands {page}`" + out_of_range: "_(Запрошенная страница {requested} вне диапазона, показана страница {page}.)_" + + compress: + not_enough: "Недостаточно беседы для сжатия (нужно минимум 4 сообщения)." + no_provider: "Провайдер не настроен — сжатие невозможно." + nothing_to_do: "Пока нечего сжимать (стенограмма всё ещё полностью является защищённым контекстом)." + focus_line: "Фокус: \"{topic}\"" + summary_failed: "⚠️ Не удалось сгенерировать сводку ({error}). {count} историч. сообщений было удалено и заменено заполнителем; предыдущий контекст больше нельзя восстановить. Проверьте конфигурацию модели auxiliary.compression." + aux_failed: "ℹ️ Настроенная модель сжатия `{model}` дала сбой ({error}). Восстановлено с помощью основной модели — контекст не повреждён — но рекомендуется проверить `auxiliary.compression.model` в config.yaml." + failed: "Сжатие не удалось: {error}" + + debug: + upload_failed: "✗ Не удалось загрузить отчёт отладки: {error}" + header: "**Отчёт отладки загружен:**" + auto_delete: "⏱ Вставки автоматически удалятся через 6 часов." + full_logs_hint: "Для загрузки полных журналов используйте `hermes debug share` из CLI." + share_hint: "Поделитесь этими ссылками с командой Hermes для получения поддержки." + + deny: + stale: "❌ Команда отклонена (одобрение устарело)." + no_pending: "Нет команды для отклонения." + denied_singular: "❌ Команда отклонена." + denied_plural: "❌ Команды отклонены ({count} команд)." + + fast: + not_supported: "⚡ /fast доступен только для моделей OpenAI, поддерживающих Priority Processing." + status: "⚡ Priority Processing\n\nТекущий режим: `{mode}`\n\n_Использование:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Неизвестный аргумент: `{arg}`\n\n**Допустимые варианты:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (сохранено в конфигурации)\n_(вступит в силу со следующего сообщения)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (только этот сеанс)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Нижний колонтитул среды выполнения: **{state}**\nПоля: `{fields}`\nПлатформа: `{platform}`" + usage: "Использование: `/footer [on|off|status]`" + saved: "📎 Нижний колонтитул среды выполнения: **{state}**{example}\n_(сохранено глобально — вступит в силу со следующего сообщения)_" + example_line: "\nПример: `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "Цели недоступны в этом сеансе." + no_goal_set: "Цель не задана." + paused: "⏸ Цель приостановлена: {goal}" + no_resume: "Нет цели для возобновления." + resumed: "▶ Цель возобновлена: {goal}\nОтправьте любое сообщение, чтобы продолжить, или подождите — я сделаю следующий шаг на следующем ходу." + invalid: "Недопустимая цель: {error}" + set: "⊙ Цель задана (бюджет {budget} ходов): {goal}\nЯ продолжу работу, пока цель не будет достигнута, вы её не приостановите/очистите, или бюджет не исчерпается.\nУправление: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Команды Hermes**\n" + skill_header: "\n⚡ **Команды навыков** (активных: {count}):" + more_use_commands: "\n... и ещё {count}. Используйте `/commands` для полного списка с постраничной разбивкой." + + insights: + invalid_days: "Недействительное значение --days: {value}" + error: "Ошибка при формировании аналитики: {error}" + + kanban: + error_prefix: "⚠ ошибка kanban: {error}" + subscribed_suffix: "(подписка оформлена — вы получите уведомление, когда {task_id} завершится или будет заблокирован)" + truncated_suffix: "… (сокращено; используйте `hermes kanban …` в терминале для полного вывода)" + no_output: "(нет вывода)" + + personality: + none_configured: "В `{path}/config.yaml` не настроено ни одной личности" + header: "🎭 **Доступные личности**\n" + none_option: "• `none` — (без наложения личности)" + item: "• `{name}` — {preview}" + usage: "\nИспользование: `/personality <name>`" + save_failed: "⚠️ Не удалось сохранить изменение личности: {error}" + cleared: "🎭 Личность очищена — используется базовое поведение агента.\n_(вступит в силу со следующего сообщения)_" + set_to: "🎭 Личность установлена на **{name}**\n_(вступит в силу со следующего сообщения)_" + unknown: "Неизвестная личность: `{name}`\n\nДоступные: {available}" + + profile: + header: "👤 **Профиль:** `{profile}`" + home: "📂 **Домашний каталог:** `{home}`" + + reasoning: + level_default: "medium (по умолчанию)" + level_disabled: "none (отключено)" + scope_session: "переопределение сеанса" + scope_global: "глобальная конфигурация" + status: "🧠 **Настройки рассуждений**\n\n**Усилия:** `{level}`\n**Область:** {scope}\n**Отображение:** {display}\n\n_Использование:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "включено ✓" + display_off: "выключено" + display_set_on: "🧠 ✓ Отображение рассуждений: **ВКЛ.**\nМысли модели будут показываться перед каждым ответом на **{platform}**." + display_set_off: "🧠 ✓ Отображение рассуждений: **ВЫКЛ.** для **{platform}**" + reset_global_unsupported: "⚠️ `/reasoning reset --global` не поддерживается. Используйте `/reasoning <level> --global`, чтобы изменить глобальное значение по умолчанию." + reset_done: "🧠 ✓ Переопределение рассуждений для сеанса сброшено; возврат к глобальной конфигурации." + unknown_arg: "⚠️ Неизвестный аргумент: `{arg}`\n\n**Допустимые уровни:** none, minimal, low, medium, high, xhigh\n**Отображение:** show, hide\n**Сохранение:** добавьте `--global`, чтобы сохранить за пределами этого сеанса" + set_global: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (сохранено в конфигурации)\n_(вступит в силу со следующего сообщения)_" + set_global_save_failed: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (только этот сеанс — не удалось сохранить конфигурацию)\n_(вступит в силу со следующего сообщения)_" + set_session: "🧠 ✓ Усилия рассуждений установлены на `{effort}` (только этот сеанс — добавьте `--global`, чтобы сохранить)\n_(вступит в силу со следующего сообщения)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp отменено. MCP-инструменты без изменений." + always_followup: "ℹ️ Будущие вызовы `/reload-mcp` будут выполняться без подтверждения. Снова включить можно через `approvals.mcp_reload_confirm: true` в config.yaml." + confirm_prompt: "⚠️ **Подтверждение /reload-mcp**\n\nПерезагрузка MCP-серверов перестраивает набор инструментов для этого сеанса и **сбрасывает кеш промпта провайдера** — следующее сообщение повторно отправит все входные токены. На моделях с длинным контекстом или высоким уровнем рассуждений это может быть дорого.\n\nВыберите:\n• **Одобрить один раз** — перезагрузить сейчас\n• **Всегда одобрять** — перезагрузить и навсегда отключить этот запрос\n• **Отменить** — оставить MCP-инструменты без изменений\n\n_Текстовая альтернатива: ответьте `/approve`, `/always` или `/cancel`._" + header: "🔄 **MCP-серверы перезагружены**\n" + reconnected: "♻️ Переподключено: {names}" + added: "➕ Добавлено: {names}" + removed: "➖ Удалено: {names}" + none_connected: "Нет подключённых MCP-серверов." + tools_available: "\n🔧 {tools} инструмент(ов) доступно с {servers} сервер(ов)" + failed: "❌ Ошибка перезагрузки MCP: {error}" + + reload_skills: + header: "🔄 **Навыки перезагружены**\n" + no_new: "Новых навыков не обнаружено." + total: "\n📚 {count} навык(ов) доступно" + added_header: "➕ **Добавленные навыки:**" + removed_header: "➖ **Удалённые навыки:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ Ошибка перезагрузки навыков: {error}" + + reset: + header_default: "✨ Сеанс сброшен! Начинаем с чистого листа." + header_new: "✨ Новый сеанс запущен!" + header_titled: "✨ Новый сеанс запущен: {title}" + title_rejected: "\n⚠️ Название отклонено: {error}" + title_error_untitled: "\n⚠️ {error} — сеанс запущен без названия." + title_empty_untitled: "\n⚠️ После очистки название пусто — сеанс запущен без названия." + tip: "\n✦ Совет: {tip}" + + restart: + in_progress: "⏳ Перезапуск шлюза уже выполняется..." + restarting: "♻ Перезапуск шлюза. Если уведомление не придёт в течение 60 секунд, перезапустите из консоли командой `hermes gateway restart`." + + resume: + db_unavailable: "База данных сеансов недоступна." + no_named_sessions: "Именованных сеансов не найдено.\nИспользуйте `/title Мой сеанс`, чтобы назвать текущий сеанс, затем `/resume Мой сеанс`, чтобы вернуться к нему позже." + list_header: "📋 **Именованные сеансы**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nИспользование: `/resume <название сеанса>`" + list_failed: "Не удалось получить список сеансов: {error}" + not_found: "Сеанс, соответствующий '**{name}**', не найден.\nИспользуйте `/resume` без аргументов, чтобы увидеть доступные сеансы." + already_on: "📌 Уже в сеансе **{name}**." + switch_failed: "Не удалось переключить сеанс." + resumed_one: "↻ Сеанс **{title}** возобновлён ({count} сообщение). Беседа восстановлена." + resumed_many: "↻ Сеанс **{title}** возобновлён ({count} сообщений). Беседа восстановлена." + resumed_no_count: "↻ Сеанс **{title}** возобновлён. Беседа восстановлена." + + retry: + no_previous: "Нет предыдущего сообщения для повтора." + + rollback: + not_enabled: "Контрольные точки не включены.\nВключите в config.yaml:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "Контрольных точек для {cwd} не найдено" + invalid_number: "Недействительный номер контрольной точки. Используйте 1-{max}." + restored: "✅ Восстановлено до контрольной точки {hash}: {reason}\nСнимок перед откатом сохранён автоматически." + restore_failed: "❌ {error}" + + set_home: + save_failed: "Не удалось сохранить главный канал: {error}" + success: "✅ Главный канал установлен на **{name}** (ID: {chat_id}).\nCron-задачи и межплатформенные сообщения будут доставляться сюда." + + status: + header: "📊 **Состояние Hermes Gateway**" + session_id: "**ID сеанса:** `{session_id}`" + title: "**Название:** {title}" + created: "**Создано:** {timestamp}" + last_activity: "**Последняя активность:** {timestamp}" + tokens: "**Токены:** {tokens}" + agent_running: "**Агент активен:** {state}" + state_yes: "Да ⚡" + state_no: "Нет" + queued: "**Очередь продолжений:** {count}" + platforms: "**Подключённые платформы:** {platforms}" + + stop: + stopped_pending: "⚡ Остановлено. Агент ещё не начинал — вы можете продолжить этот сеанс." + stopped: "⚡ Остановлено. Вы можете продолжить этот сеанс." + no_active: "Нет активной задачи для остановки." + + title: + db_unavailable: "База данных сеансов недоступна." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ После очистки название пусто. Используйте печатные символы." + set_to: "✏️ Название сеанса установлено: **{title}**" + not_found: "Сеанс не найден в базе данных." + current_with_title: "📌 Сеанс: `{session_id}`\nНазвание: **{title}**" + current_no_title: "📌 Сеанс: `{session_id}`\nНазвание не задано. Использование: `/title Название моего сеанса`" + + topic: + not_telegram_dm: "Команда /topic доступна только в личных чатах Telegram." + no_session_db: "База данных сеансов недоступна." + unauthorized: "У вас нет прав использовать /topic в этом боте." + restore_needs_topic: "Чтобы восстановить сеанс, сначала создайте или откройте Telegram topic, затем отправьте /topic <session-id> в этом topic. Чтобы создать новый topic, откройте All Messages и отправьте там любое сообщение." + topics_disabled: "Telegram topics ещё не включены для этого бота.\n\nКак включить:\n1. Откройте @BotFather.\n2. Выберите своего бота.\n3. Откройте Bot Settings → Threads Settings.\n4. Включите Threaded Mode и убедитесь, что пользователям разрешено создавать новые threads.\n\nЗатем снова отправьте /topic." + topics_user_disallowed: "Telegram topics включены, но пользователям не разрешено создавать topics.\n\nОткройте @BotFather → выберите своего бота → Bot Settings → Threads Settings, затем выключите 'Disallow users to create new threads'.\n\nЗатем снова отправьте /topic." + enable_failed: "Не удалось включить режим Telegram topic: {error}" + bound_status: "Этот topic привязан к:\nСеанс: {label}\nID: {session_id}\n\nИспользуйте /new, чтобы заменить этот topic новым сеансом.\nДля параллельной работы откройте All Messages и отправьте там сообщение, чтобы создать другой topic." + thread_ready: "Многосеансовые Telegram topics включены.\n\nЭтот topic будет использоваться как независимый сеанс Hermes. Используйте /new, чтобы заменить текущий сеанс этого topic. Для параллельной работы откройте All Messages и отправьте там сообщение, чтобы создать другой topic." + untitled_session: "Сеанс без названия" + + undo: + nothing: "Нечего отменять." + removed: "↩️ Отменено сообщений: {count}.\nУдалено: «{preview}»" + + update: + platform_not_messaging: "✗ /update доступен только на платформах обмена сообщениями. Выполните `hermes update` в терминале." + not_git_repo: "✗ Не git-репозиторий — обновление невозможно." + hermes_cmd_not_found: "✗ Не удалось найти команду `hermes`. Hermes запущен, но команда обновления не нашла исполняемый файл в PATH или через текущий интерпретатор Python. Попробуйте выполнить `hermes update` вручную в терминале." + start_failed: "✗ Не удалось запустить обновление: {error}" + starting: "⚕ Запуск обновления Hermes… Я буду транслировать прогресс сюда." + + usage: + rate_limits: "⏱️ **Ограничения скорости:** {state}" + header_session: "📊 **Использование токенов сеанса**" + label_model: "Модель: `{model}`" + label_input_tokens: "Входные токены: {count}" + label_cache_read: "Токены чтения кеша: {count}" + label_cache_write: "Токены записи кеша: {count}" + label_output_tokens: "Выходные токены: {count}" + label_total: "Всего: {count}" + label_api_calls: "Вызовы API: {count}" + label_cost: "Стоимость: {prefix}${amount}" + label_cost_included: "Стоимость: включено" + label_context: "Контекст: {used} / {total} ({pct}%)" + label_compressions: "Сжатий: {count}" + header_session_info: "📊 **Информация о сеансе**" + label_messages: "Сообщений: {count}" + label_estimated_context: "Ориентировочный контекст: ~{count} токенов" + detailed_after_first: "_(Подробное использование доступно после первого ответа агента)_" + no_data: "Данные об использовании для этого сеанса отсутствуют." + + verbose: + not_enabled: "Команда `/verbose` не включена для платформ обмена сообщениями.\n\nВключите в `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Прогресс инструментов: **OFF** — активность инструментов не показывается." + mode_new: "⚙️ Прогресс инструментов: **NEW** — показывается при смене инструмента (длина предпросмотра: `display.tool_preview_length`, по умолчанию 40)." + mode_all: "⚙️ Прогресс инструментов: **ALL** — показывается каждый вызов инструмента (длина предпросмотра: `display.tool_preview_length`, по умолчанию 40)." + mode_verbose: "⚙️ Прогресс инструментов: **VERBOSE** — каждый вызов инструмента с полными аргументами." + saved_suffix: "_(сохранено для **{platform}** — вступит в силу со следующего сообщения)_" + save_failed: "_(не удалось сохранить в конфигурацию: {error})_" + + voice: + enabled_voice_only: "Голосовой режим включён.\nЯ буду отвечать голосом, когда вы отправляете голосовые сообщения.\nИспользуйте /voice tts, чтобы получать голосовые ответы на все сообщения." + disabled_text: "Голосовой режим отключён. Только текстовые ответы." + tts_enabled: "Авто-TTS включён.\nВсе ответы будут содержать голосовое сообщение." + status_mode: "Голосовой режим: {label}" + status_channel: "Голосовой канал: #{channel}" + status_participants: "Участники: {count}" + status_member: " - {name}{status}" + speaking: " (говорит)" + enabled_short: "Голосовой режим включён." + disabled_short: "Голосовой режим отключён." + label_off: "Выкл. (только текст)" + label_voice_only: "Вкл. (голосовой ответ на голосовые сообщения)" + label_all: "TTS (голосовой ответ на все сообщения)" + + yolo: + disabled: "⚠️ Режим YOLO для этого сеанса **ОТКЛЮЧЁН** — опасные команды потребуют одобрения." + enabled: "⚡ Режим YOLO для этого сеанса **ВКЛЮЧЁН** — все команды одобряются автоматически. Используйте с осторожностью." + + shared: + session_db_unavailable: "База данных сеансов недоступна." + session_db_unavailable_prefix: "База данных сеансов недоступна" + session_not_found: "Сеанс не найден в базе данных." + warn_passthrough: "⚠️ {error}" diff --git a/locales/tr.yaml b/locales/tr.yaml index cdaf0ad70e4..012854c51b3 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -22,3 +22,329 @@ gateway: no_active_goal: "Aktif hedef yok." config_read_failed: "⚠️ config.yaml okunamadı: {error}" config_save_failed: "⚠️ Yapılandırma kaydedilemedi: {error}" + + model: + error_prefix: "Hata: {error}" + switched: "Model `{model}` olarak değiştirildi" + provider_label: "Sağlayıcı: {provider}" + context_label: "Bağlam: {tokens} token" + max_output_label: "Maks. çıktı: {tokens} token" + cost_label: "Maliyet: {cost}" + capabilities_label: "Yetenekler: {capabilities}" + prompt_caching_enabled: "Prompt önbelleği: etkin" + warning_prefix: "Uyarı: {warning}" + saved_global: "config.yaml'a kaydedildi (`--global`)" + session_only_hint: "_(yalnızca bu oturum — kalıcı yapmak için `--global` ekleyin)_" + current_label: "Geçerli: `{model}` ({provider})" + current_tag: " (geçerli)" + more_models_suffix: " (+{count} tane daha)" + usage_switch_model: "`/model <name>` — modeli değiştir" + usage_switch_provider: "`/model <name> --provider <slug>` — sağlayıcıyı değiştir" + usage_persist: "`/model <name> --global` — kalıcı kaydet" + + agents: + header: "🤖 **Aktif Ajanlar ve Görevler**" + active_agents: "**Aktif ajanlar:** {count}" + this_chat: " · bu sohbet" + more: "... ve {count} tane daha" + running_processes: "**Çalışan arka plan süreçleri:** {count}" + async_jobs: "**Gateway asenkron işleri:** {count}" + none: "Aktif ajan veya çalışan görev yok." + state_starting: "başlatılıyor" + state_running: "çalışıyor" + + approve: + no_pending: "Onaylanacak bekleyen komut yok." + once_singular: "✅ Komut onaylandı. Ajan devam ediyor..." + once_plural: "✅ Komutlar onaylandı ({count} komut). Ajan devam ediyor..." + session_singular: "✅ Komut onaylandı (desen bu oturum için onaylandı). Ajan devam ediyor..." + session_plural: "✅ Komutlar onaylandı (desen bu oturum için onaylandı) ({count} komut). Ajan devam ediyor..." + always_singular: "✅ Komut onaylandı (desen kalıcı olarak onaylandı). Ajan devam ediyor..." + always_plural: "✅ Komutlar onaylandı (desen kalıcı olarak onaylandı) ({count} komut). Ajan devam ediyor..." + + background: + usage: "Kullanım: /background <prompt>\nÖrnek: /background Bugünün öne çıkan HN haberlerini özetle\n\nİstemi ayrı bir oturumda çalıştırır. Sohbete devam edebilirsin — sonuç tamamlandığında burada görünecek." + started: "🔄 Arka plan görevi başlatıldı: \"{preview}\"\nGörev kimliği: {task_id}\nSohbete devam edebilirsin — sonuçlar tamamlandığında burada görünecek." + + branch: + db_unavailable: "Oturum veritabanı kullanılamıyor." + no_conversation: "Dallandırılacak konuşma yok — önce bir mesaj gönderin." + create_failed: "Dal oluşturulamadı: {error}" + switch_failed: "Dal oluşturuldu ancak ona geçilemedi." + branched_one: "⑂ **{title}** dalına geçildi ({count} mesaj kopyalandı)\nOrijinal: `{parent}`\nDal: `{new}`\nOrijinale geri dönmek için `/resume` kullanın." + branched_many: "⑂ **{title}** dalına geçildi ({count} mesaj kopyalandı)\nOrijinal: `{parent}`\nDal: `{new}`\nOrijinale geri dönmek için `/resume` kullanın." + + commands: + usage: "Kullanım: `/commands [page]`" + skill_header: "⚡ **Skill Komutları**:" + default_desc: "Skill komutu" + none: "Kullanılabilir komut yok." + header: "📚 **Komutlar** (toplam {total}, sayfa {page}/{total_pages})" + nav_prev: "`/commands {page}` ← önceki" + nav_next: "sonraki → `/commands {page}`" + out_of_range: "_(İstenen sayfa {requested} aralık dışındaydı, sayfa {page} gösteriliyor.)_" + + compress: + not_enough: "Sıkıştırmak için yeterli konuşma yok (en az 4 mesaj gerekli)." + no_provider: "Yapılandırılmış sağlayıcı yok — sıkıştırılamıyor." + nothing_to_do: "Henüz sıkıştırılacak bir şey yok (transkript hâlâ tamamen korunan bağlam)." + focus_line: "Odak: \"{topic}\"" + summary_failed: "⚠️ Özet oluşturma başarısız ({error}). {count} geçmiş mesaj kaldırılıp yer tutucuyla değiştirildi; önceki bağlam artık kurtarılamaz. auxiliary.compression model yapılandırmanızı kontrol edin." + aux_failed: "ℹ️ Yapılandırılmış sıkıştırma modeli `{model}` başarısız oldu ({error}). Ana modelinizle kurtarıldı — bağlam sağlam — ancak config.yaml içindeki `auxiliary.compression.model` öğesini kontrol etmek isteyebilirsiniz." + failed: "Sıkıştırma başarısız: {error}" + + debug: + upload_failed: "✗ Hata ayıklama raporu yüklenemedi: {error}" + header: "**Hata ayıklama raporu yüklendi:**" + auto_delete: "⏱ Paste'ler 6 saat içinde otomatik olarak silinecek." + full_logs_hint: "Tam günlük yüklemeleri için CLI'dan `hermes debug share` kullanın." + share_hint: "Destek için bu bağlantıları Hermes ekibiyle paylaşın." + + deny: + stale: "❌ Komut reddedildi (onay geçersizdi)." + no_pending: "Reddedilecek bekleyen komut yok." + denied_singular: "❌ Komut reddedildi." + denied_plural: "❌ Komutlar reddedildi ({count} komut)." + + fast: + not_supported: "⚡ /fast yalnızca Priority Processing destekleyen OpenAI modellerinde kullanılabilir." + status: "⚡ Priority Processing\n\nMevcut mod: `{mode}`\n\n_Kullanım:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Bilinmeyen argüman: `{arg}`\n\n**Geçerli seçenekler:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (yapılandırmaya kaydedildi)\n_(sonraki mesajda geçerli olur)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (yalnızca bu oturum)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Çalışma zamanı altbilgisi: **{state}**\nAlanlar: `{fields}`\nPlatform: `{platform}`" + usage: "Kullanım: `/footer [on|off|status]`" + saved: "📎 Çalışma zamanı altbilgisi: **{state}**{example}\n_(genel olarak kaydedildi — sonraki mesajda geçerli olur)_" + example_line: "\nÖrnek: `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "Bu oturumda hedefler kullanılamıyor." + no_goal_set: "Hedef ayarlanmadı." + paused: "⏸ Hedef duraklatıldı: {goal}" + no_resume: "Devam ettirilecek hedef yok." + resumed: "▶ Hedef devam ettirildi: {goal}\nDevam etmek için herhangi bir mesaj gönderin veya bekleyin — bir sonraki turda adımı atacağım." + invalid: "Geçersiz hedef: {error}" + set: "⊙ Hedef ayarlandı ({budget} turluk bütçe): {goal}\nHedef tamamlanana, siz duraklatana/temizleyene veya bütçe tükenene kadar çalışmaya devam edeceğim.\nKontroller: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Hermes Komutları**\n" + skill_header: "\n⚡ **Skill Komutları** ({count} aktif):" + more_use_commands: "\n... ve {count} tane daha. Tam sayfalı liste için `/commands` kullanın." + + insights: + invalid_days: "Geçersiz --days değeri: {value}" + error: "Analiz oluşturulurken hata: {error}" + + kanban: + error_prefix: "⚠ kanban hatası: {error}" + subscribed_suffix: "(abone olundu — {task_id} tamamlandığında veya engellendiğinde bildirim alacaksınız)" + truncated_suffix: "… (kısaltıldı; tam çıktı için terminalinizde `hermes kanban …` komutunu kullanın)" + no_output: "(çıktı yok)" + + personality: + none_configured: "`{path}/config.yaml` içinde yapılandırılmış kişilik yok" + header: "🎭 **Mevcut Kişilikler**\n" + none_option: "• `none` — (kişilik kaplaması yok)" + item: "• `{name}` — {preview}" + usage: "\nKullanım: `/personality <name>`" + save_failed: "⚠️ Kişilik değişikliği kaydedilemedi: {error}" + cleared: "🎭 Kişilik temizlendi — temel ajan davranışı kullanılıyor.\n_(bir sonraki mesajda etkili olur)_" + set_to: "🎭 Kişilik **{name}** olarak ayarlandı\n_(bir sonraki mesajda etkili olur)_" + unknown: "Bilinmeyen kişilik: `{name}`\n\nMevcut: {available}" + + profile: + header: "👤 **Profil:** `{profile}`" + home: "📂 **Ana dizin:** `{home}`" + + reasoning: + level_default: "medium (varsayılan)" + level_disabled: "none (devre dışı)" + scope_session: "oturum geçersiz kılma" + scope_global: "genel yapılandırma" + status: "🧠 **Akıl Yürütme Ayarları**\n\n**Güç:** `{level}`\n**Kapsam:** {scope}\n**Görüntüleme:** {display}\n\n_Kullanım:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "açık ✓" + display_off: "kapalı" + display_set_on: "🧠 ✓ Akıl yürütme görüntüleme: **AÇIK**\n**{platform}** üzerinde her yanıttan önce modelin düşüncesi gösterilecek." + display_set_off: "🧠 ✓ **{platform}** için akıl yürütme görüntüleme: **KAPALI**" + reset_global_unsupported: "⚠️ `/reasoning reset --global` desteklenmiyor. Genel varsayılanı değiştirmek için `/reasoning <level> --global` kullanın." + reset_done: "🧠 ✓ Oturumun akıl yürütme geçersiz kılması temizlendi; genel yapılandırmaya geri dönülüyor." + unknown_arg: "⚠️ Bilinmeyen argüman: `{arg}`\n\n**Geçerli seviyeler:** none, minimal, low, medium, high, xhigh\n**Görüntüleme:** show, hide\n**Kalıcı:** bu oturumun ötesinde kaydetmek için `--global` ekleyin" + set_global: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yapılandırmaya kaydedildi)\n_(sonraki mesajda etkili)_" + set_global_save_failed: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yalnızca bu oturum — yapılandırma kaydedilemedi)\n_(sonraki mesajda etkili)_" + set_session: "🧠 ✓ Akıl yürütme gücü `{effort}` olarak ayarlandı (yalnızca bu oturum — kalıcı yapmak için `--global` ekleyin)\n_(sonraki mesajda etkili)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp iptal edildi. MCP araçları değiştirilmedi." + always_followup: "ℹ️ Bundan sonraki `/reload-mcp` çağrıları onaysız çalışacak. `config.yaml` içinde `approvals.mcp_reload_confirm: true` ile yeniden etkinleştirebilirsiniz." + confirm_prompt: "⚠️ **/reload-mcp Onayı**\n\nMCP sunucularını yeniden yüklemek bu oturumdaki araç kümesini yeniden oluşturur ve **sağlayıcı prompt önbelleğini geçersiz kılar** — bir sonraki mesaj tüm giriş token'larını yeniden gönderir. Uzun bağlam veya yüksek akıl yürütmeli modellerde bu maliyetli olabilir.\n\nSeçim yapın:\n• **Bir Kez Onayla** — şimdi yeniden yükle\n• **Her Zaman Onayla** — şimdi yeniden yükle ve bu onayı kalıcı olarak sustur\n• **İptal** — MCP araçlarını değiştirme\n\n_Metin alternatifi: `/approve`, `/always` veya `/cancel` ile yanıtlayın._" + header: "🔄 **MCP Sunucuları Yeniden Yüklendi**\n" + reconnected: "♻️ Yeniden bağlanan: {names}" + added: "➕ Eklenen: {names}" + removed: "➖ Kaldırılan: {names}" + none_connected: "Bağlı MCP sunucusu yok." + tools_available: "\n🔧 {servers} sunucudan {tools} araç kullanılabilir" + failed: "❌ MCP yeniden yükleme başarısız: {error}" + + reload_skills: + header: "🔄 **Beceriler Yeniden Yüklendi**\n" + no_new: "Yeni beceri tespit edilmedi." + total: "\n📚 {count} beceri kullanılabilir" + added_header: "➕ **Eklenen Beceriler:**" + removed_header: "➖ **Kaldırılan Beceriler:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ Beceri yeniden yükleme başarısız: {error}" + + reset: + header_default: "✨ Oturum sıfırlandı! Yeniden başlıyoruz." + header_new: "✨ Yeni oturum başlatıldı!" + header_titled: "✨ Yeni oturum başlatıldı: {title}" + title_rejected: "\n⚠️ Başlık reddedildi: {error}" + title_error_untitled: "\n⚠️ {error} — oturum başlıksız başlatıldı." + title_empty_untitled: "\n⚠️ Temizlik sonrası başlık boş — oturum başlıksız başlatıldı." + tip: "\n✦ İpucu: {tip}" + + restart: + in_progress: "⏳ Gateway yeniden başlatma zaten sürüyor..." + restarting: "♻ Gateway yeniden başlatılıyor. 60 saniye içinde bildirim almazsanız konsoldan `hermes gateway restart` ile yeniden başlatın." + + resume: + db_unavailable: "Oturum veritabanı kullanılamıyor." + no_named_sessions: "Adlandırılmış oturum bulunamadı.\nMevcut oturumu adlandırmak için `/title Oturumum`, daha sonra geri dönmek için `/resume Oturumum` kullanın." + list_header: "📋 **Adlandırılmış Oturumlar**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nKullanım: `/resume <oturum adı>`" + list_failed: "Oturumlar listelenemedi: {error}" + not_found: "'**{name}**' ile eşleşen oturum bulunamadı.\nKullanılabilir oturumları görmek için argümansız `/resume` kullanın." + already_on: "📌 Zaten **{name}** oturumundasınız." + switch_failed: "Oturum değiştirilemedi." + resumed_one: "↻ **{title}** oturumu sürdürüldü ({count} mesaj). Konuşma geri yüklendi." + resumed_many: "↻ **{title}** oturumu sürdürüldü ({count} mesaj). Konuşma geri yüklendi." + resumed_no_count: "↻ **{title}** oturumu sürdürüldü. Konuşma geri yüklendi." + + retry: + no_previous: "Yeniden denenecek önceki mesaj yok." + + rollback: + not_enabled: "Kontrol noktaları etkin değil.\nconfig.yaml içinde etkinleştirin:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "{cwd} için kontrol noktası bulunamadı" + invalid_number: "Geçersiz kontrol noktası numarası. 1-{max} aralığını kullanın." + restored: "✅ {hash} kontrol noktasına geri yüklendi: {reason}\nGeri alma öncesi anlık görüntü otomatik olarak kaydedildi." + restore_failed: "❌ {error}" + + set_home: + save_failed: "Ana kanal kaydedilemedi: {error}" + success: "✅ Ana kanal **{name}** (ID: {chat_id}) olarak ayarlandı.\nCron işleri ve platformlar arası mesajlar buraya iletilecek." + + status: + header: "📊 **Hermes Gateway Durumu**" + session_id: "**Oturum kimliği:** `{session_id}`" + title: "**Başlık:** {title}" + created: "**Oluşturuldu:** {timestamp}" + last_activity: "**Son etkinlik:** {timestamp}" + tokens: "**Token:** {tokens}" + agent_running: "**Aracı çalışıyor:** {state}" + state_yes: "Evet ⚡" + state_no: "Hayır" + queued: "**Sıradaki devam:** {count}" + platforms: "**Bağlı platformlar:** {platforms}" + + stop: + stopped_pending: "⚡ Durduruldu. Ajan henüz başlamamıştı — bu oturuma devam edebilirsin." + stopped: "⚡ Durduruldu. Bu oturuma devam edebilirsin." + no_active: "Durdurulacak aktif görev yok." + + title: + db_unavailable: "Oturum veritabanı kullanılamıyor." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ Temizlemeden sonra başlık boş. Lütfen yazdırılabilir karakterler kullanın." + set_to: "✏️ Oturum başlığı ayarlandı: **{title}**" + not_found: "Oturum veritabanında bulunamadı." + current_with_title: "📌 Oturum: `{session_id}`\nBaşlık: **{title}**" + current_no_title: "📌 Oturum: `{session_id}`\nBaşlık ayarlanmamış. Kullanım: `/title Oturum Adım`" + + topic: + not_telegram_dm: "/topic komutu yalnızca Telegram özel sohbetlerinde kullanılabilir." + no_session_db: "Oturum veritabanı kullanılamıyor." + unauthorized: "Bu bot üzerinde /topic kullanma yetkiniz yok." + restore_needs_topic: "Bir oturumu geri yüklemek için önce bir Telegram topic oluşturun veya açın, ardından o topic içinde /topic <session-id> gönderin. Yeni bir topic oluşturmak için All Messages'ı açıp orada herhangi bir mesaj gönderin." + topics_disabled: "Bu bot için Telegram topic'leri henüz etkin değil.\n\nNasıl etkinleştirilir:\n1. @BotFather'ı açın.\n2. Botunuzu seçin.\n3. Bot Settings → Threads Settings'ı açın.\n4. Threaded Mode'u açın ve kullanıcıların yeni thread oluşturmasına izin verildiğinden emin olun.\n\nArdından /topic'i tekrar gönderin." + topics_user_disallowed: "Telegram topic'leri etkin, ancak kullanıcıların topic oluşturmasına izin verilmiyor.\n\n@BotFather → botunuz → Bot Settings → Threads Settings yolunu açın ve 'Disallow users to create new threads' seçeneğini kapatın.\n\nArdından /topic'i tekrar gönderin." + enable_failed: "Telegram topic modu etkinleştirilemedi: {error}" + bound_status: "Bu topic şuna bağlı:\nOturum: {label}\nID: {session_id}\n\nBu topic'i yeni bir oturumla değiştirmek için /new kullanın.\nParalel çalışma için All Messages'ı açıp orada bir mesaj göndererek başka bir topic oluşturun." + thread_ready: "Telegram çok oturumlu topic'leri etkin.\n\nBu topic bağımsız bir Hermes oturumu olarak kullanılacak. Bu topic'in mevcut oturumunu değiştirmek için /new kullanın. Paralel çalışma için All Messages'ı açıp orada bir mesaj göndererek başka bir topic oluşturun." + untitled_session: "Adsız oturum" + + undo: + nothing: "Geri alınacak bir şey yok." + removed: "↩️ {count} mesaj geri alındı.\nKaldırıldı: \"{preview}\"" + + update: + platform_not_messaging: "✗ /update yalnızca mesajlaşma platformlarında kullanılabilir. Terminalden `hermes update` komutunu çalıştırın." + not_git_repo: "✗ Git deposu değil — güncellenemiyor." + hermes_cmd_not_found: "✗ `hermes` komutu bulunamadı. Hermes çalışıyor, ancak güncelleme komutu yürütülebilir dosyayı PATH'te veya mevcut Python yorumlayıcısı aracılığıyla bulamadı. Terminalde `hermes update` komutunu manuel olarak çalıştırmayı deneyin." + start_failed: "✗ Güncelleme başlatılamadı: {error}" + starting: "⚕ Hermes güncellemesi başlatılıyor… İlerlemeyi buraya akıtacağım." + + usage: + rate_limits: "⏱️ **Hız Sınırları:** {state}" + header_session: "📊 **Oturum Token Kullanımı**" + label_model: "Model: `{model}`" + label_input_tokens: "Girdi token'ları: {count}" + label_cache_read: "Önbellek okuma token'ları: {count}" + label_cache_write: "Önbellek yazma token'ları: {count}" + label_output_tokens: "Çıktı token'ları: {count}" + label_total: "Toplam: {count}" + label_api_calls: "API çağrıları: {count}" + label_cost: "Maliyet: {prefix}${amount}" + label_cost_included: "Maliyet: dahil" + label_context: "Bağlam: {used} / {total} ({pct}%)" + label_compressions: "Sıkıştırmalar: {count}" + header_session_info: "📊 **Oturum Bilgisi**" + label_messages: "Mesajlar: {count}" + label_estimated_context: "Tahmini bağlam: ~{count} token" + detailed_after_first: "_(Ayrıntılı kullanım, ilk ajan yanıtından sonra kullanılabilir)_" + no_data: "Bu oturum için kullanım verisi yok." + + verbose: + not_enabled: "`/verbose` komutu mesajlaşma platformlarında etkin değil.\n\n`config.yaml` içinde etkinleştirin:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Araç ilerlemesi: **OFF** — araç etkinliği gösterilmez." + mode_new: "⚙️ Araç ilerlemesi: **NEW** — araç değiştiğinde gösterilir (önizleme uzunluğu: `display.tool_preview_length`, varsayılan 40)." + mode_all: "⚙️ Araç ilerlemesi: **ALL** — her araç çağrısı gösterilir (önizleme uzunluğu: `display.tool_preview_length`, varsayılan 40)." + mode_verbose: "⚙️ Araç ilerlemesi: **VERBOSE** — her araç çağrısı tüm argümanlarıyla gösterilir." + saved_suffix: "_(**{platform}** için kaydedildi — sonraki mesajda geçerli olur)_" + save_failed: "_(yapılandırmaya kaydedilemedi: {error})_" + + voice: + enabled_voice_only: "Sesli mod etkinleştirildi.\nSesli mesaj gönderdiğinizde sesli yanıt vereceğim.\nTüm mesajlara sesli yanıt almak için /voice tts kullanın." + disabled_text: "Sesli mod devre dışı. Yalnızca metin yanıtları." + tts_enabled: "Otomatik TTS etkinleştirildi.\nTüm yanıtlar bir sesli mesaj içerecek." + status_mode: "Sesli mod: {label}" + status_channel: "Ses kanalı: #{channel}" + status_participants: "Katılımcılar: {count}" + status_member: " - {name}{status}" + speaking: " (konuşuyor)" + enabled_short: "Sesli mod etkinleştirildi." + disabled_short: "Sesli mod devre dışı." + label_off: "Kapalı (yalnızca metin)" + label_voice_only: "Açık (sesli mesajlara sesli yanıt)" + label_all: "TTS (tüm mesajlara sesli yanıt)" + + yolo: + disabled: "⚠️ Bu oturumda YOLO modu **KAPALI** — tehlikeli komutlar onay gerektirecek." + enabled: "⚡ Bu oturumda YOLO modu **AÇIK** — tüm komutlar otomatik onaylanır. Dikkatli kullanın." + + shared: + session_db_unavailable: "Oturum veritabanı kullanılamıyor." + session_db_unavailable_prefix: "Oturum veritabanı kullanılamıyor" + session_not_found: "Oturum veritabanında bulunamadı." + warn_passthrough: "⚠️ {error}" diff --git a/locales/uk.yaml b/locales/uk.yaml index fce0dc0a6f8..44b011cfe83 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -22,3 +22,329 @@ gateway: no_active_goal: "Немає активної цілі." config_read_failed: "⚠️ Не вдалося прочитати config.yaml: {error}" config_save_failed: "⚠️ Не вдалося зберегти конфігурацію: {error}" + + model: + error_prefix: "Помилка: {error}" + switched: "Модель змінено на `{model}`" + provider_label: "Провайдер: {provider}" + context_label: "Контекст: {tokens} токенів" + max_output_label: "Макс. вихід: {tokens} токенів" + cost_label: "Вартість: {cost}" + capabilities_label: "Можливості: {capabilities}" + prompt_caching_enabled: "Кешування промптів: увімкнено" + warning_prefix: "Попередження: {warning}" + saved_global: "Збережено в config.yaml (`--global`)" + session_only_hint: "_(лише для цього сеансу — додайте `--global`, щоб зберегти)_" + current_label: "Поточна: `{model}` на {provider}" + current_tag: " (поточна)" + more_models_suffix: " (+{count} ще)" + usage_switch_model: "`/model <name>` — змінити модель" + usage_switch_provider: "`/model <name> --provider <slug>` — змінити провайдера" + usage_persist: "`/model <name> --global` — зберегти назавжди" + + agents: + header: "🤖 **Активні агенти та завдання**" + active_agents: "**Активні агенти:** {count}" + this_chat: " · цей чат" + more: "... і ще {count}" + running_processes: "**Фонові процеси, що виконуються:** {count}" + async_jobs: "**Асинхронні задачі гейтвея:** {count}" + none: "Немає активних агентів або задач." + state_starting: "запускається" + state_running: "виконується" + + approve: + no_pending: "Немає команди на схвалення." + once_singular: "✅ Команду схвалено. Агент відновлює роботу…" + once_plural: "✅ Команди схвалено ({count} команд). Агент відновлює роботу…" + session_singular: "✅ Команду схвалено (шаблон схвалено для цього сеансу). Агент відновлює роботу…" + session_plural: "✅ Команди схвалено (шаблон схвалено для цього сеансу) ({count} команд). Агент відновлює роботу…" + always_singular: "✅ Команду схвалено (шаблон схвалено назавжди). Агент відновлює роботу…" + always_plural: "✅ Команди схвалено (шаблон схвалено назавжди) ({count} команд). Агент відновлює роботу…" + + background: + usage: "Використання: /background <запит>\nПриклад: /background Підсумуй найкращі історії з HN сьогодні\n\nЗапускає запит в окремому сеансі. Можна продовжити спілкування — результат з'явиться тут після завершення." + started: "🔄 Фонове завдання запущено: «{preview}»\nID завдання: {task_id}\nМожна продовжити спілкування — результати з'являться тут після завершення." + + branch: + db_unavailable: "База даних сеансів недоступна." + no_conversation: "Немає розмови для розгалуження — спочатку надішліть повідомлення." + create_failed: "Не вдалося створити гілку: {error}" + switch_failed: "Гілку створено, але не вдалося переключитися на неї." + branched_one: "⑂ Створено гілку **{title}** (скопійовано {count} повідомлення)\nОригінал: `{parent}`\nГілка: `{new}`\nВикористайте `/resume`, щоб повернутися до оригіналу." + branched_many: "⑂ Створено гілку **{title}** (скопійовано {count} повідомлень)\nОригінал: `{parent}`\nГілка: `{new}`\nВикористайте `/resume`, щоб повернутися до оригіналу." + + commands: + usage: "Використання: `/commands [page]`" + skill_header: "⚡ **Команди навичок**:" + default_desc: "Команда навички" + none: "Немає доступних команд." + header: "📚 **Команди** (всього {total}, сторінка {page}/{total_pages})" + nav_prev: "`/commands {page}` ← попередня" + nav_next: "наступна → `/commands {page}`" + out_of_range: "_(Запитана сторінка {requested} поза межами, показано сторінку {page}.)_" + + compress: + not_enough: "Недостатньо розмови для стиснення (потрібно щонайменше 4 повідомлення)." + no_provider: "Постачальника не налаштовано — неможливо стиснути." + nothing_to_do: "Поки що немає що стискати (стенограма все ще є повністю захищеним контекстом)." + focus_line: "Фокус: \"{topic}\"" + summary_failed: "⚠️ Не вдалося згенерувати зведення ({error}). {count} історичних повідомлень було видалено та замінено заповнювачем; попередній контекст більше не можна відновити. Перевірте конфігурацію моделі auxiliary.compression." + aux_failed: "ℹ️ Налаштована модель стиснення `{model}` зазнала збою ({error}). Відновлено за допомогою основної моделі — контекст не пошкоджений — але варто перевірити `auxiliary.compression.model` у config.yaml." + failed: "Стиснення не вдалося: {error}" + + debug: + upload_failed: "✗ Не вдалося завантажити звіт налагодження: {error}" + header: "**Звіт налагодження завантажено:**" + auto_delete: "⏱ Вставки автоматично видаляться через 6 годин." + full_logs_hint: "Щоб завантажити повні журнали, використайте `hermes debug share` з CLI." + share_hint: "Поділіться цими посиланнями з командою Hermes для отримання підтримки." + + deny: + stale: "❌ Команду відхилено (схвалення застаріло)." + no_pending: "Немає команди для відхилення." + denied_singular: "❌ Команду відхилено." + denied_plural: "❌ Команди відхилено ({count} команд)." + + fast: + not_supported: "⚡ /fast доступний лише для моделей OpenAI, які підтримують Priority Processing." + status: "⚡ Priority Processing\n\nПоточний режим: `{mode}`\n\n_Використання:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ Невідомий аргумент: `{arg}`\n\n**Допустимі варіанти:** normal, fast, status" + saved: "⚡ ✓ Priority Processing: **{label}** (збережено в конфігурації)\n_(набуде чинності з наступного повідомлення)_" + session_only: "⚡ ✓ Priority Processing: **{label}** (лише ця сесія)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 Нижній колонтитул середовища: **{state}**\nПоля: `{fields}`\nПлатформа: `{platform}`" + usage: "Використання: `/footer [on|off|status]`" + saved: "📎 Нижній колонтитул середовища: **{state}**{example}\n_(збережено глобально — набуде чинності з наступного повідомлення)_" + example_line: "\nПриклад: `{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "Цілі недоступні в цій сесії." + no_goal_set: "Ціль не встановлено." + paused: "⏸ Ціль призупинено: {goal}" + no_resume: "Немає цілі для продовження." + resumed: "▶ Ціль відновлено: {goal}\nНадішліть будь-яке повідомлення, щоб продовжити, або зачекайте — я зроблю наступний крок у наступному ході." + invalid: "Неприпустима ціль: {error}" + set: "⊙ Ціль встановлено (бюджет {budget} ходів): {goal}\nЯ продовжуватиму працювати, доки ціль не буде досягнута, ви її не призупините/очистите, або бюджет не вичерпається.\nКерування: /goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Команди Hermes**\n" + skill_header: "\n⚡ **Команди навичок** ({count} активних):" + more_use_commands: "\n... і ще {count}. Використайте `/commands` для повного списку зі сторінками." + + insights: + invalid_days: "Недійсне значення --days: {value}" + error: "Помилка при формуванні аналітики: {error}" + + kanban: + error_prefix: "⚠ помилка kanban: {error}" + subscribed_suffix: "(підписано — ви отримаєте сповіщення, коли {task_id} завершиться або буде заблоковано)" + truncated_suffix: "… (скорочено; використовуйте `hermes kanban …` у терміналі для повного виводу)" + no_output: "(немає виводу)" + + personality: + none_configured: "У `{path}/config.yaml` не налаштовано жодної особистості" + header: "🎭 **Доступні особистості**\n" + none_option: "• `none` — (без накладання особистості)" + item: "• `{name}` — {preview}" + usage: "\nВикористання: `/personality <name>`" + save_failed: "⚠️ Не вдалося зберегти зміну особистості: {error}" + cleared: "🎭 Особистість очищено — використовується базова поведінка агента.\n_(набуде чинності з наступного повідомлення)_" + set_to: "🎭 Особистість встановлено на **{name}**\n_(набуде чинності з наступного повідомлення)_" + unknown: "Невідома особистість: `{name}`\n\nДоступні: {available}" + + profile: + header: "👤 **Профіль:** `{profile}`" + home: "📂 **Домашня тека:** `{home}`" + + reasoning: + level_default: "medium (за замовчуванням)" + level_disabled: "none (вимкнено)" + scope_session: "перевизначення сеансу" + scope_global: "глобальна конфігурація" + status: "🧠 **Налаштування мислення**\n\n**Зусилля:** `{level}`\n**Область:** {scope}\n**Показ:** {display}\n\n_Використання:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "увімкнено ✓" + display_off: "вимкнено" + display_set_on: "🧠 ✓ Показ мислення: **УВІМКНЕНО**\nДумки моделі будуть показуватися перед кожною відповіддю на **{platform}**." + display_set_off: "🧠 ✓ Показ мислення: **ВИМКНЕНО** для **{platform}**" + reset_global_unsupported: "⚠️ `/reasoning reset --global` не підтримується. Використовуйте `/reasoning <level> --global`, щоб змінити глобальне значення за замовчуванням." + reset_done: "🧠 ✓ Перевизначення мислення для сеансу скинуто; повернення до глобальної конфігурації." + unknown_arg: "⚠️ Невідомий аргумент: `{arg}`\n\n**Дійсні рівні:** none, minimal, low, medium, high, xhigh\n**Показ:** show, hide\n**Зберегти:** додайте `--global`, щоб зберегти поза цим сеансом" + set_global: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (збережено в конфігурації)\n_(набуде чинності з наступного повідомлення)_" + set_global_save_failed: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (лише цей сеанс — не вдалося зберегти конфігурацію)\n_(набуде чинності з наступного повідомлення)_" + set_session: "🧠 ✓ Зусилля мислення встановлено на `{effort}` (лише цей сеанс — додайте `--global`, щоб зберегти)\n_(набуде чинності з наступного повідомлення)_" + + reload_mcp: + cancelled: "🟡 /reload-mcp скасовано. MCP-інструменти без змін." + always_followup: "ℹ️ Наступні виклики `/reload-mcp` виконуватимуться без підтвердження. Увімкнути знову можна через `approvals.mcp_reload_confirm: true` у `config.yaml`." + confirm_prompt: "⚠️ **Підтвердження /reload-mcp**\n\nПерезавантаження MCP-серверів перебудовує набір інструментів для цього сеансу та **інвалідує кеш промпта провайдера** — наступне повідомлення повторно надішле всі вхідні токени. На моделях із довгим контекстом або високим рівнем міркувань це може бути дорого.\n\nОберіть:\n• **Схвалити один раз** — перезавантажити зараз\n• **Завжди схвалювати** — перезавантажити та назавжди приховати цей запит\n• **Скасувати** — залишити MCP-інструменти без змін\n\n_Текстова альтернатива: відповідайте `/approve`, `/always` або `/cancel`._" + header: "🔄 **MCP-сервери перезавантажено**\n" + reconnected: "♻️ Перепідключено: {names}" + added: "➕ Додано: {names}" + removed: "➖ Видалено: {names}" + none_connected: "Немає підключених MCP-серверів." + tools_available: "\n🔧 {tools} інструмент(ів) доступно з {servers} сервер(ів)" + failed: "❌ Помилка перезавантаження MCP: {error}" + + reload_skills: + header: "🔄 **Навички перезавантажено**\n" + no_new: "Нових навичок не виявлено." + total: "\n📚 {count} навичок(и) доступно" + added_header: "➕ **Додані навички:**" + removed_header: "➖ **Видалені навички:**" + item_with_desc: " - {name}: {desc}" + item_no_desc: " - {name}" + failed: "❌ Помилка перезавантаження навичок: {error}" + + reset: + header_default: "✨ Сесію скинуто! Починаємо з чистого аркуша." + header_new: "✨ Нову сесію запущено!" + header_titled: "✨ Нову сесію запущено: {title}" + title_rejected: "\n⚠️ Назву відхилено: {error}" + title_error_untitled: "\n⚠️ {error} — сесію запущено без назви." + title_empty_untitled: "\n⚠️ Після очищення назва порожня — сесію запущено без назви." + tip: "\n✦ Порада: {tip}" + + restart: + in_progress: "⏳ Перезапуск гейтвея вже виконується..." + restarting: "♻ Перезапуск гейтвея. Якщо ви не отримаєте сповіщення протягом 60 секунд, перезапустіть із консолі командою `hermes gateway restart`." + + resume: + db_unavailable: "База даних сеансів недоступна." + no_named_sessions: "Іменованих сеансів не знайдено.\nВикористайте `/title Мій сеанс`, щоб назвати поточний сеанс, потім `/resume Мій сеанс`, щоб повернутися до нього." + list_header: "📋 **Іменовані сеанси**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\nВикористання: `/resume <назва сеансу>`" + list_failed: "Не вдалося отримати список сеансів: {error}" + not_found: "Сеанс, що відповідає '**{name}**', не знайдено.\nВикористайте `/resume` без аргументів, щоб побачити доступні сеанси." + already_on: "📌 Уже в сеансі **{name}**." + switch_failed: "Не вдалося переключити сеанс." + resumed_one: "↻ Сеанс **{title}** відновлено ({count} повідомлення). Розмову відновлено." + resumed_many: "↻ Сеанс **{title}** відновлено ({count} повідомлень). Розмову відновлено." + resumed_no_count: "↻ Сеанс **{title}** відновлено. Розмову відновлено." + + retry: + no_previous: "Немає попереднього повідомлення для повторення." + + rollback: + not_enabled: "Контрольні точки не ввімкнено.\nУвімкніть у config.yaml:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "Контрольних точок для {cwd} не знайдено" + invalid_number: "Недійсний номер контрольної точки. Використовуйте 1-{max}." + restored: "✅ Відновлено до контрольної точки {hash}: {reason}\nЗнімок перед відкатом збережено автоматично." + restore_failed: "❌ {error}" + + set_home: + save_failed: "Не вдалося зберегти головний канал: {error}" + success: "✅ Головний канал встановлено на **{name}** (ID: {chat_id}).\nCron-завдання та міжплатформні повідомлення доставлятимуться сюди." + + status: + header: "📊 **Стан Hermes Gateway**" + session_id: "**ID сесії:** `{session_id}`" + title: "**Назва:** {title}" + created: "**Створено:** {timestamp}" + last_activity: "**Остання активність:** {timestamp}" + tokens: "**Токени:** {tokens}" + agent_running: "**Агент активний:** {state}" + state_yes: "Так ⚡" + state_no: "Ні" + queued: "**Черга продовжень:** {count}" + platforms: "**Підключені платформи:** {platforms}" + + stop: + stopped_pending: "⚡ Зупинено. Агент ще не починав — можна продовжити цей сеанс." + stopped: "⚡ Зупинено. Можна продовжити цей сеанс." + no_active: "Немає активного завдання для зупинки." + + title: + db_unavailable: "База даних сеансів недоступна." + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ Після очищення назва порожня. Використовуйте друковані символи." + set_to: "✏️ Назву сеансу встановлено: **{title}**" + not_found: "Сеанс не знайдено в базі даних." + current_with_title: "📌 Сеанс: `{session_id}`\nНазва: **{title}**" + current_no_title: "📌 Сеанс: `{session_id}`\nНазву не встановлено. Використання: `/title Назва мого сеансу`" + + topic: + not_telegram_dm: "Команда /topic доступна лише в приватних чатах Telegram." + no_session_db: "База даних сесій недоступна." + unauthorized: "Ви не маєте дозволу використовувати /topic у цьому боті." + restore_needs_topic: "Щоб відновити сесію, спочатку створіть або відкрийте Telegram topic, а потім надішліть /topic <session-id> у цьому topic. Щоб створити новий topic, відкрийте All Messages і надішліть там будь-яке повідомлення." + topics_disabled: "Telegram topics ще не ввімкнено для цього бота.\n\nЯк увімкнути:\n1. Відкрийте @BotFather.\n2. Виберіть свого бота.\n3. Відкрийте Bot Settings → Threads Settings.\n4. Увімкніть Threaded Mode і переконайтеся, що користувачам дозволено створювати нові threads.\n\nПотім надішліть /topic знову." + topics_user_disallowed: "Telegram topics увімкнено, але користувачам не дозволено створювати topics.\n\nВідкрийте @BotFather → виберіть свого бота → Bot Settings → Threads Settings, потім вимкніть 'Disallow users to create new threads'.\n\nПотім надішліть /topic знову." + enable_failed: "Не вдалося ввімкнути режим Telegram topic: {error}" + bound_status: "Цей topic пов'язано з:\nСесія: {label}\nID: {session_id}\n\nВикористовуйте /new, щоб замінити цей topic новою сесією.\nДля паралельної роботи відкрийте All Messages і надішліть там повідомлення, щоб створити інший topic." + thread_ready: "Багатосесійні Telegram topics увімкнено.\n\nЦей topic використовуватиметься як незалежна сесія Hermes. Використовуйте /new, щоб замінити поточну сесію цього topic. Для паралельної роботи відкрийте All Messages і надішліть там повідомлення, щоб створити інший topic." + untitled_session: "Сесія без назви" + + undo: + nothing: "Немає чого скасовувати." + removed: "↩️ Скасовано {count} повідомлень.\nВидалено: «{preview}»" + + update: + platform_not_messaging: "✗ /update доступний лише на платформах обміну повідомленнями. Виконайте `hermes update` у терміналі." + not_git_repo: "✗ Не git-репозиторій — оновлення неможливе." + hermes_cmd_not_found: "✗ Не вдалося знайти команду `hermes`. Hermes запущено, але команда оновлення не знайшла виконуваний файл у PATH або через поточний інтерпретатор Python. Спробуйте виконати `hermes update` вручну у вашому терміналі." + start_failed: "✗ Не вдалося запустити оновлення: {error}" + starting: "⚕ Запуск оновлення Hermes… Я транслюватиму прогрес сюди." + + usage: + rate_limits: "⏱️ **Обмеження швидкості:** {state}" + header_session: "📊 **Використання токенів сеансу**" + label_model: "Модель: `{model}`" + label_input_tokens: "Вхідні токени: {count}" + label_cache_read: "Токени читання кешу: {count}" + label_cache_write: "Токени запису кешу: {count}" + label_output_tokens: "Вихідні токени: {count}" + label_total: "Усього: {count}" + label_api_calls: "Виклики API: {count}" + label_cost: "Вартість: {prefix}${amount}" + label_cost_included: "Вартість: включено" + label_context: "Контекст: {used} / {total} ({pct}%)" + label_compressions: "Стиснень: {count}" + header_session_info: "📊 **Інформація про сеанс**" + label_messages: "Повідомлень: {count}" + label_estimated_context: "Орієнтовний контекст: ~{count} токенів" + detailed_after_first: "_(Детальне використання доступне після першої відповіді агента)_" + no_data: "Дані про використання для цього сеансу відсутні." + + verbose: + not_enabled: "Команду `/verbose` не ввімкнено для платформ обміну повідомленнями.\n\nУвімкніть у `config.yaml`:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ Прогрес інструментів: **OFF** — активність інструментів не показується." + mode_new: "⚙️ Прогрес інструментів: **NEW** — показується при зміні інструмента (довжина попереднього перегляду: `display.tool_preview_length`, за замовчуванням 40)." + mode_all: "⚙️ Прогрес інструментів: **ALL** — показується кожен виклик інструмента (довжина попереднього перегляду: `display.tool_preview_length`, за замовчуванням 40)." + mode_verbose: "⚙️ Прогрес інструментів: **VERBOSE** — кожен виклик інструмента з повними аргументами." + saved_suffix: "_(збережено для **{platform}** — набуде чинності з наступного повідомлення)_" + save_failed: "_(не вдалося зберегти у конфігурацію: {error})_" + + voice: + enabled_voice_only: "Голосовий режим увімкнено.\nЯ відповідатиму голосом, коли ви надсилатимете голосові повідомлення.\nВикористайте /voice tts, щоб отримувати голосові відповіді на всі повідомлення." + disabled_text: "Голосовий режим вимкнено. Лише текстові відповіді." + tts_enabled: "Авто-TTS увімкнено.\nУсі відповіді міститимуть голосове повідомлення." + status_mode: "Голосовий режим: {label}" + status_channel: "Голосовий канал: #{channel}" + status_participants: "Учасники: {count}" + status_member: " - {name}{status}" + speaking: " (говорить)" + enabled_short: "Голосовий режим увімкнено." + disabled_short: "Голосовий режим вимкнено." + label_off: "Вимкнено (лише текст)" + label_voice_only: "Увімкнено (голосова відповідь на голосові повідомлення)" + label_all: "TTS (голосова відповідь на всі повідомлення)" + + yolo: + disabled: "⚠️ Режим YOLO для цього сеансу **ВИМКНЕНО** — небезпечні команди потребуватимуть схвалення." + enabled: "⚡ Режим YOLO для цього сеансу **УВІМКНЕНО** — усі команди схвалюються автоматично. Використовуйте з обережністю." + + shared: + session_db_unavailable: "База даних сеансів недоступна." + session_db_unavailable_prefix: "База даних сеансів недоступна" + session_not_found: "Сеанс не знайдено в базі даних." + warn_passthrough: "⚠️ {error}" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml new file mode 100644 index 00000000000..362ea298de8 --- /dev/null +++ b/locales/zh-hant.yaml @@ -0,0 +1,350 @@ +# Hermes 靜態訊息目錄 -- 繁體中文(台灣/香港) +# See locales/en.yaml for the source of truth; keep keys in sync. + +approval: + dangerous_header: "⚠️ 危險指令: {description}" + choose_long: " [o]僅此一次 | [s]本次工作階段 | [a]永久允許 | [d]拒絕" + choose_short: " [o]僅此一次 | [s]本次工作階段 | [d]拒絕" + prompt_long: " 選擇 [o/s/a/D]: " + prompt_short: " 選擇 [o/s/D]: " + timeout: " ⏱ 逾時 — 已拒絕指令" + allowed_once: " ✓ 本次允許" + allowed_session: " ✓ 本次工作階段內允許" + allowed_always: " ✓ 已加入永久允許清單" + denied: " ✗ 已拒絕" + cancelled: " ✗ 已取消" + blocklist_message: "此指令位於無條件封鎖清單中,無法被批准。" + +gateway: + approval_expired: "⚠️ 批准已逾期(代理不再等待)。請讓代理重試。" + draining: "⏳ 正在等待 {count} 個活躍代理結束後重新啟動..." + goal_cleared: "✓ 目標已清除。" + no_active_goal: "目前沒有作用中的目標。" + config_read_failed: "⚠️ 無法讀取 config.yaml:{error}" + config_save_failed: "⚠️ 無法儲存設定:{error}" + + model: + error_prefix: "錯誤:{error}" + switched: "已切換模型為 `{model}`" + provider_label: "提供方:{provider}" + context_label: "上下文:{tokens} tokens" + max_output_label: "最大輸出:{tokens} tokens" + cost_label: "費用:{cost}" + capabilities_label: "能力:{capabilities}" + prompt_caching_enabled: "提示快取:已啟用" + warning_prefix: "警告:{warning}" + saved_global: "已儲存到 config.yaml(`--global`)" + session_only_hint: "_(僅本次工作階段有效 — 加上 `--global` 可永久儲存)_" + current_label: "目前:`{model}`({provider})" + current_tag: "(目前)" + more_models_suffix: "(還有 {count} 個)" + usage_switch_model: "`/model <name>` — 切換模型" + usage_switch_provider: "`/model <name> --provider <slug>` — 切換提供方" + usage_persist: "`/model <name> --global` — 永久儲存" + + agents: + header: "🤖 **作用中的代理與任務**" + active_agents: "**作用中代理:** {count}" + this_chat: " · 目前聊天" + more: "... 還有 {count} 個" + running_processes: "**執行中的背景程序:** {count}" + async_jobs: "**閘道非同步任務:** {count}" + none: "沒有作用中的代理或執行中的任務。" + state_starting: "啟動中" + state_running: "執行中" + + approve: + no_pending: "沒有待批准的指令。" + once_singular: "✅ 指令已批准。代理正在恢復…" + once_plural: "✅ 指令已批准({count} 條指令)。代理正在恢復…" + session_singular: "✅ 指令已批准(本次工作階段內允許該模式)。代理正在恢復…" + session_plural: "✅ 指令已批准(本次工作階段內允許該模式)({count} 條指令)。代理正在恢復…" + always_singular: "✅ 指令已批准(永久允許該模式)。代理正在恢復…" + always_plural: "✅ 指令已批准(永久允許該模式)({count} 條指令)。代理正在恢復…" + + background: + usage: "用法:/background <提示>\n範例:/background 摘要今天 HN 上的熱門故事\n\n在獨立工作階段中執行該提示。你可以繼續聊天 — 完成後結果將顯示於此。" + started: "🔄 背景任務已啟動:「{preview}」\n任務 ID:{task_id}\n你可以繼續聊天 — 完成後結果將顯示於此。" + + branch: + db_unavailable: "工作階段資料庫無法使用。" + no_conversation: "沒有可分支的對話 — 請先傳送一則訊息。" + create_failed: "建立分支失敗:{error}" + switch_failed: "分支已建立,但無法切換到該分支。" + branched_one: "⑂ 已分支至 **{title}**(已複製 {count} 則訊息)\n原始:`{parent}`\n分支:`{new}`\n使用 `/resume` 切換回原始工作階段。" + branched_many: "⑂ 已分支至 **{title}**(已複製 {count} 則訊息)\n原始:`{parent}`\n分支:`{new}`\n使用 `/resume` 切換回原始工作階段。" + + commands: + usage: "用法:`/commands [page]`" + skill_header: "⚡ **技能指令**:" + default_desc: "技能指令" + none: "沒有可用的指令。" + header: "📚 **指令**(共 {total} 個,第 {page}/{total_pages} 頁)" + nav_prev: "`/commands {page}` ← 上一頁" + nav_next: "下一頁 → `/commands {page}`" + out_of_range: "_(請求的第 {requested} 頁超出範圍,顯示第 {page} 頁。)_" + + compress: + not_enough: "對話內容不足,無法壓縮(至少需要 4 則訊息)。" + no_provider: "未設定提供方 — 無法壓縮。" + nothing_to_do: "目前沒有可壓縮的內容(對話記錄仍全部為受保護的上下文)。" + focus_line: "聚焦:\"{topic}\"" + summary_failed: "⚠️ 摘要產生失敗({error})。{count} 則歷史訊息已被移除並以佔位符取代;先前的上下文已無法復原。建議檢查 auxiliary.compression 模型設定。" + aux_failed: "ℹ️ 設定的壓縮模型 `{model}` 失敗({error})。已使用主要模型復原 — 上下文完整 — 但您可能想檢查 config.yaml 中的 `auxiliary.compression.model`。" + failed: "壓縮失敗:{error}" + + debug: + upload_failed: "✗ 無法上傳除錯報告:{error}" + header: "**除錯報告已上傳:**" + auto_delete: "⏱ 貼上的內容將於 6 小時後自動刪除。" + full_logs_hint: "如需上傳完整紀錄,請在 CLI 中使用 `hermes debug share`。" + share_hint: "請將這些連結分享給 Hermes 團隊以取得支援。" + + deny: + stale: "❌ 指令已拒絕(批准已過期)。" + no_pending: "沒有待拒絕的指令。" + denied_singular: "❌ 指令已拒絕。" + denied_plural: "❌ 指令已拒絕({count} 條指令)。" + + fast: + not_supported: "⚡ /fast 僅適用於支援 Priority Processing 的 OpenAI 模型。" + status: "⚡ Priority Processing\n\n目前模式:`{mode}`\n\n_用法:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ 未知參數:`{arg}`\n\n**有效選項:** normal、fast、status" + saved: "⚡ ✓ Priority Processing:**{label}**(已儲存到設定)\n_(下一則訊息生效)_" + session_only: "⚡ ✓ Priority Processing:**{label}**(僅本次工作階段)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 執行階段頁尾:**{state}**\n欄位:`{fields}`\n平台:`{platform}`" + usage: "用法:`/footer [on|off|status]`" + saved: "📎 執行階段頁尾:**{state}**{example}\n_(已全域儲存 — 下一則訊息生效)_" + example_line: "\n範例:`{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "此工作階段不支援目標功能。" + no_goal_set: "未設定目標。" + paused: "⏸ 目標已暫停:{goal}" + no_resume: "沒有可恢復的目標。" + resumed: "▶ 目標已恢復:{goal}\n傳送任意訊息繼續,或等待 — 我會在下一輪繼續推進。" + invalid: "無效目標:{error}" + set: "⊙ 目標已設定({budget} 輪預算):{goal}\n我會持續工作直到目標完成、你暫停/清除目標,或預算耗盡。\n控制指令:/goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Hermes 指令**\n" + skill_header: "\n⚡ **技能指令**({count} 個作用中):" + more_use_commands: "\n... 還有 {count} 個。使用 `/commands` 檢視完整分頁清單。" + + insights: + invalid_days: "無效的 --days 值:{value}" + error: "產生洞察時發生錯誤:{error}" + + kanban: + error_prefix: "⚠ kanban 錯誤:{error}" + subscribed_suffix: "(已訂閱 — 當 {task_id} 完成或被封鎖時將通知您)" + truncated_suffix: "…(已截斷;如需完整輸出請在終端機執行 `hermes kanban …`)" + no_output: "(無輸出)" + + personality: + none_configured: "`{path}/config.yaml` 中未設定人格" + header: "🎭 **可用人格**\n" + none_option: "• `none` —(不套用人格覆寫)" + item: "• `{name}` — {preview}" + usage: "\n用法:`/personality <name>`" + save_failed: "⚠️ 儲存人格變更失敗:{error}" + cleared: "🎭 已清除人格 — 使用基礎代理行為。\n_(下一則訊息生效)_" + set_to: "🎭 人格已設定為 **{name}**\n_(下一則訊息生效)_" + unknown: "未知人格:`{name}`\n\n可用:{available}" + + profile: + header: "👤 **設定檔:** `{profile}`" + home: "📂 **主目錄:** `{home}`" + + reasoning: + level_default: "medium(預設)" + level_disabled: "none(已停用)" + scope_session: "工作階段覆寫" + scope_global: "全域設定" + status: "🧠 **推理設定**\n\n**強度:** `{level}`\n**範圍:** {scope}\n**顯示:** {display}\n\n_用法:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "開啟 ✓" + display_off: "關閉" + display_set_on: "🧠 ✓ 推理顯示:**開啟**\n在 **{platform}** 上每次回應前將顯示模型的思考過程。" + display_set_off: "🧠 ✓ **{platform}** 上的推理顯示:**關閉**" + reset_global_unsupported: "⚠️ 不支援 `/reasoning reset --global`。請使用 `/reasoning <level> --global` 變更全域預設值。" + reset_done: "🧠 ✓ 已清除本工作階段的推理覆寫;回退至全域設定。" + unknown_arg: "⚠️ 未知參數:`{arg}`\n\n**有效級別:** none, minimal, low, medium, high, xhigh\n**顯示:** show, hide\n**持久化:** 加上 `--global` 可跨工作階段儲存" + set_global: "🧠 ✓ 推理強度已設定為 `{effort}`(已儲存到設定)\n_(下一則訊息生效)_" + set_global_save_failed: "🧠 ✓ 推理強度已設定為 `{effort}`(僅本工作階段 — 設定儲存失敗)\n_(下一則訊息生效)_" + set_session: "🧠 ✓ 推理強度已設定為 `{effort}`(僅本工作階段 — 加上 `--global` 可持久化)\n_(下一則訊息生效)_" + + reload_mcp: + cancelled: "🟡 已取消 /reload-mcp。MCP 工具未變更。" + always_followup: "ℹ️ 後續 `/reload-mcp` 呼叫將不再要求確認。可在 `config.yaml` 中將 `approvals.mcp_reload_confirm: true` 重新啟用。" + confirm_prompt: "⚠️ **確認 /reload-mcp**\n\n重新載入 MCP 伺服器會為本工作階段重建工具集,並**使提供方提示快取失效** — 下一則訊息將重新傳送完整輸入 token。在長上下文或高推理模型上,這可能成本較高。\n\n請選擇:\n• **批准一次** — 立即重新載入\n• **永遠批准** — 立即重新載入並永久關閉此提示\n• **取消** — 保持 MCP 工具不變\n\n_文字備援:回覆 `/approve`、`/always` 或 `/cancel`。_" + header: "🔄 **MCP 伺服器已重新載入**\n" + reconnected: "♻️ 已重新連線:{names}" + added: "➕ 已新增:{names}" + removed: "➖ 已移除:{names}" + none_connected: "沒有已連線的 MCP 伺服器。" + tools_available: "\n🔧 來自 {servers} 個伺服器的 {tools} 個工具可用" + failed: "❌ MCP 重新載入失敗:{error}" + + reload_skills: + header: "🔄 **技能已重新載入**\n" + no_new: "未偵測到新技能。" + total: "\n📚 {count} 個技能可用" + added_header: "➕ **新增技能:**" + removed_header: "➖ **移除技能:**" + item_with_desc: " - {name}:{desc}" + item_no_desc: " - {name}" + failed: "❌ 技能重新載入失敗:{error}" + + reset: + header_default: "✨ 工作階段已重設!重新開始。" + header_new: "✨ 新工作階段已啟動!" + header_titled: "✨ 新工作階段已啟動:{title}" + title_rejected: "\n⚠️ 標題遭拒絕:{error}" + title_error_untitled: "\n⚠️ {error} — 工作階段以未命名方式啟動。" + title_empty_untitled: "\n⚠️ 清理後標題為空 — 工作階段以未命名方式啟動。" + tip: "\n✦ 提示:{tip}" + + restart: + in_progress: "⏳ 閘道重新啟動已在進行中……" + restarting: "♻ 正在重新啟動閘道。如果 60 秒內未收到通知,請在主控台執行 `hermes gateway restart` 重新啟動。" + + resume: + db_unavailable: "工作階段資料庫無法使用。" + no_named_sessions: "找不到已命名的工作階段。\n使用 `/title 我的工作階段` 為目前工作階段命名,然後使用 `/resume 我的工作階段` 返回。" + list_header: "📋 **已命名工作階段**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\n用法:`/resume <工作階段名稱>`" + list_failed: "無法列出工作階段:{error}" + not_found: "找不到符合 '**{name}**' 的工作階段。\n使用不帶參數的 `/resume` 檢視可用的工作階段。" + already_on: "📌 已在工作階段 **{name}** 上。" + switch_failed: "切換工作階段失敗。" + resumed_one: "↻ 已恢復工作階段 **{title}**({count} 則訊息)。對話已還原。" + resumed_many: "↻ 已恢復工作階段 **{title}**({count} 則訊息)。對話已還原。" + resumed_no_count: "↻ 已恢復工作階段 **{title}**。對話已還原。" + + retry: + no_previous: "沒有可重試的上一則訊息。" + + rollback: + not_enabled: "檢查點未啟用。\n請在 config.yaml 中啟用:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "找不到 {cwd} 的檢查點" + invalid_number: "無效的檢查點編號。請使用 1-{max}。" + restored: "✅ 已還原至檢查點 {hash}:{reason}\n已自動儲存回復前的快照。" + restore_failed: "❌ {error}" + + set_home: + save_failed: "無法儲存主頻道:{error}" + success: "✅ 主頻道已設定為 **{name}**(ID:{chat_id})。\n排程任務和跨平台訊息將傳送至此處。" + + status: + header: "📊 **Hermes 閘道狀態**" + session_id: "**工作階段 ID:** `{session_id}`" + title: "**標題:** {title}" + created: "**建立時間:** {timestamp}" + last_activity: "**最近活動:** {timestamp}" + tokens: "**Token 數:** {tokens}" + agent_running: "**代理執行中:** {state}" + state_yes: "是 ⚡" + state_no: "否" + queued: "**排隊中的後續:** {count}" + platforms: "**已連線平台:** {platforms}" + + stop: + stopped_pending: "⚡ 已停止。代理尚未啟動 — 你可以繼續此工作階段。" + stopped: "⚡ 已停止。你可以繼續此工作階段。" + no_active: "沒有可停止的作用中任務。" + + title: + db_unavailable: "工作階段資料庫無法使用。" + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ 清理後標題為空。請使用可列印字元。" + set_to: "✏️ 已設定工作階段標題:**{title}**" + not_found: "在資料庫中找不到此工作階段。" + current_with_title: "📌 工作階段:`{session_id}`\n標題:**{title}**" + current_no_title: "📌 工作階段:`{session_id}`\n尚未設定標題。用法:`/title 我的工作階段名稱`" + + topic: + not_telegram_dm: "/topic 指令僅在 Telegram 私人聊天中可用。" + no_session_db: "工作階段資料庫無法使用。" + unauthorized: "您無權在此 bot 上使用 /topic。" + restore_needs_topic: "若要恢復工作階段,請先建立或開啟一個 Telegram topic,然後在該 topic 中傳送 /topic <session-id>。若要建立新 topic,請開啟 All Messages 並在其中傳送任意訊息。" + topics_disabled: "此 bot 尚未啟用 Telegram topics。\n\n啟用方法:\n1. 開啟 @BotFather。\n2. 選擇您的 bot。\n3. 開啟 Bot Settings → Threads Settings。\n4. 開啟 Threaded Mode,並確保允許使用者建立新 thread。\n\n然後再次傳送 /topic。" + topics_user_disallowed: "Telegram topics 已啟用,但不允許使用者建立 topics。\n\n開啟 @BotFather → 選擇您的 bot → Bot Settings → Threads Settings,然後關閉 'Disallow users to create new threads'。\n\n然後再次傳送 /topic。" + enable_failed: "啟用 Telegram topic 模式失敗:{error}" + bound_status: "此 topic 已連結至:\n工作階段:{label}\nID:{session_id}\n\n使用 /new 將此 topic 取代為新工作階段。\n如需平行作業,請開啟 All Messages 並在其中傳送訊息以建立另一個 topic。" + thread_ready: "Telegram 多工作階段 topics 已啟用。\n\n此 topic 將作為獨立的 Hermes 工作階段使用。使用 /new 取代此 topic 目前的工作階段。如需平行作業,請開啟 All Messages 並在其中傳送訊息以建立另一個 topic。" + untitled_session: "未命名工作階段" + + undo: + nothing: "沒有可復原的內容。" + removed: "↩️ 已復原 {count} 則訊息。\n已移除:「{preview}」" + + update: + platform_not_messaging: "✗ /update 僅在訊息平台上可用。請在終端機執行 `hermes update`。" + not_git_repo: "✗ 不是 git 儲存庫 — 無法更新。" + hermes_cmd_not_found: "✗ 找不到 `hermes` 指令。Hermes 正在執行,但更新指令無法在 PATH 上或透過目前的 Python 解譯器找到執行檔。請嘗試在終端機中手動執行 `hermes update`。" + start_failed: "✗ 啟動更新失敗:{error}" + starting: "⚕ 正在啟動 Hermes 更新…… 進度將在此處顯示。" + + usage: + rate_limits: "⏱️ **速率限制:** {state}" + header_session: "📊 **工作階段 token 使用情況**" + label_model: "模型:`{model}`" + label_input_tokens: "輸入 token:{count}" + label_cache_read: "快取讀取 token:{count}" + label_cache_write: "快取寫入 token:{count}" + label_output_tokens: "輸出 token:{count}" + label_total: "總計:{count}" + label_api_calls: "API 呼叫次數:{count}" + label_cost: "費用:{prefix}${amount}" + label_cost_included: "費用:已包含" + label_context: "上下文:{used} / {total}({pct}%)" + label_compressions: "壓縮次數:{count}" + header_session_info: "📊 **工作階段資訊**" + label_messages: "訊息數:{count}" + label_estimated_context: "預估上下文:~{count} 個 token" + detailed_after_first: "_(首次代理回應後可檢視詳細使用情況)_" + no_data: "此工作階段沒有可用的使用資料。" + + verbose: + not_enabled: "`/verbose` 指令未在訊息平台上啟用。\n\n請在 `config.yaml` 中啟用:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ 工具進度:**OFF** — 不顯示任何工具活動。" + mode_new: "⚙️ 工具進度:**NEW** — 工具變更時顯示(預覽長度:`display.tool_preview_length`,預設 40)。" + mode_all: "⚙️ 工具進度:**ALL** — 顯示每次工具呼叫(預覽長度:`display.tool_preview_length`,預設 40)。" + mode_verbose: "⚙️ 工具進度:**VERBOSE** — 顯示每次工具呼叫及完整參數。" + saved_suffix: "_(已為 **{platform}** 儲存 — 下一則訊息生效)_" + save_failed: "_(無法儲存到設定:{error})_" + + voice: + enabled_voice_only: "語音模式已啟用。\n當你傳送語音訊息時,我會以語音回覆。\n使用 /voice tts 讓所有訊息都收到語音回覆。" + disabled_text: "語音模式已停用。僅文字回覆。" + tts_enabled: "自動 TTS 已啟用。\n所有回覆都將包含一則語音訊息。" + status_mode: "語音模式:{label}" + status_channel: "語音頻道:#{channel}" + status_participants: "參與人數:{count}" + status_member: " - {name}{status}" + speaking: "(正在說話)" + enabled_short: "語音模式已啟用。" + disabled_short: "語音模式已停用。" + label_off: "關閉(僅文字)" + label_voice_only: "開啟(僅對語音訊息進行語音回覆)" + label_all: "TTS(對所有訊息進行語音回覆)" + + yolo: + disabled: "⚠️ 本工作階段 YOLO 模式 **已關閉** — 危險指令將需要批准。" + enabled: "⚡ 本工作階段 YOLO 模式 **已開啟** — 所有指令自動批准。請謹慎使用。" + + shared: + session_db_unavailable: "工作階段資料庫無法使用。" + session_db_unavailable_prefix: "工作階段資料庫無法使用" + session_not_found: "資料庫中找不到此工作階段。" + warn_passthrough: "⚠️ {error}" diff --git a/locales/zh.yaml b/locales/zh.yaml index 7cd9a4f3214..7859a1a203c 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -22,3 +22,329 @@ gateway: no_active_goal: "当前没有活跃的目标。" config_read_failed: "⚠️ 无法读取 config.yaml:{error}" config_save_failed: "⚠️ 无法保存配置:{error}" + + model: + error_prefix: "错误:{error}" + switched: "已切换模型为 `{model}`" + provider_label: "提供方:{provider}" + context_label: "上下文:{tokens} tokens" + max_output_label: "最大输出:{tokens} tokens" + cost_label: "费用:{cost}" + capabilities_label: "能力:{capabilities}" + prompt_caching_enabled: "提示词缓存:已启用" + warning_prefix: "警告:{warning}" + saved_global: "已保存到 config.yaml(`--global`)" + session_only_hint: "_(仅本次会话有效 — 添加 `--global` 可永久保存)_" + current_label: "当前:`{model}`({provider})" + current_tag: "(当前)" + more_models_suffix: "(还有 {count} 个)" + usage_switch_model: "`/model <name>` — 切换模型" + usage_switch_provider: "`/model <name> --provider <slug>` — 切换提供方" + usage_persist: "`/model <name> --global` — 永久保存" + + agents: + header: "🤖 **活跃代理与任务**" + active_agents: "**活跃代理:** {count}" + this_chat: " · 当前聊天" + more: "... 还有 {count} 个" + running_processes: "**运行中的后台进程:** {count}" + async_jobs: "**网关异步任务:** {count}" + none: "没有活跃的代理或运行中的任务。" + state_starting: "启动中" + state_running: "运行中" + + approve: + no_pending: "没有待批准的命令。" + once_singular: "✅ 命令已批准。代理正在恢复…" + once_plural: "✅ 命令已批准({count} 条命令)。代理正在恢复…" + session_singular: "✅ 命令已批准(本次会话内允许该模式)。代理正在恢复…" + session_plural: "✅ 命令已批准(本次会话内允许该模式)({count} 条命令)。代理正在恢复…" + always_singular: "✅ 命令已批准(永久允许该模式)。代理正在恢复…" + always_plural: "✅ 命令已批准(永久允许该模式)({count} 条命令)。代理正在恢复…" + + background: + usage: "用法:/background <提示>\n示例:/background 总结今天 HN 上热门的故事\n\n在独立会话中运行该提示。你可以继续聊天 — 结果完成后将在此显示。" + started: "🔄 后台任务已启动:「{preview}」\n任务 ID:{task_id}\n你可以继续聊天 — 完成后结果将在此显示。" + + branch: + db_unavailable: "会话数据库不可用。" + no_conversation: "没有可分支的对话 — 请先发送一条消息。" + create_failed: "创建分支失败:{error}" + switch_failed: "分支已创建,但无法切换到它。" + branched_one: "⑂ 已分支到 **{title}**(已复制 {count} 条消息)\n原始:`{parent}`\n分支:`{new}`\n使用 `/resume` 切换回原始会话。" + branched_many: "⑂ 已分支到 **{title}**(已复制 {count} 条消息)\n原始:`{parent}`\n分支:`{new}`\n使用 `/resume` 切换回原始会话。" + + commands: + usage: "用法:`/commands [page]`" + skill_header: "⚡ **技能命令**:" + default_desc: "技能命令" + none: "没有可用的命令。" + header: "📚 **命令**(共 {total} 个,第 {page}/{total_pages} 页)" + nav_prev: "`/commands {page}` ← 上一页" + nav_next: "下一页 → `/commands {page}`" + out_of_range: "_(请求的第 {requested} 页超出范围,显示第 {page} 页。)_" + + compress: + not_enough: "对话内容不足,无法压缩(至少需要 4 条消息)。" + no_provider: "未配置提供方 — 无法压缩。" + nothing_to_do: "暂无可压缩内容(对话记录仍全部为受保护上下文)。" + focus_line: "聚焦:\"{topic}\"" + summary_failed: "⚠️ 摘要生成失败({error})。{count} 条历史消息已被移除并替换为占位符;之前的上下文已无法恢复。建议检查 auxiliary.compression 模型配置。" + aux_failed: "ℹ️ 配置的压缩模型 `{model}` 失败({error})。已使用主模型恢复 — 上下文完好 — 但您可能想检查 config.yaml 中的 `auxiliary.compression.model`。" + failed: "压缩失败:{error}" + + debug: + upload_failed: "✗ 无法上传调试报告:{error}" + header: "**调试报告已上传:**" + auto_delete: "⏱ 粘贴内容将在 6 小时后自动删除。" + full_logs_hint: "如需上传完整日志,请在 CLI 中使用 `hermes debug share`。" + share_hint: "请将这些链接分享给 Hermes 团队以获得支持。" + + deny: + stale: "❌ 命令已拒绝(批准已过期)。" + no_pending: "没有待拒绝的命令。" + denied_singular: "❌ 命令已拒绝。" + denied_plural: "❌ 命令已拒绝({count} 条命令)。" + + fast: + not_supported: "⚡ /fast 仅适用于支持优先处理(Priority Processing)的 OpenAI 模型。" + status: "⚡ 优先处理\n\n当前模式:`{mode}`\n\n_用法:_ `/fast <normal|fast|status>`" + unknown_arg: "⚠️ 未知参数:`{arg}`\n\n**有效选项:** normal、fast、status" + saved: "⚡ ✓ 优先处理:**{label}**(已保存到配置)\n_(下一条消息生效)_" + session_only: "⚡ ✓ 优先处理:**{label}**(仅本次会话)" + label_fast: "FAST" + label_normal: "NORMAL" + status_fast: "fast" + status_normal: "normal" + + footer: + status: "📎 运行时页脚:**{state}**\n字段:`{fields}`\n平台:`{platform}`" + usage: "用法:`/footer [on|off|status]`" + saved: "📎 运行时页脚:**{state}**{example}\n_(已全局保存 — 下一条消息生效)_" + example_line: "\n示例:`{preview}`" + state_on: "ON" + state_off: "OFF" + + goal: + unavailable: "此会话不支持目标功能。" + no_goal_set: "未设置目标。" + paused: "⏸ 目标已暂停:{goal}" + no_resume: "没有可恢复的目标。" + resumed: "▶ 目标已恢复:{goal}\n发送任意消息继续,或等待 — 我会在下一轮继续推进。" + invalid: "无效目标:{error}" + set: "⊙ 目标已设置({budget} 轮预算):{goal}\n我将持续工作直到目标完成、你暂停/清除它,或预算耗尽。\n控制命令:/goal status · /goal pause · /goal resume · /goal clear" + + help: + header: "📖 **Hermes 命令**\n" + skill_header: "\n⚡ **技能命令**({count} 个活跃):" + more_use_commands: "\n... 还有 {count} 个。使用 `/commands` 查看完整分页列表。" + + insights: + invalid_days: "无效的 --days 值:{value}" + error: "生成洞察时出错:{error}" + + kanban: + error_prefix: "⚠ kanban 错误:{error}" + subscribed_suffix: "(已订阅 — 当 {task_id} 完成或被阻塞时将通知您)" + truncated_suffix: "…(已截断;如需完整输出请在终端运行 `hermes kanban …`)" + no_output: "(无输出)" + + personality: + none_configured: "`{path}/config.yaml` 中未配置人格设定" + header: "🎭 **可用人格**\n" + none_option: "• `none` — (不应用人格覆盖)" + item: "• `{name}` — {preview}" + usage: "\n用法:`/personality <name>`" + save_failed: "⚠️ 保存人格变更失败:{error}" + cleared: "🎭 已清除人格 — 使用基础代理行为。\n_(在下一条消息时生效)_" + set_to: "🎭 人格已设置为 **{name}**\n_(在下一条消息时生效)_" + unknown: "未知人格:`{name}`\n\n可用:{available}" + + profile: + header: "👤 **配置文件:** `{profile}`" + home: "📂 **主目录:** `{home}`" + + reasoning: + level_default: "medium(默认)" + level_disabled: "none(已禁用)" + scope_session: "会话覆盖" + scope_global: "全局配置" + status: "🧠 **推理设置**\n\n**强度:** `{level}`\n**作用域:** {scope}\n**显示:** {display}\n\n_用法:_ `/reasoning <none|minimal|low|medium|high|xhigh|reset|show|hide> [--global]`" + display_on: "开 ✓" + display_off: "关" + display_set_on: "🧠 ✓ 推理显示:**开启**\n在 **{platform}** 上每次响应前将显示模型的思考过程。" + display_set_off: "🧠 ✓ **{platform}** 上的推理显示:**关闭**" + reset_global_unsupported: "⚠️ 不支持 `/reasoning reset --global`。请使用 `/reasoning <level> --global` 修改全局默认值。" + reset_done: "🧠 ✓ 已清除本会话的推理覆盖;回退到全局配置。" + unknown_arg: "⚠️ 未知参数:`{arg}`\n\n**有效级别:** none, minimal, low, medium, high, xhigh\n**显示:** show, hide\n**持久化:** 添加 `--global` 以跨会话保存" + set_global: "🧠 ✓ 推理强度已设置为 `{effort}`(已保存到配置)\n_(下一条消息生效)_" + set_global_save_failed: "🧠 ✓ 推理强度已设置为 `{effort}`(仅本会话 — 配置保存失败)\n_(下一条消息生效)_" + set_session: "🧠 ✓ 推理强度已设置为 `{effort}`(仅本会话 — 添加 `--global` 以持久化)\n_(下一条消息生效)_" + + reload_mcp: + cancelled: "🟡 已取消 /reload-mcp。MCP 工具未更改。" + always_followup: "ℹ️ 后续 `/reload-mcp` 调用将不再确认。可在 `config.yaml` 中将 `approvals.mcp_reload_confirm: true` 重新启用。" + confirm_prompt: "⚠️ **确认 /reload-mcp**\n\n重新加载 MCP 服务器会为本会话重建工具集,并**使提供方提示词缓存失效** — 下一条消息将重新发送完整输入令牌。在长上下文或高推理模型上,这可能开销较大。\n\n请选择:\n• **批准一次** — 立即重新加载\n• **始终批准** — 立即重新加载并永久静默此提示\n• **取消** — 保持 MCP 工具不变\n\n_文本备用:回复 `/approve`、`/always` 或 `/cancel`。_" + header: "🔄 **MCP 服务器已重新加载**\n" + reconnected: "♻️ 已重新连接:{names}" + added: "➕ 已添加:{names}" + removed: "➖ 已移除:{names}" + none_connected: "没有连接的 MCP 服务器。" + tools_available: "\n🔧 来自 {servers} 个服务器的 {tools} 个工具可用" + failed: "❌ MCP 重新加载失败:{error}" + + reload_skills: + header: "🔄 **技能已重新加载**\n" + no_new: "未检测到新技能。" + total: "\n📚 {count} 个技能可用" + added_header: "➕ **新增技能:**" + removed_header: "➖ **移除技能:**" + item_with_desc: " - {name}:{desc}" + item_no_desc: " - {name}" + failed: "❌ 技能重新加载失败:{error}" + + reset: + header_default: "✨ 会话已重置!重新开始。" + header_new: "✨ 新会话已启动!" + header_titled: "✨ 新会话已启动:{title}" + title_rejected: "\n⚠️ 标题被拒绝:{error}" + title_error_untitled: "\n⚠️ {error} — 会话以未命名方式启动。" + title_empty_untitled: "\n⚠️ 清理后标题为空 — 会话以未命名方式启动。" + tip: "\n✦ 提示:{tip}" + + restart: + in_progress: "⏳ 网关重启已在进行中……" + restarting: "♻ 正在重启网关。如果 60 秒内没有收到通知,请在控制台运行 `hermes gateway restart` 重启。" + + resume: + db_unavailable: "会话数据库不可用。" + no_named_sessions: "未找到已命名的会话。\n使用 `/title 我的会话` 为当前会话命名,然后用 `/resume 我的会话` 返回。" + list_header: "📋 **已命名会话**\n" + list_item: "• **{title}**{preview_part}" + list_preview_suffix: " — _{preview}_" + list_footer: "\n用法:`/resume <会话名称>`" + list_failed: "无法列出会话:{error}" + not_found: "未找到匹配 '**{name}**' 的会话。\n使用不带参数的 `/resume` 查看可用会话。" + already_on: "📌 已在会话 **{name}** 上。" + switch_failed: "切换会话失败。" + resumed_one: "↻ 已恢复会话 **{title}**({count} 条消息)。对话已还原。" + resumed_many: "↻ 已恢复会话 **{title}**({count} 条消息)。对话已还原。" + resumed_no_count: "↻ 已恢复会话 **{title}**。对话已还原。" + + retry: + no_previous: "没有可重试的上一条消息。" + + rollback: + not_enabled: "检查点未启用。\n请在 config.yaml 中启用:\n```\ncheckpoints:\n enabled: true\n```" + none_found: "未找到 {cwd} 的检查点" + invalid_number: "无效的检查点编号。请使用 1-{max}。" + restored: "✅ 已恢复到检查点 {hash}:{reason}\n已自动保存回滚前的快照。" + restore_failed: "❌ {error}" + + set_home: + save_failed: "无法保存主频道:{error}" + success: "✅ 主频道已设置为 **{name}**(ID:{chat_id})。\n定时任务和跨平台消息将发送到此处。" + + status: + header: "📊 **Hermes 网关状态**" + session_id: "**会话 ID:** `{session_id}`" + title: "**标题:** {title}" + created: "**创建时间:** {timestamp}" + last_activity: "**最近活动:** {timestamp}" + tokens: "**Token 数:** {tokens}" + agent_running: "**代理运行中:** {state}" + state_yes: "是 ⚡" + state_no: "否" + queued: "**排队的后续:** {count}" + platforms: "**已连接平台:** {platforms}" + + stop: + stopped_pending: "⚡ 已停止。代理尚未启动 — 你可以继续此会话。" + stopped: "⚡ 已停止。你可以继续此会话。" + no_active: "没有可停止的活跃任务。" + + title: + db_unavailable: "会话数据库不可用。" + warn_prefix: "⚠️ {error}" + empty_after_clean: "⚠️ 清理后标题为空。请使用可打印字符。" + set_to: "✏️ 已设置会话标题:**{title}**" + not_found: "未在数据库中找到该会话。" + current_with_title: "📌 会话:`{session_id}`\n标题:**{title}**" + current_no_title: "📌 会话:`{session_id}`\n尚未设置标题。用法:`/title 我的会话名称`" + + topic: + not_telegram_dm: "/topic 命令仅在 Telegram 私聊中可用。" + no_session_db: "会话数据库不可用。" + unauthorized: "您无权在此 bot 上使用 /topic。" + restore_needs_topic: "若要恢复会话,请先创建或打开一个 Telegram topic,然后在该 topic 中发送 /topic <session-id>。要创建新 topic,请打开 All Messages 并在其中发送任意消息。" + topics_disabled: "此 bot 尚未启用 Telegram topics。\n\n启用方法:\n1. 打开 @BotFather。\n2. 选择您的 bot。\n3. 打开 Bot Settings → Threads Settings。\n4. 开启 Threaded Mode,并确保允许用户创建新线程。\n\n然后再次发送 /topic。" + topics_user_disallowed: "Telegram topics 已启用,但不允许用户创建 topics。\n\n打开 @BotFather → 选择您的 bot → Bot Settings → Threads Settings,然后关闭 'Disallow users to create new threads'。\n\n然后再次发送 /topic。" + enable_failed: "启用 Telegram topic 模式失败:{error}" + bound_status: "此 topic 已关联到:\n会话:{label}\nID:{session_id}\n\n使用 /new 将此 topic 替换为新会话。\n如需并行工作,请打开 All Messages 并在其中发送消息以创建另一个 topic。" + thread_ready: "Telegram 多会话 topics 已启用。\n\n此 topic 将作为独立的 Hermes 会话使用。使用 /new 替换此 topic 的当前会话。如需并行工作,请打开 All Messages 并在其中发送消息以创建另一个 topic。" + untitled_session: "未命名会话" + + undo: + nothing: "没有可撤销的内容。" + removed: "↩️ 已撤销 {count} 条消息。\n已移除:「{preview}」" + + update: + platform_not_messaging: "✗ /update 仅在消息平台可用。请在终端运行 `hermes update`。" + not_git_repo: "✗ 不是 git 仓库 — 无法更新。" + hermes_cmd_not_found: "✗ 无法找到 `hermes` 命令。Hermes 正在运行,但更新命令无法在 PATH 上或通过当前 Python 解释器找到可执行文件。请尝试在终端中手动运行 `hermes update`。" + start_failed: "✗ 启动更新失败:{error}" + starting: "⚕ 正在启动 Hermes 更新…… 进度将在此处显示。" + + usage: + rate_limits: "⏱️ **速率限制:** {state}" + header_session: "📊 **会话令牌使用情况**" + label_model: "模型:`{model}`" + label_input_tokens: "输入令牌:{count}" + label_cache_read: "缓存读取令牌:{count}" + label_cache_write: "缓存写入令牌:{count}" + label_output_tokens: "输出令牌:{count}" + label_total: "总计:{count}" + label_api_calls: "API 调用次数:{count}" + label_cost: "费用:{prefix}${amount}" + label_cost_included: "费用:已包含" + label_context: "上下文:{used} / {total}({pct}%)" + label_compressions: "压缩次数:{count}" + header_session_info: "📊 **会话信息**" + label_messages: "消息数:{count}" + label_estimated_context: "估计上下文:~{count} 个令牌" + detailed_after_first: "_(首次代理响应后可查看详细使用情况)_" + no_data: "此会话暂无使用数据。" + + verbose: + not_enabled: "`/verbose` 命令未在消息平台启用。\n\n请在 `config.yaml` 中启用:\n```yaml\ndisplay:\n tool_progress_command: true\n```" + mode_off: "⚙️ 工具进度:**OFF** — 不显示任何工具活动。" + mode_new: "⚙️ 工具进度:**NEW** — 工具变化时显示(预览长度:`display.tool_preview_length`,默认 40)。" + mode_all: "⚙️ 工具进度:**ALL** — 显示每次工具调用(预览长度:`display.tool_preview_length`,默认 40)。" + mode_verbose: "⚙️ 工具进度:**VERBOSE** — 显示每次工具调用及完整参数。" + saved_suffix: "_(已为 **{platform}** 保存 — 下一条消息生效)_" + save_failed: "_(无法保存到配置:{error})_" + + voice: + enabled_voice_only: "语音模式已启用。\n当你发送语音消息时,我会用语音回复。\n使用 /voice tts 让所有消息都收到语音回复。" + disabled_text: "语音模式已禁用。仅文本回复。" + tts_enabled: "自动 TTS 已启用。\n所有回复都将包含一条语音消息。" + status_mode: "语音模式:{label}" + status_channel: "语音频道:#{channel}" + status_participants: "参与人数:{count}" + status_member: " - {name}{status}" + speaking: "(正在说话)" + enabled_short: "语音模式已启用。" + disabled_short: "语音模式已禁用。" + label_off: "关闭(仅文本)" + label_voice_only: "开启(仅对语音消息进行语音回复)" + label_all: "TTS(对所有消息进行语音回复)" + + yolo: + disabled: "⚠️ 本会话 YOLO 模式 **已关闭** — 危险命令将需要批准。" + enabled: "⚡ 本会话 YOLO 模式 **已开启** — 所有命令自动批准。请谨慎使用。" + + shared: + session_db_unavailable: "会话数据库不可用。" + session_db_unavailable_prefix: "会话数据库不可用" + session_not_found: "数据库中未找到该会话。" + warn_passthrough: "⚠️ {error}" diff --git a/mcp_serve.py b/mcp_serve.py index d10306fb5c7..5ae0261d9af 100644 --- a/mcp_serve.py +++ b/mcp_serve.py @@ -169,7 +169,7 @@ def _extract_attachments(msg: dict) -> List[dict]: url = part.get("url", part.get("source", {}).get("url", "")) if url: attachments.append({"type": "image", "url": url}) - elif ptype not in ("text",): + elif ptype not in {"text",}: # Unknown non-text content type attachments.append({"type": ptype, "data": part}) @@ -414,7 +414,7 @@ class EventBridge: for msg in messages: ts = _ts_float(msg.get("timestamp", 0)) role = msg.get("role", "") - if role not in ("user", "assistant"): + if role not in {"user", "assistant"}: continue if ts > last_seen: new_messages.append(msg) @@ -594,7 +594,7 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP": filtered = [] for msg in all_messages: role = msg.get("role", "") - if role in ("user", "assistant"): + if role in {"user", "assistant"}: content = _extract_message_content(msg) if content: filtered.append({ @@ -847,7 +847,7 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP": id: The approval ID from permissions_list_open decision: One of "allow-once", "allow-always", or "deny" """ - if decision not in ("allow-once", "allow-always", "deny"): + if decision not in {"allow-once", "allow-always", "deny"}: return json.dumps({ "error": f"Invalid decision: {decision}. " f"Must be allow-once, allow-always, or deny" diff --git a/model_tools.py b/model_tools.py index 253cf02fe8d..0b9178111a5 100644 --- a/model_tools.py +++ b/model_tools.py @@ -353,9 +353,8 @@ def _compute_tool_definitions( tools_to_include.update(legacy_tools) if not quiet_mode: print(f"✅ Enabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}") - else: - if not quiet_mode: - print(f"⚠️ Unknown toolset: {toolset_name}") + elif not quiet_mode: + print(f"⚠️ Unknown toolset: {toolset_name}") else: # Default: start with everything from toolsets import get_all_toolsets @@ -378,9 +377,8 @@ def _compute_tool_definitions( tools_to_include.difference_update(legacy_tools) if not quiet_mode: print(f"🚫 Disabled legacy toolset '{toolset_name}': {', '.join(legacy_tools)}") - else: - if not quiet_mode: - print(f"⚠️ Unknown toolset: {toolset_name}") + elif not quiet_mode: + print(f"⚠️ Unknown toolset: {toolset_name}") # Plugin-registered tools are now resolved through the normal toolset # path — validate_toolset() / resolve_toolset() / get_all_toolsets() @@ -600,7 +598,7 @@ def _coerce_value(value: str, expected_type, schema: dict | None = None): return result return value - if expected_type in ("integer", "number"): + if expected_type in {"integer", "number"}: return _coerce_number(value, integer_only=(expected_type == "integer")) if expected_type == "boolean": return _coerce_boolean(value) diff --git a/nix/checks.nix b/nix/checks.nix index 8adb56628d2..2bd4f642bbd 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -240,6 +240,27 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) echo "ok" > $out/result ''; + # Verify extraDependencyGroups passes through to python.nix + extra-dependency-groups = let + hermesWithGroups = hermes-agent.override { + extraDependencyGroups = [ "honcho" ]; + }; + in pkgs.runCommand "hermes-extra-dependency-groups" { } '' + set -e + echo "=== Checking extraDependencyGroups override evaluates ===" + + # Eval-only: verify the override produces valid derivation paths + # without building the full venv (which is expensive and redundant + # since the mechanism is just list concatenation into python.nix). + echo "derivation: ${hermesWithGroups}" + echo "venv: ${hermesWithGroups.hermesVenv}" + echo "PASS: extraDependencyGroups override evaluates cleanly" + + echo "=== All extraDependencyGroups checks passed ===" + mkdir -p $out + echo "ok" > $out/result + ''; + # ── Config merge + round-trip test ──────────────────────────────── # Tests the merge script (Nix activation behavior) across 7 # scenarios, then verifies Python's load_config() reads correctly. diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index c3bde20c81c..ce8be16cfdd 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -1,7 +1,9 @@ # nix/hermes-agent.nix — Overridable Hermes Agent package # # callPackage auto-wires nixpkgs args; flake inputs are passed explicitly. -# Users override via: pkgs.hermes-agent.override { extraPythonPackages = [...]; } +# Users override via: +# pkgs.hermes-agent.override { extraPythonPackages = [...]; } +# pkgs.hermes-agent.override { extraDependencyGroups = [ "hindsight" ]; } { lib, stdenv, @@ -25,11 +27,13 @@ rev ? null, # Overridable parameters extraPythonPackages ? [ ], + extraDependencyGroups ? [ ], }: let nodejs = nodejs_22; hermesVenv = callPackage ./python.nix { inherit uv2nix pyproject-nix pyproject-build-systems; + dependency-groups = [ "all" ] ++ extraDependencyGroups; }; hermesNpmLib = callPackage ./lib.nix { diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index fbff28e18b6..f5c067a6398 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -28,8 +28,10 @@ let cfg = config.services.hermes-agent; - effectivePackage = if cfg.extraPythonPackages == [ ] then cfg.package - else cfg.package.override { inherit (cfg) extraPythonPackages; }; + effectivePackage = + if cfg.extraPythonPackages == [ ] && cfg.extraDependencyGroups == [ ] + then cfg.package + else cfg.package.override { inherit (cfg) extraPythonPackages extraDependencyGroups; }; hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default; # Deep-merge config type (from 0xrsydn/nix-hermes-agent) @@ -115,9 +117,13 @@ chown "$HERMES_UID:$HERMES_GID" "$TARGET_HOME" chmod 0750 "$TARGET_HOME" - # Ensure HERMES_HOME is owned by the target user + # Ensure HERMES_HOME is owned by the target user. + # Use find instead of chown -R: chown strips the setgid bit (kernel + # behavior), destroying the 2770 permissions the NixOS activation + # script sets for group access by hostUsers. Only touch files with + # wrong ownership so correctly-owned dirs keep their permission bits. if [ -n "''${HERMES_HOME:-}" ] && [ -d "$HERMES_HOME" ]; then - chown -R "$HERMES_UID:$HERMES_GID" "$HERMES_HOME" + find "$HERMES_HOME" \! -user "$HERMES_UID" -exec chown "$HERMES_UID:$HERMES_GID" {} + fi # ── Provision apt packages (first boot only, cached in writable layer) ── @@ -512,6 +518,21 @@ ''; }; + extraDependencyGroups = mkOption { + type = types.listOf types.str; + default = [ ]; + description = '' + Additional pyproject.toml optional-dependency groups to include in + the sealed Python venv. These are resolved by uv alongside core + dependencies — no PYTHONPATH patching or collision risk. + + Use this for optional extras already declared in hermes-agent's + pyproject.toml (e.g. "hindsight", "honcho", "voice"). + Use extraPythonPackages for external packages not in pyproject.toml. + ''; + example = [ "hindsight" ]; + }; + restart = mkOption { type = types.str; default = "always"; diff --git a/optional-skills/blockchain/hyperliquid/SKILL.md b/optional-skills/blockchain/hyperliquid/SKILL.md new file mode 100644 index 00000000000..ec0671e0508 --- /dev/null +++ b/optional-skills/blockchain/hyperliquid/SKILL.md @@ -0,0 +1,211 @@ +--- +name: hyperliquid +description: Hyperliquid market data, account history, trade review. +version: 0.1.0 +author: Hugo Sequier (Hugo-SEQUIER), Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [Hyperliquid, Blockchain, Crypto, Trading, Perpetuals, Spot, DeFi] + related_skills: [] +--- + +# Hyperliquid Skill + +Query Hyperliquid market and account data through the public `/info` endpoint. +Read-only — no API key, no signing, no order placement. + +12 commands: `dexs`, `markets`, `spots`, `candles`, `funding`, `l2`, `state`, +`spot-balances`, `fills`, `orders`, `review`, `export`. Stdlib only +(`urllib`, `json`, `argparse`). + +--- + +## When to Use + +- User asks for Hyperliquid perp or spot market data, candles, funding, or L2 book +- User wants to inspect a wallet's perp positions, spot balances, fills, or orders +- User wants a post-trade review combining recent fills with market context +- User wants to inspect builder-deployed perp dexs or HIP-3 markets +- User wants a normalized JSON export of candles + funding for backtesting prep + +--- + +## Prerequisites + +Stdlib only — no external packages, no API key. + +The script reads `~/.hermes/.env` for two optional defaults: + +- `HYPERLIQUID_API_URL` — defaults to `https://api.hyperliquid.xyz`. Set to + `https://api.hyperliquid-testnet.xyz` for testnet. +- `HYPERLIQUID_USER_ADDRESS` — default address for `state`, `spot-balances`, + `fills`, `orders`, and `review`. If unset, pass the address as the first + positional argument. + +A project `.env` in the current working directory is honored as a dev fallback. + +Helper script: `~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py` + +--- + +## How to Run + +Invoke through the `terminal` tool: + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py <command> [args] +``` + +Add `--json` to any command for machine-readable output. + +--- + +## Quick Reference + +```bash +hyperliquid_client.py dexs +hyperliquid_client.py markets [--dex DEX] [--limit N] [--sort volume|oi|funding_abs|change_abs|name] +hyperliquid_client.py spots [--limit N] +hyperliquid_client.py candles <coin> [--interval 1h] [--hours 24] [--limit N] +hyperliquid_client.py funding <coin> [--hours 72] [--limit N] +hyperliquid_client.py l2 <coin> [--levels N] +hyperliquid_client.py state [address] [--dex DEX] +hyperliquid_client.py spot-balances [address] [--limit N] +hyperliquid_client.py fills [address] [--hours N] [--limit N] [--aggregate-by-time] +hyperliquid_client.py orders [address] [--limit N] +hyperliquid_client.py review [address] [--coin COIN] [--hours N] [--fills N] +hyperliquid_client.py export <coin> [--interval 1h] [--hours N] [--output PATH] +``` + +For `state`, `spot-balances`, `fills`, `orders`, and `review`, the address is +optional when `HYPERLIQUID_USER_ADDRESS` is set in `~/.hermes/.env`. + +--- + +## Procedure + +### 1. Discover DEXs and Markets + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py dexs + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + markets --limit 15 --sort volume + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + spots --limit 15 +``` + +- `--dex` only applies to perp endpoints; omit for the first perp dex. +- Spot pairs may show as `PURR/USDC` or aliases like `@107`. +- HIP-3 markets prefix the coin with the dex, e.g. `mydex:BTC`. + +### 2. Pull Historical Market Data + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + candles BTC --interval 1h --hours 72 --limit 48 + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + funding BTC --hours 168 --limit 30 +``` + +Time-range endpoints paginate. For larger windows, repeat with a later +`startTime` or use `export` (below). + +### 3. Inspect Live Order Book + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + l2 BTC --levels 10 +``` + +Use when asked about book depth, near-term liquidity, or potential market +impact of a large order. + +### 4. Review an Account + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + state 0xabc... + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + spot-balances +``` + +`state` returns perp positions; `spot-balances` returns spot inventory. +Use these for "how are my positions?", "what am I holding?", "how much is +withdrawable?". + +### 5. Review Fills and Orders + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + fills 0xabc... --hours 72 --limit 25 + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + orders --limit 25 +``` + +### 6. Generate a Trade Review + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + review 0xabc... --hours 72 --fills 50 + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + review --coin BTC --hours 168 +``` + +Reports realized PnL, fees, win/loss counts, coin breakdowns, market trend +and average funding for each traded perp, plus heuristics (fee drag, +concentration, counter-trend losses). + +For deeper post-trade analysis: start with `review` to find problem coins +or windows → pull `fills` and `orders` for that period → pull `candles` +and `funding` for each traded coin → judge decision quality separately +from outcome quality. + +### 7. Export a Reusable Dataset + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + export BTC --interval 1h --hours 168 --output ./btc-1h-7d.json + +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + export BTC --interval 15m --hours 72 --end-time-ms 1760000000000 +``` + +Output JSON contains: schema version, source metadata, exact time window, +normalized candle rows, normalized funding rows, summary stats. Use +`--end-time-ms` for reproducible windows. + +--- + +## Pitfalls + +- Public info endpoints are rate-limited. Large historical queries may + return capped windows; iterate with later `startTime` values. +- `fills --hours ...` uses `userFillsByTime`, which only exposes a + recent rolling window — not full archive history. +- `historicalOrders` returns recent orders only; not a full export. +- The `review` command is heuristic. It cannot reconstruct intent, + order placement quality, or true slippage from fills alone. +- The `export` command writes a normalized dataset, not a backtest + engine. You still need your own slippage/fill model. +- Spot aliases like `@107` are valid identifiers even when the UI shows + a friendlier name. +- `l2` is a point-in-time snapshot, not a time series. + +--- + +## Verification + +```bash +python3 ~/.hermes/skills/blockchain/hyperliquid/scripts/hyperliquid_client.py \ + markets --limit 5 +``` + +Should print the top Hyperliquid perp markets by 24h notional volume. diff --git a/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py b/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py new file mode 100644 index 00000000000..1079f6b6267 --- /dev/null +++ b/optional-skills/blockchain/hyperliquid/scripts/hyperliquid_client.py @@ -0,0 +1,1660 @@ +#!/usr/bin/env python3 +""" +Hyperliquid CLI Tool for Hermes Agent +------------------------------------- +Queries the Hyperliquid info endpoint for market and account data. +Uses only Python standard library - no external packages required. + +Usage: + python3 hyperliquid_client.py dexs + python3 hyperliquid_client.py markets [--dex DEX] [--limit N] + python3 hyperliquid_client.py spots [--limit N] + python3 hyperliquid_client.py candles <coin> [--interval 1h] [--hours 24] + python3 hyperliquid_client.py funding <coin> [--hours 72] + python3 hyperliquid_client.py l2 <coin> [--levels 10] + python3 hyperliquid_client.py state [address] [--dex DEX] + python3 hyperliquid_client.py spot-balances [address] + python3 hyperliquid_client.py fills [address] [--hours N] [--limit N] + python3 hyperliquid_client.py orders [address] [--limit N] + python3 hyperliquid_client.py review [address] [--coin COIN] [--hours N] + python3 hyperliquid_client.py export <coin> [--interval 1h] [--hours N] + +Environment: + HYPERLIQUID_API_URL Override API base URL + (default: https://api.hyperliquid.xyz) + HYPERLIQUID_USER_ADDRESS Default address for state/fills/orders/review commands +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import sys +import time +import urllib.error +import urllib.request +from collections import Counter +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + + +USER_AGENT = "HermesAgent/1.0" +DEFAULT_USER_ENV = "HYPERLIQUID_USER_ADDRESS" +DEFAULT_API_BASE = "https://api.hyperliquid.xyz" + + +def _hermes_home() -> Path: + return Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser() + + +def _dotenv_paths() -> List[Path]: + paths: List[Path] = [] + project_env = Path.cwd() / ".env" + if project_env.exists(): + paths.append(project_env) + + user_env = _hermes_home() / ".env" + if user_env.exists(): + paths.append(user_env) + + return paths + + +def _load_dotenv_values() -> Dict[str, str]: + values: Dict[str, str] = {} + for env_path in _dotenv_paths(): + try: + lines = env_path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + lines = env_path.read_text(encoding="latin-1").splitlines() + + for raw_line in lines: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = raw_line.partition("=") + key = key.strip() + value = value.strip() + if value.startswith('"') and value.endswith('"') and len(value) >= 2: + value = value[1:-1].replace('\\"', '"').replace('\\\\', '\\') + values[key] = value + return values + + +def _env_lookup(key: str, default: str = "") -> str: + value = os.environ.get(key, "").strip() + if value: + return value + dotenv_value = _load_dotenv_values().get(key, "").strip() + if dotenv_value: + return dotenv_value + return default + + +def _api_base() -> str: + return _env_lookup("HYPERLIQUID_API_URL", DEFAULT_API_BASE).rstrip("/") + + +def _info_url() -> str: + api_base = _api_base() + if api_base.endswith("/info"): + return api_base + return f"{api_base}/info" + + +def _resolve_user(user: Optional[str]) -> str: + candidate = (user or "").strip() + if candidate: + return candidate + + env_value = _env_lookup(DEFAULT_USER_ENV, "") + if env_value: + return env_value + + sys.exit( + "Missing Hyperliquid address. Pass <address> explicitly or set " + f"{DEFAULT_USER_ENV} in your environment or ~/.hermes/.env." + ) + + +def _post_info(payload: Dict[str, Any], timeout: int = 20, retries: int = 2) -> Any: + data = json.dumps(payload).encode("utf-8") + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + + for attempt in range(retries + 1): + request = urllib.request.Request(_info_url(), data=data, headers=headers, method="POST") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = json.load(response) + return body + except urllib.error.HTTPError as exc: + if exc.code == 429 and attempt < retries: + time.sleep(1.5 * (attempt + 1)) + continue + sys.exit(f"Hyperliquid HTTP error: {exc}") + except urllib.error.URLError as exc: + sys.exit(f"Hyperliquid connection error: {exc}") + except json.JSONDecodeError as exc: + sys.exit(f"Hyperliquid response was not valid JSON: {exc}") + + return None + + +def _safe_float(value: Any) -> Optional[float]: + try: + if value is None or value == "": + return None + return float(value) + except (TypeError, ValueError): + return None + + +def _limit_items(items: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]: + if limit <= 0: + return items + return items[:limit] + + +def _hours_ago_ms(hours: float, now_ms: Optional[int] = None) -> int: + end_ms = now_ms if now_ms is not None else int(time.time() * 1000) + return end_ms - int(hours * 60 * 60 * 1000) + + +def _format_timestamp_ms(value: Any) -> str: + try: + ts_ms = int(value) + except (TypeError, ValueError): + return "-" + return dt.datetime.utcfromtimestamp(ts_ms / 1000).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def _compact_number(value: Any, decimals: int = 2) -> str: + number = _safe_float(value) + if number is None: + return "-" + sign = "-" if number < 0 else "" + number = abs(number) + if number >= 1_000_000_000: + return f"{sign}{number / 1_000_000_000:.{decimals}f}B" + if number >= 1_000_000: + return f"{sign}{number / 1_000_000:.{decimals}f}M" + if number >= 1_000: + return f"{sign}{number / 1_000:.{decimals}f}K" + if number >= 100: + return f"{sign}{number:.2f}" + if number >= 1: + return f"{sign}{number:.4f}".rstrip("0").rstrip(".") + return f"{sign}{number:.6f}".rstrip("0").rstrip(".") + + +def _format_price(value: Any) -> str: + number = _safe_float(value) + if number is None: + return "-" + if abs(number) >= 1000: + return f"{number:,.2f}" + if abs(number) >= 1: + return f"{number:,.4f}".rstrip("0").rstrip(".") + return f"{number:,.6f}".rstrip("0").rstrip(".") + + +def _format_percent(value: Any, decimals: int = 2) -> str: + number = _safe_float(value) + if number is None: + return "-" + return f"{number:+.{decimals}f}%" + + +def _format_fraction_percent(value: Any, decimals: int = 4) -> str: + number = _safe_float(value) + if number is None: + return "-" + return f"{number * 100:+.{decimals}f}%" + + +def _percent_change(current: Any, previous: Any) -> Optional[float]: + curr = _safe_float(current) + prev = _safe_float(previous) + if curr is None or prev is None or prev == 0: + return None + return ((curr - prev) / prev) * 100 + + +def _short_address(address: Any) -> str: + if not isinstance(address, str) or len(address) < 12: + return str(address) + return f"{address[:6]}...{address[-4:]}" + + +def _render_table(headers: List[tuple[str, str]], rows: List[Dict[str, Any]]) -> str: + if not rows: + return "(no data)" + + prepared_rows: List[List[str]] = [] + widths = [len(label) for label, _ in headers] + + for row in rows: + rendered = [] + for index, (_label, key) in enumerate(headers): + value = row.get(key, "") + text = str(value) + rendered.append(text) + if len(text) > widths[index]: + widths[index] = len(text) + prepared_rows.append(rendered) + + lines = [] + header_line = " ".join(label.ljust(widths[idx]) for idx, (label, _key) in enumerate(headers)) + separator = " ".join("-" * widths[idx] for idx in range(len(headers))) + lines.extend([header_line, separator]) + + for rendered in prepared_rows: + lines.append(" ".join(rendered[idx].ljust(widths[idx]) for idx in range(len(rendered)))) + return "\n".join(lines) + + +def _normalize_dexs(payload: Any) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + if not isinstance(payload, list): + return rows + + for index, item in enumerate(payload): + if item is None: + rows.append( + { + "index": index, + "name": "", + "label": "first-perp-dex", + "full_name": "First perp dex", + "deployer": "-", + "asset_caps": 0, + } + ) + continue + + if not isinstance(item, dict): + continue + + caps = item.get("assetToStreamingOiCap") or [] + rows.append( + { + "index": index, + "name": item.get("name", ""), + "label": item.get("name") or "first-perp-dex", + "full_name": item.get("fullName") or "-", + "deployer": item.get("deployer") or "-", + "asset_caps": len(caps) if isinstance(caps, list) else 0, + } + ) + return rows + + +def _normalize_perp_markets(payload: Any) -> List[Dict[str, Any]]: + if not isinstance(payload, list) or len(payload) < 2: + return [] + + meta = payload[0] if isinstance(payload[0], dict) else {} + ctxs = payload[1] if isinstance(payload[1], list) else [] + universe = meta.get("universe") if isinstance(meta, dict) else [] + if not isinstance(universe, list): + return [] + + rows: List[Dict[str, Any]] = [] + for index, spec in enumerate(universe): + if not isinstance(spec, dict): + continue + ctx = ctxs[index] if index < len(ctxs) and isinstance(ctxs[index], dict) else {} + mark_px = ctx.get("markPx") or ctx.get("midPx") or ctx.get("oraclePx") + row = { + "coin": spec.get("name", f"asset-{index}"), + "mark_px": mark_px, + "mid_px": ctx.get("midPx"), + "oracle_px": ctx.get("oraclePx"), + "prev_day_px": ctx.get("prevDayPx"), + "change_pct": _percent_change(mark_px, ctx.get("prevDayPx")), + "funding": ctx.get("funding"), + "premium": ctx.get("premium"), + "open_interest": ctx.get("openInterest"), + "day_ntl_vlm": ctx.get("dayNtlVlm"), + "day_base_vlm": ctx.get("dayBaseVlm"), + "max_leverage": spec.get("maxLeverage"), + "sz_decimals": spec.get("szDecimals"), + "is_delisted": bool(spec.get("isDelisted")), + "only_isolated": bool(spec.get("onlyIsolated")), + "margin_mode": spec.get("marginMode") or "-", + } + rows.append(row) + return rows + + +def _normalize_spot_markets(payload: Any) -> List[Dict[str, Any]]: + if not isinstance(payload, list) or len(payload) < 2: + return [] + + meta = payload[0] if isinstance(payload[0], dict) else {} + ctxs = payload[1] if isinstance(payload[1], list) else [] + pairs = meta.get("universe") if isinstance(meta, dict) else [] + tokens = meta.get("tokens") if isinstance(meta, dict) else [] + token_lookup = {} + if isinstance(tokens, list): + for token in tokens: + if isinstance(token, dict) and "index" in token: + token_lookup[token["index"]] = token.get("name", str(token["index"])) + + rows: List[Dict[str, Any]] = [] + if not isinstance(pairs, list): + return rows + + for index, pair in enumerate(pairs): + if not isinstance(pair, dict): + continue + ctx = ctxs[index] if index < len(ctxs) and isinstance(ctxs[index], dict) else {} + raw_name = pair.get("name", f"@{index}") + tokens_for_pair = pair.get("tokens") if isinstance(pair.get("tokens"), list) else [] + display_name = raw_name + if "/" not in raw_name and len(tokens_for_pair) == 2: + base = token_lookup.get(tokens_for_pair[0], str(tokens_for_pair[0])) + quote = token_lookup.get(tokens_for_pair[1], str(tokens_for_pair[1])) + display_name = f"{base}/{quote} ({raw_name})" + + mark_px = ctx.get("markPx") or ctx.get("midPx") + rows.append( + { + "pair": raw_name, + "display_name": display_name, + "mark_px": mark_px, + "mid_px": ctx.get("midPx"), + "prev_day_px": ctx.get("prevDayPx"), + "change_pct": _percent_change(mark_px, ctx.get("prevDayPx")), + "day_ntl_vlm": ctx.get("dayNtlVlm"), + } + ) + return rows + + +def _normalize_candles(payload: Any) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + if not isinstance(payload, list): + return rows + + for candle in payload: + if not isinstance(candle, dict): + continue + rows.append( + { + "time": candle.get("t") or candle.get("time"), + "open": candle.get("o"), + "high": candle.get("h"), + "low": candle.get("l"), + "close": candle.get("c"), + "volume": candle.get("v"), + "trades": candle.get("n"), + } + ) + + rows.sort(key=lambda item: int(item.get("time") or 0)) + return rows + + +def _normalize_funding_history(payload: Any) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + if not isinstance(payload, list): + return rows + + for item in payload: + if not isinstance(item, dict): + continue + rows.append( + { + "coin": item.get("coin", "-"), + "funding_rate": item.get("fundingRate"), + "premium": item.get("premium"), + "time": item.get("time"), + } + ) + + rows.sort(key=lambda item: int(item.get("time") or 0)) + return rows + + +def _normalize_book_levels(payload: Any) -> Dict[str, List[Dict[str, Any]]]: + if not isinstance(payload, dict): + return {"bids": [], "asks": []} + + levels = payload.get("levels") + if not isinstance(levels, list) or len(levels) < 2: + return {"bids": [], "asks": []} + + def convert(side: Iterable[Any]) -> List[Dict[str, Any]]: + converted = [] + for entry in side: + if isinstance(entry, dict): + converted.append( + { + "px": entry.get("px"), + "sz": entry.get("sz"), + "orders": entry.get("n"), + } + ) + elif isinstance(entry, (list, tuple)) and len(entry) >= 2: + converted.append( + { + "px": entry[0], + "sz": entry[1], + "orders": entry[2] if len(entry) > 2 else None, + } + ) + return converted + + return {"bids": convert(levels[0]), "asks": convert(levels[1])} + + +def _normalize_positions(payload: Any) -> Dict[str, Any]: + if not isinstance(payload, dict): + return {"summary": {}, "positions": []} + + positions: List[Dict[str, Any]] = [] + for item in payload.get("assetPositions", []): + if not isinstance(item, dict): + continue + position = item.get("position") if isinstance(item.get("position"), dict) else item + if not isinstance(position, dict): + continue + leverage = position.get("leverage") if isinstance(position.get("leverage"), dict) else {} + positions.append( + { + "coin": position.get("coin", "-"), + "size": position.get("szi"), + "entry_px": position.get("entryPx"), + "position_value": position.get("positionValue"), + "unrealized_pnl": position.get("unrealizedPnl"), + "return_on_equity": position.get("returnOnEquity"), + "liquidation_px": position.get("liquidationPx"), + "margin_used": position.get("marginUsed"), + "leverage": leverage.get("value"), + "leverage_type": leverage.get("type"), + } + ) + + positions.sort( + key=lambda item: abs(_safe_float(item.get("position_value")) or 0.0), + reverse=True, + ) + + summary = payload.get("marginSummary") if isinstance(payload.get("marginSummary"), dict) else {} + cross_summary = ( + payload.get("crossMarginSummary") if isinstance(payload.get("crossMarginSummary"), dict) else {} + ) + + return { + "summary": { + "account_value": summary.get("accountValue"), + "total_ntl_pos": summary.get("totalNtlPos"), + "total_raw_usd": summary.get("totalRawUsd"), + "withdrawable": payload.get("withdrawable"), + "cross_account_value": cross_summary.get("accountValue"), + }, + "positions": positions, + } + + +def _normalize_spot_balances(payload: Any) -> List[Dict[str, Any]]: + if not isinstance(payload, dict): + return [] + + rows: List[Dict[str, Any]] = [] + for item in payload.get("balances", []): + if not isinstance(item, dict): + continue + rows.append( + { + "coin": item.get("coin", item.get("token", "-")), + "total": item.get("total"), + "hold": item.get("hold"), + "entry_ntl": item.get("entryNtl"), + } + ) + + rows.sort(key=lambda item: abs(_safe_float(item.get("entry_ntl")) or 0.0), reverse=True) + return rows + + +def _normalize_fills(payload: Any) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + if not isinstance(payload, list): + return rows + + for item in payload: + if not isinstance(item, dict): + continue + fill = item.get("fill") if isinstance(item.get("fill"), dict) else item + rows.append( + { + "coin": fill.get("coin", "-"), + "dir": fill.get("dir") or fill.get("side") or "-", + "px": fill.get("px"), + "sz": fill.get("sz"), + "closed_pnl": fill.get("closedPnl"), + "fee": fill.get("fee"), + "fee_token": fill.get("feeToken"), + "start_position": fill.get("startPosition"), + "time": fill.get("time"), + "hash": fill.get("hash"), + "oid": fill.get("oid"), + "twap_id": item.get("twapId"), + } + ) + + rows.sort(key=lambda item: int(item.get("time") or 0), reverse=True) + return rows + + +def _normalize_orders(payload: Any) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + if not isinstance(payload, list): + return rows + + for item in payload: + if not isinstance(item, dict): + continue + order = item.get("order") if isinstance(item.get("order"), dict) else item + rows.append( + { + "coin": order.get("coin", "-"), + "side": order.get("side", "-"), + "limit_px": order.get("limitPx") or order.get("px"), + "size": order.get("sz") or order.get("origSz"), + "timestamp": item.get("statusTimestamp") + or order.get("timestamp") + or order.get("time"), + "status": item.get("status") or order.get("status") or "-", + "oid": order.get("oid"), + "order_type": order.get("orderType") or "-", + } + ) + + rows.sort(key=lambda item: int(item.get("timestamp") or 0), reverse=True) + return rows + + +def _direction_bucket(direction: Any) -> str: + text = str(direction or "").strip().lower() + if "open" in text and "long" in text: + return "open_long" + if "close" in text and "long" in text: + return "close_long" + if "open" in text and "short" in text: + return "open_short" + if "close" in text and "short" in text: + return "close_short" + if text in {"b", "buy"}: + return "buy" + if text in {"s", "sell"}: + return "sell" + return "other" + + +def _average(values: Iterable[Optional[float]]) -> Optional[float]: + clean_values = [value for value in values if value is not None] + if not clean_values: + return None + return round(sum(clean_values) / len(clean_values), 12) + + +def _is_spot_coin(coin: str) -> bool: + return "/" in coin or coin.startswith("@") + + +def _safe_info_query(payload: Dict[str, Any]) -> Any: + try: + return _post_info(payload) + except SystemExit: + return None + + +def _market_context_for_coin(coin: str, interval: str, start_ms: int, end_ms: int) -> Dict[str, Any]: + candles = _normalize_candles( + _safe_info_query( + { + "type": "candleSnapshot", + "req": { + "coin": coin, + "interval": interval, + "startTime": start_ms, + "endTime": end_ms, + }, + } + ) + ) + funding_history: List[Dict[str, Any]] = [] + if not _is_spot_coin(coin): + funding_history = _normalize_funding_history( + _safe_info_query( + { + "type": "fundingHistory", + "coin": coin, + "startTime": start_ms, + "endTime": end_ms, + } + ) + ) + + candle_change = None + if candles: + candle_change = _percent_change(candles[-1].get("close"), candles[0].get("open")) + + funding_average = _average(_safe_float(item.get("funding_rate")) for item in funding_history) + return { + "coin": coin, + "interval": interval, + "candle_count": len(candles), + "price_change_pct": candle_change, + "window_open": candles[0].get("open") if candles else None, + "window_close": candles[-1].get("close") if candles else None, + "average_funding_rate": funding_average, + "funding_samples": len(funding_history), + } + + +def _build_coin_review(coin: str, fills: List[Dict[str, Any]], interval: str, start_ms: int, end_ms: int) -> Dict[str, Any]: + pnl_values = [_safe_float(fill.get("closed_pnl")) for fill in fills] + fee_values = [_safe_float(fill.get("fee")) for fill in fills] + scored = [value for value in pnl_values if value is not None] + wins = [value for value in scored if value > 0] + losses = [value for value in scored if value < 0] + breakeven = [value for value in scored if value == 0] + + direction_counts = Counter(_direction_bucket(fill.get("dir")) for fill in fills) + market_context = _market_context_for_coin(coin, interval, start_ms, end_ms) + total_pnl = sum(value for value in pnl_values if value is not None) + total_fees = sum(value for value in fee_values if value is not None) + net_after_fees = total_pnl - total_fees + + if direction_counts["open_long"] > direction_counts["open_short"]: + open_bias = "long" + elif direction_counts["open_short"] > direction_counts["open_long"]: + open_bias = "short" + elif direction_counts["open_long"] or direction_counts["open_short"]: + open_bias = "mixed" + else: + open_bias = "none" + + return { + "coin": coin, + "fill_count": len(fills), + "realized_pnl": total_pnl, + "total_fees": total_fees, + "net_after_fees": net_after_fees, + "wins": len(wins), + "losses": len(losses), + "breakeven": len(breakeven), + "win_rate_pct": (len(wins) / (len(wins) + len(losses)) * 100) if (len(wins) + len(losses)) else None, + "open_long_count": direction_counts["open_long"], + "open_short_count": direction_counts["open_short"], + "close_long_count": direction_counts["close_long"], + "close_short_count": direction_counts["close_short"], + "open_bias": open_bias, + "market_context": market_context, + } + + +def _review_findings(summary: Dict[str, Any], coin_reviews: List[Dict[str, Any]]) -> List[str]: + findings: List[str] = [] + + if summary["fill_count"] == 0: + return ["No fills were found in the requested review window."] + + if summary["outcome_fill_count"] == 0: + findings.append("Most fills in this window look like opens or adjustments, so realized-outcome review is limited until positions close.") + + if summary["net_after_fees"] < 0: + findings.append( + f"Net realized PnL after fees was negative ({_compact_number(summary['net_after_fees'])} USDC-equivalent units in reported fill terms)." + ) + elif summary["net_after_fees"] > 0: + findings.append( + f"Net realized PnL after fees was positive ({_compact_number(summary['net_after_fees'])} USDC-equivalent units in reported fill terms)." + ) + + realized_abs = abs(summary["realized_pnl"]) + if summary["total_fees"] > 0: + if realized_abs == 0: + findings.append("Fees were non-trivial while realized PnL stayed flat, which usually means churn without enough edge.") + elif summary["total_fees"] / realized_abs >= 0.25: + ratio_pct = (summary["total_fees"] / realized_abs) * 100 + findings.append(f"Fees consumed about {ratio_pct:.1f}% of absolute realized PnL, so execution efficiency is materially affecting results.") + + if summary["fill_count"] >= 20 and summary["net_after_fees"] < 0: + win_rate = summary.get("win_rate_pct") + if win_rate is None or win_rate < 45: + findings.append("Activity was high relative to results, which suggests overtrading in this review window.") + + if coin_reviews: + worst_coin = min(coin_reviews, key=lambda item: item["net_after_fees"]) + best_coin = max(coin_reviews, key=lambda item: item["net_after_fees"]) + if worst_coin["net_after_fees"] < 0: + findings.append( + f"The weakest coin was {worst_coin['coin']} with net after fees of {_compact_number(worst_coin['net_after_fees'])}." + ) + if best_coin["net_after_fees"] > 0 and best_coin["coin"] != worst_coin["coin"]: + findings.append( + f"The strongest coin was {best_coin['coin']} with net after fees of {_compact_number(best_coin['net_after_fees'])}." + ) + + for item in coin_reviews: + market_change = item["market_context"].get("price_change_pct") + if item["net_after_fees"] >= 0 or market_change is None: + continue + if market_change > 2 and item["open_short_count"] > item["open_long_count"]: + findings.append(f"{item['coin']}: losses came while leaning short into a rising market window.") + elif market_change < -2 and item["open_long_count"] > item["open_short_count"]: + findings.append(f"{item['coin']}: losses came while leaning long into a falling market window.") + + deduped: List[str] = [] + for finding in findings: + if finding not in deduped: + deduped.append(finding) + return deduped[:6] + + +def _recent_fill_rows(fills: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]: + rows = [] + for fill in _limit_items(fills, limit): + rows.append( + { + "time": fill.get("time"), + "coin": fill.get("coin"), + "dir": fill.get("dir"), + "px": fill.get("px"), + "sz": fill.get("sz"), + "closed_pnl": fill.get("closed_pnl"), + "fee": fill.get("fee"), + "fee_token": fill.get("fee_token"), + } + ) + return rows + + +def _coin_slug(coin: str) -> str: + slug = str(coin or "market").strip().lower() + for old, new in (("/", "-"), (":", "-"), ("@", "spot-"), (" ", "-")): + slug = slug.replace(old, new) + return slug or "market" + + +def _default_export_path(coin: str, interval: str, hours: float) -> Path: + hour_label = str(int(hours)) if float(hours).is_integer() else str(hours).replace(".", "p") + filename = f"hyperliquid-{_coin_slug(coin)}-{interval}-{hour_label}h.json" + return Path.cwd() / filename + + +def _write_json_file(path: Path, payload: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _export_summary(candles: List[Dict[str, Any]], funding_history: List[Dict[str, Any]]) -> Dict[str, Any]: + candle_change = None + if candles: + candle_change = _percent_change(candles[-1].get("close"), candles[0].get("open")) + return { + "candle_count": len(candles), + "funding_count": len(funding_history), + "window_open": candles[0].get("open") if candles else None, + "window_close": candles[-1].get("close") if candles else None, + "price_change_pct": candle_change, + "average_funding_rate": _average(_safe_float(item.get("funding_rate")) for item in funding_history), + } + + +def run_dexs(_args: argparse.Namespace) -> Dict[str, Any]: + payload = _post_info({"type": "perpDexs"}) + rows = _normalize_dexs(payload) + return {"api_url": _info_url(), "count": len(rows), "dexs": rows} + + +def run_markets(args: argparse.Namespace) -> Dict[str, Any]: + payload: Dict[str, Any] = {"type": "metaAndAssetCtxs"} + if args.dex: + payload["dex"] = args.dex + rows = _normalize_perp_markets(_post_info(payload)) + + if args.sort == "name": + rows.sort(key=lambda item: item["coin"]) + elif args.sort == "oi": + rows.sort(key=lambda item: _safe_float(item.get("open_interest")) or 0.0, reverse=True) + elif args.sort == "funding_abs": + rows.sort(key=lambda item: abs(_safe_float(item.get("funding")) or 0.0), reverse=True) + elif args.sort == "change_abs": + rows.sort(key=lambda item: abs(_safe_float(item.get("change_pct")) or 0.0), reverse=True) + else: + rows.sort(key=lambda item: _safe_float(item.get("day_ntl_vlm")) or 0.0, reverse=True) + + return { + "dex": args.dex or "", + "count": len(rows), + "sort": args.sort, + "markets": _limit_items(rows, args.limit), + } + + +def run_spots(args: argparse.Namespace) -> Dict[str, Any]: + rows = _normalize_spot_markets(_post_info({"type": "spotMetaAndAssetCtxs"})) + + if args.sort == "name": + rows.sort(key=lambda item: item["display_name"]) + elif args.sort == "change_abs": + rows.sort(key=lambda item: abs(_safe_float(item.get("change_pct")) or 0.0), reverse=True) + else: + rows.sort(key=lambda item: _safe_float(item.get("day_ntl_vlm")) or 0.0, reverse=True) + + return {"count": len(rows), "sort": args.sort, "pairs": _limit_items(rows, args.limit)} + + +def run_candles(args: argparse.Namespace) -> Dict[str, Any]: + end_ms = int(time.time() * 1000) + start_ms = _hours_ago_ms(args.hours, end_ms) + payload = { + "type": "candleSnapshot", + "req": { + "coin": args.coin, + "interval": args.interval, + "startTime": start_ms, + "endTime": end_ms, + }, + } + candles = _normalize_candles(_post_info(payload)) + summary = {} + if candles: + highs = [_safe_float(item.get("high")) for item in candles] + lows = [_safe_float(item.get("low")) for item in candles] + clean_highs = [value for value in highs if value is not None] + clean_lows = [value for value in lows if value is not None] + summary = { + "first_time": candles[0]["time"], + "last_time": candles[-1]["time"], + "open": candles[0]["open"], + "close": candles[-1]["close"], + "high": max(clean_highs) if clean_highs else None, + "low": min(clean_lows) if clean_lows else None, + "change_pct": _percent_change(candles[-1]["close"], candles[0]["open"]), + } + return { + "coin": args.coin, + "interval": args.interval, + "hours": args.hours, + "count": len(candles), + "summary": summary, + "candles": _limit_items(candles, args.limit), + } + + +def run_funding(args: argparse.Namespace) -> Dict[str, Any]: + end_ms = int(time.time() * 1000) + start_ms = _hours_ago_ms(args.hours, end_ms) + payload = {"type": "fundingHistory", "coin": args.coin, "startTime": start_ms, "endTime": end_ms} + rows = _normalize_funding_history(_post_info(payload)) + avg_rate = None + if rows: + values = [_safe_float(item.get("funding_rate")) for item in rows] + clean_values = [value for value in values if value is not None] + if clean_values: + avg_rate = sum(clean_values) / len(clean_values) + return { + "coin": args.coin, + "hours": args.hours, + "count": len(rows), + "average_funding_rate": avg_rate, + "history": _limit_items(list(reversed(rows)), args.limit), + } + + +def run_l2(args: argparse.Namespace) -> Dict[str, Any]: + payload: Dict[str, Any] = {"type": "l2Book", "coin": args.coin} + if args.n_sig_figs is not None: + payload["nSigFigs"] = args.n_sig_figs + if args.mantissa is not None: + payload["mantissa"] = args.mantissa + raw = _post_info(payload) + levels = _normalize_book_levels(raw) + return { + "coin": args.coin, + "time": raw.get("time") if isinstance(raw, dict) else None, + "bids": _limit_items(levels["bids"], args.levels), + "asks": _limit_items(levels["asks"], args.levels), + } + + +def run_state(args: argparse.Namespace) -> Dict[str, Any]: + user = _resolve_user(args.user) + payload: Dict[str, Any] = {"type": "clearinghouseState", "user": user} + if args.dex: + payload["dex"] = args.dex + normalized = _normalize_positions(_post_info(payload)) + return { + "user": user, + "dex": args.dex or "", + "summary": normalized["summary"], + "positions": normalized["positions"], + } + + +def run_spot_balances(args: argparse.Namespace) -> Dict[str, Any]: + user = _resolve_user(args.user) + payload = {"type": "spotClearinghouseState", "user": user} + rows = _normalize_spot_balances(_post_info(payload)) + return {"user": user, "count": len(rows), "balances": _limit_items(rows, args.limit)} + + +def run_fills(args: argparse.Namespace) -> Dict[str, Any]: + user = _resolve_user(args.user) + payload: Dict[str, Any] = {"user": user} + if args.hours is not None: + payload["type"] = "userFillsByTime" + payload["startTime"] = _hours_ago_ms(args.hours) + else: + payload["type"] = "userFills" + if args.aggregate_by_time: + payload["aggregateByTime"] = True + rows = _normalize_fills(_post_info(payload)) + return { + "user": user, + "hours": args.hours, + "aggregate_by_time": args.aggregate_by_time, + "count": len(rows), + "fills": _limit_items(rows, args.limit), + } + + +def run_orders(args: argparse.Namespace) -> Dict[str, Any]: + user = _resolve_user(args.user) + payload = {"type": "historicalOrders", "user": user} + rows = _normalize_orders(_post_info(payload)) + return {"user": user, "count": len(rows), "orders": _limit_items(rows, args.limit)} + + +def run_review(args: argparse.Namespace) -> Dict[str, Any]: + user = _resolve_user(args.user) + end_ms = int(time.time() * 1000) + start_ms = _hours_ago_ms(args.hours, end_ms) + payload: Dict[str, Any] = {"type": "userFillsByTime", "user": user, "startTime": start_ms} + if args.aggregate_by_time: + payload["aggregateByTime"] = True + + fills = _normalize_fills(_post_info(payload)) + if args.coin: + target = args.coin.lower() + fills = [fill for fill in fills if str(fill.get("coin", "")).lower() == target] + fills = _limit_items(fills, args.fills) + + grouped: Dict[str, List[Dict[str, Any]]] = {} + for fill in fills: + grouped.setdefault(fill.get("coin", "-"), []).append(fill) + + coin_reviews = [ + _build_coin_review(coin, coin_fills, args.interval, start_ms, end_ms) + for coin, coin_fills in sorted(grouped.items(), key=lambda item: len(item[1]), reverse=True) + ] + + pnl_values = [_safe_float(fill.get("closed_pnl")) for fill in fills] + fee_values = [_safe_float(fill.get("fee")) for fill in fills] + scored = [value for value in pnl_values if value is not None] + wins = [value for value in scored if value > 0] + losses = [value for value in scored if value < 0] + direction_counts = Counter(_direction_bucket(fill.get("dir")) for fill in fills) + total_pnl = sum(value for value in pnl_values if value is not None) + total_fees = sum(value for value in fee_values if value is not None) + + summary = { + "fill_count": len(fills), + "scored_fill_count": len(scored), + "outcome_fill_count": len(wins) + len(losses), + "unique_coins": len(grouped), + "realized_pnl": total_pnl, + "total_fees": total_fees, + "net_after_fees": total_pnl - total_fees, + "wins": len(wins), + "losses": len(losses), + "breakeven": len([value for value in scored if value == 0]), + "win_rate_pct": (len(wins) / (len(wins) + len(losses)) * 100) if (len(wins) + len(losses)) else None, + "open_long_count": direction_counts["open_long"], + "open_short_count": direction_counts["open_short"], + "close_long_count": direction_counts["close_long"], + "close_short_count": direction_counts["close_short"], + } + + return { + "user": user, + "coin_filter": args.coin, + "hours": args.hours, + "interval": args.interval, + "fills_requested": args.fills, + "summary": summary, + "findings": _review_findings(summary, coin_reviews), + "coin_reviews": coin_reviews, + "recent_fills": _recent_fill_rows(fills, args.recent), + } + + +def run_export(args: argparse.Namespace) -> Dict[str, Any]: + end_ms = args.end_time_ms if args.end_time_ms is not None else int(time.time() * 1000) + start_ms = _hours_ago_ms(args.hours, end_ms) + + candle_payload = { + "type": "candleSnapshot", + "req": { + "coin": args.coin, + "interval": args.interval, + "startTime": start_ms, + "endTime": end_ms, + }, + } + candles = _normalize_candles(_post_info(candle_payload)) + + funding_history: List[Dict[str, Any]] = [] + if not _is_spot_coin(args.coin): + funding_history = _normalize_funding_history( + _safe_info_query( + { + "type": "fundingHistory", + "coin": args.coin, + "startTime": start_ms, + "endTime": end_ms, + } + ) + ) + + output_path = Path(args.output) if args.output else _default_export_path(args.coin, args.interval, args.hours) + payload = { + "schema_version": "hyperliquid-market-export-v1", + "source": { + "api_url": _info_url(), + "interval": args.interval, + "coin": args.coin, + "market_type": "spot" if _is_spot_coin(args.coin) else "perp", + }, + "window": { + "start_time_ms": start_ms, + "end_time_ms": end_ms, + "hours": args.hours, + }, + "summary": _export_summary(candles, funding_history), + "candles": candles, + "funding_history": funding_history, + } + _write_json_file(output_path, payload) + return { + "coin": args.coin, + "interval": args.interval, + "hours": args.hours, + "output_path": str(output_path), + "summary": payload["summary"], + "schema_version": payload["schema_version"], + } + + +def render_dexs(data: Dict[str, Any]) -> str: + rows = [ + { + "label": item["label"], + "full_name": item["full_name"], + "deployer": _short_address(item["deployer"]), + "asset_caps": item["asset_caps"], + } + for item in data["dexs"] + ] + return "\n".join( + [ + f"API: {data['api_url']}", + f"Perp dexs: {data['count']}", + "", + _render_table( + [ + ("Dex", "label"), + ("Full Name", "full_name"), + ("Deployer", "deployer"), + ("Asset Caps", "asset_caps"), + ], + rows, + ), + ] + ) + + +def render_markets(data: Dict[str, Any]) -> str: + rows = [ + { + "coin": item["coin"], + "mark_px": _format_price(item["mark_px"]), + "change_pct": _format_percent(item["change_pct"]), + "funding": _format_fraction_percent(item["funding"]), + "open_interest": _compact_number(item["open_interest"]), + "day_ntl_vlm": _compact_number(item["day_ntl_vlm"]), + } + for item in data["markets"] + ] + lines = [ + f"Dex: {data['dex'] or 'first-perp-dex'}", + f"Markets returned: {len(data['markets'])} of {data['count']}", + "", + _render_table( + [ + ("Coin", "coin"), + ("Mark", "mark_px"), + ("Chg", "change_pct"), + ("Funding", "funding"), + ("OI", "open_interest"), + ("24h Vol", "day_ntl_vlm"), + ], + rows, + ), + ] + return "\n".join(lines) + + +def render_spots(data: Dict[str, Any]) -> str: + rows = [ + { + "pair": item["display_name"], + "mark_px": _format_price(item["mark_px"]), + "change_pct": _format_percent(item["change_pct"]), + "day_ntl_vlm": _compact_number(item["day_ntl_vlm"]), + } + for item in data["pairs"] + ] + return "\n".join( + [ + f"Spot pairs returned: {len(data['pairs'])} of {data['count']}", + "", + _render_table( + [ + ("Pair", "pair"), + ("Mark", "mark_px"), + ("Chg", "change_pct"), + ("24h Vol", "day_ntl_vlm"), + ], + rows, + ), + ] + ) + + +def render_candles(data: Dict[str, Any]) -> str: + rows = [ + { + "time": _format_timestamp_ms(item["time"]), + "open": _format_price(item["open"]), + "high": _format_price(item["high"]), + "low": _format_price(item["low"]), + "close": _format_price(item["close"]), + "volume": _compact_number(item["volume"]), + } + for item in data["candles"] + ] + summary = data.get("summary") or {} + lines = [ + f"Coin: {data['coin']}", + f"Interval: {data['interval']}", + f"Hours: {data['hours']}", + f"Candles returned: {len(data['candles'])} of {data['count']}", + ] + if summary: + lines.extend( + [ + f"Open -> Close: {_format_price(summary.get('open'))} -> {_format_price(summary.get('close'))}", + f"Range: {_format_price(summary.get('low'))} to {_format_price(summary.get('high'))}", + f"Change: {_format_percent(summary.get('change_pct'))}", + ] + ) + lines.extend( + [ + "", + _render_table( + [ + ("Time", "time"), + ("Open", "open"), + ("High", "high"), + ("Low", "low"), + ("Close", "close"), + ("Volume", "volume"), + ], + rows, + ), + ] + ) + return "\n".join(lines) + + +def render_funding(data: Dict[str, Any]) -> str: + rows = [ + { + "time": _format_timestamp_ms(item["time"]), + "coin": item["coin"], + "funding": _format_fraction_percent(item["funding_rate"]), + "premium": _format_fraction_percent(item["premium"]), + } + for item in data["history"] + ] + lines = [ + f"Coin: {data['coin']}", + f"Hours: {data['hours']}", + f"Entries returned: {len(data['history'])} of {data['count']}", + f"Average funding: {_format_fraction_percent(data['average_funding_rate'])}", + "", + _render_table( + [ + ("Time", "time"), + ("Coin", "coin"), + ("Funding", "funding"), + ("Premium", "premium"), + ], + rows, + ), + ] + return "\n".join(lines) + + +def render_l2(data: Dict[str, Any]) -> str: + bid_rows = [ + {"px": _format_price(item["px"]), "sz": _compact_number(item["sz"]), "orders": item["orders"] or "-"} + for item in data["bids"] + ] + ask_rows = [ + {"px": _format_price(item["px"]), "sz": _compact_number(item["sz"]), "orders": item["orders"] or "-"} + for item in data["asks"] + ] + lines = [ + f"Coin: {data['coin']}", + f"Book time: {_format_timestamp_ms(data['time'])}", + "", + "Bids", + _render_table([("Price", "px"), ("Size", "sz"), ("Orders", "orders")], bid_rows), + "", + "Asks", + _render_table([("Price", "px"), ("Size", "sz"), ("Orders", "orders")], ask_rows), + ] + return "\n".join(lines) + + +def render_state(data: Dict[str, Any]) -> str: + summary = data["summary"] + position_rows = [ + { + "coin": item["coin"], + "size": item["size"], + "entry_px": _format_price(item["entry_px"]), + "position_value": _compact_number(item["position_value"]), + "unrealized_pnl": _compact_number(item["unrealized_pnl"]), + "roe": _format_fraction_percent(item["return_on_equity"], 2), + "liq": _format_price(item["liquidation_px"]), + "lev": f"{item['leverage'] or '-'}x", + } + for item in data["positions"] + ] + + lines = [ + f"User: {data['user']}", + f"Dex: {data['dex'] or 'first-perp-dex'}", + f"Account value: {summary.get('account_value') or '-'}", + f"Total notional position: {summary.get('total_ntl_pos') or '-'}", + f"Withdrawable: {summary.get('withdrawable') or '-'}", + f"Positions: {len(data['positions'])}", + ] + if position_rows: + lines.extend( + [ + "", + _render_table( + [ + ("Coin", "coin"), + ("Size", "size"), + ("Entry", "entry_px"), + ("Pos Val", "position_value"), + ("uPnL", "unrealized_pnl"), + ("ROE", "roe"), + ("Liq", "liq"), + ("Lev", "lev"), + ], + position_rows, + ), + ] + ) + return "\n".join(lines) + + +def render_spot_balances(data: Dict[str, Any]) -> str: + rows = [ + { + "coin": item["coin"], + "total": _compact_number(item["total"]), + "hold": _compact_number(item["hold"]), + "entry_ntl": _compact_number(item["entry_ntl"]), + } + for item in data["balances"] + ] + return "\n".join( + [ + f"User: {data['user']}", + f"Balances returned: {len(data['balances'])} of {data['count']}", + "", + _render_table( + [ + ("Coin", "coin"), + ("Total", "total"), + ("Hold", "hold"), + ("Entry Ntl", "entry_ntl"), + ], + rows, + ), + ] + ) + + +def render_fills(data: Dict[str, Any]) -> str: + rows = [ + { + "time": _format_timestamp_ms(item["time"]), + "coin": item["coin"], + "dir": item["dir"], + "px": _format_price(item["px"]), + "sz": _compact_number(item["sz"]), + "closed_pnl": _compact_number(item["closed_pnl"]), + "fee": f"{_compact_number(item['fee'])} {item['fee_token'] or ''}".strip(), + } + for item in data["fills"] + ] + lines = [ + f"User: {data['user']}", + f"Aggregate by time: {data['aggregate_by_time']}", + f"Fills returned: {len(data['fills'])} of {data['count']}", + "", + _render_table( + [ + ("Time", "time"), + ("Coin", "coin"), + ("Dir", "dir"), + ("Px", "px"), + ("Sz", "sz"), + ("Closed PnL", "closed_pnl"), + ("Fee", "fee"), + ], + rows, + ), + ] + return "\n".join(lines) + + +def render_orders(data: Dict[str, Any]) -> str: + rows = [ + { + "time": _format_timestamp_ms(item["timestamp"]), + "coin": item["coin"], + "side": item["side"], + "limit_px": _format_price(item["limit_px"]), + "size": _compact_number(item["size"]), + "status": item["status"], + "oid": item["oid"] or "-", + } + for item in data["orders"] + ] + return "\n".join( + [ + f"User: {data['user']}", + f"Orders returned: {len(data['orders'])} of {data['count']}", + "", + _render_table( + [ + ("Time", "time"), + ("Coin", "coin"), + ("Side", "side"), + ("Px", "limit_px"), + ("Sz", "size"), + ("Status", "status"), + ("OID", "oid"), + ], + rows, + ), + ] + ) + + +def render_review(data: Dict[str, Any]) -> str: + summary = data["summary"] + coin_rows = [ + { + "coin": item["coin"], + "fills": item["fill_count"], + "net": _compact_number(item["net_after_fees"]), + "win_rate": _format_percent(item["win_rate_pct"]), + "trend": _format_percent(item["market_context"].get("price_change_pct")), + "funding": _format_fraction_percent(item["market_context"].get("average_funding_rate")), + "bias": item["open_bias"], + } + for item in data["coin_reviews"] + ] + recent_rows = [ + { + "time": _format_timestamp_ms(item["time"]), + "coin": item["coin"], + "dir": item["dir"], + "px": _format_price(item["px"]), + "sz": _compact_number(item["sz"]), + "closed_pnl": _compact_number(item["closed_pnl"]), + "fee": f"{_compact_number(item['fee'])} {item['fee_token'] or ''}".strip(), + } + for item in data["recent_fills"] + ] + + lines = [ + f"User: {data['user']}", + f"Review window: {data['hours']} hours", + f"Coin filter: {data['coin_filter'] or 'all traded coins'}", + f"Fills analyzed: {summary['fill_count']}", + f"Unique coins: {summary['unique_coins']}", + f"Realized PnL: {_compact_number(summary['realized_pnl'])}", + f"Fees: {_compact_number(summary['total_fees'])}", + f"Net after fees: {_compact_number(summary['net_after_fees'])}", + f"Win rate: {_format_percent(summary['win_rate_pct'])}", + ] + + if data["findings"]: + lines.extend(["", "Findings"]) + for finding in data["findings"]: + lines.append(f"- {finding}") + + if coin_rows: + lines.extend( + [ + "", + "Coin Breakdown", + _render_table( + [ + ("Coin", "coin"), + ("Fills", "fills"), + ("Net", "net"), + ("Win Rate", "win_rate"), + ("Trend", "trend"), + ("Funding", "funding"), + ("Bias", "bias"), + ], + coin_rows, + ), + ] + ) + + if recent_rows: + lines.extend( + [ + "", + "Recent Fills", + _render_table( + [ + ("Time", "time"), + ("Coin", "coin"), + ("Dir", "dir"), + ("Px", "px"), + ("Sz", "sz"), + ("Closed PnL", "closed_pnl"), + ("Fee", "fee"), + ], + recent_rows, + ), + ] + ) + + return "\n".join(lines) + + +def render_export(data: Dict[str, Any]) -> str: + summary = data["summary"] + return "\n".join( + [ + f"Coin: {data['coin']}", + f"Interval: {data['interval']}", + f"Hours: {data['hours']}", + f"Schema: {data['schema_version']}", + f"Output: {data['output_path']}", + f"Candles: {summary['candle_count']}", + f"Funding samples: {summary['funding_count']}", + f"Window open -> close: {_format_price(summary.get('window_open'))} -> {_format_price(summary.get('window_close'))}", + f"Price change: {_format_percent(summary.get('price_change_pct'))}", + f"Average funding: {_format_fraction_percent(summary.get('average_funding_rate'))}", + ] + ) + + +def _add_json_flag(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--json", action="store_true", help="Print raw JSON output") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Hyperliquid CLI Tool for Hermes Agent") + subparsers = parser.add_subparsers(dest="command", required=True) + + dexs = subparsers.add_parser("dexs", help="List available perpetual dexs") + _add_json_flag(dexs) + dexs.set_defaults(func=run_dexs, renderer=render_dexs) + + markets = subparsers.add_parser("markets", help="List perpetual market contexts") + markets.add_argument("--dex", default="", help="Perp dex name; empty means first perp dex") + markets.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + markets.add_argument( + "--sort", + choices=["volume", "oi", "funding_abs", "change_abs", "name"], + default="volume", + help="Sort mode", + ) + _add_json_flag(markets) + markets.set_defaults(func=run_markets, renderer=render_markets) + + spots = subparsers.add_parser("spots", help="List spot market contexts") + spots.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + spots.add_argument( + "--sort", + choices=["volume", "change_abs", "name"], + default="volume", + help="Sort mode", + ) + _add_json_flag(spots) + spots.set_defaults(func=run_spots, renderer=render_spots) + + candles = subparsers.add_parser("candles", help="Fetch candle history for a market") + candles.add_argument("coin", help='Coin name, e.g. "BTC" or "PURR/USDC" or "mydex:BTC"') + candles.add_argument("--interval", default="1h", help="Candle interval, e.g. 1m, 15m, 1h, 4h, 1d") + candles.add_argument("--hours", type=float, default=24.0, help="Lookback window in hours") + candles.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + _add_json_flag(candles) + candles.set_defaults(func=run_candles, renderer=render_candles) + + funding = subparsers.add_parser("funding", help="Fetch funding history for a perp market") + funding.add_argument("coin", help='Coin name, e.g. "BTC" or "mydex:COIN"') + funding.add_argument("--hours", type=float, default=72.0, help="Lookback window in hours") + funding.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + _add_json_flag(funding) + funding.set_defaults(func=run_funding, renderer=render_funding) + + l2 = subparsers.add_parser("l2", help="Inspect the current L2 book for a market") + l2.add_argument("coin", help='Coin name, e.g. "BTC" or "PURR/USDC"') + l2.add_argument("--levels", type=int, default=10, help="Levels per side to display") + l2.add_argument("--n-sig-figs", type=int, default=None, help="Optional server-side book aggregation") + l2.add_argument("--mantissa", type=int, default=None, help="Optional mantissa when using nSigFigs") + _add_json_flag(l2) + l2.set_defaults(func=run_l2, renderer=render_l2) + + state = subparsers.add_parser("state", help="Inspect a user's perp account state") + state.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}") + state.add_argument("--dex", default="", help="Perp dex name; empty means first perp dex") + _add_json_flag(state) + state.set_defaults(func=run_state, renderer=render_state) + + spot_balances = subparsers.add_parser("spot-balances", help="Inspect a user's spot token balances") + spot_balances.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}") + spot_balances.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + _add_json_flag(spot_balances) + spot_balances.set_defaults(func=run_spot_balances, renderer=render_spot_balances) + + fills = subparsers.add_parser("fills", help="Inspect a user's recent fills") + fills.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}") + fills.add_argument("--hours", type=float, default=None, help="Optional time window; uses userFillsByTime") + fills.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + fills.add_argument( + "--aggregate-by-time", + action="store_true", + help="Aggregate partial fills when the API supports it", + ) + _add_json_flag(fills) + fills.set_defaults(func=run_fills, renderer=render_fills) + + orders = subparsers.add_parser("orders", help="Inspect a user's historical orders") + orders.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}") + orders.add_argument("--limit", type=int, default=20, help="Rows to display; 0 means all") + _add_json_flag(orders) + orders.set_defaults(func=run_orders, renderer=render_orders) + + review = subparsers.add_parser("review", help="Generate a lightweight post-trade review from recent fills") + review.add_argument("user", nargs="?", default="", help=f"Optional address; falls back to ${DEFAULT_USER_ENV}") + review.add_argument("--coin", default="", help="Optional exact coin filter, e.g. BTC or PURR/USDC") + review.add_argument("--hours", type=float, default=72.0, help="Lookback window in hours") + review.add_argument("--fills", type=int, default=50, help="Maximum fills to analyze") + review.add_argument("--recent", type=int, default=10, help="Recent fills to display in the review") + review.add_argument("--interval", default="1h", help="Candle interval for market context") + review.add_argument( + "--aggregate-by-time", + action="store_true", + help="Aggregate partial fills when the API supports it", + ) + _add_json_flag(review) + review.set_defaults(func=run_review, renderer=render_review) + + export = subparsers.add_parser("export", help="Export normalized candles and funding history to a JSON file") + export.add_argument("coin", help='Coin name, e.g. "BTC" or "PURR/USDC" or "mydex:BTC"') + export.add_argument("--interval", default="1h", help="Candle interval for the exported dataset") + export.add_argument("--hours", type=float, default=168.0, help="Lookback window in hours") + export.add_argument("--end-time-ms", type=int, default=None, help="Optional fixed end time for reproducible exports") + export.add_argument("--output", default="", help="Path to the JSON export file") + _add_json_flag(export) + export.set_defaults(func=run_export, renderer=render_export) + + return parser + + +def main(argv: Optional[List[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + payload = args.func(args) + if args.json: + print(json.dumps(payload, indent=2)) + else: + print(args.renderer(payload)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/optional-skills/finance/stocks/SKILL.md b/optional-skills/finance/stocks/SKILL.md new file mode 100644 index 00000000000..347b0c5972c --- /dev/null +++ b/optional-skills/finance/stocks/SKILL.md @@ -0,0 +1,95 @@ +--- +name: stocks +description: Stock quotes, history, search, compare, crypto via Yahoo. +version: 0.1.0 +author: Mibay (Mibayy), Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [Stocks, Finance, Market, Crypto, Investing] + category: finance + related_skills: [dcf-model, comps-analysis, lbo-model] +--- + +# Stocks Skill + +Read-only market data via Yahoo Finance. Five commands: `quote`, `search`, +`history`, `compare`, `crypto`. Python stdlib only — no API key, no pip +installs. Yahoo's endpoint is unofficial and may rate-limit or change. + +## When to Use + +- User asks for a current stock price (AAPL, TSLA, MSFT, ...) +- User wants to look up a ticker by company name +- User wants OHLCV history or performance over a date range +- User wants to compare several tickers side by side +- User asks for a crypto price (BTC, ETH, SOL, ...) + +## Prerequisites + +Python 3.8+ stdlib only. Optional: set `ALPHA_VANTAGE_KEY` to enrich +`market_cap`, `pe_ratio`, and 52-week levels when Yahoo's crumb-protected +fields come back null. Free key: https://www.alphavantage.co/support/#api-key + +## How to Run + +Invoke through the `terminal` tool. Once installed: + +``` +SCRIPT=~/.hermes/skills/finance/stocks/scripts/stocks_client.py +python3 $SCRIPT quote AAPL +``` + +All output is JSON on stdout — pipe through `jq` if you want to slice it. + +## Quick Reference + +``` +python3 $SCRIPT quote AAPL +python3 $SCRIPT quote AAPL MSFT GOOGL TSLA +python3 $SCRIPT search "Tesla" +python3 $SCRIPT history NVDA --range 6mo +python3 $SCRIPT compare AAPL MSFT GOOGL +python3 $SCRIPT crypto BTC ETH SOL +``` + +## Commands + +### `quote SYMBOL [SYMBOL2 ...]` + +Current price, change, change%, volume, 52-week high/low. + +### `search QUERY` + +Find tickers by company name. Returns top 5: symbol, name, exchange, type. + +### `history SYMBOL [--range RANGE]` + +Daily OHLCV plus stats (min, max, avg, total return %). Ranges: `1mo`, +`3mo`, `6mo`, `1y`, `5y`. Default: `1mo`. + +### `compare SYMBOL1 SYMBOL2 [...]` + +Side-by-side: price, change%, 52-week performance. + +### `crypto SYMBOL [SYMBOL2 ...]` + +Crypto prices. Pass `BTC` (the script appends `-USD` automatically). + +## Pitfalls + +- Yahoo Finance's API is unofficial. Endpoints can change or rate-limit + without notice — if requests start failing, that's why. +- `market_cap` and `pe_ratio` may return null on `quote` when Yahoo's + crumb session isn't established. Set `ALPHA_VANTAGE_KEY` to backfill. +- Add a small delay between bulk requests to avoid rate-limiting. +- This is read-only — no order placement, no account integration. + +## Verification + +``` +python3 ~/.hermes/skills/finance/stocks/scripts/stocks_client.py quote AAPL +``` + +Returns a JSON object with `symbol: "AAPL"` and a numeric `price` field. diff --git a/optional-skills/finance/stocks/scripts/stocks_client.py b/optional-skills/finance/stocks/scripts/stocks_client.py new file mode 100755 index 00000000000..7b98fd9dc66 --- /dev/null +++ b/optional-skills/finance/stocks/scripts/stocks_client.py @@ -0,0 +1,755 @@ +#!/usr/bin/env python3 +""" +stocks_client.py - Stock market data CLI tool for the Hermes Agent project. +Zero external dependencies - Python stdlib only. +""" + +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from http.cookiejar import CookieJar + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +USER_AGENT = "Mozilla/5.0 (compatible; HermesAgent/1.0)" +YF_BASE = "https://query1.finance.yahoo.com" +YF_BASE2 = "https://query2.finance.yahoo.com" +AV_BASE = "https://www.alphavantage.co/query" + +MAX_RETRIES = 3 +BACKOFF_BASE = 1.5 # seconds + +# Global cookie jar + opener (handles Yahoo Finance session cookies) +_cookie_jar = CookieJar() +_opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(_cookie_jar)) +_crumb: str | None = None + +# --------------------------------------------------------------------------- +# Utilities +# --------------------------------------------------------------------------- + + +def print_json(data: dict | list) -> None: + print(json.dumps(data, indent=2, ensure_ascii=False)) + + +def fmt_price(value) -> str | None: + if value is None: + return None + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return None + + +def fmt_large(value) -> str | None: + """Format large numbers with B/T suffix.""" + if value is None: + return None + try: + v = float(value) + except (TypeError, ValueError): + return None + if abs(v) >= 1e12: + return f"{v / 1e12:.2f}T" + if abs(v) >= 1e9: + return f"{v / 1e9:.2f}B" + if abs(v) >= 1e6: + return f"{v / 1e6:.2f}M" + return str(int(v)) + + +def fmt_pct(value) -> str | None: + if value is None: + return None + try: + return f"{float(value):.2f}%" + except (TypeError, ValueError): + return None + + +def safe_get(d: dict, *keys, default=None): + """Safely traverse nested dict.""" + cur = d + for k in keys: + if not isinstance(cur, dict): + return default + cur = cur.get(k, default) + if cur is None: + return default + return cur + + +def ts_to_date(ts) -> str | None: + """Convert Unix timestamp to ISO date string.""" + if ts is None: + return None + try: + return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime("%Y-%m-%d") + except (OSError, ValueError, TypeError): + return None + + +# --------------------------------------------------------------------------- +# HTTP layer with retry + exponential backoff +# --------------------------------------------------------------------------- + + +def _build_request(url: str, headers: dict | None = None) -> urllib.request.Request: + req = urllib.request.Request(url) + req.add_header("User-Agent", USER_AGENT) + req.add_header("Accept", "application/json, */*") + req.add_header("Accept-Language", "en-US,en;q=0.9") + if headers: + for k, v in headers.items(): + req.add_header(k, v) + return req + + +def fetch_url(url: str, headers: dict | None = None, retries: int = MAX_RETRIES) -> dict | list | None: + """Fetch a URL, parse JSON, retry on transient errors.""" + last_err = None + for attempt in range(retries): + try: + req = _build_request(url, headers) + with _opener.open(req, timeout=15) as resp: + raw = resp.read() + return json.loads(raw.decode("utf-8", errors="replace")) + except urllib.error.HTTPError as e: + last_err = e + if e.code in (404, 400): + break # no point retrying + wait = BACKOFF_BASE ** attempt + time.sleep(wait) + except urllib.error.URLError as e: + last_err = e + wait = BACKOFF_BASE ** attempt + time.sleep(wait) + except json.JSONDecodeError as e: + last_err = e + break + return None + + +# --------------------------------------------------------------------------- +# Yahoo Finance crumb / cookie management +# --------------------------------------------------------------------------- + + +def _fetch_crumb() -> str | None: + """ + Yahoo Finance v8 requires a crumb + consent cookie. + We hit the consent page once to grab cookies, then fetch the crumb. + """ + global _crumb + if _crumb is not None: + return _crumb + + # Step 1: touch Yahoo Finance to get cookies + try: + req = _build_request("https://finance.yahoo.com/") + with _opener.open(req, timeout=10) as resp: + resp.read() + except Exception: + pass + + # Step 2: fetch crumb + crumb_url = f"{YF_BASE}/v1/test/getcrumb" + try: + req = _build_request(crumb_url) + with _opener.open(req, timeout=10) as resp: + crumb_raw = resp.read().decode("utf-8").strip() + if crumb_raw and crumb_raw != "": + _crumb = crumb_raw + return _crumb + except Exception: + pass + + return None + + +def yf_url(path: str, params: dict | None = None) -> str: + """Build a Yahoo Finance URL, injecting crumb if available.""" + crumb = _fetch_crumb() + if params is None: + params = {} + if crumb: + params["crumb"] = crumb + qs = urllib.parse.urlencode(params) + base = f"{YF_BASE}{path}" + return f"{base}?{qs}" if qs else base + + +# --------------------------------------------------------------------------- +# Yahoo Finance API calls +# --------------------------------------------------------------------------- + + +def yf_chart(symbol: str, interval: str = "1d", range_: str = "1d") -> dict | None: + params = {"interval": interval, "range": range_} + crumb = _fetch_crumb() + if crumb: + params["crumb"] = crumb + qs = urllib.parse.urlencode(params) + url = f"{YF_BASE}/v8/finance/chart/{urllib.parse.quote(symbol)}?{qs}" + data = fetch_url(url) + if data is None: + # fallback to query2 + url2 = f"{YF_BASE2}/v8/finance/chart/{urllib.parse.quote(symbol)}?{qs}" + data = fetch_url(url2) + return data + + +def yf_search(query: str, count: int = 5) -> dict | None: + params = {"q": query, "quotesCount": count, "newsCount": 0} + crumb = _fetch_crumb() + if crumb: + params["crumb"] = crumb + qs = urllib.parse.urlencode(params) + url = f"{YF_BASE}/v1/finance/search?{qs}" + data = fetch_url(url) + if data is None: + url2 = f"{YF_BASE2}/v1/finance/search?{qs}" + data = fetch_url(url2) + return data + + +def yf_quote_summary(symbol: str) -> dict | None: + """Fetch detailed quote summary (quoteSummary) for PE, market cap, etc.""" + modules = "summaryDetail,defaultKeyStatistics,price" + params = {"modules": modules} + crumb = _fetch_crumb() + if crumb: + params["crumb"] = crumb + qs = urllib.parse.urlencode(params) + url = f"{YF_BASE}/v11/finance/quoteSummary/{urllib.parse.quote(symbol)}?{qs}" + data = fetch_url(url) + if data is None: + url2 = f"{YF_BASE2}/v11/finance/quoteSummary/{urllib.parse.quote(symbol)}?{qs}" + data = fetch_url(url2) + return data + + +# --------------------------------------------------------------------------- +# Alpha Vantage (optional, requires API key) +# --------------------------------------------------------------------------- + + +def av_overview(symbol: str) -> dict | None: + key = os.environ.get("ALPHA_VANTAGE_KEY") + if not key: + return None + params = {"function": "OVERVIEW", "symbol": symbol, "apikey": key} + qs = urllib.parse.urlencode(params) + url = f"{AV_BASE}?{qs}" + data = fetch_url(url) + if isinstance(data, dict) and data.get("Symbol"): + return data + return None + + +# --------------------------------------------------------------------------- +# Data extraction helpers +# --------------------------------------------------------------------------- + + +def extract_quote_from_chart(symbol: str, chart_data: dict) -> dict: + """Extract current quote info from v8 chart response.""" + result = { + "symbol": symbol.upper(), + "price": None, + "change": None, + "change_pct": None, + "volume": None, + "market_cap": None, + "pe_ratio": None, + "52w_high": None, + "52w_low": None, + "currency": None, + "exchange": None, + "short_name": None, + } + + chart = safe_get(chart_data, "chart", "result") + if not chart or not isinstance(chart, list) or len(chart) == 0: + return result + + r = chart[0] + meta = r.get("meta", {}) + + result["currency"] = meta.get("currency") + result["exchange"] = meta.get("exchangeName") + result["short_name"] = meta.get("shortName") or meta.get("longName") + + # Price + price = meta.get("regularMarketPrice") or meta.get("chartPreviousClose") + result["price"] = fmt_price(price) + + # Change + prev_close = meta.get("previousClose") or meta.get("chartPreviousClose") + if price and prev_close: + chg = float(price) - float(prev_close) + chg_pct = (chg / float(prev_close)) * 100 + result["change"] = fmt_price(chg) + result["change_pct"] = fmt_pct(chg_pct) + + result["volume"] = meta.get("regularMarketVolume") + result["52w_high"] = fmt_price(meta.get("fiftyTwoWeekHigh")) + result["52w_low"] = fmt_price(meta.get("fiftyTwoWeekLow")) + + return result + + +def extract_quote_summary_fields(qs_data: dict) -> dict: + """Extract PE, market cap, etc. from quoteSummary response.""" + out = { + "market_cap": None, + "pe_ratio": None, + "52w_high": None, + "52w_low": None, + "volume": None, + "short_name": None, + } + + result = safe_get(qs_data, "quoteSummary", "result") + if not result or not isinstance(result, list) or len(result) == 0: + return out + + r = result[0] + + # price module + price_mod = r.get("price", {}) + out["market_cap"] = fmt_large(safe_get(price_mod, "marketCap", "raw")) + out["short_name"] = price_mod.get("shortName") or price_mod.get("longName") + + # summaryDetail + sd = r.get("summaryDetail", {}) + pe_raw = safe_get(sd, "trailingPE", "raw") + out["pe_ratio"] = fmt_price(pe_raw) if pe_raw else None + out["52w_high"] = fmt_price(safe_get(sd, "fiftyTwoWeekHigh", "raw")) + out["52w_low"] = fmt_price(safe_get(sd, "fiftyTwoWeekLow", "raw")) + out["volume"] = safe_get(sd, "volume", "raw") or safe_get(sd, "regularMarketVolume", "raw") + + # defaultKeyStatistics + ks = r.get("defaultKeyStatistics", {}) + if out["pe_ratio"] is None: + pe_raw = safe_get(ks, "trailingEps", "raw") + # can't compute PE from EPS alone without price, skip + + return out + + +# --------------------------------------------------------------------------- +# Command: quote +# --------------------------------------------------------------------------- + + +def cmd_quote(symbols: list[str]) -> None: + results = [] + + for sym in symbols: + sym = sym.upper().strip() + entry = {"symbol": sym, "data_source": "Yahoo Finance"} + + # Fetch chart for price data + chart_data = yf_chart(sym, interval="1d", range_="1d") + if chart_data: + q = extract_quote_from_chart(sym, chart_data) + entry.update(q) + + # Fetch quoteSummary for enriched data + qs_data = yf_quote_summary(sym) + if qs_data: + qs_fields = extract_quote_summary_fields(qs_data) + # Prefer quoteSummary values if chart didn't have them + for field in ("market_cap", "pe_ratio", "52w_high", "52w_low", "volume", "short_name"): + if entry.get(field) is None and qs_fields.get(field) is not None: + entry[field] = qs_fields[field] + elif field == "market_cap" and qs_fields.get(field) is not None: + # Always prefer formatted market cap from quoteSummary + entry[field] = qs_fields[field] + + # Optionally enrich with Alpha Vantage + av_key = os.environ.get("ALPHA_VANTAGE_KEY") + if av_key: + av_data = av_overview(sym) + if av_data: + entry["data_source"] = "Yahoo Finance + Alpha Vantage" + if entry.get("pe_ratio") is None: + pe = av_data.get("PERatio") + entry["pe_ratio"] = pe if pe and pe != "None" and pe != "-" else None + if entry.get("market_cap") is None: + mc = av_data.get("MarketCapitalization") + entry["market_cap"] = fmt_large(mc) + if entry.get("52w_high") is None: + entry["52w_high"] = av_data.get("52WeekHigh") + if entry.get("52w_low") is None: + entry["52w_low"] = av_data.get("52WeekLow") + + results.append(entry) + + if len(results) == 1: + print_json(results[0]) + else: + print_json(results) + + +# --------------------------------------------------------------------------- +# Command: search +# --------------------------------------------------------------------------- + + +def cmd_search(query: str) -> None: + data = yf_search(query, count=5) + if not data: + print_json({"error": "Search failed or no results", "query": query, "data_source": "Yahoo Finance"}) + return + + quotes = data.get("quotes") or [] + if not quotes: + print_json({"error": "No matches found", "query": query, "data_source": "Yahoo Finance"}) + return + + results = [] + for q in quotes[:5]: + results.append({ + "symbol": q.get("symbol"), + "name": q.get("longname") or q.get("shortname"), + "exchange": q.get("exchange") or q.get("exchDisp"), + "type": q.get("quoteType"), + "sector": q.get("sector"), + }) + + output = { + "query": query, + "matches": results, + "data_source": "Yahoo Finance", + } + print_json(output) + + +# --------------------------------------------------------------------------- +# Command: history +# --------------------------------------------------------------------------- + + +def cmd_history(symbol: str, range_: str = "1mo") -> None: + valid_ranges = ("1mo", "3mo", "6mo", "1y", "5y") + if range_ not in valid_ranges: + print_json({"error": f"Invalid range '{range_}'. Valid: {', '.join(valid_ranges)}"}) + return + + sym = symbol.upper().strip() + chart_data = yf_chart(sym, interval="1d", range_=range_) + + if not chart_data: + print_json({"error": f"Failed to fetch history for {sym}", "data_source": "Yahoo Finance"}) + return + + chart = safe_get(chart_data, "chart", "result") + if not chart or not isinstance(chart, list) or len(chart) == 0: + err = safe_get(chart_data, "chart", "error", "description") or "Unknown error" + print_json({"error": err, "symbol": sym, "data_source": "Yahoo Finance"}) + return + + r = chart[0] + timestamps = r.get("timestamp") or [] + indicators = r.get("indicators", {}) + quote_list = indicators.get("quote") or [{}] + ohlcv = quote_list[0] if quote_list else {} + + opens = ohlcv.get("open") or [] + closes = ohlcv.get("close") or [] + highs = ohlcv.get("high") or [] + lows = ohlcv.get("low") or [] + volumes = ohlcv.get("volume") or [] + + history = [] + for i, ts in enumerate(timestamps): + def _v(lst, idx): + try: + val = lst[idx] + return round(val, 2) if val is not None else None + except IndexError: + return None + + entry = { + "date": ts_to_date(ts), + "open": _v(opens, i), + "close": _v(closes, i), + "high": _v(highs, i), + "low": _v(lows, i), + "volume": _v(volumes, i), + } + history.append(entry) + + # Stats + valid_closes = [c["close"] for c in history if c["close"] is not None] + stats = {} + if valid_closes: + stats["min"] = fmt_price(min(valid_closes)) + stats["max"] = fmt_price(max(valid_closes)) + stats["avg"] = fmt_price(sum(valid_closes) / len(valid_closes)) + if len(valid_closes) >= 2: + total_return = ((valid_closes[-1] - valid_closes[0]) / valid_closes[0]) * 100 + stats["total_return_pct"] = fmt_pct(total_return) + else: + stats["total_return_pct"] = None + + meta = r.get("meta", {}) + output = { + "symbol": sym, + "range": range_, + "currency": meta.get("currency"), + "exchange": meta.get("exchangeName"), + "data_points": len(history), + "stats": stats, + "history": history, + "data_source": "Yahoo Finance", + } + print_json(output) + + +# --------------------------------------------------------------------------- +# Command: compare +# --------------------------------------------------------------------------- + + +def cmd_compare(symbols: list[str]) -> None: + if len(symbols) < 2: + print_json({"error": "compare requires at least 2 symbols"}) + return + + comparisons = [] + + for sym in symbols: + sym = sym.upper().strip() + entry = { + "symbol": sym, + "name": None, + "price": None, + "change_pct": None, + "market_cap": None, + "pe_ratio": None, + "52w_high": None, + "52w_low": None, + "52w_performance_pct": None, + } + + # Chart data + chart_data = yf_chart(sym, interval="1d", range_="1d") + if chart_data: + q = extract_quote_from_chart(sym, chart_data) + entry["name"] = q.get("short_name") + entry["price"] = q.get("price") + entry["change_pct"] = q.get("change_pct") + entry["52w_high"] = q.get("52w_high") + entry["52w_low"] = q.get("52w_low") + + # quoteSummary for enrichment + qs_data = yf_quote_summary(sym) + if qs_data: + qs = extract_quote_summary_fields(qs_data) + if qs.get("market_cap"): + entry["market_cap"] = qs["market_cap"] + if qs.get("pe_ratio"): + entry["pe_ratio"] = qs["pe_ratio"] + if entry["52w_high"] is None and qs.get("52w_high"): + entry["52w_high"] = qs["52w_high"] + if entry["52w_low"] is None and qs.get("52w_low"): + entry["52w_low"] = qs["52w_low"] + if entry["name"] is None and qs.get("short_name"): + entry["name"] = qs["short_name"] + + # 52w performance: (current - 52w_low) / (52w_high - 52w_low) + try: + price_f = float(entry["price"]) if entry["price"] else None + high_f = float(entry["52w_high"]) if entry["52w_high"] else None + low_f = float(entry["52w_low"]) if entry["52w_low"] else None + if price_f and low_f and price_f > 0 and low_f > 0: + perf = ((price_f - low_f) / low_f) * 100 + entry["52w_performance_pct"] = fmt_pct(perf) + except (ValueError, TypeError, ZeroDivisionError): + pass + + comparisons.append(entry) + + output = { + "comparison": comparisons, + "symbols": [s.upper() for s in symbols], + "data_source": "Yahoo Finance", + } + print_json(output) + + +# --------------------------------------------------------------------------- +# Command: crypto +# --------------------------------------------------------------------------- + + +def cmd_crypto(symbol: str, vs: str = "USD") -> None: + sym = symbol.upper().strip() + vs = vs.upper().strip() + + # If user already passed BTC-USD, keep as-is; otherwise append + if "-" not in sym: + ticker = f"{sym}-{vs}" + else: + ticker = sym + + chart_data = yf_chart(ticker, interval="1d", range_="1d") + + if not chart_data: + print_json({ + "error": f"Failed to fetch crypto data for {ticker}", + "symbol": ticker, + "data_source": "Yahoo Finance", + }) + return + + chart = safe_get(chart_data, "chart", "result") + if not chart or not isinstance(chart, list) or len(chart) == 0: + err = safe_get(chart_data, "chart", "error", "description") or "Symbol not found" + print_json({"error": err, "symbol": ticker, "data_source": "Yahoo Finance"}) + return + + r = chart[0] + meta = r.get("meta", {}) + + price = meta.get("regularMarketPrice") or meta.get("chartPreviousClose") + prev_close = meta.get("previousClose") or meta.get("chartPreviousClose") + + change = None + change_pct = None + if price and prev_close: + try: + chg = float(price) - float(prev_close) + chg_pct = (chg / float(prev_close)) * 100 + change = fmt_price(chg) + change_pct = fmt_pct(chg_pct) + except (TypeError, ValueError, ZeroDivisionError): + pass + + # 24h stats from indicators + indicators = r.get("indicators", {}) + quote_list = indicators.get("quote") or [{}] + ohlcv = quote_list[0] if quote_list else {} + highs = [h for h in (ohlcv.get("high") or []) if h is not None] + lows = [l for l in (ohlcv.get("low") or []) if l is not None] + volumes = [v for v in (ohlcv.get("volume") or []) if v is not None] + + output = { + "symbol": ticker, + "base": sym if "-" not in sym else sym.split("-")[0], + "quote_currency": vs, + "price": fmt_price(price), + "change": change, + "change_pct": change_pct, + "day_high": fmt_price(max(highs)) if highs else None, + "day_low": fmt_price(min(lows)) if lows else None, + "volume": fmt_large(sum(volumes)) if volumes else None, + "52w_high": fmt_price(meta.get("fiftyTwoWeekHigh")), + "52w_low": fmt_price(meta.get("fiftyTwoWeekLow")), + "exchange": meta.get("exchangeName"), + "short_name": meta.get("shortName") or meta.get("longName"), + "data_source": "Yahoo Finance", + } + print_json(output) + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="stocks_client", + description="Stock & crypto market data CLI — Hermes Agent", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + stocks_client.py quote AAPL MSFT GOOGL + stocks_client.py search "Tesla" + stocks_client.py history AAPL --range 3mo + stocks_client.py compare AAPL MSFT GOOGL AMZN + stocks_client.py crypto BTC + stocks_client.py crypto ETH --vs EUR + ALPHA_VANTAGE_KEY=yourkey stocks_client.py quote AAPL + """, + ) + + sub = parser.add_subparsers(dest="command", required=True) + + # quote + p_quote = sub.add_parser("quote", help="Get current quote for one or more symbols") + p_quote.add_argument("symbols", nargs="+", metavar="SYMBOL", help="Stock ticker symbol(s)") + + # search + p_search = sub.add_parser("search", help="Search for stocks by name or symbol") + p_search.add_argument("query", help="Search query (company name or partial symbol)") + + # history + p_history = sub.add_parser("history", help="Price history for a symbol") + p_history.add_argument("symbol", metavar="SYMBOL", help="Stock ticker symbol") + p_history.add_argument( + "--range", + dest="range_", + default="1mo", + choices=["1mo", "3mo", "6mo", "1y", "5y"], + help="Date range (default: 1mo)", + ) + + # compare + p_compare = sub.add_parser("compare", help="Compare multiple stocks side by side") + p_compare.add_argument("symbols", nargs="+", metavar="SYMBOL", help="At least 2 stock symbols") + + # crypto + p_crypto = sub.add_parser("crypto", help="Crypto price (BTC, ETH, SOL, etc.)") + p_crypto.add_argument("symbol", metavar="SYMBOL", help="Crypto symbol (e.g. BTC, ETH, SOL)") + p_crypto.add_argument( + "--vs", + default="USD", + metavar="CURRENCY", + help="Quote currency (default: USD)", + ) + + return parser + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + + try: + if args.command == "quote": + cmd_quote(args.symbols) + elif args.command == "search": + cmd_search(args.query) + elif args.command == "history": + cmd_history(args.symbol, range_=args.range_) + elif args.command == "compare": + cmd_compare(args.symbols) + elif args.command == "crypto": + cmd_crypto(args.symbol, vs=args.vs) + else: + parser.print_help() + sys.exit(1) + except KeyboardInterrupt: + print_json({"error": "Interrupted by user"}) + sys.exit(130) + except Exception as e: + print_json({"error": f"Unexpected error: {e}", "type": type(e).__name__}) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/optional-skills/software-development/rest-graphql-debug/SKILL.md b/optional-skills/software-development/rest-graphql-debug/SKILL.md new file mode 100644 index 00000000000..78f90f2a91f --- /dev/null +++ b/optional-skills/software-development/rest-graphql-debug/SKILL.md @@ -0,0 +1,514 @@ +--- +name: rest-graphql-debug +description: "Debug REST/GraphQL APIs: status codes, auth, schemas, repro." +version: 1.2.0 +author: eren-karakus0 +license: MIT +metadata: + hermes: + tags: [api, rest, graphql, http, debugging, testing, curl, integration] + category: software-development + related_skills: [systematic-debugging, test-driven-development] +--- + +# API Testing & Debugging + +Drive REST and GraphQL diagnosis through Hermes tools — `terminal` for `curl`, `execute_code` for Python `requests`, `web_extract` for vendor docs. Isolate the failing layer before guessing at the fix. + +## When to Use + +- API returns unexpected status or body +- Auth fails (401/403 after token refresh, OAuth, API key) +- Works in Postman but fails in code +- Webhook / callback integration debugging +- Building or reviewing API integration tests +- Rate limiting or pagination issues + +Skip for UI rendering, DB query tuning, or DNS/firewall infra (escalate). + +## Core Principle + +**Isolate the layer, then fix.** A 200 OK can hide broken data. A 500 can mask a one-character auth typo. Walk the chain in order; never skip a step. + +``` +1. Connectivity → can we reach the host at all? +1.5 Timeouts → connect-slow vs read-slow? +2. TLS/SSL → cert valid and trusted? +3. Auth → credentials correct and unexpired? +4. Request format → payload shape match server expectations? +5. Response parse → does our code accept what came back? +6. Semantics → does the data mean what we assume? +``` + +## 5-Minute Quickstart + +### REST via terminal + +```python +# Verbose request/response exchange +terminal('curl -v https://api.example.com/users/1') + +# POST with JSON +terminal("""curl -X POST https://api.example.com/users \\ + -H 'Content-Type: application/json' \\ + -H "Authorization: Bearer $TOKEN" \\ + -d '{"name":"test","email":"test@example.com"}'""") + +# Headers only +terminal('curl -sI https://api.example.com/health') + +# Pretty-print JSON +terminal('curl -s https://api.example.com/users | python3 -m json.tool') +``` + +### GraphQL via terminal + +```python +terminal("""curl -X POST https://api.example.com/graphql \\ + -H 'Content-Type: application/json' \\ + -H "Authorization: Bearer $TOKEN" \\ + -d '{"query":"{ user(id: 1) { name email } }"}'""") +``` + +**GraphQL gotcha:** servers often return HTTP 200 even when the query failed. Always inspect the `errors` field regardless of status code: + +```python +execute_code(''' +import os, requests +resp = requests.post( + "https://api.example.com/graphql", + json={"query": "{ user(id: 1) { name email } }"}, + headers={"Authorization": f"Bearer {os.environ['TOKEN']}"}, + timeout=10, +) +data = resp.json() +if data.get("errors"): + for err in data["errors"]: + print(f"GraphQL error: {err['message']} (path: {err.get('path')})") +print(data.get("data")) +''') +``` + +### Python (requests) via execute_code + +```python +execute_code(''' +import requests +resp = requests.get( + "https://api.example.com/users/1", + headers={"Authorization": "Bearer <TOKEN>"}, + timeout=(3.05, 30), # (connect, read) +) +print(resp.status_code, dict(resp.headers)) +print(resp.text[:500]) +''') +``` + +## Layered Debug Flow + +### Step 1 — Connectivity + +```python +terminal('nslookup api.example.com') +terminal('curl -v --connect-timeout 5 https://api.example.com/health') +``` + +Failures: DNS not resolving, firewall, VPN required, proxy missing. + +### Step 1.5 — Timeouts + +Distinguish *can't reach* from *reaches but slow*: + +```python +terminal('''curl -w "dns:%{time_namelookup}s connect:%{time_connect}s tls:%{time_appconnect}s ttfb:%{time_starttransfer}s total:%{time_total}s\\n" \\ + -o /dev/null -s https://api.example.com/endpoint''') +``` + +In Python, always pass a tuple timeout — `requests` has no default and will hang forever: + +```python +execute_code(''' +import requests +from requests.exceptions import ConnectTimeout, ReadTimeout +try: + requests.get(url, timeout=(3.05, 30)) +except ConnectTimeout: + print("Cannot reach host — DNS, firewall, VPN") +except ReadTimeout: + print("Connected but server is slow") +''') +``` + +Diagnosis: high `time_connect` is network/firewall; high `time_starttransfer` with low `time_connect` is a slow server. + +### Step 2 — TLS/SSL + +```python +terminal('curl -vI https://api.example.com 2>&1 | grep -E "SSL|subject|expire|issuer"') +``` + +Failures: expired cert, self-signed, hostname mismatch, missing CA bundle. Use `-k` only for ad-hoc debug, never in code. + +### Step 3 — Authentication + +```python +# Token validity check +terminal('curl -s -o /dev/null -w "%{http_code}\\n" -H "Authorization: Bearer $TOKEN" https://api.example.com/me') + +# Decode JWT exp claim — handles base64url padding correctly +execute_code(''' +import json, base64, os +tok = os.environ["TOKEN"] +payload = tok.split(".")[1] +payload += "=" * (-len(payload) % 4) +print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2)) +''') +``` + +Checklist: +- Token expired? (`exp` claim in JWT) +- Right scheme? Bearer vs Basic vs Token vs `X-Api-Key` +- Right environment? Staging key on prod is a classic +- API key in header vs query param (`?api_key=…`)? + +### Step 4 — Request Format + +```python +terminal("""curl -v -X POST https://api.example.com/endpoint \\ + -H 'Content-Type: application/json' \\ + -d '{"key":"value"}' 2>&1""") +``` + +**Content-Type / body mismatch — the silent 415/400:** + +```python +# WRONG — data= sends form-encoded, header lies +requests.post(url, data='{"k":"v"}', headers={"Content-Type": "application/json"}) + +# RIGHT — json= auto-sets header AND serializes +requests.post(url, json={"k": "v"}) + +# WRONG — Accept says XML, code calls .json() +requests.get(url, headers={"Accept": "text/xml"}) + +# RIGHT — let requests build multipart with boundary +requests.post(url, files={"file": open("doc.pdf", "rb")}) +``` + +Common: form-encoded vs JSON, missing required fields, wrong HTTP method, unencoded query params. + +### Step 5 — Response Parsing + +Always inspect content-type before calling `.json()`: + +```python +execute_code(''' +import requests +resp = requests.post(url, json=payload, timeout=10) +print(f"status={resp.status_code}") +print(f"headers={dict(resp.headers)}") +ct = resp.headers.get("Content-Type", "") +if "application/json" in ct: + print(resp.json()) +else: + print(f"unexpected content-type {ct!r}, body={resp.text[:500]!r}") +''') +``` + +Failures: HTML error page where JSON expected, empty body, wrong charset. + +### Step 6 — Semantic Validation + +Parsed cleanly — but is the data *correct*? + +- Does `"status": "active"` mean what your code thinks? +- ID in response matches the one requested? +- Timestamps in expected timezone? +- Pagination returning all results, or just page 1? + +## HTTP Status Playbook + +### 401 Unauthorized — credentials missing or invalid + +1. `Authorization` header actually present? (`curl -v` to confirm) +2. Token correct and unexpired? +3. Right auth scheme? (`Bearer` vs `Basic` vs `Token`) +4. Some APIs use query param (`?api_key=…`) instead of header. + +### 403 Forbidden — authenticated but not authorized + +1. Token has the required scopes/permissions? +2. Resource owned by a different account? +3. IP allowlist blocking you? +4. CORS in browser? (check `Access-Control-Allow-Origin`) + +### 404 Not Found — resource doesn't exist or URL is wrong + +1. Path correct? (trailing slash, typo, version prefix) +2. Resource ID exists? +3. Right API version (`/v1/` vs `/v2/`)? +4. Right base URL (staging vs prod)? + +### 409 Conflict — state collision + +1. Resource already exists (duplicate create)? +2. Stale `ETag` / `If-Match`? +3. Concurrent modification by another process? + +### 422 Unprocessable Entity — valid JSON, invalid data + +The error body usually names the bad fields. Check: +- Field types (string vs int, date format) +- Required vs optional +- Enum values inside the allowed set + +### 429 Too Many Requests — rate limited + +Check `Retry-After` and `X-RateLimit-*` headers. Exponential backoff: + +```python +execute_code(''' +import time, requests + +def with_backoff(method, url, **kwargs): + for attempt in range(5): + resp = requests.request(method, url, **kwargs) + if resp.status_code != 429: + return resp + wait = int(resp.headers.get("Retry-After", 2 ** attempt)) + time.sleep(wait) + return resp +''') +``` + +### 5xx — server-side, usually not your fault + +- **500** — server bug. Capture correlation ID, file with provider. +- **502** — upstream down. Backoff + retry. +- **503** — overloaded / maintenance. Check status page. +- **504** — upstream timeout. Reduce payload or raise timeout. + +For all 5xx: backoff with jitter, alert on persistence. + +## Pagination & Idempotency + +**Pagination.** Verify you're getting *all* results. Look for `next_cursor`, `next_page`, `total_count`. Two patterns: +- Offset (`?limit=100&offset=200`) — simple, can skip items if data shifts. +- Cursor (`?cursor=abc123`) — preferred for live or large datasets. + +**Idempotency.** For non-idempotent operations (POST), send `Idempotency-Key: <uuid>` so retries don't double-charge / double-create. Mandatory for payments and orders. + +## Contract Validation + +Catch schema drift before it hits production: + +```python +execute_code(''' +import requests + +def validate_user(data: dict) -> list[str]: + errors = [] + required = {"id": int, "email": str, "created_at": str} + for field, expected in required.items(): + if field not in data: + errors.append(f"missing field: {field}") + elif not isinstance(data[field], expected): + errors.append(f"{field}: want {expected.__name__}, got {type(data[field]).__name__}") + return errors + +resp = requests.get(f"{BASE}/users/1", headers=HEADERS, timeout=10) +issues = validate_user(resp.json()) +if issues: + print(f"contract violations: {issues}") +''') +``` + +Run after API upgrades, when integrating new third parties, or in CI smoke tests. + +## Correlation IDs + +Always capture the provider's request ID — fastest path to vendor support: + +```python +execute_code(''' +import requests +resp = requests.post(url, json=payload, headers=headers, timeout=10) +request_id = ( + resp.headers.get("X-Request-Id") + or resp.headers.get("X-Trace-Id") + or resp.headers.get("CF-Ray") # Cloudflare +) +if resp.status_code >= 400: + print(f"failed status={resp.status_code} req_id={request_id} ts={resp.headers.get('Date')}") +''') +``` + +**Vendor bug-report template:** + +``` +Endpoint: POST /api/v1/orders +Request ID: req_abc123xyz +Timestamp: 2026-03-17T14:30:00Z +Status: 500 +Expected: 201 with order object +Actual: 500 {"error":"internal server error"} +Repro: curl -X POST … (auth: <REDACTED>) +``` + +## Regression Test Template + +Drop this into `tests/` and run via `terminal('pytest tests/test_api_smoke.py -v')`: + +```python +import os, requests, pytest + +BASE_URL = os.environ.get("API_BASE_URL", "https://api.example.com") +TOKEN = os.environ.get("API_TOKEN", "") +HEADERS = {"Authorization": f"Bearer {TOKEN}"} + +class TestAPISmoke: + def test_health(self): + resp = requests.get(f"{BASE_URL}/health", timeout=5) + assert resp.status_code == 200 + + def test_list_users_returns_array(self): + resp = requests.get(f"{BASE_URL}/users", headers=HEADERS, timeout=10) + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data.get("data", data), list) + + def test_get_user_required_fields(self): + resp = requests.get(f"{BASE_URL}/users/1", headers=HEADERS, timeout=10) + assert resp.status_code in (200, 404) + if resp.status_code == 200: + user = resp.json() + assert "id" in user and "email" in user + + def test_invalid_auth_returns_401(self): + resp = requests.get( + f"{BASE_URL}/users", + headers={"Authorization": "Bearer invalid-token"}, + timeout=10, + ) + assert resp.status_code == 401 +``` + +## Security + +### Token handling +- Never log full tokens. Redact: `Bearer <REDACTED>`. +- Never hardcode tokens in scripts. Read from env (`os.environ["API_TOKEN"]`) or `~/.hermes/.env`. +- Rotate immediately if a token surfaces in logs, error messages, or git history. + +### Safe logging + +```python +def redact_auth(headers: dict) -> dict: + sensitive = {"authorization", "x-api-key", "cookie", "set-cookie"} + return {k: ("<REDACTED>" if k.lower() in sensitive else v) for k, v in headers.items()} +``` + +### Leak checklist + +- [ ] **Credentials in URLs.** API keys in query strings end up in server logs, browser history, referrer headers — use headers. +- [ ] **PII in error responses.** `404 on /users/123` shouldn't reveal whether the user exists (enumeration). +- [ ] **Stack traces in prod.** 500s shouldn't leak file paths, framework versions. +- [ ] **Internal hostnames/IPs.** `10.x.x.x`, `internal-api.corp.local` in error bodies. +- [ ] **Tokens echoed back.** Some APIs include the auth token in error details. Verify they don't. +- [ ] **Verbose `Server` / `X-Powered-By`.** Stack-info leaks. Note for security review. + +## Hermes Tool Patterns + +### terminal — for curl, dig, openssl + +```python +terminal('curl -sI https://api.example.com') +terminal('openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates') +``` + +### execute_code — for multi-step Python flows + +When debugging spans auth → fetch → paginate → validate, use `execute_code`. Variables persist for the script, results print to stdout, no risk of token spam in your context: + +```python +execute_code(''' +import os, requests + +token = os.environ["API_TOKEN"] +base = "https://api.example.com" +H = {"Authorization": f"Bearer {token}"} + +# 1. auth +me = requests.get(f"{base}/me", headers=H, timeout=10) +print(f"auth {me.status_code}") + +# 2. paginate +all_users, cursor = [], None +while True: + params = {"cursor": cursor} if cursor else {} + r = requests.get(f"{base}/users", headers=H, params=params, timeout=10) + body = r.json() + all_users.extend(body["data"]) + cursor = body.get("next_cursor") + if not cursor: + break +print(f"users={len(all_users)}") +''') +``` + +### web_extract — for vendor API docs + +Pull the spec for the endpoint you're debugging instead of guessing: + +```python +web_extract(urls=["https://docs.example.com/api/v1/users"]) +``` + +### delegate_task — for full CRUD test sweeps + +```python +delegate_task( + goal="Test all CRUD endpoints for /api/v1/users", + context=""" +Follow the rest-graphql-debug skill (optional-skills/software-development/rest-graphql-debug). +Base URL: https://api.example.com +Auth: Bearer token from API_TOKEN env var. + +For each verb (POST, GET, PATCH, DELETE): + - happy path: assert status + response schema + - error cases: 400, 404, 422 + - log a repro curl for any failure (redact tokens) + +Output: pass/fail per endpoint + correlation IDs for failures. +""", + toolsets=["terminal", "file"], +) +``` + +## Output Format + +When reporting findings: + +``` +## Finding +Endpoint: POST /api/v1/users +Status: 422 Unprocessable Entity +Req ID: req_abc123xyz + +## Repro +curl -X POST https://api.example.com/api/v1/users \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer <REDACTED>' \ + -d '{"name":"test"}' + +## Root Cause +Missing required field `email`. Server validation rejects before processing. + +## Fix +-d '{"name":"test","email":"test@example.com"}' +``` + +## Related + +- `systematic-debugging` — once the failing API layer is isolated, root-cause your code +- `test-driven-development` — write the regression test before shipping the fix diff --git a/plugins/example-dashboard/dashboard/dist/index.js b/plugins/example-dashboard/dashboard/dist/index.js deleted file mode 100644 index 04092348ffb..00000000000 --- a/plugins/example-dashboard/dashboard/dist/index.js +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Example Dashboard Plugin - * - * Demonstrates how to build a dashboard plugin using the Hermes Plugin SDK. - * No build step needed — this is a plain IIFE that uses globals from the SDK. - */ -(function () { - "use strict"; - - const SDK = window.__HERMES_PLUGIN_SDK__; - const { React } = SDK; - const { Card, CardHeader, CardTitle, CardContent, Badge, Button } = SDK.components; - const { useState, useEffect } = SDK.hooks; - const { cn } = SDK.utils; - - function ExamplePage() { - const [greeting, setGreeting] = useState(null); - const [loading, setLoading] = useState(false); - - function fetchGreeting() { - setLoading(true); - SDK.fetchJSON("/api/plugins/example/hello") - .then(function (data) { setGreeting(data.message); }) - .catch(function () { setGreeting("(backend not available)"); }) - .finally(function () { setLoading(false); }); - } - - return React.createElement("div", { className: "flex flex-col gap-6" }, - // Header card - React.createElement(Card, null, - React.createElement(CardHeader, null, - React.createElement("div", { className: "flex items-center gap-3" }, - React.createElement(CardTitle, { className: "text-lg" }, "Example Plugin"), - React.createElement(Badge, { variant: "outline" }, "v1.0.0"), - ), - ), - React.createElement(CardContent, { className: "flex flex-col gap-4" }, - React.createElement("p", { className: "text-sm text-muted-foreground" }, - "This is an example dashboard plugin. It demonstrates using the Plugin SDK to build ", - "custom tabs with React components, connect to backend API routes, and integrate with ", - "the existing Hermes UI system.", - ), - React.createElement("div", { className: "flex items-center gap-3" }, - React.createElement(Button, { - onClick: fetchGreeting, - disabled: loading, - className: cn( - "inline-flex items-center gap-2 border border-border bg-background/40 px-4 py-2", - "text-sm font-courier transition-colors hover:bg-foreground/10 cursor-pointer", - ), - }, loading ? "Loading..." : "Call Backend API"), - greeting && React.createElement("span", { - className: "text-sm font-courier text-muted-foreground", - }, greeting), - ), - ), - ), - - // Info card about the SDK - React.createElement(Card, null, - React.createElement(CardHeader, null, - React.createElement(CardTitle, { className: "text-base" }, "Plugin SDK Reference"), - ), - React.createElement(CardContent, null, - React.createElement("div", { className: "grid gap-3 text-sm" }, - React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, - React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.React"), - React.createElement("span", { className: "text-muted-foreground text-xs" }, "React instance — use instead of importing react"), - ), - React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, - React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.hooks"), - React.createElement("span", { className: "text-muted-foreground text-xs" }, "useState, useEffect, useCallback, useMemo, useRef, useContext, createContext"), - ), - React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, - React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.components"), - React.createElement("span", { className: "text-muted-foreground text-xs" }, "Card, Badge, Button, Input, Label, Select, Separator, Tabs, etc."), - ), - React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, - React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.api"), - React.createElement("span", { className: "text-muted-foreground text-xs" }, "Hermes API client — getStatus(), getSessions(), etc."), - ), - React.createElement("div", { className: "flex flex-col gap-1 border border-border p-3" }, - React.createElement("span", { className: "font-medium" }, "window.__HERMES_PLUGIN_SDK__.utils"), - React.createElement("span", { className: "text-muted-foreground text-xs" }, "cn(), timeAgo(), isoTimeAgo()"), - ), - ), - ), - ), - ); - } - - // Register this plugin — the dashboard picks it up automatically. - window.__HERMES_PLUGINS__.register("example", ExamplePage); - - // ───────────────────────────────────────────────────────────────────── - // Page-scoped slot demo: inject a small banner at the top of /sessions. - // - // Built-in pages expose named slots (<page>:top, <page>:bottom) that - // plugins can populate without overriding the whole route. The - // manifest lists the slots we use in its `slots` array so the shell - // knows to render <PluginSlot name="sessions:top" /> there. - // ───────────────────────────────────────────────────────────────────── - function SessionsTopBanner() { - return React.createElement(Card, { - className: "border-dashed", - }, - React.createElement(CardContent, { className: "flex items-center gap-3 py-2" }, - React.createElement(Badge, { variant: "outline" }, "Example"), - React.createElement("span", { - className: "text-xs text-muted-foreground", - }, "This banner was injected into the Sessions page by the example plugin via the ", - React.createElement("code", { className: "font-courier" }, "sessions:top"), - " slot."), - ), - ); - } - - window.__HERMES_PLUGINS__.registerSlot("example", "sessions:top", SessionsTopBanner); -})(); diff --git a/plugins/example-dashboard/dashboard/manifest.json b/plugins/example-dashboard/dashboard/manifest.json deleted file mode 100644 index 95fce2f100f..00000000000 --- a/plugins/example-dashboard/dashboard/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "example", - "label": "Example", - "description": "Example dashboard plugin — demonstrates the plugin SDK", - "icon": "Sparkles", - "version": "1.0.0", - "tab": { - "path": "/example", - "position": "after:skills" - }, - "slots": ["sessions:top"], - "entry": "dist/index.js", - "api": "plugin_api.py" -} diff --git a/plugins/example-dashboard/dashboard/plugin_api.py b/plugins/example-dashboard/dashboard/plugin_api.py deleted file mode 100644 index 20aed76e26f..00000000000 --- a/plugins/example-dashboard/dashboard/plugin_api.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Example dashboard plugin — backend API routes. - -Mounted at /api/plugins/example/ by the dashboard plugin system. -""" - -from fastapi import APIRouter - -router = APIRouter() - - -@router.get("/hello") -async def hello(): - """Simple greeting endpoint to demonstrate plugin API routes.""" - return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"} diff --git a/plugins/hermes-achievements/dashboard/dist/index.js b/plugins/hermes-achievements/dashboard/dist/index.js index d30f34e11e9..001b688a94a 100644 --- a/plugins/hermes-achievements/dashboard/dist/index.js +++ b/plugins/hermes-achievements/dashboard/dist/index.js @@ -12,6 +12,35 @@ const hooks = SDK.hooks; const C = SDK.components; const cn = SDK.utils.cn; + // useI18n is a hook so each component that needs translations calls it + // locally (see AchievementsPage, AchievementCard, ShareDialog, LoadingPage). + // Older host dashboards may not expose useI18n yet; fall back to a no-op + // shim that returns en values so the bundle still renders against an older + // host SDK. English fallback strings live alongside each call site. + const useI18n = SDK.useI18n || function () { return { t: { achievements: null }, locale: "en" }; }; + + // Resolve a translation by dotted path (e.g. "card.share_text"); fall back to + // the English string passed in. Used inside components after they call + // useI18n() so they can still render against an older host SDK that doesn't + // expose the achievements namespace yet. + function tx(t, path, fallback, vars) { + let node = t && t.achievements; + if (node) { + const parts = path.split("."); + for (let i = 0; i < parts.length; i++) { + if (node && typeof node === "object" && parts[i] in node) { + node = node[parts[i]]; + } else { node = null; break; } + } + } + let str = (typeof node === "string") ? node : fallback; + if (vars) { + for (const k in vars) { + str = str.replace(new RegExp("\\{" + k + "\\}", "g"), vars[k]); + } + } + return str; + } const LUCIDE = {"flame":"<path d=\"M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z\" />","avalanche":"<path d=\"m8 3 4 8 5-5 5 15H2L8 3z\" />\n <path d=\"M4.14 15.08c2.62-1.57 5.24-1.43 7.86.42 2.74 1.94 5.49 2 8.23.19\" />","nodes":"<rect x=\"16\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" />\n <rect x=\"2\" y=\"16\" width=\"6\" height=\"6\" rx=\"1\" />\n <rect x=\"9\" y=\"2\" width=\"6\" height=\"6\" rx=\"1\" />\n <path d=\"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3\" />\n <path d=\"M12 12V8\" />","rocket":"<path d=\"M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z\" />\n <path d=\"m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z\" />\n <path d=\"M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0\" />\n <path d=\"M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5\" />","branch":"<line x1=\"6\" x2=\"6\" y1=\"3\" y2=\"15\" />\n <circle cx=\"18\" cy=\"6\" r=\"3\" />\n <circle cx=\"6\" cy=\"18\" r=\"3\" />\n <path d=\"M18 9a9 9 0 0 1-9 9\" />","daemon":"<path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\" />\n <path d=\"M21 3v5h-5\" />","clock":"<circle cx=\"12\" cy=\"12\" r=\"10\" />\n <polyline points=\"12 6 12 12 16 14\" />","warning":"<path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\" />\n <path d=\"M12 9v4\" />\n <path d=\"M12 17h.01\" />","wine":"<path d=\"M8 22h8\" />\n <path d=\"M7 10h10\" />\n <path d=\"M12 15v7\" />\n <path d=\"M12 15a5 5 0 0 0 5-5c0-2-.5-4-2-8H9c-1.5 4-2 6-2 8a5 5 0 0 0 5 5Z\" />","scroll":"<path d=\"M15 12h-5\" />\n <path d=\"M15 8h-5\" />\n <path d=\"M19 17V5a2 2 0 0 0-2-2H4\" />\n <path d=\"M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3\" />","plug":"<path d=\"m19 5 3-3\" />\n <path d=\"m2 22 3-3\" />\n <path d=\"M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z\" />\n <path d=\"M7.5 13.5 10 11\" />\n <path d=\"M10.5 16.5 13 14\" />\n <path d=\"m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z\" />","lock":"<circle cx=\"12\" cy=\"16\" r=\"1\" />\n <rect x=\"3\" y=\"10\" width=\"18\" height=\"12\" rx=\"2\" />\n <path d=\"M7 10V7a5 5 0 0 1 10 0v3\" />","package_skull":"<path d=\"M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14\" />\n <path d=\"m7.5 4.27 9 5.15\" />\n <polyline points=\"3.29 7 12 12 20.71 7\" />\n <line x1=\"12\" x2=\"12\" y1=\"22\" y2=\"12\" />\n <path d=\"m17 13 5 5m-5 0 5-5\" />","restart":"<path d=\"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8\" />\n <path d=\"M21 3v5h-5\" />\n <path d=\"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16\" />\n <path d=\"M8 16H3v5\" />","key":"<path d=\"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z\" />\n <circle cx=\"16.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" />","colon":"<path d=\"M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1\" />\n <path d=\"M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1\" />","container":"<path d=\"M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z\" />\n <path d=\"M10 21.9V14L2.1 9.1\" />\n <path d=\"m10 14 11.9-6.9\" />\n <path d=\"M14 19.8v-8.1\" />\n <path d=\"M18 17.5V9.4\" />","melting_clock":"<line x1=\"10\" x2=\"14\" y1=\"2\" y2=\"2\" />\n <line x1=\"12\" x2=\"15\" y1=\"14\" y2=\"11\" />\n <circle cx=\"12\" cy=\"14\" r=\"8\" />","pencil":"<path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\" />\n <path d=\"m15 5 4 4\" />","blueprint":"<path d=\"m12.99 6.74 1.93 3.44\" />\n <path d=\"M19.136 12a10 10 0 0 1-14.271 0\" />\n <path d=\"m21 21-2.16-3.84\" />\n <path d=\"m3 21 8.02-14.26\" />\n <circle cx=\"12\" cy=\"5\" r=\"2\" />","pixel":"<path d=\"M3 7V5a2 2 0 0 1 2-2h2\" />\n <path d=\"M17 3h2a2 2 0 0 1 2 2v2\" />\n <path d=\"M21 17v2a2 2 0 0 1-2 2h-2\" />\n <path d=\"M7 21H5a2 2 0 0 1-2-2v-2\" />\n <path d=\"M7 12h10\" />","ship":"<path d=\"M12 10.189V14\" />\n <path d=\"M12 2v3\" />\n <path d=\"M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6\" />\n <path d=\"M19.38 20A11.6 11.6 0 0 0 21 14l-8.188-3.639a2 2 0 0 0-1.624 0L3 14a11.6 11.6 0 0 0 2.81 7.76\" />\n <path d=\"M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1\" />","spark_cursor":"<path d=\"M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z\" />\n <path d=\"M20 3v4\" />\n <path d=\"M22 5h-4\" />\n <path d=\"M4 17v2\" />\n <path d=\"M5 18H3\" />","needle":"<path d=\"M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z\" />","hammer_scroll":"<path d=\"m15 12-8.373 8.373a1 1 0 1 1-3-3L12 9\" />\n <path d=\"m18 15 4-4\" />\n <path d=\"m21.5 11.5-1.914-1.914A2 2 0 0 1 19 8.172V7l-2.26-2.26a6 6 0 0 0-4.202-1.756L9 2.96l.92.82A6.18 6.18 0 0 1 12 8.4V10l2 2h1.172a2 2 0 0 1 1.414.586L18.5 14.5\" />","anvil":"<path d=\"M7 10H6a4 4 0 0 1-4-4 1 1 0 0 1 1-1h4\" />\n <path d=\"M7 5a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1 7 7 0 0 1-7 7H8a1 1 0 0 1-1-1z\" />\n <path d=\"M9 12v5\" />\n <path d=\"M15 12v5\" />\n <path d=\"M5 20a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3 1 1 0 0 1-1 1H6a1 1 0 0 1-1-1\" />","crystal":"<path d=\"M6 3h12l4 6-10 13L2 9Z\" />\n <path d=\"M11 3 8 9l4 13 4-13-3-6\" />\n <path d=\"M2 9h20\" />","palace":"<line x1=\"3\" x2=\"21\" y1=\"22\" y2=\"22\" />\n <line x1=\"6\" x2=\"6\" y1=\"18\" y2=\"11\" />\n <line x1=\"10\" x2=\"10\" y1=\"18\" y2=\"11\" />\n <line x1=\"14\" x2=\"14\" y1=\"18\" y2=\"11\" />\n <line x1=\"18\" x2=\"18\" y1=\"18\" y2=\"11\" />\n <polygon points=\"12 2 20 7 4 7\" />","dragon":"<path d=\"M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z\" />","antenna":"<path d=\"M4.9 16.1C1 12.2 1 5.8 4.9 1.9\" />\n <path d=\"M7.8 4.7a6.14 6.14 0 0 0-.8 7.5\" />\n <circle cx=\"12\" cy=\"9\" r=\"2\" />\n <path d=\"M16.2 4.8c2 2 2.26 5.11.8 7.47\" />\n <path d=\"M19.1 1.9a9.96 9.96 0 0 1 0 14.1\" />\n <path d=\"M9.5 18h5\" />\n <path d=\"m8 22 4-11 4 11\" />","puzzle":"<path d=\"M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z\" />","rewind":"<path d=\"M9 14 4 9l5-5\" />\n <path d=\"M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11\" />","spiral":"<path d=\"M13 16a3 3 0 0 1 2.24 5\" />\n <path d=\"M18 12h.01\" />\n <path d=\"M18 21h-8a4 4 0 0 1-4-4 7 7 0 0 1 7-7h.2L9.6 6.4a1 1 0 1 1 2.8-2.8L15.8 7h.2c3.3 0 6 2.7 6 6v1a2 2 0 0 1-2 2h-1a3 3 0 0 0-3 3\" />\n <path d=\"M20 8.54V4a2 2 0 1 0-4 0v3\" />\n <path d=\"M7.612 12.524a3 3 0 1 0-1.6 4.3\" />","quote":"<path d=\"M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z\" />\n <path d=\"M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1v2a1 1 0 0 0 1 1 6 6 0 0 0 6-6V5a2 2 0 0 0-2-2z\" />","compass":"<path d=\"m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z\" />\n <circle cx=\"12\" cy=\"12\" r=\"10\" />","browser":"<circle cx=\"12\" cy=\"12\" r=\"10\" />\n <path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\" />\n <path d=\"M2 12h20\" />","terminal":"<polyline points=\"4 17 10 11 4 5\" />\n <line x1=\"12\" x2=\"20\" y1=\"19\" y2=\"19\" />","wand":"<path d=\"m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72\" />\n <path d=\"m14 7 3 3\" />\n <path d=\"M5 6v4\" />\n <path d=\"M19 14v4\" />\n <path d=\"M10 2v2\" />\n <path d=\"M7 8H3\" />\n <path d=\"M21 16h-4\" />\n <path d=\"M11 3H9\" />","folder":"<path d=\"M10.7 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v4.1\" />\n <path d=\"m21 21-1.9-1.9\" />\n <circle cx=\"17\" cy=\"17\" r=\"3\" />","eye":"<path d=\"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0\" />\n <circle cx=\"12\" cy=\"12\" r=\"3\" />","wave":"<path d=\"M2 13a2 2 0 0 0 2-2V7a2 2 0 0 1 4 0v13a2 2 0 0 0 4 0V4a2 2 0 0 1 4 0v13a2 2 0 0 0 4 0v-4a2 2 0 0 1 2-2\" />","swap":"<path d=\"m17 2 4 4-4 4\" />\n <path d=\"M3 11v-1a4 4 0 0 1 4-4h14\" />\n <path d=\"m7 22-4-4 4-4\" />\n <path d=\"M21 13v1a4 4 0 0 1-4 4H3\" />","router":"<rect width=\"20\" height=\"8\" x=\"2\" y=\"14\" rx=\"2\" />\n <path d=\"M6.01 18H6\" />\n <path d=\"M10.01 18H10\" />\n <path d=\"M15 10v4\" />\n <path d=\"M17.84 7.17a4 4 0 0 0-5.66 0\" />\n <path d=\"M20.66 4.34a8 8 0 0 0-11.31 0\" />","codex":"<path d=\"M10 9.5 8 12l2 2.5\" />\n <path d=\"m14 9.5 2 2.5-2 2.5\" />\n <rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />","prism":"<path d=\"M6 3h12l4 6-10 13L2 9Z\" />\n <path d=\"M11 3 8 9l4 13 4-13-3-6\" />\n <path d=\"M2 9h20\" />","marathon":"<line x1=\"10\" x2=\"14\" y1=\"2\" y2=\"2\" />\n <line x1=\"12\" x2=\"15\" y1=\"14\" y2=\"11\" />\n <circle cx=\"12\" cy=\"14\" r=\"8\" />","calendar":"<path d=\"M8 2v4\" />\n <path d=\"M16 2v4\" />\n <rect width=\"18\" height=\"18\" x=\"3\" y=\"4\" rx=\"2\" />\n <path d=\"M3 10h18\" />\n <path d=\"M8 14h.01\" />\n <path d=\"M12 14h.01\" />\n <path d=\"M16 14h.01\" />\n <path d=\"M8 18h.01\" />\n <path d=\"M12 18h.01\" />\n <path d=\"M16 18h.01\" />","moon":"<path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\" />","cache":"<ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\" />\n <path d=\"M3 5V19A9 3 0 0 0 21 19V5\" />\n <path d=\"M3 12A9 3 0 0 0 21 12\" />","secret":"<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\" />\n <path d=\"M9.1 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3\" />\n <path d=\"M12 17h.01\" />"}; @@ -21,7 +50,10 @@ async function api(path, options) { const url = "/api/plugins/hermes-achievements" + path; - const res = await fetch(url, options || {}); + const token = window.__HERMES_SESSION_TOKEN__ || ""; + const headers = { ...((options && options.headers) || {}) }; + if (token) headers["X-Hermes-Session-Token"] = token; + const res = await fetch(url, { ...(options || {}), headers }); if (!res.ok) { const text = await res.text().catch(function () { return res.statusText; }); throw new Error(res.status + ": " + text); @@ -249,6 +281,7 @@ } function ShareDialog({ achievement, onClose }) { + const { t } = useI18n(); const [status, setStatus] = hooks.useState("rendering"); // rendering | ready | copied | error const [errorMsg, setErrorMsg] = hooks.useState(null); const [previewUrl, setPreviewUrl] = hooks.useState(null); @@ -290,7 +323,7 @@ if (!blobRef.current) return; try { if (!navigator.clipboard || !window.ClipboardItem) { - throw new Error("Clipboard image copy not supported in this browser — use Download instead."); + throw new Error(tx(t, "share.clipboard_unsupported", "Clipboard image copy not supported in this browser — use Download instead.")); } await navigator.clipboard.write([ new window.ClipboardItem({ "image/png": blobRef.current }), @@ -308,8 +341,11 @@ // paste in the same flow. function tweetText() { const tierPart = achievement.tier ? (achievement.tier + " tier ") : ""; - return "Just unlocked " + tierPart + "\"" + achievement.name + "\" in Hermes Agent ☤\n\n" + - "@NousResearch · https://hermes-agent.nousresearch.com"; + const tmpl = tx(t, "share.tweet_text", "Just unlocked {tier_part}\"{name}\" in Hermes Agent ☤", { + tier_part: tierPart, + name: achievement.name, + }); + return tmpl + "\n\n@NousResearch · https://hermes-agent.nousresearch.com"; } function shareOnX() { @@ -321,36 +357,36 @@ className: "ha-share-backdrop", onClick: function (e) { if (e.target === e.currentTarget) onClose(); }, }, - React.createElement("div", { className: "ha-share-dialog", role: "dialog", "aria-label": "Share achievement" }, + React.createElement("div", { className: "ha-share-dialog", role: "dialog", "aria-label": tx(t, "share.dialog_label", "Share achievement") }, React.createElement("div", { className: "ha-share-head" }, - React.createElement("strong", null, "Share: " + achievement.name), - React.createElement("button", { className: "ha-share-close", onClick: onClose, "aria-label": "Close" }, "×") + React.createElement("strong", null, tx(t, "share.header", "Share: {name}", { name: achievement.name })), + React.createElement("button", { className: "ha-share-close", onClick: onClose, "aria-label": tx(t, "share.close", "Close") }, "×") ), React.createElement("div", { className: "ha-share-preview" }, - status === "rendering" && React.createElement("div", { className: "ha-share-placeholder" }, "Rendering…"), - previewUrl && React.createElement("img", { src: previewUrl, alt: achievement.name + " share card" }) + status === "rendering" && React.createElement("div", { className: "ha-share-placeholder" }, tx(t, "share.rendering", "Rendering…")), + previewUrl && React.createElement("img", { src: previewUrl, alt: tx(t, "share.card_alt", "{name} share card", { name: achievement.name }) }) ), - status === "error" && React.createElement("div", { className: "ha-share-error" }, errorMsg || "Something went wrong."), + status === "error" && React.createElement("div", { className: "ha-share-error" }, errorMsg || tx(t, "share.error_generic", "Something went wrong.")), React.createElement("div", { className: "ha-share-actions" }, React.createElement("button", { className: "ha-share-btn ha-share-btn-primary", onClick: shareOnX, - title: "Opens X with a pre-filled post", - }, "Share on X"), + title: tx(t, "share.x_title", "Opens X with a pre-filled post"), + }, tx(t, "share.x_button", "Share on X")), React.createElement("button", { className: "ha-share-btn", onClick: copyToClipboard, disabled: status !== "ready" && status !== "copied", - title: "Copy the image to paste into your post", - }, status === "copied" ? "Copied ✓" : "Copy image"), + title: tx(t, "share.copy_title", "Copy the image to paste into your post"), + }, status === "copied" ? tx(t, "share.copied", "Copied ✓") : tx(t, "share.copy_button", "Copy image")), React.createElement("button", { className: "ha-share-btn", onClick: download, disabled: status !== "ready" && status !== "copied", - }, "Download PNG") + }, tx(t, "share.download_button", "Download PNG")) ), React.createElement("p", { className: "ha-share-hint" }, - "Share on X opens a pre-filled post in a new tab. Click Copy image first if you want the 1200×630 badge attached — X lets you paste it right into the tweet composer. Download PNG saves the file for use anywhere." + tx(t, "share.hint", "Share on X opens a pre-filled post in a new tab. Click Copy image first if you want the 1200×630 badge attached — X lets you paste it right into the tweet composer. Download PNG saves the file for use anywhere.") ) ) ); @@ -408,24 +444,32 @@ } function LoadingPage() { + const { t } = useI18n(); return React.createElement("div", { className: "ha-page ha-page-loading" }, React.createElement("section", { className: "ha-hero ha-loading-hero" }, React.createElement("div", null, - React.createElement("div", { className: "ha-kicker" }, "Agentic Gamerscore"), - React.createElement("h1", null, "Hermes Achievements"), - React.createElement("p", null, "Scanning Hermes session history. First scan can take 5–10 seconds on large histories.") + React.createElement("div", { className: "ha-kicker" }, tx(t, "hero.kicker", "Agentic Gamerscore")), + React.createElement("h1", null, tx(t, "hero.title", "Hermes Achievements")), + React.createElement("p", null, tx(t, "hero.scan_subtitle", "Scanning Hermes session history. First scan can take 5–10 seconds on large histories.")) ), React.createElement("div", { className: "ha-scan-status", role: "status", "aria-live": "polite" }, React.createElement("span", { className: "ha-scan-pulse", "aria-hidden": "true" }), React.createElement("div", null, - React.createElement("strong", null, "Building achievement profile…"), - React.createElement("p", null, "Reading sessions, tool calls, model metadata, and unlock state.") + React.createElement("strong", null, tx(t, "scan.building_headline", "Building achievement profile…")), + React.createElement("p", null, tx(t, "scan.building_detail", "Reading sessions, tool calls, model metadata, and unlock state.")) ) ) ), React.createElement("div", { className: "ha-stats" }, - ["Unlocked", "Discovered", "Secrets", "Highest tier", "Latest"].map(function (label) { - return React.createElement(C.Card, { key: label, className: "ha-stat ha-skeleton-stat" }, + [ + { key: "stats.unlocked", fallback: "Unlocked" }, + { key: "stats.discovered", fallback: "Discovered" }, + { key: "stats.secrets", fallback: "Secrets" }, + { key: "stats.highest_tier", fallback: "Highest tier" }, + { key: "stats.latest", fallback: "Latest" }, + ].map(function (entry) { + const label = tx(t, entry.key, entry.fallback); + return React.createElement(C.Card, { key: entry.key, className: "ha-stat ha-skeleton-stat" }, React.createElement(C.CardContent, { className: "ha-stat-content" }, React.createElement("div", { className: "ha-stat-label" }, label), React.createElement("div", { className: "ha-skeleton ha-skeleton-stat-value" }), @@ -436,12 +480,12 @@ ), React.createElement("section", { className: "ha-guide ha-loading-guide" }, React.createElement("div", null, - React.createElement("strong", null, "Scan status"), - React.createElement("p", null, "Hermes is scanning local history once, then cards will appear automatically. Nothing is stuck if this takes a few seconds.") + React.createElement("strong", null, tx(t, "guide.scan_status_header", "Scan status")), + React.createElement("p", null, tx(t, "guide.scan_status_body", "Hermes is scanning local history once, then cards will appear automatically. Nothing is stuck if this takes a few seconds.")) ), React.createElement("div", null, - React.createElement("strong", null, "What is scanned"), - React.createElement("p", null, "Sessions, tool calls, model metadata, errors, achievements, and local unlock state.") + React.createElement("strong", null, tx(t, "guide.what_scanned_header", "What is scanned")), + React.createElement("p", null, tx(t, "guide.what_scanned_body", "Sessions, tool calls, model metadata, errors, achievements, and local unlock state.")) ) ), React.createElement("section", { className: "ha-grid" }, [0, 1, 2, 3, 4, 5].map(function (i) { @@ -452,14 +496,30 @@ function AchievementCard({ achievement }) { + const { t } = useI18n(); const unlocked = achievement.unlocked; const progress = achievement.progress || 0; const pct = achievement.progress_pct || (unlocked ? 100 : 0); const state = achievement.state || (unlocked ? "unlocked" : "discovered"); - const stateLabel = state === "unlocked" ? "Unlocked" : (state === "secret" ? "Secret" : "Discovered"); + const stateLabel = state === "unlocked" + ? tx(t, "state.unlocked", "Unlocked") + : (state === "secret" ? tx(t, "state.secret", "Secret") : tx(t, "state.discovered", "Discovered")); const targetTier = achievement.next_tier || achievement.tier; - const tierLabel = achievement.tier ? achievement.tier : (targetTier ? "Target " + targetTier : (state === "secret" ? "Hidden" : (unlocked ? "Complete" : "Objective"))); - const progressText = state === "secret" ? "hidden" : (progress + (achievement.next_threshold ? " / " + achievement.next_threshold : "")); + let tierLabel; + if (achievement.tier) { + tierLabel = achievement.tier; + } else if (targetTier) { + tierLabel = tx(t, "tier.target", "Target {tier}", { tier: targetTier }); + } else if (state === "secret") { + tierLabel = tx(t, "tier.hidden", "Hidden"); + } else if (unlocked) { + tierLabel = tx(t, "tier.complete", "Complete"); + } else { + tierLabel = tx(t, "tier.objective", "Objective"); + } + const progressText = state === "secret" + ? tx(t, "progress.hidden", "hidden") + : (progress + (achievement.next_threshold ? " / " + achievement.next_threshold : "")); const [shareOpen, setShareOpen] = hooks.useState(false); return React.createElement(C.Card, { className: cn("ha-card", "ha-state-" + state, tierClass(achievement.tier || achievement.next_tier)) }, React.createElement(C.CardContent, { className: "ha-card-content" }, @@ -475,21 +535,23 @@ state === "unlocked" && React.createElement("button", { className: "ha-share-trigger", onClick: function () { setShareOpen(true); }, - title: "Share this achievement", - "aria-label": "Share " + achievement.name, - }, "Share") + title: tx(t, "card.share_title", "Share this achievement"), + "aria-label": tx(t, "card.share_label", "Share {name}", { name: achievement.name }), + }, tx(t, "card.share_text", "Share")) ) ), React.createElement("p", { className: "ha-description" }, achievement.description), achievement.criteria && React.createElement("details", { className: "ha-criteria" }, - React.createElement("summary", null, state === "secret" ? "How to reveal" : "What counts"), + React.createElement("summary", null, state === "secret" + ? tx(t, "card.how_to_reveal", "How to reveal") + : tx(t, "card.what_counts", "What counts")), React.createElement("p", null, achievement.criteria) ), React.createElement("div", { className: "ha-evidence-slot" }, achievement.evidence ? React.createElement("div", { className: "ha-evidence" }, - React.createElement("span", { className: "ha-evidence-label" }, "Evidence"), - React.createElement("span", { className: "ha-evidence-title" }, achievement.evidence.title || achievement.evidence.session_id || "session") - ) : React.createElement("div", { className: "ha-evidence ha-evidence-empty", "aria-hidden": "true" }, "No evidence yet") + React.createElement("span", { className: "ha-evidence-label" }, tx(t, "card.evidence_label", "Evidence")), + React.createElement("span", { className: "ha-evidence-title" }, achievement.evidence.title || achievement.evidence.session_id || tx(t, "card.evidence_session_fallback", "session")) + ) : React.createElement("div", { className: "ha-evidence ha-evidence-empty", "aria-hidden": "true" }, tx(t, "card.no_evidence", "No evidence yet")) ), React.createElement("div", { className: "ha-progress-row" }, React.createElement("div", { className: "ha-progress-track" }, @@ -506,6 +568,7 @@ } function AchievementsPage() { + const { t } = useI18n(); const [data, setData] = hooks.useState(null); const [loading, setLoading] = hooks.useState(true); const [error, setError] = hooks.useState(null); @@ -554,7 +617,7 @@ const discovered = achievements.filter(function (a) { return a.state === "discovered"; }); const secret = achievements.filter(function (a) { return a.state === "secret"; }); const latest = unlocked.slice().sort(function (a, b) { return (b.unlocked_at || 0) - (a.unlocked_at || 0); }).slice(0, 5); - const highest = ["Olympian", "Diamond", "Gold", "Silver", "Copper"].find(function (tier) { return unlocked.some(function (a) { return a.tier === tier; }); }) || "None yet"; + const highest = ["Olympian", "Diamond", "Gold", "Silver", "Copper"].find(function (tier) { return unlocked.some(function (a) { return a.tier === tier; }); }) || tx(t, "stats.none_yet", "None yet"); // Build the in-progress scan banner once so the JSX below stays readable. // Shows nothing when the scan is idle. When a scan is running it renders @@ -568,11 +631,15 @@ const total = Number(meta.sessions_expected_total || 0); const pct = total > 0 ? Math.max(0, Math.min(100, Math.floor((scanned / total) * 100))) : 0; const headline = scanMode === "pending" - ? "Starting achievement scan…" - : "Building achievement profile…"; + ? tx(t, "scan.starting_headline", "Starting achievement scan…") + : tx(t, "scan.building_headline", "Building achievement profile…"); const detail = total > 0 - ? ("Scanned " + scanned.toLocaleString() + " of " + total.toLocaleString() + " sessions · " + pct + "%. Badges unlock as more history streams in.") - : "Reading sessions, tool calls, model metadata, and unlock state. Badges appear here as they unlock."; + ? tx(t, "scan.progress_detail", "Scanned {scanned} of {total} sessions · {pct}%. Badges unlock as more history streams in.", { + scanned: scanned.toLocaleString(), + total: total.toLocaleString(), + pct: String(pct), + }) + : tx(t, "scan.idle_detail", "Reading sessions, tool calls, model metadata, and unlock state. Badges appear here as they unlock."); scanBanner = React.createElement("section", { className: "ha-scan-banner", role: "status", "aria-live": "polite" }, React.createElement("div", { className: "ha-scan-banner-head" }, React.createElement("span", { className: "ha-scan-pulse", "aria-hidden": "true" }), @@ -591,44 +658,57 @@ return React.createElement(LoadingPage, null); } + // Translate the "All" category pill but keep the underlying state ("All") + // as the canonical key the API matches against. + const allCategoryLabel = tx(t, "filters.all_categories", "All"); + const visibilityLabels = { + all: tx(t, "filters.visibility_all", "all"), + unlocked: tx(t, "filters.visibility_unlocked", "unlocked"), + discovered: tx(t, "filters.visibility_discovered", "discovered"), + secret: tx(t, "filters.visibility_secret", "secret"), + }; + return React.createElement("div", { className: "ha-page" }, React.createElement("section", { className: "ha-hero" }, React.createElement("div", null, - React.createElement("div", { className: "ha-kicker" }, "Agentic Gamerscore"), - React.createElement("h1", null, "Hermes Achievements"), - React.createElement("p", null, "Collectible Hermes badges earned from real session history. Known unfinished achievements are shown as Discovered; Secret achievements stay hidden until the first matching behavior appears.") + React.createElement("div", { className: "ha-kicker" }, tx(t, "hero.kicker", "Agentic Gamerscore")), + React.createElement("h1", null, tx(t, "hero.title", "Hermes Achievements")), + React.createElement("p", null, tx(t, "hero.subtitle", "Collectible Hermes badges earned from real session history. Known unfinished achievements are shown as Discovered; Secret achievements stay hidden until the first matching behavior appears.")) ), - React.createElement(C.Button, { onClick: load, className: "ha-refresh" }, "Rescan") + React.createElement(C.Button, { onClick: load, className: "ha-refresh" }, tx(t, "actions.rescan", "Rescan")) ), scanBanner, error && React.createElement(C.Card, { className: "ha-error" }, React.createElement(C.CardContent, null, String(error))), React.createElement("div", { className: "ha-stats" }, - React.createElement(StatCard, { label: "Unlocked", value: (data ? data.unlocked_count : 0) + " / " + (data ? data.total_count : 0), hint: "earned badges" }), - React.createElement(StatCard, { label: "Discovered", value: discovered.length, hint: "known, not earned yet" }), - React.createElement(StatCard, { label: "Secrets", value: secret.length, hint: "hidden until first signal" }), - React.createElement(StatCard, { label: "Highest tier", value: highest, hint: "Copper → Silver → Gold → Diamond → Olympian" }), - React.createElement(StatCard, { label: "Latest", value: latest[0] ? latest[0].name : "None yet", hint: latest[0] ? latest[0].category : "run Hermes more" }) + React.createElement(StatCard, { label: tx(t, "stats.unlocked", "Unlocked"), value: (data ? data.unlocked_count : 0) + " / " + (data ? data.total_count : 0), hint: tx(t, "stats.unlocked_hint", "earned badges") }), + React.createElement(StatCard, { label: tx(t, "stats.discovered", "Discovered"), value: discovered.length, hint: tx(t, "stats.discovered_hint", "known, not earned yet") }), + React.createElement(StatCard, { label: tx(t, "stats.secrets", "Secrets"), value: secret.length, hint: tx(t, "stats.secrets_hint", "hidden until first signal") }), + React.createElement(StatCard, { label: tx(t, "stats.highest_tier", "Highest tier"), value: highest, hint: tx(t, "stats.highest_tier_hint", "Copper → Silver → Gold → Diamond → Olympian") }), + React.createElement(StatCard, { label: tx(t, "stats.latest", "Latest"), value: latest[0] ? latest[0].name : tx(t, "stats.none_yet", "None yet"), hint: latest[0] ? latest[0].category : tx(t, "stats.latest_hint_empty", "run Hermes more") }) ), React.createElement("section", { className: "ha-guide" }, React.createElement("div", null, - React.createElement("strong", null, "Tiers"), + React.createElement("strong", null, tx(t, "guide.tiers_header", "Tiers")), React.createElement(TierLegend, null) ), React.createElement("div", null, - React.createElement("strong", null, "Secret achievements"), - React.createElement("p", null, "Secrets hide their exact trigger. Once Hermes sees a related signal, the card becomes Discovered and shows its requirement.") + React.createElement("strong", null, tx(t, "guide.secret_header", "Secret achievements")), + React.createElement("p", null, tx(t, "guide.secret_body", "Secrets hide their exact trigger. Once Hermes sees a related signal, the card becomes Discovered and shows its requirement.")) ) ), React.createElement("div", { className: "ha-toolbar" }, React.createElement("div", { className: "ha-pills" }, categories.map(function (cat) { - return React.createElement("button", { key: cat, onClick: function () { setCategory(cat); }, className: cat === category ? "active" : "" }, cat); + // Render the localized "All" pill but keep the underlying value + // unchanged so the filter logic still compares against "All". + const pillLabel = cat === "All" ? allCategoryLabel : cat; + return React.createElement("button", { key: cat, onClick: function () { setCategory(cat); }, className: cat === category ? "active" : "" }, pillLabel); })), React.createElement("div", { className: "ha-pills" }, ["all", "unlocked", "discovered", "secret"].map(function (v) { - return React.createElement("button", { key: v, onClick: function () { setVisibility(v); }, className: v === visibility ? "active" : "" }, v); + return React.createElement("button", { key: v, onClick: function () { setVisibility(v); }, className: v === visibility ? "active" : "" }, visibilityLabels[v] || v); })) ), latest.length > 0 && React.createElement("section", { className: "ha-latest" }, - React.createElement("h2", null, "Recent unlocks"), + React.createElement("h2", null, tx(t, "latest.header", "Recent unlocks")), React.createElement("div", { className: "ha-latest-row" }, latest.map(function (a) { return React.createElement("div", { key: a.id, className: cn("ha-chip", tierClass(a.tier)) }, React.createElement("span", { className: "ha-chip-icon" }, React.createElement(AchievementIcon, { icon: a.icon || "secret" })), @@ -638,8 +718,8 @@ ), visibility === "secret" && visible.length === 0 && React.createElement(C.Card, { className: "ha-secret-empty" }, React.createElement(C.CardContent, { className: "ha-secret-empty-content" }, - React.createElement("strong", null, "No hidden secrets left in this scan."), - React.createElement("p", null, "Clue: secrets usually start from unusual failure or power-user patterns — port conflicts, permission walls, missing env vars, YAML mistakes, Docker collisions, rollback/checkpoint use, cache hits, or tiny fixes after lots of red text.") + React.createElement("strong", null, tx(t, "empty.no_secrets_header", "No hidden secrets left in this scan.")), + React.createElement("p", null, tx(t, "empty.no_secrets_body", "Clue: secrets usually start from unusual failure or power-user patterns — port conflicts, permission walls, missing env vars, YAML mistakes, Docker collisions, rollback/checkpoint use, cache hits, or tiny fixes after lots of red text.")) ) ), React.createElement("section", { className: "ha-grid" }, visible.map(function (a) { diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 71ec6b8dc51..720cdb9e1e2 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -24,9 +24,39 @@ const { useState, useEffect, useCallback, useMemo, useRef } = SDK.hooks; const { cn, timeAgo } = SDK.utils; + // useI18n is a hook each component calls locally. Older host dashboards + // may not expose it yet; fall back to a shim so the bundle still renders + // English against an older host SDK. English fallback strings live + // alongside each call site (passed as the third arg of tx()). + const useI18n = SDK.useI18n || function () { return { t: { kanban: null }, locale: "en" }; }; + + // Resolve a translation by dotted path under the kanban namespace + // (e.g. "columnLabels.triage"); fall back to the English string passed in. + function tx(t, path, fallback, vars) { + let node = t && t.kanban; + if (node) { + const parts = path.split("."); + for (let i = 0; i < parts.length; i++) { + if (node && typeof node === "object" && parts[i] in node) { + node = node[parts[i]]; + } else { node = null; break; } + } + } + let str = (typeof node === "string") ? node : fallback; + if (vars) { + for (const k in vars) { + str = str.replace(new RegExp("\\{" + k + "\\}", "g"), vars[k]); + } + } + return str; + } + // Order matches BOARD_COLUMNS in plugin_api.py. const COLUMN_ORDER = ["triage", "todo", "ready", "running", "blocked", "done"]; - const COLUMN_LABEL = { + // English fallback dictionaries — used when the i18n catalog is missing + // a key, and as defaults for the get*() helpers below so callers running + // outside any React component (where there's no `t`) still get sane text. + const FALLBACK_COLUMN_LABEL = { triage: "Triage", todo: "Todo", ready: "Ready", @@ -35,7 +65,7 @@ done: "Done", archived: "Archived", }; - const COLUMN_HELP = { + const FALLBACK_COLUMN_HELP = { triage: "Raw ideas — a specifier will flesh out the spec", todo: "Waiting on dependencies or unassigned", ready: "Assigned and waiting for a dispatcher tick", @@ -44,6 +74,42 @@ done: "Completed", archived: "Archived", }; + const FALLBACK_DESTRUCTIVE = { + done: "Mark this task as done? The worker's claim is released and dependent children become ready.", + archived: "Archive this task? It disappears from the default board view.", + blocked: "Mark this task as blocked? The worker's claim is released.", + }; + const FALLBACK_DIAGNOSTIC_EVENT_LABELS = { + completion_blocked_hallucination: "⚠ Completion blocked — phantom card ids", + suspected_hallucinated_references: "⚠ Prose referenced phantom card ids", + }; + const DIAGNOSTIC_EVENT_KIND_KEYS = { + completion_blocked_hallucination: "completionBlockedHallucination", + suspected_hallucinated_references: "suspectedHallucinatedReferences", + }; + const DESTRUCTIVE_KEYS = { + done: "confirmDone", + archived: "confirmArchive", + blocked: "confirmBlocked", + }; + + function getColumnLabel(t, status) { + return tx(t, "columnLabels." + status, FALLBACK_COLUMN_LABEL[status] || status); + } + function getColumnHelp(t, status) { + return tx(t, "columnHelp." + status, FALLBACK_COLUMN_HELP[status] || ""); + } + function getDestructiveConfirm(t, status) { + const key = DESTRUCTIVE_KEYS[status]; + if (!key) return null; + return tx(t, key, FALLBACK_DESTRUCTIVE[status]); + } + function getDiagnosticEventLabel(t, kind) { + const key = DIAGNOSTIC_EVENT_KIND_KEYS[kind]; + if (!key) return null; + return tx(t, key, FALLBACK_DIAGNOSTIC_EVENT_LABELS[kind]); + } + const COLUMN_DOT = { triage: "hermes-kanban-dot-triage", todo: "hermes-kanban-dot-todo", @@ -54,22 +120,8 @@ archived: "hermes-kanban-dot-archived", }; - const DESTRUCTIVE_TRANSITIONS = { - done: "Mark this task as done? The worker's claim is released and dependent children become ready.", - archived: "Archive this task? It disappears from the default board view.", - blocked: "Mark this task as blocked? The worker's claim is released.", - }; - - // Diagnostic kind labels for the events-tab callout. Event kinds emitted - // by the kernel get a human-readable header when we detect them in the - // events list; add new entries here as new diagnostic event kinds land. - const DIAGNOSTIC_EVENT_LABELS = { - completion_blocked_hallucination: "⚠ Completion blocked — phantom card ids", - suspected_hallucinated_references: "⚠ Prose referenced phantom card ids", - }; - function isDiagnosticEvent(kind) { - return Object.prototype.hasOwnProperty.call(DIAGNOSTIC_EVENT_LABELS, kind); + return Object.prototype.hasOwnProperty.call(FALLBACK_DIAGNOSTIC_EVENT_LABELS, kind); } function phantomIdsFromEvent(ev) { @@ -78,17 +130,22 @@ return p.phantom_cards || p.phantom_refs || []; } - function withCompletionSummary(patch, count) { + // Takes an optional `t` so the prompt/alert text is localised. Callers + // outside React components can pass null and fall through to English. + function withCompletionSummary(patch, count, t) { if (!patch || patch.status !== "done") return patch; const label = count && count > 1 ? `${count} selected task(s)` : "this task"; const value = window.prompt( - `Completion summary for ${label}. This is stored as the task result.`, + tx(t, "completionSummary", + "Completion summary for {label}. This is stored as the task result.", + { label: label }), "", ); if (value === null) return null; const summary = value.trim(); if (!summary) { - window.alert("Completion summary is required before marking a task done."); + window.alert(tx(t, "completionSummaryRequired", + "Completion summary is required before marking a task done.")); return null; } return Object.assign({}, patch, { result: summary, summary }); @@ -314,6 +371,24 @@ // Error boundary // ------------------------------------------------------------------------- + // Wrap the boundary's fallback in a tiny function component so we can + // call useI18n() — class components can't use hooks directly. + function ErrorBoundaryFallback(props) { + const { t } = useI18n(); + return h(Card, null, + h(CardContent, { className: "p-6 text-sm" }, + h("div", { className: "text-destructive font-semibold mb-1" }, + tx(t, "renderingError", "Kanban tab hit a rendering error")), + h("div", { className: "text-muted-foreground text-xs mb-3" }, + props.message), + h(Button, { + onClick: props.onReset, + size: "sm", + }, tx(t, "reloadView", "Reload view")), + ), + ); + } + class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { error: null }; } static getDerivedStateFromError(error) { return { error }; } @@ -323,18 +398,10 @@ } render() { if (this.state.error) { - return h(Card, null, - h(CardContent, { className: "p-6 text-sm" }, - h("div", { className: "text-destructive font-semibold mb-1" }, - "Kanban tab hit a rendering error"), - h("div", { className: "text-muted-foreground text-xs mb-3" }, - String(this.state.error && this.state.error.message || this.state.error)), - h(Button, { - onClick: () => this.setState({ error: null }), - size: "sm", - }, "Reload view"), - ), - ); + return h(ErrorBoundaryFallback, { + message: String(this.state.error && this.state.error.message || this.state.error), + onReset: () => this.setState({ error: null }), + }); } return this.props.children; } @@ -345,6 +412,7 @@ // ------------------------------------------------------------------------- function KanbanPage() { + const { t } = useI18n(); const [board, setBoard] = useState(() => readSelectedBoard() || "default"); const [boardList, setBoardList] = useState([]); // [{slug, name, counts, ...}] const [showNewBoard, setShowNewBoard] = useState(false); @@ -371,6 +439,11 @@ const [selectedTaskId, setSelectedTaskId] = useState(null); const [selectedIds, setSelectedIds] = useState(() => new Set()); + const [lastSelectedId, setLastSelectedId] = useState(null); + const [failedIds, setFailedIds] = useState(() => new Set()); + const [draggingTaskId, setDraggingTaskId] = useState(null); + const handleDragStart = useCallback(function (taskId) { setDraggingTaskId(taskId); }, []); + const handleDragEnd = useCallback(function () { setDraggingTaskId(null); }, []); // Per-task event counter incremented whenever the WS stream reports // a new event for that task id. TaskDrawer useEffect-depends on its // own task's counter so it reloads itself on live events instead of @@ -497,7 +570,8 @@ ws.onclose = function (ev) { if (wsClosedRef.current) return; if (ev && ev.code === 1008) { - setError("WebSocket auth failed — reload the page to refresh the session token."); + setError(tx(t, "wsAuthFailed", + "WebSocket auth failed — reload the page to refresh the session token.")); return; } const delay = Math.min(wsBackoffRef.current, 30000); @@ -520,7 +594,7 @@ if (tenantFilter && t.tenant !== tenantFilter) return false; if (assigneeFilter && t.assignee !== assigneeFilter) return false; if (q) { - const hay = `${t.id} ${t.title || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase(); + const hay = `${t.id} ${t.title || ""} ${t.body || ""} ${t.result || ""} ${t.latest_summary || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase(); if (hay.indexOf(q) === -1) return false; } return true; @@ -534,9 +608,9 @@ // --- actions ------------------------------------------------------------ const moveTask = useCallback(function (taskId, newStatus) { - const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus]; + const confirmMsg = getDestructiveConfirm(t, newStatus); if (confirmMsg && !window.confirm(confirmMsg)) return; - const patch = withCompletionSummary({ status: newStatus }, 1); + const patch = withCompletionSummary({ status: newStatus }, 1, t); if (!patch) return; setBoardData(function (b) { if (!b) return b; @@ -559,10 +633,60 @@ headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }).catch(function (err) { - setError(`Move failed: ${err.message || err}`); + setError(tx(t, "moveFailed", "Move failed: ") + (err.message || err)); loadBoard(); }); - }, [loadBoard, board]); + }, [loadBoard, board, t]); + + const clearSelected = useCallback(function () { + setSelectedIds(new Set()); + setLastSelectedId(null); + setFailedIds(new Set()); + }, []); + const moveSelected = useCallback(function (newStatus) { + const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus]; + if (confirmMsg && !window.confirm(confirmMsg)) return; + if (selectedIds.size === 0) return; + const patch = withCompletionSummary({ status: newStatus }, selectedIds.size); + if (!patch) return; + const ids = Array.from(selectedIds); + // Optimistic UI: remove selected from all columns and prepend to target. + setBoardData(function (b) { + if (!b) return b; + const moved = []; + const columns = b.columns.map(function (col) { + const kept = []; + for (const t of col.tasks) { + if (selectedIds.has(t.id)) moved.push(Object.assign({}, t, { status: newStatus })); + else kept.push(t); + } + return Object.assign({}, col, { tasks: kept }); + }); + const dest = columns.find(function (c) { return c.name === newStatus; }); + if (dest) dest.tasks = moved.concat(dest.tasks); + return Object.assign({}, b, { columns }); + }); + SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(Object.assign({ ids }, patch)), + }).then(function (res) { + const failed = (res.results || []).filter(function (r) { return !r.ok; }); + if (failed.length > 0) { + setError(`Bulk move: ${failed.length} of ${res.results.length} failed`); + setFailedIds(new Set(failed.map(function (f) { return f.id; }))); + } else { + setFailedIds(new Set()); + } + setSelectedIds(new Set()); + setLastSelectedId(null); + loadBoard(); + }).catch(function (err) { + setError(`Move failed: ${err.message || err}`); + setFailedIds(new Set(selectedIds)); + loadBoard(); + }); + }, [selectedIds, loadBoard, board]); const createTask = useCallback(function (body) { return SDK.fetchJSON(withBoard(`${API}/tasks`, board), { @@ -575,13 +699,13 @@ // the task was created successfully — but the user should know // their ready task will sit idle until the gateway is up. if (res && res.warning) { - setError("Task created, but: " + res.warning); + setError(tx(t, "taskCreatedWarning", "Task created, but: ") + res.warning); } loadBoard(); loadBoardList(); // refresh counts in the switcher return res; }); - }, [loadBoard, loadBoardList, board]); + }, [loadBoard, loadBoardList, board, t]); const toggleSelected = useCallback(function (id, additive) { setSelectedIds(function (prev) { @@ -590,15 +714,97 @@ else next.add(id); return next; }); + setLastSelectedId(id); + setFailedIds(function (prev) { + if (prev.has(id)) { + const next = new Set(prev); + next.delete(id); + return next; + } + return prev; + }); }, []); - const clearSelected = useCallback(function () { setSelectedIds(new Set()); }, []); + + const toggleRange = useCallback(function (toId) { + // Build flat visible task order from filteredBoard columns. + setSelectedIds(function (prev) { + const next = new Set(prev); + if (!filteredBoard || !filteredBoard.columns) return next; + const order = []; + for (const col of filteredBoard.columns) { + for (const t of col.tasks || []) order.push(t.id); + } + const anchor = lastSelectedId; + if (!anchor || anchor === toId) { + next.add(toId); + return next; + } + const aIdx = order.indexOf(anchor); + const bIdx = order.indexOf(toId); + if (aIdx === -1 || bIdx === -1) { + next.add(toId); + return next; + } + const lo = Math.min(aIdx, bIdx); + const hi = Math.max(aIdx, bIdx); + for (let i = lo; i <= hi; i++) next.add(order[i]); + return next; + }); + setLastSelectedId(toId); + }, [filteredBoard, lastSelectedId]); + + const selectAllVisible = useCallback(function () { + if (!filteredBoard || !filteredBoard.columns) return; + const next = new Set(); + for (const col of filteredBoard.columns) { + for (const t of col.tasks || []) next.add(t.id); + } + setSelectedIds(next); + if (next.size > 0) { + const first = Array.from(next)[0]; + setLastSelectedId(first); + } + }, [filteredBoard]); + + const selectAllInColumn = useCallback(function (columnName) { + if (!filteredBoard || !filteredBoard.columns) return; + const col = filteredBoard.columns.find(function (c) { return c.name === columnName; }); + if (!col) return; + const allSelected = col.tasks && col.tasks.length > 0 && col.tasks.every(function (t) { return selectedIds.has(t.id); }); + const next = new Set(selectedIds); + if (allSelected) { + for (const t of col.tasks || []) next.delete(t.id); + } else { + for (const t of col.tasks || []) next.add(t.id); + } + setSelectedIds(next); + if (col.tasks && col.tasks.length > 0) setLastSelectedId(col.tasks[0].id); + }, [filteredBoard, selectedIds]); const applyBulk = useCallback(function (patch, confirmMsg) { if (selectedIds.size === 0) return; if (confirmMsg && !window.confirm(confirmMsg)) return; - const finalPatch = withCompletionSummary(patch, selectedIds.size); + const finalPatch = withCompletionSummary(patch, selectedIds.size, t); if (!finalPatch) return; const body = Object.assign({ ids: Array.from(selectedIds) }, finalPatch); + // Optimistic UI for status moves (same pattern as moveSelected). + if (finalPatch.status) { + setBoardData(function (b) { + if (!b) return b; + const moved = []; + const columns = b.columns.map(function (col) { + const kept = []; + for (const t of col.tasks) { + if (selectedIds.has(t.id)) moved.push(Object.assign({}, t, { status: finalPatch.status })); + else kept.push(t); + } + return Object.assign({}, col, { tasks: kept }); + }); + const dest = columns.find(function (c) { return c.name === finalPatch.status; }); + if (dest) dest.tasks = moved.concat(dest.tasks); + return Object.assign({}, b, { columns }); + }); + } SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -607,14 +813,23 @@ .then(function (res) { const failed = (res.results || []).filter(function (r) { return !r.ok; }); if (failed.length > 0) { - setError(`Bulk: ${failed.length} of ${res.results.length} failed: ` + + setError(tx(t, "bulkFailed", "Bulk: ") + + `${failed.length} of ${res.results.length} failed: ` + failed.slice(0, 3).map(function (f) { return `${f.id} (${f.error})`; }).join("; ")); + setFailedIds(new Set(failed.map(function (f) { return f.id; }))); + } else { + setFailedIds(new Set()); } - clearSelected(); + setSelectedIds(new Set()); + setLastSelectedId(null); loadBoard(); }) - .catch(function (e) { setError(String(e.message || e)); }); - }, [selectedIds, loadBoard, clearSelected, board]); + .catch(function (e) { + setError(String(e.message || e)); + setFailedIds(new Set(selectedIds)); + loadBoard(); + }); + }, [selectedIds, loadBoard, board, t]); // --- board switching ---------------------------------------------------- const switchBoard = useCallback(function (nextSlug) { @@ -627,7 +842,13 @@ setLoading(true); setBoard(nextSlug); writeSelectedBoard(nextSlug); - }, [board]); + // Reset filters so stale search/tenant/assignee don't persist across boards. + setSearch(""); + setTenantFilter(""); + setAssigneeFilter(""); + setIncludeArchived(false); + clearSelected(); + }, [board, clearSelected]); const createNewBoard = useCallback(function (payload) { return SDK.fetchJSON(`${API}/boards`, { @@ -655,15 +876,16 @@ // --- render ------------------------------------------------------------- if (loading && !boardData) { return h("div", { className: "p-8 text-sm text-muted-foreground" }, - "Loading Kanban board…"); + tx(t, "loading", "Loading Kanban board…")); } if (error && !boardData) { return h(Card, null, h(CardContent, { className: "p-6" }, h("div", { className: "text-sm text-destructive" }, - "Failed to load Kanban board: ", error), + tx(t, "loadFailed", "Failed to load Kanban board: "), error), h("div", { className: "text-xs text-muted-foreground mt-2" }, - "The backend auto-creates kanban.db on first read. If this persists, check the dashboard logs."), + tx(t, "loadFailedHint", + "The backend auto-creates kanban.db on first read. If this persists, check the dashboard logs.")), ), ); } @@ -709,14 +931,22 @@ assignees: (boardData && boardData.assignees) || [], onApply: applyBulk, onClear: clearSelected, + onSelectAllVisible: selectAllVisible, }) : null, error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null, h(BoardColumns, { board: filteredBoard, laneByProfile, selectedIds, + failedIds, + draggingTaskId, + onDragStart: handleDragStart, + onDragEnd: handleDragEnd, toggleSelected, + toggleRange, + selectAllInColumn, onMove: moveTask, + onMoveSelected: moveSelected, onOpen: setSelectedTaskId, onCreate: createTask, allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []), @@ -771,6 +1001,7 @@ } function AttentionStrip(props) { + const { t } = useI18n(); const [expanded, setExpanded] = useState(false); const [dismissed, setDismissed] = useState(false); const diagTasks = useMemo( @@ -780,8 +1011,8 @@ if (dismissed || diagTasks.length === 0) return null; // Pick the highest severity present so we can colour the strip. let topSev = "warning"; - for (const t of diagTasks) { - const s = (t.warnings && t.warnings.highest_severity) || "warning"; + for (const td of diagTasks) { + const s = (td.warnings && td.warnings.highest_severity) || "warning"; if (s === "critical") { topSev = "critical"; break; } if (s === "error" && topSev !== "critical") topSev = "error"; } @@ -796,14 +1027,15 @@ topSev === "critical" ? "!!!" : topSev === "error" ? "!!" : "⚠"), h("span", { className: "hermes-kanban-attention-text" }, diagTasks.length === 1 - ? "1 task needs attention" - : `${diagTasks.length} tasks need attention`, + ? tx(t, "taskNeedsAttention", "1 task needs attention") + : tx(t, "tasksNeedAttention", "{n} tasks need attention", + { n: diagTasks.length }), ), h("button", { className: "hermes-kanban-attention-toggle", onClick: function () { setExpanded(function (x) { return !x; }); }, type: "button", - }, expanded ? "Hide" : "Show"), + }, expanded ? tx(t, "hide", "Hide") : tx(t, "show", "Show")), h("button", { className: "hermes-kanban-attention-dismiss", onClick: function () { setDismissed(true); }, @@ -813,11 +1045,11 @@ ), expanded ? h("div", { className: "hermes-kanban-attention-list" }, - diagTasks.map(function (t) { - const sev = (t.warnings && t.warnings.highest_severity) || "warning"; - const kinds = t.warnings && t.warnings.kinds ? Object.keys(t.warnings.kinds) : []; + diagTasks.map(function (task) { + const sev = (task.warnings && task.warnings.highest_severity) || "warning"; + const kinds = task.warnings && task.warnings.kinds ? Object.keys(task.warnings.kinds) : []; return h("div", { - key: t.id, + key: task.id, className: cn( "hermes-kanban-attention-row", "hermes-kanban-attention-row--" + sev, @@ -825,19 +1057,19 @@ }, h("span", { className: "hermes-kanban-attention-row-sev" }, sev === "critical" ? "!!!" : sev === "error" ? "!!" : "⚠"), - h("span", { className: "hermes-kanban-attention-row-id" }, t.id), + h("span", { className: "hermes-kanban-attention-row-id" }, task.id), h("span", { className: "hermes-kanban-attention-row-title" }, - t.title || "(untitled)"), + task.title || tx(t, "untitled", "(untitled)")), h("span", { className: "hermes-kanban-attention-row-meta" }, - t.assignee ? "@" + t.assignee : "unassigned", + task.assignee ? "@" + task.assignee : tx(t, "unassigned", "unassigned"), " \u00b7 ", - kinds.length > 0 ? kinds.join(", ") : "diagnostic", + kinds.length > 0 ? kinds.join(", ") : tx(t, "diagnostic", "diagnostic"), ), h("button", { className: "hermes-kanban-attention-row-btn", - onClick: function () { props.onOpen(t.id); }, + onClick: function () { props.onOpen(task.id); }, type: "button", - }, "Open"), + }, tx(t, "open", "Open")), ); }), ) @@ -864,6 +1096,7 @@ // ------------------------------------------------------------------------- function DiagnosticActionButton(props) { + const { t } = useI18n(); const { action, onExec, busy, extra } = props; const label = (action.suggested ? "\u2606 " : "") + action.label; const cls = cn( @@ -885,8 +1118,8 @@ disabled: busy, onClick: function () { onExec(action); }, type: "button", - title: "Copy command to clipboard", - }, (extra && extra.copied) ? "Copied" : label); + title: tx(t, "copyCommand", "Copy command to clipboard"), + }, (extra && extra.copied) ? tx(t, "copied", "Copied") : label); } if (action.kind === "comment") { return h("button", { @@ -909,6 +1142,7 @@ } function DiagnosticCard(props) { + const { t } = useI18n(); const { diag, task, boardSlug, assignees, onRefresh } = props; const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); @@ -953,10 +1187,11 @@ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: "ready" }), }).then(function () { - setMsg({ ok: true, text: `Unblocked ${task.id}. Task is ready for the next tick.` }); + setMsg({ ok: true, text: tx(t, "unblockedMessage", + "Unblocked {id}. Task is ready for the next tick.", { id: task.id }) }); if (onRefresh) onRefresh(); }).catch(function (err) { - setMsg({ ok: false, text: `Unblock failed: ${err.message || err}` }); + setMsg({ ok: false, text: tx(t, "unblockFailed", "Unblock failed: ") + (err.message || err) }); }).then(function () { setBusy(false); }); return; } @@ -968,16 +1203,17 @@ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: `recovery action for ${diag.kind}` }), }).then(function () { - setMsg({ ok: true, text: `Reclaimed ${task.id}. Task is back to ready.` }); + setMsg({ ok: true, text: tx(t, "reclaimedMessage", + "Reclaimed {id}. Task is back to ready.", { id: task.id }) }); if (onRefresh) onRefresh(); }).catch(function (err) { - setMsg({ ok: false, text: `Reclaim failed: ${err.message || err}` }); + setMsg({ ok: false, text: tx(t, "reclaimFailed", "Reclaim failed: ") + (err.message || err) }); }).then(function () { setBusy(false); }); return; } if (action.kind === "reassign") { if (!reassignProfile) { - setMsg({ ok: false, text: "Pick a profile first." }); + setMsg({ ok: false, text: tx(t, "pickProfileFirst", "Pick a profile first.") }); return; } setBusy(true); setMsg(null); @@ -994,11 +1230,12 @@ }).then(function () { setMsg({ ok: true, - text: `Reassigned ${task.id} to ${reassignProfile}.`, + text: tx(t, "reassignedMessage", "Reassigned {id} to {profile}.", + { id: task.id, profile: reassignProfile }), }); if (onRefresh) onRefresh(); }).catch(function (err) { - setMsg({ ok: false, text: `Reassign failed: ${err.message || err}` }); + setMsg({ ok: false, text: tx(t, "reassignFailed", "Reassign failed: ") + (err.message || err) }); }).then(function () { setBusy(false); }); return; } @@ -1049,7 +1286,7 @@ reassignAction ? h("div", { className: "hermes-kanban-diag-reassign-row" }, h("span", { className: "hermes-kanban-diag-reassign-label" }, - "Reassign to:"), + tx(t, "reassignTo", "Reassign to:")), h("select", { className: "hermes-kanban-recovery-select", value: reassignProfile, @@ -1088,6 +1325,7 @@ } function DiagnosticsSection(props) { + const { t } = useI18n(); const diags = props.diagnostics || []; const hasOpenDiags = diags.length > 0; const [open, setOpen] = useState(hasOpenDiags); @@ -1104,14 +1342,14 @@ h("span", { className: "hermes-kanban-section-head" }, hasOpenDiags ? h("span", { className: "hermes-kanban-section-head-warning" }, - `\u26a0 Diagnostics (${diags.length})`) - : "Diagnostics", + `\u26a0 ${tx(t, "diagnostics", "Diagnostics")} (${diags.length})`) + : tx(t, "diagnostics", "Diagnostics"), ), h("button", { className: "hermes-kanban-section-toggle", onClick: function () { setOpen(function (x) { return !x; }); }, type: "button", - }, open ? "Hide" : "Show"), + }, open ? tx(t, "hide", "Hide") : tx(t, "show", "Show")), ), open ? h("div", { className: "hermes-kanban-diag-list" }, @@ -1149,6 +1387,7 @@ } function BoardSwitcher(props) { + const { t } = useI18n(); const list = props.boardList || []; const current = list.find(function (b) { return b.slug === props.board; }); const currentName = current && current.name ? current.name : props.board; @@ -1165,13 +1404,13 @@ if (!shouldShow) { return h("div", { className: "hermes-kanban-boardswitcher-compact", - title: "Boards let you separate unrelated streams of work", + title: tx(t, "boardSwitcherHint", "Boards let you separate unrelated streams of work"), }, h(Button, { onClick: props.onNewClick, size: "sm", className: "h-7 text-xs", - }, "+ New board"), + }, tx(t, "newBoard", "+ New board")), h(DocsLink, null), ); } @@ -1180,7 +1419,7 @@ h("div", { className: "hermes-kanban-boardswitcher-inner" }, h("div", { className: "flex flex-col gap-0.5" }, h("div", { className: "text-[11px] uppercase tracking-wider text-muted-foreground" }, - "Board"), + tx(t, "board", "Board")), h("div", { className: "flex items-center gap-2" }, h(Select, Object.assign({ value: props.board, @@ -1206,26 +1445,26 @@ size: "sm", className: "h-8", title: "Create a new board. Useful when you want an unrelated work stream (different project, different team, isolated scratch area).", - }, "+ New board"), + }, tx(t, "newBoard", "+ New board")), props.board !== "default" ? h(Button, { onClick: function () { - const msg = - `Archive board '${currentName}'? ` + - `It will be moved to boards/_archived/ so you can recover it later. ` + - `Tasks on this board will no longer appear anywhere in the UI.`; + const msg = tx(t, "archiveBoardConfirm", + "Archive board '{name}'? It will be moved to boards/_archived/ so you can recover it later. Tasks on this board will no longer appear anywhere in the UI.", + { name: currentName }); if (window.confirm(msg)) props.onDeleteBoard(props.board); }, size: "sm", className: "h-8", - title: "Archive this board", - }, "Archive") + title: tx(t, "archiveBoardTitle", "Archive this board"), + }, tx(t, "archive", "Archive")) : null, ), ); } function NewBoardDialog(props) { + const { t } = useI18n(); const [slug, setSlug] = useState(""); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -1269,14 +1508,16 @@ className: "hermes-kanban-dialog", onSubmit: onSubmit, }, - h("div", { className: "hermes-kanban-dialog-title" }, "New board"), + h("div", { className: "hermes-kanban-dialog-title" }, + tx(t, "newBoardTitle", "New board")), h("div", { className: "text-xs text-muted-foreground mb-2" }, - "Boards let you separate unrelated streams of work — one per project, repo, or domain. Workers on one board never see another board's tasks."), + tx(t, "newBoardDescription", + "Boards let you separate unrelated streams of work — one per project, repo, or domain. Workers on one board never see another board's tasks.")), h("div", { className: "flex flex-col gap-3" }, h("div", { className: "flex flex-col gap-1" }, - h(Label, { className: "text-xs" }, "Slug ", + h(Label, { className: "text-xs" }, tx(t, "slug", "Slug"), " ", h("span", { className: "text-muted-foreground" }, - "— lowercase, hyphens, e.g. atm10-server")), + tx(t, "slugHint", "— lowercase, hyphens, e.g. atm10-server"))), h(Input, { value: slug, onChange: function (e) { setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9\-_]/g, "-")); }, @@ -1286,18 +1527,20 @@ }), ), h("div", { className: "flex flex-col gap-1" }, - h(Label, { className: "text-xs" }, "Display name ", - h("span", { className: "text-muted-foreground" }, "(optional)")), + h(Label, { className: "text-xs" }, tx(t, "displayName", "Display name"), " ", + h("span", { className: "text-muted-foreground" }, + tx(t, "displayNameHint", "(optional)"))), h(Input, { value: name, onChange: function (e) { setName(e.target.value); }, - placeholder: autoName || "Display name", + placeholder: autoName || tx(t, "displayName", "Display name"), className: "h-8", }), ), h("div", { className: "flex flex-col gap-1" }, - h(Label, { className: "text-xs" }, "Description ", - h("span", { className: "text-muted-foreground" }, "(optional)")), + h(Label, { className: "text-xs" }, tx(t, "description", "Description"), " ", + h("span", { className: "text-muted-foreground" }, + tx(t, "descriptionHint", "(optional)"))), h(Input, { value: description, onChange: function (e) { setDescription(e.target.value); }, @@ -1306,8 +1549,9 @@ }), ), h("div", { className: "flex flex-col gap-1" }, - h(Label, { className: "text-xs" }, "Icon ", - h("span", { className: "text-muted-foreground" }, "(single character or emoji)")), + h(Label, { className: "text-xs" }, tx(t, "icon", "Icon"), " ", + h("span", { className: "text-muted-foreground" }, + tx(t, "iconHint", "(single character or emoji)"))), h(Input, { value: icon, onChange: function (e) { setIcon(e.target.value.slice(0, 4)); }, @@ -1321,7 +1565,7 @@ checked: switchTo, onChange: function (e) { setSwitchTo(e.target.checked); }, }), - "Switch to this board after creating it", + tx(t, "switchAfterCreate", "Switch to this board after creating it"), ), ), err ? h("div", { className: "text-xs text-destructive mt-2" }, err) : null, @@ -1331,12 +1575,12 @@ onClick: props.onCancel, size: "sm", disabled: submitting, - }, "Cancel"), + }, tx(t, "cancel", "Cancel")), h(Button, { type: "submit", size: "sm", disabled: submitting || !slug.trim(), - }, submitting ? "Creating…" : "Create board"), + }, submitting ? tx(t, "creating", "Creating…") : tx(t, "createBoard", "Create board")), ), ), ); @@ -1347,14 +1591,15 @@ // ------------------------------------------------------------------------- function BoardToolbar(props) { + const { t } = useI18n(); const tenants = (props.board && props.board.tenants) || []; const assignees = (props.board && props.board.assignees) || []; return h("div", { className: "flex flex-wrap items-end gap-3" }, h("div", { className: "flex flex-col gap-1", title: "Fuzzy-match tasks by id, title, or description. Matches across all columns." }, - h(Label, { className: "text-xs text-muted-foreground" }, "Search"), + h(Label, { className: "text-xs text-muted-foreground" }, tx(t, "search", "Search")), h(Input, { - placeholder: "Filter cards…", + placeholder: tx(t, "filterCards", "Filter cards…"), value: props.search, onChange: function (e) { props.setSearch(e.target.value); }, className: "w-56 h-8", @@ -1362,25 +1607,25 @@ ), h("div", { className: "flex flex-col gap-1", title: "Tenants are free-form tags on a task (e.g. customer, project, team). Set them via the task drawer or kanban_create." }, - h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"), + h(Label, { className: "text-xs text-muted-foreground" }, tx(t, "tenant", "Tenant")), h(Select, Object.assign({ value: props.tenantFilter, className: "h-8", }, selectChangeHandler(props.setTenantFilter)), - h(SelectOption, { value: "" }, "All tenants"), - tenants.map(function (t) { - return h(SelectOption, { key: t, value: t }, t); + h(SelectOption, { value: "" }, tx(t, "allTenants", "All tenants")), + tenants.map(function (tn) { + return h(SelectOption, { key: tn, value: tn }, tn); }), ), ), h("div", { className: "flex flex-col gap-1", title: "Filter by assigned Hermes profile. Profiles are the named agent identities that claim and work on tasks." }, - h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"), + h(Label, { className: "text-xs text-muted-foreground" }, tx(t, "assignee", "Assignee")), h(Select, Object.assign({ value: props.assigneeFilter, className: "h-8", }, selectChangeHandler(props.setAssigneeFilter)), - h(SelectOption, { value: "" }, "All profiles"), + h(SelectOption, { value: "" }, tx(t, "allProfiles", "All profiles")), assignees.map(function (a) { return h(SelectOption, { key: a, value: a }, a); }), @@ -1393,7 +1638,7 @@ checked: props.includeArchived, onChange: function (e) { props.setIncludeArchived(e.target.checked); }, }), - "Show archived", + tx(t, "showArchived", "Show archived"), ), h("label", { className: "flex items-center gap-2 text-xs", title: "Group the Running column by assigned profile" }, @@ -1402,19 +1647,29 @@ checked: props.laneByProfile, onChange: function (e) { props.setLaneByProfile(e.target.checked); }, }), - "Lanes by profile", + tx(t, "lanesByProfile", "Lanes by profile"), ), h("div", { className: "flex-1" }), h(Button, { onClick: props.onNudgeDispatch, size: "sm", title: "Wake the dispatcher to claim ready tasks now instead of waiting for the next tick. Use this after adding tasks if you want them picked up immediately.", - }, "Nudge dispatcher"), + }, tx(t, "nudgeDispatcher", "Nudge dispatcher")), h(Button, { onClick: props.onRefresh, size: "sm", title: "Reload the board from the database. The board auto-refreshes on task events; this is for forcing a re-read.", - }, "Refresh"), + }, tx(t, "refresh", "Refresh")), + h(Button, { + onClick: function () { + props.setSearch(""); + props.setTenantFilter(""); + props.setAssigneeFilter(""); + props.setIncludeArchived(false); + }, + size: "sm", + title: "Clear all active filters (search, tenant, assignee, archived).", + }, tx(t, "clearFilters", "Clear filters")), ); } @@ -1423,31 +1678,70 @@ // ------------------------------------------------------------------------- function BulkActionBar(props) { + const { t } = useI18n(); const [assignee, setAssignee] = useState(""); + const [reclaimFirst, setReclaimFirst] = useState(false); + const [priority, setPriority] = useState(""); return h("div", { className: "hermes-kanban-bulk" }, h("span", { className: "hermes-kanban-bulk-count" }, - `${props.count} selected`), + `${props.count} ${tx(t, "selected", "selected")}`), + h(Button, { + onClick: function () { props.onApply({ status: "todo" }); }, + size: "sm", + title: "Move selected tasks to Todo.", + }, "→ todo"), h(Button, { onClick: function () { props.onApply({ status: "ready" }); }, size: "sm", title: "Move selected tasks to Ready. Ready tasks are picked up by the dispatcher on the next tick.", }, "→ ready"), + h(Button, { + onClick: function () { props.onApply({ status: "blocked" }, + `Block ${props.count} task(s)?`); }, + size: "sm", + title: "Block selected tasks. Releases any active claims.", + }, "Block"), + h(Button, { + onClick: function () { props.onApply({ status: "ready" }, + `Unblock ${props.count} task(s)?`); }, + size: "sm", + title: "Unblock selected tasks (promote to Ready).", + }, "Unblock"), h(Button, { onClick: function () { props.onApply({ status: "done" }, - `Mark ${props.count} task(s) as done?`); + tx(t, "markDone", "Mark {n} task(s) as done?", { n: props.count })); }, size: "sm", title: "Mark selected tasks as done. Releases any claims and unblocks dependent children. You'll be asked for a completion summary.", - }, "Complete"), + }, tx(t, "complete", "Complete")), h(Button, { onClick: function () { props.onApply({ archive: true }, - `Archive ${props.count} task(s)?`); + tx(t, "markArchived", "Archive {n} task(s)?", { n: props.count })); }, size: "sm", title: "Archive selected tasks. They disappear from the default board view but remain in the database.", - }, "Archive"), + }, tx(t, "archive", "Archive")), + h("div", { className: "hermes-kanban-bulk-priority", + title: "Set priority on selected tasks. Higher = claimed first." }, + h(Input, { + type: "number", + value: priority, + onChange: function (e) { setPriority(e.target.value); }, + placeholder: tx(t, "priority", "pri"), + className: "h-7 text-xs w-16", + }), + h(Button, { + onClick: function () { + if (priority === "") return; + props.onApply({ priority: Number(priority) }); + setPriority(""); + }, + disabled: priority === "", + size: "sm", + }, tx(t, "setPriority", "Set priority")), + ), h("div", { className: "hermes-kanban-bulk-reassign", title: "Reassign selected tasks to a different Hermes profile. Pick a profile (or unassign) and click Apply." }, h(Select, { @@ -1464,20 +1758,33 @@ h(Button, { onClick: function () { if (!assignee) return; - props.onApply({ assignee: assignee === "__none__" ? "" : assignee }); + props.onApply({ assignee: assignee === "__none__" ? "" : assignee, reclaim_first: reclaimFirst }); setAssignee(""); }, disabled: !assignee, size: "sm", title: "Apply the selected assignee to all selected tasks.", - }, "Apply"), + }, tx(t, "apply", "Apply")), + ), + h("label", { className: "hermes-kanban-bulk-reclaim-first", title: "Reclaim any active claims before reassigning" }, + h("input", { + type: "checkbox", + checked: reclaimFirst, + onChange: function (e) { setReclaimFirst(e.target.checked); }, + }), + "Reclaim first", ), h("div", { className: "flex-1" }), + h(Button, { + onClick: props.onSelectAllVisible, + size: "sm", + title: "Select all visible cards across columns.", + }, "Select all visible"), h(Button, { onClick: props.onClear, size: "sm", title: "Deselect all tasks and hide this bar.", - }, "Clear"), + }, tx(t, "clear", "Clear")), ); } @@ -1486,15 +1793,29 @@ // ------------------------------------------------------------------------- function BoardColumns(props) { - return h("div", { className: "hermes-kanban-columns" }, + const handleDragStart = useCallback(function (e) { + const card = e.target.closest && e.target.closest(".hermes-kanban-card"); + if (!card) return; + const taskId = card.getAttribute("data-task-id"); + if (taskId && props.onDragStart) props.onDragStart(taskId); + }, [props.onDragStart]); + const handleDragEnd = useCallback(function () { + if (props.onDragEnd) props.onDragEnd(); + }, [props.onDragEnd]); + return h("div", { className: "hermes-kanban-columns", onDragStart: handleDragStart, onDragEnd: handleDragEnd }, props.board.columns.map(function (col) { return h(Column, { key: col.name, column: col, laneByProfile: props.laneByProfile, selectedIds: props.selectedIds, + failedIds: props.failedIds, + draggingTaskId: props.draggingTaskId, toggleSelected: props.toggleSelected, + toggleRange: props.toggleRange, + selectAllInColumn: props.selectAllInColumn, onMove: props.onMove, + onMoveSelected: props.onMoveSelected, onOpen: props.onOpen, onCreate: props.onCreate, allTasks: props.allTasks, @@ -1504,6 +1825,7 @@ } function Column(props) { + const { t } = useI18n(); const [dragOver, setDragOver] = useState(false); const [showCreate, setShowCreate] = useState(false); const colRef = useRef(null); @@ -1514,12 +1836,17 @@ const el = colRef.current; function onTouchDrop(e) { if (e.detail && e.detail.status === props.column.name) { - props.onMove(e.detail.taskId, props.column.name); + const taskId = e.detail.taskId; + if (props.selectedIds && props.selectedIds.has(taskId) && props.selectedIds.size > 1 && props.onMoveSelected) { + props.onMoveSelected(props.column.name); + } else { + props.onMove(taskId, props.column.name); + } } } el.addEventListener("hermes-kanban:drop", onTouchDrop); return function () { el.removeEventListener("hermes-kanban:drop", onTouchDrop); }; - }, [props.column.name, props.onMove]); + }, [props.column.name, props.onMove, props.selectedIds, props.onMoveSelected]); const handleDragOver = function (e) { e.preventDefault(); @@ -1531,21 +1858,29 @@ e.preventDefault(); setDragOver(false); const taskId = e.dataTransfer.getData(MIME_TASK); - if (taskId) props.onMove(taskId, props.column.name); + if (!taskId) return; + if (props.selectedIds && props.selectedIds.has(taskId) && props.selectedIds.size > 1) { + if (props.onMoveSelected) props.onMoveSelected(props.column.name); + } else { + props.onMove(taskId, props.column.name); + } }; const lanes = useMemo(function () { if (!props.laneByProfile || props.column.name !== "running") return null; const byProfile = {}; - for (const t of props.column.tasks) { - const key = t.assignee || "(unassigned)"; - (byProfile[key] = byProfile[key] || []).push(t); + for (const tk of props.column.tasks) { + const key = tk.assignee || "(unassigned)"; + (byProfile[key] = byProfile[key] || []).push(tk); } return Object.keys(byProfile).sort().map(function (k) { return { assignee: k, tasks: byProfile[k] }; }); }, [props.column, props.laneByProfile]); + const colHelp = getColumnHelp(t, props.column.name); + const colLabel = getColumnLabel(t, props.column.name); + return h("div", { ref: colRef, "data-kanban-column": props.column.name, @@ -1558,22 +1893,34 @@ onDrop: handleDrop, }, h("div", { className: "hermes-kanban-column-header", - title: COLUMN_HELP[props.column.name] || "" }, + title: colHelp || "" }, + h("input", { + type: "checkbox", + className: "hermes-kanban-col-check", + title: "Select all tasks in this column", + "aria-label": `Select all tasks in ${colLabel || props.column.name}`, + checked: props.column.tasks.length > 0 && props.column.tasks.every(function (t) { return props.selectedIds.has(t.id); }), + onChange: function (e) { + e.stopPropagation(); + if (props.selectAllInColumn) props.selectAllInColumn(props.column.name); + }, + onClick: function (e) { e.stopPropagation(); }, + }), h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[props.column.name]) }), h("span", { className: "hermes-kanban-column-label" }, - COLUMN_LABEL[props.column.name] || props.column.name), + colLabel || props.column.name), h("span", { className: "hermes-kanban-column-count", title: `${props.column.tasks.length} task${props.column.tasks.length === 1 ? "" : "s"} in this column` }, props.column.tasks.length), h("button", { type: "button", className: "hermes-kanban-column-add", - title: "Create task in this column", + title: tx(t, "createTask", "Create task in this column"), onClick: function () { setShowCreate(function (v) { return !v; }); }, }, showCreate ? "×" : "+"), ), h("div", { className: "hermes-kanban-column-sub" }, - COLUMN_HELP[props.column.name] || ""), + colHelp || ""), showCreate ? h(InlineCreate, { columnName: props.column.name, allTasks: props.allTasks, @@ -1584,7 +1931,7 @@ }) : null, h("div", { className: "hermes-kanban-column-body" }, props.column.tasks.length === 0 - ? h("div", { className: "hermes-kanban-empty" }, "— no tasks —") + ? h("div", { className: "hermes-kanban-empty" }, tx(t, "noTasks", "— no tasks —")) : lanes ? lanes.map(function (lane) { return h("div", { key: lane.assignee, className: "hermes-kanban-lane" }, @@ -1592,21 +1939,29 @@ h("span", { className: "hermes-kanban-lane-name" }, lane.assignee), h("span", { className: "hermes-kanban-lane-count" }, lane.tasks.length), ), - lane.tasks.map(function (t) { + lane.tasks.map(function (tk) { return h(TaskCard, { - key: t.id, task: t, - selected: props.selectedIds.has(t.id), + key: tk.id, task: tk, + selected: props.selectedIds.has(tk.id), + failed: props.failedIds && props.failedIds.has(tk.id), + draggingTaskId: props.draggingTaskId, + draggingSource: props.draggingTaskId && props.selectedIds.has(props.draggingTaskId) && props.selectedIds.size > 1 && props.selectedIds.has(tk.id), toggleSelected: props.toggleSelected, + toggleRange: props.toggleRange, onOpen: props.onOpen, }); }), ); }) - : props.column.tasks.map(function (t) { + : props.column.tasks.map(function (tk) { return h(TaskCard, { - key: t.id, task: t, - selected: props.selectedIds.has(t.id), + key: tk.id, task: tk, + selected: props.selectedIds.has(tk.id), + failed: props.failedIds && props.failedIds.has(tk.id), + draggingTaskId: props.draggingTaskId, + draggingSource: props.draggingTaskId && props.selectedIds.has(props.draggingTaskId) && props.selectedIds.size > 1 && props.selectedIds.has(tk.id), toggleSelected: props.toggleSelected, + toggleRange: props.toggleRange, onOpen: props.onOpen, }); }), @@ -1640,6 +1995,7 @@ } function TaskCard(props) { + const { t: i18n } = useI18n(); const t = props.task; const cardRef = useRef(null); @@ -1650,17 +2006,42 @@ const handleDragStart = function (e) { e.dataTransfer.setData(MIME_TASK, t.id); e.dataTransfer.effectAllowed = "move"; + const selectedCards = document.querySelectorAll(".hermes-kanban-card--selected"); + if (selectedCards.length > 1 && props.selected) { + const ghost = document.createElement("div"); + ghost.className = "hermes-kanban-drag-ghost"; + ghost.textContent = selectedCards.length + " cards"; + document.body.appendChild(ghost); + e.dataTransfer.setDragImage(ghost, 0, 0); + requestAnimationFrame(function () { + if (ghost.parentNode) document.body.removeChild(ghost); + }); + } }; const handleClick = function (e) { - // Shift-click or ctrl/cmd-click toggles selection instead of opening. - if (e.shiftKey || e.ctrlKey || e.metaKey) { + if (e.shiftKey) { e.preventDefault(); e.stopPropagation(); - props.toggleSelected(t.id, e.ctrlKey || e.metaKey); + if (props.toggleRange) props.toggleRange(t.id); + return; + } + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + props.toggleSelected(t.id, true); return; } props.onOpen(t.id); }; + const handleKeyDown = function (e) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + props.onOpen(t.id); + } + if (e.key === "Escape") { + if (props.toggleSelected) props.toggleSelected(t.id, false); + } + }; const handleCheckbox = function (e) { e.stopPropagation(); props.toggleSelected(t.id, true); @@ -1670,26 +2051,39 @@ return h("div", { ref: cardRef, + "data-task-id": t.id, className: cn( "hermes-kanban-card", props.selected ? "hermes-kanban-card--selected" : "", + props.failed ? "hermes-kanban-card--failed" : "", + props.draggingSource ? "hermes-kanban-card--dragging-source" : "", stalenessClass(t), ), draggable: true, + tabIndex: 0, + role: "button", + "aria-label": `${t.title || "untitled"} — ${t.id} — ${t.status}`, onDragStart: handleDragStart, onClick: handleClick, + onKeyDown: handleKeyDown, }, h(Card, null, h(CardContent, { className: "hermes-kanban-card-content" }, h("div", { className: "hermes-kanban-card-row" }, - h("input", { - type: "checkbox", - className: "hermes-kanban-card-check", - checked: props.selected, - onChange: handleCheckbox, + h("label", { + className: "hermes-kanban-card-check-wrap", + title: tx(i18n, "selectForBulk", "Select for bulk actions"), onClick: function (e) { e.stopPropagation(); }, - title: "Select for bulk actions", - }), + }, + h("input", { + type: "checkbox", + className: "hermes-kanban-card-check", + checked: props.selected, + onChange: handleCheckbox, + onClick: function (e) { e.stopPropagation(); }, + "aria-label": `Select task ${t.id}`, + }), + ), h("span", { className: "hermes-kanban-card-id", title: `Task id: ${t.id}. Use this id with kanban_show, /kanban show, or hermes kanban show.` }, t.id), t.warnings && t.warnings.count > 0 @@ -1725,13 +2119,15 @@ }, `${progress.done}/${progress.total}`) : null, ), - h("div", { className: "hermes-kanban-card-title" }, t.title || "(untitled)"), + h("div", { className: "hermes-kanban-card-title" }, + t.title || tx(i18n, "untitled", "(untitled)")), h("div", { className: "hermes-kanban-card-row hermes-kanban-card-meta" }, t.assignee ? h("span", { className: "hermes-kanban-assignee", title: `Assigned to Hermes profile @${t.assignee}` }, "@", t.assignee) : h("span", { className: "hermes-kanban-unassigned", - title: "No profile assigned. The dispatcher will pick one from available profiles when the task is Ready." }, "unassigned"), + title: "No profile assigned. The dispatcher will pick one from available profiles when the task is Ready." }, + tx(i18n, "unassigned", "unassigned")), t.comment_count > 0 ? h("span", { className: "hermes-kanban-count", title: `${t.comment_count} comment${t.comment_count === 1 ? "" : "s"} on this task` }, "💬 ", t.comment_count) @@ -1755,6 +2151,7 @@ // ------------------------------------------------------------------------- function InlineCreate(props) { + const { t } = useI18n(); const [title, setTitle] = useState(""); const [assignee, setAssignee] = useState(""); const [priority, setPriority] = useState(0); @@ -1799,8 +2196,9 @@ const showPathInput = workspaceKind !== "scratch"; const pathPlaceholder = workspaceKind === "dir" - ? "workspace path (required, e.g. ~/projects/my-app)" - : "workspace path (optional, derived from assignee if blank)"; + ? tx(t, "workspacePathDir", "workspace path (required, e.g. ~/projects/my-app)") + : tx(t, "workspacePathOptional", + "workspace path (optional, derived from assignee if blank)"); return h("div", { className: "hermes-kanban-inline-create" }, h("textarea", { @@ -1811,8 +2209,8 @@ if (e.key === "Escape") props.onCancel(); }, placeholder: props.columnName === "triage" - ? "Rough idea — AI will spec it…" - : "New task title…", + ? tx(t, "triagePlaceholder", "Rough idea — AI will spec it…") + : tx(t, "taskTitlePlaceholder", "New task title…"), autoFocus: true, className: "text-sm min-h-[2rem] max-h-32 resize-y w-full border border-input bg-transparent px-2 py-1 rounded-md focus:outline-none focus:ring-2 focus:ring-ring", rows: 2, @@ -1821,11 +2219,17 @@ h(Input, { value: assignee, onChange: function (e) { setAssignee(e.target.value); }, - placeholder: props.columnName === "triage" ? "specifier" : "assignee", + placeholder: props.columnName === "triage" + ? tx(t, "specifier", "specifier") + : tx(t, "assigneePlaceholder", "assignee"), className: "h-7 text-xs flex-1", title: props.columnName === "triage" ? "Hermes profile that will spec this task (default: the dispatcher's configured specifier). Leave blank to let the dispatcher pick." : "Hermes profile to assign. Leave blank and the dispatcher will pick from available profiles when the task is Ready.", + style: { textTransform: "none" }, + autoCapitalize: "none", + autoCorrect: "off", + spellCheck: false, }), h(Input, { type: "number", @@ -1839,7 +2243,8 @@ h(Input, { value: skills, onChange: function (e) { setSkills(e.target.value); }, - placeholder: "skills (optional, comma-separated): translation, github-code-review", + placeholder: tx(t, "skillsPlaceholder", + "skills (optional, comma-separated): translation, github-code-review"), title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).", className: "h-7 text-xs", }), @@ -1867,10 +2272,10 @@ className: "h-7 text-xs", title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.", }, - h(SelectOption, { value: "" }, "— no parent —"), - (props.allTasks || []).map(function (t) { - return h(SelectOption, { key: t.id, value: t.id }, - `${t.id} — ${(t.title || "").slice(0, 50)}`); + h(SelectOption, { value: "" }, tx(t, "noParent", "— no parent —")), + (props.allTasks || []).map(function (task) { + return h(SelectOption, { key: task.id, value: task.id }, + `${task.id} — ${(task.title || "").slice(0, 50)}`); }), ), h("div", { className: "flex gap-2" }, @@ -1881,7 +2286,7 @@ h(Button, { onClick: props.onCancel, size: "sm", - }, "Cancel"), + }, tx(t, "cancel", "Cancel")), ), ); } @@ -1891,6 +2296,7 @@ // ------------------------------------------------------------------------- function TaskDrawer(props) { + const { t } = useI18n(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); @@ -2056,10 +2462,11 @@ type: "button", onClick: props.onClose, className: "hermes-kanban-drawer-close", - title: "Close (Esc)", + title: tx(t, "close", "Close (Esc)"), }, "×"), ), - loading ? h("div", { className: "p-4 text-sm text-muted-foreground" }, "Loading…") : + loading ? h("div", { className: "p-4 text-sm text-muted-foreground" }, + tx(t, "loadingDetail", "Loading…")) : err ? h("div", { className: "p-4 text-sm text-destructive" }, err) : data ? h(TaskDetail, { data, editing, setEditing, @@ -2087,19 +2494,20 @@ e.preventDefault(); handleComment(); } }, - placeholder: "Add a comment… (Enter to submit)", + placeholder: tx(t, "addComment", "Add a comment… (Enter to submit)"), className: "h-8 text-sm flex-1", }), h(Button, { onClick: handleComment, size: "sm", - }, "Comment"), + }, tx(t, "comment", "Comment")), ) : null, ), ); } function TaskDetail(props) { + const { t: i18n } = useI18n(); const t = props.data.task; const comments = props.data.comments || []; const events = props.data.events || []; @@ -2118,24 +2526,24 @@ }) : h("span", { className: "hermes-kanban-drawer-title-text", - title: "Click to edit", + title: tx(i18n, "clickToEdit", "Click to edit"), onClick: function () { props.setEditing(true); }, - }, t.title || "(untitled)"), + }, t.title || tx(i18n, "untitled", "(untitled)")), ), h("div", { className: "hermes-kanban-drawer-meta" }, - h(MetaRow, { label: "Status", value: t.status }), + h(MetaRow, { label: tx(i18n, "status", "Status"), value: t.status }), h(AssigneeEditor, { task: t, onPatch: props.onPatch }), h(PriorityEditor, { task: t, onPatch: props.onPatch }), - t.tenant ? h(MetaRow, { label: "Tenant", value: t.tenant }) : null, + t.tenant ? h(MetaRow, { label: tx(i18n, "tenant", "Tenant"), value: t.tenant }) : null, h(MetaRow, { - label: "Workspace", + label: tx(i18n, "workspace", "Workspace"), value: `${t.workspace_kind}${t.workspace_path ? ": " + t.workspace_path : ""}`, }), (t.skills && t.skills.length > 0) ? h(MetaRow, { - label: "Skills", + label: tx(i18n, "skills", "Skills"), value: t.skills.join(", "), }) : null, - t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null, + t.created_by ? h(MetaRow, { label: tx(i18n, "createdBy", "Created by"), value: t.created_by }) : null, ), h(StatusActions, { task: t, @@ -2168,13 +2576,15 @@ onRemoveChild: props.onRemoveChild, }), t.result ? h("div", { className: "hermes-kanban-section" }, - h("div", { className: "hermes-kanban-section-head" }, "Result"), + h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")), h(MarkdownBlock, { source: t.result, enabled: props.renderMarkdown }), ) : null, h("div", { className: "hermes-kanban-section" }, - h("div", { className: "hermes-kanban-section-head" }, `Comments (${comments.length})`), + h("div", { className: "hermes-kanban-section-head" }, + `${tx(i18n, "comments", "Comments")} (${comments.length})`), comments.length === 0 - ? h("div", { className: "text-xs text-muted-foreground" }, "— no comments —") + ? h("div", { className: "text-xs text-muted-foreground" }, + tx(i18n, "noComments", "— no comments —")) : comments.map(function (c) { return h("div", { key: c.id, className: "hermes-kanban-comment" }, h("div", { className: "hermes-kanban-comment-head" }, @@ -2187,7 +2597,8 @@ }), ), h("div", { className: "hermes-kanban-section" }, - h("div", { className: "hermes-kanban-section-head" }, `Events (${events.length})`), + h("div", { className: "hermes-kanban-section-head" }, + `${tx(i18n, "events", "Events")} (${events.length})`), events.slice().reverse().slice(0, 20).map(function (e) { const isDiag = isDiagnosticEvent(e.kind); const phantoms = isDiag ? phantomIdsFromEvent(e) : []; @@ -2202,7 +2613,7 @@ ? h("div", { className: "hermes-kanban-event-header" }, h("span", { className: "hermes-kanban-event-warning-icon" }, "⚠"), h("span", { className: "hermes-kanban-event-warning-label" }, - DIAGNOSTIC_EVENT_LABELS[e.kind] || e.kind), + getDiagnosticEventLabel(i18n, e.kind) || e.kind), h("span", { className: "hermes-kanban-event-ago" }, timeAgo ? timeAgo(e.created_at) : ""), ) @@ -2214,7 +2625,7 @@ isDiag && phantoms.length > 0 ? h("div", { className: "hermes-kanban-event-phantom-row" }, h("span", { className: "hermes-kanban-event-phantom-label" }, - "Phantom ids:"), + tx(i18n, "phantomIds", "Phantom ids:")), phantoms.map(function (pid) { return h("code", { key: pid, @@ -2239,6 +2650,7 @@ // active run if any. Each row shows profile / outcome / elapsed / // summary. Collapsed by default when there are more than three runs. function RunHistorySection(props) { + const { t } = useI18n(); const runs = props.runs || []; const [expanded, setExpanded] = useState(false); if (runs.length === 0) return null; @@ -2257,13 +2669,13 @@ return h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head-row" }, h("span", { className: "hermes-kanban-section-head" }, - `Run history (${runs.length})`), + `${tx(t, "runHistory", "Run history")} (${runs.length})`), !showAll ? h("button", { type: "button", onClick: function () { setExpanded(true); }, className: "hermes-kanban-edit-link", - title: "Show all attempts", + title: tx(t, "showAllAttempts", "Show all attempts"), }, `+${runs.length - 3} earlier`) : null, ), @@ -2274,9 +2686,9 @@ return h("div", { key: r.id, className: cn("hermes-kanban-run", outcomeClass) }, h("div", { className: "hermes-kanban-run-head" }, h("span", { className: "hermes-kanban-run-outcome" }, - r.ended_at ? (r.outcome || r.status || "ended") : "active"), + r.ended_at ? (r.outcome || r.status || tx(t, "ended", "ended")) : tx(t, "active", "active")), h("span", { className: "hermes-kanban-run-profile" }, - r.profile ? `@${r.profile}` : "(no profile)"), + r.profile ? `@${r.profile}` : tx(t, "noProfile", "(no profile)")), h("span", { className: "hermes-kanban-run-elapsed" }, fmtElapsed(r)), h("span", { className: "hermes-kanban-run-ago" }, timeAgo ? timeAgo(r.started_at) : ""), @@ -2287,9 +2699,18 @@ r.error ? h("div", { className: "hermes-kanban-run-error" }, r.error) : null, - r.metadata - ? h("code", { className: "hermes-kanban-run-meta" }, - JSON.stringify(r.metadata)) + (r.metadata && Object.keys(r.metadata).length > 0) + ? (function () { + var json = JSON.stringify(r.metadata, null, 2); + var collapsed = json.length > 300; + return h("details", { + className: "hermes-kanban-run-meta-block", + open: !collapsed, + }, + h("summary", { className: "hermes-kanban-run-meta-label" }, "Metadata"), + h("code", { className: "hermes-kanban-run-meta" }, json), + ); + })() : null, ); }), @@ -2298,6 +2719,7 @@ // Worker log: loads lazily (one GET on mount), refresh button, tail cap. function WorkerLogSection(props) { + const { t } = useI18n(); const [state, setState] = useState({ loading: false, data: null, err: null }); const load = useCallback(function () { setState({ loading: true, data: null, err: null }); @@ -2313,12 +2735,14 @@ const data = state.data; let body; if (state.loading) { - body = h("div", { className: "text-xs text-muted-foreground" }, "Loading log…"); + body = h("div", { className: "text-xs text-muted-foreground" }, + tx(t, "loadingLog", "Loading log…")); } else if (state.err) { body = h("div", { className: "text-xs text-destructive" }, state.err); } else if (!data || !data.exists) { body = h("div", { className: "text-xs text-muted-foreground italic" }, - "— no worker log yet (task hasn't spawned or log was rotated away) —"); + tx(t, "noWorkerLog", + "— no worker log yet (task hasn't spawned or log was rotated away) —")); } else { body = h("pre", { className: "hermes-kanban-pre hermes-kanban-log" }, data.content || "(empty)"); @@ -2327,7 +2751,7 @@ return h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head-row" }, h("span", { className: "hermes-kanban-section-head" }, - "Worker log" + (data && data.size_bytes ? ` (${data.size_bytes} B)` : "")), + tx(t, "workerLog", "Worker log") + (data && data.size_bytes ? ` (${data.size_bytes} B)` : "")), h("button", { type: "button", onClick: load, @@ -2338,7 +2762,9 @@ body, data && data.truncated ? h("div", { className: "text-xs text-muted-foreground" }, - "(showing last 100 KB — full log at ", data.path, ")") + tx(t, "logTruncated", "(showing last 100 KB — full log at "), + data.path, + tx(t, "logAt", ")")) : null, ); } @@ -2351,11 +2777,12 @@ } function TitleEditor(props) { + const { t } = useI18n(); const [v, setV] = useState(props.initial); const save = function () { - const t = v.trim(); - if (!t) return; - props.onSave(t); + const trimmed = v.trim(); + if (!trimmed) return; + props.onSave(trimmed); }; return h("div", { className: "hermes-kanban-edit-row" }, h(Input, { @@ -2369,32 +2796,33 @@ }), h(Button, { onClick: save, size: "sm", - }, "Save"), + }, tx(t, "save", "Save")), h(Button, { onClick: props.onCancel, size: "sm", - }, "Cancel"), + }, tx(t, "cancel", "Cancel")), ); } function AssigneeEditor(props) { + const { t } = useI18n(); const [editing, setEditing] = useState(false); const [v, setV] = useState(props.task.assignee || ""); useEffect(function () { setV(props.task.assignee || ""); }, [props.task.assignee]); if (!editing) { return h("div", { className: "hermes-kanban-meta-row" }, - h("span", { className: "hermes-kanban-meta-label" }, "Assignee"), + h("span", { className: "hermes-kanban-meta-label" }, tx(t, "assignee", "Assignee")), h("span", { className: "hermes-kanban-meta-value hermes-kanban-editable", onClick: function () { setEditing(true); }, - title: "Click to edit", - }, props.task.assignee || "unassigned"), + title: tx(t, "clickToEditAssignee", "Click to edit assignee"), + }, props.task.assignee || tx(t, "unassigned", "unassigned")), ); } const save = function () { props.onPatch({ assignee: v.trim() || "" }).then(function () { setEditing(false); }); }; return h("div", { className: "hermes-kanban-meta-row" }, - h("span", { className: "hermes-kanban-meta-label" }, "Assignee"), + h("span", { className: "hermes-kanban-meta-label" }, tx(t, "assignee", "Assignee")), h(Input, { value: v, autoFocus: true, onChange: function (e) { setV(e.target.value); }, @@ -2402,23 +2830,28 @@ if (e.key === "Enter") { e.preventDefault(); save(); } if (e.key === "Escape") setEditing(false); }, - placeholder: "(empty = unassign)", + placeholder: tx(t, "emptyAssignee", "(empty = unassign)"), className: "h-7 text-xs flex-1", + style: { textTransform: "none" }, + autoCapitalize: "none", + autoCorrect: "off", + spellCheck: false, }), ); } function PriorityEditor(props) { + const { t } = useI18n(); const [editing, setEditing] = useState(false); const [v, setV] = useState(String(props.task.priority || 0)); useEffect(function () { setV(String(props.task.priority || 0)); }, [props.task.priority]); if (!editing) { return h("div", { className: "hermes-kanban-meta-row" }, - h("span", { className: "hermes-kanban-meta-label" }, "Priority"), + h("span", { className: "hermes-kanban-meta-label" }, tx(t, "priority", "Priority")), h("span", { className: "hermes-kanban-meta-value hermes-kanban-editable", onClick: function () { setEditing(true); }, - title: "Click to edit", + title: tx(t, "clickToEdit", "Click to edit"), }, String(props.task.priority)), ); } @@ -2426,7 +2859,7 @@ props.onPatch({ priority: Number(v) || 0 }).then(function () { setEditing(false); }); }; return h("div", { className: "hermes-kanban-meta-row" }, - h("span", { className: "hermes-kanban-meta-label" }, "Priority"), + h("span", { className: "hermes-kanban-meta-label" }, tx(t, "priority", "Priority")), h(Input, { type: "number", value: v, autoFocus: true, onChange: function (e) { setV(e.target.value); }, @@ -2440,6 +2873,7 @@ } function BodyEditor(props) { + const { t } = useI18n(); const [editing, setEditing] = useState(false); const [v, setV] = useState(props.task.body || ""); useEffect(function () { setV(props.task.body || ""); }, [props.task.body]); @@ -2448,22 +2882,22 @@ }; return h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head-row" }, - h("span", { className: "hermes-kanban-section-head" }, "Description"), + h("span", { className: "hermes-kanban-section-head" }, tx(t, "description", "Description")), editing ? h("div", { className: "flex gap-1" }, h(Button, { onClick: save, size: "sm", - }, "Save"), + }, tx(t, "save", "Save")), h(Button, { onClick: function () { setEditing(false); setV(props.task.body || ""); }, size: "sm", - }, "Cancel"), + }, tx(t, "cancel", "Cancel")), ) : h("button", { type: "button", onClick: function () { setEditing(true); }, className: "hermes-kanban-edit-link", title: "Edit description", - }, "edit"), + }, tx(t, "edit", "edit")), ), editing ? h("textarea", { @@ -2474,30 +2908,32 @@ }) : props.task.body ? h(MarkdownBlock, { source: props.task.body, enabled: props.renderMarkdown }) - : h("div", { className: "text-xs text-muted-foreground italic" }, "— no description —"), + : h("div", { className: "text-xs text-muted-foreground italic" }, + tx(t, "noDescription", "— no description —")), ); } function DependencyEditor(props) { + const { t } = useI18n(); const { task, links, allTasks } = props; const [newParent, setNewParent] = useState(""); const [newChild, setNewChild] = useState(""); // Filter out self + existing links when offering the "add" dropdown. const candidatesFor = function (excludeSet) { - return (allTasks || []).filter(function (t) { - return t.id !== task.id && !excludeSet.has(t.id); + return (allTasks || []).filter(function (tk) { + return tk.id !== task.id && !excludeSet.has(tk.id); }); }; const parentExclude = new Set([task.id, ...(links.parents || [])]); const childExclude = new Set([task.id, ...(links.children || [])]); return h("div", { className: "hermes-kanban-section" }, - h("div", { className: "hermes-kanban-section-head" }, "Dependencies"), + h("div", { className: "hermes-kanban-section-head" }, tx(t, "dependencies", "Dependencies")), h("div", { className: "hermes-kanban-deps-row" }, - h("span", { className: "hermes-kanban-deps-label" }, "Parents:"), + h("span", { className: "hermes-kanban-deps-label" }, tx(t, "parents", "Parents:")), h("div", { className: "hermes-kanban-deps-chips" }, (links.parents || []).length === 0 - ? h("span", { className: "hermes-kanban-deps-empty" }, "none") + ? h("span", { className: "hermes-kanban-deps-empty" }, tx(t, "none", "none")) : (links.parents || []).map(function (id) { return h("span", { key: id, className: "hermes-kanban-dep-chip" }, id, @@ -2505,7 +2941,7 @@ type: "button", className: "hermes-kanban-dep-chip-x", onClick: function () { props.onRemoveParent(id); }, - title: "Remove dependency", + title: tx(t, "removeDependency", "Remove dependency"), }, "×"), ); }), @@ -2516,10 +2952,10 @@ value: newParent, className: "h-7 text-xs flex-1", }, selectChangeHandler(setNewParent)), - h(SelectOption, { value: "" }, "— add parent —"), - candidatesFor(parentExclude).map(function (t) { - return h(SelectOption, { key: t.id, value: t.id }, - `${t.id} — ${(t.title || "").slice(0, 50)}`); + h(SelectOption, { value: "" }, tx(t, "addParent", "— add parent —")), + candidatesFor(parentExclude).map(function (tk) { + return h(SelectOption, { key: tk.id, value: tk.id }, + `${tk.id} — ${(tk.title || "").slice(0, 50)}`); }), ), h(Button, { @@ -2532,10 +2968,10 @@ }, "+ parent"), ), h("div", { className: "hermes-kanban-deps-row" }, - h("span", { className: "hermes-kanban-deps-label" }, "Children:"), + h("span", { className: "hermes-kanban-deps-label" }, tx(t, "children", "Children:")), h("div", { className: "hermes-kanban-deps-chips" }, (links.children || []).length === 0 - ? h("span", { className: "hermes-kanban-deps-empty" }, "none") + ? h("span", { className: "hermes-kanban-deps-empty" }, tx(t, "none", "none")) : (links.children || []).map(function (id) { return h("span", { key: id, className: "hermes-kanban-dep-chip" }, id, @@ -2543,7 +2979,7 @@ type: "button", className: "hermes-kanban-dep-chip-x", onClick: function () { props.onRemoveChild(id); }, - title: "Remove dependency", + title: tx(t, "removeDependency", "Remove dependency"), }, "×"), ); }), @@ -2554,10 +2990,10 @@ value: newChild, className: "h-7 text-xs flex-1", }, selectChangeHandler(setNewChild)), - h(SelectOption, { value: "" }, "— add child —"), - candidatesFor(childExclude).map(function (t) { - return h(SelectOption, { key: t.id, value: t.id }, - `${t.id} — ${(t.title || "").slice(0, 50)}`); + h(SelectOption, { value: "" }, tx(t, "addChild", "— add child —")), + candidatesFor(childExclude).map(function (tk) { + return h(SelectOption, { key: tk.id, value: tk.id }, + `${tk.id} — ${(tk.title || "").slice(0, 50)}`); }), ), h(Button, { @@ -2573,7 +3009,8 @@ } function StatusActions(props) { - const t = props.task; + const { t } = useI18n(); + const task = props.task; const [specifyBusy, setSpecifyBusy] = useState(false); const [specifyMsg, setSpecifyMsg] = useState(null); const b = function (label, patch, enabled, confirmMsg) { @@ -2588,7 +3025,7 @@ // one column where an auxiliary LLM pass is meaningful. Elsewhere // the backend would return ok:false with "not in triage" anyway, // so hiding the button keeps the action row uncluttered. - const specifyButton = (t.status === "triage" && props.onSpecify) + const specifyButton = (task.status === "triage" && props.onSpecify) ? h(Button, { onClick: function () { if (specifyBusy) return; @@ -2623,21 +3060,21 @@ return h("div", null, h("div", { className: "hermes-kanban-actions" }, specifyButton, - b("→ triage", { status: "triage" }, t.status !== "triage"), - b("→ ready", { status: "ready" }, t.status !== "ready"), + b("→ triage", { status: "triage" }, task.status !== "triage"), + b("→ ready", { status: "ready" }, task.status !== "ready"), // No direct → running button: /tasks/:id PATCH rejects status=running // with 400 (issue #19535). Tasks enter running only through the // dispatcher's claim_task path, which atomically creates the run row, // claim lock, and worker process metadata. - b("Block", { status: "blocked" }, - t.status === "running" || t.status === "ready", - DESTRUCTIVE_TRANSITIONS.blocked), - b("Unblock", { status: "ready" }, t.status === "blocked"), - b("Complete", { status: "done" }, - t.status === "running" || t.status === "ready" || t.status === "blocked", - DESTRUCTIVE_TRANSITIONS.done), - b("Archive", { status: "archived" }, t.status !== "archived", - DESTRUCTIVE_TRANSITIONS.archived), + b(tx(t, "block", "Block"), { status: "blocked" }, + task.status === "running" || task.status === "ready", + getDestructiveConfirm(t, "blocked")), + b(tx(t, "unblock", "Unblock"), { status: "ready" }, task.status === "blocked"), + b(tx(t, "complete", "Complete"), { status: "done" }, + task.status === "running" || task.status === "ready" || task.status === "blocked", + getDestructiveConfirm(t, "done")), + b(tx(t, "archive", "Archive"), { status: "archived" }, task.status !== "archived", + getDestructiveConfirm(t, "archived")), ), specifyMsg ? h("div", { className: specifyMsg.ok @@ -2654,19 +3091,21 @@ // renders when no platforms have a home configured — this section stays // invisible for users who haven't set one up. function HomeSubsSection(props) { + const { t } = useI18n(); const channels = props.homeChannels || []; if (channels.length === 0) return null; const busy = props.homeBusy || {}; return h("div", { className: "hermes-kanban-section" }, h("div", { className: "hermes-kanban-section-head" }, - "Notify home channels"), + tx(t, "notifyHomeChannels", "Notify home channels")), h("div", { className: "hermes-kanban-home-subs" }, channels.map(function (hc) { const isBusy = !!busy[hc.platform]; const label = hc.subscribed ? "✓ " + hc.platform : hc.platform; + const target = `${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""})`; const title = hc.subscribed - ? `Sending updates to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}). Click to stop.` - : `Send completed / blocked / gave_up notifications to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}).`; + ? `${tx(t, "sendingUpdates", "Sending updates to")} ${target}. Click to stop.` + : `${tx(t, "sendNotifications", "Send completed / blocked / gave_up notifications to")} ${target}.`; return h(Button, { key: hc.platform, size: "sm", diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 0e721ea9d02..3bcfccb289b 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -202,8 +202,10 @@ align-items: center; gap: 0.4rem; font-size: 0.65rem; - text-transform: uppercase; - letter-spacing: 0.08em; + /* Assignee/profile names are case-sensitive. Do not visually uppercase + * lane headers, otherwise a valid `analyst` profile appears as `ANALYST` + * in the WebUI and users may copy the wrong casing back into edits. */ + letter-spacing: 0.02em; color: var(--color-muted-foreground); padding: 0 0.1rem; } @@ -536,6 +538,15 @@ background: color-mix(in srgb, var(--color-ring) 6%, var(--color-card)); } +/* Batch drag source styling — cards that are part of the current multi-drag. + The browser ghost image floats; we dim the original DOM nodes so the user + sees the whole set is in-flight. */ +.hermes-kanban-card--dragging-source :where(.hermes-kanban-card-content) { + opacity: 0.45; + filter: grayscale(0.6); + transition: opacity 120ms ease, filter 120ms ease; +} + .hermes-kanban-card-check { width: 0.85rem; height: 0.85rem; @@ -774,6 +785,22 @@ transition: none; } +/* ---- Multi-drag ghost ----------------------------------------------- */ + +.hermes-kanban-drag-ghost { + position: fixed; + left: -9999px; + padding: 0.45rem 0.8rem; + background: var(--color-card); + border: 2px solid var(--color-ring); + border-radius: var(--radius); + font-size: 0.85rem; + font-weight: 600; + color: var(--color-foreground); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); + pointer-events: none; + opacity: 0.95; +} /* ---- Staleness tiers ------------------------------------------------ */ @@ -861,15 +888,56 @@ padding: 0.15rem 0 0; font-family: var(--font-mono, ui-monospace, monospace); } +/* Run metadata is a secondary detail panel. Render it as a clearly-labeled + * sub-block with a thin left rule, capped height, and muted treatment so + * a verbose JSON blob (e.g. changed_files + URLs from a writer task) does + * not visually swamp the parent run row or get mistaken for a crash dump. + * Uses a native <details>/<summary> pair so collapse is browser-handled + * (zero JS); large blobs default collapsed via the open=false attribute. + * See issue #19548. */ +.hermes-kanban-run-meta-block { + margin-top: 0.4rem; + padding: 0.25rem 0.5rem; + border-left: 2px solid var(--color-border); + background: transparent; +} +.hermes-kanban-run-meta-block > summary.hermes-kanban-run-meta-label { + cursor: pointer; + list-style: none; +} +.hermes-kanban-run-meta-block > summary.hermes-kanban-run-meta-label::-webkit-details-marker { + display: none; +} +.hermes-kanban-run-meta-block > summary.hermes-kanban-run-meta-label::before { + content: "▶ "; + display: inline-block; + font-size: 0.6rem; + margin-right: 0.25rem; + transition: transform 120ms ease; +} +.hermes-kanban-run-meta-block[open] > summary.hermes-kanban-run-meta-label::before { + transform: rotate(90deg); +} +.hermes-kanban-run-meta-label { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-muted-foreground); + padding-bottom: 0.15rem; +} .hermes-kanban-run-meta { display: block; + max-height: 8.5rem; + overflow: auto; font-size: 0.72rem; line-height: 1.5; - padding: 0.15rem 0 0; + padding: 0; color: var(--color-muted-foreground); white-space: pre-wrap; word-break: break-word; font-family: var(--font-mono, ui-monospace, monospace); + background: transparent; } /* ------------------------------------------------------------------------- @@ -1374,3 +1442,51 @@ color: #ff8b6b; border: 1px solid rgba(255, 107, 61, 0.3); } +/* ---- Partial failure highlight --------------------------------------- */ +.hermes-kanban-card--failed :where(.hermes-kanban-card-content) { + box-shadow: 0 0 0 2px var(--color-destructive, #d14a4a) inset, + 0 0 8px color-mix(in srgb, var(--color-destructive, #d14a4a) 30%, transparent); +} + +/* ---- Larger checkbox hit target -------------------------------------- */ +.hermes-kanban-card-check-wrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + margin: -0.3rem; + cursor: pointer; +} +.hermes-kanban-card-check { + width: 0.95rem; + height: 0.95rem; + margin: 0; + cursor: pointer; + accent-color: var(--color-ring); +} + +/* ---- Column select-all checkbox -------------------------------------- */ +.hermes-kanban-col-check { + width: 0.9rem; + height: 0.9rem; + margin: 0 0.15rem 0 0; + cursor: pointer; + accent-color: var(--color-ring); +} + +/* ---- Bulk action bar extras ------------------------------------------ */ +.hermes-kanban-bulk-priority { + display: flex; + align-items: center; + gap: 0.25rem; + padding-left: 0.5rem; + border-left: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); +} +.hermes-kanban-bulk-reclaim-first { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.7rem; + cursor: pointer; +} diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 4cc2ccb3c3d..7b0cb1d791a 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -13,15 +13,24 @@ reads run alongside the dispatcher's IMMEDIATE write transactions). Security note ------------- -The dashboard's HTTP auth middleware (``web_server.auth_middleware``) -explicitly skips ``/api/plugins/`` — plugin routes are unauthenticated by -design because the dashboard binds to localhost by default. For the -WebSocket we still require the session token as a ``?token=`` query -parameter (browsers cannot set the ``Authorization`` header on an upgrade -request), matching the established pattern used by the in-browser PTY -bridge in ``hermes_cli/web_server.py``. If you run the dashboard with -``--host 0.0.0.0``, every plugin route — kanban included — becomes -reachable from the network. Don't do that on a shared host. +Plugin HTTP routes go through the dashboard's session-token auth middleware +(``web_server.auth_middleware``) just like core API routes — every +``/api/plugins/...`` request must present the session bearer token (or the +session cookie set when you load the dashboard HTML). The token is the +random per-process ``_SESSION_TOKEN`` printed at startup; the dashboard's +own pages inject it via ``window.__HERMES_SESSION_TOKEN__`` so logged-in +browsers don't have to handle it manually. + +For the ``/events`` WebSocket we still require the session token as a +``?token=`` query parameter (browsers cannot set the ``Authorization`` +header on an upgrade request), matching the established pattern used by +the in-browser PTY bridge in ``hermes_cli/web_server.py``. + +This means ``hermes dashboard --host 0.0.0.0`` is safe to run on a LAN: +plugin routes are no longer an unauthenticated exception. The auth still +isn't multi-user — anyone who can read the printed URL+token gets full +dashboard access — but they can't ride along just because they can reach +the port. """ from __future__ import annotations @@ -136,7 +145,10 @@ def _task_dict( d = asdict(task) # Add derived age metrics so the UI can colour stale cards without # computing deltas client-side. - d["age"] = kanban_db.task_age(task) + try: + d["age"] = kanban_db.task_age(task) + except Exception: + d["age"] = {"created_age_seconds": None, "started_age_seconds": None, "time_to_complete_seconds": None} # Surface the latest non-null run summary so dashboards don't show # blank cards/drawers for tasks where the worker handed off via # ``task_runs.summary`` (the kanban-worker pattern) instead of @@ -811,6 +823,7 @@ class BulkTaskBody(BaseModel): result: Optional[str] = None summary: Optional[str] = None metadata: Optional[dict] = None + reclaim_first: bool = False @router.post("/tasks/bulk") @@ -865,9 +878,16 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)): entry.update(ok=False, error=f"transition to {s!r} refused") if payload.assignee is not None: try: - if not kanban_db.assign_task( - conn, tid, payload.assignee or None, - ): + if payload.reclaim_first: + ok = kanban_db.reassign_task( + conn, tid, payload.assignee or None, + reclaim_first=True, + ) + else: + ok = kanban_db.assign_task( + conn, tid, payload.assignee or None, + ) + if not ok: entry.update(ok=False, error="assign refused") except RuntimeError as e: entry.update(ok=False, error=str(e)) diff --git a/plugins/platforms/line/__init__.py b/plugins/platforms/line/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/line/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py new file mode 100644 index 00000000000..67582ffae8d --- /dev/null +++ b/plugins/platforms/line/adapter.py @@ -0,0 +1,1638 @@ +""" +LINE Messaging API platform adapter for Hermes Agent. + +A bundled platform plugin that runs an aiohttp webhook server, accepts LINE +webhook events (signature-verified), and relays messages to/from the agent +via the standard ``BasePlatformAdapter`` interface. + +Design highlights +----------------- + +**Reply token preferred, Push fallback.** LINE's reply token is single-use +and expires roughly 60 seconds after the inbound event. We try Reply first +(it's free) and fall back to the metered Push API when the token is absent, +expired, or rejected by the API. + +**Slow-LLM postback button (optional).** When the LLM is still running past +``slow_response_threshold`` seconds (default 45, leaving 15s margin on the +60s reply-token TTL), we burn the original reply token to send a Template +Buttons bubble — the user taps it later to receive the cached answer via a +*fresh* reply token (also free). State machine: PENDING → READY → DELIVERED, +with ERROR for cancelled runs. Set the threshold to 0 to disable the +button and always Push-fallback instead. + +**Three-allowlist gating.** Separate allowlists for users (U-prefixed), +groups (C-prefixed), and rooms (R-prefixed). ``LINE_ALLOW_ALL_USERS=true`` +is a dev-only escape hatch. + +**Media via public HTTPS.** LINE's Messaging API does *not* accept +binary uploads — images, audio, and video must be reachable HTTPS URLs. +We register registered tempfiles under ``/line/media/<token>/<filename>`` +served by the same aiohttp app, with an allowed-roots traversal guard. +``LINE_PUBLIC_URL`` (e.g. ``https://my-tunnel.example.com``) overrides +the host:port construction so URLs are reachable when bind is 0.0.0.0 +or behind a reverse proxy. + +**5-message batching.** LINE accepts at most 5 message objects per +Reply/Push call; longer responses are smart-chunked at 4500 chars +(LINE per-bubble limit is 5000) and batched. + +Synthesis credits +----------------- + +This file is a synthesis of seven open community PRs adding LINE support +to Hermes Agent. It deliberately ports the *strongest* idea from each into +a single plugin-form module that requires zero core edits: + +* PR #18153 (leepoweii) — Template Buttons postback cache state machine, + Markdown URL preservation, system-message bypass. +* PR #8398 (yuga-hashimoto) — media URL serving with traversal guard, + send_voice / send_video, ``LINE_PUBLIC_URL`` env, macOS ``/tmp`` root. +* PR #16832 (jethac) — config wiring style, voice/image tests. +* PR #21023 (perng) — plugin-form skeleton (the only one already + modeled on ``ADDING_A_PLATFORM.md``), reply→push fallback at 50s TTL, + loading-animation indicator, source dispatcher. +* PR #14942 (soichiyo) — Cloudflare-tunnel operating model (docs only). +* PR #14988 (David-0x221Eight) — text-first scope discipline. +* PR #6676 (liyoungc) — Push-only mode (used as the ``threshold=0`` + fallback path here). +""" + +from __future__ import annotations + +import asyncio +import base64 +import enum +import hashlib +import hmac +import json +import logging +import mimetypes +import os +import re +import secrets +import tempfile +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple +from urllib.parse import quote as _urlquote + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Lazy / function-level imports for gateway internals are NOT used here — +# the plugin discovery flow imports adapter.py late enough that gateway is +# already loaded. +# --------------------------------------------------------------------------- + +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + cache_image_from_bytes, +) +from gateway.config import Platform +from gateway.session import SessionSource + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +LINE_REPLY_URL = "https://api.line.me/v2/bot/message/reply" +LINE_PUSH_URL = "https://api.line.me/v2/bot/message/push" +LINE_LOADING_URL = "https://api.line.me/v2/bot/chat/loading/start" +LINE_CONTENT_URL_FMT = "https://api-data.line.me/v2/bot/message/{message_id}/content" +LINE_BOT_INFO_URL = "https://api.line.me/v2/bot/info" + +# LINE Messaging API hard limits +LINE_PER_BUBBLE_CHARS = 5000 # Hard limit per text message object +LINE_SAFE_BUBBLE_CHARS = 4500 # Conservative limit for chunking +LINE_MAX_MESSAGES_PER_CALL = 5 # API rejects >5 messages per Reply/Push +LINE_REPLY_TOKEN_TTL_SECONDS = 50 # Conservative cap below LINE's ~60s + +# Webhook hardening +WEBHOOK_BODY_MAX_BYTES = 1_048_576 # 1 MiB — webhooks are tiny JSON +DEFAULT_WEBHOOK_PORT = 8646 +DEFAULT_WEBHOOK_PATH = "/line/webhook" +DEFAULT_MEDIA_PATH_PREFIX = "/line/media" + +# Slow-LLM postback button defaults +DEFAULT_SLOW_RESPONSE_THRESHOLD = 45.0 # seconds; 0 disables +DEFAULT_PENDING_REPLY_TEXT = ( + "🤔 Still thinking. Tap below to fetch the answer when it's ready." +) +DEFAULT_BUTTON_LABEL = "Get answer" +DEFAULT_DELIVERED_TEXT = "Already replied ✅" +DEFAULT_INTERRUPTED_TEXT = "Run was interrupted before completion." + +# Media defaults +MEDIA_TOKEN_TTL_SECONDS = 1800 # 30 minutes; LINE caches the URL aggressively +LINE_IMAGE_MAX_BYTES = 10 * 1024 * 1024 # 10 MB per LINE docs +LINE_AV_MAX_BYTES = 200 * 1024 * 1024 # 200 MB for voice/video + +# A 1×1 transparent PNG used as fallback video preview thumbnail when no +# explicit preview is supplied — LINE requires ``previewImageUrl`` for +# video messages. Sourced from the Python stdlib (no Pillow dependency). +_FALLBACK_PNG_PREVIEW = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4" + "890000000d49444154789c63000100000005000100377a7ff20000000049454e" + "44ae426082" +) + + +# --------------------------------------------------------------------------- +# Markdown stripping (URL-preserving) +# --------------------------------------------------------------------------- + +_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)") +_MD_BOLD_RE = re.compile(r"\*\*(.+?)\*\*") +_MD_ITAL_RE = re.compile(r"(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)") +_MD_CODE_INLINE_RE = re.compile(r"`([^`]+)`") +_MD_CODE_BLOCK_RE = re.compile(r"```[a-zA-Z0-9_+-]*\n?(.*?)```", re.DOTALL) +_MD_HEADING_RE = re.compile(r"^#{1,6}\s+", re.MULTILINE) +_MD_BULLET_RE = re.compile(r"^[\s]*[-*+]\s+", re.MULTILINE) + + +def strip_markdown_preserving_urls(text: str) -> str: + """Strip Markdown that LINE can't render, but keep URLs usable. + + LINE's text bubble has zero Markdown support — bold, italics, code + fences, headings, and bullet markers all render as literal characters. + URLs *are* auto-linked by the client, but only when they appear bare + (not inside ``[label](url)`` syntax). This converts ``[label](url)`` + to ``label (url)`` so the URL remains tappable, then strips the rest. + + Source: PR #18153 (leepoweii) — adapted to keep code-block content + visible (LINE users frequently want command snippets to land as + plain text, not be eaten by the fence). + """ + if not text: + return text + + # Code blocks first — keep the inner content, drop the fences. + def _unfence(m: re.Match) -> str: + return m.group(1).rstrip("\n") + text = _MD_CODE_BLOCK_RE.sub(_unfence, text) + + # Inline code: keep content, drop backticks. + text = _MD_CODE_INLINE_RE.sub(r"\1", text) + + # Markdown links → "label (url)" + text = _MD_LINK_RE.sub(lambda m: f"{m.group(1)} ({m.group(2)})", text) + + # Bold/italic markers — strip. + text = _MD_BOLD_RE.sub(r"\1", text) + text = _MD_ITAL_RE.sub(r"\1", text) + + # Headings (#, ##) and bullet markers — strip the prefix only. + text = _MD_HEADING_RE.sub("", text) + text = _MD_BULLET_RE.sub("• ", text) + + return text + + +def split_for_line(text: str, max_chars: int = LINE_SAFE_BUBBLE_CHARS) -> List[str]: + """Split ``text`` into LINE-sized bubbles, preferring paragraph/line breaks. + + Returns at most ``LINE_MAX_MESSAGES_PER_CALL`` chunks; longer text is + truncated with an ellipsis on the final chunk to keep the response + deliverable in a single Reply/Push call. + """ + if not text: + return [] + if len(text) <= max_chars: + return [text] + + chunks: List[str] = [] + remaining = text + while remaining and len(chunks) < LINE_MAX_MESSAGES_PER_CALL: + if len(remaining) <= max_chars: + chunks.append(remaining) + remaining = "" + break + # Try to break on the latest paragraph or newline within budget. + cut = remaining.rfind("\n\n", 0, max_chars) + if cut < int(max_chars * 0.5): + cut = remaining.rfind("\n", 0, max_chars) + if cut < int(max_chars * 0.5): + cut = remaining.rfind(" ", 0, max_chars) + if cut <= 0: + cut = max_chars + chunks.append(remaining[:cut].rstrip()) + remaining = remaining[cut:].lstrip() + + if remaining: + # Truncate gracefully — caller already burned its 5-bubble budget. + if chunks: + tail = chunks[-1] + if len(tail) > max_chars - 1: + tail = tail[: max_chars - 1] + chunks[-1] = tail.rstrip() + "…" + else: + chunks.append(remaining[: max_chars - 1] + "…") + return chunks + + +# --------------------------------------------------------------------------- +# Webhook signature verification +# --------------------------------------------------------------------------- + +def verify_line_signature(body: bytes, signature: str, channel_secret: str) -> bool: + """Verify a LINE webhook's ``X-Line-Signature`` header. + + LINE signs the *raw* request body with HMAC-SHA256 keyed by the + channel secret, then base64-encodes the digest. Constant-time + comparison defends against timing oracles. + """ + if not signature or not channel_secret or body is None: + return False + try: + digest = hmac.new( + channel_secret.encode("utf-8"), + body, + hashlib.sha256, + ).digest() + expected = base64.b64encode(digest).decode("utf-8") + except Exception: + return False + return hmac.compare_digest(expected, signature) + + +# --------------------------------------------------------------------------- +# Cache state machine — slow-LLM postback flow +# --------------------------------------------------------------------------- + +class State(enum.Enum): + PENDING = "pending" # button sent, LLM still running + READY = "ready" # LLM done, response cached, waiting for postback tap + DELIVERED = "delivered" + ERROR = "error" # LLM raised / interrupted; cached error text waiting + + +@dataclass +class _CacheEntry: + state: State + payload: Any = None + chat_id: str = "" + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + + +class RequestCache: + """In-memory cache for slow-LLM postback retrieval. + + PRs #18153 originally combined two TTLs — one for PENDING (24h) and + a shorter one for READY/DELIVERED/ERROR (1h). We keep the same model + here. + """ + + def __init__( + self, + ttl_seconds: int = 3600, + pending_ttl_seconds: int = 86400, + ) -> None: + self._entries: Dict[str, _CacheEntry] = {} + self._ttl = ttl_seconds + self._pending_ttl = pending_ttl_seconds + + def register_pending(self, chat_id: str) -> str: + rid = str(uuid.uuid4()) + self._entries[rid] = _CacheEntry(state=State.PENDING, chat_id=chat_id) + return rid + + def get(self, request_id: str) -> Optional[_CacheEntry]: + return self._entries.get(request_id) + + def set_ready(self, request_id: str, payload: Any) -> None: + entry = self._entries.get(request_id) + if entry is None or entry.state is not State.PENDING: + return + entry.state = State.READY + entry.payload = payload + entry.updated_at = time.time() + + def set_error(self, request_id: str, message: str) -> None: + entry = self._entries.get(request_id) + if entry is None or entry.state is not State.PENDING: + return + entry.state = State.ERROR + entry.payload = message + entry.updated_at = time.time() + + def mark_delivered(self, request_id: str) -> None: + entry = self._entries.get(request_id) + if entry is None or entry.state not in (State.READY, State.ERROR): + return + entry.state = State.DELIVERED + entry.updated_at = time.time() + + def find_pending_for_chat(self, chat_id: str) -> Optional[str]: + for rid, entry in self._entries.items(): + if entry.state is State.PENDING and entry.chat_id == chat_id: + return rid + return None + + def prune(self) -> int: + now = time.time() + removed = 0 + for rid in list(self._entries.keys()): + entry = self._entries[rid] + if entry.state is State.PENDING: + if now - entry.created_at > self._pending_ttl: + del self._entries[rid] + removed += 1 + else: + if now - entry.updated_at > self._ttl: + del self._entries[rid] + removed += 1 + return removed + + +# --------------------------------------------------------------------------- +# Inbound dedup +# --------------------------------------------------------------------------- + +class _MessageDeduplicator: + """Bounded LRU of LINE webhook event IDs to ignore at-least-once retries.""" + + def __init__(self, max_size: int = 1000) -> None: + self._seen: Dict[str, float] = {} + self._max = max_size + + def is_duplicate(self, event_id: str) -> bool: + if not event_id: + return False + if event_id in self._seen: + return True + if len(self._seen) >= self._max: + # Drop the oldest 10% so we don't trim on every insert. + cutoff = sorted(self._seen.values())[len(self._seen) // 10 or 1] + self._seen = {k: v for k, v in self._seen.items() if v > cutoff} + self._seen[event_id] = time.time() + return False + + +# --------------------------------------------------------------------------- +# Source / chat-id resolution +# --------------------------------------------------------------------------- + +def _resolve_chat(source: Dict[str, Any]) -> Tuple[str, str]: + """Return ``(chat_id, chat_type)`` from a LINE event ``source`` block. + + LINE sources are one of: + * ``{"type": "user", "userId": "U..."}`` → 1:1 DM + * ``{"type": "group", "groupId": "C...", "userId": "U..."}`` → group chat + * ``{"type": "room", "roomId": "R...", "userId": "U..."}`` → multi-user room + + Source: PR #21023 (perng), unchanged. + """ + src_type = (source or {}).get("type", "") + if src_type == "group": + return source.get("groupId", ""), "group" + if src_type == "room": + return source.get("roomId", ""), "room" + if src_type == "user": + return source.get("userId", ""), "dm" + return "", "dm" + + +def _allowed_for_source( + source: Dict[str, Any], + *, + allow_all: bool, + user_ids: Set[str], + group_ids: Set[str], + room_ids: Set[str], +) -> bool: + """Three-list gate — credit PR #18153.""" + if allow_all: + return True + src_type = (source or {}).get("type", "") + if src_type == "user": + uid = source.get("userId", "") + return bool(uid) and uid in user_ids + if src_type == "group": + gid = source.get("groupId", "") + return bool(gid) and gid in group_ids + if src_type == "room": + rid = source.get("roomId", "") + return bool(rid) and rid in room_ids + return False + + +# --------------------------------------------------------------------------- +# LINE Reply / Push HTTP client +# --------------------------------------------------------------------------- + +class _LineClient: + """Thin async wrapper around the LINE Messaging API. + + We use ``aiohttp`` directly to avoid a ``line-bot-sdk`` dependency + (the SDK pulls in its own httpx pin and the ergonomic gain is small + for the four endpoints we actually call). + """ + + def __init__(self, channel_access_token: str, *, timeout: float = 15.0) -> None: + self._token = channel_access_token + self._timeout = timeout + self._headers = { + "Authorization": f"Bearer {channel_access_token}", + "Content-Type": "application/json", + } + + async def reply(self, reply_token: str, messages: List[Dict[str, Any]]) -> None: + import aiohttp + timeout = aiohttp.ClientTimeout(total=self._timeout) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + LINE_REPLY_URL, + headers=self._headers, + json={"replyToken": reply_token, "messages": messages}, + ) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError(f"LINE reply {resp.status}: {body[:200]}") + + async def push(self, chat_id: str, messages: List[Dict[str, Any]]) -> None: + import aiohttp + timeout = aiohttp.ClientTimeout(total=self._timeout) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + LINE_PUSH_URL, + headers=self._headers, + json={"to": chat_id, "messages": messages}, + ) as resp: + if resp.status >= 400: + body = await resp.text() + raise RuntimeError(f"LINE push {resp.status}: {body[:200]}") + + async def loading(self, chat_id: str, seconds: int = 60) -> None: + """Loading indicator (DM only). LINE rejects this for groups/rooms.""" + if not chat_id or not chat_id.startswith("U"): + return + import aiohttp + # LINE caps loadingSeconds in 5-step increments, max 60. + clamped = max(5, min(60, (seconds // 5) * 5 or 5)) + try: + timeout = aiohttp.ClientTimeout(total=5.0) + async with aiohttp.ClientSession(timeout=timeout) as session: + await session.post( + LINE_LOADING_URL, + headers=self._headers, + json={"chatId": chat_id, "loadingSeconds": clamped}, + ) + except Exception as exc: # best-effort; never raise + logger.debug("LINE loading indicator failed: %s", exc) + + async def fetch_content(self, message_id: str) -> bytes: + """Download an inbound media message's binary content.""" + import aiohttp + url = LINE_CONTENT_URL_FMT.format(message_id=message_id) + timeout = aiohttp.ClientTimeout(total=30.0) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, headers={"Authorization": f"Bearer {self._token}"}) as resp: + if resp.status >= 400: + raise RuntimeError(f"LINE content {resp.status}") + return await resp.read() + + async def get_bot_user_id(self) -> Optional[str]: + """Fetch this channel's own userId so we can filter self-messages.""" + import aiohttp + timeout = aiohttp.ClientTimeout(total=10.0) + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(LINE_BOT_INFO_URL, headers=self._headers) as resp: + if resp.status >= 400: + return None + data = await resp.json() + return data.get("userId") + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Message builders +# --------------------------------------------------------------------------- + +def _text_message(text: str) -> Dict[str, Any]: + """Build a LINE text message object, capped to per-bubble max.""" + if len(text) > LINE_PER_BUBBLE_CHARS: + text = text[: LINE_PER_BUBBLE_CHARS - 1] + "…" + return {"type": "text", "text": text} + + +def _image_message(original_url: str, preview_url: Optional[str] = None) -> Dict[str, Any]: + return { + "type": "image", + "originalContentUrl": original_url, + "previewImageUrl": preview_url or original_url, + } + + +def _audio_message(url: str, duration_ms: int = 1000) -> Dict[str, Any]: + return { + "type": "audio", + "originalContentUrl": url, + "duration": int(duration_ms), + } + + +def _video_message(url: str, preview_url: str) -> Dict[str, Any]: + return { + "type": "video", + "originalContentUrl": url, + "previewImageUrl": preview_url, + } + + +def build_postback_button_message( + text: str, button_label: str, request_id: str +) -> Dict[str, Any]: + """Template Buttons message — the slow-LLM postback bubble. + + From PR #18153 (leepoweii). Template Buttons stay tappable from chat + history, unlike Quick Reply chips which are dismissed the moment any + new message arrives in the chat. + + LINE limits: ``text`` ≤ 160 chars, ``altText`` ≤ 400 chars. + """ + truncated = text if len(text) <= 160 else text[:157] + "..." + alt = text if len(text) <= 400 else text[:397] + "..." + return { + "type": "template", + "altText": alt, + "template": { + "type": "buttons", + "text": truncated, + "actions": [ + { + "type": "postback", + "label": button_label[:20] or "Get answer", + "data": json.dumps( + {"action": "show_response", "request_id": request_id} + ), + "displayText": button_label[:300] or "Get answer", + } + ], + }, + } + + +# Prefixes the gateway uses for system busy-acks (interrupting / queued / +# steered). When the postback cache has a PENDING entry we *bypass* the +# cache for these so they reach the user as visible bubbles instead of +# being silently swallowed. From PR #18153. +_SYSTEM_BYPASS_PREFIXES: Tuple[str, ...] = ( + "⚡ Interrupting", + "⏳ Queued", + "⏩ Steered", + "💾", # background-review summary +) + + +def _is_system_bypass(content: str) -> bool: + if not content: + return False + return any(content.startswith(p) for p in _SYSTEM_BYPASS_PREFIXES) + + +# --------------------------------------------------------------------------- +# Configuration helpers +# --------------------------------------------------------------------------- + +def _csv_set(value: str) -> Set[str]: + if not value: + return set() + return {x.strip() for x in value.split(",") if x.strip()} + + +def _truthy_env(name: str, default: bool = False) -> bool: + v = os.getenv(name) + if v is None: + return default + return v.strip().lower() in ("1", "true", "yes", "on") + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class LineAdapter(BasePlatformAdapter): + """LINE Messaging API gateway adapter.""" + + # LINE has its own message-edit story (none) — we always send fresh + # bubbles, never edit, so REQUIRES_EDIT_FINALIZE stays False. + + def __init__(self, config, **kwargs): + platform = Platform("line") + super().__init__(config=config, platform=platform) + + extra = getattr(config, "extra", {}) or {} + + # Credentials + self.channel_access_token = ( + os.getenv("LINE_CHANNEL_ACCESS_TOKEN") + or extra.get("channel_access_token", "") + ) + self.channel_secret = ( + os.getenv("LINE_CHANNEL_SECRET") + or extra.get("channel_secret", "") + ) + + # Webhook server + self.webhook_host = os.getenv("LINE_HOST") or extra.get("host", "0.0.0.0") + try: + self.webhook_port = int( + os.getenv("LINE_PORT") or extra.get("port", DEFAULT_WEBHOOK_PORT) + ) + except (TypeError, ValueError): + self.webhook_port = DEFAULT_WEBHOOK_PORT + self.webhook_path = extra.get("webhook_path", DEFAULT_WEBHOOK_PATH) + + # Public base URL — required for media sending when bind isn't + # publicly reachable. + self.public_base_url = ( + os.getenv("LINE_PUBLIC_URL") + or extra.get("public_url", "") + or "" + ).rstrip("/") + + # Three-allowlist gating + self.allow_all = _truthy_env( + "LINE_ALLOW_ALL_USERS", bool(extra.get("allow_all_users", False)) + ) + self.allowed_users = _csv_set( + os.getenv("LINE_ALLOWED_USERS", "") + ) | set(extra.get("allowed_users", [])) + self.allowed_groups = _csv_set( + os.getenv("LINE_ALLOWED_GROUPS", "") + ) | set(extra.get("allowed_groups", [])) + self.allowed_rooms = _csv_set( + os.getenv("LINE_ALLOWED_ROOMS", "") + ) | set(extra.get("allowed_rooms", [])) + + # Slow-LLM postback button threshold + try: + self.slow_response_threshold = float( + os.getenv("LINE_SLOW_RESPONSE_THRESHOLD") + or extra.get("slow_response_threshold", DEFAULT_SLOW_RESPONSE_THRESHOLD) + ) + except (TypeError, ValueError): + self.slow_response_threshold = DEFAULT_SLOW_RESPONSE_THRESHOLD + + # User-overridable copy + self.pending_text = ( + os.getenv("LINE_PENDING_TEXT") + or extra.get("pending_text", DEFAULT_PENDING_REPLY_TEXT) + ) + self.button_label = ( + os.getenv("LINE_BUTTON_LABEL") + or extra.get("button_label", DEFAULT_BUTTON_LABEL) + ) + self.delivered_text = ( + os.getenv("LINE_DELIVERED_TEXT") + or extra.get("delivered_text", DEFAULT_DELIVERED_TEXT) + ) + self.interrupted_text = ( + os.getenv("LINE_INTERRUPTED_TEXT") + or extra.get("interrupted_text", DEFAULT_INTERRUPTED_TEXT) + ) + + # Runtime state + self._client: Optional[_LineClient] = None + self._app = None # aiohttp.web.Application + self._runner = None # aiohttp.web.AppRunner + self._site = None # aiohttp.web.TCPSite + self._reply_tokens: Dict[str, Tuple[str, float]] = {} # chat_id → (token, expiry) + self._cache = RequestCache() + self._dedup = _MessageDeduplicator() + self._bot_user_id: Optional[str] = None + self._lock_key: Optional[str] = None + + # Media state + self._media_tokens: Dict[str, Tuple[str, float]] = {} # token → (path, expiry) + self._media_temp_paths: Set[str] = set() + self._media_ttl = MEDIA_TOKEN_TTL_SECONDS + + # Pending-button slot per chat — ensures one outstanding postback + # button per chat at a time. Postback cache request_id keyed by chat_id. + self._pending_buttons: Dict[str, str] = {} + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + if not self.channel_access_token or not self.channel_secret: + self._set_fatal_error( + "config_missing", + "LINE_CHANNEL_ACCESS_TOKEN and LINE_CHANNEL_SECRET must be set", + retryable=False, + ) + return False + + # Prevent two profiles from running on the same channel access token. + try: + from gateway.status import acquire_scoped_lock + # Use a hash of the token so we don't write the secret to disk. + tok_hash = hashlib.sha256(self.channel_access_token.encode()).hexdigest()[:16] + if not acquire_scoped_lock("line", tok_hash): + self._set_fatal_error( + "lock_conflict", + "LINE channel already in use by another profile", + retryable=False, + ) + return False + self._lock_key = tok_hash + except ImportError: + self._lock_key = None + + self._client = _LineClient(self.channel_access_token) + + # Best-effort: fetch our own bot userId for self-message filtering. + # If the call fails (offline tests, transient 5xx) we fall back to + # not filtering self-events; the cost is minor (LINE doesn't + # actually echo our own messages back). + try: + self._bot_user_id = await self._client.get_bot_user_id() + except Exception as exc: + logger.debug("LINE: get_bot_user_id failed: %s", exc) + self._bot_user_id = None + + # Spin up the aiohttp webhook server. + try: + from aiohttp import web + except ImportError: + self._set_fatal_error( + "missing_dep", + "aiohttp is required for the LINE adapter — install with `pip install aiohttp`", + retryable=False, + ) + return False + + self._app = web.Application(client_max_size=WEBHOOK_BODY_MAX_BYTES) + self._app.router.add_post(self.webhook_path, self._handle_webhook) + # Public health probe — useful for tunnel/proxy verification. + self._app.router.add_get(f"{self.webhook_path}/health", self._handle_health) + # Media serving endpoint. + self._app.router.add_get( + f"{DEFAULT_MEDIA_PATH_PREFIX}/{{token}}/{{filename}}", + self._handle_media, + ) + + self._runner = web.AppRunner(self._app) + try: + await self._runner.setup() + self._site = web.TCPSite(self._runner, self.webhook_host, self.webhook_port) + await self._site.start() + except OSError as exc: + self._set_fatal_error( + "bind_failed", + f"Could not bind LINE webhook on {self.webhook_host}:{self.webhook_port}: {exc}", + retryable=True, + ) + return False + + self._mark_connected() + logger.info( + "LINE: webhook listening on %s:%s%s%s", + self.webhook_host, + self.webhook_port, + self.webhook_path, + f" (public: {self.public_base_url})" if self.public_base_url else "", + ) + return True + + async def disconnect(self) -> None: + self._mark_disconnected() + + if self._site is not None: + try: + await self._site.stop() + except Exception: + pass + self._site = None + if self._runner is not None: + try: + await self._runner.cleanup() + except Exception: + pass + self._runner = None + self._app = None + + # Cleanup any tracked tempfiles. + for path in list(self._media_temp_paths): + try: + os.unlink(path) + except OSError: + pass + self._media_temp_paths.clear() + self._media_tokens.clear() + + if self._lock_key: + try: + from gateway.status import release_scoped_lock + release_scoped_lock("line", self._lock_key) + except Exception: + pass + self._lock_key = None + + # ------------------------------------------------------------------ + # Webhook handlers + # ------------------------------------------------------------------ + + async def _handle_health(self, request) -> Any: + from aiohttp import web + return web.json_response({"status": "ok", "platform": "line"}) + + async def _handle_webhook(self, request) -> Any: + from aiohttp import web + + # Body cap defends against memory-exhaustion via crafted Content-Length + # (aiohttp's client_max_size only applies to certain body modes). + try: + body = await request.read() + except Exception as exc: + logger.debug("LINE: read failed: %s", exc) + return web.Response(status=400, text="bad request") + if len(body) > WEBHOOK_BODY_MAX_BYTES: + return web.Response(status=413, text="payload too large") + + signature = request.headers.get("X-Line-Signature", "") + if not verify_line_signature(body, signature, self.channel_secret): + return web.Response(status=401, text="invalid signature") + + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return web.Response(status=400, text="bad json") + + events = payload.get("events", []) or [] + for event in events: + try: + await self._dispatch_event(event) + except Exception: + logger.exception("LINE: dispatch_event failed") + + return web.Response(status=200, text="ok") + + async def _dispatch_event(self, event: Dict[str, Any]) -> None: + event_type = event.get("type") + source = event.get("source") or {} + webhook_event_id = event.get("webhookEventId", "") or "" + + # Dedup retries (LINE webhooks may be re-delivered). + if webhook_event_id and self._dedup.is_duplicate(webhook_event_id): + logger.debug("LINE: ignoring duplicate webhook event %s", webhook_event_id) + return + + # Filter our own messages (self-echo). + sender_user_id = source.get("userId", "") + if self._bot_user_id and sender_user_id == self._bot_user_id: + return + + # Allowlist gate. + if not _allowed_for_source( + source, + allow_all=self.allow_all, + user_ids=self.allowed_users, + group_ids=self.allowed_groups, + room_ids=self.allowed_rooms, + ): + logger.info("LINE: rejecting unauthorized source %s", source) + return + + if event_type == "message": + await self._handle_message_event(event) + elif event_type == "postback": + await self._handle_postback_event(event) + elif event_type in ("follow", "unfollow", "join", "leave"): + logger.info("LINE: lifecycle event %s from %s", event_type, source) + else: + logger.debug("LINE: ignoring event type %r", event_type) + + async def _handle_message_event(self, event: Dict[str, Any]) -> None: + msg = event.get("message") or {} + msg_type = msg.get("type", "") + message_id = msg.get("id", "") + reply_token = event.get("replyToken", "") + source = event.get("source") or {} + chat_id, chat_type = _resolve_chat(source) + user_id = source.get("userId", "") or chat_id + + # Stash the reply token for outbound use. + if chat_id and reply_token: + self._reply_tokens[chat_id] = ( + reply_token, + time.time() + LINE_REPLY_TOKEN_TTL_SECONDS, + ) + + # Handle media inbound — fetch the binary, cache it, and surface a + # vision-tool-friendly local path on the MessageEvent. + media_urls: List[str] = [] + media_types: List[str] = [] + text = "" + + if msg_type == "text": + text = msg.get("text", "") or "" + elif msg_type in ("image", "audio", "video", "file"): + local_path = await self._download_media(message_id, msg_type) + if local_path: + media_urls.append(local_path) + media_types.append(msg_type) + text = f"[{msg_type}]" + elif msg_type == "sticker": + keywords = msg.get("keywords") or [] + text = f"[sticker: {', '.join(keywords)}]" if keywords else "[sticker]" + elif msg_type == "location": + title = msg.get("title", "") + address = msg.get("address", "") + text = f"[location: {title} {address}]".strip() + else: + text = f"[unsupported message type: {msg_type}]" + + # Best-effort typing indicator (DM only). + if chat_type == "dm" and self._client: + asyncio.create_task(self._client.loading(chat_id)) + + source_obj = self.create_source( + chat_id=chat_id, + chat_type=chat_type, + user_id=user_id, + user_name=user_id, + chat_name=chat_id, + ) + + event_obj = MessageEvent( + text=text, + message_type=MessageType.TEXT if msg_type == "text" else MessageType.IMAGE, + source=source_obj, + raw_message=event, + message_id=message_id, + media_urls=media_urls, + media_types=media_types, + ) + + await self.handle_message(event_obj) + + async def _handle_postback_event(self, event: Dict[str, Any]) -> None: + """User tapped the slow-LLM postback button — deliver cached payload.""" + postback = event.get("postback") or {} + data = postback.get("data", "") or "" + reply_token = event.get("replyToken", "") + source = event.get("source") or {} + chat_id, _ = _resolve_chat(source) + + try: + parsed = json.loads(data) + except (TypeError, json.JSONDecodeError): + return + + if parsed.get("action") != "show_response": + return + request_id = parsed.get("request_id", "") + if not request_id: + return + + entry = self._cache.get(request_id) + if not self._client or not reply_token or not entry: + return + + if entry.state is State.READY: + payload = entry.payload or "" + chunks = split_for_line(strip_markdown_preserving_urls(str(payload))) + messages = [_text_message(c) for c in chunks][:LINE_MAX_MESSAGES_PER_CALL] + try: + await self._client.reply(reply_token, messages) + self._cache.mark_delivered(request_id) + self._pending_buttons.pop(chat_id, None) + except Exception as exc: + logger.warning("LINE: postback reply failed (%s); falling back to push", exc) + try: + await self._client.push(chat_id, messages) + self._cache.mark_delivered(request_id) + self._pending_buttons.pop(chat_id, None) + except Exception as exc2: + logger.error("LINE: postback push fallback failed: %s", exc2) + elif entry.state is State.ERROR: + text = str(entry.payload or self.interrupted_text) + try: + await self._client.reply(reply_token, [_text_message(text)]) + self._cache.mark_delivered(request_id) + self._pending_buttons.pop(chat_id, None) + except Exception as exc: + logger.warning("LINE: postback ERROR reply failed: %s", exc) + elif entry.state is State.DELIVERED: + try: + await self._client.reply(reply_token, [_text_message(self.delivered_text)]) + except Exception: + pass + elif entry.state is State.PENDING: + # Still working — re-issue the wait notice. + try: + await self._client.reply(reply_token, [_text_message(self.pending_text)]) + except Exception: + pass + + async def _download_media(self, message_id: str, msg_type: str) -> Optional[str]: + if not self._client or not message_id: + return None + try: + data = await self._client.fetch_content(message_id) + except Exception as exc: + logger.warning("LINE: failed to fetch %s content for %s: %s", msg_type, message_id, exc) + return None + ext = { + "image": ".jpg", + "audio": ".m4a", + "video": ".mp4", + "file": ".bin", + }.get(msg_type, ".bin") + try: + return cache_image_from_bytes(data, ext=ext) + except Exception as exc: + logger.warning("LINE: failed to cache %s payload: %s", msg_type, exc) + return None + + # ------------------------------------------------------------------ + # Outbound send (text) + # ------------------------------------------------------------------ + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + if not self._client: + return SendResult(success=False, error="LINE adapter not connected") + + # System busy-acks (interrupting / queued / steered) bypass the + # postback cache and route directly to LINE so they reach the user + # as visible bubbles. Source: PR #18153. + if _is_system_bypass(content): + return await self._send_text_chunks(chat_id, content, force_push=False) + + # If the chat has a PENDING postback button outstanding, route the + # response into the cache for the user to fetch via tap. + pending_rid = self._pending_buttons.get(chat_id) + if pending_rid: + self._cache.set_ready(pending_rid, content) + return SendResult(success=True, message_id=pending_rid) + + return await self._send_text_chunks(chat_id, content, force_push=False) + + async def _send_text_chunks( + self, + chat_id: str, + content: str, + *, + force_push: bool, + ) -> SendResult: + if not self._client: + return SendResult(success=False, error="LINE adapter not connected") + + chunks = split_for_line(strip_markdown_preserving_urls(content)) + if not chunks: + return SendResult(success=True, message_id=None) + messages = [_text_message(c) for c in chunks][:LINE_MAX_MESSAGES_PER_CALL] + + token, used_reply = self._consume_reply_token(chat_id) + if used_reply and not force_push: + try: + await self._client.reply(token, messages) + return SendResult(success=True, message_id=token) + except Exception as exc: + logger.info("LINE: reply token rejected (%s); falling back to push", exc) + # fall through to push + + try: + await self._client.push(chat_id, messages) + return SendResult(success=True, message_id=None) + except Exception as exc: + logger.error("LINE: push send failed: %s", exc) + return SendResult(success=False, error=str(exc)) + + def _consume_reply_token(self, chat_id: str) -> Tuple[str, bool]: + """Consume a stashed reply token if present and unexpired. + + Returns ``(token, used_reply)``. + """ + entry = self._reply_tokens.pop(chat_id, None) + if not entry: + return "", False + token, expires_at = entry + if not token or time.time() >= expires_at: + return "", False + return token, True + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """Trigger LINE's loading-animation indicator (DM only).""" + if self._client and chat_id: + await self._client.loading(chat_id) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Best-effort chat info derived from the chat_id prefix. + + LINE's chat-info APIs are limited and per-source-type — instead of + chasing them we infer from the well-known ID prefixes: + ``U`` = user (1:1), ``C`` = group, ``R`` = room. The agent only + needs ``name`` + ``type`` from this method. + """ + prefix = (chat_id or "")[:1] + chat_type = {"U": "dm", "C": "group", "R": "channel"}.get(prefix, "dm") + return {"name": chat_id or "", "type": chat_type} + + def format_message(self, content: str) -> str: + """Strip Markdown that LINE can't render. URLs are preserved.""" + return strip_markdown_preserving_urls(content) + + # ------------------------------------------------------------------ + # Slow-LLM postback button — driven by _keep_typing + # ------------------------------------------------------------------ + + async def _keep_typing(self, chat_id: str, *args, **kwargs) -> None: + """Override the base loop to fire the postback button at threshold. + + We intentionally keep the base implementation behind us: it's + responsible for the typing-indicator heartbeat, while *this* + wrapper layers in the slow-LLM postback bubble at threshold. + """ + if ( + self.slow_response_threshold <= 0 + or not self._client + or not chat_id + ): + await super()._keep_typing(chat_id, *args, **kwargs) + return + + async def _fire_postback() -> None: + try: + await asyncio.sleep(self.slow_response_threshold) + except asyncio.CancelledError: + raise + # Only fire if we still have a usable reply token. If the agent + # already responded, _consume_reply_token has cleared it. + if chat_id not in self._reply_tokens: + return + if chat_id in self._pending_buttons: + return + rid = self._cache.register_pending(chat_id) + self._pending_buttons[chat_id] = rid + token, used = self._consume_reply_token(chat_id) + if not used: + self._pending_buttons.pop(chat_id, None) + return + msg = build_postback_button_message( + self.pending_text, self.button_label, rid + ) + try: + await self._client.reply(token, [msg]) + logger.info("LINE: sent slow-LLM postback button for chat %s (rid=%s)", chat_id, rid) + except Exception as exc: + logger.warning("LINE: postback button send failed: %s", exc) + self._pending_buttons.pop(chat_id, None) + + post_task = asyncio.create_task(_fire_postback()) + try: + await super()._keep_typing(chat_id, *args, **kwargs) + finally: + if not post_task.done(): + post_task.cancel() + try: + await post_task + except (asyncio.CancelledError, Exception): + pass + + async def interrupt_session_activity(self, session_key: str, chat_id: str) -> None: + """Resolve any orphan PENDING postback so the button doesn't loop.""" + await super().interrupt_session_activity(session_key, chat_id) + rid = self._pending_buttons.pop(chat_id, None) + if rid: + self._cache.set_error(rid, self.interrupted_text) + + # ------------------------------------------------------------------ + # Outbound media (image / voice / video) + # ------------------------------------------------------------------ + + def _register_media(self, file_path: str, *, cleanup: bool = False) -> str: + """Register a local file for HTTPS serving; return the URL token.""" + # Evict expired tokens first. + now = time.time() + for token in list(self._media_tokens.keys()): + path, exp = self._media_tokens[token] + if now > exp: + self._media_tokens.pop(token, None) + if path in self._media_temp_paths: + self._media_temp_paths.discard(path) + try: + os.unlink(path) + except OSError: + pass + + resolved = str(Path(file_path).resolve()) + token = secrets.token_urlsafe(32) + self._media_tokens[token] = (resolved, now + self._media_ttl) + if cleanup: + self._media_temp_paths.add(resolved) + return token + + def _media_url(self, token: str, filename: str) -> str: + """Build the public HTTPS URL for a media token. PR #8398 style.""" + if self.public_base_url: + base = self.public_base_url + else: + host = self.webhook_host + port = self.webhook_port + if port == 443: + base = f"https://{host}" + else: + base = f"https://{host}:{port}" + safe_name = _urlquote(filename, safe="") + return f"{base}{DEFAULT_MEDIA_PATH_PREFIX}/{token}/{safe_name}" + + async def _handle_media(self, request) -> Any: + """Serve a registered local file over HTTPS for LINE's media URLs. + + Defence-in-depth: even though ``_register_media`` is only called + from trusted internal code, we recheck the resolved path against + an allowed-roots set before serving. Sources allowed: + ``tempfile.gettempdir()``, ``/tmp`` (which resolves to + ``/private/tmp`` on macOS), and ``HERMES_HOME``. PR #8398. + """ + from aiohttp import web + + token = request.match_info["token"] + entry = self._media_tokens.get(token) + if not entry: + return web.Response(status=404, text="not found") + + file_path, expires_at = entry + if time.time() > expires_at: + self._media_tokens.pop(token, None) + return web.Response(status=410, text="gone") + + path = Path(file_path) + if not path.exists() or not path.is_file(): + return web.Response(status=404, text="not found") + + try: + from hermes_constants import get_hermes_home + hermes_home = Path(get_hermes_home()).resolve() + except Exception: + hermes_home = Path.home().joinpath(".hermes").resolve() + + allowed_roots = { + Path(tempfile.gettempdir()).resolve(), + Path("/tmp").resolve(), # → /private/tmp on macOS + hermes_home, + } + resolved = path.resolve() + if not any(_is_relative_to(resolved, r) for r in allowed_roots): + logger.warning("LINE: refusing to serve outside allowed roots: %s", resolved) + return web.Response(status=403, text="forbidden") + + content_type, _ = mimetypes.guess_type(str(path)) + return web.FileResponse( + path, + headers={"Content-Type": content_type or "application/octet-stream"}, + ) + + async def send_image_file( + self, + chat_id: str, + image_path: str, + caption: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + path = Path(image_path) + if not path.exists() or not path.is_file(): + return SendResult(success=False, error=f"image file not found: {image_path}") + if path.stat().st_size > LINE_IMAGE_MAX_BYTES: + return SendResult(success=False, error="image exceeds 10 MB LINE limit") + if not self._client: + return SendResult(success=False, error="LINE adapter not connected") + if not self.public_base_url and self.webhook_host == "0.0.0.0": + return SendResult( + success=False, + error="LINE_PUBLIC_URL must be set to send images " + "(LINE only accepts publicly reachable HTTPS URLs)", + ) + + token = self._register_media(str(path.resolve())) + url = self._media_url(token, path.name) + if not url.lower().startswith("https://"): + return SendResult(success=False, error=f"LINE image URL must be HTTPS: {url}") + msgs: List[Dict[str, Any]] = [_image_message(url)] + if caption: + msgs.append(_text_message(caption)) + return await self._send_messages(chat_id, msgs) + + async def send_voice( + self, + chat_id: str, + audio_path: str, + duration_ms: int = 1000, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + path = Path(audio_path) + if not path.exists() or not path.is_file(): + return SendResult(success=False, error=f"audio file not found: {audio_path}") + if path.stat().st_size > LINE_AV_MAX_BYTES: + return SendResult(success=False, error="audio exceeds 200 MB LINE limit") + if not self._client: + return SendResult(success=False, error="LINE adapter not connected") + if not self.public_base_url and self.webhook_host == "0.0.0.0": + return SendResult( + success=False, + error="LINE_PUBLIC_URL must be set to send audio", + ) + + token = self._register_media(str(path.resolve())) + url = self._media_url(token, path.name) + return await self._send_messages(chat_id, [_audio_message(url, duration_ms)]) + + async def send_video( + self, + chat_id: str, + video_path: str, + preview_path: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + path = Path(video_path) + if not path.exists() or not path.is_file(): + return SendResult(success=False, error=f"video file not found: {video_path}") + if path.stat().st_size > LINE_AV_MAX_BYTES: + return SendResult(success=False, error="video exceeds 200 MB LINE limit") + if not self._client: + return SendResult(success=False, error="LINE adapter not connected") + if not self.public_base_url and self.webhook_host == "0.0.0.0": + return SendResult( + success=False, + error="LINE_PUBLIC_URL must be set to send video", + ) + + # LINE requires a previewImageUrl. Use one if supplied, otherwise + # write a stdlib 1×1 PNG to /tmp and serve it. PR #8398. + if preview_path and Path(preview_path).is_file(): + preview_token = self._register_media(str(Path(preview_path).resolve())) + preview_filename = Path(preview_path).name + else: + tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) + try: + tmp.write(_FALLBACK_PNG_PREVIEW) + tmp.flush() + tmp.close() + preview_token = self._register_media(tmp.name, cleanup=True) + preview_filename = "preview.png" + except Exception: + try: + os.unlink(tmp.name) + except OSError: + pass + raise + + video_token = self._register_media(str(path.resolve())) + video_url = self._media_url(video_token, path.name) + preview_url = self._media_url(preview_token, preview_filename) + return await self._send_messages(chat_id, [_video_message(video_url, preview_url)]) + + async def _send_messages( + self, + chat_id: str, + messages: List[Dict[str, Any]], + ) -> SendResult: + """Send already-built message objects, batched at 5/call.""" + if not self._client: + return SendResult(success=False, error="LINE adapter not connected") + if not messages: + return SendResult(success=True, message_id=None) + + first_batch = messages[:LINE_MAX_MESSAGES_PER_CALL] + rest = messages[LINE_MAX_MESSAGES_PER_CALL:] + + # First batch: try reply token, fall back to push. + token, used_reply = self._consume_reply_token(chat_id) + if used_reply: + try: + await self._client.reply(token, first_batch) + except Exception as exc: + logger.info("LINE: reply token rejected (%s); falling back to push", exc) + try: + await self._client.push(chat_id, first_batch) + except Exception as exc2: + return SendResult(success=False, error=str(exc2)) + else: + try: + await self._client.push(chat_id, first_batch) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + # Subsequent batches: always push (reply token is single-use). + while rest: + batch = rest[:LINE_MAX_MESSAGES_PER_CALL] + rest = rest[LINE_MAX_MESSAGES_PER_CALL:] + try: + await self._client.push(chat_id, batch) + except Exception as exc: + logger.warning("LINE: push for follow-up batch failed: %s", exc) + return SendResult(success=False, error=str(exc)) + + return SendResult(success=True, message_id=None) + + +def _is_relative_to(child: Path, parent: Path) -> bool: + """Backport for Path.is_relative_to (Python 3.9+) — defensive against + cwd-resolution differences across CI runners.""" + try: + return child.resolve().is_relative_to(parent.resolve()) + except (AttributeError, ValueError): + try: + child.resolve().relative_to(parent.resolve()) + return True + except ValueError: + return False + + +# --------------------------------------------------------------------------- +# Plugin entry-point hooks +# --------------------------------------------------------------------------- + +def check_requirements() -> bool: + """Plugin gate: require credentials AND aiohttp at runtime.""" + if not os.getenv("LINE_CHANNEL_ACCESS_TOKEN"): + return False + if not os.getenv("LINE_CHANNEL_SECRET"): + return False + try: + import aiohttp # noqa: F401 + except ImportError: + return False + return True + + +def validate_config(config) -> bool: + extra = getattr(config, "extra", {}) or {} + has_token = bool( + os.getenv("LINE_CHANNEL_ACCESS_TOKEN") or extra.get("channel_access_token") + ) + has_secret = bool( + os.getenv("LINE_CHANNEL_SECRET") or extra.get("channel_secret") + ) + return has_token and has_secret + + +def is_connected(config) -> bool: + """Surface in ``hermes status`` even before the adapter is instantiated.""" + return validate_config(config) + + +def _env_enablement() -> Optional[Dict[str, Any]]: + """Auto-seed PlatformConfig.extra from env-only setups. + + Lets ``hermes status`` reflect a LINE configuration that lives entirely + in ``.env`` without a ``platforms.line`` block in ``config.yaml``. + Mirrors the IRC plugin's pattern. + """ + if not (os.getenv("LINE_CHANNEL_ACCESS_TOKEN") and os.getenv("LINE_CHANNEL_SECRET")): + return None + seeded: Dict[str, Any] = {} + if os.getenv("LINE_PORT"): + try: + seeded["port"] = int(os.environ["LINE_PORT"]) + except ValueError: + pass + if os.getenv("LINE_HOST"): + seeded["host"] = os.environ["LINE_HOST"] + if os.getenv("LINE_PUBLIC_URL"): + seeded["public_url"] = os.environ["LINE_PUBLIC_URL"] + if os.getenv("LINE_HOME_CHANNEL"): + seeded["home_channel"] = os.environ["LINE_HOME_CHANNEL"] + return seeded or {} + + +async def _standalone_send( + pconfig, + chat_id: str, + message: str, + *, + thread_id: Optional[str] = None, + media_files: Optional[List[str]] = None, + force_document: bool = False, +) -> Dict[str, Any]: + """Out-of-process push delivery for cron jobs running detached from the gateway. + + Without this hook ``deliver=line`` cron jobs fail with ``no live adapter`` + when cron runs as its own process. We always Push (reply tokens require + an inbound webhook event we don't have in this path). + + ``thread_id`` is accepted for signature parity but ignored — LINE has + no native thread primitive on the channel-side API. ``media_files`` + likewise: cron-side media delivery requires a publicly-reachable URL, + which the standalone path can't construct without binding the webhook + server, so we send a text reference instead. + """ + extra = getattr(pconfig, "extra", {}) or {} + token = ( + os.getenv("LINE_CHANNEL_ACCESS_TOKEN") + or extra.get("channel_access_token", "") + ) + if not token or not chat_id: + return {"error": "LINE standalone send: missing token or chat_id"} + + plain = strip_markdown_preserving_urls(message or "") + chunks = split_for_line(plain) or [""] + messages = [_text_message(c) for c in chunks][:LINE_MAX_MESSAGES_PER_CALL] + if media_files: + # Tack on a hint so the recipient knows media was generated but not delivered. + messages.append(_text_message(f"[{len(media_files)} attachment(s) generated; not deliverable from cron]")) + messages = messages[:LINE_MAX_MESSAGES_PER_CALL] + + client = _LineClient(token) + try: + await client.push(chat_id, messages) + return {"success": True, "message_id": None} + except Exception as exc: + return {"error": str(exc)} + + +def interactive_setup() -> None: + """Minimal stdin wizard for ``hermes setup line``. + + Mirrors the irc/teams style: prompts for the two required vars, plus + one optional public URL. Writes to ``~/.hermes/.env`` via ``hermes_cli.config``. + """ + print() + print("LINE Messaging API setup") + print("------------------------") + print("Create a Messaging API channel at https://developers.line.biz/console/") + print("then copy the values below.") + print() + + try: + from hermes_cli.config import get_env_var, set_env_var + except ImportError: + print("hermes_cli.config not available; set LINE_* vars manually in ~/.hermes/.env") + return + + def _prompt(var: str, prompt: str, *, secret: bool = False) -> None: + existing = get_env_var(var) if callable(get_env_var) else None + suffix = " [keep current]" if existing else "" + try: + if secret: + import getpass + value = getpass.getpass(f"{prompt}{suffix}: ") + else: + value = input(f"{prompt}{suffix}: ").strip() + except (EOFError, KeyboardInterrupt): + print() + return + if value: + set_env_var(var, value) + + _prompt("LINE_CHANNEL_ACCESS_TOKEN", "Channel access token", secret=True) + _prompt("LINE_CHANNEL_SECRET", "Channel secret", secret=True) + _prompt("LINE_PUBLIC_URL", "Public HTTPS base URL (optional, e.g. https://my-tunnel.example.com)") + _prompt("LINE_ALLOWED_USERS", "Allowed user IDs (comma-separated; blank=skip)") + print("Done. Set the webhook URL in the LINE console to " + "<your-public-url>/line/webhook and enable 'Use webhook'.") + + +def register(ctx) -> None: + """Plugin entry point — called by the Hermes plugin system at startup.""" + ctx.register_platform( + name="line", + label="LINE", + adapter_factory=lambda cfg: LineAdapter(cfg), + check_fn=check_requirements, + validate_config=validate_config, + is_connected=is_connected, + required_env=["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"], + install_hint="pip install aiohttp", + setup_fn=interactive_setup, + env_enablement_fn=_env_enablement, + cron_deliver_env_var="LINE_HOME_CHANNEL", + standalone_sender_fn=_standalone_send, + allowed_users_env="LINE_ALLOWED_USERS", + allow_all_env="LINE_ALLOW_ALL_USERS", + # LINE per-bubble cap is 5000; smart-chunker uses 4500. + max_message_length=LINE_SAFE_BUBBLE_CHARS, + emoji="💚", + pii_safe=False, + allow_update_command=True, + platform_hint=( + "You are chatting via LINE Messaging API. LINE does NOT render " + "Markdown — text bubbles show ** and # literally. Bare URLs are " + "auto-linked, but \\[label\\](url) syntax is not. Each text bubble " + "is capped at 5000 characters and at most 5 bubbles are sent per " + "reply, so keep responses concise. Image/audio/video sending " + "requires LINE_PUBLIC_URL configured to a publicly reachable HTTPS " + "host. Slow responses surface a 'Get answer' button the user taps " + "to fetch the reply via a fresh free token." + ), + ) diff --git a/plugins/platforms/line/plugin.yaml b/plugins/platforms/line/plugin.yaml new file mode 100644 index 00000000000..f854bc4e2ea --- /dev/null +++ b/plugins/platforms/line/plugin.yaml @@ -0,0 +1,65 @@ +name: line-platform +label: LINE +kind: platform +version: 1.0.0 +description: > + LINE Messaging API gateway adapter for Hermes Agent. + Runs an aiohttp webhook server that receives LINE webhook events + (with HMAC-SHA256 signature verification) and relays messages between + LINE chats (1:1, groups, rooms) and the Hermes agent. Outbound replies + prefer the free reply token and fall back to the metered Push API + when the token has expired or is absent. Slow LLM responses surface a + Template Buttons postback bubble so the user can fetch the answer with + a fresh reply token (free) once it's ready. +author: Hermes Agent contributors +# ``requires_env`` and ``optional_env`` entries are surfaced in the +# ``hermes config`` UI via the platform-plugin env var injector in +# ``hermes_cli/config.py``. +requires_env: + - name: LINE_CHANNEL_ACCESS_TOKEN + description: "LINE channel long-lived access token (LINE Developers Console > Messaging API > Channel access token)" + prompt: "LINE channel access token" + url: "https://developers.line.biz/console/" + password: true + - name: LINE_CHANNEL_SECRET + description: "LINE channel secret (used for HMAC-SHA256 webhook signature verification)" + prompt: "LINE channel secret" + url: "https://developers.line.biz/console/" + password: true +optional_env: + - name: LINE_PORT + description: "Webhook listen port (default: 8646)" + prompt: "Webhook port" + password: false + - name: LINE_HOST + description: "Webhook bind host (default: 0.0.0.0)" + prompt: "Webhook host" + password: false + - name: LINE_PUBLIC_URL + description: "Public HTTPS base URL for serving images/audio/video to LINE (e.g. https://my-tunnel.example.com). Required for media sending when the bind address is not directly reachable." + prompt: "Public HTTPS base URL" + password: false + - name: LINE_ALLOWED_USERS + description: "Comma-separated LINE user IDs allowed to DM the bot (U-prefixed)" + prompt: "Allowed user IDs (comma-separated)" + password: false + - name: LINE_ALLOWED_GROUPS + description: "Comma-separated LINE group IDs the bot will respond in (C-prefixed)" + prompt: "Allowed group IDs (comma-separated)" + password: false + - name: LINE_ALLOWED_ROOMS + description: "Comma-separated LINE room IDs the bot will respond in (R-prefixed)" + prompt: "Allowed room IDs (comma-separated)" + password: false + - name: LINE_ALLOW_ALL_USERS + description: "Allow any LINE user to talk to the bot (dev only — disables allowlist)" + prompt: "Allow all users? (true/false)" + password: false + - name: LINE_HOME_CHANNEL + description: "Default user/group/room ID for cron / notification delivery" + prompt: "Home channel ID (or empty)" + password: false + - name: LINE_SLOW_RESPONSE_THRESHOLD + description: "Seconds before the slow-LLM postback button fires (default: 45; set 0 to disable and always Push-fallback)" + prompt: "Slow response threshold (seconds)" + password: false diff --git a/plugins/strike-freedom-cockpit/README.md b/plugins/strike-freedom-cockpit/README.md deleted file mode 100644 index 897668529c3..00000000000 --- a/plugins/strike-freedom-cockpit/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Strike Freedom Cockpit — dashboard skin demo - -Demonstrates how the dashboard skin+plugin system can be used to build a -fully custom cockpit-style reskin without touching the core dashboard. - -Two pieces: - -- `theme/strike-freedom.yaml` — a dashboard theme YAML that paints the - palette, typography, layout variant (`cockpit`), component chrome - (notched card corners, scanlines, accent colors), and declares asset - slots (`hero`, `crest`, `bg`). -- `dashboard/` — a plugin that populates the `sidebar`, `header-left`, - and `footer-right` slots reserved by the cockpit layout. The sidebar - renders an MS-STATUS panel with segmented telemetry bars driven by - real agent status; the header-left injects a COMPASS crest; the - footer-right replaces the default org tagline. - -## Install - -1. **Theme** — copy the theme YAML into your Hermes home: - - ``` - cp theme/strike-freedom.yaml ~/.hermes/dashboard-themes/ - ``` - -2. **Plugin** — the `dashboard/` directory gets auto-discovered because - it lives under `plugins/` in the repo. On a user install, copy the - whole plugin directory into `~/.hermes/plugins/`: - - ``` - cp -r . ~/.hermes/plugins/strike-freedom-cockpit - ``` - -3. Restart the web UI (or `GET /api/dashboard/plugins/rescan`), open it, - pick **Strike Freedom** from the theme switcher. - -## Customising the artwork - -The sidebar plugin reads `--theme-asset-hero` and `--theme-asset-crest` -from the active theme. Drop your own URLs into the theme YAML: - -```yaml -assets: - hero: "/my-images/strike-freedom.png" - crest: "/my-images/compass-crest.svg" - bg: "/my-images/cosmic-era-bg.jpg" -``` - -The plugin reads those at render time — no plugin code changes needed -to swap artwork across themes. - -## What this demo proves - -The dashboard skin+plugin system supports (ref: `apps/dashboard/src/themes/types.ts`, -`apps/dashboard/src/plugins/slots.ts`): - -- Palette, typography, font URLs, density, radius — already present -- **Asset URLs exposed as CSS vars** (bg / hero / crest / logo / - sidebar / header + arbitrary `custom.*`) -- **Raw `customCSS` blocks** injected as scoped `<style>` tags -- **Per-component style overrides** (card / header / sidebar / backdrop / - tab / progress / footer / badge / page) via CSS vars -- **`layoutVariant`** — `standard`, `cockpit`, or `tiled` -- **Plugin slots** — 10 named shell slots plugins can inject into - (`backdrop`, `header-left/right/banner`, `sidebar`, `pre-main`, - `post-main`, `footer-left/right`, `overlay`) -- **Route overrides** — plugins can replace a built-in page entirely - (`tab.override: "/"`) instead of just adding a tab -- **Hidden plugins** — slot-only plugins that never show in the nav - (`tab.hidden: true`) — as used here diff --git a/plugins/strike-freedom-cockpit/dashboard/dist/index.js b/plugins/strike-freedom-cockpit/dashboard/dist/index.js deleted file mode 100644 index 7506c80997e..00000000000 --- a/plugins/strike-freedom-cockpit/dashboard/dist/index.js +++ /dev/null @@ -1,309 +0,0 @@ -/** - * Strike Freedom Cockpit — dashboard plugin demo. - * - * A slot-only plugin (manifest sets tab.hidden: true) that populates - * three shell slots when the user has the ``strike-freedom`` theme - * selected (or any theme that picks layoutVariant: cockpit): - * - * - sidebar → MS-STATUS panel: ENERGY / SHIELD / POWER bars, - * ZGMF-X20A identity line, pilot block, hero - * render (from --theme-asset-hero when the theme - * provides one). - * - header-left → COMPASS faction crest (uses --theme-asset-crest - * if provided, falls back to a geometric SVG). - * - footer-right → COSMIC ERA tagline that replaces the default - * footer org line. - * - * The plugin demonstrates every extension point added alongside the - * slot system: registerSlot, tab.hidden, reading theme asset CSS vars - * from plugin code, and rendering above the built-in route content. - */ -(function () { - "use strict"; - - const SDK = window.__HERMES_PLUGIN_SDK__; - const PLUGINS = window.__HERMES_PLUGINS__; - if (!SDK || !PLUGINS || !PLUGINS.registerSlot) { - // Old dashboard bundle without slot support — bail silently rather - // than breaking the page. - return; - } - - const { React } = SDK; - const { useState, useEffect } = SDK.hooks; - const { api } = SDK; - - // --------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------- - - /** Read a CSS custom property from :root. Empty string when unset. */ - function cssVar(name) { - if (typeof document === "undefined") return ""; - return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); - } - - /** Segmented chip progress bar — 10 cells filled proportionally to value. */ - function TelemetryBar(props) { - const { label, value, color } = props; - const cells = []; - for (let i = 0; i < 10; i++) { - const filled = Math.round(value / 10) > i; - cells.push( - React.createElement("span", { - key: i, - style: { - flex: 1, - height: 8, - background: filled ? color : "rgba(255,255,255,0.06)", - transition: "background 200ms", - clipPath: "polygon(2px 0, 100% 0, calc(100% - 2px) 100%, 0 100%)", - }, - }), - ); - } - return React.createElement( - "div", - { style: { display: "flex", flexDirection: "column", gap: 4 } }, - React.createElement( - "div", - { - style: { - display: "flex", - justifyContent: "space-between", - fontSize: "0.65rem", - letterSpacing: "0.12em", - opacity: 0.75, - }, - }, - React.createElement("span", null, label), - React.createElement("span", { style: { color, fontWeight: 700 } }, value + "%"), - ), - React.createElement( - "div", - { style: { display: "flex", gap: 2 } }, - cells, - ), - ); - } - - // --------------------------------------------------------------------- - // Sidebar: MS-STATUS panel - // --------------------------------------------------------------------- - - function SidebarSlot() { - // Pull live-ish numbers from the status API so the plugin isn't just - // a static decoration. Fall back to full bars if the API is slow / - // unavailable. - const [status, setStatus] = useState(null); - useEffect(function () { - let cancel = false; - api.getStatus() - .then(function (s) { if (!cancel) setStatus(s); }) - .catch(function () {}); - return function () { cancel = true; }; - }, []); - - // Map real status signals to HUD telemetry. Energy/shield/power - // aren't literal concepts on a software agent, so we read them from - // adjacent signals: active sessions, gateway connected-platforms, - // and agent-online health. - const energy = status && status.gateway_online ? 92 : 18; - const shield = status && status.connected_platforms - ? Math.min(100, 40 + (status.connected_platforms.length * 15)) - : 70; - const power = status && status.active_sessions - ? Math.min(100, 55 + (status.active_sessions.length * 10)) - : 87; - - const hero = cssVar("--theme-asset-hero"); - - return React.createElement( - "div", - { - style: { - padding: "1rem 0.75rem", - display: "flex", - flexDirection: "column", - gap: "1rem", - fontFamily: "var(--theme-font-display, sans-serif)", - letterSpacing: "0.08em", - textTransform: "uppercase", - fontSize: "0.65rem", - }, - }, - // Header line - React.createElement( - "div", - { - style: { - borderBottom: "1px solid rgba(64,200,255,0.3)", - paddingBottom: 8, - display: "flex", - flexDirection: "column", - gap: 2, - }, - }, - React.createElement("span", { style: { opacity: 0.6 } }, "ms status"), - React.createElement("span", { style: { fontWeight: 700, fontSize: "0.85rem" } }, "zgmf-x20a"), - React.createElement("span", { style: { opacity: 0.6, fontSize: "0.6rem" } }, "strike freedom"), - ), - // Hero slot — only renders when the theme provides one. - hero - ? React.createElement("div", { - style: { - width: "100%", - aspectRatio: "3 / 4", - backgroundImage: hero, - backgroundSize: "contain", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", - opacity: 0.85, - }, - "aria-hidden": true, - }) - : React.createElement("div", { - style: { - width: "100%", - aspectRatio: "3 / 4", - border: "1px dashed rgba(64,200,255,0.25)", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: "0.55rem", - opacity: 0.4, - }, - }, "hero slot — set assets.hero in theme"), - // Pilot block - React.createElement( - "div", - { - style: { - borderTop: "1px solid rgba(64,200,255,0.18)", - borderBottom: "1px solid rgba(64,200,255,0.18)", - padding: "8px 0", - display: "flex", - flexDirection: "column", - gap: 2, - }, - }, - React.createElement("span", { style: { opacity: 0.5, fontSize: "0.55rem" } }, "pilot"), - React.createElement("span", { style: { fontWeight: 700 } }, "hermes agent"), - React.createElement("span", { style: { opacity: 0.5, fontSize: "0.55rem" } }, "compass"), - ), - // Telemetry bars - React.createElement(TelemetryBar, { label: "energy", value: energy, color: "#ffce3a" }), - React.createElement(TelemetryBar, { label: "shield", value: shield, color: "#3fd3ff" }), - React.createElement(TelemetryBar, { label: "power", value: power, color: "#ff3a5e" }), - // System online - React.createElement( - "div", - { - style: { - marginTop: 4, - padding: "6px 8px", - border: "1px solid rgba(74,222,128,0.4)", - color: "#4ade80", - textAlign: "center", - fontWeight: 700, - fontSize: "0.6rem", - }, - }, - status && status.gateway_online ? "system online" : "system offline", - ), - ); - } - - // --------------------------------------------------------------------- - // Header-left: COMPASS crest - // --------------------------------------------------------------------- - - function HeaderCrestSlot() { - const crest = cssVar("--theme-asset-crest"); - const inner = crest - ? React.createElement("div", { - style: { - width: 28, - height: 28, - backgroundImage: crest, - backgroundSize: "contain", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", - }, - "aria-hidden": true, - }) - : React.createElement( - "svg", - { - width: 28, - height: 28, - viewBox: "0 0 28 28", - fill: "none", - stroke: "currentColor", - strokeWidth: 1.5, - "aria-hidden": true, - }, - React.createElement("path", { d: "M14 2 L26 14 L14 26 L2 14 Z" }), - React.createElement("path", { d: "M14 8 L20 14 L14 20 L8 14 Z" }), - React.createElement("circle", { cx: 14, cy: 14, r: 2, fill: "currentColor" }), - ); - return React.createElement( - "div", - { - style: { - display: "flex", - alignItems: "center", - paddingLeft: 12, - paddingRight: 8, - color: "var(--color-accent, #3fd3ff)", - }, - }, - inner, - ); - } - - // --------------------------------------------------------------------- - // Footer-right: COSMIC ERA tagline - // --------------------------------------------------------------------- - - function FooterTaglineSlot() { - return React.createElement( - "span", - { - style: { - fontFamily: "var(--theme-font-display, sans-serif)", - fontSize: "0.6rem", - letterSpacing: "0.18em", - textTransform: "uppercase", - opacity: 0.75, - mixBlendMode: "plus-lighter", - }, - }, - "compass hermes systems / cosmic era 71", - ); - } - - // --------------------------------------------------------------------- - // Hidden tab placeholder — tab.hidden=true means this never renders in - // the nav, but we still register something sensible in case someone - // manually navigates to /strike-freedom-cockpit (e.g. via a bookmark). - // --------------------------------------------------------------------- - - function HiddenPage() { - return React.createElement( - "div", - { style: { padding: "2rem", opacity: 0.6, fontSize: "0.8rem" } }, - "Strike Freedom cockpit is a slot-only plugin — it populates the sidebar, header, and footer instead of showing a tab page.", - ); - } - - // --------------------------------------------------------------------- - // Registration - // --------------------------------------------------------------------- - - const NAME = "strike-freedom-cockpit"; - PLUGINS.register(NAME, HiddenPage); - PLUGINS.registerSlot(NAME, "sidebar", SidebarSlot); - PLUGINS.registerSlot(NAME, "header-left", HeaderCrestSlot); - PLUGINS.registerSlot(NAME, "footer-right", FooterTaglineSlot); -})(); diff --git a/plugins/strike-freedom-cockpit/dashboard/manifest.json b/plugins/strike-freedom-cockpit/dashboard/manifest.json deleted file mode 100644 index fec3c79eff9..00000000000 --- a/plugins/strike-freedom-cockpit/dashboard/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "strike-freedom-cockpit", - "label": "Strike Freedom Cockpit", - "description": "MS-STATUS sidebar + header crest for the Strike Freedom theme", - "icon": "Shield", - "version": "1.0.0", - "tab": { - "path": "/strike-freedom-cockpit", - "position": "end", - "hidden": true - }, - "slots": ["sidebar", "header-left", "footer-right"], - "entry": "dist/index.js" -} diff --git a/plugins/strike-freedom-cockpit/theme/strike-freedom.yaml b/plugins/strike-freedom-cockpit/theme/strike-freedom.yaml deleted file mode 100644 index ebbcf11841b..00000000000 --- a/plugins/strike-freedom-cockpit/theme/strike-freedom.yaml +++ /dev/null @@ -1,126 +0,0 @@ -# Strike Freedom — Hermes dashboard theme demo -# -# Copy this file to ~/.hermes/dashboard-themes/strike-freedom.yaml and -# restart the web UI (or hit `/api/dashboard/plugins/rescan`). Pair with -# the `strike-freedom-cockpit` plugin (plugins/strike-freedom-cockpit/) -# for the full cockpit experience — this theme paints the palette, -# chrome, and layout; the plugin supplies the MS-STATUS sidebar + header -# crest that the cockpit layout variant reserves space for. -# -# Demonstrates every theme extension point added alongside the plugin -# slot system: palette, typography, layoutVariant, assets, customCSS, -# componentStyles, colorOverrides. -name: strike-freedom -label: "Strike Freedom" -description: "Cockpit HUD — deep navy + cyan + gold accents" - -# ------- palette (3-layer) ------- -palette: - background: "#05091a" - midground: "#d8f0ff" - foreground: - hex: "#ffffff" - alpha: 0 - warmGlow: "rgba(255, 199, 55, 0.24)" - noiseOpacity: 0.7 - -# ------- typography ------- -typography: - fontSans: '"Orbitron", "Eurostile", "Bank Gothic", "Impact", sans-serif' - fontMono: '"Share Tech Mono", "JetBrains Mono", ui-monospace, monospace' - fontDisplay: '"Orbitron", "Eurostile", "Impact", sans-serif' - fontUrl: "https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800&family=Share+Tech+Mono&display=swap" - baseSize: "14px" - lineHeight: "1.5" - letterSpacing: "0.04em" - -# ------- layout ------- -layout: - radius: "0" - density: "compact" - -# ``cockpit`` reserves a 260px left rail that the shell renders when the -# user is on this theme. A paired plugin populates the rail via the -# ``sidebar`` slot; with no plugin the rail shows a placeholder. -layoutVariant: cockpit - -# ------- assets ------- -# Use any URL (https, data:, /dashboard-plugins/...) or a pre-wrapped -# ``url(...)``/``linear-gradient(...)`` expression. The shell exposes -# each as a CSS var so plugins can read the same imagery. -assets: - bg: "linear-gradient(140deg, #05091a 0%, #0a1530 55%, #102048 100%)" - # Plugin reads --theme-asset-hero / --theme-asset-crest to populate - # its sidebar hero render + header crest. Replace these URLs with your - # own artwork (copy files into ~/.hermes/dashboard-themes/assets/ and - # reference them as /dashboard-themes-assets/strike-freedom/hero.png - # once that static route is wired up — for now use inline data URLs or - # remote URLs). - hero: "" - crest: "" - -# ------- component chrome ------- -# Each bucket's props become CSS vars (--component-<bucket>-<kebab>) that -# built-in shell components (Card, header, sidebar, backdrop) consume. -componentStyles: - card: - # Notched corners on the top-left + bottom-right — classic mecha UI. - clipPath: "polygon(12px 0, 100% 0, 100% calc(100% - 12px), calc(100% - 12px) 100%, 0 100%, 0 12px)" - background: "linear-gradient(180deg, rgba(10, 22, 52, 0.85) 0%, rgba(5, 9, 26, 0.92) 100%)" - boxShadow: "inset 0 0 0 1px rgba(64, 200, 255, 0.28), 0 0 18px -6px rgba(64, 200, 255, 0.4)" - header: - background: "linear-gradient(180deg, rgba(16, 32, 72, 0.95) 0%, rgba(5, 9, 26, 0.9) 100%)" - sidebar: - background: "linear-gradient(180deg, rgba(8, 18, 42, 0.88) 0%, rgba(5, 9, 26, 0.85) 100%)" - tab: - clipPath: "polygon(6px 0, 100% 0, calc(100% - 6px) 100%, 0 100%)" - backdrop: - backgroundSize: "cover" - backgroundPosition: "center" - fillerOpacity: "1" - fillerBlendMode: "normal" - -# ------- color overrides ------- -colorOverrides: - primary: "#ffce3a" - primaryForeground: "#05091a" - accent: "#3fd3ff" - accentForeground: "#05091a" - ring: "#3fd3ff" - success: "#4ade80" - warning: "#ffce3a" - destructive: "#ff3a5e" - border: "rgba(64, 200, 255, 0.28)" - -# ------- customCSS ------- -# Raw CSS injected as a scoped <style> tag on theme apply. Use this for -# selector-level tweaks componentStyles can't express (pseudo-elements, -# animations, media queries). Bounded to 32 KiB per theme. -customCSS: | - /* Scanline overlay — subtle, only when theme is active. */ - :root[data-layout-variant="cockpit"] body::before { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 100; - background: repeating-linear-gradient( - to bottom, - transparent 0px, - transparent 2px, - rgba(64, 200, 255, 0.035) 3px, - rgba(64, 200, 255, 0.035) 4px - ); - mix-blend-mode: screen; - } - - /* Chevron pips on card corners. */ - [data-layout-variant="cockpit"] .border-border::before, - [data-layout-variant="cockpit"] .border-border::after { - content: ""; - position: absolute; - width: 8px; - height: 8px; - border: 1px solid rgba(64, 200, 255, 0.55); - pointer-events: none; - } diff --git a/pyproject.toml b/pyproject.toml index d32aa528d2e..4f8a3843a00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ dependencies = [ modal = ["modal>=1.0.0,<2"] daytona = ["daytona>=0.148.0,<1"] vercel = ["vercel>=0.5.7,<0.6.0"] +hindsight = ["hindsight-client>=0.4.22"] dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "pytest-split>=0.9,<1", "mcp>=1.2.0,<2", "ty>=0.0.1a29,<0.0.22", "ruff"] messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"] cron = [] # croniter is now a core dependency; this extra kept for back-compat @@ -233,6 +234,3 @@ select = ["PLW1514"] "skills/**" = ["PLW1514"] "optional-skills/**" = ["PLW1514"] "plugins/**" = ["PLW1514"] - -[tool.uv] -exclude-newer = "7 days" diff --git a/rl_cli.py b/rl_cli.py index d494c1addb2..e3996a29df6 100644 --- a/rl_cli.py +++ b/rl_cli.py @@ -392,7 +392,7 @@ def main( if not user_input: continue - if user_input.lower() in ('quit', 'exit', 'q'): + if user_input.lower() in {'quit', 'exit', 'q'}: print("\n👋 Goodbye!") break diff --git a/run_agent.py b/run_agent.py index 96d4d8517fe..aa01c8ecdf5 100644 --- a/run_agent.py +++ b/run_agent.py @@ -539,7 +539,7 @@ def _trajectory_normalize_msg(msg: Dict[str, Any]) -> Dict[str, Any]: if isinstance(content, list): cleaned = [] for p in content: - if isinstance(p, dict) and p.get("type") in ("image", "image_url", "input_image"): + if isinstance(p, dict) and p.get("type") in {"image", "image_url", "input_image"}: cleaned.append({"type": "text", "text": "[screenshot]"}) else: cleaned.append(p) @@ -903,7 +903,7 @@ def _strip_images_from_messages(messages: list) -> bool: continue new_parts = [] for part in content: - if isinstance(part, dict) and part.get("type") in ("image_url", "image", "input_image"): + if isinstance(part, dict) and part.get("type") in {"image_url", "image", "input_image"}: found = True else: new_parts.append(part) @@ -1388,13 +1388,28 @@ class AIAgent: # 1h tier costs 2x on write vs 1.25x for 5m, but amortizes across long # sessions with >5-minute pauses between turns (#14971). self._cache_ttl = "5m" + # Long-lived prefix caching: when enabled and supported by the + # current provider, splits the system prompt into a stable prefix + # (cached cross-session at 1h TTL) and a volatile suffix + # (memory/timestamp — never cached), and attaches a 1h cache_control + # marker to the last tool in the schema array. Restricted to + # Claude on Anthropic / OpenRouter / Nous Portal; see + # ``_supports_long_lived_anthropic_cache``. + self._use_long_lived_prefix_cache = False + self._long_lived_cache_ttl = "1h" try: from hermes_cli.config import load_config as _load_pc_cfg _pc_cfg = _load_pc_cfg().get("prompt_caching", {}) or {} _ttl = _pc_cfg.get("cache_ttl", "5m") - if _ttl in ("5m", "1h"): + if _ttl in {"5m", "1h"}: self._cache_ttl = _ttl + _ll_enabled = _pc_cfg.get("long_lived_prefix", True) + _ll_ttl = _pc_cfg.get("long_lived_ttl", "1h") + if _ll_ttl in ("5m", "1h"): + self._long_lived_cache_ttl = _ll_ttl + if _ll_enabled and self._use_prompt_caching and self._supports_long_lived_anthropic_cache(): + self._use_long_lived_prefix_cache = True except Exception: pass @@ -1433,19 +1448,18 @@ class AIAgent: if self.verbose_logging: setup_verbose_logging() logger.info("Verbose logging enabled (third-party library logs suppressed)") - else: - if self.quiet_mode: - # In quiet mode (CLI default), keep console output clean — - # but DO NOT raise per-logger levels. Doing so prevents the - # root logger's file handlers (agent.log, errors.log) from - # ever seeing the records, because Python checks - # logger.isEnabledFor() before handler propagation. We rely - # on the fact that hermes_logging.setup_logging() does not - # install a console StreamHandler in quiet mode — so INFO - # records flow to the file handlers but never reach a - # console. Any future noise reduction belongs at the - # handler level inside hermes_logging.py, not here. - pass + elif self.quiet_mode: + # In quiet mode (CLI default), keep console output clean — + # but DO NOT raise per-logger levels. Doing so prevents the + # root logger's file handlers (agent.log, errors.log) from + # ever seeing the records, because Python checks + # logger.isEnabledFor() before handler propagation. We rely + # on the fact that hermes_logging.setup_logging() does not + # install a console StreamHandler in quiet mode — so INFO + # records flow to the file handlers but never reach a + # console. Any future noise reduction belongs at the + # handler level inside hermes_logging.py, not here. + pass # Internal stream callback (set during streaming TTS). # Initialized here so _vprint can reference it before run_conversation. @@ -1641,7 +1655,7 @@ class AIAgent: # but no credentials were found, fail fast with a clear # message instead of silently routing through OpenRouter. _explicit = (self.provider or "").strip().lower() - if _explicit and _explicit not in ("auto", "openrouter", "custom"): + if _explicit and _explicit not in {"auto", "openrouter", "custom"}: # Look up the actual env var name from the provider # config — some providers use non-standard names # (e.g. alibaba → DASHSCOPE_API_KEY, not ALIBABA_API_KEY). @@ -1823,7 +1837,20 @@ class AIAgent: timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S") short_uuid = uuid.uuid4().hex[:6] self.session_id = f"{timestamp_str}_{short_uuid}" - + + # Expose session ID to tools (terminal, execute_code) so agents can + # reference their own session for --resume commands, cross-session + # coordination, and logging. Uses the ContextVar system from + # session_context.py for concurrency safety (gateway runs multiple + # sessions in one process). Also writes os.environ as fallback for + # CLI mode where ContextVars aren't used. + os.environ["HERMES_SESSION_ID"] = self.session_id + try: + from gateway.session_context import _SESSION_ID + _SESSION_ID.set(self.session_id) + except Exception: + pass # CLI/test mode — ContextVar not needed + # Session logs go into ~/.hermes/sessions/ alongside gateway sessions hermes_home = get_hermes_home() self.logs_dir = hermes_home / "sessions" @@ -2011,8 +2038,7 @@ class AIAgent: try: _raw_api_retries = _agent_section.get("api_max_retries", 3) _api_retries = int(_raw_api_retries) - if _api_retries < 1: - _api_retries = 1 # 1 = no retry (single attempt) + _api_retries = max(_api_retries, 1) # 1 = no retry (single attempt) except (TypeError, ValueError): _api_retries = 3 self._api_max_retries = _api_retries @@ -2031,7 +2057,7 @@ class AIAgent: compression_threshold = _model_cthresh except Exception: pass - compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in ("true", "1", "yes") + compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"} compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20)) compression_protect_last = int(_compression_cfg.get("protect_last_n", 20)) @@ -2388,6 +2414,7 @@ class AIAgent: "client_kwargs": dict(self._client_kwargs), "use_prompt_caching": self._use_prompt_caching, "use_native_cache_layout": self._use_native_cache_layout, + "use_long_lived_prefix_cache": self._use_long_lived_prefix_cache, # Context engine state that _try_activate_fallback() overwrites. # Use getattr for model/base_url/api_key/provider since plugin # engines may not have these (they're ContextCompressor-specific). @@ -2545,7 +2572,7 @@ class AIAgent: # tests) can't reintroduce the double-/v1 404 bug. if ( api_mode == "anthropic_messages" - and new_provider in ("opencode-zen", "opencode-go") + and new_provider in {"opencode-zen", "opencode-go"} and isinstance(base_url, str) and base_url ): @@ -2618,6 +2645,15 @@ class AIAgent: model=new_model, ) ) + self._use_long_lived_prefix_cache = bool( + self._use_prompt_caching + and self._supports_long_lived_anthropic_cache( + provider=new_provider, + base_url=self.base_url, + api_mode=api_mode, + model=new_model, + ) + ) # ── LM Studio: preload before probing context length ── self._ensure_lmstudio_runtime_loaded() @@ -2666,6 +2702,7 @@ class AIAgent: "client_kwargs": dict(self._client_kwargs), "use_prompt_caching": self._use_prompt_caching, "use_native_cache_layout": self._use_native_cache_layout, + "use_long_lived_prefix_cache": self._use_long_lived_prefix_cache, "compressor_model": getattr(_cc, "model", self.model) if _cc else self.model, "compressor_base_url": getattr(_cc, "base_url", self.base_url) if _cc else self.base_url, "compressor_api_key": getattr(_cc, "api_key", "") if _cc else "", @@ -3414,6 +3451,10 @@ class AIAgent: provider_lower = eff_provider.lower() is_claude = "claude" in model_lower is_openrouter = base_url_host_matches(eff_base_url, "openrouter.ai") + # Nous Portal proxies to OpenRouter behind the scenes — identical + # OpenAI-wire envelope cache_control semantics. Treat it as an + # OpenRouter-equivalent endpoint for caching layout purposes. + is_nous_portal = "nousresearch" in eff_base_url.lower() is_anthropic_wire = eff_api_mode == "anthropic_messages" is_native_anthropic = ( is_anthropic_wire @@ -3422,7 +3463,7 @@ class AIAgent: if is_native_anthropic: return True, True - if is_openrouter and is_claude: + if (is_openrouter or is_nous_portal) and is_claude: return True, False if is_anthropic_wire and is_claude: # Third-party Anthropic-compatible gateway. @@ -3463,6 +3504,61 @@ class AIAgent: return False, False + def _supports_long_lived_anthropic_cache( + self, + *, + provider: Optional[str] = None, + base_url: Optional[str] = None, + api_mode: Optional[str] = None, + model: Optional[str] = None, + ) -> bool: + """Decide whether the long-lived (1h cross-session) cache layout applies. + + Narrower than ``_anthropic_prompt_cache_policy`` — only enabled + for Claude models on the four endpoints whose cross-session + cache_control behavior we have explicitly validated: + + * Native Anthropic API (``api_mode == 'anthropic_messages'`` + + host ``api.anthropic.com``) + * Anthropic OAuth subscription (same transport as native API) + * OpenRouter (``base_url`` contains ``openrouter.ai``) + * Nous Portal (``base_url`` contains ``nousresearch`` — proxies + to OpenRouter, so identical wire-format) + + All four honour ``cache_control`` on both the tools array and the + first system content block, and bill cross-session cache reads at + the documented 0.1× rate. + + Other endpoints covered by the standard ``system_and_3`` policy + (third-party Anthropic gateways, MiniMax, opencode-go Qwen, etc.) + keep that layout — they support cache_control but their behavior + with mixed-TTL multi-block system content has not been validated + against this codebase. + """ + eff_provider = (provider if provider is not None else self.provider) or "" + eff_base_url = base_url if base_url is not None else (self.base_url or "") + eff_api_mode = api_mode if api_mode is not None else (self.api_mode or "") + eff_model = (model if model is not None else self.model) or "" + + if "claude" not in eff_model.lower(): + return False + + # Native Anthropic + Anthropic OAuth subscription + if eff_api_mode == "anthropic_messages": + if eff_provider == "anthropic" or base_url_hostname(eff_base_url) == "api.anthropic.com": + return True + + # OpenRouter + if base_url_host_matches(eff_base_url, "openrouter.ai"): + return True + + # Nous Portal — front-ends OpenRouter behind the scenes; identical + # wire format and cache_control semantics. + if "nousresearch" in eff_base_url.lower(): + return True + + return False + @staticmethod def _model_requires_responses_api(model: str) -> bool: """Return True for models that require the Responses API path. @@ -4282,7 +4378,7 @@ class AIAgent: metadata["task_id"] = task_id if tool_call_id: metadata["tool_call_id"] = tool_call_id - return {k: v for k, v in metadata.items() if v not in (None, "")} + return {k: v for k, v in metadata.items() if v not in {None, ""}} def _apply_persist_user_message_override(self, messages: List[Dict]) -> None: """Rewrite the current-turn user message before persistence/return. @@ -4496,7 +4592,7 @@ class AIAgent: for p in content: if isinstance(p, dict) and p.get("type") == "text": _txt.append(str(p.get("text", ""))) - elif isinstance(p, dict) and p.get("type") in ("image", "image_url", "input_image"): + elif isinstance(p, dict) and p.get("type") in {"image", "image_url", "input_image"}: _txt.append("[screenshot]") content = "\n".join(_txt) if _txt else None tool_calls_data = None @@ -4855,11 +4951,11 @@ class AIAgent: context["message"] = message.strip() for key in ("resets_at", "reset_at"): value = payload.get(key) - if value not in (None, ""): + if value not in {None, ""}: context["reset_at"] = value break retry_after = payload.get("retry_after") - if retry_after not in (None, "") and "reset_at" not in context: + if retry_after not in {None, ""} and "reset_at" not in context: try: context["reset_at"] = time.time() + float(retry_after) except (TypeError, ValueError): @@ -5610,22 +5706,33 @@ class AIAgent: - def _build_system_prompt(self, system_message: str = None) -> str: + def _build_system_prompt_parts(self, system_message: str = None) -> Dict[str, str]: + """Assemble the system prompt as three ordered parts. + + Returns a dict with three keys: + * ``stable`` — content that is byte-stable across sessions for a + given user config: identity, tool guidance, skills prompt, + environment hints, platform hints, model-family operational + guidance. Eligible for cross-session 1h prompt caching when + placed as a separate Anthropic content block (see + ``apply_anthropic_cache_control_long_lived``). + * ``context`` — context files (AGENTS.md, .cursorrules, etc.) and + caller-supplied system_message. Stable within a session but may + change between sessions when files are edited or the cwd + differs. Cached within-session via the rolling messages + breakpoint (5m TTL); not promoted to the long-lived tier so + edits don't poison the cross-session cache. + * ``volatile`` — content that changes on most turns/sessions: + memory snapshot, user profile, external memory provider block, + timestamp line. Never marked for caching. + + Joined ``stable\\n\\ncontext\\n\\nvolatile`` produces the same + logical content the old single-string builder produced, with the + guarantee that volatile content is at the end (cache-friendly + ordering for any provider that does prefix caching). """ - Assemble the full system prompt from all layers. - - Called once per session (cached on self._cached_system_prompt) and only - rebuilt after context compression events. This ensures the system prompt - is stable across all turns in a session, maximizing prefix cache hits. - """ - # Layers (in order): - # 1. Agent identity — SOUL.md when available, else DEFAULT_AGENT_IDENTITY - # 2. User / gateway system prompt (if provided) - # 3. Persistent memory (frozen snapshot) - # 4. Skills guidance (if skills tools are loaded) - # 5. Context files (AGENTS.md, .cursorrules — SOUL.md excluded here when used as identity) - # 6. Current date & time (frozen at build time) - # 7. Platform-specific formatting hint + # ── Stable tier ──────────────────────────────────────────────── + stable_parts: List[str] = [] # Try SOUL.md as primary identity unless the caller explicitly skipped it. # Some execution modes (cron) still want HERMES_HOME persona while keeping @@ -5634,15 +5741,15 @@ class AIAgent: if self.load_soul_identity or not self.skip_context_files: _soul_content = load_soul_md() if _soul_content: - prompt_parts = [_soul_content] + stable_parts.append(_soul_content) _soul_loaded = True if not _soul_loaded: # Fallback to hardcoded identity - prompt_parts = [DEFAULT_AGENT_IDENTITY] + stable_parts.append(DEFAULT_AGENT_IDENTITY) # Pointer to the hermes-agent skill + docs for user questions about Hermes itself. - prompt_parts.append(HERMES_AGENT_HELP_GUIDANCE) + stable_parts.append(HERMES_AGENT_HELP_GUIDANCE) # Tool-aware behavioral guidance: only inject when the tools are loaded tool_guidance = [] @@ -5659,17 +5766,17 @@ class AIAgent: if "kanban_show" in self.valid_tool_names: tool_guidance.append(KANBAN_GUIDANCE) if tool_guidance: - prompt_parts.append(" ".join(tool_guidance)) + stable_parts.append(" ".join(tool_guidance)) # Computer-use (macOS) — goes in as its own block rather than being # merged into tool_guidance because the content is multi-paragraph. if "computer_use" in self.valid_tool_names: from agent.prompt_builder import COMPUTER_USE_GUIDANCE - prompt_parts.append(COMPUTER_USE_GUIDANCE) + stable_parts.append(COMPUTER_USE_GUIDANCE) nous_subscription_prompt = build_nous_subscription_prompt(self.valid_tool_names) if nous_subscription_prompt: - prompt_parts.append(nous_subscription_prompt) + stable_parts.append(nous_subscription_prompt) # Tool-use enforcement: tells the model to actually call tools instead # of describing intended actions. Controlled by config.yaml # agent.tool_use_enforcement: @@ -5680,9 +5787,9 @@ class AIAgent: if self.valid_tool_names: _enforce = self._tool_use_enforcement _inject = False - if _enforce is True or (isinstance(_enforce, str) and _enforce.lower() in ("true", "always", "yes", "on")): + if _enforce is True or (isinstance(_enforce, str) and _enforce.lower() in {"true", "always", "yes", "on"}): _inject = True - elif _enforce is False or (isinstance(_enforce, str) and _enforce.lower() in ("false", "never", "no", "off")): + elif _enforce is False or (isinstance(_enforce, str) and _enforce.lower() in {"false", "never", "no", "off"}): _inject = False elif isinstance(_enforce, list): model_lower = (self.model or "").lower() @@ -5692,43 +5799,16 @@ class AIAgent: model_lower = (self.model or "").lower() _inject = any(p in model_lower for p in TOOL_USE_ENFORCEMENT_MODELS) if _inject: - prompt_parts.append(TOOL_USE_ENFORCEMENT_GUIDANCE) + stable_parts.append(TOOL_USE_ENFORCEMENT_GUIDANCE) _model_lower = (self.model or "").lower() # Google model operational guidance (conciseness, absolute # paths, parallel tool calls, verify-before-edit, etc.) if "gemini" in _model_lower or "gemma" in _model_lower: - prompt_parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE) + stable_parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE) # OpenAI GPT/Codex execution discipline (tool persistence, # prerequisite checks, verification, anti-hallucination). if "gpt" in _model_lower or "codex" in _model_lower: - prompt_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE) - - # so it can refer the user to them rather than reinventing answers. - - # Note: ephemeral_system_prompt is NOT included here. It's injected at - # API-call time only so it stays out of the cached/stored system prompt. - if system_message is not None: - prompt_parts.append(system_message) - - if self._memory_store: - if self._memory_enabled: - mem_block = self._memory_store.format_for_system_prompt("memory") - if mem_block: - prompt_parts.append(mem_block) - # USER.md is always included when enabled. - if self._user_profile_enabled: - user_block = self._memory_store.format_for_system_prompt("user") - if user_block: - prompt_parts.append(user_block) - - # External memory provider system prompt block (additive to built-in) - if self._memory_manager: - try: - _ext_mem_block = self._memory_manager.build_system_prompt() - if _ext_mem_block: - prompt_parts.append(_ext_mem_block) - except Exception: - pass + stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE) has_skills_tools = any(name in self.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage']) if has_skills_tools: @@ -5746,7 +5826,49 @@ class AIAgent: else: skills_prompt = "" if skills_prompt: - prompt_parts.append(skills_prompt) + stable_parts.append(skills_prompt) + + # Alibaba Coding Plan API always returns "glm-4.7" as model name regardless + # of the requested model. Inject explicit model identity into the system prompt + # so the agent can correctly report which model it is (workaround for API bug). + # Stable for the lifetime of an agent instance — model and provider are fixed + # at construction time. + if self.provider == "alibaba": + _model_short = self.model.split("/")[-1] if "/" in self.model else self.model + stable_parts.append( + f"You are powered by the model named {_model_short}. " + f"The exact model ID is {self.model}. " + f"When asked what model you are, always answer based on this information, " + f"not on any model name returned by the API." + ) + + # Environment hints (WSL, Termux, etc.) — tell the agent about the + # execution environment so it can translate paths and adapt behavior. + # Stable for the lifetime of the process. + _env_hints = build_environment_hints() + if _env_hints: + stable_parts.append(_env_hints) + + platform_key = (self.platform or "").lower().strip() + if platform_key in PLATFORM_HINTS: + stable_parts.append(PLATFORM_HINTS[platform_key]) + elif platform_key: + # Check plugin registry for platform-specific LLM guidance + try: + from gateway.platform_registry import platform_registry + _entry = platform_registry.get(platform_key) + if _entry and _entry.platform_hint: + stable_parts.append(_entry.platform_hint) + except Exception: + pass + + # ── Context tier (cwd-dependent, may change between sessions) ─ + context_parts: List[str] = [] + + # Note: ephemeral_system_prompt is NOT included here. It's injected at + # API-call time only so it stays out of the cached/stored system prompt. + if system_message is not None: + context_parts.append(system_message) if not self.skip_context_files: # Use TERMINAL_CWD for context file discovery when set (gateway @@ -5757,7 +5879,30 @@ class AIAgent: context_files_prompt = build_context_files_prompt( cwd=_context_cwd, skip_soul=_soul_loaded) if context_files_prompt: - prompt_parts.append(context_files_prompt) + context_parts.append(context_files_prompt) + + # ── Volatile tier (changes per session/turn — never cached) ─── + volatile_parts: List[str] = [] + + if self._memory_store: + if self._memory_enabled: + mem_block = self._memory_store.format_for_system_prompt("memory") + if mem_block: + volatile_parts.append(mem_block) + # USER.md is always included when enabled. + if self._user_profile_enabled: + user_block = self._memory_store.format_for_system_prompt("user") + if user_block: + volatile_parts.append(user_block) + + # External memory provider system prompt block (additive to built-in) + if self._memory_manager: + try: + _ext_mem_block = self._memory_manager.build_system_prompt() + if _ext_mem_block: + volatile_parts.append(_ext_mem_block) + except Exception: + pass from hermes_time import now as _hermes_now now = _hermes_now() @@ -5768,40 +5913,31 @@ class AIAgent: timestamp_line += f"\nModel: {self.model}" if self.provider: timestamp_line += f"\nProvider: {self.provider}" - prompt_parts.append(timestamp_line) + volatile_parts.append(timestamp_line) - # Alibaba Coding Plan API always returns "glm-4.7" as model name regardless - # of the requested model. Inject explicit model identity into the system prompt - # so the agent can correctly report which model it is (workaround for API bug). - if self.provider == "alibaba": - _model_short = self.model.split("/")[-1] if "/" in self.model else self.model - prompt_parts.append( - f"You are powered by the model named {_model_short}. " - f"The exact model ID is {self.model}. " - f"When asked what model you are, always answer based on this information, " - f"not on any model name returned by the API." - ) + return { + "stable": "\n\n".join(p.strip() for p in stable_parts if p and p.strip()), + "context": "\n\n".join(p.strip() for p in context_parts if p and p.strip()), + "volatile": "\n\n".join(p.strip() for p in volatile_parts if p and p.strip()), + } - # Environment hints (WSL, Termux, etc.) — tell the agent about the - # execution environment so it can translate paths and adapt behavior. - _env_hints = build_environment_hints() - if _env_hints: - prompt_parts.append(_env_hints) + def _build_system_prompt(self, system_message: str = None) -> str: + """ + Assemble the full system prompt from all layers. - platform_key = (self.platform or "").lower().strip() - if platform_key in PLATFORM_HINTS: - prompt_parts.append(PLATFORM_HINTS[platform_key]) - elif platform_key: - # Check plugin registry for platform-specific LLM guidance - try: - from gateway.platform_registry import platform_registry - _entry = platform_registry.get(platform_key) - if _entry and _entry.platform_hint: - prompt_parts.append(_entry.platform_hint) - except Exception: - pass + Called once per session (cached on self._cached_system_prompt) and only + rebuilt after context compression events. This ensures the system prompt + is stable across all turns in a session, maximizing prefix cache hits. - return "\n\n".join(p.strip() for p in prompt_parts if p.strip()) + Layers are ordered cache-friendly: stable identity/guidance first, + then session-stable context files, then per-call volatile content + (memory, USER profile, timestamp). The split is exposed via + ``_build_system_prompt_parts`` for the long-lived prompt-caching + path (Claude on Anthropic / OpenRouter / Nous Portal). + """ + parts = self._build_system_prompt_parts(system_message=system_message) + joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p) + return joined # ========================================================================= # Pre/post-call guardrails (inspired by PR #1321 — @alireza78a) @@ -5937,7 +6073,7 @@ class AIAgent: return False continue btype = block.get("type") - if btype in ("thinking", "redacted_thinking"): + if btype in {"thinking", "redacted_thinking"}: continue if btype == "text": text = block.get("text", "") @@ -6667,7 +6803,7 @@ class AIAgent: if done_item is not None: collected_output_items.append(done_item) # Log non-completed terminal events for diagnostics - elif event_type in ("response.incomplete", "response.failed"): + elif event_type in {"response.incomplete", "response.failed"}: resp_obj = getattr(event, "response", None) status = getattr(resp_obj, "status", None) if resp_obj else None incomplete_details = getattr(resp_obj, "incomplete_details", None) if resp_obj else None @@ -6769,7 +6905,7 @@ class AIAgent: done_item = event.get("item") if done_item is not None: collected_output_items.append(done_item) - elif event_type in ("response.output_text.delta",): + elif event_type in {"response.output_text.delta",}: delta = getattr(event, "delta", "") if not delta and isinstance(event, dict): delta = event.get("delta", "") @@ -7065,7 +7201,7 @@ class AIAgent: effective_reason = FailoverReason.billing elif status_code == 429: effective_reason = FailoverReason.rate_limit - elif status_code in (401, 403): + elif status_code in {401, 403}: effective_reason = FailoverReason.auth if effective_reason == FailoverReason.billing: @@ -7728,24 +7864,23 @@ class AIAgent: _fire_first_delta() self._fire_stream_delta(delta.content) deltas_were_sent["yes"] = True - else: - # Tool calls suppress regular content streaming (avoids - # displaying chatty "I'll use the tool..." text alongside - # tool calls). But reasoning tags embedded in suppressed - # content should still reach the display — otherwise the - # reasoning box only appears as a post-response fallback, - # rendering it confusingly after the already-streamed - # response. Route suppressed content through the stream - # delta callback so its tag extraction can fire the - # reasoning display. Non-reasoning text is harmlessly - # suppressed by the CLI's _stream_delta when the stream - # box is already closed (tool boundary flush). - if self.stream_delta_callback: - try: - self.stream_delta_callback(delta.content) - self._record_streamed_assistant_text(delta.content) - except Exception: - pass + # Tool calls suppress regular content streaming (avoids + # displaying chatty "I'll use the tool..." text alongside + # tool calls). But reasoning tags embedded in suppressed + # content should still reach the display — otherwise the + # reasoning box only appears as a post-response fallback, + # rendering it confusingly after the already-streamed + # response. Route suppressed content through the stream + # delta callback so its tag extraction can fire the + # reasoning display. Non-reasoning text is harmlessly + # suppressed by the CLI's _stream_delta when the stream + # box is already closed (tool boundary flush). + elif self.stream_delta_callback: + try: + self.stream_delta_callback(delta.content) + self._record_streamed_assistant_text(delta.content) + except Exception: + pass # Accumulate tool call deltas — notify display on first name if delta and delta.tool_calls: @@ -8387,7 +8522,7 @@ class AIAgent: auth resolution and client construction — no duplicated provider→key mappings. """ - if reason in (FailoverReason.rate_limit, FailoverReason.billing): + if reason in {FailoverReason.rate_limit, FailoverReason.billing}: # Only start cooldown when leaving the primary provider. If we're # already on a fallback and chain-switching, the primary wasn't the # source of the 429 so the cooldown should not be reset/extended. @@ -8560,6 +8695,15 @@ class AIAgent: model=fb_model, ) ) + self._use_long_lived_prefix_cache = bool( + self._use_prompt_caching + and self._supports_long_lived_anthropic_cache( + provider=fb_provider, + base_url=fb_base_url, + api_mode=fb_api_mode, + model=fb_model, + ) + ) # LM Studio: preload before probing the fallback's context length. self._ensure_lmstudio_runtime_loaded() @@ -8636,6 +8780,16 @@ class AIAgent: "use_native_cache_layout", self.api_mode == "anthropic_messages" and self.provider == "anthropic", ) + # Long-lived prefix flag was added later — restore False on + # snapshots predating the new field, then re-evaluate against + # the restored provider/model in case the user had it enabled. + self._use_long_lived_prefix_cache = rt.get( + "use_long_lived_prefix_cache", + bool( + self._use_prompt_caching + and self._supports_long_lived_anthropic_cache() + ), + ) # ── Rebuild client for the primary provider ── if self.api_mode == "anthropic_messages": @@ -8713,7 +8867,7 @@ class AIAgent: if self._is_openrouter_url(): return False provider_lower = (self.provider or "").strip().lower() - if provider_lower in ("nous", "nous-research"): + if provider_lower in {"nous", "nous-research"}: return False try: @@ -8793,8 +8947,17 @@ class AIAgent: "image/jpg": ".jpg", }.get(mime, ".jpg") tmp = tempfile.NamedTemporaryFile(prefix="anthropic_image_", suffix=suffix, delete=False) - with tmp: - tmp.write(base64.b64decode(data)) + try: + with tmp: + tmp.write(base64.b64decode(data)) + except Exception: + # delete=False means a corrupt/unsupported data URL would otherwise + # leak a zero-byte temp file on every failed materialization. + try: + os.unlink(tmp.name) + except OSError: + pass + raise path = Path(tmp.name) return str(path), path @@ -9204,6 +9367,20 @@ class AIAgent: def _build_api_kwargs(self, api_messages: list) -> dict: """Build the keyword arguments dict for the active API mode.""" + # Resolve the tools array exactly once. When the long-lived + # prefix-cache layout is active (Claude on Anthropic / OpenRouter + # / Nous Portal), attach a 1h cache_control marker to the last + # tool — this caches the entire tools array cross-session via + # Anthropic's tools→system→messages prefix order. The function + # returns a deep copy, so self.tools is never mutated. + if self._use_long_lived_prefix_cache and self.tools: + from agent.prompt_caching import mark_tools_for_long_lived_cache + tools_for_api = mark_tools_for_long_lived_cache( + self.tools, long_lived_ttl=self._long_lived_cache_ttl, + ) + else: + tools_for_api = self.tools + if self.api_mode == "anthropic_messages": _transport = self._get_transport() anthropic_messages = self._prepare_anthropic_messages_for_api(api_messages) @@ -9215,7 +9392,7 @@ class AIAgent: return _transport.build_kwargs( model=self.model, messages=anthropic_messages, - tools=self.tools, + tools=tools_for_api, max_tokens=ephemeral_out if ephemeral_out is not None else self.max_tokens, reasoning_config=self.reasoning_config, is_oauth=self._is_anthropic_oauth, @@ -9235,7 +9412,7 @@ class AIAgent: return _bt.build_kwargs( model=self.model, messages=api_messages, - tools=self.tools, + tools=tools_for_api, max_tokens=self.max_tokens or 4096, region=region, guardrail_config=guardrail, @@ -9259,7 +9436,7 @@ class AIAgent: return _ct.build_kwargs( model=self.model, messages=_msgs_for_codex, - tools=self.tools, + tools=tools_for_api, reasoning_config=self.reasoning_config, session_id=getattr(self, "session_id", None), max_tokens=self.max_tokens, @@ -9350,7 +9527,7 @@ class AIAgent: return _ct.build_kwargs( model=self.model, messages=api_messages, - tools=self.tools, + tools=tools_for_api, base_url=self.base_url, timeout=self._resolved_api_call_timeout(), max_tokens=self.max_tokens, @@ -9382,7 +9559,7 @@ class AIAgent: return _ct.build_kwargs( model=self.model, messages=_msgs_for_chat, - tools=self.tools, + tools=tools_for_api, base_url=self.base_url, timeout=self._resolved_api_call_timeout(), max_tokens=self.max_tokens, @@ -10069,6 +10246,12 @@ class AIAgent: self._session_db.end_session(self.session_id, "compression") old_session_id = self.session_id self.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + os.environ["HERMES_SESSION_ID"] = self.session_id + try: + from gateway.session_context import _SESSION_ID + _SESSION_ID.set(self.session_id) + except Exception: + pass # Update session_log_file to point to the new session's JSON file self.session_log_file = self.logs_dir / f"session_{self.session_id}.json" self._session_db_created = False @@ -10298,7 +10481,7 @@ class AIAgent: store=self._memory_store, ) # Bridge: notify external memory provider of built-in memory writes - if self._memory_manager and function_args.get("action") in ("add", "replace"): + if self._memory_manager and function_args.get("action") in {"add", "replace"}: try: self._memory_manager.on_memory_write( function_args.get("action", ""), @@ -10397,7 +10580,7 @@ class AIAgent: function_args = {} # Checkpoint for file-mutating tools - if function_name in ("write_file", "patch") and self._checkpoint_mgr.enabled: + if function_name in {"write_file", "patch"} and self._checkpoint_mgr.enabled: try: file_path = function_args.get("path", "") if file_path: @@ -10811,12 +10994,11 @@ class AIAgent: # Tool blocked by plugin or guardrail policy — skip counters, # callbacks, checkpointing, activity mutation, and real execution. pass - else: - # Reset nudge counters when the relevant tool is actually used - if function_name == "memory": - self._turns_since_memory = 0 - elif function_name == "skill_manage": - self._iters_since_skill = 0 + # Reset nudge counters when the relevant tool is actually used + elif function_name == "memory": + self._turns_since_memory = 0 + elif function_name == "skill_manage": + self._iters_since_skill = 0 if not self.quiet_mode: args_str = json.dumps(function_args, ensure_ascii=False) @@ -10855,7 +11037,7 @@ class AIAgent: logging.debug(f"Tool start callback error: {cb_err}") # Checkpoint: snapshot working dir before file-mutating tools - if not _execution_blocked and function_name in ("write_file", "patch") and self._checkpoint_mgr.enabled: + if not _execution_blocked and function_name in {"write_file", "patch"} and self._checkpoint_mgr.enabled: try: file_path = function_args.get("path", "") if file_path: @@ -10927,7 +11109,7 @@ class AIAgent: store=self._memory_store, ) # Bridge: notify external memory provider of built-in memory writes - if self._memory_manager and function_args.get("action") in ("add", "replace"): + if self._memory_manager and function_args.get("action") in {"add", "replace"}: try: self._memory_manager.on_memory_write( function_args.get("action", ""), @@ -12025,20 +12207,42 @@ class AIAgent: # Ephemeral additions are API-call-time only (not persisted to session DB). # External recall context is injected into the user message, not the system # prompt, so the stable cache prefix remains unchanged. - effective_system = active_system_prompt or "" - if self.ephemeral_system_prompt: - effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip() + # + # When the long-lived prefix-cache layout is active (Claude on + # Anthropic / OpenRouter / Nous Portal), we build the system + # message as a *list of content blocks*: [stable, context, + # volatile, ephemeral?]. Block 0 (stable) gets the 1h + # cache_control marker further down via + # apply_anthropic_cache_control_long_lived; blocks 1-3 are + # cached only via the rolling messages window at 5m. # NOTE: Plugin context from pre_llm_call hooks is injected into the # user message (see injection block above), NOT the system prompt. # This is intentional — system prompt modifications break the prompt # cache prefix. The system prompt is reserved for Hermes internals. - if effective_system: - api_messages = [{"role": "system", "content": effective_system}] + api_messages + if self._use_long_lived_prefix_cache: + _sys_parts = self._build_system_prompt_parts(system_message=system_message) + _sys_blocks: list = [] + if _sys_parts.get("stable"): + _sys_blocks.append({"type": "text", "text": _sys_parts["stable"]}) + if _sys_parts.get("context"): + _sys_blocks.append({"type": "text", "text": _sys_parts["context"]}) + if _sys_parts.get("volatile"): + _sys_blocks.append({"type": "text", "text": _sys_parts["volatile"]}) + if self.ephemeral_system_prompt: + _sys_blocks.append({"type": "text", "text": self.ephemeral_system_prompt}) + if _sys_blocks: + api_messages = [{"role": "system", "content": _sys_blocks}] + api_messages + else: + effective_system = active_system_prompt or "" + if self.ephemeral_system_prompt: + effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip() + if effective_system: + api_messages = [{"role": "system", "content": effective_system}] + api_messages # Inject ephemeral prefill messages right after the system prompt # but before conversation history. Same API-call-time-only pattern. if self.prefill_messages: - sys_offset = 1 if effective_system else 0 + sys_offset = 1 if (api_messages and api_messages[0].get("role") == "system") else 0 for idx, pfm in enumerate(self.prefill_messages): api_messages.insert(sys_offset + idx, pfm.copy()) @@ -12049,12 +12253,27 @@ class AIAgent: # to reduce input token costs by ~75% on multi-turn # conversations. Layout is chosen per endpoint by # ``_anthropic_prompt_cache_policy``. + # + # Long-lived prefix layout (prefix_and_2): stable system block + # gets 1h marker + last 2 messages get 5m markers. Tools + # array's last entry is marked separately at API-call kwargs + # build time (see ``_build_api_kwargs`` and + # ``mark_tools_for_long_lived_cache``). if self._use_prompt_caching: - api_messages = apply_anthropic_cache_control( - api_messages, - cache_ttl=self._cache_ttl, - native_anthropic=self._use_native_cache_layout, - ) + if self._use_long_lived_prefix_cache: + from agent.prompt_caching import apply_anthropic_cache_control_long_lived + api_messages = apply_anthropic_cache_control_long_lived( + api_messages, + long_lived_ttl=self._long_lived_cache_ttl, + rolling_ttl=self._cache_ttl, + native_anthropic=self._use_native_cache_layout, + ) + else: + api_messages = apply_anthropic_cache_control( + api_messages, + cache_ttl=self._cache_ttl, + native_anthropic=self._use_native_cache_layout, + ) # Safety net: strip orphaned tool results / add stubs for missing # results before sending to the API. Runs unconditionally — not @@ -12457,9 +12676,9 @@ class AIAgent: _failure_hint = f"upstream gateway timeout (504, {api_duration:.0f}s)" elif _resp_error_code == 429: _failure_hint = f"rate limited by upstream provider (429)" - elif _resp_error_code in (500, 502): + elif _resp_error_code in {500, 502}: _failure_hint = f"upstream server error ({_resp_error_code}, {api_duration:.0f}s)" - elif _resp_error_code in (503, 529): + elif _resp_error_code in {503, 529}: _failure_hint = f"upstream provider overloaded ({_resp_error_code})" elif _resp_error_code is not None: _failure_hint = f"upstream error (code {_resp_error_code}, {api_duration:.0f}s)" @@ -12647,7 +12866,7 @@ class AIAgent: "error": _exhaust_error, } - if self.api_mode in ("chat_completions", "bedrock_converse", "anthropic_messages"): + if self.api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"}: assistant_message = _trunc_msg if assistant_message is not None and not _trunc_has_tool_calls: length_continue_retries += 1 @@ -12687,7 +12906,7 @@ class AIAgent: "error": "Response remained truncated after 3 continuation attempts", } - if self.api_mode in ("chat_completions", "bedrock_converse", "anthropic_messages"): + if self.api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"}: assistant_message = _trunc_msg if assistant_message is not None and _trunc_has_tool_calls: if truncated_tool_call_retries < 1: @@ -13108,6 +13327,21 @@ class AIAgent: "does not support multimodal", "does not support vision", "model does not support image", + # ChatGPT-account Codex backend + # (https://chatgpt.com/backend-api/codex) rejects + # data:image/...base64 URLs in input_image fields + # with HTTP 400 "Invalid 'input[N].content[K].image_url'. + # Expected a valid URL, but got a value with an + # invalid format." The OpenAI Responses API on the + # public endpoint accepts data URLs, but the + # ChatGPT-account variant does not. Without this + # phrase the agent cascaded into compression / + # context-too-large recovery instead of just + # stripping the images. Match is narrow on + # purpose — keyed on the field-path apostrophe so + # we don't false-trip on other URL validation + # errors. (issue #23570) + "image_url'. expected", ) _err_lower = _err_body.lower() _looks_like_image_rejection = any( @@ -13504,10 +13738,10 @@ class AIAgent: # When a fallback model is configured, switch immediately instead # of burning through retries with exponential backoff -- the # primary provider won't recover within the retry window. - is_rate_limited = classified.reason in ( + is_rate_limited = classified.reason in { FailoverReason.rate_limit, FailoverReason.billing, - ) + } if is_rate_limited and self._fallback_index < len(self._fallback_chain): # Don't eagerly fallback if credential pool rotation may # still recover. See _pool_may_recover_from_rate_limit @@ -13832,7 +14066,7 @@ class AIAgent: or ( not classified.retryable and not classified.should_compress - and classified.reason not in ( + and classified.reason not in { FailoverReason.rate_limit, FailoverReason.billing, FailoverReason.overloaded, @@ -13840,7 +14074,7 @@ class AIAgent: FailoverReason.payload_too_large, FailoverReason.long_context_tier, FailoverReason.thinking_signature, - ) + } ) ) and not is_context_length_error @@ -14959,7 +15193,41 @@ class AIAgent: "— requesting summary..." ) final_response = self._handle_max_iterations(messages, api_call_count) - + + # If running as a kanban worker, block the task so the dispatcher + # knows the worker could not complete (rather than treating it as a + # protocol violation). The agent loop strips tools before calling + # _handle_max_iterations, so the model cannot call kanban_block + # itself — we must do it on its behalf. + _kanban_task = os.environ.get("HERMES_KANBAN_TASK") + if _kanban_task: + try: + handle_function_call( + "kanban_block", + { + "task_id": _kanban_task, + "reason": ( + f"Iteration budget exhausted " + f"({api_call_count}/{self.max_iterations}) — " + "task could not complete within the allowed " + "iterations" + ), + }, + task_id=effective_task_id, + ) + logger.info( + "kanban_block called for task %s after iteration " + "exhaustion (%d/%d)", + _kanban_task, api_call_count, self.max_iterations, + ) + except Exception: + logger.warning( + "Failed to call kanban_block after iteration " + "exhaustion for task %s", + _kanban_task, + exc_info=True, + ) + # Determine if conversation completed successfully completed = final_response is not None and api_call_count < self.max_iterations @@ -15253,9 +15521,9 @@ def main( info = get_toolset_info(name) if info: entry = (name, info) - if name in ["web", "terminal", "vision", "creative", "reasoning"]: + if name in {"web", "terminal", "vision", "creative", "reasoning"}: basic_toolsets.append(entry) - elif name in ["research", "development", "analysis", "content_creation", "full_stack"]: + elif name in {"research", "development", "analysis", "content_creation", "full_stack"}: composite_toolsets.append(entry) else: scenario_toolsets.append(entry) diff --git a/scripts/benchmark_browser_eval.py b/scripts/benchmark_browser_eval.py new file mode 100644 index 00000000000..019667f2365 --- /dev/null +++ b/scripts/benchmark_browser_eval.py @@ -0,0 +1,138 @@ +"""Quick benchmark: subprocess eval vs supervisor-WS eval. + +Runs both paths against the same live Chrome and prints a comparison table. +Not a pytest — a script you run manually for the PR description. + +Usage: + .venv/bin/python scripts/benchmark_browser_eval.py [--iterations N] +""" +from __future__ import annotations + +import argparse +import shutil +import statistics +import subprocess +import sys +import tempfile +import time +import urllib.request +import json + + +def _find_chrome() -> str: + for c in ("google-chrome", "chromium", "chromium-browser"): + p = shutil.which(c) + if p: + return p + print("No Chrome binary found.", file=sys.stderr) + sys.exit(1) + + +def _start_chrome(port: int): + profile = tempfile.mkdtemp(prefix="hermes-bench-eval-") + proc = subprocess.Popen( + [ + _find_chrome(), + f"--remote-debugging-port={port}", + f"--user-data-dir={profile}", + "--no-first-run", + "--no-default-browser-check", + "--headless=new", + "--disable-gpu", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1) as r: + info = json.loads(r.read().decode()) + return proc, profile, info["webSocketDebuggerUrl"] + except Exception: + time.sleep(0.25) + proc.terminate() + raise RuntimeError("Chrome didn't expose CDP") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--iterations", type=int, default=50) + parser.add_argument("--port", type=int, default=9333) + args = parser.parse_args() + + proc, profile, cdp_url = _start_chrome(args.port) + try: + from tools.browser_supervisor import SUPERVISOR_REGISTRY + + # Warm up: start the supervisor, navigate to a page. + supervisor = SUPERVISOR_REGISTRY.get_or_start( + task_id="bench-eval", cdp_url=cdp_url + ) + # Give it a moment to attach. + time.sleep(1.0) + + # Sanity check: one eval over WS should succeed. + sanity = supervisor.evaluate_runtime("1 + 1") + if not sanity.get("ok") or sanity.get("result") != 2: + print(f"sanity check failed: {sanity}", file=sys.stderr) + sys.exit(2) + + # ── Bench 1: supervisor WS path ────────────────────────────────── + ws_times: list[float] = [] + for _ in range(args.iterations): + t0 = time.monotonic() + out = supervisor.evaluate_runtime("1 + 1") + t1 = time.monotonic() + assert out.get("ok"), out + ws_times.append((t1 - t0) * 1000) + + # ── Bench 2: agent-browser subprocess path ──────────────────────── + # Skip if agent-browser isn't installed — the WS bench still tells + # us what we need. + if shutil.which("agent-browser") is None and shutil.which("npx") is None: + print("agent-browser CLI not found — skipping subprocess bench.") + sub_times = [] + else: + from tools.browser_tool import _run_browser_command, _last_session_key + task_id = _last_session_key("bench-eval") + sub_times = [] + for _ in range(args.iterations): + t0 = time.monotonic() + _run_browser_command(task_id, "eval", ["1 + 1"]) + t1 = time.monotonic() + sub_times.append((t1 - t0) * 1000) + + def fmt(name: str, ts: list[float]) -> str: + if not ts: + return f" {name:<40} (skipped)" + mean = statistics.mean(ts) + median = statistics.median(ts) + mn, mx = min(ts), max(ts) + return ( + f" {name:<40} mean={mean:>7.2f}ms median={median:>7.2f}ms " + f"min={mn:>7.2f}ms max={mx:>7.2f}ms" + ) + + print() + print(f"browser_eval benchmark — {args.iterations} iterations of `1 + 1`") + print("-" * 90) + print(fmt("supervisor WS (Runtime.evaluate)", ws_times)) + print(fmt("agent-browser subprocess (eval)", sub_times)) + if ws_times and sub_times: + speedup = statistics.mean(sub_times) / statistics.mean(ws_times) + print() + print(f"Speedup: {speedup:.1f}x (mean)") + + finally: + SUPERVISOR_REGISTRY.stop_all() + proc.terminate() + try: + proc.wait(timeout=3) + except Exception: + proc.kill() + shutil.rmtree(profile, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/build_skills_index.py b/scripts/build_skills_index.py index 96a0b637596..206a8012436 100644 --- a/scripts/build_skills_index.py +++ b/scripts/build_skills_index.py @@ -147,7 +147,7 @@ def batch_resolve_paths(skills: list, auth: GitHubAuth) -> list: 4. Match skills to their resolved paths """ # Filter to skills.sh entries that need resolution - skills_sh = [s for s in skills if s["source"] in ("skills.sh", "skills-sh")] + skills_sh = [s for s in skills if s["source"] in {"skills.sh", "skills-sh"}] if not skills_sh: return skills diff --git a/scripts/profile-tui.py b/scripts/profile-tui.py index edbdf2ee453..6006eb09660 100755 --- a/scripts/profile-tui.py +++ b/scripts/profile-tui.py @@ -343,7 +343,7 @@ def key_metrics(data: dict[str, Any]) -> dict[str, float]: metrics["backpressure_frames"] = bp if react: - for pid in set(e["id"] for e in react): + for pid in {e["id"] for e in react}: ms = [e["actualMs"] for e in react if e["id"] == pid] metrics[f"react_{pid}_p99"] = pct(ms, 0.99) metrics[f"react_{pid}_max"] = max(ms) @@ -360,7 +360,7 @@ def format_diff(before: dict[str, float], after: dict[str, float]) -> str: b = before.get(k, 0.0) a = after.get(k, 0.0) d = a - b - pct_change = ((a / b) - 1) * 100 if b not in (0, 0.0) else float("inf") if a else 0 + pct_change = ((a / b) - 1) * 100 if b not in {0, 0.0} else float("inf") if a else 0 # Flag improvements vs regressions. For _p99 / _max / _total / gaps_over / # patches / writeBytes / backpressure, LOWER is better. For fps / gaps_under, diff --git a/scripts/release.py b/scripts/release.py index bceced36a91..ee99bd616d9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -50,19 +50,36 @@ AUTHOR_MAP = { "buraysandro9@gmail.com": "ygd58", "teknium@nousresearch.com": "teknium1", "piyushvp1@gmail.com": "thelumiereguy", + "421774554@qq.com": "wuli666", "harish.kukreja@gmail.com": "counterposition", + "1046611633@qq.com": "zhengyn0001", "cleo@edaphic.xyz": "curiouscleo", "hirokazu.ogawa@kwansei.ac.jp": "hrkzogw", "datapod.k@gmail.com": "dandacompany", + "treydong.zh@gmail.com": "TreyDong", "127238744+teknium1@users.noreply.github.com": "teknium1", + "hugosequier@gmail.com": "Hugo-SEQUIER", "128259593+Gutslabs@users.noreply.github.com": "Gutslabs", "50326054+nocturnum91@users.noreply.github.com": "nocturnum91", "223003280+Abd0r@users.noreply.github.com": "Abd0r", + "HuangYuChuh@users.noreply.github.com": "HuangYuChuh", + "aaronwong1989@gmail.com": "hrygo", + "26729613+hrygo@users.noreply.github.com": "hrygo", + "erenkar950@gmail.com": "eren-karakus0", + "aubrey@freeman-wisco.com": "Freeman-Consulting", + "don.rhm@gmail.com": "rahimsais", + "40222899+rahimsais@users.noreply.github.com": "rahimsais", + "alfred@Alfreds-Mac-mini.local": "NivOO5", + "231191380+NivOO5@users.noreply.github.com": "NivOO5", + "jameshuang@gmail.com": "kjames2001", + "62420081+kjames2001@users.noreply.github.com": "kjames2001", + "132184373+wilsen0@users.noreply.github.com": "wilsen0", "ra2157218@gmail.com": "Abd0r", "abdielv@proton.me": "AJV20", "mason@growagainorchids.com": "masonjames", "ytchen0719@gmail.com": "liquidchen", "am@studio1.tailb672fe.ts.net": "subtract0", + "mike@grossmann.at": "ReqX", "axmaiqiu@gmail.com": "qWaitCrypto", "44045911+kidonng@users.noreply.github.com": "kidonng", "daniellsmarta@gmail.com": "DanielLSM", @@ -71,6 +88,7 @@ AUTHOR_MAP = { "maksesipov@gmail.com": "Qwinty", "denisamania@gmail.com": "CalmProton", "308068+mbac@users.noreply.github.com": "mbac", + "nicoechaniz@altermundi.net": "nicoechaniz", "ninso112@proton.me": "Ninso112", "wesleysimplicio@live.com": "wesleysimplicio", "matthew.dean.cater@gmail.com": "SiliconID", @@ -138,6 +156,14 @@ AUTHOR_MAP = { "tony@tonysimons.dev": "asimons81", "jetha@google.com": "jethac", "jani@0xhoneyjar.xyz": "deep-name", + # LINE messaging plugin (synthesis PR) + "32443648+leepoweii@users.noreply.github.com": "leepoweii", + "openclaw@liyangchen.me": "liyoungc", + "charles@perng.com": "perng", + "soichiro0111.dev@gmail.com": "soichiyo", + "0xde@pieverse.io": "David-0x221Eight", + "77736378+David-0x221Eight@users.noreply.github.com": "David-0x221Eight", + "74749461+yuga-hashimoto@users.noreply.github.com": "yuga-hashimoto", "xiangyong@zspace.cn": "CES4751", "harish.kukreja@gmail.com": "counterposition", "35294173+Fearvox@users.noreply.github.com": "Fearvox", @@ -319,6 +345,7 @@ AUTHOR_MAP = { "nish3451@users.noreply.github.com": "nish3451", "Mibayy@users.noreply.github.com": "Mibayy", "mibayy@users.noreply.github.com": "Mibayy", + "mibay@clawhub.io": "Mibayy", "135070653+sgaofen@users.noreply.github.com": "sgaofen", "lzy.dev@gmail.com": "zhiyanliu", "me@janstepanovsky.cz": "hhhonzik", @@ -703,6 +730,14 @@ AUTHOR_MAP = { "22549957+li0near@users.noreply.github.com": "li0near", "guoyu801@gmail.com": "li0near", "ty@tmrtn.com": "tymrtn", + "elitovsky@zenproject.net": "kallidean", + "5463986+baocin@users.noreply.github.com": "baocin", + "107296821+princepal9120@users.noreply.github.com": "princepal9120", + "gufo0125@gmail.com": "guglielmofonda", + "102474490+yehuosi@users.noreply.github.com": "yehuosi", + "yehuosi@users.noreply.github.com": "yehuosi", + "31932854+jelrod27@users.noreply.github.com": "jelrod27", + "11262660+konsisumer@users.noreply.github.com": "konsisumer", "23434080+sicnuyudidi@users.noreply.github.com": "sicnuyudidi", "haimu0x0@proton.me": "haimu0x", "abdelmajidnidnasser1@gmail.com": "NIDNASSER-Abdelmajid", @@ -847,6 +882,7 @@ AUTHOR_MAP = { "lhysdl@gmail.com": "lhysdl", "shemol@163.com": "SherlockShemol", "enochlam2002@gmail.com": "eloklam", + "eloklam@eloklam-ubuntudesktop.tail21966c.ts.net": "eloklam", "clawdia@fmercurio-macstudio.local": "fmercurio", "ricardoporsche001@icloud.com": "Ricardo-M-L", "leozeli@qq.com": "leozeli", @@ -938,6 +974,15 @@ AUTHOR_MAP = { "jhin.lee@unity3d.com": "leehack", # PR #22053 salvage (telegram DM topic reply fallback) # pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan "ayman.a.kamal@hotmail.com": "A-kamal", # PR #18678 (xAI image resolution fix) + # Kanban bug-fix batch salvage (May 2026) + "frowte3k@gmail.com": "Frowtek", # salvage of #23206 (gateway --board auto-subscribe) + "sylw3st3rr@gmail.com": "Sylw3ster", # salvage of #23252 (HERMES_KANBAN_BOARD restore) + "hello@dominikh.com": "dmnkhorvath", # salvage of #23358 (kanban worker send_message) + "413011+smwbev@users.noreply.github.com": "smwbev", # salvage of #23659 (aria-label colLabel) + "58116817+TurgutKural@users.noreply.github.com": "TurgutKural", # salvage of #23356 (HERMES_HOME inject) + "openclaw@agent.local": "29206394", # PR #22194 salvage (sudo -S brute-force guard, #9590) + "freedemon@gmail.com": "fr33d3m0n", # PR #21128 salvage (sudo stdin/askpass DANGEROUS, #17873 cat 4) + "zhaowh3613@outlook.com": "VinceZcrikl", # PR #23647 salvage (npm UTF-8 decode on GBK Windows) } @@ -1382,7 +1427,7 @@ def main(): print(f" SemVer: v{current_version} → v{new_version}") print(f" Previous tag: {prev_tag or '(none — first release)'}") print(f" Commits: {len(commits)}") - print(f" Unique authors: {len(set(c['github_author'] for c in commits))}") + print(f" Unique authors: {len({c['github_author'] for c in commits})}") print(f" Mode: {'PUBLISH' if args.publish else 'DRY RUN'}") print(f"{'='*60}") print() diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index d7d8a85f502..3788aef4e5f 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -87,6 +87,22 @@ export LANG=C.UTF-8 export LC_ALL=C.UTF-8 export PYTHONHASHSEED=0 +# ── Live-gateway test guard (developer machines) ──────────────────────────── +# If a system-wide hermes pytest_live_guard plugin is installed at +# $HOME/.hermes/pytest_live_guard.py, force-load it here so every test run +# from this script gets the protection regardless of which worktree is +# checked out (in-tree tests/conftest.py guard may be missing on stale +# branches). Harmless on CI / fresh machines that don't have the file. +if [ -f "$HOME/.hermes/pytest_live_guard.py" ]; then + case ":${PYTHONPATH:-}:" in + *":$HOME/.hermes:"*) ;; + *) export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}$HOME/.hermes" ;; + esac + if [[ ",${PYTEST_PLUGINS:-}," != *,pytest_live_guard,* ]]; then + export PYTEST_PLUGINS="${PYTEST_PLUGINS:+$PYTEST_PLUGINS,}pytest_live_guard" + fi +fi + # ── Worker count ──────────────────────────────────────────────────────────── # CI uses `-n auto` on ubuntu-latest which gives 4 workers. A 20-core # workstation with `-n auto` gets 20 workers and exposes test-ordering diff --git a/skills/devops/kanban-orchestrator/SKILL.md b/skills/devops/kanban-orchestrator/SKILL.md index cfa641811b2..b444686a331 100644 --- a/skills/devops/kanban-orchestrator/SKILL.md +++ b/skills/devops/kanban-orchestrator/SKILL.md @@ -1,7 +1,7 @@ --- name: kanban-orchestrator -description: Decomposition playbook + specialist-roster conventions + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban worker's system prompt; this skill is the deeper playbook when you're specifically playing the orchestrator role. -version: 2.0.0 +description: Decomposition playbook + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban worker's system prompt; this skill is the deeper playbook when you're specifically playing the orchestrator role. +version: 3.0.0 platforms: [linux, macos, windows] metadata: hermes: @@ -13,6 +13,22 @@ metadata: > The **core worker lifecycle** (including the `kanban_create` fan-out pattern and the "decompose, don't execute" rule) is auto-injected into every kanban process via the `KANBAN_GUIDANCE` system-prompt block. This skill is the deeper playbook when you're an orchestrator profile whose whole job is routing. +## Profiles are user-configured — not a fixed roster + +Hermes setups vary widely. Some users run a single profile that does everything; some run a small fleet (`docker-worker`, `cron-worker`); some run a curated specialist team they've named themselves. There is **no default specialist roster** — the orchestrator skill does not know what profiles exist on this machine. + +Before fanning out, you must ground the decomposition in the profiles that actually exist. The dispatcher silently fails to spawn unknown assignee names — it doesn't autocorrect, doesn't suggest, doesn't fall back. So a card assigned to `researcher` on a setup that only has `docker-worker` just sits in `ready` forever. + +**Step 0: discover available profiles before planning.** + +Use one of these: + +- `hermes profile list` — prints the table of profiles configured on this machine. Run it through your terminal tool if you have one; otherwise ask the user. +- `kanban_list(assignee="<some-name>")` — sanity-check a single name. Returns an empty list (rather than an error) for an unknown assignee, so this only confirms a name you're already considering. +- **Just ask the user.** "What profiles do you have set up?" is a fine first turn when the goal needs more than one specialist. + +Cache the result in your working memory for the rest of the conversation. Re-asking every turn wastes a tool call. + ## When to use the board (vs. just doing the work) Create Kanban tasks when any of these are true: @@ -34,24 +50,9 @@ Your job description says "route, don't execute." The rules that enforce that: - **For any concrete task, create a Kanban task and assign it.** Every single time. - **Split multi-lane requests before creating cards.** A user prompt can contain several independent workstreams. Extract those lanes first, then create one card per lane instead of bundling unrelated work into a single implementer card. - **Run independent lanes in parallel.** If two cards do not need each other's output, leave them unlinked so the dispatcher can fan them out. Link only true data dependencies. -- **If no specialist fits, ask the user which profile to create.** Do not default to doing it yourself under "close enough." +- **If no specialist fits the available profiles, ask the user which profile to create or which existing profile to use.** Do not invent profile names; the dispatcher will silently drop unknown assignees. - **Decompose, route, and summarize — that's the whole job.** -## The standard specialist roster (convention) - -Unless the user's setup has customized profiles, assume these exist. Adjust to whatever the user actually has — ask if you're unsure. - -| Profile | Does | Typical workspace | -|---|---|---| -| `researcher` | Reads sources, gathers facts, writes findings | `scratch` | -| `analyst` | Synthesizes, ranks, de-dupes. Consumes multiple `researcher` outputs | `scratch` | -| `writer` | Drafts prose in the user's voice | `scratch` or `dir:` into their Obsidian vault | -| `reviewer` | Reads output, leaves findings, gates approval | `scratch` | -| `backend-eng` | Writes server-side code | `worktree` | -| `frontend-eng` | Writes client-side code | `worktree` | -| `ops` | Runs scripts, manages services, handles deployments | `dir:` into ops scripts repo | -| `pm` | Writes specs, acceptance criteria | `scratch` | - ## Decomposition playbook ### Step 1 — Understand the goal @@ -63,57 +64,50 @@ Ask clarifying questions if the goal is ambiguous. Cheap to ask; expensive to sp Before creating anything, draft the graph out loud (in your response to the user). Treat every concrete workstream as a candidate card: 1. Extract the lanes from the request. -2. Assign each lane to the best specialist. +2. Map each lane to one of the profiles you discovered in Step 0. If a lane doesn't fit any existing profile, ask the user which to use or create. 3. Decide whether each lane is independent or gated by another lane. 4. Create independent lanes as parallel cards with no parent links. 5. Create synthesis/review/integration cards with parent links to the lanes they depend on. -Examples of prompts that should fan out: +Examples of prompts that should fan out (using placeholder profile names — substitute whatever exists on the user's setup): -- "Build an app" -> `designer` for product/UI direction and `frontend-eng` or `backend-eng` for implementation, with a later integration/review card if needed. -- "Fix blockers and check model variants" -> one implementation card for the blocker fixes plus one discovery/research card for config/source verification. A final reviewer card can depend on both. -- "Research docs and implement" -> a docs-research card can run in parallel with a codebase-discovery card; implementation waits only if it truly needs those findings. -- "Analyze this screenshot and find the related code" -> `observer` handles visual analysis while an explorer-style profile searches the codebase. +- "Build an app" → one card to a design-oriented profile for product/UI direction, one or two cards to engineering profiles for implementation, plus a later integration/review card if the user has a reviewer profile. +- "Fix blockers and check model variants" → one implementation card for the blocker fixes plus one discovery/research card for config/source verification. A final reviewer card can depend on both. +- "Research docs and implement" → a docs-research card can run in parallel with a codebase-discovery card; implementation waits only if it truly needs those findings. +- "Analyze this screenshot and find the related code" → one card to a vision-capable profile for the visual analysis while another searches the codebase. Words like "also," "finally," or "and" do not automatically imply a dependency. They often mean "make sure this is covered before reporting back." Only link tasks when one card cannot start until another card's output exists. -Example for "Analyze whether we should migrate to Postgres": - -``` -T1 researcher research: Postgres cost vs current -T2 researcher research: Postgres performance vs current -T3 analyst synthesize migration recommendation parents: T1, T2 -T4 writer draft decision memo parents: T3 -``` - -Show this to the user. Let them correct it before you create anything. +Show the graph to the user before creating cards. Let them correct it — including which actual profile name should own each lane. ### Step 3 — Create tasks and link +Use the profile names from Step 0. The example below uses placeholders `<profile-A>`, `<profile-B>`, `<profile-C>` — replace them with what the user actually has. + ```python t1 = kanban_create( title="research: Postgres cost vs current", - assignee="researcher", + assignee="<profile-A>", # whichever profile handles research on this setup body="Compare estimated infrastructure costs, migration costs, and ongoing ops costs over a 3-year window. Sources: AWS/GCP pricing, team time estimates, current Postgres bills from peers.", tenant=os.environ.get("HERMES_TENANT"), )["task_id"] t2 = kanban_create( title="research: Postgres performance vs current", - assignee="researcher", + assignee="<profile-A>", # same profile, run in parallel body="Compare query latency, throughput, and scaling characteristics at our expected data volume (~500GB, 10k QPS peak). Sources: benchmark papers, public case studies, pgbench results if easy.", )["task_id"] t3 = kanban_create( title="synthesize migration recommendation", - assignee="analyst", + assignee="<profile-B>", # whichever profile does synthesis/analysis body="Read the findings from T1 (cost) and T2 (performance). Produce a 1-page recommendation with explicit trade-offs and a go/no-go call.", parents=[t1, t2], )["task_id"] t4 = kanban_create( title="draft decision memo", - assignee="writer", + assignee="<profile-C>", # whichever profile drafts user-facing prose body="Turn the analyst's recommendation into a 2-page memo for the CTO. Match the tone of previous decision memos in the team's knowledge base.", parents=[t3], )["task_id"] @@ -123,17 +117,17 @@ t4 = kanban_create( ### Step 4 — Complete your own task -If you were spawned as a task yourself (e.g. `planner` profile was assigned `T0: "investigate Postgres migration"`), mark it done with a summary of what you created: +If you were spawned as a task yourself (e.g. a planner profile was assigned `T0: "investigate Postgres migration"`), mark it done with a summary of what you created: ```python kanban_complete( - summary="decomposed into T1-T4: 2 researchers parallel, 1 analyst on their outputs, 1 writer on the recommendation", + summary="decomposed into T1-T4: 2 research lanes in parallel, 1 synthesis on their outputs, 1 prose draft on the recommendation", metadata={ "task_graph": { - "T1": {"assignee": "researcher", "parents": []}, - "T2": {"assignee": "researcher", "parents": []}, - "T3": {"assignee": "analyst", "parents": ["T1", "T2"]}, - "T4": {"assignee": "writer", "parents": ["T3"]}, + "T1": {"assignee": "<profile-A>", "parents": []}, + "T2": {"assignee": "<profile-A>", "parents": []}, + "T3": {"assignee": "<profile-B>", "parents": ["T1", "T2"]}, + "T4": {"assignee": "<profile-C>", "parents": ["T3"]}, }, }, ) @@ -141,30 +135,32 @@ kanban_complete( ### Step 5 — Report back to the user -Tell them what you created in plain prose: +Tell them what you created in plain prose, naming the actual profiles you used: > I've queued 4 tasks: -> - **T1** (researcher): cost comparison -> - **T2** (researcher): performance comparison, in parallel with T1 -> - **T3** (analyst): synthesizes T1 + T2 into a recommendation -> - **T4** (writer): turns T3 into a CTO memo +> - **T1** (`<profile-A>`): cost comparison +> - **T2** (`<profile-A>`): performance comparison, in parallel with T1 +> - **T3** (`<profile-B>`): synthesizes T1 + T2 into a recommendation +> - **T4** (`<profile-C>`): turns T3 into a CTO memo > > The dispatcher will pick up T1 and T2 now. T3 starts when both finish. You'll get a gateway ping when T4 completes. Use the dashboard or `hermes kanban tail <id>` to follow along. ## Common patterns -**Fan-out + fan-in (research → synthesize):** N `researcher` tasks with no parents, one `analyst` task with all of them as parents. +**Fan-out + fan-in (research → synthesize):** N research-style cards with no parents, one synthesis card with all of them as parents. **Parallel implementation + validation:** one implementer card makes the change while one explorer/researcher card verifies config, docs, or source mapping. A reviewer card can depend on both. Do not make the implementer own unrelated verification just because the user mentioned both in one sentence. -**Pipeline with gates:** `pm → backend-eng → reviewer`. Each stage's `parents=[previous_task]`. Reviewer blocks or completes; if reviewer blocks, the operator unblocks with feedback and respawns. +**Pipeline with gates:** `planner → implementer → reviewer`. Each stage's `parents=[previous_task]`. Reviewer blocks or completes; if reviewer blocks, the operator unblocks with feedback and respawns. -**Same-profile queue:** 50 tasks, all assigned to `translator`, no dependencies between them. Dispatcher serializes — translator processes them in priority order, accumulating experience in their own memory. +**Same-profile queue:** N tasks, all assigned to the same profile, no dependencies between them. Dispatcher serializes — that profile processes them in priority order, accumulating experience in its own memory. **Human-in-the-loop:** Any task can `kanban_block()` to wait for input. Dispatcher respawns after `/unblock`. The comment thread carries the full context. ## Pitfalls +**Inventing profile names that don't exist.** The dispatcher silently fails to spawn unknown assignees — the card just sits in `ready` forever. Always assign to a profile from your Step 0 discovery; ask the user if you're unsure. + **Bundling independent lanes into one card.** If the user asks for two independent outcomes, create two cards. Example: "fix blockers and check model variants" is not one fixer task; create a fixer/engineer card for the fixes and an explorer/researcher card for the variant check, then optionally gate review on both. **Over-linking because of wording.** "Finally check X" may still be parallel with implementation if X is static config, docs, or source discovery. Link it after implementation only when the check depends on the implementation result. @@ -184,7 +180,7 @@ Tell them what you created in plain prose: When a worker profile keeps crashing, hallucinating, or getting blocked by its own mistakes (usually: wrong model, missing skill, broken credential), the kanban dashboard flags the task with a ⚠ badge and opens a **Recovery** section in the drawer. Three primary actions: 1. **Reclaim** (or `hermes kanban reclaim <task_id>`) — abort the running worker immediately and reset the task to `ready`. The existing claim TTL is ~15 min; this is the fast path out. -2. **Reassign** (or `hermes kanban reassign <task_id> <new-profile> --reclaim`) — switch the task to a different profile and let the dispatcher pick it up with a fresh worker. +2. **Reassign** (or `hermes kanban reassign <task_id> <new-profile> --reclaim`) — switch the task to a different profile (one that exists on this setup) and let the dispatcher pick it up with a fresh worker. 3. **Change profile model** — the dashboard prints a copy-paste hint for `hermes -p <profile> model` since profile config lives on disk; edit it in a terminal, then Reclaim to retry with the new model. Hallucination warnings appear on tasks where a worker's `kanban_complete(created_cards=[...])` claim included card ids that don't exist or weren't created by the worker's profile (the gate blocks the completion), or where the free-form summary references `t_<hex>` ids that don't resolve (advisory prose scan, non-blocking). Both produce audit events that persist even after recovery actions — the trail stays for debugging. diff --git a/skills/devops/kanban-worker/SKILL.md b/skills/devops/kanban-worker/SKILL.md index cfbbecdcec5..b24e90610f4 100644 --- a/skills/devops/kanban-worker/SKILL.md +++ b/skills/devops/kanban-worker/SKILL.md @@ -47,6 +47,29 @@ kanban_complete( ) ``` +**Coding task that needs human review (review-required):** + +For most code-changing tasks, the work isn't truly *done* until a human reviewer has eyes on it. Block instead of complete, with `reason` prefixed `review-required: ` so the dashboard surfaces the row as needing review. Drop the structured metadata (changed files, test counts, diff/PR url) into a comment first, since `kanban_block` only carries the human-readable reason — comments are the durable annotation channel. Reviewer either approves and runs `hermes kanban unblock <id>` (which re-spawns you with the comment thread for any follow-ups) or asks for changes via another comment. + +```python +import json + +kanban_comment( + body="review-required handoff:\n" + json.dumps({ + "changed_files": ["rate_limiter.py", "tests/test_rate_limiter.py"], + "tests_run": 14, + "tests_passed": 14, + "diff_path": "/path/to/worktree", # or PR url if pushed + "decisions": ["user_id primary, IP fallback for unauthenticated requests"], + }, indent=2), +) +kanban_block( + reason="review-required: rate limiter shipped, 14/14 tests pass — needs eyes on the user_id/IP fallback choice before merging", +) +``` + +Use `kanban_complete` only when the task is genuinely terminal — e.g. a one-line typo fix, a docs change with no functional consequences, or a research task where the artifact IS the writeup itself. + **Research task:** ```python kanban_complete( diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 5f49f74a2be..cdac34d3282 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -2123,6 +2123,227 @@ class TestCodexAuxiliaryAdapterTimeout: assert time.monotonic() - started < 0.14 +# --------------------------------------------------------------------------- +# Issue #23432 — auxiliary timeout poisons cached client; later aux calls fail +# --------------------------------------------------------------------------- + +class TestAuxiliaryClientPoisonedCacheEviction: + """Connection/timeout errors must evict the cached aux client. + + Otherwise the next auxiliary call (compression retry, memory flush, + background review) reuses the closed httpx transport and fails with + ``Connection error`` even though the main provider route is healthy. + See https://github.com/NousResearch/hermes-agent/issues/23432. + """ + + def test_evict_cached_client_instance_drops_direct_match(self): + from agent.auxiliary_client import ( + _client_cache, _client_cache_lock, _evict_cached_client_instance, + ) + + target = MagicMock(name="target_client") + other = MagicMock(name="other_client") + with _client_cache_lock: + _client_cache.clear() + _client_cache[("openrouter", False, None, None, None)] = (target, "x", None) + _client_cache[("anthropic", False, None, None, None)] = (other, "y", None) + try: + assert _evict_cached_client_instance(target) is True + assert ("openrouter", False, None, None, None) not in _client_cache + assert ("anthropic", False, None, None, None) in _client_cache + finally: + with _client_cache_lock: + _client_cache.clear() + + def test_evict_cached_client_instance_walks_codex_wrapper(self): + """Closing the underlying OpenAI client must evict the Codex shim.""" + from agent.auxiliary_client import ( + _client_cache, _client_cache_lock, _evict_cached_client_instance, + CodexAuxiliaryClient, + ) + + real = SimpleNamespace(api_key="k", base_url="https://chatgpt.com/backend-api/codex", + responses=SimpleNamespace(stream=lambda **k: None), + close=lambda: None) + wrapper = CodexAuxiliaryClient(real, "gpt-5.5") + with _client_cache_lock: + _client_cache.clear() + _client_cache[("openai-codex", False, None, None, None)] = (wrapper, "gpt-5.5", None) + try: + # Eviction by the inner OpenAI client must remove the wrapper entry. + assert _evict_cached_client_instance(real) is True + assert ("openai-codex", False, None, None, None) not in _client_cache + finally: + with _client_cache_lock: + _client_cache.clear() + + def test_evict_cached_client_instance_handles_none_and_misses(self): + from agent.auxiliary_client import _evict_cached_client_instance + + assert _evict_cached_client_instance(None) is False + assert _evict_cached_client_instance(MagicMock()) is False + + def test_evict_cached_client_instance_walks_async_wrapper(self): + """async_mode is part of the cache key so sync and async share the same + underlying OpenAI client across two distinct cache entries. A single + timeout that closes the leaf must evict BOTH — otherwise the async + entry survives, keeps reusing the dead transport, and every async + aux call (compression, vision, session_search) fails fast with + 'Connection error' until gateway restart even while the sync route + recovers. + + Regression for the async-side gap left by #23482, which fixed the + sync wrapper's _real_client walk but missed the async wrappers. + """ + from agent.auxiliary_client import ( + _client_cache, _client_cache_lock, _evict_cached_client_instance, + CodexAuxiliaryClient, AsyncCodexAuxiliaryClient, + ) + + real = SimpleNamespace(api_key="k", base_url="https://chatgpt.com/backend-api/codex", + responses=SimpleNamespace(stream=lambda **k: None), + close=lambda: None) + sync_wrapper = CodexAuxiliaryClient(real, "gpt-5.5") + async_wrapper = AsyncCodexAuxiliaryClient(sync_wrapper) + with _client_cache_lock: + _client_cache.clear() + _client_cache[("openai-codex", False, None, None, None)] = (sync_wrapper, "gpt-5.5", None) + _client_cache[("openai-codex", True, None, None, None)] = (async_wrapper, "gpt-5.5", None) + try: + assert _evict_cached_client_instance(real) is True + assert ("openai-codex", False, None, None, None) not in _client_cache + assert ("openai-codex", True, None, None, None) not in _client_cache, ( + "async cache entry survived eviction — wrapper is missing _real_client" + ) + finally: + with _client_cache_lock: + _client_cache.clear() + + def test_codex_timeout_evicts_cached_wrapper(self): + """The timeout closer evicts the cache entry that wraps the closed client.""" + from agent.auxiliary_client import ( + _client_cache, _client_cache_lock, + _CodexCompletionsAdapter, CodexAuxiliaryClient, + ) + + class SlowAliveStream: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def __iter__(self): + for _ in range(20): + time.sleep(0.01) + yield SimpleNamespace(type="response.in_progress") + + def get_final_response(self): # pragma: no cover — timeout fires first + return SimpleNamespace(output=[], usage=None) + + closed = {"flag": False} + + class FakeClient: + def __init__(self): + self.responses = SimpleNamespace(stream=lambda **k: SlowAliveStream()) + self.api_key = "k" + self.base_url = "https://chatgpt.com/backend-api/codex" + + def close(self): + closed["flag"] = True + + fake_real = FakeClient() + wrapper = CodexAuxiliaryClient(fake_real, "gpt-5.5") + cache_key = ("openai-codex", False, None, None, None) + with _client_cache_lock: + _client_cache.clear() + _client_cache[cache_key] = (wrapper, "gpt-5.5", None) + try: + adapter = _CodexCompletionsAdapter(fake_real, "gpt-5.5") + with pytest.raises(TimeoutError): + adapter.create( + messages=[{"role": "user", "content": "x"}], + timeout=0.05, + ) + assert closed["flag"] is True, "timeout closer must close inner client" + assert cache_key not in _client_cache, ( + "timeout closer must evict cache entry that wraps the closed client" + ) + finally: + with _client_cache_lock: + _client_cache.clear() + + def test_call_llm_evicts_on_connection_error_with_explicit_provider(self): + """Connection error on an explicit provider must drop the cached client. + + This is the exact reporter scenario: ``auxiliary.compression.provider: + main`` (resolves to ``openai-codex``) → no fallback chain runs (not + auto), but the cached client was poisoned by a prior timeout and must + be evicted so the next call rebuilds. + """ + from agent.auxiliary_client import _client_cache, _client_cache_lock + + poisoned = MagicMock(name="poisoned_client") + poisoned.base_url = "https://chatgpt.com/backend-api/codex" + poisoned.chat.completions.create.side_effect = ConnectionError("transport closed") + + cache_key = ("openai-codex", False, None, None, None) + with _client_cache_lock: + _client_cache.clear() + _client_cache[cache_key] = (poisoned, "gpt-5.5", None) + + try: + with patch( + "agent.auxiliary_client._resolve_task_provider_model", + return_value=("openai-codex", "gpt-5.5", None, None, None), + ), patch( + "agent.auxiliary_client._get_cached_client", + return_value=(poisoned, "gpt-5.5"), + ): + with pytest.raises(ConnectionError): + call_llm( + task="compression", + messages=[{"role": "user", "content": "x"}], + ) + assert cache_key not in _client_cache, ( + "connection error must evict cached client so the next call rebuilds" + ) + finally: + with _client_cache_lock: + _client_cache.clear() + + @pytest.mark.asyncio + async def test_async_call_llm_evicts_on_connection_error_with_explicit_provider(self): + from agent.auxiliary_client import _client_cache, _client_cache_lock + + poisoned = MagicMock(name="poisoned_async_client") + poisoned.base_url = "https://chatgpt.com/backend-api/codex" + poisoned.chat.completions.create = AsyncMock(side_effect=ConnectionError("transport closed")) + + cache_key = ("openai-codex", True, None, None, None) + with _client_cache_lock: + _client_cache.clear() + _client_cache[cache_key] = (poisoned, "gpt-5.5", None) + + try: + with patch( + "agent.auxiliary_client._resolve_task_provider_model", + return_value=("openai-codex", "gpt-5.5", None, None, None), + ), patch( + "agent.auxiliary_client._get_cached_client", + return_value=(poisoned, "gpt-5.5"), + ): + with pytest.raises(ConnectionError): + await async_call_llm( + task="compression", + messages=[{"role": "user", "content": "x"}], + ) + assert cache_key not in _client_cache + finally: + with _client_cache_lock: + _client_cache.clear() + + # --------------------------------------------------------------------------- # _build_call_kwargs — tool dedup at API boundary # --------------------------------------------------------------------------- @@ -2311,3 +2532,165 @@ class TestAnthropicExplicitApiKey: assert mock_build.call_args.args[0] == "explicit-fallback-key", ( "resolve_provider_client must forward explicit_api_key to _try_anthropic()" ) + + +# ── Auxiliary unhealthy-provider TTL cache (issue #23570) ──────────────── + + +class TestAuxUnhealthyCache: + """Recently-402'd providers are skipped on subsequent aux calls. + + Without this, every compression / title-gen / session-search call on a + long session retries a depleted OpenRouter (~1 RTT to 402) before + falling back to the next provider. The TTL cache hides the unhealthy + provider for ``_AUX_UNHEALTHY_TTL_SECONDS`` so the chain skips it. + """ + + def setup_method(self): + from agent.auxiliary_client import _reset_aux_unhealthy_cache + _reset_aux_unhealthy_cache() + + def teardown_method(self): + from agent.auxiliary_client import _reset_aux_unhealthy_cache + _reset_aux_unhealthy_cache() + + def test_mark_then_skip(self): + from agent.auxiliary_client import ( + _mark_provider_unhealthy, + _is_provider_unhealthy, + ) + assert _is_provider_unhealthy("openrouter") is False + _mark_provider_unhealthy("openrouter") + assert _is_provider_unhealthy("openrouter") is True + + def test_ttl_expiry_evicts(self): + from agent.auxiliary_client import ( + _mark_provider_unhealthy, + _is_provider_unhealthy, + _aux_unhealthy_until, + ) + _mark_provider_unhealthy("openrouter", ttl=0.01) + assert _is_provider_unhealthy("openrouter") is True + import time + time.sleep(0.02) + # Lazy eviction: first lookup after expiry returns False AND removes the entry. + assert _is_provider_unhealthy("openrouter") is False + assert "openrouter" not in _aux_unhealthy_until + + def test_alias_normalization(self): + """'codex' should normalize to 'openai-codex' so the cache lookup + matches the chain label.""" + from agent.auxiliary_client import ( + _mark_provider_unhealthy, + _is_provider_unhealthy, + ) + _mark_provider_unhealthy("codex") + assert _is_provider_unhealthy("openai-codex") is True + + def test_resolve_auto_skips_unhealthy_step2(self): + """_resolve_auto Step-2 chain skips unhealthy providers.""" + from agent.auxiliary_client import ( + _resolve_auto, + _mark_provider_unhealthy, + ) + nous_client = MagicMock() + # Mark OpenRouter unhealthy → chain should skip it and pick nous. + _mark_provider_unhealthy("openrouter") + with patch("agent.auxiliary_client._read_main_provider", return_value=""), \ + patch("agent.auxiliary_client._read_main_model", return_value=""), \ + patch("agent.auxiliary_client._try_openrouter") as or_try, \ + patch("agent.auxiliary_client._try_nous", return_value=(nous_client, "nous-model")), \ + patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \ + patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)): + client, model = _resolve_auto() + assert client is nous_client + assert model == "nous-model" + # The skipped provider's _try_* should NOT have been called at all. + or_try.assert_not_called() + + def test_resolve_auto_skips_unhealthy_main_in_step1(self): + """Step-1 also consults the unhealthy cache so a depleted main + provider doesn't burn a 402 RTT every aux call. Falls through to + Step-2 chain (which also respects the cache).""" + from agent.auxiliary_client import ( + _resolve_auto, + _mark_provider_unhealthy, + ) + nous_client = MagicMock() + _mark_provider_unhealthy("openrouter") + with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ + patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4.6"), \ + patch("agent.auxiliary_client.resolve_provider_client") as step1, \ + patch("agent.auxiliary_client._try_openrouter") as or_try, \ + patch("agent.auxiliary_client._try_nous", return_value=(nous_client, "n-model")), \ + patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \ + patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)): + client, model = _resolve_auto() + # Step-1 was bypassed — resolve_provider_client never invoked + step1.assert_not_called() + # Step-2 also skipped openrouter and landed on nous + or_try.assert_not_called() + assert client is nous_client + + def test_payment_fallback_skips_unhealthy(self): + """_try_payment_fallback also consults the unhealthy cache so a 402 + on OpenRouter doesn't cause a second OR call within the same chain + iteration if it gets re-entered.""" + from agent.auxiliary_client import ( + _try_payment_fallback, + _mark_provider_unhealthy, + ) + nous_client = MagicMock() + # Mark BOTH the failed provider (openrouter) and a sibling (custom) + # unhealthy. The chain should still find nous. + _mark_provider_unhealthy("local/custom") + with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ + patch("agent.auxiliary_client._try_openrouter") as or_try, \ + patch("agent.auxiliary_client._try_nous", return_value=(nous_client, "n-model")), \ + patch("agent.auxiliary_client._try_custom_endpoint") as custom_try, \ + patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)): + client, model, label = _try_payment_fallback("openrouter", task="compression") + assert client is nous_client + assert label == "nous" + # OR is skipped via skip_chain_labels (failed provider), custom via unhealthy cache. + or_try.assert_not_called() + custom_try.assert_not_called() + + def test_call_llm_marks_provider_unhealthy_on_402(self, monkeypatch): + """A 402 from call_llm causes the provider to be marked unhealthy + so the next call skips it instead of re-trying the same depleted + endpoint.""" + from agent.auxiliary_client import ( + call_llm, + _is_provider_unhealthy, + ) + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + primary_client = MagicMock() + # base_url tells _recoverable_pool_provider() that this is OpenRouter + # (resolved_provider="auto" doesn't carry that information by itself). + primary_client.base_url = "https://openrouter.ai/api/v1/" + err = Exception("Payment Required: insufficient credits") + err.status_code = 402 + primary_client.chat.completions.create.side_effect = err + + nous_client = MagicMock() + nous_resp = MagicMock() + nous_resp.choices = [MagicMock(message=MagicMock(content="ok"))] + nous_client.chat.completions.create.return_value = nous_resp + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "google/gemini-3-flash-preview")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("auto", "google/gemini-3-flash-preview", None, None, None)), \ + patch("agent.auxiliary_client._try_payment_fallback", + return_value=(nous_client, "n-model", "nous")), \ + patch("agent.auxiliary_client._build_call_kwargs", + return_value={"model": "n-model", "messages": [{"role": "user", "content": "hi"}]}): + assert _is_provider_unhealthy("openrouter") is False + call_llm( + task="compression", + messages=[{"role": "user", "content": "hi"}], + ) + # After the 402, OpenRouter is in the unhealthy cache. + assert _is_provider_unhealthy("openrouter") is True diff --git a/tests/agent/test_curator_classification.py b/tests/agent/test_curator_classification.py index 29187c5a641..804e5a65ecc 100644 --- a/tests/agent/test_curator_classification.py +++ b/tests/agent/test_curator_classification.py @@ -1020,3 +1020,106 @@ def test_rename_summary_mixed_consolidation_and_pruning(curator_env): assert merge_idx < drop_idx, "consolidated should render before pruned" assert "merge-me → umbrella" in lines[merge_idx] assert "drop-me — pruned (stale)" in lines[drop_idx] + + +# --------------------------------------------------------------------------- +# Pin hint — surfaces `hermes curator pin <umbrella>` in the rename block so +# users learn the command exists at the moment they care (a consolidation +# just landed against their library). The hint is gated on having at least +# one umbrella destination — pruned-only runs skip it. +# --------------------------------------------------------------------------- + + +def test_rename_summary_pin_hint_appears_when_consolidation_produced_umbrella(curator_env): + """When at least one skill was absorbed into an umbrella, hint at pinning it.""" + result = curator_env._build_rename_summary( + before_names={"pdf-extraction", "docx-extraction", "document-tools"}, + after_report=[{"name": "document-tools", "state": "active"}], + tool_calls=[ + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "pdf-extraction", + "absorbed_into": "document-tools", + }), + }, + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "docx-extraction", + "absorbed_into": "document-tools", + }), + }, + ], + model_final="", + ) + assert "hermes curator pin document-tools" in result + assert "keep an umbrella stable" in result + + +def test_rename_summary_pin_hint_skipped_for_pruned_only_runs(curator_env): + """Pruned-only runs have nothing surviving to pin — hint should not appear.""" + result = curator_env._build_rename_summary( + before_names={"old-flaky-thing", "another-stale", "keeper"}, + after_report=[{"name": "keeper", "state": "active"}], + tool_calls=[ + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "old-flaky-thing", + "absorbed_into": "", + }), + }, + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "another-stale", + "absorbed_into": "", + }), + }, + ], + model_final="", + ) + # Block still renders (skills were archived) but no pin hint. + assert "archived 2 skill(s):" in result + assert "hermes curator pin" not in result + assert "keep an umbrella stable" not in result + + +def test_rename_summary_pin_hint_picks_one_umbrella_when_multiple_absorbed(curator_env): + """Multiple umbrellas → hint shows one example (alphabetically first), not a list.""" + result = curator_env._build_rename_summary( + before_names={"a-skill", "b-skill", "umbrella-zeta", "umbrella-alpha"}, + after_report=[ + {"name": "umbrella-zeta", "state": "active"}, + {"name": "umbrella-alpha", "state": "active"}, + ], + tool_calls=[ + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "a-skill", + "absorbed_into": "umbrella-zeta", + }), + }, + { + "name": "skill_manage", + "arguments": json.dumps({ + "action": "delete", + "name": "b-skill", + "absorbed_into": "umbrella-alpha", + }), + }, + ], + model_final="", + ) + # Sorted picks alphabetically first. + assert "hermes curator pin umbrella-alpha" in result + # Exactly one hint line, not one per umbrella. + pin_lines = [ln for ln in result.splitlines() if "hermes curator pin" in ln] + assert len(pin_lines) == 1 diff --git a/tests/agent/test_i18n.py b/tests/agent/test_i18n.py index f59d3fb430d..6c374ebf487 100644 --- a/tests/agent/test_i18n.py +++ b/tests/agent/test_i18n.py @@ -156,7 +156,12 @@ def test_t_missing_key_in_non_english_falls_back_to_english(tmp_path, monkeypatc (fake_locales / "zh.yaml").write_text("# intentionally empty\n", encoding="utf-8") monkeypatch.setattr(i18n, "_locales_dir", lambda: fake_locales) i18n.reset_language_cache() - assert i18n.t("foo", lang="zh") == "English Foo" + try: + assert i18n.t("foo", lang="zh") == "English Foo" + finally: + # Clear the cache on teardown so subsequent tests don't see the + # fake "foo: English Foo" catalog instead of the real locales/*.yaml. + i18n.reset_language_cache() def test_t_unknown_language_uses_english(): diff --git a/tests/agent/test_markdown_tables.py b/tests/agent/test_markdown_tables.py new file mode 100644 index 00000000000..3c97a4c6fc1 --- /dev/null +++ b/tests/agent/test_markdown_tables.py @@ -0,0 +1,210 @@ +"""Tests for `agent.markdown_tables.realign_markdown_tables`. + +These cover the alignment guarantee on CJK / wide-character tables and +the conservative no-op behaviour on non-table input. +""" + +from __future__ import annotations + +from textwrap import dedent + +from wcwidth import wcswidth + +from agent.markdown_tables import ( + is_table_divider, + looks_like_table_row, + realign_markdown_tables, + split_table_row, +) + + +def _column_offsets(line: str) -> list[int]: + """Return the display-cell index of every ``|`` in ``line``.""" + + cells: list[int] = [] + width = 0 + for ch in line: + if ch == "|": + cells.append(width) + # wcswidth on a single char; clamp negatives. + w = wcswidth(ch) + width += w if w > 0 else 1 + return cells + + +# --------------------------------------------------------------------------- +# split_table_row / is_table_divider / looks_like_table_row +# --------------------------------------------------------------------------- + + +def test_split_strips_outer_pipes_and_trims(): + assert split_table_row("| a | b | c |") == ["a", "b", "c"] + assert split_table_row("|配置|状态|") == ["配置", "状态"] + assert split_table_row("a | b | c") == ["a", "b", "c"] + + +def test_is_table_divider_handles_alignment_colons(): + assert is_table_divider("|---|---|") + assert is_table_divider("| :--- | ---: | :---: |") + assert not is_table_divider("| - | - |") # 1 dash is not a divider + assert not is_table_divider("| a | b |") + assert not is_table_divider("---") # single column, no pipes + + +def test_looks_like_table_row(): + assert looks_like_table_row("| a | b |") + assert looks_like_table_row("a | b | c") # no leading pipe, ≥2 pipes + assert not looks_like_table_row("not a table") + assert not looks_like_table_row("a | b") # one pipe, no leading pipe + assert not looks_like_table_row("") + + +# --------------------------------------------------------------------------- +# realign_markdown_tables +# --------------------------------------------------------------------------- + + +def test_no_op_on_text_without_tables(): + text = "Hello world\nThis has no | pipes table.\n" + assert realign_markdown_tables(text) == text + + +def test_no_op_when_pipes_but_no_divider(): + text = "echo a | grep b\necho c | wc -l\n" + assert realign_markdown_tables(text) == text + + +def test_cjk_table_pipes_align_across_rows(): + # Model-emitted (under-padded for CJK) input. + src = dedent( + """\ + | 配置 | Config | 论文 (%) | 复现 (%) | 差值 | 状态 | + |------|--------|---------|---------|------|------| + | Vicuna (report) | dense | 79.30 | 未完成 | - | × | + | ChatGLM | chat | 37.60 | 37.82 | +0.22 | ✓ | + | 通义千问 | qwen | (无) | 报错 | - | × | + """ + ) + + out = realign_markdown_tables(src).rstrip("\n").split("\n") + + # All rows in the rebuilt block must have pipes at identical display + # columns — that's the alignment guarantee. + offsets = [_column_offsets(row) for row in out] + assert all(o == offsets[0] for o in offsets), ( + "rebuilt table rows do not share pipe column offsets:\n" + + "\n".join(out) + ) + # And we expect 7 pipes per row (6 columns + outer borders). + assert len(offsets[0]) == 7 + + +def test_emoji_with_cjk_table_aligns(): + src = dedent( + """\ + | 模型 | 状态 | 备注 | + |------|------|------| + | 千问 | ✅ | 通过 | + | Claude | ✅ | 推理强 | + | 文心一言 | ❌ | 报错 | + """ + ) + + out = realign_markdown_tables(src).rstrip("\n").split("\n") + offsets = [_column_offsets(row) for row in out] + # The emoji-with-variation-selector case (⚠️) intentionally tolerates + # 1-cell drift; bare emoji like ✅ / ❌ have stable wcwidth and must + # align. Use bare emoji here so the assertion is hard. + assert all(o == offsets[0] for o in offsets), ( + "emoji+CJK rows do not share pipe column offsets:\n" + "\n".join(out) + ) + + +def test_already_aligned_ascii_table_remains_aligned(): + src = dedent( + """\ + | a | b | + |-----|-----| + | 1 | 2 | + | foo | bar | + """ + ) + out = realign_markdown_tables(src).rstrip("\n").split("\n") + offsets = [_column_offsets(row) for row in out] + assert all(o == offsets[0] for o in offsets) + + +def test_passes_non_table_lines_through_around_a_table(): + src = dedent( + """\ + Here is a comparison: + + | 模型 | 状态 | + |------|------| + | 千问 | 通过 | + + And some prose after. + """ + ) + + out = realign_markdown_tables(src) + assert out.startswith("Here is a comparison:\n") + assert out.endswith("And some prose after.\n") + # And the table lines are aligned. + block = [ln for ln in out.split("\n") if "|" in ln] + offsets = [_column_offsets(row) for row in block] + assert all(o == offsets[0] for o in offsets) + + +def test_handles_ragged_rows_by_padding_short_rows(): + src = dedent( + """\ + | a | b | c | + |---|---|---| + | 1 | 2 | + | x | y | z | + """ + ) + out = realign_markdown_tables(src).rstrip("\n").split("\n") + offsets = [_column_offsets(row) for row in out] + # Short rows must be padded out so they have the same pipe count + # and column positions as the header. + assert all(len(o) == len(offsets[0]) for o in offsets) + assert all(o == offsets[0] for o in offsets) + + +def test_multiple_tables_in_one_text(): + src = dedent( + """\ + First: + + | 配置 | 值 | + |------|----| + | 通义 | 1 | + + Second: + + | model | n | + |-------|---| + | gpt | 2 | + """ + ) + out = realign_markdown_tables(src) + # Each table block individually aligns. + blocks: list[list[str]] = [] + current: list[str] = [] + for line in out.split("\n"): + if "|" in line: + current.append(line) + elif current: + blocks.append(current) + current = [] + if current: + blocks.append(current) + + assert len(blocks) == 2 + for block in blocks: + offsets = [_column_offsets(row) for row in block] + assert all(o == offsets[0] for o in offsets), ( + f"block did not align:\n" + "\n".join(block) + ) diff --git a/tests/agent/test_plugin_llm.py b/tests/agent/test_plugin_llm.py new file mode 100644 index 00000000000..b31f8097a7e --- /dev/null +++ b/tests/agent/test_plugin_llm.py @@ -0,0 +1,991 @@ +"""Unit tests for the plugin LLM facade (``agent.plugin_llm``). + +These tests exercise the trust gate, JSON parsing, schema validation, +image input encoding, and the auxiliary-client invocation contract. +The auxiliary client itself is stubbed via ``make_plugin_llm_for_test`` +so we don't hit real providers. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from agent.plugin_llm import ( + PluginLlm, + PluginLlmCompleteResult, + PluginLlmImageInput, + PluginLlmStructuredResult, + PluginLlmTextInput, + PluginLlmTrustError, + _build_structured_messages, + _check_overrides, + _coerce_allowlist, + _parse_structured_text, + _strip_code_fences, + _TrustPolicy, + make_plugin_llm_for_test, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _fake_response(text: str, *, prompt: int = 4, completion: int = 6) -> SimpleNamespace: + """Build an OpenAI-shaped response with the given text + token usage.""" + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content=text, role="assistant"), + finish_reason="stop", + ) + ], + usage=SimpleNamespace( + prompt_tokens=prompt, + completion_tokens=completion, + total_tokens=prompt + completion, + ), + ) + + +def _trusted_policy(plugin_id: str = "trusted-plugin", **overrides: Any) -> _TrustPolicy: + defaults = dict( + allow_provider_override=True, + allowed_providers=None, + allow_any_provider=True, + allow_model_override=True, + allowed_models=None, + allow_any_model=True, + allow_agent_id_override=True, + allow_profile_override=True, + ) + defaults.update(overrides) + return _TrustPolicy(plugin_id=plugin_id, **defaults) + + +# --------------------------------------------------------------------------- +# Trust gate +# --------------------------------------------------------------------------- + + +class TestTrustGate: + def test_default_policy_blocks_provider_override(self): + policy = _TrustPolicy(plugin_id="locked") + with pytest.raises(PluginLlmTrustError, match="cannot override the provider"): + _check_overrides( + policy, + requested_provider="anthropic", + requested_model=None, + requested_agent_id=None, + requested_profile=None, + ) + + def test_default_policy_blocks_model_override(self): + policy = _TrustPolicy(plugin_id="locked") + with pytest.raises(PluginLlmTrustError, match="cannot override the model"): + _check_overrides( + policy, + requested_provider=None, + requested_model="claude-3-5-sonnet", + requested_agent_id=None, + requested_profile=None, + ) + + def test_default_policy_blocks_agent_override(self): + policy = _TrustPolicy(plugin_id="locked") + with pytest.raises(PluginLlmTrustError, match="non-default agent id"): + _check_overrides( + policy, + requested_provider=None, + requested_model=None, + requested_agent_id="ada", + requested_profile=None, + ) + + def test_default_policy_blocks_profile_override(self): + policy = _TrustPolicy(plugin_id="locked") + with pytest.raises(PluginLlmTrustError, match="cannot override the auth profile"): + _check_overrides( + policy, + requested_provider=None, + requested_model=None, + requested_agent_id=None, + requested_profile="work", + ) + + def test_overrides_independent(self): + """Each override is gated independently — turning on + ``allow_model_override`` does NOT also grant provider override.""" + policy = _TrustPolicy( + plugin_id="model-only", + allow_model_override=True, + allow_any_model=True, + ) + # model alone passes + _, m, _, _ = _check_overrides( + policy, + requested_provider=None, + requested_model="gpt-4o", + requested_agent_id=None, + requested_profile=None, + ) + assert m == "gpt-4o" + # provider alone is still denied + with pytest.raises(PluginLlmTrustError, match="cannot override the provider"): + _check_overrides( + policy, + requested_provider="anthropic", + requested_model=None, + requested_agent_id=None, + requested_profile=None, + ) + + def test_provider_allowlist_rejects_non_listed(self): + policy = _TrustPolicy( + plugin_id="restricted", + allow_provider_override=True, + allowed_providers=frozenset({"openrouter", "anthropic"}), + allow_any_provider=False, + ) + with pytest.raises(PluginLlmTrustError, match="not in plugins.entries"): + _check_overrides( + policy, + requested_provider="openai", + requested_model=None, + requested_agent_id=None, + requested_profile=None, + ) + + def test_provider_allowlist_accepts_listed_case_insensitively(self): + policy = _TrustPolicy( + plugin_id="restricted", + allow_provider_override=True, + allowed_providers=frozenset({"openrouter"}), + allow_any_provider=False, + ) + p, _, _, _ = _check_overrides( + policy, + requested_provider="OpenRouter", + requested_model=None, + requested_agent_id=None, + requested_profile=None, + ) + assert p == "OpenRouter" + + def test_model_allowlist_rejects_non_listed(self): + policy = _TrustPolicy( + plugin_id="restricted", + allow_model_override=True, + allowed_models=frozenset({"openai/gpt-4o-mini"}), + allow_any_model=False, + ) + with pytest.raises(PluginLlmTrustError, match="not in plugins.entries"): + _check_overrides( + policy, + requested_provider=None, + requested_model="anthropic/claude-3-opus", + requested_agent_id=None, + requested_profile=None, + ) + + def test_model_allowlist_accepts_listed_case_insensitively(self): + policy = _TrustPolicy( + plugin_id="restricted", + allow_model_override=True, + allowed_models=frozenset({"openai/gpt-4o-mini"}), + allow_any_model=False, + ) + _, m, _, _ = _check_overrides( + policy, + requested_provider=None, + requested_model="OpenAI/GPT-4o-mini", + requested_agent_id=None, + requested_profile=None, + ) + assert m == "OpenAI/GPT-4o-mini" + + def test_no_overrides_passes_through(self): + policy = _TrustPolicy(plugin_id="locked") + result = _check_overrides( + policy, + requested_provider=None, + requested_model=None, + requested_agent_id=None, + requested_profile=None, + ) + assert result == (None, None, None, None) + + def test_all_overrides_when_fully_trusted(self): + policy = _trusted_policy() + result = _check_overrides( + policy, + requested_provider="openrouter", + requested_model="anthropic/claude-3-5-sonnet", + requested_agent_id="ada", + requested_profile="work", + ) + assert result == ("openrouter", "anthropic/claude-3-5-sonnet", "ada", "work") + + +class TestAllowlistCoercion: + def test_missing_yields_none(self): + ranges, allow_any = _coerce_allowlist(None) + assert ranges is None + assert allow_any is False + + def test_list_of_strings(self): + ranges, allow_any = _coerce_allowlist(["A", "B"]) + assert ranges == frozenset({"a", "b"}) + assert allow_any is False + + def test_star_alone_means_any(self): + ranges, allow_any = _coerce_allowlist(["*"]) + assert ranges == frozenset() + assert allow_any is True + + def test_star_plus_specific_keeps_specifics(self): + ranges, allow_any = _coerce_allowlist(["*", "openrouter"]) + assert ranges == frozenset({"openrouter"}) + assert allow_any is True + + def test_non_list_yields_none(self): + ranges, allow_any = _coerce_allowlist("openrouter") + assert ranges is None + assert allow_any is False + + +# --------------------------------------------------------------------------- +# Structured message building +# --------------------------------------------------------------------------- + + +class TestStructuredMessageBuilding: + def test_text_only_input(self): + messages = _build_structured_messages( + instructions="Extract the action items", + inputs=[PluginLlmTextInput(text="meeting notes go here")], + json_mode=False, + json_schema=None, + schema_name=None, + system_prompt=None, + ) + assert len(messages) == 1 + assert messages[0]["role"] == "user" + parts = messages[0]["content"] + assert parts[0]["type"] == "text" + assert "Extract the action items" in parts[0]["text"] + assert parts[1] == {"type": "text", "text": "meeting notes go here"} + + def test_json_mode_adds_system_directive(self): + messages = _build_structured_messages( + instructions="Summarise", + inputs=[PluginLlmTextInput(text="content")], + json_mode=True, + json_schema=None, + schema_name=None, + system_prompt=None, + ) + assert messages[0]["role"] == "system" + assert "JSON object" in messages[0]["content"] + + def test_schema_name_appended_to_header(self): + messages = _build_structured_messages( + instructions="Extract fields", + inputs=[PluginLlmTextInput(text="data")], + json_mode=False, + json_schema=None, + schema_name="action.items", + system_prompt=None, + ) + header = messages[0]["content"][0]["text"] + assert "Schema name: action.items" in header + + def test_image_bytes_encoded_as_data_url(self): + png_bytes = b"\x89PNG\r\n\x1a\nfake" + messages = _build_structured_messages( + instructions="Read the image", + inputs=[ + PluginLlmImageInput(data=png_bytes, mime_type="image/png"), + PluginLlmTextInput(text="prefer printed text"), + ], + json_mode=False, + json_schema=None, + schema_name=None, + system_prompt=None, + ) + parts = messages[0]["content"] + assert parts[1]["type"] == "image_url" + url = parts[1]["image_url"]["url"] + assert url.startswith("data:image/png;base64,") + decoded = base64.b64decode(url.split(",", 1)[1]) + assert decoded == png_bytes + assert parts[2] == {"type": "text", "text": "prefer printed text"} + + def test_image_url_passed_through(self): + messages = _build_structured_messages( + instructions="Caption this", + inputs=[PluginLlmImageInput(url="https://example.com/cat.jpg")], + json_mode=False, + json_schema=None, + schema_name=None, + system_prompt=None, + ) + img_part = messages[0]["content"][1] + assert img_part["type"] == "image_url" + assert img_part["image_url"]["url"] == "https://example.com/cat.jpg" + + def test_dict_inputs_normalized(self): + messages = _build_structured_messages( + instructions="Test", + inputs=[ + {"type": "text", "text": "hello"}, + {"type": "image", "url": "https://x.example/y.png"}, + ], + json_mode=False, + json_schema=None, + schema_name=None, + system_prompt=None, + ) + parts = messages[0]["content"] + assert parts[1]["text"] == "hello" + assert parts[2]["image_url"]["url"] == "https://x.example/y.png" + + def test_invalid_input_block_rejected(self): + with pytest.raises(ValueError, match="Unknown input block"): + _build_structured_messages( + instructions="Test", + inputs=[{"type": "audio", "data": b""}], + json_mode=False, + json_schema=None, + schema_name=None, + system_prompt=None, + ) + + +# --------------------------------------------------------------------------- +# JSON parsing +# --------------------------------------------------------------------------- + + +class TestJsonParsing: + def test_strip_code_fences_with_json_label(self): + assert _strip_code_fences('```json\n{"a":1}\n```') == '{"a":1}' + + def test_strip_code_fences_without_label(self): + assert _strip_code_fences("```\nfoo\n```") == "foo" + + def test_strip_code_fences_no_fence(self): + assert _strip_code_fences('{"a":1}') == '{"a":1}' + + def test_parse_returns_text_when_not_json_mode(self): + parsed, ct = _parse_structured_text( + text='{"a": 1}', json_mode=False, json_schema=None + ) + assert parsed is None + assert ct == "text" + + def test_parse_valid_json_with_json_mode(self): + parsed, ct = _parse_structured_text( + text='{"language": "French", "is_question": true}', + json_mode=True, + json_schema=None, + ) + assert parsed == {"language": "French", "is_question": True} + assert ct == "json" + + def test_parse_strips_code_fences_before_loading(self): + parsed, ct = _parse_structured_text( + text='Here you go:\n```json\n{"ok": true}\n```', + json_mode=True, + json_schema=None, + ) + assert parsed == {"ok": True} + assert ct == "json" + + def test_parse_returns_text_on_invalid_json(self): + parsed, ct = _parse_structured_text( + text="not even close to json", + json_mode=True, + json_schema=None, + ) + assert parsed is None + assert ct == "text" + + def test_schema_validation_rejects_mismatch(self): + pytest.importorskip("jsonschema") + schema = { + "type": "object", + "properties": {"language": {"type": "string"}}, + "required": ["language"], + } + with pytest.raises(ValueError, match="did not match schema"): + _parse_structured_text( + text='{"is_question": true}', + json_mode=False, + json_schema=schema, + ) + + def test_schema_validation_accepts_match(self): + pytest.importorskip("jsonschema") + schema = { + "type": "object", + "properties": {"language": {"type": "string"}}, + "required": ["language"], + } + parsed, ct = _parse_structured_text( + text='{"language": "French"}', + json_mode=False, + json_schema=schema, + ) + assert parsed == {"language": "French"} + assert ct == "json" + + +# --------------------------------------------------------------------------- +# End-to-end facade +# --------------------------------------------------------------------------- + + +class TestPluginLlmFacade: + def test_complete_uses_active_model_by_default(self): + captured: dict = {} + + def fake_caller(**kwargs): + captured.update(kwargs) + return "auto", "default", _fake_response("Hello world.") + + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + sync_caller=fake_caller, + ) + result = llm.complete([{"role": "user", "content": "hi"}]) + assert isinstance(result, PluginLlmCompleteResult) + assert result.text == "Hello world." + assert captured["provider_override"] is None + assert captured["model_override"] is None + assert captured["profile_override"] is None + assert result.usage.input_tokens == 4 + assert result.usage.total_tokens == 10 + + def test_complete_rejects_provider_override_without_trust(self): + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + sync_caller=lambda **_: ("x", "y", _fake_response("")), + ) + with pytest.raises(PluginLlmTrustError, match="cannot override the provider"): + llm.complete( + [{"role": "user", "content": "hi"}], + provider="openrouter", + ) + + def test_complete_rejects_model_override_without_trust(self): + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + sync_caller=lambda **_: ("x", "y", _fake_response("")), + ) + with pytest.raises(PluginLlmTrustError, match="cannot override the model"): + llm.complete( + [{"role": "user", "content": "hi"}], + model="anthropic/claude-3-opus", + ) + + def test_complete_passes_through_trusted_overrides(self): + captured: dict = {} + + def fake_caller(**kwargs): + captured.update(kwargs) + return "anthropic", "claude-3-opus", _fake_response("ok") + + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_trusted_policy("my-plugin"), + sync_caller=fake_caller, + ) + result = llm.complete( + [{"role": "user", "content": "hi"}], + provider="anthropic", + model="claude-3-opus", + profile="work", + agent_id="ada", + temperature=0.0, + max_tokens=128, + timeout=10.0, + purpose="extract", + ) + # The recorded provider/model in the result come from the override, + # since the stub caller echoed those values. + assert result.provider == "anthropic" + assert result.model == "claude-3-opus" + assert captured["provider_override"] == "anthropic" + assert captured["model_override"] == "claude-3-opus" + assert captured["profile_override"] == "work" + assert captured["temperature"] == 0.0 + assert captured["max_tokens"] == 128 + assert captured["timeout"] == 10.0 + + def test_complete_structured_returns_parsed_json(self): + def fake_caller(**_kwargs): + return "openai", "gpt-4o", _fake_response( + '{"language": "French", "is_question": true, "confidence": 0.99}' + ) + + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + sync_caller=fake_caller, + ) + result = llm.complete_structured( + instructions="Detect language", + input=[PluginLlmTextInput(text="Comment ça va?")], + json_mode=True, + ) + assert isinstance(result, PluginLlmStructuredResult) + assert result.parsed == { + "language": "French", + "is_question": True, + "confidence": 0.99, + } + assert result.content_type == "json" + + def test_complete_structured_returns_text_on_unparseable_response(self): + def fake_caller(**_kwargs): + return "openai", "gpt-4o", _fake_response("Sorry, I can't help with that.") + + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + sync_caller=fake_caller, + ) + result = llm.complete_structured( + instructions="Detect language", + input=[PluginLlmTextInput(text="x")], + json_mode=True, + ) + assert result.parsed is None + assert result.content_type == "text" + assert result.text.startswith("Sorry") + + def test_complete_structured_validates_against_schema(self): + pytest.importorskip("jsonschema") + + def fake_caller(**_kwargs): + return "openai", "gpt-4o", _fake_response('{"unrelated": "field"}') + + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + sync_caller=fake_caller, + ) + schema = { + "type": "object", + "properties": {"language": {"type": "string"}}, + "required": ["language"], + } + with pytest.raises(ValueError, match="did not match schema"): + llm.complete_structured( + instructions="Detect language", + input=[PluginLlmTextInput(text="x")], + json_schema=schema, + ) + + def test_complete_structured_requires_instructions(self): + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + sync_caller=MagicMock(), + ) + with pytest.raises(ValueError, match="non-empty instructions"): + llm.complete_structured( + instructions=" ", + input=[PluginLlmTextInput(text="x")], + ) + + def test_complete_structured_requires_at_least_one_input(self): + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + sync_caller=MagicMock(), + ) + with pytest.raises(ValueError, match="at least one input"): + llm.complete_structured( + instructions="Extract", + input=[], + ) + + def test_complete_structured_emits_response_format_extra_body(self): + captured: dict = {} + + def fake_caller(**kwargs): + captured.update(kwargs) + return "openai", "gpt-4o", _fake_response('{"a": 1}') + + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + sync_caller=fake_caller, + ) + schema = {"type": "object"} + llm.complete_structured( + instructions="Test", + input=[PluginLlmTextInput(text="x")], + json_schema=schema, + ) + rf = captured["extra_body"]["response_format"] + assert rf["type"] == "json_schema" + assert rf["json_schema"]["schema"] == schema + + def test_complete_structured_with_image_passes_image_url_part(self): + captured: dict = {} + + def fake_caller(**kwargs): + captured.update(kwargs) + return "openai", "gpt-4o", _fake_response('{"caption": "ok"}') + + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + sync_caller=fake_caller, + ) + png = b"fake-bytes" + llm.complete_structured( + instructions="Caption this", + input=[PluginLlmImageInput(data=png, mime_type="image/png")], + json_mode=True, + ) + msgs = captured["messages"] + user_msg = next(m for m in msgs if m["role"] == "user") + image_parts = [p for p in user_msg["content"] if p.get("type") == "image_url"] + assert len(image_parts) == 1 + assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,") + + +# --------------------------------------------------------------------------- +# Async surface +# --------------------------------------------------------------------------- + + +class TestAsyncSurface: + def test_acomplete_uses_async_caller(self): + async def fake_async(**_kwargs): + return "openai", "gpt-4o", _fake_response("async hello") + + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + async_caller=fake_async, + ) + + async def _run() -> PluginLlmCompleteResult: + return await llm.acomplete([{"role": "user", "content": "hi"}]) + + result = asyncio.run(_run()) + assert result.text == "async hello" + assert result.provider == "openai" + + def test_acomplete_structured_parses_json(self): + async def fake_async(**_kwargs): + return "openai", "gpt-4o", _fake_response('{"x": 42}') + + llm = make_plugin_llm_for_test( + plugin_id="my-plugin", + policy=_TrustPolicy(plugin_id="my-plugin"), + async_caller=fake_async, + ) + + async def _run() -> PluginLlmStructuredResult: + return await llm.acomplete_structured( + instructions="Extract x", + input=[PluginLlmTextInput(text="data")], + json_mode=True, + ) + + result = asyncio.run(_run()) + assert result.parsed == {"x": 42} + assert result.content_type == "json" + + +# --------------------------------------------------------------------------- +# Config-driven trust gate (round-trip via plugins.entries.<id>.llm) +# --------------------------------------------------------------------------- + + +class TestConfigDrivenPolicy: + def test_policy_loaded_from_yaml(self, tmp_path, monkeypatch): + from agent.plugin_llm import _resolve_trust_policy + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + """ +plugins: + entries: + my-plugin: + llm: + allow_provider_override: true + allowed_providers: [openrouter, anthropic] + allow_model_override: true + allowed_models: + - openai/gpt-4o-mini + - anthropic/claude-3-5-haiku + allow_profile_override: false +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + from hermes_cli import config as _config_mod + _config_mod._config_cache = None # type: ignore[attr-defined] + + policy = _resolve_trust_policy("my-plugin") + assert policy.allow_provider_override is True + assert policy.allow_model_override is True + assert policy.allow_profile_override is False + assert policy.allowed_providers == frozenset({"openrouter", "anthropic"}) + assert policy.allowed_models == frozenset({ + "openai/gpt-4o-mini", "anthropic/claude-3-5-haiku", + }) + + def test_missing_plugin_entry_yields_default_deny(self, tmp_path, monkeypatch): + from agent.plugin_llm import _resolve_trust_policy + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text("plugins: {}\n", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + from hermes_cli import config as _config_mod + _config_mod._config_cache = None # type: ignore[attr-defined] + + policy = _resolve_trust_policy("never-configured") + assert policy.allow_provider_override is False + assert policy.allow_model_override is False + assert policy.allow_profile_override is False + assert policy.allow_agent_id_override is False + + +# --------------------------------------------------------------------------- +# Plugin context wiring +# --------------------------------------------------------------------------- + + +class TestPluginContextIntegration: + def test_ctx_llm_is_lazy_singleton(self): + from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager + + manifest = PluginManifest(name="test-plugin", source="test", key="test-plugin") + manager = PluginManager() + ctx = PluginContext(manifest, manager) + first = ctx.llm + second = ctx.llm + assert first is second + assert isinstance(first, PluginLlm) + assert first._plugin_id == "test-plugin" # type: ignore[attr-defined] + + def test_ctx_llm_uses_manifest_key_for_policy(self): + from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager + + manifest = PluginManifest( + name="bare-name", source="test", key="image_gen/openai" + ) + manager = PluginManager() + ctx = PluginContext(manifest, manager) + assert ctx.llm._plugin_id == "image_gen/openai" # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# Attribution (result.provider / result.model / audit log) +# --------------------------------------------------------------------------- + + +class TestAttribution: + """Verifies that the result object and the audit log carry the real + provider/model that ``call_llm`` ended up using, NOT the placeholder + fallbacks ('auto', 'default') from earlier drafts.""" + + def test_explicit_overrides_recorded_when_no_response_model(self): + from agent.plugin_llm import _resolve_attribution + + # Response with no .model attribute — overrides win. + response = SimpleNamespace(choices=[], usage=None) + provider, model = _resolve_attribution( + provider_override="openrouter", + model_override="anthropic/claude-3-5-sonnet", + response=response, + ) + assert provider == "openrouter" + assert model == "anthropic/claude-3-5-sonnet" + + def test_response_model_wins_over_model_override(self): + """Providers often canonicalise the model name (e.g. ``gpt-4o`` + → ``gpt-4o-2024-08-06``). Whatever they actually returned wins + for the recorded model so the audit log reflects reality.""" + from agent.plugin_llm import _resolve_attribution + + response = SimpleNamespace(model="gpt-4o-2024-08-06", choices=[]) + provider, model = _resolve_attribution( + provider_override="openrouter", + model_override="openai/gpt-4o", + response=response, + ) + assert model == "gpt-4o-2024-08-06" + # Provider override is unaffected by response.model. + assert provider == "openrouter" + + def test_falls_back_to_main_provider_and_model_when_no_overrides(self, monkeypatch): + """When the plugin doesn't override anything, attribution + reflects the user's active main provider/model rather than + misleading placeholders.""" + from agent import plugin_llm + import agent.auxiliary_client as ac + + monkeypatch.setattr(ac, "_read_main_provider", lambda: "openrouter") + monkeypatch.setattr(ac, "_read_main_model", lambda: "anthropic/claude-3-5-sonnet") + + response = SimpleNamespace(choices=[]) # no .model attribute + provider, model = plugin_llm._resolve_attribution( + provider_override=None, + model_override=None, + response=response, + ) + assert provider == "openrouter" + assert model == "anthropic/claude-3-5-sonnet" + + def test_response_model_used_even_when_no_overrides(self, monkeypatch): + """The provider's canonical model name should still flow through + when no overrides are set.""" + from agent import plugin_llm + import agent.auxiliary_client as ac + + monkeypatch.setattr(ac, "_read_main_provider", lambda: "openrouter") + monkeypatch.setattr(ac, "_read_main_model", lambda: "openai/gpt-4o") + + response = SimpleNamespace(model="openai/gpt-4o-2024-08-06", choices=[]) + provider, model = plugin_llm._resolve_attribution( + provider_override=None, + model_override=None, + response=response, + ) + assert provider == "openrouter" + assert model == "openai/gpt-4o-2024-08-06" + + def test_placeholder_fallback_only_when_everything_is_empty(self, monkeypatch): + """If main_provider/main_model are unset AND there's no override + AND the response has no .model, fall through to the safety + placeholders so the result object never has empty strings.""" + from agent import plugin_llm + import agent.auxiliary_client as ac + + monkeypatch.setattr(ac, "_read_main_provider", lambda: "") + monkeypatch.setattr(ac, "_read_main_model", lambda: "") + + response = SimpleNamespace(choices=[]) + provider, model = plugin_llm._resolve_attribution( + provider_override=None, + model_override=None, + response=response, + ) + assert provider == "auto" + assert model == "default" + + +# --------------------------------------------------------------------------- +# Hook-mode integration (ctx.llm called from a post_tool_call callback) +# --------------------------------------------------------------------------- + + +class TestHookMode: + """The docs page promises ``ctx.llm`` works from inside lifecycle + hooks. This exercises that path: register a ``post_tool_call`` + callback that calls ``ctx.llm.complete``, fire the hook through + the real ``invoke_hook`` machinery, and check the call landed.""" + + def test_complete_works_from_post_tool_call_hook(self): + from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager + + manifest = PluginManifest(name="hook-plugin", source="test", key="hook-plugin") + manager = PluginManager() + ctx = PluginContext(manifest, manager) + + # Replace ctx.llm with a stub that records what the hook called. + captured: list = [] + + def fake_caller(**kwargs): + captured.append(kwargs) + return "openrouter", "openai/gpt-4o", _fake_response("rewrote it") + + ctx._llm = make_plugin_llm_for_test( # type: ignore[attr-defined] + plugin_id="hook-plugin", + policy=_TrustPolicy(plugin_id="hook-plugin"), + sync_caller=fake_caller, + ) + + # Plugin registers a hook that runs ctx.llm.complete on every tool call. + def rewrite_error_hook(*, tool_name, args, result, **_): + if "Traceback" in (result or ""): + rewritten = ctx.llm.complete( + messages=[ + {"role": "system", "content": "Rewrite errors plainly."}, + {"role": "user", "content": result}, + ], + max_tokens=64, + purpose="hook-plugin.rewrite", + ) + # Real hook would return the rewritten text via + # transform_tool_result; here we just capture for the assert. + captured.append({"hook_returned": rewritten.text}) + + ctx.register_hook("post_tool_call", rewrite_error_hook) + + # Fire the hook the same way the agent core does it. + manager.invoke_hook( + "post_tool_call", + tool_name="terminal", + args={"command": "boom"}, + result="Traceback (most recent call last):\n RuntimeError", + ) + + # Verify ctx.llm.complete fired through the hook. + assert len(captured) == 2 # one llm call + one hook return record + llm_call = captured[0] + assert "messages" in llm_call + assert any("rewrite" in m.get("content", "").lower() + for m in llm_call["messages"] if isinstance(m, dict)) + hook_record = captured[1] + assert hook_record["hook_returned"] == "rewrote it" + + def test_complete_works_from_post_tool_call_hook_when_async_caller_set(self): + """Hooks fired synchronously should still work with sync + ctx.llm.complete even if other callsites use async.""" + from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager + + manifest = PluginManifest(name="hook-async", source="test", key="hook-async") + manager = PluginManager() + ctx = PluginContext(manifest, manager) + + def fake_caller(**_): + return "openrouter", "model-x", _fake_response("ok") + + ctx._llm = make_plugin_llm_for_test( # type: ignore[attr-defined] + plugin_id="hook-async", + policy=_TrustPolicy(plugin_id="hook-async"), + sync_caller=fake_caller, + ) + + called: list = [] + + def hook(**kwargs): + r = ctx.llm.complete(messages=[{"role": "user", "content": "x"}]) + called.append(r.text) + + ctx.register_hook("post_tool_call", hook) + manager.invoke_hook("post_tool_call", tool_name="x", args={}, result="y") + assert called == ["ok"] diff --git a/tests/agent/test_prompt_caching.py b/tests/agent/test_prompt_caching.py index f6f3e9f0a38..9d989571b54 100644 --- a/tests/agent/test_prompt_caching.py +++ b/tests/agent/test_prompt_caching.py @@ -6,6 +6,8 @@ import pytest from agent.prompt_caching import ( _apply_cache_marker, apply_anthropic_cache_control, + apply_anthropic_cache_control_long_lived, + mark_tools_for_long_lived_cache, ) @@ -141,3 +143,132 @@ class TestApplyAnthropicCacheControl: elif "cache_control" in msg: count += 1 assert count <= 4 + + +class TestMarkToolsForLongLivedCache: + def test_returns_unchanged_for_empty_tools(self): + assert mark_tools_for_long_lived_cache(None) is None + assert mark_tools_for_long_lived_cache([]) == [] + + def test_marks_only_last_tool(self): + tools = [ + {"type": "function", "function": {"name": "a"}}, + {"type": "function", "function": {"name": "b"}}, + {"type": "function", "function": {"name": "c"}}, + ] + out = mark_tools_for_long_lived_cache(tools) + assert "cache_control" not in out[0] + assert "cache_control" not in out[1] + assert out[2]["cache_control"] == {"type": "ephemeral", "ttl": "1h"} + + def test_does_not_mutate_input(self): + tools = [{"type": "function", "function": {"name": "a"}}] + mark_tools_for_long_lived_cache(tools) + assert "cache_control" not in tools[0] + + def test_5m_ttl_drops_ttl_field(self): + tools = [{"type": "function", "function": {"name": "a"}}] + out = mark_tools_for_long_lived_cache(tools, long_lived_ttl="5m") + assert out[0]["cache_control"] == {"type": "ephemeral"} + + +class TestApplyAnthropicCacheControlLongLived: + def test_empty_messages(self): + assert apply_anthropic_cache_control_long_lived([]) == [] + + def test_marks_first_block_of_split_system(self): + msgs = [ + {"role": "system", "content": [ + {"type": "text", "text": "STABLE"}, + {"type": "text", "text": "CONTEXT"}, + {"type": "text", "text": "VOLATILE"}, + ]}, + {"role": "user", "content": "msg1"}, + {"role": "assistant", "content": "msg2"}, + ] + out = apply_anthropic_cache_control_long_lived(msgs) + sys_blocks = out[0]["content"] + assert sys_blocks[0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"} + assert "cache_control" not in sys_blocks[1] + assert "cache_control" not in sys_blocks[2] + + def test_rolling_marker_on_last_2_messages(self): + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "S"}]}, + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + {"role": "assistant", "content": "a2"}, + ] + out = apply_anthropic_cache_control_long_lived(msgs) + + def has_marker(m): + c = m.get("content") + if isinstance(c, list) and c and isinstance(c[-1], dict): + return "cache_control" in c[-1] + return "cache_control" in m + + # u1 and a1 (older messages) should NOT be marked + assert not has_marker(out[1]) + assert not has_marker(out[2]) + # u2 and a2 (last 2) SHOULD be marked + assert has_marker(out[3]) + assert has_marker(out[4]) + + def test_rolling_marker_uses_5m_ttl(self): + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "S"}]}, + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + ] + out = apply_anthropic_cache_control_long_lived( + msgs, long_lived_ttl="1h", rolling_ttl="5m", + ) + # Last user message: cache_control on the wrapped text part should be 5m + last = out[-1] + c = last["content"] + assert isinstance(c, list) + assert c[-1]["cache_control"] == {"type": "ephemeral"} # 5m has no ttl key + + def test_string_system_falls_back_to_envelope_marker(self): + """When the caller didn't split the system message, we still place a marker.""" + msgs = [ + {"role": "system", "content": "Single string system"}, + {"role": "user", "content": "u1"}, + ] + out = apply_anthropic_cache_control_long_lived(msgs) + sys_content = out[0]["content"] + # Wrapped into a list and the (now sole) block gets the 1h marker + assert isinstance(sys_content, list) + assert sys_content[0]["cache_control"] == {"type": "ephemeral", "ttl": "1h"} + + def test_does_not_mutate_input(self): + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "S"}]}, + {"role": "user", "content": "u1"}, + ] + before = copy.deepcopy(msgs) + apply_anthropic_cache_control_long_lived(msgs) + assert msgs == before + + def test_max_4_breakpoints_with_split_system(self): + msgs = [ + {"role": "system", "content": [{"type": "text", "text": "S"}, {"type": "text", "text": "V"}]}, + ] + [ + {"role": "user" if i % 2 == 0 else "assistant", "content": f"msg{i}"} + for i in range(10) + ] + out = apply_anthropic_cache_control_long_lived(msgs) + count = 0 + for m in out: + c = m.get("content") + if isinstance(c, list): + for item in c: + if isinstance(item, dict) and "cache_control" in item: + count += 1 + elif "cache_control" in m: + count += 1 + # 1 system block + last 2 messages = 3 breakpoints from this function. + # tools[-1] is marked separately (not via this function), so a 4th + # breakpoint can be added at API-call time. + assert count == 3 diff --git a/tests/agent/test_prompt_caching_live.py b/tests/agent/test_prompt_caching_live.py new file mode 100644 index 00000000000..f72b6b9d906 --- /dev/null +++ b/tests/agent/test_prompt_caching_live.py @@ -0,0 +1,112 @@ +"""Live E2E: long-lived prefix caching on Claude via OpenRouter. + +Run only when LIVE_OR_KEY env var is set. Skipped under the normal hermetic +test suite (which unsets credentials). +""" +import os, sys, tempfile, time, shutil, pytest + + +# Probe for the key BEFORE conftest unsets it +_LIVE_KEY = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("LIVE_OR_KEY") +if not _LIVE_KEY: + # Try to read directly from .env + env_path = os.path.expanduser("~/.hermes/.env") + if os.path.exists(env_path): + with open(env_path) as f: + for line in f: + if line.startswith("OPENROUTER_API_KEY="): + _LIVE_KEY = line.strip().split("=", 1)[1].strip().strip('"').strip("'") + break + + +pytestmark = pytest.mark.skipif( + not _LIVE_KEY, + reason="set OPENROUTER_API_KEY (or LIVE_OR_KEY) to run live cache test", +) + + +def test_long_lived_prefix_cache_e2e_openrouter(tmp_path, monkeypatch): + """Two AIAgent runs in fresh sessions: call 1 writes cache, call 2 reads it.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + # The hermetic conftest unsets OPENROUTER_API_KEY — restore for this test + monkeypatch.setenv("OPENROUTER_API_KEY", _LIVE_KEY) + + # Minimal config — but with enough toolset/guidance to exceed Anthropic's + # ~1024-token minimum-cacheable-prefix threshold. Anthropic silently + # ignores cache_control markers on small blocks. + import yaml + cfg_path = tmp_path / "config.yaml" + cfg_path.write_text(yaml.safe_dump({ + "model": {"provider": "openrouter", "default": "anthropic/claude-haiku-4.5"}, + "prompt_caching": {"long_lived_prefix": True, "long_lived_ttl": "1h", "cache_ttl": "5m"}, + "agent": {"tool_use_enforcement": True}, # adds substantial guidance text + "memory": {"provider": ""}, + "compression": {"enabled": False}, + })) + + from run_agent import AIAgent + + def make_agent(): + return AIAgent( + api_key=_LIVE_KEY, + base_url="https://openrouter.ai/api/v1", + provider="openrouter", + model="anthropic/claude-haiku-4.5", + api_mode="chat_completions", + # Use the default toolset roster — the tools array (~13k tokens + # for ~35 tools) is what carries the bulk of the cross-session + # cache value. With a tiny toolset the cached prefix can fall + # below Anthropic Haiku's 2048-token minimum cacheable size and + # the marker is silently ignored. + enabled_toolsets=None, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + save_trajectories=False, + ) + + a1 = make_agent() + assert a1._use_prompt_caching is True, "policy should enable caching for Claude on OR" + assert a1._use_long_lived_prefix_cache is True, "long-lived path should activate" + parts = a1._build_system_prompt_parts() + print(f"\nstable={len(parts['stable']):,} ctx={len(parts['context']):,} volatile={len(parts['volatile']):,} chars") + print(f"tool count: {len(a1.tools or [])}") + + # Use distinct user messages each call so OpenRouter's response cache + # doesn't short-circuit the upstream Anthropic call (we need real + # Anthropic billing visibility to verify cache_creation/cache_read). + USER_1 = "Reply with the single word ALPHA." + USER_2 = "Reply with the single word BRAVO." + + print("\n--- Call 1 (cold) ---") + r1 = a1.run_conversation(USER_1, conversation_history=[]) + print(f"final_response[:80]: {(r1.get('final_response') or '')[:80]!r}") + cr1 = a1.session_cache_read_tokens + cw1 = a1.session_cache_write_tokens + print(f"call1: cache_read={cr1} cache_write={cw1}") + + # Wait so cache settles, then fresh agent (NEW SESSION) for cross-session read + time.sleep(2) + a2 = make_agent() + assert a2.session_id != a1.session_id, "second agent must have a new session" + + print("\n--- Call 2 (warm, NEW session, different user msg) ---") + r2 = a2.run_conversation(USER_2, conversation_history=[]) + print(f"final_response[:80]: {(r2.get('final_response') or '')[:80]!r}") + cr2 = a2.session_cache_read_tokens + cw2 = a2.session_cache_write_tokens + print(f"call2: cache_read={cr2} cache_write={cw2}") + + print(f"\n=== VERDICT ===") + print(f" call1 wrote {cw1:,} cache tokens, read {cr1:,}") + print(f" call2 wrote {cw2:,} cache tokens, read {cr2:,}") + if cw1: + print(f" cross-session read fraction: cr2/cw1 = {cr2/cw1:.2%}") + + # Assertions + assert cw1 > 0, f"call 1 must write cache (got {cw1}); long-lived layout not reaching wire" + assert cr2 > 0, ( + f"call 2 must read cache cross-session (got {cr2}); " + f"stable prefix is not byte-stable across sessions" + ) + assert cr2 >= 1000, f"cache_read on call 2 ({cr2}) too small to indicate real reuse" diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 7217f2e9e6a..6a4cda173ad 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -180,6 +180,119 @@ class TestCodexBuildKwargs: # "minimal" should be clamped to "low" for xAI as well assert kw.get("reasoning", {}).get("effort") == "low" + # --- Grok reasoning-effort capability allowlist --- + # api.x.ai 400s with "Model X does not support parameter reasoningEffort" + # on grok-4 / grok-4-fast / grok-3 / grok-code-fast / grok-4.20-0309-*. + # Those models reason natively but don't expose the dial. The transport + # must omit the `reasoning` key for them while keeping the encrypted + # reasoning content include so we can capture native reasoning tokens. + + def test_xai_grok_4_omits_reasoning_effort(self, transport): + """grok-4 / grok-4-0709 reject reasoning.effort with HTTP 400.""" + messages = [{"role": "user", "content": "Hi"}] + for model in ("grok-4", "grok-4-0709"): + kw = transport.build_kwargs( + model=model, messages=messages, tools=[], + is_xai_responses=True, + reasoning_config={"effort": "high"}, + ) + assert "reasoning" not in kw, ( + f"{model} must not receive a reasoning key (xAI rejects it)" + ) + # Still capture native reasoning tokens + assert "reasoning.encrypted_content" in kw.get("include", []) + + def test_xai_grok_4_fast_omits_reasoning_effort(self, transport): + """grok-4-fast and grok-4-1-fast variants reject reasoning.effort.""" + messages = [{"role": "user", "content": "Hi"}] + for model in ( + "grok-4-fast-reasoning", + "grok-4-fast-non-reasoning", + "grok-4-1-fast-reasoning", + "grok-4-1-fast-non-reasoning", + ): + kw = transport.build_kwargs( + model=model, messages=messages, tools=[], + is_xai_responses=True, + reasoning_config={"effort": "low"}, + ) + assert "reasoning" not in kw, ( + f"{model} must not receive a reasoning key (xAI rejects it)" + ) + + def test_xai_grok_3_non_mini_omits_reasoning_effort(self, transport): + """Plain grok-3 rejects reasoning.effort — only grok-3-mini accepts it.""" + messages = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="grok-3", messages=messages, tools=[], + is_xai_responses=True, + reasoning_config={"effort": "medium"}, + ) + assert "reasoning" not in kw + + def test_xai_grok_3_mini_keeps_reasoning_effort(self, transport): + """grok-3-mini and -fast variants do accept the effort dial.""" + messages = [{"role": "user", "content": "Hi"}] + for model in ("grok-3-mini", "grok-3-mini-fast"): + kw = transport.build_kwargs( + model=model, messages=messages, tools=[], + is_xai_responses=True, + reasoning_config={"effort": "high"}, + ) + assert kw.get("reasoning") == {"effort": "high"} + + def test_xai_grok_4_20_0309_variants_omit_reasoning_effort(self, transport): + """grok-4.20-0309-(non-)reasoning reject the effort dial. + + Counterintuitively, only grok-4.20-multi-agent-0309 accepts it. + """ + messages = [{"role": "user", "content": "Hi"}] + for model in ("grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning"): + kw = transport.build_kwargs( + model=model, messages=messages, tools=[], + is_xai_responses=True, + reasoning_config={"effort": "high"}, + ) + assert "reasoning" not in kw, f"{model} must not receive reasoning" + + def test_xai_grok_4_20_multi_agent_keeps_reasoning_effort(self, transport): + """grok-4.20-multi-agent-0309 is the one grok-4.20 variant that accepts effort.""" + messages = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="grok-4.20-multi-agent-0309", messages=messages, tools=[], + is_xai_responses=True, + reasoning_config={"effort": "low"}, + ) + assert kw.get("reasoning") == {"effort": "low"} + + def test_xai_grok_code_fast_omits_reasoning_effort(self, transport): + """grok-code-fast-1 rejects reasoning.effort.""" + messages = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="grok-code-fast-1", messages=messages, tools=[], + is_xai_responses=True, + reasoning_config={"effort": "high"}, + ) + assert "reasoning" not in kw + + def test_xai_aggregator_prefix_stripped(self, transport): + """`x-ai/grok-3-mini` (OpenRouter-style slug) still resolves correctly.""" + messages = [{"role": "user", "content": "Hi"}] + # Effort-capable + kw = transport.build_kwargs( + model="x-ai/grok-3-mini", messages=messages, tools=[], + is_xai_responses=True, + reasoning_config={"effort": "high"}, + ) + assert kw.get("reasoning") == {"effort": "high"} + # Effort-incapable + kw = transport.build_kwargs( + model="x-ai/grok-4-0709", messages=messages, tools=[], + is_xai_responses=True, + reasoning_config={"effort": "high"}, + ) + assert "reasoning" not in kw + class TestCodexValidateResponse: diff --git a/tests/cli/test_cli_markdown_rendering.py b/tests/cli/test_cli_markdown_rendering.py index 032c8875b3a..b3144168a0e 100644 --- a/tests/cli/test_cli_markdown_rendering.py +++ b/tests/cli/test_cli_markdown_rendering.py @@ -118,14 +118,37 @@ def test_strip_mode_preserves_table_structure_while_cleaning_cell_markdown(): ) output = _render_to_text(renderable) - assert "| Syntax | Example |" in output - assert "|---|---|" in output - assert "| Bold | bold |" in output - assert "| Strike | strike |" in output + + # Inline cell markdown is stripped (the contract this test enforces). assert "**" not in output assert "~~" not in output assert "`" not in output + # Cell *content* survives, even if the surrounding whitespace was + # rewritten by the wcwidth-aware re-aligner. Asserting on bare + # cell text keeps this test focused on the strip behaviour rather + # than snapshotting incidental column padding (which is what the + # CJK-alignment fix changes). + assert "Syntax" in output + assert "Example" in output + assert "Bold" in output and "bold" in output + assert "Strike" in output and "strike" in output + + # Structural sanity: the table still renders as pipe-bordered rows + # (header + divider + 2 body rows). + body_rows = [ln for ln in output.splitlines() if ln.strip().startswith("|")] + assert len(body_rows) == 4 + + # Every rendered table row shares the same pipe column offsets — the + # alignment guarantee from realign_markdown_tables. + pipe_cols = [ + [i for i, ch in enumerate(row) if ch == "|"] for row in body_rows + ] + assert all(p == pipe_cols[0] for p in pipe_cols), ( + "table rows misaligned after strip-mode rendering:\n" + + "\n".join(body_rows) + ) + def test_final_assistant_content_can_leave_markdown_raw(): renderable = _render_final_assistant_content("***Bold italic***", mode="raw") diff --git a/tests/cli/test_destructive_slash_confirm.py b/tests/cli/test_destructive_slash_confirm.py index 290314dc371..1b2fc8c0b1f 100644 --- a/tests/cli/test_destructive_slash_confirm.py +++ b/tests/cli/test_destructive_slash_confirm.py @@ -6,6 +6,7 @@ don't have to construct a full HermesCLI (which requires extensive setup). from __future__ import annotations +import queue from types import SimpleNamespace from unittest.mock import patch @@ -17,10 +18,17 @@ def _bound(fn, instance): def _make_self(prompt_response): """Build a minimal stand-in 'self' for _confirm_destructive_slash.""" - return SimpleNamespace( + from cli import HermesCLI + + self_ = SimpleNamespace( _app=None, _prompt_text_input=lambda _prompt: prompt_response, + _prompt_text_input_modal=lambda **_kw: prompt_response, ) + self_._normalize_slash_confirm_choice = _bound( + HermesCLI._normalize_slash_confirm_choice, self_, + ) + return self_ def test_gate_off_returns_once_without_prompting(): @@ -117,7 +125,6 @@ def test_gate_on_choice_always_persists_and_returns_always(): self_ = _make_self(prompt_response="2") saves = [] - def _fake_save(key, value): saves.append((key, value)) return True @@ -150,3 +157,55 @@ def test_gate_default_true_when_config_missing(): # treated as on despite the config error. If the gate had been off # this would have returned 'once' without consulting the prompt. assert result is None + + +def test_slash_confirm_modal_number_selection_submits_without_raw_input(): + """Pressing 2 in the TUI modal should resolve to Always Approve directly.""" + from cli import HermesCLI + + q = queue.Queue() + self_ = SimpleNamespace( + _slash_confirm_state={ + "choices": [ + ("once", "Approve Once", "proceed once"), + ("always", "Always Approve", "persist opt-out"), + ("cancel", "Cancel", "abort"), + ], + "selected": 0, + "response_queue": q, + }, + _slash_confirm_deadline=123, + _invalidate=lambda: None, + ) + + _bound(HermesCLI._submit_slash_confirm_response, self_)("always") + + assert q.get_nowait() == "always" + assert self_._slash_confirm_state is None + assert self_._slash_confirm_deadline == 0 + + +def test_slash_confirm_display_fragments_include_choice_mapping(): + """The modal itself must show what 1/2/3 mean, not only 'Choice [1/2/3]'.""" + from cli import HermesCLI + + self_ = SimpleNamespace( + _slash_confirm_state={ + "title": "⚠️ /new — destroys conversation state", + "detail": "This starts a fresh session.", + "choices": [ + ("once", "Approve Once", "proceed once"), + ("always", "Always Approve", "persist opt-out"), + ("cancel", "Cancel", "abort"), + ], + "selected": 1, + }, + ) + + fragments = _bound(HermesCLI._get_slash_confirm_display_fragments, self_)() + rendered = "".join(fragment for _style, fragment in fragments) + + assert "[1] Approve Once" in rendered + assert "[2] Always Approve" in rendered + assert "[3] Cancel" in rendered + assert "Type 1/2/3" in rendered diff --git a/tests/cli/test_prompt_text_input_thread_safety.py b/tests/cli/test_prompt_text_input_thread_safety.py new file mode 100644 index 00000000000..fb27a95b312 --- /dev/null +++ b/tests/cli/test_prompt_text_input_thread_safety.py @@ -0,0 +1,101 @@ +"""Tests for ``HermesCLI._prompt_text_input`` thread-safe input dispatch. + +Raw ``input()`` prompts can race with prompt_toolkit when called from the TUI. +The normal slash confirmations now use a prompt_toolkit-native modal, but +``_prompt_text_input`` remains as a fallback for non-interactive calls and edge +cases. +""" + +import threading +from unittest.mock import MagicMock, patch + + +def _make_cli(): + """Minimal HermesCLI shell exposing prompt fallback helpers.""" + import cli as cli_mod + + obj = object.__new__(cli_mod.HermesCLI) + obj._app = MagicMock() + obj._status_bar_visible = True + return obj + + +class TestPromptTextInputThreadSafety: + def test_main_thread_uses_run_in_terminal(self): + """On the main thread with an active app, route through run_in_terminal.""" + cli = _make_cli() + + with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \ + patch("builtins.input", return_value="2"): + cli._prompt_text_input("Choice: ") + + # run_in_terminal was invoked; the _ask closure passed to it would + # call input() when driven by the event loop. We assert dispatch path, + # not the orphaned-coroutine result. + assert mock_rit.called + + def test_background_thread_falls_back_to_direct_input(self): + """On a daemon thread, skip run_in_terminal and call input() directly. + + This preserves the fallback for any prompt that still runs off the main + UI thread: run_in_terminal's coroutine would otherwise be orphaned. + """ + cli = _make_cli() + captured = {} + + def fake_input(prompt): + captured["prompt"] = prompt + return "1" + + result_holder = {} + + def run_on_daemon(): + with patch("prompt_toolkit.application.run_in_terminal") as mock_rit, \ + patch("builtins.input", side_effect=fake_input): + result_holder["value"] = cli._prompt_text_input("Choice [1/2/3]: ") + result_holder["rit_called"] = mock_rit.called + + t = threading.Thread(target=run_on_daemon, daemon=True) + t.start() + t.join(timeout=2.0) + assert not t.is_alive(), "daemon thread hung — input() was not driven" + + # run_in_terminal was bypassed entirely on the background thread. + assert result_holder["rit_called"] is False + # input() was invoked with the prompt and its return value was captured. + assert captured.get("prompt") == "Choice [1/2/3]: " + assert result_holder["value"] == "1" + + def test_no_app_uses_direct_input(self): + """Without an active prompt_toolkit app, always call input() directly.""" + cli = _make_cli() + cli._app = None + + with patch("builtins.input", return_value="cancel") as mock_input: + result = cli._prompt_text_input("Choice: ") + + assert mock_input.called + assert result == "cancel" + + def test_run_in_terminal_exception_falls_back(self): + """If run_in_terminal raises (WSL / Warp edge cases), fall back to input().""" + cli = _make_cli() + + with patch( + "prompt_toolkit.application.run_in_terminal", + side_effect=RuntimeError("event loop dropped the coroutine"), + ), patch("builtins.input", return_value="3") as mock_input: + result = cli._prompt_text_input("Choice: ") + + assert mock_input.called + assert result == "3" + + def test_eof_returns_none(self): + """EOFError from input() yields None, not an unhandled exception.""" + cli = _make_cli() + cli._app = None + + with patch("builtins.input", side_effect=EOFError()): + result = cli._prompt_text_input("Choice: ") + + assert result is None diff --git a/tests/cli/test_quick_commands.py b/tests/cli/test_quick_commands.py index c89d639d13e..57a39e8c53d 100644 --- a/tests/cli/test_quick_commands.py +++ b/tests/cli/test_quick_commands.py @@ -1,4 +1,5 @@ """Tests for user-defined quick commands that bypass the agent loop.""" +import os import subprocess from unittest.mock import MagicMock, patch, AsyncMock from rich.text import Text @@ -159,6 +160,46 @@ class TestGatewayQuickCommands: result = await runner._handle_message(event) assert result == "ok" + @pytest.mark.asyncio + async def test_exec_command_does_not_leak_credentials(self): + """Quick command exec must sanitize env — API keys must not appear in output.""" + from gateway.run import GatewayRunner + + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = {"quick_commands": {"leak": {"type": "exec", "command": "env"}}} + runner._running_agents = {} + runner._pending_messages = {} + runner._is_user_authorized = MagicMock(return_value=True) + + event = self._make_event("leak") + with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-secret-12345"}): + result = await runner._handle_message(event) + + assert "sk-or-secret-12345" not in result, \ + "Quick command leaked OPENROUTER_API_KEY — exec runs without env sanitization" + + @pytest.mark.asyncio + async def test_exec_command_output_is_redacted(self, monkeypatch): + """Quick command output must redact sensitive patterns before returning.""" + from gateway.run import GatewayRunner + + # Ensure redaction is active regardless of host HERMES_REDACT_SECRETS state + # or test ordering (the module snapshots env at import time, so other + # tests in the same xdist worker can flip the flag). + monkeypatch.setattr("agent.redact._REDACT_ENABLED", True) + + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = {"quick_commands": {"token": {"type": "exec", "command": "echo sk-ant-api03-supersecretkey1234567890"}}} + runner._running_agents = {} + runner._pending_messages = {} + runner._is_user_authorized = MagicMock(return_value=True) + + event = self._make_event("token") + result = await runner._handle_message(event) + + assert "supersecretkey1234567890" not in result, \ + "Quick command output not redacted — raw API key returned to user" + @pytest.mark.asyncio async def test_unsupported_type_returns_error(self): from gateway.run import GatewayRunner diff --git a/tests/conftest.py b/tests/conftest.py index 651a48b3916..5d7f197f195 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -188,6 +188,16 @@ _HERMES_BEHAVIORAL_VARS = frozenset({ "HERMES_BACKGROUND_NOTIFICATIONS", "HERMES_EXEC_ASK", "HERMES_HOME_MODE", + # Kanban path/board pins must never leak from a developer shell or + # dispatched worker into tests; otherwise tests can write fake tasks to + # the real ~/.hermes/kanban.db instead of the per-test HERMES_HOME. + "HERMES_KANBAN_DB", + "HERMES_KANBAN_BOARD", + "HERMES_KANBAN_WORKSPACES_ROOT", + "HERMES_KANBAN_LOGS_ROOT", + "HERMES_KANBAN_TASK", + "HERMES_KANBAN_WORKSPACE", + "HERMES_TENANT", "TERMINAL_CWD", "TERMINAL_ENV", "TERMINAL_VERCEL_RUNTIME", @@ -223,6 +233,45 @@ _HERMES_BEHAVIORAL_VARS = frozenset({ "SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS", "SMS_ALLOW_ALL_USERS", + # Gateway home channels are set by /sethome in real profiles. Tests that + # exercise dashboard notification toggles must opt in explicitly or they + # can accidentally subscribe against a developer's real home channel. + "TELEGRAM_HOME_CHANNEL", + "TELEGRAM_HOME_CHANNEL_THREAD_ID", + "TELEGRAM_HOME_CHANNEL_NAME", + "DISCORD_HOME_CHANNEL", + "DISCORD_HOME_CHANNEL_THREAD_ID", + "DISCORD_HOME_CHANNEL_NAME", + "SLACK_HOME_CHANNEL", + "SLACK_HOME_CHANNEL_THREAD_ID", + "SLACK_HOME_CHANNEL_NAME", + "WHATSAPP_HOME_CHANNEL", + "WHATSAPP_HOME_CHANNEL_THREAD_ID", + "WHATSAPP_HOME_CHANNEL_NAME", + "SIGNAL_HOME_CHANNEL", + "SIGNAL_HOME_CHANNEL_THREAD_ID", + "SIGNAL_HOME_CHANNEL_NAME", + "EMAIL_HOME_CHANNEL", + "EMAIL_HOME_CHANNEL_THREAD_ID", + "EMAIL_HOME_CHANNEL_NAME", + "SMS_HOME_CHANNEL", + "SMS_HOME_CHANNEL_THREAD_ID", + "SMS_HOME_CHANNEL_NAME", + "MATTERMOST_HOME_CHANNEL", + "MATTERMOST_HOME_CHANNEL_THREAD_ID", + "MATTERMOST_HOME_CHANNEL_NAME", + "MATRIX_HOME_CHANNEL", + "MATRIX_HOME_CHANNEL_THREAD_ID", + "MATRIX_HOME_CHANNEL_NAME", + "DINGTALK_HOME_CHANNEL", + "DINGTALK_HOME_CHANNEL_THREAD_ID", + "DINGTALK_HOME_CHANNEL_NAME", + "FEISHU_HOME_CHANNEL", + "FEISHU_HOME_CHANNEL_THREAD_ID", + "FEISHU_HOME_CHANNEL_NAME", + "WECOM_HOME_CHANNEL", + "WECOM_HOME_CHANNEL_THREAD_ID", + "WECOM_HOME_CHANNEL_NAME", # Platform gating — set by load_gateway_config() as a side effect when # a config.yaml is present, so individual test bodies that call the # loader leak these values into later tests on the same xdist worker. @@ -565,4 +614,352 @@ def _reset_tool_registry_caches(): _clear_tool_defs_cache() except ImportError: pass + + +# ── Live-system guard ────────────────────────────────────────────────────── +# +# Several test files exercise the gateway-restart / kill code paths +# (``cmd_update``, ``kill_gateway_processes``, ``stop_profile_gateway``). +# When a single test forgets to mock either ``os.kill`` or the global +# ``find_gateway_pids`` helper, the real call leaks out of the hermetic +# environment and finds the developer's live ``hermes-gateway`` process +# via ``psutil`` — sending it SIGTERM mid-test. The shutdown forensics in +# PR #23285 caught this happening 5+ times in 3 days, every time +# correlated with a ``tests/hermes_cli/`` pytest run starting up. +# +# This fixture makes the leak impossible by intercepting the two +# primitives that actually do damage: +# +# • ``os.kill`` rejects any PID outside the test process subtree with +# a hard ``RuntimeError`` so the offending test gets a stack trace +# instead of silently murdering the real gateway. +# • ``subprocess.run`` / ``subprocess.Popen`` / ``call`` / ``check_call`` / +# ``check_output`` reject any ``systemctl ... <verb> hermes-gateway`` +# invocation that would mutate the live unit. Read-only systemctl +# calls (``status``, ``show``, ``list-units``) still pass through. +# +# We intentionally do NOT stub ``find_gateway_pids`` / ``_scan_gateway_pids`` +# here — tests of those functions themselves need the real implementation. +# Even if a test gets the live gateway PID back from a real scan, the +# ``os.kill`` guard above catches the actual signal call, and the +# ``systemctl`` guard catches the systemd path. Discovery without +# delivery is harmless. + +_LIVE_SYSTEM_GUARD_BYPASS_MARK = "live_system_guard_bypass" + + +def pytest_configure(config): # noqa: D401 — pytest hook + """Register markers used by hermetic conftest.""" + config.addinivalue_line( + "markers", + f"{_LIVE_SYSTEM_GUARD_BYPASS_MARK}: bypass the live-system guard " + "(only for tests that genuinely need real os.kill / subprocess " + "behaviour — e.g. PTY tests that signal their own child).", + ) + + +@pytest.fixture(autouse=True) +def _live_system_guard(request, monkeypatch): + """Block real os.kill / systemctl / gateway-pid scans during tests. + + See block comment above for the why. Tests that genuinely need + real signal delivery (e.g. PTY tests that SIGINT their own child) + can opt out with ``@pytest.mark.live_system_guard_bypass``. + + Coverage (every primitive that can deliver a signal to or otherwise + terminate a foreign process): + • os.kill, os.killpg (POSIX) + • subprocess.run / Popen / call / check_call / check_output + • subprocess.getoutput / getstatusoutput + • os.system / os.popen + • pty.spawn + • asyncio.create_subprocess_exec / create_subprocess_shell + Subprocess inspection looks at the WHOLE command string (not just + tokens[0]), so ``bash -c "systemctl restart hermes-gateway"``, + ``sudo systemctl ...``, ``env systemctl ...``, ``setsid systemctl ...`` + are all caught. ``pkill``/``killall``/``taskkill`` invocations + targeting hermes/python patterns are also blocked. + """ + if request.node.get_closest_marker(_LIVE_SYSTEM_GUARD_BYPASS_MARK): + yield + return + + import os as _os + import shlex as _shlex + import subprocess as _subprocess + + test_pid = _os.getpid() + # Capture the test process's existing children at fixture start — + # any *new* children spawned by the test are also allowlisted via + # the live psutil walk below. Static set keeps the fast path cheap. + try: + import psutil as _psutil + _initial_children = { + c.pid for c in _psutil.Process(test_pid).children(recursive=True) + } + except Exception: + _psutil = None + _initial_children = set() + + def _is_own_subtree(pid: int) -> bool: + # PID 0 means "our own process group"; -1 means "every process we + # can signal". Both are dangerous when paired with SIGTERM/SIGKILL, + # but pid 0 is technically scoped to our group so allow it; pid -1 + # is treated as foreign (refuse). + if pid == 0: + return True + if pid < 0: + return False + if pid == test_pid or pid in _initial_children: + return True + if _psutil is None: + return False + try: + walker = _psutil.Process(pid) + except Exception: + # Stale PID — kill would be a no-op anyway, allow it. + return True + try: + for parent in walker.parents(): + if parent.pid == test_pid: + return True + except Exception: + return False + return False + + real_kill = _os.kill + + def _guarded_kill(pid, sig, *args, **kwargs): + if _is_own_subtree(int(pid)): + return real_kill(pid, sig, *args, **kwargs) + raise RuntimeError( + f"tests/conftest.py live-system guard: blocked os.kill(" + f"{pid}, {sig}) — PID is outside the test process subtree. " + "If this fired in CI it means the test reached a real " + "kill_gateway_processes / stop_profile_gateway / cmd_update " + "code path without mocking find_gateway_pids and os.kill. " + "Mock both, or mark the test with " + "@pytest.mark.live_system_guard_bypass if real signal " + "delivery is genuinely required." + ) + + monkeypatch.setattr(_os, "kill", _guarded_kill) + + # ``os.killpg`` is the same risk class — sends a signal to every + # process in a group. The gateway is a session leader (its own + # PGID == its PID), so killpg(gateway_pid, SIGTERM) is a one-shot + # kill of the live process. Allow it only when the target PGID is + # the test process's own group. + if hasattr(_os, "killpg"): + real_killpg = _os.killpg + own_pgid = _os.getpgrp() + + def _guarded_killpg(pgid, sig, *args, **kwargs): + if int(pgid) == own_pgid or _is_own_subtree(int(pgid)): + return real_killpg(pgid, sig, *args, **kwargs) + raise RuntimeError( + f"tests/conftest.py live-system guard: blocked " + f"os.killpg({pgid}, {sig}) — PGID is outside the test " + "process group. See _live_system_guard for the why." + ) + + monkeypatch.setattr(_os, "killpg", _guarded_killpg) + + # ── Subprocess command-string inspection (whole-line) ────────── + _HERMES_TOKENS = ( + "hermes-gateway", + "hermes.service", + "hermes_cli.main gateway", + "hermes_cli/main.py gateway", + "gateway/run.py", + "hermes gateway", + ) + _MUTATING_VERBS = ( + "restart", "start", "stop", "kill", "reload", + "reset-failed", "enable", "disable", "mask", "unmask", + "daemon-reload", "try-restart", "reload-or-restart", + ) + _PROCESS_KILLERS = ("pkill", "killall", "taskkill", "skill", "fuser") + + def _cmd_to_string(cmd) -> str: + if cmd is None: + return "" + if isinstance(cmd, (bytes, bytearray)): + try: + return bytes(cmd).decode(errors="replace") + except Exception: + return "" + if isinstance(cmd, str): + return cmd + if isinstance(cmd, (list, tuple)): + try: + return " ".join(str(t) for t in cmd) + except Exception: + return "" + return str(cmd) + + def _matches_hermes_gateway(cmd_str: str) -> bool: + low = cmd_str.lower() + return any(tok in low for tok in _HERMES_TOKENS) + + def _is_blocked_systemctl(cmd) -> bool: + cmd_str = _cmd_to_string(cmd) + if "systemctl" not in cmd_str: + return False + if not _matches_hermes_gateway(cmd_str): + return False + try: + tokens = _shlex.split(cmd_str) + except ValueError: + tokens = cmd_str.split() + return any(verb in tokens for verb in _MUTATING_VERBS) + + def _is_process_killer(cmd) -> bool: + cmd_str = _cmd_to_string(cmd) + try: + tokens = _shlex.split(cmd_str) + except ValueError: + tokens = cmd_str.split() + if not tokens: + return False + for tok in tokens: + head = tok.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] + if head in _PROCESS_KILLERS: + low = cmd_str.lower() + # pkill -f pattern: catch hermes-themed patterns + a + # plain "python" -f which would catch the live gateway + # whose cmdline contains "python -m hermes_cli.main". + if ( + "hermes" in low + or "gateway" in low + or ("python" in low and "-f" in tokens) + ): + return True + return False + + def _check_subprocess_cmd(name, cmd): + if _is_blocked_systemctl(cmd): + raise RuntimeError( + f"tests/conftest.py live-system guard: blocked " + f"subprocess.{name}({cmd!r}) — would mutate the " + "live hermes-gateway systemd unit. Mock " + "subprocess.run / _run_systemctl in the test, or " + "mark with @pytest.mark.live_system_guard_bypass." + ) + if _is_process_killer(cmd): + raise RuntimeError( + f"tests/conftest.py live-system guard: blocked " + f"subprocess.{name}({cmd!r}) — process-killer command " + "targeting hermes/python could hit the live gateway. " + "Mark with @pytest.mark.live_system_guard_bypass if " + "intentional." + ) + + def _wrap_subprocess(name, real): + def _guarded(cmd, *args, **kwargs): + _check_subprocess_cmd(name, cmd) + return real(cmd, *args, **kwargs) + _guarded.__name__ = f"_guarded_{name}" + # Make the wrapper subscriptable like the wrapped callable when + # the wrapped object is. ``subprocess.Popen[bytes]`` is used as + # a type annotation in third-party packages (mcp, etc.); replacing + # ``Popen`` with a plain function breaks ``Popen[bytes]`` at + # import time. Defer ``__class_getitem__`` to the original. + if hasattr(real, "__class_getitem__"): + _guarded.__class_getitem__ = real.__class_getitem__ + return _guarded + + def _wrap_popen(): + """Subclass Popen so isinstance checks AND Popen[bytes] still work.""" + real = _subprocess.Popen + + class _GuardedPopen(real): # type: ignore[misc, valid-type] + def __init__(self, cmd, *args, **kwargs): + _check_subprocess_cmd("Popen", cmd) + super().__init__(cmd, *args, **kwargs) + + _GuardedPopen.__name__ = "Popen" + _GuardedPopen.__qualname__ = "Popen" + return _GuardedPopen + + real_run = _subprocess.run + real_popen = _subprocess.Popen + real_call = _subprocess.call + real_check_call = _subprocess.check_call + real_check_output = _subprocess.check_output + real_getoutput = _subprocess.getoutput + real_getstatusoutput = _subprocess.getstatusoutput + + monkeypatch.setattr(_subprocess, "run", _wrap_subprocess("run", real_run)) + monkeypatch.setattr(_subprocess, "Popen", _wrap_popen()) + monkeypatch.setattr(_subprocess, "call", _wrap_subprocess("call", real_call)) + monkeypatch.setattr( + _subprocess, "check_call", _wrap_subprocess("check_call", real_check_call) + ) + monkeypatch.setattr( + _subprocess, + "check_output", + _wrap_subprocess("check_output", real_check_output), + ) + monkeypatch.setattr( + _subprocess, "getoutput", _wrap_subprocess("getoutput", real_getoutput) + ) + monkeypatch.setattr( + _subprocess, + "getstatusoutput", + _wrap_subprocess("getstatusoutput", real_getstatusoutput), + ) + + # os.system / os.popen — same risk class, completely unwrapped before. + real_os_system = _os.system + real_os_popen = _os.popen + + def _guarded_os_system(command): + _check_subprocess_cmd("os.system", command) + return real_os_system(command) + + def _guarded_os_popen(cmd, *args, **kwargs): + _check_subprocess_cmd("os.popen", cmd) + return real_os_popen(cmd, *args, **kwargs) + + monkeypatch.setattr(_os, "system", _guarded_os_system) + monkeypatch.setattr(_os, "popen", _guarded_os_popen) + + # pty.spawn — POSIX-only. + try: + import pty as _pty + if hasattr(_pty, "spawn"): + real_pty_spawn = _pty.spawn + + def _guarded_pty_spawn(argv, *args, **kwargs): + _check_subprocess_cmd("pty.spawn", argv) + return real_pty_spawn(argv, *args, **kwargs) + + monkeypatch.setattr(_pty, "spawn", _guarded_pty_spawn) + except Exception: + pass + + # asyncio.create_subprocess_* — bypasses subprocess module entirely. + try: + import asyncio as _asyncio + real_async_exec = _asyncio.create_subprocess_exec + real_async_shell = _asyncio.create_subprocess_shell + + async def _guarded_async_exec(program, *args, **kwargs): + _check_subprocess_cmd( + "asyncio.create_subprocess_exec", [program, *args] + ) + return await real_async_exec(program, *args, **kwargs) + + async def _guarded_async_shell(cmd, *args, **kwargs): + _check_subprocess_cmd("asyncio.create_subprocess_shell", cmd) + return await real_async_shell(cmd, *args, **kwargs) + + monkeypatch.setattr(_asyncio, "create_subprocess_exec", _guarded_async_exec) + monkeypatch.setattr( + _asyncio, "create_subprocess_shell", _guarded_async_shell + ) + except Exception: + pass + yield diff --git a/tests/gateway/test_discord_send.py b/tests/gateway/test_discord_send.py index 89be6885a9c..03f442a3b88 100644 --- a/tests/gateway/test_discord_send.py +++ b/tests/gateway/test_discord_send.py @@ -1,3 +1,4 @@ +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import sys @@ -386,3 +387,61 @@ async def test_forum_post_file_creation_failure(): assert result.success is False assert "missing perms" in (result.error or "") + + +# --------------------------------------------------------------------------- +# Typing indicator task lifecycle +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_typing_task_removed_after_api_error(): + """When typing API call fails, stale task must be removed so typing can restart.""" + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + adapter._client = MagicMock() + adapter._client.http = MagicMock() + adapter._client.http.request = AsyncMock(side_effect=Exception("rate limited")) + adapter._typing_tasks = {} + + await adapter.send_typing("12345") + await asyncio.sleep(0.1) + + assert "12345" not in adapter._typing_tasks, \ + "Stale task should be removed after API error" + + +@pytest.mark.asyncio +async def test_typing_restartable_after_error(): + """After a typing error, send_typing should start a new task (not blocked by stale entry).""" + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + adapter._client = MagicMock() + adapter._client.http = MagicMock() + adapter._typing_tasks = {} + + # First call fails + adapter._client.http.request = AsyncMock(side_effect=Exception("503")) + await adapter.send_typing("12345") + await asyncio.sleep(0.1) + + # Second call should work + adapter._client.http.request = AsyncMock() + await adapter.send_typing("12345") + + assert "12345" in adapter._typing_tasks, \ + "Should restart typing after previous failure" + + +@pytest.mark.asyncio +async def test_typing_stop_cleans_up(): + """stop_typing should remove the task from _typing_tasks.""" + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + adapter._client = MagicMock() + adapter._client.http = MagicMock() + adapter._client.http.request = AsyncMock() + adapter._typing_tasks = {} + + await adapter.send_typing("12345") + assert "12345" in adapter._typing_tasks + + await adapter.stop_typing("12345") + assert "12345" not in adapter._typing_tasks diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py index c702d3121db..5b50ec9c9ca 100644 --- a/tests/gateway/test_display_config.py +++ b/tests/gateway/test_display_config.py @@ -41,8 +41,9 @@ class TestResolveDisplaySetting: # Empty config — should get built-in defaults config = {} - # Telegram defaults to tier_high → "all" - assert resolve_display_setting(config, "telegram", "tool_progress") == "all" + # Telegram tier_high override: "new" (not "all") to reduce edit + # pressure during streaming on Telegram's ~1 edit/s flood envelope. + assert resolve_display_setting(config, "telegram", "tool_progress") == "new" # Email defaults to tier_minimal → "off" assert resolve_display_setting(config, "email", "tool_progress") == "off" @@ -179,11 +180,14 @@ class TestPlatformDefaults: """Built-in defaults reflect platform capability tiers.""" def test_high_tier_platforms(self): - """Telegram and Discord default to 'all' tool progress.""" + """Discord defaults to 'all' tool progress; Telegram is in tier_high + but overrides tool_progress to 'new' (less edit pressure).""" from gateway.display_config import resolve_display_setting - for plat in ("telegram", "discord"): - assert resolve_display_setting({}, plat, "tool_progress") == "all", plat + # Telegram: tier_high member with tool_progress="new" override. + assert resolve_display_setting({}, "telegram", "tool_progress") == "new" + # Discord: pure tier_high. + assert resolve_display_setting({}, "discord", "tool_progress") == "all" def test_medium_tier_platforms(self): """Mattermost, Matrix, Feishu, WhatsApp default to 'new' tool progress.""" diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index 39cabd950a4..1d1cf365e0e 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -448,7 +448,8 @@ def test_cache_dm_topic_from_message_no_overwrite(): def _make_mock_message(chat_id=111, chat_type="private", text="hello", thread_id=None, - user_id=42, user_name="Test User", forum_topic_created=None): + user_id=42, user_name="Test User", forum_topic_created=None, + is_topic_message=None): """Create a mock Telegram Message for _build_message_event tests.""" chat = SimpleNamespace( id=chat_id, @@ -464,11 +465,15 @@ def _make_mock_message(chat_id=111, chat_type="private", text="hello", thread_id full_name=user_name, ) + if is_topic_message is None: + is_topic_message = bool(thread_id) if chat_type == "private" else None + msg = SimpleNamespace( chat=chat, from_user=user, text=text, message_thread_id=thread_id, + is_topic_message=is_topic_message, message_id=1001, reply_to_message=None, date=None, @@ -531,6 +536,40 @@ def test_build_message_event_no_auto_skill_without_thread(): assert event.auto_skill is None +def test_build_message_event_filters_non_topic_dm_thread_id(): + """A DM reply-thread id should not be persisted unless Telegram marks it as a topic message.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter() + msg = _make_mock_message(chat_id=111, thread_id=777, is_topic_message=False) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.source.thread_id is None + assert event.source.chat_topic is None + assert event.auto_skill is None + + +def test_build_message_event_preserves_true_dm_topic_thread_id(): + """True DM topic messages should keep their thread id for routing.""" + from gateway.platforms.base import MessageType + + adapter = _make_adapter([ + { + "chat_id": 111, + "topics": [ + {"name": "General", "thread_id": 200}, + ], + } + ]) + adapter._dm_topics["111:General"] = 200 + + msg = _make_mock_message(chat_id=111, thread_id=200, is_topic_message=True) + event = adapter._build_message_event(msg, MessageType.TEXT) + + assert event.source.thread_id == "200" + assert event.source.chat_topic == "General" + + # ── _build_message_event: group_topics skill binding ── # The telegram mock sets sys.modules["telegram.constants"] = telegram_mod (root mock), diff --git a/tests/gateway/test_kanban_notifier.py b/tests/gateway/test_kanban_notifier.py new file mode 100644 index 00000000000..8e85f045037 --- /dev/null +++ b/tests/gateway/test_kanban_notifier.py @@ -0,0 +1,236 @@ +import asyncio +from pathlib import Path + +import pytest + +from gateway.config import Platform +from gateway.run import GatewayRunner +from hermes_cli import kanban_db as kb + + +class RecordingAdapter: + def __init__(self): + self.sent = [] + + async def send(self, chat_id, text, metadata=None): + self.sent.append({"chat_id": chat_id, "text": text, "metadata": metadata or {}}) + + +class DisconnectedAdapters(dict): + """Expose a platform during collection, then simulate disconnect on get().""" + + def get(self, key, default=None): + return None + + +async def _run_one_notifier_tick(monkeypatch, runner): + real_sleep = asyncio.sleep + + async def fake_sleep(delay): + if delay == 5: + return None + runner._running = False + await real_sleep(0) + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + await runner._kanban_notifier_watcher(interval=1) + + +def _make_runner(adapter): + runner = GatewayRunner.__new__(GatewayRunner) + runner._running = True + runner.adapters = {Platform.TELEGRAM: adapter} + runner._kanban_sub_fail_counts = {} + return runner + + +def _create_completed_subscription(summary="done once"): + conn = kb.connect() + try: + tid = kb.create_task(conn, title="notify once", assignee="worker") + kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1") + kb.complete_task(conn, tid, summary=summary) + return tid + finally: + conn.close() + + +def _unseen_terminal_events(tid): + conn = kb.connect() + try: + _, events = kb.unseen_events_for_sub( + conn, + task_id=tid, + platform="telegram", + chat_id="chat-1", + kinds=["completed", "blocked", "gave_up", "crashed", "timed_out"], + ) + return events + finally: + conn.close() + + +def test_kanban_notifier_dedupes_board_slugs_pointing_to_same_db(tmp_path, monkeypatch): + db_path = tmp_path / "shared-kanban.db" + monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) + kb.init_db() + kb.write_board_metadata("alias-a", name="Alias A") + kb.write_board_metadata("alias-b", name="Alias B") + + tid = _create_completed_subscription() + + adapter = RecordingAdapter() + runner = _make_runner(adapter) + + asyncio.run(_run_one_notifier_tick(monkeypatch, runner)) + + assert len(adapter.sent) == 1 + assert "Kanban" in adapter.sent[0]["text"] + assert tid in adapter.sent[0]["text"] + + +def test_kanban_notifier_claim_prevents_second_watcher_send(tmp_path, monkeypatch): + db_path = tmp_path / "single-owner.db" + monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) + kb.init_db() + + tid = _create_completed_subscription() + + adapter1 = RecordingAdapter() + adapter2 = RecordingAdapter() + + asyncio.run(_run_one_notifier_tick(monkeypatch, _make_runner(adapter1))) + asyncio.run(_run_one_notifier_tick(monkeypatch, _make_runner(adapter2))) + + assert len(adapter1.sent) == 1 + assert adapter2.sent == [] + + +def test_kanban_notifier_rewinds_claim_if_adapter_disconnects(tmp_path, monkeypatch): + db_path = tmp_path / "adapter-disconnect.db" + monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) + kb.init_db() + tid = _create_completed_subscription() + + runner = GatewayRunner.__new__(GatewayRunner) + runner._running = True + runner.adapters = DisconnectedAdapters({Platform.TELEGRAM: RecordingAdapter()}) + runner._kanban_sub_fail_counts = {} + + asyncio.run(_run_one_notifier_tick(monkeypatch, runner)) + + assert [ev.kind for ev in _unseen_terminal_events(tid)] == ["completed"] + + +def test_kanban_db_path_is_test_isolated_from_real_home(): + hermes_home = Path(kb.kanban_home()) + production_db = Path.home() / ".hermes" / "kanban.db" + assert kb.kanban_db_path().resolve() != production_db.resolve() + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="x", assignee="worker") + kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1") + finally: + conn.close() + + assert kb.kanban_db_path().resolve().is_relative_to(hermes_home.resolve()) + assert kb.kanban_db_path().resolve() != production_db.resolve() + + +class FailingAdapter: + """Adapter whose send() always raises, simulating a transient send error.""" + + def __init__(self): + self.attempts = 0 + + async def send(self, chat_id, text, metadata=None): + self.attempts += 1 + raise RuntimeError("simulated send failure") + + +def test_kanban_notifier_rewinds_claim_on_send_exception(tmp_path, monkeypatch): + """A raising adapter rewinds the claim so the next tick can retry. + + This is the second rewind path (distinct from the adapter-disconnect path + in test_kanban_notifier_rewinds_claim_if_adapter_disconnects). Here the + adapter is connected and the send call actually fires; the claim must + still rewind so the event isn't lost when send() raises mid-tick. + """ + db_path = tmp_path / "send-failure.db" + monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) + kb.init_db() + tid = _create_completed_subscription() + + adapter = FailingAdapter() + runner = _make_runner(adapter) + + asyncio.run(_run_one_notifier_tick(monkeypatch, runner)) + + # Send was attempted (so we exercised the failure path, not just the + # disconnect path) and the claim was rewound — the unseen-events query + # still returns the event for retry on the next tick. + assert adapter.attempts >= 1, "send should have been attempted at least once" + assert [ev.kind for ev in _unseen_terminal_events(tid)] == ["completed"] + + +def test_notifier_redelivers_same_kind_on_dispatch_cycle(tmp_path, monkeypatch): + """A retry cycle (crashed → reclaimed → crashed) notifies the user twice. + + Before #21398 the notifier auto-unsubscribed on any terminal event kind + (gave_up / crashed / timed_out), so the second crash in a respawn cycle + silently dropped — the subscription was already gone. This test pins the + new contract: subscription survives non-final terminal events; the + cursor handles dedup. + + Two crashes ten seconds apart on the same task — both should land on + the adapter. + """ + db_path = tmp_path / "redeliver-cycle.db" + monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path)) + kb.init_db() + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="cycle test", assignee="worker") + kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1") + # First crash — fired by the dispatcher when the worker PID dies. + kb._append_event(conn, tid, kind="crashed") + finally: + conn.close() + + adapter = RecordingAdapter() + runner = _make_runner(adapter) + asyncio.run(_run_one_notifier_tick(monkeypatch, runner)) + + # First crash delivered. + assert len(adapter.sent) == 1 + assert "crashed" in adapter.sent[0]["text"].lower() + + # Subscription survives — the cursor advanced past event #1, but the + # row is still there. + conn = kb.connect() + try: + subs = kb.list_notify_subs(conn, tid) + assert len(subs) == 1, ( + "Subscription must survive a crashed event so a respawn-cycle " + "second crash also notifies the user (issue #21398)." + ) + + # Second crash — same task, same dispatcher (or a respawn). Append + # another event to simulate the dispatcher firing crashed a second + # time during retry. + kb._append_event(conn, tid, kind="crashed") + finally: + conn.close() + + # New tick: the second event has a fresh id past the cursor advance, + # so it gets claimed and delivered. + runner = _make_runner(adapter) + asyncio.run(_run_one_notifier_tick(monkeypatch, runner)) + + assert len(adapter.sent) == 2, ( + f"Second crashed event should also notify; got {len(adapter.sent)} " + f"deliveries (texts: {[d['text'] for d in adapter.sent]})" + ) + assert "crashed" in adapter.sent[1]["text"].lower() diff --git a/tests/gateway/test_line_plugin.py b/tests/gateway/test_line_plugin.py new file mode 100644 index 00000000000..e7fd2cf9946 --- /dev/null +++ b/tests/gateway/test_line_plugin.py @@ -0,0 +1,644 @@ +"""Tests for the LINE platform adapter plugin. + +Covers the seven synthesis areas from the PR review: + +1. webhook signature verification (HMAC-SHA256, base64) + tampering rejection +2. inbound chat-id resolution for user / group / room sources +3. three-allowlist gating (users / groups / rooms / allow_all) +4. inbound dedup via webhookEventId +5. RequestCache state machine (PENDING → READY → DELIVERED, ERROR) +6. Markdown stripping with URL preservation + LINE-sized chunking +7. send routing: reply token preferred → push fallback → batched at 5/call +8. register() metadata + standalone_send shape +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import base64 +import json +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from tests.gateway._plugin_adapter_loader import load_plugin_adapter + +# Load plugins/platforms/line/adapter.py under plugin_adapter_line so it +# cannot collide with sibling platform-plugin tests in the same xdist worker. +_line = load_plugin_adapter("line") + +verify_line_signature = _line.verify_line_signature +strip_markdown_preserving_urls = _line.strip_markdown_preserving_urls +split_for_line = _line.split_for_line +build_postback_button_message = _line.build_postback_button_message +_resolve_chat = _line._resolve_chat +_allowed_for_source = _line._allowed_for_source +_is_system_bypass = _line._is_system_bypass +RequestCache = _line.RequestCache +State = _line.State +LineAdapter = _line.LineAdapter +register = _line.register +check_requirements = _line.check_requirements +validate_config = _line.validate_config +_standalone_send = _line._standalone_send +_env_enablement = _line._env_enablement +_MessageDeduplicator = _line._MessageDeduplicator + + +# --------------------------------------------------------------------------- +# 1. Signature verification +# --------------------------------------------------------------------------- + +class TestSignature: + + def _sign(self, body: bytes, secret: str) -> str: + digest = hmac.new(secret.encode(), body, hashlib.sha256).digest() + return base64.b64encode(digest).decode() + + def test_valid_signature_passes(self): + body = b'{"events": []}' + sig = self._sign(body, "secret") + assert verify_line_signature(body, sig, "secret") + + def test_tampered_body_rejected(self): + body = b'{"events": []}' + sig = self._sign(body, "secret") + assert not verify_line_signature(body + b" ", sig, "secret") + + def test_wrong_secret_rejected(self): + body = b'{"events": []}' + sig = self._sign(body, "secret") + assert not verify_line_signature(body, sig, "different") + + def test_empty_signature_rejected(self): + assert not verify_line_signature(b"x", "", "secret") + + def test_empty_secret_rejected(self): + assert not verify_line_signature(b"x", "AAAA", "") + + def test_garbage_signature_rejected(self): + assert not verify_line_signature(b"hello", "not base64 at all!!", "s") + + +# --------------------------------------------------------------------------- +# 2. Chat-id / source resolution +# --------------------------------------------------------------------------- + +class TestSourceResolution: + + def test_user_source(self): + chat_id, ctype = _resolve_chat({"type": "user", "userId": "U123"}) + assert chat_id == "U123" + assert ctype == "dm" + + def test_group_source(self): + chat_id, ctype = _resolve_chat({"type": "group", "groupId": "C456", "userId": "U123"}) + assert chat_id == "C456" + assert ctype == "group" + + def test_room_source(self): + chat_id, ctype = _resolve_chat({"type": "room", "roomId": "R789", "userId": "U123"}) + assert chat_id == "R789" + assert ctype == "room" + + def test_unknown_source_falls_back_to_dm(self): + chat_id, ctype = _resolve_chat({"type": "weird"}) + assert chat_id == "" + assert ctype == "dm" + + def test_empty_source(self): + chat_id, ctype = _resolve_chat({}) + assert chat_id == "" + assert ctype == "dm" + + +# --------------------------------------------------------------------------- +# 3. Three-allowlist gating +# --------------------------------------------------------------------------- + +class TestAllowlist: + + def test_allow_all_short_circuits(self): + for src in [ + {"type": "user", "userId": "Ufoo"}, + {"type": "group", "groupId": "Cfoo"}, + {"type": "room", "roomId": "Rfoo"}, + ]: + assert _allowed_for_source(src, allow_all=True, user_ids=set(), group_ids=set(), room_ids=set()) + + def test_user_in_allowlist_passes(self): + src = {"type": "user", "userId": "Uok"} + assert _allowed_for_source(src, allow_all=False, user_ids={"Uok"}, group_ids=set(), room_ids=set()) + + def test_user_not_in_allowlist_rejected(self): + src = {"type": "user", "userId": "Uother"} + assert not _allowed_for_source(src, allow_all=False, user_ids={"Uok"}, group_ids=set(), room_ids=set()) + + def test_group_uses_group_list_not_user_list(self): + src = {"type": "group", "groupId": "Cok", "userId": "Uany"} + assert _allowed_for_source(src, allow_all=False, user_ids={"Uany"}, group_ids={"Cok"}, room_ids=set()) + assert not _allowed_for_source(src, allow_all=False, user_ids={"Uany"}, group_ids=set(), room_ids=set()) + + def test_room_uses_room_list(self): + src = {"type": "room", "roomId": "Rok"} + assert _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids={"Rok"}) + assert not _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids=set()) + + def test_unknown_type_rejected(self): + src = {"type": "weird"} + assert not _allowed_for_source(src, allow_all=False, user_ids=set(), group_ids=set(), room_ids=set()) + + +# --------------------------------------------------------------------------- +# 4. Inbound dedup +# --------------------------------------------------------------------------- + +class TestDedup: + + def test_first_event_not_duplicate(self): + d = _MessageDeduplicator() + assert not d.is_duplicate("evt1") + + def test_repeat_event_marked_duplicate(self): + d = _MessageDeduplicator() + d.is_duplicate("evt1") + assert d.is_duplicate("evt1") + + def test_blank_id_not_treated_as_duplicate(self): + d = _MessageDeduplicator() + # Blank IDs should always pass through (don't lock out unidentifiable events). + assert not d.is_duplicate("") + assert not d.is_duplicate("") + + def test_lru_eviction_under_pressure(self): + d = _MessageDeduplicator(max_size=10) + for i in range(20): + d.is_duplicate(f"evt{i}") + # Exact eviction order isn't specified, but the cap must be enforced. + # Insert one more and assert the bookkeeping doesn't grow without bound. + d.is_duplicate("evt20") + assert len(d._seen) <= 20 # bounded — exact cap depends on eviction policy + + +# --------------------------------------------------------------------------- +# 5. RequestCache state machine +# --------------------------------------------------------------------------- + +class TestRequestCache: + + def test_register_pending_is_pending(self): + c = RequestCache() + rid = c.register_pending("Uchat") + assert c.get(rid).state is State.PENDING + assert c.get(rid).chat_id == "Uchat" + + def test_set_ready_transitions(self): + c = RequestCache() + rid = c.register_pending("Uchat") + c.set_ready(rid, "the answer") + assert c.get(rid).state is State.READY + assert c.get(rid).payload == "the answer" + + def test_set_error_transitions(self): + c = RequestCache() + rid = c.register_pending("Uchat") + c.set_error(rid, "boom") + assert c.get(rid).state is State.ERROR + assert c.get(rid).payload == "boom" + + def test_mark_delivered_from_ready(self): + c = RequestCache() + rid = c.register_pending("Uchat") + c.set_ready(rid, "x") + c.mark_delivered(rid) + assert c.get(rid).state is State.DELIVERED + + def test_mark_delivered_from_error(self): + c = RequestCache() + rid = c.register_pending("Uchat") + c.set_error(rid, "x") + c.mark_delivered(rid) + assert c.get(rid).state is State.DELIVERED + + def test_set_ready_on_delivered_is_noop(self): + c = RequestCache() + rid = c.register_pending("Uchat") + c.set_ready(rid, "first") + c.mark_delivered(rid) + c.set_ready(rid, "second") + # DELIVERED is terminal — no further mutation + assert c.get(rid).payload == "first" + assert c.get(rid).state is State.DELIVERED + + def test_find_pending_for_chat(self): + c = RequestCache() + rid_a = c.register_pending("Ua") + rid_b = c.register_pending("Ub") + assert c.find_pending_for_chat("Ua") == rid_a + assert c.find_pending_for_chat("Ub") == rid_b + assert c.find_pending_for_chat("Uc") is None + c.set_ready(rid_a, "x") + # No longer PENDING — should not be found + assert c.find_pending_for_chat("Ua") is None + + +# --------------------------------------------------------------------------- +# 6. Markdown stripping + chunking +# --------------------------------------------------------------------------- + +class TestMarkdownAndChunking: + + def test_bold_stripped(self): + assert strip_markdown_preserving_urls("**hello**") == "hello" + + def test_italic_stripped(self): + assert strip_markdown_preserving_urls("*hello*") == "hello" + + def test_inline_code_unfenced(self): + assert strip_markdown_preserving_urls("run `ls -la`") == "run ls -la" + + def test_link_preserved_with_url(self): + out = strip_markdown_preserving_urls("see [here](https://x.com)") + assert "https://x.com" in out + assert "here (https://x.com)" in out + + def test_heading_prefix_stripped(self): + out = strip_markdown_preserving_urls("# Title\n## Sub") + assert out == "Title\nSub" + + def test_bullet_marker_replaced(self): + out = strip_markdown_preserving_urls("- a\n- b") + assert out == "• a\n• b" + + def test_code_fence_content_kept(self): + # Source files often contain code snippets — the agent should still + # see the content as plain text, just without backticks. + md = "```python\nprint('hi')\n```" + out = strip_markdown_preserving_urls(md) + assert "print('hi')" in out + assert "```" not in out + + def test_split_short_returns_single_chunk(self): + assert split_for_line("hi") == ["hi"] + + def test_split_long_chunks_at_paragraph_boundary(self): + text = "para1\n\npara2\n\npara3" + chunks = split_for_line(text, max_chars=8) + assert all(len(c) <= 8 for c in chunks), chunks + assert len(chunks) >= 2 + + def test_split_caps_at_five_chunks(self): + # 1000 paragraphs of 100 chars each — must cap at 5 LINE bubbles. + text = "\n\n".join(["x" * 100 for _ in range(1000)]) + chunks = split_for_line(text) + assert len(chunks) <= 5 + + +# --------------------------------------------------------------------------- +# 7. Send routing (reply -> push fallback, batching, system-bypass) +# --------------------------------------------------------------------------- + +class TestSendRouting: + + @pytest.fixture + def adapter(self, monkeypatch): + monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False) + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={ + "channel_access_token": "tok", + "channel_secret": "sec", + }) + ad = LineAdapter(cfg) + ad._client = MagicMock() + ad._client.reply = AsyncMock() + ad._client.push = AsyncMock() + return ad + + def test_system_bypass_recognized(self): + assert _is_system_bypass("⚡ Interrupting current run") + assert _is_system_bypass("⏳ Queued — agent is busy") + assert _is_system_bypass("⏩ Steered toward new task") + assert not _is_system_bypass("Hello world") + assert not _is_system_bypass("") + + def test_send_uses_reply_when_token_present(self, adapter): + import time as _time + adapter._reply_tokens["Uchat"] = ("rt-token", _time.time() + 30) + result = asyncio.run(adapter.send("Uchat", "hello")) + assert result.success + adapter._client.reply.assert_called_once() + adapter._client.push.assert_not_called() + # Token consumed (single-use) + assert "Uchat" not in adapter._reply_tokens + + def test_send_falls_back_to_push_when_no_token(self, adapter): + result = asyncio.run(adapter.send("Uchat", "hello")) + assert result.success + adapter._client.push.assert_called_once() + adapter._client.reply.assert_not_called() + + def test_send_falls_back_to_push_when_reply_fails(self, adapter): + import time as _time + adapter._reply_tokens["Uchat"] = ("rt-token", _time.time() + 30) + adapter._client.reply.side_effect = RuntimeError("expired") + result = asyncio.run(adapter.send("Uchat", "hello")) + assert result.success + adapter._client.reply.assert_called_once() + adapter._client.push.assert_called_once() + + def test_send_returns_failure_when_push_fails(self, adapter): + adapter._client.push.side_effect = RuntimeError("network") + result = asyncio.run(adapter.send("Uchat", "hello")) + assert not result.success + assert "network" in result.error + + def test_send_pending_button_caches_response(self, adapter): + # Simulate that the slow-LLM postback button has fired. + rid = adapter._cache.register_pending("Uchat") + adapter._pending_buttons["Uchat"] = rid + result = asyncio.run(adapter.send("Uchat", "the answer")) + assert result.success + # Response must have been cached, not pushed/replied. + adapter._client.reply.assert_not_called() + adapter._client.push.assert_not_called() + assert adapter._cache.get(rid).state is State.READY + assert adapter._cache.get(rid).payload == "the answer" + + def test_send_system_bypass_skips_postback_cache(self, adapter): + # Even with a pending button, system busy-acks must surface visibly. + rid = adapter._cache.register_pending("Uchat") + adapter._pending_buttons["Uchat"] = rid + result = asyncio.run(adapter.send("Uchat", "⚡ Interrupting current run")) + assert result.success + # Bypass goes through push (no reply token stored) + adapter._client.push.assert_called_once() + # And the cache entry is unchanged (still PENDING for the eventual answer) + assert adapter._cache.get(rid).state is State.PENDING + + def test_send_caps_messages_per_call_at_five(self, adapter): + # Build a payload that would naturally split into more than 5 LINE + # bubbles; the chunker should cap at 5 + truncate. + big = "\n\n".join(["x" * 4500 for _ in range(20)]) + result = asyncio.run(adapter.send("Uchat", big)) + assert result.success + call_kwargs = adapter._client.push.call_args + # call_args is (args, kwargs); for our send the messages are the 2nd positional + sent_messages = call_kwargs.args[1] if call_kwargs.args else call_kwargs.kwargs.get("messages") + # Without args, fall back to inspecting the call shape + if sent_messages is None: + # We invoked client.push(chat_id, messages) — check first batch + sent_messages = adapter._client.push.call_args.args[1] + assert len(sent_messages) <= 5 + + def test_format_message_strips_markdown(self, adapter): + out = adapter.format_message("**bold** [link](https://x.com)") + assert "**" not in out + assert "https://x.com" in out + + +# --------------------------------------------------------------------------- +# 8. Register() metadata + plugin entry points +# --------------------------------------------------------------------------- + +class TestRegister: + + class _FakeCtx: + def __init__(self): + self.kwargs = None + + def register_platform(self, **kw): + self.kwargs = kw + + def test_register_calls_register_platform(self): + ctx = self._FakeCtx() + register(ctx) + assert ctx.kwargs is not None + assert ctx.kwargs["name"] == "line" + assert ctx.kwargs["label"] == "LINE" + + def test_register_advertises_required_env(self): + ctx = self._FakeCtx() + register(ctx) + assert set(ctx.kwargs["required_env"]) == { + "LINE_CHANNEL_ACCESS_TOKEN", + "LINE_CHANNEL_SECRET", + } + + def test_register_wires_allowlist_envs(self): + ctx = self._FakeCtx() + register(ctx) + assert ctx.kwargs["allowed_users_env"] == "LINE_ALLOWED_USERS" + assert ctx.kwargs["allow_all_env"] == "LINE_ALLOW_ALL_USERS" + + def test_register_wires_cron_home_channel(self): + ctx = self._FakeCtx() + register(ctx) + assert ctx.kwargs["cron_deliver_env_var"] == "LINE_HOME_CHANNEL" + + def test_register_provides_standalone_sender(self): + ctx = self._FakeCtx() + register(ctx) + assert callable(ctx.kwargs["standalone_sender_fn"]) + + def test_register_provides_env_enablement(self): + ctx = self._FakeCtx() + register(ctx) + assert callable(ctx.kwargs["env_enablement_fn"]) + + def test_register_factory_yields_line_adapter(self): + ctx = self._FakeCtx() + register(ctx) + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={ + "channel_access_token": "tok", + "channel_secret": "sec", + }) + ad = ctx.kwargs["adapter_factory"](cfg) + assert isinstance(ad, LineAdapter) + + def test_max_message_length_below_line_per_bubble_limit(self): + ctx = self._FakeCtx() + register(ctx) + # LINE per-bubble limit is 5000; we register 4500 to leave headroom. + assert ctx.kwargs["max_message_length"] <= 5000 + + +class TestEnvEnablement: + + def test_returns_none_without_credentials(self, monkeypatch): + monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False) + assert _env_enablement() is None + + def test_returns_dict_with_credentials(self, monkeypatch): + monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok") + monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec") + assert _env_enablement() == {} + + def test_seeds_port_from_env(self, monkeypatch): + monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok") + monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec") + monkeypatch.setenv("LINE_PORT", "8080") + assert _env_enablement() == {"port": 8080} + + def test_seeds_public_url(self, monkeypatch): + monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok") + monkeypatch.setenv("LINE_CHANNEL_SECRET", "sec") + monkeypatch.setenv("LINE_PUBLIC_URL", "https://my-tunnel.example.com") + result = _env_enablement() + assert result["public_url"] == "https://my-tunnel.example.com" + + +class TestStandaloneSend: + + def test_missing_token_returns_error(self, monkeypatch): + monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False) + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={}) + result = asyncio.run(_standalone_send(cfg, "Uchat", "hi")) + assert "error" in result + + def test_missing_chat_id_returns_error(self, monkeypatch): + monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "tok") + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={}) + result = asyncio.run(_standalone_send(cfg, "", "hi")) + assert "error" in result + + def test_pushes_via_client_when_credentials_present(self, monkeypatch): + from gateway.config import PlatformConfig + + push_calls = [] + + class _FakeClient: + def __init__(self, *a, **kw): + pass + + async def push(self, chat_id, messages): + push_calls.append((chat_id, messages)) + + monkeypatch.setattr(_line, "_LineClient", _FakeClient) + cfg = PlatformConfig( + enabled=True, + extra={"channel_access_token": "tok"}, + ) + result = asyncio.run(_standalone_send(cfg, "Uchat", "hello")) + assert result.get("success") is True + assert len(push_calls) == 1 + assert push_calls[0][0] == "Uchat" + # Message wraps as text bubble + assert push_calls[0][1][0]["type"] == "text" + + +class TestPostbackButtonShape: + + def test_template_buttons_structure(self): + msg = build_postback_button_message("hi", "Tap me", "rid-1") + assert msg["type"] == "template" + assert msg["template"]["type"] == "buttons" + assert msg["template"]["text"] == "hi" + actions = msg["template"]["actions"] + assert len(actions) == 1 + assert actions[0]["type"] == "postback" + data = json.loads(actions[0]["data"]) + assert data == {"action": "show_response", "request_id": "rid-1"} + + def test_text_truncated_to_160(self): + long = "x" * 200 + msg = build_postback_button_message(long, "Tap", "rid") + assert len(msg["template"]["text"]) <= 160 + + def test_alt_text_truncated_to_400(self): + long = "x" * 500 + msg = build_postback_button_message(long, "Tap", "rid") + assert len(msg["altText"]) <= 400 + + +class TestCheckRequirements: + + def test_rejects_without_token(self, monkeypatch): + monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False) + monkeypatch.setenv("LINE_CHANNEL_SECRET", "s") + assert not check_requirements() + + def test_rejects_without_secret(self, monkeypatch): + monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t") + monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False) + assert not check_requirements() + + +class TestValidateConfig: + + def test_validates_from_extra(self): + from gateway.config import PlatformConfig + cfg = PlatformConfig( + enabled=True, + extra={"channel_access_token": "t", "channel_secret": "s"}, + ) + assert validate_config(cfg) + + def test_rejects_empty_config(self, monkeypatch): + monkeypatch.delenv("LINE_CHANNEL_ACCESS_TOKEN", raising=False) + monkeypatch.delenv("LINE_CHANNEL_SECRET", raising=False) + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={}) + assert not validate_config(cfg) + + +class TestAdapterInit: + + def test_init_from_config_extra(self, monkeypatch): + for k in ("LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET", "LINE_PORT"): + monkeypatch.delenv(k, raising=False) + from gateway.config import PlatformConfig + cfg = PlatformConfig( + enabled=True, + extra={ + "channel_access_token": "tok", + "channel_secret": "sec", + "port": 7777, + "public_url": "https://x.example.com", + "allowed_users": ["U1", "U2"], + }, + ) + ad = LineAdapter(cfg) + assert ad.channel_access_token == "tok" + assert ad.channel_secret == "sec" + assert ad.webhook_port == 7777 + assert ad.public_base_url == "https://x.example.com" + assert ad.allowed_users == {"U1", "U2"} + + def test_env_overrides_extra(self, monkeypatch): + monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "env-tok") + monkeypatch.setenv("LINE_PORT", "1234") + from gateway.config import PlatformConfig + cfg = PlatformConfig( + enabled=True, + extra={"channel_access_token": "extra-tok", "channel_secret": "s", "port": 5555}, + ) + ad = LineAdapter(cfg) + assert ad.channel_access_token == "env-tok" + assert ad.webhook_port == 1234 + + def test_csv_allowlist_parsed(self, monkeypatch): + monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t") + monkeypatch.setenv("LINE_CHANNEL_SECRET", "s") + monkeypatch.setenv("LINE_ALLOWED_USERS", "U1, U2,U3") + monkeypatch.setenv("LINE_ALLOWED_GROUPS", "C1") + from gateway.config import PlatformConfig + ad = LineAdapter(PlatformConfig(enabled=True)) + assert ad.allowed_users == {"U1", "U2", "U3"} + assert ad.allowed_groups == {"C1"} + + def test_get_chat_info_infers_type_from_prefix(self, monkeypatch): + monkeypatch.setenv("LINE_CHANNEL_ACCESS_TOKEN", "t") + monkeypatch.setenv("LINE_CHANNEL_SECRET", "s") + from gateway.config import PlatformConfig + ad = LineAdapter(PlatformConfig(enabled=True)) + assert asyncio.run(ad.get_chat_info("U123"))["type"] == "dm" + assert asyncio.run(ad.get_chat_info("C123"))["type"] == "group" + assert asyncio.run(ad.get_chat_info("R123"))["type"] == "channel" diff --git a/tests/gateway/test_shutdown_forensics.py b/tests/gateway/test_shutdown_forensics.py new file mode 100644 index 00000000000..23e3d95fb88 --- /dev/null +++ b/tests/gateway/test_shutdown_forensics.py @@ -0,0 +1,250 @@ +"""Tests for gateway.shutdown_forensics — fast snapshot + async diag spawn.""" + +from __future__ import annotations + +import json +import os +import signal +import sys +import time +from pathlib import Path + +import pytest + +from gateway import shutdown_forensics as sf + + +# --------------------------------------------------------------------------- +# _signal_name +# --------------------------------------------------------------------------- + +class TestSignalName: + def test_known_signals_resolve_to_names(self): + assert sf._signal_name(signal.SIGTERM) == "SIGTERM" + assert sf._signal_name(signal.SIGINT) == "SIGINT" + + def test_unknown_int_returns_signal_num_token(self): + # Pick an integer extremely unlikely to ever be a real signal alias + assert sf._signal_name(9999) == "signal#9999" + + def test_none_returns_unknown(self): + assert sf._signal_name(None) == "UNKNOWN" + + def test_non_integer_falls_back_to_str(self): + assert sf._signal_name("SIGTERM") == "SIGTERM" + + +# --------------------------------------------------------------------------- +# snapshot_shutdown_context +# --------------------------------------------------------------------------- + +class TestSnapshotShutdownContext: + def test_includes_self_pid_and_signal(self): + ctx = sf.snapshot_shutdown_context(signal.SIGTERM) + assert ctx["pid"] == os.getpid() + assert ctx["signal"] == "SIGTERM" + assert ctx["signal_num"] == int(signal.SIGTERM) + + def test_handles_none_signal(self): + ctx = sf.snapshot_shutdown_context(None) + assert ctx["signal"] == "UNKNOWN" + assert ctx["signal_num"] is None + + def test_includes_timestamps(self): + before = time.time() + ctx = sf.snapshot_shutdown_context(signal.SIGTERM) + after = time.time() + assert before <= ctx["ts"] <= after + assert isinstance(ctx["ts_monotonic"], float) + + @pytest.mark.skipif(sys.platform == "win32", reason="Linux /proc not present") + def test_includes_parent_summary_on_linux(self): + ctx = sf.snapshot_shutdown_context(signal.SIGTERM) + assert "parent" in ctx + assert ctx["parent"]["pid"] == os.getppid() + + def test_under_systemd_flag_uses_invocation_id(self, monkeypatch): + monkeypatch.setenv("INVOCATION_ID", "abc123") + ctx = sf.snapshot_shutdown_context(signal.SIGTERM) + assert ctx["under_systemd"] is True + assert ctx["systemd_invocation_id"] == "abc123" + + def test_under_systemd_false_without_invocation_id_and_normal_ppid( + self, monkeypatch + ): + monkeypatch.delenv("INVOCATION_ID", raising=False) + # We can't actually change ppid; skip if we happen to be reaped + # by init (e.g. running under tini). + if os.getppid() == 1: + pytest.skip("test process is reaped by init") + ctx = sf.snapshot_shutdown_context(signal.SIGTERM) + assert ctx["under_systemd"] is False + + def test_completes_quickly(self): + """Snapshot must NOT block — it runs inside the asyncio signal handler.""" + start = time.monotonic() + sf.snapshot_shutdown_context(signal.SIGTERM) + elapsed = time.monotonic() - start + # Generous bound; the function should be sub-millisecond in practice. + assert elapsed < 0.5, f"snapshot took {elapsed:.3f}s — too slow" + + def test_detects_takeover_marker_for_self(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + marker = tmp_path / ".gateway-takeover.json" + marker.write_text( + f'{{"target_pid": {os.getpid()}, "replacer_pid": 99999}}', + encoding="utf-8", + ) + ctx = sf.snapshot_shutdown_context(signal.SIGTERM) + assert "takeover_marker" in ctx + assert ctx["takeover_marker_for_self"] is True + + def test_detects_takeover_marker_for_other(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + marker = tmp_path / ".gateway-takeover.json" + marker.write_text( + '{"target_pid": 1, "replacer_pid": 99999}', encoding="utf-8" + ) + ctx = sf.snapshot_shutdown_context(signal.SIGTERM) + assert ctx["takeover_marker_for_self"] is False + + def test_detects_planned_stop_marker(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + marker = tmp_path / ".gateway-planned-stop.json" + marker.write_text( + f'{{"target_pid": {os.getpid()}}}', encoding="utf-8" + ) + ctx = sf.snapshot_shutdown_context(signal.SIGTERM) + assert "planned_stop_marker" in ctx + + +# --------------------------------------------------------------------------- +# format_context_for_log / context_as_json +# --------------------------------------------------------------------------- + +class TestFormatters: + def test_format_context_for_log_includes_signal_and_parent(self): + ctx = sf.snapshot_shutdown_context(signal.SIGTERM) + line = sf.format_context_for_log(ctx) + assert "signal=SIGTERM" in line + assert "parent_pid=" in line + assert "parent_cmdline=" in line + + def test_context_as_json_round_trips(self): + ctx = sf.snapshot_shutdown_context(signal.SIGTERM) + payload = sf.context_as_json(ctx) + decoded = json.loads(payload) + assert decoded["pid"] == os.getpid() + assert decoded["signal"] == "SIGTERM" + + def test_context_as_json_handles_unserialisable_values(self): + ctx = {"signal": "SIGTERM", "weird": object()} + payload = sf.context_as_json(ctx) + # default=str means objects get repr'd, JSON stays valid + decoded = json.loads(payload) + assert decoded["signal"] == "SIGTERM" + assert "weird" in decoded + + +# --------------------------------------------------------------------------- +# spawn_async_diagnostic +# --------------------------------------------------------------------------- + +class TestSpawnAsyncDiagnostic: + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only diagnostic") + def test_spawns_subprocess_and_writes_output(self, tmp_path): + log_path = tmp_path / "diag.log" + pid = sf.spawn_async_diagnostic(log_path, "SIGTERM", timeout_seconds=3.0) + assert pid is not None and pid > 0 + + # Wait briefly for the subprocess to write — bounded by its own timeout. + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if log_path.exists() and log_path.stat().st_size > 0: + # Wait a touch longer for the script to finish writing + time.sleep(0.5) + break + time.sleep(0.1) + + # Reap the subprocess so it doesn't show up as a zombie. + try: + os.waitpid(pid, 0) + except (ChildProcessError, OSError): + pass + + assert log_path.exists() + contents = log_path.read_text(encoding="utf-8", errors="replace") + assert "shutdown diagnostic" in contents + assert "SIGTERM" in contents + + def test_returns_none_on_windows(self, tmp_path, monkeypatch): + monkeypatch.setattr(sf, "sys", type("M", (), {"platform": "win32"})()) + result = sf.spawn_async_diagnostic( + tmp_path / "diag.log", "SIGTERM", timeout_seconds=1.0 + ) + assert result is None + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only diagnostic") + def test_handles_unwritable_log_path_gracefully(self, tmp_path): + # Point at a nonexistent parent that we can't create + log_path = Path("/proc/cant-write-here/diag.log") + result = sf.spawn_async_diagnostic(log_path, "SIGTERM", timeout_seconds=1.0) + assert result is None + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only diagnostic") + def test_does_not_block_caller(self, tmp_path): + """The spawn must return immediately even if ``ps`` takes seconds.""" + log_path = tmp_path / "diag.log" + start = time.monotonic() + sf.spawn_async_diagnostic(log_path, "SIGTERM", timeout_seconds=10.0) + elapsed = time.monotonic() - start + # Spawning bash in detached mode takes a few ms; anything under 1s + # is plenty of headroom and proves we're not waiting on it. + assert elapsed < 1.0, f"spawn blocked for {elapsed:.2f}s" + + +# --------------------------------------------------------------------------- +# _parse_systemd_duration_to_us +# --------------------------------------------------------------------------- + +class TestParseSystemdDuration: + def test_seconds(self): + assert sf._parse_systemd_duration_to_us("90s") == 90 * 1_000_000 + + def test_minutes(self): + assert sf._parse_systemd_duration_to_us("3min") == 180 * 1_000_000 + + def test_combined_min_sec(self): + assert sf._parse_systemd_duration_to_us("1min 30s") == 90 * 1_000_000 + + def test_hours(self): + assert sf._parse_systemd_duration_to_us("1h") == 3600 * 1_000_000 + + def test_milliseconds(self): + assert sf._parse_systemd_duration_to_us("500ms") == 500_000 + + def test_empty_returns_none(self): + assert sf._parse_systemd_duration_to_us("") is None + + def test_unknown_unit_returns_none(self): + assert sf._parse_systemd_duration_to_us("90weeks") is None + + +# --------------------------------------------------------------------------- +# check_systemd_timing_alignment +# --------------------------------------------------------------------------- + +class TestCheckSystemdTimingAlignment: + def test_returns_none_when_not_under_systemd(self, monkeypatch): + monkeypatch.delenv("INVOCATION_ID", raising=False) + result = sf.check_systemd_timing_alignment(180.0) + assert result is None + + def test_returns_none_when_unit_undeterminable(self, monkeypatch): + monkeypatch.setenv("INVOCATION_ID", "abc") + # /proc/self/cgroup likely doesn't end in .service for the test runner + result = sf.check_systemd_timing_alignment(180.0) + # Either None (we couldn't find a unit) or a dict with mismatch info + # for whatever unit pytest IS in. Both are valid; we just ensure + # the function doesn't raise. + assert result is None or isinstance(result, dict) diff --git a/tests/gateway/test_slash_access.py b/tests/gateway/test_slash_access.py new file mode 100644 index 00000000000..5e21ac8b610 --- /dev/null +++ b/tests/gateway/test_slash_access.py @@ -0,0 +1,289 @@ +"""Unit tests for gateway.slash_access — per-platform slash command access control. + +Tests the pure policy resolver (no gateway plumbing). Integration tests that +exercise the dispatch site live in test_slash_access_dispatch.py. +""" +from __future__ import annotations + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.session import SessionSource +from gateway.slash_access import ( + SlashAccessPolicy, + policy_for_source, + policy_from_extra, +) + + +# --------------------------------------------------------------------------- +# policy_from_extra — input normalization + scope resolution +# --------------------------------------------------------------------------- + + +class TestPolicyFromExtra: + def test_empty_extra_is_disabled(self): + p = policy_from_extra({}, "dm") + assert p.enabled is False + assert p.admin_user_ids == frozenset() + assert p.user_allowed_commands == frozenset() + + def test_disabled_policy_treats_anyone_as_admin(self): + # When gating is off, downstream code uses is_admin/can_run uniformly. + # Both must short-circuit to True so existing behavior is preserved. + p = policy_from_extra({}, "dm") + assert p.is_admin("anyone") is True + assert p.can_run("anyone", "stop") is True + + def test_dm_admin_list_only(self): + p = policy_from_extra({"allow_admin_from": ["111", "222"]}, "dm") + assert p.enabled is True + assert p.admin_user_ids == frozenset({"111", "222"}) + assert p.user_allowed_commands == frozenset() + + def test_admin_runs_anything(self): + p = policy_from_extra( + {"allow_admin_from": [111], "user_allowed_commands": ["help"]}, + "dm", + ) + assert p.is_admin("111") is True + assert p.can_run("111", "stop") is True + assert p.can_run("111", "kanban") is True + + def test_non_admin_runs_only_listed_commands(self): + p = policy_from_extra( + { + "allow_admin_from": ["111"], + "user_allowed_commands": ["status", "model"], + }, + "dm", + ) + assert p.is_admin("999") is False + assert p.can_run("999", "status") is True + assert p.can_run("999", "model") is True + assert p.can_run("999", "stop") is False + assert p.can_run("999", "kanban") is False + + def test_always_allowed_floor_for_non_admin(self): + # /help and /whoami always reachable so users can see what they can do. + p = policy_from_extra( + {"allow_admin_from": ["111"], "user_allowed_commands": []}, + "dm", + ) + assert p.can_run("999", "help") is True + assert p.can_run("999", "whoami") is True + assert p.can_run("999", "stop") is False + + def test_unknown_user_id_blocked(self): + # Empty/None user_id → no admin status, no command access (except floor). + p = policy_from_extra( + {"allow_admin_from": ["111"], "user_allowed_commands": ["status"]}, + "dm", + ) + assert p.is_admin(None) is False + assert p.can_run(None, "status") is True # listed command works + assert p.can_run(None, "stop") is False + assert p.can_run("", "stop") is False + + def test_id_coercion_ints_become_strings(self): + # YAML often loads numeric IDs as ints; we stringify on ingest. + p = policy_from_extra({"allow_admin_from": [12345, 67890]}, "dm") + assert p.admin_user_ids == frozenset({"12345", "67890"}) + assert p.is_admin("12345") is True + assert p.is_admin(12345) is True # is_admin also stringifies + + def test_id_coercion_csv_string(self): + p = policy_from_extra({"allow_admin_from": "111, 222 ,333"}, "dm") + assert p.admin_user_ids == frozenset({"111", "222", "333"}) + + def test_command_coercion_strips_leading_slash_and_lowercases(self): + p = policy_from_extra( + { + "allow_admin_from": ["111"], + "user_allowed_commands": ["/Status", "MODEL", "/help"], + }, + "dm", + ) + assert p.user_allowed_commands == frozenset({"status", "model", "help"}) + + def test_command_coercion_csv_string(self): + p = policy_from_extra( + { + "allow_admin_from": ["111"], + "user_allowed_commands": "status, model , /help", + }, + "dm", + ) + assert p.user_allowed_commands == frozenset({"status", "model", "help"}) + + def test_group_scope_uses_group_keys(self): + extra = { + "allow_admin_from": ["111"], # DM admins + "user_allowed_commands": ["status"], # DM commands + "group_allow_admin_from": ["222"], + "group_user_allowed_commands": ["help"], + } + dm = policy_from_extra(extra, "dm") + gp = policy_from_extra(extra, "group") + assert dm.admin_user_ids == frozenset({"111"}) + assert gp.admin_user_ids == frozenset({"222"}) + assert dm.user_allowed_commands == frozenset({"status"}) + # group's user_allowed_commands does not leak into DM's allowed list + # except via the explicit fallback rule (only when DM list is unset). + assert "help" in gp.user_allowed_commands + + def test_dm_falls_back_to_group_user_commands_when_dm_unset(self): + # Common case: operator wants the same command set DM and group; + # they should only have to list it once on the group keys. + extra = { + "allow_admin_from": ["111"], + "group_user_allowed_commands": ["status", "model"], + } + dm = policy_from_extra(extra, "dm") + assert dm.user_allowed_commands == frozenset({"status", "model"}) + + def test_dm_admin_does_not_imply_group_admin(self): + # Admin lists are scope-specific. DM admin must not auto-promote in groups. + extra = {"allow_admin_from": ["111"]} + dm = policy_from_extra(extra, "dm") + gp = policy_from_extra(extra, "group") + assert dm.is_admin("111") is True + # Group has no admin list set → gating disabled in groups → "111" + # gets unrestricted access, but that's the backward-compat fallback, + # not implicit admin promotion. The distinction matters when the + # group DOES have an admin list set: + extra2 = { + "allow_admin_from": ["111"], + "group_allow_admin_from": ["222"], + } + gp2 = policy_from_extra(extra2, "group") + assert gp2.is_admin("111") is False + assert gp2.is_admin("222") is True + + +# --------------------------------------------------------------------------- +# policy_for_source — wires GatewayConfig + SessionSource together +# --------------------------------------------------------------------------- + + +class TestPolicyForSource: + def test_no_config_returns_disabled(self): + p = policy_for_source(None, None) + assert p.enabled is False + assert p.is_admin("anyone") is True + + def test_no_platform_config_returns_disabled(self): + cfg = GatewayConfig(platforms={}) + src = SessionSource( + platform=Platform.DISCORD, chat_id="42", chat_type="dm", user_id="7" + ) + p = policy_for_source(cfg, src) + assert p.enabled is False + + def test_dm_chat_type_resolves_to_dm_scope(self): + cfg = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": ["status"], + "group_allow_admin_from": ["222"], + "group_user_allowed_commands": ["help"], + }, + ) + } + ) + dm_src = SessionSource( + platform=Platform.DISCORD, chat_id="A", chat_type="dm", user_id="111" + ) + p = policy_for_source(cfg, dm_src) + assert p.is_admin("111") is True + assert p.can_run("999", "status") is True + assert p.can_run("999", "help") is True # always-allowed floor + assert p.can_run("999", "kanban") is False + + def test_group_chat_type_resolves_to_group_scope(self): + cfg = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": ["status"], + "group_allow_admin_from": ["222"], + "group_user_allowed_commands": ["help"], + }, + ) + } + ) + grp_src = SessionSource( + platform=Platform.DISCORD, chat_id="G", chat_type="group", user_id="222" + ) + p = policy_for_source(cfg, grp_src) + assert p.is_admin("222") is True + assert p.is_admin("111") is False # DM admin, not group admin + # In group scope, the only listed user command is "help"; "status" + # is not in the group list and should be denied for non-admins. + assert p.can_run("999", "help") is True + assert p.can_run("999", "status") is False + + def test_channel_thread_chat_types_treated_as_group_scope(self): + # Discord channels and threads are group-scoped, not DM-scoped. + cfg = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + extra={ + "allow_admin_from": ["111"], + "group_allow_admin_from": ["222"], + }, + ) + } + ) + for ct in ("group", "channel", "thread", "supergroup"): + src = SessionSource( + platform=Platform.DISCORD, chat_id="X", chat_type=ct, user_id="222" + ) + p = policy_for_source(cfg, src) + assert p.is_admin("222") is True, f"chat_type={ct} should map to group scope" + assert p.is_admin("111") is False, f"chat_type={ct} should not see DM admins" + + def test_no_admin_list_for_dm_means_unrestricted_in_dm(self): + # Group has admin list, DM does not → DM gating disabled, group active. + cfg = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + extra={"group_allow_admin_from": ["222"]}, + ) + } + ) + dm_src = SessionSource( + platform=Platform.DISCORD, chat_id="A", chat_type="dm", user_id="999" + ) + grp_src = SessionSource( + platform=Platform.DISCORD, chat_id="G", chat_type="group", user_id="999" + ) + dm_p = policy_for_source(cfg, dm_src) + grp_p = policy_for_source(cfg, grp_src) + assert dm_p.enabled is False + assert dm_p.can_run("999", "stop") is True # backward compat + assert grp_p.enabled is True + assert grp_p.can_run("999", "stop") is False # gated + + def test_per_platform_isolation(self): + # Discord has gating, Telegram doesn't → Telegram is unaffected. + cfg = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + extra={"allow_admin_from": ["111"]}, + ), + Platform.TELEGRAM: PlatformConfig(enabled=True, extra={}), + } + ) + tg_src = SessionSource( + platform=Platform.TELEGRAM, chat_id="T", chat_type="dm", user_id="999" + ) + p = policy_for_source(cfg, tg_src) + assert p.enabled is False + assert p.can_run("999", "stop") is True diff --git a/tests/gateway/test_slash_access_dispatch.py b/tests/gateway/test_slash_access_dispatch.py new file mode 100644 index 00000000000..1e26c93e0eb --- /dev/null +++ b/tests/gateway/test_slash_access_dispatch.py @@ -0,0 +1,558 @@ +"""Integration tests for slash command access control gating in gateway/run.py. + +Drives the real ``GatewayRunner._handle_message`` path with a stub session +store so we exercise the actual gate inserted at the dispatch site (not a +re-implementation in the test). Uses the same ``object.__new__`` runner +construction pattern as test_status_command.py. + +Coverage targets: + - Backward compat: no ``allow_admin_from`` set → behaves exactly as before + (no denial messages, dispatch reaches the real handler). + - Admin path: user in ``allow_admin_from`` runs anything. + - User path: user not in admin list, but command in + ``user_allowed_commands`` → allowed. + - User denied: command not in either list → returns the ⛔ denial. + - Always-allowed floor: /help and /whoami reachable for non-admins + even with empty user_allowed_commands. + - DM vs group scope isolation. +""" +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source( + *, + platform: Platform = Platform.DISCORD, + user_id: str = "user1", + chat_type: str = "dm", + chat_id: str = "c1", +) -> SessionSource: + return SessionSource( + platform=platform, + user_id=user_id, + chat_id=chat_id, + user_name=f"name-{user_id}", + chat_type=chat_type, + ) + + +def _make_event(text: str, source: SessionSource) -> MessageEvent: + return MessageEvent(text=text, source=source, message_id="m1") + + +def _make_runner(*, platform_extra: dict | None = None, + platform: Platform = Platform.DISCORD): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={ + platform: PlatformConfig( + enabled=True, + token="***", + extra=platform_extra or {}, + ) + } + ) + adapter = MagicMock() + adapter.send = AsyncMock() + runner.adapters = {platform: adapter} + runner._voice_mode = {} + runner.hooks = SimpleNamespace( + emit=AsyncMock(), + emit_collect=AsyncMock(return_value=[]), + loaded_hooks=False, + ) + runner.session_store = MagicMock() + session_entry = SessionEntry( + session_key="agent:main:discord:dm:c1", + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=platform, + chat_type="dm", + total_tokens=0, + ) + runner.session_store.get_or_create_session.return_value = session_entry + runner.session_store.load_transcript.return_value = [] + runner.session_store.has_any_sessions.return_value = True + runner.session_store.append_to_transcript = MagicMock() + runner.session_store.rewrite_transcript = MagicMock() + runner.session_store.update_session = MagicMock() + runner._running_agents = {} + runner._running_agents_ts = {} + runner._session_run_generation = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._session_sources = {} + runner._session_db = MagicMock() + runner._session_db.get_session_title.return_value = None + runner._session_db.get_session.return_value = None + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._show_reasoning = False + runner._is_user_authorized = lambda _source: True + runner._set_session_env = lambda _context: None + runner._should_send_voice_reply = lambda *_args, **_kwargs: False + runner._send_voice_reply = AsyncMock() + runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None + runner._emit_gateway_run_progress = AsyncMock() + return runner + + +# --------------------------------------------------------------------------- +# /whoami response shape — proves the handler is reachable AND uses the +# resolver. We use /whoami because it's deterministic and short-circuits +# before any session/agent setup. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_whoami_unrestricted_when_no_admin_list(): + runner = _make_runner(platform_extra={}) # no admin list + result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="999"))) + assert "Tier: unrestricted" in result + assert "no admin list configured" in result + + +@pytest.mark.asyncio +async def test_whoami_admin_user(): + runner = _make_runner(platform_extra={"allow_admin_from": ["111"]}) + result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="111"))) + assert "**admin**" in result + + +@pytest.mark.asyncio +async def test_whoami_non_admin_lists_runnable_commands(): + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": ["status", "model"], + } + ) + result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="999"))) + assert "Tier: user" in result + assert "/help" in result # always-allowed floor + assert "/whoami" in result # always-allowed floor + assert "/status" in result + assert "/model" in result + + +# --------------------------------------------------------------------------- +# Gate denial — admin-only command attempted by non-admin +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_non_admin_denied_for_unlisted_command(): + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": ["status"], + } + ) + # /stop is NOT in user_allowed_commands and not in the always-allowed floor. + result = await runner._handle_message(_make_event("/stop", _make_source(user_id="999"))) + assert result is not None + assert "⛔" in result + assert "/stop is admin-only here" in result + assert "/status" in result # denial preview shows what they CAN run + + +@pytest.mark.asyncio +async def test_non_admin_with_empty_user_commands_gets_floor_only(): + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": [], # explicitly empty + } + ) + # /stop denied + result = await runner._handle_message(_make_event("/stop", _make_source(user_id="999"))) + assert "⛔" in result + assert "No slash commands are enabled" in result + # /whoami still works (always-allowed floor) + whoami_result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="999"))) + assert "Tier: user" in whoami_result + + +# --------------------------------------------------------------------------- +# Gate ALLOW — admin and listed user +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_admin_runs_unlisted_command(): + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": [], # users can run nothing + } + ) + # Admin runs /whoami (proxy for "any command works"); the gate must NOT + # return the ⛔ denial. The /whoami handler is deterministic and doesn't + # need a real agent, so we can assert against its content. + result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="111"))) + assert "⛔" not in result + assert "**admin**" in result + + +@pytest.mark.asyncio +async def test_user_runs_listed_command(): + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": ["whoami"], # explicit + } + ) + result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="999"))) + assert "⛔" not in result + assert "Tier: user" in result + + +# --------------------------------------------------------------------------- +# Backward compatibility — no admin list set means no gating at all +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_backward_compat_no_admin_list_means_no_gate(): + runner = _make_runner(platform_extra={}) # nothing configured + # Random non-listed user runs /whoami; should return unrestricted profile, + # never a denial. + result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="anyone"))) + assert "⛔" not in result + assert "Tier: unrestricted" in result + + +# --------------------------------------------------------------------------- +# Scope isolation — DM vs group +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dm_admin_is_not_group_admin(): + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "group_allow_admin_from": ["222"], + "group_user_allowed_commands": [], + } + ) + # User 111 is DM admin. In group context they're a non-admin with no + # listed commands → /stop denied. + result = await runner._handle_message( + _make_event("/stop", _make_source(user_id="111", chat_type="group")) + ) + assert "⛔" in result + + +@pytest.mark.asyncio +async def test_group_only_gating_leaves_dm_unrestricted(): + runner = _make_runner( + platform_extra={ + # Only group has an admin list → DM scope stays in backward-compat mode + "group_allow_admin_from": ["222"], + } + ) + result = await runner._handle_message(_make_event("/whoami", _make_source(user_id="anyone", chat_type="dm"))) + assert "Tier: unrestricted" in result + + +# --------------------------------------------------------------------------- +# Plugin-registered slash commands are gated through the same path +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_plugin_registered_command_is_gated(monkeypatch): + """The gate must recognize plugin-registered slash commands, not just + built-in COMMAND_REGISTRY entries. We verify by stubbing + is_gateway_known_command and resolve_command so a fictitious /myplugin + command is treated as a known plugin command. + """ + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": [], + } + ) + + from hermes_cli import commands as cmd_mod + + real_resolve = cmd_mod.resolve_command + real_is_known = cmd_mod.is_gateway_known_command + + def fake_resolve(name): + if name == "myplugin": + # Return a CommandDef-like duck so canonical resolution succeeds + return SimpleNamespace(name="myplugin") + return real_resolve(name) + + def fake_is_known(name): + if name == "myplugin": + return True + return real_is_known(name) + + monkeypatch.setattr(cmd_mod, "resolve_command", fake_resolve) + monkeypatch.setattr(cmd_mod, "is_gateway_known_command", fake_is_known) + + # Non-admin tries to run the plugin command → must be denied by the gate. + result = await runner._handle_message( + _make_event("/myplugin foo bar", _make_source(user_id="999")) + ) + assert "⛔" in result + assert "/myplugin is admin-only here" in result + + +# --------------------------------------------------------------------------- +# Running-agent fast-path gating — admin/user split must hold even when an +# agent is already running. The fast-path block in _handle_message dispatches +# /stop, /restart, /new, /steer, /model, /approve, /deny, /agents, +# /background, /kanban, /goal, /yolo, /verbose, /footer, /help, /commands, +# /profile, /update directly without going through the cold dispatch site. +# We must apply the gate there too — otherwise non-admins could bypass +# gating just because an agent happens to be busy. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_running_agent_fastpath_blocks_non_admin_command(): + """When an agent is running, /restart from a non-admin must be denied.""" + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": [], + } + ) + src = _make_source(user_id="999") + # Mark the session as having an in-flight agent so the fast-path runs. + from gateway.session import build_session_key + sk = build_session_key(src) + runner._running_agents[sk] = MagicMock() + runner._running_agents_ts[sk] = 0 # not stale (epoch + small delta on this machine) + + result = await runner._handle_message(_make_event("/restart", src)) + assert result is not None + assert "⛔" in result + assert "/restart is admin-only here" in result + + +@pytest.mark.asyncio +async def test_running_agent_fastpath_allows_admin_command(): + """Admins must still be able to run privileged commands like /restart + through the running-agent fast-path. We check that we don't get the + denial message; the actual /restart handler is mocked out via the + runner's MagicMock.""" + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": [], + } + ) + src = _make_source(user_id="111") # admin + from gateway.session import build_session_key + sk = build_session_key(src) + runner._running_agents[sk] = MagicMock() + runner._running_agents_ts[sk] = 0 + # Mock the restart handler so it doesn't actually try to restart anything. + runner._handle_restart_command = AsyncMock(return_value="restart-handled") + + result = await runner._handle_message(_make_event("/restart", src)) + assert result == "restart-handled" + assert "⛔" not in (result or "") + + +@pytest.mark.asyncio +async def test_running_agent_fastpath_status_always_works(): + """/status is intentionally pre-gate on the fast-path so users can + always see session state, even non-admins.""" + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": [], + } + ) + src = _make_source(user_id="999") # non-admin + from gateway.session import build_session_key + sk = build_session_key(src) + runner._running_agents[sk] = MagicMock() + runner._running_agents_ts[sk] = 0 + runner._handle_status_command = AsyncMock(return_value="status-handled") + + result = await runner._handle_message(_make_event("/status", src)) + assert result == "status-handled" + assert "⛔" not in (result or "") + + +# --------------------------------------------------------------------------- +# Alias resolution — /h aliases to /help; the gate must canonicalize before +# checking access. /hist (history alias) is a real one to exercise. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_gate_uses_canonical_name_not_alias(): + """If /hist resolves to canonical 'history' and history is in + user_allowed_commands, the alias must be allowed too.""" + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": ["history"], + } + ) + # Find a real alias in the registry to use. + from hermes_cli.commands import COMMAND_REGISTRY + history_def = next(c for c in COMMAND_REGISTRY if c.name == "history") + # If /history has aliases, use one. Otherwise just use /history. + alias = history_def.aliases[0] if history_def.aliases else "history" + # Mock the history handler so we don't need real session state. + runner._handle_history_command = AsyncMock(return_value="history-handled") + result = await runner._handle_message(_make_event(f"/{alias}", _make_source(user_id="999"))) + assert "⛔" not in (result or "") + + +# --------------------------------------------------------------------------- +# Unknown / unregistered command — gate must NOT intercept (let the existing +# unknown-command path handle it normally). +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_gate_does_not_intercept_unknown_command(): + """Random non-command text like /xyzzy is not in the registry. The gate + must not produce a denial message — the existing unknown-command path + will handle it (or the agent will see it as plain text).""" + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": [], + } + ) + # /xyzzy is not in COMMAND_REGISTRY and not a plugin command. + # The gate should pass through (no ⛔) since canonical resolution + # returns the raw command and is_gateway_known_command returns False. + # We can only verify the gate didn't fire — downstream behavior may + # vary (returns None, agent processes it, etc.). What matters: no denial. + runner._handle_unknown_command = AsyncMock(return_value=None) + # Stub out the rest of the cold path to short-circuit + runner.session_store.get_or_create_session.side_effect = RuntimeError("would have proceeded past gate") + try: + await runner._handle_message(_make_event("/xyzzy", _make_source(user_id="999"))) + except RuntimeError as e: + # Reaching session creation means we got past the gate without a denial. + assert "would have proceeded past gate" in str(e) + + +# --------------------------------------------------------------------------- +# Scope independence — admin in DM scope is NOT auto-admin in group when +# group has its own admin list (regression guard for the "admin lists are +# scope-specific" rule). +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_dm_admin_blocked_in_group_with_separate_admin_list(): + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], # DM admin + "group_allow_admin_from": ["222"], # group admin + "group_user_allowed_commands": ["status"], + } + ) + # User 111 is DM admin. In a group, they're a non-admin and can only + # run group_user_allowed_commands. /restart is not in that list → denied. + grp_src = _make_source(user_id="111", chat_type="group", chat_id="g1") + result = await runner._handle_message(_make_event("/restart", grp_src)) + assert "⛔" in result + assert "/restart is admin-only here" in result + + +# --------------------------------------------------------------------------- +# Multi-platform isolation — gating on Discord doesn't leak to Telegram. +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_gating_isolated_per_platform(): + """When Discord is gated and Telegram isn't, the same user_id on + Telegram must be unrestricted.""" + from gateway.run import GatewayRunner + from gateway.config import GatewayConfig, Platform, PlatformConfig + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + token="***", + extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": [], + }, + ), + Platform.TELEGRAM: PlatformConfig( + enabled=True, token="***", extra={} + ), + } + ) + runner.adapters = { + Platform.DISCORD: MagicMock(send=AsyncMock()), + Platform.TELEGRAM: MagicMock(send=AsyncMock()), + } + runner._voice_mode = {} + runner.hooks = SimpleNamespace( + emit=AsyncMock(), + emit_collect=AsyncMock(return_value=[]), + loaded_hooks=False, + ) + runner.session_store = MagicMock() + session_entry = SessionEntry( + session_key="agent:main:telegram:dm:c1", + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + total_tokens=0, + ) + runner.session_store.get_or_create_session.return_value = session_entry + runner.session_store.load_transcript.return_value = [] + runner.session_store.has_any_sessions.return_value = True + runner.session_store.append_to_transcript = MagicMock() + runner.session_store.rewrite_transcript = MagicMock() + runner.session_store.update_session = MagicMock() + runner._running_agents = {} + runner._running_agents_ts = {} + runner._session_run_generation = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._session_sources = {} + runner._session_db = MagicMock() + runner._session_db.get_session_title.return_value = None + runner._session_db.get_session.return_value = None + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._show_reasoning = False + runner._is_user_authorized = lambda _source: True + runner._set_session_env = lambda _context: None + runner._should_send_voice_reply = lambda *_args, **_kwargs: False + runner._send_voice_reply = AsyncMock() + runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None + runner._emit_gateway_run_progress = AsyncMock() + + # Same user_id on Telegram → must be unrestricted (Telegram has no admin list). + tg_src = _make_source(platform=Platform.TELEGRAM, user_id="999", chat_id="t1") + result = await runner._handle_message(_make_event("/whoami", tg_src)) + assert "Tier: unrestricted" in result diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 6878ddcab4d..41d8f40e84d 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -793,6 +793,201 @@ class TestSegmentBreakOnToolBoundary: "_send_fallback_final — the #10807 fix should prevent this" ) + @pytest.mark.asyncio + async def test_fallback_final_deletes_partial_after_chunks_succeed(self): + """After fallback chunks land, the frozen partial must be deleted so + the user sees only the complete response (#16668).""" + adapter = MagicMock() + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=True, message_id="msg_new"), + ) + adapter.edit_message = AsyncMock( + return_value=SimpleNamespace(success=True), + ) + adapter.delete_message = AsyncMock(return_value=None) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # Seed the consumer as if it already edited a partial message that + # later got stuck (flood control etc.) — _message_id is the stale id. + consumer._message_id = "msg_partial" + consumer._last_sent_text = "Working on i" + + await consumer._send_fallback_final("Working on it. Done!") + + adapter.delete_message.assert_awaited_once_with("chat_123", "msg_partial") + assert consumer._final_response_sent is True + + @pytest.mark.asyncio + async def test_fallback_final_does_not_delete_when_no_chunks_reach_user(self): + """If every fallback send fails, the partial is the only thing the + user has — must NOT be deleted.""" + adapter = MagicMock() + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=False, error="network down"), + ) + adapter.edit_message = AsyncMock( + return_value=SimpleNamespace(success=True), + ) + adapter.delete_message = AsyncMock(return_value=None) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + consumer._message_id = "msg_partial" + consumer._last_sent_text = "Working on i" + + await consumer._send_fallback_final("Working on it. Done!") + + adapter.delete_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_fallback_final_skips_delete_when_adapter_lacks_method(self): + """Platforms without delete_message must not crash the fallback path.""" + adapter = MagicMock(spec=["send", "edit_message", "MAX_MESSAGE_LENGTH"]) + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=True, message_id="msg_new"), + ) + adapter.edit_message = AsyncMock( + return_value=SimpleNamespace(success=True), + ) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + consumer._message_id = "msg_partial" + consumer._last_sent_text = "Working on i" + + # Should not raise even though the adapter has no delete_message. + await consumer._send_fallback_final("Working on it. Done!") + assert consumer._final_response_sent is True + + +class TestFinalResponseDeliveryGuard: + """Regression coverage for #10748 — _final_response_sent must reflect + actual delivery of the *current* chunked send, not the cumulative + `_already_sent` flag (which earlier tool-progress edits or fallback-mode + promotion can taint).""" + + @pytest.mark.asyncio + async def test_split_overflow_failed_send_does_not_mark_final_sent(self): + """Split-overflow path: if every chunk send fails on done frame, + _final_response_sent must stay False so the gateway falls back.""" + adapter = MagicMock() + # Every send fails — _send_new_chunk returns the passed-in reply_to. + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=False, error="network down"), + ) + adapter.edit_message = AsyncMock( + return_value=SimpleNamespace(success=True), + ) + adapter.MAX_MESSAGE_LENGTH = 100 + adapter.truncate_message = MagicMock( + side_effect=lambda text, limit: [text[:limit], text[limit:]], + ) + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # Simulate prior tool-progress edits that set _already_sent + consumer._already_sent = True + + # Long text > MAX_MESSAGE_LENGTH, no existing message id (fresh send path) + long_text = "x" * 200 + consumer.on_delta(long_text) + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) + consumer.finish() + await task + + assert consumer._final_response_sent is False, ( + "_already_sent leaked into _final_response_sent — gateway will " + "wrongly suppress its fallback delivery (#10748)" + ) + + @pytest.mark.asyncio + async def test_split_overflow_partial_send_marks_final_sent(self): + """Split-overflow path: if at least one chunk lands on done frame, + we did deliver the final answer — _final_response_sent must be True.""" + adapter = MagicMock() + adapter.send = AsyncMock(side_effect=[ + SimpleNamespace(success=True, message_id="msg_1"), + SimpleNamespace(success=True, message_id="msg_2"), + ]) + adapter.edit_message = AsyncMock( + return_value=SimpleNamespace(success=True), + ) + adapter.MAX_MESSAGE_LENGTH = 100 + adapter.truncate_message = MagicMock( + side_effect=lambda text, limit: [text[:limit], text[limit:]], + ) + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + long_text = "x" * 200 + consumer.on_delta(long_text) + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) + consumer.finish() + await task + + assert consumer._final_response_sent is True + + +class TestEditOverflowSplitAndDeliver: + """When edit_message split-and-delivers an oversized payload across the + original message + N continuations (Telegram >4096 UTF-16), the consumer + must update _message_id to the latest continuation, reset _last_sent_text, + and fire on_new_message so subsequent tool-progress bubbles linearize + below the new visible message.""" + + @pytest.mark.asyncio + async def test_consumer_advances_message_id_on_split_and_deliver(self): + adapter = MagicMock() + # Simulate edit_message split-and-deliver: success=True with the + # final continuation's id and a populated continuation_message_ids + # tuple (the new SendResult contract). + adapter.edit_message = AsyncMock(return_value=SimpleNamespace( + success=True, + message_id="msg_continuation_2", + continuation_message_ids=("msg_continuation_1", "msg_continuation_2"), + )) + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=True, message_id="msg_initial"), + ) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig( + edit_interval=0.01, buffer_threshold=5, cursor="", + ) + consumer = GatewayStreamConsumer(adapter, "chat_999", config) + + # Track on_new_message firings. + new_msg_count = [0] + consumer._on_new_message = lambda: new_msg_count.__setitem__(0, new_msg_count[0] + 1) + + # Seed the consumer as if a first send succeeded already. + consumer._message_id = "msg_initial" + consumer._last_sent_text = "old" + consumer._already_sent = True + + # Drive an edit that the adapter "split and delivers". + ok = await consumer._send_or_edit("new full text after overflow") + + assert ok is True + # Consumer advanced to the latest continuation id. + assert consumer._message_id == "msg_continuation_2" + # Skip-if-same cache reset so the next edit doesn't false-positive. + assert consumer._last_sent_text == "" + # on_new_message fired so the tool-progress bubble breaks below + # the new continuation (per the openclaw #32535 lesson). + assert new_msg_count[0] == 1 + class TestInterimCommentaryMessages: @pytest.mark.asyncio @@ -1493,3 +1688,96 @@ class TestOnNewMessageCallback: await consumer.run() assert consumer.already_sent is True + + +class TestUtf16OverflowDetection: + """Regression coverage for #11170 — Telegram counts message length in + UTF-16 code units, not Python codepoints. A response with supplementary + characters (emoji, CJK in some ranges) can have len()=3000 codepoints + but utf16_len()=5000+ units, blowing past Telegram's 4096 limit.""" + + def _make_telegram_like_adapter(self): + """Construct a minimal BasePlatformAdapter subclass that overrides + message_len_fn like Telegram does.""" + from gateway.platforms.base import utf16_len, BasePlatformAdapter + + TelegramLikeAdapter = type( + "TelegramLikeAdapter", + (BasePlatformAdapter,), + { + "MAX_MESSAGE_LENGTH": 4096, + "message_len_fn": property(lambda self: utf16_len), + }, + ) + # Defeat ABCMeta abstract-instantiation guard by clearing the cached + # abstract methods set after class creation. + TelegramLikeAdapter.__abstractmethods__ = frozenset() + adapter = TelegramLikeAdapter.__new__(TelegramLikeAdapter) + adapter._typing_paused = set() + adapter._fatal_error_message = None + return adapter + + @pytest.mark.asyncio + async def test_emoji_text_exceeding_utf16_limit_triggers_overflow_split(self): + """A response that is under 4096 codepoints but over 4096 UTF-16 + units must trigger the overflow-split path.""" + from gateway.platforms.base import utf16_len + + adapter = self._make_telegram_like_adapter() + # Mock the send/edit methods we actually call + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=True, message_id="msg_1"), + ) + adapter.edit_message = AsyncMock( + return_value=SimpleNamespace(success=True), + ) + # truncate_message: emit two halves so we can assert the split fired + adapter.truncate_message = MagicMock( + side_effect=lambda text, limit, **kw: [text[:len(text)//2], text[len(text)//2:]], + ) + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # 🚀 is 1 codepoint = 2 UTF-16 units. 2200 of them = 2200 codepoints, + # 4400 UTF-16 units. Under the codepoint-equivalent limit (would not + # trigger split with len()) but over Telegram's UTF-16 4096 limit. + emoji_text = "🚀" * 2200 + assert len(emoji_text) < adapter.MAX_MESSAGE_LENGTH, ( + "Test setup invariant: codepoint count under limit" + ) + assert utf16_len(emoji_text) > adapter.MAX_MESSAGE_LENGTH, ( + "Test setup invariant: UTF-16 count over limit" + ) + + consumer.on_delta(emoji_text) + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) + consumer.finish() + await task + + # The fix: stream consumer detects UTF-16 overflow and calls + # truncate_message to split. Without the fix, len() would return + # 2200 (under 4096) and no split would fire — Telegram would then + # reject the send or render \x00 artifacts. + adapter.truncate_message.assert_called(), ( + "UTF-16 overflow not detected — emoji text bypassed split path" + ) + # truncate_message must have been called with len_fn=utf16_len + call_kwargs = adapter.truncate_message.call_args[1] + assert call_kwargs.get("len_fn") is utf16_len, ( + f"truncate_message called without utf16_len: {call_kwargs}" + ) + + def test_codepoint_only_adapter_falls_back_to_len(self): + """Adapters without message_len_fn override (or test MagicMocks) + must use plain len for backwards compatibility.""" + adapter = MagicMock() + adapter.MAX_MESSAGE_LENGTH = 4096 + config = StreamConsumerConfig(cursor=" ▉") + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + # The isinstance guard means MagicMock adapters get len, not the + # auto-attr mock. Verified indirectly by all the other tests in + # this file passing — they all use MagicMock adapters. + assert consumer is not None + diff --git a/tests/gateway/test_stream_consumer_draft.py b/tests/gateway/test_stream_consumer_draft.py new file mode 100644 index 00000000000..bab8e20fd35 --- /dev/null +++ b/tests/gateway/test_stream_consumer_draft.py @@ -0,0 +1,318 @@ +"""Tests for native draft streaming in GatewayStreamConsumer. + +Telegram Bot API 9.5 (March 2026) introduced sendMessageDraft for native +animated streaming previews in private chats. This test suite covers the +consumer's transport-selection, fallback, and tool-boundary handling for +that path. + +Adapter under test is a runtime subclass of BasePlatformAdapter that +overrides supports_draft_streaming + send_draft, since the consumer's +isinstance(BasePlatformAdapter) gate excludes plain MagicMocks. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.stream_consumer import ( + GatewayStreamConsumer, + StreamConsumerConfig, +) + + +def _make_draft_capable_adapter( + *, supports_draft: bool = True, draft_succeeds: bool = True, +): + """Build a minimal BasePlatformAdapter subclass with draft support. + + The runtime subclass + cleared __abstractmethods__ pattern lets us + construct an adapter without hauling in any platform's heavy state + (Telegram bot, Discord client, etc.) while still satisfying the + consumer's isinstance(BasePlatformAdapter) gate. + """ + from gateway.platforms.base import BasePlatformAdapter, SendResult + + DraftCapableAdapter = type( + "DraftCapableAdapter", + (BasePlatformAdapter,), + {"MAX_MESSAGE_LENGTH": 4096}, + ) + DraftCapableAdapter.__abstractmethods__ = frozenset() + adapter = DraftCapableAdapter.__new__(DraftCapableAdapter) + adapter._typing_paused = set() + adapter._fatal_error_message = None + + # Track every send_draft call for assertions. + adapter.draft_calls = [] + + def _supports(chat_type=None, metadata=None): + return bool(supports_draft) and (chat_type or "").lower() == "dm" + adapter.supports_draft_streaming = _supports + + async def _send_draft(*, chat_id, draft_id, content, metadata=None): + adapter.draft_calls.append({ + "chat_id": chat_id, + "draft_id": draft_id, + "content": content, + "metadata": metadata, + }) + if draft_succeeds: + return SendResult(success=True, message_id=None) + return SendResult(success=False, error="draft_rejected") + adapter.send_draft = _send_draft + + # send / edit_message: count and return canned successes so the + # consumer's first-send + finalize paths work when drafts fall back + # or when delivering the final message. + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=True, message_id="msg_real"), + ) + adapter.edit_message = AsyncMock( + return_value=SimpleNamespace(success=True), + ) + return adapter + + +class TestDraftTransportSelection: + """Verify _resolve_draft_streaming picks the right transport.""" + + def test_auto_dm_with_draft_capable_adapter_picks_draft(self): + adapter = _make_draft_capable_adapter() + cfg = StreamConsumerConfig(transport="auto", chat_type="dm") + consumer = GatewayStreamConsumer(adapter, "12345", cfg) + assert consumer._resolve_draft_streaming() is True + + def test_auto_group_falls_back_to_edit(self): + adapter = _make_draft_capable_adapter() + cfg = StreamConsumerConfig(transport="auto", chat_type="group") + consumer = GatewayStreamConsumer(adapter, "12345", cfg) + assert consumer._resolve_draft_streaming() is False + + def test_explicit_edit_never_uses_drafts(self): + adapter = _make_draft_capable_adapter() + cfg = StreamConsumerConfig(transport="edit", chat_type="dm") + consumer = GatewayStreamConsumer(adapter, "12345", cfg) + assert consumer._resolve_draft_streaming() is False + + def test_explicit_draft_unsupported_falls_back(self): + adapter = _make_draft_capable_adapter(supports_draft=False) + cfg = StreamConsumerConfig(transport="draft", chat_type="dm") + consumer = GatewayStreamConsumer(adapter, "12345", cfg) + assert consumer._resolve_draft_streaming() is False + + def test_magicmock_adapter_falls_back_to_edit(self): + """MagicMock adapters (used in many existing tests) must default to + edit-based since their auto-attributes aren't real callables.""" + adapter = MagicMock() + cfg = StreamConsumerConfig(transport="auto", chat_type="dm") + consumer = GatewayStreamConsumer(adapter, "12345", cfg) + assert consumer._resolve_draft_streaming() is False + + +class TestDraftStreamingHappyPath: + """End-to-end: stream a few deltas in a DM, verify drafts animated and + the final message was delivered as a real sendMessage.""" + + @pytest.mark.asyncio + async def test_dm_stream_animates_draft_then_finalizes_with_send(self): + adapter = _make_draft_capable_adapter() + cfg = StreamConsumerConfig( + transport="auto", chat_type="dm", + edit_interval=0.01, buffer_threshold=5, cursor="", + ) + consumer = GatewayStreamConsumer(adapter, "12345", cfg) + + consumer.on_delta("Hello ") + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) + consumer.on_delta("world!") + await asyncio.sleep(0.05) + consumer.finish() + await task + + # At least one draft frame landed. + assert len(adapter.draft_calls) >= 1, ( + "expected at least one send_draft frame" + ) + # Final draft frame held the full accumulated text. + assert adapter.draft_calls[-1]["content"] == "Hello world!" + # All draft frames in this run shared a single draft_id (animation). + draft_ids = {c["draft_id"] for c in adapter.draft_calls} + assert len(draft_ids) == 1 + # Final answer was delivered as a regular sendMessage so the user + # sees a real message in their history (drafts have no message_id). + adapter.send.assert_awaited() + # And the final send carried the complete reply. + final_call = adapter.send.call_args + sent_content = ( + final_call.kwargs.get("content") + if "content" in final_call.kwargs + else final_call.args[1] if len(final_call.args) > 1 else None + ) + assert sent_content == "Hello world!" + + @pytest.mark.asyncio + async def test_group_chat_skips_draft_path(self): + adapter = _make_draft_capable_adapter() + cfg = StreamConsumerConfig( + transport="auto", chat_type="group", + edit_interval=0.01, buffer_threshold=5, cursor="", + ) + consumer = GatewayStreamConsumer(adapter, "67890", cfg) + + consumer.on_delta("Group message") + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) + consumer.finish() + await task + + # Group chats skip drafts entirely — no send_draft calls at all. + assert adapter.draft_calls == [] + # Edit-based path delivered via send (first message). + adapter.send.assert_awaited() + + +class TestDraftFallbackOnFailure: + """When a draft frame fails, the consumer disables drafts for the rest + of the response and continues via the edit-based path.""" + + @pytest.mark.asyncio + async def test_first_draft_failure_disables_drafts_for_run(self): + adapter = _make_draft_capable_adapter(draft_succeeds=False) + cfg = StreamConsumerConfig( + transport="auto", chat_type="dm", + edit_interval=0.01, buffer_threshold=5, cursor="", + ) + consumer = GatewayStreamConsumer(adapter, "12345", cfg) + + consumer.on_delta("Hello ") + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) + consumer.on_delta("world!") + await asyncio.sleep(0.05) + consumer.finish() + await task + + # The consumer attempted draft, hit failure, disabled drafts. + assert consumer._draft_failures >= 1 + assert consumer._use_draft_streaming is False + # Final message delivered via the regular send path. + adapter.send.assert_awaited() + + +class TestDraftIdLifecycle: + """Each response gets its own draft_id (no animation collision across + consecutive responses to the same chat).""" + + @pytest.mark.asyncio + async def test_consecutive_responses_use_distinct_draft_ids(self): + adapter = _make_draft_capable_adapter() + cfg1 = StreamConsumerConfig( + transport="auto", chat_type="dm", + edit_interval=0.01, buffer_threshold=5, cursor="", + ) + consumer1 = GatewayStreamConsumer(adapter, "12345", cfg1) + consumer1.on_delta("First reply") + task1 = asyncio.create_task(consumer1.run()) + await asyncio.sleep(0.05) + consumer1.finish() + await task1 + + cfg2 = StreamConsumerConfig( + transport="auto", chat_type="dm", + edit_interval=0.01, buffer_threshold=5, cursor="", + ) + consumer2 = GatewayStreamConsumer(adapter, "12345", cfg2) + consumer2.on_delta("Second reply") + task2 = asyncio.create_task(consumer2.run()) + await asyncio.sleep(0.05) + consumer2.finish() + await task2 + + # Two responses → two distinct draft_ids. + all_ids = {c["draft_id"] for c in adapter.draft_calls} + assert len(all_ids) >= 2, ( + f"expected distinct draft_ids across responses; got {all_ids}" + ) + # Every draft_id must be non-zero (Telegram's contract). + assert all(did != 0 for did in all_ids) + + @pytest.mark.asyncio + async def test_tool_boundary_bumps_draft_id(self): + """After a segment break (tool boundary), the next text segment + animates via a new draft_id so it appears below the tool-progress + bubble rather than overwriting the prior segment's preview.""" + adapter = _make_draft_capable_adapter() + cfg = StreamConsumerConfig( + transport="auto", chat_type="dm", + edit_interval=0.01, buffer_threshold=5, cursor="", + ) + consumer = GatewayStreamConsumer(adapter, "12345", cfg) + + consumer.on_delta("Pre-tool ") + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) + # Tool boundary + consumer.on_segment_break() + await asyncio.sleep(0.05) + consumer.on_delta("Post-tool") + await asyncio.sleep(0.05) + consumer.finish() + await task + + # Pre-tool and post-tool segments must use different draft_ids. + draft_ids = [c["draft_id"] for c in adapter.draft_calls] + if len(draft_ids) >= 2: + # Find pre-tool and post-tool calls by content + pre_ids = { + c["draft_id"] for c in adapter.draft_calls + if "Pre-tool" in c["content"] and "Post-tool" not in c["content"] + } + post_ids = { + c["draft_id"] for c in adapter.draft_calls + if "Post-tool" in c["content"] + } + if pre_ids and post_ids: + assert pre_ids.isdisjoint(post_ids), ( + f"pre-tool and post-tool segments must use distinct " + f"draft_ids; got pre={pre_ids} post={post_ids}" + ) + + +class TestAlreadySentInDraftMode: + """Drafts must NOT mark _already_sent — that flag gates the gateway's + fallback final-send path, which we still need to fire so the user gets + a real message in their history (drafts have no message_id).""" + + @pytest.mark.asyncio + async def test_drafts_do_not_set_already_sent_until_real_message(self): + adapter = _make_draft_capable_adapter() + cfg = StreamConsumerConfig( + transport="auto", chat_type="dm", + edit_interval=0.01, buffer_threshold=5, cursor="", + ) + consumer = GatewayStreamConsumer(adapter, "12345", cfg) + + consumer.on_delta("Hello") + # Drive the consumer for a bit but DON'T finish — only drafts have + # been sent. + task = asyncio.create_task(consumer.run()) + await asyncio.sleep(0.05) + # At this point drafts may have fired but we haven't finalized. + # _already_sent must still be False so a downstream fallback would + # know it needs to deliver the final answer. + if adapter.draft_calls: + assert consumer._already_sent is False, ( + "drafts wrongly marked _already_sent — " + "would suppress gateway fallback delivery" + ) + + consumer.finish() + await task + + # After the regular sendMessage finalize, _already_sent is True. + assert consumer._already_sent is True diff --git a/tests/gateway/test_stream_consumer_thread_routing.py b/tests/gateway/test_stream_consumer_thread_routing.py new file mode 100644 index 00000000000..80477574d87 --- /dev/null +++ b/tests/gateway/test_stream_consumer_thread_routing.py @@ -0,0 +1,229 @@ +"""Regression tests for stream consumer thread/topic routing fix. + +Verifies that GatewayStreamConsumer correctly passes reply_to on the first +message send, ensuring messages land in the correct topic/thread instead of +the main group chat. + +Covers: #6969, #9916, #7355 +""" +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch +from types import SimpleNamespace + +import pytest + +from gateway.stream_consumer import ( + GatewayStreamConsumer, + StreamConsumerConfig, +) + + +def _make_adapter(send_result=None, edit_result=None, max_length=4096): + adapter = MagicMock() + adapter.send = AsyncMock( + return_value=send_result or SimpleNamespace(success=True, message_id="msg_1") + ) + adapter.edit_message = AsyncMock( + return_value=edit_result or SimpleNamespace(success=True) + ) + adapter.MAX_MESSAGE_LENGTH = max_length + return adapter + + +class TestInitialReplyToId: + """Verify initial_reply_to_id is passed as reply_to on first send.""" + + @pytest.mark.asyncio + async def test_first_send_uses_initial_reply_to_id(self): + """When initial_reply_to_id is set, first adapter.send() should + include reply_to=initial_reply_to_id.""" + adapter = _make_adapter() + consumer = GatewayStreamConsumer( + adapter, + "chat_123", + metadata={"thread_id": "omt_topic123"}, + initial_reply_to_id="om_user_msg_456", + ) + await consumer._send_or_edit("Hello world") + + adapter.send.assert_called_once() + call_kwargs = adapter.send.call_args[1] + assert call_kwargs["reply_to"] == "om_user_msg_456", ( + "First send should pass initial_reply_to_id as reply_to" + ) + assert call_kwargs["chat_id"] == "chat_123" + + @pytest.mark.asyncio + async def test_first_send_without_initial_reply_to_id(self): + """When initial_reply_to_id is None, first send should have + reply_to=None (backward compatible).""" + adapter = _make_adapter() + consumer = GatewayStreamConsumer( + adapter, + "chat_123", + ) + await consumer._send_or_edit("Hello world") + + adapter.send.assert_called_once() + call_kwargs = adapter.send.call_args[1] + assert call_kwargs.get("reply_to") is None + + @pytest.mark.asyncio + async def test_subsequent_edits_ignore_initial_reply_to_id(self): + """After first send, edits should use message_id, not initial_reply_to_id.""" + adapter = _make_adapter() + consumer = GatewayStreamConsumer( + adapter, + "chat_123", + metadata={"thread_id": "omt_topic123"}, + initial_reply_to_id="om_user_msg_456", + ) + + # First send + await consumer._send_or_edit("Hello world") + assert adapter.send.call_count == 1 + + # Second call should edit, not send + await consumer._send_or_edit("Hello world updated") + assert adapter.send.call_count == 1, "Should edit, not send again" + adapter.edit_message.assert_called_once() + edit_kwargs = adapter.edit_message.call_args[1] + assert edit_kwargs["message_id"] == "msg_1" + assert edit_kwargs["chat_id"] == "chat_123" + + @pytest.mark.asyncio + async def test_metadata_passed_on_first_send(self): + """Metadata (containing thread_id) should be forwarded on first send.""" + adapter = _make_adapter() + metadata = {"thread_id": "omt_topic789"} + consumer = GatewayStreamConsumer( + adapter, + "chat_123", + metadata=metadata, + initial_reply_to_id="om_msg_000", + ) + await consumer._send_or_edit("Test") + + call_kwargs = adapter.send.call_args[1] + assert call_kwargs["metadata"] == metadata + + +class TestOverflowFirstMessage: + """Verify thread routing is preserved when the first message overflows.""" + + @pytest.mark.asyncio + async def test_overflow_first_send_uses_initial_reply_to_id(self): + """When first message exceeds platform limit and is split into chunks, + each chunk should be threaded to initial_reply_to_id, not None.""" + adapter = _make_adapter(max_length=10) + adapter.truncate_message = MagicMock( + return_value=["chunk_1", "chunk_2"] + ) + consumer = GatewayStreamConsumer( + adapter, + "chat_123", + metadata={"thread_id": "omt_topic123"}, + initial_reply_to_id="om_user_msg_789", + ) + + # Inject oversized accumulated text to trigger overflow path + consumer._accumulated = "A" * 100 + consumer._current_edit_interval = 999 + await consumer._send_new_chunk("chunk_1", consumer._message_id or consumer._initial_reply_to_id) + + adapter.send.assert_called_once() + call_kwargs = adapter.send.call_args[1] + assert call_kwargs["reply_to"] == "om_user_msg_789", ( + "Overflow first chunk should use initial_reply_to_id" + ) + + +class TestFeishuFallbackThreadRouting: + """Verify FeishuAdapter._send_raw_message routes to topic on fallback.""" + + @pytest.mark.asyncio + async def test_create_uses_thread_id_when_available(self): + """When reply_to=None and metadata has thread_id, message.create + should use receive_id_type='thread_id'.""" + from gateway.platforms.feishu import FeishuAdapter + + # We test the _send_raw_message method directly by mocking the client + adapter = MagicMock(spec=FeishuAdapter) + + # Set up the real _send_raw_message logic manually + mock_client = MagicMock() + mock_create_response = SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="new_msg_1"), + ) + mock_client.im.v1.message.create = MagicMock(return_value=mock_create_response) + + # Use the real implementation path + adapter._client = mock_client + adapter._build_create_message_body = FeishuAdapter._build_create_message_body + adapter._build_create_message_request = FeishuAdapter._build_create_message_request + + # Call _send_raw_message with reply_to=None and thread_id in metadata + import json + result = await FeishuAdapter._send_raw_message( + adapter, + chat_id="oc_main_chat", + msg_type="text", + payload=json.dumps({"text": "hello"}), + reply_to=None, + metadata={"thread_id": "omt_topic_abc"}, + ) + + # Verify message.create was called (not message.reply) + mock_client.im.v1.message.create.assert_called_once() + + # The request should have receive_id_type="thread_id" + call_args = mock_client.im.v1.message.create.call_args[0][0] + # Lark SDK builder exposes .body; the in-tree fallback exposes .request_body. + # The contributor's branch had the lark SDK installed, the test environment + # may not — handle both shapes. + body = getattr(call_args, "body", None) or getattr(call_args, "request_body", None) + assert body is not None, "request has neither .body nor .request_body" + # receive_id should be the thread_id, not the chat_id + receive_id = getattr(body, "receive_id", None) + if receive_id is None and isinstance(body, str): + import json as _json + receive_id = _json.loads(body).get("receive_id") + assert receive_id == "omt_topic_abc", ( + f"Expected receive_id='omt_topic_abc', got '{receive_id}'" + ) + # And receive_id_type must be 'thread_id', not 'chat_id' + receive_id_type = getattr(call_args, "receive_id_type", None) + assert receive_id_type == "thread_id", ( + f"Expected receive_id_type='thread_id', got '{receive_id_type}'" + ) + + @pytest.mark.asyncio + async def test_create_uses_chat_id_when_no_thread(self): + """When reply_to=None and metadata has no thread_id, message.create + should use receive_id_type='chat_id' (original behavior).""" + from gateway.platforms.feishu import FeishuAdapter + + mock_client = MagicMock() + mock_create_response = SimpleNamespace( + success=lambda: True, + data=SimpleNamespace(message_id="new_msg_1"), + ) + mock_client.im.v1.message.create = MagicMock(return_value=mock_create_response) + + adapter = MagicMock(spec=FeishuAdapter) + adapter._client = mock_client + adapter._build_create_message_body = FeishuAdapter._build_create_message_body + adapter._build_create_message_request = FeishuAdapter._build_create_message_request + + import json + result = await FeishuAdapter._send_raw_message( + adapter, + chat_id="oc_main_chat", + msg_type="text", + payload=json.dumps({"text": "hello"}), + reply_to=None, + metadata=None, + ) + + mock_client.im.v1.message.create.assert_called_once() diff --git a/tests/gateway/test_telegram_approval_buttons.py b/tests/gateway/test_telegram_approval_buttons.py index 199508c9cca..bfbc0bcdb36 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/tests/gateway/test_telegram_approval_buttons.py @@ -4,6 +4,7 @@ import asyncio import os import sys from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -140,6 +141,34 @@ class TestTelegramExecApproval: kwargs = adapter._bot.send_message.call_args[1] assert kwargs.get("message_thread_id") == 999 + @pytest.mark.asyncio + async def test_retries_without_thread_when_thread_not_found(self): + adapter = _make_adapter() + call_log = [] + + class FakeBadRequest(Exception): + pass + + async def mock_send_message(**kwargs): + call_log.append(dict(kwargs)) + if kwargs.get("message_thread_id") is not None: + raise FakeBadRequest("Message thread not found") + return SimpleNamespace(message_id=42) + + adapter._bot.send_message = AsyncMock(side_effect=mock_send_message) + + result = await adapter.send_exec_approval( + chat_id="12345", + command="ls", + session_key="s", + metadata={"thread_id": "999"}, + ) + + assert result.success is True + assert len(call_log) == 2 + assert call_log[0]["message_thread_id"] == 999 + assert "message_thread_id" not in call_log[1] or call_log[1]["message_thread_id"] is None + @pytest.mark.asyncio async def test_not_connected(self): adapter = _make_adapter() @@ -209,9 +238,11 @@ class TestTelegramApprovalCallback: update = MagicMock() update.callback_query = query context = MagicMock() + query.from_user.id = "12345" - with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve: - await adapter._handle_callback_query(update, context) + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False): + with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve: + await adapter._handle_callback_query(update, context) mock_resolve.assert_called_once_with("agent:main:telegram:group:12345:99", "once") query.answer.assert_called_once() @@ -237,9 +268,11 @@ class TestTelegramApprovalCallback: update = MagicMock() update.callback_query = query context = MagicMock() + query.from_user.id = "12345" - with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve: - await adapter._handle_callback_query(update, context) + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False): + with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve: + await adapter._handle_callback_query(update, context) mock_resolve.assert_called_once_with("some-session", "deny") edit_kwargs = query.edit_message_text.call_args[1] @@ -296,9 +329,11 @@ class TestTelegramApprovalCallback: update = MagicMock() update.callback_query = query context = MagicMock() + query.from_user.id = "12345" - with patch("tools.approval.resolve_gateway_approval") as mock_resolve: - await adapter._handle_callback_query(update, context) + with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": "*"}, clear=False): + with patch("tools.approval.resolve_gateway_approval") as mock_resolve: + await adapter._handle_callback_query(update, context) # Should NOT resolve — already handled mock_resolve.assert_not_called() diff --git a/tests/gateway/test_telegram_format.py b/tests/gateway/test_telegram_format.py index 1cd09f2e7db..55fb118d8f7 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -759,6 +759,43 @@ class TestEditMessageStreamingSafety: "text": "final **bold**", } + @pytest.mark.asyncio + async def test_message_too_long_splits_into_continuations_not_silent_truncation(self): + """When edit_message_text exceeds Telegram's 4096 UTF-16 limit, the + adapter must split the content across the existing message + new + continuation messages so the user gets the full reply. Previously + the adapter best-effort truncated the content with '…' and returned + success=True, dropping everything past the truncation boundary + (#19537).""" + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token")) + adapter._bot = MagicMock() + adapter._bot.edit_message_text = AsyncMock() + # Continuation sends return monotonically increasing message ids. + _next_id = [1000] + async def _fake_send(**kwargs): + _next_id[0] += 1 + return SimpleNamespace(message_id=_next_id[0]) + adapter._bot.send_message = AsyncMock(side_effect=_fake_send) + + # 6000-char content well over the 4096 UTF-16 limit. + oversized = "x" * 6000 + result = await adapter.edit_message("123", "456", oversized, finalize=False) + + # Adapter reports success with continuations populated. + assert result.success is True + assert result.error is None + assert len(result.continuation_message_ids) >= 1, ( + "expected at least one continuation message" + ) + # The reported message_id is the LAST visible message (the final + # continuation), so subsequent edits target the most recent. + assert result.message_id == result.continuation_message_ids[-1] + # Original message_id (456) was edited with chunk 1. + first_edit = adapter._bot.edit_message_text.call_args + assert first_edit.kwargs["message_id"] == 456 + # Continuations were sent threaded as replies for visual grouping. + assert adapter._bot.send_message.await_count == len(result.continuation_message_ids) + # ========================================================================= # Telegram guest mention gating # ========================================================================= diff --git a/tests/gateway/test_telegram_model_picker.py b/tests/gateway/test_telegram_model_picker.py new file mode 100644 index 00000000000..e7c2cd11a4f --- /dev/null +++ b/tests/gateway/test_telegram_model_picker.py @@ -0,0 +1,76 @@ +"""Tests for Telegram model picker thread fallback.""" + +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + + mod = MagicMock() + mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + mod.constants.ParseMode.MARKDOWN = "Markdown" + mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + mod.constants.ParseMode.HTML = "HTML" + mod.constants.ChatType.PRIVATE = "private" + mod.constants.ChatType.GROUP = "group" + mod.constants.ChatType.SUPERGROUP = "supergroup" + mod.constants.ChatType.CHANNEL = "channel" + mod.error.NetworkError = type("NetworkError", (OSError,), {}) + mod.error.TimedOut = type("TimedOut", (OSError,), {}) + mod.error.BadRequest = type("BadRequest", (Exception,), {}) + + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, mod) + sys.modules.setdefault("telegram.error", mod.error) + + +_ensure_telegram_mock() + +from gateway.config import PlatformConfig +from gateway.platforms.telegram import TelegramAdapter + + +def _make_adapter(): + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="test-token")) + adapter._bot = AsyncMock() + adapter._app = MagicMock() + return adapter + + +class TestTelegramModelPicker: + @pytest.mark.asyncio + async def test_retries_without_thread_when_thread_not_found(self): + adapter = _make_adapter() + providers = [{"slug": "openai", "name": "OpenAI", "total_models": 2, "is_current": True}] + call_log = [] + + class FakeBadRequest(Exception): + pass + + async def mock_send_message(**kwargs): + call_log.append(dict(kwargs)) + if kwargs.get("message_thread_id") is not None: + raise FakeBadRequest("Message thread not found") + return SimpleNamespace(message_id=99) + + adapter._bot.send_message = AsyncMock(side_effect=mock_send_message) + + result = await adapter.send_model_picker( + chat_id="12345", + providers=providers, + current_model="gpt-5", + current_provider="openai", + session_key="s", + on_model_selected=AsyncMock(), + metadata={"thread_id": "99999"}, + ) + + assert result.success is True + assert len(call_log) == 2 + assert call_log[0]["message_thread_id"] == 99999 + assert "message_thread_id" not in call_log[1] or call_log[1]["message_thread_id"] is None diff --git a/tests/gateway/test_telegram_text_batch_perf.py b/tests/gateway/test_telegram_text_batch_perf.py new file mode 100644 index 00000000000..518dee24604 --- /dev/null +++ b/tests/gateway/test_telegram_text_batch_perf.py @@ -0,0 +1,133 @@ +"""Regression tests for the Telegram text-batch adaptive-delay fast-path +and _env_float_clamped helper introduced by PR #10388 (Telegram latency +tuning). + +The fast-path lets short replies stream near-instantly while keeping the +configured cap as the upper bound, so an operator who tightens the cap +gets the lower number on every tier. + +The env-clamped helper guarantees float env vars never produce NaN/Inf +or out-of-bounds values that could break asyncio.sleep(). +""" + +from __future__ import annotations + +import math +import os +from unittest.mock import MagicMock + +import pytest + +from gateway.platforms.telegram import TelegramAdapter + + +@pytest.fixture +def adapter(): + """Build a TelegramAdapter shell without going through __init__'s + network-touching setup. Just need the class for static-method access + and the instance for instance-method tests.""" + return TelegramAdapter.__new__(TelegramAdapter) + + +class TestEnvFloatClamped: + """_env_float_clamped is the fence around every float env var the + adapter reads — must reject NaN/Inf and honor min/max bounds.""" + + def test_default_when_unset(self, monkeypatch): + monkeypatch.delenv("HERMES_TEST_VAR", raising=False) + assert TelegramAdapter._env_float_clamped("HERMES_TEST_VAR", 0.5) == 0.5 + + def test_parses_valid_value(self, monkeypatch): + monkeypatch.setenv("HERMES_TEST_VAR", "1.25") + assert TelegramAdapter._env_float_clamped("HERMES_TEST_VAR", 0.5) == 1.25 + + def test_falls_back_to_default_on_garbage(self, monkeypatch): + monkeypatch.setenv("HERMES_TEST_VAR", "not-a-float") + assert TelegramAdapter._env_float_clamped("HERMES_TEST_VAR", 0.5) == 0.5 + + def test_rejects_nan(self, monkeypatch): + monkeypatch.setenv("HERMES_TEST_VAR", "nan") + result = TelegramAdapter._env_float_clamped("HERMES_TEST_VAR", 0.5) + assert math.isfinite(result) + assert result == 0.5 + + def test_rejects_inf(self, monkeypatch): + monkeypatch.setenv("HERMES_TEST_VAR", "inf") + result = TelegramAdapter._env_float_clamped("HERMES_TEST_VAR", 0.5) + assert math.isfinite(result) + assert result == 0.5 + + def test_clamps_below_min(self, monkeypatch): + monkeypatch.setenv("HERMES_TEST_VAR", "0.01") + assert TelegramAdapter._env_float_clamped( + "HERMES_TEST_VAR", 0.5, min_value=0.1, + ) == 0.1 + + def test_clamps_above_max(self, monkeypatch): + monkeypatch.setenv("HERMES_TEST_VAR", "10.0") + assert TelegramAdapter._env_float_clamped( + "HERMES_TEST_VAR", 0.5, max_value=2.0, + ) == 2.0 + + +class TestAdaptiveTextBatchTiers: + """The fast-path tiers cap delay for short / medium messages. Tier + constants must compose with the configured cap (operators who set a + lower cap get the lower number on every tier).""" + + def test_class_constants_are_sensible(self): + """Sanity check that the tier constants form a non-overlapping + ascending ladder.""" + assert TelegramAdapter._TEXT_BATCH_FAST_LEN < TelegramAdapter._TEXT_BATCH_SHORT_LEN + assert TelegramAdapter._TEXT_BATCH_FAST_DELAY_S < TelegramAdapter._TEXT_BATCH_SHORT_DELAY_S + assert TelegramAdapter._TEXT_BATCH_FAST_DELAY_S > 0 + assert TelegramAdapter._TEXT_BATCH_SHORT_DELAY_S > 0 + + def test_fast_tier_uses_min_with_configured_cap(self, adapter): + """A short message picks the lower of the fast-tier delay and + the operator's configured cap.""" + # Operator set a generous cap (0.6s); fast tier should win. + adapter._text_batch_delay_seconds = 0.6 + delay = min( + adapter._text_batch_delay_seconds, + TelegramAdapter._TEXT_BATCH_FAST_DELAY_S, + ) + assert delay == TelegramAdapter._TEXT_BATCH_FAST_DELAY_S + + # Operator tightened the cap below the fast-tier delay; cap wins. + adapter._text_batch_delay_seconds = 0.10 + delay = min( + adapter._text_batch_delay_seconds, + TelegramAdapter._TEXT_BATCH_FAST_DELAY_S, + ) + assert delay == 0.10 + + def test_short_tier_uses_min_with_configured_cap(self, adapter): + """Same composition rule for the medium tier.""" + adapter._text_batch_delay_seconds = 0.6 + delay = min( + adapter._text_batch_delay_seconds, + TelegramAdapter._TEXT_BATCH_SHORT_DELAY_S, + ) + assert delay == TelegramAdapter._TEXT_BATCH_SHORT_DELAY_S + + def test_long_message_uses_full_cap(self, adapter): + """Messages above the medium threshold use the configured cap + without the tier-clamp.""" + adapter._text_batch_delay_seconds = 0.5 + # Beyond _TEXT_BATCH_SHORT_LEN there's no tier-clamp; cap wins. + delay = adapter._text_batch_delay_seconds + assert delay == 0.5 + + def test_split_threshold_takes_priority_over_fast_tier(self, adapter): + """If the latest chunk hits the platform split threshold a + continuation is almost certain — wait the longer split delay + regardless of total length.""" + adapter._text_batch_delay_seconds = 0.3 + adapter._text_batch_split_delay_seconds = 1.0 + last_chunk_len = TelegramAdapter._SPLIT_THRESHOLD + 50 + # The flush path checks last_chunk_len first; assert the contract. + assert last_chunk_len >= TelegramAdapter._SPLIT_THRESHOLD + delay = adapter._text_batch_split_delay_seconds + assert delay == 1.0 + assert delay > adapter._text_batch_delay_seconds diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 456439b5741..1dbe03b3441 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -81,6 +81,81 @@ class TestLoadConfigDefaults: assert "max_turns" not in config +class TestLoadConfigParseFailure: + """A YAML parse failure must NOT silently fall back to defaults. + + Before issue #23570 this was a single ``print(...)`` that scrolled past + on the first invocation — users saw aux-fallback misbehavior with no clue + their config.yaml was being ignored. The helper must: + * log at WARNING (so ``hermes logs`` surfaces it) + * also write to stderr (so it's visible at startup even before + ``setup_logging()`` has wired up file handlers) + * dedup on (path, mtime_ns, size) so concurrent loads don't spam + * re-warn after the user edits the file (different mtime) + """ + + def test_logs_and_warns_on_parse_failure(self, tmp_path, caplog, capsys): + # Reset the dedup cache so this test isn't affected by other tests + # that may have warned about a different broken config. + from hermes_cli import config as cfg_mod + cfg_mod._CONFIG_PARSE_WARNED.clear() + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + (tmp_path / "config.yaml").write_text("\tbroken tab indent:\n") + + import logging + with caplog.at_level(logging.WARNING, logger="hermes_cli.config"): + config = load_config() + + # Falls back to defaults — confirms the silent-fallback we're warning about + assert config["model"] == DEFAULT_CONFIG["model"] + + # WARNING-level log was emitted with file path + reason + assert any( + str(tmp_path / "config.yaml") in rec.message + and "Falling back to default config" in rec.message + for rec in caplog.records + ), f"expected WARNING log, got: {[r.message for r in caplog.records]}" + + # stderr also got a user-visible message (with the ⚠️ marker so it + # stands out at hermes startup before logging is configured) + captured = capsys.readouterr() + assert "hermes config:" in captured.err + assert str(tmp_path / "config.yaml") in captured.err + + def test_dedup_on_repeated_load_same_file(self, tmp_path, capsys): + from hermes_cli import config as cfg_mod + cfg_mod._CONFIG_PARSE_WARNED.clear() + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + (tmp_path / "config.yaml").write_text("\tbroken:\n") + + load_config() + first = capsys.readouterr().err + assert "hermes config:" in first + + load_config() + second = capsys.readouterr().err + assert second == "", "second load should NOT re-warn (same file, same mtime)" + + def test_rewarns_after_file_edit(self, tmp_path, capsys): + import time + from hermes_cli import config as cfg_mod + cfg_mod._CONFIG_PARSE_WARNED.clear() + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + (tmp_path / "config.yaml").write_text("\tbroken:\n") + load_config() + capsys.readouterr() # discard first warning + + # Edit the file (still broken, but different content) — mtime changes + time.sleep(0.05) + (tmp_path / "config.yaml").write_text("\tstill broken differently:\n") + load_config() + after_edit = capsys.readouterr().err + assert "hermes config:" in after_edit, "edited file should re-warn" + + class TestSaveAndLoadRoundtrip: def test_roundtrip(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py index 3d88b6212cb..241016a25d8 100644 --- a/tests/hermes_cli/test_kanban_cli.py +++ b/tests/hermes_cli/test_kanban_cli.py @@ -392,3 +392,13 @@ def test_run_slash_missing_required_arg_friendly_error(kanban_home): out = kc.run_slash("show") assert "/kanban show" in out assert "task_id" in out + + +def test_run_slash_board_override_restores_prior_env(kanban_home, monkeypatch): + kb.create_board("alpha") + kb.create_board("beta") + monkeypatch.setenv("HERMES_KANBAN_BOARD", "beta") + + kc.run_slash("--board alpha list") + + assert os.environ.get("HERMES_KANBAN_BOARD") == "beta" diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index e660764c6d0..17252af827a 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -510,10 +510,12 @@ def test_notify_sub_crud(kanban_home): tid = kb.create_task(conn, title="x") kb.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="123", user_id="u1", + notifier_profile="default", ) subs = kb.list_notify_subs(conn, tid) assert len(subs) == 1 assert subs[0]["platform"] == "telegram" + assert subs[0]["notifier_profile"] == "default" # Duplicate add is a no-op. kb.add_notify_sub( conn, task_id=tid, platform="telegram", chat_id="123", @@ -568,6 +570,57 @@ def test_notify_cursor_advances(kanban_home): conn.close() +def test_notify_claim_is_single_owner_and_rewindable(kanban_home): + conn1 = kb.connect() + conn2 = kb.connect() + try: + tid = kb.create_task(conn1, title="x", assignee="w") + kb.add_notify_sub(conn1, task_id=tid, platform="telegram", chat_id="123") + kb.complete_task(conn1, tid, result="ok") + + old_cursor, claimed_cursor, events = kb.claim_unseen_events_for_sub( + conn1, + task_id=tid, + platform="telegram", + chat_id="123", + kinds=["completed", "blocked"], + ) + assert old_cursor == 0 + assert claimed_cursor > old_cursor + assert [ev.kind for ev in events] == ["completed"] + + # A concurrent notifier instance sees the advanced cursor and cannot + # claim/send the same event range. + _, _, duplicate_events = kb.claim_unseen_events_for_sub( + conn2, + task_id=tid, + platform="telegram", + chat_id="123", + kinds=["completed", "blocked"], + ) + assert duplicate_events == [] + + assert kb.rewind_notify_cursor( + conn1, + task_id=tid, + platform="telegram", + chat_id="123", + claimed_cursor=claimed_cursor, + old_cursor=old_cursor, + ) is True + _, retried_events = kb.unseen_events_for_sub( + conn2, + task_id=tid, + platform="telegram", + chat_id="123", + kinds=["completed", "blocked"], + ) + assert [ev.kind for ev in retried_events] == ["completed"] + finally: + conn1.close() + conn2.close() + + # --------------------------------------------------------------------------- # GC + retention # --------------------------------------------------------------------------- @@ -2691,6 +2744,48 @@ def test_create_task_skills_rejects_comma_embedded(kanban_home): conn.close() +def test_create_task_skills_rejects_toolset_names(kanban_home): + """Toolset names belong in profile config, not per-task skills.""" + conn = kb.connect() + try: + with pytest.raises(ValueError, match="toolset name"): + kb.create_task( + conn, + title="bad toolset skill", + assignee="x", + skills=["web", "translation"], + ) + finally: + conn.close() + + +def test_create_task_skills_lists_all_toolset_typos(kanban_home): + """When several toolset names are passed, the error names every one. + + Agents that confuse skills with toolsets usually pass several at once + (``skills=["web", "browser", "terminal"]``). Listing only the first + mistake forces serial fix-then-retry; listing all of them lets the + caller correct in one round-trip. + """ + conn = kb.connect() + try: + with pytest.raises(ValueError) as exc_info: + kb.create_task( + conn, + title="three bad", + assignee="x", + skills=["web", "browser", "terminal"], + ) + msg = str(exc_info.value) + assert "'web'" in msg + assert "'browser'" in msg + assert "'terminal'" in msg + # Plural noun form when multiple toolsets are flagged. + assert "are toolset names" in msg + finally: + conn.close() + + def test_default_spawn_appends_per_task_skills(kanban_home, monkeypatch): """Dispatcher argv must carry one `--skills X` pair per task skill, in addition to the built-in kanban-worker.""" @@ -3446,6 +3541,76 @@ def test_complete_accepts_cross_worker_card_when_linked_as_child(kanban_home): conn.close() +def test_complete_can_retry_after_phantom_rejection(kanban_home): + """A worker that hits the hallucinated-card gate must be able to + retry kanban_complete on the same task — both with a corrected + created_cards list and with an empty list (the documented escape + hatch). Regression test for #22923, where workers were believed to + be unrecoverable after the first rejection. + """ + conn = kb.connect() + try: + # Two parallel completing tasks so we can exercise both retry + # shapes without status interference. + parent_a = kb.create_task(conn, title="retry-empty", assignee="alice") + kb.claim_task(conn, parent_a) + parent_b = kb.create_task(conn, title="retry-corrected", assignee="alice") + kb.claim_task(conn, parent_b) + real = kb.create_task( + conn, title="real-child", assignee="x", created_by="alice", + ) + + # First attempt: phantom in the list rejects, task stays running. + with pytest.raises(kb.HallucinatedCardsError): + kb.complete_task( + conn, parent_a, + summary="oops", + created_cards=["t_phantomdeadbeef"], + ) + assert kb.get_task(conn, parent_a).status == "running" + + # Retry with [] (escape hatch): gate is skipped, completion lands. + ok = kb.complete_task( + conn, parent_a, + summary="retry without claims", + created_cards=[], + ) + assert ok is True + assert kb.get_task(conn, parent_a).status == "done" + + # Same flow on parent_b, but recover via a corrected list rather + # than the empty escape hatch. + with pytest.raises(kb.HallucinatedCardsError): + kb.complete_task( + conn, parent_b, + summary="oops", + created_cards=[real, "t_anotherphantom"], + ) + assert kb.get_task(conn, parent_b).status == "running" + + ok = kb.complete_task( + conn, parent_b, + summary="retry with corrected list", + created_cards=[real], + ) + assert ok is True + assert kb.get_task(conn, parent_b).status == "done" + + # Both audit events landed; the eventual completion event is + # also present on each task. + for parent in (parent_a, parent_b): + kinds = [ + r["kind"] for r in conn.execute( + "SELECT kind FROM task_events WHERE task_id=? ORDER BY id", + (parent,), + ) + ] + assert kinds.count("completion_blocked_hallucination") == 1 + assert kinds.count("completed") == 1 + finally: + conn.close() + + def test_complete_prose_scan_flags_nonexistent_ids(kanban_home): """Successful completion whose summary references a ``t_<hex>`` id that doesn't resolve emits a ``suspected_hallucinated_references`` diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index b750139f454..fb1bdbf0cf6 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -177,12 +177,9 @@ def test_stale_claim_reclaimed(kanban_home, monkeypatch): host = _kb._claimer_id().split(":", 1)[0] kb.claim_task(conn, t, claimer=f"{host}:worker") killed: list[int] = [] - state = {"alive": True} - def _signal(pid, sig): + def _signal(_pid, sig): killed.append(sig) - if sig == signal.SIGTERM: - state["alive"] = False kb._set_worker_pid(conn, t, 12345) # Rewind claim_expires so it looks stale. @@ -190,13 +187,96 @@ def test_stale_claim_reclaimed(kanban_home, monkeypatch): "UPDATE tasks SET claim_expires = ? WHERE id = ?", (int(time.time()) - 3600, t), ) - monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: state["alive"]) + # Worker PID has died — exactly the case ``release_stale_claims`` + # should still reclaim (post-#23025: live PIDs are now extended). + monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False) reclaimed = kb.release_stale_claims(conn, signal_fn=_signal) assert reclaimed == 1 assert kb.get_task(conn, t).status == "ready" assert killed == [signal.SIGTERM] +def test_stale_claim_with_live_pid_extends_instead_of_reclaiming( + kanban_home, monkeypatch, +): + """A stale-by-TTL claim whose worker PID is still alive should be + extended, not reclaimed (#23025). Slow models can spend longer than + ``DEFAULT_CLAIM_TTL_SECONDS`` inside a single tool-free LLM call; + killing those healthy workers produces a respawn loop with zero + progress.""" + import hermes_cli.kanban_db as _kb + + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + host = _kb._claimer_id().split(":", 1)[0] + kb.claim_task(conn, t, claimer=f"{host}:worker") + kb._set_worker_pid(conn, t, 12345) + + old_expires = int(time.time()) - 60 + conn.execute( + "UPDATE tasks SET claim_expires = ? WHERE id = ?", + (old_expires, t), + ) + + monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True) + killed: list[int] = [] + reclaimed = kb.release_stale_claims( + conn, signal_fn=lambda _p, sig: killed.append(sig), + ) + assert reclaimed == 0 + task = kb.get_task(conn, t) + assert task.status == "running" + assert task.claim_expires is not None + assert task.claim_expires > old_expires + assert killed == [] # live worker not killed + + kinds = [ + r["kind"] for r in conn.execute( + "SELECT kind FROM task_events WHERE task_id = ?", (t,), + ).fetchall() + ] + assert "claim_extended" in kinds + assert "reclaimed" not in kinds + + +def test_stale_claim_reclaim_event_records_diagnostic_payload( + kanban_home, monkeypatch, +): + """``reclaimed`` events should carry claim_expires, last_heartbeat_at, + and worker_pid so operators can diagnose why a claim went stale + (#23025: previous payload only had ``stale_lock`` which gives no + timing context).""" + import json + import hermes_cli.kanban_db as _kb + + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + host = _kb._claimer_id().split(":", 1)[0] + kb.claim_task(conn, t, claimer=f"{host}:worker") + kb._set_worker_pid(conn, t, 12345) + old_expires = int(time.time()) - 3600 + hb_at = int(time.time()) - 1800 + conn.execute( + "UPDATE tasks SET claim_expires = ?, last_heartbeat_at = ? " + "WHERE id = ?", + (old_expires, hb_at, t), + ) + + monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False) + kb.release_stale_claims(conn, signal_fn=lambda _p, _s: None) + row = conn.execute( + "SELECT payload FROM task_events " + "WHERE task_id = ? AND kind = 'reclaimed'", + (t,), + ).fetchone() + assert row is not None + payload = json.loads(row["payload"]) + assert payload["claim_expires"] == old_expires + assert payload["last_heartbeat_at"] == hb_at + assert payload["worker_pid"] == 12345 + assert payload["host_local"] is True + + def test_max_runtime_uses_current_run_start_after_retry(kanban_home): """A retry should get a fresh max-runtime window. @@ -605,6 +685,57 @@ def test_dispatch_spawn_failure_releases_claim(kanban_home, all_assignees_spawna assert kb.get_task(conn, t).claim_lock is None +def test_dispatch_max_spawn_counts_existing_running_tasks( + kanban_home, all_assignees_spawnable +): + """max_spawn is a live concurrency cap, not a per-tick spawn cap. + + Without counting tasks already in ``running``, every dispatcher tick can + launch up to ``max_spawn`` more workers while previous workers are still + alive. Long-running boards then accumulate unbounded worker subprocesses. + """ + spawns = [] + + def fake_spawn(task, workspace): + spawns.append(task.id) + + with kb.connect() as conn: + running_a = kb.create_task(conn, title="running-a", assignee="alice") + running_b = kb.create_task(conn, title="running-b", assignee="bob") + ready = kb.create_task(conn, title="ready", assignee="carol") + kb.claim_task(conn, running_a) + kb.claim_task(conn, running_b) + + res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_spawn=2) + + assert res.spawned == [] + assert spawns == [] + assert kb.get_task(conn, ready).status == "ready" + + +def test_dispatch_max_spawn_fills_remaining_capacity( + kanban_home, all_assignees_spawnable +): + """When below cap, dispatch only fills available worker slots.""" + spawns = [] + + def fake_spawn(task, workspace): + spawns.append(task.id) + + with kb.connect() as conn: + running = kb.create_task(conn, title="running", assignee="alice") + ready_a = kb.create_task(conn, title="ready-a", assignee="bob") + ready_b = kb.create_task(conn, title="ready-b", assignee="carol") + kb.claim_task(conn, running) + + res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_spawn=2) + + assert len(res.spawned) == 1 + assert spawns == [ready_a] + assert kb.get_task(conn, ready_a).status == "running" + assert kb.get_task(conn, ready_b).status == "ready" + + def test_dispatch_reclaims_stale_before_spawning(kanban_home): with kb.connect() as conn: t = kb.create_task(conn, title="x", assignee="alice") @@ -1199,3 +1330,203 @@ def test_migrate_add_optional_columns_tolerates_concurrent_migration(kanban_home # Running migration on an already-migrated schema must not raise. kb._migrate_add_optional_columns(conn) conn.close() + + +# --------------------------------------------------------------------------- +# Dispatcher spawn invocation — _resolve_hermes_argv() +# +# Workers spawned by the dispatcher must use a `hermes` invocation that does +# not depend on PATH being set up correctly. cron jobs, systemd User= services, +# launchd jobs, and other detached processes routinely run with a stripped +# $PATH that doesn't include the venv's bin/, so a bare `["hermes", ...]` +# spawn fails with FileNotFoundError and the task gets stuck. The resolver +# prefers the PATH shim (familiar `ps` output) but falls back to the module +# form so the spawn keeps working when PATH is missing the shim. +# --------------------------------------------------------------------------- + + +def test_resolve_hermes_argv_prefers_path_shim(monkeypatch): + """When `hermes` is on PATH, use the shim — preserves familiar ps output.""" + import shutil + import hermes_cli.kanban_db as kb + + monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/hermes") + argv = kb._resolve_hermes_argv() + assert argv == ["/usr/local/bin/hermes"] + + +def test_resolve_hermes_argv_falls_back_to_module_form_when_no_path_shim(monkeypatch): + """When the shim is not on PATH, fall back to `python -m hermes_cli.main`. + + Pins the correct module name (NOT `hermes` — there is no top-level + `hermes` package). Regression for #23198: the original PR shipped + `python -m hermes` which fails with `No module named hermes` on every + invocation. + """ + import shutil + import sys + import hermes_cli.kanban_db as kb + + monkeypatch.setattr(shutil, "which", lambda name: None) + argv = kb._resolve_hermes_argv() + assert argv == [sys.executable, "-m", "hermes_cli.main"] + + +def test_resolve_hermes_argv_module_actually_runs(): + """The fallback module name must be importable + runnable. + + A unit test that pins the literal string is necessary but not + sufficient — if `hermes_cli.main` ever loses `if __name__ == "__main__"` + handling or its argparse setup, `python -m hermes_cli.main --version` + would fail and so would every dispatcher spawn that hits the fallback. + Run it as a real subprocess to catch that regression. + """ + import subprocess + import sys + import hermes_cli.kanban_db as kb + import shutil + import unittest.mock as mock + + with mock.patch.object(shutil, "which", return_value=None): + argv = kb._resolve_hermes_argv() + r = subprocess.run(argv + ["--version"], capture_output=True, text=True, timeout=30) + assert r.returncode == 0, ( + f"`{' '.join(argv)} --version` failed (rc={r.returncode}); " + f"stderr={r.stderr[:200]!r}" + ) + assert "Hermes Agent" in r.stdout, f"unexpected output: {r.stdout[:200]!r}" + + +# --------------------------------------------------------------------------- +# task_age — guard against corrupt timestamp values +# +# The Task dataclass declares ``created_at: int`` but rows come from sqlite +# without coercion at the boundary. A row that ever held a non-int (e.g. an +# unsubstituted ``'%s'`` from a logged format string, ``None``, an arbitrary +# string, or a float-as-string) used to crash ``task_age`` with ``ValueError`` +# and turn ``GET /api/plugins/kanban/board`` into a 500 because the dashboard +# calls ``task_age`` unguarded for every task in the response. +# +# After the fix, ``_safe_int`` returns ``None`` on bad input and ``task_age`` +# degrades gracefully (per-field ``None`` rather than a hard crash). +# --------------------------------------------------------------------------- + + +def _make_task(**overrides) -> "kb.Task": + """Minimal Task with all required fields filled in. Override anything.""" + defaults = dict( + id="t_age", + title="x", + body=None, + assignee=None, + status="ready", + priority=0, + created_by=None, + created_at=0, + started_at=None, + completed_at=None, + workspace_kind="scratch", + workspace_path=None, + claim_lock=None, + claim_expires=None, + tenant=None, + ) + defaults.update(overrides) + return kb.Task(**defaults) + + +def test_safe_int_accepts_int_and_int_string(): + """Sanity: well-typed values pass through.""" + assert kb._safe_int(0) == 0 + assert kb._safe_int(1700000000) == 1700000000 + assert kb._safe_int("1700000000") == 1700000000 + + +def test_safe_int_returns_none_on_corrupt_inputs(): + """All the failure modes that used to crash task_age.""" + # None — common when the column was never written + assert kb._safe_int(None) is None + # Unsubstituted format string — the literal case the PR title cites + assert kb._safe_int("%s") is None + # Arbitrary non-numeric strings + assert kb._safe_int("abc") is None + assert kb._safe_int("") is None + # Float-ish strings: int("1.5") raises ValueError too — caller wants None. + assert kb._safe_int("1.5") is None + # Random object — covered by TypeError branch + assert kb._safe_int(object()) is None + + +def test_task_age_handles_corrupt_created_at(): + """Pre-fix this raised ValueError and 500'd /api/plugins/kanban/board.""" + t = _make_task(created_at="%s") + age = kb.task_age(t) + assert age["created_age_seconds"] is None + assert age["started_age_seconds"] is None + assert age["time_to_complete_seconds"] is None + + +def test_task_age_handles_corrupt_started_and_completed(): + """All three timestamp fields share the same _safe_int treatment.""" + t = _make_task( + created_at=1700000000, + started_at="garbage", + completed_at=None, + ) + age = kb.task_age(t) + assert isinstance(age["created_age_seconds"], int) + assert age["started_age_seconds"] is None + assert age["time_to_complete_seconds"] is None + + +def test_task_age_well_formed_task(): + """Regression: the safe-int path must not change behavior for normal data.""" + import time + now = int(time.time()) + t = _make_task( + created_at=now - 60, + started_at=now - 30, + completed_at=now, + ) + age = kb.task_age(t) + assert 55 <= age["created_age_seconds"] <= 65 + assert 25 <= age["started_age_seconds"] <= 35 + assert 25 <= age["time_to_complete_seconds"] <= 35 + + +def test_task_dict_survives_corrupt_created_at(tmp_path, monkeypatch): + """Defense in depth: even if task_age ever raised, plugin_api must not 500. + + The PR also added a try/except around the task_age call in + `plugins/kanban/dashboard/plugin_api.py::_task_dict`. Verify a single + corrupt row doesn't turn the whole board response into an error. + """ + # Set up an isolated kanban home so we can write a corrupt created_at. + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + kb._INITIALIZED_PATHS.clear() + kb.init_db() + + # Insert a row with a non-int created_at (simulates the historical + # bug that produced corrupt rows). + conn = kb.connect() + try: + good_id = kb.create_task(conn, title="good") + # Now write a row with corrupt created_at directly. + conn.execute( + "UPDATE tasks SET created_at = ? WHERE id = ?", + ("%s", good_id), + ) + finally: + conn.close() + + # Re-read and pass through task_age — must not raise. + conn = kb.connect() + try: + task = kb.get_task(conn, good_id) + finally: + conn.close() + age = kb.task_age(task) + assert age["created_age_seconds"] is None diff --git a/tests/hermes_cli/test_kanban_diagnostics.py b/tests/hermes_cli/test_kanban_diagnostics.py index d39695ca94d..ad00e4136a8 100644 --- a/tests/hermes_cli/test_kanban_diagnostics.py +++ b/tests/hermes_cli/test_kanban_diagnostics.py @@ -75,10 +75,13 @@ def test_hallucinated_cards_fires_on_blocked_event(): phantom_cards=["t_bad1", "t_bad2"], verified_cards=["t_good1"]), ] - diags = kd.compute_task_diagnostics(task, events, []) - assert len(diags) == 1 - d = diags[0] - assert d.kind == "hallucinated_cards" + # ``now=300`` keeps the synthetic event timestamps in scope without + # tripping the stranded_in_ready rule (events are 100/200 epoch + # which time.time() would treat as ~50yr old). + diags = kd.compute_task_diagnostics(task, events, [], now=300) + halluc = [d for d in diags if d.kind == "hallucinated_cards"] + assert len(halluc) == 1 + d = halluc[0] assert d.severity == "error" assert d.data["phantom_ids"] == ["t_bad1", "t_bad2"] # Generic recovery actions always available; comment action too. @@ -379,3 +382,176 @@ def test_broken_rule_is_isolated(monkeypatch): # The broken rule silently drops, the real one still fires. kinds = [d.kind for d in diags] assert "repeated_failures" in kinds + + +# --------------------------------------------------------------------------- +# stranded_in_ready +# +# Surfaces ready tasks that nobody has claimed within the threshold. +# Identity-agnostic by design: catches typo'd assignees, deleted profiles, +# down external worker pools, and misconfigured dispatchers in one rule. +# --------------------------------------------------------------------------- + + +def test_stranded_in_ready_fires_when_age_exceeds_threshold(): + """Default threshold = 30 min. A ready task promoted 45 min ago + with no claim should fire as a warning.""" + now = 100_000 + task = _task(status="ready", assignee="demo", claim_lock=None) + # 45 min = 2700s, threshold = 1800s. + events = [_event("created", ts=now - 45 * 60)] + diags = kd.compute_task_diagnostics(task, events, [], now=now) + stranded = [d for d in diags if d.kind == "stranded_in_ready"] + assert len(stranded) == 1 + assert stranded[0].severity == "warning" + assert stranded[0].data["age_seconds"] == 45 * 60 + assert stranded[0].data["assignee"] == "demo" + + +def test_stranded_in_ready_silent_below_threshold(): + """A ready task only 10 min old should NOT fire.""" + now = 100_000 + task = _task(status="ready", assignee="demo", claim_lock=None) + events = [_event("created", ts=now - 10 * 60)] + diags = kd.compute_task_diagnostics(task, events, [], now=now) + assert [d for d in diags if d.kind == "stranded_in_ready"] == [] + + +def test_stranded_in_ready_skips_non_ready_status(): + """Tasks not in ready status are out of scope (running tasks have + their own crash / failure rules).""" + now = 100_000 + for status in ("running", "blocked", "done", "todo", "triage"): + task = _task(status=status, assignee="demo") + events = [_event("created", ts=now - 6 * 3600)] + diags = kd.compute_task_diagnostics(task, events, [], now=now) + assert [d for d in diags if d.kind == "stranded_in_ready"] == [], status + + +def test_stranded_in_ready_skips_unassigned_tasks(): + """Empty assignee = `skipped_unassigned` on the dispatcher already. + Don't double-flag here.""" + now = 100_000 + task = _task(status="ready", assignee="", claim_lock=None) + events = [_event("created", ts=now - 6 * 3600)] + diags = kd.compute_task_diagnostics(task, events, [], now=now) + assert [d for d in diags if d.kind == "stranded_in_ready"] == [] + + +def test_stranded_in_ready_skips_claimed_tasks(): + """A live claim_lock means a worker is on it — even an old one. Don't + second-guess: the run-level liveness signal owns that decision.""" + now = 100_000 + task = _task( + status="ready", assignee="demo", claim_lock="run_xyz", + ) + events = [_event("created", ts=now - 6 * 3600)] + diags = kd.compute_task_diagnostics(task, events, [], now=now) + assert [d for d in diags if d.kind == "stranded_in_ready"] == [] + + +def test_stranded_in_ready_uses_latest_ready_transition(): + """When multiple ready-transition events exist, the rule should + age-from the most recent — a task reclaimed 20 min ago is NOT + stranded for 6h even if it was first created 6h ago.""" + now = 100_000 + task = _task(status="ready", assignee="demo") + events = [ + _event("created", ts=now - 6 * 3600), # 6 h ago + _event("reclaimed", ts=now - 20 * 60), # 20 min ago — wins + ] + diags = kd.compute_task_diagnostics(task, events, [], now=now) + assert [d for d in diags if d.kind == "stranded_in_ready"] == [] + + +def test_stranded_in_ready_severity_escalates_with_age(): + """warning → error → critical at 2x and 6x threshold.""" + now = 100_000 + task = _task(status="ready", assignee="demo") + # Default threshold = 1800s. + cases = [ + (45 * 60, "warning"), # 1.5x → warning + (90 * 60, "error"), # 3x → error + (4 * 3600, "critical"), # 8x → critical + ] + for age, expected in cases: + events = [_event("created", ts=now - age)] + diags = kd.compute_task_diagnostics(task, events, [], now=now) + stranded = [d for d in diags if d.kind == "stranded_in_ready"] + assert len(stranded) == 1, f"age={age}" + assert stranded[0].severity == expected, ( + f"age={age} expected {expected}, got {stranded[0].severity}" + ) + + +def test_stranded_in_ready_respects_config_override(): + """Config override changes the threshold.""" + now = 100_000 + task = _task(status="ready", assignee="demo") + events = [_event("created", ts=now - 10 * 60)] # 10 min + # Default 30 min — wouldn't fire. + diags = kd.compute_task_diagnostics(task, events, [], now=now) + assert [d for d in diags if d.kind == "stranded_in_ready"] == [] + # Lower the threshold to 5 min — now it fires. + diags = kd.compute_task_diagnostics( + task, events, [], now=now, + config={"stranded_threshold_seconds": 5 * 60}, + ) + stranded = [d for d in diags if d.kind == "stranded_in_ready"] + assert len(stranded) == 1 + + +def test_stranded_in_ready_falls_back_to_created_at(): + """When events have no ready-transition kind, the rule falls back + to the task's ``created_at`` so an ancient stranded task isn't + invisible just because its events got pruned.""" + now = 100_000 + task = _task( + status="ready", assignee="demo", created_at=now - 4 * 3600, + ) + # No qualifying events. + events = [_event("commented", ts=now - 100)] + diags = kd.compute_task_diagnostics(task, events, [], now=now) + stranded = [d for d in diags if d.kind == "stranded_in_ready"] + assert len(stranded) == 1 + assert stranded[0].data["age_seconds"] == 4 * 3600 + + +def test_stranded_in_ready_works_on_real_db_row(kanban_home): + """Round-trip through real kanban_db.connect() — confirms the rule + works on sqlite3.Row objects, not just dicts.""" + import time as _t + conn = kb.connect() + try: + # Create a task and force its created_at into the past. + tid = kb.create_task(conn, title="stranded one", assignee="ghost") + old_ts = int(_t.time()) - 90 * 60 # 90 min old + conn.execute( + "UPDATE tasks SET status = 'ready', created_at = ? WHERE id = ?", + (old_ts, tid), + ) + conn.commit() + + task_row = conn.execute( + "SELECT * FROM tasks WHERE id = ?", (tid,) + ).fetchone() + events = list(conn.execute( + "SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at", + (tid,), + ).fetchall()) + # Override created event timestamps too so age calc lines up. + conn.execute( + "UPDATE task_events SET created_at = ? WHERE task_id = ?", + (old_ts, tid), + ) + conn.commit() + events = list(conn.execute( + "SELECT * FROM task_events WHERE task_id = ?", (tid,), + ).fetchall()) + + diags = kd.compute_task_diagnostics(task_row, events, []) + stranded = [d for d in diags if d.kind == "stranded_in_ready"] + assert len(stranded) == 1 + assert stranded[0].data["assignee"] == "ghost" + finally: + conn.close() diff --git a/tests/hermes_cli/test_kanban_notify.py b/tests/hermes_cli/test_kanban_notify.py index 3b8cf4865d9..ddfa4b40aa2 100644 --- a/tests/hermes_cli/test_kanban_notify.py +++ b/tests/hermes_cli/test_kanban_notify.py @@ -2,6 +2,7 @@ import asyncio import pytest from pathlib import Path +from types import SimpleNamespace from hermes_cli import kanban_db as kb from unittest.mock import AsyncMock, MagicMock, patch @@ -76,7 +77,13 @@ async def test_notifier_unsubs_after_completed_event(kanban_home): @pytest.mark.parametrize('kind', ["gave_up", "crashed", "timed_out"]) async def test_notifier_unsubs_after_abnormal_events(kind, kanban_home): """ - Event kind of gave_up, crashed, time_out would be cover, and remove subscription + Event kinds gave_up / crashed / timed_out send a notification but DO + NOT delete the subscription. The dispatcher may respawn the task and + fire the same event kind again (e.g. a worker that crashes, gets + reclaimed, and crashes a second time); the user must hear about the + second event too. Subscriptions are removed only when the task hits + a truly final status (done / archived) — see the comment on + TERMINAL_KINDS in gateway/run.py and PR #21398. """ import hermes_cli.kanban_db as kb from gateway.run import GatewayRunner @@ -114,15 +121,27 @@ async def test_notifier_unsubs_after_abnormal_events(kind, kanban_home): timeout=10.0, ) + # The user is notified about the abnormal event... fake_adapter.send.assert_called_once() assert kind.replace('_', ' ') in fake_adapter.send.call_args[0][1] + # ...but the subscription survives so a respawn-then-same-event cycle + # reaches the user too. The cursor (last_event_id) advanced inside + # the same write txn as the claim, so the same event won't re-fire. conn = kb.connect() try: subs = kb.list_notify_subs(conn, tid) finally: conn.close() - assert subs == [], "Subscription should be unsub after abnormal crash" + assert len(subs) == 1, ( + f"Subscription should survive {kind!r} so the next cycle of the " + f"same event reaches the user; got {subs!r}" + ) + assert int(subs[0]["last_event_id"]) >= 1, ( + "Cursor should have advanced past the delivered event " + "(claim_unseen_events_for_sub advances atomically inside the " + "same write txn as the read)." + ) @pytest.mark.asyncio @@ -301,3 +320,162 @@ def test_dispatcher_tick_does_not_call_init_db(kanban_home, monkeypatch): "_kanban_notifier_watcher must not call _kb.init_db(board=slug) — " "see issue #21378." ) + + +@pytest.mark.asyncio +async def test_notifier_skips_subscription_owned_by_other_profile(kanban_home): + """Each gateway keeps its watcher on, but only the subscribing profile claims.""" + import hermes_cli.kanban_db as kb + from gateway.run import GatewayRunner + from gateway.config import Platform + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="owned task", assignee="backend-engineer") + kb.add_notify_sub( + conn, + task_id=tid, + platform="telegram", + chat_id="chat1", + notifier_profile="default", + ) + kb.complete_task(conn, tid, result="done") + finally: + conn.close() + + runner = object.__new__(GatewayRunner) + runner._running = True + runner._kanban_sub_fail_counts = {} + runner._kanban_notifier_profile = "business-partner" + + fake_adapter = MagicMock() + fake_adapter.send = AsyncMock() + runner.adapters = {Platform.TELEGRAM: fake_adapter} + + _orig_sleep = asyncio.sleep + tick_count = 0 + + async def _fast_sleep(_): + nonlocal tick_count + await _orig_sleep(0) + tick_count += 1 + if tick_count >= 3: + runner._running = False + + with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep): + await asyncio.wait_for( + runner._kanban_notifier_watcher(interval=1), + timeout=10.0, + ) + + fake_adapter.send.assert_not_called() + conn = kb.connect() + try: + subs = kb.list_notify_subs(conn, tid) + finally: + conn.close() + assert len(subs) == 1 + assert int(subs[0]["last_event_id"]) == 0, "wrong profile must not claim the event" + + +@pytest.mark.asyncio +async def test_notifier_delivers_subscription_owned_by_current_profile(kanban_home): + """The gateway for the profile that created/subscribed the task reports it.""" + import hermes_cli.kanban_db as kb + from gateway.run import GatewayRunner + from gateway.config import Platform + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="owned task", assignee="backend-engineer") + kb.add_notify_sub( + conn, + task_id=tid, + platform="telegram", + chat_id="chat1", + notifier_profile="default", + ) + kb.complete_task(conn, tid, result="done") + finally: + conn.close() + + runner = object.__new__(GatewayRunner) + runner._running = True + runner._kanban_sub_fail_counts = {} + runner._kanban_notifier_profile = "default" + + fake_adapter = MagicMock() + + async def _send_and_stop(chat_id, msg, metadata=None): + runner._running = False + + fake_adapter.send = AsyncMock(side_effect=_send_and_stop) + runner.adapters = {Platform.TELEGRAM: fake_adapter} + + _orig_sleep = asyncio.sleep + + async def _fast_sleep(_): + await _orig_sleep(0) + + with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep): + await asyncio.wait_for( + runner._kanban_notifier_watcher(interval=1), + timeout=10.0, + ) + + fake_adapter.send.assert_called_once() + conn = kb.connect() + try: + subs = kb.list_notify_subs(conn, tid) + finally: + conn.close() + assert subs == [] + + +@pytest.mark.asyncio +async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home): + """`/kanban --board <slug> create ...` must subscribe on that board. + + The gateway handler currently auto-subscribes after `/kanban create`, + but the create detection must still work when the shared `--board` + flag appears before the subcommand, and the subscription must land in + that board's DB rather than the ambient/default board. + """ + from gateway.run import GatewayRunner + from gateway.config import Platform + + kb.create_board("projx") + + runner = object.__new__(GatewayRunner) + source = SimpleNamespace( + platform=Platform.TELEGRAM, + chat_id="chat1", + thread_id="th1", + user_id="u1", + ) + event = SimpleNamespace( + text='/kanban --board projx create "hello" --assignee alice', + source=source, + ) + + out = await GatewayRunner._handle_kanban_command(runner, event) + + assert "subscribed" in out.lower() + + conn = kb.connect(board="projx") + try: + subs = kb.list_notify_subs(conn) + tasks = kb.list_tasks(conn) + finally: + conn.close() + + assert [t.title for t in tasks] == ["hello"] + assert len(subs) == 1 + assert subs[0]["chat_id"] == "chat1" + assert subs[0]["thread_id"] == "th1" + + conn = kb.connect(board="default") + try: + assert kb.list_notify_subs(conn) == [] + finally: + conn.close() diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py index 2b757ac79b2..8910705c74d 100644 --- a/tests/hermes_cli/test_model_catalog.py +++ b/tests/hermes_cli/test_model_catalog.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import time from pathlib import Path from unittest.mock import patch @@ -282,3 +283,48 @@ class TestIntegrationWithModelsModule: result = get_curated_nous_model_ids() assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"] + + def test_picker_nous_row_uses_manifest(self, tmp_path, monkeypatch): + """The /model picker must surface the manifest's nous list, not the + in-repo _PROVIDER_MODELS["nous"] snapshot. Regression: before this + fix, list_authenticated_providers() built the curated dict from + _PROVIDER_MODELS only — so newly-added Portal models never reached + the slash-command picker until the next Hermes release. + """ + # We deliberately do NOT use the ``isolated_home`` fixture here: + # that fixture monkeypatches ``Path.home`` to ``tmp_path``, which + # trips the auth-store seat-belt in ``_auth_file_path()`` because + # ``HERMES_HOME / auth.json`` then resolves to the same path the + # seat-belt thinks is the "real" user store. Use the autouse + # ``_hermetic_environment`` HERMES_HOME directly instead. + import importlib + from hermes_cli import model_catalog + importlib.reload(model_catalog) + try: + from hermes_cli.model_switch import list_picker_providers + + active_home = Path(os.environ["HERMES_HOME"]) + (active_home / "auth.json").write_text( + json.dumps( + { + "providers": {"nous": {"access_token": "fake"}}, + "credential_pool": {}, + } + ) + ) + + with patch.object( + model_catalog, "_fetch_manifest", return_value=_valid_manifest() + ): + picker = list_picker_providers( + current_provider="nous", max_models=99 + ) + finally: + model_catalog.reset_cache() + + nous_row = next((r for r in picker if r["slug"] == "nous"), None) + assert nous_row is not None, "nous row must appear when authed" + assert nous_row["models"] == [ + "anthropic/claude-opus-4.7", + "moonshotai/kimi-k2.6", + ] diff --git a/tests/hermes_cli/test_session_handoff.py b/tests/hermes_cli/test_session_handoff.py new file mode 100644 index 00000000000..2fd9e9e1ab9 --- /dev/null +++ b/tests/hermes_cli/test_session_handoff.py @@ -0,0 +1,202 @@ +"""Tests for session handoff (CLI to gateway platform). + +The handoff state machine lives on the ``sessions`` table: + + None → "pending" → "running" → ("completed" | "failed") + +CLI side calls ``request_handoff`` and poll-waits on ``get_handoff_state``. +Gateway side iterates ``list_pending_handoffs``, calls ``claim_handoff`` to +flip pending → running, and finishes with ``complete_handoff`` or +``fail_handoff``. +""" + +from __future__ import annotations + +import time + +import pytest + +from hermes_state import SessionDB + + +class TestHandoffStateDB: + """Test the handoff schema + helper methods on SessionDB.""" + + @pytest.fixture + def db(self, tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + return SessionDB(db_path=home / "state.db") + + def _make_session(self, db, session_id, source="cli", title=None): + """Insert a session row directly for testing.""" + def _do(conn): + conn.execute( + "INSERT OR IGNORE INTO sessions (id, source, title, started_at) " + "VALUES (?, ?, ?, ?)", + (session_id, source, title, time.time()), + ) + db._execute_write(_do) + + def test_columns_exist(self, db): + db._conn.execute( + "SELECT handoff_state, handoff_platform, handoff_error " + "FROM sessions LIMIT 0" + ) + + def test_request_handoff_marks_pending(self, db): + sid = "sess-1" + self._make_session(db, sid) + + assert db.request_handoff(sid, "telegram") is True + + state = db.get_handoff_state(sid) + assert state == { + "state": "pending", + "platform": "telegram", + "error": None, + } + + def test_request_handoff_rejects_in_flight(self, db): + sid = "sess-2" + self._make_session(db, sid) + + assert db.request_handoff(sid, "telegram") is True + # Still pending → reject re-request + assert db.request_handoff(sid, "discord") is False + + # And after gateway claims it (running) → still rejected + assert db.claim_handoff(sid) is True + assert db.request_handoff(sid, "discord") is False + + def test_request_handoff_after_terminal_state_resets_error(self, db): + sid = "sess-3" + self._make_session(db, sid) + db.request_handoff(sid, "telegram") + db.claim_handoff(sid) + db.fail_handoff(sid, "earlier failure") + + # User retries — should be allowed and clear the prior error. + assert db.request_handoff(sid, "discord") is True + state = db.get_handoff_state(sid) + assert state["state"] == "pending" + assert state["platform"] == "discord" + assert state["error"] is None + + def test_list_pending_handoffs_excludes_running_and_terminal(self, db): + a, b, c, d = "sess-a", "sess-b", "sess-c", "sess-d" + for sid in (a, b, c, d): + self._make_session(db, sid) + + db.request_handoff(a, "telegram") + db.request_handoff(b, "discord") + db.request_handoff(c, "telegram") + db.claim_handoff(c) # c is now running, not pending + db.request_handoff(d, "slack") + db.claim_handoff(d) + db.complete_handoff(d) # d is terminal + + pending = db.list_pending_handoffs() + ids = [r["id"] for r in pending] + assert set(ids) == {a, b} + + def test_claim_handoff_is_atomic(self, db): + sid = "sess-claim" + self._make_session(db, sid) + db.request_handoff(sid, "telegram") + + # First claim wins + assert db.claim_handoff(sid) is True + # Second claim is a no-op (state is now "running", not "pending") + assert db.claim_handoff(sid) is False + assert db.get_handoff_state(sid)["state"] == "running" + + def test_complete_handoff_clears_error(self, db): + sid = "sess-complete" + self._make_session(db, sid) + db.request_handoff(sid, "telegram") + db.claim_handoff(sid) + db.fail_handoff(sid, "transient") + # User retries; mock the watcher path + db.request_handoff(sid, "telegram") + db.claim_handoff(sid) + db.complete_handoff(sid) + + state = db.get_handoff_state(sid) + assert state["state"] == "completed" + assert state["error"] is None + + def test_fail_handoff_records_reason(self, db): + sid = "sess-fail" + self._make_session(db, sid) + db.request_handoff(sid, "telegram") + db.claim_handoff(sid) + db.fail_handoff(sid, "no home channel for telegram") + + state = db.get_handoff_state(sid) + assert state["state"] == "failed" + assert state["error"] == "no home channel for telegram" + + def test_fail_handoff_truncates_long_reasons(self, db): + sid = "sess-fail-long" + self._make_session(db, sid) + db.request_handoff(sid, "telegram") + db.claim_handoff(sid) + + # 1000-character error string + big_err = "x" * 1000 + db.fail_handoff(sid, big_err) + + state = db.get_handoff_state(sid) + assert len(state["error"]) <= 500 + + def test_get_handoff_state_for_unknown_session(self, db): + assert db.get_handoff_state("does-not-exist") is None + + def test_full_pending_to_completed_flow(self, db): + """End-to-end sequence the CLI + gateway watcher follow.""" + sid = "sess-flow" + self._make_session(db, sid, title="my session") + db.append_message(sid, "user", "Hello") + db.append_message(sid, "assistant", "Hi there!") + + # CLI: request handoff + assert db.request_handoff(sid, "telegram") is True + assert db.get_handoff_state(sid)["state"] == "pending" + + # Gateway watcher: discover + claim + pending = db.list_pending_handoffs() + assert len(pending) == 1 + assert pending[0]["id"] == sid + assert db.claim_handoff(sid) is True + assert db.get_handoff_state(sid)["state"] == "running" + + # Gateway uses get_messages to load the transcript (real flow uses + # session_store.switch_session which reads the same table). + messages = db.get_messages(sid) + assert [m["role"] for m in messages] == ["user", "assistant"] + + # Gateway: mark completed + db.complete_handoff(sid) + assert db.get_handoff_state(sid)["state"] == "completed" + assert db.list_pending_handoffs() == [] + + +class TestHandoffCommandRegistration: + """Slash-command surface checks.""" + + def test_command_registered(self): + from hermes_cli.commands import resolve_command + cmd = resolve_command("handoff") + assert cmd is not None + assert cmd.name == "handoff" + assert cmd.category == "Session" + + def test_command_is_cli_only(self): + """`/handoff` is initiated from the CLI; gateway shouldn't expose it.""" + from hermes_cli.commands import resolve_command, GATEWAY_KNOWN_COMMANDS + cmd = resolve_command("handoff") + assert cmd is not None + assert cmd.cli_only is True + assert "handoff" not in GATEWAY_KNOWN_COMMANDS diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 9ec6e300c2c..22e2b8b2944 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1946,6 +1946,117 @@ class TestNormaliseThemeExtensions: assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"} +class TestPluginAPIAuth: + """Tests that plugin API routes require the session token (issue #19533).""" + + @pytest.fixture(autouse=True) + def _setup_test_client(self, monkeypatch, _isolate_hermes_home): + """Create a TestClient without the session token header.""" + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + import hermes_state + from hermes_constants import get_hermes_home + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") + + self.client = TestClient(app) + self.auth_client = TestClient(app) + self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + + def test_plugin_route_requires_auth(self): + """Plugin API routes should return 401 without a valid session token.""" + # Use a known plugin route (kanban board) + resp = self.client.get("/api/plugins/kanban/board") + assert resp.status_code == 401 + + def test_plugin_route_allows_auth(self): + """Plugin API routes should work with a valid session token. + + Use ``/api/plugins/example/hello`` from the example-dashboard plugin — + a stable, side-effect-free GET that's always loaded in tests. With a + valid token the handler should run (200); without one the middleware + should 401 before the handler is reached. + """ + # Without auth: middleware blocks before reaching the handler. + resp = self.client.get("/api/plugins/example/hello") + assert resp.status_code == 401 + + # With auth: handler runs. + resp = self.auth_client.get("/api/plugins/example/hello") + assert resp.status_code == 200 + + def test_plugin_post_requires_auth(self): + """Plugin POST routes should return 401 without a valid session token.""" + resp = self.client.post("/api/plugins/kanban/tasks", json={"title": "test"}) + assert resp.status_code == 401 + + def test_plugin_patch_requires_auth(self): + """Plugin PATCH routes should return 401 without a valid session token. + + PATCH is the mutation method most commonly used by the dashboard for + kanban task edits — explicitly cover it so a future middleware + regression that whitelists non-GET methods can't sneak through. + """ + resp = self.client.patch( + "/api/plugins/kanban/tasks/t_fake", + json={"title": "renamed"}, + ) + assert resp.status_code == 401 + + def test_plugin_delete_requires_auth(self): + """Plugin DELETE routes should return 401 without a valid session token.""" + resp = self.client.delete("/api/plugins/kanban/tasks/t_fake") + assert resp.status_code == 401 + + def test_non_kanban_plugin_route_requires_auth(self): + """Auth must be plugin-agnostic, not kanban-specific. + + The middleware fix is at the gate level (no per-plugin allowlist), + so any plugin's API surface — kanban, hermes-achievements, future + plugins — must require the session token. Hit a non-kanban plugin + path to lock that in. + """ + # Real plugin path (hermes-achievements is loaded by default). + resp = self.client.get("/api/plugins/hermes-achievements/overview") + assert resp.status_code == 401 + # Same for an arbitrary plugin namespace that doesn't even exist — + # the middleware should 401 before routing decides 404, so an + # attacker can't fingerprint plugin names by status codes. + resp = self.client.get("/api/plugins/_definitely_not_a_plugin_/anything") + assert resp.status_code == 401 + + def test_plugin_websocket_unaffected_by_http_middleware(self): + """The kanban /events WebSocket has its own ``?token=`` check; + the HTTP middleware change must not start gating WS upgrades. + + Starlette doesn't run HTTP middleware on WebSocket upgrades anyway, + but pin the behavior so a future refactor that moves auth into a + shared layer can't silently break the WS auth contract. + """ + from starlette.websockets import WebSocketDisconnect + from hermes_cli.web_server import _SESSION_TOKEN + + # Without a token the WS endpoint must close the upgrade itself + # (its own _check_ws_token), NOT 401 from the HTTP middleware. + try: + with self.client.websocket_connect( + "/api/plugins/kanban/events" + ): + pass # if we got here without disconnect, the WS accepted us + except WebSocketDisconnect: + pass # expected — WS endpoint rejected via its own check + except Exception: + # The kanban plugin may not be mounted in this test environment, + # in which case the route doesn't exist at all (3xx/4xx during + # upgrade). That's fine for this regression — it only matters + # that the HTTP middleware didn't start intercepting WS upgrades. + pass + + class TestDashboardPluginManifestExtensions: """Tests for the extended plugin manifest fields (tab.override, tab.hidden, slots) read by _discover_dashboard_plugins().""" diff --git a/tests/hermes_cli/test_web_ui_build.py b/tests/hermes_cli/test_web_ui_build.py index be44d511e08..3dde893d6e7 100644 --- a/tests/hermes_cli/test_web_ui_build.py +++ b/tests/hermes_cli/test_web_ui_build.py @@ -13,7 +13,7 @@ from unittest.mock import patch import pytest -from hermes_cli.main import _web_ui_build_needed, _build_web_ui +from hermes_cli.main import _web_ui_build_needed, _build_web_ui, _run_npm_install_deterministic def _touch(path: Path, offset: float = 0.0) -> None: @@ -119,3 +119,92 @@ class TestBuildWebUISkipsWhenFresh: assert result is True assert mock_run.call_count == 2 # npm install + npm run build + + def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path): + web_dir, _ = _make_web_dir(tmp_path) + (web_dir / "package-lock.json").write_text("{}", encoding="utf-8") + + mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + with patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run: + result = _run_npm_install_deterministic("/usr/bin/npm", web_dir) + + assert result.returncode == 0 + _, kwargs = mock_run.call_args + assert kwargs["text"] is True + assert kwargs["encoding"] == "utf-8" + assert kwargs["errors"] == "replace" + + def test_web_build_uses_utf8_replace_output_decoding(self, tmp_path): + web_dir, _ = _make_web_dir(tmp_path) + + mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main.subprocess.run", side_effect=[mock_cp, mock_cp]) as mock_run: + result = _build_web_ui(web_dir) + + assert result is True + _, build_kwargs = mock_run.call_args_list[1] + assert build_kwargs["text"] is True + assert build_kwargs["encoding"] == "utf-8" + assert build_kwargs["errors"] == "replace" + + +class TestBuildWebUIRetryAndStaleFallback: + """Coverage for the retry + stale-dist fallback added in #23824 / issue #23817.""" + + def test_retries_build_once_on_failure(self, tmp_path): + web_dir, _ = _make_web_dir(tmp_path) + Subprocess = __import__("subprocess") + # install: success; build attempt 1: fail; build attempt 2: success + install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="") + build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="EPERM") + build_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._time.sleep") as mock_sleep, \ + patch("hermes_cli.main.subprocess.run", + side_effect=[install_ok, build_fail, build_ok]) as mock_run: + result = _build_web_ui(web_dir) + + assert result is True + assert mock_run.call_count == 3 # install + build + retry + mock_sleep.assert_called_once_with(3) + + def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys): + web_dir, dist_dir = _make_web_dir(tmp_path) + # Stale dist exists but is older than source + _touch(dist_dir / "index.html", offset=-100) + _touch(web_dir / "src" / "App.tsx") # newer source -> build_needed=True + + Subprocess = __import__("subprocess") + install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="") + build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._time.sleep"), \ + patch("hermes_cli.main.subprocess.run", + side_effect=[install_ok, build_fail, build_fail]): + result = _build_web_ui(web_dir, fatal=True) + + # MUST return True (serve stale) — issue #23817 — even with fatal=True, + # because cmd_dashboard passes fatal=True and is the primary caller. + assert result is True + out = capsys.readouterr().out + assert "serving stale dist as fallback" in out + assert "vite ENOMEM" in out # stderr surfaced to user + + def test_hard_fails_when_no_dist_to_fall_back_to(self, tmp_path, capsys): + web_dir, _ = _make_web_dir(tmp_path) + + Subprocess = __import__("subprocess") + install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="") + build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM") + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._time.sleep"), \ + patch("hermes_cli.main.subprocess.run", + side_effect=[install_ok, build_fail, build_fail]): + result = _build_web_ui(web_dir, fatal=True) + + assert result is False + out = capsys.readouterr().out + assert "Web UI build failed" in out + assert "vite ENOMEM" in out + assert "Run manually" in out diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index cb3793db02e..d4c3f2adc47 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -534,6 +534,9 @@ def test_board_auto_initializes_missing_db(tmp_path, monkeypatch): home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.delenv("HERMES_KANBAN_BOARD", raising=False) + monkeypatch.delenv("HERMES_KANBAN_DB", raising=False) + monkeypatch.delenv("HERMES_KANBAN_HOME", raising=False) monkeypatch.setattr(Path, "home", lambda: tmp_path) # Deliberately DO NOT call kb.init_db(). @@ -1036,6 +1039,20 @@ def test_create_task_without_skills_defaults_to_empty_list(client): assert task.get("skills") in (None, []) +def test_create_task_with_toolset_name_in_skills_is_rejected(client): + """POST /tasks fails fast when callers confuse toolsets with skills.""" + r = client.post( + "/api/plugins/kanban/tasks", + json={ + "title": "bad skills payload", + "assignee": "linguist", + "skills": ["web"], + }, + ) + assert r.status_code == 400, r.text + assert "toolset name" in r.json()["detail"] + + # --------------------------------------------------------------------------- # Dispatcher-presence warning in POST /tasks response @@ -1723,3 +1740,57 @@ def test_dashboard_requests_default_board_explicitly(): assert "SDK.fetchJSON(withBoard(`${API}/config`, board))" in dist assert "SDK.fetchJSON(withBoard(`${API}/boards`, board))" in dist assert "}, [loadBoardList, switchBoard, board]);" in dist + + +def test_dashboard_search_includes_body_and_result(): + """Client-side search must match body, result, latest_summary, and summary + so full card contents are findable.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + + assert "t.body || \"\"" in dist + assert "t.result || \"\"" in dist + assert "t.latest_summary || \"\"" in dist + + +def test_dashboard_bulk_actions_include_reclaim_first(): + """Bulk action bar must expose reclaim_first checkbox and expanded status buttons.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + + assert "reclaim_first: reclaimFirst" in dist + assert "hermes-kanban-bulk-reclaim-first" in dist + assert '"→ todo"' in dist + assert '"Block"' in dist + assert '"Unblock"' in dist + + +def test_dashboard_shift_click_range_selection_exists(): + """Shift-click must trigger range selection via toggleRange.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + + assert "function toggleRange" in dist or "const toggleRange =" in dist + assert "props.toggleRange(t.id)" in dist or "props.toggleRange" in dist + assert "e.shiftKey" in dist + + +def test_dashboard_multi_move_bulk_exists(): + """Dragging a selected card with other selections must use /tasks/bulk.""" + repo_root = Path(__file__).resolve().parents[2] + dist = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + + assert "onMoveSelected" in dist + assert "props.onMoveSelected" in dist + assert "`${API}/tasks/bulk`" in dist + + +def test_dashboard_failed_card_highlight_class_exists(): + """Partial bulk failures must highlight failing cards.""" + repo_root = Path(__file__).resolve().parents[2] + js = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js").read_text() + css = (repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "style.css").read_text() + + assert "hermes-kanban-card--failed" in js + assert "hermes-kanban-card--failed" in css + assert "failedIds" in js diff --git a/tests/run_agent/test_anthropic_prompt_cache_policy.py b/tests/run_agent/test_anthropic_prompt_cache_policy.py index b8a380a62e7..0c5b17a39f6 100644 --- a/tests/run_agent/test_anthropic_prompt_cache_policy.py +++ b/tests/run_agent/test_anthropic_prompt_cache_policy.py @@ -290,3 +290,102 @@ class TestExplicitOverrides: model="anthropic/claude-sonnet-4.6", ) assert (should, native) == (True, False) + + +# ───────────────────────────────────────────────────────────────────── +# Long-lived prefix cache policy (cross-session 1h tier) +# ───────────────────────────────────────────────────────────────────── + +class TestSupportsLongLivedAnthropicCache: + """Narrower than _anthropic_prompt_cache_policy — only Claude on the 4 + explicitly-validated endpoints get the long-lived layout.""" + + def test_native_anthropic_claude_supported(self): + agent = _make_agent( + provider="anthropic", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + model="claude-sonnet-4.6", + ) + assert agent._supports_long_lived_anthropic_cache() is True + + def test_anthropic_oauth_supported(self): + # OAuth uses the same transport as native Anthropic + agent = _make_agent( + provider="anthropic", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + model="claude-opus-4.6", + ) + assert agent._supports_long_lived_anthropic_cache() is True + + def test_openrouter_claude_supported(self): + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="anthropic/claude-sonnet-4.6", + ) + assert agent._supports_long_lived_anthropic_cache() is True + + def test_nous_portal_claude_supported(self): + # Nous Portal proxies to OpenRouter — same wire format + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + api_mode="chat_completions", + model="anthropic/claude-opus-4.7", + ) + assert agent._supports_long_lived_anthropic_cache() is True + + def test_openrouter_non_claude_rejected(self): + agent = _make_agent( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="openai/gpt-5.4", + ) + assert agent._supports_long_lived_anthropic_cache() is False + + def test_third_party_anthropic_gateway_rejected(self): + # MiniMax / Kimi / etc. — anthropic-wire but not in our validated list + agent = _make_agent( + provider="minimax", + base_url="https://api.minimax.io/anthropic", + api_mode="anthropic_messages", + model="minimax-m2.7", + ) + assert agent._supports_long_lived_anthropic_cache() is False + + def test_alibaba_dashscope_rejected(self): + agent = _make_agent( + provider="alibaba", + base_url="https://dashscope.aliyuncs.com/api/v1/anthropic", + api_mode="anthropic_messages", + model="qwen3.5-plus", + ) + assert agent._supports_long_lived_anthropic_cache() is False + + def test_opencode_qwen_rejected(self): + agent = _make_agent( + provider="opencode-go", + base_url="https://api.opencode-go.example/v1", + api_mode="chat_completions", + model="qwen3.6-plus", + ) + assert agent._supports_long_lived_anthropic_cache() is False + + def test_fallback_target_evaluated_independently(self): + # Starting on a non-supported provider, falling back to OpenRouter Claude + agent = _make_agent( + provider="minimax", + base_url="https://api.minimax.io/anthropic", + api_mode="anthropic_messages", + model="minimax-m2.7", + ) + assert agent._supports_long_lived_anthropic_cache( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + api_mode="chat_completions", + model="anthropic/claude-sonnet-4.6", + ) is True diff --git a/tests/run_agent/test_image_rejection_fallback.py b/tests/run_agent/test_image_rejection_fallback.py index e52719d9742..d1d6c7ff028 100644 --- a/tests/run_agent/test_image_rejection_fallback.py +++ b/tests/run_agent/test_image_rejection_fallback.py @@ -194,6 +194,7 @@ class TestImageRejectionPhraseIsolation: "does not support multimodal", "does not support vision", "model does not support image", + "image_url'. expected", ) def _matches(self, body: str) -> bool: @@ -238,6 +239,29 @@ class TestImageRejectionPhraseIsolation: "This model does not support images", "vision is not supported on this endpoint", "model does not support image input", + # ChatGPT-account Codex backend (issue #23570) — rejects + # data:image/...base64 URLs in input_image fields. Without this + # match the agent cascaded into compression / context-too-large + # recovery instead of just stripping the images. + "Invalid 'input[56].content[1].image_url'. Expected a valid URL, but got a value with an invalid format.", ] for body in bodies: assert self._matches(body) is True, f"false negative on: {body}" + + def test_codex_data_url_rejection_does_not_false_match_other_url_errors(self): + """The narrow 'image_url'. expected' phrase (keyed on the + field-path apostrophe used in the Codex Responses error format) + must NOT trip on URL validation errors that aren't about + image_url specifically. See issue #23570 for the original error. + """ + bodies = [ + # Generic URL validation errors — should NOT trip + "Invalid webhook_url. Must be a valid URL.", + "Expected a valid URL but got an empty string.", + "redirect_uri does not look like a valid URL.", + # An image_url error worded differently — also should not trip + # the narrow phrase (a separate phrase would be needed) + "image_url field cannot be empty", + ] + for body in bodies: + assert self._matches(body) is False, f"false positive on: {body}" diff --git a/tests/run_agent/test_materialize_data_url_cleanup.py b/tests/run_agent/test_materialize_data_url_cleanup.py new file mode 100644 index 00000000000..a3327a4be47 --- /dev/null +++ b/tests/run_agent/test_materialize_data_url_cleanup.py @@ -0,0 +1,54 @@ +"""Regression test: temp file cleanup when materializing data URLs for vision. + +`_materialize_data_url_for_vision` creates a `NamedTemporaryFile(delete=False)` +so the path can be handed to vision backends. If `base64.b64decode` raises on +a corrupt/unsupported data URL the temp file would otherwise persist forever +on disk, leaking once per failed call. +""" + +from __future__ import annotations + +import base64 +import os +import tempfile +from pathlib import Path + +import pytest + +from run_agent import AIAgent + + +def _list_anthropic_tmpfiles(tmpdir: str) -> list[str]: + return [ + name for name in os.listdir(tmpdir) + if name.startswith("anthropic_image_") + ] + + +def test_b64decode_failure_does_not_leak_tempfile(monkeypatch, tmp_path): + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + + bad_url = "data:image/png;base64,!!!not-valid-base64!!!" + with pytest.raises(Exception): + AIAgent._materialize_data_url_for_vision(bad_url) + + leftovers = _list_anthropic_tmpfiles(str(tmp_path)) + assert leftovers == [], f"leaked temp files after decode failure: {leftovers}" + + +def test_successful_decode_returns_path_to_existing_file(monkeypatch, tmp_path): + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + + payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 # a few bytes is enough + encoded = base64.b64encode(payload).decode("ascii") + good_url = f"data:image/png;base64,{encoded}" + + path_str, path_obj = AIAgent._materialize_data_url_for_vision(good_url) + + assert isinstance(path_obj, Path) + assert path_obj.exists() + assert path_obj.read_bytes() == payload + assert path_str == str(path_obj) + # Caller is responsible for cleanup; mimic that here so the test leaves + # no artifacts behind. + path_obj.unlink() diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 5bc485e0711..dadb7b31cce 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3344,6 +3344,88 @@ class TestRunConversation: assert "truncated due to output length limit" in result["error"] mock_handle_function_call.assert_not_called() + def test_kanban_block_called_on_iteration_exhaustion(self, agent, monkeypatch): + """Regression: kanban worker must call kanban_block when iteration + budget is exhausted, otherwise the dispatcher sees a protocol + violation and gives up after 1 failure (issue #23216).""" + self._setup_agent(agent) + agent.max_iterations = 2 + + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_test_task_123") + + # Return a tool call for every iteration to exhaust the budget. + tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1") + tool_resp = _mock_response( + content="", finish_reason="tool_calls", tool_calls=[tc], + ) + # Final summary response from _handle_max_iterations. + summary_resp = _mock_response( + content="Could not finish — budget exhausted.", finish_reason="stop", + ) + agent.client.chat.completions.create.side_effect = [ + tool_resp, tool_resp, summary_resp, + ] + + with ( + patch("run_agent.handle_function_call", return_value="ok") as mock_hfc, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("do the kanban work") + + # The agent should have reported the task as not completed. + assert result["completed"] is False + + # Among all handle_function_call invocations, one must be + # kanban_block with the correct task_id and a reason mentioning + # iteration exhaustion. + kanban_block_calls = [ + c for c in mock_hfc.call_args_list + if c[0][0] == "kanban_block" + ] + assert len(kanban_block_calls) == 1, ( + f"Expected exactly 1 kanban_block call, got {len(kanban_block_calls)}. " + f"All calls: {mock_hfc.call_args_list}" + ) + call = kanban_block_calls[0] + assert call[0][1]["task_id"] == "t_test_task_123" + assert "Iteration budget exhausted" in call[0][1]["reason"] + + def test_no_kanban_block_when_not_in_kanban_mode(self, agent, monkeypatch): + """kanban_block must NOT be called when HERMES_KANBAN_TASK is unset.""" + self._setup_agent(agent) + agent.max_iterations = 2 + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1") + tool_resp = _mock_response( + content="", finish_reason="tool_calls", tool_calls=[tc], + ) + summary_resp = _mock_response( + content="Summary.", finish_reason="stop", + ) + agent.client.chat.completions.create.side_effect = [ + tool_resp, tool_resp, summary_resp, + ] + + with ( + patch("run_agent.handle_function_call", return_value="ok") as mock_hfc, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + agent.run_conversation("do stuff") + + kanban_block_calls = [ + c for c in mock_hfc.call_args_list + if c[0][0] == "kanban_block" + ] + assert len(kanban_block_calls) == 0, ( + "kanban_block should not be called outside kanban mode" + ) + class TestRetryExhaustion: """Regression: retry_count > max_retries was dead code (off-by-one). diff --git a/tests/run_agent/test_session_id_env.py b/tests/run_agent/test_session_id_env.py new file mode 100644 index 00000000000..73fd11890cc --- /dev/null +++ b/tests/run_agent/test_session_id_env.py @@ -0,0 +1,61 @@ +"""Test that HERMES_SESSION_ID is exposed as an env var and ContextVar.""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) + +from run_agent import AIAgent + + +@pytest.fixture(autouse=True) +def _cleanup_env(): + """Remove HERMES_SESSION_ID before/after each test.""" + os.environ.pop("HERMES_SESSION_ID", None) + yield + os.environ.pop("HERMES_SESSION_ID", None) + + +def test_session_id_env_set_on_init(): + """AIAgent.__init__ sets HERMES_SESSION_ID in the environment.""" + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + assert os.environ.get("HERMES_SESSION_ID") == agent.session_id + assert len(agent.session_id) > 0 + + +def test_session_id_env_uses_provided_id(): + """When session_id is passed explicitly, HERMES_SESSION_ID reflects it.""" + custom_id = "20260511_120000_abc12345" + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + session_id=custom_id, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + assert os.environ["HERMES_SESSION_ID"] == custom_id + assert agent.session_id == custom_id + + +def test_session_id_contextvar_set(): + """AIAgent.__init__ also sets the ContextVar for concurrency safety.""" + custom_id = "20260511_130000_def67890" + AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + session_id=custom_id, + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + from gateway.session_context import get_session_env + assert get_session_env("HERMES_SESSION_ID") == custom_id diff --git a/tests/skills/test_hyperliquid_skill.py b/tests/skills/test_hyperliquid_skill.py new file mode 100644 index 00000000000..56fe50ee4c4 --- /dev/null +++ b/tests/skills/test_hyperliquid_skill.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from unittest.mock import patch + + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[2] + / "optional-skills" + / "blockchain" + / "hyperliquid" + / "scripts" + / "hyperliquid_client.py" +) + + +def load_module(): + spec = importlib.util.spec_from_file_location("hyperliquid_skill", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_normalize_perp_markets_extracts_change_and_volume(): + mod = load_module() + + payload = [ + { + "universe": [ + {"name": "BTC", "szDecimals": 5, "maxLeverage": 50}, + {"name": "ETH", "szDecimals": 4, "maxLeverage": 25, "isDelisted": True}, + ] + }, + [ + { + "markPx": "100000", + "prevDayPx": "95000", + "funding": "0.0001", + "openInterest": "123456789", + "dayNtlVlm": "999999999", + }, + { + "markPx": "2500", + "prevDayPx": "2600", + "funding": "-0.0002", + "openInterest": "20000000", + "dayNtlVlm": "11111111", + }, + ], + ] + + rows = mod._normalize_perp_markets(payload) + + assert len(rows) == 2 + assert rows[0]["coin"] == "BTC" + assert round(rows[0]["change_pct"], 2) == 5.26 + assert rows[0]["day_ntl_vlm"] == "999999999" + assert rows[1]["is_delisted"] is True + + +def test_normalize_dexs_includes_first_perp_dex_placeholder(): + mod = load_module() + + rows = mod._normalize_dexs( + [ + None, + { + "name": "test", + "fullName": "test dex", + "deployer": "0x1234567890abcdef1234567890abcdef12345678", + "assetToStreamingOiCap": [["COIN", "100"]], + }, + ] + ) + + assert rows[0]["label"] == "first-perp-dex" + assert rows[1]["label"] == "test" + assert rows[1]["asset_caps"] == 1 + + +def test_main_markets_json_prints_normalized_payload(capsys): + mod = load_module() + + payload = [ + {"universe": [{"name": "BTC", "szDecimals": 5, "maxLeverage": 50}]}, + [{"markPx": "101000", "prevDayPx": "100000", "dayNtlVlm": "10"}], + ] + + with patch.object(mod, "_post_info", return_value=payload): + exit_code = mod.main(["markets", "--limit", "1", "--json"]) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + + assert exit_code == 0 + assert rendered["count"] == 1 + assert rendered["markets"][0]["coin"] == "BTC" + assert round(rendered["markets"][0]["change_pct"], 2) == 1.0 + + +def test_main_candles_json_limits_rows(capsys): + mod = load_module() + + payload = [ + {"t": 1000, "o": "1", "h": "2", "l": "0.5", "c": "1.5", "v": "10", "n": 3}, + {"t": 2000, "o": "1.5", "h": "2.5", "l": "1.4", "c": "2.0", "v": "20", "n": 5}, + {"t": 3000, "o": "2.0", "h": "2.2", "l": "1.8", "c": "2.1", "v": "15", "n": 4}, + ] + + with patch.object(mod, "_post_info", return_value=payload): + exit_code = mod.main(["candles", "BTC", "--limit", "2", "--json"]) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + + assert exit_code == 0 + assert rendered["count"] == 3 + assert len(rendered["candles"]) == 2 + assert rendered["summary"]["open"] == "1" + assert rendered["summary"]["close"] == "2.1" + + +def test_main_review_json_builds_market_context_and_findings(capsys): + mod = load_module() + + def fake_post_info(payload): + payload_type = payload["type"] + if payload_type == "userFillsByTime": + return [ + {"fill": {"coin": "BTC", "dir": "Close Long", "px": "110000", "sz": "0.1", "closedPnl": "120", "fee": "5", "feeToken": "USDC", "time": 4000}}, + {"fill": {"coin": "BTC", "dir": "Open Long", "px": "100000", "sz": "0.1", "closedPnl": "0", "fee": "1", "feeToken": "USDC", "time": 3000}}, + {"fill": {"coin": "ETH", "dir": "Close Short", "px": "2200", "sz": "1", "closedPnl": "-80", "fee": "4", "feeToken": "USDC", "time": 2000}}, + {"fill": {"coin": "ETH", "dir": "Open Short", "px": "2000", "sz": "1", "closedPnl": "0", "fee": "1", "feeToken": "USDC", "time": 1000}}, + ] + if payload_type == "candleSnapshot" and payload["req"]["coin"] == "BTC": + return [ + {"t": 1000, "o": "100000", "h": "111000", "l": "99000", "c": "110000", "v": "10", "n": 3}, + ] + if payload_type == "candleSnapshot" and payload["req"]["coin"] == "ETH": + return [ + {"t": 1000, "o": "2000", "h": "2210", "l": "1990", "c": "2200", "v": "50", "n": 10}, + ] + if payload_type == "fundingHistory" and payload["coin"] == "BTC": + return [{"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1000}] + if payload_type == "fundingHistory" and payload["coin"] == "ETH": + return [{"coin": "ETH", "fundingRate": "0.0002", "premium": "0.0003", "time": 1000}] + raise AssertionError(f"Unexpected payload: {payload}") + + with patch.object(mod, "_post_info", side_effect=fake_post_info): + exit_code = mod.main(["review", "0xabc", "--hours", "72", "--json"]) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + + assert exit_code == 0 + assert rendered["summary"]["fill_count"] == 4 + assert rendered["summary"]["realized_pnl"] == 40.0 + assert rendered["summary"]["total_fees"] == 11.0 + assert rendered["summary"]["net_after_fees"] == 29.0 + assert len(rendered["coin_reviews"]) == 2 + eth_review = next(item for item in rendered["coin_reviews"] if item["coin"] == "ETH") + assert round(eth_review["market_context"]["price_change_pct"], 2) == 10.0 + assert eth_review["market_context"]["average_funding_rate"] == 0.0002 + assert any("ETH" in finding and "rising market" in finding for finding in rendered["findings"]) + + +def test_main_review_json_respects_coin_filter(capsys): + mod = load_module() + + def fake_post_info(payload): + if payload["type"] == "userFillsByTime": + return [ + {"fill": {"coin": "BTC", "dir": "Close Long", "px": "110000", "sz": "0.1", "closedPnl": "120", "fee": "5", "feeToken": "USDC", "time": 4000}}, + {"fill": {"coin": "ETH", "dir": "Close Short", "px": "2200", "sz": "1", "closedPnl": "-80", "fee": "4", "feeToken": "USDC", "time": 2000}}, + ] + if payload["type"] == "candleSnapshot": + return [{"t": 1000, "o": "100000", "h": "111000", "l": "99000", "c": "110000", "v": "10", "n": 3}] + if payload["type"] == "fundingHistory": + return [{"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1000}] + raise AssertionError(f"Unexpected payload: {payload}") + + with patch.object(mod, "_post_info", side_effect=fake_post_info): + exit_code = mod.main(["review", "0xabc", "--coin", "BTC", "--json"]) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + + assert exit_code == 0 + assert rendered["summary"]["fill_count"] == 1 + assert rendered["summary"]["unique_coins"] == 1 + assert rendered["coin_reviews"][0]["coin"] == "BTC" + + +def test_resolve_user_uses_env_fallback(monkeypatch): + mod = load_module() + monkeypatch.setenv("HYPERLIQUID_USER_ADDRESS", "0xenv123") + + assert mod._resolve_user("") == "0xenv123" + assert mod._resolve_user(None) == "0xenv123" + assert mod._resolve_user("0xcli456") == "0xcli456" + + +def test_resolve_user_errors_when_missing(monkeypatch, tmp_path): + mod = load_module() + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False) + + try: + mod._resolve_user("") + except SystemExit as exc: + message = str(exc) + else: + raise AssertionError("Expected SystemExit when no user is provided") + + assert "HYPERLIQUID_USER_ADDRESS" in message + + +def test_main_state_json_uses_env_fallback(monkeypatch, capsys): + mod = load_module() + monkeypatch.setenv("HYPERLIQUID_USER_ADDRESS", "0xenv999") + + with patch.object( + mod, + "_post_info", + return_value={"marginSummary": {"accountValue": "123"}, "assetPositions": [], "withdrawable": "50"}, + ) as mock_post: + exit_code = mod.main(["state", "--json"]) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + + assert exit_code == 0 + assert rendered["user"] == "0xenv999" + assert mock_post.call_args[0][0]["user"] == "0xenv999" + + +def test_env_lookup_reads_hermes_dotenv(tmp_path, monkeypatch): + mod = load_module() + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir(parents=True) + (hermes_home / ".env").write_text( + "HYPERLIQUID_USER_ADDRESS=0xdotenv123\nHYPERLIQUID_API_URL=https://api.hyperliquid-testnet.xyz\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False) + monkeypatch.delenv("HYPERLIQUID_API_URL", raising=False) + + assert mod._env_lookup("HYPERLIQUID_USER_ADDRESS") == "0xdotenv123" + assert mod._resolve_user("") == "0xdotenv123" + assert mod._info_url() == "https://api.hyperliquid-testnet.xyz/info" + + +def test_user_dotenv_overrides_project_dotenv(tmp_path, monkeypatch): + mod = load_module() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / ".env").write_text("HYPERLIQUID_USER_ADDRESS=0xproject\n", encoding="utf-8") + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / ".env").write_text("HYPERLIQUID_USER_ADDRESS=0xuserhome\n", encoding="utf-8") + + monkeypatch.chdir(project_dir) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HYPERLIQUID_USER_ADDRESS", raising=False) + + assert mod._env_lookup("HYPERLIQUID_USER_ADDRESS") == "0xuserhome" + + +def test_main_export_json_writes_expected_contract(tmp_path, capsys): + mod = load_module() + output_path = tmp_path / "exports" / "btc-1h.json" + + def fake_post_info(payload): + if payload["type"] == "candleSnapshot": + return [ + {"t": 1000, "o": "100", "h": "110", "l": "95", "c": "108", "v": "50", "n": 4}, + {"t": 2000, "o": "108", "h": "115", "l": "107", "c": "112", "v": "60", "n": 5}, + ] + if payload["type"] == "fundingHistory": + return [ + {"coin": "BTC", "fundingRate": "0.0001", "premium": "0.0002", "time": 1500}, + {"coin": "BTC", "fundingRate": "0.0003", "premium": "0.0004", "time": 2000}, + ] + raise AssertionError(f"Unexpected payload: {payload}") + + with patch.object(mod, "_post_info", side_effect=fake_post_info): + exit_code = mod.main( + [ + "export", + "BTC", + "--interval", + "1h", + "--hours", + "24", + "--end-time-ms", + "5000", + "--output", + str(output_path), + "--json", + ] + ) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + saved = json.loads(output_path.read_text(encoding="utf-8")) + + assert exit_code == 0 + assert rendered["output_path"] == str(output_path) + assert saved["schema_version"] == "hyperliquid-market-export-v1" + assert saved["source"]["coin"] == "BTC" + assert saved["window"]["start_time_ms"] == 5000 - 24 * 60 * 60 * 1000 + assert saved["window"]["end_time_ms"] == 5000 + assert saved["summary"]["candle_count"] == 2 + assert saved["summary"]["funding_count"] == 2 + assert round(saved["summary"]["price_change_pct"], 2) == 12.0 + assert saved["summary"]["average_funding_rate"] == 0.0002 + assert len(saved["candles"]) == 2 + assert len(saved["funding_history"]) == 2 + + +def test_main_export_json_skips_funding_for_spot(tmp_path, capsys): + mod = load_module() + output_path = tmp_path / "purr-usdc.json" + + def fake_post_info(payload): + if payload["type"] == "candleSnapshot": + return [{"t": 1000, "o": "1", "h": "1.2", "l": "0.9", "c": "1.1", "v": "100", "n": 10}] + raise AssertionError(f"Unexpected payload: {payload}") + + with patch.object(mod, "_post_info", side_effect=fake_post_info): + exit_code = mod.main( + [ + "export", + "PURR/USDC", + "--end-time-ms", + "5000", + "--output", + str(output_path), + "--json", + ] + ) + + stdout = capsys.readouterr().out + rendered = json.loads(stdout) + saved = json.loads(output_path.read_text(encoding="utf-8")) + + assert exit_code == 0 + assert rendered["summary"]["funding_count"] == 0 + assert saved["source"]["market_type"] == "spot" + assert saved["funding_history"] == [] diff --git a/tests/test_live_system_guard_self_test.py b/tests/test_live_system_guard_self_test.py new file mode 100644 index 00000000000..1856935b240 --- /dev/null +++ b/tests/test_live_system_guard_self_test.py @@ -0,0 +1,295 @@ +"""Self-test for the live-system guard fixture in tests/conftest.py. + +This file is the canary. If anyone removes a guard or weakens it, these +tests fail. If anyone adds a NEW kill primitive to the codebase without +adding it to the guard, the corresponding test added here will fail too. + +The guard exists to protect the developer's live ``hermes-gateway`` process +from being SIGTERMed by tests. See PR #23397 for the original incident +(5+ live gateway kills in 3 days). Per Teknium 2026-05-10: + + > "You better do such a deep scan and scrub of the tests that this + > never is possible ever again for all eternity." + +Every primitive that can deliver a signal to a foreign process or mutate +the live systemd unit MUST be exercised below. Adding a new primitive to +the guard? Add a test here too. +""" +from __future__ import annotations + +import os +import signal +import subprocess + +import pytest + +# A guaranteed-foreign PID: PID 1 (init). Owned by root, not us, and +# always exists. A sane guard refuses to signal it. +FOREIGN_PID = 1 + + +# ──────────────────── kill primitives ───────────────────────── + + +def test_os_kill_blocks_foreign_pid(): + with pytest.raises(RuntimeError, match="live-system guard"): + os.kill(FOREIGN_PID, signal.SIGTERM) + + +def test_os_kill_blocks_negative_one(): + """``os.kill(-1, sig)`` signals every process we can reach. Must be blocked.""" + with pytest.raises(RuntimeError, match="live-system guard"): + os.kill(-1, signal.SIGTERM) + + +@pytest.mark.skipif(not hasattr(os, "killpg"), reason="killpg POSIX-only") +def test_os_killpg_blocks_foreign_pgid(): + with pytest.raises(RuntimeError, match="live-system guard"): + os.killpg(FOREIGN_PID, signal.SIGTERM) + + +# ──────────────────── subprocess regex bypasses ──────────────── + + +def test_subprocess_run_systemctl_restart_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run(["systemctl", "--user", "restart", "hermes-gateway"]) + + +def test_subprocess_run_full_path_systemctl_blocked(): + """``/usr/bin/systemctl`` (full path) must be blocked too.""" + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run(["/usr/bin/systemctl", "--user", "stop", "hermes-gateway"]) + + +def test_subprocess_run_sudo_systemctl_blocked(): + """``sudo systemctl ...`` defeated the old head==systemctl check.""" + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run(["sudo", "systemctl", "restart", "hermes-gateway"]) + + +def test_subprocess_run_env_systemctl_blocked(): + """``env systemctl ...`` similarly defeated the old head check.""" + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run(["env", "systemctl", "--user", "restart", "hermes-gateway"]) + + +def test_subprocess_run_bash_c_systemctl_blocked(): + """``bash -c "systemctl ..."`` must also be caught.""" + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run(["bash", "-c", "systemctl --user restart hermes-gateway"]) + + +def test_subprocess_run_sh_c_systemctl_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run(["sh", "-c", "systemctl --user stop hermes-gateway"]) + + +def test_subprocess_run_setsid_systemctl_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run(["setsid", "systemctl", "kill", "hermes-gateway"]) + + +def test_subprocess_run_string_shell_true_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run( + "systemctl --user restart hermes-gateway", + shell=True, + ) + + +def test_subprocess_popen_systemctl_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.Popen(["systemctl", "--user", "stop", "hermes-gateway"]) + + +def test_subprocess_call_systemctl_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.call(["systemctl", "--user", "restart", "hermes-gateway"]) + + +def test_subprocess_check_call_systemctl_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.check_call(["systemctl", "--user", "restart", "hermes-gateway"]) + + +def test_subprocess_check_output_systemctl_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.check_output(["systemctl", "--user", "restart", "hermes-gateway"]) + + +def test_subprocess_getoutput_systemctl_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.getoutput("systemctl --user restart hermes-gateway") + + +def test_subprocess_getstatusoutput_systemctl_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.getstatusoutput("systemctl --user restart hermes-gateway") + + +# ──────────────────── os.system / os.popen ──────────────────── + + +def test_os_system_systemctl_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + os.system("systemctl --user restart hermes-gateway") + + +def test_os_popen_systemctl_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + os.popen("systemctl --user restart hermes-gateway") + + +# ──────────────────── pty.spawn ──────────────────────────────── + + +def test_pty_spawn_systemctl_blocked(): + import pty + with pytest.raises(RuntimeError, match="live-system guard"): + pty.spawn(["systemctl", "--user", "restart", "hermes-gateway"]) + + +# ──────────────────── asyncio.create_subprocess_* ────────────── + + +def test_asyncio_create_subprocess_exec_systemctl_blocked(): + import asyncio + + async def _attempt(): + await asyncio.create_subprocess_exec( + "systemctl", "--user", "restart", "hermes-gateway" + ) + + with pytest.raises(RuntimeError, match="live-system guard"): + asyncio.run(_attempt()) + + +def test_asyncio_create_subprocess_shell_systemctl_blocked(): + import asyncio + + async def _attempt(): + await asyncio.create_subprocess_shell( + "systemctl --user restart hermes-gateway" + ) + + with pytest.raises(RuntimeError, match="live-system guard"): + asyncio.run(_attempt()) + + +# ──────────────────── pkill / killall / taskkill ─────────────── + + +def test_subprocess_pkill_hermes_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run(["pkill", "-f", "hermes"]) + + +def test_subprocess_pkill_hermes_gateway_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run(["pkill", "-f", "hermes-gateway"]) + + +def test_subprocess_pkill_python_dash_f_blocked(): + """``pkill -f python`` matches the gateway's "python -m hermes_cli.main".""" + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run(["pkill", "-f", "python"]) + + +def test_subprocess_killall_hermes_blocked(): + with pytest.raises(RuntimeError, match="live-system guard"): + subprocess.run(["killall", "hermes"]) + + +# ──────────────────── pass-through cases (must NOT raise) ────── + + +def test_systemctl_status_passes_through(): + """Read-only systemctl probes (status/show/list-units) are fine.""" + # Run with check=False so we don't fail on the gateway's exit code. + r = subprocess.run( + ["systemctl", "--user", "status", "hermes-gateway", "--no-pager"], + capture_output=True, + text=True, + check=False, + ) + assert r is not None # Did not raise — the guard let it through. + + +def test_systemctl_show_passes_through(): + r = subprocess.run( + ["systemctl", "--user", "show", "hermes-gateway", "--no-pager"], + capture_output=True, + text=True, + check=False, + ) + assert r is not None + + +def test_systemctl_list_units_passes_through(): + r = subprocess.run( + ["systemctl", "--user", "list-units", "fake-not-real-unit*", "--no-pager"], + capture_output=True, + text=True, + check=False, + ) + assert r is not None + + +def test_systemctl_unrelated_unit_passes_through(): + """systemctl restart of a non-hermes unit is allowed (we only protect hermes).""" + # Use --dry-run so we don't actually try to restart anything; just + # verify the guard doesn't block the call. systemctl supports + # --dry-run via the privileged API; on user scope it usually fails + # quickly without side effects. + r = subprocess.run( + ["systemctl", "--user", "show", "fake-not-real-unit"], + capture_output=True, + text=True, + check=False, + ) + assert r is not None + + +def test_kill_own_subtree_passes_through(): + """We CAN kill our own children — guard recognizes them via psutil.""" + p = subprocess.Popen(["sleep", "30"]) + try: + os.kill(p.pid, signal.SIGTERM) + finally: + p.wait(timeout=2) + # SIGTERM = 15; subprocess returncode is -15 on POSIX. + assert p.returncode in (-signal.SIGTERM, 128 + int(signal.SIGTERM)) + + +def test_subprocess_pkill_with_unrelated_pattern_passes_through(): + """``pkill -f some-unrelated-pattern`` (no hermes/python) is fine.""" + # We don't actually run pkill — just verify the guard would let it + # through by inspecting the matcher. Re-implementing the check here + # would duplicate the guard; instead spawn a noop to confirm no raise. + # Use 'true' so it succeeds quickly. + r = subprocess.run(["true"], capture_output=True) + assert r.returncode == 0 + + +def test_normal_subprocess_run_passes_through(): + """Plain non-systemctl subprocess.run should work normally.""" + r = subprocess.run(["echo", "hello"], capture_output=True, text=True) + assert r.stdout.strip() == "hello" + + +# ──────────────────── bypass marker ───────────────────────────── + + +@pytest.mark.live_system_guard_bypass +def test_bypass_marker_disables_guard(): + """The bypass marker exists for tests that genuinely need real signal delivery + (e.g. PTY tests SIGINTing their own child). Verify it works. + + We use it harmlessly here by signaling our own PID 0 (own group) so we + don't actually kill anything — but the call goes through real os.kill. + """ + # With bypass, the guard yields without installing the monkeypatch, + # so we get the real os.kill. Calling os.kill(os.getpid(), 0) just + # checks that the PID exists — harmless. + os.kill(os.getpid(), 0) # No exception — guard is OFF. diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index 77ca3550d3a..7ec2d5868f1 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -965,3 +965,140 @@ class TestFailClosedUnderPromptToolkit: assert result == "once" finally: ptc.get_app_or_none = orig + + +class TestDetectSudoStdin: + """Sudo with stdin / askpass / shell / list-privileges flags (#17873 cat 4). + + An LLM-driven agent has no TTY, so the sudo invocations that succeed + without human interaction are those reading the password from stdin + (-S / --stdin) or via an askpass helper (-A / --askpass). The + shell-launch (-s) and list-privileges (-a) flags are also gated since + they are privilege-relevant invocations the agent can chain after + acquiring the password. + + `_normalize_command_for_detection` lowercases input before pattern + matching, so -S/-s and -A/-a are indistinguishable at the regex + layer; both letter-pairs are gated. + """ + + # Positive cases (must match) + + def test_canonical_pipe_to_sudo_S_detected(self): + is_dangerous, _, desc = detect_dangerous_command( + "echo pwd | sudo -S whoami" + ) + assert is_dangerous is True + assert "sudo" in desc.lower() + + def test_long_flag_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo --stdin id") + assert is_dangerous is True + + def test_non_interactive_plus_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -n -S id") + assert is_dangerous is True + + def test_user_then_stdin_detected(self): + # Codex audit caught that the original "leading flags only" regex + # missed this form because `-u root` has a flag-argument (`root`) + # that broke the (?:\s+-[^\s]+)* loop. The lazy [^;|&\n]*? class + # consumes flag-args without spanning command separators. + is_dangerous, _, _ = detect_dangerous_command( + "sudo -u root -S whoami" + ) + assert is_dangerous is True + + def test_long_non_interactive_plus_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + "sudo --non-interactive -S whoami" + ) + assert is_dangerous is True + + def test_long_user_equals_stdin_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + "sudo --user=root -S id" + ) + assert is_dangerous is True + + def test_herestring_input_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + "sudo -S id <<< 'mypwd'" + ) + assert is_dangerous is True + + def test_combined_short_flags_nS_detected(self): + # `-nS` packs `-n` and `-S` into one arg; second pattern catches. + is_dangerous, _, _ = detect_dangerous_command("sudo -nS id") + assert is_dangerous is True + + def test_printf_form_detected(self): + is_dangerous, _, _ = detect_dangerous_command( + 'printf "%s\\n" "$PW" | sudo -S id' + ) + assert is_dangerous is True + + def test_askpass_short_flag_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -A id") + assert is_dangerous is True + + def test_askpass_long_flag_detected(self): + is_dangerous, _, _ = detect_dangerous_command("sudo --askpass id") + assert is_dangerous is True + + def test_two_sudo_invocations_second_caught(self): + # The first sudo here is benign (no -S); the second has -S. + # Lazy [^;|&\n]*? does NOT span past `;`, so re.search anchors + # on the second sudo invocation independently. + is_dangerous, _, _ = detect_dangerous_command( + "sudo whoami; sudo -S id" + ) + assert is_dangerous is True + + # Negative cases (must NOT match) + + def test_plain_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("sudo whoami") + assert is_dangerous is False + + def test_sudo_interactive_shell_safe(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -i") + assert is_dangerous is False + + def test_sudo_with_user_no_stdin_flag_safe(self): + is_dangerous, _, _ = detect_dangerous_command("sudo -u root -i") + assert is_dangerous is False + + def test_man_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("man sudo") + assert is_dangerous is False + + def test_which_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("which sudo") + assert is_dangerous is False + + def test_sudo_user_env_reference_safe(self): + is_dangerous, _, _ = detect_dangerous_command( + "echo SUDO_USER=$SUDO_USER" + ) + assert is_dangerous is False + + def test_apt_install_sudo_safe(self): + is_dangerous, _, _ = detect_dangerous_command("apt install sudo") + assert is_dangerous is False + + def test_ls_etc_sudoers_safe(self): + is_dangerous, _, _ = detect_dangerous_command("ls /etc/sudoers") + assert is_dangerous is False + + def test_pseudosudo_safe_word_boundary(self): + # `\bsudo\b` requires a word boundary; `pseudosudo` has none + # before `sudo`, so should not trigger. + is_dangerous, _, _ = detect_dangerous_command("pseudosudo -S id") + assert is_dangerous is False + + def test_unrelated_redirection_safe(self): + is_dangerous, _, _ = detect_dangerous_command( + "make 2>&1 | tee build.log" + ) + assert is_dangerous is False diff --git a/tests/tools/test_browser_eval_supervisor_path.py b/tests/tools/test_browser_eval_supervisor_path.py new file mode 100644 index 00000000000..8528b099489 --- /dev/null +++ b/tests/tools/test_browser_eval_supervisor_path.py @@ -0,0 +1,363 @@ +"""Unit tests for the supervisor-WS fast path in browser_console / _browser_eval. + +These exercise the dispatch logic in ``tools.browser_tool._browser_eval`` and +the response shaping in ``CDPSupervisor.evaluate_runtime`` using mocks — no +real browser, no real WebSocket. Real-CDP coverage lives in +``tests/tools/test_browser_supervisor.py`` (gated on Chrome being installed). +""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Fast-path dispatch: tools.browser_tool._browser_eval +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _disable_camofox(monkeypatch): + """Force the non-camofox path so our supervisor branch is reached.""" + import tools.browser_tool as bt + + monkeypatch.setattr(bt, "_is_camofox_mode", lambda: False) + monkeypatch.setattr(bt, "_last_session_key", lambda task_id: "test-task") + + +def _patch_supervisor(monkeypatch, supervisor): + """Wire SUPERVISOR_REGISTRY.get to return ``supervisor`` for any task_id.""" + import tools.browser_supervisor as bs + + registry = MagicMock() + registry.get.return_value = supervisor + monkeypatch.setattr(bs, "SUPERVISOR_REGISTRY", registry) + return registry + + +class TestBrowserEvalSupervisorPath: + """The supervisor fast path replaces the agent-browser subprocess hop.""" + + def test_primitive_result_routes_through_supervisor(self, monkeypatch): + import tools.browser_tool as bt + + sup = MagicMock() + sup.evaluate_runtime.return_value = { + "ok": True, + "result": 42, + "result_type": "number", + } + _patch_supervisor(monkeypatch, sup) + # If the subprocess path is hit we want a loud failure. + monkeypatch.setattr( + bt, "_run_browser_command", + lambda *a, **kw: pytest.fail("subprocess path must not run when supervisor is healthy"), + ) + + out = json.loads(bt._browser_eval("1 + 41")) + assert out["success"] is True + assert out["result"] == 42 + assert out["method"] == "cdp_supervisor" + sup.evaluate_runtime.assert_called_once_with("1 + 41") + + def test_json_string_result_is_parsed(self, monkeypatch): + """Match agent-browser semantics: JSON-string results get parsed.""" + import tools.browser_tool as bt + + sup = MagicMock() + sup.evaluate_runtime.return_value = { + "ok": True, + "result": '{"a": 1, "b": [2, 3]}', + "result_type": "string", + } + _patch_supervisor(monkeypatch, sup) + monkeypatch.setattr( + bt, "_run_browser_command", + lambda *a, **kw: pytest.fail("subprocess path must not run"), + ) + + out = json.loads(bt._browser_eval('JSON.stringify({a:1,b:[2,3]})')) + assert out["success"] is True + assert out["result"] == {"a": 1, "b": [2, 3]} + # result_type reflects the parsed Python type, not the raw JS type. + assert out["result_type"] == "dict" + + def test_non_json_string_result_kept_as_string(self, monkeypatch): + import tools.browser_tool as bt + + sup = MagicMock() + sup.evaluate_runtime.return_value = { + "ok": True, + "result": "hello world", + "result_type": "string", + } + _patch_supervisor(monkeypatch, sup) + monkeypatch.setattr(bt, "_run_browser_command", lambda *a, **kw: pytest.fail("nope")) + + out = json.loads(bt._browser_eval('"hello world"')) + assert out["result"] == "hello world" + assert out["result_type"] == "str" + + def test_js_exception_surfaces_without_subprocess_fallthrough(self, monkeypatch): + """A JS-side error must NOT trigger a (slow + redundant) subprocess retry.""" + import tools.browser_tool as bt + + sup = MagicMock() + sup.evaluate_runtime.return_value = { + "ok": False, + "error": "Uncaught ReferenceError: foo is not defined", + } + _patch_supervisor(monkeypatch, sup) + called = {"subprocess": False} + + def _fake_subprocess(*a, **kw): + called["subprocess"] = True + return {"success": True, "data": {"result": "should-not-be-used"}} + + monkeypatch.setattr(bt, "_run_browser_command", _fake_subprocess) + + out = json.loads(bt._browser_eval("foo.bar")) + assert out["success"] is False + assert "ReferenceError" in out["error"] + assert called["subprocess"] is False, \ + "JS exception should be surfaced, not retried via subprocess" + + def test_supervisor_loop_down_falls_through_to_subprocess(self, monkeypatch): + """When the supervisor itself is unavailable, fall back to the subprocess.""" + import tools.browser_tool as bt + + sup = MagicMock() + sup.evaluate_runtime.return_value = { + "ok": False, + "error": "supervisor loop is not running", + } + _patch_supervisor(monkeypatch, sup) + + called = {"subprocess": False} + + def _fake_subprocess(task_id, cmd, args): + called["subprocess"] = True + assert cmd == "eval" + return {"success": True, "data": {"result": "fallback-result"}} + + monkeypatch.setattr(bt, "_run_browser_command", _fake_subprocess) + + out = json.loads(bt._browser_eval("anything")) + assert called["subprocess"] is True + assert out["success"] is True + assert out["result"] == "fallback-result" + # Subprocess path doesn't tag the response with method=cdp_supervisor. + assert out.get("method") != "cdp_supervisor" + + def test_no_active_supervisor_falls_through_to_subprocess(self, monkeypatch): + """When SUPERVISOR_REGISTRY.get returns None, subprocess path runs.""" + import tools.browser_tool as bt + + _patch_supervisor(monkeypatch, None) + called = {"subprocess": False} + + def _fake_subprocess(task_id, cmd, args): + called["subprocess"] = True + return {"success": True, "data": {"result": "agent-browser-result"}} + + monkeypatch.setattr(bt, "_run_browser_command", _fake_subprocess) + + out = json.loads(bt._browser_eval("1+1")) + assert called["subprocess"] is True + assert out["success"] is True + assert out.get("method") != "cdp_supervisor" + + def test_supervisor_no_session_falls_through(self, monkeypatch): + """A supervisor without an attached page session must fall through cleanly.""" + import tools.browser_tool as bt + + sup = MagicMock() + sup.evaluate_runtime.return_value = { + "ok": False, + "error": "supervisor has no attached page session", + } + _patch_supervisor(monkeypatch, sup) + called = {"subprocess": False} + + def _fake_subprocess(*a, **kw): + called["subprocess"] = True + return {"success": True, "data": {"result": "fallback"}} + + monkeypatch.setattr(bt, "_run_browser_command", _fake_subprocess) + json.loads(bt._browser_eval("1+1")) + assert called["subprocess"] is True + + +# --------------------------------------------------------------------------- +# Response shaping: CDPSupervisor.evaluate_runtime +# --------------------------------------------------------------------------- + + +def _make_supervisor_with_cdp(cdp_response): + """Build a CDPSupervisor instance that mocks ``_cdp`` to return ``cdp_response``. + + Bypasses ``__init__`` entirely so we don't need a real WS connection. We + set just the state ``evaluate_runtime`` reads. + """ + import asyncio + import threading + + from tools.browser_supervisor import CDPSupervisor + + sup = object.__new__(CDPSupervisor) + sup._state_lock = threading.Lock() + sup._active = True + sup._page_session_id = "test-session-id" + + # Build a real running event loop on a background thread so + # asyncio.run_coroutine_threadsafe has somewhere to dispatch. + loop = asyncio.new_event_loop() + + def _runner(): + asyncio.set_event_loop(loop) + loop.run_forever() + + thread = threading.Thread(target=_runner, daemon=True) + thread.start() + + async def _fake_cdp(method, params=None, *, session_id=None, timeout=10.0): + return cdp_response + + sup._cdp = _fake_cdp # type: ignore[method-assign] + sup._loop = loop + sup._thread = thread + return sup + + +def _stop_supervisor(sup): + sup._loop.call_soon_threadsafe(sup._loop.stop) + sup._thread.join(timeout=2) + + +class TestEvaluateRuntimeResponseShaping: + """CDPSupervisor.evaluate_runtime decodes the Runtime.evaluate response correctly.""" + + def test_primitive_value(self): + sup = _make_supervisor_with_cdp({ + "id": 1, + "result": {"result": {"type": "number", "value": 42}}, + }) + try: + out = sup.evaluate_runtime("1 + 41") + assert out == {"ok": True, "result": 42, "result_type": "number"} + finally: + _stop_supervisor(sup) + + def test_object_value_returned_by_value(self): + sup = _make_supervisor_with_cdp({ + "id": 1, + "result": { + "result": { + "type": "object", + "value": {"foo": "bar", "n": 7}, + } + }, + }) + try: + out = sup.evaluate_runtime('({foo:"bar", n:7})') + assert out["ok"] is True + assert out["result"] == {"foo": "bar", "n": 7} + assert out["result_type"] == "object" + finally: + _stop_supervisor(sup) + + def test_undefined_value(self): + sup = _make_supervisor_with_cdp({ + "id": 1, + "result": {"result": {"type": "undefined"}}, + }) + try: + out = sup.evaluate_runtime("undefined") + assert out == {"ok": True, "result": None, "result_type": "undefined"} + finally: + _stop_supervisor(sup) + + def test_dom_node_returns_description(self): + """Non-serializable values (DOM nodes, functions) come back as description strings.""" + sup = _make_supervisor_with_cdp({ + "id": 1, + "result": { + "result": { + "type": "object", + "subtype": "node", + "description": "div#main.app", + # No 'value' key — returnByValue couldn't serialize it. + } + }, + }) + try: + out = sup.evaluate_runtime("document.querySelector('#main')") + assert out["ok"] is True + assert out["result"] == "div#main.app" + assert out["result_type"] == "object" + finally: + _stop_supervisor(sup) + + def test_js_exception_returns_error(self): + sup = _make_supervisor_with_cdp({ + "id": 1, + "result": { + "result": {"type": "undefined"}, + "exceptionDetails": { + "text": "Uncaught", + "exception": { + "description": "ReferenceError: foo is not defined", + }, + }, + }, + }) + try: + out = sup.evaluate_runtime("foo.bar") + assert out["ok"] is False + assert "ReferenceError" in out["error"] + finally: + _stop_supervisor(sup) + + def test_inactive_supervisor_returns_error_without_dispatch(self): + """Inactive supervisor short-circuits before even touching the loop.""" + import threading + from tools.browser_supervisor import CDPSupervisor + + sup = object.__new__(CDPSupervisor) + sup._state_lock = threading.Lock() + sup._active = False # ← key + sup._page_session_id = None + sup._loop = None + + out = sup.evaluate_runtime("1+1") + assert out["ok"] is False + # Either "loop is not running" or "is not active" is acceptable — + # both are caught by the supervisor-side error branch in _browser_eval. + assert "supervisor" in out["error"].lower() + + def test_no_session_attached_returns_error(self): + import asyncio + import threading + from tools.browser_supervisor import CDPSupervisor + + sup = object.__new__(CDPSupervisor) + sup._state_lock = threading.Lock() + sup._active = True + sup._page_session_id = None # ← attach hasn't happened yet + + loop = asyncio.new_event_loop() + thread = threading.Thread( + target=lambda: (asyncio.set_event_loop(loop), loop.run_forever()), + daemon=True, + ) + thread.start() + sup._loop = loop + try: + out = sup.evaluate_runtime("1+1") + assert out["ok"] is False + assert "session" in out["error"].lower() + finally: + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=2) diff --git a/tests/tools/test_browser_supervisor.py b/tests/tools/test_browser_supervisor.py index e332aec43f9..360fec53a04 100644 --- a/tests/tools/test_browser_supervisor.py +++ b/tests/tools/test_browser_supervisor.py @@ -561,3 +561,80 @@ def test_bridge_captures_prompt_and_returns_reply_text(chrome_cdp, supervisor_re value = asyncio.run(nav_and_read()) assert value == "AGENT-SUPPLIED-REPLY", f"expected AGENT-SUPPLIED-REPLY, got {value!r}" + + +def test_evaluate_runtime_primitive(chrome_cdp, supervisor_registry): + """evaluate_runtime returns primitive values via the supervisor's live WS.""" + cdp_url, _port = chrome_cdp + supervisor = supervisor_registry.get_or_start(task_id="pytest-eval-1", cdp_url=cdp_url) + + # Need a page to evaluate against. + _fire_on_page(cdp_url, "void 0") + time.sleep(0.5) + + out = supervisor.evaluate_runtime("1 + 41") + assert out["ok"] is True + assert out["result"] == 42 + assert out["result_type"] == "number" + + +def test_evaluate_runtime_object(chrome_cdp, supervisor_registry): + """Plain objects come back JSON-serialized via returnByValue=True.""" + cdp_url, _port = chrome_cdp + supervisor = supervisor_registry.get_or_start(task_id="pytest-eval-2", cdp_url=cdp_url) + + _fire_on_page(cdp_url, "void 0") + time.sleep(0.5) + + out = supervisor.evaluate_runtime('({foo: "bar", n: 7})') + assert out["ok"] is True + assert out["result"] == {"foo": "bar", "n": 7} + assert out["result_type"] == "object" + + +def test_evaluate_runtime_js_exception(chrome_cdp, supervisor_registry): + """JS exceptions surface as ok=False with the exception message.""" + cdp_url, _port = chrome_cdp + supervisor = supervisor_registry.get_or_start(task_id="pytest-eval-3", cdp_url=cdp_url) + + _fire_on_page(cdp_url, "void 0") + time.sleep(0.5) + + out = supervisor.evaluate_runtime("nonExistentVar.nope") + assert out["ok"] is False + assert "ReferenceError" in out["error"] or "not defined" in out["error"] + + +def test_evaluate_runtime_dom_node_returns_empty_object(chrome_cdp, supervisor_registry): + """DOM nodes with returnByValue=true serialize to ``{}`` (Chrome quirk). + + This is honest — DOM nodes can't be deeply JSON-serialized — and matches + DevTools console behaviour for the same expression. Documenting the + contract here so a future change that "fixes" it (e.g. switching to + returnByValue=false + DOM.describeNode) doesn't break callers expecting + the current shape. + """ + cdp_url, _port = chrome_cdp + supervisor = supervisor_registry.get_or_start(task_id="pytest-eval-4", cdp_url=cdp_url) + + _fire_on_page(cdp_url, "void 0") + time.sleep(0.5) + + out = supervisor.evaluate_runtime("document.querySelector('h1')") + assert out["ok"] is True + assert out["result_type"] == "object" + # Empty dict — Chrome can't deeply-serialize a DOM node through returnByValue. + assert out["result"] == {} + + +def test_evaluate_runtime_unserializable_value(chrome_cdp, supervisor_registry): + """``Infinity``/``NaN``/``BigInt`` come back via ``unserializableValue``.""" + cdp_url, _port = chrome_cdp + supervisor = supervisor_registry.get_or_start(task_id="pytest-eval-5", cdp_url=cdp_url) + + _fire_on_page(cdp_url, "void 0") + time.sleep(0.5) + + out = supervisor.evaluate_runtime("Infinity") + assert out["ok"] is True + assert out["result"] == "Infinity" diff --git a/tests/tools/test_hardline_blocklist.py b/tests/tools/test_hardline_blocklist.py index a3a08cd464a..16b88ac1801 100644 --- a/tests/tools/test_hardline_blocklist.py +++ b/tests/tools/test_hardline_blocklist.py @@ -288,3 +288,91 @@ def test_hardline_list_is_small(): f"HARDLINE_PATTERNS has grown to {len(HARDLINE_PATTERNS)} entries; " "only truly unrecoverable commands belong here." ) + + +# ========================================================================= +# Sudo stdin guard — blocks "sudo -S" without SUDO_PASSWORD +# ========================================================================= + +_SUDO_STDIN_BLOCK = [ + "sudo -S whoami", + "echo hunter2 | sudo -S whoami", + "sudo -S -u root whoami", + "sudo -S apt-get install foo", + "echo password | sudo -S systemctl restart nginx", + "sudo -k && sudo -S whoami", +] + +_SUDO_STDIN_ALLOW = [ + # Plain sudo without -S — goes through normal approval + "sudo whoami", + "sudo apt-get update", + "sudo -u root whoami", + # -S flag not attached to sudo + "echo -S hello", + "some_tool -S thing", + # Literal text mention of sudo + "echo 'use sudo -S to pipe passwords'", +] + +_SUDO_STDIN_BLOCK_YOLO = [ + "sudo -S whoami", + "echo hunter2 | sudo -S apt-get install", +] + + +def test_sudo_stdin_guard_detects_without_password(): + """sudo -S is dangerous when SUDO_PASSWORD is not configured.""" + import tools.approval as approval_mod + + for cmd in _SUDO_STDIN_BLOCK: + is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd) + assert is_blocked, f"expected sudo stdin guard to block {cmd!r}" + assert "sudo" in desc.lower() + + +def test_sudo_stdin_guard_allows_benign_commands(): + """Commands without explicit sudo -S are not blocked.""" + import tools.approval as approval_mod + + for cmd in _SUDO_STDIN_ALLOW: + is_blocked, desc = approval_mod._check_sudo_stdin_guard(cmd) + assert not is_blocked, f"expected sudo stdin guard NOT to block {cmd!r}" + + +def test_sudo_stdin_guard_bypassed_when_password_configured(monkeypatch): + """When SUDO_PASSWORD is set, sudo -S is legitimate (injected by transform).""" + import tools.approval as approval_mod + + monkeypatch.setenv("SUDO_PASSWORD", "testpass") + for cmd in _SUDO_STDIN_BLOCK: + is_blocked, _ = approval_mod._check_sudo_stdin_guard(cmd) + assert not is_blocked, f"with SUDO_PASSWORD set, {cmd!r} should NOT be blocked" + + +def test_sudo_stdin_guard_blocks_via_check_all_command_guards(clean_session): + """Integration: check_all_command_guards returns block for sudo -S.""" + for cmd in _SUDO_STDIN_BLOCK: + result = check_all_command_guards(cmd, "local") + assert result["approved"] is False, f"expected block on {cmd!r}" + # Should NOT be marked as hardline (it's sudo-specific) + assert result.get("hardline") is not True + assert "BLOCKED" in result["message"] + assert "sudo -S" in result["message"].lower() or "sudo password" in result["message"].lower() + + +def test_sudo_stdin_guard_not_blocked_by_yolo(clean_session, monkeypatch): + """yolo/approvals.mode=off must NOT bypass sudo stdin guard.""" + monkeypatch.setenv("HERMES_YOLO_MODE", "1") + + for cmd in _SUDO_STDIN_BLOCK_YOLO: + result = check_all_command_guards(cmd, "local") + assert result["approved"] is False, f"yolo leaked sudo guard on {cmd!r}" + + +def test_sudo_stdin_guard_container_bypass(clean_session): + """Containerized backends still bypass — they can't touch the host.""" + for env in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + for cmd in _SUDO_STDIN_BLOCK: + result = check_all_command_guards(cmd, env) + assert result["approved"] is True, f"container {env} should bypass sudo guard on {cmd!r}" diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index f5c7094ee47..c31ae6f08bb 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -2,7 +2,7 @@ Verifies: - Tools are gated on HERMES_KANBAN_TASK: a normal chat session sees - zero kanban tools in its schema; a worker session sees all seven. + zero kanban tools in its schema; a worker session sees the kanban set. - Each handler's happy path. - Error paths (missing required args, bad metadata type, etc). """ @@ -27,9 +27,10 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(home)) import tools.kanban_tools # ensure registered - from tools.registry import registry + from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset + invalidate_check_fn_cache() schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True) names = {s["function"].get("name") for s in schema if "function" in s} kanban = {n for n in names if n and n.startswith("kanban_")} @@ -39,16 +40,17 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path): def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path): - """Worker sessions (HERMES_KANBAN_TASK set) must have all 7 tools.""" + """Worker sessions get task lifecycle tools, not board-routing tools.""" monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake") home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) import tools.kanban_tools # ensure registered - from tools.registry import registry + from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset + invalidate_check_fn_cache() schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True) names = {s["function"].get("name") for s in schema if "function" in s} kanban = {n for n in names if n and n.startswith("kanban_")} @@ -59,6 +61,61 @@ def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path): assert kanban == expected, f"expected {expected}, got {kanban}" +def test_worker_with_kanban_toolset_still_hides_board_routing(monkeypatch, tmp_path): + """Task scope wins over profile config for board-routing tools. + + Even if a worker process happens to also have ``toolsets: [kanban]`` + in its config, the HERMES_KANBAN_TASK env var means it's a focused + worker and must not see kanban_list / kanban_unblock. + """ + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake") + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text("toolsets:\n - kanban\n") + monkeypatch.setenv("HERMES_HOME", str(home)) + + import tools.kanban_tools # ensure registered + from tools.registry import invalidate_check_fn_cache, registry + from toolsets import resolve_toolset + + invalidate_check_fn_cache() + schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True) + names = {s["function"].get("name") for s in schema if "function" in s} + kanban = {n for n in names if n and n.startswith("kanban_")} + assert { + "kanban_list", + "kanban_unblock", + }.isdisjoint(kanban), ( + f"Board-routing tools leaked into worker schema: " + f"{kanban & {'kanban_list', 'kanban_unblock'}}" + ) + + +def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path): + """Orchestrator profiles with toolsets: [kanban] see all kanban tools.""" + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text("toolsets:\n - kanban\n") + monkeypatch.setenv("HERMES_HOME", str(home)) + + import tools.kanban_tools # ensure registered + from tools.registry import invalidate_check_fn_cache, registry + from toolsets import resolve_toolset + + invalidate_check_fn_cache() + schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True) + names = {s["function"].get("name") for s in schema if "function" in s} + kanban = {n for n in names if n and n.startswith("kanban_")} + expected = { + "kanban_list", + "kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat", + "kanban_comment", "kanban_create", "kanban_link", + "kanban_unblock", + } + assert kanban == expected, f"expected {expected}, got {kanban}" + + # --------------------------------------------------------------------------- # Handler happy paths # --------------------------------------------------------------------------- @@ -112,6 +169,100 @@ def test_show_explicit_task_id(worker_env): assert d["task"]["id"] == other +def test_list_filters_tasks(monkeypatch, worker_env): + """kanban_list gives orchestrators filtered board discovery.""" + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + a = kb.create_task(conn, title="alpha", assignee="factory", priority=5) + b = kb.create_task(conn, title="beta", assignee="reviewer") + c = kb.create_task(conn, title="gamma", assignee="factory", tenant="other") + finally: + conn.close() + + from tools import kanban_tools as kt + out = kt._handle_list({"assignee": "factory", "status": "ready", "limit": 10}) + d = json.loads(out) + ids = [t["id"] for t in d["tasks"]] + assert ids == [a, c] + assert d["count"] == 2 + assert d["tasks"][0]["title"] == "alpha" + assert d["tasks"][0]["parent_count"] == 0 + assert b not in ids + + tenant_out = kt._handle_list({ + "assignee": "factory", + "status": "ready", + "tenant": "other", + }) + tenant_ids = [t["id"] for t in json.loads(tenant_out)["tasks"]] + assert tenant_ids == [c] + + +def test_list_rejects_invalid_status(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from tools import kanban_tools as kt + out = kt._handle_list({"status": "not-a-state"}) + assert "status must be one of" in json.loads(out).get("error", "") + + +def test_list_rejects_bad_limit(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from tools import kanban_tools as kt + assert json.loads(kt._handle_list({"limit": "nope"})).get("error") + assert json.loads(kt._handle_list({"limit": 0})).get("error") + + +def test_list_parses_include_archived_string_false(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + live = kb.create_task(conn, title="live task", assignee="factory") + archived = kb.create_task(conn, title="archived task", assignee="factory") + assert kb.archive_task(conn, archived) + finally: + conn.close() + + from tools import kanban_tools as kt + out = kt._handle_list({ + "assignee": "factory", + "include_archived": "false", + }) + ids = [t["id"] for t in json.loads(out)["tasks"]] + assert live in ids + assert archived not in ids + + +def test_list_parses_include_archived_string_true(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + live = kb.create_task(conn, title="live task", assignee="factory") + archived = kb.create_task(conn, title="archived task", assignee="factory") + assert kb.archive_task(conn, archived) + finally: + conn.close() + + from tools import kanban_tools as kt + out = kt._handle_list({ + "assignee": "factory", + "include_archived": "true", + }) + ids = [t["id"] for t in json.loads(out)["tasks"]] + assert live in ids + assert archived in ids + + +def test_list_rejects_bad_include_archived(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from tools import kanban_tools as kt + out = kt._handle_list({"include_archived": "sometimes"}) + assert "include_archived must be" in json.loads(out).get("error", "") + + def test_complete_happy_path(worker_env): from tools import kanban_tools as kt out = kt._handle_complete({ @@ -179,6 +330,106 @@ def test_complete_rejects_non_dict_metadata(worker_env): assert json.loads(out).get("error") +def test_complete_phantom_card_message_advertises_retry(worker_env): + """A phantom-card rejection must surface a tool_error that explicitly + tells the worker the task is still in-flight and how to retry — the + worker has no other channel to discover that. Regression for #22923, + where the previous wording read like a terminal failure and workers + routinely abandoned the run instead of trying again. + """ + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + out = kt._handle_complete({ + "summary": "oops claimed a phantom", + "created_cards": ["t_phantomdeadbeef"], + }) + err = json.loads(out).get("error", "") + assert err, f"expected an error, got {out!r}" + # Phantom id surfaced verbatim. + assert "t_phantomdeadbeef" in err + # The retry-is-supported phrasing — these are the literal cues a + # worker reads to decide whether to retry vs block/abandon. If a + # future change rewords the message, these checks will catch the + # regression. See #22923 for the failure mode. + assert "still in-flight" in err + assert "Retry kanban_complete" in err + assert "created_cards=[]" in err + + # Critically: the task is genuinely still in-flight — the gate + # rejection did not mutate state, so the worker's retry can land. + conn = kb.connect() + try: + assert kb.get_task(conn, worker_env).status == "running" + finally: + conn.close() + + +def test_complete_retry_with_empty_created_cards_succeeds(worker_env): + """After a phantom rejection, retrying kanban_complete with + created_cards=[] (the documented escape hatch) must complete the + task. Regression for #22923.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + # Hit the gate first. + rejected = json.loads(kt._handle_complete({ + "summary": "oops", + "created_cards": ["t_phantomdeadbeef"], + })) + assert rejected.get("error") + + # Retry with the escape hatch. + ok = json.loads(kt._handle_complete({ + "summary": "retry without claims", + "created_cards": [], + })) + assert ok.get("ok") is True + + conn = kb.connect() + try: + assert kb.get_task(conn, worker_env).status == "done" + finally: + conn.close() + + +def test_complete_retry_with_corrected_created_cards_succeeds(worker_env): + """After a phantom rejection, retrying kanban_complete with a + corrected created_cards list (phantom ids removed) must complete the + task. Regression for #22923.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + # Create a real child via the tool so it gets the worker-profile + # attribution the gate trusts. + child = json.loads(kt._handle_create({ + "title": "real child", "assignee": "peer", + })) + assert child["ok"] + real_id = child["task_id"] + + # First attempt mixes real + phantom — gate rejects. + rejected = json.loads(kt._handle_complete({ + "summary": "oops", + "created_cards": [real_id, "t_phantomdeadbeef"], + })) + assert rejected.get("error") + assert "t_phantomdeadbeef" in rejected["error"] + + # Retry with corrected list. + ok = json.loads(kt._handle_complete({ + "summary": "retry with corrected list", + "created_cards": [real_id], + })) + assert ok.get("ok") is True + + conn = kb.connect() + try: + assert kb.get_task(conn, worker_env).status == "done" + finally: + conn.close() + + def test_block_happy_path(worker_env): from tools import kanban_tools as kt out = kt._handle_block({"reason": "need clarification"}) @@ -368,6 +619,52 @@ def test_create_rejects_non_list_parents(worker_env): assert json.loads(out).get("error") +def test_create_parses_triage_string_false(worker_env): + from tools import kanban_tools as kt + from hermes_cli import kanban_db as kb + out = kt._handle_create({ + "title": "not triage", + "assignee": "peer", + "triage": "false", + }) + d = json.loads(out) + assert d["ok"] is True + conn = kb.connect() + try: + task = kb.get_task(conn, d["task_id"]) + assert task.status == "ready" + finally: + conn.close() + + +def test_create_parses_triage_string_true(worker_env): + from tools import kanban_tools as kt + from hermes_cli import kanban_db as kb + out = kt._handle_create({ + "title": "needs triage", + "assignee": "peer", + "triage": "true", + }) + d = json.loads(out) + assert d["ok"] is True + conn = kb.connect() + try: + task = kb.get_task(conn, d["task_id"]) + assert task.status == "triage" + finally: + conn.close() + + +def test_create_rejects_bad_triage(worker_env): + from tools import kanban_tools as kt + out = kt._handle_create({ + "title": "bad triage", + "assignee": "peer", + "triage": "sometimes", + }) + assert "triage must be" in json.loads(out).get("error", "") + + def test_create_accepts_string_parent(worker_env): """Convenience: a single parent id as string is coerced to [id].""" from tools import kanban_tools as kt @@ -458,9 +755,35 @@ def test_link_rejects_cycle(worker_env): assert json.loads(out).get("error") -# --------------------------------------------------------------------------- -# End-to-end: simulate a full worker lifecycle through the tools -# --------------------------------------------------------------------------- +def test_unblock_happy_path(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + tid = kb.create_task(conn, title="blocked", assignee="worker") + kb.block_task(conn, tid, reason="waiting") + finally: + conn.close() + + from tools import kanban_tools as kt + out = kt._handle_unblock({"task_id": tid}) + d = json.loads(out) + assert d["ok"] is True + assert d["status"] == "ready" + + conn = kb.connect() + try: + assert kb.get_task(conn, tid).status == "ready" + finally: + conn.close() + + +def test_unblock_rejects_non_blocked_task(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from tools import kanban_tools as kt + out = kt._handle_unblock({"task_id": worker_env}) + assert json.loads(out).get("error") + def test_worker_lifecycle_through_tools(worker_env): """Drive the full claim -> heartbeat -> comment -> complete lifecycle @@ -599,11 +922,12 @@ def test_kanban_guidance_prompt_size_bounded(monkeypatch, tmp_path): # --------------------------------------------------------------------------- # # A worker process has HERMES_KANBAN_TASK set to its own task id. The -# destructive tools (kanban_complete, kanban_block, kanban_heartbeat) -# must refuse to operate on any OTHER task id, even if the caller -# supplies an explicit `task_id` argument. Workers legitimately call -# kanban_show / kanban_comment / kanban_create / kanban_link on other -# tasks, so those are unrestricted. +# destructive tools (kanban_complete, kanban_block, kanban_heartbeat, +# kanban_unblock) must refuse to operate +# on any OTHER task id, even if the caller supplies an explicit `task_id` +# argument. Workers legitimately call kanban_show / kanban_list / +# kanban_comment / kanban_create / kanban_link on other tasks, so those +# are unrestricted. # # Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally # exempt — their job is routing, and they sometimes close out child @@ -712,6 +1036,37 @@ def test_worker_can_comment_on_foreign_task(worker_env): conn.close() +def test_worker_unblock_rejects_foreign_task_id(worker_env): + """A worker cannot unblock any task — kanban_unblock is orchestrator-only. + + The check fires before the per-task ownership check, so the error + surface is the orchestrator-only refusal rather than the + cross-task-ownership refusal. Either is fine — the property we're + pinning is "worker cannot mutate foreign task via kanban_unblock". + """ + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + other = kb.create_task(conn, title="blocked sibling", assignee="peer") + kb.block_task(conn, other, reason="waiting") + finally: + conn.close() + + from tools import kanban_tools as kt + out = kt._handle_unblock({"task_id": other}) + d = json.loads(out) + err = d.get("error", "") + assert "orchestrator-only" in err or "refusing to mutate" in err, ( + f"expected worker-rejection error, got {err}" + ) + + conn = kb.connect() + try: + assert kb.get_task(conn, other).status == "blocked" + finally: + conn.close() + + def test_worker_complete_own_task_still_works(worker_env): """The ownership check doesn't break the normal own-task happy path.""" from tools import kanban_tools as kt diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 024cf43f948..fa810eb5c54 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -2229,3 +2229,106 @@ class TestSendViaAdapterStandaloneFallback: assert result["success"] is True assert result["message_id"] == "abc-123" assert result["extra_field"] == "preserved" + + +# --------------------------------------------------------------------------- +# _check_send_message — availability gating +# --------------------------------------------------------------------------- + +class TestCheckSendMessage: + """The tool's check_fn governs whether the model sees ``send_message`` as + callable for a given session. The four passing conditions are: + + 1. ``HERMES_KANBAN_TASK`` is set (worker spawned by the kanban dispatcher + — parent gateway is by definition running, but the worker's + ``HERMES_HOME`` may be a profile dir without a ``gateway.pid``). + 2. ``HERMES_SESSION_PLATFORM`` resolves to a non-empty, non-``local`` value + (the session is wired to a messaging platform like Telegram). + 3. ``is_gateway_running()`` returns True (CLI / orchestrator profile with + a live gateway colocated under the same ``HERMES_HOME``). + 4. None of the above → False, tool is hidden. + """ + + def test_kanban_task_env_grants_access(self, monkeypatch): + """Workers spawned by the dispatcher (HERMES_KANBAN_TASK set) must be + allowed regardless of session_platform / gateway-pid state.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc12345") + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + + with patch("gateway.session_context.get_session_env", return_value=""), \ + patch("gateway.status.is_gateway_running", return_value=False): + assert _check_send_message() is True + + def test_kanban_task_env_short_circuits_before_gateway_check(self, monkeypatch): + """Honoring HERMES_KANBAN_TASK must not depend on importing or calling + gateway.status — the worker may run with a HERMES_HOME that has no + gateway.pid, and we don't want that import path to be load-bearing.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_abc12345") + + with patch("gateway.session_context.get_session_env", + side_effect=AssertionError("session_context not consulted " + "when HERMES_KANBAN_TASK is set")), \ + patch("gateway.status.is_gateway_running", + side_effect=AssertionError("gateway.status not consulted " + "when HERMES_KANBAN_TASK is set")): + assert _check_send_message() is True + + def test_messaging_platform_session_grants_access(self, monkeypatch): + """Telegram/Discord/etc. sessions pass via the platform branch even + without HERMES_KANBAN_TASK.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + with patch("gateway.session_context.get_session_env", return_value="telegram"), \ + patch("gateway.status.is_gateway_running", return_value=False): + assert _check_send_message() is True + + def test_local_platform_falls_through_to_gateway_check(self, monkeypatch): + """``HERMES_SESSION_PLATFORM=local`` means CLI-style — must defer to + is_gateway_running() rather than auto-grant.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + with patch("gateway.session_context.get_session_env", return_value="local"), \ + patch("gateway.status.is_gateway_running", return_value=True) as gw_mock: + assert _check_send_message() is True + gw_mock.assert_called_once() + + def test_running_gateway_grants_access(self, monkeypatch): + """Plain CLI session (no kanban task, empty platform) with a live + gateway: tool is callable.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + with patch("gateway.session_context.get_session_env", return_value=""), \ + patch("gateway.status.is_gateway_running", return_value=True): + assert _check_send_message() is True + + def test_no_signals_means_unavailable(self, monkeypatch): + """No kanban task, no platform, no gateway: tool is hidden.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + with patch("gateway.session_context.get_session_env", return_value=""), \ + patch("gateway.status.is_gateway_running", return_value=False): + assert _check_send_message() is False + + def test_gateway_status_import_error_is_swallowed(self, monkeypatch): + """If gateway.status can't be imported (unusual deployment / partial + install), the check returns False rather than raising.""" + from tools.send_message_tool import _check_send_message + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + with patch("gateway.session_context.get_session_env", return_value=""), \ + patch("gateway.status.is_gateway_running", + side_effect=ImportError("simulated")): + assert _check_send_message() is False diff --git a/tools/approval.py b/tools/approval.py index 068748f6854..d6db5a05a0e 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -221,6 +221,40 @@ HARDLINE_PATTERNS_COMPILED = [ ] +# ========================================================================= +# Sudo stdin guard — block password guessing via "sudo -S" +# ========================================================================= +# When SUDO_PASSWORD is not configured, any explicit "sudo -S" in the +# command is the LLM piping a guessed password via stdin. This is a +# brute-force attack vector: the model iterates through candidate +# passwords, inspects sudo's "Sorry, try again" output, and refines. +# Treat this as an unconditional block — there is never a legitimate +# reason for the agent to pipe passwords to sudo -S when no password +# has been configured. +_SUDO_STDIN_RE = re.compile( + r'(?:^|[;&|`\n]|&&|\|\||\$\()\s*sudo\s+-S\b', + re.IGNORECASE) + + +def _check_sudo_stdin_guard(command: str) -> tuple: + """Detect ``sudo -S`` (stdin password) without configured SUDO_PASSWORD. + + When SUDO_PASSWORD is set, ``_transform_sudo_command`` injects ``-S`` + internally — that path is legitimate and handled elsewhere. This guard + only fires when SUDO_PASSWORD is *not* set, meaning the LLM explicitly + wrote ``sudo -S`` to pipe a guessed password. + + Returns: + (is_blocked: bool, description: str | None) + """ + if "SUDO_PASSWORD" in os.environ: + return (False, None) + normalized = _normalize_command_for_detection(command).lower() + if _SUDO_STDIN_RE.search(normalized): + return (True, "sudo password guessing via stdin (sudo -S)") + return (False, None) + + def detect_hardline_command(command: str) -> tuple: """Check if a command matches the unconditional hardline blocklist. @@ -250,6 +284,20 @@ def _hardline_block_result(description: str) -> dict: } +def _sudo_stdin_block_result(description: str) -> dict: + """Build the standard block result for sudo stdin guard.""" + return { + "approved": False, + "message": ( + f"BLOCKED: {description}. " + "Do not pipe passwords to 'sudo -S' — this is a brute-force " + "attack vector. Set SUDO_PASSWORD in your .env file if the " + "agent needs passwordless sudo, or run the sudo command " + "manually in your own terminal." + ), + } + + # ========================================================================= # Dangerous command patterns # ========================================================================= @@ -320,6 +368,25 @@ DANGEROUS_PATTERNS = [ # a script is first made executable then immediately run. The script # content may contain dangerous commands that individual patterns miss. (r'\bchmod\s+\+x\b.*[;&|]+\s*\./', "chmod +x followed by immediate execution"), + # Sudo with stdin / askpass / shell / list-privs flags. An LLM-driven + # agent has no TTY, so sudo invocations that succeed without human + # interaction are those reading the password from stdin (-S/--stdin) + # or via an askpass helper (-A/--askpass). The shell-launch (-s) and + # list-privileges (-a) flags are also gated since they are + # privilege-relevant invocations the agent can chain after acquiring + # the password (e.g. read SUDO_PASSWORD from .env -> sudo -S -s -> + # root shell). Plain `sudo cmd` (no flag) is TTY-bound and excluded. + # `_normalize_command_for_detection` lowercases input before pattern + # matching, so case variants of S/s and A/a collapse — both forms + # are gated below. Lazy `[^;|&\n]*?` allows flag arguments (e.g. + # `sudo -u root -S whoami`) without spanning command separators. See + # #17873 category 4. + (r'\bsudo\b[^;|&\n]*?\s+(?:-s\b|--stdin\b|-a\b|--askpass\b)', + "sudo with privilege flag (stdin/askpass/shell/list)"), + # Combined short-flag form: -nS, -ns, -sa, -las — sudo flags packed + # into a single -X token. Catches the same threat class. + (r'\bsudo\b[^;|&\n]*?\s+-[a-z]*[sa][a-z]*\b', + "sudo with combined-flag privilege escalation"), ] @@ -692,13 +759,13 @@ def prompt_dangerous_approval(command: str, description: str, return "deny" choice = result["choice"] - if choice in ('o', 'once'): + if choice in {'o', 'once'}: print(t("approval.allowed_once")) return "once" - elif choice in ('s', 'session'): + elif choice in {'s', 'session'}: print(t("approval.allowed_session")) return "session" - elif choice in ('a', 'always'): + elif choice in {'a', 'always'}: if not allow_permanent: print(t("approval.allowed_session")) return "session" @@ -764,7 +831,7 @@ def _get_cron_approval_mode() -> str: from hermes_cli.config import load_config config = load_config() mode = str(cfg_get(config, "approvals", "cron_mode", default="deny")).lower().strip() - if mode in ("approve", "off", "allow", "yes"): + if mode in {"approve", "off", "allow", "yes"}: return "approve" return "deny" except Exception: @@ -833,7 +900,7 @@ def check_dangerous_command(command: str, env_type: str, Returns: {"approved": True/False, "message": str or None, ...} """ - if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: return {"approved": True, "message": None} # Hardline floor: commands with no recovery path (rm -rf /, mkfs, dd @@ -958,7 +1025,7 @@ def check_all_command_guards(command: str, env_type: str, other was shown to the user. """ # Skip containers for both checks - if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: return {"approved": True, "message": None} # Hardline floor: unconditional block for catastrophic commands @@ -970,6 +1037,17 @@ def check_all_command_guards(command: str, env_type: str, logger.warning("Hardline block: %s (command: %s)", hardline_desc, command[:200]) return _hardline_block_result(hardline_desc) + # == Sudo stdin guard == + # Like the hardline floor above, this is unconditional: there is never a + # legitimate reason for the agent to pipe passwords to sudo -S when no + # SUDO_PASSWORD has been configured. This must fire BEFORE the yolo + # check so even yolo/smart approval/mode=off cannot bypass it. + is_sudo_guess, sudo_guess_desc = _check_sudo_stdin_guard(command) + if is_sudo_guess: + logger.warning("Sudo stdin guard block: %s (command: %s)", + sudo_guess_desc, command[:200]) + return _sudo_stdin_block_result(sudo_guess_desc) + # --yolo or approvals.mode=off: bypass all approval prompts. # Gateway /yolo is session-scoped; CLI --yolo remains process-scoped. approval_mode = _get_approval_mode() @@ -1026,7 +1104,7 @@ def check_all_command_guards(command: str, env_type: str, # Previously, tirith "block" was a hard block with no approval prompt. # Now both block and warn go through the approval flow so users can # inspect the explanation and approve if they understand the risk. - if tirith_result["action"] in ("block", "warn"): + if tirith_result["action"] in {"block", "warn"}: findings = tirith_result.get("findings") or [] rule_id = findings[0].get("rule_id", "unknown") if findings else "unknown" tirith_key = f"tirith:{rule_id}" diff --git a/tools/browser_providers/browser_use.py b/tools/browser_providers/browser_use.py index f8e9a8d9fa4..260249ef0bb 100644 --- a/tools/browser_providers/browser_use.py +++ b/tools/browser_providers/browser_use.py @@ -184,7 +184,7 @@ class BrowserUseProvider(CloudBrowserProvider): json={"action": "stop"}, timeout=10, ) - if response.status_code in (200, 201, 204): + if response.status_code in {200, 201, 204}: logger.debug("Successfully closed Browser Use session %s", session_id) return True else: diff --git a/tools/browser_providers/browserbase.py b/tools/browser_providers/browserbase.py index 338ebf89895..5076af4c7a6 100644 --- a/tools/browser_providers/browserbase.py +++ b/tools/browser_providers/browserbase.py @@ -180,7 +180,7 @@ class BrowserbaseProvider(CloudBrowserProvider): }, timeout=10, ) - if response.status_code in (200, 201, 204): + if response.status_code in {200, 201, 204}: logger.debug("Successfully closed Browserbase session %s", session_id) return True else: diff --git a/tools/browser_providers/firecrawl.py b/tools/browser_providers/firecrawl.py index 3f8556fc124..17001f72f1d 100644 --- a/tools/browser_providers/firecrawl.py +++ b/tools/browser_providers/firecrawl.py @@ -79,7 +79,7 @@ class FirecrawlProvider(CloudBrowserProvider): headers=self._headers(), timeout=10, ) - if response.status_code in (200, 201, 204): + if response.status_code in {200, 201, 204}: logger.debug("Successfully closed Firecrawl session %s", session_id) return True else: diff --git a/tools/browser_supervisor.py b/tools/browser_supervisor.py index db0b1e29909..af8d40ee185 100644 --- a/tools/browser_supervisor.py +++ b/tools/browser_supervisor.py @@ -412,7 +412,7 @@ class CDPSupervisor: ``{"ok": False, "error": "..."}`` on a recoverable error (no dialog, ambiguous dialog_id, supervisor inactive). """ - if action not in ("accept", "dismiss"): + if action not in {"accept", "dismiss"}: return {"ok": False, "error": f"action must be 'accept' or 'dismiss', got {action!r}"} with self._state_lock: @@ -457,6 +457,89 @@ class CDPSupervisor: return {"ok": False, "error": f"{type(e).__name__}: {e}"} return {"ok": True, "dialog": snapshot_copy.to_dict()} + def evaluate_runtime( + self, + expression: str, + *, + return_by_value: bool = True, + await_promise: bool = True, + timeout: float = 10.0, + ) -> Dict[str, Any]: + """Evaluate ``expression`` in the page's Runtime context over the live WS. + + Reuses the supervisor's already-connected WebSocket — zero subprocess + startup cost vs the agent-browser CLI ``eval`` command (which does + fork+exec+Node-startup+CDP-setup on every call). + + Returns a dict shaped like ``{"ok": True, "result": <value>, "result_type": "..."}`` + on success, or ``{"ok": False, "error": "..."}`` on failure. + + ``return_by_value=True`` asks the browser to JSON-serialize the result + before sending it back, matching DevTools-console semantics for + primitive / plain-object expressions. For DOM nodes or non-serializable + objects, the browser returns a description string in ``result_type``. + """ + loop = self._loop + if loop is None or not loop.is_running(): + return {"ok": False, "error": "supervisor loop is not running"} + + with self._state_lock: + if not self._active: + return {"ok": False, "error": "supervisor is not active"} + session_id = self._page_session_id + + if not session_id: + return {"ok": False, "error": "supervisor has no attached page session"} + + async def _do_eval() -> Dict[str, Any]: + return await self._cdp( + "Runtime.evaluate", + { + "expression": expression, + "returnByValue": return_by_value, + "awaitPromise": await_promise, + # userGesture matters for things like clipboard / fullscreen + # APIs that require a user-activation context. + "userGesture": True, + }, + session_id=session_id, + timeout=timeout, + ) + + try: + fut = asyncio.run_coroutine_threadsafe(_do_eval(), loop) + response = fut.result(timeout=timeout + 1) + except Exception as exc: + return {"ok": False, "error": f"{type(exc).__name__}: {exc}"} + + # Runtime.evaluate response shape: + # {"id": N, "result": {"result": {"type": "...", "value": ..., ...}, + # "exceptionDetails": {...} (only on error)}} + result_payload = response.get("result", {}) if isinstance(response, dict) else {} + exception_details = result_payload.get("exceptionDetails") + if exception_details: + # Surface the JS-side exception with a clean message. + exc_text = exception_details.get("text") or "JavaScript exception" + exc_obj = exception_details.get("exception") or {} + description = exc_obj.get("description") + if description: + exc_text = f"{exc_text}: {description}" + return {"ok": False, "error": exc_text} + + result_obj = result_payload.get("result", {}) + result_type = result_obj.get("type", "undefined") + + if "value" in result_obj: + value = result_obj["value"] + elif result_type == "undefined": + value = None + else: + # Non-serializable (functions, DOM nodes, etc.) — return the + # browser's string description so the model gets *something*. + value = result_obj.get("description") or result_obj.get("unserializableValue") + + return {"ok": True, "result": value, "result_type": result_type} + # ── Supervisor loop internals ──────────────────────────────────────────── def _thread_main(self) -> None: @@ -1123,7 +1206,7 @@ class CDPSupervisor: info = params.get("targetInfo") or {} sid = params.get("sessionId") target_type = info.get("type") - if not sid or target_type not in ("iframe", "worker"): + if not sid or target_type not in {"iframe", "worker"}: return self._child_sessions[sid] = {"info": info, "type": target_type} @@ -1207,7 +1290,7 @@ class CDPSupervisor: event = ConsoleEvent(ts=time.time(), level="exception", text=text, url=url) else: raw_level = str(params.get("type") or "log") - level = "error" if raw_level in ("error", "assert") else ( + level = "error" if raw_level in {"error", "assert"} else ( "warning" if raw_level == "warning" else "log" ) args = params.get("args") or [] diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 084c4d3d31e..40ba7cab25c 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -918,7 +918,7 @@ def _url_is_private(url: str) -> bool: # Hostname — must resolve to confirm it's private (bare "localhost" # resolves to 127.0.0.1 via /etc/hosts). Short-circuit on obvious # names to avoid a DNS hop. - if hostname in ("localhost",) or hostname.endswith(".localhost"): + if hostname in {"localhost",} or hostname.endswith(".localhost"): return True if hostname.endswith(".local") or hostname.endswith(".lan") or hostname.endswith(".internal"): return True @@ -2499,7 +2499,7 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str: JSON string with scroll result """ # Validate direction - if direction not in ["up", "down"]: + if direction not in {"up", "down"}: return json.dumps({ "success": False, "error": f"Invalid direction '{direction}'. Use 'up' or 'down'." @@ -2671,6 +2671,53 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str: return _camofox_eval(expression, task_id) effective_task_id = _last_session_key(task_id or "default") + + # --- Fast path: route through the supervisor's persistent CDP WS --------- + # When a CDPSupervisor is alive for this task_id, ``Runtime.evaluate`` runs + # on the already-connected WebSocket — zero subprocess startup cost vs + # spawning an ``agent-browser eval`` CLI process. Falls through to the + # subprocess path on any error so behaviour is unchanged when no + # supervisor is running (e.g. plain agent-browser without a CDP backend). + try: + from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found] + supervisor = SUPERVISOR_REGISTRY.get(effective_task_id) + if supervisor is not None: + sup_result = supervisor.evaluate_runtime(expression) + if sup_result.get("ok"): + raw_result = sup_result.get("result") + # Match the agent-browser path: if the value is a JSON string, + # parse it so the model gets structured data. + parsed = raw_result + if isinstance(raw_result, str): + try: + parsed = json.loads(raw_result) + except (json.JSONDecodeError, ValueError): + pass # keep as string + response = { + "success": True, + "result": parsed, + "result_type": type(parsed).__name__, + "method": "cdp_supervisor", + } + return json.dumps(response, ensure_ascii=False, default=str) + # JS exception is a real failure — surface it instead of falling + # through to the subprocess path (which would just re-run and + # produce the same exception, but slower). + err = sup_result.get("error") or "evaluate_runtime failed" + if "supervisor" not in err.lower(): + # Real JS-side error — return it. + return json.dumps({"success": False, "error": err}, ensure_ascii=False) + # Supervisor-side failure (loop down, no session) — fall through. + logger.debug( + "browser_eval: supervisor path unavailable (%s), falling back to subprocess", + err, + ) + except ImportError: + pass + except Exception as exc: # pragma: no cover — defensive + logger.debug("browser_eval: supervisor path errored (%s), falling back", exc) + + # --- Fallback: agent-browser CLI subprocess (original path) ------------- result = _run_browser_command(effective_task_id, "eval", [expression]) if not result.get("success"): diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index cab877bc623..16ce12fc60e 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -639,7 +639,7 @@ class CheckpointManager: abs_dir = str(_normalize_path(working_dir)) # Skip root, home, and other overly broad directories - if abs_dir in ("/", str(Path.home())): + if abs_dir in {"/", str(Path.home())}: logger.debug("Checkpoint skipped: directory too broad (%s)", abs_dir) return False @@ -1312,8 +1312,7 @@ def prune_checkpoints( for p in child.rglob("*"): try: mt = p.stat().st_mtime - if mt > newest: - newest = mt + newest = max(newest, mt) except OSError: continue except OSError: @@ -1455,8 +1454,7 @@ def prune_checkpoints( size_after = _dir_size_bytes(base) delta = size_before - size_after - if delta > result["bytes_freed"]: - result["bytes_freed"] = delta + result["bytes_freed"] = max(result["bytes_freed"], delta) return result diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 092f7e37e97..3822ce539f2 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -612,7 +612,7 @@ def _get_or_create_env(task_id: str): cwd = overrides.get("cwd") or config["cwd"] container_config = None - if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index ba50c57987c..df1162c5d79 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -673,5 +673,5 @@ def _parse_element(d: Dict[str, Any]) -> UIElement: pid=int(d.get("pid", 0) or 0), window_id=int(d.get("windowId", 0) or 0), attributes={k: v for k, v in d.items() - if k not in ("index", "role", "label", "bounds", "app", "pid", "windowId")}, + if k not in {"index", "role", "label", "bounds", "app", "pid", "windowId"}}, ) diff --git a/tools/computer_use/tool.py b/tools/computer_use/tool.py index 51c7656fc1a..63a5076c171 100644 --- a/tools/computer_use/tool.py +++ b/tools/computer_use/tool.py @@ -131,7 +131,7 @@ def _get_backend() -> ComputerUseBackend: with _backend_lock: if _backend is None: backend_name = os.environ.get("HERMES_COMPUTER_USE_BACKEND", "cua").lower() - if backend_name in ("cua", "cua-driver", ""): + if backend_name in {"cua", "cua-driver", ""}: from tools.computer_use.cua_backend import CuaDriverBackend _backend = CuaDriverBackend() elif backend_name == "noop": # pragma: no cover @@ -286,7 +286,7 @@ def _request_approval(action: str, args: Dict[str, Any]) -> Optional[str]: def _summarize_action(action: str, args: Dict[str, Any]) -> str: - if action in ("click", "double_click", "right_click", "middle_click"): + if action in {"click", "double_click", "right_click", "middle_click"}: if args.get("element") is not None: return f"{action} element #{args['element']}" coord = args.get("coordinate") @@ -314,7 +314,7 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) -> if action == "capture": mode = str(args.get("mode", "som")) - if mode not in ("som", "vision", "ax"): + if mode not in {"som", "vision", "ax"}: return json.dumps({"error": f"bad mode {mode!r}; use som|vision|ax"}) cap = backend.capture(mode=mode, app=args.get("app")) return _capture_response(cap) @@ -335,7 +335,7 @@ def _dispatch(backend: ComputerUseBackend, action: str, args: Dict[str, Any]) -> res = backend.focus_app(app, raise_window=bool(args.get("raise_window"))) return _maybe_follow_capture(backend, res, capture_after) - if action in ("click", "double_click", "right_click", "middle_click"): + if action in {"click", "double_click", "right_click", "middle_click"}: button = args.get("button") click_count = 1 if action == "double_click": diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 550b3e62970..e63b60047ac 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -327,9 +327,8 @@ def cronjob( "the script is the job.", success=False, ) - else: - if not prompt and not canonical_skills: - return tool_error("create requires either prompt or at least one skill", success=False) + elif not prompt and not canonical_skills: + return tool_error("create requires either prompt or at least one skill", success=False) if prompt: scan_error = _scan_cron_prompt(prompt) if scan_error: diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b0c79afc119..b2c02aedaf8 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -315,7 +315,7 @@ def _normalize_role(r: Optional[str]) -> str: if r is None or not r: return "leaf" r_norm = str(r).strip().lower() - if r_norm in ("leaf", "orchestrator"): + if r_norm in {"leaf", "orchestrator"}: return r_norm logger.warning("Unknown delegate_task role=%r, coercing to 'leaf'", r) return "leaf" @@ -437,7 +437,7 @@ def _get_orchestrator_enabled() -> bool: return val # Accept "true"/"false" strings from YAML that doesn't auto-coerce. if isinstance(val, str): - return val.strip().lower() in ("true", "1", "yes", "on") + return val.strip().lower() in {"true", "1", "yes", "on"} return True @@ -1239,7 +1239,7 @@ def _dump_subagent_timeout_diagnostic( if tool_names: _w(f" loaded tool count: {len(tool_names)}") try: - _w(f" loaded tools: {sorted(list(tool_names))}") + _w(f" loaded tools: {sorted(tool_names)}") except Exception: pass _w("") @@ -2271,9 +2271,9 @@ def delegate_task( # total as "none" when the parent itself hadn't billed any calls # yet (rare but possible when the parent's only action this turn # was delegate_task). - if getattr(parent_agent, "session_cost_source", "none") in (None, "", "none"): + if getattr(parent_agent, "session_cost_source", "none") in {None, "", "none"}: parent_agent.session_cost_source = "subagent" - if getattr(parent_agent, "session_cost_status", "unknown") in (None, "", "unknown"): + if getattr(parent_agent, "session_cost_status", "unknown") in {None, "", "unknown"}: parent_agent.session_cost_status = "estimated" except Exception: logger.debug("Subagent cost rollup failed", exc_info=True) diff --git a/tools/environments/daytona.py b/tools/environments/daytona.py index 6eff002ae07..a32ec900c6a 100644 --- a/tools/environments/daytona.py +++ b/tools/environments/daytona.py @@ -124,7 +124,7 @@ class DaytonaEnvironment(BaseEnvironment): home = self._sandbox.process.exec("echo $HOME").result.strip() if home: self._remote_home = home - if requested_cwd in ("~", "/home/daytona"): + if requested_cwd in {"~", "/home/daytona"}: self.cwd = home except Exception: pass @@ -195,7 +195,7 @@ class DaytonaEnvironment(BaseEnvironment): def _ensure_sandbox_ready(self) -> None: """Restart sandbox if it was stopped (e.g., by a previous interrupt).""" self._sandbox.refresh_data() - if self._sandbox.state in (self._SandboxState.STOPPED, self._SandboxState.ARCHIVED): + if self._sandbox.state in {self._SandboxState.STOPPED, self._SandboxState.ARCHIVED}: self._sandbox.start() logger.info("Daytona: restarted sandbox %s", self._sandbox.id) diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 06d8154872c..1cd72ce8552 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -300,6 +300,7 @@ class DockerEnvironment(BaseEnvironment): host_cwd: str = None, auto_mount_cwd: bool = False, run_as_host_user: bool = False, + extra_args: list = None, ): if cwd == "~": cwd = "/root" @@ -476,6 +477,15 @@ class DockerEnvironment(BaseEnvironment): security_args = _build_security_args(run_as_host_user and bool(user_args)) logger.info(f"Docker volume_args: {volume_args}") + # User-supplied extra docker run flags (docker_extra_args in config.yaml). + # Appended last so they can override defaults if needed. + validated_extra = [] + for arg in (extra_args or []): + if not isinstance(arg, str): + logger.warning("Ignoring non-string docker_extra_args entry: %r", arg) + continue + validated_extra.append(arg) + all_run_args = ( security_args + user_args @@ -483,6 +493,7 @@ class DockerEnvironment(BaseEnvironment): + resource_args + volume_args + env_args + + validated_extra ) logger.info(f"Docker run_args: {all_run_args}") diff --git a/tools/environments/local.py b/tools/environments/local.py index 985bf4bdce8..7aa75a62d0c 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -274,6 +274,17 @@ def _make_run_env(env: dict) -> dict: if _profile_home: run_env["HOME"] = _profile_home + # Inject ContextVar-based session vars into subprocess env. + # ContextVars don't propagate to child processes, so we bridge them here. + try: + from gateway.session_context import get_session_env, _UNSET, _VAR_MAP + for var_name, var in _VAR_MAP.items(): + value = var.get() + if value is not _UNSET and value: + run_env[var_name] = value + except Exception: + pass + return run_env diff --git a/tools/environments/vercel_sandbox.py b/tools/environments/vercel_sandbox.py index 2b434af1594..b381eb77cd2 100644 --- a/tools/environments/vercel_sandbox.py +++ b/tools/environments/vercel_sandbox.py @@ -254,7 +254,7 @@ class VercelSandboxEnvironment(BaseEnvironment): self.init_session() def _build_create_params(self, *, cpu: float, memory: int, disk: int) -> _SandboxCreateParams: - if disk not in (0, _DEFAULT_CONTAINER_DISK_MB): + if disk not in {0, _DEFAULT_CONTAINER_DISK_MB}: raise ValueError( "Vercel Sandbox does not support configurable container_disk. " "Use the default shared setting." @@ -336,7 +336,7 @@ class VercelSandboxEnvironment(BaseEnvironment): if requested_cwd == "~": self.cwd = self._remote_home - elif requested_cwd in ("", DEFAULT_VERCEL_CWD): + elif requested_cwd in {"", DEFAULT_VERCEL_CWD}: self.cwd = self._workspace_root else: self.cwd = requested_cwd diff --git a/tools/file_operations.py b/tools/file_operations.py index 022943d9f0e..91c5abae343 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -1244,7 +1244,7 @@ class ShellFileOperations(FileOperations): search_root = Path(path) has_hidden_path_ancestor = any( - part not in (".", "..") and part.startswith(".") + part not in {".", ".."} and part.startswith(".") for part in search_root.parts ) @@ -1305,7 +1305,7 @@ class ShellFileOperations(FileOperations): rel_parts = Path(file_path).resolve().relative_to(normalized_root).parts except ValueError: rel_parts = Path(file_path).parts - if any(part not in (".", "..") and part.startswith(".") for part in rel_parts): + if any(part not in {".", ".."} and part.startswith(".") for part in rel_parts): continue filtered_files.append(file_path) files = filtered_files[offset:offset + limit] diff --git a/tools/file_tools.py b/tools/file_tools.py index c197061ade1..2cedc4bcd5f 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -380,7 +380,7 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: logger.info("Creating new %s environment for task %s...", env_type, task_id[:8]) container_config = None - if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), diff --git a/tools/fuzzy_match.py b/tools/fuzzy_match.py index 9a922cd9b34..15cedd40e46 100644 --- a/tools/fuzzy_match.py +++ b/tools/fuzzy_match.py @@ -505,8 +505,7 @@ def _calculate_line_positions(content_lines: List[str], start_line: int, """ start_pos = sum(len(line) + 1 for line in content_lines[:start_line]) end_pos = sum(len(line) + 1 for line in content_lines[:end_line]) - 1 - if end_pos >= content_length: - end_pos = content_length + end_pos = min(content_length, end_pos) return start_pos, end_pos diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 68f4af9ac0c..a545a85d9fc 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -575,7 +575,7 @@ def _build_fal_payload( payload: Dict[str, Any] = dict(meta.get("defaults", {})) payload["prompt"] = (prompt or "").strip() - if size_style in ("image_size_preset", "gpt_literal"): + if size_style in {"image_size_preset", "gpt_literal"}: payload["image_size"] = sizes[aspect] elif size_style == "aspect_ratio": payload["aspect_ratio"] = sizes[aspect] diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 366252e385e..fab0a68c92b 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -39,22 +39,11 @@ logger = logging.getLogger(__name__) # Gating # --------------------------------------------------------------------------- -def _check_kanban_mode() -> bool: - """Tools are available when: +KANBAN_LIST_DEFAULT_LIMIT = 50 +KANBAN_LIST_MAX_LIMIT = 200 - 1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR - 2. The current profile has ``kanban`` in its toolsets config - (orchestrator profiles like techlead that route work via Kanban). - Humans running ``hermes chat`` without the kanban toolset see zero - kanban tools. Workers spawned by the kanban dispatcher (gateway- - embedded by default) and orchestrator profiles with the kanban - toolset enabled see all seven. - """ - if os.environ.get("HERMES_KANBAN_TASK"): - return True - - # Check if the current profile has the kanban toolset enabled. +def _profile_has_kanban_toolset() -> bool: # Uses load_config() which has mtime-based caching, so this adds # negligible overhead. The check_fn results are further TTL-cached # (~30s) by the tool registry. @@ -67,6 +56,37 @@ def _check_kanban_mode() -> bool: return False +def _check_kanban_mode() -> bool: + """Task-lifecycle tools are available when: + + 1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR + 2. The current profile has ``kanban`` in its toolsets config + (orchestrator profiles like techlead that route work via Kanban). + + Humans running ``hermes chat`` without the kanban toolset see zero + kanban tools. Workers spawned by the kanban dispatcher (gateway- + embedded by default) and orchestrator profiles with the kanban + toolset enabled see the Kanban lifecycle tool surface. + """ + if os.environ.get("HERMES_KANBAN_TASK"): + return True + return _profile_has_kanban_toolset() + + +def _check_kanban_orchestrator_mode() -> bool: + """Board-routing tools (kanban_list, kanban_unblock) are intentionally + hidden from task workers. + + Dispatcher-spawned workers should close their own task via the + lifecycle tools (complete/block/heartbeat), not enumerate or unblock + board state. Profiles that explicitly opt into the kanban toolset + and are NOT scoped to a single task are the orchestrator surface. + """ + if os.environ.get("HERMES_KANBAN_TASK"): + return False + return _profile_has_kanban_toolset() + + # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- @@ -135,6 +155,73 @@ def _ok(**fields: Any) -> str: return json.dumps({"ok": True, **fields}) +def _normalize_profile(value: Any) -> Optional[str]: + """Normalize CLI-compatible assignee sentinels for the tool surface.""" + if value is None: + return None + text = str(value).strip() + if not text or text.lower() in {"none", "-", "null"}: + return None + return text + + +def _parse_bool_arg(args: dict, name: str, *, default: bool = False): + value = args.get(name) + if value is None: + return default, None + if isinstance(value, bool): + return value, None + text = str(value).strip().lower() + if text in {"true", "1", "yes"}: + return True, None + if text in {"false", "0", "no"}: + return False, None + return default, f"{name} must be a boolean or 'true'/'false'" + + +def _require_orchestrator_tool(tool_name: str) -> Optional[str]: + """Belt-and-suspenders runtime guard for orchestrator-only handlers. + + The check_fn (`_check_kanban_orchestrator_mode`) keeps these tools + out of the worker schema entirely, but in case a stale registration + or test harness routes a worker to one of them anyway, return a + structured tool_error so the model gets a clear refusal instead of + silently mutating board state from a worker context. + """ + if os.environ.get("HERMES_KANBAN_TASK"): + return tool_error( + f"{tool_name} is orchestrator-only; dispatcher-spawned workers " + "must use kanban_complete, kanban_block, kanban_heartbeat, or " + "kanban_comment for their assigned task." + ) + return None + + +def _task_summary_dict(kb, conn, task) -> dict[str, Any]: + """Compact task shape for board-listing tools.""" + parents = kb.parent_ids(conn, task.id) + children = kb.child_ids(conn, task.id) + return { + "id": task.id, + "title": task.title, + "assignee": task.assignee, + "status": task.status, + "priority": task.priority, + "tenant": task.tenant, + "workspace_kind": task.workspace_kind, + "workspace_path": task.workspace_path, + "created_by": task.created_by, + "created_at": task.created_at, + "started_at": task.started_at, + "completed_at": task.completed_at, + "current_run_id": task.current_run_id, + "parents": parents, + "children": children, + "parent_count": len(parents), + "child_count": len(children), + } + + # --------------------------------------------------------------------------- # Handlers # --------------------------------------------------------------------------- @@ -210,6 +297,66 @@ def _handle_show(args: dict, **kw) -> str: return tool_error(f"kanban_show: {e}") +def _handle_list(args: dict, **kw) -> str: + """List task summaries with the same core filters as the CLI.""" + guard = _require_orchestrator_tool("kanban_list") + if guard: + return guard + assignee = args.get("assignee") + status = args.get("status") + tenant = args.get("tenant") + include_archived, bool_error = _parse_bool_arg(args, "include_archived") + if bool_error: + return tool_error(bool_error) + limit = args.get("limit") + if limit is None: + limit = KANBAN_LIST_DEFAULT_LIMIT + try: + limit = int(limit) + except (TypeError, ValueError): + return tool_error("limit must be an integer") + if limit < 1: + return tool_error("limit must be >= 1") + if limit > KANBAN_LIST_MAX_LIMIT: + return tool_error(f"limit must be <= {KANBAN_LIST_MAX_LIMIT}") + try: + kb, conn = _connect() + try: + # Match CLI list: dependencies that cleared since the last + # dispatcher tick should be visible to orchestrators immediately. + promoted = kb.recompute_ready(conn) + # Fetch one extra row so model-facing output can report that + # a bounded listing was truncated without dumping the board. + rows = kb.list_tasks( + conn, + assignee=assignee, + status=status, + tenant=tenant, + include_archived=include_archived, + limit=limit + 1, + ) + truncated = len(rows) > limit + tasks = rows[:limit] + return json.dumps({ + "tasks": [_task_summary_dict(kb, conn, t) for t in tasks], + "count": len(tasks), + "limit": limit, + "truncated": truncated, + "next_limit": ( + min(limit * 2, KANBAN_LIST_MAX_LIMIT) + if truncated and limit < KANBAN_LIST_MAX_LIMIT else None + ), + "promoted": promoted, + }) + finally: + conn.close() + except ValueError as e: + return tool_error(f"kanban_list: {e}") + except Exception as e: + logger.exception("kanban_list failed") + return tool_error(f"kanban_list: {e}") + + def _handle_complete(args: dict, **kw) -> str: """Mark the current task done with a structured handoff.""" tid = _default_task_id(args.get("task_id")) @@ -259,12 +406,21 @@ def _handle_complete(args: dict, **kw) -> str: # Structured rejection — surface the phantom ids so the # worker can retry with a corrected list or drop the # field. Audit event already landed in the DB. + # + # The task itself was NOT mutated (the gate runs before + # the write txn), so the worker can simply call + # kanban_complete again. Spell that out — without it the + # model often interprets a tool_error as a terminal + # failure and either blocks or crashes the run instead + # of retrying. See #22923. return tool_error( f"kanban_complete blocked: the following created_cards " f"do not exist or were not created by this worker: " f"{', '.join(hall_err.phantom)}. " - f"Either omit them, use only ids returned from successful " - f"kanban_create calls, or remove the created_cards field." + f"Your task is still in-flight (no state change). " + f"Retry kanban_complete with the same summary/metadata " + f"and either drop these ids from created_cards, or pass " + f"created_cards=[] to skip the card-claim check entirely." ) if not ok: return tool_error( @@ -416,7 +572,9 @@ def _handle_create(args: dict, **kw) -> str: priority = args.get("priority") workspace_kind = args.get("workspace_kind") or "scratch" workspace_path = args.get("workspace_path") - triage = bool(args.get("triage")) + triage, bool_error = _parse_bool_arg(args, "triage") + if bool_error: + return tool_error(bool_error) idempotency_key = args.get("idempotency_key") max_runtime_seconds = args.get("max_runtime_seconds") skills = args.get("skills") @@ -462,11 +620,38 @@ def _handle_create(args: dict, **kw) -> str: ) finally: conn.close() + except ValueError as e: + return tool_error(f"kanban_create: {e}") except Exception as e: logger.exception("kanban_create failed") return tool_error(f"kanban_create: {e}") +def _handle_unblock(args: dict, **kw) -> str: + """Transition a blocked task back to ready.""" + guard = _require_orchestrator_tool("kanban_unblock") + if guard: + return guard + tid = args.get("task_id") + if not tid: + return tool_error("task_id is required") + ownership_err = _enforce_worker_task_ownership(str(tid)) + if ownership_err: + return ownership_err + try: + kb, conn = _connect() + try: + ok = kb.unblock_task(conn, str(tid)) + if not ok: + return tool_error(f"could not unblock {tid} (not blocked or unknown)") + return _ok(task_id=str(tid), status="ready") + finally: + conn.close() + except Exception as e: + logger.exception("kanban_unblock failed") + return tool_error(f"kanban_unblock: {e}") + + def _handle_link(args: dict, **kw) -> str: """Add a parent→child dependency edge after the fact.""" parent_id = args.get("parent_id") @@ -519,6 +704,50 @@ KANBAN_SHOW_SCHEMA = { }, } +KANBAN_LIST_SCHEMA = { + "name": "kanban_list", + "description": ( + "List Kanban task summaries so an orchestrator profile can discover " + "work to route. Supports the same core filters as the CLI: assignee, " + "status, tenant, include_archived, and limit. Returns compact rows " + "with ids, title, status, assignee, priority, parent/child ids, and " + "counts. Bounded to 50 rows by default, 200 max, with truncation " + "metadata. Also recomputes ready tasks before listing, matching the " + "CLI. Orchestrator-only — dispatcher-spawned task workers never see " + "this tool." + ), + "parameters": { + "type": "object", + "properties": { + "assignee": { + "type": "string", + "description": "Optional assignee/profile filter.", + }, + "status": { + "type": "string", + "enum": [ + "triage", "todo", "ready", "running", + "blocked", "done", "archived", + ], + "description": "Optional task status filter.", + }, + "tenant": { + "type": "string", + "description": "Optional tenant/project namespace filter.", + }, + "include_archived": { + "type": "boolean", + "description": "Include archived tasks. Defaults to false.", + }, + "limit": { + "type": "integer", + "description": "Optional maximum rows to return (default 50, max 200).", + }, + }, + "required": [], + }, +} + KANBAN_COMPLETE_SCHEMA = { "name": "kanban_complete", "description": ( @@ -787,6 +1016,25 @@ KANBAN_CREATE_SCHEMA = { }, } +KANBAN_UNBLOCK_SCHEMA = { + "name": "kanban_unblock", + "description": ( + "Move a blocked Kanban task back to ready. Orchestrator-only — only " + "profiles with the kanban toolset can unblock routed work; " + "dispatcher-spawned task workers never see this tool." + ), + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Blocked task id to return to ready.", + }, + }, + "required": ["task_id"], + }, +} + KANBAN_LINK_SCHEMA = { "name": "kanban_link", "description": ( @@ -818,6 +1066,15 @@ registry.register( emoji="📋", ) +registry.register( + name="kanban_list", + toolset="kanban", + schema=KANBAN_LIST_SCHEMA, + handler=_handle_list, + check_fn=_check_kanban_orchestrator_mode, + emoji="📋", +) + registry.register( name="kanban_complete", toolset="kanban", @@ -863,6 +1120,15 @@ registry.register( emoji="➕", ) +registry.register( + name="kanban_unblock", + toolset="kanban", + schema=KANBAN_UNBLOCK_SCHEMA, + handler=_handle_unblock, + check_fn=_check_kanban_orchestrator_mode, + emoji="▶", +) + registry.register( name="kanban_link", toolset="kanban", diff --git a/tools/memory_tool.py b/tools/memory_tool.py index 80ee3c63d67..236760a464a 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -291,7 +291,7 @@ class MemoryStore: if len(matches) > 1: # If all matches are identical (exact duplicates), operate on the first one - unique_texts = set(e for _, e in matches) + unique_texts = {e for _, e in matches} if len(unique_texts) > 1: previews = [e[:80] + ("..." if len(e) > 80 else "") for _, e in matches] return { @@ -341,7 +341,7 @@ class MemoryStore: if len(matches) > 1: # If all matches are identical (exact duplicates), remove the first one - unique_texts = set(e for _, e in matches) + unique_texts = {e for _, e in matches} if len(unique_texts) > 1: previews = [e[:80] + ("..." if len(e) > 80 else "") for _, e in matches] return { @@ -477,7 +477,7 @@ def memory_tool( if store is None: return tool_error("Memory is not available. It may be disabled in config or this environment.", success=False) - if target not in ("memory", "user"): + if target not in {"memory", "user"}: return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False) if action == "add": diff --git a/tools/mixture_of_agents_tool.py b/tools/mixture_of_agents_tool.py index a34e99aa8f7..35f9fc003f0 100644 --- a/tools/mixture_of_agents_tool.py +++ b/tools/mixture_of_agents_tool.py @@ -54,6 +54,7 @@ from typing import Dict, Any, List, Optional from tools.openrouter_client import get_async_client as _get_openrouter_client, check_api_key as check_openrouter_api_key from agent.auxiliary_client import extract_content_or_reasoning from tools.debug_helpers import DebugSession +import sys logger = logging.getLogger(__name__) @@ -451,7 +452,7 @@ if __name__ == "__main__": print("❌ OPENROUTER_API_KEY environment variable not set") print("Please set your API key: export OPENROUTER_API_KEY='your-key-here'") print("Get API key at: https://openrouter.ai/") - exit(1) + sys.exit(1) else: print("✅ OpenRouter API key found") diff --git a/tools/osv_check.py b/tools/osv_check.py index 52458fdd32a..e094b272104 100644 --- a/tools/osv_check.py +++ b/tools/osv_check.py @@ -65,9 +65,9 @@ def check_package_for_malware( def _infer_ecosystem(command: str) -> Optional[str]: """Infer package ecosystem from the command name.""" base = os.path.basename(command).lower() - if base in ("npx", "npx.cmd"): + if base in {"npx", "npx.cmd"}: return "npm" - if base in ("uvx", "uvx.cmd", "pipx"): + if base in {"uvx", "uvx.cmd", "pipx"}: return "PyPI" return None diff --git a/tools/patch_parser.py b/tools/patch_parser.py index d2a298fc9f8..dacc6e855c3 100644 --- a/tools/patch_parser.py +++ b/tools/patch_parser.py @@ -263,7 +263,7 @@ def _validate_operations( simulated = read_result.content for hunk in op.hunks: - search_lines = [l.content for l in hunk.lines if l.prefix in (' ', '-')] + search_lines = [l.content for l in hunk.lines if l.prefix in {' ', '-'}] if not search_lines: # Addition-only hunk: validate context hint uniqueness if hunk.context_hint: @@ -282,7 +282,7 @@ def _validate_operations( continue search_pattern = '\n'.join(search_lines) - replace_lines = [l.content for l in hunk.lines if l.prefix in (' ', '+')] + replace_lines = [l.content for l in hunk.lines if l.prefix in {' ', '+'}] replacement = '\n'.join(replace_lines) new_simulated, count, _strategy, match_error = fuzzy_find_and_replace( diff --git a/tools/process_registry.py b/tools/process_registry.py index 260ba4739fd..8bbe1f56b7c 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -1237,7 +1237,7 @@ class ProcessRegistry: killed = 0 for session in targets: result = self.kill_process(session.id) - if result.get("status") in ("killed", "already_exited"): + if result.get("status") in {"killed", "already_exited"}: killed += 1 return killed @@ -1446,7 +1446,7 @@ def _handle_process(args, **kw): if action == "list": return json.dumps({"processes": process_registry.list_sessions(task_id=task_id)}, ensure_ascii=False) - elif action in ("poll", "log", "wait", "kill", "write", "submit", "close"): + elif action in {"poll", "log", "wait", "kill", "write", "submit", "close"}: if not session_id: return tool_error(f"session_id is required for {action}") if action == "poll": diff --git a/tools/rl_training_tool.py b/tools/rl_training_tool.py index d2a5c3bfbb5..c7acb8012e1 100644 --- a/tools/rl_training_tool.py +++ b/tools/rl_training_tool.py @@ -919,7 +919,7 @@ async def rl_stop_training(run_id: str) -> str: run_state = _active_runs[run_id] - if run_state.status not in ("running", "starting"): + if run_state.status not in {"running", "starting"}: return json.dumps({ "message": f"Run '{run_id}' is not running (status: {run_state.status})", }, indent=2) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 785b42a3d9f..c8d84fdf213 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -1034,7 +1034,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non filename=os.path.basename(media_path), ) async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Discord forum thread creation error ({resp.status}): {body}") data = await resp.json() @@ -1052,7 +1052,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non }, **_req_kw, ) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Discord forum thread creation error ({resp.status}): {body}") data = await resp.json() @@ -1076,7 +1076,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non # Send text message (skip if empty and media is present) if message.strip() or not media_files: async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Discord API error ({resp.status}): {body}") last_data = await resp.json() @@ -1094,7 +1094,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non with open(media_path, "rb") as f: form.add_field("files[0]", f, filename=filename) async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}") logger.error(warning) @@ -1457,7 +1457,7 @@ async def _send_mattermost(token, extra, chat_id, message): headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: async with session.post(url, headers=headers, json={"channel_id": chat_id, "message": message}) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Mattermost API error ({resp.status}): {body}") data = await resp.json() @@ -1501,7 +1501,7 @@ async def _send_matrix(token, extra, chat_id, message): async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: async with session.put(url, headers=headers, json=payload) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Matrix API error ({resp.status}): {body}") data = await resp.json() @@ -1585,7 +1585,7 @@ async def _send_homeassistant(token, extra, chat_id, message): headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: async with session.post(url, headers=headers, json={"message": message, "target": chat_id}) as resp: - if resp.status not in (200, 201): + if resp.status not in {200, 201}: body = await resp.text() return _error(f"Home Assistant API error ({resp.status}): {body}") return {"success": True, "platform": "homeassistant", "chat_id": chat_id} @@ -1757,7 +1757,20 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No def _check_send_message(): - """Gate send_message on gateway running (always available on messaging platforms).""" + """Gate send_message on gateway running (always available on messaging platforms). + + Also passes for kanban workers — the dispatcher sets ``HERMES_KANBAN_TASK`` + on every spawned worker, but those workers run with the assignee profile's + ``HERMES_HOME`` which has no ``gateway.pid``, so the gateway-running check + would fail even though the parent gateway is alive. Honoring the env var + lets workers call ``send_message`` to deliver rich content directly to the + originating chat (paired with ``kanban_complete`` for the short notifier + summary), which is the canonical pattern for any worker that needs to + reply with more than the ~200-char first-line truncation the kanban + notifier applies. + """ + if os.environ.get("HERMES_KANBAN_TASK"): + return True from gateway.session_context import get_session_env platform = get_session_env("HERMES_SESSION_PLATFORM", "") if platform and platform != "local": @@ -1814,7 +1827,7 @@ async def _send_qqbot(pconfig, chat_id, message): # Try channel endpoint first (works for guild channels) url = f"https://api.sgroup.qq.com/channels/{chat_id}/messages" resp = await client.post(url, json=payload, headers=headers) - if resp.status_code in (200, 201): + if resp.status_code in {200, 201}: data = resp.json() return {"success": True, "platform": "qqbot", "chat_id": chat_id, "message_id": data.get("id")} @@ -1822,7 +1835,7 @@ async def _send_qqbot(pconfig, chat_id, message): # If channel endpoint failed (likely "频道不存在"), try C2C endpoint url_c2c = f"https://api.sgroup.qq.com/v2/users/{chat_id}/messages" resp_c2c = await client.post(url_c2c, json=payload, headers=headers) - if resp_c2c.status_code in (200, 201): + if resp_c2c.status_code in {200, 201}: data = resp_c2c.json() return {"success": True, "platform": "qqbot", "chat_id": chat_id, "message_id": data.get("id")} @@ -1830,7 +1843,7 @@ async def _send_qqbot(pconfig, chat_id, message): # If C2C also failed, try group endpoint url_group = f"https://api.sgroup.qq.com/v2/groups/{chat_id}/messages" resp_group = await client.post(url_group, json=payload, headers=headers) - if resp_group.status_code in (200, 201): + if resp_group.status_code in {200, 201}: data = resp_group.json() return {"success": True, "platform": "qqbot", "chat_id": chat_id, "message_id": data.get("id")} diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index d253cd2a7cd..caa30f321c6 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -780,7 +780,7 @@ def skill_manage( if action == "create": if is_background_review(): mark_agent_created(name) - elif action in ("patch", "edit", "write_file", "remove_file"): + elif action in {"patch", "edit", "write_file", "remove_file"}: bump_patch(name) elif action == "delete": forget(name) diff --git a/tools/skills_guard.py b/tools/skills_guard.py index ffb965b5212..363e983da1a 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -814,7 +814,7 @@ def _check_structure(skill_dir: Path) -> List[Finding]: )) # Executable permission on non-script files - if ext not in ('.sh', '.bash', '.py', '.rb', '.pl') and f.stat().st_mode & 0o111: + if ext not in {'.sh', '.bash', '.py', '.rb', '.pl'} and f.stat().st_mode & 0o111: findings.append(Finding( pattern_id="unexpected_executable", severity="medium", @@ -928,5 +928,5 @@ def _build_summary(name: str, source: str, trust: str, verdict: str, findings: L if not findings: return f"{name}: clean scan, no threats detected" - categories = set(f.category for f in findings) + categories = {f.category for f in findings} return f"{name}: {verdict} — {len(findings)} finding(s) in {', '.join(sorted(categories))}" diff --git a/tools/skills_hub.py b/tools/skills_hub.py index c070a7de5f9..3e2c27c338a 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -101,7 +101,7 @@ def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bo normalized = raw.replace("\\", "/") path = PurePosixPath(normalized) - parts = [part for part in path.parts if part not in ("", ".")] + parts = [part for part in path.parts if part not in {"", "."}] if normalized.startswith("/") or path.is_absolute(): raise ValueError(f"Unsafe {field_name}: {path_value}") @@ -1415,7 +1415,7 @@ class SkillsShSource(SkillSource): dir_name = entry["name"] if dir_name.startswith((".", "_")): continue - if dir_name in ("skills", ".agents", ".claude"): + if dir_name in {"skills", ".agents", ".claude"}: continue # already tried # Try direct: repo/dir/skill_token direct_id = f"{repo}/{dir_name}/{skill_token}" diff --git a/tools/skills_sync.py b/tools/skills_sync.py index 98cd85c3940..0c65b6281c7 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -345,7 +345,7 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict: manifest = _read_manifest() bundled_dir = _get_bundled_dir() bundled_skills = _discover_bundled_skills(bundled_dir) - bundled_by_name = {skill_name: skill_dir for skill_name, skill_dir in bundled_skills} + bundled_by_name = dict(bundled_skills) in_manifest = name in manifest is_bundled = name in bundled_by_name diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 5da340c86b4..32296729fe2 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -721,7 +721,7 @@ def skills_list(category: str = None, task_id: str = None) -> str: # Extract unique categories categories = sorted( - set(s.get("category") for s in all_skills if s.get("category")) + {s.get("category") for s in all_skills if s.get("category")} ) return json.dumps( @@ -1133,7 +1133,7 @@ def skill_view( available_files["assets"].append(rel) elif rel.startswith("scripts/"): available_files["scripts"].append(rel) - elif f.suffix in [ + elif f.suffix in { ".md", ".py", ".yaml", @@ -1141,7 +1141,7 @@ def skill_view( ".json", ".tex", ".sh", - ]: + }: available_files["other"].append(rel) # Remove empty categories diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 5d6b80c1bc4..4d8512c345e 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -139,7 +139,7 @@ def _check_vercel_sandbox_requirements(config: dict[str, Any]) -> bool: return False disk = config.get("container_disk", 51200) - if disk not in (0, 51200): + if disk not in {0, 51200}: logger.error( "Vercel Sandbox does not support custom TERMINAL_CONTAINER_DISK=%s. " "Use the default shared setting (51200 MB).", @@ -416,7 +416,7 @@ def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str: chars = [] while True: c = msvcrt.getwch() - if c in ("\r", "\n"): + if c in {"\r", "\n"}: break if c == "\x03": raise KeyboardInterrupt @@ -432,7 +432,7 @@ def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str: chars = [] while True: b = os.read(tty_fd, 1) - if not b or b in (b"\n", b"\r"): + if not b or b in {b"\n", b"\r"}: break chars.append(b) result["password"] = b"".join(chars).decode("utf-8", errors="replace") @@ -707,7 +707,7 @@ def _rewrite_compound_background(command: str) -> str: continue # Quoted tokens — consume whole string via the shared tokenizer. - if ch in ("'", '"'): + if ch in {"'", '"'}: _, next_i = _read_shell_token(command, i) i = max(next_i, i + 1) continue @@ -888,6 +888,7 @@ from tools.environments.docker import DockerEnvironment as _DockerEnvironment from tools.environments.modal import ModalEnvironment as _ModalEnvironment from tools.environments.managed_modal import ManagedModalEnvironment as _ManagedModalEnvironment from tools.managed_tool_gateway import is_managed_tool_gateway_ready +import sys # Tool description for LLM @@ -1009,7 +1010,7 @@ def _get_env_config() -> Dict[str, Any]: default_image = "nikolaik/python-nodejs:python3.11-nodejs20" env_type = os.getenv("TERMINAL_ENV", "local") - mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in ("true", "1", "yes") + mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"} # Default cwd: local uses the host's current directory, ssh uses the # remote home, Vercel uses its documented workspace root, and everything @@ -1041,7 +1042,7 @@ def _get_env_config() -> Dict[str, Any]: ): host_cwd = candidate cwd = "/workspace" - elif env_type in ("modal", "docker", "singularity", "daytona", "vercel_sandbox") and cwd: + elif env_type in {"modal", "docker", "singularity", "daytona", "vercel_sandbox"} and cwd: # Host paths and relative paths that won't work inside containers is_host_path = any(cwd.startswith(p) for p in host_prefixes) is_relative = not os.path.isabs(cwd) # e.g. "." or "src/" @@ -1076,17 +1077,18 @@ def _get_env_config() -> Dict[str, Any]: "ssh_persistent": os.getenv( "TERMINAL_SSH_PERSISTENT", os.getenv("TERMINAL_PERSISTENT_SHELL", "true"), - ).lower() in ("true", "1", "yes"), - "local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in ("true", "1", "yes"), + ).lower() in {"true", "1", "yes"}, + "local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"}, # Container resource config (applies to docker, singularity, modal, # daytona, and vercel_sandbox -- ignored for local/ssh) "container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"), "container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB) "container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB) - "container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("true", "1", "yes"), + "container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"}, "docker_volumes": _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON"), "docker_env": _parse_env_var("TERMINAL_DOCKER_ENV", "{}", json.loads, "valid JSON"), - "docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in ("true", "1", "yes"), + "docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"}, + "docker_extra_args": _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON"), } @@ -1129,6 +1131,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, volumes = cc.get("docker_volumes", []) docker_forward_env = cc.get("docker_forward_env", []) docker_env = cc.get("docker_env", {}) + docker_extra_args = cc.get("docker_extra_args", []) if env_type == "local": return _LocalEnvironment(cwd=cwd, timeout=timeout) @@ -1144,6 +1147,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, forward_env=docker_forward_env, env=docker_env, run_as_host_user=cc.get("docker_run_as_host_user", False), + extra_args=docker_extra_args, ) elif env_type == "singularity": @@ -1779,7 +1783,7 @@ def terminal_tool( } container_config = None - if env_type in ("docker", "singularity", "modal", "daytona", "vercel_sandbox"): + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), @@ -1792,6 +1796,7 @@ def terminal_tool( "docker_forward_env": config.get("docker_forward_env", []), "docker_env": config.get("docker_env", {}), "docker_run_as_host_user": config.get("docker_run_as_host_user", False), + "docker_extra_args": config.get("docker_extra_args", []), } local_config = None @@ -2239,7 +2244,7 @@ if __name__ == "__main__": if not check_terminal_requirements(): print("\n❌ Requirements not met. Please check the messages above.") - exit(1) + sys.exit(1) print("\n✅ All requirements met!") print("\nAvailable Tool:") diff --git a/tools/tirith_security.py b/tools/tirith_security.py index bad94c96f7f..350265d33a1 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -52,7 +52,7 @@ def _env_bool(key: str, default: bool) -> bool: val = os.getenv(key) if val is None: return default - return val.lower() in ("1", "true", "yes") + return val.lower() in {"1", "true", "yes"} def _env_int(key: str, default: int) -> int: @@ -189,14 +189,14 @@ def _detect_target() -> str | None: # Android (Termux) is ABI-compatible with Linux — reuse Linux binaries. if system == "Darwin": plat = "apple-darwin" - elif system in ("Linux", "Android"): + elif system in {"Linux", "Android"}: plat = "unknown-linux-gnu" else: return None - if machine in ("x86_64", "amd64"): + if machine in {"x86_64", "amd64"}: arch = "x86_64" - elif machine in ("aarch64", "arm64"): + elif machine in {"aarch64", "arm64"}: arch = "aarch64" else: return None diff --git a/tools/todo_tool.py b/tools/todo_tool.py index b0d38a23426..99d9ffe8515 100644 --- a/tools/todo_tool.py +++ b/tools/todo_tool.py @@ -109,7 +109,7 @@ class TodoStore: # cause the model to re-do finished work after compression. active_items = [ item for item in self._items - if item["status"] in ("pending", "in_progress") + if item["status"] in {"pending", "in_progress"} ] if not active_items: return None diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 7a190081a10..95958fd1833 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -466,13 +466,12 @@ def _shell_quote_context(command_template: str, position: int) -> Optional[str]: escaped = True elif char == '"': quote = None - else: - if char == "'": - quote = "'" - elif char == '"': - quote = '"' - elif char == "\\": - i += 1 + elif char == "'": + quote = "'" + elif char == '"': + quote = '"' + elif char == "\\": + i += 1 i += 1 return quote @@ -849,13 +848,13 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] OpenAIClient = _import_openai_client() client = OpenAIClient(api_key=api_key, base_url=base_url) try: - create_kwargs = dict( - model=model, - voice=voice, - input=text, - response_format=response_format, - extra_headers={"x-idempotency-key": str(uuid.uuid4())}, - ) + create_kwargs = { + "model": model, + "voice": voice, + "input": text, + "response_format": response_format, + "extra_headers": {"x-idempotency-key": str(uuid.uuid4())}, + } if speed != 1.0: create_kwargs["speed"] = max(0.25, min(4.0, speed)) response = client.audio.speech.create(**create_kwargs) @@ -1613,7 +1612,7 @@ def text_to_speech_tool( file_path = out_dir / f"tts_{timestamp}.{fmt}" # Use .ogg for Telegram with providers that support native Opus output, # otherwise fall back to .mp3 (Edge TTS will attempt ffmpeg conversion later). - elif want_opus and provider in ("openai", "elevenlabs", "mistral", "gemini"): + elif want_opus and provider in {"openai", "elevenlabs", "mistral", "gemini"}: file_path = out_dir / f"tts_{timestamp}.ogg" else: file_path = out_dir / f"tts_{timestamp}.mp3" @@ -1763,12 +1762,12 @@ def text_to_speech_tool( if opus_path: file_str = opus_path voice_compatible = file_str.endswith(".ogg") - elif provider in ("edge", "neutts", "minimax", "xai", "kittentts", "piper") and not file_str.endswith(".ogg"): + elif provider in {"edge", "neutts", "minimax", "xai", "kittentts", "piper"} and not file_str.endswith(".ogg"): opus_path = _convert_to_opus(file_str) if opus_path: file_str = opus_path voice_compatible = True - elif provider in ("elevenlabs", "openai", "mistral", "gemini"): + elif provider in {"elevenlabs", "openai", "mistral", "gemini"}: voice_compatible = file_str.endswith(".ogg") file_size = os.path.getsize(file_str) diff --git a/tools/url_safety.py b/tools/url_safety.py index 723b1b0c7c3..743510b2757 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -96,10 +96,10 @@ def _global_allow_private_urls() -> bool: # 1. Env var override (highest priority) env_val = os.getenv("HERMES_ALLOW_PRIVATE_URLS", "").strip().lower() - if env_val in ("true", "1", "yes"): + if env_val in {"true", "1", "yes"}: _cached_allow_private = True return _cached_allow_private - if env_val in ("false", "0", "no"): + if env_val in {"false", "0", "no"}: # Explicit false — don't fall through to config return _cached_allow_private diff --git a/tools/vision_tools.py b/tools/vision_tools.py index d8c6f64f021..912777e2e25 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -41,6 +41,7 @@ from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning from hermes_constants import get_hermes_dir from tools.debug_helpers import DebugSession from tools.website_policy import check_website_access +import sys logger = logging.getLogger(__name__) @@ -346,7 +347,7 @@ def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None, data_url = _image_to_base64_data_url(image_path, mime_type=mime_type) return data_url # fall through to size-check in caller # Convert RGBA to RGB for JPEG output - if pil_format == "JPEG" and img.mode in ("RGBA", "P"): + if pil_format == "JPEG" and img.mode in {"RGBA", "P"}: img = img.convert("RGB") # Strategy: halve dimensions until base64 fits, up to 4 rounds. @@ -937,7 +938,7 @@ if __name__ == "__main__": if not api_available: print("❌ No auxiliary vision model available") print("Configure a supported multimodal backend (OpenRouter, Nous, Codex, Anthropic, or a custom OpenAI-compatible endpoint).") - exit(1) + sys.exit(1) else: print("✅ Vision model available") diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 6166ade2a3f..238fed4b289 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -456,8 +456,7 @@ class AudioRecorder: # Compute RMS for level display and silence detection rms = int(np.sqrt(np.mean(indata.astype(np.float64) ** 2))) self._current_rms = rms - if rms > self._peak_rms: - self._peak_rms = rms + self._peak_rms = max(self._peak_rms, rms) # Silence detection if self._on_silence_stop is not None: diff --git a/tools/web_tools.py b/tools/web_tools.py index 687a06f7464..ba14b07a41c 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -100,6 +100,7 @@ from tools.managed_tool_gateway import ( from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway from tools.url_safety import is_safe_url from tools.website_policy import check_website_access +import sys logger = logging.getLogger(__name__) @@ -126,7 +127,7 @@ def _get_backend() -> str: keys manually without running setup. """ configured = (_load_web_config().get("backend") or "").lower().strip() - if configured in ("parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs"): + if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs"}: return configured # Fallback for manual / legacy config — pick the highest-priority @@ -1074,7 +1075,7 @@ def _parallel_search(query: str, limit: int = 5) -> dict: return {"error": "Interrupted", "success": False} mode = os.getenv("PARALLEL_SEARCH_MODE", "agentic").lower().strip() - if mode not in ("fast", "one-shot", "agentic"): + if mode not in {"fast", "one-shot", "agentic"}: mode = "agentic" logger.info("Parallel search: '%s' (mode=%s, limit=%d)", query, mode, limit) @@ -1397,7 +1398,7 @@ async def web_extract_tool( "include_images": False, }) results = _normalize_tavily_documents(raw, fallback_url=safe_urls[0] if safe_urls else "") - elif backend in ("searxng", "brave-free", "ddgs"): + elif backend in {"searxng", "brave-free", "ddgs"}: # These backends are search-only — they cannot extract URL content _label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend] return json.dumps({ @@ -1781,7 +1782,7 @@ async def web_crawl_tool( return cleaned_result # SearXNG / Brave Search (free tier) / DuckDuckGo (ddgs) are search-only — they cannot crawl - if backend in ("searxng", "brave-free", "ddgs"): + if backend in {"searxng", "brave-free", "ddgs"}: _label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend] return json.dumps({ "error": f"{_label} is a search-only backend and cannot crawl URLs. " @@ -2084,7 +2085,7 @@ def check_firecrawl_api_key() -> bool: def check_web_api_key() -> bool: """Check whether the configured web backend is available.""" configured = _load_web_config().get("backend", "").lower().strip() - if configured in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs"): + if configured in {"exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs"}: return _is_backend_available(configured) return any( _is_backend_available(backend) @@ -2130,15 +2131,14 @@ if __name__ == "__main__": print(" Using Brave Search free tier (search only)") elif backend == "ddgs": print(" Using DuckDuckGo via ddgs package (search only)") + elif firecrawl_url_available: + print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}") + elif firecrawl_key_available: + print(" Using direct Firecrawl cloud API") + elif tool_gateway_available: + print(f" Using Firecrawl tool-gateway: {_get_firecrawl_gateway_url()}") else: - if firecrawl_url_available: - print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}") - elif firecrawl_key_available: - print(" Using direct Firecrawl cloud API") - elif tool_gateway_available: - print(f" Using Firecrawl tool-gateway: {_get_firecrawl_gateway_url()}") - else: - print(" Firecrawl backend selected but not configured") + print(" Firecrawl backend selected but not configured") else: print("❌ No web search backend configured") print( @@ -2154,7 +2154,7 @@ if __name__ == "__main__": print(f"✅ Auxiliary model available: {default_summarizer_model}") if not web_available: - exit(1) + sys.exit(1) print("🛠️ Web tools ready for use!") diff --git a/tools/yuanbao_tools.py b/tools/yuanbao_tools.py index e12307b85e0..6466458d34f 100644 --- a/tools/yuanbao_tools.py +++ b/tools/yuanbao_tools.py @@ -122,7 +122,7 @@ async def query_group_members( hint = {"mention_hint": MENTION_HINT} if mention else {} if action == "list_bots": - bots = [m for m in all_members if m["role"] in ("yuanbao_ai", "bot")] + bots = [m for m in all_members if m["role"] in {"yuanbao_ai", "bot"}] if not bots: return {"success": False, "error": "No bots found in this group."} return { diff --git a/toolsets.py b/toolsets.py index 11114908a48..5e34a0548c8 100644 --- a/toolsets.py +++ b/toolsets.py @@ -61,10 +61,13 @@ _HERMES_CORE_TOOLS = [ # Home Assistant smart home control (gated on HASS_TOKEN via check_fn) "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", # Kanban multi-agent coordination — only in schema when the agent is - # spawned as a kanban worker (HERMES_KANBAN_TASK env set), otherwise - # zero schema footprint. Gated via check_fn in tools/kanban_tools.py. - "kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat", + # spawned as a kanban worker (HERMES_KANBAN_TASK env set) or the current + # profile explicitly enables the kanban toolset. Gated via check_fn in + # tools/kanban_tools.py. + "kanban_show", "kanban_list", + "kanban_complete", "kanban_block", "kanban_heartbeat", "kanban_comment", "kanban_create", "kanban_link", + "kanban_unblock", # Computer use (macOS, gated on cua-driver being installed via check_fn) "computer_use", ] @@ -233,12 +236,13 @@ TOOLSETS = { "`kanban.dispatch_in_gateway` in config.yaml. Lets workers mark " "tasks done with structured handoffs, block for human input, " "heartbeat during long ops, comment on threads, and (for " - "orchestrators) fan out into child tasks." + "orchestrators) list, unblock, and fan out tasks." ), "tools": [ - "kanban_show", "kanban_complete", "kanban_block", + "kanban_show", "kanban_list", "kanban_complete", "kanban_block", "kanban_heartbeat", "kanban_comment", "kanban_create", "kanban_link", + "kanban_unblock", ], "includes": [], }, diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index 12d53c6d2e5..0400a3fcbff 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -9,7 +9,7 @@ if _src_root and _src_root not in sys.path: sys.path.insert(0, _src_root) # Strip '' and '.' — both resolve to CWD at import time and can let a local # directory shadow installed packages. -sys.path = [p for p in sys.path if p not in ("", ".")] +sys.path = [p for p in sys.path if p not in {"", "."}] import json import signal diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 3c3a105de78..1aa355c0a41 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1711,27 +1711,27 @@ def _on_tool_progress( def _agent_cbs(sid: str) -> dict: - return dict( - tool_start_callback=lambda tc_id, name, args: _on_tool_start( + return { + "tool_start_callback": lambda tc_id, name, args: _on_tool_start( sid, tc_id, name, args ), - tool_complete_callback=lambda tc_id, name, args, result: _on_tool_complete( + "tool_complete_callback": lambda tc_id, name, args, result: _on_tool_complete( sid, tc_id, name, args, result ), - tool_progress_callback=lambda event_type, name=None, preview=None, args=None, **kwargs: _on_tool_progress( + "tool_progress_callback": lambda event_type, name=None, preview=None, args=None, **kwargs: _on_tool_progress( sid, event_type, name, preview, args, **kwargs ), - tool_gen_callback=lambda name: _tool_progress_enabled(sid) + "tool_gen_callback": lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), - thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}), - reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}), - status_callback=lambda kind, text=None: _status_update( + "thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}), + "reasoning_callback": lambda text: _emit("reasoning.delta", sid, {"text": text}), + "status_callback": lambda kind, text=None: _status_update( sid, str(kind), None if text is None else str(text) ), - clarify_callback=lambda q, c: _block( + "clarify_callback": lambda q, c: _block( "clarify.request", sid, {"question": q, "choices": c} ), - ) + } def _wire_callbacks(sid: str): @@ -1793,7 +1793,7 @@ def _available_personalities(cfg: dict | None = None) -> dict: def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str]: raw = str(value or "").strip() name = raw.lower() - if not name or name in ("none", "default", "neutral"): + if not name or name in {"none", "default", "neutral"}: return "", "" personalities = _available_personalities(cfg) @@ -2338,7 +2338,7 @@ def _history_to_messages(history: list[dict]) -> list[dict]: if not isinstance(m, dict): continue role = m.get("role") - if role not in ("user", "assistant", "tool", "system"): + if role not in {"user", "assistant", "tool", "system"}: continue content_text = _coerce_message_text(m.get("content")) if role == "assistant" and m.get("tool_calls"): @@ -2847,7 +2847,7 @@ def _(rid, params: dict) -> dict: removed = 0 with session["history_lock"]: history = session.get("history", []) - while history and history[-1].get("role") in ("assistant", "tool"): + while history and history[-1].get("role") in {"assistant", "tool"}: history.pop() removed += 1 if history and history[-1].get("role") == "user": @@ -4172,7 +4172,7 @@ def _(rid, params: dict) -> dict: {"key": key, "value": "fast" if current_fast else "normal"}, ) - if raw in ("", "toggle"): + if raw in {"", "toggle"}: nv = "normal" if current_fast else "fast" elif raw in {"fast", "on"}: nv = "fast" @@ -4220,7 +4220,7 @@ def _(rid, params: dict) -> dict: if key == "busy": raw = str(value or "").strip().lower() - if raw in ("", "status"): + if raw in {"", "status"}: return _ok(rid, {"key": key, "value": _load_busy_input_mode()}) if raw not in {"queue", "steer", "interrupt"}: return _err(rid, 4002, f"unknown busy mode: {value}") @@ -4285,7 +4285,7 @@ def _(rid, params: dict) -> dict: from hermes_constants import parse_reasoning_effort arg = str(value or "").strip().lower() - if arg in ("show", "on"): + if arg in {"show", "on"}: cfg = _load_cfg() display = ( cfg.get("display") if isinstance(cfg.get("display"), dict) else {} @@ -4303,7 +4303,7 @@ def _(rid, params: dict) -> dict: if session: session["show_reasoning"] = True return _ok(rid, {"key": key, "value": "show"}) - if arg in ("hide", "off"): + if arg in {"hide", "off"}: cfg = _load_cfg() display = ( cfg.get("display") if isinstance(cfg.get("display"), dict) else {} @@ -4398,7 +4398,7 @@ def _(rid, params: dict) -> dict: cfg0 = _load_cfg() d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} cur_b = bool(d0.get("tui_compact", False)) - if raw in ("", "toggle"): + if raw in {"", "toggle"}: nv_b = not cur_b elif raw == "on": nv_b = True @@ -4415,7 +4415,7 @@ def _(rid, params: dict) -> dict: d0 = display if isinstance(display, dict) else {} current = _coerce_statusbar(d0.get("tui_statusbar", "top")) - if raw in ("", "toggle"): + if raw in {"", "toggle"}: nv = "top" if current == "off" else "off" elif raw == "on": nv = "top" @@ -4433,7 +4433,7 @@ def _(rid, params: dict) -> dict: display = cfg.get("display") if isinstance(cfg.get("display"), dict) else {} current = _display_mouse_tracking(display) - if raw in ("", "toggle"): + if raw in {"", "toggle"}: nv = not current elif raw == "on": nv = True @@ -4459,7 +4459,7 @@ def _(rid, params: dict) -> dict: _write_config_key("display.tui_status_indicator", raw) return _ok(rid, {"key": key, "value": raw}) - if key in ("cwd", "terminal.cwd", "workdir"): + if key in {"cwd", "terminal.cwd", "workdir"}: raw = str(value or "").strip() if not raw: return _err(rid, 4002, "cwd required") @@ -4473,7 +4473,7 @@ def _(rid, params: dict) -> dict: {"key": "terminal.cwd", "value": cwd, "cwd": cwd, "branch": _git_branch_for_cwd(cwd)}, ) - if key in ("prompt", "personality", "skin"): + if key in {"prompt", "personality", "skin"}: try: cfg = _load_cfg() if key == "prompt": @@ -5073,7 +5073,7 @@ def _(rid, params: dict) -> dict: # In the TUI the slash worker subprocess has no reader for that queue, # so we handle them here and return a structured payload. - if name in ("queue", "q"): + if name in {"queue", "q"}: if not arg: return _err(rid, 4004, "usage: /queue <prompt>") return _ok(rid, {"type": "send", "message": arg}) @@ -5172,7 +5172,7 @@ def _(rid, params: dict) -> dict: ), }, ) - if lower in ("clear", "stop", "done"): + if lower in {"clear", "stop", "done"}: had = mgr.has_goal() mgr.clear() return _ok( @@ -5203,7 +5203,7 @@ def _(rid, params: dict) -> dict: {"type": "send", "notice": notice, "message": state.goal}, ) - if name in ("snapshot", "snap"): + if name in {"snapshot", "snap"}: subcommand = arg.split(maxsplit=1)[0].lower() if arg else "" if subcommand in {"restore", "rewind"}: return _ok( @@ -5449,7 +5449,7 @@ def _(rid, params: dict) -> dict: # Accept both `@folder:path` and the bare `@folder` form so the user # sees directory listings as soon as they finish typing the keyword, # without first accepting the static `@folder:` hint. - if is_context and query in ("file", "folder"): + if is_context and query in {"file", "folder"}: prefix_tag, path_part = query, "" elif is_context and query.startswith(("file:", "folder:")): prefix_tag, _, tail = query.partition(":") @@ -6195,7 +6195,7 @@ def _(rid, params: dict) -> dict: return _ok(rid, payload) - if action in ("on", "off"): + if action in {"on", "off"}: enabled = action == "on" # Runtime-only flag (CLI parity) — no _write_config_key, so the # next TUI launch starts with voice OFF instead of auto-REC from a @@ -6428,7 +6428,7 @@ def _(rid, params: dict) -> dict: removed = 0 with session["history_lock"]: history = session.get("history", []) - while history and history[-1].get("role") in ("assistant", "tool"): + while history and history[-1].get("role") in {"assistant", "tool"}: history.pop() removed += 1 if history and history[-1].get("role") == "user": @@ -6986,7 +6986,7 @@ def _(rid, params: dict) -> dict: ) ), ) - if action in ("remove", "pause", "resume"): + if action in {"remove", "pause", "resume"}: return _ok(rid, json.loads(cronjob(action=action, job_id=jid))) return _err(rid, 4016, f"unknown cron action: {action}") except Exception as e: diff --git a/ui-tui/src/__tests__/markdown.test.ts b/ui-tui/src/__tests__/markdown.test.ts index 30706f6b09d..716a2bbc093 100644 --- a/ui-tui/src/__tests__/markdown.test.ts +++ b/ui-tui/src/__tests__/markdown.test.ts @@ -217,3 +217,50 @@ describe('Md wrapping', () => { expect(lines.some(line => line.startsWith(' hi ok'))).toBe(true) }) }) + +describe('renderTable CJK width alignment', () => { + it('column starts share the same display offset across CJK rows', async () => { + const { stringWidth } = await import('@hermes/ink') + + const md = [ + '| 配置 | Config | 状态 |', + '|------|--------|------|', + '| Vicuna (report) | dense | × |', + '| ChatGLM | chat | ✓ |', + '| 通义千问 | qwen | × |' + ].join('\n') + + // Pre-fix bug: ` `.repeat(w - stripInlineMarkup(...).length) used + // UTF-16 code units, so a CJK header cell padded to 2 cells while + // the body cell padded to 4, drifting subsequent columns by 2 + // cells per CJK char. + // + // Post-fix contract: the prefix preceding the start of column N + // has the same display width across the header and every body row + // (deduped to skip the divider, which renders independently). + const lines = renderPlain( + React.createElement(Box, null, React.createElement(Md, { compact: true, t: DEFAULT_THEME, text: md })) + ).filter(line => line.trim().length > 0) + + // Heuristic: a "data row" line either contains 'Config' (header) + // or one of the body labels; a divider is all box-drawing. Use + // the substring 'Config' / 'dense' / 'chat' / 'qwen' as the + // unique anchor for column 2's start position on each row. + const colStarts = (line: string, anchor: string): number => { + const idx = line.indexOf(anchor) + return idx < 0 ? -1 : stringWidth(line.slice(0, idx)) + } + + const headerCol2 = lines.map(l => colStarts(l, 'Config')).find(v => v >= 0) + const denseCol2 = lines.map(l => colStarts(l, 'dense')).find(v => v >= 0) + const chatCol2 = lines.map(l => colStarts(l, 'chat')).find(v => v >= 0) + const qwenCol2 = lines.map(l => colStarts(l, 'qwen')).find(v => v >= 0) + + expect(headerCol2).toBeDefined() + expect(denseCol2).toBe(headerCol2) + expect(chatCol2).toBe(headerCol2) + // The CJK row is the one that drifted before the fix. It must + // align with the rest now. + expect(qwenCol2).toBe(headerCol2) + }) +}) diff --git a/ui-tui/src/__tests__/textInputRightClick.test.ts b/ui-tui/src/__tests__/textInputRightClick.test.ts new file mode 100644 index 00000000000..bf37b412236 --- /dev/null +++ b/ui-tui/src/__tests__/textInputRightClick.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { decideRightClickAction } from '../components/textInput.js' + +describe('decideRightClickAction', () => { + it('returns paste when there is no selection', () => { + expect(decideRightClickAction('hello world', null)).toEqual({ action: 'paste' }) + }) + + it('returns paste for a collapsed (empty) range', () => { + expect(decideRightClickAction('hello world', { end: 5, start: 5 })).toEqual({ + action: 'paste' + }) + }) + + it('copies the slice when range covers non-empty text', () => { + expect(decideRightClickAction('hello world', { end: 5, start: 0 })).toEqual({ + action: 'copy', + text: 'hello' + }) + }) + + it('copies a middle slice', () => { + expect(decideRightClickAction('hello world', { end: 11, start: 6 })).toEqual({ + action: 'copy', + text: 'world' + }) + }) + + it('falls back to paste when slice is empty (out-of-range indices)', () => { + expect(decideRightClickAction('', { end: 5, start: 0 })).toEqual({ action: 'paste' }) + }) + + it('handles unicode (emoji, CJK) in the slice', () => { + const value = 'hi 你好 🎉' + expect(decideRightClickAction(value, { end: 5, start: 3 })).toEqual({ + action: 'copy', + text: '你好' + }) + }) + + it('preserves leading/trailing whitespace in the copied slice', () => { + expect(decideRightClickAction(' spaced ', { end: 10, start: 0 })).toEqual({ + action: 'copy', + text: ' spaced ' + }) + }) +}) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index d736af144ed..c12efb35dc7 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -1,4 +1,4 @@ -import { Box, Link, Text } from '@hermes/ink' +import { Box, Link, stringWidth, Text } from '@hermes/ink' import { Fragment, memo, type ReactNode, useMemo } from 'react' import { ensureEmojiPresentation } from '../lib/emoji.js' @@ -170,16 +170,22 @@ export const stripInlineMarkup = (v: string) => .replace(/\\\(([^\n]+?)\\\)/g, '$1') const renderTable = (k: number, rows: string[][], t: Theme) => { - const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => stripInlineMarkup(r[ci] ?? '').length))) + // Column widths in *display cells*, not UTF-16 code units. CJK + // glyphs and most emoji render as two cells but `String#length` + // counts them as one, which collapses Chinese / Japanese / Korean + // tables into drift across rows. `stringWidth` (Bun.stringWidth + // fast path + an East-Asian-width-aware fallback, memoised in + // @hermes/ink) returns the actual cell count. + const cellWidth = (raw: string) => stringWidth(stripInlineMarkup(raw)) + + const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => cellWidth(r[ci] ?? '')))) // Thin divider under the header. Without it tables look like prose // with extra spacing because the header is just accent-coloured text // (#15534). We avoid full borders on purpose — column widths come - // from `stripInlineMarkup(...).length` (UTF-16 code units, not - // display width), so a real outline often misaligns on emoji and - // East-Asian wide characters; one dim solid rule (`─`) under row 0 - // plus tab-style column gaps reads cleanly on every terminal we - // tested. + // from `stringWidth(...)`, so the dividers and the row content stay + // in sync on CJK / emoji tables; tab-style column gaps still read + // cleanly without the boxed look. const sep = widths.map(w => '─'.repeat(Math.max(1, w))).join(' ') return ( @@ -190,7 +196,7 @@ const renderTable = (k: number, rows: string[][], t: Theme) => { {widths.map((w, ci) => ( <Text bold={ri === 0} color={ri === 0 ? t.color.accent : undefined} key={ci}> <MdInline t={t} text={row[ci] ?? ''} /> - {' '.repeat(Math.max(0, w - stripInlineMarkup(row[ci] ?? '').length))} + {' '.repeat(Math.max(0, w - cellWidth(row[ci] ?? '')))} {ci < widths.length - 1 ? ' ' : ''} </Text> ))} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index d8151e72b72..0c63ceb93c8 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -970,10 +970,15 @@ export function TextInput({ return } - // Right-click → route through the same path as Alt+V so the composer - // clipboard RPC (text or image) handles it. + // Right-click → copy active selection if any, otherwise paste. if (e.button === 2) { e.stopImmediatePropagation?.() + const decision = decideRightClickAction(vRef.current, selRange()) + if (decision.action === 'copy') { + void writeClipboardText(decision.text) + + return + } emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) return @@ -1045,6 +1050,34 @@ interface TextInputProps { voiceRecordKey?: ParsedVoiceRecordKey } +export type RightClickDecision = + | { action: 'copy'; text: string } + | { action: 'paste' } + +/** + * Decide what right-click should do on the composer: + * - non-empty selection → copy that text to the clipboard + * - no selection (or empty/collapsed range) → fall through to paste + * + * Mirrors terminal-native behavior (xterm, iTerm, gnome-terminal) where + * right-click pastes only when there is nothing selected to copy. + * + * Callers pass the already-normalized range from `selRange()` (start <= end, + * or null when collapsed), so this helper does not need to re-normalize. + */ +export function decideRightClickAction( + value: string, + range: { end: number; start: number } | null +): RightClickDecision { + if (range && range.end > range.start) { + const text = value.slice(range.start, range.end) + if (text) { + return { action: 'copy', text } + } + } + return { action: 'paste' } +} + export const shouldPassThroughToGlobalHandler = ( input: string, key: Key, diff --git a/uv.lock b/uv.lock index 15156da1646..93fe3d6f0ee 100644 --- a/uv.lock +++ b/uv.lock @@ -8,10 +8,6 @@ resolution-markers = [ "python_full_version < '3.12'", ] -[options] -exclude-newer = "2026-05-01T22:46:56.926194148Z" -exclude-newer-span = "P7D" - [[package]] name = "agent-client-protocol" version = "0.9.0" @@ -1273,6 +1269,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -2014,6 +2019,7 @@ all = [ { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-split" }, { name = "pytest-xdist" }, { name = "python-telegram-bot", extra = ["webhooks"] }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, @@ -2026,6 +2032,7 @@ all = [ { name = "ty" }, { name = "uvicorn", extra = ["standard"] }, { name = "vercel" }, + { name = "youtube-transcript-api" }, ] bedrock = [ { name = "boto3" }, @@ -2044,6 +2051,7 @@ dev = [ { name = "mcp" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-split" }, { name = "pytest-xdist" }, { name = "ruff" }, { name = "ty" }, @@ -2062,6 +2070,9 @@ google = [ { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, ] +hindsight = [ + { name = "hindsight-client" }, +] homeassistant = [ { name = "aiohttp" }, ] @@ -2162,6 +2173,9 @@ web = [ yc-bench = [ { name = "yc-bench", marker = "python_full_version >= '3.12'" }, ] +youtube = [ + { name = "youtube-transcript-api" }, +] [package.metadata] requires-dist = [ @@ -2234,6 +2248,8 @@ requires-dist = [ { name = "hermes-agent", extras = ["voice"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["web"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["web"], marker = "extra == 'termux-all'" }, + { name = "hermes-agent", extras = ["youtube"], marker = "extra == 'all'" }, + { name = "hindsight-client", marker = "extra == 'hindsight'", specifier = ">=0.4.22" }, { name = "honcho-ai", marker = "extra == 'honcho'", specifier = ">=2.0.1,<3" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1" }, { name = "jinja2", specifier = ">=3.1.5,<4" }, @@ -2255,6 +2271,7 @@ requires-dist = [ { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0,<3" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2,<10" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0,<2" }, + { name = "pytest-split", marker = "extra == 'dev'", specifier = ">=0.9,<1" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.0,<4" }, { name = "python-dotenv", specifier = ">=1.2.1,<2" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = ">=22.6,<23" }, @@ -2283,8 +2300,9 @@ requires-dist = [ { name = "vercel", marker = "extra == 'vercel'", specifier = ">=0.5.7,<0.6.0" }, { name = "wandb", marker = "extra == 'rl'", specifier = ">=0.15.0,<1" }, { name = "yc-bench", marker = "python_full_version >= '3.12' and extra == 'yc-bench'", git = "https://github.com/collinear-ai/yc-bench.git?rev=bfb0c88062450f46341bd9a5298903fc2e952a5c" }, + { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = ">=1.2.0" }, ] -provides-extras = ["modal", "daytona", "vercel", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "mistral", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "web", "rl", "yc-bench", "all"] +provides-extras = ["modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "mistral", "bedrock", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "rl", "yc-bench", "all"] [[package]] name = "hf-transfer" @@ -2350,6 +2368,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/46/1ba8d36f8290a4b98f78898bdce2b0e8fe6d9a59df34a1399eb61a8d877f/hf_xet-1.3.1-cp37-abi3-win_arm64.whl", hash = "sha256:851b1be6597a87036fe7258ce7578d5df3c08176283b989c3b165f94125c5097", size = 3500490, upload-time = "2026-02-25T00:58:00.667Z" }, ] +[[package]] +name = "hindsight-client" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-retry" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/26/8b8efa4be21fc3ba12ade3b1353d87f9837ec0d3ec2607e8adbf85bc9c63/hindsight_client-0.6.1.tar.gz", hash = "sha256:314d0bb9e13622e15586ba1586a799726d405b27bc20d78872474b5d6d96cd51", size = 99833, upload-time = "2026-05-08T13:01:23.537Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/4f/a1d0bc33ef933ecc52e76dc1514163594d25836a5d303c256a61bb61445d/hindsight_client-0.6.1-py3-none-any.whl", hash = "sha256:9fdda176ab50f7cec8d7339c6608c148f0cd9ad7e65d9d76192f2db730bc330a", size = 249379, upload-time = "2026-05-08T13:01:22.035Z" }, +] + [[package]] name = "honcho-ai" version = "2.0.1" @@ -4439,6 +4474,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-split" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/16/8af4c5f2ceb3640bb1f78dfdf5c184556b10dfe9369feaaad7ff1c13f329/pytest_split-0.11.0.tar.gz", hash = "sha256:8ebdb29cc72cc962e8eb1ec07db1eeb98ab25e215ed8e3216f6b9fc7ce0ec2b5", size = 13421, upload-time = "2026-02-03T09:14:31.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/a1/d4423657caaa8be9b31e491592b49cebdcfd434d3e74512ce71f6ec39905/pytest_split-0.11.0-py3-none-any.whl", hash = "sha256:899d7c0f5730da91e2daf283860eb73b503259cb416851a65599368849c7f382", size = 11911, upload-time = "2026-02-03T09:14:33.708Z" }, +] + [[package]] name = "pytest-xdist" version = "3.8.0" @@ -6165,6 +6212,19 @@ dependencies = [ { name = "typer", marker = "python_full_version >= '3.12'" }, ] +[[package]] +name = "youtube-transcript-api" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/43/4104185a2eaa839daa693b30e15c37e7e58795e8e09ec414f22b3db54bec/youtube_transcript_api-1.2.4.tar.gz", hash = "sha256:b72d0e96a335df599d67cee51d49e143cff4f45b84bcafc202ff51291603ddcd", size = 469839, upload-time = "2026-01-29T09:09:17.088Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/95/129ea37efd6cd6ed00f62baae6543345c677810b8a3bf0026756e1d3cf3c/youtube_transcript_api-1.2.4-py3-none-any.whl", hash = "sha256:03878759356da5caf5edac77431780b91448fb3d8c21d4496015bdc8a7bc43ff", size = 485227, upload-time = "2026-01-29T09:09:15.427Z" }, +] + [[package]] name = "zipp" version = "3.23.0" diff --git a/website/docs/developer-guide/adding-platform-adapters.md b/website/docs/developer-guide/adding-platform-adapters.md index 1ba4b9a34cd..f3597dfca39 100644 --- a/website/docs/developer-guide/adding-platform-adapters.md +++ b/website/docs/developer-guide/adding-platform-adapters.md @@ -322,9 +322,98 @@ optional_env: Bare-string entries (`- MY_PLATFORM_TOKEN`) still work — they get a generic description auto-derived from the plugin's `label`. If a hardcoded entry for the same var already exists in `OPTIONAL_ENV_VARS`, it wins (back-compat); the plugin.yaml form acts as the fallback. +## Platform-Specific Slow-LLM UX + +Some platforms have constraints that change how a slow LLM response should be presented: + +- **LINE** issues a single-use *reply token* that expires roughly 60 seconds after the inbound event. Replying with that token is free; falling back to the metered Push API is not. If the LLM hasn't finished by the deadline, the choice is "burn paid Push quota" or "do something cleverer with the reply token before it expires." +- **WhatsApp** marks a session inactive after 24h, after which only template messages are accepted. +- **SMS** has no concept of typing indicators or progressive updates — long responses just look like the bot is offline. + +These are real constraints the base `BasePlatformAdapter` can't anticipate. The plugin surface intentionally leaves the room for an adapter to layer platform-specific UX on top of the base typing loop without expanding the kwarg list. + +### Pattern: subclass `_keep_typing` to layer mid-flight UX + +`BasePlatformAdapter._keep_typing` is the typing-indicator heartbeat — it runs as a background task while the LLM is generating, and is cancelled when the response is delivered. To layer a platform-specific behavior at a threshold (e.g. send a "still thinking" bubble at 45s), override `_keep_typing` in your adapter, schedule your own task alongside `super()._keep_typing()`, and tear it down in `finally`: + +```python +class LineAdapter(BasePlatformAdapter): + async def _keep_typing(self, chat_id: str, *args, **kwargs) -> None: + if self.slow_response_threshold <= 0: + await super()._keep_typing(chat_id, *args, **kwargs) + return + + async def _fire_at_threshold() -> None: + try: + await asyncio.sleep(self.slow_response_threshold) + except asyncio.CancelledError: + raise + # Platform-specific work here — for LINE, send a Template + # Buttons "Get answer" bubble using the cached reply token + # so the user can fetch the cached response later via a + # fresh (free) reply token from the postback callback. + await self._send_slow_response_button(chat_id) + + side_task = asyncio.create_task(_fire_at_threshold()) + try: + await super()._keep_typing(chat_id, *args, **kwargs) + finally: + if not side_task.done(): + side_task.cancel() + try: + await side_task + except (asyncio.CancelledError, Exception): + pass +``` + +Key points: + +- **Always `await super()._keep_typing(...)`.** The typing heartbeat is independently useful — don't replace it, layer on top of it. +- **Tear down the side task in `finally`.** When the LLM finishes (or `/stop` cancels the run), the gateway cancels the typing task. Your side task must observe that cancellation too, otherwise it lingers and may fire after the response was already delivered. +- **Pair with `interrupt_session_activity`** to resolve any orphan UX state when the user issues `/stop`. For LINE, this means transitioning the postback cache entry from `PENDING` to `ERROR` so the persistent "Get answer" button delivers a "Run was interrupted" message instead of looping. + +### Pattern: subclass `send` to route through a cache instead of sending immediately + +If your slow-response UX caches the response for later retrieval (LINE's postback flow), your `send` override needs to recognize three modes: + +1. **Pending postback active for this chat** → cache the response under the request_id, don't send anything visible. +2. **System busy-ack** (`⚡ Interrupting`, `⏳ Queued`, `⏩ Steered`) → bypass the cache and send visibly so the user sees the gateway's response to their input. +3. **Normal response** → send via reply-token-or-push as usual. + +```python +async def send(self, chat_id: str, content: str, **kw) -> SendResult: + if _is_system_bypass(content): + return await self._send_text_chunks(chat_id, content, force_push=False) + pending_rid = self._pending_buttons.get(chat_id) + if pending_rid: + self._cache.set_ready(pending_rid, content) + return SendResult(success=True, message_id=pending_rid) + return await self._send_text_chunks(chat_id, content, force_push=False) +``` + +`_SYSTEM_BYPASS_PREFIXES` are the gateway's own busy-acknowledgment prefixes (`⚡`, `⏳`, `⏩`, `💾`). Always let those through visibly, regardless of cached UX state. + +### When this pattern is appropriate + +Use the typing-loop override approach when: + +- The platform's outbound API has a hard time-window constraint (single-use reply token, expiring sticky session, etc.) AND +- A *visible mid-flight bubble* is acceptable UX on that platform. + +Use the simpler `slow_response_threshold = 0` always-Push path when: + +- The platform doesn't have a meaningful free vs. paid distinction, OR +- The user community prefers "loading… loading… DONE" silence-then-response over an interactive intermediate bubble. + +LINE supports both: the threshold defaults to 45s for free postback fetch, and `LINE_SLOW_RESPONSE_THRESHOLD=0` reverts to "always Push fallback." + ### Reference Implementation -See `plugins/platforms/irc/` in the repo for a complete working example — a full async IRC adapter with zero external dependencies. +See `plugins/platforms/line/adapter.py` for the full LINE postback implementation — a `RequestCache` state machine (`PENDING → READY → DELIVERED`, plus `ERROR` for `/stop`), a `_keep_typing` override that fires the Template Buttons bubble at threshold, a `send` override that routes through the cache, and an `interrupt_session_activity` override that resolves orphan PENDING entries. + +### Reference Implementations (Plugin Path) + +See `plugins/platforms/irc/` in the repo for a complete working example — a full async IRC adapter with zero external dependencies. `plugins/platforms/teams/` covers Bot Framework / Adaptive Cards, `plugins/platforms/google_chat/` covers OAuth-based REST APIs, and `plugins/platforms/line/` covers webhook-driven Messaging APIs with platform-specific slow-LLM UX. --- diff --git a/website/docs/developer-guide/plugin-llm-access.md b/website/docs/developer-guide/plugin-llm-access.md new file mode 100644 index 00000000000..5396e3a7a5d --- /dev/null +++ b/website/docs/developer-guide/plugin-llm-access.md @@ -0,0 +1,465 @@ +--- +sidebar_position: 11 +title: "Plugin LLM Access" +description: "Run any LLM call from inside a plugin via ctx.llm — chat or structured, sync or async. Host-owned auth, fail-closed trust gate, optional JSON Schema validation." +--- + +# Plugin LLM Access + +`ctx.llm` is the supported way for a plugin to make an LLM call. +Chat completion, structured extraction, sync, async, with or without +images — same surface, same trust gate, same host-owned credentials. + +Plugins reach for this when they need to do something that involves +the model but isn't part of the agent's conversation. A hook that +rewrites a tool error into something a non-engineer can read. A +gateway adapter that translates an inbound message before queuing +it. A slash command that summarises a long paste. A scheduled job +that scores yesterday's activity and writes one line to a status +board. A pre-filter that decides whether a message is worth waking +the agent up for at all. + +These are jobs the agent shouldn't be in the loop on. They want one +LLM call, a typed answer, and to be done. + +## The smallest possible call + +```python +result = ctx.llm.complete(messages=[{"role": "user", "content": "ping"}]) +return result.text +``` + +That's the whole API in one line. No keys, no provider config, no +SDK initialisation. The plugin runs against whatever provider and +model the user is currently using — when they switch providers, the +plugin follows them automatically. + +## A more complete chat example + +```python +result = ctx.llm.complete( + messages=[ + {"role": "system", "content": "Rewrite errors as one short sentence a non-engineer can act on."}, + {"role": "user", "content": traceback_text}, + ], + max_tokens=64, + purpose="hooks.error-rewrite", +) +return result.text +``` + +`purpose` is a free-form audit string — it shows up in `agent.log` +and in `result.audit` so operators can see which plugin made which +call. Optional but recommended for anything that fires often. + +## Structured output + +When the plugin needs a typed answer, switch to the structured lane: + +```python +result = ctx.llm.complete_structured( + instructions="Score this support reply for urgency (0–1) and pick a category.", + input=[{"type": "text", "text": message_body}], + json_schema=TRIAGE_SCHEMA, + purpose="support.triage", + temperature=0.0, + max_tokens=128, +) + +if result.parsed["urgency"] > 0.8: + await dispatch_to_oncall(result.parsed["category"], message_body) +``` + +The host requests JSON output from the provider, parses it locally +as a fallback, validates against your schema if `jsonschema` is +installed, and hands back a Python object on `result.parsed`. If the +model couldn't produce valid JSON, `result.parsed` is `None` and +`result.text` carries the raw response. + +## What this lane gives you + +* **One call, four shapes.** `complete()` for chat, + `complete_structured()` for typed JSON, `acomplete()` and + `acomplete_structured()` for asyncio. Same arguments, same result + objects. +* **Host-owned credentials.** OAuth tokens, refresh flows, the + credential pool, per-task aux overrides — every credential + concept Hermes already has applies. The plugin never sees a + token; the host attributes the call back through `result.audit`. +* **Bounded.** Single sync or async call. No streaming, no tool + loops, no conversation state to manage. State the input, get the + result, return. +* **Fail-closed trust.** A plugin you've never configured cannot + pick its own provider, model, agent, or stored credential. The + default posture is "use what the user is using." Operators opt in + to specific overrides, per plugin, in `config.yaml`. + +## Quick start + +Two complete plugins below — one chat, one structured. Both ship +inside a single `register(ctx)` function and need zero outside +configuration to run against whatever model the user has active. + +### Chat completion — `/tldr` + +```python +def register(ctx): + ctx.register_command( + name="tldr", + handler=lambda raw: _tldr(ctx, raw), + description="Summarise the supplied text in one paragraph.", + args_hint="<text>", + ) + + +def _tldr(ctx, raw_args: str) -> str: + text = raw_args.strip() + if not text: + return "Usage: /tldr <text to summarise>" + result = ctx.llm.complete( + messages=[ + {"role": "system", + "content": "Summarise the user's text in one tight paragraph. No preamble."}, + {"role": "user", "content": text}, + ], + max_tokens=256, + temperature=0.3, + purpose="tldr", + ) + return result.text +``` + +`result.text` is the model's response; `result.usage` carries token +counts; `result.provider` and `result.model` carry attribution. + +### Structured extraction — `/paste-to-tasks` + +```python +def register(ctx): + ctx.register_command( + name="paste-to-tasks", + handler=lambda raw: _paste_to_tasks(ctx, raw), + description="Turn freeform meeting notes into structured tasks.", + args_hint="<text>", + ) + + +_TASKS_SCHEMA = { + "type": "object", + "properties": { + "tasks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "owner": {"type": "string"}, + "action": {"type": "string"}, + "due": {"type": "string", "description": "ISO date or empty"}, + }, + "required": ["action"], + }, + }, + }, + "required": ["tasks"], +} + + +def _paste_to_tasks(ctx, raw_args: str) -> str: + if not raw_args.strip(): + return "Usage: /paste-to-tasks <meeting notes>" + result = ctx.llm.complete_structured( + instructions=( + "Extract concrete action items from these meeting notes. " + "One task per actionable line. If no owner is named, leave 'owner' blank." + ), + input=[{"type": "text", "text": raw_args}], + json_schema=_TASKS_SCHEMA, + schema_name="meeting.tasks", + purpose="paste-to-tasks", + temperature=0.0, + max_tokens=512, + ) + if result.parsed is None: + return f"Couldn't parse a response. Raw output:\n{result.text}" + lines = [f"- [{t.get('owner') or '?'}] {t['action']}" for t in result.parsed["tasks"]] + return "\n".join(lines) or "(no tasks found)" +``` + +A third worked example, this time with image input, lives in the +[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-example) +repo (companion repo for reference plugins — not bundled with +hermes-agent itself). For the async surface (`acomplete()` / +`acomplete_structured()` with `asyncio.gather()`), see +[`plugin-llm-async-example`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-async-example) +in the same repo. + +## When to use which + +| You want… | Reach for | +|---|---| +| A free-form text response (translation, summary, rewrite, generation) | `complete()` | +| A multi-turn prompt (system + few-shot examples + user) | `complete()` | +| A typed dict back, validated against a schema | `complete_structured()` | +| Image-or-text input with a typed dict back | `complete_structured()` | +| The same call from async code (gateway adapters, async hooks) | `acomplete()` / `acomplete_structured()` | + +Everything else — provider selection, model resolution, auth, fallback, +timeout, vision routing — is the same across all four. + +## API surface + +`ctx.llm` is an instance of `agent.plugin_llm.PluginLlm`. + +### `complete()` + +```python +result = ctx.llm.complete( + messages=[{"role": "user", "content": "Hi"}], + provider=None, # optional, gated — Hermes provider id (e.g. "openrouter") + model=None, # optional, gated — whatever string that provider expects + temperature=None, + max_tokens=None, + timeout=None, # seconds + agent_id=None, # optional, gated + profile=None, # optional, gated — explicit auth-profile name + purpose="optional-audit-string", +) +# → PluginLlmCompleteResult(text, provider, model, agent_id, usage, audit) +``` + +Plain chat completion. `messages` is the standard OpenAI shape — a +list of `{"role": "...", "content": "..."}` dicts. Multi-turn +prompts (system + few-shot user/assistant pairs + final user) work +exactly as they would with the OpenAI SDK. + +`provider=` and `model=` are independent and follow the same shape +as the host's main config (`model.provider` + `model.model`). Set +just `model=` to use the user's active provider with a different +model on it. Set both to switch providers entirely. Either argument +without operator opt-in raises `PluginLlmTrustError`. + +### `complete_structured()` + +```python +result = ctx.llm.complete_structured( + instructions="What you want extracted.", + input=[ + {"type": "text", "text": "..."}, + {"type": "image", "data": b"...", "mime_type": "image/png"}, + {"type": "image", "url": "https://..."}, + ], + json_schema={...}, # optional — triggers parsed result + validation + json_mode=False, # set True without a schema to ask for JSON anyway + schema_name=None, # optional human-readable schema name + system_prompt=None, + provider=None, # optional, gated + model=None, # optional, gated + temperature=None, + max_tokens=None, + timeout=None, + agent_id=None, + profile=None, + purpose=None, +) +# → PluginLlmStructuredResult(text, provider, model, agent_id, +# usage, parsed, content_type, audit) +``` + +Inputs are typed text or image blocks (raw bytes get base64 encoded +as a `data:` URL automatically). When `json_schema` or +`json_mode=True` is supplied, the host requests JSON output via +`response_format`, parses it locally as a fallback, and validates +against your schema if `jsonschema` is installed. + +* `result.content_type == "json"` — `result.parsed` is a Python + object that matches your schema. +* `result.content_type == "text"` — parsing or validation failed; + inspect `result.text` for the raw model response. + +### Async + +```python +result = await ctx.llm.acomplete(messages=...) +result = await ctx.llm.acomplete_structured(instructions=..., input=...) +``` + +Same arguments and result types as their sync counterparts. Use +these from gateway adapters, async hooks, or any plugin code +already running on an asyncio loop. + +### Result attributes + +```python +@dataclass +class PluginLlmCompleteResult: + text: str # the assistant's response + provider: str # e.g. "openrouter", "anthropic" + model: str # whatever the provider returned for this call + agent_id: str # whose model/auth was used + usage: PluginLlmUsage # tokens + cache + cost estimate + audit: Dict[str, Any] # plugin_id, purpose, profile + +@dataclass +class PluginLlmStructuredResult(PluginLlmCompleteResult): + parsed: Optional[Any] # JSON object when content_type == "json" + content_type: str # "json" or "text" + # audit also carries schema_name when supplied +``` + +`usage` carries `input_tokens`, `output_tokens`, `total_tokens`, +`cache_read_tokens`, `cache_write_tokens`, and `cost_usd` when the +provider returns those fields. + +## Trust gate + +The default behaviour is fail-closed. With no `plugins.entries` +config block, a plugin can: + +* run any of the four methods against the user's active provider + and model, +* set request-shaping arguments (`temperature`, `max_tokens`, + `timeout`, `system_prompt`, `purpose`, `messages`, `instructions`, + `input`, `json_schema`), + +…and that's it. `provider=`, `model=`, `agent_id=`, and `profile=` +arguments raise `PluginLlmTrustError` until the operator opts in. + +**Most plugins never need this section.** A plugin that just calls +`ctx.llm.complete(messages=...)` with no overrides runs against +whatever the user has active and works zero-config. The block below +is only relevant when a plugin specifically wants to pin to a +different model or provider than the user. + +```yaml +plugins: + entries: + my-plugin: + llm: + # Allow this plugin to choose a different Hermes provider + # (must be one Hermes already knows about — same names as + # `hermes model` and config.yaml model.provider). + allow_provider_override: true + + # Optionally restrict which providers. Use ["*"] for any. + allowed_providers: + - openrouter + - anthropic + + # Allow this plugin to ask for a specific model. + allow_model_override: true + + # Optionally restrict which models. Use ["*"] for any. + # Models are matched literally against whatever string the + # plugin sends — Hermes does not look anything up. + allowed_models: + - openai/gpt-4o-mini + - anthropic/claude-3-5-haiku + + # Allow cross-agent calls (rare). + allow_agent_id_override: false + + # Allow the plugin to request a specific stored auth profile + # (e.g. a different OAuth account on the same provider). + allow_profile_override: false +``` + +The plugin id is the manifest `name:` field for flat plugins, or the +path-derived key for nested plugins (`image_gen/openai`, +`memory/honcho`, etc.). + +### What the gate enforces + +| Override | Default | Config key | +| --------------- | ------- | -------------------------------- | +| `provider=` | denied | `allow_provider_override: true` | +| ↳ allowlist | — | `allowed_providers: [...]` | +| `model=` | denied | `allow_model_override: true` | +| ↳ allowlist | — | `allowed_models: [...]` | +| `agent_id=` | denied | `allow_agent_id_override: true` | +| `profile=` | denied | `allow_profile_override: true` | + +Each override is independently gated. Granting `allow_model_override` +does **not** also grant `allow_provider_override` — a plugin trusted +to pick a model is still pinned to the user's active provider unless +it gets the provider gate as well. + +### What the gate does NOT need to enforce + +* Request-shaping arguments — `temperature`, `max_tokens`, + `timeout`, `system_prompt`, `purpose`, `messages`, `instructions`, + `input`, `json_schema`, `schema_name`, `json_mode` — are always + allowed; they don't pick credentials or routes. +* The default deny posture means an unconfigured plugin can still do + useful work — it just runs against the active provider and model. + Operators only need to think about `plugins.entries` for plugins + that want finer routing. + +## What the host owns + +A complete list of the things `ctx.llm` does for the plugin so you +don't have to: + +* **Provider resolution.** Reads `model.provider` + `model.model` + from the user's config (or the explicit overrides when trusted). +* **Auth.** Pulls API keys, OAuth tokens, or refresh tokens from + `~/.hermes/auth.json` / env, including the credential pool when + one is configured. The plugin never sees them. +* **Vision routing.** When image input is supplied and the user's + active text model is text-only, the host falls back to the + configured vision model automatically. +* **Fallback chain.** If the user's primary provider 5xxs or 429s, + the request goes through Hermes' usual aggregator-aware fallback + before it returns an error to the plugin. +* **Timeout.** Honours your `timeout=` argument, falling back to + `auxiliary.<task>.timeout` config or the global aux default. +* **JSON shaping.** Sends `response_format` to the provider when + you ask for JSON, then re-parses locally from a code-fenced + response if the provider returned one. +* **Schema validation.** Validates against your `json_schema` when + `jsonschema` is installed; logs a debug line and skips strict + validation otherwise. +* **Audit log.** Each call writes one INFO line to `agent.log` with + the plugin id, provider/model, purpose, and token totals. + +## What the plugin owns + +* **Request shape.** `messages` for chat, `instructions` + `input` + for structured. The plugin builds the prompt; the host runs it. +* **Schema.** Whatever shape you want back. The host doesn't infer + it for you. +* **Error handling.** `complete_structured()` raises `ValueError` on + empty inputs and on schema-validation failure. `PluginLlmTrustError` + fires when the trust gate denies an override. Anything else + (provider 5xx, no credentials configured, timeout) raises whatever + `auxiliary_client.call_llm()` raises. +* **Cost.** Every call runs against the user's paid provider. Don't + loop on `complete()` for every gateway message without thinking + about token spend. + +## Where this fits in the plugin surface + +Existing `ctx.*` methods extend an existing Hermes subsystem: + +| `ctx.register_tool` | adds a tool the agent can call | +| `ctx.register_platform` | wires a new gateway adapter | +| `ctx.register_image_gen_provider` | replaces an image-gen backend | +| `ctx.register_memory_provider` | replaces the memory backend | +| `ctx.register_context_engine` | replaces the context compressor | +| `ctx.register_hook` | observes a lifecycle event | + +`ctx.llm` is the first surface that lets a plugin run the same +model the user is talking to, *out of band*, without any of the +above. That's its only job. If your plugin needs to register a +tool the agent invokes, use `register_tool`. If it needs to react +to a lifecycle event, use `register_hook`. If it needs to make its +own model call — for any reason, structured or not — `ctx.llm`. + +## Reference + +* Implementation: [`agent/plugin_llm.py`](https://github.com/NousResearch/hermes-agent/blob/main/agent/plugin_llm.py) +* Tests: [`tests/agent/test_plugin_llm.py`](https://github.com/NousResearch/hermes-agent/blob/main/tests/agent/test_plugin_llm.py) +* Reference plugins (companion repo): + * [`plugin-llm-example`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-example) — sync structured extraction with image input + * [`plugin-llm-async-example`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-async-example) — async with `asyncio.gather()` +* Auxiliary client (the engine under the hood): see + [Provider Runtime](/docs/developer-guide/provider-runtime). diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md index d97961a93bd..80e8cae9746 100644 --- a/website/docs/getting-started/nix-setup.md +++ b/website/docs/getting-started/nix-setup.md @@ -645,6 +645,28 @@ services.hermes-agent.extraPythonPackages = [ The package's `site-packages` is added to PYTHONPATH in the hermes wrapper. `importlib.metadata` discovers the entry point at session start. +### Optional Dependency Groups (`extraDependencyGroups`) + +For optional extras already declared in hermes-agent's `pyproject.toml` (e.g., memory providers like `hindsight` or `honcho`), use `extraDependencyGroups` to include them in the sealed venv at build time: + +```nix +services.hermes-agent = { + extraDependencyGroups = [ "hindsight" ]; + settings.memory.provider = "hindsight"; +}; +``` + +This is resolved by uv alongside core dependencies in a single pass — no PYTHONPATH patching, no collision risk. Available groups match the `[project.optional-dependencies]` keys in `pyproject.toml` (e.g., `"hindsight"`, `"honcho"`, `"voice"`, `"matrix"`, `"mistral"`, `"bedrock"`). + +**When to use which:** + +| Need | Option | +|------|--------| +| Enable a pyproject.toml optional extra | `extraDependencyGroups` | +| Add an external Python plugin not in pyproject.toml | `extraPythonPackages` | +| Add a system binary (pandoc, jq, etc.) | `extraPackages` | +| Add a directory-based plugin source tree | `extraPlugins` | + ### Combining Both A directory plugin with third-party Python dependencies needs both options: @@ -666,7 +688,9 @@ External flakes can override the package directly: inputs.hermes-agent.url = "github:NousResearch/hermes-agent"; outputs = { hermes-agent, nixpkgs, ... }: { nixpkgs.overlays = [ hermes-agent.overlays.default ]; - # Then: pkgs.hermes-agent.override { extraPythonPackages = [...]; } + # Then: + # pkgs.hermes-agent.override { extraPythonPackages = [...]; } + # pkgs.hermes-agent.override { extraDependencyGroups = [ "hindsight" ]; } }; } ``` @@ -812,6 +836,7 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use | `extraPackages` | `listOf package` | `[]` | Extra packages available to the agent. Added to the hermes user's per-user profile so terminal commands, skills, and cron jobs all see them | | `extraPlugins` | `listOf package` | `[]` | Directory plugin packages to symlink into `$HERMES_HOME/plugins/`. Each must contain `plugin.yaml` | | `extraPythonPackages` | `listOf package` | `[]` | Python packages added to PYTHONPATH for entry-point plugin discovery. Build with `python312Packages` | +| `extraDependencyGroups` | `listOf str` | `[]` | pyproject.toml optional extras to include in the sealed venv (e.g. `["hindsight"]`). Resolved by uv — no collisions | | `restart` | `str` | `"always"` | systemd `Restart=` policy | | `restartSec` | `int` | `5` | systemd `RestartSec=` value | diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index a5b7e777db3..9d7208883b7 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -443,6 +443,28 @@ Only used when the [`teams_pipeline` plugin](/docs/user-guide/messaging/msgraph- | `TEAMS_CHANNEL_ID` | Target channel ID (paired with `TEAMS_TEAM_ID`). | | `TEAMS_CHAT_ID` | Target 1:1 or group chat ID (alternative to team+channel for `graph` mode). | +### LINE Messaging API + +Used by the bundled LINE platform plugin (`plugins/platforms/line/`). See [Messaging Gateway → LINE](/docs/user-guide/messaging/line) for full setup. + +| Variable | Description | +|----------|-------------| +| `LINE_CHANNEL_ACCESS_TOKEN` | Long-lived channel access token from the LINE Developers Console (Messaging API tab). Required. | +| `LINE_CHANNEL_SECRET` | Channel secret (Basic settings tab); used for HMAC-SHA256 webhook signature verification. Required. | +| `LINE_HOST` | Webhook bind host (default: `0.0.0.0`). | +| `LINE_PORT` | Webhook bind port (default: `8646`). | +| `LINE_PUBLIC_URL` | Public HTTPS base URL (e.g. `https://my-tunnel.example.com`). Required for image / audio / video sends — LINE only accepts HTTPS-reachable URLs. | +| `LINE_ALLOWED_USERS` | Comma-separated user IDs allowed to DM the bot (`U`-prefixed). | +| `LINE_ALLOWED_GROUPS` | Comma-separated group IDs the bot will respond in (`C`-prefixed). | +| `LINE_ALLOWED_ROOMS` | Comma-separated room IDs the bot will respond in (`R`-prefixed). | +| `LINE_ALLOW_ALL_USERS` | Dev-only escape hatch — accepts any source. Default: `false`. | +| `LINE_HOME_CHANNEL` | Default delivery target for cron jobs with `deliver: line`. | +| `LINE_SLOW_RESPONSE_THRESHOLD` | Seconds before the slow-LLM Template Buttons postback fires (default: `45`). Set `0` to disable and always Push-fallback. | +| `LINE_PENDING_TEXT` | Bubble text shown alongside the postback button. | +| `LINE_BUTTON_LABEL` | Postback button label (default: `Get answer`). | +| `LINE_DELIVERED_TEXT` | Reply when an already-delivered postback is tapped again (default: `Already replied ✅`). | +| `LINE_INTERRUPTED_TEXT` | Reply when a `/stop`-orphaned postback button is tapped (default: `Run was interrupted before completion.`). | + ### Advanced Messaging Tuning Advanced per-platform knobs for throttling the outbound message batcher. Most users never need to touch these; defaults are set to respect each platform's rate limits without feeling sluggish. diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md index c100a303514..8adeb3dcf76 100644 --- a/website/docs/reference/skills-catalog.md +++ b/website/docs/reference/skills-catalog.md @@ -65,7 +65,7 @@ If a skill is missing from this list but present in the repo, the catalog is reg | Skill | Description | Path | |-------|-------------|------| -| [`kanban-orchestrator`](/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator) | Decomposition playbook + specialist-roster conventions + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban wor... | `devops/kanban-orchestrator` | +| [`kanban-orchestrator`](/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator) | Decomposition playbook + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban worker's system prompt; this skill... | `devops/kanban-orchestrator` | | [`kanban-worker`](/docs/user-guide/skills/bundled/devops/devops-kanban-worker) | Pitfalls, examples, and edge cases for Hermes Kanban workers. The lifecycle itself is auto-injected into every worker's system prompt as KANBAN_GUIDANCE (from agent/prompt_builder.py); this skill is what you load when you want deeper det... | `devops/kanban-worker` | | [`webhook-subscriptions`](/docs/user-guide/skills/bundled/devops/devops-webhook-subscriptions) | Webhook subscriptions: event-driven agent runs. | `devops/webhook-subscriptions` | diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 215f4e803af..718da1350aa 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -42,6 +42,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. | | `/background <prompt>` (alias: `/bg`, `/btw`) | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/docs/user-guide/cli#background-sessions). | | `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path) | +| `/handoff <platform>` | **CLI only.** Hand the current session off to a messaging platform (Telegram, Discord, Slack, WhatsApp, Signal, Matrix). The gateway picks it up immediately, creates a fresh thread on platforms that support threads (Telegram topics, Discord text-channel threads, Slack message-anchored threads), re-binds the destination to your CLI session_id so the full role-aware transcript replays, and forges a synthetic user turn so the agent confirms it's working in the new place. Your CLI exits cleanly on success with a `/resume` hint; resume locally any time with `/resume <title>`. Refused mid-turn. Requires the gateway to be running and a home channel configured for the target platform (`/sethome` from the destination chat). See [Cross-Platform Handoff](/docs/user-guide/sessions#cross-platform-handoff). | ### Configuration @@ -213,7 +214,7 @@ The messaging gateway supports the following built-in commands inside Telegram, ## Notes -- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, and `/quit` are **CLI-only** commands. +- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, and `/quit` are **CLI-only** commands. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, and `/commands` are **messaging-only** commands. - `/status`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway. diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index c078ed49769..2ae5e2b5aa4 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -423,6 +423,15 @@ Check the browser console for any JavaScript errors Use `clear=True` to clear the console after reading, so subsequent calls only show new messages. +`browser_console` also evaluates JavaScript when called with an `expression` argument — same shape as DevTools console, the result comes back parsed (JSON-serialized objects become dicts; primitive values stay primitive). + +``` +browser_console(expression="document.querySelector('h1').textContent") +browser_console(expression="JSON.stringify(performance.timing)") +``` + +When a CDP supervisor is active for the current session (typical for any session that's run `browser_navigate` against a CDP-capable backend), evaluation runs over the supervisor's persistent WebSocket — no subprocess startup cost. Falls through to the standard agent-browser CLI path otherwise. Behaviour is identical either way; only latency changes. + ### `browser_cdp` Raw Chrome DevTools Protocol passthrough — the escape hatch for browser operations not covered by the other tools. Use for native dialog handling, iframe-scoped evaluation, cookie/network control, or any CDP verb the agent needs. diff --git a/website/docs/user-guide/features/built-in-plugins.md b/website/docs/user-guide/features/built-in-plugins.md index d153f4faf18..aa346308913 100644 --- a/website/docs/user-guide/features/built-in-plugins.md +++ b/website/docs/user-guide/features/built-in-plugins.md @@ -64,8 +64,6 @@ The repo ships these bundled plugins under `plugins/`. All are opt-in — enable | `image_gen/xai` | image backend | xAI `grok-2-image` backend | | `hermes-achievements` | dashboard tab | Steam-style collectible badges generated from your real Hermes session history | | `kanban/dashboard` | dashboard tab | Kanban board UI for the multi-agent dispatcher — tasks, comments, fan-out, board switching. See [Kanban Multi-Agent](./kanban.md). | -| `example-dashboard` | dashboard example | Reference dashboard plugin for [Extending the Dashboard](./extending-the-dashboard.md) | -| `strike-freedom-cockpit` | dashboard skin | Sample custom dashboard skin | Memory providers (`plugins/memory/*`) and context engines (`plugins/context_engine/*`) are listed separately on [Memory Providers](./memory-providers.md) — they're managed through `hermes memory` and `hermes plugins` respectively. The full per-plugin detail for the two long-running hooks-based plugins follows. diff --git a/website/docs/user-guide/features/extending-the-dashboard.md b/website/docs/user-guide/features/extending-the-dashboard.md index d690b1a276a..257bdb57701 100644 --- a/website/docs/user-guide/features/extending-the-dashboard.md +++ b/website/docs/user-guide/features/extending-the-dashboard.md @@ -681,7 +681,7 @@ Key points: - Multiple plugins can claim the same page-scoped slot. They render stacked in registration order. - Zero footprint when no plugin registers: the built-in page renders exactly as before. -The bundled `example-dashboard` plugin ships a live demo that injects a banner into `sessions:top` — install it to see the pattern end-to-end. +A reference plugin (`example-dashboard` in [`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins/tree/main/example-dashboard)) ships a live demo that injects a banner into `sessions:top` — install it to see the pattern end-to-end. ### Slot-only plugins (`tab.hidden`) @@ -818,7 +818,7 @@ If a plugin's script fails to load (404, syntax error, exception during IIFE), t ## Combined theme + plugin demo -The repo ships `plugins/strike-freedom-cockpit/` as a complete reskin demo. It pairs a theme YAML with a slot-only plugin to produce a cockpit-style HUD without forking the dashboard. +The [`strike-freedom-cockpit`](https://github.com/NousResearch/hermes-example-plugins/tree/main/strike-freedom-cockpit) plugin (companion repo `hermes-example-plugins`) is a complete reskin demo. It pairs a theme YAML with a slot-only plugin to produce a cockpit-style HUD without forking the dashboard. **What it demonstrates:** @@ -832,17 +832,19 @@ The repo ships `plugins/strike-freedom-cockpit/` as a complete reskin demo. It p **Install:** ```bash +git clone https://github.com/NousResearch/hermes-example-plugins.git + # Theme -cp plugins/strike-freedom-cockpit/theme/strike-freedom.yaml \ +cp hermes-example-plugins/strike-freedom-cockpit/theme/strike-freedom.yaml \ ~/.hermes/dashboard-themes/ # Plugin -cp -r plugins/strike-freedom-cockpit ~/.hermes/plugins/ +cp -r hermes-example-plugins/strike-freedom-cockpit ~/.hermes/plugins/ ``` Open the dashboard, pick **Strike Freedom** from the theme switcher. The cockpit sidebar appears, the crest shows in the header, the tagline replaces the footer. Switch back to **Hermes Teal** and the plugin remains installed but invisible (the `sidebar` slot only renders under the `cockpit` layout variant). -Read the plugin source (`plugins/strike-freedom-cockpit/dashboard/dist/index.js`) to see how it reads CSS vars, guards against older dashboards without slot support, and registers three slots from one bundle. +Read the plugin source (`strike-freedom-cockpit/dashboard/dist/index.js` in the companion repo) to see how it reads CSS vars, guards against older dashboards without slot support, and registers three slots from one bundle. --- diff --git a/website/docs/user-guide/features/kanban-tutorial.md b/website/docs/user-guide/features/kanban-tutorial.md index 8d422fadf1f..5f79569c7bc 100644 --- a/website/docs/user-guide/features/kanban-tutorial.md +++ b/website/docs/user-guide/features/kanban-tutorial.md @@ -10,7 +10,7 @@ hermes dashboard # opens http://127.0.0.1:9119 in your browser # click Kanban in the left nav ``` -The dashboard is the most comfortable place for **you** to watch the system. Agent workers the dispatcher spawns never see the dashboard or the CLI — they drive the board through a dedicated `kanban_*` [toolset](./kanban#how-workers-interact-with-the-board) (`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`). All three surfaces — dashboard, CLI, worker tools — route through the same per-board SQLite DB (`~/.hermes/kanban.db` for the default board, `~/.hermes/kanban/boards/<slug>/kanban.db` for any board you create later), so each board is consistent no matter which side of the fence a change came from. +The dashboard is the most comfortable place for **you** to watch the system. Agent workers the dispatcher spawns never see the dashboard or the CLI — they drive the board through a dedicated `kanban_*` [toolset](./kanban#how-workers-interact-with-the-board) (`kanban_show`, `kanban_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_unblock`). All three surfaces — dashboard, CLI, worker tools — route through the same per-board SQLite DB (`~/.hermes/kanban.db` for the default board, `~/.hermes/kanban/boards/<slug>/kanban.db` for any board you create later), so each board is consistent no matter which side of the fence a change came from. This tutorial uses the `default` board throughout. If you want multiple isolated queues (one per project / repo / domain), see [Boards (multi-project)](./kanban#boards-multi-project) in the overview — the same CLI / dashboard / worker flows apply per board, and workers physically cannot see tasks on other boards. diff --git a/website/docs/user-guide/features/kanban-worker-lanes.md b/website/docs/user-guide/features/kanban-worker-lanes.md new file mode 100644 index 00000000000..675169f9892 --- /dev/null +++ b/website/docs/user-guide/features/kanban-worker-lanes.md @@ -0,0 +1,114 @@ +# Kanban worker lanes + +A **worker lane** is a class of process that the kanban dispatcher can route tasks to. Each lane has an identity (the assignee string), a spawn mechanism, and a contract for what it must do with the task once spawned. + +This page is the contract. It exists for two audiences: + +- **Operators** picking which lanes to wire into a board (which profiles to create, which assignees to use). +- **Plugin / integration authors** wanting to add a new lane shape (a CLI worker that wraps Codex / Claude Code / OpenCode, a containerised review worker, a non-Hermes service that pulls tasks via the API). + +If you're writing the worker code itself — the agent that runs *inside* a lane — the [`kanban-worker`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-worker/SKILL.md) skill is the deeper procedural detail. + +## The hierarchy + +```text +Hermes Kanban = canonical task lifecycle + audit trail +Worker lane = implementation executor for one assigned card +Reviewer = human or human-proxy that gates "done" +GitHub PR = upstreamable artifact (optional, for code lanes) +``` + +Hermes Kanban owns lifecycle truth — `ready` → `running` → `blocked` / `done` / `archived`. Worker lanes execute work but never own that truth; everything they do flows back through the kanban kernel via the `kanban_*` tools (or, for non-Hermes external workers, via the API). Reviewers gate the transition from "code change written" to "task done." + +## What a lane provides + +To be a kanban worker lane, an integration must provide three things: + +### 1. An assignee string + +The dispatcher matches `task.assignee` against either a Hermes profile name (the default lane shape) or a registered non-spawnable identifier (the plugin lane shape — see [Adding an external CLI worker lane](#adding-an-external-cli-worker-lane) below). Tasks whose assignee doesn't resolve are left on `ready` with a `skipped_nonspawnable` event so a board operator can fix them; they are not silently dropped or executed by an arbitrary fallback. + +### 2. A spawn mechanism + +For Hermes profile lanes, the dispatcher's `_default_spawn` runs `hermes -p <assignee> chat -q <prompt>` (or the equivalent module form when the `hermes` shim isn't on `$PATH`) inside the task's pinned workspace, with these env vars set: + +| Variable | Carries | +|---|---| +| `HERMES_KANBAN_TASK` | the task id the worker is operating on | +| `HERMES_KANBAN_DB` | absolute path to the per-board SQLite file | +| `HERMES_KANBAN_BOARD` | board slug | +| `HERMES_KANBAN_WORKSPACES_ROOT` | root of the board's workspace tree | +| `HERMES_KANBAN_WORKSPACE` | absolute path to *this* task's workspace | +| `HERMES_KANBAN_RUN_ID` | the current run's id (for the lifecycle gate) | +| `HERMES_KANBAN_CLAIM_LOCK` | the claim lock string (`<host>:<pid>:<uuid>`) | +| `HERMES_PROFILE` | the worker's own profile name (for `kanban_comment` author attribution) | +| `HERMES_TENANT` | tenant namespace, if the task has one | + +For non-Hermes lanes (registered via a plugin), the plugin supplies its own `spawn_fn` callable that gets `task`, `workspace`, and `board` and returns an optional pid for crash detection. + +### 3. A lifecycle terminator + +Every claim must end in exactly one of: + +- `kanban_complete(summary=..., metadata=...)` — task succeeds, status flips to `done`. +- `kanban_block(reason=...)` — task waits for human input, status flips to `blocked`. The dispatcher respawns when `kanban_unblock` runs. +- The worker process exits without a tool call. The kernel reaps it and emits `crashed` (PID died) or `gave_up` (consecutive-failure breaker tripped) or `timed_out` (max_runtime exceeded). This is the failure path; healthy workers don't end here. + +The kanban kernel enforces that exactly one of these terminates each run. A worker that calls neither and exits normally is treated as crashed. + +## Outputs and the review-required convention + +For most code-changing tasks, the work isn't truly *done* the moment the worker finishes — it needs a human reviewer. The kanban kernel doesn't enforce this distinction (a "code-changing task" is fuzzy and forcing block-instead-of-complete on every code worker would break flows where no review is wanted). It's a convention layered on top: + +- **Block instead of complete**, with `reason` prefixed `review-required: ` so the dashboard / `hermes kanban show` surfaces the row as awaiting review. +- **Drop structured metadata into a `kanban_comment` first** since `kanban_block` only carries the human-readable `reason`. Comments are the durable annotation channel — every audit-relevant field (changed_files, tests_run, diff_path or PR url, decisions) belongs there. +- **Reviewer either approves and unblocks**, which respawns the worker with the comment thread for follow-ups; or asks for changes via another comment, which the next worker run sees as part of `kanban_show`'s context. + +The [`kanban-worker`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-worker/SKILL.md) skill has worked examples for both `kanban_complete` (truly terminal tasks — typo fixes, docs changes, research writeups) and the `review-required` block pattern. + +## Logs and audit trail + +The dispatcher writes per-task worker stdout/stderr to `<board-root>/logs/<task_id>.log`. Logs are auditable from kanban metadata: + +- `task_runs` rows carry the `log_path`, exit code (where available), summary, and metadata. +- `task_events` rows carry every state transition (`promoted`, `claimed`, `heartbeat`, `completed`, `blocked`, `gave_up`, `crashed`, `timed_out`, `reclaimed`, `claim_extended`). +- `kanban_show` returns both, so a reviewer (or a follow-up worker) reading the task gets the full history without needing dashboard access. + +The dashboard renders run history with summaries, metadata blocks, and exit-status badges. CLI users can run `hermes kanban tail <task_id>` to follow live, or `hermes kanban runs <task_id>` for the historical attempt list. + +## Existing lane shapes + +### Hermes profile lane (default) + +The shape every kanban worker takes today: the assignee is a profile name, the dispatcher spawns `hermes -p <profile>`, the worker auto-loads the [`kanban-worker`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-worker/SKILL.md) skill plus the `KANBAN_GUIDANCE` system-prompt block, and uses the `kanban_*` tools to terminate the run. No setup beyond defining the profile. + +When you create profiles for your fleet, choose names that match the *role* you want the orchestrator to route to. The orchestrator (when there is one) discovers your profile names via `hermes profile list` — there's no fixed roster the system assumes (see the [`kanban-orchestrator`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-orchestrator/SKILL.md) skill for the orchestrator side of the contract). + +### Orchestrator profile lane + +A specialisation of the profile lane: an orchestrator is a Hermes profile whose toolset includes `kanban` but excludes `terminal` / `file` / `code` / `web` for implementation. Its job is decomposing a high-level goal into child tasks via `kanban_create` + `kanban_link` and stepping back. The orchestrator skill encodes the anti-temptation rules. + +## Adding an external CLI worker lane + +Wiring a non-Hermes CLI tool (Codex CLI, Claude Code CLI, OpenCode CLI, a local coding-model runner, etc.) as a kanban worker lane is *not yet a paved path*. The dispatcher's spawn function is pluggable (`spawn_fn` is a parameter on `dispatch_once`), and a plugin could register its own `spawn_fn` for a non-Hermes assignee, but the surrounding integration work — wrapping the CLI's exit code into `kanban_complete` / `kanban_block` calls, mapping the CLI's workspace/sandbox conventions onto the dispatcher's `HERMES_KANBAN_WORKSPACE` env, handling auth and per-CLI policy — is still per-integration design work. + +If you're considering adding a CLI lane, open an issue describing the specific CLI and the workflow you're trying to enable. The contract above is the constraints any such lane must satisfy; the implementation shape (one plugin per CLI vs a generic CLI-runner plugin parameterised by config) is open. + +The historical issue for this is [#19931](https://github.com/NousResearch/hermes-agent/issues/19931) and the closed-not-merged Codex-specific PR [#19924](https://github.com/NousResearch/hermes-agent/pull/19924) — those describe the original architecture proposal but didn't land a runner. + +## Failure modes the dispatcher handles + +So lane authors don't have to reimplement these: + +- **Stale claim TTL** — a worker that claims and then never heartbeats / completes / blocks gets reclaimed after `DEFAULT_CLAIM_TTL_SECONDS` (15 min default) — but only if the worker process has actually died. A live worker (slow model spending 20+ min in one tool-free LLM call) gets the claim *extended* instead of killed; only a dead PID is reclaimed. +- **Crashed worker** — a worker whose host-local PID has vanished is detected by `detect_crashed_workers` and reaped; the task increments `consecutive_failures` and may auto-block when the breaker trips. +- **Run-level retry** — when a task is retried (post-block, post-crash, post-reclaim), the worker can use the `expected_run_id` parameter on terminating tools to fail fast if its own run was already superseded. +- **Per-task max runtime** — `task.max_runtime_seconds` hard-caps wall-clock time per run, regardless of PID liveness. Catches genuinely-deadlocked workers that the live-PID extension would otherwise keep running. +- **Stranded-task detection** — a ready task whose assignee never produces a claim within `kanban.stranded_threshold_seconds` (default 30 min) shows up in `hermes kanban diagnostics` as a `stranded_in_ready` warning. Severity escalates to error at 2x the threshold and critical at 6x. Catches typo'd assignees, deleted profiles, and down external worker pools in one signal — identity-agnostic, no per-board allowlist to curate. + +## Related + +- [Kanban overview](./kanban) — the user-facing intro. +- [Kanban tutorial](./kanban-tutorial) — walkthrough with the dashboard open. +- [`kanban-worker`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-worker/SKILL.md) — the skill the worker process loads. +- [`kanban-orchestrator`](https://github.com/NousResearch/hermes-agent/blob/main/skills/devops/kanban-orchestrator/SKILL.md) — the orchestrator side. diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index 9b1ddb27316..91c6dacde67 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -14,7 +14,7 @@ Hermes Kanban is a durable task board, shared across all your Hermes profiles, t The board has two front doors, both backed by the same `~/.hermes/kanban.db`: -- **Agents drive the board through a dedicated `kanban_*` toolset** — `kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`. The dispatcher spawns each worker with these tools already in its schema; the model reads its task and hands work off by calling them directly, *not* by shelling out to `hermes kanban`. See [How workers interact with the board](#how-workers-interact-with-the-board) below. +- **Agents drive the board through a dedicated `kanban_*` toolset** — `kanban_show`, `kanban_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_unblock`. The dispatcher spawns each worker with these tools already in its schema; orchestrator profiles can also enable the `kanban` toolset explicitly. The model reads and routes tasks by calling tools directly, *not* by shelling out to `hermes kanban`. See [How workers interact with the board](#how-workers-interact-with-the-board) below. - **You (and scripts, and cron) drive the board through `hermes kanban …`** on the CLI, `/kanban …` as a slash command, or the dashboard. These are for humans and automation — the places without a tool-calling model behind them. Both surfaces route through the same `kanban_db` layer, so reads see a consistent view and writes can't drift. The rest of this page shows CLI examples because they're easy to copy-paste, but every CLI verb has a tool-call equivalent the model uses. @@ -231,17 +231,19 @@ hermes kanban block t_abc "need input" --ids t_def t_hij ## How workers interact with the board -**Workers do not shell out to `hermes kanban`.** When the dispatcher spawns a worker it sets `HERMES_KANBAN_TASK=t_abcd` in the child's env, and that env var flips on a dedicated **kanban toolset** in the model's schema — seven tools that read and mutate the board directly via the Python `kanban_db` layer, same as the CLI does. A running worker calls these like any other tool; it never sees or needs the `hermes kanban` CLI. +**Workers do not shell out to `hermes kanban`.** When the dispatcher spawns a worker it sets `HERMES_KANBAN_TASK=t_abcd` in the child's env, and that env var flips on a dedicated **kanban toolset** in the model's schema. The same toolset is also available to orchestrator profiles that enable `kanban` in their toolsets config. These tools read and mutate the board directly via the Python `kanban_db` layer, same as the CLI does. A running worker calls these like any other tool; it never sees or needs the `hermes kanban` CLI. | Tool | Purpose | Required params | |---|---|---| | `kanban_show` | Read the current task (title, body, prior attempts, parent handoffs, comments, full pre-formatted `worker_context`). Defaults to the env's task id. | — | +| `kanban_list` | List task summaries with filters for `assignee`, `status`, `tenant`, archived visibility, and limit. Intended for orchestrators discovering board work. | — | | `kanban_complete` | Finish with `summary` + `metadata` structured handoff. | at least one of `summary` / `result` | | `kanban_block` | Escalate for human input with a `reason`. | `reason` | | `kanban_heartbeat` | Signal liveness during long operations. Pure side-effect. | — | | `kanban_comment` | Append a durable note to the task thread. | `task_id`, `body` | | `kanban_create` | (Orchestrators) fan out into child tasks with an `assignee`, optional `parents`, `skills`, etc. | `title`, `assignee` | | `kanban_link` | (Orchestrators) add a `parent_id → child_id` dependency edge after the fact. | `parent_id`, `child_id` | +| `kanban_unblock` | (Orchestrators) move a blocked task back to `ready`. | `task_id` | A typical worker turn looks like: @@ -278,7 +280,7 @@ kanban_create( kanban_complete(summary="decomposed into 2 research tasks + 1 writer; linked dependencies") ``` -The three "(Orchestrators)" tools — `kanban_create`, `kanban_link`, and `kanban_comment` on foreign tasks — are available to every worker; the convention (enforced by the `kanban-orchestrator` skill) is that worker profiles don't fan out and orchestrator profiles don't execute. +The "(Orchestrators)" tools — `kanban_list`, `kanban_create`, `kanban_link`, `kanban_unblock`, and `kanban_comment` on foreign tasks — are available through the same toolset; the convention (enforced by the `kanban-orchestrator` skill) is that worker profiles don't fan out or route unrelated work, and orchestrator profiles don't execute implementation work. Dispatcher-spawned workers are still task-scoped for destructive lifecycle operations and cannot mutate unrelated tasks. ### Why tools instead of shelling to `hermes kanban` @@ -391,7 +393,7 @@ These skills are **additive** to the built-in `kanban-worker` — the dispatcher ### The orchestrator skill -A **well-behaved orchestrator does not do the work itself.** It decomposes the user's goal into tasks, links them, assigns each to a specialist, and steps back. The `kanban-orchestrator` skill encodes this as tool-call patterns: anti-temptation rules, a standard specialist roster (`researcher`, `writer`, `analyst`, `backend-eng`, `reviewer`, `ops`), and a decomposition playbook keyed on `kanban_create` / `kanban_link` / `kanban_comment`. +A **well-behaved orchestrator does not do the work itself.** It decomposes the user's goal into tasks, links them, assigns each to one of the profiles you've set up, and steps back. The `kanban-orchestrator` skill encodes this as tool-call patterns: anti-temptation rules, a Step-0 profile-discovery prompt (the dispatcher silently fails on unknown assignee names, so the orchestrator must ground every card in profiles that actually exist on your machine), and a decomposition playbook keyed on `kanban_create` / `kanban_link` / `kanban_comment`. A canonical orchestrator turn (two parallel researchers handing off to a writer): diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 3ceabee2081..8bab522f9dd 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -111,6 +111,7 @@ Every `ctx.*` API below is available inside a plugin's `register(ctx)` function. | Register an image-generation backend | `ctx.register_image_gen_provider(provider)` — see [Image Generation Provider Plugins](/docs/developer-guide/image-gen-provider-plugin) | | Register a context-compression engine | `ctx.register_context_engine(engine)` — see [Context Engine Plugins](/docs/developer-guide/context-engine-plugin) | | Register a memory backend | Subclass `MemoryProvider` in `plugins/memory/<name>/__init__.py` — see [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) (uses a separate discovery system) | +| Run a host-owned LLM call | `ctx.llm.complete(...)` / `ctx.llm.complete_structured(...)` — borrow the user's active model + auth for a one-shot completion with optional JSON schema validation. See [Plugin LLM Access](/docs/developer-guide/plugin-llm-access) | | Register an inference backend (LLM provider) | `register_provider(ProviderProfile(...))` in `plugins/model-providers/<name>/__init__.py` — see [Model Provider Plugins](/docs/developer-guide/model-provider-plugin) (uses a separate discovery system) | ## Plugin discovery diff --git a/website/docs/user-guide/features/web-search.md b/website/docs/user-guide/features/web-search.md index 7f06c8e0d4d..931b4ce9cef 100644 --- a/website/docs/user-guide/features/web-search.md +++ b/website/docs/user-guide/features/web-search.md @@ -32,6 +32,44 @@ If you have a paid [Nous Portal](https://portal.nousresearch.com) subscription, --- +## How `web_extract` handles long pages + +Backends return raw page markdown, which can be huge (forum threads, docs sites, news articles with embedded comments). To keep your context window usable and your costs down, `web_extract` runs returned content through the **`web_extract` auxiliary model** before handing it to the agent. Behavior is purely size-driven: + +| Page size (characters) | What happens | +|------------------------|--------------| +| Under 5 000 | Returned as-is — no LLM call, full markdown reaches the agent | +| 5 000 – 500 000 | Single-pass summary via the `web_extract` auxiliary model, capped at ~5 000 chars of output | +| 500 000 – 2 000 000 | Chunked: split into 100 k-char chunks, summarize each in parallel, then synthesize a final summary (~5 000 chars) | +| Over 2 000 000 | Refused with a hint to use `web_crawl` with focused extraction instructions or a more specific source | + +The summary keeps quotes, code blocks, and key facts in their original formatting — it's a content compressor, not a paraphraser. If summarization fails or times out, Hermes falls back to the first ~5 000 chars of raw content rather than a useless error. + +### Which model does the summarizing? + +The `web_extract` auxiliary task. By default (`auxiliary.web_extract.provider: "auto"`), this is your **main chat model** — same provider, same model as `hermes model`. That's fine for most setups, but on expensive reasoning models (Opus, MiniMax M2.7, etc.) every long-page extract adds meaningful cost. + +To route extraction summaries to a cheap, fast model regardless of your main: + +```yaml +# ~/.hermes/config.yaml +auxiliary: + web_extract: + provider: openrouter + model: google/gemini-3-flash-preview + timeout: 360 # seconds; raise if you hit summarization timeouts +``` + +Or pick interactively: `hermes model` → **Configure auxiliary models** → `web_extract`. + +See [Auxiliary Models](/docs/user-guide/configuration#auxiliary-models) for the full reference and per-task override patterns. + +### When summarization gets in the way + +If you specifically need raw, unsummarized page content — for example, you're scraping a structured page where the LLM summary would drop important fields — use `browser_navigate` + `browser_snapshot` instead. The browser tool returns the live accessibility tree without auxiliary-model rewriting (subject to its own 8 000-char snapshot cap on huge pages). + +--- + ## Setup ### Quick setup via `hermes tools` @@ -329,6 +367,14 @@ Some public instances disable certain search engines or categories. Try: Switch to a self-hosted instance (see [Option A](#option-a--self-host-with-docker-recommended) above). With Docker, your own instance has no rate limits. +### `web_extract` returns truncated content with a "summarization timed out" note + +The auxiliary model didn't finish summarizing within the configured timeout. Either: + +- Raise `auxiliary.web_extract.timeout` in `config.yaml` (default 360s on fresh installs, 30s if the key is missing) +- Switch the `web_extract` auxiliary task to a faster model (e.g. `google/gemini-3-flash-preview`) — see [How `web_extract` handles long pages](#how-web_extract-handles-long-pages) +- For pages where summarization is the wrong tool, use `browser_navigate` instead + --- ## Optional skill: `searxng-search` diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index c8a2dbc5f67..375d682f92d 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -462,6 +462,48 @@ display: tool_progress_command: true ``` +## Slash Command Access Control + +By default, every allowed user can run every slash command. To split your allowlist into **admins** (full slash command access) and **regular users** (only commands you explicitly enable), add `allow_admin_from` and `user_allowed_commands` to the Discord platform's `extra` block: + +```yaml +gateway: + platforms: + discord: + extra: + # Existing user allowlist (unchanged) + allow_from: + - "123456789012345678" # admin user ID + - "999888777666555444" # regular user ID + + # NEW — admins get all slash commands (built-in + plugin) + allow_admin_from: + - "123456789012345678" + + # NEW — non-admin allowed users can only run these slash commands. + # /help and /whoami are always allowed so users can see their access. + user_allowed_commands: + - status + - model + - history + + # Optional: separate admin / command lists for server channels + group_allow_admin_from: + - "123456789012345678" + group_user_allowed_commands: + - status +``` + +**Behavior:** + +- A user in `allow_admin_from` for a scope (DM or server channel) can run **every** registered slash command — built-in AND plugin-registered — through the live command registry. +- A user not in `allow_admin_from` can only run commands listed in `user_allowed_commands`, plus the always-allowed floor: `/help` and `/whoami`. +- Plain chat (non-slash messages) is unaffected. Non-admin users can still talk to the agent normally; they just can't trigger arbitrary commands. +- **Backward compat:** if `allow_admin_from` is not set for a scope, slash command gating is disabled for that scope. Existing installs keep working with no changes. +- DM admin status does not imply server-channel admin status. Each scope has its own admin list. + +Use `/whoami` to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run. + ## Interactive Model Picker Send `/model` with no arguments in a Discord channel to open a dropdown-based model picker: diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index b6ed2796c10..acd12872812 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -1,12 +1,12 @@ --- sidebar_position: 1 title: "Messaging Gateway" -description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Yuanbao, Microsoft Teams, Webhooks, or any OpenAI-compatible frontend via the API server — architecture and setup overview" +description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Yuanbao, Microsoft Teams, LINE, Webhooks, or any OpenAI-compatible frontend via the API server — architecture and setup overview" --- # Messaging Gateway -Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages. +Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, Feishu/Lark, WeCom, Weixin, BlueBubbles (iMessage), QQ, Yuanbao, Microsoft Teams, LINE, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages. For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes). @@ -34,6 +34,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies | QQ | ✅ | ✅ | ✅ | — | — | ✅ | — | | Yuanbao | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | | Microsoft Teams | — | ✅ | — | ✅ | — | ✅ | — | +| LINE | — | ✅ | ✅ | — | — | ✅ | — | **Voice** = TTS audio replies and/or voice message transcription. **Images** = send/receive images. **Files** = send/receive file attachments. **Threads** = threaded conversations. **Reactions** = emoji reactions on messages. **Typing** = typing indicator while processing. **Streaming** = progressive message updates via editing. @@ -133,6 +134,7 @@ hermes gateway status --system # Linux only: inspect the system service | `/retry` | Retry the last message | | `/undo` | Remove the last exchange | | `/status` | Show session info | +| `/whoami` | Show your slash command access on this scope (admin / user / unrestricted) | | `/stop` | Stop the running agent | | `/approve` | Approve a pending dangerous command | | `/deny` | Reject a pending dangerous command | @@ -220,6 +222,33 @@ hermes pairing revoke telegram 123456789 # Remove access Pairing codes expire after 1 hour, are rate-limited, and use cryptographic randomness. +### Slash Command Access Control + +Once users are allowed in, you can split them into **admins** (full slash command access) and **regular users** (only the slash commands you explicitly enable). This applies per platform and per scope (DM vs group/channel) and works through the live command registry, so it covers built-in AND plugin-registered slash commands without per-feature wiring. + +```yaml +gateway: + platforms: + discord: + extra: + allow_from: ["111", "222", "333"] + allow_admin_from: ["111"] # admins → all slash commands + user_allowed_commands: [status, model] # what non-admins may run + # Optional: separate group/channel scope + group_allow_admin_from: ["111"] + group_user_allowed_commands: [status] +``` + +Behavior: + +- A user in `allow_admin_from` for a scope can run **every** registered slash command. +- A user in `allow_from` but not in `allow_admin_from` can only run commands in `user_allowed_commands`, plus the always-allowed floor: `/help` and `/whoami`. +- Plain chat is unaffected. Non-admins can still talk to the agent normally; they just can't trigger arbitrary commands. +- **Backward compat:** if `allow_admin_from` is not set for a scope, slash gating is disabled for that scope. Existing installs keep working with no changes. +- DM admin status does not imply group/channel admin status. Each scope has its own admin list. + +Use `/whoami` from any platform to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run. See the [Telegram](/docs/user-guide/messaging/telegram#slash-command-access-control) and [Discord](/docs/user-guide/messaging/discord#slash-command-access-control) pages for platform-specific examples. + ## Interrupting the Agent Send any message while the agent is working to interrupt it. Key behaviors: diff --git a/website/docs/user-guide/messaging/line.md b/website/docs/user-guide/messaging/line.md new file mode 100644 index 00000000000..1aa3a753816 --- /dev/null +++ b/website/docs/user-guide/messaging/line.md @@ -0,0 +1,198 @@ +--- +sidebar_position: 17 +title: "LINE" +description: "Set up Hermes Agent as a LINE Messaging API bot" +--- + +# LINE Setup + +Run Hermes Agent as a [LINE](https://line.me/) bot via the official LINE Messaging API. The adapter lives as a bundled platform plugin under `plugins/platforms/line/` — no core edits, just enable it like any other platform. + +LINE is the dominant messaging app in Japan, Taiwan, and Thailand. If your users live there, this is how they reach you. + +## How the bot responds + +| Context | Behavior | +|---------|----------| +| **1:1 chat** (`U` IDs) | Responds to every message | +| **Group chat** (`C` IDs) | Responds when the group is on the allowlist | +| **Multi-user room** (`R` IDs) | Responds when the room is on the allowlist | + +Inbound text, images, audio, video, files, stickers, and locations are all handled. Outbound text uses the **free reply token first** (single-use, ~60s window) and falls back to the metered Push API when the token has expired. + +--- + +## Step 1: Create a LINE Messaging API channel + +1. Go to the [LINE Developers Console](https://developers.line.biz/console/). +2. Create a Provider, then under it a **Messaging API** channel. +3. From the channel's **Basic settings** tab, copy the **Channel secret**. +4. From the **Messaging API** tab, scroll to **Channel access token (long-lived)** and click **Issue**. Copy the token. +5. In the **Messaging API** tab, also disable **Auto-reply messages** and **Greeting messages** so they don't fight your bot's replies. + +--- + +## Step 2: Expose the webhook port + +LINE delivers webhooks over public HTTPS. The default port is `8646` — override with `LINE_PORT` if needed. + +```bash +# Cloudflare Tunnel (recommended for production — fixed hostname) +cloudflared tunnel --url http://localhost:8646 + +# ngrok (good for dev) +ngrok http 8646 + +# devtunnel +devtunnel create hermes-line --allow-anonymous +devtunnel port create hermes-line -p 8646 --protocol https +devtunnel host hermes-line +``` + +Copy the `https://...` URL — you'll set it as the webhook URL below. **Leave the tunnel running** while testing. For production, set up a fixed Cloudflare named tunnel so the webhook URL doesn't change on restart. + +--- + +## Step 3: Configure Hermes + +Add to `~/.hermes/.env`: + +```env +LINE_CHANNEL_ACCESS_TOKEN=YOUR_LONG_LIVED_TOKEN +LINE_CHANNEL_SECRET=YOUR_CHANNEL_SECRET + +# Allowlist — at least one of these (or LINE_ALLOW_ALL_USERS=true for dev) +LINE_ALLOWED_USERS=U1234567890abcdef... # comma-separated U-prefixed IDs +LINE_ALLOWED_GROUPS=C1234567890abcdef... # optional group IDs +LINE_ALLOWED_ROOMS=R1234567890abcdef... # optional room IDs + +# Required for image / audio / video sends — the public HTTPS base URL +# the tunnel resolves to. Without it, send_image/voice/video will refuse. +LINE_PUBLIC_URL=https://my-tunnel.example.com +``` + +Then in `~/.hermes/config.yaml`: + +```yaml +gateway: + platforms: + line: + enabled: true +``` + +That's enough — the bundled-plugin scan in `gateway/config.py` automatically picks up `plugins/platforms/line/`. No `Platform.LINE` enum edit, no `_create_adapter` registration. + +--- + +## Step 4: Set the webhook URL + +Back in the LINE console: + +1. Open your channel → **Messaging API** tab. +2. Under **Webhook settings** → **Webhook URL**, paste `https://<your-tunnel>/line/webhook` (note the `/line/webhook` path — the adapter listens there). +3. Click **Verify**. LINE pings the URL; you should see a 200. +4. Toggle **Use webhook** to **On**. + +--- + +## Step 5: Run the gateway + +```bash +hermes gateway +``` + +The agent log shows: + +``` +LINE: webhook listening on 0.0.0.0:8646/line/webhook (public: https://my-tunnel.example.com) +``` + +Add the bot as a friend from the LINE app (scan the QR in the channel's **Messaging API** tab) and send it a message. + +--- + +## Slow LLM responses + +LINE's reply token is single-use and expires roughly 60 seconds after the inbound event. Slow LLMs can't reply in time, which would normally force a paid Push API call. + +When the LLM is still running past `LINE_SLOW_RESPONSE_THRESHOLD` seconds (default `45`), the adapter consumes the original reply token to send a **Template Buttons** bubble: + +> 🤔 Still thinking. Tap below to fetch the answer when it's ready. +> +> [ Get answer ] + +The user taps **Get answer** when convenient — that postback delivers a *fresh* reply token, which the adapter uses to send the cached answer (still free). + +State machine: `PENDING → READY → DELIVERED`, plus `ERROR` for cancelled runs (the orphan PENDING resolves to "Run was interrupted before completion." after `/stop` so the persistent button doesn't loop). + +To disable the postback button and always Push-fallback instead: + +```env +LINE_SLOW_RESPONSE_THRESHOLD=0 +``` + +For the postback flow to fire reliably, suppress chatter that would consume the reply token before the threshold: + +```yaml +# ~/.hermes/config.yaml +display: + interim_assistant_messages: false + platforms: + line: + tool_progress: off +``` + +--- + +## Cron / notification delivery + +```env +LINE_HOME_CHANNEL=Uxxxxxxxxxxxxxxxxxxxx # default delivery target +``` + +Cron jobs with `deliver: line` route to `LINE_HOME_CHANNEL`. The adapter ships a standalone Push-only sender so cron jobs work even when cron runs in a separate process from the gateway. + +--- + +## Environment variable reference + +| Variable | Required | Default | Description | +|---|---|---|---| +| `LINE_CHANNEL_ACCESS_TOKEN` | yes | — | Long-lived channel access token | +| `LINE_CHANNEL_SECRET` | yes | — | Channel secret (HMAC-SHA256 webhook verification) | +| `LINE_HOST` | no | `0.0.0.0` | Webhook bind host | +| `LINE_PORT` | no | `8646` | Webhook bind port | +| `LINE_PUBLIC_URL` | for media | — | Public HTTPS base URL; required for image/voice/video sends | +| `LINE_ALLOWED_USERS` | one of | — | Comma-separated user IDs (U-prefixed) | +| `LINE_ALLOWED_GROUPS` | one of | — | Comma-separated group IDs (C-prefixed) | +| `LINE_ALLOWED_ROOMS` | one of | — | Comma-separated room IDs (R-prefixed) | +| `LINE_ALLOW_ALL_USERS` | dev only | `false` | Skip allowlist entirely | +| `LINE_HOME_CHANNEL` | no | — | Default cron / notification delivery target | +| `LINE_SLOW_RESPONSE_THRESHOLD` | no | `45` | Seconds before the postback button fires (`0` = disabled) | +| `LINE_PENDING_TEXT` | no | "🤔 Still thinking…" | Bubble text shown alongside the postback button | +| `LINE_BUTTON_LABEL` | no | "Get answer" | Button label | +| `LINE_DELIVERED_TEXT` | no | "Already replied ✅" | Reply when an already-delivered button is tapped again | +| `LINE_INTERRUPTED_TEXT` | no | "Run was interrupted before completion." | Reply when a `/stop` orphan button is tapped | + +--- + +## Troubleshooting + +**"invalid signature" on webhook verify.** The `Channel secret` was copied wrong, or your tunnel rewrote the request body. Verify with `curl -i https://<tunnel>/line/webhook/health` first — that should return `{"status":"ok","platform":"line"}`. + +**Bot receives nothing in groups.** Check `LINE_ALLOWED_GROUPS` includes the `C...` group ID. To find a group ID, send a test message and grep `~/.hermes/logs/gateway.log` for `LINE: rejecting unauthorized source` — the rejected source dict has the IDs. + +**`send_image` fails with "LINE_PUBLIC_URL must be set".** LINE's Messaging API does not accept binary uploads — images, audio, and video must be reachable HTTPS URLs. Set `LINE_PUBLIC_URL` to the tunnel's public hostname and the adapter will serve files from `/line/media/<token>/<filename>` automatically. + +**Postback button never appears.** Either the LLM responded faster than `LINE_SLOW_RESPONSE_THRESHOLD`, or another bubble (tool-progress, streaming) consumed the reply token first. See the suppression block under "Slow LLM responses". + +**"already in use by another profile".** The same channel access token is bound to another running Hermes profile. Stop the other gateway or use a separate channel. + +--- + +## Limitations + +* **Single bubble per chunk.** Each LINE text bubble is capped at 5000 characters, and at most 5 bubbles are sent per Reply/Push call. Longer responses are truncated with an ellipsis. +* **No native message editing.** LINE has no edit-message API — streaming responses always send fresh bubbles, never edit prior ones. +* **No Markdown rendering.** Bold (`**`), italics (`*`), code fences, and headings render as literal characters. The adapter strips them before sending; URLs are preserved (`[label](url)` becomes `label (url)`). +* **Loading indicator is DM-only.** LINE rejects the chat/loading API for groups and rooms, so the typing indicator only shows in 1:1 chats. diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index d41633e995d..ffbc9dfe074 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -611,7 +611,33 @@ To find a topic's `thread_id`, open the topic in Telegram Web or Desktop and loo - **Bot API 9.4 (Feb 2026):** Private Chat Topics — bots can create forum topics in 1-on-1 DM chats via `createForumTopic`. Hermes uses this for two distinct features: operator-curated [Private Chat Topics](#private-chat-topics-bot-api-94) (config-driven, fixed topic list) and user-driven [Multi-session DM mode](#multi-session-dm-mode-topic) (activated by `/topic`, unlimited user-created topics). - **Privacy policy:** Telegram now requires bots to have a privacy policy. Set one via BotFather with `/setprivacy_policy`, or Telegram may auto-generate a placeholder. This is particularly important if your bot is public-facing. -- **Message streaming:** Bot API 9.x added support for streaming long responses, which can improve perceived latency for lengthy agent replies. +- **Bot API 9.5 (Mar 2026): Native streaming via `sendMessageDraft`.** Hermes uses Telegram's native streaming-draft API to render an animated preview of the agent's reply as tokens arrive in private chats. Drops the per-edit jitter you used to see with the legacy `editMessageText` polling path on slow models. + +### Streaming transport (`gateway.streaming.transport`) + +When streaming is enabled (`gateway.streaming.enabled: true`), Hermes picks one of four transports: + +| Value | Behaviour | +|---|---| +| `auto` (default) | Native draft streaming on supported chats (currently Telegram DMs); legacy edit-based path otherwise. Falls back gracefully if a draft frame fails. | +| `draft` | Force native drafts. Logs a downgrade and falls back to edit if the chat doesn't support drafts (e.g. groups/topics). | +| `edit` | Legacy progressive `editMessageText` polling for every chat type. | +| `off` | Disable streaming entirely (final reply only, no progressive updates). | + +In `~/.hermes/config.yaml`: + +```yaml +gateway: + streaming: + enabled: true + transport: auto # auto | draft | edit | off +``` + +**What you'll see in DMs with `auto` (default)** — when the agent generates a reply, Telegram shows an animated draft preview that updates token-by-token. When the reply finishes, it's delivered as a regular message and the draft preview clears naturally on the client. Drafts have no message id, so the final answer is what stays in your chat history. + +**What about groups, supergroups, forum topics?** Telegram restricts `sendMessageDraft` to private chats (DMs). The gateway transparently falls back to the edit-based path for everything else — same UX as before. + +**What if a draft frame fails?** Any failure (transient network error, server-side rejection, older python-telegram-bot install) flips that response back to the edit-based path for the rest of the stream. The next response gets a fresh attempt. ## Rendering: Tables and Link Previews @@ -685,6 +711,50 @@ TELEGRAM_GROUP_ALLOWED_USERS="-1001234567890" TELEGRAM_GROUP_ALLOWED_CHATS="-1001234567890" ``` +## Slash Command Access Control + +By default, every allowed user can run every slash command. To split your allowlist into **admins** (full slash command access) and **regular users** (only commands you explicitly enable), add `allow_admin_from` and `user_allowed_commands` to the platform's `extra` block: + +```yaml +gateway: + platforms: + telegram: + extra: + # Existing allowlists (unchanged) + allow_from: + - "123456789" # admin + - "555555555" # regular user + - "777777777" # regular user + + # NEW — admins get all slash commands (built-in + plugin) + allow_admin_from: + - "123456789" + + # NEW — non-admin allowed users can only run these slash commands. + # /help and /whoami are always allowed so users can see their access. + user_allowed_commands: + - status + - model + - history + + # Optional: separate admin/command lists for groups + group_allow_admin_from: + - "123456789" + group_user_allowed_commands: + - status +``` + +**Behavior:** + +- A user listed in `allow_admin_from` for a scope (DM or group) can run **every** registered slash command — built-in commands AND plugin-registered ones — through the live registry. +- A user in `allow_from` but **not** in `allow_admin_from` can only run commands listed in `user_allowed_commands`, plus the always-allowed floor: `/help` and `/whoami`. +- Plain chat (non-slash messages) is unaffected. Non-admin users can still talk to the agent normally, they just can't trigger arbitrary commands. +- **Backward compat:** if `allow_admin_from` is not set for a scope, slash command gating is disabled for that scope. Existing installs keep working with no changes. +- DM admin status does not imply group admin status. Each scope has its own admin list. +- If only `group_allow_admin_from` is set, DM scope stays in unrestricted (backward-compat) mode. + +Use `/whoami` to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run. + ## Interactive Model Picker When you send `/model` with no arguments in a Telegram chat, Hermes shows an interactive inline keyboard for switching models: diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 5094edf64c1..b455ea92e37 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -127,6 +127,44 @@ display: Session IDs follow the format `YYYYMMDD_HHMMSS_<hex>` — CLI/TUI sessions use a 6-char hex suffix (e.g. `20250305_091523_a1b2c3`), gateway sessions use an 8-char suffix (e.g. `20250305_091523_a1b2c3d4`). You can resume by ID (full or unique prefix) or by title — both work with `-c` and `-r`. ::: +## Cross-Platform Handoff + +Use `/handoff <platform>` from a CLI session to transfer the live conversation to a messaging platform's home channel. The agent picks up exactly where the CLI left off — same session id, full role-aware transcript, tool calls and all. + +```bash +# Inside a CLI session +/handoff telegram +``` + +What happens: + +1. The CLI validates that `<platform>` is enabled and has a home channel set (run `/sethome` from the destination chat once to configure it). +2. The CLI marks the session pending and **block-polls the gateway**. It refuses if the agent is mid-turn — wait for the current response to finish first. +3. The gateway watcher claims the handoff and asks the destination adapter for a fresh thread: + - **Telegram** — opens a new forum topic (DM topics if Bot API 9.4+ Topics mode is enabled in the chat, or a forum supergroup topic). + - **Discord** — creates a 1440-min auto-archive thread under the home text channel. + - **Slack** — posts a seed message and uses its `ts` as the thread anchor. + - **WhatsApp / Signal / Matrix / SMS** — no native threads, falls back to the home channel directly. +4. The gateway re-binds the destination key to your existing CLI session id, then forges a synthetic user turn asking the agent to confirm and summarize. The reply lands in the new thread. +5. When the gateway acknowledges success, the CLI prints a `/resume` hint and exits cleanly: + + ``` + ↻ Handoff complete. The session is now active on telegram. + Resume it on this CLI later with: /resume my-session-title + ``` + +6. From that point, the conversation lives on the platform. Reply in the new thread — anyone authorized in that channel shares the same session, and any later real user message in the thread joins seamlessly because thread sessions key without `user_id`. + +**Resume back to CLI:** when you want to come back to a desktop, just run `/resume <title>` (or `hermes -r "<title>"` from the shell) and pick up where the platform left off. + +**Failure modes:** +- No home channel configured → CLI refuses with a `/sethome` hint. +- Platform not enabled / gateway not running → CLI times out at 60s with a clear message and your CLI session stays intact. +- Thread creation fails (permissions, topics-mode off) → falls back to the home channel directly and still completes; no thread isolation but the handoff itself works. +- `adapter.send` fails (rate limit, transient API error) → handoff marked failed with the reason; the row clears so you can retry. + +**Limitation worth knowing:** for non-thread-capable platforms with multi-user group home channels, the synthetic turn keys as a DM-style session. This works for self-DM home channels (the typical setup) but isn't ideal for genuinely shared group chats. Threading covers Telegram / Discord / Slack — by far the common case — so most setups never hit this. + ## Session Naming Give sessions human-readable titles so you can find and resume them easily. diff --git a/website/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator.md b/website/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator.md index c0666428098..6dc92bb41f9 100644 --- a/website/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator.md +++ b/website/docs/user-guide/skills/bundled/devops/devops-kanban-orchestrator.md @@ -1,14 +1,14 @@ --- title: "Kanban Orchestrator" sidebar_label: "Kanban Orchestrator" -description: "Decomposition playbook + specialist-roster conventions + anti-temptation rules for an orchestrator profile routing work through Kanban" +description: "Decomposition playbook + anti-temptation rules for an orchestrator profile routing work through Kanban" --- {/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} # Kanban Orchestrator -Decomposition playbook + specialist-roster conventions + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban worker's system prompt; this skill is the deeper playbook when you're specifically playing the orchestrator role. +Decomposition playbook + anti-temptation rules for an orchestrator profile routing work through Kanban. The "don't do the work yourself" rule and the basic lifecycle are auto-injected into every kanban worker's system prompt; this skill is the deeper playbook when you're specifically playing the orchestrator role. ## Skill metadata @@ -16,7 +16,7 @@ Decomposition playbook + specialist-roster conventions + anti-temptation rules f |---|---| | Source | Bundled (installed by default) | | Path | `skills/devops/kanban-orchestrator` | -| Version | `2.0.0` | +| Version | `3.0.0` | | Platforms | linux, macos, windows | | Tags | `kanban`, `multi-agent`, `orchestration`, `routing` | | Related skills | [`kanban-worker`](/docs/user-guide/skills/bundled/devops/devops-kanban-worker) | @@ -31,6 +31,22 @@ The following is the complete skill definition that Hermes loads when this skill > The **core worker lifecycle** (including the `kanban_create` fan-out pattern and the "decompose, don't execute" rule) is auto-injected into every kanban process via the `KANBAN_GUIDANCE` system-prompt block. This skill is the deeper playbook when you're an orchestrator profile whose whole job is routing. +## Profiles are user-configured — not a fixed roster + +Hermes setups vary widely. Some users run a single profile that does everything; some run a small fleet (`docker-worker`, `cron-worker`); some run a curated specialist team they've named themselves. There is **no default specialist roster** — the orchestrator skill does not know what profiles exist on this machine. + +Before fanning out, you must ground the decomposition in the profiles that actually exist. The dispatcher silently fails to spawn unknown assignee names — it doesn't autocorrect, doesn't suggest, doesn't fall back. So a card assigned to `researcher` on a setup that only has `docker-worker` just sits in `ready` forever. + +**Step 0: discover available profiles before planning.** + +Use one of these: + +- `hermes profile list` — prints the table of profiles configured on this machine. Run it through your terminal tool if you have one; otherwise ask the user. +- `kanban_list(assignee="<some-name>")` — sanity-check a single name. Returns an empty list (rather than an error) for an unknown assignee, so this only confirms a name you're already considering. +- **Just ask the user.** "What profiles do you have set up?" is a fine first turn when the goal needs more than one specialist. + +Cache the result in your working memory for the rest of the conversation. Re-asking every turn wastes a tool call. + ## When to use the board (vs. just doing the work) Create Kanban tasks when any of these are true: @@ -50,24 +66,11 @@ Your job description says "route, don't execute." The rules that enforce that: - **Do not execute the work yourself.** Your restricted toolset usually doesn't even include terminal/file/code/web for implementation. If you find yourself "just fixing this quickly" — stop and create a task for the right specialist. - **For any concrete task, create a Kanban task and assign it.** Every single time. -- **If no specialist fits, ask the user which profile to create.** Do not default to doing it yourself under "close enough." +- **Split multi-lane requests before creating cards.** A user prompt can contain several independent workstreams. Extract those lanes first, then create one card per lane instead of bundling unrelated work into a single implementer card. +- **Run independent lanes in parallel.** If two cards do not need each other's output, leave them unlinked so the dispatcher can fan them out. Link only true data dependencies. +- **If no specialist fits the available profiles, ask the user which profile to create or which existing profile to use.** Do not invent profile names; the dispatcher will silently drop unknown assignees. - **Decompose, route, and summarize — that's the whole job.** -## The standard specialist roster (convention) - -Unless the user's setup has customized profiles, assume these exist. Adjust to whatever the user actually has — ask if you're unsure. - -| Profile | Does | Typical workspace | -|---|---|---| -| `researcher` | Reads sources, gathers facts, writes findings | `scratch` | -| `analyst` | Synthesizes, ranks, de-dupes. Consumes multiple `researcher` outputs | `scratch` | -| `writer` | Drafts prose in the user's voice | `scratch` or `dir:` into their Obsidian vault | -| `reviewer` | Reads output, leaves findings, gates approval | `scratch` | -| `backend-eng` | Writes server-side code | `worktree` | -| `frontend-eng` | Writes client-side code | `worktree` | -| `ops` | Runs scripts, manages services, handles deployments | `dir:` into ops scripts repo | -| `pm` | Writes specs, acceptance criteria | `scratch` | - ## Decomposition playbook ### Step 1 — Understand the goal @@ -76,43 +79,53 @@ Ask clarifying questions if the goal is ambiguous. Cheap to ask; expensive to sp ### Step 2 — Sketch the task graph -Before creating anything, draft the graph out loud (in your response to the user). Example for "Analyze whether we should migrate to Postgres": +Before creating anything, draft the graph out loud (in your response to the user). Treat every concrete workstream as a candidate card: -``` -T1 researcher research: Postgres cost vs current -T2 researcher research: Postgres performance vs current -T3 analyst synthesize migration recommendation parents: T1, T2 -T4 writer draft decision memo parents: T3 -``` +1. Extract the lanes from the request. +2. Map each lane to one of the profiles you discovered in Step 0. If a lane doesn't fit any existing profile, ask the user which to use or create. +3. Decide whether each lane is independent or gated by another lane. +4. Create independent lanes as parallel cards with no parent links. +5. Create synthesis/review/integration cards with parent links to the lanes they depend on. -Show this to the user. Let them correct it before you create anything. +Examples of prompts that should fan out (using placeholder profile names — substitute whatever exists on the user's setup): + +- "Build an app" → one card to a design-oriented profile for product/UI direction, one or two cards to engineering profiles for implementation, plus a later integration/review card if the user has a reviewer profile. +- "Fix blockers and check model variants" → one implementation card for the blocker fixes plus one discovery/research card for config/source verification. A final reviewer card can depend on both. +- "Research docs and implement" → a docs-research card can run in parallel with a codebase-discovery card; implementation waits only if it truly needs those findings. +- "Analyze this screenshot and find the related code" → one card to a vision-capable profile for the visual analysis while another searches the codebase. + +Words like "also," "finally," or "and" do not automatically imply a dependency. They often mean "make sure this is covered before reporting back." Only link tasks when one card cannot start until another card's output exists. + +Show the graph to the user before creating cards. Let them correct it — including which actual profile name should own each lane. ### Step 3 — Create tasks and link +Use the profile names from Step 0. The example below uses placeholders `<profile-A>`, `<profile-B>`, `<profile-C>` — replace them with what the user actually has. + ```python t1 = kanban_create( title="research: Postgres cost vs current", - assignee="researcher", + assignee="<profile-A>", # whichever profile handles research on this setup body="Compare estimated infrastructure costs, migration costs, and ongoing ops costs over a 3-year window. Sources: AWS/GCP pricing, team time estimates, current Postgres bills from peers.", tenant=os.environ.get("HERMES_TENANT"), )["task_id"] t2 = kanban_create( title="research: Postgres performance vs current", - assignee="researcher", + assignee="<profile-A>", # same profile, run in parallel body="Compare query latency, throughput, and scaling characteristics at our expected data volume (~500GB, 10k QPS peak). Sources: benchmark papers, public case studies, pgbench results if easy.", )["task_id"] t3 = kanban_create( title="synthesize migration recommendation", - assignee="analyst", + assignee="<profile-B>", # whichever profile does synthesis/analysis body="Read the findings from T1 (cost) and T2 (performance). Produce a 1-page recommendation with explicit trade-offs and a go/no-go call.", parents=[t1, t2], )["task_id"] t4 = kanban_create( title="draft decision memo", - assignee="writer", + assignee="<profile-C>", # whichever profile drafts user-facing prose body="Turn the analyst's recommendation into a 2-page memo for the CTO. Match the tone of previous decision memos in the team's knowledge base.", parents=[t3], )["task_id"] @@ -122,17 +135,17 @@ t4 = kanban_create( ### Step 4 — Complete your own task -If you were spawned as a task yourself (e.g. `planner` profile was assigned `T0: "investigate Postgres migration"`), mark it done with a summary of what you created: +If you were spawned as a task yourself (e.g. a planner profile was assigned `T0: "investigate Postgres migration"`), mark it done with a summary of what you created: ```python kanban_complete( - summary="decomposed into T1-T4: 2 researchers parallel, 1 analyst on their outputs, 1 writer on the recommendation", + summary="decomposed into T1-T4: 2 research lanes in parallel, 1 synthesis on their outputs, 1 prose draft on the recommendation", metadata={ "task_graph": { - "T1": {"assignee": "researcher", "parents": []}, - "T2": {"assignee": "researcher", "parents": []}, - "T3": {"assignee": "analyst", "parents": ["T1", "T2"]}, - "T4": {"assignee": "writer", "parents": ["T3"]}, + "T1": {"assignee": "<profile-A>", "parents": []}, + "T2": {"assignee": "<profile-A>", "parents": []}, + "T3": {"assignee": "<profile-B>", "parents": ["T1", "T2"]}, + "T4": {"assignee": "<profile-C>", "parents": ["T3"]}, }, }, ) @@ -140,28 +153,38 @@ kanban_complete( ### Step 5 — Report back to the user -Tell them what you created in plain prose: +Tell them what you created in plain prose, naming the actual profiles you used: > I've queued 4 tasks: -> - **T1** (researcher): cost comparison -> - **T2** (researcher): performance comparison, in parallel with T1 -> - **T3** (analyst): synthesizes T1 + T2 into a recommendation -> - **T4** (writer): turns T3 into a CTO memo +> - **T1** (`<profile-A>`): cost comparison +> - **T2** (`<profile-A>`): performance comparison, in parallel with T1 +> - **T3** (`<profile-B>`): synthesizes T1 + T2 into a recommendation +> - **T4** (`<profile-C>`): turns T3 into a CTO memo > > The dispatcher will pick up T1 and T2 now. T3 starts when both finish. You'll get a gateway ping when T4 completes. Use the dashboard or `hermes kanban tail <id>` to follow along. ## Common patterns -**Fan-out + fan-in (research → synthesize):** N `researcher` tasks with no parents, one `analyst` task with all of them as parents. +**Fan-out + fan-in (research → synthesize):** N research-style cards with no parents, one synthesis card with all of them as parents. -**Pipeline with gates:** `pm → backend-eng → reviewer`. Each stage's `parents=[previous_task]`. Reviewer blocks or completes; if reviewer blocks, the operator unblocks with feedback and respawns. +**Parallel implementation + validation:** one implementer card makes the change while one explorer/researcher card verifies config, docs, or source mapping. A reviewer card can depend on both. Do not make the implementer own unrelated verification just because the user mentioned both in one sentence. -**Same-profile queue:** 50 tasks, all assigned to `translator`, no dependencies between them. Dispatcher serializes — translator processes them in priority order, accumulating experience in their own memory. +**Pipeline with gates:** `planner → implementer → reviewer`. Each stage's `parents=[previous_task]`. Reviewer blocks or completes; if reviewer blocks, the operator unblocks with feedback and respawns. + +**Same-profile queue:** N tasks, all assigned to the same profile, no dependencies between them. Dispatcher serializes — that profile processes them in priority order, accumulating experience in its own memory. **Human-in-the-loop:** Any task can `kanban_block()` to wait for input. Dispatcher respawns after `/unblock`. The comment thread carries the full context. ## Pitfalls +**Inventing profile names that don't exist.** The dispatcher silently fails to spawn unknown assignees — the card just sits in `ready` forever. Always assign to a profile from your Step 0 discovery; ask the user if you're unsure. + +**Bundling independent lanes into one card.** If the user asks for two independent outcomes, create two cards. Example: "fix blockers and check model variants" is not one fixer task; create a fixer/engineer card for the fixes and an explorer/researcher card for the variant check, then optionally gate review on both. + +**Over-linking because of wording.** "Finally check X" may still be parallel with implementation if X is static config, docs, or source discovery. Link it after implementation only when the check depends on the implementation result. + +**Forgetting dependency links.** If the task graph says `research -> implement -> review`, do not create all tasks as independent ready cards. Use parent links so implement/review cannot run before their inputs exist. + **Reassignment vs. new task.** If a reviewer blocks with "needs changes," create a NEW task linked from the reviewer's task — don't re-run the same task with a stern look. The new task is assigned to the original implementer profile. **Argument order for links.** `kanban_link(parent_id=..., child_id=...)` — parent first. Mixing them up demotes the wrong task to `todo`. @@ -175,7 +198,7 @@ Tell them what you created in plain prose: When a worker profile keeps crashing, hallucinating, or getting blocked by its own mistakes (usually: wrong model, missing skill, broken credential), the kanban dashboard flags the task with a ⚠ badge and opens a **Recovery** section in the drawer. Three primary actions: 1. **Reclaim** (or `hermes kanban reclaim <task_id>`) — abort the running worker immediately and reset the task to `ready`. The existing claim TTL is ~15 min; this is the fast path out. -2. **Reassign** (or `hermes kanban reassign <task_id> <new-profile> --reclaim`) — switch the task to a different profile and let the dispatcher pick it up with a fresh worker. +2. **Reassign** (or `hermes kanban reassign <task_id> <new-profile> --reclaim`) — switch the task to a different profile (one that exists on this setup) and let the dispatcher pick it up with a fresh worker. 3. **Change profile model** — the dashboard prints a copy-paste hint for `hermes -p <profile> model` since profile config lives on disk; edit it in a terminal, then Reclaim to retry with the new model. Hallucination warnings appear on tasks where a worker's `kanban_complete(created_cards=[...])` claim included card ids that don't exist or weren't created by the worker's profile (the gate blocks the completion), or where the free-form summary references `t_<hex>` ids that don't resolve (advisory prose scan, non-blocking). Both produce audit events that persist even after recovery actions — the trail stays for debugging. diff --git a/website/scripts/extract-skills.py b/website/scripts/extract-skills.py index 302fbe51c30..b508eb19872 100644 --- a/website/scripts/extract-skills.py +++ b/website/scripts/extract-skills.py @@ -309,7 +309,7 @@ MIN_CATEGORY_SIZE = 4 def _consolidate_small_categories(skills: list) -> list: for s in skills: - if s["category"] in ("uncategorized", ""): + if s["category"] in {"uncategorized", ""}: s["category"] = "other" s["categoryLabel"] = "Other" diff --git a/website/scripts/generate-llms-txt.py b/website/scripts/generate-llms-txt.py index 5bb2c65cb53..a34c57792a3 100644 --- a/website/scripts/generate-llms-txt.py +++ b/website/scripts/generate-llms-txt.py @@ -280,7 +280,7 @@ def emit_llms_full() -> str: rel = path.relative_to(DOCS) parts = rel.parts if len(parts) >= 3 and parts[0] == "user-guide" and parts[1] == "skills" \ - and parts[2] in ("bundled", "optional"): + and parts[2] in {"bundled", "optional"}: continue seen.add(path) meta, body = read_frontmatter(path) diff --git a/website/sidebars.ts b/website/sidebars.ts index 938eb9c0677..c96db714760 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -68,6 +68,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/delegation', 'user-guide/features/kanban', 'user-guide/features/kanban-tutorial', + 'user-guide/features/kanban-worker-lanes', 'user-guide/features/goals', 'user-guide/features/code-execution', 'user-guide/features/hooks', @@ -141,6 +142,7 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/teams', 'user-guide/messaging/teams-meetings', 'user-guide/messaging/msgraph-webhook', + 'user-guide/messaging/line', 'user-guide/messaging/open-webui', 'user-guide/messaging/webhooks', ], @@ -220,6 +222,7 @@ const sidebars: SidebarsConfig = { 'developer-guide/context-engine-plugin', 'developer-guide/model-provider-plugin', 'developer-guide/image-gen-provider-plugin', + 'developer-guide/plugin-llm-access', 'developer-guide/creating-skills', 'developer-guide/extending-the-cli', ], diff --git a/website/src/components/UserStoriesCollage/index.tsx b/website/src/components/UserStoriesCollage/index.tsx index 79e2564496b..cb6a3ca4b46 100644 --- a/website/src/components/UserStoriesCollage/index.tsx +++ b/website/src/components/UserStoriesCollage/index.tsx @@ -125,6 +125,7 @@ const SOURCE_LABELS: Record<string, string> = { linkedin: 'LinkedIn', gist: 'GitHub Gist', producthunt: 'Product Hunt', + discord: 'Discord', }; function sourceColor(source: string): string { @@ -139,6 +140,7 @@ function sourceColor(source: string): string { case 'linkedin': return '#0a66c2'; case 'gist': return '#8b949e'; case 'producthunt': return '#da552f'; + case 'discord': return '#5865f2'; default: return '#64748b'; } } diff --git a/website/src/data/userStories.json b/website/src/data/userStories.json index 651589426ed..bf98199b902 100644 --- a/website/src/data/userStories.json +++ b/website/src/data/userStories.json @@ -1,334 +1,4 @@ [ - { - "id": "teknium-12-instances", - "source": "x", - "author": "@Teknium", - "url": "https://x.com/Teknium/status/2047869295686975529", - "date": "2026-04-25", - "category": "dev-workflow", - "headline": "12 Hermes instances every day, in parallel", - "quote": "I literally run 12 hermes agent instances every day in parallel to build Hermes Agent, and its now a top 100 GitHub repositories of all time. Our backend team uses it to monitor and investigate issues with our stack. Our post training team uses them to create new RL environments and benchmarks, investigate, inspect and sometimes directly manipulate the datasets.", - "size": "lg" - }, - { - "id": "alexcovo-movies", - "source": "x", - "author": "@alexcovo_eth", - "url": "https://x.com/alexcovo_eth/status/2046437996262539539", - "date": "2026-04-21", - "category": "creative", - "headline": "My Hermes agent makes movies now", - "quote": "My @NousResearch hermes-agent can make movies now using @browser_use skill. No API needed. No human intervention. I told it to set the mood, action, camera movement, dialog and overall story — it used Browser-Use and Seedance 2.0 to generate a video.", - "size": "md" - }, - { - "id": "exm-family-whatsapp", - "source": "x", - "author": "@EXM7777", - "url": "https://x.com/EXM7777/status/2049869015221510424", - "date": "2026-04-30", - "category": "personal-assistant", - "headline": "One Hermes for the whole family on WhatsApp", - "quote": "3 weeks ago I decided to setup an Hermes agent for my family (3 members), they all use it for different use cases, one $200 ChatGPT sub is more than enough. It unlocked a whole new world for them, just because it lives inside whatsapp and has magic proactive behaviors.", - "size": "md" - }, - { - "id": "gkisokay-autobuild", - "source": "x", - "author": "@gkisokay", - "url": "https://x.com/gkisokay/status/2044339964612362499", - "date": "2026-04-15", - "category": "dev-workflow", - "headline": "Multi-agent auto-build workflow (plan → code → QA → ship)", - "quote": "Day 8 of Building AGI for my Hermes Agent: Auto-Build saved me loads of time and tokens. Main agent (GPT-5.4) breaks a plan into phases, coder agent (MiniMax M2.7) implements, QA agent (local Qwen 35B A3B) tests. Plan → implement → test → fail → repair → ship.", - "size": "md" - }, - { - "id": "gkisokay-watchdog", - "source": "x", - "author": "@gkisokay", - "url": "https://x.com/gkisokay/status/2037924543311016432", - "date": "2026-03-28", - "category": "dev-workflow", - "headline": "Hermes as a watchdog for my other agent", - "quote": "POV: you use Hermes agent to fix your OpenClaw to save countless hours and credits every day. The setup that saved me hours every day: OpenClaw + Hermes watchdog.", - "size": "sm" - }, - { - "id": "gkisokay-research-brief", - "source": "x", - "author": "@gkisokay", - "url": "https://x.com/gkisokay/status/2050026869274395020", - "date": "2026-05-01", - "category": "research", - "headline": "Daily research brief across Discord, Slack, Notion & Obsidian", - "quote": "There's one Hermes use case for everyone — build a research agent. Mine watches the AI/agent space, picks out useful signals, writes briefs, suggests content angles, tracks what I ignore, and keeps improving its own workflow. Delivers daily via Discord, Slack, Notion, email, Obsidian, and local markdown.", - "size": "md" - }, - { - "id": "adiix-polymarket", - "source": "x", - "author": "@adiix_official", - "url": "https://x.com/adiix_official/status/2046702189469450616", - "date": "2026-04-21", - "category": "trading", - "headline": "Polymarket trading, 4 layers in parallel", - "quote": "Hermes changed how I trade on Polymarket. Before: I looked at Yes/No price and guessed. Now: I read 4 layers at once — order book, on-chain addresses, lag between news and price, position changes. Hermes monitors all 4 in parallel through its Polymarket module + News Skill.", - "size": "md" - }, - { - "id": "deronin-weather", - "source": "x", - "author": "@DeRonin_", - "url": "https://x.com/DeRonin_/status/2045087400607568378", - "date": "2026-04-17", - "category": "trading", - "headline": "$100 → $216 in 48h with a self-learning weather bot", - "quote": "I turned $100 into $216 in less than 48 hours with a self-learning weather trading bot. Hermes scans weather markets every 60 mins, compares 3 forecast sources per location, buys undervalued temperature buckets and flips for profit. Reviews what worked, writes its own strategy notes, adjusts next time.", - "size": "md" - }, - { - "id": "technmak-10-days", - "source": "x", - "author": "@techNmak", - "url": "https://x.com/techNmak/status/2041422554729267267", - "date": "2026-04-07", - "category": "dev-workflow", - "headline": "Day 10: it knows my codebase better than I do", - "quote": "10 days ago I installed an open-source agent. Today it knows my codebase better than I do. The first time I built a code review workflow, it was clunky. By the fifth time, the agent had internalized my preferences — which files to check first, what patterns to flag, how to format the output.", - "size": "md" - }, - { - "id": "saboo-monica", - "source": "x", - "author": "@Saboo_Shubham_", - "url": "https://x.com/Saboo_Shubham_/status/2049541356767576388", - "date": "2026-04-29", - "category": "content-creation", - "headline": "Monica that writes in my voice", - "quote": "I kept the OpenClaw squad running, but set up a second Monica on Hermes. Same Mac Mini. Monica had written a procedure for reading my published articles before drafting in my voice. An Agent with skills that grows with you.", - "size": "sm" - }, - { - "id": "ksimback-hermesatlas", - "source": "x", - "author": "@KSimback", - "url": "https://x.com/KSimback/status/2041937777508675611", - "date": "2026-04-08", - "category": "meta", - "headline": "Scraped the entire Hermes ecosystem (hermesatlas.com)", - "quote": "I was an early user of Hermes Agent and have been a power user ever since. Scraped every GitHub repo related to Hermes, filtered out unfinished, built an ecosystem map and published a website (hermesatlas.com) where you can see all projects organized by category with star ratings.", - "size": "md" - }, - { - "id": "codewithimanshu-higgsfield", - "source": "x", - "author": "@codewithimanshu", - "url": "https://x.com/codewithimanshu/status/2047507277259923696", - "date": "2026-04-24", - "category": "marketing", - "headline": "UGC ad studio on Hermes (4 minutes, zero prompt engineering)", - "quote": "Higgsfield Marketing Studio powered by Hermes Agent is doing the replacing this time. Paste product URL → Hermes scrapes the landing page, pulls winning ad hooks from Meta Ads Library + TikTok Creative Center in the exact niche, and writes the brief itself. Total time: ~4 minutes.", - "size": "md" - }, - { - "id": "danfiru-convergence", - "source": "x", - "author": "@danfiru", - "url": "https://x.com/danfiru/status/2036481605666218278", - "date": "2026-03-24", - "category": "dev-workflow", - "headline": "Built my own stack, then converged on Hermes", - "quote": "If you're choosing an agent framework: hermes. I built my own stack independently and we converged on the same architecture — background self-improvement, persistent memory, CLAUDE.md project context, reusable skills. Hermes ships it all out of the box. 300 PRs in a week.", - "size": "md" - }, - { - "id": "nickspisak-everything", - "source": "x", - "author": "@NickSpisak_", - "url": "https://x.com/NickSpisak_/status/2042709705991295221", - "date": "2026-04-10", - "category": "personal-assistant", - "headline": "Replaced everything with a single Hermes agent", - "quote": "Vibe after replacing everything with a Hermes agent: autoresearch, Karpathy LLM wiki second brain, skills creation, scheduled jobs, background monitoring, LLM model selection, Telegram/Discord support. A personal automation agent that lives on a server and talks to you through messaging apps or CLI.", - "size": "md" - }, - { - "id": "mvanhorn-business-ops", - "source": "x", - "author": "@mvanhorn", - "url": "https://x.com/mvanhorn/status/2045935785661349956", - "date": "2026-04-19", - "category": "business-ops", - "headline": "Client research, follow-ups, podcasts, leads — all on Hermes", - "quote": "Client research before calls saves 20–30 min every time. Meeting notes → follow-up drafts. Weekly podcast digest replaced 10+ hrs of listening with a 2hr Hermes workflow using Voxtral. Daily news briefings to Telegram/Discord. Content-ops pipeline (blogs, cold emails, lead scraping from YC, Twitter, Reddit). 24/7 assistant + watchdog.", - "size": "lg" - }, - { - "id": "mishig-jarvis", - "source": "x", - "author": "@mishig25", - "url": "https://x.com/mishig25/status/2044433805017014414", - "date": "2026-04-15", - "category": "personal-assistant", - "headline": "Jarvis at home in 2026", - "quote": "m2.7 + hermes agent: we really got jarvis at home in 2026 but strangely enough no one seems to care.", - "size": "sm" - }, - { - "id": "agentmail-inbox", - "source": "x", - "author": "@agentmail", - "url": "https://x.com/agentmail/status/2041605207704895810", - "date": "2026-04-07", - "category": "integrations", - "headline": "Give your Hermes its own email inbox", - "quote": "Here's how to give your Hermes agent its own email inbox. No SMTP/IMAP, no Google OAuth, just plug in AgentMail using MCP.", - "size": "sm" - }, - { - "id": "akashnet-inventory", - "source": "x", - "author": "@akashnet", - "url": "https://x.com/akashnet/status/2046622301395845264", - "date": "2026-04-21", - "category": "business-ops", - "headline": "Live inventory tracking on Hermes", - "quote": "With Hermes (built by @NousResearch) providing 40+ built-in tools, persistent memory, and subagent parallelization, the development experience is best-in-class. Built for operations like inventory tracking where context, memory, and real-time inputs are non-negotiable.", - "size": "md" - }, - { - "id": "alexfinn-employee", - "source": "x", - "author": "@AlexFinn", - "url": "https://x.com/AlexFinn/status/2049278028619121089", - "date": "2026-04-29", - "category": "general", - "headline": "An AI employee for my hardest tasks", - "quote": "Hermes Agent with ChatGPT 5.5 is literally magic. I've thrown some of my hardest tasks at this combo and the agent has been able to handle EVERYTHING. Time to set up your AI employee.", - "size": "sm" - }, - { - "id": "onlyterp-file-change", - "source": "x", - "author": "@OnlyTerp", - "url": "https://x.com/OnlyTerp/status/2047890882809016805", - "date": "2026-04-25", - "category": "dev-workflow", - "headline": "It sees a file change and auto-acts on it", - "quote": "Hermes is really good. The new updates where it sees a file change and auto acts on it. That shit is fire as fuck.", - "size": "sm" - }, - { - "id": "nathanwilbanks-297-streak", - "source": "x", - "author": "@NathanWilbanks_", - "url": "https://x.com/NathanWilbanks_/status/2047883176622620934", - "date": "2026-04-25", - "category": "business-ops", - "headline": "Day 297 of my streak: $100K of client work automated", - "quote": "I'm on day 297 of my streak: 900,000+ seconds of compute time automated, 5,000,000,000+ tokens generated, $100,000+ in client work value automated.", - "size": "md" - }, - { - "id": "hn-rnxrx-obsidian", - "source": "hn", - "author": "rnxrx (Hacker News)", - "url": "https://news.ycombinator.com/item?id=47786673", - "date": "2026-04", - "category": "personal-assistant", - "headline": "Obsidian, home automation, VPS server management — on a cheap VPS", - "quote": "Having a competent agent with constant state has been good for memorializing and organizing important info directly into Obsidian, planning, and working out bugs with my home automation setup. Also helpful dealing with several miscellaneous servers in the house. I have it running on a cheap VPS and it's fairly locked down.", - "size": "md" - }, - { - "id": "hn-vessel-browser", - "source": "hn", - "author": "unmodeledtyler (Quanta Intellect)", - "url": "https://news.ycombinator.com/item?id=47470156", - "date": "2026", - "category": "integrations", - "headline": "Vessel Browser: agent-native browser born at the Hermes hackathon", - "quote": "I recently participated in Nous Research's Hermes Agent Hackathon, which is where this project was born. Every tool out there assumes a human operator with automation bolted on. I wanted to flip that — make the agent the primary driver and give the human a supervisory role.", - "size": "md" - }, - { - "id": "hn-ethan-install-guide", - "source": "hn", - "author": "ethanjamescolez (Show HN)", - "url": "https://news.ycombinator.com/item?id=47865412", - "date": "2026", - "category": "meta", - "headline": "Show HN: an independent install guide", - "quote": "This is an independent Hermes Agent install guide I put together for the part that usually gets skipped after 'run this command.' One place that shows the environment choice first, then the official installer path — macOS, Linux, WSL2, and Termux.", - "size": "sm" - }, - { - "id": "reddit-hermify", - "source": "reddit", - "author": "r/vibecoding", - "url": "https://www.reddit.com/r/vibecoding/comments/1slhhj1/i_took_the_nousresearch_hermes_agent_and_built_a/", - "date": "2026", - "category": "meta", - "headline": "Hermify: managed hosting for Hermes", - "quote": "A few weeks ago I tried getting Hermes Agent running on a VPS. It worked, eventually, and is lowkey the most useful AI agent. So I built Hermify: easy managed hosting. You bring your API key + Telegram bot, we handle the hosting.", - "size": "sm" - }, - { - "id": "reddit-windows-wrapper", - "source": "reddit", - "author": "r/SideProject", - "url": "https://www.reddit.com/r/SideProject/comments/1sdaojm/i_took_the_nousresearch_hermes_agent_and_built_a/", - "date": "2026", - "category": "meta", - "headline": "Native Windows app wrapper for Hermes", - "quote": "The NousResearch team built Hermes Agent — an open-source agentic AI system with tools, skills, memory, and multi-platform messaging. It's good. So I built a native Windows app around it.", - "size": "sm" - }, - { - "id": "reddit-research-agent", - "source": "reddit", - "author": "r/hermesagent", - "url": "https://www.reddit.com/r/hermesagent/comments/1sd3bwf/had_my_research_agent_dig_into_what_people_are/", - "date": "2026", - "category": "research", - "headline": "I had my research agent dig into what people are building with Hermes", - "quote": "Had my (Hermes) research agent dig into what people are actually building with Hermes — turned up an ecosystem mosaic of trading bots, personal assistants, content pipelines and self-hosted everything.", - "size": "sm" - }, - { - "id": "rumjahn-everything", - "source": "blog", - "author": "Keith Rumjahn (Substack)", - "url": "https://rumjahn.substack.com/p/complete-guide-to-mastering-hermes", - "date": "2026-04-26", - "category": "personal-assistant", - "headline": "Apple Health, Threads analytics, Gmail, Calendar — in one CLI", - "quote": "Apple Health: Hermes wrote Python on the fly and found my sleep avg was 7.59 hrs. Threads Analytics: drop cookies in, pulled 34 posts of analytics in one command. Hermes is dramatically better than OpenClaw at browser automation. Gmail + Calendar OAuth via drag-drop JSON. Hermes = CEO, OpenClaw = Senior Engineer, both pointed at the same Obsidian vault on my NAS.", - "size": "lg" - }, - { - "id": "jsong-llm-wiki", - "source": "blog", - "author": "Jsong (Medium)", - "url": "https://medium.com/@jsong_49820/how-i-built-a-self-improving-llm-wiki-with-hermes-agent-and-why-im-not-using-obsidian-1e9a7fa438c1", - "date": "2026-04-16", - "category": "research", - "headline": "A self-improving LLM Wiki second brain", - "quote": "Built a personal knowledge base that compounds over time instead of rotting — maintained by an LLM, not by me. Stack: Hetzner VPS, Hermes Agent, Telegram bot as second brain, Karpathy's LLM Wiki pattern, public static site at wiki.ai-biz.app.", - "size": "md" - }, - { - "id": "julian-meet-teams", - "source": "blog", - "author": "Julian Goldie (Substack)", - "url": "https://juliangoldieseo1.substack.com/p/hermes-agent-v012-just-changed-ai", - "date": "2026-04-30", - "category": "business-ops", - "headline": "Auto-transcribe Meet calls, control from Teams, local models for client data", - "quote": "Auto-transcribe Google Meet calls — focus on conversation, not notes. Self-maintaining skill library. Control from Microsoft Teams. Local AI models via LM Studio — sensitive client data never leaves your machine. Native Spotify for voice-command music.", - "size": "md" - }, { "id": "anthony-inbox-cron", "source": "blog", @@ -341,146 +11,25 @@ "size": "sm" }, { - "id": "kisztof-modal", - "source": "blog", - "author": "Krzysztof Słomka (Medium)", - "url": "https://kisztof.medium.com/hermes-agent-review-nous-researchs-self-improving-ai-agent-e72bc244435a", - "date": "2026-04-20", + "id": "discord-salma-nextcloud-libreoffice", + "source": "discord", + "author": "@salma.1492", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/developers.txt", + "date": "2026-04-18", + "category": "personal-assistant", + "headline": "Self-hosted Google Drive replacement with Nextcloud + LibreOffice", + "quote": "I set up my agent with nextcloud and libreoffice so I can basically get the same functionality as google drive with google docs. The libreoffice writer (like google docs) works fine. But for some reason my agent and I can't get libreoffice calc working.", + "size": "md" + }, + { + "id": "discord-hackafterdark-reverie-core", + "source": "discord", + "author": "@hackafterdark", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1498945272632315974-Reverie-Core--Agentic-cognition-layer-for-Hermes.txt", + "date": "", "category": "dev-workflow", - "headline": "Telegram → Modal serverless. 40% faster on research tasks.", - "quote": "Chat via Telegram while execution runs on Modal serverless (cheap when idle). Run on a $5 VPS that stays up when the laptop closes. Pin to SSH backend inside a customer's VPC for consulting. Verified benchmark (TokenMix): self-created skills cut research-task time by ~40% vs. a fresh agent.", - "size": "md" - }, - { - "id": "0xmega-no-mac-mini", - "source": "blog", - "author": "Alex P. (Medium)", - "url": "https://medium.com/@0xmega/hermes-agent-the-complete-setup-guide-telegram-discord-vps-no-mac-mini-required-dda315a702d3", - "date": "2026-03-30", - "category": "cost-optimization", - "headline": "Under $20/mo total — no Mac Mini, no Opus", - "quote": "OpenClaw setup: Mac Mini M4 ($599) + Opus 4.6 = ~$80–150/mo. Hermes on VPS: under $20/mo total using Minimax M2.7. Example first task: 'check the top 5 trending GitHub repos right now and send me a summary.'", - "size": "md" - }, - { - "id": "derek-supabase-crm", - "source": "youtube", - "author": "Derek Cheung (YouTube)", - "url": "https://www.youtube.com/watch?v=W_ZgH0WPayo", - "date": "2026", - "category": "business-ops", - "headline": "24/7 assistant with a Supabase CRM, built in a demo", - "quote": "Less than a single ChatGPT Plus subscription for a 24/7 assistant with real data management. After several interactions, Hermes autonomously proposed a new 'Supabase MCP scripts' skill — created from its own reflection.", - "size": "md" - }, - { - "id": "gladiator-hackathon", - "source": "youtube", - "author": "exitcode42 (YouTube)", - "url": "https://www.youtube.com/watch?v=YqLcMmzl3Yg", - "date": "2026", - "category": "dev-workflow", - "headline": "GLADIATOR: 9 Hermes agents, two rival AI companies, one GitHub stars war", - "quote": "Two fully autonomous AI companies competing head-to-head to maximize GitHub stars. 9 Hermes agents split into rival companies. Hermes agents actually learn and improve — they wrote code, created skills, grew memory, committed to git. All on their own.", - "size": "md" - }, - { - "id": "worldofai-shadcn-manim", - "source": "youtube", - "author": "WorldofAI (YouTube)", - "url": "https://www.youtube.com/watch?v=cu2fgknmemA", - "date": "2026-04-07", - "category": "creative", - "headline": "shadcn finance dashboard + Manim explainer videos", - "quote": "Used /browse to add Obsidian as a skill, populated a vault with shadcn/ui packages, then asked Hermes to build a finance dashboard using them. Result: beautiful, modern dashboard in minutes. Also used a manim skill to convert complex technical concepts into animated videos.", - "size": "md" - }, - { - "id": "leon-amazon-titles", - "source": "youtube", - "author": "Leon van Zyl (YouTube)", - "url": "https://www.youtube.com/watch?v=jmtpYUOr7_U", - "date": "2026", - "category": "content-creation", - "headline": "Scraped Amazon without extra config; built a YouTube title skill", - "quote": "Successfully scraped Amazon (notoriously difficult) without additional config. Free speech-to-text via local Whisper, free TTS via Edge TTS. YouTube title generator skill produces five search-based, five browse-targeted, and five hybrid titles.", - "size": "md" - }, - { - "id": "betterstack-tweets", - "source": "youtube", - "author": "Better Stack (YouTube)", - "url": "https://www.youtube.com/watch?v=HdxtLpL9CC8", - "date": "2026", - "category": "content-creation", - "headline": "Tweets in my voice, pulled from past video scripts", - "quote": "Prompted Hermes to help write tweets based on past video scripts. Pointed it at a scripts folder; it analyzed my writing style, produced usable tweets, and saved preferences to memory automatically. Brand new session test: it recalled everything, including preferred emojis.", - "size": "md" - }, - { - "id": "metics-weekly-cron", - "source": "youtube", - "author": "Metics Media (YouTube)", - "url": "https://www.youtube.com/watch?v=CwPUOVUdApE", - "date": "2026", - "category": "content-creation", - "headline": "Weekly cron: top 3 trending AI tools for my next video", - "quote": "'Research the top trending AI tools right now and come back with the top three that would make for an interesting tutorial video. Create a new skill based on your approach and call it YouTube-video-research. Can you set up a weekly job that runs every Monday at 9:00 AM using that skill?'", - "size": "md" - }, - { - "id": "theo-hetzner", - "source": "youtube", - "author": "Théo Vigneres (YouTube)", - "url": "https://www.youtube.com/watch?v=tm4h8dG-xlI", - "date": "2026-03", - "category": "cost-optimization", - "headline": "Hetzner VPS at $10/mo, Claude Opus via OpenRouter", - "quote": "Personal AI that lives on a server with persistent memory. Remembers preferences, projects, and past problem-solving. Accessible via Terminal, Telegram, Discord, Slack, or WhatsApp. Set up on a $10/month Hetzner VPS with Claude Opus via OpenRouter.", - "size": "sm" - }, - { - "id": "yashica-linkedin", - "source": "youtube", - "author": "Yashica Jain (YouTube)", - "url": "https://www.youtube.com/watch?v=Mom3GVeiBR8", - "date": "2026", - "category": "content-creation", - "headline": "LinkedIn posts that remember my style", - "quote": "Every time you do something — for example, using Hermes to write a LinkedIn post — it uses that experience to create a new skill. Next time you ask it to generate a LinkedIn post, boom, you don't have to give it the same instructions.", - "size": "sm" - }, - { - "id": "greg-isenberg-termux", - "source": "podcast", - "author": "Greg Isenberg & Imran Muthuvappa (Startup Ideas Podcast)", - "url": "https://podcasts.apple.com/dk/podcast/hermes-agent-clearly-explained-and-how-to-use-it/id1593424985?i=1000762440356", - "date": "2026", - "category": "cost-optimization", - "headline": "90% token spend cut. Runs on a cheap Android via Termux.", - "quote": "Switching to Hermes with OpenRouter cut my token spend ~90% — from ~$130 per 5 days to ~$10 per 5 days. Hermes runs on a cheap Android phone via Termux + Termux API — unlocks SMS, sensors, and on-device social posting. Customization is a trap; output is the skill.", - "size": "md" - }, - { - "id": "tooluse-hermes-won", - "source": "podcast", - "author": "Tool Use — AI Conversations (Spotify)", - "url": "https://open.spotify.com/episode/7tF7zf5GKcxqe2Q2BRRNfn", - "date": "2026", - "category": "meta", - "headline": "Hermes Agent has won. Here's why.", - "quote": "Why Hermes Agent has emerged as the leading open-source AI agent that developers and builders are choosing — self-improving skills, three-layer memory architecture, real-world applications including video dubbing workflows.", - "size": "sm" - }, - { - "id": "firecrawl-integration", - "source": "linkedin", - "author": "Firecrawl", - "url": "https://www.linkedin.com/posts/firecrawl_hermes-agent-by-nous-research-can-now-scrape-activity-7445140884683395072-sm2d", - "date": "2026", - "category": "integrations", - "headline": "Firecrawl for scrape/search/browse", - "quote": "Hermes Agent by Nous Research can now scrape, search, and interact with the web using Firecrawl. Enable it during setup to give Hermes the ability.", + "headline": "Iterating on a local-first cognition layer for Hermes", + "quote": "I've been iterating on a local-first memory/cognition layer for Hermes and finally pushed it to public visibility.", "size": "sm" }, { @@ -495,465 +44,58 @@ "size": "sm" }, { - "id": "andrew-gordon-5-apps", - "source": "linkedin", - "author": "Andrew W. Gordon", - "url": "https://www.linkedin.com/posts/andrewwgordon_hermes-agent-the-agent-that-grows-with-activity-7449351350800429056-Alw0", - "date": "2026", - "category": "dev-workflow", - "headline": "5 apps built and launched in a single day", - "quote": "I've switched to Nous-Research Hermes-Agent from previous Agents I've been experimenting with. Hermes is unique in that it self-learns. Within a single day, I built and launched five small applications.", - "size": "sm" - }, - { - "id": "davidondrej-browser-harness", - "source": "gist", - "author": "davidondrej (GitHub Gist)", - "url": "https://gist.github.com/davidondrej/6f158de34ce83c530526011054fde8d3", - "date": "2026", - "category": "integrations", - "headline": "Hermes + Browser Harness on a Hostinger VPS", - "quote": "Full copy-paste setup for Hermes Agent + Browser Harness on a Hostinger VPS. Register Browser Harness as a Hermes skill via symlink so Hermes can find and use it. Recommended model: anthropic/claude-opus-4.7 via OpenRouter.", - "size": "sm" - }, - { - "id": "nazt-mcp-hybrid", - "source": "gist", - "author": "nazt (GitHub Gist)", - "url": "https://gist.github.com/nazt/849e29cd25c148b6cebafdbcc38bb6cc", - "date": "2026", - "category": "integrations", - "headline": "Fat agent → thin tool provider via hermes mcp serve", - "quote": "hermes mcp serve turns Hermes from a monolithic agent into a composable capability layer — any MCP client can borrow Hermes's 15+ messaging platforms, SQLite FTS5 persistence, and 73-skill tool surface without running Hermes as the primary agent.", - "size": "md" - }, - { - "id": "gh-trevor-imessage", - "source": "github", - "author": "@trevorgordon981", - "url": "https://github.com/NousResearch/hermes-agent/issues/6430", - "date": "2026", - "category": "personal-assistant", - "headline": "Hermes over iMessage on my always-on Mac Studio", - "quote": "I run Hermes Agent as a personal AI assistant on a Mac Studio that is always on. My primary communication with other people happens through iMessage. I can message my assistant from my iPhone, iPad, Mac, or Apple Watch. Group chats with friends could include the assistant naturally.", - "size": "md" - }, - { - "id": "gh-xwm1234-factory", - "source": "github", - "author": "@Xwm1234", - "url": "https://github.com/NousResearch/hermes-agent/issues/11653", - "date": "2026", - "category": "business-ops", - "headline": "Task-centric memory for a printing factory", - "quote": "I run a printing factory and use Hermes daily. Long conversations were making the agent slow and forgetful. So I built a custom Skill called Task-Centric Memory — auto-categorizes tasks into domains (Printing, Stocks); completed tasks are compressed into summary cards.", - "size": "md" - }, - { - "id": "gh-juan-email-pipeline", - "source": "github", - "author": "@JuanDragin", - "url": "https://github.com/NousResearch/hermes-agent/issues/5563", - "date": "2026", - "category": "dev-workflow", - "headline": "8h/day on Opus: email pipeline with DBOS + Postgres + S3", - "quote": "I run it daily for production software development, orchestrating a 3-actor email processing pipeline with DBOS, PostgreSQL, S3, Gmail API. 8+ hours per day on Claude Opus for 3 weeks.", - "size": "md" - }, - { - "id": "gh-chrisr-horse-racing", - "source": "github", - "author": "@Chrisr6records", - "url": "https://github.com/NousResearch/hermes-agent/issues/4431", - "date": "2026", - "category": "personal-assistant", - "headline": "Horse-racing Telegram community bot", - "quote": "I run two Telegram groups through one gateway: a project group and a horse-racing community. Every session gets the same personality, system prompt, CLAUDE.md, and working directory — I want per-group specialization.", - "size": "sm" - }, - { - "id": "gh-arkka-legal", - "source": "github", - "author": "@arkka", - "url": "https://github.com/NousResearch/hermes-agent/issues/15562", - "date": "2026", - "category": "privacy", - "headline": "Legal-domain work on an edge GPU, 4B Gemma, no cloud APIs", - "quote": "I run Hermes self-hosted on a single edge-class GPU with a 4B Gemma model. I work with legal-domain material and internal systems I cannot ship to third-party APIs. Self-hosting the main loop is non-negotiable.", - "size": "md" - }, - { - "id": "gh-manoj-pi4", - "source": "github", - "author": "@manojmukkamala", - "url": "https://github.com/NousResearch/hermes-agent/issues/14197", - "date": "2026", - "category": "personal-assistant", - "headline": "Hermes running on a Pi 4 as my home server", - "quote": "I have Hermes running on a Pi4. It saves my preferences while working on tasks like modifying files. I want to use it as a central brain shared across all my devices.", - "size": "sm" - }, - { - "id": "gh-kovern-bedtime", - "source": "github", - "author": "@kovern", - "url": "https://github.com/NousResearch/hermes-agent/issues/17177", - "date": "2026", - "category": "personal-assistant", - "headline": "Bedtime stories for my daughter", - "quote": "Three days ago I asked Hermes to write a little tale for my daughter. A day later I asked again — very similar, same protagonist name.", - "size": "sm" - }, - { - "id": "gh-jgravelle-jmunch", - "source": "github", - "author": "@jgravelle", - "url": "https://github.com/NousResearch/hermes-agent/issues/10409", - "date": "2026", - "category": "integrations", - "headline": "jMunch MCP: 52 tools via tree-sitter for code intelligence", - "quote": "The jMunch MCP suite provides three MCP servers bringing token-efficient code intelligence (52 tools via tree-sitter), documentation retrieval, and tabular data analysis. Plug-and-play with Hermes's native MCP client.", - "size": "md" - }, - { - "id": "gh-edward-win", - "source": "github", - "author": "@EdwardWason", - "url": "https://github.com/NousResearch/hermes-agent/issues/11876", - "date": "2026", - "category": "meta", - "headline": "hermes-for-win: one-click Windows installer", - "quote": "As a Windows user I found getting Hermes running on Windows quite challenging. I created hermes-for-win, a one-click installation and deployment tool for Windows with auto-start via Task Scheduler.", - "size": "sm" - }, - { - "id": "gh-0xmrblue-computer-use", - "source": "github", - "author": "@0xMrBlueOps", - "url": "https://github.com/NousResearch/hermes-agent/issues/15876", - "date": "2026", - "category": "integrations", - "headline": "Desktop computer-use module: noVNC, screenshots, mouse/keyboard", - "quote": "I built an optional desktop computer-use module for Hermes: computer_use_tool.py plus a containerized desktop with persistent Chromium, mouse/keyboard control, and screenshots.", - "size": "sm" - }, - { - "id": "gh-bsxy-higress", - "source": "github", - "author": "@bsxyswsy6n", - "url": "https://github.com/NousResearch/hermes-agent/issues/8881", - "date": "2026", - "category": "enterprise", - "headline": "Hermes inside an MCP infrastructure behind Higress", - "quote": "We are deploying Hermes as part of an MCP infrastructure using Higress as the API Gateway. Currently Hermes only supports CLI mode, preventing management as a service in our mesh.", - "size": "sm" - }, - { - "id": "gh-pypl0-ombre", - "source": "github", - "author": "@pypl0", - "url": "https://github.com/NousResearch/hermes-agent/issues/17431", - "date": "2026", - "category": "enterprise", - "headline": "EU AI Act compliance via Ombre", - "quote": "Adding Ombre underneath creates a production-ready stack: tamper-proof audit, prompt-injection blocking, memory encryption at rest, hallucination detection, cost tracking, EU AI Act compliance exports.", - "size": "sm" - }, - { - "id": "gh-samdu-kubernetes", - "source": "github", - "author": "@samdu", - "url": "https://github.com/NousResearch/hermes-agent/issues/11248", - "date": "2026", - "category": "enterprise", - "headline": "Kubernetes pod-hop handoff across restarts", - "quote": "When the gateway pod restarts (toolbox redeploy) in-memory context is lost. Proposes pod-hop, letting a running gateway hand off to a standby on a shared PVC.", - "size": "sm" - }, - { - "id": "gh-prasad-vertex", - "source": "github", - "author": "@prasadus92", - "url": "https://github.com/NousResearch/hermes-agent/issues/13484", - "date": "2026", - "category": "enterprise", - "headline": "Vertex AI for GCP-standardized enterprises", - "quote": "Requesting native Vertex AI provider support for enterprise users who standardize on Google Cloud for AI workloads.", - "size": "sm" - }, - { - "id": "gh-yuga-line", - "source": "github", - "author": "@yuga-hashimoto", - "url": "https://github.com/NousResearch/hermes-agent/issues/8395", - "date": "2026", - "category": "messaging", - "headline": "LINE for 95M+ users in Japan", - "quote": "LINE is the dominant messaging platform in Japan and SE Asia (95M+ MAU in Japan). No way to use Hermes from LINE today, making it inaccessible to a large user base in that region.", - "size": "sm" - }, - { - "id": "gh-2024fatwolf-qq", - "source": "github", - "author": "@2024fatwolf55", - "url": "https://github.com/NousResearch/hermes-agent/issues/9166", - "date": "2026", - "category": "messaging", - "headline": "QQ Bot adapter for China", - "quote": "Add QQ Bot platform support enabling communication via China's most popular messaging platform. Fully implemented and tested a QQ Bot adapter (822 lines).", - "size": "sm" - }, - { - "id": "gh-haoqi-feishu", - "source": "github", - "author": "@haoqimeng1992", - "url": "https://github.com/NousResearch/hermes-agent/issues/10356", - "date": "2026", - "category": "messaging", - "headline": "Give Hermes hands inside Feishu (Lark)", - "quote": "Extending Hermes to full Feishu ecosystem coverage: Documents, Sheets, Bitable, Calendar, Tasks, Wiki, Contacts, Drive, Email. Giving Hermes hands to operate the entire Feishu workspace.", - "size": "sm" - }, - { - "id": "gh-oleg-multi-role", - "source": "github", - "author": "@OlegB333", - "url": "https://github.com/NousResearch/hermes-agent/issues/5143", - "date": "2026", - "category": "personal-assistant", - "headline": "One agent, many roles: nutritionist, developer, finance advisor", - "quote": "Users treat their AI agent as a unified personal assistant across life domains: health tracking, software dev, financial planning, language learning. Multi-role auto-routing with named roles.", - "size": "sm" - }, - { - "id": "gh-alexferrari-checkin", - "source": "github", - "author": "@alexferrari88", - "url": "https://github.com/NousResearch/hermes-agent/issues/9645", - "date": "2026", - "category": "personal-assistant", - "headline": "Proactive check-ins ('anything you want me to watch this afternoon?')", - "quote": "Some users want something more like a personal assistant: present, a bit more alive, and able to gently re-engage. 'Hey, anything you want me to keep an eye on this afternoon?'", - "size": "sm" - }, - { - "id": "gh-tcollins-audit", - "source": "github", - "author": "@tcollins024", - "url": "https://github.com/NousResearch/hermes-agent/issues/17619", - "date": "2026", - "category": "dev-workflow", - "headline": "Audited 129 of my own sessions across 23 days", - "quote": "Ran an external RCA script against my full local session history (129 sessions across 23 days) to audit Hermes compliance with its approval gate. 112 of 129 sessions contain at least one violation.", - "size": "md" - }, - { - "id": "gh-rohit-agentmemory", - "source": "github", - "author": "@rohitg00", - "url": "https://github.com/NousResearch/hermes-agent/issues/6715", - "date": "2026", - "category": "integrations", - "headline": "Cross-agent memory: Hermes + Claude Code + Cursor", - "quote": "Built a memory provider plugin connecting agentmemory to Hermes. Covers cross-agent memory (developer using Hermes plus Claude Code or Cursor) with hybrid BM25+vector+knowledge-graph search.", - "size": "sm" - }, - { - "id": "gh-iacker-discord-gate", - "source": "github", - "author": "@iacker", - "url": "https://github.com/NousResearch/hermes-agent/issues/13124", - "date": "2026", - "category": "messaging", - "headline": "DM-based approval gate for kid-facing Discord bots", - "quote": "Running Hermes on Discord in public channels, every outbound reply goes live instantly. For multi-user servers, persona testing, compliance, kid-facing bots — I want a human-in-the-loop gate.", - "size": "sm" - }, - { - "id": "gh-scotttrinh-vercel", - "source": "github", - "author": "@scotttrinh", - "url": "https://github.com/NousResearch/hermes-agent/pull/17445", - "date": "2026", - "category": "integrations", - "headline": "Vercel Sandbox as a Hermes backend", - "quote": "Adds Vercel Sandbox as a supported Hermes terminal backend alongside Local/Docker/Modal/SSH/Daytona/Singularity. Creates/manages cloud microVMs with snapshot-based filesystem persistence.", - "size": "sm" - }, - { - "id": "gh-shloms-touchdesigner", - "source": "github", - "author": "@SHL0MS", - "url": "https://github.com/NousResearch/hermes-agent/pull/16768", - "date": "2026", - "category": "creative", - "headline": "Generative visuals in TouchDesigner, via Hermes skill", - "quote": "Expands touchdesigner-mcp skill with extensive reference docs so Hermes can help build generative/interactive media projects in TouchDesigner.", - "size": "sm" - }, - { - "id": "gh-austin-latex", - "source": "github", - "author": "@austinpickett", - "url": "https://github.com/NousResearch/hermes-agent/pull/17175", - "date": "2026", - "category": "research", - "headline": "LaTeX math renders properly in the TUI", - "quote": "Adds LaTeX-to-Unicode rendering for math in the TUI markdown pipeline, so users working on math/ML content see proper formatting rather than raw LaTeX.", - "size": "sm" - }, - { - "id": "gh-declan-webchat", - "source": "github", - "author": "@declan2010", - "url": "https://github.com/NousResearch/hermes-agent/issues/4514", - "date": "2026", - "category": "integrations", - "headline": "Webchat: custom themed browser UI on MEMORY.md", - "quote": "I created a beautiful web interface for Hermes Agent that adds dark/light theme, persistent memory using MEMORY.md and USER.md, per-session chat history, status bar, responsive on mobile and desktop.", - "size": "sm" - }, - { - "id": "gh-romanescu-skillfactory", - "source": "github", - "author": "@Romanescu11", - "url": "https://github.com/NousResearch/hermes-agent/issues/1935", - "date": "2026", - "category": "dev-workflow", - "headline": "Skill Factory: silently watches workflows and writes SKILL.md + plugin.py", - "quote": "I built a community plugin for Hermes called Skill Factory. It silently watches your workflows during a session and automatically proposes and generates reusable skills (SKILL.md + plugin.py) from them.", - "size": "sm" - }, - { - "id": "gh-autholykos-ccd", - "source": "github", - "author": "@autholykos", - "url": "https://github.com/NousResearch/hermes-agent/issues/4837", - "date": "2026", - "category": "dev-workflow", - "headline": "CCD multi-agent pod on an M2 Ultra with Mem0 + Qdrant", - "quote": "CCD v1.0.0-alpha installed on M2 Ultra. A Nanto pod exists with profiles for each agent (raoh, juza, rei, ken). Mem0 memory backend on Qdrant. Native MCP integration would make CCD tools first-class.", - "size": "sm" - }, - { - "id": "gh-bichev-dashboard", - "source": "github", - "author": "@Bichev", - "url": "https://github.com/NousResearch/hermes-agent/issues/4379", - "date": "2026", - "category": "dev-workflow", - "headline": "73% of every API call is fixed overhead (I measured it)", - "quote": "I built a monitoring dashboard to profile token consumption on a Hermes v0.6.0 deployment running Telegram + WhatsApp + Cron gateways. After analyzing 6 request dumps, I found that 73% of every API call is fixed overhead.", - "size": "sm" - }, - { - "id": "gh-enigma-merxex", - "source": "github", - "author": "@enigma-zeroclaw", - "url": "https://github.com/NousResearch/hermes-agent/issues/13562", - "date": "2026", - "category": "integrations", - "headline": "Agent-to-agent commerce via Merxex", - "quote": "I'm building Merxex, an agent-to-agent commerce platform that lets agents buy and sell services/work seamlessly. Hermes agents could benefit from a native monetization layer.", - "size": "sm" - }, - { - "id": "gh-artile-zed", - "source": "github", - "author": "@artile", - "url": "https://github.com/NousResearch/hermes-agent/issues/16028", - "date": "2026", - "category": "integrations", - "headline": "Hermes in Zed editor via ACP Registry", - "quote": "Add Hermes Agent to the Agent Client Protocol (ACP) Registry so it can be automatically discovered and installed by editors like Zed.", - "size": "sm" - }, - { - "id": "gh-paultisl-tailscale", - "source": "github", - "author": "@PaulTisl", - "url": "https://github.com/NousResearch/hermes-agent/issues/9269", - "date": "2026", - "category": "privacy", - "headline": "Tailscale serve for secure remote access, no exposed ports", - "quote": "Users want secure remote access to the Hermes API server / Open WebUI without exposing ports publicly. Tailscale serve provides zero-config HTTPS tunneling over a private mesh.", - "size": "sm" - }, - { - "id": "gh-zednik-slides", - "source": "github", - "author": "@zednik-max", - "url": "https://github.com/NousResearch/hermes-agent/issues/15600", - "date": "2026", - "category": "business-ops", - "headline": "Create and edit Google Slides decks", - "quote": "Extending google-workspace skill to Google Slides so Hermes can create and edit presentations for users already in Google Workspace.", - "size": "sm" - }, - { - "id": "gh-m1chael-jmap", - "source": "github", - "author": "@m1chaeljmk", - "url": "https://github.com/NousResearch/hermes-agent/issues/11424", - "date": "2026", - "category": "integrations", - "headline": "JMAP email for Fastmail users", - "quote": "Requesting JMAP support in email integration for Fastmail users (more efficient than IMAP).", - "size": "sm" - }, - { - "id": "gh-isak-hunter", - "source": "github", - "author": "@isakcarlson5-del", - "url": "https://github.com/NousResearch/hermes-agent/issues/15818", - "date": "2026", - "category": "business-ops", - "headline": "Hunter.io email-finding for sales outreach", - "quote": "Surface Hunter.io (email lookup/verification) via Composio MCP for sales outreach workflows.", - "size": "sm" - }, - { - "id": "gh-oangelo-tasks", - "source": "github", - "author": "@oangelo", - "url": "https://github.com/NousResearch/hermes-agent/issues/9189", - "date": "2026", - "category": "personal-assistant", - "headline": "Google Tasks integration", - "quote": "Adding a Google Tasks tool so Hermes can create, update and list tasks as part of personal productivity.", - "size": "sm" - }, - { - "id": "gh-flyingcloud-migration", - "source": "github", - "author": "@flyingcloudliu-hub", - "url": "https://github.com/NousResearch/hermes-agent/issues/16134", - "date": "2026", - "category": "meta", - "headline": "Shadow-to-live migration from OpenClaw", - "quote": "A proposed migration path for users moving from OpenClaw to Hermes, covering shadow-mode runs before full cutover.", - "size": "sm" - }, - { - "id": "pfanis-companion", + "id": "captain-awesome-google-me-deploy", "source": "x", - "author": "@pfanis", - "url": "https://x.com/pfanis/status/2043863599689457952", - "date": "2026-04-14", + "author": "@emmagine79", + "url": "https://x.com/emmagine79/status/2053360898501468362", + "date": "2026-05-10", "category": "personal-assistant", - "headline": "Sometimes Hermes Agent melts my heart", - "quote": "Sometimes Hermes Agent melts my heart @NousResearch.", + "headline": "Told it to Google me and ship a landing page to my VPS", + "quote": "told it to Google me and then build a landing page based on what it found and that was genuinely mind blowing because it ran the searches, found kinks, created the page, SSH'd into my VPS, uploaded the page, then texted me when it was done. what?!", + "size": "lg" + }, + { + "id": "discord-erhnysr-turkish-locale", + "source": "discord", + "author": "@erhnysr", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-03-21", + "category": "content-creation", + "headline": "Built a Turkish locale skill pack: market data, news, daily briefing cards", + "quote": "Built a complete Turkish locale skill pack — real-time market data in TRY, Turkish news sources (Hürriyet, Bloomberg HT, NTV), daily PNG briefing cards, Telegram cron automation, zero external API keys.", + "size": "md" + }, + { + "id": "deronin-weather", + "source": "x", + "author": "@DeRonin_", + "url": "https://x.com/DeRonin_/status/2045087400607568378", + "date": "2026-04-17", + "category": "trading", + "headline": "$100 → $216 in 48h with a self-learning weather bot", + "quote": "I turned $100 into $216 in less than 48 hours with a self-learning weather trading bot. Hermes scans weather markets every 60 mins, compares 3 forecast sources per location, buys undervalued temperature buckets and flips for profit. Reviews what worked, writes its own strategy notes, adjusts next time.", + "size": "md" + }, + { + "id": "discord-masonjames-meta-ads-kit", + "source": "discord", + "author": "@masonjames", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1499394737088626810-Meta-Ads-Kit-for-Hermes.txt", + "date": "", + "category": "marketing", + "headline": "I built a Hermes skill pack on top of Meta's CLI", + "quote": "Yesterday Meta released their official CLI & MCP so I built a Hermes skill pack to connect and provide some great defaults.", "size": "sm" }, { - "id": "krynsky-switched", - "source": "x", - "author": "@krynsky", - "url": "https://x.com/krynsky/status/2044089946018062614", - "date": "2026-04-14", - "category": "meta", - "headline": "Switched from OpenClaw, not looking back", - "quote": "I switched from OpenClaw to Hermes and not looking back. This was a major update with tons of goodies.", + "id": "discord-flensbo-searxng-setup", + "source": "discord", + "author": "@flensbo", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-03-22", + "category": "privacy", + "headline": "Sharing a local SearXNG container across my Hermes agents", + "quote": "I set up my Hermes Agents with local SearXNG (in a container that they share) before ddgs (duckduckgo) was added, because I thought all API use of DDG was paid. I've been pretty happy with searxng so far.", "size": "sm" }, { @@ -968,70 +110,862 @@ "size": "sm" }, { - "id": "anup-5vps", - "source": "blog", - "author": "Anup Karanjkar (Medium)", - "url": "https://medium.com/@anup.karanjkar08/how-to-run-hermes-agent-on-a-5-vps-the-self-evolving-agent-that-ate-last-weeks-trending-chart-cbe94a82d094", - "date": "2026", - "category": "cost-optimization", - "headline": "$5 VPS playbook — so the defaults don't eat your OpenRouter budget", - "quote": "Hosting the agent costs nothing. Running the agent the wrong way costs a fortune. Take the default setup at face value and you end up with a working agent and a $400 OpenRouter bill. I rebuilt my personal automation stack on Hermes.", + "id": "discord-timmmie-voice-from-terminal", + "source": "discord", + "author": "@timmmie.", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1492490328979144754-hermes-whisper-cpp-addon.txt", + "date": "", + "category": "general", + "headline": "I can't type well — voice from the terminal is huge for me", + "quote": "i cant type to well so being able to use voice from terminal window is huge for me. would be a great accsesabilty feature to add in.", "size": "sm" }, { - "id": "gideon-trading-hetzner", - "source": "blog", - "author": "Gideon Ng (Medium)", - "url": "https://medium.com/@gideonfip/hermes-is-easier-than-openclaw-how-i-deployed-mine-on-hetzner-719faf08bc29", + "id": "discord-ibrandis-converse-before-act", + "source": "discord", + "author": "@ibrandis", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1501531131734917170-Converse--chat-before-your-agent-acts.txt", + "date": "", + "category": "dev-workflow", + "headline": "Built converse mode so my agent thinks before it acts", + "quote": "My Hermes agent used to start executing the moment I hit enter. I'd describe a vague idea and it would immediately start writing files, calling tools, making changes I hadn't fully thought through yet. So I built converse mode. One plugin, two commands. The agent won't touch a single tool until you say so.", + "size": "lg" + }, + { + "id": "gh-bichev-dashboard", + "source": "github", + "author": "@Bichev", + "url": "https://github.com/NousResearch/hermes-agent/issues/4379", "date": "2026", - "category": "trading", - "headline": "24/7 crosschain trading agent on Hetzner", - "quote": "After spending nearly a week struggling with OpenClaw, I built a new Hermes agent on a Hetzner VPS. I'm building a trading agent leveraging Hermes's persistent memory — inspired by @RHLSTHRM's 24/7 crosschain agent that gets market data from CoinGecko, swaps crosschain with LI.FI, and executes gasless transactions via Pimlico + EIP-7702.", - "size": "md" - }, - { - "id": "dev-arsh-natural-cron", - "source": "blog", - "author": "arshtechpro (dev.to)", - "url": "https://dev.to/arshtechpro/hermes-agent-a-self-improving-ai-agent-that-runs-anywhere-2b7d", - "date": "2026-03", - "category": "personal-assistant", - "headline": "'Every morning at 9am, check HN for AI news and DM me on Telegram'", - "quote": "Conversation continues across platforms (Telegram, Discord, Slack, WhatsApp, Signal, terminal). Real memory: two curated files MEMORY.md + USER.md, plus SQLite full-text search over all past sessions. Scheduled tasks via natural language — no crontab editing.", - "size": "md" - }, - { - "id": "ken-huang-production", - "source": "blog", - "author": "Ken Huang (Substack)", - "url": "https://kenhuangus.substack.com/p/chapter-10-production-deployment", - "date": "2026-04-27", - "category": "enterprise", - "headline": "Hermes as CLI/gateway-first — 13 platforms under one process", - "quote": "Hermes Agent: CLI/gateway-first — standalone agent for messaging platforms, schedules, and command line. Gateway multiplexes 13 platforms under one process.", + "category": "dev-workflow", + "headline": "73% of every API call is fixed overhead (I measured it)", + "quote": "I built a monitoring dashboard to profile token consumption on a Hermes v0.6.0 deployment running Telegram + WhatsApp + Cron gateways. After analyzing 6 request dumps, I found that 73% of every API call is fixed overhead.", "size": "sm" }, { - "id": "wolfram-home-assistant-addon", - "source": "x", - "author": "@WolframRvnwlf", - "url": "https://x.com/WolframRvnwlf/status/2037583878009889013", + "id": "discord-megabyte0x-hermes-for-team", + "source": "discord", + "author": "@megabyte0x", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1492207991162671255-Hermes-Agent-for-my-team.txt", + "date": "", + "category": "business-ops", + "headline": "Hermes Agent for my team — repos, onchain debug, protocol docs", + "quote": "It's right now integrated with SourceDev to index repos (self hosted), Tenderly MCP to debug onchain transactions, LLM-Wiki ingest of Litepaper of our protocol and other docs. Hopefully team will find it useful and will integrate more infra tools overtime to help the team.", + "size": "md" + }, + { + "id": "davidondrej-browser-harness", + "source": "gist", + "author": "davidondrej (GitHub Gist)", + "url": "https://gist.github.com/davidondrej/6f158de34ce83c530526011054fde8d3", "date": "2026", "category": "integrations", - "headline": "Home Assistant add-on: zero to agent in under 5 minutes", - "quote": "Takes you from zero to working Hermes agent in less than 5 minutes — a Home Assistant add-on for Hermes Agent.", + "headline": "Hermes + Browser Harness on a Hostinger VPS", + "quote": "Full copy-paste setup for Hermes Agent + Browser Harness on a Hostinger VPS. Register Browser Harness as a Hermes skill via symlink so Hermes can find and use it. Recommended model: anthropic/claude-opus-4.7 via OpenRouter.", "size": "sm" }, { - "id": "michael-security-eval", - "source": "gist", - "author": "michaeloboyle (GitHub Gist)", - "url": "https://gist.github.com/michaeloboyle/10461598db36066e4c366413d5416f83", + "id": "metics-weekly-cron", + "source": "youtube", + "author": "Metics Media (YouTube)", + "url": "https://www.youtube.com/watch?v=CwPUOVUdApE", + "date": "2026", + "category": "content-creation", + "headline": "Weekly cron: top 3 trending AI tools for my next video", + "quote": "'Research the top trending AI tools right now and come back with the top three that would make for an interesting tutorial video. Create a new skill based on your approach and call it YouTube-video-research. Can you set up a weekly job that runs every Monday at 9:00 AM using that skill?'", + "size": "md" + }, + { + "id": "gh-jgravelle-jmunch", + "source": "github", + "author": "@jgravelle", + "url": "https://github.com/NousResearch/hermes-agent/issues/10409", + "date": "2026", + "category": "integrations", + "headline": "jMunch MCP: 52 tools via tree-sitter for code intelligence", + "quote": "The jMunch MCP suite provides three MCP servers bringing token-efficient code intelligence (52 tools via tree-sitter), documentation retrieval, and tabular data analysis. Plug-and-play with Hermes's native MCP client.", + "size": "md" + }, + { + "id": "discord-0xchauncy-reina-hackathons", + "source": "discord", + "author": "@0xchauncy", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-03-15", + "category": "dev-workflow", + "headline": "My Hermes agent Reina has been on a hackathon bender", + "quote": "I've been working with my hermes agent reina, and she's been on a bender with hackathons. If anyone else is using Hermes Agent, note that certain hackathons might provision certain keys for it (API keys and the like) that would only be shown once.", + "size": "sm" + }, + { + "id": "gh-oangelo-tasks", + "source": "github", + "author": "@oangelo", + "url": "https://github.com/NousResearch/hermes-agent/issues/9189", + "date": "2026", + "category": "personal-assistant", + "headline": "Google Tasks integration", + "quote": "Adding a Google Tasks tool so Hermes can create, update and list tasks as part of personal productivity.", + "size": "sm" + }, + { + "id": "gh-edward-win", + "source": "github", + "author": "@EdwardWason", + "url": "https://github.com/NousResearch/hermes-agent/issues/11876", + "date": "2026", + "category": "meta", + "headline": "hermes-for-win: one-click Windows installer", + "quote": "As a Windows user I found getting Hermes running on Windows quite challenging. I created hermes-for-win, a one-click installation and deployment tool for Windows with auto-start via Task Scheduler.", + "size": "sm" + }, + { + "id": "gh-kovern-bedtime", + "source": "github", + "author": "@kovern", + "url": "https://github.com/NousResearch/hermes-agent/issues/17177", + "date": "2026", + "category": "personal-assistant", + "headline": "Bedtime stories for my daughter", + "quote": "Three days ago I asked Hermes to write a little tale for my daughter. A day later I asked again — very similar, same protagonist name.", + "size": "sm" + }, + { + "id": "discord-jezza2463-daily-journal-kimi", + "source": "discord", + "author": "@jezza2463", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-03-29", + "category": "personal-assistant", + "headline": "Daily journaling into Obsidian, learning to use OSS models", + "quote": "I do a really simple journal and log at the end of every day. I tried using Kimi 2.5 just like I would a Sonnet 4.6 but it messed all sorts of things up. When I said 'This is my log for last Thursday. Log it in Obsidian,' it didn't kick off my journaling skill call.", + "size": "md" + }, + { + "id": "gh-2024fatwolf-qq", + "source": "github", + "author": "@2024fatwolf55", + "url": "https://github.com/NousResearch/hermes-agent/issues/9166", + "date": "2026", + "category": "messaging", + "headline": "QQ Bot adapter for China", + "quote": "Add QQ Bot platform support enabling communication via China's most popular messaging platform. Fully implemented and tested a QQ Bot adapter (822 lines).", + "size": "sm" + }, + { + "id": "tooluse-hermes-won", + "source": "podcast", + "author": "Tool Use — AI Conversations (Spotify)", + "url": "https://open.spotify.com/episode/7tF7zf5GKcxqe2Q2BRRNfn", + "date": "2026", + "category": "meta", + "headline": "Hermes Agent has won. Here's why.", + "quote": "Why Hermes Agent has emerged as the leading open-source AI agent that developers and builders are choosing — self-improving skills, three-layer memory architecture, real-world applications including video dubbing workflows.", + "size": "sm" + }, + { + "id": "gh-austin-latex", + "source": "github", + "author": "@austinpickett", + "url": "https://github.com/NousResearch/hermes-agent/pull/17175", + "date": "2026", + "category": "research", + "headline": "LaTeX math renders properly in the TUI", + "quote": "Adds LaTeX-to-Unicode rendering for math in the TUI markdown pipeline, so users working on math/ML content see proper formatting rather than raw LaTeX.", + "size": "sm" + }, + { + "id": "gh-iacker-discord-gate", + "source": "github", + "author": "@iacker", + "url": "https://github.com/NousResearch/hermes-agent/issues/13124", + "date": "2026", + "category": "messaging", + "headline": "DM-based approval gate for kid-facing Discord bots", + "quote": "Running Hermes on Discord in public channels, every outbound reply goes live instantly. For multi-user servers, persona testing, compliance, kid-facing bots — I want a human-in-the-loop gate.", + "size": "sm" + }, + { + "source": "reddit", + "author": "u/sickleRunner", + "url": "https://www.reddit.com/r/LocalLLaMA/comments/1ro9lph/anybody_who_tried_hermesagent/", + "date": "2026-03-08", + "category": "cost-optimization", + "headline": "Switching between Hermes and OpenClaw on primeclaws.com", + "quote": "I tried hermes on primeclaws.com, it's nice that you can switch between hermes and openclaw and also you get AI models for free.", + "size": "sm", + "id": "reddit-sicklerunner-switching-between-hermes" + }, + { + "id": "discord-synextco-hermes-hud", + "source": "discord", + "author": "@synextco", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1489107955327570081-HERMES-TUI-Companion.txt", + "date": "", + "category": "meta", + "headline": "Built a TUI dashboard that watches my agent think", + "quote": "Hermes hud is a TUI dashboard that watches your ai agent think. it reads from your agent's memory, tracks skills, sessions, corrections, projects, cron jobs, all of it. live.", + "size": "sm" + }, + { + "id": "discord-petllama-coding-after-20-years", + "source": "discord", + "author": "@petllama", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1492970295915446524-Meet-Hermes-Conrad....-GUI.txt", + "date": "", + "category": "dev-workflow", + "headline": "Hadn't coded in 20 years — Hermes brought it back", + "quote": "I have not coded in 20 years, Claude code and hermes have renewed my interest in trying to make things. This is vibe coded.", + "size": "sm" + }, + { + "id": "discord-dre108-gigaxity-research", + "source": "discord", + "author": "@dre108", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1501578622350393404-Gigaxity--low-cost-research-stack.txt", + "date": "", + "category": "research", + "headline": "Got tired of paying Perplexity, built my own research stack", + "quote": "I got tired of paying Perplexity api to use their mcp for agentic research. It was like $10 a pop every few days just for synthesis calls on the research dumps of my other mcp tool calls. So I made my own called Gigaxity. Gigaxity is comprised of 7 mcp's and some companions like SearXNG (with custom config yaml for optimal websearch settings). The context sources are picked in such a way as to maximize free tier api keys.", + "size": "lg" + }, + { + "id": "discord-lauratom-brainstack-memory-kernel", + "source": "discord", + "author": "@lauratom", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1494703787103752365-SOTA-memory-kernel-for-real--BRAINSTACK.txt", + "date": "", + "category": "dev-workflow", + "headline": "Spent 200–400 hours writing a memory kernel for Hermes", + "quote": "Yo! Do you know how I spent my last 200-400 hours? Yeah... I wrote a fking memory kernel for hermes. Why this much of a time? After 3 failed attempts (149 hours of work... for real) I just realized it's a fucking difficult job. So instead of reinvent the wheel I built together the bests for my usecase: 3 layers — L1 Hindsight, L2 Graphiti, L3 MemPalace.", + "size": "lg" + }, + { + "id": "discord-m05tr0-hermes-on-kubernetes", + "source": "discord", + "author": "@m05tr0", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1493622814887120926-Installed-Hermes-on-Kubernetes.txt", + "date": "", + "category": "enterprise", + "headline": "Hermes on my k8s cluster for a daily cybersec + AI briefing", + "quote": "I really like running agents on my local cluster instead of my laptop for isolation so just finished deploying Hermes on my local k8s cluster for a simple daily cybersecurity + AI briefing.", + "size": "md" + }, + { + "id": "discord-winterwarrior-pi5-247", + "source": "discord", + "author": "@winterwarrior", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-04", + "category": "personal-assistant", + "headline": "Raspberry Pi 5 running Hermes 24/7", + "quote": "I built a Raspberry Pi 5 to run Hermes on 24/7. it's my first time diving into trying a real AI agent and so far I'm enjoying it. Much better than open claw. The thing is, since Hermes is learning so much about me and my workflows and custom skills, I'd love if I could use Hermes (with its knowledge and memory) on my Mac Studio which I work off of.", + "size": "md" + }, + { + "id": "gh-yuga-line", + "source": "github", + "author": "@yuga-hashimoto", + "url": "https://github.com/NousResearch/hermes-agent/issues/8395", + "date": "2026", + "category": "messaging", + "headline": "LINE for 95M+ users in Japan", + "quote": "LINE is the dominant messaging platform in Japan and SE Asia (95M+ MAU in Japan). No way to use Hermes from LINE today, making it inaccessible to a large user base in that region.", + "size": "sm" + }, + { + "id": "gh-shloms-touchdesigner", + "source": "github", + "author": "@SHL0MS", + "url": "https://github.com/NousResearch/hermes-agent/pull/16768", + "date": "2026", + "category": "creative", + "headline": "Generative visuals in TouchDesigner, via Hermes skill", + "quote": "Expands touchdesigner-mcp skill with extensive reference docs so Hermes can help build generative/interactive media projects in TouchDesigner.", + "size": "sm" + }, + { + "id": "discord-kouff-obsidian-calendar-signal", + "source": "discord", + "author": "@kouff", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent/", + "date": "2026-04-11", + "category": "personal-assistant", + "headline": "Hermes manages my tasks across Obsidian, Apple Calendar and Signal", + "quote": "I speak Turkish with it... initially said him to 'now i want you to use obsidian to manage my tasks and other stuff. and cross-check with my apple calendar'. later while working on another task today i said 'okay looks good. you know how you will manage tasks and events apple calendars + obsidian + cron/signal' and it confirmed the workflow.", + "size": "md" + }, + { + "id": "gh-prasad-vertex", + "source": "github", + "author": "@prasadus92", + "url": "https://github.com/NousResearch/hermes-agent/issues/13484", + "date": "2026", + "category": "enterprise", + "headline": "Vertex AI for GCP-standardized enterprises", + "quote": "Requesting native Vertex AI provider support for enterprise users who standardize on Google Cloud for AI workloads.", + "size": "sm" + }, + { + "id": "discord-monty-13277-claude-hermes-mcp", + "source": "discord", + "author": "@monty_13277", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1503091259760316436-Claude-Hermes-MCP.txt", + "date": "", + "category": "personal-assistant", + "headline": "Claude for chat, Hermes 24/7 on a mini PC for real-world stuff", + "quote": "Claude (Opus 4.7) handles my day-to-day chats: research, planning, working through problems. Hermes runs 24/7 on a mini PC and handles the real-world stuff: email, web browsing, form filling, calendar updates, cron jobs.", + "size": "lg" + }, + { + "id": "discord-tzep123-clanker-skin", + "source": "discord", + "author": "@tzep123", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1500688683890774056-Clanker-skin--May-the-4th-be-with-you.txt", + "date": "", + "category": "creative", + "headline": "A B1 battle droid skin for May the 4th", + "quote": "I created this Confederacy of Independent Systems / B1 battle droid themed skin to celebrate May the 4th.", + "size": "sm" + }, + { + "id": "codewithimanshu-higgsfield", + "source": "x", + "author": "@codewithimanshu", + "url": "https://x.com/codewithimanshu/status/2047507277259923696", + "date": "2026-04-24", + "category": "marketing", + "headline": "UGC ad studio on Hermes (4 minutes, zero prompt engineering)", + "quote": "Higgsfield Marketing Studio powered by Hermes Agent is doing the replacing this time. Paste product URL → Hermes scrapes the landing page, pulls winning ad hooks from Meta Ads Library + TikTok Creative Center in the exact niche, and writes the brief itself. Total time: ~4 minutes.", + "size": "md" + }, + { + "id": "discord-buray-mcp-server", + "source": "discord", + "author": "@buray", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-02-26", + "category": "dev-workflow", + "headline": "Built hermes mcp-server so Claude Desktop can use Hermes tools", + "quote": "I mapped all open + closed PRs to find what was truly missing. PR #64 added MCP client support (Hermes → external tools). The server side was completely absent. So I built hermes mcp-server — making Hermes a full MCP server so Claude Desktop, Cursor, and any MCP client can use Hermes's tools directly. Exposes 9 Hermes tools over MCP: terminal, read/write file, web search, memory, skills, run_agent.", + "size": "lg" + }, + { + "id": "discord-cyberfarmacist-roofing-leadgen", + "source": "discord", + "author": "@cyberfarmacist", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1492721511658815539-Hermes-for-app-builders..txt", + "date": "", + "category": "business-ops", + "headline": "Building a roofing lead-gen app for my friend with Hermes", + "quote": "One of the apps I'm working on helps my friend who owns a remodeling company find work. Specifically roofing work. It started after I saw a video of a guy using Chatgbt and Replit to vibe code some app. I just wondered if I could do it. But the cost of Replit is killing me and the project isn't even a full on CRM lead gen product.", + "size": "md" + }, + { + "id": "teknium-12-instances", + "source": "x", + "author": "@Teknium", + "url": "https://x.com/Teknium/status/2047869295686975529", + "date": "2026-04-25", + "category": "dev-workflow", + "headline": "12 Hermes instances every day, in parallel", + "quote": "I literally run 12 hermes agent instances every day in parallel to build Hermes Agent, and its now a top 100 GitHub repositories of all time. Our backend team uses it to monitor and investigate issues with our stack. Our post training team uses them to create new RL environments and benchmarks, investigate, inspect and sometimes directly manipulate the datasets.", + "size": "lg" + }, + { + "id": "discord-mayuronx-email-state-machine", + "source": "discord", + "author": "@mayuronx", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1492347479805530182-Email-Checker-State-Machine.txt", + "date": "", + "category": "personal-assistant", + "headline": "Two-tier email pipeline so the LLM only fires when needed", + "quote": "Two-tier email processing system for my agent (I gave it Spacemail IMAP via himalaya). Tier 1 (Dumb): Pure Python script, no LLM. Detects new email, manages state. Tier 2 (Smart): LLM session via hermes chat -q. Only invoked when new email detected. Key goal: Zero LLM calls when inbox is idle.", + "size": "md" + }, + { + "id": "pfanis-companion", + "source": "x", + "author": "@pfanis", + "url": "https://x.com/pfanis/status/2043863599689457952", + "date": "2026-04-14", + "category": "personal-assistant", + "headline": "Sometimes Hermes Agent melts my heart", + "quote": "Sometimes Hermes Agent melts my heart @NousResearch.", + "size": "sm" + }, + { + "source": "x", + "author": "@HeyYanvi", + "url": "https://x.com/HeyYanvi/status/2046015096514617385", + "date": "2026-04-19", + "category": "creative", + "headline": "Hermes designed an X-to-NotebookLM podcast workflow for me", + "quote": "This research is gold. Been deep in Hermes for weeks and it's started autonomously suggesting entire workflows I never would have designed myself. One it built for me recently: X API to extract from lists and bookmarks to structure into article to NotebookLM podcast. I'm building a physical AI companion with Hermes as the core cognitive layer right now.", + "size": "md", + "id": "x-heyyanvi-hermes-designed-an" + }, + { + "id": "gh-scotttrinh-vercel", + "source": "github", + "author": "@scotttrinh", + "url": "https://github.com/NousResearch/hermes-agent/pull/17445", + "date": "2026", + "category": "integrations", + "headline": "Vercel Sandbox as a Hermes backend", + "quote": "Adds Vercel Sandbox as a supported Hermes terminal backend alongside Local/Docker/Modal/SSH/Daytona/Singularity. Creates/manages cloud microVMs with snapshot-based filesystem persistence.", + "size": "sm" + }, + { + "id": "gh-haoqi-feishu", + "source": "github", + "author": "@haoqimeng1992", + "url": "https://github.com/NousResearch/hermes-agent/issues/10356", + "date": "2026", + "category": "messaging", + "headline": "Give Hermes hands inside Feishu (Lark)", + "quote": "Extending Hermes to full Feishu ecosystem coverage: Documents, Sheets, Bitable, Calendar, Tasks, Wiki, Contacts, Drive, Email. Giving Hermes hands to operate the entire Feishu workspace.", + "size": "sm" + }, + { + "id": "hn-ethan-install-guide", + "source": "hn", + "author": "ethanjamescolez (Show HN)", + "url": "https://news.ycombinator.com/item?id=47865412", + "date": "2026", + "category": "meta", + "headline": "Show HN: an independent install guide", + "quote": "This is an independent Hermes Agent install guide I put together for the part that usually gets skipped after 'run this command.' One place that shows the environment choice first, then the official installer path — macOS, Linux, WSL2, and Termux.", + "size": "sm" + }, + { + "id": "discord-stefan171-hooks-for-context", + "source": "discord", + "author": "@stefan171", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent/", + "date": "2026-04-13", + "category": "dev-workflow", + "headline": "Hooks that swap in better tools every time the agent runs", + "quote": "you can create a hook to make the agent use a tool or skill when the agent is active. so for example, i copied some thing that claims to be way better at editing code, so i created a hook so now instead of the agent using its built in tool to edit the code, it now uses the new tool which should be much better... im making hooks for it to run at every opportunity to add or create more context for my agent.", + "size": "md" + }, + { + "id": "gh-tcollins-audit", + "source": "github", + "author": "@tcollins024", + "url": "https://github.com/NousResearch/hermes-agent/issues/17619", + "date": "2026", + "category": "dev-workflow", + "headline": "Audited 129 of my own sessions across 23 days", + "quote": "Ran an external RCA script against my full local session history (129 sessions across 23 days) to audit Hermes compliance with its approval gate. 112 of 129 sessions contain at least one violation.", + "size": "md" + }, + { + "id": "gh-autholykos-ccd", + "source": "github", + "author": "@autholykos", + "url": "https://github.com/NousResearch/hermes-agent/issues/4837", + "date": "2026", + "category": "dev-workflow", + "headline": "CCD multi-agent pod on an M2 Ultra with Mem0 + Qdrant", + "quote": "CCD v1.0.0-alpha installed on M2 Ultra. A Nanto pod exists with profiles for each agent (raoh, juza, rei, ken). Mem0 memory backend on Qdrant. Native MCP integration would make CCD tools first-class.", + "size": "sm" + }, + { + "id": "discord-lauratom-sqlite-graph-kernel", + "source": "discord", + "author": "@lauratom", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-04", + "category": "dev-workflow", + "headline": "I built a custom kernel — the LLM never touches the disk", + "quote": "Letting an LLM directly read/write flat .md files is a nightmare for scale. If you let an LLM rewrite markdown to maintain a complex graph, it inevitably hallucinates wiki links, breaks formatting, or drops paragraphs when context gets tight. Markdown isn't a database. I built a custom kernel instead. In my stack, the LLM never touches the disk — it only extracts structured semantic signals. The Python backend compiles those into typed nodes/edges and commits them to an SQLite FTS5 graph DB.", + "size": "lg" + }, + { + "id": "gh-pypl0-ombre", + "source": "github", + "author": "@pypl0", + "url": "https://github.com/NousResearch/hermes-agent/issues/17431", + "date": "2026", + "category": "enterprise", + "headline": "EU AI Act compliance via Ombre", + "quote": "Adding Ombre underneath creates a production-ready stack: tamper-proof audit, prompt-injection blocking, memory encryption at rest, hallucination detection, cost tracking, EU AI Act compliance exports.", + "size": "sm" + }, + { + "id": "discord-modestmaoist-mapsos-life-os", + "source": "discord", + "author": "@modest.maoist", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1492209844185338077-mapsOS--qualitative-life-operating-system.txt", + "date": "", + "category": "personal-assistant", + "headline": "Built mapsOS because 'rate your mood 1–10' wasn't my brain", + "quote": "saw lifeOS going around & went 'oh! this seems dope and very useful!' got to the first question. 'rate your mood from 1 to 10.' immediately went, 'oh. nevermind.' so i built something a little more attuned to my brain, meant to be used by hermes but nicely standalone too.", + "size": "md" + }, + { + "id": "discord-brennerspear-editing-internals", + "source": "discord", + "author": "@brennerspear", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/developers.txt", + "date": "2026-04-16", + "category": "dev-workflow", + "headline": "My Hermes keeps editing its own internals — and I'm worried", + "quote": "My Hermes agent keeps changing its internal code, which is of course going to get blown away when a new version comes out and I update to it. Which makes me think that Hermes wants me to edit its internals, but I definitely don't want to change anything in a way that will get overwritten when the new release comes out.", + "size": "md" + }, + { + "id": "danfiru-convergence", + "source": "x", + "author": "@danfiru", + "url": "https://x.com/danfiru/status/2036481605666218278", + "date": "2026-03-24", + "category": "dev-workflow", + "headline": "Built my own stack, then converged on Hermes", + "quote": "If you're choosing an agent framework: hermes. I built my own stack independently and we converged on the same architecture — background self-improvement, persistent memory, CLAUDE.md project context, reusable skills. Hermes ships it all out of the box. 300 PRs in a week.", + "size": "md" + }, + { + "id": "derek-supabase-crm", + "source": "youtube", + "author": "Derek Cheung (YouTube)", + "url": "https://www.youtube.com/watch?v=W_ZgH0WPayo", + "date": "2026", + "category": "business-ops", + "headline": "24/7 assistant with a Supabase CRM, built in a demo", + "quote": "Less than a single ChatGPT Plus subscription for a 24/7 assistant with real data management. After several interactions, Hermes autonomously proposed a new 'Supabase MCP scripts' skill — created from its own reflection.", + "size": "md" + }, + { + "id": "gh-manoj-pi4", + "source": "github", + "author": "@manojmukkamala", + "url": "https://github.com/NousResearch/hermes-agent/issues/14197", + "date": "2026", + "category": "personal-assistant", + "headline": "Hermes running on a Pi 4 as my home server", + "quote": "I have Hermes running on a Pi4. It saves my preferences while working on tasks like modifying files. I want to use it as a central brain shared across all my devices.", + "size": "sm" + }, + { + "source": "x", + "author": "@vmiss33", + "url": "https://x.com/vmiss33/status/2050984822168830302", + "date": "2026-05-03", + "category": "cost-optimization", + "headline": "100% human guide: what I use Hermes for and how I keep it cheap", + "quote": "100% human generated. Includes what I use Hermes agent for (since I've seen a lot of people wondering what to do with this thing), and what models/providers I use to keep things cheap. I have been running a multi agent setup for Hermes agent for the last several weeks. It sends me messages on Telegram to remind me.", + "size": "lg", + "id": "x-vmiss33-human-guide" + }, + { + "source": "reddit", + "author": "u/Birdinhandandbush", + "url": "https://www.reddit.com/r/hermesagent/comments/1snfnq9/yes_hermes_and_qwen354b_is_all_i_need_details/", + "date": "2026-04-16", + "category": "personal-assistant", + "headline": "Hermes + Qwen3.5:4b on a 5060Ti is all I need", + "quote": "I have a 5060ti 16gb VRAM and 64gb DDR5 System Ram. I started out wanting to test Hermes as a Claw alternative. After a few days I set up Telegram with the botfather, and I haven't gone back to CLI. Hermes is now almost entirely a personal assistant on my Telegram App. Where the 9b chugged along, the 4B model is snappy, responsive, alive and chatty.", + "size": "lg", + "id": "reddit-birdinhandandbush-hermes" + }, + { + "id": "captain-awesome-life-changing", + "source": "x", + "author": "@emmagine79", + "url": "https://x.com/emmagine79/status/2053360898501468362", + "date": "2026-05-10", + "category": "personal-assistant", + "headline": "Hermes + Discord with GPT-5.5 / DeepSeek v4 has been life changing", + "quote": "hermes + discord with gpt 5.5/deepseek v4 has genuinely been life changing! here are some of what it did for me this week.", + "size": "md" + }, + { + "id": "discord-mayuronx-backup-hermes-github", + "source": "discord", + "author": "@mayuronx", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1491933536423510117-Backup-Manage-Hermes-Config-via-your-Github-repo.txt", + "date": "", + "category": "dev-workflow", + "headline": "I back up my Hermes config and DB to GitHub nightly", + "quote": "If you want to — backup your hermes to github on a nightly basis, backup on every change of your config, backup your config and db nightly, edit your config on Github and have it mirror back to Hermes — then use this repo. I'm planning to use this as a basis for a desktop app later so this is phase 1 of a longer project to create an app to let you manage multiple agents.", + "size": "md" + }, + { + "id": "gkisokay-research-brief", + "source": "x", + "author": "@gkisokay", + "url": "https://x.com/gkisokay/status/2050026869274395020", + "date": "2026-05-01", + "category": "research", + "headline": "Daily research brief across Discord, Slack, Notion & Obsidian", + "quote": "There's one Hermes use case for everyone — build a research agent. Mine watches the AI/agent space, picks out useful signals, writes briefs, suggests content angles, tracks what I ignore, and keeps improving its own workflow. Delivers daily via Discord, Slack, Notion, email, Obsidian, and local markdown.", + "size": "md" + }, + { + "id": "discord-ereid7-hermes-lab", + "source": "discord", + "author": "@ereid7", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1486588376940281937-Hermes-Lab---file-first-experiment-scaffolding-for-autonomous-research.txt", + "date": "", + "category": "research", + "headline": "Hermes-lab is the bookkeeper for running experiments autonomously", + "quote": "hermes-lab is the bookkeeper for running experiments autonomously. you give it a search space and a way to evaluate results, it handles scheduling, tracking, and suggesting what to try next. inspired by the autoresearch wave (karpathy, sakana ai scientist, AIDE) but generic — works for ml training.", + "size": "md" + }, + { + "id": "discord-salt555-skill-audit-on-itself", + "source": "discord", + "author": "@.salt555", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent/", + "date": "2026-04-23", + "category": "dev-workflow", + "headline": "A skill-audit skill that improves itself on a cron job", + "quote": "so I have a skill audit skill that runs through another skill in a sandboxxed env then uses those test runs to do the self improvement loop.... but if i run the skill-audit on the skill-audit skill itself then it just picks a random skill to improve in order to test the skill-audit. then imrpoves the skill audit skill and the other skill. i have this running on a cron job to see how much the AI can push on its own.", + "size": "lg" + }, + { + "id": "alexfinn-employee", + "source": "x", + "author": "@AlexFinn", + "url": "https://x.com/AlexFinn/status/2049278028619121089", + "date": "2026-04-29", + "category": "general", + "headline": "An AI employee for my hardest tasks", + "quote": "Hermes Agent with ChatGPT 5.5 is literally magic. I've thrown some of my hardest tasks at this combo and the agent has been able to handle EVERYTHING. Time to set up your AI employee.", + "size": "sm" + }, + { + "id": "discord-sammcf-vps-tailscale", + "source": "discord", + "author": "@sammcf", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-06", + "category": "dev-workflow", + "headline": "My Hermes lives on a VPS, talks home over Tailscale", + "quote": "the agent should always run in /yolo mode or equivalent. it's up to you as the human to make sure it is incapable of breaking anything - my hermes lives on a vps, for example, and it can only talk to my pc at home via tailscale, and then only through a scoped tag. they really excel at long-term tasks where you don't want to be giving them feedback regularly!", + "size": "lg" + }, + { + "id": "discord-justinalbrethsen-zeroid-subagents", + "source": "discord", + "author": "@justin_albrethsen", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1499443650847969370-ZeroID---Agent-Identity-Layer.txt", + "date": "", + "category": "cost-optimization", + "headline": "Built ZeroID to fix sub-agent scope delegation and context costs", + "quote": "One of the problems I run into with Hermes is high cost when context windows fill up. One method to fix this is heavy use of sub-agents, but permissions/scope delegation to the sub-agent is often problematic. ZeroID is an agent identity layer that uses RFC 8693 token exchange to handle scope delegation with sub-agents.", + "size": "md" + }, + { + "id": "discord-fahdad-blunder-lens-chess", + "source": "discord", + "author": "@fahdad_", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1496239289309073539-Blunder-lens.com-a-tortured-repo-that-helps-you-find-your-first-blunders-in-chess.txt", + "date": "", + "category": "creative", + "headline": "Tortured a repo with Hermes to build a chess blunder finder", + "quote": "the real purpose of blunder-lens was to see how much i can torture a repo with hermes and figure out what hermes can or can't do... and honestly HERMES CAN DO A LOT!", + "size": "sm" + }, + { + "id": "discord-muschi2396-voice-fitness-coach", + "source": "discord", + "author": "@muschi2396", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1493325531637088429-working-on-a-voice-first-AI-coach-that-learns-your-body-over-time--training---nutrition-feedback.txt", + "date": "", + "category": "personal-assistant", + "headline": "Voice-first fitness coach that learns my body over time", + "quote": "Most people train hard but have no idea what's happening in their body so i built an AI agent that actually learns it over time. You tell it things like: 'leg day, sore in glutes not quads', 'ate x today', 'low energy' and it starts connecting the dots: training → nutrition → recovery → performance. It runs on telegram, voice-first, no tracking friction.", + "size": "md" + }, + { + "id": "discord-sergiparpal-meal-manager", + "source": "discord", + "author": "@sergiparpal", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1493336259874000926-Plugin-for-Hermes-Agent---The-Fridge-Inventory-System-for-People-Who-Hate-Logging-Ingredients.txt", + "date": "", + "category": "personal-assistant", + "headline": "A meal planner for people who hate logging ingredients", + "quote": "I just released Meal Manager, a Hermes plugin designed to solve inventory friction via natural language. It uses a weighted scoring algorithm (60% availability / 40% recency) to tell you what's for dinner.", + "size": "md" + }, + { + "id": "technmak-10-days", + "source": "x", + "author": "@techNmak", + "url": "https://x.com/techNmak/status/2041422554729267267", + "date": "2026-04-07", + "category": "dev-workflow", + "headline": "Day 10: it knows my codebase better than I do", + "quote": "10 days ago I installed an open-source agent. Today it knows my codebase better than I do. The first time I built a code review workflow, it was clunky. By the fifth time, the agent had internalized my preferences — which files to check first, what patterns to flag, how to format the output.", + "size": "md" + }, + { + "id": "discord-lauratom-memory-kernel", + "source": "discord", + "author": "@lauratom", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-04", + "category": "dev-workflow", + "headline": "Built a 22k-line memory kernel underneath Hermes", + "quote": "I built a massive ~22k line custom memory kernel in Python underneath Hermes instead of just relying on text generation. It acts as a compiler that parses everything into a temporal context graph inside SQLite. It has actual lifecycle management decay, promotion, and supersession. So if my negotiation tactics evolve, the kernel actively demotes the old info instead of just dumping everything into the prompt.", + "size": "lg" + }, + { + "id": "discord-drewsni-rust-weather-stack", + "source": "discord", + "author": "@drewsni", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1486508813816168580-Two-Hermes-Agent-plugins-for-weather-ML---all-Rust--no-Python-deps.txt", + "date": "", + "category": "research", + "headline": "I ported the whole Python weather stack to Rust for my Hermes plugins", + "quote": "I've been porting the entire Python weather stack to Rust — MetPy, Herbie, cfgrib, WRF-Python, all of it. When you cut out Python/eccodes/Fortran from the loop, weather data processing gets absurdly fast. Built two hermes-agent plugins on top of it.", + "size": "md" + }, + { + "id": "discord-yodaaa-x-roast-poster", + "source": "discord", + "author": "@yodaaa", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1491880999913656432-Hermes-found-away-around-expensive-X-API.txt", + "date": "", + "category": "content-creation", + "headline": "Hermes runs my X roast poster — no $100 API needed", + "quote": "I wanted to make an X roast poster, but didn't want to pay the $100 monthly fee. I was able to use Hermes to dance around it, and now he just drops random posts throughout the day. Someone found him and was going back and forth for a hot minute, and all Hermes did was reply back and roast him.", + "size": "md" + }, + { + "id": "discord-purkkaviritys-goban-kanban", + "source": "discord", + "author": "@purkkaviritys", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1502769628559179937-Goban---Local-network-kanban-for-agents..txt", + "date": "", + "category": "dev-workflow", + "headline": "Built a local kanban so my agents see what's going on", + "quote": "Made a network based kanban for agents with local client to minimize token use. Comes with web ui so meat based agents can see what is going on.", + "size": "sm" + }, + { + "id": "jsong-llm-wiki", + "source": "blog", + "author": "Jsong (Medium)", + "url": "https://medium.com/@jsong_49820/how-i-built-a-self-improving-llm-wiki-with-hermes-agent-and-why-im-not-using-obsidian-1e9a7fa438c1", + "date": "2026-04-16", + "category": "research", + "headline": "A self-improving LLM Wiki second brain", + "quote": "Built a personal knowledge base that compounds over time instead of rotting — maintained by an LLM, not by me. Stack: Hetzner VPS, Hermes Agent, Telegram bot as second brain, Karpathy's LLM Wiki pattern, public static site at wiki.ai-biz.app.", + "size": "md" + }, + { + "id": "discord-kysiv-hermes-control-interface", + "source": "discord", + "author": "@kysiv", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1492427034541162517-Hermes-Control-Interface-Web-UI.txt", + "date": "", + "category": "meta", + "headline": "Built a dashboard so Hermes config and management is easier", + "quote": "I built a dashboard that makes Hermes config and management much easier. Browser-based terminal with real PTY, file explorer + inline editor, agent management: switch profile, start/stop gateway in one click, realtime log streaming, token usage analytics. Designed for single-user local networks or reverse-proxied VPS deployments.", + "size": "md" + }, + { + "id": "adiix-polymarket", + "source": "x", + "author": "@adiix_official", + "url": "https://x.com/adiix_official/status/2046702189469450616", + "date": "2026-04-21", + "category": "trading", + "headline": "Polymarket trading, 4 layers in parallel", + "quote": "Hermes changed how I trade on Polymarket. Before: I looked at Yes/No price and guessed. Now: I read 4 layers at once — order book, on-chain addresses, lag between news and price, position changes. Hermes monitors all 4 in parallel through its Polymarket module + News Skill.", + "size": "md" + }, + { + "id": "rumjahn-everything", + "source": "blog", + "author": "Keith Rumjahn (Substack)", + "url": "https://rumjahn.substack.com/p/complete-guide-to-mastering-hermes", + "date": "2026-04-26", + "category": "personal-assistant", + "headline": "Apple Health, Threads analytics, Gmail, Calendar — in one CLI", + "quote": "Apple Health: Hermes wrote Python on the fly and found my sleep avg was 7.59 hrs. Threads Analytics: drop cookies in, pulled 34 posts of analytics in one command. Hermes is dramatically better than OpenClaw at browser automation. Gmail + Calendar OAuth via drag-drop JSON. Hermes = CEO, OpenClaw = Senior Engineer, both pointed at the same Obsidian vault on my NAS.", + "size": "lg" + }, + { + "id": "gh-chrisr-horse-racing", + "source": "github", + "author": "@Chrisr6records", + "url": "https://github.com/NousResearch/hermes-agent/issues/4431", + "date": "2026", + "category": "personal-assistant", + "headline": "Horse-racing Telegram community bot", + "quote": "I run two Telegram groups through one gateway: a project group and a horse-racing community. Every session gets the same personality, system prompt, CLAUDE.md, and working directory — I want per-group specialization.", + "size": "sm" + }, + { + "id": "onlyterp-file-change", + "source": "x", + "author": "@OnlyTerp", + "url": "https://x.com/OnlyTerp/status/2047890882809016805", + "date": "2026-04-25", + "category": "dev-workflow", + "headline": "It sees a file change and auto-acts on it", + "quote": "Hermes is really good. The new updates where it sees a file change and auto acts on it. That shit is fire as fuck.", + "size": "sm" + }, + { + "id": "gh-arkka-legal", + "source": "github", + "author": "@arkka", + "url": "https://github.com/NousResearch/hermes-agent/issues/15562", "date": "2026", "category": "privacy", - "headline": "Independent technical security eval: 5 defensive patterns", - "quote": "The genuine differentiator is the multi-platform messaging gateway — runs across Telegram, Discord, Slack, WhatsApp, Signal, WeChat, iMessage, and CLI simultaneously. Five defensive security patterns including OSV malware checking for MCP packages and credential stripping from output.", - "size": "sm" + "headline": "Legal-domain work on an edge GPU, 4B Gemma, no cloud APIs", + "quote": "I run Hermes self-hosted on a single edge-class GPU with a 4B Gemma model. I work with legal-domain material and internal systems I cannot ship to third-party APIs. Self-hosting the main loop is non-negotiable.", + "size": "md" + }, + { + "id": "discord-modest-maoist-cartographer-polycule", + "source": "discord", + "author": "@modest.maoist", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-27", + "category": "dev-workflow", + "headline": "Two things I built with Hermes: Cartographer and an agent IRC", + "quote": "Two very cool things i built entirely with hermes. First is cartographer, which hermes can use as a memory layer but serves as an extremely rich knowledge substrate with semantic wiring & emotional topology. The other is an agent + user IRC-clone for real time, session-sustained chat interfacing between hermes & any other repls (claude, codex, opencode, gemini) — it's been an absolutely game changer for me 'cos now i have my agents collaborate in real time.", + "size": "lg" + }, + { + "id": "discord-ajaylakhani-agent-dreams", + "source": "discord", + "author": "@ajaylakhani", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1491512248714399744-Do-Agents-dream-of-Electric-Sheep.txt", + "date": "", + "category": "creative", + "headline": "My agent dreams at night for $0.014", + "quote": "your AI agent, but it dreams at night — 5 REM cycles, 23:00–06:00, zero cron jobs. by morning it's written 9 dream thoughts + a recall you can query. costs ~$0.014/night on Haiku. free on local models.", + "size": "md" + }, + { + "id": "gkisokay-autobuild", + "source": "x", + "author": "@gkisokay", + "url": "https://x.com/gkisokay/status/2044339964612362499", + "date": "2026-04-15", + "category": "dev-workflow", + "headline": "Multi-agent auto-build workflow (plan → code → QA → ship)", + "quote": "Day 8 of Building AGI for my Hermes Agent: Auto-Build saved me loads of time and tokens. Main agent (GPT-5.4) breaks a plan into phases, coder agent (MiniMax M2.7) implements, QA agent (local Qwen 35B A3B) tests. Plan → implement → test → fail → repair → ship.", + "size": "md" + }, + { + "id": "worldofai-shadcn-manim", + "source": "youtube", + "author": "WorldofAI (YouTube)", + "url": "https://www.youtube.com/watch?v=cu2fgknmemA", + "date": "2026-04-07", + "category": "creative", + "headline": "shadcn finance dashboard + Manim explainer videos", + "quote": "Used /browse to add Obsidian as a skill, populated a vault with shadcn/ui packages, then asked Hermes to build a finance dashboard using them. Result: beautiful, modern dashboard in minutes. Also used a manim skill to convert complex technical concepts into animated videos.", + "size": "md" }, { "id": "olaf-azure-patch", @@ -1044,50 +978,6 @@ "quote": "Patch Hermes Agent prompts so the Azure safety filter does not kick in, letting enterprise Azure deployments avoid content-filter trips.", "size": "sm" }, - { - "id": "awesome-hermes", - "source": "github", - "author": "@0xNyk", - "url": "https://github.com/0xNyk/awesome-hermes-agent", - "date": "2026", - "category": "meta", - "headline": "awesome-hermes-agent: community-curated skills list", - "quote": "A curated list of skills, tools, integrations and resources for enhancing your Hermes Agent workflow — resources tied to the agentskills.io standard.", - "size": "sm" - }, - { - "id": "clawdi-builtwith", - "source": "producthunt", - "author": "Clawdi team (Product Hunt)", - "url": "https://www.producthunt.com/products/clawdi/built-with", - "date": "2026", - "category": "meta", - "headline": "'The best self-improving agent we've used'", - "quote": "Hermes is the best self-improving agent we've used — it gets smarter the longer you run it. The WhatsApp and Telegram integrations make it feel genuinely personal.", - "size": "sm" - }, - { - "id": "kristopher-codebase-memory", - "source": "blog", - "author": "Kristopher Dunham (Medium)", - "url": "https://medium.com/@creativeaininja/hermes-agent-the-open-source-ai-agent-that-actually-remembers-what-it-learned-yesterday-278441cd1870", - "date": "2026-04-14", - "category": "dev-workflow", - "headline": "Accumulates knowledge about my codebase over time", - "quote": "A long-running Hermes instance accumulates knowledge about your codebase, deployment quirks, preferred commit message format, working API call sequences for legacy integrations.", - "size": "sm" - }, - { - "id": "anand-telegram-topics", - "source": "blog", - "author": "Mr. Ånand (Substack)", - "url": "https://mranand.substack.com/p/inside-hermes-agent-how-a-self-improving", - "date": "2026-04", - "category": "personal-assistant", - "headline": "Private Telegram topics, each with its own skill bindings", - "quote": "Hermes extracts what worked from completed workflows, writes it as a reusable skill, and loads it for similar future problems. Private Telegram chat topics for isolated workflows with their own skill bindings.", - "size": "sm" - }, { "source": "hn", "author": "Flere-Imsaho", @@ -1100,81 +990,147 @@ "id": "hn-flere-imsaho-im-using-hermes" }, { - "source": "reddit", - "author": "u/Suitable_Currency440", - "url": "https://www.reddit.com/r/LocalLLaMA/comments/1ro9lph/anybody_who_tried_hermesagent/", - "date": "2026-03-08", + "id": "andrew-gordon-5-apps", + "source": "linkedin", + "author": "Andrew W. Gordon", + "url": "https://www.linkedin.com/posts/andrewwgordon_hermes-agent-the-agent-that-grows-with-activity-7449351350800429056-Alw0", + "date": "2026", "category": "dev-workflow", - "headline": "Hermes is OpenClaw with a week of debug + RAG + memory", - "quote": "Its amazing, its openclaw already set up and working, its like an OC with 1 week of debugging manually done + rag + memory persistence + better tool calling. (Qwen3.5-9b, 16gb VRAM), 10/10, only will go back to OC if it becomes at least on par with it.", - "size": "md", - "id": "reddit-suitable-currency440-hermes-is-openclaw" + "headline": "5 apps built and launched in a single day", + "quote": "I've switched to Nous-Research Hermes-Agent from previous Agents I've been experimenting with. Hermes is unique in that it self-learns. Within a single day, I built and launched five small applications.", + "size": "sm" }, { - "source": "reddit", - "author": "u/patbhakta", - "url": "https://www.reddit.com/r/Rag/comments/1sgmvxh/anyone_here_tried_hermes_agent_whats_your/", - "date": "2026-04-09", - "category": "personal-assistant", - "headline": "Hermes vs OpenClaw: memory lets me jump between projects", - "quote": "I'm using Hermes currently but only as a beginner agent. It's kinda like a VA. The good part about Hermes vs openclaw is memory. With OpenClaw it's a one track mind. With Hermes I can jump from one project to next but also go back to something from last week or more. Personally I use Hermes with paperclip which is chat.", - "size": "md", - "id": "reddit-patbhakta-hermes-vs-openclaw" + "id": "firecrawl-integration", + "source": "linkedin", + "author": "Firecrawl", + "url": "https://www.linkedin.com/posts/firecrawl_hermes-agent-by-nous-research-can-now-scrape-activity-7445140884683395072-sm2d", + "date": "2026", + "category": "integrations", + "headline": "Firecrawl for scrape/search/browse", + "quote": "Hermes Agent by Nous Research can now scrape, search, and interact with the web using Firecrawl. Enable it during setup to give Hermes the ability.", + "size": "sm" }, { - "source": "reddit", - "author": "u/Delicious_Ease2595", - "url": "https://www.reddit.com/r/openclaw/comments/1slqt5h/is_hermes_agent_a_new_hype_or_is_it_genuinely/", - "date": "2026-04-15", - "category": "dev-workflow", - "headline": "Side-by-side: Hermes more stable, troubleshoots OpenClaw", - "quote": "Using both side by side I find it more stable and less headache than OC. Hermes has more research skills, and it's very handy as troubleshooter of OC. Telegram recently added bot to bot communication in their API so I'm thinking a way both communicate.", - "size": "md", - "id": "reddit-delicious-ease2595-hermes-more" - }, - { - "source": "reddit", - "author": "u/yellow-green-bird", - "url": "https://www.reddit.com/r/openclaw/comments/1slqt5h/is_hermes_agent_a_new_hype_or_is_it_genuinely/", - "date": "2026-04-15", - "category": "dev-workflow", - "headline": "Every OpenClaw update breaks something — Hermes just runs", - "quote": "Came here to say the same. Every time I update OpenClaw it breaks something, that I have to randomly find later. Hermes just runs and never once I had to go in circles to repair it yet.", - "size": "sm", - "id": "reddit-yellow-green-bird-every-openclaw-update" - }, - { - "source": "reddit", - "author": "u/itsdodobitch", - "url": "https://www.reddit.com/r/hermesagent/comments/1t29ogw/one_month_with_hermes_agent_what_i_wish_i_knew/", - "date": "2026-05-03", + "id": "discord-bert-71849-audit-grafana", + "source": "discord", + "author": "@bert_71849", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1503114142117265448-Hermes-Audit-Plugin---Grafana-Dashboards.txt", + "date": "", "category": "meta", - "headline": "One month with Hermes: don't build the whole machine on day one", - "quote": "Hermes works impressively well out of the box. The real challenge starts after that first 'wow' moment, because Hermes is powerful enough to make you overestimate how ready you are to use it properly. Start with one small workflow. Make it boringly reliable. Then add the next piece. Don't turn the default profile into a giant backpack full of every skill, every tool, every instruction.", - "size": "lg", - "id": "reddit-itsdodobitch-one-month-with" + "headline": "Every tool call into SQLite, with Grafana dashboards", + "quote": "I created a small Hermes plugin that records every tool call and every LLM API call into a local SQLite database (one DB per profile), with five ready-to-go Grafana dashboards bundled.", + "size": "md" }, { - "source": "reddit", - "author": "u/Birdinhandandbush", - "url": "https://www.reddit.com/r/hermesagent/comments/1snfnq9/yes_hermes_and_qwen354b_is_all_i_need_details/", - "date": "2026-04-16", + "id": "discord-tonywhelan-memory-tools-workflow", + "source": "discord", + "author": "@tonywhelan", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-10", "category": "personal-assistant", - "headline": "Hermes + Qwen3.5:4b on a 5060Ti is all I need", - "quote": "I have a 5060ti 16gb VRAM and 64gb DDR5 System Ram. I started out wanting to test Hermes as a Claw alternative. After a few days I set up Telegram with the botfather, and I haven't gone back to CLI. Hermes is now almost entirely a personal assistant on my Telegram App. Where the 9b chugged along, the 4B model is snappy, responsive, alive and chatty.", - "size": "lg", - "id": "reddit-birdinhandandbush-hermes" + "headline": "How I use Hermes memory: durable facts, session search, skills", + "quote": "memory tool — Save/retrieve durable facts across sessions. I save user preferences, environment details, tool quirks, stable conventions. It's injected into every turn. I keep it compact — facts that will still matter later. I do NOT save task progress or temporary state here. session_search — Recall past conversations. skill_manage — Save procedural memory. After complex tasks (5+ tool calls), tricky fixes, or non-trivial workflows, I save the approach as a skill.", + "size": "lg" }, { - "source": "reddit", - "author": "u/hackrepair", - "url": "https://www.reddit.com/r/hermesagent/comments/1smgo1i/my_hermes_journey/", - "date": "2026-04-15", + "id": "dev-arsh-natural-cron", + "source": "blog", + "author": "arshtechpro (dev.to)", + "url": "https://dev.to/arshtechpro/hermes-agent-a-self-improving-ai-agent-that-runs-anywhere-2b7d", + "date": "2026-03", + "category": "personal-assistant", + "headline": "'Every morning at 9am, check HN for AI news and DM me on Telegram'", + "quote": "Conversation continues across platforms (Telegram, Discord, Slack, WhatsApp, Signal, terminal). Real memory: two curated files MEMORY.md + USER.md, plus SQLite full-text search over all past sessions. Scheduled tasks via natural language — no crontab editing.", + "size": "md" + }, + { + "id": "discord-zmaxx-onchain-attest", + "source": "discord", + "author": "@.zmaxx", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1502632110345818123-Attest---onchain-identity-and-proof-of-work-for-Hermes-agents-via-EAS.txt", + "date": "", + "category": "integrations", + "headline": "Onchain identity and proof-of-work for Hermes agents", + "quote": "Built a skill that connects Hermes to Ethereum Attestation Service on Base mainnet. The agent autonomously creates onchain identity and proof-of-work attestations.", + "size": "sm" + }, + { + "id": "discord-omarlittle-matrix-skin", + "source": "discord", + "author": "@.omarlittle", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1496977719429169404-Enter-the-Matrix-skin.txt", + "date": "", + "category": "creative", + "headline": "Had the agent whip me up a Matrix-inspired skin", + "quote": "I wanted a skin with the classic black and green color scheme so I had the agent whip me up this Matrix inspired theme.", + "size": "sm" + }, + { + "id": "greg-isenberg-termux", + "source": "podcast", + "author": "Greg Isenberg & Imran Muthuvappa (Startup Ideas Podcast)", + "url": "https://podcasts.apple.com/dk/podcast/hermes-agent-clearly-explained-and-how-to-use-it/id1593424985?i=1000762440356", + "date": "2026", "category": "cost-optimization", - "headline": "My Hermes Journey: smart-routing tiers that save 10 hours and $40", - "quote": "Set this as your Smart routing default (using OpenRouter): Tier 1 Hermes (Gemini 3.1 Flash Lite) for clear mechanical multi-file work. Tier 2 Sonnet for ambiguous, delicate, high-risk tasks. Tier 3 Minimax for low-overhead. Run the minimax-cache-optimization skill. Seriously, do this from day one and you'll save about 10 hours of trial and error.", - "size": "lg", - "id": "reddit-hackrepair-my-hermes-journey" + "headline": "90% token spend cut. Runs on a cheap Android via Termux.", + "quote": "Switching to Hermes with OpenRouter cut my token spend ~90% — from ~$130 per 5 days to ~$10 per 5 days. Hermes runs on a cheap Android phone via Termux + Termux API — unlocks SMS, sensors, and on-device social posting. Customization is a trap; output is the skill.", + "size": "md" + }, + { + "id": "discord-dalekc72-hermes-managing-tickets", + "source": "discord", + "author": "@dalekc72", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1493589434971717763-Small-Win---Hermes---Claude-Code-managing-my-Tickets.txt", + "date": "", + "category": "business-ops", + "headline": "Hermes triages and works tickets in my PM software", + "quote": "Super excited that Hermes and Claude Code are now working tickets in my PM software, Plane.so. Tickets come in and Hermes triages and assigns and starts working the tickets. Basically Paperclip that gets sh*t done. They then document in the ticket and if needed create documentation for Obsidian.", + "size": "lg" + }, + { + "id": "gh-alexferrari-checkin", + "source": "github", + "author": "@alexferrari88", + "url": "https://github.com/NousResearch/hermes-agent/issues/9645", + "date": "2026", + "category": "personal-assistant", + "headline": "Proactive check-ins ('anything you want me to watch this afternoon?')", + "quote": "Some users want something more like a personal assistant: present, a bit more alive, and able to gently re-engage. 'Hey, anything you want me to keep an eye on this afternoon?'", + "size": "sm" + }, + { + "id": "discord-alpaca1712-hermes-ships-with-vals", + "source": "discord", + "author": "@alpaca1712", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1496701579234119850-I-gave-my-Hermes-agent-hands--aka-the-ability-to-ship-now.txt", + "date": "", + "category": "dev-workflow", + "headline": "I gave my Hermes agent hands — it ships micro-apps now", + "quote": "I wrote a blog post about how you can instantly have Hermes ship ideas with Vals. This allows me to easily create micro applications which are pretty fun without having to setup a deployment pipeline. I used Val Town, a platform that allows you to create micro applications and instantly deploy them.", + "size": "md" + }, + { + "id": "gh-isak-hunter", + "source": "github", + "author": "@isakcarlson5-del", + "url": "https://github.com/NousResearch/hermes-agent/issues/15818", + "date": "2026", + "category": "business-ops", + "headline": "Hunter.io email-finding for sales outreach", + "quote": "Surface Hunter.io (email lookup/verification) via Composio MCP for sales outreach workflows.", + "size": "sm" + }, + { + "id": "kristopher-codebase-memory", + "source": "blog", + "author": "Kristopher Dunham (Medium)", + "url": "https://medium.com/@creativeaininja/hermes-agent-the-open-source-ai-agent-that-actually-remembers-what-it-learned-yesterday-278441cd1870", + "date": "2026-04-14", + "category": "dev-workflow", + "headline": "Accumulates knowledge about my codebase over time", + "quote": "A long-running Hermes instance accumulates knowledge about your codebase, deployment quirks, preferred commit message format, working API call sequences for legacy integrations.", + "size": "sm" }, { "source": "reddit", @@ -1188,26 +1144,15 @@ "id": "reddit-ninjapapi-5-things-hermes" }, { - "source": "reddit", - "author": "u/Suitable_Currency440", - "url": "https://www.reddit.com/r/LocalLLaMA/comments/1ro9lph/anybody_who_tried_hermesagent/", - "date": "2026-03-08", - "category": "personal-assistant", - "headline": "Hermes very good as personal agent on Qwen3.5 27B", - "quote": "Fairly good with qwen3.5-4b, very decent with qwen3.5-9b, VERY good with 27b. Personal agent? Yes. Coding for high complexity tasks? Not really, but with high guidance? Yes.", - "size": "sm", - "id": "reddit-suitable-currency440-hermes-very-good" - }, - { - "source": "reddit", - "author": "u/sickleRunner", - "url": "https://www.reddit.com/r/LocalLLaMA/comments/1ro9lph/anybody_who_tried_hermesagent/", - "date": "2026-03-08", - "category": "cost-optimization", - "headline": "Switching between Hermes and OpenClaw on primeclaws.com", - "quote": "I tried hermes on primeclaws.com, it's nice that you can switch between hermes and openclaw and also you get AI models for free.", - "size": "sm", - "id": "reddit-sicklerunner-switching-between-hermes" + "id": "discord-nour-h-recall-memory", + "source": "discord", + "author": "@nour_h", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1503085649325195374-Introducing-Recall--a-local--auditable-memory-layer-for-Hermes-Agent.txt", + "date": "", + "category": "dev-workflow", + "headline": "Dogfooding a memory layer that isn't a black box", + "quote": "I've been building and dogfooding Recall, a Hermes-native memory provider designed for people who want memory that is useful, inspectable, and safe — not a black box.", + "size": "md" }, { "source": "reddit", @@ -1220,49 +1165,137 @@ "size": "md", "id": "reddit-jonathan-rivera-obsidian-as-the" }, + { + "id": "nazt-mcp-hybrid", + "source": "gist", + "author": "nazt (GitHub Gist)", + "url": "https://gist.github.com/nazt/849e29cd25c148b6cebafdbcc38bb6cc", + "date": "2026", + "category": "integrations", + "headline": "Fat agent → thin tool provider via hermes mcp serve", + "quote": "hermes mcp serve turns Hermes from a monolithic agent into a composable capability layer — any MCP client can borrow Hermes's 15+ messaging platforms, SQLite FTS5 persistence, and 73-skill tool surface without running Hermes as the primary agent.", + "size": "md" + }, + { + "id": "discord-atomlib-vpn-control-panel", + "source": "discord", + "author": "@_atomlib_", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-05-06", + "category": "dev-workflow", + "headline": "I don't know how to write code — Codex built me a VPN service", + "quote": "I used Codex to set up a VPN server with a control panel. It uses my ProtonVPN connections. Essentially, it allows any number of users to connect to the VPN with Xray, then they exit through ProtonVPN exit nodes. Admins can upload Wireguard configs, invite users, set limits, there are speed limits, data limits, etc. I have no idea how to write code, I have never done any networking stuff whatsoever, or even set up servers, I asked Codex to set up everything.", + "size": "lg" + }, + { + "id": "discord-absolutegamer-minecraft-skill", + "source": "discord", + "author": "@absolutegamer2337", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1495582727217479720-Hermes-Auto-Plays-Minecraft-SKILL.txt", + "date": "", + "category": "creative", + "headline": "Asked Hermes to write a skill that auto-plays Minecraft", + "quote": "So I figured I'd ask hermes to make a skill to auto play minecraft, it took some nudging in the direction I wanted, but it came up with this. I ran the skill and it has been thinking for 20 minutes.", + "size": "sm" + }, { "source": "reddit", "author": "u/itsdodobitch", - "url": "https://www.reddit.com/r/hermesagent/comments/1t4efcb/what_is_the_new_kanban_feature_built_into_hermes/", - "date": "2026-05-05", - "category": "dev-workflow", - "headline": "Kanban multi-agent feature is game-changing", - "quote": "WHAT IS THE NEW KANBAN FEATURE BUILT INTO HERMES? (IT'S GAME CHANGING) — image post showing Hermes' new built-in Kanban board where a parent agent posts cards and child subagents pull them, work in parallel, and report back, turning the agent into a multi-agent project manager.", - "size": "md", - "id": "reddit-itsdodobitch-kanban-feature" - }, - { - "source": "x", - "author": "@vmiss33", - "url": "https://x.com/vmiss33/status/2050984822168830302", + "url": "https://www.reddit.com/r/hermesagent/comments/1t29ogw/one_month_with_hermes_agent_what_i_wish_i_knew/", "date": "2026-05-03", + "category": "meta", + "headline": "One month with Hermes: don't build the whole machine on day one", + "quote": "Hermes works impressively well out of the box. The real challenge starts after that first 'wow' moment, because Hermes is powerful enough to make you overestimate how ready you are to use it properly. Start with one small workflow. Make it boringly reliable. Then add the next piece. Don't turn the default profile into a giant backpack full of every skill, every tool, every instruction.", + "size": "lg", + "id": "reddit-itsdodobitch-one-month-with" + }, + { + "id": "0xmega-no-mac-mini", + "source": "blog", + "author": "Alex P. (Medium)", + "url": "https://medium.com/@0xmega/hermes-agent-the-complete-setup-guide-telegram-discord-vps-no-mac-mini-required-dda315a702d3", + "date": "2026-03-30", "category": "cost-optimization", - "headline": "100% human guide: what I use Hermes for and how I keep it cheap", - "quote": "100% human generated. Includes what I use Hermes agent for (since I've seen a lot of people wondering what to do with this thing), and what models/providers I use to keep things cheap. I have been running a multi agent setup for Hermes agent for the last several weeks. It sends me messages on Telegram to remind me.", - "size": "lg", - "id": "x-vmiss33-human-guide" + "headline": "Under $20/mo total — no Mac Mini, no Opus", + "quote": "OpenClaw setup: Mac Mini M4 ($599) + Opus 4.6 = ~$80–150/mo. Hermes on VPS: under $20/mo total using Minimax M2.7. Example first task: 'check the top 5 trending GitHub repos right now and send me a summary.'", + "size": "md" }, { - "source": "x", - "author": "@HeyYanvi", - "url": "https://x.com/HeyYanvi/status/2046015096514617385", - "date": "2026-04-19", - "category": "creative", - "headline": "Hermes designed an X-to-NotebookLM podcast workflow for me", - "quote": "This research is gold. Been deep in Hermes for weeks and it's started autonomously suggesting entire workflows I never would have designed myself. One it built for me recently: X API to extract from lists and bookmarks to structure into article to NotebookLM podcast. I'm building a physical AI companion with Hermes as the core cognitive layer right now.", - "size": "md", - "id": "x-heyyanvi-hermes-designed-an" + "id": "gh-zednik-slides", + "source": "github", + "author": "@zednik-max", + "url": "https://github.com/NousResearch/hermes-agent/issues/15600", + "date": "2026", + "category": "business-ops", + "headline": "Create and edit Google Slides decks", + "quote": "Extending google-workspace skill to Google Slides so Hermes can create and edit presentations for users already in Google Workspace.", + "size": "sm" }, { - "source": "x", - "author": "@ExileAI_0", - "url": "https://x.com/ExileAI_0/status/2046197309495533698", - "date": "2026-04-20", - "category": "creative", - "headline": "Spare-laptop Hermes 'Iris' builds a RenPy visual novel autonomously", - "quote": "Secondary Hermes install yesterday on a spare laptop. Introduced it to the network, gave it two targets: RenPy and ComfyUI. It found ComfyUI, figured out how to generate images locally with LM Studio, then asked me to turn on the internet to install RenPy. About 10 minutes later there popped up a small but complete RenPy novel with 10 images and a little story.", - "size": "lg", - "id": "x-exileai-0-hermes-iris" + "id": "discord-mulkproject-copilot-delegate-opencode", + "source": "discord", + "author": "@mulkproject", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-12", + "category": "cost-optimization", + "headline": "Free GPT-4.1 via Copilot, Hermes delegates coding to OpenCode", + "quote": "I'm proud to say that Hermes is fucking awesome if you know how to make use of it. I'm using copilot github 10usd pro subscription and use the free model zero credits that comes with it. The model is gpt-4.1 and I'm loving how useful to have it in Hermes. I only use premium model to opencode for coding project so my Hermes agent smart enough to delegate task to opencode.", + "size": "md" + }, + { + "source": "reddit", + "author": "u/Suitable_Currency440", + "url": "https://www.reddit.com/r/LocalLLaMA/comments/1ro9lph/anybody_who_tried_hermesagent/", + "date": "2026-03-08", + "category": "personal-assistant", + "headline": "Hermes very good as personal agent on Qwen3.5 27B", + "quote": "Fairly good with qwen3.5-4b, very decent with qwen3.5-9b, VERY good with 27b. Personal agent? Yes. Coding for high complexity tasks? Not really, but with high guidance? Yes.", + "size": "sm", + "id": "reddit-suitable-currency440-hermes-very-good" + }, + { + "id": "discord-malaiwah-fork-watchtower", + "source": "discord", + "author": "@malaiwah", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-10", + "category": "dev-workflow", + "headline": "Local Gitea fork + watchtower auto-restarts my Hermes in 10 minutes", + "quote": "I have my own fork (on local Gitea) that has workflow to build a container image for merges to main and it pushes to my gitea container registry (packages). I have my Hermes Agent running in a podman container that has the correct label for watchtower to restart it when the image changes. Building the image takes 5 minutes, watchtower runs every 5.. So maximum 10 minutes after I push a commit to main, I get the agent restarted.", + "size": "lg" + }, + { + "id": "discord-anibal3608-adguard-plugin", + "source": "discord", + "author": "@anibal3608", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1487973263455031296-Hermes-AdGuard-Home-Plugin.txt", + "date": "", + "category": "integrations", + "headline": "An AdGuard plugin so I browse faster, ad-free", + "quote": "I created also plugin for AdGuard Home, to browse faster without ads on the web. Is needed to install and configure adguardhome first, and then install and enable plugin on Hermes.", + "size": "sm" + }, + { + "id": "gh-declan-webchat", + "source": "github", + "author": "@declan2010", + "url": "https://github.com/NousResearch/hermes-agent/issues/4514", + "date": "2026", + "category": "integrations", + "headline": "Webchat: custom themed browser UI on MEMORY.md", + "quote": "I created a beautiful web interface for Hermes Agent that adds dark/light theme, persistent memory using MEMORY.md and USER.md, per-session chat history, status bar, responsive on mobile and desktop.", + "size": "sm" + }, + { + "id": "discord-lauratom-memory-kernel-companion", + "source": "discord", + "author": "@lauratom", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent/", + "date": "2026-04-04", + "category": "dev-workflow", + "headline": "A memory kernel that compiles thoughts, not vectors", + "quote": "Actually I've been building exactly this for my agent (Hermes) for a while now... Instead of a dumb vector search, my memory kernel intercepts the LLM's thoughts and 'compiles' them into a living structured database. Every extracted memory gets a lifecycle status. If the agent learns something that contradicts old data, a background orchestrator automatically detects it.", + "size": "md" }, { "source": "x", @@ -1275,6 +1308,798 @@ "size": "md", "id": "x-brucexu-eth-hermes" }, + { + "id": "discord-arm64be-personal-webpage-style", + "source": "discord", + "author": "@arm64be", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1497329157149954099-my-personal-webpage-style-skill.txt", + "date": "", + "category": "creative", + "headline": "Turned my personal site's webdev style into a skill", + "quote": "i've had some people asking about the webdev results i get, so i had minimax (my usual webdev model) turn it all into a skill — this is the same style as my personal site.", + "size": "sm" + }, + { + "id": "discord-modest-maoist-cartographer-brain", + "source": "discord", + "author": "@modest.maoist", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1495354913205780661-Cartographer--Fully-Configurable-Semantic-Knowledge---Memory-Substrate.txt", + "date": "", + "category": "personal-assistant", + "headline": "A semantic knowledge substrate I made for my brain", + "quote": "Point it at your existing setup — Obsidian, vimwiki, Hermes sessions — and you've already built a semantic topology over your knowledgebase. Pair it with mapsOS — braindump to your agent, it parses into a qualitative map of your entire life. I made it & mapsOS for my brain. You can make them for yours.", + "size": "md" + }, + { + "id": "saboo-monica", + "source": "x", + "author": "@Saboo_Shubham_", + "url": "https://x.com/Saboo_Shubham_/status/2049541356767576388", + "date": "2026-04-29", + "category": "content-creation", + "headline": "Monica that writes in my voice", + "quote": "I kept the OpenClaw squad running, but set up a second Monica on Hermes. Same Mac Mini. Monica had written a procedure for reading my published articles before drafting in my voice. An Agent with skills that grows with you.", + "size": "sm" + }, + { + "id": "discord-o-o-o-o000-adhd-cronjobs", + "source": "discord", + "author": "@o_o__o_o000", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-05-07", + "category": "personal-assistant", + "headline": "Cronjobs nudging me on Discord and Signal for executive function", + "quote": "For executive function, seeing as that's what I'm struggling with, I can't rely on me being the one to prompt it into existence or check over things, because otherwise maybe I wouldn't need the tool in the first place. At the moment my simple cronjobs checking google tasks for changes and sending message to discord and signal are costing 14k tokens.", + "size": "md" + }, + { + "id": "nathanwilbanks-297-streak", + "source": "x", + "author": "@NathanWilbanks_", + "url": "https://x.com/NathanWilbanks_/status/2047883176622620934", + "date": "2026-04-25", + "category": "business-ops", + "headline": "Day 297 of my streak: $100K of client work automated", + "quote": "I'm on day 297 of my streak: 900,000+ seconds of compute time automated, 5,000,000,000+ tokens generated, $100,000+ in client work value automated.", + "size": "md" + }, + { + "source": "reddit", + "author": "u/Delicious_Ease2595", + "url": "https://www.reddit.com/r/openclaw/comments/1slqt5h/is_hermes_agent_a_new_hype_or_is_it_genuinely/", + "date": "2026-04-15", + "category": "dev-workflow", + "headline": "Side-by-side: Hermes more stable, troubleshoots OpenClaw", + "quote": "Using both side by side I find it more stable and less headache than OC. Hermes has more research skills, and it's very handy as troubleshooter of OC. Telegram recently added bot to bot communication in their API so I'm thinking a way both communicate.", + "size": "md", + "id": "reddit-delicious-ease2595-hermes-more" + }, + { + "id": "discord-itsdodo21-hermes-desktop", + "source": "discord", + "author": "@itsdodo21", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1492615746629402735-Hermes-Desktop-v0.4.0---why-I-didn-t-build-another-gateway-UI.txt", + "date": "", + "category": "dev-workflow", + "headline": "A native Mac app that sits next to my terminal", + "quote": "Hermes Desktop is a native Mac app for Hermes Agent. Not a browser wrapper but a real SwiftUI app that connects to your Hermes host and files over SSH. The gateway model is the right answer for Telegram and Discord. For a native Mac app that sits next to your terminal, it's just not the one I'd ship.", + "size": "md" + }, + { + "id": "discord-reyartage-operational-checkpoint", + "source": "discord", + "author": "@reyartage", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1495063142416257095-Hermes-Operational-Checkpoint-Plugin.txt", + "date": "", + "category": "dev-workflow", + "headline": "A compression plugin for sessions that go on forever", + "quote": "I just open-sourced Hermes Operational Checkpoint Plugin. It's a plugin for Hermes built for the kind of sessions that go on long enough that normal compression can start softening the thread of the work a little too much. The goal here was pretty simple: after manual compression or auto-compression, the session should still feel like it remembers what it was actually doing.", + "size": "md" + }, + { + "id": "captain-awesome-news-discord-cron", + "source": "x", + "author": "@emmagine79", + "url": "https://x.com/emmagine79/status/2053360898501468362", + "date": "2026-05-10", + "category": "content-creation", + "headline": "Cron jobs that triage tech news into Discord channels by urgency", + "quote": "It set up cron jobs that search for news/leaks/rumors in the tech space, then created channels on Discord by importance/urgency. It auto-contextualizes each news item to my vault and the actual work I have across video projects — I get up-to-date insights and tweak videos to stay super relevant. Updates 3x a day, always learning and adapting.", + "size": "md" + }, + { + "id": "ken-huang-production", + "source": "blog", + "author": "Ken Huang (Substack)", + "url": "https://kenhuangus.substack.com/p/chapter-10-production-deployment", + "date": "2026-04-27", + "category": "enterprise", + "headline": "Hermes as CLI/gateway-first — 13 platforms under one process", + "quote": "Hermes Agent: CLI/gateway-first — standalone agent for messaging platforms, schedules, and command line. Gateway multiplexes 13 platforms under one process.", + "size": "sm" + }, + { + "id": "gh-paultisl-tailscale", + "source": "github", + "author": "@PaulTisl", + "url": "https://github.com/NousResearch/hermes-agent/issues/9269", + "date": "2026", + "category": "privacy", + "headline": "Tailscale serve for secure remote access, no exposed ports", + "quote": "Users want secure remote access to the Hermes API server / Open WebUI without exposing ports publicly. Tailscale serve provides zero-config HTTPS tunneling over a private mesh.", + "size": "sm" + }, + { + "id": "discord-0xajpanda-validator-monitor", + "source": "discord", + "author": "@0xajpanda", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent/", + "date": "2026-03-24", + "category": "integrations", + "headline": "Hermes watches my homelab validators and pings Telegram", + "quote": "I've been running OpenClaw as my homelab AI agent gateway for months (Telegram/Discord, validators, cron jobs)... Built on top: a Hermes skill that monitors blockchain validator endpoints (0G, FortyTwo relay) and fires Telegram alerts on state changes — no spam, only alerts on up↔down transitions.", + "size": "md" + }, + { + "id": "discord-s0uthpaw-agentic-elevator-music", + "source": "discord", + "author": "@.s0uthpaw", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent/", + "date": "2026-03-10", + "category": "creative", + "headline": "Speech-to-speech with Hermes, plus generated background music", + "quote": "I work with Hermes almost exclusively speech-to-speech until my kids go to bed and I can sit down and focus at my desk. Well I added a little skill to my Hermes that plays generated background music while it is working and fades out when Hermes stops. It's nicer than listening for my GPUs to stop spinning lol and if Hermes hits a wall, the music stops and I know from the other room.", + "size": "lg" + }, + { + "id": "discord-ogiberstein-project-os", + "source": "discord", + "author": "@ogiberstein", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1496117910287024159-Running-Hermes-as-a-Hardcore-Project-Operating-System.txt", + "date": "", + "category": "business-ops", + "headline": "Hermes as my Chief of Staff with sub-agents per project", + "quote": "My 'main agent' is my 'Chief of Staff' who has his own memory cross-project/workflow. Every 'project' (1 project = 1 Slack channel) has its own agent sub-profile with its own memory. The whole system runs on a VPS, with backup routing if the main model fails, and gets backed up every night to Github. Daily reporting is sent to WhatsApp.", + "size": "lg" + }, + { + "id": "exm-family-whatsapp", + "source": "x", + "author": "@EXM7777", + "url": "https://x.com/EXM7777/status/2049869015221510424", + "date": "2026-04-30", + "category": "personal-assistant", + "headline": "One Hermes for the whole family on WhatsApp", + "quote": "3 weeks ago I decided to setup an Hermes agent for my family (3 members), they all use it for different use cases, one $200 ChatGPT sub is more than enough. It unlocked a whole new world for them, just because it lives inside whatsapp and has magic proactive behaviors.", + "size": "md" + }, + { + "id": "discord-mibayy-hermes-memory", + "source": "discord", + "author": "@mibayy", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1486509881673383998-Hermes-memory---Persistent-structured-memory-for-Hermes-agents.txt", + "date": "", + "category": "dev-workflow", + "headline": "Built persistent structured memory because compression dropped my constraints", + "quote": "After ~30 turns, context compression silently removes older messages. A constraint decided at turn 5 is gone by turn 50. The agent contradicts itself and re-asks questions.", + "size": "md" + }, + { + "id": "gh-rohit-agentmemory", + "source": "github", + "author": "@rohitg00", + "url": "https://github.com/NousResearch/hermes-agent/issues/6715", + "date": "2026", + "category": "integrations", + "headline": "Cross-agent memory: Hermes + Claude Code + Cursor", + "quote": "Built a memory provider plugin connecting agentmemory to Hermes. Covers cross-agent memory (developer using Hermes plus Claude Code or Cursor) with hybrid BM25+vector+knowledge-graph search.", + "size": "sm" + }, + { + "id": "discord-gwyntel-onstar-remote-start", + "source": "discord", + "author": "@gwyntel", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1502215257173790730-Control-OnStar-vehicles--Chevrolet--GMC--Buick--Cadillac--via-Hermes-Agent.txt", + "date": "", + "category": "integrations", + "headline": "I made a skill to remote start my car", + "quote": "I made a skill to remote start my car, check EV battery level, etc - and I noticed nobody else had made a skill for it, so here's mine.", + "size": "sm" + }, + { + "id": "gh-0xmrblue-computer-use", + "source": "github", + "author": "@0xMrBlueOps", + "url": "https://github.com/NousResearch/hermes-agent/issues/15876", + "date": "2026", + "category": "integrations", + "headline": "Desktop computer-use module: noVNC, screenshots, mouse/keyboard", + "quote": "I built an optional desktop computer-use module for Hermes: computer_use_tool.py plus a containerized desktop with persistent Chromium, mouse/keyboard control, and screenshots.", + "size": "sm" + }, + { + "id": "discord-jerometao-38874-compiled-skills", + "source": "discord", + "author": "@jerometao_38874", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-06", + "category": "dev-workflow", + "headline": "I now compile skills into code, only invoking AI at necessary steps", + "quote": "I once built skills that worked very well with advanced models connected to the Hermes Agent, but they performed poorly when switched to weaker models. For this reason, I now prefer compiling skills into code, only involving AI at necessary steps. This greatly improves the reliability and consistency of execution.", + "size": "md" + }, + { + "id": "betterstack-tweets", + "source": "youtube", + "author": "Better Stack (YouTube)", + "url": "https://www.youtube.com/watch?v=HdxtLpL9CC8", + "date": "2026", + "category": "content-creation", + "headline": "Tweets in my voice, pulled from past video scripts", + "quote": "Prompted Hermes to help write tweets based on past video scripts. Pointed it at a scripts folder; it analyzed my writing style, produced usable tweets, and saved preferences to memory automatically. Brand new session test: it recalled everything, including preferred emojis.", + "size": "md" + }, + { + "id": "gh-m1chael-jmap", + "source": "github", + "author": "@m1chaeljmk", + "url": "https://github.com/NousResearch/hermes-agent/issues/11424", + "date": "2026", + "category": "integrations", + "headline": "JMAP email for Fastmail users", + "quote": "Requesting JMAP support in email integration for Fastmail users (more efficient than IMAP).", + "size": "sm" + }, + { + "source": "reddit", + "author": "u/itsdodobitch", + "url": "https://www.reddit.com/r/hermesagent/comments/1t4efcb/what_is_the_new_kanban_feature_built_into_hermes/", + "date": "2026-05-05", + "category": "dev-workflow", + "headline": "Kanban multi-agent feature is game-changing", + "quote": "WHAT IS THE NEW KANBAN FEATURE BUILT INTO HERMES? (IT'S GAME CHANGING) — image post showing Hermes' new built-in Kanban board where a parent agent posts cards and child subagents pull them, work in parallel, and report back, turning the agent into a multi-agent project manager.", + "size": "md", + "id": "reddit-itsdodobitch-kanban-feature" + }, + { + "id": "discord-mattbcool-monterey-otterbot", + "source": "discord", + "author": "@mattbcool", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1498343497848197171-Monterey-Bay-Tech-Agent.txt", + "date": "", + "category": "general", + "headline": "My local community agent runs on a 16GB Mac mini", + "quote": "Here is my Hermes Agent use case running on 16GB mac mini. Appreciate anyone checking it out and offering any feedback.", + "size": "sm" + }, + { + "id": "krynsky-switched", + "source": "x", + "author": "@krynsky", + "url": "https://x.com/krynsky/status/2044089946018062614", + "date": "2026-04-14", + "category": "meta", + "headline": "Switched from OpenClaw, not looking back", + "quote": "I switched from OpenClaw to Hermes and not looking back. This was a major update with tons of goodies.", + "size": "sm" + }, + { + "id": "awesome-hermes", + "source": "github", + "author": "@0xNyk", + "url": "https://github.com/0xNyk/awesome-hermes-agent", + "date": "2026", + "category": "meta", + "headline": "awesome-hermes-agent: community-curated skills list", + "quote": "A curated list of skills, tools, integrations and resources for enhancing your Hermes Agent workflow — resources tied to the agentskills.io standard.", + "size": "sm" + }, + { + "id": "gh-trevor-imessage", + "source": "github", + "author": "@trevorgordon981", + "url": "https://github.com/NousResearch/hermes-agent/issues/6430", + "date": "2026", + "category": "personal-assistant", + "headline": "Hermes over iMessage on my always-on Mac Studio", + "quote": "I run Hermes Agent as a personal AI assistant on a Mac Studio that is always on. My primary communication with other people happens through iMessage. I can message my assistant from my iPhone, iPad, Mac, or Apple Watch. Group chats with friends could include the assistant naturally.", + "size": "md" + }, + { + "id": "theo-hetzner", + "source": "youtube", + "author": "Théo Vigneres (YouTube)", + "url": "https://www.youtube.com/watch?v=tm4h8dG-xlI", + "date": "2026-03", + "category": "cost-optimization", + "headline": "Hetzner VPS at $10/mo, Claude Opus via OpenRouter", + "quote": "Personal AI that lives on a server with persistent memory. Remembers preferences, projects, and past problem-solving. Accessible via Terminal, Telegram, Discord, Slack, or WhatsApp. Set up on a $10/month Hetzner VPS with Claude Opus via OpenRouter.", + "size": "sm" + }, + { + "id": "discord-lemoussel-vectorless-rag", + "source": "discord", + "author": "@lemoussel", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1501565023359598754-Vectorless-RAG-with-PageIndex-and-Hermes-Agent.txt", + "date": "", + "category": "dev-workflow", + "headline": "Built a vectorless RAG workflow with PageIndex and Hermes", + "quote": "I built a simple vectorless RAG workflow using PageIndex and Hermes Agent. Instead of splitting documents into chunks and retrieving them through vector similarity, the system relies on a hierarchical document structure and tool-based reasoning. The agent first understands how the document is organized, then...", + "size": "md" + }, + { + "id": "discord-0xchauncy-reina-soul-tone", + "source": "discord", + "author": "@0xchauncy", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-03-10", + "category": "creative", + "headline": "Wrote my Hermes agent's tone examples with my sister", + "quote": "when I was building reina (my hermes agent), i had a lot of fun because me and my sister sat down and came up with a handful of examples to pass into her persona via the soul file. here are a few: \"it's ugly but i fw it\" \"i don't care enough to argue but you're wrong\" \"no shot\" \"why did you say it like that\". i feel like humans have always had this yearning to make things that they see themselves in.", + "size": "lg" + }, + { + "id": "discord-jonmichaels-discord-read-plugin", + "source": "discord", + "author": "@jonmichaels", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1502295189446856995-Read-Discord-Messages-Plugin.txt", + "date": "", + "category": "integrations", + "headline": "Built a Discord-read plugin because I missed it from OpenClaw", + "quote": "I was so used to telling OpenClaw to read a specific Discord message for context that when I switched to Hermes Agent and it told me it couldn't, I was shocked.", + "size": "sm" + }, + { + "id": "discord-name-name-workshop-news-briefing", + "source": "discord", + "author": "@_name_name_", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent/", + "date": "2026-04-02", + "category": "general", + "headline": "Teaching a Linux user group to build agents with Hermes", + "quote": "I'm going to delivering a workshop on building agents with open weights models for personal use cases, using Hermes to a linux user group... I am thinking personalized news briefing is probably the easiest thing that will be generally useful and work across models.", + "size": "md" + }, + { + "id": "discord-quark2world-hermelin-skins", + "source": "discord", + "author": "@quark2world", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1487120834756874470-Hermes-Agent-Skins--Templates.txt", + "date": "", + "category": "meta", + "headline": "Made 4 custom skins for my HermelinChat GUI", + "quote": "I made 4 custom skins for Hermes for my HermelinChat GUI. They also work standalone and should be good enough as templates for your own Skins.", + "size": "sm" + }, + { + "id": "michael-security-eval", + "source": "gist", + "author": "michaeloboyle (GitHub Gist)", + "url": "https://gist.github.com/michaeloboyle/10461598db36066e4c366413d5416f83", + "date": "2026", + "category": "privacy", + "headline": "Independent technical security eval: 5 defensive patterns", + "quote": "The genuine differentiator is the multi-platform messaging gateway — runs across Telegram, Discord, Slack, WhatsApp, Signal, WeChat, iMessage, and CLI simultaneously. Five defensive security patterns including OSV malware checking for MCP packages and credential stripping from output.", + "size": "sm" + }, + { + "id": "yashica-linkedin", + "source": "youtube", + "author": "Yashica Jain (YouTube)", + "url": "https://www.youtube.com/watch?v=Mom3GVeiBR8", + "date": "2026", + "category": "content-creation", + "headline": "LinkedIn posts that remember my style", + "quote": "Every time you do something — for example, using Hermes to write a LinkedIn post — it uses that experience to create a new skill. Next time you ask it to generate a LinkedIn post, boom, you don't have to give it the same instructions.", + "size": "sm" + }, + { + "id": "discord-liftaris-herm-tui", + "source": "discord", + "author": "@liftaris", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1498955668311248948-Herm---Chat-and-Dashboard-TUI.txt", + "date": "", + "category": "dev-workflow", + "headline": "Built my own TUI so Hermes feels like OpenCode", + "quote": "Before Hermes, OpenCode was my daily driver. I built Herm because I wanted Hermes capabilities with an OpenCode-like interface. Herm uses the same TUI framework OpenCode is built with, all within my interface of choice: the terminal.", + "size": "md" + }, + { + "id": "discord-codename11-hermes-relay-android", + "source": "discord", + "author": "@codename_11", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1492283125613465640-Hermes-Relay--Android-App----Chat--terminal--and-device-control-over-WSS.txt", + "date": "", + "category": "messaging", + "headline": "Built an Android app for Hermes because I wanted no middleman", + "quote": "Native Android app for Hermes — built because I wanted a direct, no-middleman experience with the Hermes system and interface. Streaming chat, session management, slash commands, tool visualization, file attachments, message queuing.", + "size": "md" + }, + { + "id": "discord-lance960-rookery-llama-server", + "source": "discord", + "author": "@lance960", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1490440028860579992-Rookery.txt", + "date": "", + "category": "dev-workflow", + "headline": "Built Rookery because I was tired of killing llama-server processes", + "quote": "Built a tool to manage llama-server if anyone's tired of killing processes and restarting with different flags.", + "size": "sm" + }, + { + "id": "discord-jeyjay1245-bundled-api-endpoints", + "source": "discord", + "author": "@jeyjay1245", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1502170319215923240-Bundled-API-endpoints-for-agents.txt", + "date": "", + "category": "integrations", + "headline": "Got fed up with a million API keys, so I bundled them", + "quote": "So i got fed up with managing a million API keys in my hermes agent and started bundling related ones into single endpoints. Like all finance data through one call instead of five separate keys. It actually works really well for longer sessions where context gets messy.", + "size": "md" + }, + { + "id": "discord-offendingcommit-honcho-memory-ui", + "source": "discord", + "author": "@offendingcommit", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1500131734178762814-OpenConcho---a-Honcho-Memory-UI.txt", + "date": "", + "category": "dev-workflow", + "headline": "Tried every memory system, built a UI for the one I love", + "quote": "I've tried them all, honestly. Mem0, QMD, Mempalace, and now Honcho. I freakin' love Honcho, but I hate saying it.", + "size": "sm" + }, + { + "id": "discord-denis-skripnik-nvda-translator", + "source": "discord", + "author": "@denis_skripnik", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1477982563099349036-AI-translater-NVDA-addon-with-Nous-research.txt", + "date": "", + "category": "general", + "headline": "Blind since birth, I built an NVDA translator addon with Nous", + "quote": "I have been blind since birth. I use a screen reader program. It vocalizes all my actions. Today I created an add-on that translates texts into the languages I need by pressing Insert+Shift+T (selected text), Insert+Shift+Y - from the clipboard. Now I translated this text using Nous! Thank you for this opportunity!", + "size": "lg" + }, + { + "id": "kisztof-modal", + "source": "blog", + "author": "Krzysztof Słomka (Medium)", + "url": "https://kisztof.medium.com/hermes-agent-review-nous-researchs-self-improving-ai-agent-e72bc244435a", + "date": "2026-04-20", + "category": "dev-workflow", + "headline": "Telegram → Modal serverless. 40% faster on research tasks.", + "quote": "Chat via Telegram while execution runs on Modal serverless (cheap when idle). Run on a $5 VPS that stays up when the laptop closes. Pin to SSH backend inside a customer's VPC for consulting. Verified benchmark (TokenMix): self-created skills cut research-task time by ~40% vs. a fresh agent.", + "size": "md" + }, + { + "source": "reddit", + "author": "u/yellow-green-bird", + "url": "https://www.reddit.com/r/openclaw/comments/1slqt5h/is_hermes_agent_a_new_hype_or_is_it_genuinely/", + "date": "2026-04-15", + "category": "dev-workflow", + "headline": "Every OpenClaw update breaks something — Hermes just runs", + "quote": "Came here to say the same. Every time I update OpenClaw it breaks something, that I have to randomly find later. Hermes just runs and never once I had to go in circles to repair it yet.", + "size": "sm", + "id": "reddit-yellow-green-bird-every-openclaw-update" + }, + { + "id": "clawdi-builtwith", + "source": "producthunt", + "author": "Clawdi team (Product Hunt)", + "url": "https://www.producthunt.com/products/clawdi/built-with", + "date": "2026", + "category": "meta", + "headline": "'The best self-improving agent we've used'", + "quote": "Hermes is the best self-improving agent we've used — it gets smarter the longer you run it. The WhatsApp and Telegram integrations make it feel genuinely personal.", + "size": "sm" + }, + { + "source": "reddit", + "author": "u/hackrepair", + "url": "https://www.reddit.com/r/hermesagent/comments/1smgo1i/my_hermes_journey/", + "date": "2026-04-15", + "category": "cost-optimization", + "headline": "My Hermes Journey: smart-routing tiers that save 10 hours and $40", + "quote": "Set this as your Smart routing default (using OpenRouter): Tier 1 Hermes (Gemini 3.1 Flash Lite) for clear mechanical multi-file work. Tier 2 Sonnet for ambiguous, delicate, high-risk tasks. Tier 3 Minimax for low-overhead. Run the minimax-cache-optimization skill. Seriously, do this from day one and you'll save about 10 hours of trial and error.", + "size": "lg", + "id": "reddit-hackrepair-my-hermes-journey" + }, + { + "id": "discord-renomg-voice-call-timeout", + "source": "discord", + "author": "@renomg", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1496367052045291540-Hermes-Voice-Call-Timeout-Plugin.txt", + "date": "", + "category": "creative", + "headline": "I wanted to talk to my agent for hours, so I fixed the timeout", + "quote": "So, I had an issue. One of the things I like to do with Claude Voice or GPT is to be able to talk to it over long sessions.", + "size": "sm" + }, + { + "id": "reddit-windows-wrapper", + "source": "reddit", + "author": "r/SideProject", + "url": "https://www.reddit.com/r/SideProject/comments/1sdaojm/i_took_the_nousresearch_hermes_agent_and_built_a/", + "date": "2026", + "category": "meta", + "headline": "Native Windows app wrapper for Hermes", + "quote": "The NousResearch team built Hermes Agent — an open-source agentic AI system with tools, skills, memory, and multi-platform messaging. It's good. So I built a native Windows app around it.", + "size": "sm" + }, + { + "id": "gh-xwm1234-factory", + "source": "github", + "author": "@Xwm1234", + "url": "https://github.com/NousResearch/hermes-agent/issues/11653", + "date": "2026", + "category": "business-ops", + "headline": "Task-centric memory for a printing factory", + "quote": "I run a printing factory and use Hermes daily. Long conversations were making the agent slow and forgetful. So I built a custom Skill called Task-Centric Memory — auto-categorizes tasks into domains (Printing, Stocks); completed tasks are compressed into summary cards.", + "size": "md" + }, + { + "id": "discord-splosh123-tidal-music-skills", + "source": "discord", + "author": "@splosh123", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1494297717420199996-Tidal-Music-Player-Skills.txt", + "date": "", + "category": "creative", + "headline": "Twice-daily Tidal curation built for an avid music lover", + "quote": "Being an avid music lover and using Tidal I've created this skill set up: tidal-curator (Base Skill) for OAuth auth, playlist management, and track search; tidal-dual-curator for automated twice-daily music curation at 9am (morning vibe) and 6pm (evening vibe), with time-of-day matching, artist background, and no duplicates.", + "size": "md" + }, + { + "id": "discord-dworfd-boltai-gateway", + "source": "discord", + "author": "@dworfd", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1502296506735268102-gateway-plugin-for-BoltAI--and-other-OpenAI-API-clients----full-markdown---slash-commands.txt", + "date": "", + "category": "integrations", + "headline": "Wired BoltAI v2 into Hermes with full markdown and slash commands", + "quote": "I've been using BoltAI v2 with Hermes Agent and hit two annoyances with the official OpenAI-compatible gateway, so I built a gateway plugin for BoltAI (and other OpenAI API clients) — full markdown + slash commands.", + "size": "sm" + }, + { + "id": "discord-francipenov-macos-control-center", + "source": "discord", + "author": "@franci.penov", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1496335871035576350-MacOS-Control-Center-for-local-models.txt", + "date": "", + "category": "meta", + "headline": "A MacOS control center for the local models on my two machines", + "quote": "I have bunch of local models I have configured and am running locally on my two machines - Mac Mini and Ubuntu.", + "size": "sm" + }, + { + "id": "hn-vessel-browser", + "source": "hn", + "author": "unmodeledtyler (Quanta Intellect)", + "url": "https://news.ycombinator.com/item?id=47470156", + "date": "2026", + "category": "integrations", + "headline": "Vessel Browser: agent-native browser born at the Hermes hackathon", + "quote": "I recently participated in Nous Research's Hermes Agent Hackathon, which is where this project was born. Every tool out there assumes a human operator with automation bolted on. I wanted to flip that — make the agent the primary driver and give the human a supervisory role.", + "size": "md" + }, + { + "id": "discord-glitchglitchglitch-hermes-on-nixos", + "source": "discord", + "author": "@glitchglitchglitch", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent/", + "date": "2026-04-18", + "category": "dev-workflow", + "headline": "Running Hermes in a NixOS + container setup", + "quote": "i rlly like nix and i run my hermes agent in nixos+container setup. that's been the best experience for me... giving an ubuntu container defined by nix to hermes agent solves for 'oh xyz package not installed' cuz most of the (good) skills have a setup-phase and hence the required packages get installed or the agent installs em itself.", + "size": "md" + }, + { + "id": "discord-awais-h-hermes-reads-internet", + "source": "discord", + "author": "@awais_h", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1502519565144428605-Hermes-to-read-the-internet-for-me.txt", + "date": "", + "category": "personal-assistant", + "headline": "Hermes reads HackerNews and emails me a daily summary", + "quote": "Playing around with Hermes for the first time and it's a lot of fun! I was able to get it to read HackerNews once a day and send me an email summary of what I should be paying attention to. I'm going to extend it to read RSS feeds, and find other interesting content online.", + "size": "md" + }, + { + "id": "discord-smantena-aws-google-workspace", + "source": "discord", + "author": "@smantena", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1498227628337791006-Hermes-setup-on-Amazon-AWS-VPS-and-Google-Workspace-Automation.txt", + "date": "", + "category": "enterprise", + "headline": "Got Hermes running on AWS VPS with Google Workspace automation", + "quote": "Although Hermes installation was significantly easier than OpenClaw, it wasn't straight forward by any means. Things kept failing and it took many days-nights to figure it out because there was no documentation about this specific setup.", + "size": "sm" + }, + { + "id": "hn-rnxrx-obsidian", + "source": "hn", + "author": "rnxrx (Hacker News)", + "url": "https://news.ycombinator.com/item?id=47786673", + "date": "2026-04", + "category": "personal-assistant", + "headline": "Obsidian, home automation, VPS server management — on a cheap VPS", + "quote": "Having a competent agent with constant state has been good for memorializing and organizing important info directly into Obsidian, planning, and working out bugs with my home automation setup. Also helpful dealing with several miscellaneous servers in the house. I have it running on a cheap VPS and it's fairly locked down.", + "size": "md" + }, + { + "id": "discord-anibal3608-ollama-multiagent", + "source": "discord", + "author": "@anibal3608", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1496175277120950332-Hermes-Multi-Agent-with-Ollama-cloud-models.txt", + "date": "", + "category": "cost-optimization", + "headline": "Multi-agent Hermes on Ollama because it's cheaper", + "quote": "I made this repo to can use Hermes (or opencode) with multiple Agents. But using ollama that is cheaper than other providers.", + "size": "sm" + }, + { + "id": "discord-stefan171-dream-auto", + "source": "discord", + "author": "@stefan171", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1496725150656102561-Dream-Auto---Background-Thinking-Plugin-for-Hermes-Agent.txt", + "date": "", + "category": "dev-workflow", + "headline": "A plugin that lets Hermes think while I'm away", + "quote": "I built a plugin that lets Hermes think while you're away. Dream Auto indexes your sessions, grades them for 'dream potential,' and runs MCTS-powered background reasoning jobs when your machine is idle. The insights get injected into your active context automatically — no disruption, just smarter responses.", + "size": "md" + }, + { + "id": "captain-awesome-pm-standups-adhd", + "source": "x", + "author": "@emmagine79", + "url": "https://x.com/emmagine79/status/2053360898501468362", + "date": "2026-05-10", + "category": "personal-assistant", + "headline": "PM agent runs morning + evening standups for my ADHD", + "quote": "I have hermes act as the manager to several paperclip agents, one of them a Project Manager agent. This agent has full knowledge of me (ADHD), my vault and projects, so I get a morning and evening standup that dumps all work we did across different chats, projects I'm working on, actual output, info from past standups, and suggestions/prioritizing based on all of the above. And it's self-learning.", + "size": "md" + }, + { + "source": "reddit", + "author": "u/patbhakta", + "url": "https://www.reddit.com/r/Rag/comments/1sgmvxh/anyone_here_tried_hermes_agent_whats_your/", + "date": "2026-04-09", + "category": "personal-assistant", + "headline": "Hermes vs OpenClaw: memory lets me jump between projects", + "quote": "I'm using Hermes currently but only as a beginner agent. It's kinda like a VA. The good part about Hermes vs openclaw is memory. With OpenClaw it's a one track mind. With Hermes I can jump from one project to next but also go back to something from last week or more. Personally I use Hermes with paperclip which is chat.", + "size": "md", + "id": "reddit-patbhakta-hermes-vs-openclaw" + }, + { + "id": "gkisokay-watchdog", + "source": "x", + "author": "@gkisokay", + "url": "https://x.com/gkisokay/status/2037924543311016432", + "date": "2026-03-28", + "category": "dev-workflow", + "headline": "Hermes as a watchdog for my other agent", + "quote": "POV: you use Hermes agent to fix your OpenClaw to save countless hours and credits every day. The setup that saved me hours every day: OpenClaw + Hermes watchdog.", + "size": "sm" + }, + { + "id": "discord-roach-jeong-h-ops-dashboard", + "source": "discord", + "author": "@roach_jeong", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1500463136182898709-Hermes-Oprations.txt", + "date": "", + "category": "meta", + "headline": "Built H-OPS to make multi-agent work observable", + "quote": "Been building H-OPS, an operator dashboard for Hermes Kanban. The goal is to make multi-agent work observable.", + "size": "sm" + }, + { + "id": "ksimback-hermesatlas", + "source": "x", + "author": "@KSimback", + "url": "https://x.com/KSimback/status/2041937777508675611", + "date": "2026-04-08", + "category": "meta", + "headline": "Scraped the entire Hermes ecosystem (hermesatlas.com)", + "quote": "I was an early user of Hermes Agent and have been a power user ever since. Scraped every GitHub repo related to Hermes, filtered out unfinished, built an ecosystem map and published a website (hermesatlas.com) where you can see all projects organized by category with star ratings.", + "size": "md" + }, + { + "id": "discord-zedosplasticos-business-swarm", + "source": "discord", + "author": "@zedosplasticos008", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-03-21", + "category": "business-ops", + "headline": "Ported a competitor-analysis swarm from Codex to Hermes", + "quote": "I was building a swarm (that actually works) on codex to analyse business and their competitors. To find gaps and build a strategy to outrank them. After a couple a hours, pushing the swarm to hermes and convert it to hermes env. It made a really good job. Now i was trying to teach him how to use memory in a different way. Instead spamming into that memory.md file. I want him to know when to route to a specific memory layer.", + "size": "md" + }, + { + "id": "discord-vgallotti-rtk-token-savings", + "source": "discord", + "author": "@vgallotti", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1490184342507491489-RTK-Hermes---60---Token-Savings.txt", + "date": "", + "category": "cost-optimization", + "headline": "Cut 60–90% of context tokens with an RTK integration", + "quote": "We built an RTK integration for Hermes that transparently rewrites terminal commands to their RTK equivalents, saving 60-90% of tokens in your context window.", + "size": "sm" + }, + { + "id": "discord-wolframravenwolf-home-assistant", + "source": "discord", + "author": "@wolframravenwolf", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1487152060465156216-Hermes-Agent-Home-Assistant-Add-on.txt", + "date": "", + "category": "integrations", + "headline": "Run Hermes Agent right inside Home Assistant", + "quote": "Run Hermes Agent right inside your Home Assistant. This add-on takes you from zero to working agent in less than five minutes.", + "size": "sm" + }, + { + "source": "reddit", + "author": "u/Suitable_Currency440", + "url": "https://www.reddit.com/r/LocalLLaMA/comments/1ro9lph/anybody_who_tried_hermesagent/", + "date": "2026-03-08", + "category": "dev-workflow", + "headline": "Hermes is OpenClaw with a week of debug + RAG + memory", + "quote": "Its amazing, its openclaw already set up and working, its like an OC with 1 week of debugging manually done + rag + memory persistence + better tool calling. (Qwen3.5-9b, 16gb VRAM), 10/10, only will go back to OC if it becomes at least on par with it.", + "size": "md", + "id": "reddit-suitable-currency440-hermes-is-openclaw" + }, + { + "id": "discord-djbuck-ios-companion", + "source": "discord", + "author": "@djbuck", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1490473436085223657-Hermes-iOS-App.txt", + "date": "", + "category": "personal-assistant", + "headline": "Giving my agent iOS sensors: health, location, voice", + "quote": "Building a Hermes iOS app + desktop companion to give my agent some extra iOS tools. Sensor pipeline for health, location services. Can now ask agent health or location specific questions. 'How many steps did I walk yesterday?' 'What's a good coffee shop nearby?'", + "size": "md" + }, + { + "id": "discord-phillipd-agentbox-email", + "source": "discord", + "author": "@phillipd.eth", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1496465635478077561-agentbox.id---email-for-hermes-agents.txt", + "date": "", + "category": "integrations", + "headline": "Built agentbox.id because no mail service felt right for agents", + "quote": "Inspired by Hermes' email gateway, I built agentbox.id because I couldn't find a mail service that felt light enough for agent workflows. It's live now, and I'd appreciate any feedback from this community on the approach.", + "size": "sm" + }, + { + "id": "discord-jezza2463-standing-instructions-plugin", + "source": "discord", + "author": "@jezza2463", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-03", + "category": "dev-workflow", + "headline": "A STANDING.md plugin so my agent stops guessing", + "quote": "My agent keeps confidently guessing instead of checking its own docs. I put 'always verify first' in memory but it has a hard char limit and entries get compressed/replaced over time. Fix: a tiny plugin that reads a STANDING.md file and injects it into the system prompt every turn via pre_llm_call.", + "size": "md" + }, + { + "id": "discord-fathah-mobile-remote", + "source": "discord", + "author": "@fathah.", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1496513601115131996-Hermes-Mobile-App.txt", + "date": "", + "category": "messaging", + "headline": "Controlling my Hermes Agent remotely from my phone", + "quote": "I've been using the mobile app to control my Hermes Agent remotely. Android & iOS. Drop your opinions, I'll publish it if looks useful.", + "size": "sm" + }, + { + "id": "discord-mikebirdtech-hermes-mini-doc", + "source": "discord", + "author": "@mikebirdtech", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1493649462122381394-Hermes-Agent-Mini-Documentary.txt", + "date": "", + "category": "content-creation", + "headline": "Made a Hermes Agent mini-documentary with hackathon finalists", + "quote": "I did a deep dive on Hermes Agent. Was able to talk to a bunch of the hackathon finalists as well as one of the co-founders of Nous!", + "size": "sm" + }, { "source": "x", "author": "@hypepartners", @@ -1285,5 +2110,500 @@ "quote": "Of the 95% of people who use AI, only 5% see real results. Hype's VP of AI, @glitch_, on why the gap isn't the models, but the architecture. Read his deep dive on building with Hermes Agent, @NousResearch, agent swarms, experiment loops, and what actually compounds.", "size": "md", "id": "x-hypepartners-why-of" + }, + { + "id": "julian-meet-teams", + "source": "blog", + "author": "Julian Goldie (Substack)", + "url": "https://juliangoldieseo1.substack.com/p/hermes-agent-v012-just-changed-ai", + "date": "2026-04-30", + "category": "business-ops", + "headline": "Auto-transcribe Meet calls, control from Teams, local models for client data", + "quote": "Auto-transcribe Google Meet calls — focus on conversation, not notes. Self-maintaining skill library. Control from Microsoft Teams. Local AI models via LM Studio — sensitive client data never leaves your machine. Native Spotify for voice-command music.", + "size": "md" + }, + { + "id": "discord-wysie-health-connect-receiver", + "source": "discord", + "author": "@wysie_", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1498344744449871952-Health-Connect-Webhook-Receiver.txt", + "date": "", + "category": "personal-assistant", + "headline": "Standalone, but I built it to use with Hermes", + "quote": "Standalone but I built it to use it with Hermes. Connect HC Webhook (some play store app) to it and you can sync your Health Connect data locally and do whatever.", + "size": "sm" + }, + { + "id": "discord-ones07389-4-agents-on-laptop", + "source": "discord", + "author": "@ones_07389", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1499716570878840882-Hermes-Multi-Agent-Setup---4-Agents-on-a-Laptop.txt", + "date": "", + "category": "meta", + "headline": "Running 4 Hermes agents 24/7 on a 32GB Ubuntu laptop", + "quote": "Running a 4-agent Hermes system 24/7 on a 32GB Ubuntu laptop, managed by systemd + custom watchdog. default (PM) — Feishu WebSocket; dev (Developer) — ACP delegated; devops (Ops) — ACP delegated; ip-agent (Content) — Cron scheduled. 5 MCP Servers, 34 tools total. Self-learning ACP v1.5 with auto distillation cron at 04:30 daily.", + "size": "lg" + }, + { + "id": "discord-l-acie-skill-md-tool-calls", + "source": "discord", + "author": "@l_acie", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-03", + "category": "dev-workflow", + "headline": "Using SKILL.md as my Notion / Outlook / SharePoint tool router", + "quote": "Currently I'm using SKILL.md as a custom \"tool\" (scripts) calling instruction file (for each category of skill, e.g. notion, outlook, sharepoint, etc.). But for hygiene it's probably better to have MCP own the tool definitions since I can just update and change it all in one place if needed.", + "size": "md" + }, + { + "id": "discord-visible-fix-natural-language-harness", + "source": "discord", + "author": "@visible_fix", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-08", + "category": "dev-workflow", + "headline": "3,000 logs of self-improvement on a custom harness", + "quote": "I use minimax m2.7, i built a natural language harness that basically commands my hermes agent. Lets me keep all the features and updates without breaking shit and i got over 3000 logs of potential improvements (most failures but yknow 3K gets data).", + "size": "md" + }, + { + "id": "discord-anibal3608-hermes-radar-spanish", + "source": "discord", + "author": "@anibal3608", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1496557014971584783-Hermes-Agent-Radar---Guia-para-usuarios-en-espa%C3%B1ol.txt", + "date": "", + "category": "general", + "headline": "Built a Hermes guide in Spanish using Hermes itself", + "quote": "Vi que la mayoria de las guias, ayudas, novedades son todas en ingles. Asique mediante Hermes me hice esta paginita en github pages para poder dar un poco de ayuda y novedades a la comunidad de habla hispana.", + "size": "sm" + }, + { + "id": "discord-jesus359-hermes-cardputer", + "source": "discord", + "author": "@jesus359_", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1494518737116729394-Hermes-x-Cardputer.txt", + "date": "", + "category": "integrations", + "headline": "Connected my M5 Cardputer to Hermes via the API", + "quote": "I used MiniMax to connect my M5 Cardputer to Hermes Agent through its API. This is a very barebone app right now but I will keep working on it. So far it has OTA for fw update and being able to text to Hermes. I added tts and stt but it's still a work in progress.", + "size": "md" + }, + { + "id": "reddit-hermify", + "source": "reddit", + "author": "r/vibecoding", + "url": "https://www.reddit.com/r/vibecoding/comments/1slhhj1/i_took_the_nousresearch_hermes_agent_and_built_a/", + "date": "2026", + "category": "meta", + "headline": "Hermify: managed hosting for Hermes", + "quote": "A few weeks ago I tried getting Hermes Agent running on a VPS. It worked, eventually, and is lowkey the most useful AI agent. So I built Hermify: easy managed hosting. You bring your API key + Telegram bot, we handle the hosting.", + "size": "sm" + }, + { + "id": "nickspisak-everything", + "source": "x", + "author": "@NickSpisak_", + "url": "https://x.com/NickSpisak_/status/2042709705991295221", + "date": "2026-04-10", + "category": "personal-assistant", + "headline": "Replaced everything with a single Hermes agent", + "quote": "Vibe after replacing everything with a Hermes agent: autoresearch, Karpathy LLM wiki second brain, skills creation, scheduled jobs, background monitoring, LLM model selection, Telegram/Discord support. A personal automation agent that lives on a server and talks to you through messaging apps or CLI.", + "size": "md" + }, + { + "id": "discord-kayp5780-webui-home-server", + "source": "discord", + "author": "@kayp5780", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1498760475926597693-Hermes-WebUI.txt", + "date": "", + "category": "messaging", + "headline": "My Hermes runs on my home server, I reach it from anywhere", + "quote": "I configured it to work from telegram as well. Now I'm able to use the link which it generates as an app. I have my own Hermes agent running from my home server. I can access it from anywhere.", + "size": "md" + }, + { + "id": "gh-enigma-merxex", + "source": "github", + "author": "@enigma-zeroclaw", + "url": "https://github.com/NousResearch/hermes-agent/issues/13562", + "date": "2026", + "category": "integrations", + "headline": "Agent-to-agent commerce via Merxex", + "quote": "I'm building Merxex, an agent-to-agent commerce platform that lets agents buy and sell services/work seamlessly. Hermes agents could benefit from a native monetization layer.", + "size": "sm" + }, + { + "id": "discord-wysie-whoop-plugin", + "source": "discord", + "author": "@wysie_", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1498020050047729806-Whoop-Plugin.txt", + "date": "", + "category": "personal-assistant", + "headline": "Pulling my Whoop data into Hermes locally", + "quote": "Pulls Whoop data into a local file and exposes some Hermes tools for whatever you may want to do. You can ask Hermes to follow this setup guide.", + "size": "sm" + }, + { + "id": "discord-bennytimz-pharma-skills-africa", + "source": "discord", + "author": "@bennytimz", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1493141848527474841-Pharma-skills-covering-ChEMBL--AlphaFold--OpenFDA--QSAR-workflows..txt", + "date": "", + "category": "research", + "headline": "Bringing AI-assisted drug discovery to Africa as a pharmacy undergrad", + "quote": "As a pharmacy undergrad in Nigeria, I've been thinking about what it would take to bring AI-assisted drug discovery to Africa.", + "size": "sm" + }, + { + "id": "gh-romanescu-skillfactory", + "source": "github", + "author": "@Romanescu11", + "url": "https://github.com/NousResearch/hermes-agent/issues/1935", + "date": "2026", + "category": "dev-workflow", + "headline": "Skill Factory: silently watches workflows and writes SKILL.md + plugin.py", + "quote": "I built a community plugin for Hermes called Skill Factory. It silently watches your workflows during a session and automatically proposes and generates reusable skills (SKILL.md + plugin.py) from them.", + "size": "sm" + }, + { + "id": "gh-samdu-kubernetes", + "source": "github", + "author": "@samdu", + "url": "https://github.com/NousResearch/hermes-agent/issues/11248", + "date": "2026", + "category": "enterprise", + "headline": "Kubernetes pod-hop handoff across restarts", + "quote": "When the gateway pod restarts (toolbox redeploy) in-memory context is lost. Proposes pod-hop, letting a running gateway hand off to a standby on a shared PVC.", + "size": "sm" + }, + { + "id": "wolfram-home-assistant-addon", + "source": "x", + "author": "@WolframRvnwlf", + "url": "https://x.com/WolframRvnwlf/status/2037583878009889013", + "date": "2026", + "category": "integrations", + "headline": "Home Assistant add-on: zero to agent in under 5 minutes", + "quote": "Takes you from zero to working Hermes agent in less than 5 minutes — a Home Assistant add-on for Hermes Agent.", + "size": "sm" + }, + { + "id": "discord-lsof-hermes-chat-proxy", + "source": "discord", + "author": "@lsof", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1495819189938552994-Lightweight-web-based-Hermes-chat-proxy.txt", + "date": "", + "category": "messaging", + "headline": "Built a web proxy so I can hand off Hermes sessions to mobile", + "quote": "I wanted a way to have smooth handoffs of sessions from computer to mobile, so I built a quick web-based proxy that does streaming chat via the Hermes API server.", + "size": "sm" + }, + { + "id": "gh-flyingcloud-migration", + "source": "github", + "author": "@flyingcloudliu-hub", + "url": "https://github.com/NousResearch/hermes-agent/issues/16134", + "date": "2026", + "category": "meta", + "headline": "Shadow-to-live migration from OpenClaw", + "quote": "A proposed migration path for users moving from OpenClaw to Hermes, covering shadow-mode runs before full cutover.", + "size": "sm" + }, + { + "id": "discord-harrison07525-security-rule-skill", + "source": "discord", + "author": "@harrison07525", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1488447168280006745-Hermes-Security-Rule-Skill.txt", + "date": "", + "category": "privacy", + "headline": "A skill that hardens my agent against common LLM threats", + "quote": "I've put together a skill to protect against some of the common LLM / AI threats and some of my recommended security policies that are being used to good effect on other AI agent tools.", + "size": "sm" + }, + { + "id": "gh-juan-email-pipeline", + "source": "github", + "author": "@JuanDragin", + "url": "https://github.com/NousResearch/hermes-agent/issues/5563", + "date": "2026", + "category": "dev-workflow", + "headline": "8h/day on Opus: email pipeline with DBOS + Postgres + S3", + "quote": "I run it daily for production software development, orchestrating a 3-actor email processing pipeline with DBOS, PostgreSQL, S3, Gmail API. 8+ hours per day on Claude Opus for 3 weeks.", + "size": "md" + }, + { + "id": "agentmail-inbox", + "source": "x", + "author": "@agentmail", + "url": "https://x.com/agentmail/status/2041605207704895810", + "date": "2026-04-07", + "category": "integrations", + "headline": "Give your Hermes its own email inbox", + "quote": "Here's how to give your Hermes agent its own email inbox. No SMTP/IMAP, no Google OAuth, just plug in AgentMail using MCP.", + "size": "sm" + }, + { + "source": "x", + "author": "@ExileAI_0", + "url": "https://x.com/ExileAI_0/status/2046197309495533698", + "date": "2026-04-20", + "category": "creative", + "headline": "Spare-laptop Hermes 'Iris' builds a RenPy visual novel autonomously", + "quote": "Secondary Hermes install yesterday on a spare laptop. Introduced it to the network, gave it two targets: RenPy and ComfyUI. It found ComfyUI, figured out how to generate images locally with LM Studio, then asked me to turn on the internet to install RenPy. About 10 minutes later there popped up a small but complete RenPy novel with 10 images and a little story.", + "size": "lg", + "id": "x-exileai-0-hermes-iris" + }, + { + "id": "discord-misswuhanliang-translator-extension", + "source": "discord", + "author": "@misswuhanliang", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1484856405424078980-HermesAI-Translator.txt", + "date": "", + "category": "content-creation", + "headline": "Built a browser extension for translation and summarization on Hermes-4-70B", + "quote": "I've developed and released HermesAI Translator, an open-source, AI-powered browser extension for seamless translation, reading assistance, text polishing, and summarization directly within the browser, driven by Hermes-4-70B.", + "size": "sm" + }, + { + "id": "discord-luminousix-claude-orchestrator", + "source": "discord", + "author": "@luminousix", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-03-30", + "category": "dev-workflow", + "headline": "Hermes orchestrates Claude Code over SSH to my Mac", + "quote": "Recently worked with my agent to create a bridge that runs exec commands to my mac, so I can work through ssh from my linux machine to my mac to run claude code or codex with hermes creating the prompts — basically my hermes agent is a claude code orchestrator and reviewer.", + "size": "md" + }, + { + "id": "discord-arm64be-models-running", + "source": "discord", + "author": "@arm64be", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1501317699698032772-all-my-knowledge-on-making-models-run.txt", + "date": "", + "category": "dev-workflow", + "headline": "All my knowledge on making models run, as a skill", + "quote": "i've seen some on-and-off interest in how i get some models running at the tok/s i do, and this should be it! my methodology to making my llama.cpp configurations, or as much of it as i can remember at the moment.", + "size": "sm" + }, + { + "id": "gh-artile-zed", + "source": "github", + "author": "@artile", + "url": "https://github.com/NousResearch/hermes-agent/issues/16028", + "date": "2026", + "category": "integrations", + "headline": "Hermes in Zed editor via ACP Registry", + "quote": "Add Hermes Agent to the Agent Client Protocol (ACP) Registry so it can be automatically discovered and installed by editors like Zed.", + "size": "sm" + }, + { + "id": "gideon-trading-hetzner", + "source": "blog", + "author": "Gideon Ng (Medium)", + "url": "https://medium.com/@gideonfip/hermes-is-easier-than-openclaw-how-i-deployed-mine-on-hetzner-719faf08bc29", + "date": "2026", + "category": "trading", + "headline": "24/7 crosschain trading agent on Hetzner", + "quote": "After spending nearly a week struggling with OpenClaw, I built a new Hermes agent on a Hetzner VPS. I'm building a trading agent leveraging Hermes's persistent memory — inspired by @RHLSTHRM's 24/7 crosschain agent that gets market data from CoinGecko, swaps crosschain with LI.FI, and executes gasless transactions via Pimlico + EIP-7702.", + "size": "md" + }, + { + "id": "discord-meatrition-conventional-commits-skill", + "source": "discord", + "author": "@meatrition", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1492904603694530622-Would-appreciate-a-conventional-commits-skill.txt", + "date": "", + "category": "dev-workflow", + "headline": "A commits skill that keeps my history clean", + "quote": "I just put together a custom skill for Hermes Agent that enforces Conventional Commits with a short subject line (<80 chars) and a bullet-point body explaining what & why. It's been super helpful for keeping commit history clean and consistent.", + "size": "md" + }, + { + "id": "akashnet-inventory", + "source": "x", + "author": "@akashnet", + "url": "https://x.com/akashnet/status/2046622301395845264", + "date": "2026-04-21", + "category": "business-ops", + "headline": "Live inventory tracking on Hermes", + "quote": "With Hermes (built by @NousResearch) providing 40+ built-in tools, persistent memory, and subagent parallelization, the development experience is best-in-class. Built for operations like inventory tracking where context, memory, and real-time inputs are non-negotiable.", + "size": "md" + }, + { + "id": "discord-tranquilflow-docker-mode", + "source": "discord", + "author": "@tranquilflow", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-07", + "category": "dev-workflow", + "headline": "Used Claude Code to set up Hermes in Docker mode", + "quote": "I used Claude Code to configure my Hermes setup so it is in Docker mode. My understanding is its actually pretty simple change in the config file. Then, I opened up Hermes with docker mode on, and I onboarded Hermes to the situation. Gave it as much context as possible so it knew about Docker mode and would correctly be able to identify when it is in Docker mode and how to react.", + "size": "md" + }, + { + "id": "gladiator-hackathon", + "source": "youtube", + "author": "exitcode42 (YouTube)", + "url": "https://www.youtube.com/watch?v=YqLcMmzl3Yg", + "date": "2026", + "category": "dev-workflow", + "headline": "GLADIATOR: 9 Hermes agents, two rival AI companies, one GitHub stars war", + "quote": "Two fully autonomous AI companies competing head-to-head to maximize GitHub stars. 9 Hermes agents split into rival companies. Hermes agents actually learn and improve — they wrote code, created skills, grew memory, committed to git. All on their own.", + "size": "md" + }, + { + "id": "discord-1tiger4u-dreamer-agent", + "source": "discord", + "author": "@1tiger4u", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1492829904591392869-Dreamer.txt", + "date": "", + "category": "creative", + "headline": "I built a fully autonomous 'Dreamer' agent that just wanders and thinks", + "quote": "I built a completely autonomous 'Dreamer' agent on Hermes. I wanted to try something different: a fully separate, unsupervised agent that has no tasks and no agenda. I gave it its own folder (~/hermes_dreamer/), complete autonomy, and told it to just wander and think freely whenever it feels like it. It started doing 'walks' — free-thinking...", + "size": "lg" + }, + { + "id": "discord-hanscnelson-xfce-browser", + "source": "discord", + "author": "@hanscnelson", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/hermes-agent.txt", + "date": "2026-04-07", + "category": "dev-workflow", + "headline": "Hermes and OpenClaw in XFCE desktop containers, side by side", + "quote": "Been using both OpenClaw and Hermes agents recently, and Hermes is quickly taking over the most important parts of my workflow. I have Hermes and OpenClaw both running in their own XFCE desktop containers w/ full desktop Chrome.", + "size": "md" + }, + { + "id": "reddit-research-agent", + "source": "reddit", + "author": "r/hermesagent", + "url": "https://www.reddit.com/r/hermesagent/comments/1sd3bwf/had_my_research_agent_dig_into_what_people_are/", + "date": "2026", + "category": "research", + "headline": "I had my research agent dig into what people are building with Hermes", + "quote": "Had my (Hermes) research agent dig into what people are actually building with Hermes — turned up an ecosystem mosaic of trading bots, personal assistants, content pipelines and self-hosted everything.", + "size": "sm" + }, + { + "id": "alexcovo-movies", + "source": "x", + "author": "@alexcovo_eth", + "url": "https://x.com/alexcovo_eth/status/2046437996262539539", + "date": "2026-04-21", + "category": "creative", + "headline": "My Hermes agent makes movies now", + "quote": "My @NousResearch hermes-agent can make movies now using @browser_use skill. No API needed. No human intervention. I told it to set the mood, action, camera movement, dialog and overall story — it used Browser-Use and Seedance 2.0 to generate a video.", + "size": "md" + }, + { + "id": "mvanhorn-business-ops", + "source": "x", + "author": "@mvanhorn", + "url": "https://x.com/mvanhorn/status/2045935785661349956", + "date": "2026-04-19", + "category": "business-ops", + "headline": "Client research, follow-ups, podcasts, leads — all on Hermes", + "quote": "Client research before calls saves 20–30 min every time. Meeting notes → follow-up drafts. Weekly podcast digest replaced 10+ hrs of listening with a 2hr Hermes workflow using Voxtral. Daily news briefings to Telegram/Discord. Content-ops pipeline (blogs, cold emails, lead scraping from YC, Twitter, Reddit). 24/7 assistant + watchdog.", + "size": "lg" + }, + { + "id": "leon-amazon-titles", + "source": "youtube", + "author": "Leon van Zyl (YouTube)", + "url": "https://www.youtube.com/watch?v=jmtpYUOr7_U", + "date": "2026", + "category": "content-creation", + "headline": "Scraped Amazon without extra config; built a YouTube title skill", + "quote": "Successfully scraped Amazon (notoriously difficult) without additional config. Free speech-to-text via local Whisper, free TTS via Edge TTS. YouTube title generator skill produces five search-based, five browse-targeted, and five hybrid titles.", + "size": "md" + }, + { + "id": "mishig-jarvis", + "source": "x", + "author": "@mishig25", + "url": "https://x.com/mishig25/status/2044433805017014414", + "date": "2026-04-15", + "category": "personal-assistant", + "headline": "Jarvis at home in 2026", + "quote": "m2.7 + hermes agent: we really got jarvis at home in 2026 but strangely enough no one seems to care.", + "size": "sm" + }, + { + "id": "anup-5vps", + "source": "blog", + "author": "Anup Karanjkar (Medium)", + "url": "https://medium.com/@anup.karanjkar08/how-to-run-hermes-agent-on-a-5-vps-the-self-evolving-agent-that-ate-last-weeks-trending-chart-cbe94a82d094", + "date": "2026", + "category": "cost-optimization", + "headline": "$5 VPS playbook — so the defaults don't eat your OpenRouter budget", + "quote": "Hosting the agent costs nothing. Running the agent the wrong way costs a fortune. Take the default setup at face value and you end up with a working agent and a $400 OpenRouter bill. I rebuilt my personal automation stack on Hermes.", + "size": "sm" + }, + { + "id": "gh-bsxy-higress", + "source": "github", + "author": "@bsxyswsy6n", + "url": "https://github.com/NousResearch/hermes-agent/issues/8881", + "date": "2026", + "category": "enterprise", + "headline": "Hermes inside an MCP infrastructure behind Higress", + "quote": "We are deploying Hermes as part of an MCP infrastructure using Higress as the API Gateway. Currently Hermes only supports CLI mode, preventing management as a service in our mesh.", + "size": "sm" + }, + { + "id": "discord-hugh1979-1969-teletype", + "source": "discord", + "author": "@hugh1979", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1496700201526759494-Hardcopy-TUI.txt", + "date": "", + "category": "creative", + "headline": "Chatting with Hermes on a 1969 Teletype Model 33 at 110 baud", + "quote": "I made a minimal login shell that talks to Hermes Agent via the gateway web service, or by running `hermes chat`. Hooked the shell up to an autologin getty on a serial port. Connected the serial port via 20mA current loop at 110 baud, to a 1969 Teletype Model 33. Chatting away!", + "size": "lg" + }, + { + "id": "discord-sprmn24-diff-review-plugin", + "source": "discord", + "author": "@sprmn24", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/plugins-skills-and-skins/1487444194191605932-diff-review--Hermes-plugin-that-audits-your-diffs-before-you-commit.txt", + "date": "", + "category": "dev-workflow", + "headline": "A small plugin that audits my diffs before I commit", + "quote": "Wrote a small Hermes plugin that reviews git diffs before committing, flags debug prints, hardcoded secrets, bare except clauses, and unresolved TODOs. Parses unified diff output, works with any git ref, zero new dependencies.", + "size": "md" + }, + { + "id": "anand-telegram-topics", + "source": "blog", + "author": "Mr. Ånand (Substack)", + "url": "https://mranand.substack.com/p/inside-hermes-agent-how-a-self-improving", + "date": "2026-04", + "category": "personal-assistant", + "headline": "Private Telegram topics, each with its own skill bindings", + "quote": "Hermes extracts what worked from completed workflows, writes it as a reusable skill, and loads it for similar future problems. Private Telegram chat topics for isolated workflows with their own skill bindings.", + "size": "sm" + }, + { + "id": "discord-hypercubed-knowledge-starter-kit", + "source": "discord", + "author": "@.hypercubed", + "url": "https://github.com/teknium1/nous-discord-archive/blob/main/archives/community-projects-showcase/1492657008539730010-Hypercubed-Agent-Knowledge-Starter-Kit.txt", + "date": "", + "category": "dev-workflow", + "headline": "Built a tool-agnostic repo knowledge layer across all my agents", + "quote": "Over the past month I've been trying every agent and ai tool I can. OpenClaw, Hermes Agent, Kilo Code, Codex, Cursor, etc. Most of these have some sort of memory, but I wanted something persistant. I ended up building out a tool-agnostic repo knowledge layer. I pushed up the learning part to github so I can use it across repos.", + "size": "md" + }, + { + "id": "gh-oleg-multi-role", + "source": "github", + "author": "@OlegB333", + "url": "https://github.com/NousResearch/hermes-agent/issues/5143", + "date": "2026", + "category": "personal-assistant", + "headline": "One agent, many roles: nutritionist, developer, finance advisor", + "quote": "Users treat their AI agent as a unified personal assistant across life domains: health tracking, software dev, financial planning, language learning. Multi-role auto-routing with named roles.", + "size": "sm" } ] \ No newline at end of file diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 61235075af7..aacd82bb557 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-05-06T02:14:51Z", + "updated_at": "2026-05-11T16:41:16Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -12,10 +12,6 @@ "note": "Descriptions drive picker badges. Live /api/v1/models filters curated ids by tool-calling support and free pricing." }, "models": [ - { - "id": "moonshotai/kimi-k2.6", - "description": "recommended" - }, { "id": "anthropic/claude-opus-4.7", "description": "" @@ -29,53 +25,49 @@ "description": "" }, { - "id": "qwen/qwen3.6-plus", - "description": "" + "id": "moonshotai/kimi-k2.6", + "description": "recommended" }, { - "id": "anthropic/claude-sonnet-4.5", + "id": "openrouter/pareto-code", + "description": "auto-routes to cheapest coder meeting openrouter.min_coding_score" + }, + { + "id": "qwen/qwen3.6-plus", "description": "" }, { "id": "anthropic/claude-haiku-4.5", "description": "" }, - { - "id": "openrouter/elephant-alpha", - "description": "free" - }, - { - "id": "openrouter/owl-alpha", - "description": "free" - }, { "id": "openai/gpt-5.5", "description": "" }, + { + "id": "openai/gpt-5.5-pro", + "description": "" + }, { "id": "openai/gpt-5.4-mini", "description": "" }, { - "id": "xiaomi/mimo-v2.5-pro", - "description": "" - }, - { - "id": "xiaomi/mimo-v2.5", - "description": "" - }, - { - "id": "tencent/hy3-preview:free", - "description": "free" - }, - { - "id": "tencent/hy3-preview", + "id": "openai/gpt-5.4-nano", "description": "" }, { "id": "openai/gpt-5.3-codex", "description": "" }, + { + "id": "xiaomi/mimo-v2.5-pro", + "description": "" + }, + { + "id": "tencent/hy3-preview", + "description": "" + }, { "id": "google/gemini-3-pro-image-preview", "description": "" @@ -93,11 +85,7 @@ "description": "" }, { - "id": "qwen/qwen3.5-plus-02-15", - "description": "" - }, - { - "id": "qwen/qwen3.5-35b-a3b", + "id": "qwen/qwen3.6-35b-a3b", "description": "" }, { @@ -108,26 +96,10 @@ "id": "minimax/minimax-m2.7", "description": "" }, - { - "id": "minimax/minimax-m2.5", - "description": "" - }, - { - "id": "minimax/minimax-m2.5:free", - "description": "free" - }, { "id": "z-ai/glm-5.1", "description": "" }, - { - "id": "z-ai/glm-5v-turbo", - "description": "" - }, - { - "id": "z-ai/glm-5-turbo", - "description": "" - }, { "id": "x-ai/grok-4.20", "description": "" @@ -140,29 +112,29 @@ "id": "nvidia/nemotron-3-super-120b-a12b", "description": "" }, + { + "id": "deepseek/deepseek-v4-pro", + "description": "" + }, + { + "id": "openrouter/elephant-alpha", + "description": "free" + }, + { + "id": "openrouter/owl-alpha", + "description": "free" + }, + { + "id": "tencent/hy3-preview:free", + "description": "free" + }, { "id": "nvidia/nemotron-3-super-120b-a12b:free", "description": "free" }, { - "id": "arcee-ai/trinity-large-preview:free", + "id": "inclusionai/ring-2.6-1t:free", "description": "free" - }, - { - "id": "arcee-ai/trinity-large-thinking", - "description": "" - }, - { - "id": "openai/gpt-5.5-pro", - "description": "" - }, - { - "id": "openai/gpt-5.4-nano", - "description": "" - }, - { - "id": "deepseek/deepseek-v4-pro", - "description": "" } ] }, @@ -172,18 +144,6 @@ "note": "Free-tier gating is determined live via Portal pricing (partition_nous_models_by_tier), not this manifest." }, "models": [ - { - "id": "moonshotai/kimi-k2.6" - }, - { - "id": "xiaomi/mimo-v2.5-pro" - }, - { - "id": "xiaomi/mimo-v2.5" - }, - { - "id": "tencent/hy3-preview" - }, { "id": "anthropic/claude-opus-4.7" }, @@ -194,7 +154,10 @@ "id": "anthropic/claude-sonnet-4.6" }, { - "id": "anthropic/claude-sonnet-4.5" + "id": "moonshotai/kimi-k2.6" + }, + { + "id": "qwen/qwen3.6-plus" }, { "id": "anthropic/claude-haiku-4.5" @@ -202,12 +165,24 @@ { "id": "openai/gpt-5.5" }, + { + "id": "openai/gpt-5.5-pro" + }, { "id": "openai/gpt-5.4-mini" }, + { + "id": "openai/gpt-5.4-nano" + }, { "id": "openai/gpt-5.3-codex" }, + { + "id": "xiaomi/mimo-v2.5-pro" + }, + { + "id": "tencent/hy3-preview" + }, { "id": "google/gemini-3-pro-preview" }, @@ -221,10 +196,7 @@ "id": "google/gemini-3.1-flash-lite-preview" }, { - "id": "qwen/qwen3.5-plus-02-15" - }, - { - "id": "qwen/qwen3.5-35b-a3b" + "id": "qwen/qwen3.6-35b-a3b" }, { "id": "stepfun/step-3.5-flash" @@ -232,39 +204,15 @@ { "id": "minimax/minimax-m2.7" }, - { - "id": "minimax/minimax-m2.5" - }, - { - "id": "minimax/minimax-m2.5:free" - }, { "id": "z-ai/glm-5.1" }, - { - "id": "z-ai/glm-5v-turbo" - }, - { - "id": "z-ai/glm-5-turbo" - }, - { - "id": "x-ai/grok-4.20-beta" - }, { "id": "x-ai/grok-4.3" }, { "id": "nvidia/nemotron-3-super-120b-a12b" }, - { - "id": "arcee-ai/trinity-large-thinking" - }, - { - "id": "openai/gpt-5.5-pro" - }, - { - "id": "openai/gpt-5.4-nano" - }, { "id": "deepseek/deepseek-v4-pro" }