Merge remote-tracking branch 'origin/main' into bb/gui

# Conflicts:
#	apps/dashboard/src/i18n/af.ts
#	apps/dashboard/src/i18n/de.ts
#	apps/dashboard/src/i18n/es.ts
#	apps/dashboard/src/i18n/fr.ts
#	apps/dashboard/src/i18n/ga.ts
#	apps/dashboard/src/i18n/hu.ts
#	apps/dashboard/src/i18n/it.ts
#	apps/dashboard/src/i18n/ja.ts
#	apps/dashboard/src/i18n/ko.ts
#	apps/dashboard/src/i18n/pt.ts
#	apps/dashboard/src/i18n/ru.ts
#	apps/dashboard/src/i18n/tr.ts
#	apps/dashboard/src/i18n/uk.ts
#	apps/dashboard/src/i18n/zh-hant.ts
#	gateway/config.py
#	hermes_cli/main.py
#	plugins/strike-freedom-cockpit/README.md
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson 2026-05-11 16:40:09 -04:00
commit dc66a98430
310 changed files with 43818 additions and 4220 deletions

View file

@ -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
# =============================================================================

View file

@ -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: |

View file

@ -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.
---

View file

@ -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.

View file

@ -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))

View file

@ -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)

View file

@ -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"]

View file

@ -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

View file

@ -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"

View file

@ -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:

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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"))

View file

@ -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]] = {}

View file

@ -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

170
agent/markdown_tables.py Normal file
View file

@ -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)

View file

@ -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"

View file

@ -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)

View file

@ -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

View file

@ -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``

1046
agent/plugin_llm.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -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: <one-line summary>\")` 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=<right-profile>, parents=[your-task-id])` "
"to spawn a child task for the appropriate specialist profile instead of "

View file

@ -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

View file

@ -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 = [

View file

@ -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

View file

@ -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')

View file

@ -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

View file

@ -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")

View file

@ -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 ENZH 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<HTMLDivElement>(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 (
<Button
ghost
onClick={toggle}
title={t.language.switchTo}
aria-label={t.language.switchTo}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
>
<span className="inline-flex items-center gap-1.5">
<span className="text-base leading-none">
{locale === "en" ? "🇬🇧" : "🇨🇳"}
<div ref={containerRef} className="relative inline-flex">
<Button
ghost
onClick={() => setOpen((v) => !v)}
title={t.language.switchTo}
aria-label={t.language.switchTo}
aria-haspopup="listbox"
aria-expanded={open}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
>
<span className="inline-flex items-center gap-1.5">
<span className="text-base leading-none">{current.flag}</span>
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
>
{locale === "en" ? "EN" : current.name}
</Typography>
</span>
</Button>
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
{open && (
<div
role="listbox"
aria-label={t.language.switchTo}
className="absolute right-0 top-full mt-1 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto"
>
{locale === "en" ? "EN" : "中文"}
</Typography>
</span>
</Button>
{allLocales.map(([code, meta]) => {
const selected = code === locale;
return (
<button
key={code}
role="option"
aria-selected={selected}
onClick={() => {
setLocale(code);
setOpen(false);
}}
className={
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " +
(selected ? "font-semibold text-foreground" : "text-muted-foreground")
}
>
<span className="text-base leading-none">{meta.flag}</span>
<span className="truncate">{meta.name}</span>
{selected && <span className="ml-auto text-xs"></span>}
</button>
);
})}
</div>
)}
</div>
);
}

View file

@ -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 510 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: ")",
},
};

View file

@ -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<Locale, Translations> = { en, zh };
const TRANSLATIONS: Record<Locale, Translations> = {
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<Locale, { name: string; flag: string }> = {
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
}

View file

@ -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 510 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: ")",
},
};

View file

@ -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 510 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: ")",
},
};

View file

@ -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 510 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: ")",
},
};

View file

@ -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: ")",
},
};

View file

@ -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 510 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: ")",
},
};

View file

@ -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 510 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: ")",
},
};

View file

@ -1,2 +1,2 @@
export { I18nProvider, useI18n } from "./context";
export { I18nProvider, useI18n, LOCALE_META } from "./context";
export type { Locale, Translations } from "./types";

View file

@ -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 510 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: ")",
},
};

View file

@ -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 のセッション履歴をスキャンしています。履歴が大きい場合、初回スキャンには 510 秒かかることがあります。",
},
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: "",
},
};

View file

@ -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: ")",
},
};

View file

@ -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 510 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: ")",
},
};

View file

@ -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. Первое сканирование может занять 510 секунд при большой истории.",
},
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: ")",
},
};

View file

@ -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 510 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: ")",
},
};

View file

@ -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;
};
}

View file

@ -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. Перше сканування на великих історіях може тривати 510 секунд.",
},
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: ")",
},
};

View file

@ -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.yamlmemory.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 工作階段歷史。在歷史紀錄較多時,首次掃描可能需要 510 秒。",
},
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: "",
},
};

View file

@ -421,4 +421,272 @@ export const zh: Translations = {
title: "主题",
switchTheme: "切换主题",
},
achievements: {
hero: {
kicker: "Agentic Gamerscore",
title: "Hermes Achievements",
subtitle:
"从真实会话历史中获得的 Hermes 可收集徽章。已知尚未达成的成就显示为「已发现」;秘密成就在首次出现匹配行为之前保持隐藏。",
scan_subtitle:
"正在扫描 Hermes 会话历史。在历史记录较多时,首次扫描可能需要 510 秒。",
},
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: "",
},
};

View file

@ -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

View file

@ -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
# ───────────────────────────────────────────────────────────────────────────

747
cli.py

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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

View file

@ -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 ""

View file

@ -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}")

View file

@ -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}")

View file

@ -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(

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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}"

View file

@ -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")

View file

@ -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}"

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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:

View file

@ -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'}"

View file

@ -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('"', "&quot;")

View file

@ -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()}

View file

@ -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'."""

View file

@ -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:

View file

@ -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)

View file

@ -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 <subcommand> [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."""

View file

@ -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=<last-chunk-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."""

View file

@ -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

View file

@ -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}"

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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("<H", buf[6:8])[0]
h = struct.unpack("<H", buf[8:10])[0]

View file

@ -702,7 +702,7 @@ def decode_inbound_push(data: bytes) -> 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)

File diff suppressed because it is too large Load diff

View file

@ -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.

View file

@ -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,

View file

@ -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

229
gateway/slash_access.py Normal file
View file

@ -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",
]

View file

@ -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):

View file

@ -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:

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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:

View file

@ -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()

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more