diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bb4d0b553e..fdac330c6a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -181,6 +181,14 @@ jobs: if: false uses: ./.github/workflows/e2e-desktop.yml + e2e-desktop-core: + name: Desktop core E2E + needs: detect + # The deterministic core suite (apps/desktop/e2e/core): transcript + # integrity, boot/respawn/orphans, clarify+approval. Required; retries 0. + if: ${{ needs.detect.outputs.python_prod == 'true' || needs.detect.outputs.frontend == 'true' }} + uses: ./.github/workflows/e2e-desktop-core.yml + docs-site: name: Docs Site needs: detect @@ -295,6 +303,7 @@ jobs: - rust-tests - bootstrap-installer - e2e-desktop + - e2e-desktop-core - docs-site - history-check - contributor-check diff --git a/.github/workflows/e2e-desktop-core.yml b/.github/workflows/e2e-desktop-core.yml new file mode 100644 index 0000000000..3eb37accae --- /dev/null +++ b/.github/workflows/e2e-desktop-core.yml @@ -0,0 +1,99 @@ +name: E2E Desktop core + +# The core Desktop suite (apps/desktop/e2e/core): a small, deterministic lane +# that drives a real Electron build against a real `hermes serve` and a +# scripted loopback model. It guards the failures users must never see: +# duplicated / reordered / lost transcript text, a backend that never becomes +# ready or leaves orphans, and broken clarify/approval round trips. +# +# Unlike the legacy visual lane (e2e-desktop.yml, disabled for flakiness) this +# one runs with retries: 0 and one worker; every wait is event-driven, and the +# suite was held to 10/10 consecutive green full runs on a loaded host before +# it was wired in. + +on: + workflow_call: + +permissions: + contents: read + +concurrency: + group: e2e-desktop-core-${{ github.ref }} + cancel-in-progress: true + +jobs: + core: + name: Desktop core E2E (Linux) + runs-on: ubuntu-latest-32-core + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install system dependencies for Electron + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq \ + xvfb \ + libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 \ + xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 26 + cache: npm + + - name: grab npm 12 + run: npm i -g npm@12 + + # Full npm ci (not --ignore-scripts): electron's postinstall downloads + # the binary we launch. + - uses: ./.github/actions/retry + with: + command: npm ci + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 + with: + version: '0.9.28' + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + + - name: Set up Python 3.11 + uses: ./.github/actions/retry + with: + command: uv python install 3.11 + + - name: Install Python dependencies + uses: ./.github/actions/retry + with: + command: uv sync --locked --python 3.11 --extra all --extra dev + + - name: Build the desktop app + working-directory: apps/desktop + run: npm run build + + - name: Run the core suite under xvfb + working-directory: apps/desktop + run: | + xvfb-run -a --server-args="-screen 0 1280x1024x24" \ + npx playwright test -c e2e/core/playwright.config.ts + env: + CI: 'true' + OPENROUTER_API_KEY: '' + OPENAI_API_KEY: '' + NOUS_API_KEY: '' + ANTHROPIC_API_KEY: '' + + - name: Upload failure artifacts + if: failure() || cancelled() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-core-e2e-${{ github.sha }} + path: | + apps/desktop/test-results/core + apps/desktop/playwright-report/core + retention-days: 14 + overwrite: true + if-no-files-found: ignore diff --git a/.github/workflows/live-providers.yml b/.github/workflows/live-providers.yml new file mode 100644 index 0000000000..6fab6e6ff9 --- /dev/null +++ b/.github/workflows/live-providers.yml @@ -0,0 +1,137 @@ +name: Live provider canaries + +# Secrets-gated LIVE canary against real LLM provider APIs (tests/e2e/core/live, +# pytest marker `live`). Catches what mocks cannot: vendor-side request-schema +# drift, reasoning-replay rules, streaming shape changes, prompt-cache hits and +# real credential routing / `/models` parsing. Cheap models, <=3 turns per case, +# a hard per-test token and dollar guard; typical full run is well under $0.50. +# +# Each case skips cleanly when its secret is absent, so the job is safe to run +# with any subset configured. Use dedicated, spend-capped keys: +# LIVE_OPENROUTER_API_KEY, LIVE_ANTHROPIC_API_KEY, LIVE_NOUS_API_KEY, +# LIVE_OPENAI_API_KEY, LIVE_GEMINI_API_KEY, LIVE_XAI_API_KEY + +on: + schedule: + - cron: "17 7 * * *" # nightly, 07:17 UTC + push: + tags: + - "v*" # release gate + workflow_dispatch: + inputs: + filter: + description: "pytest -k expression (e.g. 'openrouter-anthropic or models_listing')" + required: false + default: "" + +permissions: + contents: read + +# One live run per ref at a time; never cancel a release-tag gate half way. +concurrency: + group: live-providers-${{ github.ref }} + cancel-in-progress: false + +jobs: + live: + name: Live provider canaries + # Forks and PRs never see these secrets; only run where they exist. + if: github.repository == 'NousResearch/hermes-agent' + runs-on: ubuntu-latest + timeout-minutes: 25 + # Only booleans at job level: the key VALUES are scoped to the one step that + # needs them ('Run live canaries'), so `uv sync` build backends and the + # third-party actions never see them. + env: + HAS_OPENROUTER: ${{ secrets.LIVE_OPENROUTER_API_KEY != '' }} + HAS_ANTHROPIC: ${{ secrets.LIVE_ANTHROPIC_API_KEY != '' }} + HAS_NOUS: ${{ secrets.LIVE_NOUS_API_KEY != '' }} + HAS_OPENAI: ${{ secrets.LIVE_OPENAI_API_KEY != '' }} + HAS_GEMINI: ${{ secrets.LIVE_GEMINI_API_KEY != '' }} + HAS_XAI: ${{ secrets.LIVE_XAI_API_KEY != '' }} + HERMES_LIVE_USAGE_FILE: ${{ github.workspace }}/live-usage.jsonl + steps: + - name: Check that at least one provider secret is configured + id: gate + run: | + n=0 + for v in HAS_OPENROUTER HAS_ANTHROPIC HAS_NOUS HAS_OPENAI HAS_GEMINI HAS_XAI; do + if [ "${!v}" = "true" ]; then n=$((n+1)); echo "configured: ${v#HAS_}"; fi + done + echo "configured=$n" >> "$GITHUB_OUTPUT" + if [ "$n" -eq 0 ]; then + echo "::notice::No LIVE_* provider secrets configured; live canaries skipped." + fi + + - name: Checkout code + if: steps.gate.outputs.configured != '0' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv + if: steps.gate.outputs.configured != '0' + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 + with: + version: "0.9.28" + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + + - name: Set up Python 3.11 + if: steps.gate.outputs.configured != '0' + uses: ./.github/actions/retry + with: + command: uv python install 3.11 + + - name: Install dependencies + if: steps.gate.outputs.configured != '0' + uses: ./.github/actions/retry + with: + command: uv sync --locked --python 3.11 --extra all --extra dev --extra anthropic + + - name: Run live canaries + if: steps.gate.outputs.configured != '0' + # Direct pytest, not scripts/run_tests.sh: the canonical runner starts from + # `env -i` precisely so no credential can reach a test, which would skip + # every case here. `-m live` overrides the default `not live` addopts. + env: + LIVE_FILTER: ${{ inputs.filter }} + OPENROUTER_API_KEY: ${{ secrets.LIVE_OPENROUTER_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.LIVE_ANTHROPIC_API_KEY }} + NOUS_API_KEY: ${{ secrets.LIVE_NOUS_API_KEY }} + OPENAI_API_KEY: ${{ secrets.LIVE_OPENAI_API_KEY }} + GEMINI_API_KEY: ${{ secrets.LIVE_GEMINI_API_KEY }} + XAI_API_KEY: ${{ secrets.LIVE_XAI_API_KEY }} + run: | + source .venv/bin/activate + args=(tests/e2e/core/live -m live -rsxX -p no:cacheprovider --tb=short --junitxml=live-junit.xml) + if [ -n "$LIVE_FILTER" ]; then args+=(-k "$LIVE_FILTER"); fi + python -m pytest "${args[@]}" + + - name: Usage and cost summary + if: always() && steps.gate.outputs.configured != '0' + shell: python + run: | + import json, os + rows = ["### Live provider canary usage", "", + "| case | model | calls | input | output | cache read | cache write | est. $ | ceiling $ |", + "|---|---|---|---|---|---|---|---|---|"] + path = os.environ["HERMES_LIVE_USAGE_FILE"] + if os.path.exists(path): + for line in open(path, encoding="utf-8"): + u = json.loads(line) + rows.append("| {case} | {model} | {api_calls} | {input} | {output} | {cache_read} " + "| {cache_write} | {hermes_est_usd} | {list_price_ceiling_usd} |".format(**u)) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as fh: + fh.write("\n".join(rows) + "\n") + + - name: Upload results + if: always() && steps.gate.outputs.configured != '0' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: live-provider-canaries + path: | + live-junit.xml + live-usage.jsonl + if-no-files-found: ignore + retention-days: 30 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0752fc28dc..1cbc0ead5e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -139,6 +139,13 @@ jobs: with: node-version: 26 + - name: Install Node deps and build the Ink TUI + # tests/e2e/core/terminal drives the real `hermes --tui` over a PTY; + # HERMES_E2E_REQUIRE_TUI=1 below turns a missing build into a failure. + uses: ./.github/actions/retry + with: + command: npm ci --workspace ui-tui --ignore-scripts --no-audit --no-fund && npm run build --workspace ui-tui + - name: Install ripgrep (prebuilt binary) run: | set -euo pipefail @@ -170,6 +177,7 @@ jobs: # One subprocess per file, in parallel: the core suites spawn real # processes (serve, gateway, tui_gateway, MCP servers, SQLite writers) # and must not share interpreter state. + # tests/e2e/core/upgrade runs in its own job (e2e-upgrade) below. run: | scripts/run_tests.sh --include-integration tests/e2e env: @@ -181,6 +189,110 @@ jobs: # exactly-once/compaction suites are race detectors, and a rare # corruption that passes on retry is still a corruption. HERMES_TEST_FILE_RETRIES: "0" + HERMES_E2E_REQUIRE_TUI: "1" + OPENROUTER_API_KEY: "" + OPENAI_API_KEY: "" + NOUS_API_KEY: "" + + e2e-upgrade: + # tests/e2e/core/upgrade: a real N-1 -> HEAD `hermes update` (clean, + # autostash, killed mid-pull / mid-deps, offline), fresh-process import and + # entrypoint smoke, and the config round-trip property matrix. Its own job: + # it needs full history + tags, bubblewrap, and one file runs ~5-15 min. + runs-on: ubuntu-latest-32-core + timeout-minutes: 60 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # N-1 = `git describe --tags --abbrev=0 HEAD~1`. + fetch-depth: 0 + fetch-tags: true + + - name: Set up Node + # `hermes update` builds the web UI / TUI workspaces. + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 26 + + - name: Install ripgrep (prebuilt binary) + run: | + set -euo pipefail + RG_VERSION=15.1.0 + RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599 + RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz + curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \ + "https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}" + echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c - + tar -xzf "$RG_TARBALL" + sudo mv "ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl/rg" /usr/local/bin/rg + rm -rf "$RG_TARBALL" "ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl" + rg --version + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 + with: + # Pin the uv version: unpinned, setup-uv resolves "latest" by + # fetching a manifest from raw.githubusercontent.com on EVERY job — + # a transient fetch failure fails the whole job (2026-07-28 slice-5 + # incident). Pinned, the binary downloads directly; no manifest hop. + # Newer than the unit job's pin on purpose: 0.9.28 only knows CPython + # 3.11.14, whose bundled SQLite has the WAL-reset bug, so Hermes runs + # state.db in DELETE mode and the WAL torture chamber would skip. + version: "0.12.13" + # Persist uv's download/wheel cache (~/.cache/uv) across runs. + # Keyed on the dependency manifests, so the cache is reused until + # pyproject.toml or uv.lock changes. `uv sync` still runs every + # time, but resolves from the warm cache instead of re-downloading + # and re-building wheels. + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + + - name: Set up Python 3.11 + run: uv python install 3.11.15 + + - name: Install dependencies + # `uv sync --locked` installs the exact pinned set from uv.lock (and + # fails if the lock is out of sync with pyproject.toml), giving a + # reproducible env. It also creates .venv itself, so no separate + # `uv venv` step is needed. + # + # Same extras as the test job's sync above: the hermetic test env + # forbids mid-run pip installs (HERMES_DISABLE_LAZY_INSTALLS=1 in + # tests/conftest.py), so lazy-install SDKs exercised by tests must be + # in the venv up front. + uses: ./.github/actions/retry + with: + command: uv sync --locked --python 3.11.15 --extra all --extra dev --extra anthropic --extra mistral --extra fal --extra modal --extra daytona --extra parallel-web + + - name: Install bubblewrap + # Every spawned updater runs in bwrap (own PID namespace, no user + # systemd bus, only the test tmp writable). Ubuntu 24.04 restricts + # unprivileged user namespaces, which bwrap needs. + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq bubblewrap + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + # The suite's own probe (_bwrap_usable, same flags as every sandboxed + # run): without it the suite silently runs the updater unsandboxed. + source .venv/bin/activate + python -c "from tests.e2e.core.upgrade._helpers import BWRAP_OK; assert BWRAP_OK, 'bwrap unusable'" && echo "bwrap ok" + + - name: Require a WAL-capable SQLite + # The state.db suites skip on a WAL-reset-vulnerable SQLite; fail + # instead of reporting green over zero coverage. + run: | + source .venv/bin/activate + python -c "import sqlite3, hermes_state_wal as w; print('sqlite', sqlite3.sqlite_version); assert not w.is_sqlite_wal_reset_vulnerable()" + + - name: Run upgrade e2e tests + run: | + source .venv/bin/activate + scripts/run_tests.sh --include-integration tests/e2e/core/upgrade + env: + HERMES_TEST_FILE_TIMEOUT: "3000" OPENROUTER_API_KEY: "" OPENAI_API_KEY: "" NOUS_API_KEY: "" diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 734ff92ba8..b129d6036c 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -20,6 +20,13 @@ else: # Stop a ``utils/``/``proxy/``/``ui/`` package in the launch cwd from shadowing Hermes modules. hermes_bootstrap.harden_import_path() +# `hermes-acp` runs without hermes_cli.main: repair a `hermes update` killed mid-pull here, before +# importing anything else from the checkout (a no-op under `hermes acp`, which already did). +from hermes_cli import _early_recovery + +if _early_recovery.restore_interrupted_pull(): + _early_recovery.relaunch_after_restore() + import argparse import asyncio import logging diff --git a/agent/coding_context.py b/agent/coding_context.py index 60f79b491c..111ac910d2 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -514,6 +514,9 @@ def project_facts_for(cwd: Optional[str | Path] = None) -> Optional[dict[str, An } +WORKSPACE_BLOCK_HEADER = "Workspace (snapshot at session start — re-check with `git` before acting on it):" + + def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str: """Workspace snapshot for the system prompt (empty outside a workspace): git state when in a repo, plus project facts — so marker-only (non-git) projects still get one.""" @@ -521,7 +524,7 @@ def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str: if root is None: return "" lines = [ - "Workspace (snapshot at session start — re-check with `git` before acting on it):", + WORKSPACE_BLOCK_HEADER, f"- Root: {root}", ] if git_root is not None: diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 57af87b558..fb79d6242f 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -31,7 +31,7 @@ from agent.prompt_caching import ( from agent.repetition_guard import REPETITION_LOOP_INTERRUPTED, is_runaway_repetition from agent.runtime_cwd import resolve_agent_cwd from agent.surface_switch import ( - identity_line_value, note_inert_pinned_tools, split_runtime_boundary, stage_surface_switch_note, + identity_line_value, note_inert_pinned_tools, runtime_host_value, stage_surface_switch_note, ) from agent.turn_context import PreflightCompressionTimedOut, build_turn_context from agent.turn_retry_state import TurnRetryState @@ -814,20 +814,6 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: """Return False when the persisted runtime-identity lines are stale.""" - - _identity, runtime_marker, runtime = split_runtime_boundary(prompt) - - def host_info_value(label: str) -> str: - """New prompts delimit runtime hints; legacy prompts put them before context.""" - prefix = f"{label}:" - host_lines = (runtime.split("\n\n", 1)[0] if runtime_marker else prompt).splitlines() - for idx, line in enumerate(host_lines): - if line.startswith("User home directory:"): - for candidate in host_lines[idx + 1: idx + 4]: - if candidate.startswith(prefix): - return candidate[len(prefix):].strip() - return "" - # Model/provider identity, then cwd drift. A cwd change is a real content change (context # files, the workspace snapshot and the coding posture are all resolved from it), so it # still rebuilds; the runtime surface does not (agent/surface_switch.py). @@ -836,9 +822,15 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: current = str(getattr(agent, attr, "") or "").strip() if stored and current and stored != current: return False + # A prompt stamped for another session (a /branch child copies its parent's bytes) must not + # tell the model a foreign Session ID. Checked only when the trailer is on: with it off, a + # "Session ID:" line in project text would read as a mismatch and rebuild every turn. + stored_sid = identity_line_value(prompt, "Session ID") + if stored_sid and getattr(agent, "pass_session_id", False) and stored_sid != agent.session_id: + return False # Compare against resolve_agent_cwd() — the SAME resolver used to build the # prompt — so TERMINAL_CWD sessions are not falsely rejected. - stored_cwd = host_info_value("Current working directory") + stored_cwd = runtime_host_value(prompt, "Current working directory") if stored_cwd and stored_cwd != str(resolve_agent_cwd()): return False # Platform is deliberately NOT an identity field: a surface switch does not invalidate the diff --git a/agent/history_commentary.py b/agent/history_commentary.py new file mode 100644 index 0000000000..578ddc00eb --- /dev/null +++ b/agent/history_commentary.py @@ -0,0 +1,176 @@ +"""Display-only Codex commentary projection shared by REST and gateway history. + +Provider items and the stored reasoning string remain untouched for model replay. +Only the projected fields may be used for public transcript text. +""" + +import json +from contextlib import contextmanager +from typing import Any + +from agent.redact import redact_sensitive_text +from hermes_constants import reset_hermes_home_override, set_hermes_home_override +from utils import is_truthy_value + + +@contextmanager +def _owning_home(home): + token = set_hermes_home_override(str(home)) if home is not None else None + try: + yield + finally: + if token is not None: + reset_hermes_home_override(token) + + +def visible_commentary(text: str, *, strip_thinking=None) -> str: + """Use the same think stripping and secret redaction as live delivery.""" + if strip_thinking is None: + from agent.agent_runtime_helpers import strip_think_blocks + + strip_thinking = lambda value: strip_think_blocks(None, value) + + visible = strip_thinking(text).strip() + return redact_sensitive_text(visible) if visible else visible + + +def _phase_message_items(message: dict, phases: frozenset[str | None]) -> list[str]: + items = message.get("codex_message_items") + if isinstance(items, str): + try: + items = json.loads(items) + except (TypeError, ValueError): + return [] + if not isinstance(items, list): + return [] + result = [] + for item in items: + if ( + not isinstance(item, dict) + or item.get("type") != "message" + or item.get("role") != "assistant" + ): + continue + phase, content = item.get("phase"), item.get("content") + if phase is not None and not isinstance(phase, str): + continue + normalized_phase = phase.strip().lower() if isinstance(phase, str) else None + if normalized_phase not in phases or not isinstance(content, list): + continue + text = "".join( + part["text"] + for part in content + if isinstance(part, dict) + and part.get("type") == "output_text" + and isinstance(part.get("text"), str) + and part["text"].strip() + ).strip() + if text: + result.append(text) + return result + + +def _commentary_items(message: dict) -> list[str]: + return _phase_message_items(message, frozenset({"commentary"})) + + +def _final_items(message: dict) -> list[str]: + # Unphased assistant messages are normalized as final content too. + return _phase_message_items(message, frozenset({"final", "final_answer", None})) + + +def _without_flattened_commentary(reasoning: str, commentary: list[str]) -> str: + """Omit exact whole-line public segments from a display copy, not source data. + + Current normalization separates parts with two newlines; older rows may + have one. If a private segment happens to be identical, origin is ambiguous: + omit both occurrences rather than leaking the public text or choosing the + wrong one. Surrounding reasoning is retained in its original line style. + """ + remaining = reasoning + for text in sorted(set(commentary), key=len, reverse=True): + search_from = 0 + while (at := remaining.find(text, search_from)) >= 0: + end = at + len(text) + if (at == 0 or remaining[at - 1] == "\n") and ( + end == len(remaining) or remaining[end] == "\n" + ): + before, after = remaining[:at], remaining[end:] + prefix, suffix = before.rstrip("\n"), after.lstrip("\n") + separator = ( + "\n\n" + if before.endswith("\n\n") or after.startswith("\n\n") + else "\n" + ) + remaining = prefix + (separator if prefix and suffix else "") + suffix + search_from = 0 + else: + search_from = end + return remaining + + +def _project_one(message: dict, *, enabled: bool) -> dict: + if message.get("role") != "assistant" or message.get("display_kind") == "hidden": + return message + raw = _commentary_items(message) + if not raw: + return message + projected = dict(message) + # A disabled profile must not recover raw sidecar text on the frontend. + projected["display_commentary"] = [] + if enabled: + projected["display_commentary"] = [ + part for text in raw if (part := visible_commentary(text)) + ] + reasoning = ( + message.get("reasoning") + or message.get("reasoning_content") + or message.get("reasoning_details") + or "" + ) + if isinstance(reasoning, str): + projected["display_reasoning"] = _without_flattened_commentary(reasoning, raw) + + # A nonempty canonical answer can also be filled by stream recovery after + # the provider sidecar was captured. Without a matching final item there + # is no way to distinguish "commentary + final" from a real final that + # merely starts with the same words. Never delete canonical answer bytes. + # Sanitize the display copy when it contains raw commentary and avoid + # duplicating a leading commentary item in a separate Desktop bubble. + content = message.get( + "display_content", message.get("content", message.get("text")) + ) + if isinstance(content, str) and any(text in content for text in raw): + finals = _final_items(message) + if not (finals and content.strip() == "\n".join(finals)): + projected["display_content"] = visible_commentary(content) + if any(content.startswith(text) for text in raw): + projected["display_commentary"] = [] + return projected + + +def project_history_commentary(messages: list[dict], *, home: Any = None) -> list[dict]: + """Project a batch inside the owning profile's config and redaction scope.""" + if not any( + isinstance(message, dict) and message.get("codex_message_items") + for message in messages + ): + return messages + with _owning_home(home): + from hermes_cli.config import load_config + + try: + display = load_config().get("display") or {} + enabled = is_truthy_value( + display.get("show_commentary"), default=True + ) and is_truthy_value( + display.get("interim_assistant_messages"), default=True + ) + except Exception: + enabled = False # unreadable policy must not publish raw provider items + return [ + _project_one(message, enabled=enabled) + if isinstance(message, dict) + else message + for message in messages + ] diff --git a/agent/legacy_cli.py b/agent/legacy_cli.py index a76b73f275..7daf9a967f 100644 --- a/agent/legacy_cli.py +++ b/agent/legacy_cli.py @@ -9,8 +9,21 @@ here too, so the installer's PATH launcher behaves the same way. from __future__ import annotations -import argparse -from typing import Callable, List, Optional +# hermes_bootstrap first (UTF-8 stdio on Windows; no-op on POSIX), like every other entry point. +try: + import hermes_bootstrap # noqa: F401 +except ModuleNotFoundError: + pass # partial `hermes update` — only skips the Windows UTF-8 stdio setup + +# The `hermes-agent` console script lands here without hermes_cli.main: repair a `hermes update` killed +# while git wrote the new tree before importing anything else from the checkout. +from hermes_cli import _early_recovery + +if _early_recovery.restore_interrupted_pull(): + _early_recovery.relaunch_after_restore() + +import argparse # noqa: E402 +from typing import Callable, List, Optional # noqa: E402 def _build_parser() -> argparse.ArgumentParser: diff --git a/agent/stream_delivery.py b/agent/stream_delivery.py index 13a8af8eba..2d88432e64 100644 --- a/agent/stream_delivery.py +++ b/agent/stream_delivery.py @@ -10,7 +10,7 @@ from typing import Any, Dict, List from agent.memory_manager import sanitize_context from agent.message_content import flatten_message_text -from agent.redact import redact_sensitive_text +from agent.history_commentary import visible_commentary # Same logger name as the origin module so log records / caplog filters are unchanged. logger = logging.getLogger("run_agent") @@ -135,8 +135,7 @@ class StreamDeliveryMixin: def _visible_commentary(self, text: str) -> str: """Think-stripped, redacted commentary text ("" when nothing visible remains).""" - visible = self._strip_think_blocks(text).strip() - return redact_sensitive_text(visible) if visible else visible + return visible_commentary(text, strip_thinking=getattr(self, "_strip_think_blocks")) def _extract_codex_interim_visible_text(self, assistant_msg: Dict[str, Any]) -> str: """All visible Codex commentary joined, for comparison/fallback.""" diff --git a/agent/surface_switch.py b/agent/surface_switch.py index 5b642ecd0b..39e1f8f6bf 100644 --- a/agent/surface_switch.py +++ b/agent/surface_switch.py @@ -36,6 +36,20 @@ def split_runtime_boundary(prompt: str) -> tuple: return (identity, runtime_marker, runtime) if prompt.endswith(RUNTIME_ENVIRONMENT_END) else (prompt, "", "") +def runtime_host_value(prompt: str, label: str) -> str: + """``Label: value`` from the host lines of a persisted prompt (``Current working directory``); + new prompts delimit runtime hints, legacy prompts put them before context. "" when absent.""" + _identity, runtime_marker, runtime = split_runtime_boundary(prompt) + prefix = f"{label}:" + host_lines = (runtime.split("\n\n", 1)[0] if runtime_marker else prompt).splitlines() + for idx, line in enumerate(host_lines): + if line.startswith("User home directory:"): + for candidate in host_lines[idx + 1: idx + 4]: + if candidate.startswith(prefix): + return candidate[len(prefix):].strip() + return "" + + def identity_line_value(prompt: str, label: str) -> str: """Last ``Label: value`` line in the identity portion (the final runtime block is embedder prose, never identity). Last match wins — safe only for the volatile-tier trailer fields.""" diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 77390ce01d..853462571c 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -26,7 +26,7 @@ from agent.prompt_builder import ( TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, drain_truncation_warnings, ) from agent import prompt_builder as _pb -from agent.runtime_cwd import resolve_context_cwd +from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd from hermes_constants import get_default_hermes_root, get_hermes_home from utils import is_truthy_value @@ -592,6 +592,70 @@ def _alibaba_identity_part(agent: Any) -> List[str]: ] +def _workspace_pin_key() -> str: + """The directory the workspace probe inspects, which is also the prompt's ``Current working + directory``: a build with no cwd bound (launch dir) and a later one binding that same dir + (TUI ``/compress``) are one workspace, not two.""" + try: + return str(resolve_context_cwd() or resolve_agent_cwd()) + except OSError: # deleted cwd + return "" + + +def _persisted_workspace_block(prompt: str, key: str) -> Optional[str]: + """The workspace snapshot inside ``prompt`` taken for ``key`` (its ``- Root:`` is ``key`` or an + ancestor); "" when the prompt has none; None when it has one for another root.""" + from agent.coding_context import WORKSPACE_BLOCK_HEADER + head = f"\n\n{WORKSPACE_BLOCK_HEADER}\n- Root: " + start = prompt.find(head) + if start < 0: + return "" + cwd = Path(key).resolve() + while start >= 0: + block = prompt[start + 2:].split("\n\n", 1)[0] + root = Path(block.split("\n", 2)[1][len("- Root: "):]).resolve() + if root == cwd or root in cwd.parents: + return block + start = prompt.find(head, start + 2) + return None + + +def _session_prompt(agent: Any) -> Optional[str]: + """Prompt bytes this session already sends: the cached copy, else its persisted row.""" + cached = getattr(agent, "_cached_system_prompt", None) + if isinstance(cached, str) and cached: + return cached + db, session_id = getattr(agent, "_session_db", None), getattr(agent, "session_id", None) + if db is None or not isinstance(session_id, str) or not session_id: + return None + try: + row = db.get_session(session_id) + except Exception: + logger.debug("workspace snapshot: session row read failed (session=%s)", session_id, exc_info=True) + return None + prompt = row.get("system_prompt") if isinstance(row, dict) else None + return prompt if isinstance(prompt, str) and prompt else None + + +def _seed_workspace_pin(agent: Any, key: str) -> None: + """Pin the snapshot the session's existing prompt already carries. An agent that did not + build those bytes (resumed, or a fresh gateway/TUI agent whose first act is ``/compress``) + would otherwise re-probe git at its first rebuild and rewrite the prompt for any repo that + moved since session start. Only a snapshot provably taken in this cwd is adopted.""" + from agent.surface_switch import runtime_host_value + prompt = _session_prompt(agent) + if not prompt: + return + stored_cwd = runtime_host_value(prompt, "Current working directory") + if stored_cwd and stored_cwd != key: + return + block = _persisted_workspace_block(prompt, key) + # Only a real snapshot is adopted: a prompt without one (built on a surface without the + # coding posture, or with tools off) leaves the pin open so this build captures one. + if block: + agent._frozen_workspace_snapshot = (key, block) + + def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]: """``(prefix, workspace, trailing)`` coding-posture blocks; all empty without tools or when probing fails (it must never block prompt build). @@ -599,15 +663,18 @@ def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]: The workspace block is a live git probe after project context, ahead of the whole volatile band; re-probing at the compaction rebuild re-emits different bytes for any repo that moved and defeats the keep-prompt fast path. So the bytes are pinned per - session on the agent, keyed by the resolved cwd (a gateway serves many cwds), and - replayed on rebuilds; ``reset_session_state`` drops the pin at a session boundary. + session on the agent, keyed by the probed cwd (a gateway serves many cwds), seeded from + the session's existing prompt when this agent did not build it, and replayed on + rebuilds; ``reset_session_state`` drops the pin at a session boundary. """ try: from agent.coding_context import coding_system_prompt_parts if not agent.valid_tool_names: return [], [], [] cwd = resolve_context_cwd() - cwd_key = str(cwd) if cwd is not None else "" + cwd_key = _workspace_pin_key() + if getattr(agent, "_frozen_workspace_snapshot", None) is None: + _seed_workspace_pin(agent, cwd_key) pinned = getattr(agent, "_frozen_workspace_snapshot", None) # "" is a real pinned value (no workspace here) — only a cwd mismatch re-probes. replay = pinned[1] if pinned is not None and pinned[0] == cwd_key else None diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index f00ae47312..97e325e6ef 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -499,9 +499,12 @@ long transcript or a busy terminal. - Every user-facing string goes through `useI18n()` (`src/i18n/context.tsx`). No literals in JSX. -- **Update all locales together** — `en`, `ja`, `zh`, `zh-hant`. A string change - in `en.ts` that skips the others is a regression (drifted punctuation, - stale labels). Keep trailing-punctuation and tone consistent across all four. +- **Update all locales together** — every catalog registered in + `src/i18n/catalog.ts`. A string change in `en.ts` that skips the others is a + regression (drifted punctuation, stale labels). Keep trailing-punctuation and + tone consistent across all of them. `fr`, `de`, and `es` are complete + `Translations` objects, so a key missing there fails the type check; the + `defineLocale()` overlays fall back to English instead. ## State (TypeScript) @@ -547,7 +550,7 @@ The detailed state contract lives in the scoped - [ ] Hot interactions avoid broad subscriptions, layout thrash, and `transition-all`? - [ ] Keyboard ownership and single-action `Esc` behavior are correct? -- [ ] All four locales updated for any new/changed string? +- [ ] All registered locales updated for any new/changed string? - [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave? - [ ] Touched a primitive, token, or variant? Its named-contract entry in this file is updated in the same change. diff --git a/apps/desktop/e2e/core/README.md b/apps/desktop/e2e/core/README.md new file mode 100644 index 0000000000..e078faf7cd --- /dev/null +++ b/apps/desktop/e2e/core/README.md @@ -0,0 +1,54 @@ +# Desktop core suite (required CI lane) + +A small, deterministic Electron suite that guards three issue classes end to end: + +- **C2 transcript integrity** — `transcript-integrity.spec.ts`: one real app + + one real `hermes serve`, only the LLM faked (`provider.ts`, scripted per turn + by a unique marker, every streamed chunk recorded). After every transition — + stream, tool-call turn, reasoning turn, steer, queued follow-up, session + switch mid-stream, warm resume, reload, WebSocket drop + reconnect mid-stream, + a non-default profile's chat, a forced second socket to the same backend, + final reload — `oracle.ts` asserts: + - every persisted user/assistant message is rendered exactly once, in order, + and nothing unpersisted is rendered; + - no marker is ever rendered twice, even transiently (in-page + MutationObserver sampler — the #120005 garble healed on its own in the + final DOM, so a final-state check alone misses it); + - backend stream integrity: each turn's concatenated `message.delta` / + `reasoning.delta` equals what the provider streamed, and + `message.complete` equals the final completion. + - one live socket per backend process (#120006). + `switch-back-race.spec.ts` forces both orders of "reply completes" vs "the + switch-back REST hydrate resolves" with gates (no sleeps) under the same + oracle. + `onboarding-first-chat.spec.ts` starts from a fresh home with no provider: + the real onboarding (custom endpoint → the fake provider's URL), then the + first chat, a second turn and a reload under the same oracle, plus + persisted config == entered endpoint and one live socket afterwards. +- **C20 interactive prompts** — `interactive-prompts.spec.ts`: clarify (one + card; the clicked choice is exactly what the model receives), approval + Run once (the command runs only after the click) and Deny (never runs; the + turn still completes), approvals in manual mode. +- **C5 boot / process lifecycle** — `boot-lifecycle.spec.ts`: interactive + composer + first turn, exactly one backend; `kill -9` backend → exactly one + supervised respawn and a working turn; quit mid-turn with a running tool + child → zero sandbox processes (/proc census on the sandbox `HERMES_HOME`, + so orphans reparented to init are counted); relaunch the same home 3× → one + backend per boot, zero after each quit, transcript cold-hydrates once. + +Rules the suite keeps (why the old lane was disabled): no fixed sleeps as +synchronisation (every wait is on a frame, pid, DOM state or persisted row +with a deadline), no shared mock state between scenarios (replies are keyed +by the turn's own marker), no visual baselines, `retries: 0`, one worker, +sandboxed `HOME`/`HERMES_HOME`/user-data per test, all `HERMES_*` and +credential env stripped from the spawned app. + +Run locally (Linux, after `npm run build` in `apps/desktop`): + +```sh +cd apps/desktop +xvfb-run -a npx playwright test -c e2e/core/playwright.config.ts +``` + +`HERMES_E2E_CORE_ROOT` picks the sandbox parent dir (default: OS tmpdir); +`HERMES_E2E_CORE_KEEP=1` keeps sandboxes for post-mortem. diff --git a/apps/desktop/e2e/core/boot-lifecycle.spec.ts b/apps/desktop/e2e/core/boot-lifecycle.spec.ts new file mode 100644 index 0000000000..5b1c23dcb7 --- /dev/null +++ b/apps/desktop/e2e/core/boot-lifecycle.spec.ts @@ -0,0 +1,278 @@ +/** + * C5 core: boot handshake and backend process lifecycle. + * + * Real Electron + real `hermes serve`; only the LLM is faked. Process facts + * come from a /proc census of every process carrying this sandbox's + * HERMES_HOME (so an orphan reparented to init is still counted), sampled + * continuously in the background so a transient extra spawn is seen too. + * + * 1. boot: composer interactive, exactly one backend, first turn completes + * and passes the transcript oracle. + * 2. kill -9 the backend: the supervisor respawns EXACTLY one replacement + * (no crash-loop, no double spawn), and the app serves a new turn. + * 3. quit while a turn is streaming and a tool subprocess is running: zero + * sandbox processes remain — no backend, no tool child, no Electron helper. + * 4. relaunch the same HERMES_HOME repeatedly: each boot has exactly one + * backend, each quit leaves zero processes, and the transcript persisted + * by the first launch cold-hydrates exactly once every time. + */ + +import * as fs from 'node:fs' + +import { expect, test } from '@playwright/test' + +import { + backendProcesses, + coreAppEnv, + createCoreSandbox, + currentSessionId, + launchCoreApp, + type ProcInfo, + recordWebSockets, + sandboxProcesses, + send, + waitForInteractive, + writeProviderHome +} from './harness' +import { assertTranscriptOracle, installDuplicateSampler, type OracleTarget } from './oracle' +import { gate, startScriptedProvider } from './provider' + +const nonce = Math.random() + .toString(36) + .slice(2, 8) + .replace(/[^a-z0-9]/g, 'x') + .padEnd(4, 'q') + +const U = (n: number) => `U${n}-${nonce}` +const A = (n: number) => `A${n}-${nonce}` +const TOOL_TAG = `core-orphan-${nonce}` + +/** Every live process whose command line carries `tag` (tool children may scrub HERMES_HOME). */ +function taggedProcesses(tag: string): ProcInfo[] { + const out: ProcInfo[] = [] + + for (const entry of fs.readdirSync('/proc')) { + const pid = Number(entry) + + if (!Number.isInteger(pid) || pid === process.pid) { + continue + } + + try { + const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').join(' ').trim() + + if (cmdline.includes(tag)) { + out.push({ pid, ppid: 0, cmdline }) + } + } catch { + /* exited mid-scan */ + } + } + + return out +} + +test('boot handshake, supervised respawn, and zero orphans on quit', async () => { + const provider = await startScriptedProvider() + const sandbox = createCoreSandbox('boot') + writeProviderHome(sandbox.hermesHome, provider.url) + const { app, page } = await launchCoreApp(coreAppEnv(sandbox)) + const ws = recordWebSockets(page) + let closed = false + + // Background census: every backend pid ever observed, with its parent, + // command line and lifetime so an unexpected extra pid explains itself. + const seen = new Map() + const t0 = Date.now() + + const census = setInterval(() => { + const now = Date.now() - t0 + + for (const proc of backendProcesses(sandbox)) { + const entry = seen.get(proc.pid) + + if (entry) { + entry.last = now + entry.samples++ + } else { + seen.set(proc.pid, { ppid: proc.ppid, cmdline: proc.cmdline.slice(0, 200), first: now, last: now, samples: 1 }) + } + } + }, 100) + + const describeSeen = () => + JSON.stringify([...seen].map(([pid, e]) => ({ pid, ...e, electronPid: app.process().pid }))) + + const finished = (marker: string, step = 0) => + expect + .poll(() => provider.completions.some(c => c.marker === marker && c.step === step && c.finished), { + timeout: 120_000, + message: `provider finished ${marker} step ${step}` + }) + .toBe(true) + + try { + const session: OracleTarget = { sessionId: '', expectUserMarkers: [] } + + await test.step('boot: interactive composer, one backend, first turn', async () => { + await waitForInteractive(app, page) + await installDuplicateSampler(page) + await expect.poll(() => backendProcesses(sandbox).length, { message: 'exactly one backend after boot' }).toBe(1) + provider.script(U(1), [{ text: [`${A(1)} `, 'booted ', 'and ', 'answered'] }]) + await send(page, `${U(1)} first`, 'Enter', ws) + await finished(U(1)) + await expect.poll(() => currentSessionId(page)).not.toBe('') + session.sessionId = await currentSessionId(page) + session.expectUserMarkers.push(U(1)) + await assertTranscriptOracle(page, ws, provider, session, 'boot first turn') + expect(seen.size, `backend pids seen during boot: ${describeSeen()}`).toBe(1) + }) + + await test.step('kill -9 backend: exactly one supervised respawn, app serves again', async () => { + const [victim] = backendProcesses(sandbox) + expect(victim).toBeTruthy() + process.kill(victim!.pid, 'SIGKILL') + await expect + .poll( + () => + backendProcesses(sandbox) + .map(p => p.pid) + .filter(pid => pid !== victim!.pid).length, + { + timeout: 120_000, + message: 'a replacement backend is spawned' + } + ) + .toBe(1) + await waitForInteractive(app, page) + provider.script(U(2), [{ text: [`${A(2)} `, 'after ', 'respawn'] }]) + await send(page, `${U(2)} still there`, 'Enter', ws) + await finished(U(2)) + session.expectUserMarkers.push(U(2)) + await assertTranscriptOracle(page, ws, provider, session, 'after backend respawn') + const alive = backendProcesses(sandbox) + expect(alive.length, `live backends after recovery: ${JSON.stringify(alive)}`).toBe(1) + // Initial + exactly one replacement, ever — a crash loop or a racing + // second spawn would add pids here even if they died again. + expect(seen.size, `backend pids ever seen: ${describeSeen()}`).toBe(2) + }) + + await test.step('quit mid-turn with a running tool child: zero processes remain', async () => { + const hold = gate() + provider.script(U(3), [ + { + text: [`${A(3)} `, 'starting ', 'tool'], + toolCalls: [{ name: 'terminal', args: { command: `exec -a ${TOOL_TAG} sleep 3600` } }] + }, + { text: [`${A(3)}b `, 'never ', 'reached'], holdAfterFirstChunk: hold } + ]) + await send(page, `${U(3)} long tool`, 'Enter', ws) + await expect + .poll(() => taggedProcesses(TOOL_TAG).length, { timeout: 120_000, message: 'tool child running' }) + .toBeGreaterThan(0) + expect(sandboxProcesses(sandbox).length).toBeGreaterThan(0) + + await app.close() + closed = true + clearInterval(census) + await expect + .poll( + () => + [...sandboxProcesses(sandbox), ...taggedProcesses(TOOL_TAG)].map( + p => `${p.pid} ${p.cmdline.slice(0, 120)}` + ), + { + timeout: 60_000, + message: 'no sandbox process (backend, tool child, Electron helper) survives quit' + } + ) + .toEqual([]) + hold.open() + }) + } finally { + clearInterval(census) + + if (!closed) { + await app.close().catch(() => undefined) + } + + // Never leave a test-made process behind even on failure (only our own tag / sandbox). + for (const proc of [...sandboxProcesses(sandbox), ...taggedProcesses(TOOL_TAG)]) { + try { + process.kill(proc.pid, 'SIGKILL') + } catch { + /* already gone */ + } + } + + await provider.close() + sandbox.cleanup() + } +}) + +test('relaunching the same home: one backend per boot, zero after each quit, transcript intact', async () => { + const provider = await startScriptedProvider() + const sandbox = createCoreSandbox('relaunch') + writeProviderHome(sandbox.hermesHome, provider.url) + const session: OracleTarget = { sessionId: '', expectUserMarkers: [U(1)] } + let live: Awaited> | null = null + + try { + for (let launch = 1; launch <= 3; launch++) { + await test.step(`launch ${launch}`, async () => { + live = await launchCoreApp(coreAppEnv(sandbox)) + const { app, page } = live + const ws = recordWebSockets(page) + await waitForInteractive(app, page) + await installDuplicateSampler(page) + await expect + .poll(() => backendProcesses(sandbox).length, { message: `one backend on launch ${launch}` }) + .toBe(1) + + if (launch === 1) { + provider.script(U(1), [{ text: [`${A(1)} `, 'persisted ', 'across ', 'launches'] }]) + await send(page, `${U(1)} remember me`, 'Enter', ws) + await expect + .poll(() => provider.completions.some(c => c.marker === U(1) && c.finished), { timeout: 120_000 }) + .toBe(true) + await expect.poll(() => currentSessionId(page)).not.toBe('') + session.sessionId = await currentSessionId(page) + } else { + await page.evaluate(id => { + window.location.hash = `#/${encodeURIComponent(id)}` + }, session.sessionId) + await expect( + page.locator('[data-slot="aui_thread-viewport"]').filter({ visible: true }).first() + ).toContainText(A(1), { + timeout: 60_000 + }) + } + + await assertTranscriptOracle(page, ws, provider, session, `launch ${launch}`) + await app.close() + live = null + await expect + .poll(() => sandboxProcesses(sandbox).map(p => `${p.pid} ${p.cmdline.slice(0, 120)}`), { + timeout: 60_000, + message: `no sandbox process survives quit #${launch}` + }) + .toEqual([]) + }) + } + } finally { + if (live) { + await (live as Awaited>).app.close().catch(() => undefined) + } + + for (const proc of sandboxProcesses(sandbox)) { + try { + process.kill(proc.pid, 'SIGKILL') + } catch { + /* already gone */ + } + } + + await provider.close() + sandbox.cleanup() + } +}) diff --git a/apps/desktop/e2e/core/harness.ts b/apps/desktop/e2e/core/harness.ts new file mode 100644 index 0000000000..1288681d8d --- /dev/null +++ b/apps/desktop/e2e/core/harness.ts @@ -0,0 +1,667 @@ +/** + * Lane-private harness for the core Desktop suite: an isolated sandbox with a + * fake HOME, a launch helper, a per-socket WebSocket recorder, a /proc-based + * process census for the backend, and the transcript oracle. + * + * Synchronisation rule for every helper here: wait on an observable fact + * (a frame, a DOM state, a persisted row, a pid) with a deadline — never a + * fixed sleep. + */ + +import * as fs from 'node:fs' +import * as net from 'node:net' +import * as os from 'node:os' +import * as path from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +import { _electron, type ElectronApplication, expect, type Page } from '@playwright/test' + +import { resolveElectronBinary } from '../electron-binary' + +export const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..', '..') +export const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') + +// ─── Sandbox ──────────────────────────────────────────────────────────── + +export interface CoreSandbox { + root: string + /** Prepended to PATH: external-platform fakes (see createCoreSandbox). */ + bin: string + home: string + hermesHome: string + userDataDir: string + cleanup: () => void +} + +/** + * HOME is faked too, not just HERMES_HOME: profile roots are anchored to + * `Path.home()/.hermes`, so a sandbox HERMES_HOME under the real ~/.hermes + * would read and write the real install's profiles/. + */ +export function createCoreSandbox(label: string): CoreSandbox { + const parent = process.env.HERMES_E2E_CORE_ROOT || os.tmpdir() + fs.mkdirSync(parent, { recursive: true }) + const root = fs.mkdtempSync(path.join(parent, `core-${label}-`)) + const home = path.join(root, 'home') + const hermesHome = path.join(home, '.hermes') + const userDataDir = path.join(root, 'user-data') + fs.mkdirSync(hermesHome, { recursive: true }) + fs.mkdirSync(userDataDir, { recursive: true }) + fs.writeFileSync( + path.join(userDataDir, 'window-state.json'), + JSON.stringify({ x: 0, y: 0, width: 1280, height: 860, isMaximized: false }) + ) + fs.writeFileSync(path.join(userDataDir, 'zoom-state.json'), JSON.stringify({ zoomLevel: 0 })) + // External platform fake: a logged-out `gh`. The backend probes `gh auth + // token` for GitHub credentials; with a sandbox HOME a real gh can block on + // the desktop keyring for ~60 s, and a probe in flight at quit outlives the + // backend (reported as a finding) — which would make the orphan census + // depend on the runner's keyring rather than on Hermes. + const bin = path.join(root, 'bin') + fs.mkdirSync(bin, { recursive: true }) + fs.writeFileSync(path.join(bin, 'gh'), '#!/bin/sh\necho "no oauth token found for github.com" >&2\nexit 1\n', { + mode: 0o755 + }) + + return { + root, + bin, + home, + hermesHome, + userDataDir, + cleanup: () => { + if (!process.env.HERMES_E2E_CORE_KEEP) { + fs.rmSync(root, { recursive: true, force: true }) + } + } + } +} + +/** + * Sandbox config: only the scripted provider. The external tirith scanner is + * off: with none on PATH the backend downloads it from GitHub on the first + * terminal command (network in a required lane), and with one on PATH it + * fetched a 12 MB threat DB that was still being written after quit. The + * approval prompts under test come from Hermes's own detector. + */ +export function providerConfigYaml(providerUrl: string, extra = '', approvals: 'manual' | 'off' = 'off'): string { + return `model: + default: mock-model + provider: mock +providers: + mock: + api: ${providerUrl}/v1 + name: Mock + api_mode: chat_completions + key_env: MOCK_API_KEY + models: + mock-model: {} + context_length: 64000 +auxiliary: + title_generation: + enabled: false +security: + tirith_enabled: false +approvals: + mode: "${approvals}" +${extra}` +} + +export function writeProviderHome( + dir: string, + providerUrl: string, + extra = '', + approvals: 'manual' | 'off' = 'off' +): void { + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'config.yaml'), providerConfigYaml(providerUrl, extra, approvals)) + fs.writeFileSync(path.join(dir, '.env'), 'MOCK_API_KEY=core-e2e-key\n') +} + +const CREDENTIAL_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD|_CREDENTIALS|_ACCESS_KEY|_PRIVATE_KEY|_BASE_URL)$/ + +/** + * The runner's own env minus credentials and every HERMES_* knob: an agent + * shell exports HERMES_YOLO_MODE / _HERMES_GATEWAY, which the spawned backend + * would inherit (auto-approving every command, changing the run under test). + */ +export function coreAppEnv(sandbox: CoreSandbox, extra: Record = {}): Record { + const env: Record = {} + + for (const [key, value] of Object.entries(process.env)) { + if (!value || CREDENTIAL_RE.test(key) || /^_?HERMES_/.test(key) || key === 'VIRTUAL_ENV') { + continue + } + + env[key] = value + } + + return { + ...env, + PATH: `${sandbox.bin}${path.delimiter}${env.PATH ?? ''}`, + HOME: sandbox.home, + HERMES_HOME: sandbox.hermesHome, + HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir, + HERMES_DESKTOP_IGNORE_EXISTING: '1', + HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT, + HERMES_DESKTOP_APP_NAME: `HermesCoreE2E-${path.basename(sandbox.root)}`, + HERMES_DESKTOP_SKIP_QUIT_CONFIRM: '1', + HERMES_DESKTOP_CDP_PORT: 'off', + // A partial-clone (blob:none) dev checkout turns some backend git read into + // a lazy `git fetch origin` over the network, which outlived quit by >60 s + // (reported as a finding). CI checkouts are not partial; keep dev runs + // offline and deterministic the same way. + GIT_NO_LAZY_FETCH: '1', + ...extra + } +} + +export async function launchCoreApp(env: Record): Promise<{ app: ElectronApplication; page: Page }> { + if (!fs.existsSync(path.join(DESKTOP_ROOT, 'dist', 'electron-main.mjs'))) { + throw new Error("Desktop dist not built: run 'npm run build' in apps/desktop first") + } + + const app = await _electron.launch({ + executablePath: resolveElectronBinary([DESKTOP_ROOT, REPO_ROOT]), + args: [DESKTOP_ROOT, '--disable-gpu', '--no-sandbox'], + env, + cwd: DESKTOP_ROOT + }) + + // Keep the main process's stdout/stderr (backend supervisor lines included) + // so a boot that never becomes interactive fails with its own story. + const lines: string[] = [] + + const collect = (chunk: Buffer) => { + lines.push(...chunk.toString('utf8').split('\n').filter(Boolean)) + lines.splice(0, Math.max(0, lines.length - 200)) + } + + app.process().stdout?.on('data', collect) + app.process().stderr?.on('data', collect) + APP_LOGS.set(app, lines) + + const page = await app.firstWindow() + + return { app, page } +} + +const APP_LOGS = new WeakMap() + +/** Last main-process output lines of `app` (for failure messages). */ +export function appLogTail(app: ElectronApplication, n = 60): string { + return (APP_LOGS.get(app) ?? []).slice(-n).join('\n') +} + +// ─── Process census ───────────────────────────────────────────────────── + +export interface ProcInfo { + pid: number + ppid: number + cmdline: string +} + +function readProc(pid: number): null | { environ: string; cmdline: string; ppid: number } { + try { + const environ = fs.readFileSync(`/proc/${pid}/environ`, 'utf8') + const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').join(' ').trim() + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8') + // Field 4 (ppid) follows the parenthesised comm, which may contain spaces. + const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]) + + return { environ, cmdline, ppid } + } catch { + return null + } +} + +/** Every live process whose environment carries this sandbox's HERMES_HOME (orphans included). */ +export function sandboxProcesses(sandbox: CoreSandbox): ProcInfo[] { + const needle = `HERMES_HOME=${sandbox.hermesHome}\0` + const out: ProcInfo[] = [] + + for (const entry of fs.readdirSync('/proc')) { + const pid = Number(entry) + + if (!Number.isInteger(pid) || pid === process.pid) { + continue + } + + const info = readProc(pid) + + if (!info || !(info.environ + '\0').includes(needle)) { + continue + } + + // Zombies have an empty cmdline and are already dead for our purposes. + if (!info.cmdline) { + continue + } + + out.push({ pid, ppid: info.ppid, cmdline: info.cmdline }) + } + + return out +} + +/** + * The `hermes serve` backend(s) spawned for this sandbox. + * + * A child of the backend still shows the backend's argv and environ between + * fork and exec, and the backend forks ~40 probes per boot (git, ps, + * ldconfig/gcc, pip): a 100 ms sampler catches one in that window every few + * boots. Such a child is not a second backend, so a serve process whose parent + * is itself a serve process is excluded. A real second spawn has the + * supervisor (Electron main) as its parent, and a pre-exec child orphaned by a + * dead backend is reparented away and still counted. + */ +export function backendProcesses(sandbox: CoreSandbox): ProcInfo[] { + const serve = sandboxProcesses(sandbox).filter( + proc => / serve( |$)/.test(proc.cmdline) && !/electron/i.test(proc.cmdline.split(' ')[0]) + ) + + const pids = new Set(serve.map(proc => proc.pid)) + + return serve.filter(proc => !pids.has(proc.ppid)) +} + +// ─── WebSocket recorder ───────────────────────────────────────────────── + +export interface GatewayEventFrame { + socket: number + type: string + sessionId: string + seq: null | number + payload: any +} + +export interface WsRecorder { + sockets: { id: number; url: string; closed: boolean }[] + events: GatewayEventFrame[] + sent: { socket: number; method: string; params: any }[] +} + +export function recordWebSockets(page: Page): WsRecorder { + const rec: WsRecorder = { sockets: [], events: [], sent: [] } + + page.on('websocket', ws => { + if (!ws.url().includes('/api/ws')) { + return + } + + const id = rec.sockets.length + const entry = { id, url: ws.url(), closed: false } + rec.sockets.push(entry) + ws.on('close', () => { + entry.closed = true + }) + ws.on('framereceived', frame => { + try { + const msg = JSON.parse(String(frame.payload)) + + if (msg?.method === 'event' && msg.params) { + rec.events.push({ + socket: id, + type: String(msg.params.type ?? ''), + sessionId: String(msg.params.session_id ?? ''), + seq: typeof msg.params.seq === 'number' ? msg.params.seq : null, + payload: msg.params.payload + }) + } + } catch { + /* binary / non-JSON frames carry no gateway event */ + } + }) + ws.on('framesent', frame => { + try { + const msg = JSON.parse(String(frame.payload)) + + if (typeof msg?.method === 'string') { + rec.sent.push({ socket: id, method: msg.method, params: msg.params }) + } + } catch { + /* ignore */ + } + }) + }) + + return rec +} + +// ─── Network fault injection ──────────────────────────────────────────── + +export interface TcpProxy { + port: number + /** Live client connections through the proxy. */ + connections: () => number + /** Reset every live connection (the renderer sees an abnormal close, as on a network drop). */ + dropAll: () => number + close: () => Promise +} + +/** A loopback TCP proxy in the test process; the renderer's primary socket is routed through it. */ +export function startTcpProxy(targetPort: number): Promise { + const live = new Set() + + const server = net.createServer(client => { + const upstream = net.connect(targetPort, '127.0.0.1') + live.add(client) + + const forget = () => { + live.delete(client) + client.destroy() + upstream.destroy() + } + + client.on('error', forget) + upstream.on('error', forget) + client.on('close', forget) + upstream.on('close', forget) + client.pipe(upstream) + upstream.pipe(client) + }) + + return new Promise((resolve, reject) => { + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + resolve({ + port: (server.address() as net.AddressInfo).port, + connections: () => live.size, + dropAll: () => { + const count = live.size + + for (const socket of [...live]) { + socket.resetAndDestroy() + } + + return count + }, + close: () => + new Promise(done => { + for (const socket of [...live]) { + socket.destroy() + } + + server.close(() => done()) + }) + }) + }) + }) +} + +/** + * Rewrite the PRIMARY gateway WebSocket URL main hands the renderer (initial + * connection and every reconnect mint) so it dials `proxyPort`. REST stays on + * main's IPC bridge. Takes effect on the renderer's next dial (reload/reconnect). + */ +export async function routePrimaryWebSocket(app: ElectronApplication, backendPort: number, proxyPort: number) { + await app.evaluate( + ({ ipcMain }, { backendPort, proxyPort }) => { + const handlers = (ipcMain as any)._invokeHandlers as Map Promise> + const from = `ws://127.0.0.1:${backendPort}/` + const to = `ws://127.0.0.1:${proxyPort}/` + + const rewrite = (value: any): any => { + if (typeof value === 'string') { + return value.startsWith(from) ? to + value.slice(from.length) : value + } + + if (Array.isArray(value)) { + return value.map(rewrite) + } + + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, rewrite(inner)])) + } + + return value + } + + for (const channel of ['hermes:connection', 'hermes:gateway:ws-url']) { + const original = handlers.get(channel) + + if (!original) { + throw new Error(`no ipc handler ${channel}`) + } + + ipcMain.removeHandler(channel) + ipcMain.handle(channel, async (event: unknown, profile?: unknown, ...rest: unknown[]) => { + const result = await original(event, profile, ...rest) + const primary = profile === undefined || profile === null || profile === '' || profile === 'default' + + return primary ? rewrite(result) : result + }) + } + }, + { backendPort, proxyPort } + ) +} + +/** + * Make main stop tagging `profile`'s route as served by the shared primary + * backend (while still pointing at that same backend). The renderer then dials + * a SECOND WebSocket to the same process for that profile's session calls — + * the #120005 topology — while calls made earlier keep the primary joined. + */ +export async function splitProfileRoute(app: ElectronApplication, profile: string): Promise { + await app.evaluate(({ ipcMain }, profile) => { + const handlers = (ipcMain as any)._invokeHandlers as Map Promise> + const original = handlers.get('hermes:connection:for') + + if (!original) { + throw new Error('no ipc handler hermes:connection:for') + } + + ipcMain.removeHandler('hermes:connection:for') + ipcMain.handle('hermes:connection:for', async (event: unknown, payload: any) => { + const result = await original(event, payload) + + if (payload?.profile === profile && result && typeof result === 'object') { + const { sharedPrimary: _shared, ...rest } = result + + return rest + } + + return result + }) + }, profile) +} + +// ─── Renderer helpers ─────────────────────────────────────────────────── + +export function composer(page: Page) { + return page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first() +} + +/** Composer mounted, no full-viewport overlay above it, window visible. */ +export async function waitForInteractive(app: ElectronApplication, page: Page, timeout = 180_000): Promise { + try { + await expect(composer(page)).toBeVisible({ timeout }) + await page.waitForFunction( + () => { + const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2) + let node: Element | null = el + + if (!el) { + return false + } + + while (node) { + const cs = window.getComputedStyle(node) + + if (cs.position === 'fixed') { + const r = node.getBoundingClientRect() + + if (r.left <= 0 && r.top <= 0 && r.right >= window.innerWidth && r.bottom >= window.innerHeight) { + return false + } + } + + node = node.parentElement + } + + return true + }, + undefined, + { timeout, polling: 250 } + ) + } catch (error) { + const blocker = await page + .evaluate(() => { + let node: Element | null = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2) + const chain: string[] = [] + + while (node && chain.length < 12) { + const slot = node.getAttribute('data-slot') ?? '' + const role = node.getAttribute('role') ?? '' + chain.push( + `${node.tagName.toLowerCase()}${slot ? `[data-slot=${slot}]` : ''}${role ? `[role=${role}]` : ''}${window.getComputedStyle(node).position === 'fixed' ? '{fixed}' : ''}` + ) + node = node.parentElement + } + + return { chain, route: location.hash, text: document.body.innerText.replace(/\s+/g, ' ').slice(0, 800) } + }) + .catch(e => ({ chain: [], route: '', text: `evaluate failed: ${String(e)}` })) + + throw new Error( + `app never became interactive: ${(error as Error).message.split('\n')[0]}\n` + + `route: ${blocker.route}\ncenter element chain: ${blocker.chain.join(' < ')}\nbody text: ${blocker.text}\n` + + `main-process log tail:\n${appLogTail(app)}` + ) + } + + await expect + .poll( + () => + app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isVisible() ?? false).catch(() => false), + { timeout, intervals: [250] } + ) + .toBe(true) +} + +/** + * Enter submits (and steers a live turn); Control+Enter queues a follow-up + * behind it. While the gateway is reconnecting the composer keeps the draft + * and ignores Enter (by design), so the press is repeated until the draft is + * accepted — but only while the draft is still in the composer AND no + * prompt.submit carrying it went out on any socket, so a retry can never + * double-submit. + */ +export async function send( + page: Page, + text: string, + key: 'Control+Enter' | 'Enter' = 'Enter', + ws?: WsRecorder +): Promise { + const box = composer(page) + const probe = text.slice(0, 12) + await box.click() + await box.fill(text) + await expect(box).toContainText(probe) + const submitted = () => (ws?.sent ?? []).some(frame => JSON.stringify(frame.params ?? '').includes(probe)) + + await expect + .poll( + async () => { + const draft = (await box.textContent().catch(() => '')) ?? '' + + if (!draft.includes(probe)) { + return 'accepted' + } + + if (!submitted()) { + await box.press(key) + } + + return 'pending' + }, + { timeout: 120_000, intervals: [1_000, 2_000, 4_000], message: `composer accepted ${probe}` } + ) + .toBe('accepted') +} + +export async function currentSessionId(page: Page): Promise { + return page.evaluate(() => decodeURIComponent(location.hash.replace(/^#\/?/, '').split('?')[0] ?? '')) +} + +export interface PersistedMessage { + role: string + content: string +} + +/** + * The stored session id holding a user message with `marker`, read straight + * from the profile's state.db (read-only) — independent of any renderer or + * REST projection. + */ +export function storedSessionForMarker(sandbox: CoreSandbox, profile: string, marker: string): null | string { + const dbPath = + profile === 'default' + ? path.join(sandbox.hermesHome, 'state.db') + : path.join(sandbox.hermesHome, 'profiles', profile, 'state.db') + + if (!fs.existsSync(dbPath)) { + return null + } + + const db = new DatabaseSync(dbPath, { readOnly: true }) + + try { + const row = db + .prepare("SELECT session_id FROM messages WHERE role = 'user' AND content LIKE ? ORDER BY id LIMIT 1") + .get(`%${marker}%`) as undefined | { session_id: string } + + return row?.session_id ?? null + } catch { + // Mid-WAL-checkpoint reads can fail transiently; the caller polls. + return null + } finally { + db.close() + } +} + +/** The backend's persisted display transcript (REST, the same read the renderer hydrates from). */ +export async function persistedTranscript( + page: Page, + sessionId: string, + profile?: string +): Promise { + const query = profile ? `&profile=${encodeURIComponent(profile)}` : '' + + const result = await page.evaluate( + async ({ sessionId, query }) => + (window as any).hermesDesktop.api({ path: `/api/sessions/${sessionId}/messages?order=oldest&limit=500${query}` }), + { sessionId, query } + ) + + return (result?.messages ?? []).map((m: any) => ({ + role: String(m.role ?? ''), + content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? '') + })) +} + +export interface RenderedMessage { + role: 'assistant' | 'user' + text: string +} + +/** Every user/assistant bubble in the visible thread, in DOM order. */ +export async function renderedTranscript(page: Page): Promise<{ bubbles: RenderedMessage[]; fullText: string }> { + return page.evaluate(() => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + + if (!viewport) { + return { bubbles: [], fullText: '' } + } + + const bubbles = [ + ...viewport.querySelectorAll('[data-slot="aui_user-message-root"], [data-slot="aui_assistant-message-root"]') + ].map(el => ({ + role: (el.getAttribute('data-slot') === 'aui_user-message-root' ? 'user' : 'assistant') as 'assistant' | 'user', + text: (el as HTMLElement).innerText.replace(/\s+/g, ' ').trim() + })) + + return { bubbles, fullText: (viewport as HTMLElement).innerText.replace(/\s+/g, ' ') } + }) +} diff --git a/apps/desktop/e2e/core/interactive-prompts.spec.ts b/apps/desktop/e2e/core/interactive-prompts.spec.ts new file mode 100644 index 0000000000..18fb9b6e40 --- /dev/null +++ b/apps/desktop/e2e/core/interactive-prompts.spec.ts @@ -0,0 +1,167 @@ +/** + * C20 core: blocking interactive prompts round-trip through the real chain. + * + * Real Electron + real `hermes serve`, approvals in manual mode; only the LLM + * is faked. For each prompt kind the invariant is end to end, not "a card + * rendered": + * - clarify: exactly one card; the choice the user clicks is exactly what + * the model receives in the tool result on its next request; the turn + * completes; transcript oracle holds. + * - approval (run once): exactly one approval card; the gated command has + * NOT run before the click and HAS run after it (observable side effect); + * the turn completes; transcript oracle holds. + * - approval (deny): the command never runs; the turn still completes (no + * wedged busy state); transcript oracle holds. + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { expect, type Page, test } from '@playwright/test' + +import { + coreAppEnv, + createCoreSandbox, + currentSessionId, + launchCoreApp, + recordWebSockets, + send, + waitForInteractive, + writeProviderHome +} from './harness' +import { assertTranscriptOracle, installDuplicateSampler, type OracleTarget } from './oracle' +import { type RecordedCompletion, startScriptedProvider } from './provider' + +const nonce = Math.random() + .toString(36) + .slice(2, 8) + .replace(/[^a-z0-9]/g, 'x') + .padEnd(4, 'q') + +const U = (n: number) => `U${n}-${nonce}` +const A = (n: number) => `A${n}-${nonce}` +const AI = (n: number) => `A${n}i-${nonce}` + +function viewport(page: Page) { + return page.locator('[data-slot="aui_thread-viewport"]').filter({ visible: true }).first() +} + +/** Text of the tool results the model received in `completion`'s request. */ +function toolResults(completion: RecordedCompletion | undefined): string { + const messages: any[] = completion?.body?.messages ?? [] + + return messages + .filter(m => m?.role === 'tool') + .map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))) + .join('\n') +} + +test('clarify and approval prompts round-trip exactly once', async () => { + const provider = await startScriptedProvider() + const sandbox = createCoreSandbox('prompts') + writeProviderHome(sandbox.hermesHome, provider.url, '', 'manual') + const { app, page } = await launchCoreApp(coreAppEnv(sandbox)) + const ws = recordWebSockets(page) + const session: OracleTarget = { sessionId: '', expectUserMarkers: [] } + + const finished = (marker: string, step = 0) => + expect + .poll(() => provider.completions.some(c => c.marker === marker && c.step === step && c.finished), { + timeout: 120_000, + message: `provider finished ${marker} step ${step}` + }) + .toBe(true) + + const completion = (marker: string, step: number) => + provider.completions.find(c => c.marker === marker && c.step === step) + + try { + await waitForInteractive(app, page) + await installDuplicateSampler(page) + + await test.step('clarify: the clicked choice is exactly what the model receives', async () => { + const question = `Q1-${nonce} which drink` + const pick = `mate-${nonce}` + const other = `chai-${nonce}` + provider.script(U(1), [ + { + text: [`${AI(1)} `, 'asking'], + toolCalls: [{ name: 'clarify', args: { questions: [{ question, choices: [other, pick] }] } }] + }, + { text: [`${A(1)} `, 'noted ', 'your ', 'choice'] } + ]) + await send(page, `${U(1)} ask me`, 'Enter', ws) + // The open (unanswered) clarify form: single-question or batch shape. + const openForms = page.locator('form[data-clarify-choices], form[data-clarify-batch]').filter({ visible: true }) + await expect(openForms).toHaveCount(1, { timeout: 120_000 }) + await expect(viewport(page).getByText(question)).toHaveCount(1) + await openForms.getByRole('button', { name: new RegExp(pick) }).click() + const submit = openForms.locator('button[type="submit"]') + await expect(submit).toBeEnabled() + await submit.click() + await finished(U(1), 1) + const received = toolResults(completion(U(1), 1)) + expect(received, 'model received the clicked choice').toContain(pick) + expect(received, 'model did not receive the other choice as the answer').not.toMatch( + new RegExp(`answer[^\\n]*${other}`, 'i') + ) + await expect( + page.locator('form[data-clarify-choices], form[data-clarify-batch]').filter({ visible: true }) + ).toHaveCount(0) + await expect(page.locator('[data-clarify-settled]').filter({ visible: true })).toHaveCount(1) + await expect.poll(() => currentSessionId(page)).not.toBe('') + session.sessionId = await currentSessionId(page) + session.expectUserMarkers.push(U(1)) + await assertTranscriptOracle(page, ws, provider, session, 'clarify round trip') + await expect(viewport(page).getByText(question)).toHaveCount(1) + }) + + await test.step('approval (run once): the command runs only after the click', async () => { + const victim = path.join(sandbox.root, `victim-run-${nonce}`) + fs.mkdirSync(victim) + provider.script(U(2), [ + { + text: [`${AI(2)} `, 'needs ', 'approval'], + toolCalls: [{ name: 'terminal', args: { command: `rm -rf ${victim}` } }] + }, + { text: [`${A(2)} `, 'deleted ', 'it'] } + ]) + await send(page, `${U(2)} delete run dir`, 'Enter', ws) + const run = page.locator('[data-approval-run]').filter({ visible: true }) + await expect(run).toHaveCount(1, { timeout: 120_000 }) + expect(fs.existsSync(victim), 'gated command must not run before approval').toBe(true) + await run.click() + await finished(U(2), 1) + expect(fs.existsSync(victim), 'approved command ran').toBe(false) + await expect(page.locator('[data-approval-run]').filter({ visible: true })).toHaveCount(0) + session.expectUserMarkers.push(U(2)) + await assertTranscriptOracle(page, ws, provider, session, 'approval run once') + }) + + await test.step('approval (deny): the command never runs, the turn still completes', async () => { + const victim = path.join(sandbox.root, `victim-deny-${nonce}`) + fs.mkdirSync(victim) + provider.script(U(3), [ + { + text: [`${AI(3)} `, 'needs ', 'approval'], + toolCalls: [{ name: 'terminal', args: { command: `rm -rf ${victim}` } }] + }, + { text: [`${A(3)} `, 'left ', 'it ', 'alone'] } + ]) + await send(page, `${U(3)} delete deny dir`, 'Enter', ws) + const deny = page.locator('[data-approval-deny]').filter({ visible: true }) + await expect(deny).toHaveCount(1, { timeout: 120_000 }) + await deny.click() + await finished(U(3), 1) + expect(fs.existsSync(victim), 'denied command never ran').toBe(true) + expect(toolResults(completion(U(3), 1)), 'the denial reached the model as the tool result').not.toBe('') + await expect(page.locator('[data-approval-deny]').filter({ visible: true })).toHaveCount(0) + session.expectUserMarkers.push(U(3)) + await assertTranscriptOracle(page, ws, provider, session, 'approval deny') + }) + } finally { + await app.close().catch(() => undefined) + await provider.close() + sandbox.cleanup() + } +}) diff --git a/apps/desktop/e2e/core/onboarding-first-chat.spec.ts b/apps/desktop/e2e/core/onboarding-first-chat.spec.ts new file mode 100644 index 0000000000..1ee4a8aeb6 --- /dev/null +++ b/apps/desktop/e2e/core/onboarding-first-chat.spec.ts @@ -0,0 +1,143 @@ +/** + * C2 core: onboarding completion, then the first chat. + * + * Fresh sandbox, NO provider configured: the real first-run onboarding shows. + * The user picks the local / custom endpoint path and enters the fake + * provider's URL; the real backend probes it (/v1/models), persists the + * assignment, and the overlay closes. Then the first chat must obey the same + * transcript oracle as everything else. A smoke test for onboarding followed + * by a first chat; the #120005 double-paint needs a non-default profile and is + * guarded by transcript-integrity.spec.ts. + * + * Invariants: the onboarding result is what the backend persisted and what + * the model is actually called with (config base_url == entered URL; the + * first completion carries the endpoint's advertised model); one live socket + * to the backend after onboarding; every message exactly once, DOM == + * persisted, streamed == provider, at every sampled frame. + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { expect, test } from '@playwright/test' + +import { + coreAppEnv, + createCoreSandbox, + currentSessionId, + launchCoreApp, + recordWebSockets, + send, + waitForInteractive +} from './harness' +import { assertTranscriptOracle, installDuplicateSampler } from './oracle' +import { startScriptedProvider } from './provider' + +const nonce = Math.random() + .toString(36) + .slice(2, 8) + .replace(/[^a-z0-9]/g, 'x') + .padEnd(4, 'q') + +const U = (n: number) => `U${n}-${nonce}` +const A = (n: number) => `A${n}-${nonce}` + +test('onboarding (custom endpoint) then first chat renders exactly once', async () => { + const provider = await startScriptedProvider() + const sandbox = createCoreSandbox('onboard') + const { app, page } = await launchCoreApp(coreAppEnv(sandbox)) + const ws = recordWebSockets(page) + + try { + await installDuplicateSampler(page) + + await test.step('first-run onboarding: custom endpoint', async () => { + // The onboarding surface masks the whole app until it finishes. + const openKeyForm = page + .locator('[data-glass-opaque]') + .filter({ visible: true }) + .getByRole('button', { name: /api key/i }) + + await expect(openKeyForm).toHaveCount(1, { timeout: 180_000 }) + await openKeyForm.click() + await page + .locator('[data-glass-opaque]') + .filter({ visible: true }) + .getByRole('button', { name: /custom endpoint/i }) + .click() + + // The endpoint URL is the form's only plain-text input (API keys are password inputs). + const form = page + .locator('[data-glass-opaque]') + .filter({ visible: true }) + .filter({ has: page.locator('input[type="text"]') }) + + await expect(form).toHaveCount(1) + await form.locator('input[type="text"]').fill(provider.url) + await form.getByRole('button', { name: /connect/i }).click() + // The overlay unmounts only after the backend probed the endpoint, saved + // the assignment and confirmed the runtime is ready; waitForInteractive + // fails while any full-viewport fixed layer still covers the composer. + await waitForInteractive(app, page) + }) + + await test.step('the persisted assignment is what the user entered', async () => { + const config = fs.readFileSync(path.join(sandbox.hermesHome, 'config.yaml'), 'utf8') + expect(config).toMatch(/provider:\s*['"]?custom/) + expect(config).toContain(`127.0.0.1:${new URL(provider.url).port}`) + }) + + await test.step('first chat after onboarding renders exactly once', async () => { + provider.script(U(1), [ + { reasoning: ['R1-', nonce, ' thinking'], text: [`${A(1)} `, 'first ', 'reply ', 'after ', 'setup'] } + ]) + await send(page, `${U(1)} hello`, 'Enter', ws) + await expect + .poll(() => provider.completions.some(c => c.marker === U(1) && c.finished), { + timeout: 120_000, + message: 'the first chat after onboarding reached the configured endpoint' + }) + .toBe(true) + const first = provider.completions.find(c => c.marker === U(1)) + // The fake endpoint advertises exactly one model at /v1/models. + expect(first?.body?.model, 'the model is called with the endpoint-advertised model').toBe('mock-model') + await expect.poll(() => currentSessionId(page)).not.toBe('') + const sessionId = await currentSessionId(page) + await assertTranscriptOracle( + page, + ws, + provider, + { sessionId, expectUserMarkers: [U(1)] }, + 'first chat after onboarding' + ) + await expect + .poll(() => ws.sockets.filter(s => !s.closed).length, { timeout: 30_000, message: 'live backend sockets' }) + .toBe(1) + }) + + await test.step('second turn and a reload keep it exactly once', async () => { + provider.script(U(2), [{ text: [`${A(2)} `, 'second ', 'reply'] }]) + await send(page, `${U(2)} again`, 'Enter', ws) + await expect + .poll(() => provider.completions.some(c => c.marker === U(2) && c.finished), { timeout: 120_000 }) + .toBe(true) + const sessionId = await currentSessionId(page) + await assertTranscriptOracle(page, ws, provider, { sessionId, expectUserMarkers: [U(1), U(2)] }, 'second turn') + await page.reload() + await waitForInteractive(app, page) + await installDuplicateSampler(page) + await expect.poll(() => currentSessionId(page)).toBe(sessionId) + await assertTranscriptOracle( + page, + ws, + provider, + { sessionId, expectUserMarkers: [U(1), U(2)] }, + 'reload after onboarding' + ) + }) + } finally { + await app.close().catch(() => undefined) + await provider.close() + sandbox.cleanup() + } +}) diff --git a/apps/desktop/e2e/core/oracle.ts b/apps/desktop/e2e/core/oracle.ts new file mode 100644 index 0000000000..357d7c32a9 --- /dev/null +++ b/apps/desktop/e2e/core/oracle.ts @@ -0,0 +1,478 @@ +/** + * The transcript oracle. + * + * Invariant, checked after every transition: each persisted user/assistant + * message is rendered EXACTLY ONCE, in persisted order; nothing scenario-made + * is rendered that is not persisted; the persisted assistant rows are exactly + * what the provider streamed; and on the wire, each turn's concatenated + * message.delta text equals what the provider streamed for that turn (and its + * message.complete text equals the final completion). + * + * Every scenario text carries a unique marker (`U-` for the user, + * `A-` / `Ai-` for replies, `R-` for reasoning), + * so "exactly once" is a count over rendered text, independent of how the + * renderer groups bubbles (live vs hydrated grouping legitimately differs). + * + * Final-state checks converge with a deadline (hydration is asynchronous); + * transient duplicates are caught separately by an in-page MutationObserver + * sampler that is never allowed to see a marker twice. + */ + +import { expect, type Page } from '@playwright/test' + +import type { GatewayEventFrame, PersistedMessage, WsRecorder } from './harness' +import { persistedTranscript } from './harness' +import type { RecordedCompletion, ScriptedProvider } from './provider' + +export const ANY_MARKER_RE = /\b[UAR]\d+i?-[a-z0-9]{3,}\b/g +const FIRST_MARKER_RE = /\b[UAR]\d+i?-[a-z0-9]{3,}\b/ + +const norm = (text: string) => text.replace(/\s+/g, ' ').trim() + +function countOccurrences(haystack: string, needle: string): number { + if (!needle) { + return 0 + } + + let count = 0 + let at = haystack.indexOf(needle) + + while (at !== -1) { + count++ + at = haystack.indexOf(needle, at + needle.length) + } + + return count +} + +function countMarker(haystack: string, marker: string): number { + return (haystack.match(new RegExp(`\\b${marker}\\b`, 'g')) ?? []).length +} + +/** The user marker a reply/reasoning marker answers: A3-x / A3i-x / R3-x → U3-x. */ +export function userMarkerFor(marker: string): string { + return marker.replace(/^[AR](\d+)i?-/, 'U$1-') +} + +// ─── Transient-duplicate sampler ──────────────────────────────────────── + +/** Idempotent; re-run after every reload (the observer dies with the document). */ +export async function installDuplicateSampler(page: Page): Promise { + await page.evaluate(source => { + const w = window as any + + if (w.__coreSampler) { + return + } + + const re = new RegExp(source, 'g') + w.__coreSampler = { samples: 0, violations: [] as { marker: string; count: number; text: string }[] } + let scheduled = false + + const sample = () => { + scheduled = false + const viewports = [...document.querySelectorAll('[data-slot="aui_thread-viewport"]')] as HTMLElement[] + + for (const viewport of viewports) { + if (viewport.getClientRects().length === 0) { + continue + } + + const text = viewport.innerText + w.__coreSampler.samples++ + const counts = new Map() + + for (const match of text.match(re) ?? []) { + counts.set(match, (counts.get(match) ?? 0) + 1) + } + + for (const [marker, count] of counts) { + if (count > 1 && w.__coreSampler.violations.length < 20) { + // Where the copies live: one bubble with doubled text vs two bubbles. + const bubbles = ( + [ + ...viewport.querySelectorAll( + '[data-slot="aui_user-message-root"], [data-slot="aui_assistant-message-root"]' + ) + ] as HTMLElement[] + ) + .filter(el => el.innerText.includes(marker)) + .map(el => `${el.getAttribute('data-slot')}#${el.getAttribute('data-message-id') ?? el.id ?? ''}`) + + w.__coreSampler.violations.push({ + marker, + count, + at: Math.round(performance.now()), + route: location.hash, + bubbles, + text: text.replace(/\s+/g, ' ').slice(0, 600) + }) + } + } + } + } + + new MutationObserver(() => { + if (!scheduled) { + scheduled = true + requestAnimationFrame(sample) + } + }).observe(document.body, { childList: true, subtree: true, characterData: true }) + }, ANY_MARKER_RE.source) +} + +async function samplerViolations(page: Page): Promise<{ samples: number; violations: any[] }> { + return page.evaluate(() => (window as any).__coreSampler ?? { samples: 0, violations: [] }) +} + +// ─── Rendered view ────────────────────────────────────────────────────── + +interface RenderedView { + text: string + userBubbles: string[] +} + +async function renderedView(page: Page): Promise { + return page.evaluate(() => { + const viewport = ([...document.querySelectorAll('[data-slot="aui_thread-viewport"]')] as HTMLElement[]).find( + el => el.getClientRects().length > 0 + ) + + if (!viewport) { + return { text: '', userBubbles: [] } + } + + return { + text: viewport.innerText.replace(/\s+/g, ' '), + userBubbles: ([...viewport.querySelectorAll('[data-slot="aui_user-message-root"]')] as HTMLElement[]).map(el => + el.innerText.replace(/\s+/g, ' ').trim() + ) + } + }) +} + +// ─── Wire check ───────────────────────────────────────────────────────── + +interface WireTurn { + sessionId: string + deltas: string + reasoning: string + complete: null | string +} + +/** + * Backend-truth turns: events deduplicated by (runtime session, seq) across + * every socket (the backend stamps seq once before fan-out), split at + * message.start, in seq order. + */ +export function wireTurns(events: GatewayEventFrame[]): WireTurn[] { + const unique = new Map() + + for (const event of events) { + if (event.seq === null || !event.sessionId) { + continue + } + + const key = `${event.sessionId}#${event.seq}` + + if (!unique.has(key)) { + unique.set(key, event) + } + } + + const bySession = new Map() + + for (const event of unique.values()) { + const list = bySession.get(event.sessionId) ?? [] + list.push(event) + bySession.set(event.sessionId, list) + } + + const turns: WireTurn[] = [] + + for (const [sessionId, list] of bySession) { + list.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0)) + let turn: null | WireTurn = null + + for (const event of list) { + if (event.type === 'message.start') { + turn = { sessionId, deltas: '', reasoning: '', complete: null } + turns.push(turn) + } else if (!turn) { + continue + } else if (event.type === 'message.delta') { + turn.deltas += String(event.payload?.text ?? '') + } else if (event.type === 'reasoning.delta') { + turn.reasoning += String(event.payload?.text ?? '') + } else if (event.type === 'message.complete') { + turn.complete = String(event.payload?.text ?? '') + } + } + } + + return turns +} + +/** + * Split a turn's streamed text at scenario markers: every scripted completion + * starts with its own marker, so each segment is one completion's output. + */ +function segments(text: string, re: RegExp): { marker: string; text: string }[] { + const out: { marker: string; text: string }[] = [] + const flat = norm(text) + const hits = [...flat.matchAll(new RegExp(re.source, 'g'))] + + hits.forEach((hit, i) => { + const end = i + 1 < hits.length ? hits[i + 1]!.index : flat.length + out.push({ marker: hit[0], text: flat.slice(hit.index, end).trim() }) + }) + + return out +} + +/** + * Backend stream integrity per turn: the concatenated message.delta (and + * reasoning.delta) text is exactly the provider's output, segment by segment — + * each completion once, whole (or a prefix when the backend itself hung up on + * it, e.g. a steer), in order — and message.complete equals the final + * completion. Only whitespace between tool-iteration segments may differ. + */ +function isSubsequence(words: string[], of: string[]): boolean { + let i = 0 + + for (const word of of) { + if (i < words.length && words[i] === word) { + i++ + } + } + + return i === words.length +} + +function wireViolations( + ws: WsRecorder, + provider: ScriptedProvider, + userMarkers: Set, + lossy: Set +): string[] { + const problems: string[] = [] + const byOpening = new Map() + + for (const completion of provider.completions) { + const opening = FIRST_MARKER_RE.exec(completion.sentText)?.[0] + const reasoningOpening = FIRST_MARKER_RE.exec(completion.sentReasoning)?.[0] + + if (opening) { + byOpening.set(opening, completion) + } + + if (reasoningOpening) { + byOpening.set(reasoningOpening, completion) + } + } + + for (const turn of wireTurns(ws.events)) { + const textSegments = segments(turn.deltas, /\bA\d+i?-[a-z0-9]{3,}\b/) + + if (textSegments.length === 0 || !userMarkers.has(userMarkerFor(textSegments.at(-1)!.marker))) { + continue + } + + // A turn that straddled an injected socket drop may have frames that went + // to the dead socket (the renderer recovers them from the persisted + // transcript, which the render oracle checks). Its frames must still be an + // in-order subsequence of what was streamed — never doubled or foreign. + const gapped = lossy.has(userMarkerFor(textSegments.at(-1)!.marker)) + + if (turn.complete === null && !gapped) { + problems.push(`wire ${textSegments.at(-1)!.marker}: turn never completed`) + + continue + } + + const seen = new Set() + + const check = (kind: string, segs: { marker: string; text: string }[], pick: (c: RecordedCompletion) => string) => { + segs.forEach((seg, i) => { + const completion = byOpening.get(seg.marker) + + if (!completion) { + problems.push(`wire: ${kind} segment ${seg.marker} was never streamed by the provider`) + + return + } + + if (seen.has(`${kind}:${seg.marker}`)) { + problems.push(`wire: ${kind} segment ${seg.marker} delivered twice in one turn`) + } + + seen.add(`${kind}:${seg.marker}`) + const sent = norm(pick(completion)) + const partialAllowed = completion.aborted && i < segs.length - 1 + + if (gapped) { + if (!isSubsequence(seg.text.split(' '), sent.split(' '))) { + problems.push( + `wire: ${kind} ${JSON.stringify(seg.text)} is not an in-order subsequence of ${JSON.stringify(sent)}` + ) + } + } else if (partialAllowed ? !sent.startsWith(seg.text) : seg.text !== sent) { + problems.push( + `wire: ${kind} ${JSON.stringify(seg.text)} != provider ${JSON.stringify(sent)}${completion.aborted ? ' (aborted)' : ''}` + ) + } + }) + } + + check('message.delta', textSegments, c => c.sentText) + check('reasoning.delta', segments(turn.reasoning, /\bR\d+-[a-z0-9]{3,}\b/), c => c.sentReasoning) + + const final = byOpening.get(textSegments.at(-1)!.marker) + + if (final && turn.complete !== null && norm(turn.complete) !== norm(final.sentText)) { + problems.push( + `wire: message.complete ${JSON.stringify(turn.complete)} != final completion ${JSON.stringify(final.sentText)}` + ) + } + } + + return problems +} + +// ─── The oracle ───────────────────────────────────────────────────────── + +export interface OracleTarget { + /** Stored session id (the route id). */ + sessionId: string + profile?: string + /** User markers this session must contain (guards against an empty/wrong session passing vacuously). */ + expectUserMarkers: string[] + /** User markers whose turn straddled an injected socket drop (wire frames may be gapped, never doubled). */ + lossyWire?: string[] +} + +function transcriptViolations(persisted: PersistedMessage[], view: RenderedView, target: OracleTarget): string[] { + const problems: string[] = [] + const rows = persisted.filter(m => (m.role === 'user' || m.role === 'assistant') && norm(m.content)) + const persistedMarkers = new Set() + + for (const marker of target.expectUserMarkers) { + if (!rows.some(row => row.role === 'user' && countMarker(row.content, marker) === 1)) { + problems.push(`persisted: user message ${marker} missing from the session`) + } + } + + for (const marker of target.expectUserMarkers) { + if (rows.filter(row => row.role === 'user' && row.content.includes(marker)).length > 1) { + problems.push(`persisted: user message ${marker} stored more than once`) + } + } + + let cursor = -1 + + for (const row of rows) { + const content = norm(row.content) + + for (const marker of content.match(ANY_MARKER_RE) ?? []) { + persistedMarkers.add(marker) + } + + const occurrences = countOccurrences(view.text, content) + + if (occurrences !== 1) { + problems.push(`rendered ${occurrences}x (want 1): ${row.role} ${JSON.stringify(content.slice(0, 80))}`) + + continue + } + + const at = view.text.indexOf(content) + + if (at < cursor) { + problems.push(`order: ${row.role} ${JSON.stringify(content.slice(0, 40))} rendered before an earlier message`) + } + + cursor = at + } + + const renderedMarkers = view.text.match(ANY_MARKER_RE) ?? [] + const counts = new Map() + + for (const marker of renderedMarkers) { + counts.set(marker, (counts.get(marker) ?? 0) + 1) + } + + for (const [marker, count] of counts) { + if (count > 1) { + problems.push(`marker ${marker} rendered ${count}x`) + } + + // Reasoning is not part of the display content; every other marker must be persisted. + if (!marker.startsWith('R') && !persistedMarkers.has(marker)) { + problems.push(`rendered but not persisted: ${marker}`) + } + } + + const userRows = rows.filter(row => row.role === 'user').map(row => norm(row.content)) + + if (view.userBubbles.length !== userRows.length) { + problems.push(`user bubbles ${view.userBubbles.length} != persisted user rows ${userRows.length}`) + } + + return problems +} + +/** + * Assert the invariant for the session currently on screen. Converges on the + * final state (deadline), then requires zero transient duplicates since the + * sampler was installed and a clean wire. + */ +export async function assertTranscriptOracle( + page: Page, + ws: WsRecorder, + provider: ScriptedProvider, + target: OracleTarget, + label: string +): Promise { + let last: { problems: string[]; persisted: PersistedMessage[]; view: RenderedView } = { + problems: ['not evaluated'], + persisted: [], + view: { text: '', userBubbles: [] } + } + + // Wire first: wait until every expected turn's message.complete is on the + // wire (it can trail the persisted row under load), so the DOM check below + // also covers whatever the renderer does on completion. Duplicate/garbled + // frames never heal, so polling cannot mask them. + await expect + .poll(() => wireViolations(ws, provider, new Set(target.expectUserMarkers), new Set(target.lossyWire ?? [])), { + timeout: 60_000, + intervals: [250, 500, 1000], + message: `wire integrity [${label}]` + }) + .toEqual([]) + + await expect + .poll( + async () => { + const persisted = await persistedTranscript(page, target.sessionId, target.profile) + const view = await renderedView(page) + last = { problems: transcriptViolations(persisted, view, target), persisted, view } + + return last.problems + }, + { timeout: 60_000, intervals: [250, 500, 1000], message: `transcript oracle [${label}]` } + ) + .toEqual([]) + .catch(error => { + throw new Error( + `transcript oracle [${label}] failed:\n ${last.problems.join('\n ')}\n` + + `persisted: ${JSON.stringify(last.persisted.map(m => [m.role, m.content.slice(0, 60)]))}\n` + + `rendered: ${JSON.stringify(last.view.text.slice(0, 1500))}\n(${(error as Error).message.split('\n')[0]})` + ) + }) + + const transient = await samplerViolations(page) + expect(transient.violations, `transient duplicate render during [${label}] (${transient.samples} samples)`).toEqual( + [] + ) +} diff --git a/apps/desktop/e2e/core/playwright.config.ts b/apps/desktop/e2e/core/playwright.config.ts new file mode 100644 index 0000000000..f8a3d846b9 --- /dev/null +++ b/apps/desktop/e2e/core/playwright.config.ts @@ -0,0 +1,32 @@ +import '../fix-electron-tracing' + +import { defineConfig } from '@playwright/test' + +/** + * The core Desktop suite: a small, deterministic, REQUIRED lane. + * + * Deliberately different from ../../playwright.config.ts: + * - retries: 0 — a required job that retries hides exactly the flake it + * should expose (the old lane retried and still went red for weeks). + * - no visual baselines / always-on screenshots; artifacts only on failure. + * - one worker: every spec owns a real Electron + `hermes serve`; running + * them concurrently on a loaded runner is the timing margin we refuse. + * - 180 s per test (green runs take 6-36 s): a stalled stream fails the one + * test fast instead of the 30-min job timeout cancelling the whole lane + * before it reports. + */ +export default defineConfig({ + testDir: '.', + testMatch: '*.spec.ts', + timeout: 180_000, + expect: { timeout: 60_000 }, + retries: 0, + workers: 1, + fullyParallel: false, + reporter: [['list'], ['html', { open: 'never', outputFolder: '../../playwright-report/core' }]], + outputDir: '../../test-results/core', + use: { + screenshot: 'only-on-failure', + trace: 'retain-on-failure' + } +}) diff --git a/apps/desktop/e2e/core/provider.ts b/apps/desktop/e2e/core/provider.ts new file mode 100644 index 0000000000..414988edd9 --- /dev/null +++ b/apps/desktop/e2e/core/provider.ts @@ -0,0 +1,321 @@ +/** + * Scripted, recording OpenAI-compatible provider for the core Desktop suite. + * + * Unlike the shared tests-js mock (trigger keywords + module-global counters, + * one of the reasons the old lane drifted), every reply here is keyed by the + * unique marker in the turn's own user message, and the step within a turn is + * derived from the request itself (assistant messages after the last user + * message). Two scenarios can never consume each other's script, and a replay + * or retry of the same request gets the same answer. + * + * Every chunk actually written is recorded so the oracle can assert + * "rendered == persisted == what the provider streamed". + */ + +import http from 'node:http' +import type { AddressInfo } from 'node:net' + +export interface Gate { + open: () => void + opened: Promise +} + +export function gate(): Gate { + let open = () => {} + + const opened = new Promise(resolve => { + open = resolve + }) + + return { open, opened } +} + +export interface ToolCallSpec { + name: string + args: Record +} + +/** One provider completion. Chunks are streamed in order: reasoning, text, tool calls. */ +export interface Step { + reasoning?: string[] + text?: string[] + toolCalls?: ToolCallSpec[] + /** Hold the stream after the first text (or reasoning) chunk until the gate opens. */ + holdAfterFirstChunk?: Gate +} + +export interface RecordedCompletion { + marker: null | string + step: number + stream: boolean + sentText: string + sentReasoning: string + toolCalls: string[] + finished: boolean + /** The client (the backend) hung up before the stream ended, e.g. an interrupt/steer. */ + aborted: boolean + body: any +} + +export interface ScriptedProvider { + url: string + /** Register the steps a turn keyed by `marker` answers with (step i = i-th completion of that turn). */ + script: (marker: string, steps: Step[]) => void + completions: RecordedCompletion[] + /** Resolves once the first chunk of `marker`'s step has been written to the wire. */ + streamStarted: (marker: string, step?: number) => Promise + close: () => Promise +} + +const MARKER_RE = /\bU\d+-[a-z0-9]+\b/ + +function textOf(content: unknown): string { + if (typeof content === 'string') { + return content + } + + if (Array.isArray(content)) { + return content.map(part => (typeof part?.text === 'string' ? part.text : '')).join('') + } + + return '' +} + +function turnPosition(messages: any[]): { marker: null | string; step: number } { + let lastUser = -1 + + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === 'user') { + lastUser = i + + break + } + } + + if (lastUser < 0) { + return { marker: null, step: 0 } + } + + const marker = MARKER_RE.exec(textOf(messages[lastUser].content))?.[0] ?? null + const step = messages.slice(lastUser + 1).filter(m => m?.role === 'assistant').length + + return { marker, step } +} + +function chunk(model: string, delta: Record, finish: null | string = null): string { + return `data: ${JSON.stringify({ + id: 'core-e2e', + object: 'chat.completion.chunk', + created: 0, + model, + choices: [{ index: 0, delta, finish_reason: finish }] + })}\n\n` +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 15)) + +export function startScriptedProvider(): Promise { + const scripts = new Map() + const completions: RecordedCompletion[] = [] + const started = new Map() + + const startedGate = (key: string) => { + let g = started.get(key) + + if (!g) { + g = gate() + started.set(key, g) + } + + return g + } + + async function streamStep(res: http.ServerResponse, model: string, step: Step, rec: RecordedCompletion) { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }) + res.on('close', () => { + if (!res.writableFinished) { + rec.aborted = true + } + }) + let first = true + + const afterChunk = async () => { + if (first) { + first = false + startedGate(`${rec.marker}#${rec.step}`).open() + + if (step.holdAfterFirstChunk) { + await step.holdAfterFirstChunk.opened + } + } + + await tick() + } + + for (const piece of step.reasoning ?? []) { + if (rec.aborted) { + return + } + + res.write(chunk(model, { reasoning_content: piece })) + rec.sentReasoning += piece + await afterChunk() + } + + for (const piece of step.text ?? []) { + if (rec.aborted) { + return + } + + res.write(chunk(model, { content: piece })) + rec.sentText += piece + await afterChunk() + } + + const calls = step.toolCalls ?? [] + + if (rec.aborted) { + return + } + + if (calls.length > 0) { + res.write( + chunk(model, { + tool_calls: calls.map((call, index) => ({ + index, + id: `call_core_${rec.marker}_${rec.step}_${index}`, + type: 'function', + function: { name: call.name, arguments: JSON.stringify(call.args) } + })) + }) + ) + rec.toolCalls.push(...calls.map(call => call.name)) + await afterChunk() + } + + res.write(chunk(model, {}, calls.length > 0 ? 'tool_calls' : 'stop')) + res.write('data: [DONE]\n\n') + res.end() + rec.finished = true + } + + function plainJson(res: http.ServerResponse, model: string, step: Step, rec: RecordedCompletion) { + const text = (step.text ?? []).join('') + const calls = step.toolCalls ?? [] + rec.sentText = text + rec.sentReasoning = (step.reasoning ?? []).join('') + rec.toolCalls.push(...calls.map(call => call.name)) + rec.finished = true + startedGate(`${rec.marker}#${rec.step}`).open() + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + id: 'core-e2e', + object: 'chat.completion', + created: 0, + model, + choices: [ + { + index: 0, + finish_reason: calls.length > 0 ? 'tool_calls' : 'stop', + message: { + role: 'assistant', + content: text || null, + ...(rec.sentReasoning ? { reasoning_content: rec.sentReasoning } : {}), + ...(calls.length > 0 + ? { + tool_calls: calls.map((call, index) => ({ + id: `call_core_${rec.marker}_${rec.step}_${index}`, + type: 'function', + function: { name: call.name, arguments: JSON.stringify(call.args) } + })) + } + : {}) + } + } + ], + usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 } + }) + ) + } + + const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url?.startsWith('/v1/models')) { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ object: 'list', data: [{ id: 'mock-model', object: 'model', created: 0, owned_by: 'core' }] }) + ) + + return + } + + if (req.method !== 'POST' || !req.url?.startsWith('/v1/chat/completions')) { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'not found' })) + + return + } + + let raw = '' + req.on('data', (data: Buffer) => { + raw += data.toString() + }) + req.on('end', () => { + let body: any = {} + + try { + body = JSON.parse(raw) + } catch { + body = {} + } + + const messages: any[] = Array.isArray(body.messages) ? body.messages : [] + const { marker, step: stepIndex } = turnPosition(messages) + const steps = marker ? scripts.get(marker) : undefined + // Unscripted traffic (auxiliary calls, a marker-less prompt) gets a + // fixed short answer; it is recorded, never matched to a scenario. + const step: Step = steps?.[Math.min(stepIndex, steps.length - 1)] ?? { text: ['ok'] } + const model = typeof body.model === 'string' ? body.model : 'mock-model' + + const rec: RecordedCompletion = { + marker: steps ? marker : null, + step: stepIndex, + stream: body.stream === true, + sentText: '', + sentReasoning: '', + toolCalls: [], + finished: false, + aborted: false, + body + } + + completions.push(rec) + + if (rec.stream) { + void streamStep(res, model, step, rec).catch(() => res.destroy()) + } else { + plainJson(res, model, step, rec) + } + }) + }) + + return new Promise((resolve, reject) => { + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as AddressInfo + resolve({ + url: `http://127.0.0.1:${port}`, + script: (marker, steps) => { + scripts.set(marker, steps) + }, + completions, + streamStarted: (marker, step = 0) => startedGate(`${marker}#${step}`).opened, + close: () => + new Promise(done => { + server.closeAllConnections?.() + server.close(() => done()) + }) + }) + }) + }) +} diff --git a/apps/desktop/e2e/core/switch-back-race.spec.ts b/apps/desktop/e2e/core/switch-back-race.spec.ts new file mode 100644 index 0000000000..6130edb737 --- /dev/null +++ b/apps/desktop/e2e/core/switch-back-race.spec.ts @@ -0,0 +1,156 @@ +/** + * C2 core: switch back to a session whose reply completes while the switch-back + * hydrate is in flight. + * + * Session B streams (provider held) -> user opens A -> user returns to B. The + * return issues a REST page read (/api/sessions/B/messages) that races the + * live message.complete. Both orders are forced deterministically: the REST + * read is parked in main (ipcMain 'hermes:api' wrapper) until the test opens + * it, and the provider stream is parked on a gate. No sleeps. + * + * complete-before-hydrate was red on base (reply rendered twice: committed row + * plus a live `assistant-stream-*` row, sometimes frozen on its first chunk); + * fixed in "render a reply once when it completes during the switch-back + * hydrate". Oracle: every message exactly once, DOM == persisted, at every + * sampled frame. + */ + +import { expect, type Page, test } from '@playwright/test' + +import { + coreAppEnv, + createCoreSandbox, + currentSessionId, + launchCoreApp, + recordWebSockets, + send, + waitForInteractive, + writeProviderHome +} from './harness' +import { assertTranscriptOracle, installDuplicateSampler, type OracleTarget } from './oracle' +import { gate, startScriptedProvider } from './provider' + +const nonce = Math.random() + .toString(36) + .slice(2, 8) + .replace(/[^a-z0-9]/g, 'x') + .padEnd(4, 'q') + +const U = (n: number) => `U${n}-${nonce}` +const A = (n: number) => `A${n}-${nonce}` + +function viewport(page: Page) { + return page.locator('[data-slot="aui_thread-viewport"]').filter({ visible: true }).first() +} + +for (const order of ['complete-before-hydrate', 'hydrate-before-complete'] as const) { + test(`switch back while away session completes: ${order}`, async () => { + const provider = await startScriptedProvider() + const sandbox = createCoreSandbox('race') + writeProviderHome(sandbox.hermesHome, provider.url) + const { app, page } = await launchCoreApp(coreAppEnv(sandbox)) + const ws = recordWebSockets(page) + + try { + await waitForInteractive(app, page) + await installDuplicateSampler(page) + provider.script(U(1), [{ text: [`${A(1)} `, 'one'] }]) + await send(page, `${U(1)} a`, 'Enter', ws) + await expect.poll(() => provider.completions.some(c => c.marker === U(1) && c.finished)).toBe(true) + await expect.poll(() => currentSessionId(page)).not.toBe('') + const a = await currentSessionId(page) + await expect(viewport(page)).toContainText(A(1)) + + await page.evaluate(() => { + window.location.hash = '#/' + }) + await expect.poll(() => currentSessionId(page)).toBe('') + const hold = gate() + provider.script(U(2), [{ text: [`${A(2)} `, 'finished ', 'while ', 'away'], holdAfterFirstChunk: hold }]) + await send(page, `${U(2)} b`, 'Enter', ws) + await provider.streamStarted(U(2)) + await expect.poll(() => currentSessionId(page)).not.toBe('') + const b = await currentSessionId(page) + await expect(viewport(page)).toContainText(A(2)) + + await page.evaluate(id => { + window.location.hash = `#/${id}` + }, a) + await expect(viewport(page)).toContainText(A(1)) + + { + const wrapped = await app.evaluate(({ ipcMain }, b) => { + const g = globalThis as any + g.__gated = [] + g.__gateOpen = false + g.__gateWaiters = [] as (() => void)[] + // Electron keeps invoke handlers in a private map; if that ever moves, + // fail loudly here rather than silently not gating. + const handlers = (ipcMain as any)._invokeHandlers as Map Promise> | undefined + const original = handlers?.get('hermes:api') + + if (!original) { + return false + } + + ipcMain.removeHandler('hermes:api') + ipcMain.handle('hermes:api', async (event: any, request: any) => { + const p = String(request?.path ?? '') + + if (p.includes(`/api/sessions/${b}`) && !g.__gateOpen) { + g.__gated.push(p) + await new Promise(resolve => g.__gateWaiters.push(resolve)) + } + + return original(event, request) + }) + + return true + }, b) + + expect(wrapped, 'hermes:api handler wrapped for gating').toBe(true) + } + + await page.evaluate(id => { + window.location.hash = `#/${id}` + }, b) + await expect.poll(() => currentSessionId(page)).toBe(b) + + const openGate = () => + app.evaluate(() => { + const g = globalThis as any + g.__gateOpen = true + + for (const w of g.__gateWaiters) { + w() + } + + return g.__gated + }) + + const completed = () => + ws.events.some(e => e.type === 'message.complete' && String(e.payload?.text ?? '').startsWith(A(2))) + + const gatedCount = () => app.evaluate(() => ((globalThis as any).__gated ?? []).length as number) + + if (order === 'complete-before-hydrate') { + await expect.poll(gatedCount).toBeGreaterThan(0) + hold.open() + await expect.poll(completed).toBe(true) + await openGate() + } else if (order === 'hydrate-before-complete') { + await expect.poll(gatedCount).toBeGreaterThan(0) + await openGate() + hold.open() + await expect.poll(completed).toBe(true) + } + + const target: OracleTarget = { sessionId: b, expectUserMarkers: [U(2)] } + await assertTranscriptOracle(page, ws, provider, target, `switch-back race ${order}`) + } finally { + await app.close().catch(() => undefined) + await provider.close() + sandbox.cleanup() + } + }) +} diff --git a/apps/desktop/e2e/core/transcript-integrity.spec.ts b/apps/desktop/e2e/core/transcript-integrity.spec.ts new file mode 100644 index 0000000000..02325c2497 --- /dev/null +++ b/apps/desktop/e2e/core/transcript-integrity.spec.ts @@ -0,0 +1,336 @@ +/** + * C2 core: transcript integrity across every transition that has shipped a + * duplicate / vanishing / reordered message bug. + * + * One real Electron app + one real `hermes serve` backend (only the LLM is + * faked, by a scripted recording provider). After every transition the + * transcript oracle (./oracle.ts) asserts: each persisted user/assistant + * message is rendered exactly once, in order, nothing unpersisted is + * rendered, no marker was ever rendered twice even transiently, and the + * backend's concatenated deltas equal what the provider streamed. + * + * Transitions (in order, sharing the app so each builds on the last): + * stream → tool-call turn → reasoning turn → steer mid-stream → + * queued follow-up → + * session switch mid-stream → warm resume → reload → + * WebSocket drop + reconnect mid-stream → second socket to the same + * backend (#120005 topology) → final reload re-checks every session. + */ + +import * as path from 'node:path' + +import { expect, type Page, test } from '@playwright/test' + +import { + coreAppEnv, + createCoreSandbox, + currentSessionId, + launchCoreApp, + recordWebSockets, + routePrimaryWebSocket, + send, + splitProfileRoute, + startTcpProxy, + storedSessionForMarker, + waitForInteractive, + writeProviderHome +} from './harness' +import { assertTranscriptOracle, installDuplicateSampler, type OracleTarget } from './oracle' +import { gate, startScriptedProvider } from './provider' + +const nonce = Math.random() + .toString(36) + .slice(2, 8) + .replace(/[^a-z0-9]/g, 'x') + .padEnd(4, 'q') + +const U = (n: number) => `U${n}-${nonce}` +const A = (n: number) => `A${n}-${nonce}` +const AI = (n: number) => `A${n}i-${nonce}` +const R = (n: number) => `R${n}-${nonce}` + +const words = (marker: string, ...rest: string[]) => [ + `${marker} `, + ...rest.map((w, i) => (i === rest.length - 1 ? w : `${w} `)) +] + +function viewport(page: Page) { + return page.locator('[data-slot="aui_thread-viewport"]').filter({ visible: true }).first() +} + +async function openSession(page: Page, sessionId: string, mustShow: string) { + await page.evaluate(id => { + window.location.hash = `#/${encodeURIComponent(id)}` + }, sessionId) + await expect.poll(() => currentSessionId(page)).toBe(sessionId) + await expect(viewport(page)).toContainText(mustShow, { timeout: 60_000 }) +} + +test('transcript oracle holds across every transition', async () => { + const provider = await startScriptedProvider() + const sandbox = createCoreSandbox('transcript') + writeProviderHome(sandbox.hermesHome, provider.url) + writeProviderHome(path.join(sandbox.hermesHome, 'profiles', 'p2'), provider.url) + const { app, page } = await launchCoreApp(coreAppEnv(sandbox)) + const ws = recordWebSockets(page) + const proxies: { close: () => Promise }[] = [] + + const finished = (marker: string, step = 0) => + expect + .poll(() => provider.completions.some(c => c.marker === marker && c.step === step && c.finished), { + timeout: 120_000, + message: `provider finished ${marker} step ${step}` + }) + .toBe(true) + + const sessionA: OracleTarget = { sessionId: '', expectUserMarkers: [] } + const sessionB: OracleTarget = { sessionId: '', expectUserMarkers: [] } + + try { + await waitForInteractive(app, page) + await installDuplicateSampler(page) + + await test.step('stream: multi-chunk reply', async () => { + provider.script(U(1), [{ text: words(A(1), 'alpha', 'bravo', 'charlie', 'delta', 'echo') }]) + await send(page, `${U(1)} hello`, 'Enter', ws) + await finished(U(1)) + await expect.poll(() => currentSessionId(page)).not.toBe('') + sessionA.sessionId = await currentSessionId(page) + sessionA.expectUserMarkers.push(U(1)) + await assertTranscriptOracle(page, ws, provider, sessionA, 'stream') + }) + + await test.step('tool-call turn: interim text + real terminal tool + final', async () => { + provider.script(U(2), [ + { + text: words(AI(2), 'checking', 'first'), + toolCalls: [{ name: 'terminal', args: { command: 'echo core-tool-ok' } }] + }, + { text: words(A(2), 'tool', 'said', 'ok') } + ]) + await send(page, `${U(2)} run a tool`, 'Enter', ws) + await finished(U(2), 1) + sessionA.expectUserMarkers.push(U(2)) + await assertTranscriptOracle(page, ws, provider, sessionA, 'tool-call turn') + }) + + await test.step('reasoning turn: reasoning deltas never leak into or double the reply', async () => { + provider.script(U(3), [ + { reasoning: words(R(3), 'weighing', 'options'), text: words(A(3), 'reasoned', 'answer') } + ]) + await send(page, `${U(3)} think first`, 'Enter', ws) + await finished(U(3)) + sessionA.expectUserMarkers.push(U(3)) + await assertTranscriptOracle(page, ws, provider, sessionA, 'reasoning turn') + }) + + await test.step('steer mid-stream: Enter while busy redirects the live turn', async () => { + const hold = gate() + provider.script(U(4), [{ text: words(A(4), 'slow', 'first', 'reply'), holdAfterFirstChunk: hold }]) + provider.script(U(5), [{ text: words(A(5), 'steered', 'reply') }]) + await send(page, `${U(4)} slow one`, 'Enter', ws) + await provider.streamStarted(U(4)) + await expect(viewport(page)).toContainText(A(4)) + await send(page, `${U(5)} change course`, 'Enter', ws) + await finished(U(5)) + hold.open() + sessionA.expectUserMarkers.push(U(4), U(5)) + await assertTranscriptOracle(page, ws, provider, sessionA, 'steer mid-stream') + }) + + await test.step('queued follow-up: Ctrl+Enter while busy runs after the live turn, once', async () => { + const hold = gate() + provider.script(U(11), [{ text: words(A(11), 'long', 'running', 'reply'), holdAfterFirstChunk: hold }]) + provider.script(U(12), [{ text: words(A(12), 'queued', 'reply') }]) + await send(page, `${U(11)} long one`, 'Enter', ws) + await provider.streamStarted(U(11)) + await expect(viewport(page)).toContainText(A(11)) + await send(page, `${U(12)} after that`, 'Control+Enter', ws) + hold.open() + await finished(U(11)) + await finished(U(12)) + // The queued message is a turn of its own, strictly after the first. + const u11 = provider.completions.findIndex(c => c.marker === U(11)) + const u12 = provider.completions.findIndex(c => c.marker === U(12)) + expect(u12).toBeGreaterThan(u11) + sessionA.expectUserMarkers.push(U(11), U(12)) + await assertTranscriptOracle(page, ws, provider, sessionA, 'queued follow-up') + }) + + await test.step('session switch mid-stream: the away session completes exactly once', async () => { + // NEW_CHAT_ROUTE ('/') — the route the sidebar's New session button opens. + await page.evaluate(() => { + window.location.hash = '#/' + }) + await expect.poll(() => currentSessionId(page)).toBe('') + await expect(viewport(page)).not.toContainText(U(1)) + const hold = gate() + provider.script(U(6), [{ text: words(A(6), 'finished', 'while', 'away'), holdAfterFirstChunk: hold }]) + await send(page, `${U(6)} in session b`, 'Enter', ws) + await provider.streamStarted(U(6)) + await expect.poll(() => currentSessionId(page)).not.toBe('') + sessionB.sessionId = await currentSessionId(page) + expect(sessionB.sessionId).not.toBe(sessionA.sessionId) + sessionB.expectUserMarkers.push(U(6)) + await openSession(page, sessionA.sessionId, A(12)) + hold.open() + await finished(U(6)) + await assertTranscriptOracle(page, ws, provider, sessionA, 'switch: session A while B streams') + await openSession(page, sessionB.sessionId, U(6)) + await assertTranscriptOracle(page, ws, provider, sessionB, 'switch: back to B after it completed away') + }) + + await test.step('warm resume: revisit cached sessions and continue one', async () => { + await openSession(page, sessionA.sessionId, A(12)) + await assertTranscriptOracle(page, ws, provider, sessionA, 'warm resume A') + await openSession(page, sessionB.sessionId, A(6)) + provider.script(U(7), [{ text: words(A(7), 'warm', 'continue') }]) + await send(page, `${U(7)} continue b`, 'Enter', ws) + await finished(U(7)) + sessionB.expectUserMarkers.push(U(7)) + await assertTranscriptOracle(page, ws, provider, sessionB, 'warm resume B + new turn') + }) + + // From here on the primary socket dials through a loopback proxy the test + // controls, so a network drop can be injected mid-stream. + const backendPort = Number(new URL(ws.sockets[0]!.url).port) + const proxy = await startTcpProxy(backendPort) + proxies.push(proxy) + await routePrimaryWebSocket(app, backendPort, proxy.port) + + await test.step('reload: hydrated transcript equals persisted', async () => { + await page.reload() + await waitForInteractive(app, page) + await installDuplicateSampler(page) + await expect.poll(() => proxy.connections()).toBeGreaterThan(0) + await openSession(page, sessionB.sessionId, A(7)) + await assertTranscriptOracle(page, ws, provider, sessionB, 'reload B') + await openSession(page, sessionA.sessionId, A(12)) + await assertTranscriptOracle(page, ws, provider, sessionA, 'reload A') + }) + + await test.step('WebSocket drop + reconnect mid-stream: no lost, doubled or replayed text', async () => { + const hold = gate() + provider.script(U(8), [{ text: words(A(8), 'survives', 'the', 'drop'), holdAfterFirstChunk: hold }]) + await send(page, `${U(8)} across a drop`, 'Enter', ws) + await provider.streamStarted(U(8)) + await expect(viewport(page)).toContainText(A(8)) + const socketsBefore = ws.sockets.length + expect(proxy.dropAll()).toBeGreaterThan(0) + // The renderer must dial a fresh socket (through the proxy) on its own. + await expect.poll(() => ws.sockets.length, { timeout: 60_000 }).toBeGreaterThan(socketsBefore) + await expect.poll(() => proxy.connections(), { timeout: 60_000 }).toBeGreaterThan(0) + hold.open() + await finished(U(8)) + sessionA.expectUserMarkers.push(U(8)) + sessionA.lossyWire = [U(8)] + await assertTranscriptOracle(page, ws, provider, sessionA, 'ws drop + reconnect') + }) + + // Sockets that delivered the message.complete of the turn whose reply opens with `marker`. + const completeSockets = (marker: string) => + new Set( + ws.events + .filter(e => e.type === 'message.complete' && String(e.payload?.text ?? '').startsWith(marker)) + .map(e => e.socket) + ) + + let sessionP2: OracleTarget = { sessionId: '', profile: 'p2', expectUserMarkers: [] } + + await test.step('non-default profile on the shared host backend: one socket per session', async () => { + await page + .getByRole('button', { name: 'Bots', exact: true }) + .or(page.getByRole('tab', { name: 'Bots', exact: true })) + .first() + .click() + const row = page.locator('[data-slot="bots-roster"] [data-roster-key="local::p2"]') + await expect(row).toBeVisible({ timeout: 60_000 }) + await row.click() + await expect(page.getByRole('tab', { name: /Draft/ })).toBeVisible({ timeout: 60_000 }) + provider.script(U(9), [{ text: words(A(9), 'profile', 'two', 'first') }]) + await send(page, `${U(9)} on p2`, 'Enter', ws) + await finished(U(9)) + await expect(viewport(page)).toContainText(A(9)) + let p2Session: null | string = null + await expect + .poll(() => (p2Session = storedSessionForMarker(sandbox, 'p2', U(9))), { + message: 'p2 turn persisted in the p2 state.db' + }) + .not.toBeNull() + sessionP2 = { sessionId: p2Session!, profile: 'p2', expectUserMarkers: [U(9)] } + await assertTranscriptOracle(page, ws, provider, sessionP2, 'p2 first turn') + + // #120005: the SECOND message of a non-default-profile chat is where a + // session-owner call used to dial a second socket to the same process. + provider.script(U(13), [{ text: words(A(13), 'profile', 'two', 'second') }]) + await send(page, `${U(13)} again on p2`, 'Enter', ws) + await finished(U(13)) + sessionP2.expectUserMarkers.push(U(13)) + await assertTranscriptOracle(page, ws, provider, sessionP2, 'p2 second turn') + expect([...completeSockets(A(9))].length, 'p2 turn 1 delivered on exactly one socket').toBe(1) + expect([...completeSockets(A(13))].length, 'p2 turn 2 delivered on exactly one socket').toBe(1) + // One backend process, one socket: the host backend serves p2 too, so + // the renderer must not hold a second live socket to it (#120006). + const sameBackend = new Set([String(backendPort), String(proxy.port)]) + await expect + .poll( + () => + ws.sockets + .filter(s => !s.closed && sameBackend.has(new URL(s.url).port)) + .map(s => s.url.replace(/token=[^&]+/, 'token=…')), + { + timeout: 30_000, + message: 'live sockets to the one host backend' + } + ) + .toHaveLength(1) + }) + + await test.step('forced second socket to the same backend: every event still renders once', async () => { + // Force the #120005 topology regardless of routing: from the next + // session call on, the p2 route is served by a second socket to the same + // backend while the primary stays joined. The renderer must still apply + // every event exactly once (tool turn → interim bubble + final). + const socketsBefore = ws.sockets.length + await splitProfileRoute(app, 'p2') + provider.script(U(10), [ + { + text: words(AI(10), 'looking', 'it', 'up'), + toolCalls: [{ name: 'terminal', args: { command: 'echo core-two-sockets' } }] + }, + { text: words(A(10), 'two', 'sockets', 'one', 'render') } + ]) + await send(page, `${U(10)} tool on p2`, 'Enter', ws) + await finished(U(10), 1) + await expect.poll(() => ws.sockets.length).toBeGreaterThan(socketsBefore) + // Precondition of the scenario: the turn really was fanned out to 2+ sockets. + await expect + .poll(() => completeSockets(A(10)).size, { + timeout: 60_000, + message: 'the forced turn reached more than one socket' + }) + .toBeGreaterThan(1) + sessionP2.expectUserMarkers.push(U(10)) + await assertTranscriptOracle(page, ws, provider, sessionP2, 'p2 tool turn over two sockets') + }) + + await test.step('final reload: every session re-hydrates exactly once', async () => { + await page.reload() + await waitForInteractive(app, page) + await installDuplicateSampler(page) + await openSession(page, sessionA.sessionId, A(8)) + await assertTranscriptOracle(page, ws, provider, sessionA, 'final reload A') + await openSession(page, sessionB.sessionId, A(7)) + await assertTranscriptOracle(page, ws, provider, sessionB, 'final reload B') + }) + } finally { + await app.close().catch(() => undefined) + + for (const proxy of proxies) { + await proxy.close() + } + + await provider.close() + sandbox.cleanup() + } +}) diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index 100c002252..bc3fd9e6b8 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -30,8 +30,9 @@ export default defineConfig({ testDir: './e2e', /* ...except `*.unit.test.ts`, which covers the e2e HELPERS (no Electron, no * app) and is owned by the vitest `electron` project. Without this the - * default testMatch would claim those files too and run them twice. */ - testIgnore: '**/*.unit.test.ts', + * default testMatch would claim those files too and run them twice. + * e2e/core/ has its own config (retries 0, one worker) and CI job. */ + testIgnore: ['**/*.unit.test.ts', 'core/**'], /* The desktop app can take a while to bootstrap on cold CI runners — 90 s * per test gives us headroom without masking real hangs. */ timeout: 90_000, diff --git a/apps/desktop/src/app/chat/composer/hooks/slash-localization.test.tsx b/apps/desktop/src/app/chat/composer/hooks/slash-localization.test.tsx index 19529f7d66..68c982754a 100644 --- a/apps/desktop/src/app/chat/composer/hooks/slash-localization.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/slash-localization.test.tsx @@ -114,7 +114,7 @@ describe('desktop slash description localization', () => { ) for (const query of ['', 'ne']) { - for (const locale of ['en', 'zh', 'ja', 'zh-hant', 'ar', 'ru', 'en'] as const) { + for (const locale of ['en', 'zh', 'ja', 'zh-hant', 'ar', 'ru', 'fr', 'de', 'es', 'en'] as const) { await act(async () => { await api.setLocale!(locale) }) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 5b16bbf6e9..c27685fec8 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -37,6 +37,7 @@ import { sessionBlockingPrompt } from '@/store/prompts' import { toggleReview } from '@/store/review' import { $gatewayState } from '@/store/session' import { $botChatSessionIds, $sessionStates, $sessionTiles, isBotChatSession } from '@/store/session-states' +import { useForcedTextDirection } from '@/store/text-direction' import { $threadScrolledUpBySession } from '@/store/thread-scroll' import { $autoSpeakReplies } from '@/store/voice-prefs' import { useTheme } from '@/themes' @@ -195,6 +196,7 @@ export function ChatBar({ ) const autoSpeak = useStore($autoSpeakReplies) + const textDirection = useForcedTextDirection() // The turn is parked on the user (clarify / approval / sudo / secret). Esc must // not interrupt it — there's nothing actively running to stop, and stopping // would discard a question the user may want to come back to. The blocking @@ -1168,6 +1170,7 @@ export function ChatBar({ contentEditable={!inputDisabled} data-placeholder={placeholder} data-slot={RICH_INPUT_SLOT} + dir={textDirection} onBeforeInput={handleEditorBeforeInput} onBlur={() => { // A composition never survives focus loss (Chromium commits the diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/interim-history.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/interim-history.test.tsx new file mode 100644 index 0000000000..24993107fb --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/interim-history.test.tsx @@ -0,0 +1,110 @@ +import type { GatewayEvent } from '@hermes/shared' +import { act, cleanup } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { chatMessageText, toChatMessages } from '@/lib/chat-messages' +import type { SessionMessage } from '@/types/hermes' + +import { renderMessageStream } from './test-harness' + +const SID = 'interim-preservation' + +afterEach(cleanup) + +describe('intermediate assistant text survives Desktop lifecycle boundaries', () => { + it.each(['message.complete', 'message.interim'] as const)( + 'keeps pre-tool text when %s settles the next response', + async terminal => { + const stream = renderMessageStream(SID) + + const emit = async (type: GatewayEvent['type'], payload: GatewayEvent['payload']) => { + await act(() => stream.handleEvent({ type, payload, session_id: SID })) + } + + await emit('message.start', {}) + await emit('message.delta', { text: 'Checking the files.', timestamp: 1 }) + // Missing/disabled interim delivery must not make this older model response + // disposable when the later model response is finalized. + await emit('tool.start', { tool_id: 'call-1', name: 'terminal', args: { command: 'pwd' }, timestamp: 2 }) + await emit('tool.complete', { tool_id: 'call-1', name: 'terminal', result: 'ok', timestamp: 3 }) + await emit('message.delta', { text: 'Partial answer', timestamp: 4 }) + await emit(terminal, { text: 'The result is ready.', timestamp: 5 }) + const messages = stream.state().messages + const visible = messages.map(chatMessageText).join('\n') + expect(visible).toContain('Checking the files.') + expect(visible).toContain('The result is ready.') + expect(visible).not.toContain('Partial answer') + expect(messages.flatMap(message => message.parts).filter(part => part.type === 'tool-call')).toHaveLength(1) + expect(stream.state().busy).toBe(terminal === 'message.interim') + } + ) + + it.each(['rpc', 'rest'] as const)( + 'preserves the same public commentary on live → %s history projection', + async transport => { + const stream = renderMessageStream(SID) + + const emit = async (type: GatewayEvent['type'], payload: GatewayEvent['payload']) => { + await act(() => stream.handleEvent({ type, payload, session_id: SID })) + } + + await emit('message.start', {}) + await emit('reasoning.delta', { text: 'Real reasoning.', timestamp: 1 }) + await emit('message.interim', { text: 'I will inspect the files.', already_streamed: false, timestamp: 2 }) + await emit('tool.start', { tool_id: 'call-1', name: 'terminal', args: { command: 'pwd' }, timestamp: 3 }) + await emit('tool.complete', { tool_id: 'call-1', name: 'terminal', result: 'ok', timestamp: 4 }) + await emit('message.delta', { text: 'All checks passed.', timestamp: 5 }) + await emit('message.complete', { text: 'All checks passed.', timestamp: 6 }) + + const items = [ + { + type: 'message', + role: 'assistant', + phase: 'commentary', + content: [{ type: 'output_text', text: 'I will inspect the files.' }] + } + ] + + const rows: SessionMessage[] = [ + { + role: 'assistant', + content: '', + timestamp: 1, + reasoning: 'Real reasoning.\n\nI will inspect the files.', + display_reasoning: 'Real reasoning.', + display_commentary: ['I will inspect the files.'], + codex_message_items: transport === 'rest' ? JSON.stringify(items) : items, + tool_calls: [ + { id: 'call-1', type: 'function', function: { name: 'terminal', arguments: '{"command":"pwd"}' } } + ] + }, + { role: 'tool', content: 'ok', tool_call_id: 'call-1', name: 'terminal', timestamp: 4 }, + { role: 'assistant', content: 'All checks passed.', timestamp: 5 } + ] + + const texts = (messages: ReturnType) => + messages + .flatMap(message => message.parts.flatMap(part => (part.type === 'text' ? [part.text.trim()] : []))) + .filter(Boolean) + + const live = texts(stream.state().messages) + const restored = texts(toChatMessages(rows)) + expect(live).toEqual(['I will inspect the files.', 'All checks passed.']) + expect(restored).toEqual(live) + expect(restored.join('')).not.toContain('Real reasoning.') + + const restoredReasoning = toChatMessages(rows) + .flatMap(message => message.parts) + .filter(part => part.type === 'reasoning') + .map(part => part.text) + + expect(restoredReasoning).toEqual(['Real reasoning.']) + + expect( + toChatMessages(rows) + .flatMap(message => message.parts) + .filter(part => part.type === 'tool-call') + ).toHaveLength(1) + } + ) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index 63e320ba2f..dcd9877659 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -1231,6 +1231,128 @@ describe('preserveLocalPendingTurnMessages', () => { ) }) + // A Codex Responses turn: an acknowledgement, two progress updates between + // tool rounds, then the answer. Live, each seals as its own bubble; history + // folds them into one row and may keep the public commentary only in + // `reasoning` (#119716). Tool call ids are the durable identity either way. + const tool = (toolCallId: string) => + ({ type: 'tool-call', toolCallId, toolName: 'terminal', result: 'ok' }) as ChatMessagePart + + const sealed = (id: string, parts: ChatMessagePart[], extra: Partial = {}) => + ({ id, role: 'assistant', parts, pending: false, interim: true, ...extra }) as ChatMessage + + const lunaTurn = (prefix: string, callPrefix: string) => [ + sealed(`assistant-stream-${prefix}-ack`, [{ type: 'text', text: `${prefix}: on it, reading the logs.` }]), + sealed(`assistant-stream-${prefix}-progress-1`, [ + tool(`${callPrefix}-1`), + { type: 'text', text: `${prefix}: logs clean.` } + ]), + sealed(`assistant-stream-${prefix}-progress-2`, [ + tool(`${callPrefix}-2`), + { type: 'text', text: `${prefix}: config fixed.` } + ]), + sealed( + `assistant-stream-${prefix}-final`, + [tool(`${callPrefix}-3`), { type: 'text', text: `${prefix}: all done.` }], + { + interim: false + } + ) + ] + + const lunaFold = (id: string, prefix: string, callPrefix: string, commentary: 'reasoning' | 'text') => + ({ + id, + role: 'assistant', + parts: [ + ...[`${prefix}: on it, reading the logs.`, `${prefix}: logs clean.`, `${prefix}: config fixed.`].flatMap( + (text, at) => [ + commentary === 'text' + ? ({ type: 'text', text } as ChatMessagePart) + : ({ type: 'reasoning', text: `**Plan**\n\n${text}` } as ChatMessagePart), + tool(`${callPrefix}-${at + 1}`) + ] + ), + { type: 'text', text: `${prefix}: all done.` } + ] + }) as ChatMessage + + it.each(['reasoning', 'text'] as const)( + 'retires every sealed bubble of a folded turn whose commentary hydrated as %s', + commentary => { + const user = msg('1-user', 'user', 'fix it', { rowId: 1 }) + const next = [user, lunaFold('2-assistant', 'a', 'call-a', commentary)] + + expect(preserveLocalPendingTurnMessages(next, [user, ...lunaTurn('a', 'call-a')])).toBe(next) + } + ) + + // The previous turn's bubbles are not owned by the newest prompt, so they + // must not resurface under it (#119511, and the self-sustaining tail of + // stale commentary in #119362) — nor may they swallow the live reply. + it.each(['reasoning', 'text'] as const)( + 'does not re-append an earlier turn under a newer prompt when its commentary hydrated as %s', + commentary => { + const next = [ + msg('1-user', 'user', 'fix it', { rowId: 1 }), + lunaFold('2-assistant', 'a', 'call-a', commentary), + msg('3-user', 'user', 'and the other one', { rowId: 9 }) + ] + + const previous = [ + msg('user-1-a', 'user', 'fix it', { rowId: 1 }), + ...lunaTurn('a', 'call-a'), + // No submit receipt yet, so no acknowledged boundary past turn a. + msg('user-2-b', 'user', 'and the other one'), + sealed('assistant-stream-live', [tool('call-b-1'), { type: 'text', text: 'b: still going.' }]) + ] + + expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([ + '1-user', + '2-assistant', + '3-user', + 'assistant-stream-live' + ]) + } + ) + + // #118228: narration bubbles still marked pending when the rehydrate lands. + // A sealed interim's text is final, so the fold carrying it retires it. + it('retires pending interim narration the merged fold already carries, even with later turns stored', () => { + const user = msg('1-user', 'user', 'run the build', { rowId: 1 }) + + // More live bubbles than stored assistant rows: ordinal pairing runs out. + const turn = [ + ...lunaTurn('a', 'call-a') + .slice(0, 3) + .map(row => ({ ...row, pending: true })), + msg('assistant-stream-a-tail', 'assistant', 'a: all done.', { pending: true }) + ] + + const next = [ + user, + lunaFold('2-assistant', 'a', 'call-a', 'text'), + msg('3-system', 'system', 'Background Process Finished: bash build.sh'), + msg('4-assistant', 'assistant', 'build verified'), + msg('5-user', 'user', 'installed it, same problem', { rowId: 20 }), + msg('6-assistant', 'assistant', 'then it is not the line count') + ] + + expect(preserveLocalPendingTurnMessages(next, [user, ...turn])).toBe(next) + }) + + // The fold committed the tool rounds but not the answer yet: that bubble is + // the only copy and must survive, while the carried commentary retires. + it('keeps the final answer a fold has not committed yet', () => { + const user = msg('1-user', 'user', 'fix it', { rowId: 1 }) + const fold = lunaFold('2-assistant', 'a', 'call-a', 'reasoning') + const next = [user, { ...fold, parts: fold.parts.filter(part => part.type !== 'text') }] + + expect( + preserveLocalPendingTurnMessages(next, [user, ...lunaTurn('a', 'call-a')]).map(message => message.id) + ).toEqual(['1-user', '2-assistant', 'assistant-stream-a-final']) + }) + // The whole point of replacing rather than appending: one reply on screen, // and the committed history around the live turn untouched. it('does not duplicate or rewrite committed history around the live turn', () => { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 2b7fc460a1..4014f3c05a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -596,19 +596,71 @@ const textPartsOf = (message: ChatMessage) => return text ? [text] : [] }) +const isPrompt = (message: ChatMessage) => message.role === 'user' && !isGatewaySystemMarker(message) + +/** + * Committed rows folding the local turn around `index`, for ANY turn, not just + * the latest: rows sharing a tool call id with that turn, plus the assistant + * rows after its prompt's durable twin. Only the latest turn may fall back to + * position (everything after the last stored prompt). + */ +function committedFoldsOfLocalTurn(candidates: ChatMessage[], previous: ChatMessage[], index: number): ChatMessage[] { + const start = previous.findLastIndex((row, at) => at < index && isPrompt(row)) + const end = previous.findIndex((row, at) => at > index && isPrompt(row)) + + const turnToolIds = new Set( + previous + .slice(start + 1, end < 0 ? undefined : end) + .flatMap(toolCallIdsOf) + .filter(Boolean) + ) + + const owner = previous[start] + const ownerRowIds = owner ? transcriptRowIds(owner) : [] + + const anchor = owner + ? candidates.findIndex(row => row.id === owner.id || transcriptRowIds(row).some(id => ownerRowIds.includes(id))) + : -1 + + const from = anchor >= 0 ? anchor : end < 0 ? candidates.findLastIndex(isPrompt) : candidates.length + const until = candidates.findIndex((row, at) => at > from && isPrompt(row)) + const segment = new Set(candidates.slice(from + 1, until < 0 ? undefined : until)) + + return candidates.filter( + row => + row.role === 'assistant' && + !isLiveTailRow(row) && + (segment.has(row) || toolCallIdsOf(row).some(id => turnToolIds.has(id))) + ) +} + +const hasWholeLines = (haystack: string, needle: string) => `\n${haystack}\n`.includes(`\n${needle}\n`) + +/** + * A fold carries sealed text verbatim as a text part, or inside Thinking when + * the provider stored public commentary in `reasoning` (Codex Responses, #119716). + */ +const foldCarriesText = (fold: ChatMessage, text: string) => + fold.parts.some(part => + part.type === 'text' + ? textWithoutReferenceLines(part.text).trim() === text + : part.type === 'reasoning' && hasWholeLines(part.text, text) + ) + /** * History folds a tool-heavy turn into one bubble, while the live stream sealed - * each interim segment and the final answer as bubbles of their own. A settled - * live bubble is that same occurrence when one committed fold holds every tool + * each interim segment and the final answer as bubbles of their own. A sealed + * live bubble is that same occurrence when its turn's folds hold every tool * call it ran (durable identity) and its text, either verbatim (a sealed middle * segment, #119540) or as the final answer, equal or extended (#118670). This * holds with a partial or missing completion receipt, where full-bubble * equality sees neither. */ -function durableFoldCoversLiveResponse(messages: ChatMessage[], live: ChatMessage): boolean { +function durableFoldCoversLiveResponse(folds: ChatMessage[], live: ChatMessage): boolean { const liveToolIds = toolCallIdsOf(live) + const sealed = live.pending !== true || live.interim === true - if (liveToolIds.length && (live.pending === true || liveToolIds.some(id => !id))) { + if (!folds.length || (liveToolIds.length && (!sealed || liveToolIds.some(id => !id)))) { return false } @@ -622,29 +674,24 @@ function durableFoldCoversLiveResponse(messages: ChatMessage[], live: ChatMessag return false } - const lastUser = messages.findLastIndex(message => message.role === 'user' && !isGatewaySystemMarker(message)) + const foldedToolIds = new Set(folds.flatMap(toolCallIdsOf)) - return messages.slice(lastUser + 1).some(message => { - if (message.role !== 'assistant' || isLiveTailRow(message)) { - return false - } + if (!liveToolIds.every(id => foldedToolIds.has(id))) { + return false + } - const foldedToolIds = new Set(toolCallIdsOf(message)) + if (sealed && liveTexts.every(text => folds.some(fold => foldCarriesText(fold, text)))) { + return true + } - if (!liveToolIds.every(id => foldedToolIds.has(id))) { - return false - } + return ( + Boolean(answer) && + folds.some(fold => { + const folded = lastFoldedResponseText(fold) - const foldedTexts = new Set(textPartsOf(message)) - - if (live.pending !== true && liveTexts.every(text => foldedTexts.has(text))) { - return true - } - - const folded = lastFoldedResponseText(message) - - return Boolean(answer) && (folded === answer || isStrictAnswerTextExtension(folded, answer)) - }) + return folded === answer || isStrictAnswerTextExtension(folded, answer) + }) + ) } export function preserveLocalPendingTurnMessages( @@ -715,7 +762,6 @@ export function preserveLocalPendingTurnMessages( // Authoritative id → richer local pending row. Replacing (not appending) // avoids painting both the empty inflight shell and the full stream bubble. const replacements = new Map() - const lastPreviousUser = previousMessages.findLastIndex(row => row.role === 'user' && !isGatewaySystemMarker(row)) let crossedUserBoundary = false for (const [index, message] of previousMessages.entries()) { @@ -902,8 +948,7 @@ export function preserveLocalPendingTurnMessages( if ( isPendingAssistant && - previousMessages.indexOf(message) > lastPreviousUser && - durableFoldCoversLiveResponse(candidates, message) + durableFoldCoversLiveResponse(committedFoldsOfLocalTurn(candidates, previousMessages, index), message) ) { continue } diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 25ff3a8396..9d38106a30 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -31,6 +31,7 @@ import { $reactionsEnabled, setReactionsEnabled } from '@/store/reactions-enable import { $reasoningCollapsedByDefault, setReasoningCollapsedByDefault } from '@/store/reasoning-disclosure' import { $sessionListDensity, type SessionListDensity, setSessionListDensity } from '@/store/session-list-density' import { $tabStripDefault, setTabStripDefault, type TabStripDefault } from '@/store/tabstrip-prefs' +import { $textDirection, setTextDirection, TEXT_DIRECTIONS, type TextDirection } from '@/store/text-direction' import { $hideThreadTimeline, setHideThreadTimeline } from '@/store/thread-timeline' import { $spentTipCount, $tipsEnabled, resetTips, setTipsEnabled } from '@/store/tips' import { @@ -433,6 +434,7 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { const translucency = useStore($translucency) const glassMode = translucency.mode === 'glass' && GLASS_SUPPORTED const userBubbleTransparency = useStore($userBubbleTransparency) + const textDirection = useStore($textDirection) const reactionsEnabled = useStore($reactionsEnabled) const tipsEnabled = useStore($tipsEnabled) const toursEnabled = useStore($toursEnabled) @@ -538,6 +540,11 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { { id: 'left', label: a.appActionsLeft } ] as const satisfies readonly { id: TitlebarAppActionsSide; label: string }[] + const textDirectionOptions = TEXT_DIRECTIONS.map(id => ({ + id, + label: a.textDirection[id] + })) satisfies readonly { id: TextDirection; label: string }[] + const embedOptions = [ { id: 'ask', label: a.embedsAsk }, { id: 'always', label: a.embedsAlways }, @@ -880,6 +887,24 @@ export function AppearanceSettings({ subpage }: AppearanceSettingsProps = {}) { /> )} + {show('chat-display') && ( + { + triggerHaptic('selection') + setTextDirection(id) + }} + options={textDirectionOptions} + value={textDirection} + /> + } + description={a.textDirectionDesc} + id={appearanceSettingElementId(APPEARANCE_SETTING_IDS.textDirection)} + title={a.textDirectionTitle} + /> + )} + {show('window-layout') && ( > = { 'appearance.language': 'general', 'appearance.minimize-to-tray': 'window-layout', 'appearance.pet': 'pet', + 'appearance.text-direction': 'chat-display', 'appearance.theme': 'theme', 'appearance.tool-view': 'chat-display', 'appearance.interface-mode': 'window-layout', diff --git a/apps/desktop/src/app/settings/settings-search.ts b/apps/desktop/src/app/settings/settings-search.ts index 135b78e150..3e768e1b20 100644 --- a/apps/desktop/src/app/settings/settings-search.ts +++ b/apps/desktop/src/app/settings/settings-search.ts @@ -20,6 +20,7 @@ export const APPEARANCE_SETTING_IDS = { introSplash: 'appearance.intro-splash', language: 'appearance.language', minimizeToTray: 'appearance.minimize-to-tray', + textDirection: 'appearance.text-direction', theme: 'appearance.theme', toolView: 'appearance.tool-view', interfaceMode: 'appearance.interface-mode', diff --git a/apps/desktop/src/app/settings/use-settings-search.ts b/apps/desktop/src/app/settings/use-settings-search.ts index 36ed0ee91a..fbf0d92836 100644 --- a/apps/desktop/src/app/settings/use-settings-search.ts +++ b/apps/desktop/src/app/settings/use-settings-search.ts @@ -188,6 +188,15 @@ export function useSettingsSearchCatalog(enabled: boolean) { label: appearance.userBubbleTitle, target: { setting: APPEARANCE_SETTING_IDS.userBubble, view: 'config:appearance' } }, + { + context: appearanceContext, + description: appearance.textDirectionDesc, + icon: Palette, + id: `setting:${APPEARANCE_SETTING_IDS.textDirection}`, + keywords: ['rtl', 'ltr', 'right to left', 'left to right', 'bidi', 'arabic', 'hebrew', 'persian', 'align'], + label: appearance.textDirectionTitle, + target: { setting: APPEARANCE_SETTING_IDS.textDirection, view: 'config:appearance' } + }, { context: appearanceContext, description: appearance.backdropDesc, diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 237750d650..73d8eb47db 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -40,6 +40,7 @@ import { previewTargetFromMarkdownHref } from '@/lib/preview-targets' import { sessionRefFromMarkdownHref } from '@/lib/session-refs' import { isDirectiveInProgress } from '@/lib/transcript-directives' import { cn } from '@/lib/utils' +import { useForcedTextDirection } from '@/store/text-direction' import { ArtifactCard } from './artifact-card' import { SessionRefLink } from './directive-text' @@ -468,6 +469,10 @@ interface MarkdownTextSurfaceProps { * routed @mentions) without owning the Markdown pipeline. Nested inline * markup and code are left as rendered. */ decorateText?: (children: ReactNode) => ReactNode + /** The reader's explicit Text direction (Appearance). Stamped on the root + * and on list/quote boxes in place of their `dir="auto"`, so every prose + * block follows it; undefined is Auto and leaves the DOM attribute-free. */ + textDirection?: 'ltr' | 'rtl' } // Headings shrink to chat scale rather than the prose default (h1≈xl). Kept @@ -587,9 +592,19 @@ function MarkdownTextSurface({ defer, disableArtifacts, previewOnly, - scratchpad + scratchpad, + textDirection }: MarkdownTextSurfaceProps) { const { status, text } = useMessagePartText() + // List/quote boxes resolve from content under Auto (see the ul/ol/blockquote + // notes below); an explicit choice replaces that vote rather than nesting it. + const boxDir = textDirection ?? 'auto' + + const surfaceContainerProps = useMemo( + () => (textDirection ? { ...containerProps, dir: textDirection } : containerProps), + [containerProps, textDirection] + ) + const isStreaming = status.type === 'running' // Keep code parsing enabled while streaming so incomplete fenced blocks still @@ -654,7 +669,7 @@ function MarkdownTextSurface({ return (
{children} @@ -662,10 +677,10 @@ function MarkdownTextSurface({ ) }, ul: ({ className, ...props }: ComponentProps<'ul'>) => ( -
    +
      ), ol: ({ className, ...props }: ComponentProps<'ol'>) => ( -
        +
          ), li: ({ children, className, ...props }: ComponentProps<'li'>) => (
        1. @@ -708,7 +723,7 @@ function MarkdownTextSurface({ ) } }) as StreamdownTextComponents, - [decorateText, disableArtifacts, isStreaming, previewOnly, scratchpad] + [boxDir, decorateText, disableArtifacts, isStreaming, previewOnly, scratchpad] ) if (text.length > MAX_MARKDOWN_CHARS) { @@ -741,7 +756,7 @@ function MarkdownTextSurface({ { - return + const textDirection = useForcedTextDirection() + + return } export const MarkdownText = memo(MarkdownTextImpl) diff --git a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx index e0a0afa797..69762b37d9 100644 --- a/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/message-parts.tsx @@ -31,6 +31,7 @@ import { isTodoToolName } from '@/lib/todos' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { $reasoningCollapsedByDefault, $showReasoning } from '@/store/reasoning-disclosure' +import { useForcedTextDirection } from '@/store/text-direction' type TimelineToolCallProps = ToolCallMessagePartProps & { completedAt?: number; timestamp?: number } @@ -390,6 +391,7 @@ const ReasoningTextPart: ReasoningMessagePartComponent = () => { // rendered without a ReasoningGroup wrapper (assistant-ui drops the group // when a ChainOfThought component is registered). const showReasoning = useStore($showReasoning) + const textDirection = useForcedTextDirection() if (!showReasoning) { return null @@ -402,6 +404,7 @@ const ReasoningTextPart: ReasoningMessagePartComponent = () => { isRunning={status.type === 'running' || messageRunning} scratchpad text={separateGluedReasoningBlocks(text.trimStart())} + textDirection={textDirection} /> ) } diff --git a/apps/desktop/src/components/assistant-ui/thread/text-direction.test.tsx b/apps/desktop/src/components/assistant-ui/thread/text-direction.test.tsx new file mode 100644 index 0000000000..20b3ee64a9 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/text-direction.test.tsx @@ -0,0 +1,209 @@ +// Appearance → Text direction. Auto is the shipped per-block first-strong +// behavior and must leave the DOM exactly as it was; RTL/LTR stamp one `dir` +// on each chat prose surface and both composers, replacing the list/quote +// boxes' `dir="auto"` vote, while inline code keeps its own `dir="ltr"`. +// jsdom neither resolves `dir` nor applies styles.css, so these pin the +// attribute contract; the stylesheet half is checked in real Chromium. +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import type { ThreadMessageLike } from '@assistant-ui/react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ChatBar } from '@/app/chat/composer' +import { RICH_INPUT_SLOT } from '@/app/chat/composer/rich-editor' +import type { ChatBarState } from '@/app/chat/composer/types' +import { I18nProvider } from '@/i18n' +import { $textDirection, setTextDirection, type TextDirection } from '@/store/text-direction' + +import { stubThreadEnvironment, stubThreadViewportSize } from '../test-utils' + +import { Thread } from '.' + +const createdAt = new Date('2026-06-01T00:00:00.000Z') +stubThreadEnvironment() +stubThreadViewportSize() + +afterEach(() => { + cleanup() + setTextDirection('auto') +}) + +// #100280's Latin-majority Arabic quote: first-strong resolves it LTR, which +// is exactly the case a reader needs to override. +const QUOTE = + 'Ergander و Aspalta وردت عليه. الضبط Care إلى Ozul + Daphne هو Seyir وفريق — Seyir + Ozul + Daphne + Saverio هو Daphne dragonheir.info فريق في' + +const ASSISTANT_TEXT = [ + QUOTE, + '', + '1. `npm install` ثم Seyir', + '2. Daphne مع Ozul', + '', + '> Ultimate - Desperate Beast يضرب مرتين' +].join('\n') + +// #83868's English-leading Arabic line, then an Arabic-leading one. +const USER_TEXT = 'Hello مرحبا كيفك\nمرحبا Hello' + +function userMessage(): ThreadMessage { + return { + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: USER_TEXT }], + attachments: [], + createdAt, + metadata: { custom: {} } + } as ThreadMessage +} + +function assistantMessage(): ThreadMessage { + return { + id: 'assistant-1', + role: 'assistant', + content: [{ type: 'text', text: ASSISTANT_TEXT }], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} } + } as ThreadMessage +} + +function ThreadHarness() { + const runtime = useExternalStoreRuntime({ + messages: [userMessage(), assistantMessage()], + isRunning: false, + onNew: async () => {} + }) + + return ( + + + + ) +} + +const chatBarState: ChatBarState = { + model: { canSwitch: false, model: '', provider: '' }, + tools: { enabled: false, label: '' }, + voice: { enabled: false, active: false } +} + +function ComposerHarness() { + const runtime = useExternalStoreRuntime({ + convertMessage: (message: ThreadMessageLike) => message, + isRunning: false, + messages: [] as ThreadMessageLike[], + onNew: async () => {} + }) + + return ( + + + + true)} + state={chatBarState} + /> + + + + ) +} + +async function renderThread() { + const view = render() + const quote = await screen.findByText(/Aspalta/) + const root = quote.closest('.aui-md') + const list = (await screen.findByText(/Daphne مع Ozul/)).closest('ol') + const blockquote = (await screen.findByText(/Desperate Beast/)).closest('blockquote') + const code = await screen.findByText('npm install') + const userText = view.container.querySelector('[data-slot="aui_user-inline-text"]') + + expect(root).not.toBeNull() + expect(list).not.toBeNull() + expect(blockquote).not.toBeNull() + expect(userText?.textContent).toBe(USER_TEXT) + + return { blockquote: blockquote!, code, list: list!, quote, root: root!, userText: userText!, view } +} + +function composerEditor(container: HTMLElement) { + const editor = container.querySelector(`[data-slot="${RICH_INPUT_SLOT}"]`) + + expect(editor).not.toBeNull() + + return editor! +} + +// Every `dir` in a rendered subtree, so "Auto adds nothing" is one comparison. +function dirAttributes(container: HTMLElement) { + return [...container.querySelectorAll('[dir]')].map(el => `${el.tagName.toLowerCase()}=${el.getAttribute('dir')}`) +} + +describe('Text direction: Auto', () => { + it('keeps the native first-strong DOM: only list/quote boxes vote, code opts out', async () => { + const { blockquote, code, list, quote, root, userText, view } = await renderThread() + + expect($textDirection.get()).toBe('auto') + expect(root.hasAttribute('dir')).toBe(false) + expect(quote.hasAttribute('dir')).toBe(false) + expect(userText.hasAttribute('dir')).toBe(false) + expect(list.getAttribute('dir')).toBe('auto') + expect(blockquote.getAttribute('dir')).toBe('auto') + expect(code.getAttribute('dir')).toBe('ltr') + expect(new Set(dirAttributes(view.container))).toEqual(new Set(['ol=auto', 'blockquote=auto', 'code=ltr'])) + }) + + it('leaves the composer attribute-free and stores nothing', () => { + const { container } = render() + + expect(composerEditor(container).hasAttribute('dir')).toBe(false) + expect(window.localStorage.getItem('hermes.desktop.textDirection')).toBeNull() + }) + + it('returns to the identical DOM after a forced direction is cleared', async () => { + const { view } = await renderThread() + const autoHtml = view.container.innerHTML + + act(() => setTextDirection('rtl')) + expect(view.container.innerHTML).not.toBe(autoHtml) + + act(() => setTextDirection('auto')) + expect(view.container.innerHTML).toBe(autoHtml) + expect(window.localStorage.getItem('hermes.desktop.textDirection')).toBeNull() + }) +}) + +describe.each>(['rtl', 'ltr'])('Text direction: %s', direction => { + it('forces the prose root, list/quote boxes and user lines; inline code stays LTR', async () => { + setTextDirection(direction) + + const { blockquote, code, list, quote, root, userText } = await renderThread() + + expect(root.getAttribute('dir')).toBe(direction) + expect(list.getAttribute('dir')).toBe(direction) + expect(blockquote.getAttribute('dir')).toBe(direction) + expect(userText.getAttribute('dir')).toBe(direction) + // Paragraphs inherit from the root rather than carrying their own vote. + expect(quote.hasAttribute('dir')).toBe(false) + expect(code.getAttribute('dir')).toBe('ltr') + // Presentation only: the text itself carries no injected bidi controls. + expect(quote.textContent).toBe(QUOTE) + expect(userText.textContent).not.toMatch(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/) + }) + + it('forces the composer live and persists the choice', () => { + const { container } = render() + + act(() => setTextDirection(direction)) + expect(composerEditor(container).getAttribute('dir')).toBe(direction) + expect(window.localStorage.getItem('hermes.desktop.textDirection')).toBe(direction) + + act(() => setTextDirection('auto')) + expect(composerEditor(container).hasAttribute('dir')).toBe(false) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 6cd1b9149a..185e70abce 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -79,6 +79,7 @@ import type { ComposerAttachment } from '@/store/composer' import { notifyError } from '@/store/notifications' import { $terminalBackend } from '@/store/session' import { isSessionRemote } from '@/store/session-states' +import { useForcedTextDirection } from '@/store/text-direction' import { notifyThreadEditClose } from '@/store/thread-scroll' interface UserEditComposerProps { @@ -92,6 +93,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess const copy = t.assistant.thread const aui = useAui() const draft = useAuiState(s => s.composer.text) + const textDirection = useForcedTextDirection() const rootRef = useRef(null) const editorRef = useRef(null) // Capture the original draft immediately before the first edit. The runtime @@ -869,6 +871,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess contentEditable data-placeholder={copy.editMessage} data-slot={RICH_INPUT_SLOT} + dir={textDirection} onBeforeInput={handleBeforeInput} onBlur={() => scheduleTimeout(closeTrigger, 80)} onCompositionEnd={event => { diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message-text.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message-text.tsx index 15f46495f1..134f91d37e 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message-text.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message-text.tsx @@ -4,6 +4,7 @@ import { Fragment, useMemo } from 'react' import { DirectiveContent } from '@/components/assistant-ui/directive-text' import { referenceRe } from '@/components/assistant-ui/reference-kinds' import { cn } from '@/lib/utils' +import { useForcedTextDirection } from '@/store/text-direction' // User messages should render the bare-minimum of markdown: backtick `code` // spans and ``` fenced blocks. We deliberately don't pull in the full @@ -148,11 +149,13 @@ export const UserMessageText: FC = ({ className, text }) = const InlineSegmentView: FC<{ text: string }> = ({ text }) => { const nodes = useMemo(() => splitInlineCode(text), [text]) + const textDirection = useForcedTextDirection() return ( // styles.css bidi hook (#44150); whitespace-pre-line makes each line its own - // UAX#9 paragraph so it resolves direction independently. - + // UAX#9 paragraph so it resolves direction independently — unless the + // reader picked an explicit Text direction, which every line then follows. + {nodes.map((node, nodeIndex) => node.kind === 'inline-code' ? ( ) - for (const locale of ['zh', 'zh-hant', 'ja'] as const) { + for (const locale of ['zh', 'zh-hant', 'ja', 'fr', 'de', 'es'] as const) { await act(() => i18n.setLocale(locale)) const indices = new Map() @@ -55,7 +57,10 @@ it('translates every shipped stock body at the same personality and rotation pos const body = container.querySelector('[data-slot="aui_intro"] > div > p:last-child')!.textContent expect(body).toBeTruthy() expect(body).not.toBe(entry.body) - expect(body).toMatch(/[\u3040-\u30ff\u3400-\u9fff]/) + + if (CJK_LOCALES.has(locale)) { + expect(body).toMatch(/[\u3040-\u30ff\u3400-\u9fff]/) + } } } @@ -76,4 +81,11 @@ it('localizes the custom-personality fallback without translating its user-suppl expect(container.querySelector('[data-slot="aui_intro"] > div > p:last-child')!.textContent).toContain( 'My Custom Voice' ) + + for (const locale of ['fr', 'de', 'es'] as const) { + await act(() => i18n.setLocale(locale)) + const body = container.querySelector('[data-slot="aui_intro"] > div > p:last-child')!.textContent + expect(body).not.toBe(english) + expect(body).toContain('My Custom Voice') + } }) diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 436c65436d..e350c33f97 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -650,6 +650,10 @@ export const ar = defineLocale({ backdropDesc: 'اختيار مقدار مزج خلفية سطح المكتب مع سطح Hermes.', userBubbleTitle: 'فقاعة الرسالة', userBubbleDesc: 'مدى شفافية رسائلك. معتمة عند 0؛ يبقى الإطار فقط عند 100.', + textDirectionTitle: 'اتجاه النص', + textDirectionDesc: + 'كيف تحدد رسائل المحادثة وحقل الكتابة اتجاهها. «تلقائي» يتبع أول حرف في كل فقرة؛ اختر اتجاهًا عندما يظهر النص المختلط بترتيب خاطئ. تبقى الشيفرة دائمًا من اليسار إلى اليمين.', + textDirection: { auto: 'تلقائي', rtl: 'من اليمين إلى اليسار', ltr: 'من اليسار إلى اليمين' }, introSplashTitle: 'شاشة المقدمة', introSplashDesc: 'الشعار النصي والعبارة التمهيدية في محادثة فارغة.', reactionsTitle: 'تفاعلات الرسائل', diff --git a/apps/desktop/src/i18n/catalog-completeness.test.ts b/apps/desktop/src/i18n/catalog-completeness.test.ts new file mode 100644 index 0000000000..65b48889ff --- /dev/null +++ b/apps/desktop/src/i18n/catalog-completeness.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' + +import { TRANSLATIONS } from './catalog' +import type { Locale } from './types' + +// Locales that shipped fully translated. They are `defineLocale` overlays like +// ja/ru, so an English key added later falls back to English instead of +// failing typecheck; these checks keep the translated copy structurally sound. +const COMPLETE_LOCALES = ['fr', 'de', 'es'] as const satisfies readonly Locale[] + +type Leaf = { path: string; value: unknown } + +function leaves(value: unknown, path = ''): Leaf[] { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return Object.entries(value).flatMap(([key, child]) => leaves(child, path ? `${path}.${key}` : key)) + } + + return [{ path, value }] +} + +// Arguments that are identifiers rather than display text; translations +// branch on them (`capability === 'search' ? … : …`) instead of echoing them. +const IDENTIFIER_ARGS: Record = { + 'settings.toolsets.webCapabilitySelectedMessage': [1] +} + +const kindOf = (value: unknown) => (Array.isArray(value) ? 'array' : typeof value) + +// `intro` is display-only: English lives in intro-copy.jsonl, so its catalog +// entry is an empty shell. intro.test.tsx covers the translated rotation. +const catalogLeaves = (locale: Locale) => + new Map( + leaves(TRANSLATIONS[locale]) + .filter(leaf => !leaf.path.startsWith('intro.')) + .map(leaf => [leaf.path, leaf.value]) + ) + +const english = catalogLeaves('en') + +describe.each(COMPLETE_LOCALES)('%s desktop catalog', locale => { + const catalog = catalogLeaves(locale) + + it('covers exactly the English key set with matching value kinds', () => { + expect([...catalog.keys()].sort()).toEqual([...english.keys()].sort()) + + for (const [path, value] of english) { + expect({ path, kind: kindOf(catalog.get(path)) }).toEqual({ path, kind: kindOf(value) }) + } + }) + + it('keeps every interpolated argument that English renders', () => { + for (const [path, value] of english) { + if (typeof value !== 'function') { + continue + } + + const translated = catalog.get(path) as (...args: unknown[]) => unknown + const probes = Array.from({ length: value.length }, (_, index) => `⟦${index}⟧`) + let englishOut: string + + try { + englishOut = JSON.stringify(value(...probes)) + } catch { + continue // needs structured arguments; the type checker covers the signature + } + + expect(translated.length, path).toBe(value.length) + const translatedOut = JSON.stringify(translated(...probes)) + + const identifiers = new Set(IDENTIFIER_ARGS[path]?.map(index => probes[index])) + + for (const probe of probes.filter(probe => englishOut.includes(probe) && !identifiers.has(probe))) { + expect(translatedOut, `${path} drops ${probe}`).toContain(probe) + } + } + }) + + it('keeps list-shaped copy the same length as English', () => { + for (const [path, value] of english) { + if (Array.isArray(value)) { + expect((catalog.get(path) as unknown[]).length, path).toBe(value.length) + } + } + }) +}) diff --git a/apps/desktop/src/i18n/catalog.ts b/apps/desktop/src/i18n/catalog.ts index 220a88b7f2..02c45d0a1c 100644 --- a/apps/desktop/src/i18n/catalog.ts +++ b/apps/desktop/src/i18n/catalog.ts @@ -1,5 +1,8 @@ import { ar } from './ar' +import { de } from './de' import { en } from './en' +import { es } from './es' +import { fr } from './fr' import { ja } from './ja' import { ru } from './ru' import type { Locale, Translations } from './types' @@ -12,5 +15,8 @@ export const TRANSLATIONS: Record = { 'zh-hant': zhHant, ja, ar, - ru + ru, + fr, + de, + es } diff --git a/apps/desktop/src/i18n/context.test.tsx b/apps/desktop/src/i18n/context.test.tsx index 2b24ca31e5..4790107cfe 100644 --- a/apps/desktop/src/i18n/context.test.tsx +++ b/apps/desktop/src/i18n/context.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { HermesConfigRecord } from '@/hermes' +import { TRANSLATIONS } from './catalog' import { type I18nConfigClient, I18nProvider, useI18n } from './context' import type { Locale } from './types' @@ -76,6 +77,31 @@ describe('I18nProvider', () => { expect(configClient.saveConfig).not.toHaveBeenCalled() }) + it.each([ + ['fr', 'fr'], + ['de-DE', 'de'], + ['es', 'es'] + ] as const)('loads display.language=%s and renders the %s catalog', async (configured, locale) => { + const configClient: I18nConfigClient = { + getConfig: vi.fn().mockResolvedValue({ display: { language: configured } }), + saveConfig: vi.fn() + } + + render( + + + + ) + + await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false')) + + expect(screen.getByTestId('locale').textContent).toBe(locale) + expect(screen.getByTestId('label').textContent).toBe(TRANSLATIONS[locale].language.label) + expect(screen.getByTestId('label').textContent).not.toBe(TRANSLATIONS.en.language.label) + expect(screen.getByTestId('save').textContent).not.toBe(TRANSLATIONS.en.common.save) + expect(configClient.saveConfig).not.toHaveBeenCalled() + }) + it('keeps English usable when config loading fails', async () => { const configClient: I18nConfigClient = { getConfig: vi.fn().mockRejectedValue(new Error('config unavailable')), @@ -97,7 +123,7 @@ describe('I18nProvider', () => { it('does not overwrite unsupported configured languages', async () => { const configClient: I18nConfigClient = { - getConfig: vi.fn().mockResolvedValue({ display: { language: 'de' } }), + getConfig: vi.fn().mockResolvedValue({ display: { language: 'it' } }), saveConfig: vi.fn() } diff --git a/apps/desktop/src/i18n/de.ts b/apps/desktop/src/i18n/de.ts new file mode 100644 index 0000000000..280363f1d3 --- /dev/null +++ b/apps/desktop/src/i18n/de.ts @@ -0,0 +1,5981 @@ +import { defineFieldCopy } from '@/app/settings/field-copy' + +import { defineLocale } from './define-locale' +import { introDe } from './intro-de' + +export const de = defineLocale({ + intro: introDe, + connectors: { + title: 'Verbinden Sie Ihre Apps', + connect: 'Verbinden', + skip: 'Nicht jetzt', + cancel: 'Warten beenden', + retry: 'Erneut versuchen', + grant: 'Neu verbinden', + connected: 'Verbunden', + checking: 'Ihre Apps werden geprüft…', + notConnected: 'Verbindung fehlgeschlagen', + skipped: 'Übersprungen', + disabled: 'Nicht verfügbar', + failed: 'Verbindung fehlgeschlagen', + needsAuth: 'Zugriff abgelaufen', + opening: 'Anmeldung wird geöffnet…', + waiting: 'Schließen Sie die Verbindung im Browser ab…', + timeout: 'Freigabe steht noch aus.', + refresh: 'Status aktualisieren', + connectError: 'Freigabe konnte nicht gestartet werden. Versuchen Sie es erneut.', + connectErrorFor: app => `Die Freigabe für ${app} konnte nicht gestartet werden.`, + unavailable: 'Verbindungen sind für diese Session nicht verfügbar.', + ownerMissing: 'Öffnen Sie diese Konversation erneut, um ihre Verbindungen zu verwalten.', + search: 'App finden', + empty: 'Keine passende App', + disclaimer: 'Das Verbinden ist freiwillig. Geben Sie nur den Apps Zugriff, die Hermes verwenden soll.', + execution: 'Verbindungs-Tools', + setup: server => `${server} einrichten`, + openInBrowser: 'Im Browser öffnen', + setupCancel: 'Abbrechen', + authorizedToolsUnavailable: 'Autorisiert. Tools nicht verfügbar.', + required: 'Erforderlich' + }, + connectorsPage: { + title: 'Konnektoren', + searchPlaceholder: (count: number) => `${count} Apps durchsuchen`, + filterCategory: 'Kategorie', + categoryAll: 'Alle Kategorien', + uncategorised: 'Ohne Kategorie', + residencyLocal: 'Auf diesem Gerät', + segment: { + all: 'Alle', + available: 'Verfügbar', + connected: 'Verbunden', + off: 'Ausgeschaltet' + }, + group: { + connected: 'Verbunden', + connectedNote: 'Fehlerhafte Verbindungen zuerst.', + available: 'Verfügbar', + off: 'Ausgeschaltet', + offNote: 'Anmeldungen bleiben erhalten.' + }, + card: { + kindManaged: 'Verwaltet', + kindCatalog: 'MCP · Katalog', + kindCustom: 'MCP · Benutzerdefiniert', + kindPlugin: (plugin: string) => `MCP · Plugin ${plugin}`, + inCatalog: 'Im Hermes-Katalog', + hostedTwin: 'Verwaltete Version verfügbar', + alsoLocal: 'Läuft auch auf diesem Gerät', + open: (name: string) => `${name} öffnen`, + turnServerOn: (name: string) => `${name} einschalten`, + turnServerOff: (name: string) => `${name} ausschalten`, + state: { + accessExpired: 'Zugriff abgelaufen', + available: 'Verfügbar', + connected: 'Verbunden', + connecting: 'Verbindet', + connectionUnknown: 'Status unbekannt', + couldNotConnect: 'Verbindung fehlgeschlagen', + offByYourOrganisation: 'Von Ihrer Organisation deaktiviert', + offForYou: 'Für Sie deaktiviert', + serverConnecting: 'Verbindet…', + serverError: 'Fehler', + serverNeedsAuth: 'Authentifizierung erforderlich', + serverOff: 'Aus', + serverOn: 'An', + serverOnUnused: 'An, ungenutzt' + }, + fact: { + tools: (count: number) => `${count} Tool${count === 1 ? '' : 's'}`, + toolsOff: (count: number) => `${count} Tool${count === 1 ? '' : 's'} aus`, + toolsOn: (count: number) => `${count} Tool${count === 1 ? '' : 's'} an`, + toolsSomeOn: (total: number, on: number) => `${total} Tools, ${on} an` + }, + verb: { + authenticate: 'Authentifizieren', + connect: 'Verbinden', + install: 'Installieren', + openLogs: 'Logs öffnen', + reconnect: 'Neu verbinden', + stopWaiting: 'Warten beenden', + tryAgain: 'Erneut versuchen', + turnBackOn: 'Wieder einschalten' + }, + reason: { + finishSignIn: 'Schließen Sie die Anmeldung in Ihrem Browser ab.', + reconnect: 'Verbinden Sie sich neu, damit diese App weiter funktioniert.', + serverError: 'Der Server hat die Verbindung abgelehnt.', + serverNeedsAuth: 'Melden Sie sich an, damit dieser Server antworten kann.' + } + }, + page: { + loading: 'Katalog und Server auf diesem Computer werden gelesen', + emptyTitle: 'Noch keine Apps. Fügen Sie einen Server auf diesem Computer hinzu, um loszulegen.', + noMatchTitle: 'Keine passenden Apps', + noMatchBody: 'Keine Treffer. Verweisen Sie Hermes auf Ihren eigenen MCP-Server, um ihn hinzuzufügen.', + clearSearch: 'Suche löschen', + hostedFailedTitle: 'Die gehosteten Apps sind nicht erreichbar.', + hostedFailedBody: + 'Die Server auf diesem Computer sind nicht betroffen und laufen weiter. Nichts wurde ausgeschaltet.', + retry: 'Erneut versuchen', + matchesElsewhere: (count: number) => `${count} weitere${count === 1 ? 'r' : ''} Treffer in anderen Gruppen.`, + showAllMatches: 'Alle Treffer anzeigen', + segmentNoMatch: (segment: string) => `Kein Treffer in ${segment}, daher werden alle Treffer angezeigt.`, + freeTierNote: 'Verbindungen bleiben auf diesem Computer, bis Sie sich anmelden.', + signInLine: 'Melden Sie sich bei Nous an, um verwaltete Apps zu nutzen.', + signIn: 'Anmelden', + managedUnavailable: 'Verwaltete Apps sind für dieses Konto noch nicht verfügbar.', + writeFailed: 'Diese Änderung wurde nicht gespeichert.', + refreshFailed: 'Die Tool-Liste wurde nicht aktualisiert.', + disconnectNoAccount: + 'Hermes hat hier kein Konto zum Trennen. Aktualisieren Sie die Seite und versuchen Sie es erneut.', + disconnectRefused: + 'Nous kann diese Anmeldung gerade nicht entfernen. Schalten Sie die App stattdessen mit dem Schalter aus oder versuchen Sie es später erneut.' + }, + add: { + action: 'Eigenen hinzufügen', + title: 'Mit einem benutzerdefinierten MCP verbinden', + hint: 'ein neuer Eintrag in mcp.json auf diesem Gerät', + pasteLabel: 'Befehl oder Snippet einfügen', + pastePlaceholder: 'npx -y @modelcontextprotocol/server-filesystem /pfad/zum/ordner', + pasteNoMatch: 'Hier ist kein Server erkennbar. Füllen Sie stattdessen die Felder unten aus.', + name: 'Name', + nameTaken: 'Dieser Name wird bereits verwendet.', + type: 'Typ', + typeStdio: 'STDIO', + typeHttp: 'Streamable HTTP', + command: 'Startbefehl', + args: 'Argumente', + addArg: '+ Argument hinzufügen', + envVars: 'Umgebungsvariablen', + addEnvVar: '+ Umgebungsvariable hinzufügen', + passthrough: 'Durchreichen von Umgebungsvariablen', + addPassthrough: '+ Variable hinzufügen', + cwd: 'Arbeitsverzeichnis', + url: 'URL', + headers: 'Header', + addHeader: '+ Header hinzufügen', + auth: 'Authentifizierung', + authNone: 'Keine', + authOauth: 'OAuth', + authBearer: 'Bearer-Token', + keyPlaceholder: 'SCHLÜSSEL', + valuePlaceholder: 'Wert', + removeRow: 'Diese Zeile entfernen', + editJson: 'mcp.json bearbeiten', + saveFailed: 'Dieser Server wurde nicht gespeichert.' + }, + dialog: { + disconnect: 'Trennen', + disconnectTitle: (name: string) => `${name} trennen?`, + disconnectBody: 'Hermes handelt nicht mehr über dieses Konto. Sie können sich jederzeit wieder verbinden.', + menuRefreshTools: 'Tools aktualisieren', + moreActions: 'Weitere Aktionen', + removeServerTitle: (name: string) => `${name} entfernen?`, + removeServerBody: 'Der Eintrag wird aus mcp.json auf diesem Computer entfernt. Sonst wird nichts gelöscht.', + appSwitch: (name: string) => `Hermes darf ${name} verwenden`, + waysTitle: (name: string) => `Wo ${name} läuft`, + wayNotConnected: (name: string) => `Noch nicht verbunden. Melden Sie sich im Browser bei ${name} an.`, + wayHosted: 'Verwaltet', + bothOn: (name: string) => `Beide sind an, daher sieht Hermes jedes ${name}-Tool doppelt.`, + turnOffLocal: 'Lokalen Server ausschalten', + providedByPlugin: (plugin: string) => `Bereitgestellt vom Plugin ${plugin}`, + openPlugins: 'Tab „Plugins“ öffnen', + nousLine: 'Nous-Apps folgen Ihrem Konto, nicht dem Profil.', + rulesReadOnly: 'Die Regeln können gerade nicht geändert werden.', + rulesAppOff: (name: string) => `Schalten Sie ${name} ein, um die Tools zu ändern.`, + rulesSignIn: 'Melden Sie sich an, um festzulegen, was Hermes hier darf.', + orgNote: (count: number) => `Ihre Organisation hat ${count} Tool${count === 1 ? '' : 's'} deaktiviert.`, + orgLink: 'Konnektor-Verwaltung öffnen', + connectEnded: 'Die Anmeldung wurde nicht abgeschlossen.', + connectOpenAgain: 'Link erneut öffnen', + tokensPerCall: 'Tokens pro Aufruf', + usesPerMonth: 'Nutzungen in 30 Tagen', + advanced: 'Erweitert', + advancedHint: 'der mcp.json-Eintrag und die Logs' + }, + tools: { + title: 'Tools', + notInstalledBody: 'Installieren Sie ihn auf diesem Gerät, um seine Tools zu sehen.', + summaryTitle: (name: string) => `Was Hermes mit ${name} tun darf`, + summaryPreviewTitle: (name: string) => `Was Hermes mit ${name} tun könnte, sobald Sie verbunden sind`, + summaryCount: (count: number) => `${count} Tool${count === 1 ? '' : 's'}`, + summaryAllTools: 'Alle Tools', + summaryOther: 'Sonstige', + allToolsSwitch: 'Alle Tools ein- oder ausschalten', + summaryAllOn: 'alle an', + summarySomeOn: (on: number, total: number) => `${on} von ${total} an`, + summaryOff: 'aus', + showAllTools: (count: number) => `Alle ${count} Tool${count === 1 ? '' : 's'} anzeigen`, + showSummary: 'Zusammenfassung anzeigen', + facetSwitch: (facet: string) => `${facet}-Tools ein- oder ausschalten`, + moreHints: (count: number) => `+${count}`, + staleSignIn: 'Melden Sie sich an, um die aktuelle Tool-Liste zu laden.', + searchCountPlaceholder: (count: number) => `${count} Tools durchsuchen`, + toolList: (name: string) => `${name}-Tools`, + categorySelect: (count: number) => `${count} Kategorie${count === 1 ? '' : 'n'}`, + showDeprecated: (count: number) => `${count} veraltete anzeigen`, + hideDeprecated: (count: number) => `${count} veraltete ausblenden`, + quickReadOnly: 'Nur lesen', + quickNoDestructive: 'Destruktive ausschalten', + quickEverythingOn: 'Alles an', + lockedHint: 'von Ihrer Organisation deaktiviert', + turnToolOn: (tool: string) => `${tool} einschalten`, + turnToolOff: (tool: string) => `${tool} ausschalten`, + showDetails: (tool: string) => `Anzeigen, was ${tool} tut`, + hideDetails: (tool: string) => `Ausblenden, was ${tool} tut`, + noMatch: 'Kein Tool passt zu diesen Filtern.', + loading: 'Tool-Liste wird gelesen', + unavailableLine: 'Tool-Liste nicht verfügbar.', + needsAuthTitle: (name: string) => `Melden Sie sich bei ${name} an, um die Tools zu laden.`, + needsAuthBody: 'Die Anmeldung bleibt auf diesem Computer. Nichts verlässt ihn.', + retry: 'Erneut versuchen', + goneTitle: (name: string) => `${name} ist nicht mehr im Katalog.`, + goneBody: + 'Hermes kann ihn nicht mehr aufrufen. Die Zeile bleibt, bis Sie sie entfernen, damit nichts verschwindet.', + remove: 'Entfernen', + offTitle: (name: string) => `${name} ist aus.`, + offBody: 'Schalten Sie ihn mit dem Schalter oben ein, um seine Tools zu laden.', + signedOutTitle: 'Melden Sie sich bei Nous an, um die Tool-Liste zu laden.', + signedOutBody: 'Ihre Server auf diesem Computer sind nicht betroffen.', + conflictTitle: 'Jemand hat diese Regel geändert, während Sie sie bearbeitet haben.', + conflictBody: (theyOff: number, theyOn: number) => { + const they = [ + theyOff > 0 ? `${theyOff} Tool${theyOff === 1 ? '' : 's'} ausgeschaltet, die bei Ihnen an sind` : '', + theyOn > 0 ? `${theyOn} Tool${theyOn === 1 ? '' : 's'} angelassen, die Sie ausgeschaltet haben` : '' + ].filter(Boolean) + + return `${they.length > 0 ? `Die andere Person hat ${they.join(' und ')}. ` : ''}Ihre Änderungen bleiben auf dem Bildschirm; nichts wurde geschrieben.` + }, + conflictReload: 'Deren Version neu laden', + conflictSave: 'Deren Version überschreiben', + saveFailed: 'Diese Tool-Regeln wurden nicht gespeichert.', + footerDirty: (off: number, backOn: number) => + `${off} Tool${off === 1 ? '' : 's'} aus, ${backOn === 0 ? 'keines' : backOn} wieder an`, + discard: 'Verwerfen', + save: 'Änderungen speichern', + saving: 'Wird gespeichert...' + }, + vocabulary: { + facetRead: { + label: 'Lesen', + long: 'Liest Daten aus dieser App. Ändert nichts.' + }, + facetWrite: { + label: 'Schreiben', + long: 'Erstellt oder ändert etwas in dieser App.' + }, + facetDestructive: { + label: 'Destruktiv', + long: 'Kann etwas in dieser App endgültig entfernen.' + }, + facetUnclassified: { + label: 'Unbekannte Wirkung', + long: 'Die App hat nie angegeben, was dieses Tool tut.' + }, + hintReadOnly: { + label: 'Nur lesen', + long: 'Das Tool gibt an, dass es nur liest.' + }, + hintCreate: { + label: 'Erstellt', + long: 'Erstellt etwas Neues.' + }, + hintUpdate: { + label: 'Aktualisiert', + long: 'Ändert etwas, das bereits existiert.' + }, + hintDelete: { + label: 'Löscht', + long: 'Entfernt etwas.' + }, + hintDestructive: { + label: 'Destruktiv', + long: 'Die Änderung lässt sich hier nicht rückgängig machen.' + }, + hintIdempotent: { + label: 'Wiederholbar', + long: 'Zweimal ausgeführt bewirkt es dasselbe wie einmal.' + }, + hintOpenWorld: { + label: 'Extern', + long: 'Greift auf etwas außerhalb dieser App zu.' + } + } + }, + sessionImport: { + title: 'Von einer anderen App fortfahren', + subtitle: 'Holen Sie eine Konversation in Hermes und machen Sie dort weiter, wo Sie aufgehört haben.', + action: 'Session importieren', + readingFrom: 'Lesen von', + connectedComputer: 'dem verbundenen Computer', + destination: 'Importieren in', + all: 'Alle', + search: 'Geladene Sessions durchsuchen', + scanning: 'Konversationen werden gesucht', + scanError: 'Sessions konnten nicht gefunden werden', + scanHelp: + 'Überprüfen Sie Ihre Backend-Verbindung und versuchen Sie es dann erneut. Ältere Backends benötigen eventuell ein Update.', + empty: 'Keine Konversationen gefunden', + emptyHelp: 'Claude Code- und Codex-Sessions auf diesem Backend werden hier angezeigt.', + noMatches: 'Keine passenden Konversationen', + searchHelp: 'Versuchen Sie es mit einem anderen Titel oder Ordner, oder laden Sie weitere Sessions.', + skipped: 'Einige Protokolle waren leer, nicht lesbar oder zu groß für die Vorschau.', + more: 'Weitere Sessions laden', + messages: 'Nachrichten', + choose: 'Eine Konversation, die sich lohnt', + chooseHelp: 'Wählen Sie eine Session, um ihren Verlauf zu lesen, bevor Sie sie in Hermes übernehmen.', + previewLoading: 'Vorschau wird geöffnet', + previewError: 'Vorschau nicht verfügbar', + previewHelp: + 'Die Quelle wurde möglicherweise verschoben oder geändert. Aktualisieren Sie die Liste und versuchen Sie es erneut.', + previewLimit: 'Vorschau für bessere Lesbarkeit gekürzt. Die vollständige Konversation wird importiert.', + you: 'Sie', + snapshot: 'Diese Konversation ist bereits in Hermes. Öffnen Sie Ihre vorhandene Kopie, um weiterzumachen.', + copyNotice: + 'Kopiert den Konversationstext. Quelldateien bleiben unverändert. Tool-Ausgaben und Überlegungen werden nicht übernommen.', + importing: 'Importieren…', + open: 'In Hermes öffnen', + continue: 'In Hermes fortfahren', + importError: 'Diese Konversation konnte nicht importiert werden.' + }, + common: { + apply: 'Übernehmen', + back: 'Zurück', + save: 'Speichern', + saving: 'Speichern…', + cancel: 'Abbrechen', + change: 'Ändern', + choose: 'Wählen', + clear: 'Leeren', + close: 'Schließen', + collapse: 'Einklappen', + confirm: 'Bestätigen', + connect: 'Verbinden', + connecting: 'Verbindung wird hergestellt…', + continue: 'Weiter', + bots: 'Bots', + copied: 'Kopiert', + copy: 'Kopieren', + copyFailed: 'Kopieren fehlgeschlagen', + delete: 'Löschen', + docs: 'Doku', + done: 'Fertig', + error: 'Fehler', + expand: 'Aufklappen', + failed: 'Fehlgeschlagen', + formatJson: 'JSON formatieren', + free: 'Kostenlos', + loading: 'Lädt…', + notSet: 'Nicht gesetzt', + refresh: 'Aktualisieren', + remove: 'Entfernen', + replace: 'Ersetzen', + retry: 'Erneut versuchen', + run: 'Ausführen', + send: 'Senden', + set: 'Setzen', + skip: 'Überspringen', + update: 'Aktualisieren', + tryHint: term => `Versuchen Sie „${term}“`, + on: 'An', + off: 'Aus' + }, + fileMenu: { + revealFinder: 'Im Finder anzeigen', + revealExplorer: 'Im Datei-Explorer anzeigen', + revealFileManager: 'Enthaltenden Ordner öffnen', + revealInSidebar: 'In Dateibaum anzeigen', + copyPath: 'Pfad kopieren', + copyRelativePath: 'Relativen Pfad kopieren', + download: 'Herunterladen', + downloadSaved: 'Gespeichert', + downloadFailed: 'Download fehlgeschlagen', + rename: 'Umbenennen…', + delete: 'Löschen', + renameTitle: 'Umbenennen', + renameLabel: 'Neuer Name', + deleteTitle: name => `„${name}“ löschen?`, + deleteBody: 'Es wird in den Papierkorb verschoben – von dort können Sie es wiederherstellen.', + pathCopied: 'Pfad kopiert', + revealMissing: 'Dieser Ordner befindet sich nicht auf diesem Computer', + revealUnavailable: + 'Dieser Pfad befindet sich nicht auf diesem Computer, sondern auf dem Backend-Rechner. Verwenden Sie „Im Dateibaum anzeigen“.' + }, + boot: { + ready: 'Hermes Desktop ist bereit', + desktopBootFailedWithMessage: message => `Desktop-Start fehlgeschlagen: ${message}`, + steps: { + connectingGateway: 'Live-Desktop-Gateway wird verbunden', + loadingSettings: 'Hermes-Einstellungen werden geladen', + loadingSessions: 'Letzte Sessions werden geladen', + retryingRemoteBackend: 'Wird mit dem Remote-Hermes-Backend neu verbunden…', + startingDesktopConnection: 'Desktop-Verbindung wird gestartet', + startingHermesDesktop: 'Hermes Desktop wird gestartet…' + }, + errors: { + backgroundExited: 'Der Hermes-Hintergrundprozess wurde beendet.', + backgroundExitedDuringStartup: 'Der Hermes-Hintergrundprozess wurde während des Starts beendet.', + backendStopped: 'Backend gestoppt', + restartHermes: 'Hermes neu starten', + openLogs: 'Logs öffnen', + desktopBootFailed: 'Desktop-Start fehlgeschlagen', + gatewayConnectionLost: 'Verbindung zum Gateway verloren', + gatewayConnectionLostDetail: + 'Im Hintergrund wird weiterhin versucht, die Verbindung herzustellen. Sie können weiterlesen und weiterschreiben – öffnen Sie die Gateway-Einstellungen, falls das anhält.', + reconnectNow: 'Jetzt neu verbinden', + connectionSettings: 'Verbindungseinstellungen', + gatewaySignInRequired: 'Gateway-Sign-in erforderlich', + gatewaySignInRequiredDetail: + 'Melden Sie sich erneut an, um die Verbindung wiederherzustellen. Ihre Chats und Einstellungen sind sicher.', + signInAgain: 'Erneut anmelden', + ipcBridgeUnavailable: 'Der Desktop-IPC-Bridge ist nicht verfügbar.' + }, + causes: { + exitedEarly: 'Der Hintergrunddienst von Hermes hat direkt nach dem Start aufgehört.', + timedOut: 'Der Hintergrunddienst von Hermes hat nicht rechtzeitig geantwortet.', + permission: 'Hermes konnte nicht in seinen Datenordner schreiben (Berechtigungsproblem).', + diskFull: 'Die Festplatte ist voll, deshalb konnte Hermes nicht starten.', + portInUse: 'Ein anderes Programm verwendet den Netzwerkport, den Hermes braucht.', + installMissing: + 'Ein Teil der Hermes-Installation fehlt. Wählen Sie „Installation reparieren“, um sie wiederherzustellen.' + }, + failure: { + title: 'Hermes konnte nicht gestartet werden', + description: + 'Das Hintergrund-Gateway ist nicht gestartet. Probieren Sie einen der Wiederherstellungsschritte unten. Keiner davon löscht Ihre Chats oder Einstellungen.', + details: 'Details', + remoteTitle: 'Remote-Gateway-Sign-in erforderlich', + remoteDescription: + 'Ihre Remote-Gateway-Session ist abgelaufen. Melden Sie sich erneut an, um die Verbindung wiederherzustellen. Keiner dieser Schritte löscht Ihre Chats oder Einstellungen.', + retry: 'Erneut versuchen', + repairInstall: 'Installation reparieren', + useLocalGateway: 'Lokales Gateway verwenden', + gatewaySettings: 'Gateway-Einstellungen', + back: 'Zurück', + openLogs: 'Logs öffnen', + repairHint: + 'Die Reparatur führt den Installer erneut aus und kann auf einem frischen Computer ein paar Minuten dauern.', + remoteSignInHint: signInLabel => + `Meldet Sie von der gespeicherten Remote-Browser-Session ab und öffnet dann ${signInLabel}. Verwenden Sie das lokale Gateway, um stattdessen zum integrierten Backend zu wechseln.`, + signOutAndSignIn: 'Abmelden & anmelden', + remoteFailureHint: + 'Überprüfen Sie die Gateway-URL und die Anmeldung in den Gateway-Einstellungen, oder wechseln Sie zum lokalen Gateway.', + cloudDownTitle: 'Nous Cloud Agent ist down', + cloudDownDescription: + 'Der von Nous verwaltete Cloud-Agent, mit dem sich dieses Gateway verbindet, meldet einen Serverfehler. Er kann von hier aus nicht neu gestartet werden – prüfen Sie seinen Status, wechseln Sie zum lokalen Gateway oder wenden Sie sich an den Support.', + cloudDownHint: + 'Die Schaltflächen unten öffnen das Nous Portal (Instanzstatus und Steuerung) und unseren Discord für Support.', + cloudDownCheckPortal: 'Portal-Status prüfen', + cloudDownDiscord: 'Hilfe auf Discord holen', + hideRecentLogs: 'Neueste Logs ausblenden', + showRecentLogs: 'Neueste Logs anzeigen', + signedInTitle: 'Angemeldet', + signedInMessage: 'Wird mit dem Remote-Gateway neu verbunden…', + signInIncompleteTitle: 'Sign-in unvollständig', + signInIncompleteMessage: 'Das Anmeldefenster wurde geschlossen, bevor die Authentifizierung abgeschlossen war.', + signInFailed: 'Sign-in fehlgeschlagen', + signInToRemoteGateway: 'Beim Remote-Gateway anmelden', + signInWithProvider: provider => `Mit ${provider} anmelden`, + identityProvider: 'Ihr Identity-Provider' + } + }, + notifications: { + region: 'Benachrichtigungen', + hide: 'Ausblenden', + show: 'Anzeigen', + more: count => `${count} weitere ${count === 1 ? 'Benachrichtigung' : 'Benachrichtigungen'}`, + clearAll: 'Alle löschen', + dismiss: 'Benachrichtigung schließen', + details: 'Details', + copyDetail: 'Detail kopieren', + copyDetailFailed: 'Notification-Detail konnte nicht kopiert werden', + backendOutOfDateTitle: 'Backend veraltet', + backendOutOfDateMessage: + 'Ihr Hermes-Backend ist älter als dieser Desktop-Build und funktioniert möglicherweise nicht richtig. Aktualisieren Sie, um beide abzugleichen.', + installMethodUnsupportedTitle: 'Nicht unterstützte Installationsmethode', + updateHermes: 'Hermes aktualisieren', + updateReadyTitle: 'Update bereit', + updateReadyMessage: count => `${count} neue Änderung${count === 1 ? '' : 'en'} verfügbar.`, + updateReadyMessageUnknown: 'Ein neues Update ist verfügbar.', + seeWhatsNew: 'Neues ansehen', + mcp: { + needsAuthTitle: 'MCP-Server braucht erneute Authentifizierung', + needsAuthMessage: name => `${name} MCP braucht erneute Authentifizierung.`, + errorTitle: 'MCP-Server nicht erreichbar', + errorMessage: name => `${name} MCP hat den Health-Check nicht bestanden.`, + signIn: 'Anmelden', + view: 'Anzeigen', + disable: 'Deaktivieren', + disabledMessage: name => + `${name} MCP deaktiviert. Sie können es jederzeit unter Fähigkeiten → MCP wieder aktivieren.`, + disableFailed: name => `${name} MCP konnte nicht deaktiviert werden.` + }, + errors: { + elevenLabsNeedsKey: 'ElevenLabs-STT braucht ELEVENLABS_API_KEY.', + elevenLabsRejectedKey: 'ElevenLabs hat den API-Key abgelehnt (401).', + diskFull: 'Festplatte voll – geben Sie Speicherplatz frei und versuchen Sie es dann erneut.', + storageFailure: + 'Hermes konnte nicht in seinen Datenordner speichern. Öffnen Sie die Wartung, um das Problem zu prüfen und zu beheben.', + gatewayAuthFailed: 'Gateway-Authentifizierung fehlgeschlagen – überprüfen Sie Ihren API_SERVER_KEY.', + methodNotAllowed: + 'Das Desktop-Backend hat diese Anfrage abgelehnt (405 Method Not Allowed). Starten Sie Hermes Desktop neu.', + microphonePermission: 'Die Mikrofonberechtigung wurde verweigert.', + openaiRejectedApiKey: + 'OpenAI hat Ihren API-Key abgelehnt. Aktualisieren Sie ihn unter Einstellungen → Schlüssel und versuchen Sie es erneut.', + openaiTtsNeedsKey: 'OpenAI-TTS braucht VOICE_TOOLS_OPENAI_KEY oder OPENAI_API_KEY.', + codeSkewRestartRequired: + 'Dieses Backend läuft nach einem Update mit altem Code. Starten Sie es neu, um den neuen Code zu laden.', + rpcOutOfSync: 'App und Backend laufen in unterschiedlichen Versionen. Aktualisieren Sie beide.', + restartHermesFailed: 'Hermes konnte nicht neu gestartet werden' + }, + actions: { + restartHermes: 'Hermes neu starten', + openKeys: 'Schlüssel öffnen', + openGateways: 'Gateways öffnen', + openMaintenance: 'Wartung öffnen' + }, + voice: { + configureSpeechToText: 'Richten Sie Speech-to-Text ein, um den Sprachmodus zu verwenden.', + couldNotStartSession: 'Sprachsession konnte nicht gestartet werden', + microphoneAccessDenied: 'Mikrofonzugriff verweigert.', + microphoneConstraintsUnsupported: 'Mikrofon-Einschränkungen werden von diesem Gerät nicht unterstützt.', + microphoneFailed: 'Mikrofon fehlgeschlagen', + microphoneInUse: 'Das Mikrofon wird bereits von einer anderen App verwendet.', + microphonePermissionDenied: 'Die Mikrofonberechtigung wurde verweigert.', + microphoneStartFailed: 'Mikrofonaufnahme konnte nicht gestartet werden.', + microphoneUnsupported: 'Diese Laufzeitumgebung unterstützt keine Mikrofonaufnahme.', + noMicrophone: 'Es wurde kein Mikrofon gefunden.', + noSpeechDetected: 'Keine Sprache erkannt', + playbackFailed: 'Sprachwiedergabe fehlgeschlagen', + recordingFailed: 'Sprachaufnahme fehlgeschlagen', + sayStopToEnd: phrase => `Sagen Sie „${phrase}“, um den Sprachchat zu beenden.`, + transcriptionFailed: 'Sprachtranskription fehlgeschlagen', + transcriptionUnavailable: 'Sprachtranskription ist noch nicht verfügbar.', + tryRecordingAgain: 'Versuchen Sie die Aufnahme erneut.', + unavailable: 'Sprache nicht verfügbar', + liveEnded: 'Live-Sprach-Session beendet', + liveEndedConnectionLost: 'Die Live-Sprach-Session hat die Verbindung verloren.', + liveEndedClosed: 'Die Live-Sprach-Session wurde vom Dienst geschlossen.', + liveError: 'Live-Sprache', + liveDelegationFailed: 'Anfrage konnte nicht an Hermes übergeben werden', + liveUnavailable: reason => + `GPT-Live-Sprachchat ist nicht verfügbar: ${reason}. Stattdessen wird Sprache-zu-Text verwendet.` + }, + native: { + approvalTitle: 'Genehmigung erforderlich', + approvalTitleNamed: session => `Freigabe erforderlich — ${session}`, + approveAction: 'Genehmigen', + rejectAction: 'Ablehnen', + inputTitle: 'Eingabe erforderlich', + inputTitleNamed: session => `Eingabe erforderlich — ${session}`, + inputBody: 'Hermes wartet auf Ihre Antwort.', + turnDoneTitle: 'Hermes fertig', + turnDoneBody: '', + turnErrorTitle: 'Turn fehlgeschlagen', + backgroundDoneTitle: 'Hintergrundaufgabe abgeschlossen', + backgroundFailedTitle: 'Hintergrundaufgabe fehlgeschlagen', + creditsTitle: 'Credits' + } + }, + remoteDisplayBanner: { + message: reason => + `Software-Rendering aktiv — Remote-Display erkannt (${reason}). GPU-Beschleunigung ist deaktiviert, um Flackern zu verhindern.` + }, + billingBlock: { + titleNous: 'Keine Nous-Credits mehr', + titleProvider: provider => `Keine Credits mehr — ${provider}`, + fallbackMessage: 'Auf Ihrem Konto sind keine Credits mehr übrig. Fügen Sie Credits hinzu, um fortzufahren.', + openBilling: 'Billing öffnen', + addCredits: 'Credits hinzufügen', + dismiss: 'Schließen' + }, + sendDiagnostics: { + title: 'Diagnosedaten an Nous senden', + privacyNotice: + 'Damit laden Sie ein Debug-Paket in den internen Nous-Speicher hoch (kein öffentliches Paste). Es enthält Systeminfos (Betriebssystem, Versionen, Provider, welche API-Keys konfiguriert sind – niemals die Keys selbst) sowie vollständige Agent-, Gateway- und Desktop-Logs (bis zu 512 KB je Datei), die sehr wahrscheinlich Gesprächsinhalte, Tool-Ausgaben und Dateipfade enthalten. Geheimnisse werden vor dem Upload geschwärzt. Das Paket ist nur für Nous-Mitarbeitende und freigeschaltete Discord-Moderatoren einsehbar und wird nach 14 Tagen automatisch gelöscht.', + upload: 'Hochladen', + uploading: 'Wird hochgeladen…', + cancel: 'Abbrechen', + close: 'Schließen', + copyLink: 'Link kopieren', + uploadIdFallback: id => `Kein Link zurückgegeben — zitiere die Upload-ID ${id} im Support`, + doneTitle: 'Diagnosedaten gesendet', + doneDescription: + 'Ihr Paket wurde privat hochgeladen. Teilen Sie den Link unten in Ihrem Support-Thread, damit das Team Ihre Logs sehen kann.', + failedTitle: 'Hochladen fehlgeschlagen', + failedHint: + 'Sie können auch `hermes debug share --nous` im Terminal ausführen oder `hermes debug share --local`, um den Bericht ohne Hochladen auszugeben.', + handoffLead: 'Diskussion hier fortsetzen:', + links: { + github: 'GitHub Issues', + portal: 'Nous-Portal-Support', + discord: 'Discord' + } + }, + titlebar: { + hideSidebar: 'Sidebar ausblenden', + showSidebar: 'Sidebar einblenden', + search: 'Suchen', + searchTitle: 'Sessions, Ansichten und Aktionen durchsuchen', + swapSidebarSides: 'Sidebar-Seiten tauschen', + hideRightSidebar: 'Rechte Sidebar ausblenden', + showRightSidebar: 'Rechte Sidebar einblenden', + unreadSessions: count => (count === 1 ? '1 ungelesene Session' : `${count} ungelesene Sessions`), + muteHaptics: 'Haptik stummschalten', + unmuteHaptics: 'Haptik einschalten', + openSettings: 'Einstellungen öffnen', + openStarmap: 'Speicher-Graph öffnen', + enterHud: 'HUD-Modus', + exitHud: 'HUD-Modus beenden', + resetHudLayout: 'HUD-Größe und -Position zurücksetzen', + layoutEditor: 'Layout-Editor', + layoutEditorTitle: mod => `Layout-Editor — ${mod}-Klick setzt das Layout zurück` + }, + keybinds: { + title: 'Tastaturkürzel', + subtitle: open => `Klicken Sie auf ein Kürzel, um es neu zu belegen · ${open} öffnet dieses Panel erneut.`, + search: 'Kürzel suchen…', + rebind: 'Neu belegen', + reset: 'Auf Standard zurücksetzen', + resetAll: 'Alle zurücksetzen', + pressKey: 'Taste drücken…', + set: 'setzen', + conflictWith: label => `Auch belegt mit „${label}“`, + categories: { + composer: 'Composer', + profiles: 'Profile', + session: 'Session', + navigation: 'Navigation', + view: 'Ansicht' + }, + actions: { + 'keybinds.openPanel': 'Tastaturkürzel öffnen', + 'nav.commandPalette': 'Befehlspalette öffnen', + 'nav.commandCenter': 'Befehlszentrum öffnen', + 'nav.settings': 'Einstellungen öffnen', + 'nav.profiles': 'Profile öffnen', + 'nav.capabilities': 'Skills öffnen', + 'nav.messaging': 'Messaging öffnen', + 'nav.artifacts': 'Artefakte öffnen', + 'nav.cron': 'Geplante Jobs öffnen', + 'nav.agents': 'Agenten öffnen', + 'session.new': 'Neue Session', + 'session.newTab': 'Neuer Session-Tab', + 'session.newWindow': 'Neues Fenster', + 'session.next': 'Nächste Session', + 'session.prev': 'Vorherige Session', + 'session.slot.1': 'Zu letzter Session 1 wechseln', + 'session.slot.2': 'Zu letzter Session 2 wechseln', + 'session.slot.3': 'Zu letzter Session 3 wechseln', + 'session.slot.4': 'Zu letzter Session 4 wechseln', + 'session.slot.5': 'Zu letzter Session 5 wechseln', + 'session.slot.6': 'Zu letzter Session 6 wechseln', + 'session.slot.7': 'Zu letzter Session 7 wechseln', + 'session.slot.8': 'Zu letzter Session 8 wechseln', + 'session.slot.9': 'Zu letzter Session 9 wechseln', + 'session.focusSearch': 'Sessions durchsuchen', + 'session.togglePin': 'Aktuelle Session anheften / lösen', + 'session.archive': 'Aktuelle Session archivieren', + 'workspace.newWorktree': 'Neues Worktree', + 'workspace.openFolder': 'Ordner als Projekt öffnen', + 'composer.focus': 'Composer fokussieren', + 'composer.modelPicker': 'Modellauswahl öffnen', + 'composer.voice': 'Sprachkonversation starten / stoppen', + 'view.toggleSidebar': 'Session-Sidebar umschalten', + 'view.cycleSidebarGrouping': 'Session-Gruppierung wechseln', + 'view.toggleRightSidebar': 'Dateibrowser umschalten', + 'view.toggleReview': 'Review-Bereich umschalten', + 'view.toggleStatusbar': 'Statusleiste umschalten', + 'view.toggleTabStrip': 'Tabs umschalten', + 'view.toggleProfileRail': 'Profil-Leiste ein-/ausblenden', + 'view.toggleSimpleMode': 'Einfachen Modus umschalten', + 'view.showFiles': 'Dateibrowser anzeigen', + 'view.showBrowser': 'Browser öffnen', + 'view.toggleHud': 'HUD-Modus umschalten', + 'hud.snapToPointer': 'HUD zum Zeiger bewegen (global, während HUD offen ist)', + 'view.showTerminal': 'Terminal umschalten', + 'view.newTerminal': 'Neues Terminal', + 'view.nextTerminal': 'Nächstes Terminal', + 'view.prevTerminal': 'Vorheriges Terminal', + 'view.closeTerminal': 'Terminal schließen', + 'view.selectionToComposer': 'Auswahl an Composer senden', + 'view.terminalCopy': 'Terminal-Auswahl kopieren', + 'view.terminalPaste': 'In Terminal einfügen', + 'view.closeTab': 'Tab schließen', + 'view.reopenTab': 'Geschlossenen Tab wieder öffnen', + 'view.flipPanes': 'Sidebar-Seiten tauschen', + 'view.findInPage': 'Auf Seite suchen', + 'view.findNext': 'Nächsten Treffer suchen', + 'view.findPrevious': 'Vorherigen Treffer suchen', + 'appearance.toggleMode': 'Hell / dunkel umschalten', + 'profile.default': 'Zu Standardprofil wechseln', + 'profile.switch.1': 'Zu Profil 1 wechseln', + 'profile.switch.2': 'Zu Profil 2 wechseln', + 'profile.switch.3': 'Zu Profil 3 wechseln', + 'profile.switch.4': 'Zu Profil 4 wechseln', + 'profile.switch.5': 'Zu Profil 5 wechseln', + 'profile.switch.6': 'Zu Profil 6 wechseln', + 'profile.switch.7': 'Zu Profil 7 wechseln', + 'profile.switch.8': 'Zu Profil 8 wechseln', + 'profile.switch.9': 'Zu Profil 9 wechseln', + 'profile.switch.10': 'Zu Profil 10 wechseln', + 'profile.switch.11': 'Zu Profil 11 wechseln', + 'profile.switch.12': 'Zu Profil 12 wechseln', + 'profile.switch.13': 'Zu Profil 13 wechseln', + 'profile.switch.14': 'Zu Profil 14 wechseln', + 'profile.switch.15': 'Zu Profil 15 wechseln', + 'profile.switch.16': 'Zu Profil 16 wechseln', + 'profile.switch.17': 'Zu Profil 17 wechseln', + 'profile.switch.18': 'Zu Profil 18 wechseln', + 'profile.next': 'Nächstes Profil', + 'profile.prev': 'Vorheriges Profil', + 'profile.toggleAll': 'Alle-Profile-Ansicht umschalten', + 'profile.create': 'Profil erstellen', + 'composer.send': 'Nachricht senden', + 'composer.newline': 'Neue Zeile einfügen', + 'composer.steer': 'Laufenden Turn steuern', + 'composer.queue': 'Nachricht in Warteschlange', + 'composer.sendQueued': 'Nächsten eingereihten Turn senden', + 'composer.mention': 'Dateien, Ordner, URLs referenzieren', + 'composer.slash': 'Slash-Befehlspalette', + 'composer.help': 'Schnellhilfe', + 'composer.history': 'Popover / Verlauf durchblättern', + 'composer.cancel': 'Popover schließen · Lauf abbrechen' + } + }, + findInPage: { + next: 'Nächster Treffer', + previous: 'Vorheriger Treffer' + }, + language: { + label: 'Sprache', + description: 'Wählen Sie die Sprache der Desktop-Oberfläche.', + saving: 'Sprache wird gespeichert…', + saveError: 'Sprachupdate fehlgeschlagen', + switchTo: 'Sprache wechseln', + searchPlaceholder: 'Sprachen suchen…', + noResults: 'Keine Sprachen gefunden' + }, + settings: { + subpages: { + appearanceTheme: 'Design', + appearanceTypography: 'Typografie', + appearanceWindowLayout: 'Fenster & Layout', + appearanceChatDisplay: 'Chat-Anzeige', + appearancePet: 'Begleiter', + appearanceGeneral: 'Allgemein', + modelMain: 'Hauptmodell', + modelAuxiliary: 'Hilfsmodelle', + modelMoa: 'Mixture of Agents', + modelFallbacks: 'Fallback-Modelle', + chatBehavior: 'Verhalten', + chatAttachments: 'Anhänge', + workspaceProjects: 'Projekte & Erkennung', + workspaceShell: 'Shell-Umgebung', + workspaceFiles: 'Dateien & Ausführung', + safetyApprovals: 'Freigaben', + safetyPrivacy: 'Datenschutz & Netzwerk', + safetyCheckpoints: 'Checkpoints', + browserProfile: 'Browserprofil', + browserNetwork: 'Lokale & private URLs', + memoryPersistent: 'Dauerhaftes Gedächtnis', + memoryContext: 'Kontext & Komprimierung', + voiceConversation: 'Sprachunterhaltung', + voiceTranscription: 'Sprache zu Text', + voiceSpeech: 'Text zu Sprache', + advancedRuntime: 'Agent-Limits', + advancedTools: 'Tool-Zugriff', + advancedTerminal: 'Terminal-Backend', + advancedOutput: 'Ausgabelimits', + advancedDelegation: 'Subagenten', + advancedDesktop: 'Desktop & Start', + gatewayConnection: 'Dieses Fenster', + gatewayDevices: 'Gespeicherte Verbindungen', + gatewayManagedUpdates: 'Remote-Updates', + gatewayManagedUpdatesUnavailable: + 'Remote-Updates erfordern eine Desktop-Version mit Unterstützung für verwaltete SSH-Updates.', + gatewayManagedUpdatesEmpty: + 'Fügen Sie unter „Gespeicherte Verbindungen“ eine SSH-Verbindung hinzu, um deren Updates hier zu verwalten.', + keyboardShortcuts: 'Tastenbelegung', + hudGesture: 'HUD-Geste', + screenCapture: 'Bildschirmaufnahme', + notificationAlerts: 'Desktop-Hinweise', + notificationSounds: 'Töne', + archivedSessions: 'Archiv & Aufbewahrung', + defaultDirectory: 'Standard-Projektordner', + vaultCredentials: 'Gespeicherte Zugangsdaten', + vaultSources: 'Passwortmanager', + appUpdates: 'Version & Updates', + uninstall: 'Deinstallieren', + billingOverview: 'Übersicht', + billingPlans: 'Tarife' + }, + closeSettings: 'Einstellungen schließen', + exportConfig: 'Konfiguration exportieren', + importConfig: 'Konfiguration importieren', + resetToDefaults: 'Auf Standard zurücksetzen', + resetConfirm: 'Alle Einstellungen auf Hermes-Standard zurücksetzen?', + exportFailed: 'Export fehlgeschlagen', + resetFailed: 'Zurücksetzen fehlgeschlagen', + nav: { + providers: 'Anbieter', + providerAccounts: 'Konten', + providerApiKeys: 'API-Schlüssel', + providerCustomEndpoints: 'Benutzerdefinierte Endpunkte', + providerLocalModels: 'Lokale Modelle', + gateway: 'Gateways', + apiKeys: 'Tools & Schlüssel', + keybinds: 'Tastaturkürzel', + keysTools: 'Tools', + keysSettings: 'Einstellungen', + mcp: 'MCP', + archivedChats: 'Archivierte Chats', + about: 'Über', + billing: 'Abrechnung', + notifications: 'Benachrichtigungen', + vault: 'Passwörter & Logins' + }, + plugins: { + title: 'Desktop-Plugins', + blurb: 'Gebündelt oder im Ordner „Desktop-Plugins“ abgelegt. Deaktivieren, um live zu entladen.', + count: n => `${n} installiert`, + openFolder: 'Ordner für Desktop-Plugins öffnen', + rescan: 'Erneut scannen', + reveal: 'Im Dateimanager anzeigen', + enable: 'Aktivieren', + disable: 'Deaktivieren', + failed: 'fehlgeschlagen', + empty: 'Noch keine Desktop-Plugins installiert.', + kinds: { + bundled: 'gebündelt', + disk: 'auf Datenträger', + runtime: 'Laufzeit' + }, + agentHalfMissing: 'Agent-Hälfte fehlt hier', + agentHalfMissingTip: + 'Das ist die Desktop-Hälfte eines gebündelten Plugins, aber seine Agent-Hälfte ist auf dem aktuell verbundenen Backend/Profil nicht installiert. Installieren Sie sie unter Fähigkeiten → Plugins.', + installModal: { + installFromGit: 'Von Git installieren', + reviewRepository: 'Repository prüfen', + repoPlaceholder: 'https://github.com/owner/repo', + title: 'Plugin installieren', + description: 'Prüfen Sie, was dieses Repository enthält, bevor Sie etwas installieren.', + repoLabel: 'Repository', + includesHeading: 'Dieses Paket enthält', + agentLabel: 'Agent-Plugin', + desktopLabel: 'Desktop-UI', + profileLabel: 'Für Profil installieren', + agentTargetLocal: (profile, dir) => `Wird in das Backend ${profile} installiert (${dir})`, + agentTargetRemote: profile => `Installiert in das verbundene ${profile}-Backend`, + catalogPinned: (name, sha) => + `Hermes-Katalog-Eintrag „${name}" — die Agent-Komponente wird am geprüften Pin installiert${sha ? ` ${sha}` : ''}, nicht an der Spitze des Branches.`, + reviewedHeading: 'Geprüfter Katalog-Eintrag', + reviewedIntro: + 'Dieser Eintrag wurde an seinem gepinnten Commit von einem Menschen geprüft. Sie können den genauen Code trotzdem unten ansehen.', + toolsConnected: n => (n === 1 ? '1 Tool verbunden' : `${n} Tools verbunden`), + skillsReady: names => (names.length === 1 ? `Skill ${names[0]} bereit` : `${names.length} Skills bereit`), + nextChat: 'weitere Tools in Ihrem nächsten Chat verfügbar', + serverNotConnected: (server, reason) => + `MCP-Server ${server} ist nicht verbunden${reason ? `: ${reason}` : '.'}`, + missingEnvAction: 'Einrichten', + alreadyInstalled: name => `${name} ist bereits installiert.`, + desktopTarget: 'Installiert in den lokalen Desktop-Plugins-Ordner dieser App', + desktopTargetFromPackage: 'Aus dem Paket oben in diese App geladen — gleich für jedes Profil', + desktopOnlyNote: 'Nur-Desktop-Pakete installieren kein Backend-Agent-Plugin.', + insecureWarning: + 'Diese URL verwendet ein unsicheres oder lokales Schema. Für Produktionsinstallationen bevorzuge https:// oder git@.', + securityHeading: 'Vor der Installation', + securityIntro: + 'Installieren Sie nur aus Quellen, denen Sie vertrauen – prüfen Sie das Repository unten, wenn Sie sehen möchten, was hinzugefügt wird.', + sourceHeading: 'Quellcode', + viewRepository: 'Repository ansehen', + viewPluginFiles: 'Plugin-Dateien ansehen', + gitCloneLabel: 'Git-Clone-URL', + enableAgent: 'Agent-Plugin nach der Installation aktivieren', + forceReinstall: 'Neuinstallation erzwingen (ersetzen, falls bereits installiert)', + pinToCommit: 'An Commit binden (optional)', + pinToCommitPlaceholder: 'Vollständiger 40-stelliger Commit-SHA', + pinToCommitHint: + 'Alle, die diesen SHA installieren, bekommen denselben Code; das Plugin lehnt danach Updates ab, bis es neu gepinnt wird. Leer lassen für den neuesten Commit.', + pinToCommitInvalid: + 'Muss ein vollständiger 40-stelliger Commit-SHA sein (Branches und Tags werden nicht akzeptiert).', + install: 'Installieren', + installing: 'Wird installiert…', + probing: 'Repository wird untersucht…', + probeUnavailable: 'Die Plugin-Untersuchung ist in dieser Umgebung nicht verfügbar.', + desktopUnavailable: 'Die Desktop-Plugin-Installation ist in dieser Umgebung nicht verfügbar.', + selectComponent: 'Wählen Sie mindestens eine Komponente zur Installation aus.', + agentSuccess: name => `Agent-Plugin ${name} installiert`, + desktopSuccess: name => `Desktop-Plugin ${name} installiert`, + agentFailed: 'Installation des Agent-Plugins fehlgeschlagen', + desktopFailed: 'Installation des Desktop-Plugins fehlgeschlagen', + missingEnv: (name, vars) => + `${name} ist installiert, benötigt aber einen Schlüssel, um zu funktionieren: ${vars}. Fügen Sie ihn jetzt hinzu, sonst schlagen die Tools des Plugins fehl.` + } + }, + vault: { + title: 'Passwörter & Logins', + blurb: + 'Sagen Sie „melde mich bei GitHub an“, und der Agent meldet Sie an. Beim ersten Mal auf einer Anmeldeseite fragt er Sie direkt dort nach dem Login; danach läuft es einfach. Passwörter sind auf diesem Rechner verschlüsselt und werden direkt in die Seite eingetragen – das Modell sieht sie nie.', + count: n => `${n} gespeichert`, + loadFailed: 'Gespeicherte Einträge konnten nicht geladen werden', + empty: 'Noch nichts gespeichert', + emptyDesc: + 'Sie müssen hier nichts eintragen. Bitten Sie den Agenten, sich bei einer Seite anzumelden – er fragt Sie dann einmal direkt dort nach dem Login. Über „Hinzufügen“ können Sie einen Eintrag auch vorab anlegen.', + add: 'Hinzufügen', + addTitle: 'Login, Karte oder Adresse hinzufügen', + addDescription: 'Verschlüsselt auf diesem Rechner gespeichert. Der Agent sieht das Passwort nie.', + added: 'Gespeichert.', + adding: 'Wird gespeichert…', + addConfirm: 'Speichern', + kindField: 'Art', + kinds: { + login: 'Login', + payment: 'Zahlungskarte', + address: 'Adresse' + }, + labelField: 'Bezeichnung', + labelPlaceholder: 'z. B. GitHub Firma', + labelRequired: 'Eine Bezeichnung ist erforderlich.', + originField: 'Ursprung der Seite', + originPlaceholder: 'https://github.com', + originPlaceholderCheckout: 'https://shop.example.com', + originInvalid: 'Geben Sie eine gültige URL wie https://example.com ein.', + identifierTypeField: 'Art der Kennung', + identifierTypes: { + email: 'E-Mail', + phone: 'Telefon', + username: 'Benutzername' + }, + identifierField: 'Kennung', + identifierShown: identifier => identifier, + passwordField: 'Passwort', + loginFieldsRequired: 'Kennung und Passwort sind erforderlich.', + cardNumberField: 'Kartennummer', + cardNameField: 'Name auf der Karte', + expMonthField: 'Ablaufmonat', + expYearField: 'Ablaufjahr', + cvcField: 'CVC', + postalField: 'Postleitzahl', + addressLine1Field: 'Adresszeile 1', + addressLine2Field: 'Adresszeile 2', + cityField: 'Stadt', + stateField: 'Bundesland / Region', + countryField: 'Land', + optional: '(optional)', + createdOn: date => `Hinzugefügt am ${date}`, + deleteAction: 'Gespeicherten Eintrag entfernen', + otpField: 'Authentifizierungsschlüssel', + otpPlaceholder: 'Base32-Geheimnis oder otpauth://-Link', + otpHint: + 'Der „Einrichtungsschlüssel", den die Seite beim Aktivieren von 2FA anzeigt. Ist er gespeichert, erzeugt Hermes die Codes selbst.', + twoFactorBadge: '2FA automatisch', + deleteTitle: 'Diesen Eintrag löschen?', + deleteDescription: label => `„${label}" wird entfernt. Das kann nicht rückgängig gemacht werden.`, + deleteConfirm: 'Löschen', + sources: { + title: 'Passwortmanager', + blurb: + 'Installierte Passwortmanager werden automatisch erkannt. Der Agent bittet Sie, einen zu entsperren, wenn er zum ersten Mal einen Login daraus braucht (einmal pro Session); nur ein Session-Token bleibt im Speicher, und der Agent sieht weder Ihr Master-Passwort noch einen Login.', + toggleFailed: 'Passwortmanager konnte nicht geändert werden', + notInstalled: name => + `Nicht erkannt. Installieren Sie das ${name}-Kommandozeilenwerkzeug und melden Sie sich dort an; Hermes erkennt es automatisch.`, + disabledDesc: 'Erkannt, aber für Hermes ausgeschaltet.', + lockedDesc: + 'Erkannt. Der Agent bittet Sie, ihn zu entsperren, wenn er einen Login braucht – oder entsperren Sie ihn jetzt.', + unlockedDesc: + 'Für diese Session entsperrt. Sperrt automatisch nach 30 Minuten Inaktivität oder wenn Hermes geschlossen wird.', + statusLocked: 'Gesperrt', + statusNotDetected: 'Nicht erkannt', + statusOff: 'Aus', + statusUnlocked: 'Entsperrt', + unlock: 'Entsperren', + unlocking: 'Wird entsperrt…', + lock: 'Sperren', + unlocked: name => `${name} ist für diese Session entsperrt.`, + unlockTitle: name => `${name} entsperren`, + unlockDescription: + 'Geben Sie Ihr Master-Passwort ein. Es geht an den Passwortmanager auf diesem Rechner und wird danach verworfen – es wird nie gespeichert, protokolliert oder dem Agenten gezeigt.', + masterPasswordPlaceholder: 'Master-Passwort' + } + }, + notifications: { + title: 'Benachrichtigungen', + intro: 'OS-Benachrichtigungen (keine In-App-Toasts). Pro Gerät.', + enableAll: 'Benachrichtigungen aktivieren', + enableAllDesc: 'Aus schaltet jede Benachrichtigung unten stumm.', + focusedHint: 'Abschluss-Alerts feuern nur, während Hermes im Hintergrund ist.', + kinds: { + approval: { + label: 'Genehmigung nötig', + description: 'Ein Befehl wartet darauf, dass Sie ihn genehmigen oder ablehnen.' + }, + input: { + label: 'Eingabe nötig', + description: 'Hermes hat eine Frage gestellt oder braucht ein Passwort oder Geheimnis.' + }, + turnDone: { + label: 'Antwort bereit', + description: 'Ein Turn wurde beendet, während Hermes im Hintergrund war.' + }, + turnError: { + label: 'Turn fehlgeschlagen', + description: 'Fehler bei Background-Turns.' + }, + backgroundDone: { + label: 'Hintergrund-Task fertig', + description: 'Ein Terminal-Befehl im Hintergrund wurde abgeschlossen.' + }, + credits: { + label: 'Credit-Alerts', + description: 'Der Credit-Zugriff wird pausiert oder wiederhergestellt.' + }, + plugin: { + label: 'Plugin-Benachrichtigungen', + description: 'Ein Desktop-Plugin hat eine Benachrichtigung gesendet, während Hermes im Hintergrund war.' + } + }, + test: 'Testbenachrichtigung senden', + testTitle: 'Hermes', + testBody: 'Benachrichtigungen funktionieren.', + testSent: + 'Test gesendet. Wenn nichts erscheint, überprüfen Sie die Benachrichtigungsberechtigungen Ihres Betriebssystems und Fokus/Nicht stören.', + testUnsupported: 'Dieses System unterstützt keine nativen Benachrichtigungen.', + completionSoundTitle: 'Abschluss-Sound', + completionSoundDesc: + 'Wird abgespielt, wenn ein Agent-Turn endet. Wählen Sie eine Vorlage aus und hören Sie sie hier an.', + completionSoundPreview: 'Vorschau' + }, + sections: { + model: 'Modell', + chat: 'Chat', + appearance: 'Darstellung', + workspace: 'Arbeitsbereich', + safety: 'Sicherheit', + memory: 'Speicher & Kontext', + voice: 'Sprache', + advanced: 'Erweitert' + }, + searchPlaceholder: { + about: 'Über Hermes Desktop', + config: 'Einstellungen durchsuchen…', + gateway: 'Gateway-Verbindung…', + keys: 'API-Schlüssel durchsuchen…', + mcp: 'MCP-Server durchsuchen…', + sessions: 'Archivierte Sessions durchsuchen…' + }, + modeOptions: { + light: { + label: 'Hell', + description: 'Helle Desktop-Oberflächen' + }, + dark: { + label: 'Dunkel', + description: 'Arbeitsbereich mit geringer Blendung' + }, + system: { + label: 'System', + description: 'Der Darstellung des Systems folgen' + } + }, + appearance: { + title: 'Darstellung', + intro: 'Nur für Desktop. Modus ist die Helligkeit; Theme ist Farbpalette und Chat-Design.', + colorMode: 'Farbmodus', + colorModeDesc: 'Wählen Sie einen festen Modus oder lassen Sie Hermes Ihrer Systemeinstellung folgen.', + toolViewTitle: 'Tool-Aufruf-Anzeige', + toolViewDesc: 'Produkt versteckt rohe Tool-Payloads; Technisch zeigt vollständige Ein-/Ausgabe.', + hideCodeDiffsTitle: 'Code-Diffs ausblenden', + hideCodeDiffsDesc: + 'Dateiänderungen als Tool-Zeilen mit Anzahl hinzugefügter/entfernter Zeilen anzeigen, ohne den Code.', + hideThreadTimelineTitle: 'Zeitleistenbalken ausblenden', + hideThreadTimelineDesc: 'Blendet die Navigationsbalken am rechten Rand jeder Unterhaltung aus.', + reasoningCollapsedTitle: 'Gedanken standardmäßig einklappen', + reasoningCollapsedDesc: 'Gestreamte Gedankengänge verfügbar halten, ohne sie aufzuklappen, bis Sie sie öffnen.', + uiScaleTitle: 'UI-Skalierung', + uiScaleDesc: (percent: number) => + `Skaliert Text und Bedienelemente in der gesamten App. Cmd/Ctrl mit +, - und 0 funktioniert ebenfalls. Aktuell: ${percent}%.`, + sessionDensityTitle: 'Dichte der Session-Liste', + sessionDensityDesc: 'Wählen Sie, wie viel Kontext unter den Session-Titeln in der Seitenleiste erscheint.', + sessionDensityCompact: 'Kompakt', + sessionDensityComfortable: 'Komfortabel', + sessionDensityDetailed: 'Detailreich', + tabStripTitle: 'Tab-Leiste', + tabStripDesc: + 'Zeigt Tabs über einer Zone. Blendet sie automatisch aus, wenn eine Zone nur einen einzelnen Bereich enthält.', + tabStripAuto: 'Automatisch', + tabStripAlways: 'Immer', + tabStripNever: 'Nie', + appActionsTitle: 'App-Aktionen', + appActionsDesc: + 'Wo Einstellungen, Layout und HUD in der Titelleiste sitzen. Rechts lässt Platz für Tabs auf der linken Seite.', + appActionsLeft: 'Links', + appActionsRight: 'Rechts', + terminalFontTitle: 'Terminalschrift', + terminalFontDesc: + 'Wählen Sie eine installierte Schrift für Desktop-Terminals. Nerd Fonts rendern Powerlevel10k- und Shell-Icons; lassen Sie das Feld leer, um das gebündelte JetBrains Mono zu verwenden.', + terminalFontPlaceholder: 'MesloLGS NF oder ein CSS-Font-Stack', + terminalFontPreview: 'Glyph-Vorschau', + terminalFontReset: 'Standard verwenden', + chatFontTitle: 'Chat-Schrift', + chatFontDesc: + 'Wählen Sie eine installierte Schrift für den Chat und den Rest der App. Praktisch für Lesbarkeitsschriften wie OpenDyslexic; lassen Sie das Feld leer, um die Schrift des Themes zu verwenden.', + chatFontPlaceholder: 'OpenDyslexic oder ein CSS-Font-Stack', + chatFontPreview: 'Vorschau', + chatFontSample: 'Franz jagt im komplett verwahrlosten Taxi quer durch Bayern. 0123456789', + chatFontReset: 'Theme-Schrift verwenden', + translucencyTitle: 'Fenster-Transluzenz', + translucencyDesc: + 'Sehen Sie Ihren Desktop durch das ganze Fenster hindurch, einschließlich Text. Für hell und dunkel separat abgestimmt.', + translucencyGlassDesc: + 'Mattglas: Der Desktop scheint als weicher Blur durch, während der Text scharf bleibt. Für hell und dunkel separat abgestimmt.', + translucencyModeClear: 'Klar', + translucencyModeGlass: 'Glas', + translucencyTintTitle: 'Tönung', + translucencyFadeTitle: 'Verblassen', + translucencyFrostTitle: 'Frost', + translucencyFrost: { + 'under-window': 'Tief', + popover: 'Weich', + titlebar: 'Hell', + header: 'Glanz' + }, + translucencyScopeTitle: 'Bereich', + translucencyScope: { + window: 'Ganzes Fenster', + sidebar: 'Nur Seitenleiste' + }, + backdropTitle: 'Chat-Hintergrund', + backdropDesc: 'Das zarte Statuenbild hinter der Konversation.', + userBubbleTitle: 'Nachrichten-Blase', + userBubbleDesc: + 'Wie durchsichtig Ihre eigenen Nachrichten sind. Bei 0 deckend; bei 100 bleibt nur die Kontur übrig.', + textDirectionTitle: 'Textrichtung', + textDirectionDesc: + 'Legt die Schreibrichtung von Chatnachrichten und Eingabefeld fest. Auto richtet sich nach dem ersten Buchstaben jedes Absatzes; wählen Sie eine Richtung, wenn gemischter Text falsch ausgerichtet ist. Code bleibt immer linksläufig.', + textDirection: { auto: 'Auto', rtl: 'Rechts nach links', ltr: 'Links nach rechts' }, + introSplashTitle: 'Intro-Splash', + introSplashDesc: 'Das Wortzeichen und der Prompt, die bei einem leeren Chat angezeigt werden.', + reactionsTitle: 'Nachrichten-Reaktionen', + reactionsDesc: + 'Emoji-Tapbacks im iMessage-Stil – reagieren Sie auf Nachrichten, und Hermes kann auf Ihre reagieren.', + tipsTitle: 'In-App-Tipps', + tipsDesc: + 'Eine kleine Blase, die auf einen Teil der App zeigt und gelegentlich im Leerlauf sowie von Hermes erscheint, wenn es hilft. Beim Schließen wird sie für immer ausgeblendet.', + tipsReset: (count: number) => `${count} geschlossene ${count === 1 ? 'Blase' : 'Blasen'} zurückholen`, + toursTitle: 'Geführte Touren', + toursDesc: + 'Lassen Sie sich von Hermes durch die App führen – der Bildschirm wird abgedunkelt und jeder Schritt hervorgehoben.', + composerPopoutTitle: 'Schwebender Composer', + composerPopoutDesc: + 'Erlaubt, den Composer aus seiner Ablage herauszuziehen. Schalten Sie das aus, um ihn unten fixiert zu halten.', + vibeHeartsTitle: 'Vibe-Herzen', + vibeHeartsDesc: + 'Schwebende Herzen, wenn Sie danke, ilu, guter Bot sagen oder ein Herz senden. Unabhängig von den Nachrichten-Reaktionen oben.', + embedsTitle: 'Inline-Embeds', + embedsDesc: + 'Reichhaltige Vorschauen werden von Drittanbieter-Sites geladen (YouTube, X, …). „Fragen“ zeigt einen Platzhalter, bis Sie jede einzelne erlauben; „Immer“ lädt sie automatisch; „Aus“ belässt einfache Links.', + embedsAsk: 'Fragen', + embedsAlways: 'Immer', + embedsOff: 'Aus', + embedsReset: (count: number) => `${count} erlaubte ${count === 1 ? 'Aktivität' : 'Aktivitäten'} zurücksetzen`, + resumeLastSessionTitle: 'Letzten Chat beim Start wieder öffnen', + resumeLastSessionDesc: + 'Wenn aktiviert, öffnet die App beim Kaltstart Ihren letzten Chat wieder. Schalten Sie es aus, um immer mit einem frischen Chat zu starten.', + product: 'Produkt', + productDesc: 'Menschlich verständliche Tool-Aktivität mit knappen Zusammenfassungen.', + technical: 'Technisch', + technicalDesc: 'Rohe Tool-Argumente/-Ergebnisse und Low-Level-Details einbeziehen.', + themeTitle: 'Theme', + themeDesc: 'Nur Desktop-Paletten. Der gewählte Modus wird oben drauf angewendet.', + themeSearchPlaceholder: 'Ihre Themes oder den VS Code Marketplace durchsuchen…', + themeProfileNote: profile => `Für das Profil ${profile} gespeichert — jedes Profil behält sein eigenes Theme.`, + installTitle: 'Aus VS Code installieren', + installDesc: + 'Fügen Sie eine Marketplace-Erweiterungs-ID ein (z. B. dracula-theme.theme-dracula), um ihr Farbschema in eine Desktop-Palette umzuwandeln.', + installPlaceholder: 'publisher.extension', + installButton: 'Installieren', + installing: 'Wird installiert…', + installError: 'Dieses Theme konnte nicht installiert werden.', + installed: name => `„${name}“ installiert.`, + removeTheme: 'Theme entfernen', + importedBadge: 'Importiert', + pet: { + title: 'Haustier', + intro: + 'Adoptieren Sie ein animiertes Petdex-Maskottchen, das über der App schwebt und darauf reagiert, was Hermes gerade tut – es rennt, während Tools laufen, feiert bei Erfolg und schmollt bei Fehlern.', + restartHint: + 'Haustiere erfordern einen kurzen Neustart – die laufende App wurde gestartet, bevor diese Funktion hinzugefügt wurde. Schließen Sie Hermes, öffnen Sie es erneut und kehren Sie dann hierher zurück.', + on: 'An', + off: 'Aus', + scaleTitle: 'Größe', + scaleDesc: 'Ändert die Größe des schwebenden Maskottchens. Wirkt überall sofort.', + roamTitle: 'Herumstreifen', + roamDesc: 'Das Haustier wandert im Leerlauf selbstständig durch das Fenster.', + chooseTitle: 'Haustier auswählen', + chooseDesc: 'Auswählen installiert eines (falls nötig) und macht es aktiv.', + searchPlaceholder: 'Haustiere suchen…', + unreachable: + 'Die Petdex-Galerie konnte nicht erreicht werden. Prüfen Sie Ihre Verbindung und öffnen Sie diese Seite erneut.', + noMatch: query => `Keine Haustiere passen zu „${query}“.`, + installedTag: 'installiert', + generatedTag: 'Generiert', + countCapped: (cap, total) => `${cap} von ${total} angezeigt – tippen Sie, um die Auswahl einzugrenzen.`, + count: n => `${n} Haustier${n === 1 ? '' : 'er'}.`, + uninstall: name => `${name} deinstallieren`, + delete: name => `${name} löschen`, + deleteTitle: name => `${name} löschen?`, + deleteBody: 'Das löscht das Haustier endgültig — es kann nicht neu installiert werden.', + deleteConfirm: 'Löschen', + rename: name => `${name} umbenennen`, + renameTitle: 'Haustier umbenennen', + renamePlaceholder: 'Geben Sie Ihrem Haustier einen Namen', + renameSave: 'Speichern', + exportPet: name => `${name} exportieren`, + adoptFailed: slug => `„${slug}“ konnte nicht adoptiert werden`, + uninstallFailed: slug => `„${slug}“ konnte nicht deinstalliert werden`, + renameFailed: slug => `„${slug}“ konnte nicht umbenannt werden`, + exportFailed: slug => `„${slug}“ konnte nicht exportiert werden`, + noneAvailable: 'Aktuell sind keine Haustiere verfügbar, die eingeschaltet werden können.', + turnOnFailed: 'Das Haustier konnte nicht eingeschaltet werden.', + turnOffFailed: 'Das Haustier konnte nicht ausgeschaltet werden.' + } + }, + fieldLabels: defineFieldCopy({ + model: 'Standardmodell', + modelContextLength: 'Kontextfenster', + fallbackProviders: 'Fallback-Modelle', + toolsets: 'Aktivierte Toolsets', + timezone: 'Zeitzone', + display: { + personality: 'Persönlichkeit', + showReasoning: 'Denkblöcke' + }, + desktop: { + repoScanEnabled: 'Automatische Repository-Erkennung', + repoScanRoots: 'Repository-Erkennungs-Wurzeln', + repoScanExcludePaths: 'Ausgeschlossene Repository-Pfade' + }, + agent: { + maxTurns: 'Maximale Agent-Schritte', + imageInputMode: 'Bildanhänge', + apiMaxRetries: 'API-Wiederholungen', + serviceTier: 'Service-Stufe', + toolUseEnforcement: 'Tool-Nutzungs-Durchsetzung' + }, + terminal: { + cwd: 'Arbeitsverzeichnis', + backend: 'Ausführungs-Backend', + timeout: 'Befehls-Timeout', + persistentShell: 'Persistente Shell', + envPassthrough: 'Umgebungsvariablen-Durchreichung', + dockerImage: 'Docker-Image', + singularityImage: 'Singularity-Image', + modalImage: 'Modal-Image', + daytonaImage: 'Daytona-Image' + }, + fileReadMaxChars: 'Datei-Lesegrenze', + toolOutput: { + maxBytes: 'Terminal-Ausgabelimit', + maxLines: 'Datei-Seitenlimit', + maxLineLength: 'Zeilenlängenlimit' + }, + codeExecution: { + mode: 'Code-Ausführungsmodus' + }, + approvals: { + mode: 'Genehmigungsmodus', + timeout: 'Genehmigungs-Timeout', + mcpReloadConfirm: 'MCP-Neuladen bestätigen' + }, + commandAllowlist: 'Befehls-Whitelist', + security: { + redactSecrets: 'Geheimnisse schwärzen', + allowPrivateUrls: 'Private URLs erlauben' + }, + browser: { + allowPrivateUrls: 'Private Browser-URLs', + autoLocalForPrivateUrls: 'Lokaler Browser für private URLs', + useRealProfile: 'Mein echtes Browser-Profil verwenden' + }, + checkpoints: { + enabled: 'Datei-Checkpoints', + maxSnapshots: 'Checkpoint-Limit' + }, + voice: { + recordKey: 'Sprach-Tastenkürzel', + maxRecordingSeconds: 'Maximale Aufnahmelänge', + autoTts: 'Antworten vorlesen', + voiceChatMode: 'Sprachchat-Modus', + gptLive: { + voice: 'GPT-Live-Stimme', + instructions: 'GPT-Live-Persona' + } + }, + stt: { + enabled: 'Spracherkennung', + echoTranscripts: 'Transkripte wiedergeben', + provider: 'Spracherkennungs-Anbieter', + local: { + model: 'Lokales Transkriptionsmodell', + language: 'Transkriptionssprache' + }, + openai: { + model: 'OpenAI-STT-Modell' + }, + groq: { + model: 'Groq-STT-Modell' + }, + mistral: { + model: 'Mistral-STT-Modell' + }, + elevenlabs: { + modelId: 'ElevenLabs-STT-Modell', + languageCode: 'ElevenLabs-Sprache', + tagAudioEvents: 'Audio-Ereignisse markieren', + diarize: 'Sprecher-Diarisierung' + } + }, + tts: { + provider: 'Text-zu-Sprache-Anbieter', + edge: { + voice: 'Edge-Stimme' + }, + openai: { + model: 'OpenAI-TTS-Modell', + voice: 'OpenAI-Stimme' + }, + elevenlabs: { + voiceId: 'ElevenLabs-Stimme', + modelId: 'ElevenLabs-Modell' + }, + xai: { + voiceId: 'xAI (Grok) Stimme', + language: 'xAI-Sprache', + speed: 'xAI-Wiedergabegeschwindigkeit', + autoSpeechTags: 'xAI automatische Sprach-Tags', + optimizeStreamingLatency: 'xAI Streaming-Latenz-Optimierung', + sampleRate: 'xAI-Abtastrate', + bitRate: 'xAI-Bitrate' + }, + minimax: { + model: 'MiniMax-TTS-Modell', + voiceId: 'MiniMax-Stimme' + }, + mistral: { + model: 'Mistral-TTS-Modell', + voiceId: 'Mistral-Stimme' + }, + gemini: { + model: 'Gemini-TTS-Modell', + voice: 'Gemini-Stimme' + }, + neutts: { + model: 'NeuTTS-Modell', + device: 'NeuTTS-Gerät' + }, + kittentts: { + model: 'KittenTTS-Modell', + voice: 'KittenTTS-Stimme' + }, + piper: { + voice: 'Piper-Stimme' + }, + deepinfra: { + model: 'DeepInfra-TTS-Modell', + voice: 'DeepInfra-Stimme' + } + }, + memory: { + memoryEnabled: 'Persistentes Gedächtnis', + userProfileEnabled: 'Benutzerprofil', + memoryCharLimit: 'Gedächtnis-Budget', + userCharLimit: 'Profil-Budget', + provider: 'Gedächtnis-Anbieter' + }, + context: { + engine: 'Kontext-Engine' + }, + compression: { + enabled: 'Auto-Kompression', + threshold: 'Kompression-Schwelle', + codexGpt55Autoraise: 'Automatische Codex-Komprimierungsanhebung', + targetRatio: 'Kompression-Ziel', + protectLastN: 'Geschützte letzte Nachrichten' + }, + auxiliary: { + compression: { + timeout: 'Timeout des Komprimierungsmodells (s)' + } + }, + delegation: { + model: 'Subagent-Modell', + provider: 'Subagent-Anbieter', + maxIterations: 'Subagent-Rundenlimit', + maxConcurrentChildren: 'Parallele Subagenten', + childTimeoutSeconds: 'Subagent-Timeout', + reasoningEffort: 'Subagent-Denkanstrengung' + }, + updates: { + nonInteractiveLocalChanges: 'Lokale Änderungen bei In-App-Update' + } + }), + fieldDescriptions: defineFieldCopy({ + model: 'Wird für neue Chats verwendet, sofern Sie im Composer kein anderes Modell wählen.', + modelContextLength: 'Auf 0 lassen, um das erkannte Kontextfenster des gewählten Modells zu verwenden.', + fallbackProviders: 'Backup-Anbieter:Modell-Einträge, die versucht werden, wenn das Standardmodell fehlschlägt.', + display: { + personality: 'Standard-Assistentenstil für neue Sessions.', + showReasoning: 'Denkabschnitte anzeigen, wenn das Backend sie liefert.' + }, + desktop: { + repoScanEnabled: 'Lokale Ordner nach Git-Repositories durchsuchen, die in Projekten angezeigt werden.', + repoScanRoots: 'Zu durchsuchende Ordner. Leer lassen, um Ihr Home-Verzeichnis zu durchsuchen.', + repoScanExcludePaths: 'Ordner und deren Unterordner, die bei der Repository-Erkennung übersprungen werden.' + }, + timezone: 'IANA-Zeitzonenkennung. Leer verwendet die Systemzeitzone.', + browser: { + useRealProfile: + 'Lokales Browsen nutzt Ihre echten Anmeldungen. Hermes kopiert das Profil Ihres Standardbrowsers (Cookies, Anmeldungen, Einstellungen) in einen verwalteten Schnappschuss und steuert ihn mit seinem gebündelten Chromium – Ihr Live-Profil wird nie direkt geöffnet, und die Kopie wird bei jedem Lauf daraus aktualisiert. Erlaubt dem Agenten außerdem, auf Anfrage eine lokale Session mit echtem Profil zu öffnen, selbst wenn ein Cloud-Browser-Backend konfiguriert ist. Nur Chromium-Browser (Chrome, Edge, Brave, Brave Origin, Chromium) werden unterstützt; ein Nicht-Chromium-Standard schlägt mit einer klaren Meldung fehl. Standardmäßig aus.' + }, + agent: { + imageInputMode: 'Steuert, wie Bildanhänge an das Modell gesendet werden.', + maxTurns: 'Obergrenze für Tool-Aufruf-Runden, bevor Hermes einen Lauf stoppt.' + }, + terminal: { + cwd: 'Standard-Projektordner für Tool- und Terminal-Arbeit.', + persistentShell: 'Shell-Zustand zwischen Befehlen beibehalten, wenn das Backend es unterstützt.', + envPassthrough: 'Umgebungsvariablen, die in die Tool-Ausführung durchgereicht werden.', + dockerImage: 'Container-Image, das verwendet wird, wenn das Ausführungs-Backend Docker ist.', + singularityImage: 'Image, das verwendet wird, wenn das Ausführungs-Backend Singularity ist.', + modalImage: 'Image, das verwendet wird, wenn das Ausführungs-Backend Modal ist.', + daytonaImage: 'Image, das verwendet wird, wenn das Ausführungs-Backend Daytona ist.' + }, + codeExecution: { + mode: 'Wie streng die Code-Ausführung auf das aktuelle Projekt begrenzt ist.' + }, + fileReadMaxChars: 'Maximale Zeichenzahl, die Hermes aus einer Dateianfrage lesen kann.', + approvals: { + mode: 'Wie Hermes Befehle behandelt, die eine explizite Genehmigung benötigen.', + timeout: 'Wie lange Genehmigungsaufforderungen warten, bevor sie ablaufen.' + }, + security: { + redactSecrets: 'Erkannte Geheimnisse nach Möglichkeit aus modellsichtbarem Inhalt ausblenden.' + }, + checkpoints: { + enabled: 'Rollback-Schnappschüsse vor Dateibearbeitungen erstellen.' + }, + memory: { + memoryEnabled: 'Dauerhafte Erinnerungen speichern, die zukünftigen Sessions helfen können.', + userProfileEnabled: 'Ein kompaktes Profil der Benutzerpräferenzen pflegen.' + }, + context: { + engine: 'Strategie zur Verwaltung langer Gespräche nahe der Kontextgrenze.' + }, + compression: { + enabled: 'Älteren Kontext zusammenfassen, wenn Gespräche groß werden.', + codexGpt55Autoraise: 'Komprimierung bei unterstützten ChatGPT-Codex-OAuth-Modellen auf 85 % anheben.' + }, + auxiliary: { + compression: { + timeout: + 'Sekunden, die pro Aufruf auf das Hilfsmodell für Komprimierung gewartet wird (Standard 120). Für langsame lokale Modelle erhöhen.' + } + }, + voice: { + autoTts: 'Assistentenantworten automatisch vorlesen.', + voiceChatMode: + 'chained: Sprache zu Text → Hermes → Text zu Sprache mit den Anbietern unten. gpt-live: Ein Vollduplex-Sprachmodell von OpenAI (gpt-live-1) hört zu und spricht und übergibt jede echte Anfrage an Hermes – das von Ihnen gewählte Modell antwortet mit allen Tools. Erfordert einen OpenAI-API-Schlüssel; die Sprachschicht kostet 0,05 $ pro Minute.', + gptLive: { + voice: 'Stimme für den GPT-Live-Modus. Eigene Stimm-IDs werden akzeptiert.', + instructions: + 'Zusätzliche Sätze für die Live-Sprachpersona (Ton, Tempo, Sprache). Hermes behält seinen eigenen System-Prompt.' + } + }, + tts: { + xai: { + voiceId: 'xAI-Stimm-ID (z. B. eve) oder eine benutzerdefinierte Stimm-ID.', + language: 'Sprachcode (z. B. en, pt-BR) oder „auto“ für automatische Erkennung.', + speed: 'Wiedergabegeschwindigkeit. 0,7 = langsamer, 1,0 = normal, 1,5 = schneller.', + autoSpeechTags: + 'Ein LLM expressive Audio-Tags ([laughing], [sighs]) vor der Synthese in das Skript einfügen lassen.', + optimizeStreamingLatency: 'Latenz- vs. Qualitäts-Abwägung. 0 = beste Qualität, 2 = niedrigste Latenz.', + sampleRate: 'Audio-Abtastrate in Hz. Höher = bessere Qualität, größere Dateien.', + bitRate: 'MP3-Bitrate in bps. Gilt nur, wenn der Codec mp3 ist.' + }, + neutts: { + device: 'Lokales Inferenzgerät für NeuTTS.' + } + }, + stt: { + enabled: 'Lokale oder anbieterbasierte Sprachtranskription aktivieren.', + echoTranscripts: 'Das rohe 🎙️-Transkript von Sprachnachrichten zurück in den Chat posten.', + elevenlabs: { + languageCode: 'Optionaler ISO-639-3-Sprachcode. Leer lässt ElevenLabs automatisch erkennen.' + } + }, + updates: { + nonInteractiveLocalChanges: + 'Wenn Hermes sich aus der App selbst aktualisiert (ohne Terminal-Aufforderung), lokale Quellcode-Änderungen behalten (stash) oder verwerfen (discard). Terminal-Updates fragen immer nach.' + } + }), + uninstallSection: { + dangerZone: 'Gefahrenzone', + checkingInstalled: 'Installierte Komponenten werden geprüft…', + uninstallHermes: 'Hermes deinstallieren', + chooseHowMuch: + 'Wählen Sie, wie viel entfernt werden soll. Die App wird zum Abschluss geschlossen; Sie können das Installationsprogramm jederzeit erneut öffnen, um zurückzukehren.', + confirmUninstall: 'Deinstallation bestätigen', + confirmBody: what => `Dadurch wird Folgendes entfernt: ${what}. Dies kann nicht rückgängig gemacht werden.`, + appLabel: 'App:', + couldNotStart: 'Die Deinstallation konnte nicht gestartet werden.', + uninstalling: 'Wird deinstalliert…', + yesUninstall: 'Ja, deinstallieren', + options: { + gui: { + title: 'Nur die Chat-Oberfläche deinstallieren', + description: + 'Entfernt diese Desktop-App. Der Hermes-Agent, Ihre Konfiguration und Ihre Chats bleiben erhalten.', + consequence: 'die Desktop-Chat-Oberfläche (diese App und ihre Daten)' + }, + lite: { + title: 'Oberfläche + Agent deinstallieren, Daten behalten', + description: + 'Entfernt die App und den Hermes-Agent, behält aber Konfiguration, Chats und Geheimnisse für eine spätere Neuinstallation.', + consequence: + 'die Chat-Oberfläche und den Hermes-Agent (Konfiguration, Chats und Geheimnisse bleiben erhalten)' + }, + full: { + title: 'Alles deinstallieren', + description: + 'Entfernt die App, den Agent und alle Benutzerdaten – Konfiguration, Chats, geplante Jobs, Geheimnisse, Logs.', + consequence: + 'ALLES – die Chat-Oberfläche, den Hermes-Agent sowie Ihre gesamte Konfiguration, Chats, Geheimnisse und Logs' + } + } + }, + poolLimits: { + warmBotBackendsAria: 'Bot-Backends vorwärmen', + warmBotBackendsTitle: 'Bot-Backends vorwärmen', + backendIdleTimeoutAria: 'Leerlauf-Timeout des Backends in Millisekunden', + backendIdleTimeoutTitle: 'Leerlauf-Timeout des Backends' + }, + customEndpoints: { + active: 'Aktiv', + apiKeySet: 'API-Schlüssel gesetzt', + use: 'Verwenden', + editTitle: 'Endpunkt bearbeiten', + addTitle: 'Endpunkt hinzufügen', + fields: { + name: 'Name', + providerId: 'Anbieter-ID', + endpointUrl: 'Endpunkt-URL', + defaultModel: 'Standardmodell', + context: 'Kontext', + apiKey: 'API-Schlüssel', + apiKeyNewPlaceholder: 'Leer lassen, um den aktuellen Schlüssel zu behalten', + apiKeyPlaceholder: 'Optional', + useNewChats: 'Für neue Chats verwenden', + discoverModels: 'Modelle ermitteln' + }, + test: 'Testen', + save: 'Speichern', + newEndpoint: 'Neuer Endpunkt', + apiMode: 'API-Modus', + autoDetect: 'Automatisch erkennen', + couldNotLoad: 'Benutzerdefinierte Endpunkte konnten nicht geladen werden', + endpointSaved: 'Benutzerdefinierter Endpunkt gespeichert.', + saveFailed: 'Speichern fehlgeschlagen', + endpointReachable: 'Der Endpunkt ist erreichbar.', + endpointReachableTransport: transport => `Der Endpunkt ist erreichbar (${transport}-Route bedient).`, + endpointReachableModels: (reachable, count) => `${reachable} ${count} Modell${count === 1 ? '' : 'e'} gefunden.`, + endpointValidationFailed: 'Die Validierung des Endpunkts ist fehlgeschlagen.', + validationFailed: 'Validierung fehlgeschlagen', + activationFailed: 'Aktivierung fehlgeschlagen', + deleteConfirm: name => `${name} löschen?`, + deleteFailed: 'Löschen fehlgeschlagen', + title: 'Eigene Endpunkte', + deleteEndpoint: 'Endpunkt löschen', + emptyDescription: 'Fügen Sie unten einen OpenAI-kompatiblen Endpunkt hinzu.', + emptyTitle: 'Keine eigenen Endpunkte', + namePlaceholder: 'Axet Proxy', + contextPlaceholder: 'Automatisch' + }, + computerUse: { + accessibility: 'Bedienungshilfen', + screenRecording: 'Bildschirmaufnahme', + driverHealth: 'Treiberstatus' + }, + about: { + updates: 'Updates' + }, + config: { + minimizeToTrayTitle: 'In den Infobereich minimieren', + minimizeToTrayDesc: + 'Beim Minimieren von Fenstern oder Schließen des Hauptfensters werden diese im Infobereich (Menüleiste unter macOS) ausgeblendet und Hermes läuft weiter. Beenden Sie über „Hermes beenden“ im Infobereich-Menü oder mit Cmd+Q. Standardmäßig aus; gilt nur für dieses Gerät.', + minimizeToTrayUnavailable: + 'Der Infobereich ist nicht verfügbar. Fenster werden normal minimiert und geschlossen. Schalten Sie die Option aus und wieder ein, um es erneut zu versuchen.', + none: 'Keine', + noneParen: '(keine)', + builtinOnly: 'Nur eingebaut', + notSet: 'Nicht festgelegt', + commaSeparated: 'durch Komma getrennte Werte', + searchPlaceholder: 'Suchen…', + noResults: 'Keine Ergebnisse gefunden', + systemDefault: 'Systemstandard', + loading: 'Hermes-Konfiguration wird geladen...', + emptyTitle: 'Nichts zu konfigurieren', + emptyDesc: 'Dieser Bereich hat keine einstellbaren Optionen.', + failedLoad: 'Einstellungen konnten nicht geladen werden', + autosaveFailed: 'Autospeichern fehlgeschlagen', + imported: 'Konfiguration importiert', + invalidJson: 'Ungültige Konfigurations-JSON', + toolsetsWipeConfirm: + 'Alle aktivierten Toolsets entfernen? Das deaktiviert Speicher, Terminal, Websuche, Delegation und die meisten anderen Tools, bis Sie sie wieder aktivieren.', + keepAwakeTitle: 'Computer wach halten', + keepAwakeDesc: + 'Verhindert, dass dieser Rechner in den Ruhezustand wechselt, damit Läufe über Nacht oder länger weiterlaufen. Der Bildschirm kann trotzdem abdunkeln.', + disableF12Title: 'F12-DevTools deaktivieren', + disableF12Desc: + 'Verhindert, dass F12 die Entwicklertools öffnet. Strg+Umschalt+I (bzw. Cmd+Opt+I auf dem Mac) funktioniert weiterhin.', + attachmentSizeTitle: 'Maximale Vorschau-/Bildladegröße', + attachmentSizeDesc: + 'Wie groß eine lokale Datei sein darf, die Desktop für Vorschauen und Bildanhänge lädt, in MB. Standard ist 16. Remote-Anhänge ohne Bild verwenden ein eigenes Limit von 256 MB. Ein sehr hoher Wert lädt die gesamte Datei in den Speicher, was die App einfrieren oder abstürzen lassen kann.', + attachmentSizeUnit: 'MB', + attachmentSizeLabel: 'Maximale Vorschau-/Bildladegröße in Megabyte', + showOptions: 'Optionen anzeigen' + }, + hudModifier: { + title: 'Tippen, um das HUD aufzurufen', + description: + 'Tippen Sie kurz auf ⌘ + Option (Mac) bzw. Strg + Alt (Windows/Linux), um das HUD aus jeder App nach vorn zu holen. Standardmäßig aus; gilt nur für dieses Gerät.', + permission: + 'Erlauben Sie Hermes unter Systemeinstellungen → Datenschutz & Sicherheit → Eingabeüberwachung und versuchen Sie es erneut. Diese Geste zeichnet keine Tastenanschläge auf und nimmt Ihren Bildschirm nicht auf.', + unavailable: + 'Das Hilfsprogramm für die HUD-Geste konnte nicht starten oder wurde unerwartet beendet. Versuchen Sie es erneut oder starten Sie Hermes neu. Das bestehende HUD-Tastenkürzel funktioniert innerhalb von Hermes weiterhin.', + missingHelper: + 'In dieser Hermes-Installation fehlt das Hilfsprogramm für die HUD-Geste. Aktualisieren oder installieren Sie Hermes neu und versuchen Sie es erneut.', + unsupportedSession: + 'Diese Desktop-Session unterstützt keine globalen Modifikator-Taps. Linux erfordert X11; Wayland wird nicht unterstützt.' + }, + screenshot: { + enabledTitle: 'Screenshot-Kurzbefehl', + enabledDesc: + 'Drücken Sie in einer beliebigen App beide Befehlstasten gleichzeitig, um deren vorderstes Fenster aufzunehmen und an Ihren aktuellen Hermes-Entwurf anzuhängen. Es wird nie automatisch gesendet. Standardmäßig aus; gilt nur für diesen Mac. Fensterinhalte können vertraulich sein – prüfen Sie den Anhang vor dem Senden.', + statusTitle: 'Status des Screenshot-Kurzbefehls', + checking: 'Screenshot-Kurzbefehl wird geprüft…', + disabled: 'Der Screenshot-Kurzbefehl ist aus.', + starting: 'Der Kurzbefehl-Listener wird gestartet. Er ist noch nicht bereit.', + ready: + 'Der Kurzbefehl ist bereit. Screenshots werden an Ihren aktuellen Entwurf angehängt, ohne gesendet zu werden.', + inputPermission: + 'Mit der Berechtigung „Eingabeüberwachung“ kann Hermes beide Befehlstasten erkennen, während eine andere App aktiv ist. Erlauben Sie Hermes unter Systemeinstellungen → Datenschutz & Sicherheit → Eingabeüberwachung, kehren Sie dann hierher zurück und versuchen Sie es erneut.', + screenPermission: + 'Mit der Berechtigung „Bildschirmaufnahme“ kann Hermes das vorderste App-Fenster aufnehmen, wenn Sie diesen Kurzbefehl verwenden. Erlauben Sie Hermes unter Systemeinstellungen → Datenschutz & Sicherheit → Bildschirmaufnahme, kehren Sie dann hierher zurück und versuchen Sie es erneut. Starten Sie Hermes neu, wenn macOS dazu auffordert.', + openSettings: 'Systemeinstellungen öffnen', + retry: 'Erneut versuchen', + unavailable: 'Der Screenshot-Kurzbefehl ist nicht verfügbar. Versuchen Sie es erneut oder schalten Sie ihn aus.', + errorTitle: 'Fehler beim Screenshot-Kurzbefehl', + loadFailed: + 'Der Status des Kurzbefehls konnte nicht gelesen werden. Versuchen Sie es erneut, um die aktuelle Einstellung zu prüfen.', + saveFailed: + 'Die Änderung am Kurzbefehl konnte nicht bestätigt werden. Versuchen Sie es erneut, um die aktuelle Einstellung zu prüfen.', + permissionFailed: + 'Die Systemeinstellungen konnten nicht geöffnet werden. Öffnen Sie „Datenschutz & Sicherheit“ manuell und versuchen Sie es erneut.', + captureFailed: 'Das vorderste Fenster konnte nicht aufgenommen werden. Es wurde nichts angehängt oder gesendet.', + contextChanged: + 'Der aktuelle Entwurf hat sich während der Aufnahme geändert. Der Screenshot wurde weder angehängt noch gesendet.' + }, + quickEntry: { + enabledTitle: 'Schnelleingabe', + enabledDesc: + 'Öffnen Sie mit einem globalen Tastaturkürzel von überall einen kleinen Eingabebereich und senden Sie einen Prompt, ohne Hermes zu öffnen.', + shortcutTitle: 'Tastaturkürzel der Schnelleingabe', + shortcutDesc: 'Benötigt mindestens eine Zusatztaste, z. B. CommandOrControl+Shift+Leertaste.', + active: 'Das Tastaturkürzel ist aktiv.', + takenBy: 'Eine andere App verwendet dieses Tastaturkürzel bereits — wählen Sie ein anderes.', + invalidShortcut: 'Kein gültiges Tastaturkürzel. Fügen Sie mindestens eine Zusatztaste hinzu.' + }, + credentials: { + pasteKey: 'Schlüssel einfügen', + pasteLabelKey: label => `Schlüssel für ${label} einfügen`, + optional: 'Optional', + enterValueFirst: 'Geben Sie zuerst einen Wert ein.', + couldNotSave: 'Die Anmeldedaten konnten nicht gespeichert werden.', + remove: 'Entfernen', + getKey: 'Einen Schlüssel erhalten', + saving: 'Wird gespeichert' + }, + envActions: { + actions: 'Aktionen', + manageInKeys: 'Unter API-Schlüssel verwalten', + docs: 'Doku', + hideValue: 'Wert ausblenden', + revealValue: 'Wert anzeigen', + replace: 'Ersetzen', + set: 'Setzen', + clear: 'Leeren' + }, + connections: { + title: 'Registrierte Gateways', + intro: + 'Verwalten Sie dieses Gerät und jedes Hermes Gateway, das es über Remote-, SSH- oder Cloud-Verbindungen erreichen kann.', + stagedNote: + 'Wechseln Sie Gateways über Sessions. Profile, Chats, Nachrichten und Cron-Jobs bleiben bei ihrem Gateway; Arbeit auf anderen Gateways läuft weiter.', + launchModeTitle: 'Beim Start zu Sessions auf dem zuletzt verwendeten Gateway zurückkehren', + launchModeDesc: 'Wenn deaktiviert, öffnet Sessions auf dem primären Gateway.', + searchPlaceholder: 'Gateways durchsuchen…', + noSearchResults: 'Keine Gateways entsprechen Ihrer Suche.', + loadFailed: 'Verbindungen konnten nicht geladen werden', + currentPill: 'Aktuell', + primaryPill: 'Primär', + managedPill: 'Von der App verwaltet', + addConnection: 'Verbindung hinzufügen', + editConnection: 'Bearbeiten', + removeConnection: 'Entfernen', + removeConfirmTitle: 'Diese Verbindung entfernen?', + removeConfirmDesc: (label: string) => + `„${label}“ wird aus dieser App entfernt. Die Instanz selbst bleibt unberührt – Sie können sie jederzeit wieder hinzufügen.`, + makePrimary: 'Als primär festlegen', + testConnection: 'Testen', + testOk: 'Erreichbar', + testFailed: 'Verbindungstest fehlgeschlagen', + saveFailed: 'Die Verbindung konnte nicht gespeichert werden', + removeFailed: 'Die Verbindung konnte nicht entfernt werden', + updateAll: 'Alle Instanzen aktualisieren', + updateAllRunning: 'Alle Instanzen werden aktualisiert…', + updateAllDone: 'Updates versendet', + updateAllFailed: 'Update-Verteilung fehlgeschlagen', + updateSkippedCloud: 'Wird von Hermes Cloud verwaltet', + kindLocal: 'Lokal', + kindRemote: 'Remote-Gateway', + kindCloud: 'Hermes Cloud', + kindSsh: 'SSH', + kindLocalDesc: 'Die Hermes-Laufzeitumgebung, die von dieser App verwaltet wird.', + kindRemoteDesc: 'Ein Hermes Gateway, das über HTTP(S) erreichbar ist – LAN, Tailscale oder das Internet.', + kindCloudDesc: 'Eine gehostete Instanz, die über Ihr Hermes-Cloud-Konto gefunden wurde.', + kindSshDesc: 'Eine Hermes-Installation, die über SSH erreicht wird.', + labelTitle: 'Name', + labelDesc: + 'Pflichtfeld. Wird überall angezeigt, wo diese Instanz erscheint; muss eindeutig sein (z. B. „Homelab“, „Arbeitslaptop“).', + labelPlaceholder: 'Homelab', + urlTitle: 'Gateway-URL', + sshHostTitle: 'SSH-Host', + headersTitle: 'Zusätzliche Gateway-Header', + headersDesc: + 'Wird mit jeder HTTP- und WebSocket-Anfrage an dieses Gateway gesendet – für Zugriffs-Proxys wie Cloudflare Access (CF-Access-Client-Id / CF-Access-Client-Secret). Werte werden verschlüsselt gespeichert. Header, die Hermes verwaltet (Authorization, Cookie, Host…), werden ignoriert.', + headerValuePlaceholder: 'Wert', + headerValueSaved: 'Gespeichert – leer lassen, um zu behalten', + headerAdd: 'Header hinzufügen', + headerRemove: 'Entfernen', + duplicateLocal: 'Diese App verwaltet bereits eine lokale Verbindung – es kann nur eine geben.', + duplicateUrl: (label: string) => `Eine Verbindung zu dieser Gateway-URL existiert bereits („${label}“).`, + duplicateSsh: (label: string) => `Eine Verbindung zu diesem SSH-Host existiert bereits („${label}“).`, + sameBackendHint: (label: string) => `Gleiches Backend wie „${label}“`, + localAddHint: 'Lokal ist nicht verfügbar: Die verwaltete lokale Verbindung existiert bereits (es gibt nur eine).', + cloudAddHint: + 'Tipp: Die Anmeldung unter Hermes Cloud oben erkennt Ihre Agents automatisch – verwenden Sie dieses Formular nur, um eine bekannte Instanz-URL manuell zu registrieren.', + save: 'Verbindung speichern', + saving: 'Wird gespeichert…', + cancel: 'Abbrechen', + empty: 'Noch keine Verbindungen registriert.' + }, + managedUpdates: { + title: 'Verwaltete Updates', + intro: + 'Aktualisiert von Desktop verwaltete SSH-Installationen transaktional: Sessions werden beendet, das Remote-Repository wird aktualisiert und jedes Profil wird mit einer zugehörigen Quittung wiederhergestellt.', + sshConnection: 'Desktop-verwaltete SSH-Installation', + update: 'Aktualisieren', + updating: 'Wird aktualisiert…', + progress: + 'Sessions werden beendet, die Remote-Installation wird aktualisiert und Profile werden wiederhergestellt…', + updated: 'Aktualisiert', + partial: 'Aktualisiert – Wiederherstellung fehlgeschlagen', + refused: 'Verweigert', + failed: 'Update fehlgeschlagen', + alreadyRunning: 'Update läuft bereits', + receipt: (id: string, outcome: string) => `Quittung ${id} · ${outcome}`, + receiptVersions: (pre: string, post: string) => `${pre} → ${post}`, + scopesRestored: (profiles: string) => `Wiederhergestellte Profile: ${profiles}`, + scopeNotRestored: (profile: string, error: string) => `Profil „${profile}“ nicht wiederhergestellt: ${error}` + }, + gateway: { + loading: 'Gateway-Einstellungen werden geladen…', + unavailableTitle: 'Gateway-Einstellungen nicht verfügbar', + unavailableDesc: 'Die Desktop-IPC-Brücke stellt keine Gateway-Einstellungen bereit.', + title: 'Gateway-Verbindung', + envOverride: 'ENV-Überschreibung', + intro: + 'Standardmäßig lokal. Verwenden Sie Remote, wenn diese App ein Hermes-Backend an einem anderen Ort steuern soll. Gateway-Verbindungen gelten pro Gerät; Profile werden von den Gateways ermittelt, mit denen Sie sich verbinden.', + envOverrideTitle: 'Umgebungsvariablen steuern diese Desktop-Session.', + envOverrideDesc: + 'Entfernen Sie HERMES_DESKTOP_REMOTE_URL und HERMES_DESKTOP_REMOTE_TOKEN, um die unten gespeicherte Einstellung zu verwenden.', + modeTitle: 'Verbindungsmodus', + localTitle: 'Lokales Gateway', + localDesc: 'Startet ein privates Hermes-Backend auf localhost. Das ist der Standard und funktioniert offline.', + remoteTitle: 'Remote-Gateway', + remoteDesc: 'Verbindet diese Desktop-Shell mit einem entfernten Hermes-Backend.', + remoteAuthHint: + 'Gehostete Gateways verwenden OAuth oder Benutzername und Passwort; selbst gehostete können ein Session-Token verwenden.', + cloudTitle: 'Hermes Cloud', + cloudDesc: + 'Melden Sie sich einmal bei Hermes Cloud an und wählen Sie aus den Agents in Ihrem Konto – ohne eine URL einzufügen.', + cloudSignInTitle: 'Hermes Cloud', + cloudSignIn: 'Bei Hermes Cloud anmelden', + cloudSignedIn: 'Bei Hermes Cloud angemeldet', + cloudNeedsSignIn: 'Melden Sie sich bei Hermes Cloud an, um die Agents in Ihrem Konto zu finden.', + cloudSignedInDesc: + 'Sie sind angemeldet. Wählen Sie unten einen Agent; die Session wird automatisch aktualisiert.', + cloudAgentsTitle: 'Ihre Agents', + cloudOrgPickerTitle: 'Organisation auswählen', + cloudOrgSelect: 'Auswählen', + cloudOrgChange: 'Organisation ändern', + cloudOrgRole: role => `Rolle: ${role}`, + cloudLoadingAgents: 'Ihre Agents werden geladen…', + cloudNoAgents: { + before: 'Keine Agents in diesem Konto gefunden. Legen Sie einen im ', + linkText: 'Nous-Portal', + after: ' an und aktualisieren Sie dann.' + }, + cloudRefresh: 'Aktualisieren', + cloudConnect: 'Verbinden', + cloudSavedTitle: 'Gespeicherte Cloud-Gateways', + cloudSavedDesc: + 'Verwenden Sie ein gespeichertes Gateway, ohne Ihren Standard zu ändern. Melden Sie sich unten an, um Instanzen hinzuzufügen. Namen und Anmeldung verwalten Sie in der Liste der gespeicherten Verbindungen.', + cloudUseSaved: 'Gateway verwenden', + cloudActive: 'In diesem Fenster aktiv', + cloudConnecting: 'Verbindung wird hergestellt…', + cloudDiscoverFailed: 'Ihre Hermes-Cloud-Agents konnten nicht geladen werden', + cloudConnectFailed: 'Keine Verbindung zu diesem Agent möglich', + cloudSignInFailed: 'Anmeldung bei Hermes Cloud fehlgeschlagen', + cloudSignedOutTitle: 'Von Hermes Cloud abgemeldet', + cloudSignedOutMessage: 'Die Hermes-Cloud-Session wurde geleert.', + cloudConnectedTitle: 'Verbunden', + cloudConnectedPill: 'Verbunden', + cloudConnectedTo: name => `Mit ${name} verbunden.`, + cloudAgentProvisioning: 'Provisionierung…', + cloudStatusLabel: status => `Status: ${status}`, + remoteUrlTitle: 'Remote-URL', + remoteUrlDesc: 'Basis-URL für das Remote-Dashboard-Backend. Pfad-Präfixe werden unterstützt, z. B. /hermes.', + probing: 'Authentifizierungsmethode dieses Gateways wird geprüft…', + probeError: + 'Dieses Gateway ist noch nicht erreichbar. Prüfen Sie die URL – die Authentifizierungsmethode erscheint, sobald es antwortet.', + signedIn: 'Angemeldet', + signIn: 'Anmelden', + signOut: 'Abmelden', + signInWith: provider => `Mit ${provider} anmelden`, + authTitle: 'Authentifizierung', + authSignedInPassword: + 'Dieses Gateway verwendet Benutzername und Passwort. Sie sind angemeldet; die Session wird automatisch aktualisiert.', + authSignedInOauth: + 'Dieses Gateway verwendet OAuth. Sie sind angemeldet; die Session wird automatisch aktualisiert.', + authNeedsPassword: + 'Dieses Gateway verwendet Benutzername und Passwort. Melden Sie sich an, um diese Desktop-App zu autorisieren.', + authNeedsOauth: provider => + `Dieses Gateway verwendet OAuth. Melden Sie sich mit ${provider} an, um diese Desktop-App zu autorisieren.`, + tokenTitle: 'Session-Token', + tokenDesc: + 'Das Dashboard-Session-Token für REST- und WebSocket-Zugriff. Leer lassen, um das gespeicherte Token zu behalten.', + existingToken: value => `Bestehendes Token ${value}`, + savedToken: 'gespeichert', + pasteSessionToken: 'Session-Token einfügen', + plainTextConfirmTitle: 'Gateway-Token im Klartext speichern?', + plainTextConfirmDesc: + 'Auf diesem Gerät wurde kein Schlüsselbund-Dienst des Betriebssystems gefunden. Das Token würde daher unverschlüsselt in der Verbindungseinstellungsdatei der App gespeichert und wäre für jeden Prozess dieses Benutzers lesbar. Installieren oder aktivieren Sie GNOME Keyring oder KWallet für verschlüsselte Speicherung.', + plainTextConfirmAction: 'Als Klartext speichern', + plainTextStoredTitle: 'Token im Klartext gespeichert', + plainTextStoredDesc: + 'Sichere Speicherung ist nicht verfügbar, daher wird das gespeicherte Token unverschlüsselt in der Verbindungseinstellungsdatei der App auf diesem Gerät gespeichert. Installieren oder aktivieren Sie GNOME Keyring oder KWallet, um es zu verschlüsseln.', + keychainEncryptionTitle: 'Gespeicherte Geheimnisse mit dem OS-Keychain verschlüsseln', + keychainEncryptionDesc: + 'Standardmäßig aus. Wenn aktiv, werden Gateway-Tokens und Anmeldedaten mit dem Schlüsselbund Ihres Systems verschlüsselt (Schlüsselbundverwaltung, GNOME Keyring oder Windows DPAPI) – Ihr System fragt möglicherweise nach einer Erlaubnis oder einem Passwort. Wenn aus, werden sie als Klartextdateien gespeichert, die nur Ihr Benutzerkonto lesen kann.', + keychainEncryptionFailed: 'Geheimnis-Verschlüsselung konnte nicht geändert werden', + testRemote: 'Remote testen', + saveForRestart: 'Für den nächsten Neustart speichern', + saveAndReconnect: 'Speichern und neu verbinden', + diagnostics: 'Diagnose', + diagnosticsDesc: 'Zeigt desktop.log in Ihrem Dateimanager an – nützlich, wenn das Gateway nicht startet.', + openLogs: 'Protokolle öffnen', + incompleteTitle: 'Remote-Gateway unvollständig', + incompleteSignIn: 'Geben Sie eine Remote-URL ein und melden Sie sich an, bevor Sie zu Remote wechseln.', + incompleteToken: 'Geben Sie eine Remote-URL und ein Session-Token ein, bevor Sie zu Remote wechseln.', + incompleteSignInTest: 'Geben Sie eine Remote-URL ein und melden Sie sich an, bevor Sie testen.', + incompleteTokenTest: 'Geben Sie eine Remote-URL und ein Session-Token ein, bevor Sie testen.', + enterUrlFirst: 'Geben Sie zuerst eine Remote-URL ein.', + restartingTitle: 'Gateway-Verbindung wird neu gestartet', + savedTitle: 'Gateway-Einstellungen gespeichert', + restartingMessage: + 'Hermes Desktop stellt mit den gespeicherten Einstellungen die Verbindung wieder her — die Shell bleibt offen.', + savedMessage: 'Für den nächsten Neustart gespeichert.', + connectedTo: (baseUrl, version) => `Verbunden mit ${baseUrl}${version ? ` · Hermes ${version}` : ''}`, + reachableTitle: 'Remote-Gateway erreichbar', + signedOutTitle: 'Abgemeldet', + signedOutMessage: 'Die Remote-Gateway-Session wurde geleert.', + failedLoad: 'Gateway-Einstellungen konnten nicht geladen werden', + signInFailed: 'Anmeldung fehlgeschlagen', + signOutFailed: 'Abmeldung fehlgeschlagen', + testFailed: 'Remote-Gateway-Test fehlgeschlagen', + applyFailed: 'Gateway-Einstellungen konnten nicht angewendet werden', + saveFailed: 'Gateway-Einstellungen konnten nicht gespeichert werden', + sshTitle: 'Über SSH verbinden', + sshDesc: + 'Hermes wird per SSH auf dem Remote-Gerät gestartet und in diese App getunnelt – Sie müssen nichts selbst starten oder freigeben. Erfordert funktionierenden, schlüsselbasierten SSH-Zugriff auf den Host.', + sshTrustHint: 'Der erste präsentierte Host-Key wird vertraut und gepinnt; spätere Änderungen schlagen fehl.', + sshHostTitle: 'Host', + sshHostDesc: 'user@host oder ein Host-Alias aus ~/.ssh/config.', + sshHostPick: 'Host auswählen…', + sshHostPickTitle: 'Host', + sshHostPickDesc: 'Ein Host-Alias aus ~/.ssh/config oder Custom zum manuellen Eingeben.', + sshHostCustom: 'Custom (manuell eingeben)…', + sshUserTitle: 'Benutzer', + sshUserDesc: 'Leer = ~/.ssh/config oder Ihr aktueller Benutzer.', + sshUserPlaceholder: 'aus ~/.ssh/config', + sshPortTitle: 'Port', + sshPortDesc: 'Leer = 22 oder der Port aus ~/.ssh/config.', + sshKeyTitle: 'Identitätsdatei', + sshKeyDesc: 'Pfad zum privaten Schlüssel. Leer = ssh-agent oder ~/.ssh/config.', + sshHermesPathTitle: 'Hermes-Pfad (optional)', + sshHermesPathDesc: 'Vollständiger Pfad zum Remote-Hermes-Binary. Leer = automatisch erkennen.', + sshHermesPathPlaceholder: 'automatisch erkennen', + sshTestConnection: 'SSH testen', + sshConnect: 'Verbinden', + sshButtonsHint: 'Speichern wird beim nächsten Start angewendet. Verbinden verbindet sofort neu.', + sshReachable: (host, platform) => `Erreichbar: ${host} (${platform}) — Hermes gefunden`, + sshIncompleteHost: 'Geben Sie einen SSH-Host ein, bevor Sie sich verbinden.', + sshErrUnreachable: 'Dieser Host ist über SSH nicht erreichbar. Prüfen Sie Host, Port und Ihr Netzwerk.', + sshErrAuth: + 'SSH-Authentifizierung fehlgeschlagen. Laden Sie Ihren Schlüssel in den ssh-agent (ssh-add) oder setzen Sie eine IdentityFile in ~/.ssh/config – Hermes führt ssh nicht interaktiv aus.', + sshErrHostKey: + 'Der Host-Key hat sich seit Ihrer letzten Verbindung GEÄNDERT. Prüfen Sie, ob das erwartet ist, führen Sie dann ssh-keygen -R aus und verbinden Sie sich erneut.', + sshErrNotInstalled: + 'Hermes ist auf dem Remote-Host nicht installiert. Installieren Sie es dort (curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh) oder legen Sie den Hermes-Pfad fest.', + sshErrPlatform: + 'Nicht unterstützte Remote-Plattform. Der Desktop-SSH-Modus von Hermes unterstützt Linux-, macOS- und Windows-Remote-Hosts.', + sshErrTimeout: 'SSH-Verbindung ist ausgelaufen. Der Host ist möglicherweise nicht erreichbar oder schläft.', + sshErrUpdateRequired: 'Aktualisieren Sie Hermes auf dem Remote-Host, bevor Sie sich mit Desktop-SSH verbinden.', + sshErrUnknown: 'SSH-Verbindung fehlgeschlagen.' + }, + keys: { + loading: 'API-Schlüssel und Anmeldedaten werden geladen…', + failedLoad: 'API-Schlüssel konnten nicht geladen werden', + empty: 'In dieser Kategorie ist noch nichts konfiguriert.' + }, + search: { + placeholder: 'Alle Einstellungen durchsuchen…', + pill: 'Suchen' + }, + profileScope: { + appliesTo: 'Gilt für', + editsProfile: profile => `Änderungen auf dieser Seite gelten für das Profil „${profile}“.` + }, + mcp: { + loading: 'MCP-Server werden geladen…', + invalidJson: 'Ungültiges MCP-JSON', + saveFailed: 'Speichern fehlgeschlagen', + removeFailed: 'Entfernen fehlgeschlagen', + reloadFailed: 'MCP-Neuladen fehlgeschlagen', + savedTitle: 'MCP-Server gespeichert', + savedMessage: name => `${name} wird nach MCP-Neuladen angewendet.`, + disabled: 'deaktiviert', + name: 'Name', + serverJson: 'Server-JSON', + remove: 'Entfernen', + test: 'Verbindung testen', + catalogLoading: 'MCP-Katalog wird geladen…', + catalogInstallFailed: name => `${name} konnte nicht installiert werden`, + catalogEnvRequired: 'Füllen Sie die erforderlichen Werte aus, bevor Sie installieren.', + capabilitySummary: (tools, prompts, resources) => + `${[`${tools} Tools`, ...(prompts ? [`${prompts} Prompts`] : []), ...(resources ? [`${resources} Ressourcen`] : [])].join(', ')} aktiviert`, + costTokens: tokens => `~${tokens} Tok/Aufruf`, + usage30d: uses => `${uses} Nutzungen/30d`, + statusConnecting: 'Verbindung wird hergestellt…', + statusNeedsAuth: 'Authentifizierung nötig', + statusError: 'Fehler', + statusOff: 'Aus', + allServers: 'Alle Server', + authenticatedTitle: 'Authentifiziert', + authenticatedMessage: (server, count) => `${server}: ${count} Tools`, + authenticate: 'Authentifizieren', + noOutput: 'Noch keine Ausgabe.', + deepLinkTitle: 'MCP-Server hinzufügen?', + deepLinkDescription: + 'Ein Link möchte diesen MCP-Server zu Hermes hinzufügen. Prüfen Sie die genaue Konfiguration unten – sie stammt vom Link, nicht von Hermes.', + deepLinkStdioWarning: + 'Dieser Server führt mit dem unten angezeigten Befehl einen lokalen Prozess auf Ihrem Rechner aus. Fahren Sie nur fort, wenn Sie seiner Quelle vertrauen.', + deepLinkConfirm: 'Server hinzufügen', + deepLinkNameInvalid: 'Namen verwenden 1–64 Buchstaben, Ziffern, Punkte, Striche oder Unterstriche.', + deepLinkNameConflict: name => + `Ein Server namens ${name} existiert bereits — wählen Sie einen anderen Namen oder brechen Sie ab.`, + deepLinkErrorTitle: 'MCP-Installationslink abgelehnt', + deepLinkErrorName: 'Der Servername des Links fehlt oder ist ungültig.', + deepLinkErrorConfig: 'Die Config des Links ist kein gültiges base64-kodiertes JSON.', + deepLinkErrorShape: 'Die Config muss ein JSON-Objekt mit einem String-Feld `url` oder `command` sein.', + deepLinkErrorUrl: 'Nur http:// und https:// Server-URLs sind erlaubt.', + deepLinkErrorTooLarge: 'Die Config-Payload überschreitet das 32-KB-Limit.' + }, + model: { + setupProviderFallback: 'Anbieter', + setUpProvider: name => `${name} einrichten`, + staleAuxBefore: (count, names) => + `${count} Hilfsaufgabe${count === 1 ? '' : 'n'} (${names}) ${count === 1 ? 'läuft' : 'laufen'} noch auf `, + staleAuxAfter: ', nicht auf Ihrem Hauptmodell.', + staleAuxOtherProviders: 'anderen Anbietern', + moaEnabled: 'Aktiviert', + moaSetDefault: 'Als Standard festlegen', + moaNewPresetPlaceholder: 'neue Vorlage', + moaAddPreset: 'Vorlage hinzufügen', + customModel: 'Eigenes Modell…', + customModelPlaceholder: 'Modell-ID', + chooseFromList: 'Aus Liste wählen', + moaDefault: 'Standard:', + moaReferenceToggle: (enabled, index) => `Referenz ${index} ${enabled ? 'deaktivieren' : 'aktivieren'}`, + moaReferenceTitle: index => `Referenz ${index}`, + moaAddReference: 'Referenzmodell hinzufügen', + loading: 'Modellkonfiguration wird geladen…', + appliesDesc: + 'Gilt für neue Sessions. Verwenden Sie die Modellauswahl im Composer, um im aktiven Chat schnell zu wechseln.', + provider: 'Anbieter', + model: 'Modell', + applying: 'Wird angewendet…', + defaultsLabel: 'Voreinstellungen', + reasoning: 'Denken', + reasoningOff: 'Aus', + defaultsFailed: 'Voreinstellungen des Modells konnten nicht gespeichert werden', + loadFailed: 'Modelle konnten nicht geladen werden', + restartRequired: + 'Dieses Backend führt nach einem Update alten Code aus. Starten Sie es neu, um den neuen Code zu laden.', + restartBackend: 'Backend neu starten', + restartingBackend: 'Backend wird neu gestartet…', + restartFailed: 'Das Backend konnte nicht neu gestartet werden', + auxiliaryTitle: 'Hilfsmodelle', + resetAllToMain: 'Alle auf Hauptmodell zurücksetzen', + auxiliaryDesc: + 'Hilfsaufgaben laufen standardmäßig auf dem Hauptmodell. Weise einer Aufgabe ein eigenes Modell zu, um das zu überschreiben.', + setToMain: 'Auf Hauptmodell setzen', + change: 'Ändern', + autoUseMain: 'automatisch · Hauptmodell verwenden', + inheritMainEffort: 'übernehmen · Aufwand des Hauptmodells', + providerDefault: '(Anbietervorgabe)', + fallbackAdd: 'Fallback hinzufügen', + fallbackEmpty: 'Keine Fallback-Modelle — es wird das Standardmodell verwendet, außer es schlägt fehl.', + notInCatalog: + 'ist nicht in der Modellliste dieses Anbieters enthalten — Aufrufe können auf ein Backup ausweichen.', + moaTitle: 'Mixture of Agents', + moaPreset: 'Voreinstellung', + moaDescription: + 'Konfigurieren Sie benannte Vorlagen, die als Modelle unter dem Anbieter „Mixture of Agents“ erscheinen. Der Aggregator ist das handelnde Modell – er führt jeden Schritt der Tool-Schleife aus, und fast alle Kosten des Laufs werden seinem Anbieter berechnet. Referenzen beraten standardmäßig nur einmal pro Benutzer-Turn.', + moaAggregator: 'Aggregator', + moaAggregatorBilled: 'handelndes Modell · wird für den Lauf berechnet', + moaReferenceHint: 'berät standardmäßig einmal pro Turn', + tasks: { + vision: { + label: 'Sehen', + hint: 'Bildanalyse' + }, + compression: { + label: 'Kompression', + hint: 'Kontext-Verdichtung' + }, + skills_hub: { + label: 'Skills-Hub', + hint: 'Skill-Suche' + }, + approval: { + label: 'Freigabe', + hint: 'Intelligente Auto-Freigabe' + }, + mcp: { + label: 'MCP', + hint: 'MCP-Tool-Routing' + }, + title_generation: { + label: 'Titel-Generierung', + hint: 'Session-Titel' + }, + review: { + label: 'Review', + hint: '/review Bewertungs-Subagent' + }, + triage_specifier: { + label: 'Triage-Spezifizierer', + hint: 'Kanban-Spezifikation ausarbeiten' + }, + kanban_decomposer: { + label: 'Kanban-Zerleger', + hint: 'Aufgaben zerlegen' + }, + profile_describer: { + label: 'Profil-Beschreiber', + hint: 'Automatische Profilbeschreibungen' + }, + curator: { + label: 'Kurator', + hint: 'Skill-Nutzungs-Review' + } + } + }, + localModels: { + title: 'Lokale Modelle', + runtimeTitle: 'Lokale Laufzeit', + runtimeReady: backend => `Bereit · ${backend}`, + serverRunning: 'Läuft', + runtimeInstalled: 'llama.cpp-Laufzeit installiert', + runtimeInstalledDetail: (tag, backend) => + `Build ${tag}, ${backend}-Backend. Hermes startet und verwaltet den Server für Sie.`, + installTitle: 'Lokale Laufzeit installieren', + installDetail: + 'Lädt die llama.cpp-Inferenz-Engine herunter (einige hundert MB). Heruntergeladene Modelle laufen komplett auf diesem Rechner – kein Konto, nichts verlässt Ihren Computer.', + installAction: 'Laufzeit installieren', + installing: 'Laufzeit wird installiert…', + installFailed: 'Laufzeit-Installation fehlgeschlagen', + hardwareTitle: 'Dieser Rechner', + hardwareLoading: 'Ihre Hardware wird geprüft…', + vram: label => `${label} GPU-Speicher`, + ram: label => `${label} RAM`, + unifiedMemory: 'Kombinierter Speicher', + modelsTitle: 'Modelle', + recommended: 'Empfohlen', + recommendedReason: { + 'best-quality-resident': + 'Das Modell mit der höchsten Qualität, das komplett auf Ihrer GPU mit voller Geschwindigkeit läuft. Die Auswahl wägt Qualität gegen die erwartete Geschwindigkeit auf dieser Hardware ab.', + 'speed-gated-quality': + 'Ein besseres Modell würde auf diesen Rechner passen, aber bei seiner Speicherbandbreite zu langsam reagieren — das ist das beste Modell, das schnell bleibt.', + 'fastest-resident': + 'Kein Modell erreicht volle Geschwindigkeit auf dieser Hardware; dieses kommt am nächsten und läuft komplett im GPU-Speicher.' + }, + noRecommendationTitle: 'Keine automatische Empfehlung für diesen Rechner', + noRecommendationDetail: + 'Die automatische Einrichtung braucht ein kuratiertes Modell, das vollständig in den Grafikspeicher oder den gemeinsamen Speicher passt. Sie können unten trotzdem ein Modell wählen oder weitere Modelle durchsuchen.', + noRecommendationAction: 'Modelle durchsuchen', + downloaded: 'Heruntergeladen', + downloadAction: size => `Download · ${size}`, + downloadProgress: (done, total) => `${done} von ${total} werden heruntergeladen`, + downloadDoneToast: model => `${model} ist bereit.`, + installDoneToast: 'Lokale Laufzeit installiert und bereit.', + quickstartTitle: 'Ein Modell auf diesem Rechner ausführen', + quickstartDetail: (model, size) => + `Ein Klick richtet alles ein: die lokale Engine, ${model} (${size} Download) und Ihre Voreinstellung für neue Chats. Nichts verlässt diesen Computer.`, + quickstartDetailReady: model => + `Ein Klick macht ${model} zu Ihrer Voreinstellung für neue Chats. Alles läuft auf diesem Rechner.`, + quickstartAction: 'Für mich einrichten', + quickstartConfigure: 'Konfigurieren…', + quickstartDoneToast: model => `${model} ist eingerichtet — neue Chats laufen auf diesem Rechner.`, + quickstartFailed: 'Einrichtung des lokalen Modells fehlgeschlagen', + quickstartStageEngine: 'Engine', + quickstartStageModel: 'Modell', + quickstartStageFinish: 'Fertig', + useAction: 'Verwenden', + activePill: 'Voreinstellung', + updateTitle: 'Engine-Update verfügbar', + updateDetail: (next, current) => + `Eine neuere llama.cpp-Version (${next}) ist bereit zur Installation – Sie verwenden ${current}. Während des Downloads laufen die Modelle weiter.`, + updateAction: 'Engine aktualisieren', + updating: 'Engine wird aktualisiert…', + upToDateTitle: 'Engine aktuell', + upToDateDetail: (tag, backend) => + `llama.cpp ${tag} (${backend}) wird ausgeführt — der neueste Build, den Hermes mitliefert.`, + activeDetail: 'Neue Chats verwenden dieses Modell – es wird geladen, wenn Sie Ihre erste Nachricht senden', + activeNotLoaded: 'Wird bei Ihrer ersten Nachricht geladen', + loadedPill: 'Im Speicher', + placementResident: 'komplett auf GPU', + placementSpilled: 'teils im RAM', + placementResidentTip: 'Läuft komplett im GPU-Speicher bei diesem Kontextfenster — volle Geschwindigkeit.', + placementSpilledTip: + 'Ein Teil dieses Modells läuft aus dem Arbeitsspeicher — es funktioniert, aber langsamer. Ein kompakterer Build oder ein kleinerer Kontext würde komplett passen.', + loadingPill: 'Wird geladen…', + ejectTip: 'GPU-Speicher freigeben (wird bei der nächsten Nachricht wieder geladen)', + ejected: 'Modell entladen — GPU-Speicher freigegeben.', + ejectFailed: 'Das Modell konnte nicht entladen werden', + stopServer: 'Ausschalten', + startServer: 'Einschalten', + runtimeRunningDetail: + 'Der lokale Server läuft. Wenn Sie ihn ausschalten, wird der gesamte GPU-Speicher freigegeben, und neue Chats können keine lokalen Modelle verwenden, bis Sie ihn wieder einschalten.', + serverStopped: 'Lokaler Server gestoppt — GPU-Speicher freigegeben.', + serverStarted: 'Lokaler Server läuft.', + serverStopFailed: 'Der lokale Server konnte nicht gestoppt werden', + serverStartFailed: 'Der lokale Server konnte nicht gestartet werden', + activating: 'Wird gestartet…', + activateFailed: model => `Konnte nicht zu ${model} wechseln`, + activateDoneToast: model => `Neue Chats verwenden ${model}.`, + downloadFailed: model => `Download von ${model} fehlgeschlagen`, + pillFitsGpu: 'Passt auf Ihre GPU', + pillUsesRam: 'Verwendet Arbeitsspeicher', + pillTooBig: 'Zu groß für diesen Rechner', + browseTitle: 'Weitere Modelle finden', + browseHint: + 'Durchsuchen Sie ganz Hugging Face. Hier heruntergeladene Modelle werden automatisch an Ihren Rechner angepasst, aber nicht von uns getestet.', + browsePlaceholder: 'Modelle nach Name oder Autor suchen…', + browseSearching: 'Hugging Face wird durchsucht', + browseListing: 'Modelldateien werden gelesen', + browseShowFiles: 'Dateien anzeigen', + browseRefresh: 'Aktualisieren', + browseDownloads: 'Downloads', + browseLikes: 'Likes', + browseGated: 'erfordert Hugging-Face-Anmeldung', + browseNoGguf: 'Keine kompatiblen Modelldateien gefunden.', + browseFitUnknown: 'Passform unbekannt', + browseAlreadyDownloaded: 'Bereits heruntergeladen.', + addedByYou: 'Von Ihnen hinzugefügt', + browseDownloadStarted: '{name} wird heruntergeladen', + browseDownloadAria: '{name} herunterladen', + sideloadButton: 'Modelldatei hinzufügen', + sideloadTitle: 'Eine GGUF-Modelldatei wählen', + sideloadDone: '{name} hinzugefügt.', + sideloadAlreadyPresent: 'Bereits in Ihrer Bibliothek.', + pillFullContext: max => `Voller ${max}-Kontext`, + pillFullContextTip: 'Läuft von Anfang an mit dem kompletten Kontextfenster des Modells', + pillUpTo: max => `Bis zu ${max} Kontext`, + pillGrowsTip: 'Wächst automatisch, wenn Ihr Gespräch mehr Platz braucht', + pillVision: 'Sieht Bilder', + deleteAction: 'Modell löschen', + deleteConfirm: model => `${model} von der Festplatte löschen?`, + deleted: model => `${model} gelöscht.`, + deleteFailed: 'Löschen fehlgeschlagen' + }, + billing: { + perMonth: (amount: string) => `${amount}/Monat`, + creditsPerMonth: (amount: string) => `${amount} Credits/Monat`, + usageLabel: (label: string) => `${label}-Nutzung`, + freeTier: { + signIn: 'Anmelden', + title: 'Sie nutzen den kostenlosen Nous-Tarif', + message: 'Melden Sie sich mit einem Nous-Konto an, um weitere Modelle und Tools freizuschalten.', + caption: + 'Läuft mit nous/welcome, Konnektoren inklusive. Nach der Anmeldung bleiben Ihre Konnektoren erhalten, und Sie erhalten die kontopflichtigen Tools sowie alle weiteren Modelle.', + name: 'Nous · kostenloser Tarif', + footnote: + 'Der kostenlose Tarif hat kein Guthaben und nichts zu bezahlen. Zahlung und Nutzung werden angezeigt, sobald Sie sich mit einem Nous-Konto anmelden.', + plan: 'Kostenloser Tarif', + model: 'Modell', + connectors: 'Konnektoren', + included: 'Inklusive' + }, + amountValidation: { + reloadTo: 'Aufladen auf', + greaterThanThreshold: 'Der Aufladebetrag muss größer als der Schwellenwert sein.', + decimal: (label: string) => `${label}: Geben Sie einen Dollarbetrag mit höchstens 2 Nachkommastellen ein.`, + positive: (label: string) => `${label}: Der Betrag muss größer als 0 $ sein.`, + minimum: (label: string, amount: string) => `${label}: Minimum ist ${amount}.`, + maximum: (label: string, amount: string) => `${label}: Maximum ist ${amount}.` + }, + stepUp: { + openVerification: 'Verifizierungsseite öffnen', + dismiss: 'Schließen', + waiting: 'Warten auf Verifizierungslink…', + verify: 'Zum Fortfahren verifizieren', + deniedTitle: 'Die Verifizierung wurde nicht genehmigt', + deniedBody: 'Die Verifizierung wurde abgeschlossen, ohne Remote-Ausgaben für dieses Terminal zu erlauben.', + successTitle: 'Verifizierung abgeschlossen', + successBody: 'Remote-Ausgaben sind für dieses Terminal erlaubt.' + }, + charge: { + added: (amount?: string) => (amount ? `${amount} $ hinzugefügt.` : 'Credits hinzugefügt.'), + failedTitle: 'Abbuchung fehlgeschlagen', + unconfirmedTitle: 'Ergebnis der Abbuchung unbestätigt', + unconfirmedBody: (message: string) => + `${message} Das Ergebnis Ihrer letzten Abbuchung ist unbestätigt - prüfen Sie Guthaben/Verlauf, bevor Sie es erneut versuchen.`, + checkTitle: 'Abbuchung konnte nicht geprüft werden', + checkBody: 'Die Abbuchung konnte nicht geprüft werden.', + untrackedTitle: 'Abbuchung konnte nicht verfolgt werden', + untrackedBody: 'Der Abrechnungsdienst hat die Anfrage angenommen, aber keine Abbuchungs-ID zurückgegeben.', + timeoutTitle: 'Nach 5 Minuten noch in Bearbeitung', + timeoutBody: + 'Die Abbuchung kann noch abgeschlossen werden. Prüfen Sie das Portal, bevor Sie es erneut versuchen.', + authenticationRequired: + 'Ihre Bank verlangt eine Verifizierung (3DS). Schließen Sie sie im Portal ab, um diesen Kauf abzuschließen.', + expired: 'Ihre Karte ist abgelaufen. Aktualisieren Sie sie im Portal.', + declined: 'Ihre Karte wurde abgelehnt. Versuchen Sie im Portal eine andere Karte.', + failedBody: (reason: string) => `Die Abbuchung ist nicht durchgegangen (${reason}).` + }, + title: 'Abrechnung', + preview: 'Vorschau', + summary: { + balance: 'Guthaben', + plan: 'Tarif', + autoRefill: 'Automatisch aufladen' + }, + sections: { + invoices: 'Rechnungen', + plan: 'Tarif', + paymentAndCredits: 'Zahlung & Credits', + usage: 'Nutzung' + }, + usage: { + title: 'Nutzung' + }, + buyCredits: { + customAmount: 'Eigener Credit-Betrag', + title: 'Jetzt Credits kaufen', + buyButton: 'Kaufen', + processing: 'Wird verarbeitet… Abrechnung wird geprüft', + added: (amount: string) => `${amount} hinzugefügt. Guthaben wird aktualisiert.`, + retry: 'Erneut versuchen', + openPortal: 'Portal öffnen' + }, + plan: { + title: 'Tarife', + changePlan: 'Tarif ändern', + viewPlans: 'Tarife ansehen', + backAria: 'Zurück zur Abrechnung', + current: 'Aktueller Tarif', + scheduled: 'Geplant', + empty: 'Derzeit sind keine Tarife zum Wechseln verfügbar.', + undo: 'Rückgängig', + undoing: 'Wird rückgängig gemacht…', + downgrade: 'Herabstufen', + confirmDowngrade: 'Herabstufung bestätigen', + tryAgain: 'Erneut versuchen', + checkingChange: 'Änderung wird geprüft…', + cannotChange: 'Diese Änderung ist hier nicht möglich.', + alreadyOn: (name: string) => `Sie nutzen bereits ${name} – nichts zu ändern.`, + notScheduleable: 'Diese Änderung kann hier nicht geplant werden.', + scheduling: 'Wird geplant…', + cancel: 'Abbrechen', + effectScheduled: (targetName: string, effectiveAt: string, creditsDelta?: string) => + `Wechsel zu ${targetName} – wirksam ${effectiveAt}. Jetzt keine Abbuchung; bis dahin behalten Sie Ihren aktuellen Tarif.${creditsDelta ? ` Änderung der monatlichen Credits: ${creditsDelta}.` : ''}` + }, + autoReload: { + threshold: 'Schwellenwert', + thresholdAria: 'Schwellenwert für automatisches Aufladen', + reloadTo: 'Aufladen auf', + reloadToAria: 'Zielbetrag für automatisches Aufladen', + turnOffConfirm: 'Automatisches Aufladen deaktivieren?', + turnOff: 'Ausschalten', + disable: 'Deaktivieren', + updated: 'Automatisches Aufladen aktualisiert.', + turnedOff: 'Automatisches Aufladen deaktiviert.', + manage: 'Verwalten', + save: 'Speichern', + saving: 'Wird gespeichert…', + cancel: 'Abbrechen' + }, + state: { + notice: { + loggedOut: { + title: 'Nous-Konto verbinden', + message: 'Führen Sie /portal in der TUI aus oder öffnen Sie das Nous-Portal, um Ihr Konto zu verbinden.', + action: 'Portal öffnen ↗' + }, + noCard: { + title: 'Keine Zahlungsmethode hinterlegt', + message: + 'Der Kauf von Credits und das automatische Aufladen bleiben deaktiviert, bis eine Karte hinterlegt ist. Fügen Sie im Portal eine hinzu.', + action: 'Karte hinzufügen ↗' + } + }, + paymentMethod: { + title: 'Zahlungsmethode', + description: 'Verwalten Sie die Karte für Aufladungen und Abo-Verlängerungen.', + addAction: 'Zahlungsmethode hinzufügen', + updateAction: 'Aktualisieren', + provenance: { + autoRefill: 'Karte für automatisches Aufladen', + customerDefault: 'Standardkarte des Kunden', + subPin: 'Abo-Karte', + suffix: (label: string) => ` - ${label}` + } + }, + buyCredits: { + description: 'Eine einmalige Abbuchung von Ihrer Karte, die noch heute Ihrem Guthaben gutgeschrieben wird.' + }, + autoRefill: { + title: 'Bei niedrigem Guthaben aufladen', + genericDescription: 'Hält Ihr Guthaben aufgeladen, wenn es unter Ihren Schwellenwert fällt.', + offPill: 'Aus', + enabledPill: 'Aktiviert', + notAvailablePill: '—', + manageCaption: 'Automatisches Aufladen im Portal verwalten.', + turnOnCaption: 'Automatisches Aufladen im Portal aktivieren', + chargesDescription: (reloadTo: string, threshold: string) => + `Bucht automatisch ${reloadTo} ab, wenn Ihr Guthaben unter ${threshold} fällt.`, + distinctCardCaption: (cardLabel: string) => + `Automatisches Aufladen belastet ${cardLabel} – im Portal abgleichen`, + distinctCardFallback: 'eine andere Karte', + reconcileAction: 'Abgleichen ↗' + }, + usage: { + subscriptionCredits: { + title: 'Abo-Credits', + barLabel: 'Verbleibende Abo-Credits', + captionResets: (date: string) => `Wird zurückgesetzt: ${date}`, + valueOf: (remaining: string, monthly: string) => `${remaining} von ${monthly} übrig`, + valueOver: (remaining: string, monthly: string, over: string) => + `${remaining} von ${monthly} übrig · ${over} darüber` + }, + topupCredits: { + title: 'Aufgeladene Credits', + caption: 'Verfallen nicht' + }, + monthlyCap: { + title: 'Monatliches Ausgabenlimit', + barLabel: 'Genutztes monatliches Ausgabenlimit', + captionDefault: 'Standardobergrenze', + captionSpending: 'Monatliche Remote-Ausgaben', + valueUsed: (spent: string, limit: string) => `${spent} von ${limit} genutzt` + } + }, + planCard: { + freeTier: 'Kostenlos', + chooseAction: 'Auswählen ↗', + adjustPlanAction: 'Tarif anpassen ↗', + unavailableCaption: 'Abo-Details sind nicht verfügbar; das Portal kann weiterhin geöffnet werden.', + downgradeCaption: (tierName: string, when: string) => `Wechselt am ${when} zu ${tierName}.`, + cancellationCaption: (when: string) => `Endet am ${when}.`, + renewsCaption: (date: string) => `Verlängert sich ${date}`, + noSubscriptionCaption: 'Kein aktives Abo – kostenpflichtige Modelle verbrauchen aufgeladene Credits.' + } + }, + errors: { + consentRequired: { + title: 'Kartenbestätigung erforderlich', + message: 'Bestätigen Sie diese Karte im Portal für Terminal-Abbuchungen' + }, + insufficientScope: { + title: 'Remote-Ausgaben müssen genehmigt werden', + message: + 'Dafür müssen Remote-Ausgaben erlaubt sein. Starten Sie eine Aufladung, um sie zu erlauben, und versuchen Sie es erneut.' + }, + remoteSpendingRevoked: { + title: 'Remote-Ausgaben wurden gestoppt', + messageByAdmin: 'Ein Administrator hat Remote-Ausgaben für dieses Terminal gestoppt.', + messageBySelf: 'Sie haben Remote-Ausgaben für dieses Terminal gestoppt.' + }, + remoteSpendingReconnect: (who: string) => + `${who} Verbinden Sie sich unter Einstellungen -> Gateway erneut, um dieses Gerät neu zu autorisieren.`, + sessionRevoked: { + title: 'Session abgemeldet', + message: 'Ihre Session wurde abgemeldet. Melden Sie sich unter Einstellungen → Gateway erneut an.' + }, + cliBillingDisabled: { + title: 'Remote-Ausgaben sind deaktiviert', + message: + 'Remote-Ausgaben sind für dieses Konto deaktiviert – ein Abrechnungsadministrator kann sie auf der Hermes-Agent-Seite des Portals aktivieren.' + }, + roleRequired: { + title: 'Administratorrolle erforderlich', + message: + 'Zum Aufladen ist ein Org-Administrator oder -Inhaber nötig. Fragen Sie einen Administrator oder verwalten Sie es im Portal.' + }, + idempotencyConflict: { + title: 'Neue Aufladung starten', + message: + '🔴 Dieser Abbuchungsschlüssel wurde bereits für einen anderen Betrag verwendet. Starten Sie eine neue Aufladung.' + }, + noPaymentMethod: { + title: 'Keine gespeicherte Karte', + message: + '💳 Noch keine gespeicherte Karte für Terminal-Abbuchungen. Richten Sie im Portal eine ein ' + + '(einmalige Credit-Käufe speichern keine wiederverwendbare Karte).' + }, + orgAccessDenied: { + title: 'Org-Zugriff verweigert', + message: 'Dieses Token ist an keine Organisation gebunden, die Sie verwalten können' + }, + monthlyCapExceeded: { + title: 'Monatliches Ausgabenlimit erreicht', + messageReached: '🔴 Monatliches Ausgabenlimit erreicht.', + messageHeadroom: (remaining: string) => + `🔴 Monatliches Ausgabenlimit erreicht – ${remaining} $ Spielraum übrig.` + }, + rateLimited: { + title: 'Zu viele Abbuchungen im Moment', + message: (mins: number) => + mins > 0 + ? `🟡 Zu viele Abbuchungen im Moment (erneut versuchen in ~${mins} Min.). Das ist kein Zahlungsfehler.` + : '🟡 Zu viele Abbuchungen im Moment. Das ist kein Zahlungsfehler.' + }, + stripeUnavailable: { + title: 'Stripe hat Probleme', + message: (mins: number) => + mins > 0 + ? `Stripe hat Probleme – erneut versuchen in ~${mins} Min.` + : 'Stripe hat Probleme – versuchen Sie es gleich erneut' + }, + upgradeCapExceeded: { + title: 'Tägliches Limit für Tarifwechsel erreicht', + message: 'Tägliches Limit für Tarifwechsel erreicht – versuchen Sie es morgen erneut' + }, + endpointUnavailable: { + title: 'Abrechnungs-Endpunkt nicht verfügbar', + message: + 'Der Abrechnungs-Endpunkt hat eine Nicht-JSON-Antwort zurückgegeben (er ist in diesem Deployment möglicherweise nicht verfügbar).' + }, + timeout: { + title: 'Zeitüberschreitung bei der Abrechnungsanfrage', + message: 'Zeitüberschreitung bei der Abrechnungsanfrage.' + }, + transport: { + title: 'Abrechnungsverbindung fehlgeschlagen', + message: 'Die Abrechnungsanfrage ist fehlgeschlagen, bevor sie das Gateway erreicht hat.' + }, + default: { + title: 'Abrechnungsanfrage fehlgeschlagen', + message: 'Die Abrechnungsanfrage ist fehlgeschlagen.' + } + } + }, + providers: { + connectAccount: 'Ein Konto verbinden', + haveApiKey: 'Haben Sie stattdessen einen API-Key?', + intro: + 'Melden Sie sich mit einem Abo an – kein API-Key zum Kopieren. Hermes übernimmt die Browser-Anmeldung für Sie, direkt hier in der App.', + connected: 'Verbunden', + collapse: 'Einklappen', + connectAnother: 'Weiteren Provider verbinden', + otherProviders: 'Weitere Provider', + disconnect: 'Trennen', + disconnectInTerminal: 'Trennen (führt den Entfernungsbefehl im Terminal aus)', + removeConfirm: provider => `${provider} entfernen?`, + removeExternalGeneric: provider => `${provider} wird von einer eigenen CLI verwaltet – entfernen Sie ihn dort.`, + removeKeyManaged: provider => + `${provider} ist über einen API-Key konfiguriert. Entfernen Sie ihn unter API-Keys.`, + removeTerminalConfirm: (provider, command) => + `${provider} trennen? Dadurch wird "${command}" im Terminal ausgeführt, um die Zugangsdaten zu löschen.`, + removeTerminalRunning: provider => `${provider}-Trennung wird im Terminal ausgeführt…`, + removedTitle: 'Konto entfernt', + removedMessage: provider => `${provider} wurde entfernt.`, + failedRemove: provider => `${provider} konnte nicht entfernt werden`, + noProviderKeys: 'Keine Provider-API-Keys verfügbar.', + searchKeys: 'Provider suchen…', + noKeysMatch: 'Keine Provider entsprechen Ihrer Suche.', + localEndpoint: { + title: 'Lokaler / eigener Endpoint', + description: + 'Verbinden Sie Hermes mit einem beliebigen OpenAI-kompatiblen Endpunkt (Zyphra, vLLM, llama.cpp, Ollama usw.).' + }, + loading: 'Provider werden geladen…' + }, + sessions: { + loading: 'Archivierte Sessions werden geladen…', + archivedTitle: 'Archivierte Sessions', + archivedIntro: + 'Archivierte Chats sind in der Seitenleiste ausgeblendet, behalten aber alle Nachrichten. Alt/⌥+Umschalt/⇧-Klick auf einen Chat in der Seitenleiste archiviert ihn.', + emptyArchivedTitle: 'Nichts archiviert', + emptyArchivedDesc: 'Archivieren Sie einen Chat, um ihn hier auszublenden.', + unarchive: 'Archivierung aufheben', + deletePermanently: 'Endgültig löschen', + messages: count => `${count} ${count === 1 ? 'Nachricht' : 'Nachrichten'}`, + restored: 'Wiederhergestellt', + deleteConfirm: title => `"${title}" endgültig löschen? Das kann nicht rückgängig gemacht werden.`, + autoArchiveTitle: 'Veraltete Chats automatisch archivieren', + autoArchiveDesc: + 'Archiviert automatisch Chats, die Sie eine Weile nicht verwendet haben. Angeheftete Chats werden nie archiviert, und nichts wird gelöscht – archivierte Chats landen nur hier.', + autoArchiveDaysLabel: 'Archivieren nach', + autoArchiveDaysUnit: 'Tagen Inaktivität', + autoArchiveFailed: 'Auto-Archivierung konnte nicht aktualisiert werden', + defaultDirTitle: 'Standard-Projektordner', + defaultDirDesc: + 'Neue Sessions starten in diesem Ordner, sofern Sie keinen anderen wählen. Nicht festlegen, um Ihr Home-Verzeichnis zu verwenden.', + defaultDirUpdated: + 'Standard-Projektordner aktualisiert – starten Sie einen neuen Chat (Strg/⌘+N), damit er wirksam wird', + defaultsTo: label => `Standardmäßig ${label}.`, + change: 'Ändern', + choose: 'Auswählen', + clear: 'Löschen', + notSet: 'Nicht festgelegt', + failedLoad: 'Archivierte Sessions konnten nicht geladen werden', + unarchiveFailed: 'Archivierung aufheben fehlgeschlagen', + deleteFailed: 'Löschen fehlgeschlagen', + updateDirFailed: 'Standardordner konnte nicht aktualisiert werden', + clearDirFailed: 'Standardordner konnte nicht geleert werden' + }, + toolsets: { + loadingConfig: 'Konfiguration wird geladen', + savedTitle: 'Zugangsdaten gespeichert', + savedMessage: key => `${key} aktualisiert.`, + removedTitle: 'Zugangsdaten entfernt', + removedMessage: key => `${key} entfernt.`, + failedSave: key => `${key} konnte nicht gespeichert werden`, + failedRemove: key => `${key} konnte nicht entfernt werden`, + failedReveal: key => `${key} konnte nicht angezeigt werden`, + removeConfirm: key => `${key} aus der .env entfernen?`, + set: 'Festgelegt', + notSet: 'Nicht festgelegt', + selectedTitle: 'Provider ausgewählt', + selectedMessage: provider => `${provider} ist jetzt aktiv.`, + failedSelect: provider => `${provider} konnte nicht ausgewählt werden`, + failedLoad: 'Tool-Konfiguration konnte nicht geladen werden', + noProviderOptions: + 'Dieses Toolset hat keine Provider-Optionen – aktivieren Sie es, und es funktioniert mit Ihrem aktuellen Setup.', + noProviders: 'Für dieses Toolset sind gerade keine Provider verfügbar.', + ready: 'Bereit', + needsSignIn: 'Anmeldung erforderlich', + needsSetup: 'Setup erforderlich', + activeBackend: 'Aktiv', + activeBackendHint: 'Das ist Ihr aktives Backend', + useBackend: 'Dieses Backend verwenden', + nousIncluded: 'In einem Nous-Abo enthalten – melden Sie sich im Nous Portal an, um es zu aktivieren.', + nousAuthNeededTitle: 'Im Nous Portal anmelden', + nousAuthNeededMessage: provider => + `${provider} ist gespeichert, wird aber erst aktiviert, wenn Sie sich im Nous Portal anmelden.`, + nousAuthSignIn: 'Anmelden', + nousAuthDoneTitle: 'Nous Portal verbunden', + nousAuthDoneMessage: 'Ihre Abo-Backends sind jetzt aktiv.', + nousAuthFailed: 'Die Nous-Portal-Anmeldung wurde nicht abgeschlossen', + nousAuthFailedMessage: 'Versuchen Sie es erneut.', + nousAuthTryAgain: 'Erneut versuchen', + noApiKeyRequired: 'Kein API-Key erforderlich.', + postSetupHint: step => + `Dieses Backend braucht eine einmalige Installation (${step}). Läuft auf diesem Rechner – kann ein paar Minuten dauern.`, + postSetupInstalledHint: 'Installiert. Führen Sie das Setup nur erneut aus, wenn etwas nicht funktioniert.', + postSetupRun: 'Setup ausführen', + postSetupRerun: 'Setup erneut ausführen', + postSetupInstalled: 'Installiert', + postSetupRunning: 'Wird installiert…', + postSetupStarting: 'Wird gestartet…', + postSetupCompleteTitle: 'Setup abgeschlossen', + postSetupCompleteMessage: step => `${step} installiert.`, + postSetupErrorTitle: 'Setup mit Fehlern abgeschlossen', + postSetupErrorMessage: step => `Prüfen Sie das ${step}-Protokoll.`, + postSetupOpenLogs: 'Logs öffnen', + postSetupRunAgain: 'Erneut ausführen', + postSetupFailed: step => `Das ${step}-Setup konnte nicht ausgeführt werden`, + webSearchActive: backend => `Suche: ${backend}`, + webExtractActive: backend => `Extrahieren: ${backend}`, + webCapabilityUnset: 'nicht festgelegt', + webUseForSearch: 'Für die Suche verwenden', + webUseForExtract: 'Für Extrahieren verwenden', + webUsedForSearch: 'Such-Backend', + webUsedForExtract: 'Extraktions-Backend', + webCapabilitySelectedMessage: (provider, capability) => + `${provider} übernimmt jetzt ${capability === 'search' ? 'die Websuche' : 'das Extrahieren von Webinhalten'}.`, + failedSelectCapability: provider => `${provider} konnte nicht festgelegt werden`, + loadingModels: 'Modellkatalog wird geladen…', + modelSectionTitle: 'Modell', + modelCount: count => `${count} Modell${count === 1 ? '' : 'e'}`, + modelInUse: 'In Verwendung', + modelDefault: 'Standard', + modelInactiveHint: 'Wählen Sie zuerst dieses Backend aus, um sein Modell zu ändern.', + modelSelectedTitle: 'Modell ausgewählt', + modelSelectedMessage: model => `${model} gilt für neue Sessions.`, + failedSelectModel: model => `${model} konnte nicht ausgewählt werden`, + terminalBackend: { + sectionTitle: 'Ausführungs-Backend', + loading: 'Ausführungs-Backends werden geprüft…', + failedLoad: 'Terminal-Backends konnten nicht geladen werden', + ready: 'Bereit', + needsSetup: 'Setup erforderlich', + unavailable: 'Nicht verfügbar', + inUse: 'In Verwendung', + selectedTitle: 'Backend ausgewählt', + selectedMessage: backend => `Terminal-Befehle laufen jetzt über ${backend}. Gilt für neue Sessions.`, + failedSelect: backend => `${backend} konnte nicht ausgewählt werden`, + needsSetupHint: + 'Sie können dieses Backend jetzt auswählen – Befehle schlagen fehl, bis das Setup abgeschlossen ist.', + needsSetupConfirmTitle: (backend: string) => `${backend} trotzdem auswählen?`, + needsSetupConfirmDescription: (detail: string) => + `${detail} Sessions, die nach dieser Änderung starten, haben keine Terminal- oder Datei-Tools, bis die Einrichtung abgeschlossen ist.`, + needsSetupConfirmDescriptionGeneric: + 'Dieses Backend ist noch nicht eingerichtet. Sessions, die nach dieser Änderung starten, haben keine Terminal- oder Datei-Tools, bis die Einrichtung abgeschlossen ist.', + needsSetupConfirmAction: 'Trotzdem auswählen', + unavailableTitle: 'Terminalbefehle sind nicht verfügbar', + unavailableMessage: backend => + `Hermes kann gerade keine Shell-Befehle ausführen: ${backend} ist nicht bereit. Wechseln Sie zu Lokal oder schließen Sie die Einrichtung von ${backend} ab und versuchen Sie es erneut.`, + openBackendSettings: 'Terminal-Einstellungen öffnen', + useLocal: 'Lokal verwenden', + switchedToLocal: 'Terminalbefehle laufen jetzt lokal. Gilt für neue Sessions.' + }, + browserRealProfile: { + label: 'Mein echtes Browser-Profil verwenden', + description: + 'Kopiert die Anmeldungen und Cookies Ihres Standardbrowsers in einen verwalteten Schnappschuss, mit dem der Agent browst. Ihr Live-Profil wird nie direkt geöffnet. Gilt für neue Sessions.', + enabledTitle: 'Echtes Profil-Browsen aktiv', + enabledMessage: 'Neue Sessions browsen mit einem Schnappschuss Ihres Standard-Browserprofils.', + disabledTitle: 'Echtes Profil-Browsen deaktiviert', + disabledMessage: 'Der Profil-Schnappschuss wird gelöscht; neue Sessions verwenden einen sauberen Browser.', + failedSave: 'Die Echtes-Profil-Einstellung konnte nicht gespeichert werden', + prompt: { + title: 'Auf Ihren Websites angemeldet bleiben', + body: 'Lassen Sie Hermes mit einem Schnappschuss Ihres Standard-Browserprofils browsen, damit Websites bereits angemeldet öffnen.', + bulletSnapshot: 'Cookies und Anmeldungen werden in einen verwalteten Schnappschuss kopiert.', + bulletLiveProfile: 'Ihr Live-Browserprofil wird nie direkt geöffnet.', + bulletLocal: 'Nichts verlässt diesen Computer.', + dontShowAgain: 'Nicht mehr anzeigen', + notNow: 'Jetzt nicht', + enable: 'Mein Profil verwenden' + } + } + } + }, + skills: { + tabSkills: 'Skills', + tabToolsets: 'Tools', + configuringProfile: 'Konfiguriert:', + all: 'Alle', + searchSkills: 'Skills durchsuchen...', + searchToolsets: 'Tools durchsuchen...', + refresh: 'Skills aktualisieren', + refreshing: 'Skills werden aktualisiert', + loading: 'Fähigkeiten werden geladen...', + noSkillsTitle: 'Keine Skills gefunden', + noSkillsDesc: 'Versuchen Sie eine breitere Suche oder eine andere Kategorie.', + noToolsetsTitle: 'Keine Toolsets gefunden', + noToolsetsDesc: 'Versuchen Sie eine breitere Suchanfrage.', + noDescription: 'Keine Beschreibung.', + configured: 'Konfiguriert', + needsKeys: 'Benötigt Keys', + visionModelHint: + 'Vision verwendet Ihre Hilfsmodell-Konfiguration – das bildfähige Modell wird dort gewählt, nicht hier pro Provider.', + visionModelLink: 'Vision-Modell in Einstellungen → Modelle wählen', + toolsetsEnabled: (enabled, total) => `${enabled}/${total} Toolsets aktiviert`, + configureToolset: label => `${label} konfigurieren`, + toggleToolset: (label, enabled) => `Toolset ${label} ${enabled ? 'einschalten' : 'ausschalten'}`, + skillsLoadFailed: 'Skills konnten nicht geladen werden', + toolsetsRefreshFailed: 'Toolsets konnten nicht aktualisiert werden', + skillEnabled: 'Skill aktiviert', + skillDisabled: 'Skill deaktiviert', + toolsetEnabled: 'Toolset aktiviert', + toolsetDisabled: 'Toolset deaktiviert', + appliesToNewSessions: name => `${name} gilt für neue Sessions.`, + failedToUpdate: name => `${name} konnte nicht aktualisiert werden`, + sortMostUsed: 'Am häufigsten genutzt', + sortAlpha: 'A–Z', + sortMostUsedDesc: '↓ Am häufigsten genutzt', + sortLeastUsedAsc: '↑ Am seltensten genutzt', + enableAll: 'Alle aktivieren', + disableAll: 'Alle deaktivieren', + disableUnused: 'Unbenutzte deaktivieren', + bulkUpdated: count => `${count} ${count === 1 ? 'Element' : 'Elemente'} für neue Sessions aktualisiert.`, + bulkNoChange: 'Nichts zu ändern.', + usageCount: count => `${count}× verwendet`, + provenance: { + agent: 'Gelernt', + bundled: 'Integriert', + hub: 'Hub' + }, + emptyNoneFound: noun => `Kein ${noun} gefunden`, + emptyNothingMatches: query => `Nichts passt zu “${query}”.`, + emptyNoneAvailable: noun => `Noch kein ${noun} verfügbar.`, + changesApplyNewSessions: 'Änderungen gelten für neue Sessions.', + skillUpdated: 'Skill aktualisiert', + edit: 'Bearbeiten', + archive: 'Archivieren', + skillArchivedTitle: 'Skill archiviert', + skillArchivedMessage: 'Wiederherstellbar über „hermes curator restore“.', + tabPlugins: 'Plugins', + plugins: { + agentTitle: 'Agent-Plugins', + agentBlurb: + 'Erweitert den Agenten für das gewählte Profil — Tools, Hooks, Anbieter. Wirkt nach einem Gateway-Neustart.', + pageBlurb: 'Ein Plugin kann diese App, den Agent oder beides erweitern – jede Hälfte hat einen eigenen Schalter.', + halfDesktop: 'Desktop', + halfDesktopHint: 'diese App, gleich für jedes Profil', + halfAgent: 'Agent', + halfAgentIn: profile => `Agent in ${profile}`, + defaultProfile: 'Hermes (Standard)', + kindAgent: 'Agent', + kindDesktop: 'Desktop', + kindBoth: 'Agent + Desktop', + installAgentHere: 'Hier installieren', + installAgentHereTip: profile => + `Die Desktop-Hälfte ist in dieser App geladen, aber die Agent-Hälfte ist in ${profile} nicht installiert. Installieren Sie sie dort.`, + installAgentHereNoOrigin: + 'Die Agent-Hälfte ist in diesem Profil nicht installiert, und dieses Paket wurde von Hand hineinkopiert (kein Katalogeintrag und kein Git-Remote), daher kann es nicht von hier aus installiert werden. Kopieren Sie seinen Ordner ins Profil oder installieren Sie es neu aus Git.', + desktopHalfPending: 'wird kopiert…', + desktopHalfPendingTip: + 'Dieses Paket enthält eine Desktop-Hälfte, die noch nicht in die App kopiert wurde. Verwenden Sie „Erneut scannen“ oder starten Sie die App neu.', + desktopHalfRemote: 'nicht verfügbar (Remote-Backend)', + desktopHalfRemoteTip: + 'Die Desktop-Hälfte dieses Pakets liegt auf der Festplatte des Remote-Backends, die diese App nicht lesen kann. Um sie hier zu nutzen, führen Sie „Aus Git installieren“ mit der Repository-URL des Pakets und aktiviertem Desktop-Ziel aus – dadurch wird die Desktop-Hälfte auf diesen Rechner geklont.', + emptyAll: 'Noch keine Plugins.', + empty: 'Für dieses Profil sind keine Agent-Plugins installiert.', + emptyHint: 'Durchsuchen Sie unten den Katalog und installieren Sie ein geprüftes Plugin mit einem Klick.', + loadFailed: 'Agent-Plugins konnten nicht geladen werden', + toggleFailed: name => `${name} konnte nicht umgeschaltet werden`, + legacyBackend: + 'Dieses Backend ist älter als schlüsseladressierte Plugin-Schalter — aktualisieren Sie Hermes, um es hier zu verwalten.', + portableBadge: 'tragbar', + serverStates: { + connected: 'verbunden', + app_not_running: 'App läuft nicht', + endpoint_unavailable: 'Endpunkt nicht verfügbar', + no_interactive_session: 'keine interaktive Session', + version_too_old: 'Version zu alt', + missing_app: 'App fehlt', + unknown: 'Status unbekannt' + }, + catalogTitle: 'Plugin-Katalog', + catalogBrowse: 'Durchsuchen', + catalogHide: 'Katalog-Browser ausblenden', + catalogHint: + 'Klicken Sie bei einem Plugin auf „+ Zu diesem Agenten hinzufügen“ – geprüfte Einträge werden an ihrem gepinnten Commit in das gewählte Profil installiert. Gebündelte Agent+Desktop-Plugins bieten beide Hälften an.', + alreadyInstalled: name => `${name} ist in diesem Profil bereits installiert.`, + catalogProvenance: sha => `Aus dem Hermes-Katalog installiert${sha ? ` am Pin ${sha}` : ''}.`, + pinnedProvenance: sha => + `An Commit ${sha} gepinnt. Updates werden abgelehnt, bis es mit einem neuen Pin neu installiert wird.`, + pinnedBadge: sha => `gepinnt @ ${sha}`, + tierOfficial: 'offiziell', + tierCommunity: 'Community', + updateToPin: sha => `Auf ${sha} aktualisieren`, + updateFailed: name => `${name} konnte nicht aktualisiert werden`, + updated: name => + `${name} wurde auf den aktuellen Katalog-Pin aktualisiert. Starten Sie das Gateway neu, damit es wirksam wird.`, + updateConsentTitle: (name: string) => `${name} fordert mehr an`, + updateConsentBody: (name: string, sha: string) => + `Der neue Katalog-Pin von ${name} (${sha}) fügt Oberflächen hinzu, die die installierte Version nicht hat. Wenden Sie ihn nur an, wenn Sie ihnen vertrauen:`, + updateConsentConfirm: 'Update anwenden', + uninstall: 'Deinstallieren', + uninstallTip: (name: string, profile: string) => `${name} aus ${profile} deinstallieren`, + uninstallConfirmTitle: (name: string) => `${name} deinstallieren?`, + uninstallConfirmBody: (name: string, profile: string) => + `Dadurch werden die Dateien des Plugins aus dem Profil ${profile} gelöscht. Eine mitgelieferte Desktop-Hälfte wird ebenfalls entfernt. Sie können es jederzeit aus dem Katalog oder aus Git neu installieren.`, + uninstallFailed: (name: string) => `${name} konnte nicht deinstalliert werden`, + uninstalled: (name: string) => `${name} deinstalliert. Starten Sie das Gateway neu, um es zu entladen.`, + uninstallDesktopTip: (name: string) => `${name} aus dieser App deinstallieren`, + uninstallDesktopConfirmBody: (name: string) => + `Dadurch wird ${name} aus dem Ordner desktop-plugins auf diesem Computer gelöscht und sofort entladen. Sie können es jederzeit aus Git neu installieren oder den Ordner zurücklegen.`, + uninstalledDesktop: (name: string) => `${name} deinstalliert.`, + deepLinkErrorTitle: 'Plugin-Installationslink abgelehnt', + deepLinkCatalogInvalidName: 'Der Katalogname im Link fehlt oder ist ungültig.', + deepLinkCatalogUnknown: (name: string) => + `\u201E${name}\u201C ist nicht im Hermes-Plugin-Katalog. Es wurde nichts installiert.`, + deepLinkCatalogUnavailable: + 'Der Hermes-Plugin-Katalog konnte nicht geladen werden. Prüfen Sie Ihre Verbindung und öffnen Sie den Link erneut.', + settingsToggle: (name: string) => `Einstellungen: ${name}`, + settingsForm: { + save: 'Einstellungen speichern', + saved: (name: string) => `Einstellungen von ${name} gespeichert.`, + saveFailed: (name: string) => `Einstellungen von ${name} konnten nicht gespeichert werden`, + optional: '(optional)', + secretSet: '•••••••• (gesetzt)', + secretStoredAs: (env: string) => + `Wird in der .env des Profils als ${env} gespeichert, nie in config.yaml; leer lassen, um den aktuellen Wert zu behalten.` + } + }, + officialCatalog: 'Verfügbar zum Installieren', + officialPill: 'Offiziell', + hub: { + searchPlaceholder: 'Skill-Hub durchsuchen', + search: 'Suchen', + searching: 'Wird gesucht...', + connectingHubs: 'Verbindung zu Skill-Hubs wird hergestellt…', + connectedHubs: 'Verbundene Hubs:', + featured: 'Empfohlene Skills', + landingHint: + 'Durchsuchen Sie den Hub nach installierbaren Skills aus dem offiziellen Index, von GitHub und aus Community-Quellen.', + noResults: 'Im Hub wurden keine passenden Skills gefunden.', + resultCount: (count, ms) => `${count} Result${count === 1 ? '' : 'ate'}${ms !== null ? ` in ${ms}ms` : ''}`, + timedOut: sources => `Zeitüberschreitung: ${sources}`, + installed: 'Installiert', + install: 'Installieren', + installing: 'Wird installiert...', + uninstall: 'Deinstallieren', + uninstalling: 'Wird deinstalliert...', + updateAll: 'Installierte aktualisieren', + updating: 'Wird aktualisiert...', + preview: 'Vorschau', + scan: 'Scannen', + scanning: 'Wird gescannt...', + close: 'Schließen', + files: 'Dateien', + noReadme: 'Dieser Skill hat keine SKILL.md-Vorschau.', + trust: { + builtin: 'integriert', + trusted: 'vertraut', + community: 'Community' + }, + verdictSafe: 'Sicher', + verdictCaution: 'Vorsicht', + verdictDangerous: 'Gefährlich', + policyAllow: 'Installation erlaubt', + policyAsk: 'Vor der Installation prüfen', + policyBlock: 'Installation durch Richtlinie blockiert', + findings: count => `${count} Befund${count === 1 ? '' : 'e'}`, + noFindings: 'Keine Sicherheitsbefunde.', + installStarted: name => `${name} wird installiert...`, + uninstallStarted: name => `${name} wird deinstalliert...`, + updateStarted: 'Installierte Skills werden aktualisiert...', + actionFailed: 'Skill-Aktion fehlgeschlagen', + installBlockedTitle: name => `${name} konnte nicht installiert werden`, + installBlockedMessage: (findings, unverified) => + `Der Sicherheitsscan hat ${findings > 0 ? `${findings} Punkt${findings === 1 ? '' : 'e'}` : 'riskante Muster'} zur Prüfung markiert${unverified ? ' und der Skill stammt aus einer unbestätigten Quelle' : ''}. Lesen Sie den Scan, bevor Sie dem Autor vertrauen.`, + viewScan: 'Scan ansehen', + openLog: 'Log öffnen', + actionLog: 'Aktionsprotokoll', + alreadyInstalled: (name: string) => `"${name}" ist bereits installiert`, + pickerTitle: 'Skills Hub', + pickerBrowse: 'Den ganzen Hub durchstöbern', + pickerHide: 'Hub-Durchsucher ausblenden', + pickerHint: + 'Klicken Sie bei einem beliebigen Skill auf „+ Zu diesem Agenten hinzufügen“ – er wird installiert und erscheint in der Liste oben.', + loadFailed: 'Skill-Hub konnte nicht geladen werden', + previewFailed: 'Skill-Vorschau fehlgeschlagen', + scanFailed: 'Sicherheitsscan fehlgeschlagen', + searchFailed: 'Hub-Suche fehlgeschlagen' + } + }, + starmap: { + title: 'Speichergraph', + subtitle: (nodes, clusters) => `${nodes} Skills in ${clusters} Kategorien`, + close: 'Speichergraph schließen', + refresh: 'Aktualisieren', + memory: 'Speicher', + filterAll: 'Alle', + filterUsed: 'Verwendet', + filterLearned: 'Gelernt', + viewGraph: 'Graph', + loadFailed: 'Speichergraph konnte nicht geladen werden', + loading: 'Wird geladen…', + emptyTitle: 'Noch nichts gelernt', + emptyDesc: 'Sobald Hermes Skills und Erinnerungen zu Ihrer Arbeit aufbaut, erscheinen sie hier.', + share: 'Map teilen', + shareHint: + 'Kopieren Sie den Code, um diese Map zu teilen, oder fügen Sie einen ein, um sie zu laden. Er enthält nur das Layout, nicht Ihren Speicher oder Skill-Text.', + shareTitle: 'Map importieren / exportieren', + sharePlaceholder: 'Einen Map-Code einfügen…', + copy: 'Map-Code kopieren', + copied: 'Kopiert!', + importMap: 'Eine Map importieren', + importBtn: 'Laden', + importEmpty: 'Fügen Sie einen Map-Code ein, um ihn zu laden.', + importSuccess: nodes => `Eine Map mit ${nodes} ${nodes === 1 ? 'Knoten' : 'Knoten'} geladen.`, + importedBadge: 'importierte Map', + resetToMine: 'Zurück zu meiner Map' + }, + agents: { + extendedTranscript: 'Extended Transcript', + transcriptTruncated: 'Zeigt die letzten 16 KiB', + transcriptUnavailable: 'Live-Transcript nicht verfügbar', + close: 'Agents schließen', + title: 'Spawn-Baum', + subtitle: 'Live-Subagent-Aktivität für die aktuelle Runde.', + emptyTitle: 'Keine Live-Subagents', + emptyDesc: 'Sobald eine Runde Arbeit delegiert, streamen die Unter-Agents ihren Fortschritt hierher.', + running: 'Läuft', + failed: 'Fehlgeschlagen', + done: 'Fertig', + streaming: 'Streaming', + files: 'Dateien', + moreFiles: count => `+${count} weitere Dateien`, + moreAgents: count => `+${count} weitere Agents`, + queued: 'In der Warteschlange', + waitingActivity: 'Warten auf Aktivität', + steer: 'Steuern', + steerPlaceholder: 'Anweisungen für diesen Subagent', + steerQueued: 'Für den nächsten Checkpoint eingereiht', + stopRequested: 'Stopp angefordert', + requestRejected: 'Der Subagent hat die Anfrage nicht angenommen', + delegation: index => `Delegation ${index}`, + workers: count => `${count} Worker`, + workersActive: count => `${count} aktiv`, + agentsCount: count => `${count} ${count === 1 ? 'Agent' : 'Agents'}`, + activeCount: count => `${count} aktiv`, + failedCount: count => `${count} fehlgeschlagen`, + toolsCount: count => `${count} Tools`, + filesCount: count => `${count} Dateien`, + updatedAgo: age => `vor ${age} aktualisiert`, + ageNow: 'jetzt', + ageSeconds: seconds => `vor ${seconds}s`, + ageMinutes: minutes => `vor ${minutes}m`, + ageHours: hours => `vor ${hours}h`, + ageDays: days => `vor ${days}d`, + durationSeconds: seconds => `${seconds}s`, + durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`, + tokens: value => `${value} Tok` + }, + commandCenter: { + close: 'Command Center schließen', + paletteTitle: 'Befehlspalette', + back: 'Zurück', + searchPlaceholder: 'Sessions, Ansichten und Aktionen durchsuchen', + goTo: 'Wechseln zu', + goToSession: 'Zur Session gehen', + branches: 'Branches', + projects: 'Projekte', + openFolder: 'Ordner als Projekt öffnen…', + openFolderAt: path => `Ordner als Projekt öffnen — ${path}`, + newSessionInProject: project => `Neue Session in ${project}`, + commands: 'Befehle', + startInBranch: branch => `Neue Konversation in ${branch}`, + commandCenter: 'Command Center', + appearance: 'Darstellung', + settings: 'Einstellungen', + changeTheme: 'Design wechseln', + changeColorMode: 'Farbmodus wechseln…', + pets: { + title: 'Pets', + placeholder: 'Pets suchen…', + loading: 'Petdex-Galerie wird geladen…', + error: 'Die Petdex-Galerie konnte nicht erreicht werden.', + staleBackend: 'Starten Sie Hermes neu, um Pets zu verwenden – das Backend ist älter als diese Funktion.', + empty: 'Keine passenden Pets.', + turnOff: 'Ausschalten', + turnOn: 'Einschalten', + installed: 'Installiert', + generatedTag: 'Generiert', + adoptFailed: 'Dieses Pet konnte nicht adoptiert werden.', + toggleFailed: enabled => `Das Pet konnte nicht ${enabled ? 'eingeschaltet' : 'ausgeschaltet'} werden.`, + noneAvailable: 'Keine Pets verfügbar — wählen Sie weiter unten eines zum Installieren aus.' + }, + generatePet: { + title: 'Ein Pet generieren', + placeholder: 'Beschreiben Sie ein Pet zum Generieren…', + promptHint: 'Geben Sie eine Beschreibung ein und drücken Sie dann Enter, um vier Designs zu entwerfen.', + readyHint: 'Drücken Sie Enter, um aus Ihrer Beschreibung vier Designs zu entwerfen.', + generate: 'Generieren', + generating: 'Wird generiert…', + retry: 'Erneut versuchen', + hatch: 'Schlüpfen', + spawning: 'Wird erzeugt…', + hatching: 'Ihr Pet schlüpft…', + hatchingSub: 'Wir erwecken es zum Leben…', + hatched: 'Es ist geschlüpft!', + hatchRow: (_state, done, total) => `Frame ${done} von ${total} wird gezeichnet…`, + hatchComposing: 'Es wird zusammengesetzt…', + hatchSaving: 'Fast geschafft…', + namePlaceholder: 'Geben Sie Ihrem Pet einen Namen', + staleBackend: 'Aktualisieren Sie Hermes, um Pets zu generieren.', + backgroundHint: 'Sie können dieses Fenster schließen – Hermes benachrichtigt Sie, wenn es fertig ist.', + slowProviderHint: 'Das kann mehrere Minuten dauern', + remix: 'Remixen', + remixConfirmTitle: 'Dieses Design remixen?', + remixConfirmBody: + 'Das erzeugt einen neuen Satz an Entwürfen und verwendet dieses als Ausgangspunkt. Das kann mehrere Minuten dauern.', + genericError: 'Generierung fehlgeschlagen – versuchen Sie es erneut oder wählen Sie einen Vorschlag.', + referenceImageTooLarge: 'Das Referenzbild ist zu groß. Verwenden Sie eines unter 16 MB.', + referenceImageInvalid: 'Das Referenzbild konnte nicht gelesen werden. Probieren Sie ein PNG, JPG, WebP oder GIF.', + adopt: 'Adoptieren', + startOver: 'Neu beginnen' + }, + installTheme: { + title: 'Design installieren…', + pageTitle: 'Design installieren', + placeholder: 'VS Code Marketplace durchsuchen...', + loading: 'Marketplace wird durchsucht...', + error: 'Der Marketplace konnte nicht erreicht werden.', + empty: 'Keine passenden Designs.', + install: 'Installieren', + installing: 'Wird installiert...', + installed: 'Installiert', + installs: count => `${count} Installationen` + }, + settingsFields: 'Einstellungsfelder', + mcpServers: 'MCP-Server', + archivedChats: 'Archivierte Chats', + sections: { + maintenance: 'Wartung', + sessions: 'Sessions', + system: 'System', + usage: 'Nutzung' + }, + sectionDescriptions: { + maintenance: 'Diagnose, Backups, Curator und Memory-Daten', + sessions: 'Sessions durchsuchen und verwalten', + system: 'Status, Logs und Systemaktionen', + usage: 'Token-, Kosten- und Skill-Aktivität im Zeitverlauf' + }, + nav: { + newChat: { + title: 'Neue Session', + detail: 'Eine neue Session starten' + }, + settings: { + title: 'Einstellungen', + detail: 'Hermes Desktop konfigurieren' + }, + capabilities: { + title: 'Fähigkeiten', + detail: 'Skills, Tools, MCP-Server und Plugins' + }, + messaging: { + title: 'Messaging', + detail: 'Telegram, Slack, Discord und mehr einrichten' + }, + artifacts: { + title: 'Artefakte', + detail: 'Generierte Ausgaben durchstöbern' + } + }, + sectionEntries: { + sessions: { + title: 'Sessions-Bereich', + detail: 'Sessions durchsuchen, anpinnen und verwalten' + }, + system: { + title: 'System-Bereich', + detail: 'Gateway-Status, Logs, Neustart/Update' + }, + usage: { + title: 'Nutzungs-Bereich', + detail: 'Token-, Kosten- und Skill-Aktivität' + } + }, + providerNavigate: 'Navigieren', + providerSessions: 'Sessions', + refresh: 'Aktualisieren', + refreshing: 'Wird aktualisiert...', + noResults: 'Keine passenden Ergebnisse gefunden.', + pinSession: 'Session anpinnen', + unpinSession: 'Session lösen', + exportSession: 'Session exportieren', + deleteSession: 'Session löschen', + noSessions: 'Noch keine Sessions.', + gatewayRunning: 'Messaging-Gateway läuft', + gatewayStopped: 'Messaging-Gateway gestoppt', + hermesActiveSessions: (version, count) => `Hermes ${version} · Aktive Sessions ${count}`, + restartGateway: 'Gateway neu starten', + openBrowser: 'Browser öffnen', + gatewayRestartFailed: 'Gateway-Neustart fehlgeschlagen.', + sharedGatewayRestartTitle: 'Gemeinsames Gateway neu starten?', + sharedGatewayRestartDescription: bots => `Alle Bots auf diesem Gerät verbinden sich neu: ${bots}`, + sharedGatewayRestartConfirm: 'Alle neu starten', + sharedGatewayRestarted: count => `Gemeinsames Gateway neu gestartet (${count} ${count === 1 ? 'Bot' : 'Bots'})`, + updateHermes: 'Hermes aktualisieren', + reloadWindow: 'Fenster neu laden', + actionRunning: 'läuft', + actionDone: 'fertig', + actionFailed: 'fehlgeschlagen', + actionStartedWaiting: 'Aktion gestartet, wartet auf Status…', + loadingStatus: 'Status wird geladen...', + recentLogs: 'Letzte Logs', + noLogs: 'Noch keine Logs geladen.', + days: count => `${count} T`, + statSessions: 'Sessions', + statApiCalls: 'API-Aufrufe', + statTokens: 'Token ein/aus', + statCost: 'Gesch. Kosten', + actualCost: cost => `tatsächlich ${cost}`, + loadingUsage: 'Nutzung wird geladen...', + noUsage: period => `In den letzten ${period} Tagen keine Nutzung.`, + retry: 'Erneut versuchen', + dailyTokens: 'Tägliche Token', + input: 'Eingang', + output: 'Ausgang', + noDailyActivity: 'Keine Tagesaktivität.', + topModels: 'Top-Modelle', + noModelUsage: 'Noch keine Model-Nutzung.', + topSkills: 'Top-Skills', + noSkillActivity: 'Noch keine Skill-Aktivität.', + actions: count => `${count} Aktionen`, + logFile: 'Logdatei', + logLevel: 'Stufe', + logSearchPlaceholder: 'Logzeilen filtern...', + maintenance: { + runOps: 'Diagnose', + doctor: 'Doctor ausführen', + doctorDesc: 'Installation, Konfiguration und Provider auf Fehler prüfen', + securityAudit: 'Sicherheitsaudit', + securityAuditDesc: 'Konfiguration und Skills auf riskante Einstellungen scannen', + backup: 'Backup erstellen', + backupDesc: 'Konfiguration, Memories, Skills und Sessions als ZIP packen', + debugShare: 'Debug-Freigabe', + debugShareDesc: + 'Einen geschwärzten Bericht + Logs hochladen, teilbare Links erhalten (wird nach 6h automatisch gelöscht)', + debugShareRunning: 'Debug-Bericht wird hochgeladen...', + debugShareLinks: 'Freigabelinks', + debugShareFailed: 'Debug-Freigabe fehlgeschlagen', + copyLink: 'Link kopieren', + linkCopied: 'Link kopiert', + curator: 'Skill-Curator', + curatorDesc: 'Hintergrundprüfung, die veraltete, von Agenten erstellte Skills archiviert', + curatorPaused: 'Pausiert', + curatorActive: 'Aktiv', + curatorDisabled: 'Deaktiviert', + curatorLastRun: when => `Zuletzt ausgeführt ${when}`, + curatorNeverRan: 'Nie ausgeführt', + pause: 'Pausieren', + resume: 'Fortsetzen', + runNow: 'Jetzt ausführen', + memoryData: 'Memory-Daten', + memoryDataDesc: 'Eingebaute Memory-Dateien, die in jede Session eingefügt werden', + memoryProvider: name => `Aktiver Provider: ${name}`, + builtinMemory: 'eingebaut', + memoryFile: 'Agent-Memory (MEMORY.md)', + userFile: 'Benutzerprofil (USER.md)', + bytes: size => size, + empty: 'leer', + resetMemory: 'Memory zurücksetzen', + resetUser: 'Profil zurücksetzen', + resetAll: 'Beide zurücksetzen', + resetConfirm: target => `${target} löschen? Das kann nicht rückgängig gemacht werden.`, + resetDone: files => `${files} gelöscht.`, + resetFailed: 'Memory-Zurücksetzen fehlgeschlagen', + actionStarted: name => `${name} gestartet — Log wird verfolgt...`, + actionFailed: name => `${name} konnte nicht gestartet werden`, + running: 'Läuft...', + viewLog: 'Aktionslog' + } + }, + messaging: { + search: 'Messaging durchsuchen...', + loading: 'Messaging-Plattformen werden geladen...', + loadFailed: 'Messaging-Plattformen konnten nicht geladen werden', + states: { + connected: 'Verbunden', + connecting: 'Verbindung wird hergestellt', + disabled: 'Deaktiviert', + fatal: 'Fehler', + gateway_stopped: 'Messaging-Gateway gestoppt', + not_configured: 'Einrichtung nötig', + pending_restart: 'Neustart nötig', + retrying: 'Neuer Versuch', + startup_failed: 'Start fehlgeschlagen' + }, + unknown: 'Unbekannt', + hintPendingRestart: 'Starten Sie das Gateway über die Statusleiste neu, um diese Änderung zu übernehmen.', + sharedListenerUrl: 'Erreichbar über den gemeinsamen Gateway-Listener unter', + hintGatewayStopped: 'Starten Sie das Gateway über die Statusleiste, um die Verbindung herzustellen.', + credentialsSet: 'Zugangsdaten gesetzt', + needsSetup: 'Einrichtung nötig', + gatewayStopped: 'Messaging-Gateway gestoppt', + getCredentials: 'Zugangsdaten abrufen', + openSetupGuide: 'Einrichtungsanleitung öffnen', + required: 'Erforderlich', + recommended: 'Empfohlen', + advanced: count => `Erweitert (${count})`, + noTokenNeeded: + 'Für diese Plattform benötigen Sie hier kein Token. Folgen Sie der Einrichtungsanleitung oben und aktivieren Sie sie darunter.', + enabled: 'Aktiviert', + disabled: 'Deaktiviert', + unsavedChanges: 'Ungespeicherte Änderungen', + saving: 'Wird gespeichert…', + saveChanges: 'Änderungen speichern', + saved: 'Gespeichert', + replaceValue: 'Aktuellen Wert ersetzen', + openDocs: 'Dokumentation öffnen', + clearField: key => `${key} löschen`, + enableAria: name => `${name} aktivieren`, + disableAria: name => `${name} deaktivieren`, + platformEnabled: name => `${name} aktiviert`, + platformDisabled: name => `${name} deaktiviert`, + restartToApply: 'Diese Änderung wird nach einem Gateway-Neustart wirksam.', + setupSaved: name => `${name}-Einrichtung gespeichert`, + restartToReconnect: 'Neue Zugangsdaten werden nach einem Gateway-Neustart wirksam.', + appliedLive: 'Auf das laufende Gateway angewendet.', + connectingLive: 'Das laufende Gateway verbindet sich mit den neuen Zugangsdaten.', + keyCleared: key => `${key} gelöscht`, + setupUpdated: name => `${name}-Einrichtung wurde aktualisiert.`, + failedUpdate: name => `Konnte ${name} nicht aktualisieren`, + failedSave: name => `Konnte ${name} nicht speichern`, + failedClear: key => `Konnte ${key} nicht löschen`, + pendingRequests: count => `Wartende Anfragen (${count})`, + pendingAria: count => `${count} wartende ${count === 1 ? 'Koppelung' : 'Koppelungen'}`, + approvedUsers: count => `Freigegebene Benutzer (${count})`, + approve: 'Freigeben', + approving: 'Freigeben...', + revoke: 'Entziehen', + revoking: 'Wird entzogen...', + revokeAria: name => `Entziehe ${name}`, + revokeTitle: 'Zugriff entziehen', + revokeDesc: (name: string) => + `${name} verliert den Zugriff und wird bei seiner nächsten Message nicht mehr erkannt.`, + approvedUser: name => `${name} freigegeben`, + approvedHint: 'Sie werden bei ihrer nächsten Message automatisch erkannt.', + revokedUser: name => `Zugriff für ${name} entzogen`, + failedApprove: name => `Konnte ${name} nicht freigeben`, + failedRevoke: name => `Konnte ${name} nicht entziehen`, + pairingLockedOut: + 'Zu viele fehlgeschlagene Freigaben – diese Plattform ist gesperrt. Versuchen Sie es später erneut.', + waitingSince: minutes => (minutes < 1 ? 'gerade eben' : `${minutes} Min. her`), + restartNeeded: 'Gespeichert. Starten Sie das Messaging-Gateway neu, damit die neuen Einstellungen wirksam werden.', + restartNow: 'Jetzt neu starten', + restarting: 'Wird neu gestartet…', + restartFailedManual: + 'Gateway-Neustart fehlgeschlagen – starten Sie es manuell neu und prüfen Sie die Gateway-Logs.', + restartFailedManualDetail: + 'Versuchen Sie den Neustart erneut; wenn er weiterhin fehlschlägt, öffnen Sie die Logs und senden Sie Diagnosedaten.', + restartAgain: 'Erneut neu starten', + openLogs: 'Logs öffnen', + telegramQr: { + title: 'Wählen Sie, wie Sie Ihren Telegram-Bot verbinden', + subtitle: + 'Beide Optionen verbinden einen Bot, den Sie kontrollieren, und speichern seine Zugangsdaten nur in dieser Hermes-Installation.', + quickSetup: 'Schnelleinrichtung', + recommended: 'Empfohlen', + quickHelp: + 'Scannen Sie einen QR-Code und bestätigen Sie in Telegram. Hermes legt den Bot an und erkennt Ihre Telegram-Benutzer-ID automatisch.', + createWithQr: 'Mit QR-Code anlegen', + starting: 'Wird gestartet…', + replaceWarning: + 'Es sind bereits Telegram-Zugangsdaten konfiguriert. Eine neue QR-Einrichtung oder ein Bot-Token ersetzt beim Speichern den aktuellen Bot.', + scanHint: 'Scannen Sie mit der Telegram-App auf Ihrem Handy oder öffnen Sie den Link auf diesem Computer.', + waiting: 'Warten auf Telegram…', + expiresIn: remaining => `Läuft ab in ${remaining}`, + expired: 'Abgelaufen', + openTelegram: 'Telegram öffnen', + ready: 'Bot angelegt', + allowedUsers: 'Erlaubte Benutzer', + ownerDetected: 'Eigentümer erkannt', + addAtLeastOne: 'Fügen Sie mindestens eine Telegram-Benutzer-ID hinzu.', + userIdPlaceholder: 'Telegram-Benutzer-ID', + add: 'Hinzufügen', + numericOnly: 'Erlaubte Telegram-Benutzer-IDs müssen numerisch sein.', + saveAndRestart: 'Speichern und neu starten', + applying: 'Wird gespeichert…', + pairingExpired: 'Telegram-Kopplung abgelaufen. Starten Sie eine neue QR-Einrichtung, um es erneut zu versuchen.', + stillWaiting: detail => `Noch keine Antwort von Telegram. Neuer Versuch nach: ${detail}`, + savedRestarting: 'Telegram gespeichert; Gateway wird neu gestartet…', + savedRestartFailed: detail => `Telegram gespeichert; Gateway-Neustart fehlgeschlagen${detail}` + }, + fieldCopy: { + TELEGRAM_BOT_TOKEN: { + label: 'Bot-Token', + help: 'Erstellen Sie einen Bot mit @BotFather und fügen Sie das Token ein, das Sie von ihm erhalten.', + placeholder: 'Telegram-Bot-Token einfügen' + }, + TELEGRAM_ALLOWED_USERS: { + label: 'Erlaubte Telegram-Benutzer-IDs', + help: 'Empfohlen. Numerische IDs von @userinfobot, durch Kommas getrennt. Ohne diese können Ihnen beliebige Benutzer Direktnachrichten senden.' + }, + TELEGRAM_PROXY: { + label: 'Proxy-URL', + help: 'Nur auf Netzwerken nötig, wo Telegram blockiert ist.' + }, + DISCORD_BOT_TOKEN: { + label: 'Bot-Token', + help: 'Erstellen Sie im Discord Developer Portal eine Anwendung, fügen Sie einen Bot hinzu und fügen Sie sein Token ein.' + }, + DISCORD_ALLOWED_USERS: { + label: 'Erlaubte Discord-Benutzer-IDs', + help: 'Empfohlen. Discord-Benutzer-IDs, durch Kommas getrennt.' + }, + DISCORD_REPLY_TO_MODE: { + label: 'Antwortstil', + help: 'first, all oder off.' + }, + DISCORD_ALLOW_ALL_USERS: { + label: 'Alle Discord-Benutzer zulassen', + help: 'Nur für die Entwicklung. Bei true kann jeder Benutzer dem Bot per DM schreiben, ohne Allowlist.' + }, + DISCORD_HOME_CHANNEL: { + label: 'Home-Kanal-ID', + help: 'Kanal, in den der Bot proaktive Messages sendet (Cron-Ausgabe, Erinnerungen).' + }, + DISCORD_HOME_CHANNEL_NAME: { + label: 'Name des Home-Kanals', + help: 'Anzeigename des Home-Kanals in Logs und Status-Ausgabe.' + }, + BLUEBUBBLES_ALLOW_ALL_USERS: { + label: 'Alle iMessage-Benutzer zulassen', + help: 'Bei true wird die BlueBubbles-Allowlist übersprungen.' + }, + MATTERMOST_ALLOW_ALL_USERS: { + label: 'Alle Mattermost-Benutzer zulassen' + }, + MATTERMOST_HOME_CHANNEL: { + label: 'Home-Kanal' + }, + QQ_ALLOW_ALL_USERS: { + label: 'Alle QQ-Benutzer zulassen' + }, + QQBOT_HOME_CHANNEL: { + label: 'QQ-Home-Kanal', + help: 'Standard-Kanal oder -Gruppe für die Cron-Zustellung.' + }, + QQBOT_HOME_CHANNEL_NAME: { + label: 'Name des QQ-Home-Kanals' + }, + SLACK_BOT_TOKEN: { + label: 'Slack-Bot-Token', + help: 'Verwenden Sie das Bot-Token aus OAuth & Permissions, nachdem Sie Ihre Slack-App installiert haben.', + placeholder: 'Slack-Bot-Token einfügen' + }, + SLACK_APP_TOKEN: { + label: 'Slack-App-Token', + help: 'Verwenden Sie das App-Level-Token, das für den Socket Mode erforderlich ist.', + placeholder: 'Slack-App-Token einfügen' + }, + SLACK_ALLOWED_USERS: { + label: 'Erlaubte Slack-Benutzer-IDs', + help: 'Empfohlen. Slack-Benutzer-IDs, durch Kommas getrennt.' + }, + MATTERMOST_URL: { + label: 'Server-URL', + placeholder: 'https://mattermost.example.com' + }, + MATTERMOST_TOKEN: { + label: 'Bot-Token' + }, + MATTERMOST_ALLOWED_USERS: { + label: 'Erlaubte Benutzer-IDs', + help: 'Empfohlen. Mattermost-Benutzer-IDs, durch Kommas getrennt.' + }, + MATRIX_HOMESERVER: { + label: 'Homeserver-URL', + placeholder: 'https://matrix.org' + }, + MATRIX_ACCESS_TOKEN: { + label: 'Zugriffs-Token' + }, + MATRIX_USER_ID: { + label: 'Bot-Benutzer-ID', + placeholder: '@hermes:example.org' + }, + MATRIX_ALLOWED_USERS: { + label: 'Erlaubte Matrix-Benutzer-IDs', + help: 'Empfohlen. Benutzer-IDs im Format @benutzer:server, durch Kommas getrennt.' + }, + SIGNAL_HTTP_URL: { + label: 'Signal-Bridge-URL', + placeholder: 'http://127.0.0.1:8080', + help: 'URL einer laufenden signal-cli-REST-Bridge.' + }, + SIGNAL_ACCOUNT: { + label: 'Telefonnummer', + help: 'Die Nummer, die bei Ihrer signal-cli-Bridge registriert ist.' + }, + SIGNAL_ALLOWED_USERS: { + label: 'Erlaubte Signal-Benutzer', + help: 'Empfohlen. Signal-Kennungen, durch Kommas getrennt.' + }, + WHATSAPP_ENABLED: { + label: 'WhatsApp-Bridge aktivieren', + help: 'Wird automatisch über den Schalter unten gesetzt. Lassen Sie es unverändert, sofern Sie es nicht ausdrücklich benötigen.' + }, + WHATSAPP_MODE: { + label: 'Bridge-Modus' + }, + WHATSAPP_ALLOWED_USERS: { + label: 'Erlaubte WhatsApp-Benutzer', + help: 'Empfohlen. Telefonnummern oder WhatsApp-IDs, durch Kommas getrennt.' + } + }, + platformIntro: {} + }, + webhooks: { + search: 'Webhooks suchen …', + loading: 'Webhooks werden geladen …', + loadFailed: 'Webhooks konnten nicht geladen werden', + subscriptions: (count: number) => `Abonnements (${count})`, + hint: 'Änderungen an Abonnements werden heiß nachgeladen, sobald der Empfänger läuft. Deaktivierte Abonnements lehnen eingehende Events ab.', + empty: 'Noch keine Webhook-Abonnements vorhanden.', + disabledTitle: 'Webhook-Empfänger deaktiviert', + disabledBody: + 'Webhooks sind eine eigene Gateway-Plattform. Aktivieren Sie sie hier, um eingehende HTTP-Events anzunehmen; Chat-Kanäle werden nur benötigt, wenn ein Abonnement an Telegram, Discord, Slack oder einen anderen Kanal zustellt.', + enable: 'Webhooks aktivieren', + enabling: 'Wird aktiviert …', + enabled: (name: string) => `Aktiviert: „${name}“`, + disabled: (name: string) => `Deaktiviert: „${name}“`, + enableRow: 'Aktivieren', + disableRow: 'Deaktivieren', + delete: 'Löschen', + deleting: 'Wird gelöscht …', + deleted: 'Webhook gelöscht', + deleteTitle: 'Webhook löschen', + deleteDescPrefix: 'Dadurch wird ', + deleteDescSuffix: ' endgültig entfernt. Das kann nicht rückgängig gemacht werden.', + deleteFailed: (name: string) => `Löschen von „${name}“ fehlgeschlagen`, + toggleFailed: (name, enabled) => + `Umschalten von „${name}“ ${enabled ? 'einschalten' : 'ausschalten'} fehlgeschlagen`, + newSubscription: 'Neues Abonnement', + restarting: 'Gateway wird neu gestartet …', + restartNeeded: + 'Webhooks sind aktiviert, aber das Gateway braucht vor dem Onlinegehen des Empfängers noch einen Neustart.', + restartGateway: 'Gateway neu starten', + restartingGateway: 'Wird neu gestartet …', + restartFailed: (detail: string) => `Gateway-Neustart fehlgeschlagen${detail}`, + enabledRestarting: 'Webhooks aktiviert; Gateway wird neu gestartet …', + all: '(alle)', + deliverOnly: 'nur zustellen', + createdTitle: 'Abonnement erstellt', + createdSecretHint: 'Kopieren Sie das Secret jetzt – es wird nur einmal angezeigt.', + webhookUrl: 'Webhook-URL', + secretOnce: 'Secret (nur einmal angezeigt)', + done: 'Fertig', + fieldName: 'Name', + fieldNamePlaceholder: 'z. B. github-push', + fieldDescription: 'Beschreibung', + fieldDescriptionPlaceholder: 'Was dieser Webhook tut (optional)', + fieldEvents: 'Events', + fieldEventsPlaceholder: 'durch Kommas getrennt, für alle leer lassen', + fieldSkills: 'Skills', + fieldSkillsPlaceholder: 'durch Kommas getrennte Skill-Namen (optional)', + fieldDeliver: 'Zustellen an', + fieldDeliverOnly: 'Nur die Payload zustellen', + fieldPrompt: 'Prompt', + fieldPromptPlaceholder: 'Anweisungen für den Agenten, wenn dieser Webhook ausgelöst wird (optional)', + nameRequired: 'Name erforderlich', + create: 'Erstellen', + creating: 'Wird erstellt …', + created: 'Erstellt', + createFailed: (detail: string) => `Erstellen fehlgeschlagen: ${detail}`, + copy: 'Kopieren', + deliverOptions: { + log: 'Log', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + email: 'E-Mail', + github_comment: 'GitHub-Kommentar' + } + }, + profiles: { + close: 'Profile schließen', + nameHint: + 'Kleinbuchstaben, Ziffern, Bindestriche und Unterstriche. Muss mit einem Buchstaben oder einer Ziffer beginnen.', + title: 'Profile', + count: count => `${count} ${count === 1 ? 'Profil' : 'Profile'}`, + search: 'Profile durchsuchen...', + loading: 'Profile werden geladen...', + newProfile: 'Neues Profil', + importProfile: 'Profil importieren…', + exportProfile: 'Profil exportieren…', + imported: 'Profil importiert', + exported: 'Profil exportiert', + failedImport: 'Profil konnte nicht importiert werden', + failedExport: 'Profil konnte nicht exportiert werden', + allProfiles: 'Alle Profile', + showAllProfiles: 'Alle Profile anzeigen', + switchToProfile: name => `Zu ${name} wechseln`, + switchToConnection: name => `Zu ${name} wechseln`, + switchConnectionFailed: name => `Keine Verbindung zu ${name} möglich`, + manageProfiles: 'Profile verwalten…', + connectGateway: 'Gateways verwalten…', + fleet: { + allOnGateway: 'Alle Profile auf diesem Gateway', + gateway: gateway => `Profile auf ${gateway}`, + gatewayUnreachable: gateway => `${gateway} · nicht erreichbar`, + onGateway: (name, gateway) => `${name} · ${gateway}`, + switchTo: (name, gateway) => `Zu ${name} auf ${gateway} wechseln`, + deleteOn: gateway => ` auf ${gateway}` + }, + remoteOverride: { + menuItem: 'Mit Remote-Host verbinden…', + badge: (host: string) => `Läuft auf ${host}`, + title: (profile: string) => `${profile} mit einem Remote-Host verbinden`, + description: + 'Sessions in diesem Profil laufen auf dem von Ihnen festgelegten Remote-Hermes statt auf diesem Computer.', + urlLabel: 'Remote-Adresse', + urlPlaceholder: 'https://hermes.example.com', + urlInvalid: 'Geben Sie eine vollständige Adresse ein, die mit http:// oder https:// beginnt', + tokenLabel: 'Access-Token', + tokenPlaceholder: 'Remote-Session-Token einfügen', + tokenSavedHint: 'Ein Token ist bereits gespeichert. Leer lassen, um es zu behalten.', + plainTextOptIn: + 'Dieser Computer hat keine sichere Schlüssel-Ablage, daher würde das Token unverschlüsselt auf der Platte gespeichert. Trotzdem speichern?', + collisionWarning: (label: string) => + `Ein Gateway mit dem Namen “${label}” existiert bereits in den Einstellungen. Diese Profil-Verbindung ist davon getrennt und ändert es nicht.`, + confirmTitle: 'Dieses Profil mit einem Remote-Host verbinden?', + confirmNote: (profile: string, host: string) => + `Neue Chats in ${profile} laufen auf ${host}. Befehle werden dort ausgeführt und Dateien dort gelesen, nicht auf diesem Rechner. Verbinden Sie sich nur mit einem Host, dem Sie vertrauen.`, + confirmBack: 'Zurück', + connect: 'Verbinden', + connecting: 'Verbindung wird hergestellt…', + disconnect: 'Remote-Verbindung entfernen', + savedTitle: 'Profil verbunden', + savedMessage: (profile: string, host: string) => `${profile} läuft jetzt auf ${host}`, + removedTitle: 'Remote-Verbindung entfernt', + removedMessage: (profile: string) => `${profile} läuft jetzt auf diesem Computer`, + removeFailed: 'Die Remote-Verbindung konnte nicht entfernt werden', + authFailedTitle: 'Remote-Host hat das gespeicherte Token abgelehnt', + authFailedMessage: (profile: string, host: string) => + `${host} hat das für ${profile} gespeicherte Token abgelehnt. Es könnte auf der Remote-Seite geändert worden sein.`, + updateToken: 'Neues Token eingeben…' + }, + actions: 'Aktionen', + color: 'Farbe…', + colorFor: 'Farbe', + openInNewWindow: 'In neuem Fenster öffnen', + setAsDefault: 'Als Standard festlegen', + defaultProfile: 'Standardprofil', + defaultSet: (name: string) => `${name} ist jetzt der Standard`, + defaultDescription: + 'Wird beim Öffnen von Hermes und für neue Chats verwendet. Bestehende Sessions bleiben in ihren Profilen.', + failedSetDefault: 'Das Standardprofil konnte nicht festgelegt werden', + setColor: color => `Farbe ${color} setzen`, + autoColor: 'Auto', + noProfiles: 'Noch keine Profile.', + selectPrompt: 'Wählen Sie ein Profil aus, um seine Details zu sehen.', + refresh: 'Profile aktualisieren', + refreshing: 'Profile werden aktualisiert', + default: 'default', + skills: count => `${count} ${count === 1 ? 'Skill' : 'Skills'}`, + env: 'env', + defaultBadge: 'Standard', + rename: 'Umbenennen', + renameMenu: 'Umbenennen…', + exportMenu: 'Exportieren…', + editSoul: 'SOUL.md bearbeiten…', + copySetup: 'Setup kopieren', + copying: 'Wird kopiert...', + modelLabel: 'Modell', + skillsLabel: 'Skills', + notSet: 'Nicht gesetzt', + soulDesc: 'Die System-Prompt- und Personen-Anweisungen, die in diesem Profil fest eingebacken sind.', + soulOptional: 'optional', + soulPlaceholder: mode => + `Die System-Prompt- bzw. Personen-Anweisung für dieses Profil.\nLeer lassen, um den ${mode}-Standard zu behalten.`, + soulPlaceholderCloned: 'geklont', + soulPlaceholderEmpty: 'leer', + unsavedChanges: 'Ungespeicherte Änderungen', + loadingSoul: 'SOUL.md wird geladen...', + emptySoul: 'Leere SOUL.md — beginnen Sie, die Persona zu schreiben…', + saving: 'Wird gespeichert...', + saveSoul: 'SOUL.md speichern', + deleteTitle: 'Profil löschen?', + deleteDescPrefix: 'Das löscht ', + deleteDescMid: ' und entfernt sein ', + deleteDescSuffix: ' Verzeichnis. Das kann nicht rückgängig gemacht werden.', + deleting: 'Wird gelöscht...', + createDesc: 'Profile sind unabhängige Hermes-Umgebungen: eigene Config, eigene Skills und eigene SOUL.md.', + nameLabel: 'Name', + cloneFrom: 'Klonen von', + cloneFromNone: 'Keine (leer)', + cloneFromDesc: 'Kopiert Config, Skills und SOUL.md aus dem gewählten Quellprofil.', + cloneFromDefault: 'Vom Standardprofil klonen', + cloneFromDefaultDesc: 'Config, Skills und SOUL.md aus Ihrem Standardprofil kopieren.', + invalidName: hint => `Ungültiger Name. ${hint}`, + nameRequired: 'Name ist erforderlich.', + creating: 'Wird erstellt...', + createAction: 'Profil erstellen', + renameTitle: 'Profil umbenennen', + renameDescPrefix: 'Durch das Umbenennen werden das Profilverzeichnis und alle Wrapper-Scripts in ', + renameDescSuffix: ' aktualisiert.', + displayNameTitle: 'Diesen Agent benennen', + displayNameDesc: + 'Legt einen Anzeigenamen fest, der in der ganzen App angezeigt wird. Die interne Profil-ID bleibt "default".', + displayNameLabel: 'Anzeigename', + newNameLabel: 'Neuer Name', + renaming: 'Wird umbenannt...', + created: 'Profil erstellt', + renamed: 'Profil umbenannt', + deleted: 'Profil gelöscht', + setupCopied: 'Setup-Befehl kopiert', + soulSaved: 'SOUL.md gespeichert', + failedLoad: 'Profile konnten nicht geladen werden', + failedDelete: 'Profil konnte nicht gelöscht werden', + failedCopy: 'Setup-Befehl konnte nicht kopiert werden', + failedLoadSoul: 'SOUL.md konnte nicht geladen werden', + failedSaveSoul: 'SOUL.md konnte nicht gespeichert werden', + failedCreate: 'Profil konnte nicht erstellt werden', + failedRename: 'Profil konnte nicht umbenannt werden' + }, + modelAssignment: { + saveFailed: 'Hermes hat diese Modelländerung nicht gespeichert.', + confirmTitle: 'Warnung zur Modellauswahl', + confirmDetail: 'Bestätigen Sie nur, wenn Sie diesen Kompromiss akzeptieren.', + confirmAction: 'Bestätigen', + declined: 'Modelländerung abgebrochen – Sie haben die Warnung zur Datentrainings-Stufe abgelehnt.' + }, + cron: { + close: 'Cron schließen', + title: 'Geplante Jobs', + count: count => `${count} ${count === 1 ? 'Job' : 'Jobs'}`, + search: 'Cron-Jobs durchsuchen...', + loading: 'Cron-Jobs werden geladen...', + states: { + enabled: 'aktiviert', + scheduled: 'geplant', + running: 'läuft', + paused: 'pausiert', + disabled: 'deaktiviert', + error: 'Fehler', + completed: 'abgeschlossen' + }, + lastRunFailed: 'Letzter Lauf fehlgeschlagen:', + editJob: 'Job bearbeiten', + runAgain: 'Erneut ausführen', + deliveryLabels: { + local: 'Dieser Desktop', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + email: 'E-Mail' + }, + scheduleLabels: { + daily: 'Täglich', + weekdays: 'Werktags', + weekly: 'Wöchentlich', + monthly: 'Monatlich', + hourly: 'Stündlich', + 'every-15-minutes': 'Alle 15 Minuten', + custom: 'Benutzerdefiniert' + }, + scheduleHints: { + daily: 'Jeden Tag um 9:00 Uhr', + weekdays: 'Montag bis Freitag um 9:00 Uhr', + weekly: 'Jeden Montag um 9:00 Uhr', + monthly: 'Am ersten Tag jedes Monats um 9:00 Uhr', + hourly: 'Zu jeder vollen Stunde', + 'every-15-minutes': 'Alle 15 Minuten', + custom: 'Cron-Syntax oder natürliche Sprache' + }, + days: { + '0': 'Sonntag', + '1': 'Montag', + '2': 'Dienstag', + '3': 'Mittwoch', + '4': 'Donnerstag', + '5': 'Freitag', + '6': 'Samstag', + '7': 'Sonntag' + }, + dayFallback: value => `Tag ${value}`, + everyDayAt: time => `Jeden Tag um ${time}`, + weekdaysAt: time => `Werktags um ${time}`, + everyDayOfWeekAt: (day, time) => `Jeden ${day} um ${time}`, + monthlyOnDayAt: (dayOfMonth, time) => `Monatlich am ${dayOfMonth}. um ${time}`, + topOfHour: 'Zu jeder vollen Stunde', + everyHourAt: minute => `Jede Stunde um :${minute}`, + newCron: 'Neuer Cron', + emptyDescNew: + 'Planen Sie einen Prompt, der nach einem Cron-Ausdruck ausgeführt wird. Hermes führt ihn aus und liefert die Ergebnisse an das von Ihnen gewählte Ziel.', + emptyDescSearch: 'Versuchen Sie eine breitere Suchanfrage.', + emptyTitleNew: 'Noch keine geplanten Jobs', + emptyTitleSearch: 'Keine Treffer', + last: 'Zuletzt:', + next: 'Als Nächstes:', + overdueSince: 'Überfällig seit:', + noRuns: 'Noch keine Ausführungen', + manage: 'Verwalten', + showRuns: 'Ausführungen anzeigen', + hideRuns: 'Ausführungen ausblenden', + runHistory: 'Ausführungsverlauf', + actionsTitle: 'Cron-Job-Aktionen', + resume: 'Cron fortsetzen', + pause: 'Cron pausieren', + resumeTitle: 'Fortsetzen', + pauseTitle: 'Pausieren', + triggerNow: 'Jetzt auslösen', + edit: 'Cron bearbeiten', + deleteTitle: 'Cron-Job löschen?', + deleteDescPrefix: 'Das entfernt ', + deleteDescSuffix: ' dauerhaft. Er wird sofort aufhören auszulösen.', + deleting: 'Wird gelöscht...', + resumed: 'Cron fortgesetzt', + paused: 'Cron pausiert', + triggered: 'Cron ausgelöst', + deleted: 'Cron gelöscht', + created: 'Cron erstellt', + updated: 'Cron aktualisiert', + failedLoad: 'Cron-Jobs konnten nicht geladen werden', + failedUpdate: 'Cron-Job konnte nicht aktualisiert werden', + failedTrigger: 'Cron-Job konnte nicht ausgelöst werden', + failedDelete: 'Cron-Job konnte nicht gelöscht werden', + failedSave: 'Cron-Job konnte nicht gespeichert werden', + editTitle: 'Cron-Job bearbeiten', + createTitle: 'Neuer Cron-Job', + editDesc: 'Aktualisieren Sie den Zeitplan, den Prompt oder das Zustellziel. Änderungen gelten beim nächsten Lauf.', + createDesc: + 'Planen Sie einen Prompt, der automatisch ausgeführt wird. Nutzen Sie die Cron-Syntax oder einen natürlichen Ausdruck wie „every 15 minutes“.', + nameLabel: 'Name', + namePlaceholder: 'Morgenübersicht', + promptLabel: 'Prompt', + promptPlaceholder: 'Fass meine ungelesenen Slack-Kanäle zusammen und schick mir die Top 5 per E-Mail...', + frequencyLabel: 'Häufigkeit', + deliverLabel: 'Zustellen an', + deliverNeedsHomeChannel: 'zuerst einen Home-Channel festlegen', + modelLabel: 'Modell', + modelDefault: 'Standard (globales Modell)', + customScheduleLabel: 'Benutzerdefinierter Zeitplan', + customPlaceholder: '0 9 * * * oder weekdays at 9am', + customHint: 'Cron-Ausdruck oder Ausdrücke wie „every hour“ oder „weekdays at 9am“.', + optional: 'Optional', + promptRequired: 'Prompt ist erforderlich.', + promptScheduleRequired: 'Prompt und Zeitplan sind erforderlich.', + scheduleRequired: 'Zeitplan ist erforderlich.', + scriptOnlyEditHint: 'Nur-Skript-Job (ohne AI-Prompt). Job-ID:', + saveChanges: 'Änderungen speichern', + createAction: 'Cron erstellen', + tabs: { + jobs: 'Jobs', + blueprints: 'Blueprints' + }, + blueprints: { + tab: 'Blueprints', + startFrom: 'Starten von', + custom: 'Benutzerdefiniert', + subtitle: 'Fertige Automatisierungen', + dialogDesc: 'Füllen Sie die Details aus und planen Sie den Job ein.', + scheduleIt: 'Einplanen', + scheduling: 'Wird eingeplant...', + scheduled: 'Blueprint eingeplant', + loading: 'Blueprints werden geladen...', + failedLoad: 'Blueprints konnten nicht geladen werden', + emptyTitle: 'Keine Blueprints verfügbar', + emptyDesc: 'Auf diesem Backend sind keine Automatisierungs-Blueprints verfügbar.' + } + }, + artifacts: { + search: 'Nach Artifacts suchen...', + refresh: 'Artifacts aktualisieren', + refreshing: 'Artifacts werden aktualisiert', + indexing: 'Artifacts aus den letzten Sessions werden indiziert', + tabAll: 'Alle', + tabImages: 'Bilder', + tabFiles: 'Dateien', + tabLinks: 'Links', + noArtifactsTitle: 'Keine Artifacts gefunden', + noArtifactsDesc: 'Generierte Bilder und Datei-Ausgaben erscheinen hier, sobald Sessions sie erstellen.', + failedLoad: 'Artifacts konnten nicht geladen werden', + openFailed: 'Öffnen fehlgeschlagen', + itemsImage: 'Bilder', + itemsLink: 'Links', + itemsFile: 'Dateien', + itemsGeneric: 'Elemente', + zero: '0', + rangeOf: (start, end, total) => `${start}-${end} von ${total}`, + goToPage: (itemLabel, page) => `Zu ${itemLabel} Seite ${page}`, + colTitleLink: 'Link-Titel', + colTitleFile: 'Name', + colTitleDefault: 'Titel / Name', + colLocationLink: 'URL', + colLocationFile: 'Pfad', + colLocationDefault: 'Speicherort', + colSession: 'Session', + kindImage: 'Bild', + kindFile: 'Datei', + kindLink: 'Link', + chat: 'Chat', + copyUrl: 'URL kopieren', + copyPath: 'Pfad kopieren' + }, + artifactCard: { + kind: { + code: 'Code', + html: 'Interaktive Seite', + svg: 'Grafik' + }, + generating: lines => `Wird generiert… ${lines} Zeilen`, + versionBadge: count => `${count} Versionen`, + open: 'Öffnen' + }, + artifactPreview: { + versionOf: (current, total) => `v${current} von ${total}`, + olderVersion: 'Ältere Version', + newerVersion: 'Neuere Version', + latest: 'Neueste', + copyContent: 'Inhalt kopieren', + download: 'Herunterladen', + openInBrowser: 'Im Browser öffnen', + openInBrowserFailed: 'Konnte nicht im Browser geöffnet werden', + missingTitle: 'Artifact nicht verfügbar', + missingBody: 'Dieses Artifact ist nicht mehr in der lokalen Registry.' + }, + sidebar: { + filter: { + grouping: 'Gruppierung', + ordering: 'Sortierung', + show: 'Anzeigen', + filters: 'Filter', + status: 'Status', + pullRequest: 'Pull Request', + profile: 'Profil', + project: 'Projekt', + archived: 'Archiviert', + resetToDefaults: 'Auf Standard zurücksetzen', + expandAll: 'Alle ausklappen', + collapseAll: 'Alle einklappen', + inboxStyle: 'Posteingangsstil', + updated: 'Aktualisiert', + created: 'Erstellt', + tokens: 'Tokens', + cost: 'Kosten', + manual: 'Manuell', + preview: 'Vorschau', + pr: 'PR', + needsInput: 'Eingabe nötig', + working: 'In Arbeit', + unread: 'Ungelesen', + draft: 'Entwurf', + idle: 'Inaktiv', + open: 'Offen', + merged: 'Gemergt', + closed: 'Geschlossen', + noPR: 'Kein PR' + }, + gatewayGroups: { + grouping: 'Gateway & Profil', + rename: 'Gruppe umbenennen', + aliasLabel: 'Anzeigename', + aliasHint: 'Nur der Anzeigename; Gateway- und Profilname bleiben unverändert.', + resetName: 'Name zurücksetzen', + moveUp: 'Nach oben', + moveDown: 'Nach unten', + reorder: 'Gruppe neu anordnen', + actions: 'Gruppenaktionen' + }, + profileRail: 'Profil-Leiste', + nav: { + 'new-session': 'Neue Session', + capabilities: 'Fähigkeiten', + messaging: 'Messaging', + artifacts: 'Artefakte', + cron: 'Geplante Jobs' + }, + searchAria: 'Sessions durchsuchen', + searchPlaceholder: 'Sessions durchsuchen…', + clearSearch: 'Suche löschen', + noMatch: query => `Keine Sessions passen zu “${query}”.`, + results: 'Ergebnisse', + pinned: 'Angepinnt', + sessions: 'Sessions', + terminal: 'Terminal', + files: 'Dateien', + review: 'Review', + logs: 'Logs', + cronJobs: 'Cron-Jobs', + groupAriaGrouped: 'Sessions als einzelne Liste anzeigen', + groupAriaUngrouped: 'Sessions nach Workspace gruppieren', + showProjects: 'Projekte anzeigen', + showSessions: 'Sessions anzeigen', + groupTitleGrouped: 'Sessions aufteilen', + groupTitleUngrouped: 'Nach Workspace gruppieren', + allPinned: 'Alles hier ist angeheftet. Lösen Sie einen Chat, damit er unter den letzten angezeigt wird.', + shiftClickHint: 'Shift-Klick auf einen Chat zum Anpinnen', + noWorkspace: 'Kein Workspace', + projectEmpty: 'Noch keine Sessions', + projectLoadFailed: 'Sessions konnten nicht geladen werden', + noSessions: 'Noch keine Sessions', + storageCorrupt: { + title: 'Die Session-Datenbank ist beschädigt', + body: (profiles: string) => + `Hermes kann nicht den gesamten Session-Verlauf für ${profiles} lesen. Chats, die in dieser Liste fehlen, wurden nicht gelöscht; die Datei, in der sie gespeichert sind, ist beschädigt.`, + action: + 'Beenden Sie Hermes für dieses Profil und prüfen Sie die Datei dann, ohne sie zu ändern, oder stellen Sie einen Snapshot wieder her:', + guide: 'Wiederherstellungsanleitung' + }, + noFilterMatches: 'Keine Sessions passen zu diesen Filtern', + projects: { + showAllSessions: 'Alle Sessions anzeigen', + sectionLabel: 'Projekte', + home: 'Start', + autoDiscovered: 'Automatisch erkannt', + newButton: 'Neues Projekt', + createTitle: 'Neues Projekt', + createDesc: 'Geben Sie dem Workspace einen Namen und fügen Sie einen oder mehrere Ordner hinzu.', + renameTitle: 'Projekt umbenennen', + addFolderTitle: 'Ordner hinzufügen', + namePlaceholder: 'z. B. Skunkworks', + foldersLabel: 'Ordner', + ideaLabel: 'Idee', + ideaPlaceholder: 'Worum geht es in diesem Projekt? (gespeichert in IDEA.md)', + ideaGenerate: 'Idee generieren', + ideaGenerating: 'Wird generiert…', + ideaShuffle: 'Vorlagen mischen', + noFolders: 'Noch keine Ordner hinzugefügt.', + addFolder: 'Ordner hinzufügen', + primaryBadge: 'primär', + removeFolder: 'Entfernen', + create: 'Erstellen', + menu: 'Aktionen', + menuRename: 'Umbenennen…', + menuAppearance: 'Aussehen', + noColor: 'Keine Farbe', + menuAddFolder: 'Ordner hinzufügen', + menuSetActive: 'Als aktiv festlegen', + menuDelete: 'Löschen', + moveToProject: 'In Projekt verschieben', + movedTo: name => `In ${name} verschoben`, + moveFailed: 'Session konnte nicht verschoben werden', + moveNoFolder: 'Dieses Projekt hat keinen Ordner, in den verschoben werden kann', + moveNoProjects: 'Keine anderen Projekte', + reveal: 'Im Ordner anzeigen', + copyPath: 'Pfad kopieren', + removeFromSidebar: 'Aus der Sidebar ausblenden', + createFailed: 'Projekt konnte nicht erstellt werden', + staleBackend: + 'Aktualisieren Sie das Hermes-Backend, um Projekte zu erstellen – Ihr Backend ist älter als diese Desktop-App (Einstellungen → Updates → Backend).', + deleteConfirm: + 'Das entfernt das gespeicherte Projekt aus Hermes. Dateien, Git-Repos und Worktrees bleiben unberührt.', + startWork: 'Neuer Worktree', + newWorktreeTitle: 'Neuer Worktree', + newWorktreeDesc: 'Benennen Sie den Branch für diesen Worktree.', + branchPlaceholder: 'z. B. my-feature', + branchOff: () => ({ after: '', before: 'abzweigen von ' }), + baseBranchPlaceholder: 'Branches durchsuchen…', + baseBranchNone: 'Keine Branches gefunden', + startWorkFailed: 'Worktree konnte nicht erstellt werden', + worktreeStaleBackend: + 'Aktualisieren Sie das Hermes-Backend, um Worktrees über diese Remote-Verbindung zu erstellen – es ist älter als die Git-Worktree-API.', + worktreeProjectLabel: 'Projekt', + worktreeProjectPlaceholder: 'Projekte durchsuchen…', + worktreeProjectNone: 'Keine Projekte mit Ordner', + convertBranch: 'Einen Branch konvertieren…', + convertBranchTitle: 'Einen Branch konvertieren', + convertBranchDesc: 'Ausgecheckte Branches öffnen oder einen Worktree für einen freien Branch erstellen.', + convertBranchPlaceholder: 'Branches durchsuchen…', + convertBranchInstead: 'Einen bestehenden Branch konvertieren', + branchOpenExisting: 'öffnen', + branchSwitchHome: 'Start wechseln', + branchCreateWorktree: 'neuer Worktree', + branchTrackRemote: 'Remote verfolgen', + branchesLoading: 'Branches werden geladen…', + noBranches: 'Keine Branches gefunden', + removeWorktree: 'Worktree entfernen', + removeWorktreeFailed: 'Worktree konnte nicht entfernt werden (nicht committete Änderungen?)', + removeWorktreeConfirm: + 'Aus Git entfernen (löscht das Worktree-Verzeichnis; der Branch bleibt), oder einfach die Lane aus der Sidebar ausblenden und den Worktree auf der Festplatte belassen.', + removeWorktreeDirty: + 'Dieser Worktree hat nicht committete Änderungen. Kraftvoll entfernen (verwirft diese Änderungen), oder einfach die Lane ausblenden und ihn auf der Festplatte behalten.', + forceRemove: 'Kraftvoll entfernen', + enter: label => `${label} öffnen`, + reorder: label => `${label} neu anordnen`, + toggle: (label, open) => `${open ? 'Anzeigen' : 'Ausblenden'} ${label} Sessions`, + showAllCount: (count: number) => `Alle ${count} Sessions anzeigen`, + back: 'Alle Projekte' + }, + newSessionIn: label => `Neue Session in ${label}`, + showMoreIn: (count, label) => `Noch ${count} mehr in ${label} anzeigen`, + loading: 'Wird geladen…', + loadMore: 'Mehr laden', + loadCount: step => `${step} mehr laden`, + messageCount: count => `${count} ${count === 1 ? 'Nachricht' : 'Nachrichten'}`, + toolCallCount: count => `${count} ${count === 1 ? 'Tool-Aufruf' : 'Tool-Aufrufe'}`, + row: { + pin: 'Anpinnen', + unpin: 'Lösen', + markUnread: 'Als ungelesen markieren', + markRead: 'Als gelesen markieren', + unreadFailed: 'Ungelesen-Status konnte nicht aktualisiert werden', + copyId: 'ID kopieren', + export: 'Exportieren', + branchFrom: 'Branch', + rename: 'Umbenennen…', + archive: 'Archivieren', + newWindow: 'Neues Fenster', + openInTerminal: 'Im Terminal öffnen', + hideTabBar: 'Tab-Leiste ausblenden', + openInNewTab: 'In neuem Tab öffnen', + openInSplit: 'Im Split öffnen', + copyIdFailed: 'Session-ID konnte nicht kopiert werden', + sessionActions: 'Session-Aktionen', + sessionRunning: 'Session läuft', + needsInput: 'Braucht Ihre Eingabe', + waitingForAnswer: 'Wartet auf Ihre Antwort', + finishedUnread: 'Abgeschlossen – ungelesen', + backgroundRunning: 'Hintergrundaufgabe läuft', + draftSession: 'Entwurf – noch nichts gesendet', + handoffOrigin: platform => `Übergeben von ${platform}`, + ownedByProfile: profile => `Profil: ${profile}`, + renamed: 'Umbenannt', + renameFailed: 'Umbenennen fehlgeschlagen', + renameTitle: 'Session umbenennen', + renameDesc: 'Leer lassen, um zu löschen.', + untitledPlaceholder: 'Unbenannte Session', + deleteTitle: 'Session löschen?', + deleteDesc: title => `Das löscht “${title}” dauerhaft. Das kann nicht rückgängig gemacht werden.`, + deleting: 'Wird gelöscht…', + deleted: 'Session gelöscht', + untitledChat: id => `Chat ${id}`, + messageCount: count => `${count} ${count === 1 ? 'Nachricht' : 'Nachrichten'}`, + todoProgress: 'Aufgaben abgeschlossen', + ageNow: 'jetzt', + ageDay: 'T', + ageHour: 'h', + ageMin: 'Min' + }, + dateDivider: { + today: 'Heute früher', + yesterday: 'Gestern', + thisWeek: 'Diese Woche früher', + lastWeek: 'Letzte Woche', + thisMonth: 'Diesen Monat früher' + }, + statusDivider: { + working: 'In Arbeit', + done: 'Erledigt' + }, + markAllRead: 'Alle als gelesen markieren' + }, + composer: { + message: 'Nachricht', + wakingProfile: profile => `Wecke ${profile}…`, + placeholderStarting: 'Hermes wird gestartet…', + placeholderReconnecting: 'Verbindung zu Hermes wird wiederhergestellt…', + placeholderFollowUp: 'Folge senden', + newSessionPlaceholders: [ + 'Was bauen wir?', + 'Geben Sie Hermes eine Aufgabe', + 'Was ist Ihnen wichtig?', + 'Beschreiben Sie, was Sie brauchen', + 'Was sollen wir angehen?', + 'Fragen Sie irgendetwas', + 'Beginnen Sie mit einem Ziel' + ], + followUpPlaceholders: [ + 'Folge senden', + 'Mehr Kontext hinzufügen', + 'Anfrage verfeinern', + 'Was kommt als Nächstes?', + 'Weiter so', + 'Noch weiter', + 'Anpassen oder fortfahren' + ], + startVoice: 'Sprachkonversation starten', + openDirective: 'Öffnen', + queueMessage: 'Nachricht einreihen', + steer: 'Laufenden Lauf steuern', + stop: 'Stopp', + send: 'Senden', + speaking: 'Spricht', + transcribing: 'Transkribiert', + thinking: 'Denkt', + muted: 'Stummgeschaltet', + listening: 'Hört zu', + muteMic: 'Mikrofon stummschalten', + unmuteMic: 'Mikrofon einschalten', + stopListening: 'Zuhören stoppen und senden', + stopShort: 'Stopp', + endConversation: 'Sprachkonversation beenden', + endShort: 'Ende', + stopDictation: 'Diktat stoppen', + transcribingDictation: 'Transkribiert Diktat', + voiceControls: 'Sprache', + voiceEngine: 'Sprachchat-Engine', + voiceEngineChained: 'Sprache-zu-Text + Hermes-Stimme', + voiceEngineLive: 'GPT-Live (Vollduplex, delegiert an Hermes)', + voiceEngineLiveNeedsKey: 'Benötigt einen OpenAI-API-Schlüssel', + voiceEngineChangeFailed: 'Sprachchat-Engine konnte nicht geändert werden', + voiceEngineChainedShort: 'Sprache-zu-Text', + voiceEngineLiveShort: 'GPT-Live', + voiceDictation: 'Sprachdiktat', + speakReplies: 'Antworten vorlesen', + stopSpeakingReplies: 'Antworten nicht mehr vorlesen', + wakeWord: (phrase: string) => `Aktivierungswort „${phrase}“`, + wakeWordListening: phrase => `Aufwachwort: „${phrase}“ — hört zu`, + wakeWordOff: phrase => `Aufwachwort: „${phrase}“ — aus`, + wakeWordPausedVoice: phrase => `Aufwachwort: „${phrase}“ — während Sprachchat pausiert`, + lookupLoading: 'Sucht…', + lookupNoMatches: 'Keine Treffer.', + lookupTry: 'Versuchen Sie', + lookupOr: 'oder', + commonCommands: 'Häufige Befehle', + hotkeys: 'Tastenkürzel', + helpFooter: 'öffnet das volle Panel · Backspace schließt', + commandDescs: { + '/help': 'Desktop-Slash-Befehle anzeigen', + '/clear': 'neue Session starten', + '/resume': 'Gespeicherte Session fortsetzen', + '/details': 'Transkript-Detailgrad steuern', + '/copy': 'Auswahl oder letzte Assistenten-Nachricht kopieren', + '/quit': 'hermes beenden', + '/start': 'Start-Pings der Plattform ohne Antwort bestätigen', + '/new': 'Neuen Desktop-Chat starten', + '/topic': 'Telegram-DM-Themen-Sessions aktivieren oder prüfen', + '/save': 'Aktuelles Transkript als JSON speichern', + '/retry': 'Letzte Nachricht wiederholen (erneut an den Agent senden)', + '/prompt': 'Nächsten Prompt in $EDITOR (Markdown) verfassen und dann senden', + '/undo': 'N Benutzer-Turns zurückgehen und neu prompten (Standard 1)', + '/title': 'Aktuelle Session umbenennen', + '/handoff': 'Diese Session an eine Messaging-Plattform übergeben', + '/branch': 'Letzte Nachricht in einen neuen Chat abzweigen', + '/worktree': 'Isolierte Git-Worktrees anzeigen, auflisten, erstellen oder bereinigen', + '/compress': 'Kontext dieser Unterhaltung komprimieren', + '/rollback': + 'Dateisystem-Checkpoints auflisten oder wiederherstellen (Wiederherstellungen behalten Ihre manuellen Änderungen; --all überschreibt sie)', + '/export': 'Ein Profil (Konfiguration, Skills, Theme) als teilbares Archiv exportieren', + '/import': 'Ein geteiltes Profilarchiv als neues Profil importieren', + '/stop': 'Aktiven Turn und Hintergrundprozesse stoppen', + '/pause': "Neue Arbeit global pausieren (Notstopp); '/pause off' setzt fort", + '/bg': 'Einen Prompt in einer separaten Hintergrund-Session ausführen', + '/btw': 'Eine Nebenfrage zu dieser Unterhaltung stellen, ohne sie zu unterbrechen', + '/agents': 'Aktive Agents und laufende Aufgaben anzeigen', + '/journey': 'Den Gedächtnisgraphen öffnen – Skills und Erinnerungen im Zeitverlauf', + '/queue': + 'Einen Prompt für den nächsten Turn einreihen oder eingereihte Prompts auflisten/bearbeiten/entfernen/verschieben/leeren', + '/steer': 'Nach dem nächsten Tool-Aufruf eine Nachricht einfügen, ohne zu unterbrechen', + '/goal': 'Ein dauerhaftes Ziel festlegen, an dem Hermes über mehrere Turns arbeitet, bis es erreicht ist', + '/heartbeat': 'Einen wiederkehrenden Prompt festlegen, der bei Leerlauf in diese Session zurückkehrt', + '/refine': 'Diese Unterhaltung jetzt prüfen und Erkenntnisse in Gedächtnis/Skills speichern', + '/review': 'Einen unabhängigen Subagent starten, der die gerade besprochene Arbeit prüft (PR, Code, Doku)', + '/loop': 'Einen Prompt in dieser Session in regelmäßigen Abständen erneut ausführen', + '/plan': 'Einen Markdown-Umsetzungsplan in .hermes/plans/ schreiben, ohne etwas auszuführen', + '/moa': 'Einen Prompt mit der Standard-Mixture-of-Agents-Vorlage ausführen und dann Ihr Modell wiederherstellen', + '/subgoal': 'Zusätzliche Kriterien zum aktiven Ziel hinzufügen oder verwalten', + '/status': 'Status der aktuellen Session anzeigen', + '/egress': 'Status des Docker-Egress-Proxys anzeigen', + '/context': + 'Detaillierte Ansicht des Kontextfensters mit Nutzungsanzeige, Aufschlüsselung nach Kategorie, Komprimierungsstatistik und Durchsatz anzeigen', + '/whoami': 'Ihren Zugriff auf Slash-Befehle anzeigen (Admin / Benutzer)', + '/profile': 'Aktives Hermes-Profil wechseln', + '/codex-runtime': 'Codex-App-Server-Runtime für OpenAI/Codex-Modelle umschalten', + '/personality': 'Eine vordefinierte Persönlichkeit festlegen', + '/battery': 'Farbcodierte Akkuanzeige in der Statusleiste umschalten', + '/timestamps': '[HH:MM]-Zeitstempel bei Nachrichten und /history umschalten', + '/diff': 'Git-Änderungen im Arbeitsverzeichnis anzeigen', + '/focus': 'Fokusansicht umschalten – nur Ihren Prompt und die finale Antwort anzeigen', + '/yolo': 'YOLO umschalten – gefährliche Befehle automatisch genehmigen', + '/approvals': 'Dauerhaften Genehmigungsmodus für gefährliche Befehle anzeigen oder festlegen', + '/reasoning': 'Reasoning-Aufwand oder -Anzeige [ [--global]|show|hide|full|clamp]', + '/skin': 'Desktop-Theme wechseln oder zum nächsten weiterschalten', + '/wake': 'Desktop-Aktivierungswort-Listener steuern [on|off|status]', + '/tools': 'Tools verwalten: /tools [list|disable|enable] [name...]', + '/memory': 'Ausstehende Gedächtnis-Schreibvorgänge prüfen / Genehmigungssperre umschalten', + '/bundles': 'Skill-Bundles auflisten (Aliasse / für mehrere Skills)', + '/pet': 'Ein Petdex-Maskottchen umschalten oder adoptieren (/pet, /pet list, /pet boba)', + '/hatch': 'Ein neues Haustier erzeugen (öffnet den Generator)', + '/learn': + 'Einen wiederverwendbaren Skill aus allem lernen, was Sie beschreiben (Ordner, URLs, dieser Chat, Notizen)', + '/init': 'AGENTS.md-Projektanweisungen aus einem Repo-Scan erzeugen oder aktualisieren', + '/suggestions': 'Vorgeschlagene Automatisierungen prüfen (annehmen/verwerfen)', + '/blueprint': 'Eine Automatisierung aus einer Blueprint-Vorlage einrichten', + '/browser': 'Browser-CDP-Verbindung verwalten [connect|disconnect|status] (nur lokales Gateway)', + '/palette': 'Die unscharfe Befehlspalette öffnen (auch Strg+P)', + '/usage': 'Token-Nutzung und Ratenlimits anzeigen; `reset` löst ein angespartes Codex-Limit-Reset ein', + '/subscription': 'Ihren Nous-Tarif ansehen und im Browser ändern', + '/topup': 'Ihr Nous-Guthaben anzeigen und die Abrechnung im Portal verwalten', + '/platform': 'Eine fehlerhafte Gateway-Plattform pausieren, fortsetzen oder auflisten', + '/version': 'Hermes-Agent-Version anzeigen', + '/debug': 'Debug-Bericht (Systeminfos + Logs) hochladen und teilbare Links erhalten', + '/model': 'Modell für diese Session wechseln' + }, + hotkeyDescs: { + 'composer.mention': 'Dateien, Ordner, URLs, git referenzieren', + 'composer.slash': 'Slash-Befehlspalette', + 'composer.help': 'diese Schnellhilfe (Löschen zum Schließen)', + 'composer.sendNewline': 'senden · Shift+Enter für neue Zeile', + 'composer.sendQueued': 'nächsten eingereihten Turn senden', + 'keybinds.openPanel': 'alle Tastaturkürzel', + 'composer.cancel': 'Popover schließen · Lauf abbrechen', + 'composer.history': 'Popover / Verlauf durchblättern' + }, + attachUrlTitle: 'URL anhängen', + attachUrlDesc: 'Hermes ruft die Seite ab und fügt sie als Kontext für diesen Turn hinzu.', + urlPlaceholder: 'https://example.com/post', + urlHintPre: 'Geben Sie die vollständige URL an, z. B. ', + attach: 'Anhängen', + queued: count => `${count} eingereiht`, + queuedPaused: count => `${count} eingereiht — pausiert`, + attachmentOnly: 'Nur-Anhang-Turn', + emptyTurn: 'Leerer Turn', + hiddenQueued: 'Einrichtungshinweis', + attachments: count => `${count} Anhang${count === 1 ? '' : 'e'}`, + editingInComposer: 'Bearbeitet im Composer', + editingQueuedInComposer: 'Bearbeitet eingereihten Turn im Composer', + restoredDraftNotice: 'Ihre nicht gesendete Nachricht wurde wiederhergestellt', + restoredDraftUndo: 'Rückgängig', + queueEdit: 'Bearbeiten', + queueSendNext: 'Weiter', + queueSteer: 'Steuern — laufenden Turn jetzt umleiten', + queueSend: 'Senden', + queueDelete: 'Löschen', + queueResume: 'Fortsetzen', + queueResumeTip: 'Durch Stopp pausiert — fortsetzen, um die eingereihten Turns zu senden', + queueStuckTitle: 'Eingereihte Nachricht nicht gesendet', + queueStuckBody: + 'Ein eingereihter Turn konnte nicht gesendet werden. Er ist noch in der Warteschlange — versuchen Sie, ihn erneut zu senden.', + previewUnavailable: 'Vorschau nicht verfügbar', + previewLabel: label => `Vorschau ${label}`, + couldNotPreview: label => `Vorschau von ${label} fehlgeschlagen`, + removeAttachment: label => `${label} entfernen`, + dictating: 'Diktiert', + preparingAudio: 'Bereitet Audio vor', + speakingResponse: 'Spricht Antwort', + readingAloud: 'Liest vor', + themeSuggestions: 'Desktop-Theme-Vorschläge', + noMatchingThemes: 'Keine passenden Themes.', + themeTryPre: 'Versuchen Sie ', + themeTryPost: '.', + attachLabel: 'Anhängen', + files: 'Dateien…', + folder: 'Ordner…', + images: 'Bilder…', + pasteImage: 'Bild einfügen', + url: 'URL…', + promptSnippets: 'Prompt-Schnipsel…', + tipPre: 'Tipp: Geben Sie ', + tipPost: ' ein, um Dateien inline zu referenzieren.', + snippetsTitle: 'Prompt-Schnipsel', + snippetsDesc: 'Wählen Sie einen Start-Prompt, um ihn in den Composer einzufügen.', + dropFiles: 'Dateien zum Anhängen ablegen', + dropSession: 'Ablegen, um diesen Chat zu verlinken', + mcpSuggestions: { + label: server => `${server} hinzufügen`, + tip: keyword => `Vorgeschlagen, weil Sie „${keyword}“ erwähnt haben – zum Verbinden klicken`, + connecting: server => `${server} wird verbunden…`, + cancelTip: 'Klicken zum Abbrechen', + added: server => `${server} hinzugefügt`, + addedTip: 'Verbunden — seine Tools sind in diesem Chat bereit', + connectFailed: server => `${server} konnte nicht verbunden werden` + }, + skillSuggestions: { + label: skill => `Fähigkeit verwenden: ${skill}`, + tip: skill => `Sie haben „${skill}“ erwähnt – klicken, um mit diesem Skill zu beginnen`, + done: skill => `/skill hinzugefügt: ${skill}`, + doneTip: 'Die Fähigkeit wird beim Senden geladen' + }, + githubSuggestions: { + label: 'GitHub einrichten', + tip: 'GitHub funktioniert hier über die gh-CLI-Skills – klicken, um Ihr Konto zu verbinden', + done: '/github-auth hinzugefügt', + doneTip: 'Senden Sie die Nachricht, und der Agent führt Sie durch die GitHub-Anmeldung' + }, + repairSuggestions: { + label: server => `${server} erneut verbinden`, + tip: server => `Ein ${server}-Aufruf ist gerade mit einem Verbindungsfehler fehlgeschlagen`, + working: server => `${server} wird erneut verbunden…`, + workingTip: 'Klicken zum Abbrechen', + done: server => `${server} erneut verbunden`, + doneTip: 'Frische Anmeldedaten sind in diesem Chat aktiv', + failed: server => `${server} konnte nicht erneut verbunden werden` + }, + cronSuggestions: { + label: 'Dies planen', + tip: phrase => `„${phrase}“ klingt wiederkehrend — führen Sie es stattdessen nach Zeitplan aus`, + prefix: 'Als geplanten Job einrichten:', + done: 'Für Planung markiert', + doneTip: 'Senden Sie die Nachricht, und der Agent erstellt den Job' + }, + snippets: { + codeReview: { + label: 'Code-Review', + description: 'Prüft die aktuelle Änderung auf Regressionen, übersehene Randfälle und fehlende Tests.', + text: 'Bitte prüfe dies auf Bugs, Regressionen und fehlende Tests.' + }, + implementationPlan: { + label: 'Implementierungsplan', + description: 'Skizziert einen Ansatz, bevor Code angefasst wird, damit der Diff fokussiert bleibt.', + text: 'Bitte erstelle einen prägnanten Implementierungsplan, bevor du Code änderst.' + }, + explainThis: { + label: 'Erkläre dies', + description: 'Erklärt, wie der ausgewählte Code funktioniert, und verlinkt die wichtigsten Dateien.', + text: 'Bitte erkläre, wie das funktioniert, und zeige mir die Schlüsseldateien.' + } + } + }, + statusStack: { + hideStack: 'Statusstapel ausblenden', + showStack: 'Statusstapel anzeigen', + agents: 'Agents', + background: count => `${count} Hintergrund`, + goalActive: 'Ziel aktiv', + goalBlocked: 'Ziel blockiert', + goalDone: 'Ziel erledigt', + goalPaused: 'Ziel pausiert', + goalWaiting: 'Ziel wartet', + subagents: count => `${count} Subagent${count === 1 ? '' : 'en'}`, + todos: (done, total) => `Aufgaben ${done}/${total}`, + running: 'Läuft', + stop: 'Stopp', + dismiss: 'Verwerfen', + exit: code => `exit ${code}`, + control: { + goalActiveTurns: (turn, maxTurns) => `Runde ${turn}/${maxTurns}`, + goalDoneTurns: turns => `${turns} Runde${turns === 1 ? '' : 'n'}`, + goalTurn: turn => `Runde ${turn}`, + goalActions: 'Ziel-Aktionen', + viewDetails: 'Details ansehen', + addCriterion: 'Kriterium hinzufügen', + addCriterionDialogTitle: 'Kriterium hinzufügen', + addCriterionPlaceholder: 'Kriterien-Text eingeben …', + criterionLabel: 'Kriterium', + pauseGoal: 'Ziel pausieren', + resumeGoal: 'Ziel fortsetzen', + resumeNow: 'Jetzt fortsetzen', + clearGoal: 'Ziel löschen', + clearGoalConfirmTitle: 'Ziel löschen?', + clearGoalConfirmBody: 'Möchten Sie das aktive Ziel wirklich löschen? Das kann nicht rückgängig gemacht werden.', + copyCriterion: index => `Kriterium ${index} kopieren`, + removeCriterion: index => `Kriterium ${index} entfernen`, + removeCriterionConfirmTitle: index => `Kriterium ${index} entfernen?`, + removeCriterionConfirmBody: index => `Möchten Sie Kriterium ${index} wirklich entfernen?`, + clearCriteria: 'Alle Kriterien löschen', + clearCriteriaConfirmTitle: 'Alle Kriterien löschen?', + clearCriteriaConfirmBody: 'Möchten Sie wirklich alle Kriterien von diesem Ziel entfernen?', + criteriaHeader: count => `Kriterien · ${count}`, + noCriteria: 'Keine Kriterien', + goalDetailsTitle: 'Ziel-Details', + objectiveLabel: 'Zielvorgabe', + contractOutcome: 'Ergebnis', + contractVerification: 'Verifikation', + contractConstraints: 'Einschränkungen', + contractBoundaries: 'Grenzen', + contractStopWhen: 'Stopp, wenn', + waitBarrierTitle: 'Wartebedingung', + waitUntil: target => `Warten bis ${target}`, + waitSession: target => `Warten auf Session ${target}`, + waitPid: pid => `Warten auf Prozess ${pid}`, + qualityGatesTitle: 'Quality Gates', + gateCommand: 'Befehl', + gateAttempts: (attempts, max) => `${attempts}/${max} Versuche`, + gateTimeout: seconds => `${seconds}s Timeout`, + gateLastExit: code => (code === null ? 'Ausstehend' : `Exit-Code: ${code}`), + loopActive: 'Loop aktiv', + loopPaused: 'Loop pausiert', + loopDeferred: 'Loop aufgeschoben', + loopFinished: 'Loop beendet', + loopRuns: runs => `${runs} Lauf${runs === 1 ? '' : 'läufe'}`, + loopRunCount: (current, total) => `Lauf ${current}/${total}`, + loopNext: time => `nächstes ${time}`, + loopEverySeconds: seconds => `alle ${seconds}s`, + loopEveryMinutes: minutes => `alle ${minutes}m`, + loopEveryHours: hours => `alle ${hours}h`, + loopSelfPaced: 'eigenes Tempo', + loopActions: 'Loop-Aktionen', + pauseLoop: 'Loop pausieren', + resumeLoop: 'Loop fortsetzen', + stopLoop: 'Loop stoppen', + stopLoopConfirmTitle: 'Loop stoppen?', + stopLoopConfirmBody: 'Möchten Sie diesen Loop wirklich stoppen?', + dismissLoop: 'Loop verwerfen', + loopPromptLabel: 'Prompt', + loopCadenceLabel: 'Takt', + loopUntilLabel: 'Bis-Bedingung', + loopDeferredNotice: 'Ein aktives Ziel steuert derzeit die Session.', + loopAwaitingResponse: 'Warten auf Antwort', + heartbeatActive: 'Heartbeat aktiv', + heartbeatPaused: 'Heartbeat pausiert', + heartbeatEveryMinutes: minutes => `alle ${minutes}m`, + heartbeatEveryHours: hours => `alle ${hours}h`, + heartbeatEverySeconds: seconds => `alle ${seconds}s`, + heartbeatNext: time => `nächstes ${time}`, + heartbeatDueWaitingForIdle: 'fällig — wartet auf Ruhezustand', + heartbeatActions: 'Heartbeat-Aktionen', + pauseHeartbeat: 'Heartbeat pausieren', + resumeHeartbeat: 'Heartbeat fortsetzen', + clearHeartbeat: 'Heartbeat löschen', + clearHeartbeatConfirmTitle: 'Heartbeat löschen?', + clearHeartbeatConfirmBody: 'Möchten Sie diesen Heartbeat wirklich löschen?', + heartbeatFiredCount: count => `${count} Mal ausgelöst${count === 1 ? '' : ''}`, + actionFailed: msg => `Aktion fehlgeschlagen: ${msg}`, + actionSucceeded: 'Aktion erfolgreich', + copySuccess: 'Kriterium in die Zwischenablage kopiert', + copyFailure: 'Kriterium konnte nicht in die Zwischenablage kopiert werden', + continuationFailed: 'Ziel-Fortsetzung konnte nicht übermittelt werden', + continuationQueued: 'Ziel fortgesetzt — Fortsetzung in der Warteschlange, bis die aktuelle Runde endet', + continuationBusy: 'Ziel fortgesetzt — Session ist beschäftigt, /interrupt zum Fortsetzen der aktuellen Runde', + controlUnavailable: msg => `Session-Steuerung nicht verfügbar: ${msg}`, + dismissError: 'Fehler verwerfen', + add: 'Hinzufügen' + }, + coding: { + title: 'Arbeitsverzeichnis', + noBranch: 'Kein Branch', + detached: 'losgelöst', + clean: 'Sauber', + changed: count => `${count} geändert`, + ahead: count => `${count} voraus`, + behind: count => `${count} zurück`, + review: 'Überprüfen', + close: 'Schließen', + openChanges: 'Änderungen öffnen', + openFile: 'Datei öffnen', + stage: 'Stagen', + unstage: 'Unstagen', + stageAll: 'Alles stagen', + viewAsTree: 'Als Baum ansehen', + viewAsList: 'Als Liste ansehen', + revert: 'Zurücksetzen', + revertAll: 'Alles zurücksetzen', + revertConfirm: + 'Änderungen an dieser Datei verwerfen und sie in den committeten Zustand zurücksetzen? Das kann nicht rückgängig gemacht werden.', + revertAllConfirm: + 'Alle Änderungen verwerfen und alle Dateien in den committeten Zustand zurücksetzen? Das kann nicht rückgängig gemacht werden.', + staged: 'Gestaged', + noChanges: 'Keine Änderungen', + notRepo: 'Kein git-Repository', + noDiff: 'Kein Diff zum Anzeigen', + scopeUncommitted: 'Nicht committet', + scopeBranch: 'Branch', + scopeLastTurn: 'Letzte Runde', + commit: 'Commit', + commitAndPush: 'Commit & Push', + commitPlaceholder: shortcut => `Nachricht (${shortcut} zum Committen)`, + generateCommitMessage: 'Commit-Message generieren', + stopGenerating: 'Generieren stoppen', + createPr: 'PR erstellen', + openPr: 'PR öffnen', + ghMissing: 'Installieren Sie die GitHub CLI (gh) und melden Sie sich an, um PRs zu öffnen', + agentShip: 'Hermes bitten, einen PR zu öffnen', + agentShipUnavailable: 'Der Chat, der diese Änderungen besitzt, ist nicht auf dem Bildschirm.', + agentShipPrompt: + 'Überprüfe die aktuellen Änderungen, committe sie mit einer klaren Conventional-Commit-Message, pushe den Branch und öffne einen Pull Request.', + newBranch: 'Neuer Branch', + branchOffFrom: base => `Neuer Branch von ${base}`, + switchTo: branch => `Zu ${branch} wechseln`, + switchFailed: branch => `Zu ${branch} konnte nicht gewechselt werden`, + worktrees: 'Worktrees' + } + }, + updates: { + stages: { + idle: 'Wird vorbereitet…', + prepare: 'Wird vorbereitet…', + fetch: 'Wird heruntergeladen…', + pull: 'Fast fertig…', + pydeps: 'Wird abgeschlossen…', + update: 'Aktualisiert Hermes…', + rebuild: 'Baut die Desktop-App neu…', + restart: 'Startet Hermes neu…', + done: 'Update abgeschlossen', + manual: 'Über Ihr Terminal aktualisieren', + guiSkew: 'Desktop-App aktualisieren', + error: 'Update pausiert' + }, + checking: 'Nach Updates wird gesucht…', + checkFailedTitle: 'Update-Check fehlgeschlagen', + tryAgain: 'Erneut versuchen', + notAvailableTitle: 'Kein Update verfügbar', + unsupportedMessage: 'Diese Hermes-Version kann sich nicht aus der App heraus aktualisieren.', + connectionRetry: 'Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.', + gitUnusable: 'Hermes konnte Git auf diesem Computer nicht ausführen und daher nicht nach Updates suchen.', + connectionSettings: 'Verbindungseinstellungen', + openDownloadPage: 'Download-Seite öffnen', + latestBody: 'Sie verwenden die neueste Version.', + latestBodyBackend: 'Das Backend läuft mit der neuesten Version.', + allSetTitle: 'Alles bereit', + availableTitle: 'Neues Update verfügbar', + availableBody: 'Eine neue Hermes-Version ist bereit zur Installation.', + availableTitleBackend: 'Backend-Update verfügbar', + availableBodyBackend: 'Eine neuere Version des verbundenen Hermes-Backends ist bereit zur Installation.', + availableBodyNoChangelog: + 'Eine neuere Version ist bereit. Release-Notizen sind für diesen Installationstyp nicht verfügbar.', + updateNow: 'Jetzt aktualisieren', + maybeLater: 'Später', + moreChanges: count => `+ ${count} weitere Änderung${count === 1 ? '' : 'en'} enthalten.`, + manualTitle: 'Über Ihr Terminal aktualisieren', + manualBody: + 'Sie haben Hermes über die Befehlszeile installiert, daher laufen Updates auch dort. Fügen Sie dies in Ihr Terminal ein:', + manualPickedUp: 'Hermes übernimmt die neue Version beim nächsten Start.', + guiSkewTitle: 'Desktop-App aktualisieren', + guiSkewBody: + 'Das Backend wurde aktualisiert, aber dieses Desktop-App-Paket nicht. Aktualisieren oder installieren Sie die Hermes-Desktop-App neu (Ihr AppImage / .deb / .rpm), um beide abzugleichen.', + copy: 'Kopieren', + copied: 'Kopiert', + done: 'Fertig', + applyingBody: + 'Der Hermes-Updater übernimmt in einem eigenen Fenster und öffnet Hermes automatisch wieder, wenn er fertig ist. Bitte öffnen Sie Hermes während des Updates nicht selbst erneut.', + applyingBodyBackend: + 'Das Remote-Backend wendet das Update an und startet neu. Hermes verbindet sich automatisch wieder, wenn es zurück ist.', + applyingClose: 'Dieses Fenster schließt sich während des Updates, dann öffnet sich Hermes von selbst wieder.', + errorTitle: 'Update nicht abgeschlossen', + errorBody: 'Keine Sorge – es ging nichts verloren. Sie können es jetzt erneut versuchen.', + blockerTitle: 'Lokale Vorschauen schließen, um Hermes zu aktualisieren?', + blockerBody: + 'Hermes muss diese lokalen Vorschauen vor dem Update stoppen. Ihre Dateien werden dabei weder geändert noch gelöscht.', + foreignBlockerTitle: 'Andere Prozesse schließen, um Hermes zu aktualisieren', + foreignBlockerBody: + 'Hermes kann diese Prozesse nicht sicher automatisch beenden. Schließen Sie die App, das Terminal oder den Dienst, zu dem sie gehören, und versuchen Sie das Update erneut.', + mixedBlockerBody: + 'Hermes kann die unten aufgeführten lokalen Vorschauen schließen. Andere Prozesse müssen manuell geschlossen werden, bevor das Update fortgesetzt werden kann.', + closePreviewsAndUpdate: 'Vorschauen schließen und aktualisieren', + closePreviewsAndCheckAgain: 'Vorschauen schließen und erneut prüfen', + localPreview: 'Lokale Vorschau', + portLabel: port => `Port ${port}`, + pidLabel: pid => `PID ${pid}`, + technicalDetails: 'Technische Details', + notNow: 'Nicht jetzt', + clientAlsoBehindTitle: 'Desktop-App ist veraltet', + clientAlsoBehindMessage: + 'Das Backend ist aktuell, aber diese Desktop-App läuft noch auf einer älteren Version. Aktualisieren Sie sie, um die neuesten Fixes zu erhalten.', + clientAlsoBehindAction: 'Desktop-App aktualisieren', + everythingDispatched: 'Update ausgelöst', + everythingSkipped: 'Übersprungen', + everythingRowFailed: 'Update fehlgeschlagen', + everythingFanoutFailedTitle: 'Andere Instanzen konnten nicht aktualisiert werden', + changeLogNew: 'Neuigkeiten', + changeLogFixed: 'Behoben', + changeLogFaster: 'Schneller', + changeLogImproved: 'Verbessert', + changeLogOther: 'Weitere Verbesserungen', + changeLogFallbackLabel: 'In diesem Update', + changeLogFallbackItem: 'Verbesserungen und Fehlerbehebungen', + applyStatus: { + preparing: 'Aktualisiert Backend…', + pulling: 'Backend wird aktualisiert…', + restarting: 'Backend startet neu, um das Update zu laden…', + notAvailable: 'Kein Update für dieses Backend verfügbar.', + failed: 'Backend-Update fehlgeschlagen.', + noReturn: + 'Das Backend kam nicht wieder online. Das Update wurde möglicherweise nicht abgeschlossen — prüfen Sie den Backend-Host.' + } + }, + handoffTour: { + profileTitle: 'Ihre erste Aufgabe läuft im Standardprofil', + profileText: + 'Diese Leiste wechselt die Profile. Das jetzt hervorgehobene ist „Standard", wo die Aufgaben-Session lebt. Das andere ist das Einrichtungsprofil, wo der Willkommens-Chat lebt.', + sessionsTitle: 'Jedes Profil führt seine eigenen Sessions', + sessionsText: + 'Diese Liste gehört zum Standardprofil. „Neue Session“ startet eine im jeweils gewählten Profil. Wechseln Sie Profile über die Leiste, und die Liste ändert sich mit.', + stayTitle: 'Hermes ist einen Klick entfernt', + stayText: + 'Wechseln Sie ins Einrichtungsprofil und öffnen Sie „Willkommen bei Hermes“, wenn Sie Hilfe brauchen. Es bleibt dort.' + }, + guidedGreeting: { + line: 'Hallo und willkommen. Ich bin Hermes. Geben Sie mir zwei Minuten, um alles für Sie einzurichten, dann setzen wir mich auf etwas an, das Sie wirklich erledigt haben möchten.\\n\\nAber zuerst: Wie soll ich Sie nennen?', + nameSuggestion: name => `(Ich kann Sie auch einfach ${name} nennen, wenn Ihnen das lieber ist.)` + }, + install: { + stageStates: { + pending: 'Ausstehend', + running: 'Am Installieren', + succeeded: 'Fertig', + skipped: 'Übersprungen', + failed: 'Fehlgeschlagen' + }, + oneTimeTitle: 'Hermes braucht eine einmalige Installation', + unsupportedDesc: platform => + `Die automatische Installation beim ersten Start ist auf ${platform} noch nicht verfügbar. Öffnen Sie ein Terminal, führen Sie den Befehl unten aus und starten Sie die App dann neu. Bei späteren Starts wird dieser Schritt übersprungen.`, + installCommand: 'Installationsbefehl', + copyCommand: 'Befehl kopieren', + viewDocs: 'Installations-Doku ansehen', + installTo: 'Wird installiert nach', + retryAfterRun: "Ich hab's ausgeführt – erneut versuchen", + setupChoiceTitle: 'Hermes Desktop einrichten', + setupChoiceDesc: + 'Verbinden Sie diese App mit einem Hermes Gateway, das Sie bereits betreiben, oder installieren Sie Hermes lokal auf diesem Computer.', + connectExistingTitle: 'Mit bestehendem Hermes verbinden', + connectExistingShort: 'Bestehendes verbinden', + connectExistingDesc: + 'Ein Remote-Backend mit Session-Token oder Browser-Anmeldung verwenden. Es wird keine lokale Installation gestartet.', + installLocalTitle: 'Hermes lokal installieren', + installLocalDesc: + 'Hermes herunterladen, seine Python-Umgebung erstellen und das Backend auf diesem Computer ausführen.', + localStartUnavailable: + 'Die lokale Installation konnte nicht gestartet werden. Starten Sie Hermes Desktop neu und versuchen Sie es erneut.', + remoteSetupTitle: 'Mit bestehendem Hermes verbinden', + remoteSetupDesc: + 'Geben Sie die URL Ihres Gateways ein. Hermes Desktop erkennt, ob ein Token oder eine Browser-Anmeldung nötig ist.', + remoteUrlTitle: 'Gateway-URL', + remoteUrlDesc: 'Verwenden Sie die Basis-URL des Hermes Gateways, bei Remote-Gateways einschließlich https://.', + remoteUrlPlaceholder: 'https://gateway.example.com/hermes', + probing: 'Gateway-Authentifizierung wird erkannt...', + probeError: 'Dieses Hermes Gateway konnte nicht erreicht werden.', + probeErrorDetails: 'Details', + identityProvider: 'Ihr Identity-Provider', + authTitle: 'Authentifizierung', + authNeedsOauth: provider => `Melden Sie sich mit ${provider} an, bevor Sie dieses Gateway testen.`, + authSignedIn: 'Browser-Sign-in abgeschlossen.', + connected: 'Verbunden', + signIn: 'Anmelden', + signInWith: provider => `Mit ${provider} anmelden`, + enterUrlFirst: 'Geben Sie zuerst eine Gateway-URL ein.', + signInIncomplete: 'Das Anmeldefenster wurde geschlossen, bevor die Authentifizierung abgeschlossen war.', + tokenTitle: 'Session-Token', + tokenDesc: 'Fügen Sie das Session-Token aus der .env-Datei des Remote-Gateways ein.', + pasteSessionToken: 'Session-Token einfügen', + incompleteSignInTest: 'Melden Sie sich an, bevor Sie dieses OAuth-geschützte Gateway testen.', + incompleteTokenTest: 'Geben Sie ein Session-Token ein, bevor Sie dieses Gateway testen.', + testConnection: 'Verbindung testen', + testSucceeded: (baseUrl, version) => `Verbunden mit ${baseUrl}${version ? ` (${version})` : ''}.`, + applyRemote: 'Übernehmen und neu verbinden', + backToSetup: 'Zurück', + failedTitle: 'Installation fehlgeschlagen', + settingUpTitle: 'Hermes Agent wird eingerichtet', + finishingTitle: 'Wird abgeschlossen', + failedDesc: + 'Einer der Installationsschritte ist fehlgeschlagen. Unter Windows kann das passieren, wenn eine andere Hermes-CLI- oder Desktop-Instanz läuft. Beenden Sie alle laufenden Hermes-Instanzen und versuchen Sie es dann erneut. Das vollständige Protokoll finden Sie in den Details unten oder im Desktop-Log.', + activeDesc: + 'Das ist eine einmalige Einrichtung. Der Hermes-Installer lädt Abhängigkeiten herunter und konfiguriert Ihren Computer. Bei späteren Starts wird dieser Schritt übersprungen.', + progress: (completed, total) => `${completed} von ${total} Schritten fertig`, + currentStage: stage => ` – gerade: ${stage}`, + fetchingManifest: 'Installer-Manifest wird geholt...', + error: 'Fehler', + hideOutput: 'Installer-Ausgabe ausblenden', + showOutput: 'Installer-Ausgabe anzeigen', + lines: count => `${count} Zeile${count === 1 ? '' : 'n'}`, + noOutput: 'Noch keine Ausgabe.', + cancelling: 'Wird abgebrochen...', + cancelInstall: 'Installation abbrechen', + transcriptSaved: 'Vollständiges Protokoll gespeichert unter', + copiedOutput: 'Kopiert!', + copyOutput: 'Ausgabe kopieren', + reloadRetry: 'Neu laden und erneut versuchen', + openLogs: 'Logs öffnen' + }, + onboarding: { + headerTitle: 'Hermes Agent für Sie einrichten', + headerDesc: + 'Verbinden Sie einen Modell-Anbieter, um mit dem Chatten zu beginnen. Die meisten Optionen brauchen nur einen Klick.', + preparingInstall: + 'Hermes schließt die Installation ab. Das dauert beim ersten Start normalerweise unter einer Minute.', + starting: 'Hermes wird gestartet…', + lookingUpProviders: 'Anbieter werden gesucht...', + collapse: 'Einklappen', + otherProviders: 'Andere Anbieter', + haveApiKey: 'Ich habe bereits einen API-Key', + chooseLater: 'Ich wähle später einen Anbieter', + recommended: 'Empfohlen', + connected: 'Verbunden', + featuredPitch: 'Ein Abo, 300+ Frontier-Modelle – die empfohlene Art, Hermes zu nutzen', + fireworksPitch: 'Direkte Model-API – Fireworks-gehostete Frontier-Modelle', + localModelsTitle: 'Modelle lokal ausführen', + localModelsPitch: 'Kein Konto nötig – laden Sie ein Modell herunter und führen Sie es auf diesem Rechner aus', + openRouterPitch: 'Ein Key, hunderte Modelle – ein solider Standard', + apiKeyOptions: { + fireworks: { + short: 'direkte Model-API', + description: 'Direkter Zugriff auf Modelle, die von Fireworks AI gehostet werden.' + }, + openrouter: { + short: 'ein Key, viele Modelle', + description: 'Hostet hunderte Modelle hinter einem einzigen Key. Guter Standard für neue Installationen.' + }, + openai: { + short: 'GPT-Klasse-Modelle', + description: 'Direkter Zugriff auf OpenAI-Modelle.' + }, + gemini: { + short: 'Gemini-Modelle', + description: 'Direkter Zugriff auf Google-Gemini-Modelle.' + }, + xai: { + short: 'Grok-Modelle', + description: 'Direkter Zugriff auf xAI-Grok-Modelle.' + }, + local: { + short: 'selbst gehostet', + description: + 'Verbinden Sie Hermes mit einem lokalen oder selbst gehosteten OpenAI-kompatiblen Endpunkt (vLLM, llama.cpp, Ollama usw.).' + } + }, + backToSignIn: 'Zurück zur Anmeldung', + getKey: 'Einen Key holen', + replaceCurrent: 'Aktuellen Wert ersetzen', + pasteApiKey: 'API-Key einfügen', + localApiKeyPlaceholder: 'API-Key (optional – nur falls Ihr Endpunkt einen benötigt)', + couldNotSave: 'Anmeldedaten konnten nicht gespeichert werden.', + connecting: 'Verbinden', + update: 'Aktualisieren', + flowSubtitles: { + pkce: 'Öffnet Ihren Browser zur Anmeldung und fährt dann hier fort', + device_code: 'Öffnet eine Verifizierungsseite in Ihrem Browser – Hermes verbindet sich automatisch', + external: 'Melden Sie sich einmal in Ihrem Terminal an und kehren Sie dann zum Chatten zurück' + }, + startingSignIn: provider => `Anmeldung für ${provider} wird gestartet...`, + verifyingCode: provider => `Ihr Code wird mit ${provider} überprüft…`, + connectedProvider: provider => `${provider} verbunden`, + connectedPicking: provider => `${provider} verbunden. Standardmodell wird ausgewählt...`, + signInFailed: 'Anmeldung fehlgeschlagen. Versuchen Sie es erneut.', + signInExpired: + 'Die Anmeldung ist beim Warten auf die Autorisierung abgelaufen. Meist bedeutet das, dass die Anmeldeseite im geöffneten Tab hängen geblieben ist (serverseitiges Problem) – schließen Sie die Anmeldung dort ab und versuchen Sie es dann erneut. Wenn es weiterhin fehlschlägt, verwenden Sie stattdessen einen API-Key oder die CLI als Alternative.', + signInDidNotFinish: provider => + `Die Anmeldung bei ${provider} wurde nicht abgeschlossen. Prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut, oder wählen Sie einen anderen Anbieter.`, + tryAgain: 'Erneut versuchen', + useApiKeyInstead: 'API-Key verwenden', + errorDetails: 'Details', + pickDifferentProvider: 'Einen anderen Anbieter wählen', + signInWith: provider => `Mit ${provider} anmelden`, + openedBrowser: provider => `Wir haben ${provider} in Ihrem Browser geöffnet.`, + authorizeThere: 'Autorisieren Sie Hermes dort.', + copyAuthCode: 'Kopieren Sie den Autorisierungscode und fügen Sie ihn unten ein.', + pasteAuthCode: 'Autorisierungscode einfügen', + reopenAuthPage: 'Autorisierungsseite erneut öffnen', + autoBrowser: provider => + `Wir haben ${provider} in Ihrem Browser geöffnet. Autorisieren Sie Hermes dort, und Sie werden automatisch verbunden – nichts zu kopieren oder einzufügen.`, + reopenSignInPage: 'Anmeldeseite erneut öffnen', + waitingAuthorize: 'Warten auf Ihre Autorisierung…', + externalPending: provider => + `${provider} meldet sich über seine eigene CLI an. Führen Sie diesen Befehl in einem Terminal aus, kehren Sie dann zurück und wählen Sie „Ich habe mich angemeldet“:`, + signedIn: 'Ich habe mich angemeldet', + deviceCodeOpened: provider => `Wir haben ${provider} in Ihrem Browser geöffnet. Geben Sie dort diesen Code ein:`, + reopenVerification: 'Verifikationsseite erneut öffnen', + copy: 'Kopieren', + defaultModel: 'Standardmodell', + freeTier: 'Free-Tier', + pro: 'Pro', + free: 'Kostenlos', + price: (input, output) => `${input} rein / ${output} raus pro Mtok`, + change: 'Ändern', + startChatting: 'Loslegen', + docs: provider => `${provider}-Doku` + }, + freeTier: { + providerRowTitle: 'Nous · Gratis-Tarif', + providerRowPitch: 'Melden Sie sich mit einem Nous-Konto an, um mehr Modelle und Tools freizuschalten.', + readyTitle: 'Hermes ist bereit.', + readyCaption: 'Kostenlos · Verbindungen inklusive', + begin: 'Loslegen', + signInInstead: 'Stattdessen mit einem Nous-Konto anmelden', + otherProviders: 'Andere Anbieter', + stripTitle: 'Kostenlose Nous-Inferenz und Verbindungen sind jetzt verfügbar.', + stripBody: 'Öffnen Sie die Modellauswahl, um sie auszuprobieren, oder melden Sie sich mit einem Nous-Konto an.', + openModelPicker: 'Modellauswahl öffnen', + dismiss: 'Ausblenden', + providerName: 'Nous', + statusLabel: model => `Nous · ${model}`, + signIn: 'Anmelden', + signInHeading: 'Melden Sie sich mit einem Nous-Konto an, um mehr Modelle und Tools freizuschalten.', + settingUp: 'Kostenlose Inferenz wird eingerichtet…', + codeBody: 'Geben Sie diesen Code in Ihrem Browser ein, um die Anmeldung abzuschließen.', + copyLink: 'Link kopieren', + doNotShare: 'Diesen Code nicht weitergeben.', + waiting: 'Warten auf Anmeldung…', + finishingHeading: 'Anmeldung wird abgeschlossen…', + finishingBody: 'Im Browser bestätigt. Ihre Konto-Tokens werden abgerufen.', + signedInAs: email => `Angemeldet als ${email}`, + signedIn: 'Angemeldet.', + completedBody: 'Ihr Konto hat jetzt Zugriff auf Inferenz und Tools.', + defaultModel: 'Standardmodell', + change: 'Ändern', + done: 'Fertig', + notNow: 'Nicht jetzt', + tryAgain: 'Erneut versuchen', + startAgain: 'Neu starten', + didNotComplete: 'Anmeldung nicht abgeschlossen', + rejectedBody: 'Die Anmeldung wurde im Browser abgelehnt. Sie bleiben im kostenlosen Tarif.', + supersededBody: 'Ein neuerer Anmeldecode hat diesen ersetzt.', + timedOutHeading: 'Anmeldung abgelaufen', + timedOutBody: 'Der Code wurde nicht rechtzeitig verwendet. Sie bleiben im kostenlosen Tarif.', + retiredBody: + 'Diese Gratis-Tarif-Identität wurde bereits verwendet oder ist abgelaufen; beim nächsten Start wird eine neue eingerichtet.', + errorBody: 'Die Anmeldung wurde nicht abgeschlossen; starten Sie sie erneut.', + busyHeading: 'Fast geschafft', + busyBody: wait => + `Hermes konnte Ihre Anmeldung nicht abschließen, weil der Nous-Dienst ausgelastet ist. Versuchen Sie es in ${wait} erneut. Ihre Session bleibt so lange erhalten.`, + unreachableBody: + 'Hermes konnte den Nous-Dienst nicht erreichen, um Ihre Anmeldung abzuschließen. Prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut. Ihre Session bleibt erhalten.', + alreadySignedInHeading: 'Bereits angemeldet.', + alreadySignedInBody: 'Dieses Hermes ist bereits mit einem Nous-Konto angemeldet.', + setupFailed: { + gateClosed: + 'Diese Hermes-Version kann ohne Nous-Konto nicht starten. Melden Sie sich an oder legen Sie eines an – kostenlos und in einer Minute erledigt.', + paused: + 'Chatten ohne Anmeldung ist vorübergehend pausiert. Hermes prüft weiter. Die Anmeldung ist kostenlos, und Sie können sofort weitermachen.', + rateLimited: wait => + `Gerade starten sehr viele Leute, deshalb versucht Hermes es in ${wait} erneut. Die Anmeldung ist kostenlos und überspringt das Warten.`, + unreachable: + 'Hermes konnte den Nous-Dienst nicht erreichen. Prüfen Sie Ihre Internetverbindung und tippen Sie dann auf „Erneut versuchen“. Oder verbinden Sie vorerst einen anderen Anbieter.', + serverError: + 'Beim Nous-Dienst ist ein Fehler aufgetreten. Tippen Sie gleich auf „Erneut versuchen“ oder verbinden Sie vorerst einen anderen Anbieter.', + powRequired: + 'Der Nous-Server verlangt einen Proof of Work, den Ihr Agent noch nicht unterstützt. Melden Sie sich an oder legen Sie ein kostenloses Nous-Konto an, um fortzufahren.', + locked: + 'Diese Session kann ohne Anmeldung nicht fortgesetzt werden. Melden Sie sich an oder legen Sie ein kostenloses Nous-Konto an, um weiterzumachen.', + generic: + 'Hermes konnte den kostenlosen Zugang ohne Anmeldung nicht einrichten. Die Anmeldung ist kostenlos — oder verbinden Sie einen anderen Anbieter.', + signInBelow: 'Die Anmeldung ist kostenlos. Wählen Sie unten Nous.', + tryAgain: 'Erneut versuchen', + retrying: 'Wird erneut versucht…' + } + }, + modelPicker: { + title: 'Modell wechseln', + current: 'aktuell:', + unknown: '(unbekannt)', + search: 'Anbieter und Modelle filtern...', + noModels: 'Keine Modelle gefunden.', + addProvider: 'Anbieter hinzufügen', + loadFailed: 'Modelle konnten nicht geladen werden', + loadingIntoMemory: 'Wird in den Speicher geladen', + downloading: 'Wird heruntergeladen', + localDownloadsHeading: 'Lokal', + noAuthenticatedProviders: 'Keine authentifizierten Anbieter.', + pro: 'Pro', + proNeedsSubscription: 'Pro-Modelle benötigen ein bezahltes Nous-Abo.', + free: 'Kostenlos', + freeTier: 'Kostenlose Stufe', + priceTitle: 'Eingabe-/Ausgabepreis pro Million Tokens', + wasPrice: 'war', + customModel: 'Eigenes Modell', + addCustomModelAction: 'Eigenes Modell hinzufügen…', + customModelPlaceholder: 'Modell-ID eingeben, z. B. openai/gpt-5' + }, + modelVisibility: { + title: 'Modelle', + search: 'Modelle suchen', + noAuthenticatedProviders: 'Keine authentifizierten Anbieter.', + addProvider: 'Anbieter hinzufügen…', + addCustomModel: 'Eigenes Modell hinzufügen', + removeCustomModel: 'Eigenes Modell entfernen' + }, + shell: { + windowControls: 'Fenster-Bedienelemente', + paneControls: 'Panele-Bedienelemente', + appControls: 'App-Bedienelemente', + modelMenu: { + search: 'Modelle durchsuchen', + noModels: 'Keine Modelle gefunden', + editModels: 'Modelle bearbeiten…', + refreshModels: 'Modelle aktualisieren', + fast: 'Schnell' + }, + modelOptions: { + noOptions: 'Keine Optionen für dieses Modell', + options: 'Optionen', + thinking: 'Denken', + fast: 'Schnell', + effort: 'Aufwand', + minimal: 'Minimal', + low: 'Niedrig', + medium: 'Mittel', + high: 'Hoch', + xhigh: 'Extra hoch', + max: 'Max', + ultra: 'Ultra', + sendsOnRoute: (level: string) => `sendet ${level} auf dieser Route`, + updateFailed: 'Aktualisierung der Modelloptie schlug fehl', + fastFailed: 'Aktualisierung des Schnell-Modus schlug fehl' + }, + gatewayMenu: { + gateway: 'Gateway', + connected: 'Verbunden', + connecting: 'Wird verbunden', + offline: 'Offline', + inferenceReady: 'Inference bereit', + inferenceNotReady: 'Inference nicht bereit', + checkingInference: 'Inference wird geprüft', + disconnected: 'Getrennt', + reconnectGateway: 'Gateway neu verbinden', + openSystem: 'System-Panel öffnen', + connection: label => `Verbindung: ${label}`, + recentActivity: 'Letzte Aktivität', + viewAllLogs: 'Alle Logs ansehen →', + messagingPlatforms: 'Messaging-Plattformen' + }, + approvalMode: { + title: 'Genehmigungsmodus', + ariaLabel: mode => `Genehmigungsmodus: ${mode}`, + manual: 'Manuell', + manualDescription: 'Nachfragen bei Aktionen, die eine Genehmigung erfordern', + smart: 'Smart', + smartDescription: 'Aktionen automatisch beurteilen und bei Bedarf nachfragen', + off: 'Aus', + offDescription: 'Ohne Genehmigungs-Abfragen ausführen' + }, + statusbar: { + unknown: 'unbekannt', + restart: 'Neustart', + update: 'Update', + updateInProgress: 'Update läuft', + commitsBehind: (count, branch) => `${count} commit${count === 1 ? '' : 's'} hinter ${branch}`, + desktopVersion: version => `Hermes Desktop v${version}`, + backendVersion: version => `Backend v${version}`, + clientLabel: version => `Client v${version}`, + connectionSsh: host => `SSH: ${host}`, + connectionRemote: host => `Remote: ${host}`, + connectionCloud: host => `Cloud: ${host}`, + connectionCloudTooltip: host => `Hermes Cloud · ${host}`, + connectionSshTooltip: host => `SSH · ${host}`, + connectionRemoteTooltip: host => `Remote · ${host}`, + backendLabel: version => `Backend v${version}`, + commit: sha => `Commit ${sha}`, + branch: branch => `Branch ${branch}`, + closeCommandCenter: 'Command Center schließen', + openCommandCenter: 'Command Center öffnen', + showTerminal: 'Terminal anzeigen', + hideTerminal: 'Terminal ausblenden', + gateway: 'Gateway', + gatewayReady: 'bereit', + gatewayNeedsSetup: 'braucht Einrichtung', + gatewayUnavailable: 'Inference nicht verfügbar', + gatewayChecking: 'wird geprüft', + gatewayConnecting: 'wird verbunden', + gatewayOffline: 'offline', + gatewayRestarting: 'wird neu gestartet…', + gatewayTitle: 'Gateway', + customizeTitle: 'In der Statusleiste anzeigen', + hideStatusbar: 'Statusleiste ausblenden', + resetStatusbar: 'Auf Standard zurücksetzen', + toggleApprovalMode: 'Genehmigungen', + toggleBackendVersion: 'Backend-Version', + toggleCacheHitRate: 'Cache-Trefferquote', + toggleCommandCenter: 'Command Center', + toggleContextUsage: 'Kontext-Anzeige', + toggleRunningTimer: 'Runden-Timer', + toggleSessionTimer: 'Session-Timer', + toggleTerminal: 'Terminal', + toggleTokensPerSecond: 'Tokens pro Sekunde', + toggleVersion: 'Version & Updates', + toggleFreeTier: 'Gratis-Tarif', + toggleWorkspace: 'Workspace', + cacheHitRateTitle: + 'Prompt-Cache-Trefferquote dieser Session — gecachte Tokens kosten weniger, also ist höher günstiger', + tokensPerSecondTitle: 'Ausgabe-Tokens pro Sekunde, gemittelt über die letzten 10 Modell-Aufrufe', + agents: 'Agents', + closeAgents: 'Agents schließen', + openAgents: 'Agents öffnen', + subagents: count => `${count} Subagent${count === 1 ? '' : 'en'}`, + failed: count => `${count} fehlgeschlagen`, + running: count => `${count} laufen`, + cron: 'Cron', + openCron: 'Cron-Jobs öffnen', + webhooks: 'Webhooks', + openWebhooks: 'Webhooks öffnen', + starmap: 'Memory-Graph', + openStarmap: 'Memory-Graph öffnen', + turnRunning: 'Läuft', + contextUsage: 'Kontext-Verbrauch', + systemResources: { + title: 'Systemressourcen', + loading: 'Ressourcen…', + gpuUtilization: 'GPU-Auslastung', + gpuMemory: 'GPU-Speicher', + ram: 'RAM', + unifiedNote: 'Unified Memory — die GPU und das System teilen sich diesen Speicherpool.', + toggle: 'Systemressourcen' + }, + contextUsagePanel: { + categories: { + conversation: 'Konversation', + mcp: 'MCP', + memory: 'Memory', + rules: 'Regeln', + skills: 'Skills', + subagent_definitions: 'Subagent-Definitionen', + system_prompt: 'System-Prompt', + tool_definitions: 'Tool-Definitionen' + }, + empty: 'Noch keine Kontext-Daten', + loading: 'Aufschlüsselung wird geladen…', + percentFull: percent => `${percent}% voll`, + title: 'Kontext-Verbrauch', + tokenSummary: (used, max) => `${used} / ${max} Tokens` + }, + session: 'Session', + yoloOn: 'YOLO an — gefährliche Befehle werden automatisch genehmigt. Shift+Klick schaltet global um.', + yoloOff: 'YOLO aus. Shift+Klick schaltet global um.', + modelNone: 'keines', + noModel: 'kein Modell', + switchModel: 'Modell wechseln', + openModelPicker: 'Modell-Auswahl öffnen', + modelPinned: + 'Von Ihnen angeheftet; neue Chats verwenden dieses Modell statt der Voreinstellung aus den Einstellungen', + modelTitle: (provider, model) => `Modell · ${provider}: ${model}`, + providerModelTitle: (provider, model) => `${provider} · ${model}` + } + }, + rightSidebar: { + aria: 'Rechte Sidebar', + panelsAria: 'Panels der rechten Sidebar', + files: 'Dateisystem', + terminal: 'Terminal', + noFolderSelected: 'Kein Ordner ausgewählt', + changeCwdTitle: 'Arbeitsverzeichnis ändern', + remotePickerTitle: 'Remote-Ordner wählen', + remotePickerDescription: 'Ordner auf dem verbundenen Backend durchsuchen.', + remotePickerSelect: 'Ordner auswählen', + folderTip: cwd => cwd, + openFolder: 'Ordner öffnen', + refreshTree: 'Baum aktualisieren', + collapseAll: 'Alle Ordner einklappen', + showIgnored: 'Von Git ignorierte Dateien anzeigen', + hideIgnored: 'Von Git ignorierte Dateien ausblenden', + previewUnavailable: 'Vorschau nicht verfügbar', + couldNotPreview: path => `Konnte ${path} nicht vorab anzeigen`, + noProjectTitle: 'Kein Projekt', + noProjectBody: 'Öffnen Sie ein Projekt, um seine Dateien zu durchsuchen und Änderungen zu prüfen.', + noProjectOpen: 'Kein Projekt geöffnet', + noDiffs: 'Keine Diffs', + unreadableTitle: 'Nicht lesbar', + unreadableBody: error => `Dieser Ordner konnte nicht gelesen werden (${error}).`, + emptyTitle: 'Leer', + emptyBody: 'Dieser Ordner ist leer.', + treeErrorTitle: 'Baumfehler', + treeErrorBody: 'Beim Anzeigen dieses Ordners ist ein Fehler im Dateibaum aufgetreten.', + tryAgain: 'Erneut versuchen', + loadingTree: 'Dateibaum wird geladen', + loadingFiles: 'Dateien werden geladen', + terminalHide: 'Terminal ausblenden', + terminalsAria: 'Terminals', + terminalNew: 'Neues Terminal', + terminalCloseOthers: 'Andere schließen', + terminalCloseAll: 'Alle schließen', + addToChat: 'Zum Chat hinzufügen' + }, + preview: { + tab: 'Vorschau', + closePane: 'Vorschau-Fenster schließen', + loading: 'Vorschau wird geladen', + unavailable: 'Vorschau nicht verfügbar', + opening: 'Wird geöffnet…', + hide: 'Ausblenden', + openPreview: 'Vorschau öffnen', + openInBrowser: 'Im Browser öffnen', + openInExternal: 'In externem Programm öffnen', + popIn: 'Eindocken', + popOut: 'Abdocken', + linkHint: '⌘/Strg-Klick für das Vorschau-Fenster', + sourceLineTitle: 'Zum Auswählen klicken · zum Erweitern Umschalt-Klick · zum Composer ziehen', + source: 'QUELLE', + renderedPreview: 'VORSCHAU', + diff: 'DIFF', + unknownSize: 'unbekannte Größe', + binaryTitle: 'Das sieht wie eine Binärdatei aus', + binaryBody: label => `Die Vorschau von ${label} könnte unlesbaren Text zeigen.`, + largeTitle: 'Diese Datei ist groß', + largeBody: (label, size) => `${label} ist ${size}. Hermes zeigt nur die ersten 512 KB an.`, + previewAnyway: 'Trotzdem anzeigen', + truncated: 'Die ersten 512 KB werden angezeigt.', + noInlineTitle: 'Keine Inline-Vorschau', + noInlineBody: mimeType => `${mimeType || 'Dieser Dateityp'} kann trotzdem als Kontext angehängt werden.`, + edit: 'Bearbeiten', + editing: 'Wird bearbeitet', + unsavedChanges: 'Nicht gespeicherte Änderungen', + saveFailed: message => `Speichern fehlgeschlagen: ${message}`, + diskChangedTitle: 'Datei auf der Festplatte geändert', + diskChangedBody: + 'Diese Datei wurde geändert, seit Sie sie geöffnet haben. Mit Ihrer Version überschreiben oder Ihre Änderungen verwerfen und neu laden?', + overwrite: 'Überschreiben', + discardReload: 'Verwerfen & neu laden', + console: { + deselect: 'Eintrag abwählen', + select: 'Eintrag auswählen', + copyFailed: 'Konsolenausgabe konnte nicht kopiert werden', + copyEntry: 'Diesen Eintrag kopieren', + sendEntry: 'Diesen Eintrag an den Chat senden', + messages: count => `${count} Konsolenmeldungen`, + resize: 'Vorschau-Konsole in der Größe ändern', + title: 'Vorschau-Konsole', + selected: count => `${count} ausgewählt`, + sendToChat: 'An Chat senden', + copySelected: 'Ausgewähltes in die Zwischenablage kopieren', + copyAll: 'Alles in die Zwischenablage kopieren', + copy: 'Kopieren', + clear: 'Leeren', + empty: 'Noch keine Konsolenmeldungen vorhanden.', + promptHeader: 'Vorschau-Konsole:', + sentTitle: 'An Chat gesendet', + sentMessage: count => `Einträge aus dem Log (${count}) wurden zum Composer hinzugefügt` + }, + web: { + appFailedToBoot: 'Die Vorschau-App konnte nicht gestartet werden', + serverNotFound: 'Server nicht gefunden', + remoteLoopback: + 'Diese Adresse verweist auf den Rechner, auf dem Ihr Agent läuft – nicht auf diesen. Das Browserfenster lädt Seiten lokal, daher braucht ein entfernter Entwicklungsserver eine Portweiterleitung oder einen erreichbaren Hostnamen.', + failedToLoad: 'Vorschau konnte nicht geladen werden', + tryAgain: 'Nochmal versuchen', + restarting: 'Hermes wird neu gestartet …', + askRestart: 'Hermes bitten, den Server neu zu starten', + lookingRestart: taskId => `Hermes sucht nach einem Vorschau-Server zum Neustarten (${taskId})`, + restartingTitle: 'Vorschau-Server wird neu gestartet', + restartingMessage: 'Hermes arbeitet im Hintergrund. Beobachte im Fortschritt die Vorschau-Konsole.', + startRestartFailed: message => `Server-Neustart konnte nicht gestartet werden: ${message}`, + restartFailed: 'Server-Neustart fehlgeschlagen', + hideConsole: 'Vorschau-Konsole ausblenden', + showConsole: 'Vorschau-Konsole anzeigen', + hideDevTools: 'Vorschau-DevTools ausblenden', + openDevTools: 'Vorschau-DevTools öffnen', + goBack: 'Zurück', + goForward: 'Vor', + reload: 'Seite neu laden', + address: 'Adresse', + addressPlaceholder: 'Adresse eingeben', + blankPageBody: 'Geben Sie oben eine Adresse ein, um zu browsen, oder bitten Sie Hermes, eine Seite zu öffnen.', + finishedRestarting: message => `Hermes hat den Vorschau-Server neu gestartet${message ? `: ${message}` : ''}`, + failedRestarting: message => `Server-Neustart fehlgeschlagen: ${message}`, + unknownError: 'unbekannter Fehler', + restartedTitle: 'Vorschau-Server neu gestartet', + reloadingNow: 'Die Vorschau wird jetzt neu geladen.', + restartFailedTitle: 'Vorschau-Neustart fehlgeschlagen', + restartFailedMessage: 'Hermes konnte den Server nicht neu starten.', + stillWorking: + 'Hermes arbeitet noch, aber es ist noch kein Ergebnis des Neustarts eingetroffen. Der Server-Befehl läuft möglicherweise im Vordergrund.', + workspaceReloading: 'Arbeitsbereich geändert, Vorschau wird neu geladen', + fileChanged: url => `Datei geändert, Vorschau wird neu geladen: ${url}`, + filesChanged: (count, url) => `${count} Dateiänderungen, Vorschau wird neu geladen: ${url}`, + watchFailed: message => `Vorschau-Datei konnte nicht überwacht werden: ${message}`, + moduleMimeDescription: + 'Modul-Skripte werden mit dem falschen MIME-Typ ausgeliefert. Das bedeutet meist, dass ein statischer Datei-Server eine Vite/React-App ausliefert statt des Projekt-Entwicklungs-Servers.', + loadFailedConsole: (code, message) => `Laden fehlgeschlagen${code ? ` (${code})` : ''}: ${message}`, + unreachableDescription: 'Die Vorschau-Seite konnte nicht erreicht werden.', + openTarget: url => `${url} öffnen`, + fallbackTitle: 'Vorschau', + annotate: 'Annotieren', + annotateOn: 'Annotation beenden', + annotateNeedPage: 'Öffnen Sie zuerst eine Seite im In-App-Browser.', + annotateFailed: 'Annotationsmodus konnte nicht gestartet werden', + commenting: 'Kommentieren', + addComments: count => (count === 1 ? '1 Kommentar hinzufügen' : `${count} Kommentare hinzufügen`), + commentPlaceholder: 'Kommentar hinzufügen …', + commentTitle: n => `Kommentar ${n}`, + saveComment: 'Speichern', + cancelComment: 'Kommentar abbrechen' + } + }, + interfaceMode: { + title: 'Oberflächenmodus', + hint: 'Ändert, was angezeigt wird, nicht was Hermes kann.', + sessionNote: + 'Vom einfachen Modus festgelegt. Eine Änderung hier gilt für diese Session; wechseln Sie zu „Erweitert“, um sie dauerhaft zu übernehmen.', + simple: { + label: 'Einfach', + description: 'Zum Chatten mit Hermes. Seitenleiste und Chat; keine Terminal-, Datei- oder Diff-Bereiche.' + }, + advanced: { + label: 'Erweitert', + description: 'Für Entwickler. Terminal, Dateien, Diffs, Statusleiste und Layouts – so, wie Sie sie einrichten.' + } + }, + zones: { + showTabStrip: 'Tabs anzeigen', + hideTabStrip: 'Tabs ausblenden', + showStripTab: title => `${title} anzeigen`, + hideStripTab: title => `${title} ausblenden`, + lastTabKeptTitle: 'Letzter Tab bleibt', + lastTabKeptBody: + 'Diese Zone braucht mindestens einen sichtbaren Tab. Zeigen Sie zuerst einen anderen Tab an oder klappen Sie die ganze Seitenleiste ein.', + toggleStripTab: title => `${title}-Tab umschalten`, + minimize: 'Minimieren', + restore: 'Wiederherstellen', + closeRunningTitle: 'Laufenden Tab schließen?', + closeRunningBody: + 'Dieser Chat arbeitet noch (oder wartet auf Ihre Eingabe). Das Schließen des Tabs blendet ihn nur aus – die Session behält ihren Fortschritt und kann über die Seitenleiste wieder geöffnet werden.', + closeRunningConfirm: 'Tab schließen', + reload: 'Neu laden', + closeOthers: 'Andere schließen', + closeToRight: 'Nach rechts schließen', + closeAll: 'Alle schließen', + newSessionTab: 'Neuer Session-Tab', + newTab: 'Neuer Tab', + pluginDisabled: pluginId => `Plugin "${pluginId}" deaktiviert`, + pluginDisabledBody: 'Aktivieren Sie es wieder unter Einstellungen → Plugins, um das Panel zurückzuholen.', + missingPane: paneId => `fehlendes Panel: ${paneId}`, + editTitle: 'Layouts', + editHint: 'Wählen Sie ein Layout oder ziehen Sie Panels zwischen den Zonen.', + reset: 'Zurücksetzen', + templates: 'Vorlagen', + custom: 'Benutzerdefiniert', + newGridLayout: 'Neues Grid-Layout', + saveCurrentAs: 'Aktuelle Anordnung als Vorlage speichern', + nameLayoutPlaceholder: 'Dieses Layout benennen…', + deletePreset: name => `${name} löschen`, + zoneEditorTitle: 'Zonen-Editor', + editorHintPre: 'Klicken zum Teilen · ', + editorHintPost: ' dreht die Linie · über Zonen ziehen zum Zusammenführen · gemeinsame Kanten ziehen zum Skalieren', + templateColumns: 'Spalten', + templateRows: 'Zeilen', + templateGrid: 'Raster', + templatePriority: 'Priorität', + zoneTag: index => `Zone ${index}`, + mergeZones: count => `${count} Zonen zusammenführen`, + customZoneName: count => `Benutzerdefinierte ${count}-Zone`, + layoutNamePlaceholder: fallback => `Layout-Name (${fallback})`, + saveApply: 'Speichern & anwenden', + notExpressible: 'diese Anordnung greift ineinander (Windrad) — noch nicht als verschachtelte Splits ausdrückbar', + zoneCount: count => `${count} Zonen`, + tabCount: count => `${count} Tabs` + }, + contextMenu: { + link: { + openInApp: 'Im In-App-Browser öffnen', + openExternal: 'Im externen Browser öffnen', + copyUrl: 'URL kopieren', + copyResolvedUrl: 'Aufgelöste URL kopieren' + }, + image: { + copyImage: 'Bild kopieren', + copyImageAddress: 'Bildadresse kopieren', + saveImageAs: 'Bild speichern unter…' + }, + edit: { + cut: 'Ausschneiden', + paste: 'Einfügen', + selectAll: 'Alles auswählen', + addToDictionary: 'Zum Wörterbuch hinzufügen' + }, + page: { + copyPageUrl: 'Seiten-URL kopieren', + inspectElement: 'Element untersuchen' + } + }, + assistant: { + thread: { + loadingSession: 'Session wird geladen', + showEarlier: 'Frühere Nachrichten anzeigen', + loadingResponse: 'Hermes lädt eine Antwort', + loadingLocalModel: model => `${model} wird in den Speicher geladen`, + processingPrompt: 'Verarbeite Prompt', + resumeWhenBackgroundDone: count => + count === 1 + ? 'Wird fortgesetzt, wenn die Hintergrundaufgabe endet' + : `Wird fortgesetzt, wenn ${count} Hintergrundaufgaben enden`, + thinking: 'Denkt', + thought: 'Gedanke', + thoughtBriefly: 'Kurz nachgedacht', + thoughtFor: duration => `${duration} nachgedacht`, + turnDuration: duration => `Dieser Turn dauerte ${duration}`, + today: time => `Heute, ${time}`, + yesterday: time => `Gestern, ${time}`, + copy: 'Kopieren', + refresh: 'Aktualisieren', + moreActions: 'Weitere Aktionen', + branchNewChat: 'In neuem Chat abzweigen', + react: 'Reagieren', + dismissError: 'Fehler schließen', + errorLayers: { + auth: 'Authentifizierungsfehler', + billing: 'Keine Credits mehr', + disk: 'Festplatte voll', + endpoint: 'Benutzerdefinierter Endpunktfehler', + gateway: 'Gateway-Fehler', + generic: 'Turn fehlgeschlagen', + provider: 'Anbieterfehler', + runtime: 'Lokaler Laufzeitfehler', + streaming: 'Streaming-Verbindungsfehler' + }, + errorLayerBodies: { + auth: 'Der KI-Dienst hat Ihre Anmeldung abgelehnt. Prüfen Sie die Zugangsdaten für diesen Anbieter und senden Sie Ihre Nachricht erneut.', + billing: + 'Ihr Konto hat bei diesem Anbieter kein Guthaben mehr. Laden Sie Guthaben auf oder wechseln Sie den Anbieter und senden Sie erneut.', + disk: 'Ihre Festplatte ist voll, daher konnte Hermes dieses Gespräch nicht speichern. Geben Sie Speicherplatz frei und versuchen Sie es erneut.', + endpoint: + 'Hermes erreicht Ihren eigenen Modellserver nicht. Prüfen Sie, ob er läuft, und senden Sie Ihre Nachricht erneut.', + gateway: + 'Beim Starten dieser Antwort ist in Hermes ein internes Problem aufgetreten. Senden Sie Ihre Nachricht erneut; wenn es bestehen bleibt, senden Sie Diagnosedaten.', + generic: + 'Beim Antworten ist etwas schiefgelaufen. Versuchen Sie es erneut oder kopieren Sie die Details, wenn es bestehen bleibt.', + provider: + 'Der KI-Dienst konnte diese Anfrage nicht abschließen. Versuchen Sie es gleich erneut oder wechseln Sie den Anbieter.', + runtime: + 'Beim Starten dieser Antwort ist in Hermes ein internes Problem aufgetreten. Senden Sie Ihre Nachricht erneut; wenn es bestehen bleibt, senden Sie Diagnosedaten.', + streaming: + 'Die Verbindung ist abgebrochen, bevor die Antwort fertig war. Versuchen Sie es erneut, um sie noch einmal zu senden.' + }, + errorCodes: { + auth: { + title: provider => `${provider} hat Ihre Anmeldung abgelehnt`, + body: provider => + `Die für ${provider} gespeicherten Zugangsdaten wurden nicht akzeptiert. Korrigieren Sie sie in den Einstellungen oder wechseln Sie den Anbieter und senden Sie Ihre Nachricht erneut.` + }, + auth_permanent: { + title: provider => `${provider} hat Ihre Anmeldung abgelehnt`, + body: provider => + `Die für ${provider} gespeicherten Zugangsdaten sind ungültig oder wurden widerrufen. Aktualisieren Sie sie oder wechseln Sie den Anbieter und senden Sie Ihre Nachricht erneut.` + }, + billing: { + title: 'Kein Guthaben mehr', + body: provider => + `Ihr ${provider}-Konto hat kein Guthaben mehr. Laden Sie Guthaben auf oder wechseln Sie den Anbieter und senden Sie erneut.` + }, + rate_limit: { + title: 'Der KI-Dienst ist ausgelastet', + body: provider => + `${provider} begrenzt gerade die Anfragen. Warten Sie eine Minute und versuchen Sie es erneut.` + }, + upstream_rate_limit: { + title: 'Der KI-Dienst ist ausgelastet', + body: provider => + `${provider} begrenzt gerade die Anfragen. Warten Sie eine Minute und versuchen Sie es erneut.` + }, + overloaded: { + title: 'Der KI-Dienst ist überlastet', + body: provider => + `${provider} hat gerade Probleme. Versuchen Sie es gleich erneut oder wechseln Sie den Anbieter.` + }, + server_error: { + title: 'Beim KI-Dienst ist ein Fehler aufgetreten', + body: provider => + `${provider} hat einen Serverfehler zurückgegeben. Versuchen Sie es gleich erneut oder wechseln Sie den Anbieter.` + }, + timeout: { + title: 'Die Antwort hat zu lange gebraucht', + body: provider => + `${provider} hat nicht rechtzeitig geantwortet. Versuchen Sie es erneut, um die Nachricht noch einmal zu senden.` + }, + stream_drop: { + title: 'Die Antwort wurde abgebrochen', + body: 'Die Verbindung ist abgebrochen, bevor die Antwort fertig war. Versuchen Sie es erneut, um sie noch einmal zu senden.' + }, + upstream_blocked: { + title: 'Eine Firewall hat die Anfrage blockiert', + body: (provider: string) => + `Eine Firewall oder ein CDN vor ${provider} hat die Anfrage blockiert, bevor sie das Modell erreicht hat – Ihr Schlüssel ist vermutlich in Ordnung. Setzen Sie in den Einstellungen über die extra_headers des Anbieters einen User-Agent-Header oder wechseln Sie den Anbieter und senden Sie die Nachricht dann erneut.` + }, + ssl_cert_verification: { + title: 'Sichere Verbindung fehlgeschlagen', + body: provider => + `Hermes konnte die sichere Verbindung zu ${provider} nicht verifizieren. Prüfen Sie Ihre Netzwerk- oder Proxy-Einstellungen oder wechseln Sie den Anbieter und senden Sie Ihre Nachricht erneut.` + }, + context_overflow: { + title: 'Dieses Gespräch ist zu lang', + body: 'Das Gespräch passt nicht mehr ins Modell. Komprimieren Sie es oder starten Sie einen neuen Chat und senden Sie erneut.' + }, + payload_too_large: { + title: 'Diese Nachricht ist zu groß', + body: 'Die Anfrage war zu groß für das Modell. Komprimieren Sie das Gespräch oder starten Sie einen neuen Chat und senden Sie erneut.' + }, + model_not_found: { + title: 'Dieses Modell ist nicht verfügbar', + body: provider => + `${provider} bietet dieses Modell für Ihr Konto nicht an. Wählen Sie ein anderes Modell und senden Sie Ihre Nachricht erneut.` + }, + provider_policy_blocked: { + title: 'Dieses Modell ist durch Ihre Kontoeinstellungen gesperrt', + body: provider => + `${provider} leitet diese Anfrage mit den Daten- und Datenschutzeinstellungen Ihres Kontos nicht weiter. Wählen Sie ein anderes Modell oder wechseln Sie den Anbieter.` + }, + content_policy_blocked: { + title: 'Der KI-Dienst hat diese Anfrage abgelehnt', + body: provider => + `${provider} wollte diese Nachricht nicht beantworten. Ändern Sie sie und senden Sie sie erneut.` + }, + format_error: { + title: 'Der KI-Dienst hat die Anfrage abgelehnt', + body: provider => + `${provider} hat den Aufbau dieser Anfrage nicht akzeptiert. Wechseln Sie den Anbieter oder senden Sie Diagnosedaten, damit wir es prüfen können.` + }, + truncated: { + title: 'Die Antwort wurde abgeschnitten', + body: 'Das Modell hat vor dem Ende abgebrochen. Versuchen Sie es erneut, um eine vollständige Antwort zu erhalten.' + }, + invalid_response: { + title: 'Der KI-Dienst hat eine unlesbare Antwort geschickt', + body: provider => + `${provider} hat etwas zurückgegeben, das Hermes nicht lesen konnte. Versuchen Sie es gleich erneut.` + }, + empty_response: { + title: 'Der KI-Dienst hat eine leere Antwort geschickt', + body: provider => `${provider} hat auf diese Nachricht nichts zurückgegeben. Versuchen Sie es gleich erneut.` + }, + loop_error: { + title: 'Hermes ist in einer Schleife hängen geblieben', + body: 'Die Antwort hat dieselben Schritte wiederholt, daher hat Hermes sie gestoppt. Versuchen Sie es erneut oder starten Sie einen neuen Chat, wenn es wieder passiert.' + }, + SESSION_NOT_OWNED: { + title: 'Dieser Chat ist woanders offen', + body: 'Dieser Chat ist gerade in einem anderen Hermes-Fenster oder Terminal geöffnet. Schließen Sie ihn dort und senden Sie Ihre Nachricht erneut, oder starten Sie hier einen neuen Chat.' + }, + disk_full: { + title: 'Festplatte voll', + body: 'Ihre Festplatte ist voll, daher konnte Hermes dieses Gespräch nicht speichern. Geben Sie Speicherplatz frei und versuchen Sie es erneut.' + }, + free_tier_disabled: { + title: 'Chatten ohne Anmeldung ist gerade abgeschaltet', + body: 'Melden Sie sich mit einem Nous-Konto an, um weiterzuschreiben – es ist kostenlos.' + }, + free_tier_rate_limited: { + title: 'Sie haben das Kontingent für Chats ohne Anmeldung aufgebraucht', + body: 'Es wird bald wieder aufgefüllt. Melden Sie sich mit einem Nous-Konto an, um ein größeres Kontingent zu erhalten – es ist kostenlos.' + }, + free_tier_at_capacity: { + title: 'Chatten ohne Anmeldung ist gerade sehr stark ausgelastet', + body: 'Melden Sie sich an, um die Warteschlange zu überspringen – es ist kostenlos –, oder versuchen Sie es später erneut.' + }, + free_tier_model_not_free: { + title: 'Dieses Modell gibt es ohne Anmeldung nicht', + body: 'Hermes verwendet vorerst das kostenlose Modell. Melden Sie sich mit einem Nous-Konto an, um mehr Modelle zu nutzen – es ist kostenlos.' + }, + free_tier_route: { + title: 'Hermes hat das kostenlose Modell über diese Route nicht erreicht', + body: 'Melden Sie sich mit einem Nous-Konto an – es ist kostenlos – oder prüfen Sie die Einstellung NOUS_INFERENCE_BASE_URL.' + }, + free_tier_outage: { + title: 'Das kostenlose Modell antwortet gerade schlecht', + body: 'Versuchen Sie in einer Minute, Ihre Nachricht erneut zu senden.' + }, + free_tier_refused: { + title: 'Hermes konnte das ohne Anmeldung nicht senden', + body: 'Eine Anmeldung mit einem Nous-Konto ist kostenlos.' + } + }, + errorAuthKinds: { + api_key: { + title: provider => `${provider} hat Ihren API-Key abgelehnt`, + body: provider => + `Der für ${provider} gespeicherte Key ist ungültig oder wurde widerrufen. Aktualisieren Sie ihn und versuchen Sie es erneut.` + }, + oauth: { + title: provider => `Ihre ${provider}-Anmeldung ist abgelaufen` + } + }, + errorDetails: 'Details', + errorGenericProvider: 'Der KI-Dienst', + errorToastTitle: 'Hermes konnte die Antwort nicht fertigstellen', + errorRetry: 'Erneut versuchen', + errorLimitResets: (time: string) => `Limit wird um ${time} zurückgesetzt`, + errorRetryAtReset: (time: string) => `Erneut versuchen, wenn das Limit zurückgesetzt wird (${time})`, + errorRetryScheduled: (time: string, wait: string) => `Neuer Versuch um ${time} – in ${wait}`, + errorRetryScheduledCancel: 'Abbrechen', + errorStartNewSession: 'Neue Session starten', + errorSwitchProvider: 'Anbieter wechseln', + errorChooseModel: 'Modell wählen', + errorCompressConversation: 'Gespräch komprimieren', + errorCompressFailed: 'Das Gespräch konnte nicht komprimiert werden', + errorOpenHermesFolder: 'Hermes-Ordner öffnen', + errorOpenHermesFolderFailed: 'Der Hermes-Ordner konnte nicht geöffnet werden', + errorUpdateApiKey: 'API-Key aktualisieren', + errorSignInAgain: provider => `Erneut bei ${provider} anmelden`, + errorSignInFreeTier: 'Mit einem Nous-Konto anmelden', + errorOauthExpired: provider => + `Ihre Anmeldung bei ${provider} ist abgelaufen oder wurde widerrufen. Melden Sie sich erneut an, um weiterzuchatten.`, + errorOpenLogs: 'Logs öffnen', + errorOpenLogsFailed: 'Der Logs-Ordner konnte nicht geöffnet werden', + errorOpenDesktopLogs: 'Desktop-Logs öffnen', + errorCopyDiagnostics: 'Fehlerdetails kopieren', + errorSendDiagnostics: 'Diagnose senden', + filesChanged: count => (count === 1 ? '1 Datei geändert' : `${count} Dateien geändert`), + reviewChanges: 'Prüfen', + readAloudFailed: 'Vorlesen fehlgeschlagen', + preparingAudio: 'Bereitet Audio vor...', + stopReading: 'Vorlesen stoppen', + readAloud: 'Vorlesen', + editMessage: 'Nachricht bearbeiten', + expandMessage: 'Nachricht aufklappen', + scrollToBottom: 'Nach unten scrollen', + stop: 'Stopp', + restorePrevious: 'Vorherigen Checkpoint wiederherstellen', + restoreCheckpoint: 'Checkpoint wiederherstellen', + restoreFromHere: 'Checkpoint wiederherstellen — von diesem Prompt erneut ausführen', + restoreTitle: 'Zu diesem Checkpoint wiederherstellen?', + restoreBody: 'Alles nach diesem Prompt wird aus der Konversation entfernt, und der Prompt läuft von hier erneut.', + restoreConfirm: 'Wiederherstellen & erneut ausführen', + restoreNext: 'Nächsten Checkpoint wiederherstellen', + goForward: 'Vorwärts', + sendEdited: 'Bearbeitete Nachricht senden', + attachingFile: 'Hängt an…' + }, + approval: { + gatewayDisconnected: 'Hermes-Gateway ist nicht verbunden', + sendFailed: 'Genehmigungsantwort konnte nicht gesendet werden', + reconnect: 'Neu verbinden', + timedOutSystemLine: + 'Die Freigabe ist abgelaufen — der Befehl wurde nicht ausgeführt. Bitten Sie Hermes, es erneut zu versuchen, oder erhöhen Sie das Limit unter Einstellungen → Sicherheit → Freigabe-Timeout.', + openSafetySettings: 'Sicherheitseinstellungen öffnen', + run: 'Ausführen', + command: 'Befehl', + moreOptions: 'Weitere Genehmigungsoptionen', + allowSession: 'Diese Session erlauben', + alwaysAllowMenu: 'Immer erlauben…', + jumpToApproval: 'Genehmigung erforderlich', + reject: 'Ablehnen', + alwaysTitle: 'Diesen Befehl immer erlauben?', + alwaysDescription: pattern => + `Dies fügt das Muster „${pattern}“ Ihrer dauerhaften Zulassungsliste hinzu (~/.hermes/config.yaml). Hermes fragt bei solchen Befehlen nicht mehr nach – weder in dieser noch in zukünftigen Sessions.`, + alwaysAllow: 'Immer erlauben' + }, + clarify: { + notReady: 'Klärungsanfrage ist noch nicht bereit', + gatewayDisconnected: 'Hermes-Gateway ist nicht verbunden', + sendFailed: 'Klärungsantwort konnte nicht gesendet werden', + loadingQuestion: 'Frage wird geladen…', + other: 'Anderes (Antwort eingeben)', + placeholder: 'Geben Sie Ihre Antwort ein…', + skip: 'Überspringen', + skipped: 'Übersprungen', + continueLabel: 'Weiter', + confirmAndContinueLabel: 'Bestätigen und fortfahren', + answeredBadge: 'Beantwortet', + questionProgress: (answered, total) => `${answered} von ${total} beantwortet`, + lateAnswer: (question, choice) => `Re: „${question}“ — meine Antwort: ${choice}`, + lateAnswerTip: 'Diese Antwort als Folgenachricht entwerfen', + lateAnswerHint: 'Dieser Prompt wartet nicht mehr. Wählen Sie eine Option, um sie als Folgenachricht zu entwerfen.' + }, + catalogInstall: { + preparing: 'Installation wird vorbereitet…', + install: 'Installieren', + advanced: 'Erweitert', + skip: 'Überspringen', + installing: 'Wird installiert…', + installed: 'Installiert', + notInstalled: 'Nicht installiert', + failed: 'Fehlgeschlagen', + showNames: 'Namen anzeigen', + hideNames: 'Namen ausblenden', + skill: (name: string) => `Skill ${name}`, + kind: { + plugin: 'Plugin', + skill: 'Skill' + }, + tier: { + official: 'offiziell', + community: 'Community' + }, + targetProfile: (profile: string) => `Wird in Ihr Profil ${profile} installiert`, + sendFailed: 'Ihre Antwort konnte nicht gesendet werden. Versuchen Sie es erneut.', + commitLabel: 'Commit', + subdirLabel: 'Ordner', + securityHeading: 'Sicherheit', + scan: { + passed: 'Scan bestanden', + warnings: 'Scan hat Warnungen gefunden', + failed: 'Scan fehlgeschlagen' + }, + requirementsLabel: 'Erfordert', + credentialsHeading: 'Zugangsdaten' + }, + mcpSetup: { + installTitle: 'MCP-Server hinzufügen', + enableTitle: 'MCP-Server aktivieren', + authorizeTitle: 'MCP-Server autorisieren', + installAction: 'Installieren', + enableAction: 'Aktivieren', + authorizeAction: 'Autorisieren', + installed: server => `${server} installiert`, + enabled: server => `${server} aktiviert`, + authorized: server => `${server} autorisiert`, + failed: server => `Einrichtung für ${server} fehlgeschlagen`, + toolCount: count => (count === 1 ? '1 Tool' : `${count} Tools`), + envRequired: 'Füllen Sie zuerst die erforderlichen Anmeldedaten aus', + sendFailed: 'MCP-Einrichtungsantwort konnte nicht gesendet werden', + reloadFailed: + 'Server gespeichert, aber das Neuladen der MCP-Tools schlug fehl — sie laden in der nächsten Session', + gatewayDisconnected: 'Hermes-Gateway ist nicht verbunden' + }, + tool: { + copyCode: 'Code kopieren', + renderingImage: 'Rendert Bild', + copyOutput: 'Ausgabe kopieren', + copyCommand: 'Befehl kopieren', + copyContent: 'Inhalt kopieren', + copyUrl: 'URL kopieren', + copyResults: 'Ergebnisse kopieren', + copyQuery: 'Abfrage kopieren', + copyFile: 'Datei kopieren', + copyPath: 'Pfad kopieren', + failedCalls: count => `${count} Tool-Aufruf${count === 1 ? '' : 'e'} fehlgeschlagen`, + skillActivity: { + loading: 'Skill wird geladen', + loaded: 'Skill geladen', + loadFailed: 'Skill konnte nicht geladen werden', + readingResource: 'Skill-Ressource wird gelesen', + readResource: 'Skill-Ressource gelesen', + resourceFailed: 'Skill-Ressource konnte nicht gelesen werden', + listing: 'Skills werden aufgelistet', + listed: 'Skills aufgelistet', + listFailed: 'Skills konnten nicht aufgelistet werden', + unavailable: 'Skill-Ergebnis nicht verfügbar' + }, + outputAlt: 'Tool-Ausgabe', + rawResponse: 'Rohantwort', + copyActivity: 'Aktivität kopieren', + recoveredOne: 'Nach 1 fehlgeschlagenem Schritt wiederhergestellt', + recoveredMany: count => `Nach ${count} fehlgeschlagenen Schritten wiederhergestellt`, + failedOne: '1 Schritt fehlgeschlagen', + failedMany: count => `${count} Schritte fehlgeschlagen`, + statusRunning: 'Läuft', + statusError: 'Fehler', + statusRecovered: 'Wiederhergestellt', + statusDone: 'Fertig', + resultUnavailable: 'Ergebnis nicht verfügbar', + resultInterrupted: 'Unterbrochen', + memoryWriteNoted: 'Speicher-Schreiben notiert', + actions: { + read: 'Lesen', + reading: 'Liest', + opened: 'Geöffnet', + opening: 'Öffnet', + failedToOpen: 'Öffnen fehlgeschlagen', + searched: 'Durchsucht', + searching: 'Durchsucht', + ran: 'Ausgeführt', + running: 'Läuft', + ranCode: 'Code ausgeführt', + runningCode: 'Skriptet' + }, + prefixes: { + browser: 'Browser', + web: 'Web' + }, + titleTemplates: { + actionCommand: (action, command) => `${action} ${command}`, + actionQuoted: (action, value) => `${action} „${value}“`, + actionTarget: (action, target) => `${action} ${target}`, + prefixedDone: (prefix, action) => `${prefix} ${action}`, + runningPrefixedTool: (prefix, action) => `${prefix} ${action.toLowerCase()} wird ausgeführt`, + runningTool: action => `${action} wird ausgeführt` + }, + titles: { + browser_click: { + done: 'Seitenelement geklickt', + pending: 'Klickt Seitenelement', + pendingAction: 'Klickt' + }, + browser_fill: { + done: 'Formularfeld ausgefüllt', + pending: 'Füllt Formularfeld', + pendingAction: 'Füllt' + }, + browser_navigate: { + done: 'Seite geöffnet', + pending: 'Öffnet Seite', + pendingAction: 'Öffnet' + }, + browser_snapshot: { + done: 'Seiten-Snapshot erfasst', + pending: 'Erfasst Seiten-Snapshot', + pendingAction: 'Erfasst' + }, + browser_take_screenshot: { + done: 'Screenshot erfasst', + pending: 'Erfasst Screenshot', + pendingAction: 'Erfasst' + }, + browser_type: { + done: 'Auf Seite getippt', + pending: 'Tippt auf Seite', + pendingAction: 'Tippt' + }, + clarify: { + done: 'Frage gestellt', + pending: 'Stellt Frage', + pendingAction: 'Stellt' + }, + cronjob: { + done: 'Cron-Job', + pending: 'Plant Cron-Job', + pendingAction: 'Plant' + }, + edit_file: { + done: 'Datei bearbeitet', + pending: 'Bearbeitet Datei', + pendingAction: 'Bearbeitet' + }, + execute_code: { + done: 'Code ausgeführt', + pending: 'Skriptet', + pendingAction: 'Skriptet' + }, + image_generate: { + done: 'Bild generiert', + pending: 'Generiert Bild', + pendingAction: 'Generiert' + }, + list_files: { + done: 'Dateien aufgelistet', + pending: 'Listet Dateien', + pendingAction: 'Listet' + }, + memory: { + done: 'Im Speicher gespeichert', + pending: 'Speichert im Speicher', + pendingAction: 'Speichert' + }, + patch: { + done: 'Datei gepatcht', + pending: 'Patcht Datei', + pendingAction: 'PATCHT' + }, + read_file: { + done: 'Datei gelesen', + pending: 'Liest Datei', + pendingAction: 'Liest' + }, + search_files: { + done: 'Dateien durchsucht', + pending: 'Durchsucht Dateien', + pendingAction: 'Durchsucht' + }, + session_search_recall: { + done: 'Session-Verlauf durchsucht', + pending: 'Durchsucht Session-Verlauf', + pendingAction: 'Durchsucht' + }, + terminal: { + done: 'Befehl ausgeführt', + pending: 'Führt Befehl aus', + pendingAction: 'Führt aus' + }, + todo: { + done: 'Todos aktualisiert', + pending: 'Aktualisiert Todos', + pendingAction: 'Aktualisiert' + }, + vision_analyze: { + done: 'Bild analysiert', + pending: 'Analysiert Bild', + pendingAction: 'Analysiert' + }, + web_extract: { + done: 'Webseite gelesen', + pending: 'Liest Webseite', + pendingAction: 'Liest' + }, + web_search: { + done: 'Web durchsucht', + pending: 'Durchsucht Web', + pendingAction: 'Durchsucht' + }, + write_file: { + done: 'Datei bearbeitet', + pending: 'Bearbeitet Datei', + pendingAction: 'Bearbeitet' + } + } + } + }, + prompts: { + gatewayDisconnected: 'Das Hermes-Gateway ist nicht verbunden', + reconnect: 'Neu verbinden', + sudoSendFailed: 'Sudo-Passwort konnte nicht gesendet werden', + secretSendFailed: 'Geheimnis konnte nicht gesendet werden', + sudoTitle: 'Administrator-Passwort', + sudoDesc: + 'Hermes benötigt Ihr Sudo-Passwort, um einen privilegierten Befehl auszuführen. Es wird nur an Ihren lokalen Agenten gesendet.', + sudoCommandUnavailable: + 'Dieser Agent hat den Befehl nicht mitgeliefert. Brechen Sie ab, wenn Sie ihn im Gespräch nicht überprüfen können.', + sudoInstallDesc: + 'Hermes benötigt Ihr sudo-Passwort, um die Bot-Screen-Pakete (TigerVNC + Xfce) auf dem Gateway-Host zu installieren. Es wird nur an diesen Host gesendet.', + sudoPlaceholder: 'Sudo-Passwort', + secretTitle: 'Geheimnis erforderlich', + secretDesc: 'Hermes benötigt eine Zugangsdaten, um fortzufahren.', + secretPlaceholder: 'Geheimnis-Wert', + vaultUnlockSendFailed: 'Master-Passwort konnte nicht gesendet werden', + vaultUnlockTitle: name => `${name} entsperren`, + vaultUnlockDesc: name => + `Der Agent möchte sich mit einem in ${name} gespeicherten Login bei einer Seite anmelden. Geben Sie Ihr Master-Passwort ein, um ihn für diese Session zu entsperren – es geht direkt an ${name} auf diesem Rechner und wird nie gespeichert oder dem Agenten gezeigt.`, + vaultUnlockPlaceholder: 'Master-Passwort', + vaultUnlockKeepLocked: 'Gesperrt lassen', + vaultUnlockConfirm: 'Entsperren', + vaultSaveSendFailed: 'Login konnte nicht gespeichert werden', + vaultSaveTitle: site => `${site}-Login speichern?`, + vaultSaveDesc: origin => + `Hermes ist auf eine Anmeldeseite unter ${origin} gestoßen und hat dafür keinen Login. Geben Sie ihn einmal hier ein; er wird auf diesem Rechner verschlüsselt und in die Seite eingetragen, ohne dass das Modell das Passwort je sieht.`, + vaultSaveIdentifierLabel: 'E-Mail oder Benutzername', + vaultSaveIdentifierPlaceholder: 'name@example.com', + vaultSavePasswordPlaceholder: 'Passwort', + vaultSaveFootnote: 'Verwalten Sie gespeicherte Logins unter Einstellungen → Passwörter & Logins.', + vaultSaveDecline: 'Nicht speichern', + vaultSaveConfirm: 'Speichern & anmelden', + vaultCodeSendFailed: 'Code konnte nicht gesendet werden', + vaultCodeTitle: site => `Bestätigungscode für ${site}`, + vaultCodeDesc: site => + `${site} verlangt einen Einmalcode (SMS, E-Mail oder Authenticator-App). Geben Sie ihn hier ein, Hermes trägt ihn in die Seite ein; das Modell sieht ihn nie.`, + vaultCodeLabel: 'Code', + vaultCodeFootnote: + 'Tipp: Speichern Sie den Authentifizierungsschlüssel zusammen mit diesem Login unter Einstellungen → Passwörter & Logins, dann gibt Hermes die Codes für Sie ein.', + vaultCodeSkip: 'Überspringen', + vaultCodeConfirm: 'Code eingeben' + }, + desktop: { + audioReadFailed: 'Aufgenommenes Audio konnte nicht gelesen werden', + sessionUnavailable: 'Session nicht verfügbar', + createSessionFailed: 'Neue Session konnte nicht erstellt werden', + promptFailed: 'Prompt fehlgeschlagen', + providerCredentialRequired: + 'Fügen Sie Anmeldedaten für einen Anbieter hinzu, bevor Sie Ihre erste Nachricht senden.', + emptySlashCommand: 'leerer Slash-Befehl', + desktopCommands: 'Desktop-Befehle', + skillCommandsAvailable: count => `${count} Fähigkeitsbefehle verfügbar.`, + warningLine: message => `Warnung: ${message}`, + yoloArmed: 'YOLO für diesen Chat aktiviert', + yoloOff: 'YOLO aus', + yoloSystem: active => `YOLO ${active ? 'an' : 'aus'} für diese Session`, + yoloTitle: 'YOLO', + yoloToggleFailed: 'YOLO konnte nicht umgeschaltet werden', + profileStatus: current => + `Profil: ${current}. Verwenden Sie /profile oder die Auswahl „Neue Session“, um einen Chat in einem anderen Profil zu starten.`, + unknownProfile: 'Unbekanntes Profil', + noProfileNamed: (target, available) => `Kein Profil namens „${target}“. Verfügbar: ${available}`, + newChatsProfile: name => `Neue Chats verwenden Profil ${name}.`, + setProfileFailed: 'Profil konnte nicht gesetzt werden', + sttDisabled: 'Sprach-zu-Text ist in den Einstellungen deaktiviert.', + stopFailed: 'Stopp fehlgeschlagen', + regenerateFailed: 'Regenerieren fehlgeschlagen', + editFailed: 'Bearbeiten fehlgeschlagen', + editTurnUnavailable: 'Dieser Turn ist nicht mehr in der Server-Historie (er wurde möglicherweise komprimiert).', + resumeFailed: 'Fortsetzen fehlgeschlagen', + readOnlyTranscriptTitle: 'Schreibgeschützt geöffnet', + readOnlyTranscriptBody: + 'Noch kein verbundenes Backend beansprucht diesen älteren Chat, also wurde er als schreibgeschütztes Transkript geöffnet. Seine Historie ist intakt; Senden ist deaktiviert, bis ein Backend ihn beansprucht.', + readOnlyTranscriptSendBlocked: + 'Dieser Chat ist als schreibgeschütztes Transkript geöffnet — Senden ist deaktiviert.', + resumeStrandedTitle: 'Diese Session konnte nicht geladen werden', + resumeStrandedBody: + 'Die Verbindung zu dieser Session ist fehlgeschlagen, und automatische Wiederholungen wurden eingestellt. Prüfen Sie, ob das Gateway läuft, und versuchen Sie es erneut.', + poolSlotTimeoutBody: + 'Alle Backend-Plätze für lokale Profile sind belegt. Erhöhen Sie „Warme Bot-Backends“ unter Einstellungen → Erweitert, oder versuchen Sie es erneut, nachdem ein inaktives Backend entfernt wurde.', + poolSlotTimeoutOpenSettings: 'Erweiterte Einstellungen öffnen', + resumeRetry: 'Erneut versuchen', + nothingToBranch: 'Nichts zum Abzweigen', + branchNeedsChat: 'Starten Sie einen Chat oder setzen Sie ihn fort, bevor Sie abzweigen.', + sessionBusy: 'Session beschäftigt', + branchStopCurrent: 'Stoppen Sie den laufenden Turn, bevor Sie von diesem Chat abzweigen.', + branchNoText: 'Diese Nachricht hat keinen Text zum Abzweigen.', + branchTitle: n => `Entwurf: Branch #${n}`, + branchFailed: 'Abzweigen fehlgeschlagen', + deleteFailed: 'Löschen fehlgeschlagen', + archived: 'Archiviert', + archiveFailed: 'Archivieren fehlgeschlagen', + cwdChangeFailed: 'Arbeitsverzeichnis-Änderung fehlgeschlagen', + cwdStagedTitle: 'Arbeitsverzeichnis bereitgestellt', + cwdStagedMessage: 'Starten Sie das Desktop-Backend neu, um cwd-Änderungen auf diese aktive Session anzuwenden.', + modelSwitchConfirmBody: 'Dieser Modellwechsel muss bestätigt werden.', + modelSwitchConfirmLabel: 'Trotzdem wechseln', + modelSwitchConfirmTitle: (model: string) => `Zu ${model} wechseln?`, + modelSwitchConfirmTitleFallback: 'Modell wechseln?', + modelSwitchFailed: 'Modellwechsel fehlgeschlagen', + modelSwitchKeepLabel: 'Aktuelles Modell behalten', + modelSwitchStaleNotice: 'Auswahl geändert – der Modellwechsel wurde nicht angewendet.', + hydrationSyncing: (profile: string) => `Synchronisiert ${profile}…`, + sessionExported: 'Session exportiert', + sessionExportFailed: 'Session konnte nicht exportiert werden', + imageSaved: 'Bild gespeichert', + downloadStarted: 'Download gestartet', + restartToUseSaveImage: 'Starten Sie Hermes Desktop neu, um „Bild speichern“ zu verwenden.', + restartToSaveImages: 'Starten Sie Hermes Desktop neu, um Bilder zu speichern', + imageDownloadFailed: 'Bild-Download fehlgeschlagen', + openImage: 'Bild öffnen', + downloadImage: 'Bild herunterladen', + savingImage: 'Speichert Bild', + imagePreviewFailed: 'Bildvorschau fehlgeschlagen', + imageAttach: 'Bild anhängen', + imageWriteFailed: 'Bild konnte nicht auf die Festplatte geschrieben werden.', + imageAttachFailed: 'Bild-Anhängen fehlgeschlagen', + pastedContent: 'Eingefügter Inhalt', + pasteAttachFailed: 'Eingefügter Text konnte nicht angehängt werden', + attachImages: 'Bilder anhängen', + clipboard: 'Zwischenablage', + noClipboardImage: 'Kein Bild in der Zwischenablage', + clipboardPasteFailed: 'Zwischenablage-Einfügen fehlgeschlagen', + dropFiles: 'Dateien ablegen', + handoff: { + pickPlatform: 'Ziel wählen', + success: platform => `Übergeben an ${platform}. Jederzeit hier fortsetzen.`, + systemNote: platform => `↻ Übergeben an ${platform} — jederzeit hier fortsetzen.`, + failed: error => `Übergabe fehlgeschlagen: ${error}`, + timedOut: 'Zeitüberschreitung beim Warten auf das Gateway. Läuft `hermes gateway`?', + startMessaging: 'Messaging starten' + } + }, + tips: { + close: 'Diesen Tip nicht mehr zeigen', + items: { + 'new-session': { + title: 'Frisch loslegen', + text: 'Ein neuer Chat bekommt seinen eigenen Context, sein eigenes Terminal und Arbeitsverzeichnis.' + }, + skills: { + title: 'Einmal beibringen', + text: 'Skills sind Ordner mit Anweisungen, die Hermes lädt, wenn die Arbeit danach verlangt.' + }, + messaging: { + title: 'Hermes abseits Ihres Schreibtischs', + text: 'Verbinden Sie Telegram, Discord, Slack und mehr – derselbe Agent, dasselbe Gedächtnis.' + }, + artifacts: { + title: 'Alles, was Hermes gemacht hat', + text: 'Bilder, Dateien und Links aus jeder Session, an einem Ort indexiert.' + }, + cron: { + title: 'Arbeit, die von selbst läuft', + text: 'Planen Sie einen Prompt stündlich, nächtlich oder nach einem Cron-Ausdruck.' + }, + 'command-palette': { + title: 'Eine Box für alles', + text: 'Sessions, Einstellungen, Skills und Befehle gehorchen alle der Palette.' + }, + profiles: { + title: 'Profile sind getrennt', + text: 'Jedes ist sein eigenes Hermes — eigene Schlüssel, eigenes Gedächtnis, eigene Sessions.' + }, + 'composer-mentions': { + title: 'Anhängen und befehlen', + text: 'Geben Sie @ ein, um eine Datei in die Konversation zu holen, oder /, um einen Befehl auszuführen.' + }, + 'local-runtime-update': { + title: 'Ein Update für die lokale Engine ist verfügbar', + text: 'Aktualisieren Sie die Engine, die Ihre lokalen Modelle ausführt. Laufende lokale Anfragen können unterbrochen werden.', + action: 'Jetzt aktualisieren' + }, + 'local-setup': { + title: 'Dieses Gerät kann Modelle lokal ausführen', + text: 'Ihre Hardware kann ein lokales Modell ausführen. Chats bleiben auf Ihrem Computer und kosten nichts.', + action: 'Einrichten' + }, + 'right-pane': { + title: 'Der Arbeitsbereich', + text: 'Dateien, Terminal, Review und der In-App-Browser teilen sich die rechte Seite.' + } + } + }, + errors: { + genericFailure: 'Etwas ist schiefgelaufen', + boundaryTitle: 'Etwas ist in der Oberfläche kaputtgegangen', + boundaryDesc: + 'In dieser Ansicht ist ein unerwarteter Fehler aufgetreten. Ihre Chats und Einstellungen sind sicher.', + boundaryDetails: 'Details', + sendDiagnostics: 'Diagnosedaten senden', + reloadWindow: 'Fenster neu laden', + openLogs: 'Logs öffnen' + }, + ui: { + search: { + clear: 'Suche löschen' + }, + pagination: { + label: 'Seitennummerierung', + previous: 'Zurück', + previousAria: 'Zur vorherigen Seite', + next: 'Weiter', + nextAria: 'Zur nächsten Seite' + }, + sidebar: { + title: 'Seitenleiste', + description: 'Zeigt die mobile Sidebar an.', + toggle: open => `${open ? 'Anzeigen' : 'Ausblenden'}: Sidebar` + } + } +}) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a63c1d4d0f..dccb2a18e6 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1111,6 +1111,10 @@ export const en: Translations = { backdropDesc: 'The faint statue image behind the conversation.', userBubbleTitle: 'Message Bubble', userBubbleDesc: 'How see-through your own messages are. Solid at 0; only the outline remains at 100.', + textDirectionTitle: 'Text direction', + textDirectionDesc: + 'How chat messages and the composer choose their direction. Auto follows the first letter of each paragraph; pick a direction when mixed text lines up the wrong way. Code always stays left-to-right.', + textDirection: { auto: 'Auto', rtl: 'Right-to-left', ltr: 'Left-to-right' }, introSplashTitle: 'Intro Splash', introSplashDesc: 'The wordmark and prompt shown on an empty chat.', reactionsTitle: 'Message Reactions', diff --git a/apps/desktop/src/i18n/es.ts b/apps/desktop/src/i18n/es.ts new file mode 100644 index 0000000000..d2c1bcc549 --- /dev/null +++ b/apps/desktop/src/i18n/es.ts @@ -0,0 +1,5971 @@ +import { defineFieldCopy } from '@/app/settings/field-copy' + +import { defineLocale } from './define-locale' +import { introEs } from './intro-es' + +export const es = defineLocale({ + intro: introEs, + connectors: { + title: 'Conecta tus apps', + connect: 'Conectar', + skip: 'Ahora no', + cancel: 'Dejar de esperar', + retry: 'Reintentar', + grant: 'Volver a conectar', + connected: 'Conectado', + checking: 'Comprobando tus apps…', + notConnected: 'No conectado', + skipped: 'Omitido', + disabled: 'No disponible', + failed: 'No se pudo conectar', + needsAuth: 'Acceso caducado', + opening: 'Abriendo el inicio de sesión…', + waiting: 'Esperando a tu navegador…', + timeout: 'Seguimos esperando la autorización.', + refresh: 'Actualizar estado', + connectError: 'No se pudo iniciar la autorización. Inténtalo de nuevo.', + connectErrorFor: (app: string) => `No se pudo iniciar la autorización para ${app}.`, + unavailable: 'Los conectores no están disponibles en esta sesión.', + ownerMissing: 'Vuelve a abrir esta conversación para gestionar sus conexiones.', + search: 'Buscar una app', + empty: 'No hay apps que coincidan', + disclaimer: 'Conectar es opcional. Autoriza solo las apps que quieras que use Hermes.', + execution: 'Herramientas de conectores', + setup: server => `Configurar ${server}`, + openInBrowser: 'Abrir en el navegador', + setupCancel: 'Cancelar', + authorizedToolsUnavailable: 'Autorizado. Herramientas no disponibles.', + required: 'Obligatorio' + }, + connectorsPage: { + title: 'Conectores', + searchPlaceholder: (count: number) => `Buscar entre ${count} apps`, + filterCategory: 'Categoría', + categoryAll: 'Todas las categorías', + uncategorised: 'Sin categoría', + residencyLocal: 'En este dispositivo', + segment: { + all: 'Todos', + available: 'Disponibles', + connected: 'Conectados', + off: 'Desactivados' + }, + group: { + connected: 'Conectados', + connectedNote: 'Primero las conexiones con fallos.', + available: 'Disponibles', + off: 'Desactivados', + offNote: 'Los inicios de sesión se conservan.' + }, + card: { + kindManaged: 'Administrado', + kindCatalog: 'MCP · Catálogo', + kindCustom: 'MCP · Personalizado', + kindPlugin: (plugin: string) => `MCP · Plugin ${plugin}`, + inCatalog: 'En el catálogo de Hermes', + hostedTwin: 'Versión administrada disponible', + alsoLocal: 'También se ejecuta en este dispositivo', + open: (name: string) => `Abrir ${name}`, + turnServerOn: (name: string) => `Activar ${name}`, + turnServerOff: (name: string) => `Desactivar ${name}`, + state: { + accessExpired: 'Acceso caducado', + available: 'Disponible', + connected: 'Conectado', + connecting: 'Conectando', + connectionUnknown: 'Estado desconocido', + couldNotConnect: 'No se pudo conectar', + offByYourOrganisation: 'Desactivado por tu organización', + offForYou: 'Desactivado para ti', + serverConnecting: 'Conectando…', + serverError: 'Error', + serverNeedsAuth: 'Requiere autenticación', + serverOff: 'Desactivado', + serverOn: 'Activado', + serverOnUnused: 'Activado, sin usar' + }, + fact: { + tools: (count: number) => `${count} herramienta${count === 1 ? '' : 's'}`, + toolsOff: (count: number) => + `${count} herramienta${count === 1 ? '' : 's'} desactivada${count === 1 ? '' : 's'}`, + toolsOn: (count: number) => `${count} herramienta${count === 1 ? '' : 's'} activada${count === 1 ? '' : 's'}`, + toolsSomeOn: (total: number, on: number) => `${total} herramientas, ${on} activada${on === 1 ? '' : 's'}` + }, + verb: { + authenticate: 'Autenticar', + connect: 'Conectar', + install: 'Instalar', + openLogs: 'Abrir registros', + reconnect: 'Volver a conectar', + stopWaiting: 'Dejar de esperar', + tryAgain: 'Reintentar', + turnBackOn: 'Volver a activar' + }, + reason: { + finishSignIn: 'Termina de iniciar sesión en tu navegador.', + reconnect: 'Vuelve a conectar para que esta app siga funcionando.', + serverError: 'El servidor rechazó la conexión.', + serverNeedsAuth: 'Inicia sesión para que este servidor pueda responder.' + } + }, + page: { + loading: 'Leyendo el catálogo y los servidores de este equipo', + emptyTitle: 'Todavía no hay apps. Añade un servidor en este equipo para empezar.', + noMatchTitle: 'No hay apps que coincidan', + noMatchBody: 'No hay coincidencias. Indica a Hermes tu propio servidor MCP para añadirlo.', + clearSearch: 'Borrar la búsqueda', + hostedFailedTitle: 'No se pudo acceder a las apps alojadas.', + hostedFailedBody: 'Los servidores de este equipo no se ven afectados y siguen funcionando. No se desactivó nada.', + retry: 'Reintentar', + matchesElsewhere: (count: number) => `${count} coincidencia${count === 1 ? '' : 's'} más en otros grupos.`, + showAllMatches: 'Mostrar todas las coincidencias', + segmentNoMatch: (segment: string) => `No hay coincidencias en ${segment}, así que se muestran todas.`, + freeTierNote: 'Las conexiones se quedan en este equipo hasta que inicies sesión.', + signInLine: 'Inicia sesión en Nous para usar las apps administradas.', + signIn: 'Iniciar sesión', + managedUnavailable: 'Las apps administradas aún no están disponibles para esta cuenta.', + writeFailed: 'No se guardó ese cambio.', + refreshFailed: 'No se actualizó la lista de herramientas.', + disconnectNoAccount: + 'Hermes no tiene ninguna cuenta que desconectar aquí. Actualiza la página e inténtalo de nuevo.', + disconnectRefused: + 'Nous no pudo quitar este inicio de sesión ahora. Desactiva la app con el interruptor o inténtalo más tarde.' + }, + add: { + action: 'Añadir el tuyo', + title: 'Conectar con un MCP personalizado', + hint: 'una nueva entrada en mcp.json en este dispositivo', + pasteLabel: 'Pega un comando o un fragmento', + pastePlaceholder: 'npx -y @modelcontextprotocol/server-filesystem /ruta/a/carpeta', + pasteNoMatch: 'Nada de esto parece un servidor. Rellena los campos de abajo.', + name: 'Nombre', + nameTaken: 'Ese nombre ya está en uso.', + type: 'Tipo', + typeStdio: 'STDIO', + typeHttp: 'HTTP transmitible', + command: 'Comando de inicio', + args: 'Argumentos', + addArg: '+ Añadir argumento', + envVars: 'Variables de entorno', + addEnvVar: '+ Añadir variable de entorno', + passthrough: 'Paso de variables de entorno', + addPassthrough: '+ Añadir variable', + cwd: 'Directorio de trabajo', + url: 'URL', + headers: 'Encabezados', + addHeader: '+ Añadir encabezado', + auth: 'Autenticación', + authNone: 'Ninguna', + authOauth: 'OAuth', + authBearer: 'Token Bearer', + keyPlaceholder: 'CLAVE', + valuePlaceholder: 'valor', + removeRow: 'Quitar esta fila', + editJson: 'Editar mcp.json', + saveFailed: 'No se guardó ese servidor.' + }, + dialog: { + disconnect: 'Desconectar', + disconnectTitle: (name: string) => `¿Desconectar ${name}?`, + disconnectBody: 'Hermes deja de actuar con esta cuenta. Puedes volver a conectarla cuando quieras.', + menuRefreshTools: 'Actualizar herramientas', + moreActions: 'Más acciones', + removeServerTitle: (name: string) => `¿Quitar ${name}?`, + removeServerBody: 'La entrada se quita de mcp.json en este equipo. No se elimina nada más.', + appSwitch: (name: string) => `Hermes puede usar ${name}`, + waysTitle: (name: string) => `Dónde se ejecuta ${name}`, + wayNotConnected: (name: string) => `Aún no está conectado. Inicia sesión en ${name} desde tu navegador.`, + wayHosted: 'Administrado', + bothOn: (name: string) => `Ambos están activados, así que Hermes ve cada herramienta de ${name} dos veces.`, + turnOffLocal: 'Desactivar el servidor local', + providedByPlugin: (plugin: string) => `Proporcionado por el plugin ${plugin}`, + openPlugins: 'Abrir la pestaña Plugins', + nousLine: 'Las apps de Nous siguen a tu cuenta, no al perfil.', + rulesReadOnly: 'Las reglas no se pueden cambiar ahora.', + rulesAppOff: (name: string) => `Activa ${name} para cambiar sus herramientas.`, + rulesSignIn: 'Inicia sesión para cambiar lo que Hermes puede hacer aquí.', + orgNote: (count: number) => `Tu organización desactivó ${count} herramienta${count === 1 ? '' : 's'}.`, + orgLink: 'Abrir la administración de conectores', + connectEnded: 'El inicio de sesión no terminó.', + connectOpenAgain: 'Abrir el enlace de nuevo', + tokensPerCall: 'tokens por llamada', + usesPerMonth: 'usos en 30 días', + advanced: 'Avanzado', + advancedHint: 'la entrada de mcp.json y los registros' + }, + tools: { + title: 'Herramientas', + notInstalledBody: 'Instálalo en este dispositivo para ver las herramientas que incluye.', + summaryTitle: (name: string) => `Lo que Hermes puede hacer con ${name}`, + summaryPreviewTitle: (name: string) => `Lo que Hermes podría hacer con ${name} cuando lo conectes`, + summaryCount: (count: number) => `${count} herramienta${count === 1 ? '' : 's'}`, + summaryAllTools: 'Todas las herramientas', + summaryOther: 'Otras', + allToolsSwitch: 'Activar o desactivar todas las herramientas', + summaryAllOn: 'todas activadas', + summarySomeOn: (on: number, total: number) => `${on} de ${total} activadas`, + summaryOff: 'desactivadas', + showAllTools: (count: number) => + count === 1 ? `Mostrar ${count} herramienta` : `Mostrar las ${count} herramientas`, + showSummary: 'Mostrar resumen', + facetSwitch: (facet: string) => `Activar o desactivar las herramientas de ${facet}`, + moreHints: (count: number) => `+${count}`, + staleSignIn: 'Inicia sesión para leer la lista de herramientas más reciente.', + searchCountPlaceholder: (count: number) => `Buscar entre ${count} herramientas`, + toolList: (name: string) => `Herramientas de ${name}`, + categorySelect: (count: number) => `${count} categoría${count === 1 ? '' : 's'}`, + showDeprecated: (count: number) => `Mostrar ${count} obsoleta${count === 1 ? '' : 's'}`, + hideDeprecated: (count: number) => `Ocultar ${count} obsoleta${count === 1 ? '' : 's'}`, + quickReadOnly: 'Solo lectura', + quickNoDestructive: 'Desactivar las destructivas', + quickEverythingOn: 'Todo activado', + lockedHint: 'desactivada por tu organización', + turnToolOn: (tool: string) => `Activar ${tool}`, + turnToolOff: (tool: string) => `Desactivar ${tool}`, + showDetails: (tool: string) => `Mostrar qué hace ${tool}`, + hideDetails: (tool: string) => `Ocultar qué hace ${tool}`, + noMatch: 'Ninguna herramienta coincide con estos filtros.', + loading: 'Leyendo la lista de herramientas', + unavailableLine: 'Lista de herramientas no disponible.', + needsAuthTitle: (name: string) => `Inicia sesión en ${name} para leer sus herramientas.`, + needsAuthBody: 'El inicio de sesión se queda en este equipo. Nada sale de él.', + retry: 'Reintentar', + goneTitle: (name: string) => `${name} salió del catálogo.`, + goneBody: 'Hermes ya no puede llamarlo. La fila se queda hasta que la quites, así que nada desaparece.', + remove: 'Quitar', + offTitle: (name: string) => `${name} está desactivado.`, + offBody: 'Actívalo con el interruptor de arriba para leer las herramientas que incluye.', + signedOutTitle: 'Inicia sesión en Nous para leer la lista de herramientas.', + signedOutBody: 'Tus servidores en este equipo no se ven afectados.', + conflictTitle: 'Alguien cambió esta regla mientras la editabas.', + conflictBody: (theyOff: number, theyOn: number) => { + const they = [ + theyOff > 0 + ? `desactivó ${theyOff} herramienta${theyOff === 1 ? '' : 's'} que tienes activada${theyOff === 1 ? '' : 's'}` + : '', + theyOn > 0 + ? `dejó activada${theyOn === 1 ? '' : 's'} ${theyOn} herramienta${theyOn === 1 ? '' : 's'} que desactivaste` + : '' + ].filter(Boolean) + + return `${they.length > 0 ? `Esa persona ${they.join(' y ')}. ` : ''}Tus cambios siguen en pantalla; no se escribió nada.` + }, + conflictReload: 'Recargar su versión', + conflictSave: 'Guardar sobre su versión', + saveFailed: 'No se guardaron esas reglas de herramientas.', + footerDirty: (off: number, backOn: number) => + `${off} herramienta${off === 1 ? '' : 's'} desactivada${off === 1 ? '' : 's'}, ${backOn === 0 ? 'ninguna' : backOn} reactivada${backOn === 1 ? '' : 's'}`, + discard: 'Descartar', + save: 'Guardar cambios', + saving: 'Guardando...' + }, + vocabulary: { + facetRead: { + label: 'Lectura', + long: 'Lee datos de esta app. No cambia nada.' + }, + facetWrite: { + label: 'Escritura', + long: 'Crea o cambia algo en esta app.' + }, + facetDestructive: { + label: 'Destructiva', + long: 'Puede eliminar algo de esta app de forma definitiva.' + }, + facetUnclassified: { + label: 'Efecto desconocido', + long: 'La app nunca indicó qué hace esta herramienta.' + }, + hintReadOnly: { + label: 'Solo lectura', + long: 'La herramienta declara que solo lee.' + }, + hintCreate: { + label: 'Crea', + long: 'Crea algo nuevo.' + }, + hintUpdate: { + label: 'Actualiza', + long: 'Cambia algo que ya existe.' + }, + hintDelete: { + label: 'Elimina', + long: 'Quita algo.' + }, + hintDestructive: { + label: 'Destructiva', + long: 'El cambio que hace no se puede deshacer aquí.' + }, + hintIdempotent: { + label: 'Repetible', + long: 'Ejecutarla dos veces hace lo mismo que ejecutarla una vez.' + }, + hintOpenWorld: { + label: 'Externa', + long: 'Llega a algo fuera de esta app.' + } + } + }, + sessionImport: { + title: 'Continuar desde otra app', + subtitle: 'Trae una conversación a Hermes y retómala donde la dejaste.', + action: 'Importar sesión', + readingFrom: 'Leyendo desde', + connectedComputer: 'el equipo conectado', + destination: 'Importar a', + all: 'Todas', + search: 'Buscar en las sesiones cargadas', + scanning: 'Buscando conversaciones', + scanError: 'No se pudieron encontrar sesiones', + scanHelp: + 'Comprueba la conexión con el backend e inténtalo de nuevo. Los backends antiguos pueden necesitar una actualización.', + empty: 'No se encontraron conversaciones', + emptyHelp: 'Aquí aparecerán las sesiones de Claude Code y Codex de este backend.', + noMatches: 'No hay conversaciones que coincidan', + searchHelp: 'Prueba con otro título o carpeta, o carga más sesiones.', + skipped: 'Algunos registros estaban vacíos, no se podían leer o eran demasiado grandes para previsualizarlos.', + more: 'Cargar más sesiones', + messages: 'mensajes', + choose: 'Una conversación que vale la pena continuar', + chooseHelp: 'Elige una sesión para leer su historial antes de traerla a Hermes.', + previewLoading: 'Abriendo la vista previa', + previewError: 'Vista previa no disponible', + previewHelp: 'Es posible que el origen se haya movido o cambiado. Actualiza la lista e inténtalo de nuevo.', + previewLimit: 'Vista previa acortada para facilitar la lectura. Se importa la conversación completa.', + you: 'Tú', + snapshot: 'Esta conversación ya está en Hermes. Abre tu copia existente para continuar.', + copyNotice: + 'Copia el texto de la conversación. Los archivos de origen no cambian. La salida de herramientas y el razonamiento no se trasladan.', + importing: 'Importando…', + open: 'Abrir en Hermes', + continue: 'Continuar en Hermes', + importError: 'No se pudo importar esta conversación.' + }, + common: { + apply: 'Aplicar', + back: 'Atrás', + save: 'Guardar', + saving: 'Guardando…', + cancel: 'Cancelar', + change: 'Cambiar', + choose: 'Elegir', + clear: 'Limpiar', + close: 'Cerrar', + collapse: 'Contraer', + confirm: 'Confirmar', + connect: 'Conectar', + connecting: 'Conectando', + continue: 'Continuar', + bots: 'Bots', + copied: 'Copiado', + copy: 'Copiar', + copyFailed: 'No se pudo copiar', + delete: 'Eliminar', + docs: 'Docs', + done: 'Listo', + error: 'Error', + expand: 'Expandir', + failed: 'Falló', + formatJson: 'Formatear JSON', + free: 'Gratis', + loading: 'Cargando…', + notSet: 'Sin definir', + refresh: 'Actualizar', + remove: 'Quitar', + replace: 'Reemplazar', + retry: 'Reintentar', + run: 'Ejecutar', + send: 'Enviar', + set: 'Definir', + skip: 'Omitir', + update: 'Actualizar', + tryHint: term => `Prueba “${term}”`, + on: 'Activado', + off: 'Desactivado' + }, + fileMenu: { + revealFinder: 'Mostrar en Finder', + revealExplorer: 'Mostrar en el Explorador de archivos', + revealFileManager: 'Abrir carpeta contenedora', + revealInSidebar: 'Mostrar en el árbol de archivos', + copyPath: 'Copiar ruta', + copyRelativePath: 'Copiar ruta relativa', + download: 'Descargar', + downloadSaved: 'Guardado', + downloadFailed: 'Error al descargar', + rename: 'Cambiar nombre…', + delete: 'Eliminar', + renameTitle: 'Cambiar nombre', + renameLabel: 'Nuevo nombre', + deleteTitle: name => `¿Eliminar ${name}?`, + deleteBody: 'Se moverá a la Papelera; podrás restaurarlo desde allí.', + pathCopied: 'Ruta copiada', + revealMissing: 'Esa carpeta no está en este equipo', + revealUnavailable: + 'Esa ruta no está en este equipo: está en la máquina del backend. Usa “Mostrar en el árbol de archivos”.' + }, + boot: { + ready: 'Hermes Desktop está listo', + desktopBootFailedWithMessage: message => `Falló el arranque del escritorio: ${message}`, + steps: { + connectingGateway: 'Conectando el gateway de escritorio en vivo', + loadingSettings: 'Cargando la configuración de Hermes', + loadingSessions: 'Cargando sesiones recientes', + retryingRemoteBackend: 'Reconectando al backend remoto de Hermes…', + startingDesktopConnection: 'Iniciando la conexión de escritorio', + startingHermesDesktop: 'Iniciando Hermes Desktop…' + }, + errors: { + backgroundExited: + 'El servicio que ejecuta tus chats se cerró de forma inesperada. Reinícialo para continuar; tus chats y ajustes están a salvo.', + backgroundExitedDuringStartup: 'Hermes se detuvo justo después de iniciarse.', + backendStopped: 'Hermes dejó de funcionar en segundo plano', + restartHermes: 'Reiniciar Hermes', + openLogs: 'Abrir registros', + desktopBootFailed: 'Hermes no pudo iniciarse', + gatewayConnectionLost: 'Hermes perdió la conexión', + gatewayConnectionLostDetail: + 'Seguimos intentando reconectar. Puedes seguir leyendo y escribiendo borradores. Si continúa, reconecta ahora o revisa los ajustes de conexión.', + reconnectNow: 'Reconectar ahora', + connectionSettings: 'Configuración de conexión', + gatewaySignInRequired: 'Tu Hermes remoto cerró tu sesión', + gatewaySignInRequiredDetail: 'Vuelve a iniciar sesión para reconectar. Tus chats y ajustes están a salvo.', + signInAgain: 'Volver a iniciar sesión', + ipcBridgeUnavailable: 'Hermes Desktop no pudo comunicarse con su propia capa en segundo plano. Reinicia la app.' + }, + causes: { + exitedEarly: 'El servicio en segundo plano de Hermes se detuvo justo después de iniciarse.', + timedOut: 'El servicio en segundo plano de Hermes no respondió a tiempo.', + permission: 'Hermes no pudo escribir en su carpeta de datos (problema de permisos).', + diskFull: 'El disco está lleno, así que Hermes no pudo iniciarse.', + portInUse: 'Otro programa está usando el puerto de red que necesita Hermes.', + installMissing: 'Falta parte de la instalación de Hermes. Elige Reparar instalación para restaurarla.' + }, + failure: { + title: 'Hermes no pudo iniciarse', + description: + 'El servicio en segundo plano de Hermes no arrancó. Prueba uno de los pasos de recuperación de abajo. Nada de esto elimina tus chats ni tus ajustes.', + details: 'Detalles', + remoteTitle: 'Se requiere iniciar sesión en el gateway remoto', + remoteDescription: + 'Tu sesión del gateway remoto caducó. Inicia sesión de nuevo para reconectar. Esto no elimina tus chats ni tu configuración.', + retry: 'Reintentar', + repairInstall: 'Reparar instalación', + useLocalGateway: 'Usar gateway local', + gatewaySettings: 'Configuración del gateway', + back: 'Atrás', + openLogs: 'Abrir registros', + repairHint: 'La reparación vuelve a ejecutar el instalador y puede tardar unos minutos en una máquina nueva.', + remoteSignInHint: signInLabel => + `Cierra la sesión guardada del navegador remoto y abre ${signInLabel}. Usa el gateway local para cambiar al backend incluido.`, + signOutAndSignIn: 'Cerrar sesión e iniciar sesión', + remoteFailureHint: 'Revisa la URL e inicia sesión en Configuración del gateway, o cambia al gateway local.', + cloudDownTitle: 'El agente de Nous Cloud no está disponible', + cloudDownDescription: + 'El agente en la nube administrado por Nous al que se conecta este gateway devuelve un error de servidor. No se puede reiniciar desde aquí: revisa su estado, cambia al gateway local o pide ayuda.', + cloudDownHint: + 'Los botones de abajo abren el Nous Portal (estado y controles de la instancia) y nuestro Discord para obtener ayuda.', + cloudDownCheckPortal: 'Ver el estado en el Portal', + cloudDownDiscord: 'Pedir ayuda en Discord', + hideRecentLogs: 'Ocultar registros recientes', + showRecentLogs: 'Mostrar registros recientes', + signedInTitle: 'Sesión iniciada', + signedInMessage: 'Reconectando con el gateway remoto…', + signInIncompleteTitle: 'Inicio de sesión incompleto', + signInIncompleteMessage: 'La ventana de inicio de sesión se cerró antes de que terminara la autenticación.', + signInFailed: 'No se pudo iniciar sesión', + signInToRemoteGateway: 'Iniciar sesión en el gateway remoto', + signInWithProvider: provider => `Iniciar sesión con ${provider}`, + identityProvider: 'tu proveedor de identidad' + } + }, + notifications: { + region: 'Notificaciones', + hide: 'Ocultar', + show: 'Mostrar', + more: count => `${count} ${count === 1 ? 'notificación más' : 'notificaciones más'}`, + clearAll: 'Borrar todo', + dismiss: 'Descartar notificación', + details: 'Detalles', + copyDetail: 'Copiar detalle', + copyDetailFailed: 'No se pudo copiar el detalle de la notificación', + backendOutOfDateTitle: 'Backend desactualizado', + backendOutOfDateMessage: + 'Tu backend de Hermes es más antiguo que esta compilación de escritorio y puede no funcionar correctamente. Actualízalo para alinearlos.', + installMethodUnsupportedTitle: 'Método de instalación no compatible', + updateHermes: 'Actualizar Hermes', + updateReadyTitle: 'Actualización lista', + updateReadyMessage: count => `${count} ${count === 1 ? 'cambio nuevo disponible' : 'cambios nuevos disponibles'}.`, + updateReadyMessageUnknown: 'Hay una nueva actualización disponible.', + seeWhatsNew: 'Ver novedades', + mcp: { + needsAuthTitle: 'El servidor MCP necesita re-autenticación', + needsAuthMessage: name => `${name} MCP necesita re-autenticación.`, + errorTitle: 'Servidor MCP inalcanzable', + errorMessage: name => `${name} MCP falló su verificación de salud.`, + signIn: 'Iniciar sesión', + view: 'Ver', + disable: 'Desactivar', + disabledMessage: name => `MCP ${name} desactivado. Vuelve a activarlo cuando quieras desde Capacidades → MCP.`, + disableFailed: name => `No se pudo desactivar el MCP ${name}.` + }, + errors: { + elevenLabsNeedsKey: 'La entrada de voz necesita una clave de ElevenLabs. Añade una en Configuración → Claves.', + elevenLabsRejectedKey: + 'ElevenLabs no aceptó tu clave API. Actualízala en Configuración → Claves e inténtalo de nuevo.', + diskFull: 'Disco lleno — libera espacio y vuelve a intentarlo.', + storageFailure: 'Hermes no pudo guardar en su carpeta de datos. Abre Mantenimiento para comprobarla y repararla.', + gatewayAuthFailed: + 'Este Hermes ya no acepta tu inicio de sesión guardado. Abre Gateways y vuelve a iniciar sesión (o pega un nuevo token de acceso) e inténtalo otra vez.', + methodNotAllowed: + 'El servicio en segundo plano de Hermes no está sincronizado con la app, probablemente tras una actualización. Reinícialo para solucionarlo.', + microphonePermission: 'Se denegó el permiso del micrófono.', + openaiRejectedApiKey: + 'OpenAI no aceptó tu clave API. Actualízala en Configuración → Claves e inténtalo de nuevo.', + openaiTtsNeedsKey: 'La voz necesita una clave de OpenAI. Añade una en Configuración → Claves.', + codeSkewRestartRequired: + 'Hermes se actualizó, pero sigue ejecutando la versión anterior. Reinícialo para terminar la actualización.', + rpcOutOfSync: 'La app y el backend están en versiones distintas. Actualiza ambos.', + restartHermesFailed: 'No se pudo reiniciar Hermes' + }, + actions: { + restartHermes: 'Reiniciar Hermes', + openKeys: 'Abrir Claves', + openGateways: 'Abrir Gateways', + openMaintenance: 'Abrir Mantenimiento' + }, + voice: { + configureSpeechToText: 'Configura voz a texto para usar el modo de voz.', + couldNotStartSession: 'No se pudo iniciar la sesión de voz', + microphoneAccessDenied: 'Acceso al micrófono denegado.', + microphoneConstraintsUnsupported: 'Este dispositivo no admite las restricciones del micrófono.', + microphoneFailed: 'Falló el micrófono', + microphoneInUse: 'El micrófono ya está en uso por otra app.', + microphonePermissionDenied: 'Se denegó el permiso del micrófono.', + microphoneStartFailed: 'No se pudo iniciar la grabación del micrófono.', + microphoneUnsupported: 'Este runtime no admite grabación de micrófono.', + noMicrophone: 'No se encontró ningún micrófono.', + noSpeechDetected: 'No se detectó voz', + playbackFailed: 'Falló la reproducción de voz', + recordingFailed: 'Falló la grabación de voz', + sayStopToEnd: phrase => `Di "${phrase}" para terminar el chat de voz.`, + transcriptionFailed: 'Falló la transcripción de voz', + transcriptionUnavailable: 'La transcripción de voz aún no está disponible.', + tryRecordingAgain: 'Prueba a grabar de nuevo.', + unavailable: 'Voz no disponible', + liveEnded: 'Sesión de voz en vivo finalizada', + liveEndedConnectionLost: 'La sesión de voz en vivo perdió la conexión.', + liveEndedClosed: 'El servicio cerró la sesión de voz en vivo.', + liveError: 'Voz en vivo', + liveDelegationFailed: 'No se pudo pasar la solicitud a Hermes', + liveUnavailable: reason => + `El chat de voz GPT-Live no está disponible: ${reason}. Se usará voz a texto en su lugar.` + }, + native: { + approvalTitle: 'Se necesita aprobación', + approvalTitleNamed: session => `Se necesita aprobación — ${session}`, + approveAction: 'Aprobar', + rejectAction: 'Rechazar', + inputTitle: 'Se necesita información', + inputTitleNamed: session => `Se necesita una respuesta — ${session}`, + inputBody: 'Hermes espera tu respuesta.', + turnDoneTitle: 'Hermes terminó', + turnDoneBody: '', + turnErrorTitle: 'El turno falló', + backgroundDoneTitle: 'Tarea en segundo plano finalizada', + backgroundFailedTitle: 'La tarea en segundo plano falló', + creditsTitle: 'Créditos' + } + }, + remoteDisplayBanner: { + message: reason => + `Renderizado por software activo — se detectó una pantalla remota (${reason}). Se desactivó la aceleración por GPU para evitar parpadeos.` + }, + billingBlock: { + titleNous: 'Sin créditos de Nous', + titleProvider: provider => `Sin créditos — ${provider}`, + fallbackMessage: 'Tu cuenta se quedó sin créditos. Añade créditos para continuar.', + openBilling: 'Abrir facturación', + addCredits: 'Añadir créditos', + dismiss: 'Descartar' + }, + sendDiagnostics: { + title: 'Enviar diagnóstico a Nous', + privacyNotice: + 'Esto sube un paquete de depuración a un almacenamiento interno de Nous (no a un sitio público). Incluye información del sistema (SO, versiones, proveedor y qué claves API están configuradas, nunca las claves en sí) y los registros completos del agente, del gateway y de la app de escritorio (hasta 512 KB cada uno), que probablemente contengan contenido de conversaciones, salidas de herramientas y rutas de archivos. Los secretos se ocultan antes de subirlo. Solo el personal de Nous y los moderadores autorizados de Discord pueden ver el paquete, que se elimina automáticamente a los 14 días.', + upload: 'Subir', + uploading: 'Subiendo…', + cancel: 'Cancelar', + close: 'Cerrar', + copyLink: 'Copiar enlace', + uploadIdFallback: id => `No se devolvió un enlace para verlo: indica el ID de subida ${id} al equipo de soporte`, + doneTitle: 'Diagnóstico enviado', + doneDescription: + 'Tu paquete se subió de forma privada. Comparte el enlace de abajo en tu hilo de soporte para que el equipo pueda ver tus registros.', + failedTitle: 'Error al subir', + failedHint: + 'También puedes ejecutar `hermes debug share --nous` desde una terminal, o `hermes debug share --local` para mostrar el informe sin subirlo.', + handoffLead: 'Continúa la conversación en:', + links: { + github: 'Issues de GitHub', + portal: 'Soporte de Nous Portal', + discord: 'Discord' + } + }, + titlebar: { + hideSidebar: 'Ocultar barra lateral', + showSidebar: 'Mostrar barra lateral', + search: 'Buscar', + searchTitle: 'Buscar sesiones, vistas y acciones', + swapSidebarSides: 'Intercambiar lados de las barras laterales', + hideRightSidebar: 'Ocultar barra lateral derecha', + showRightSidebar: 'Mostrar barra lateral derecha', + unreadSessions: count => (count === 1 ? '1 sesión sin leer' : `${count} sesiones sin leer`), + muteHaptics: 'Silenciar háptica', + unmuteHaptics: 'Activar háptica', + openSettings: 'Abrir configuración', + openStarmap: 'Abrir grafo de memoria', + enterHud: 'Modo HUD', + exitHud: 'Salir del modo HUD', + resetHudLayout: 'Restablecer el tamaño y la posición del HUD', + layoutEditor: 'Editor de diseño', + layoutEditorTitle: mod => `Editor de diseño — clic ${mod} restablece el diseño` + }, + keybinds: { + title: 'Atajos de teclado', + subtitle: open => `Haz clic en un atajo para reasignarlo · ${open} vuelve a abrir este panel.`, + search: 'Buscar atajos…', + rebind: 'Reasignar', + reset: 'Restablecer predeterminado', + resetAll: 'Restablecer todo', + pressKey: 'Pulsa una tecla…', + set: 'definido', + conflictWith: label => `También asignado a “${label}”`, + categories: { + composer: 'Compositor', + profiles: 'Perfiles', + session: 'Sesión', + navigation: 'Navegación', + view: 'Vista' + }, + actions: { + 'keybinds.openPanel': 'Abrir atajos de teclado', + 'nav.commandPalette': 'Abrir paleta de comandos', + 'nav.commandCenter': 'Abrir Centro de comandos', + 'nav.settings': 'Abrir configuración', + 'nav.profiles': 'Abrir perfiles', + 'nav.capabilities': 'Abrir skills', + 'nav.messaging': 'Abrir mensajería', + 'nav.artifacts': 'Abrir artefactos', + 'nav.cron': 'Abrir tareas programadas', + 'nav.agents': 'Abrir agentes', + 'session.new': 'Nueva sesión', + 'session.newTab': 'Nueva pestaña de sesión', + 'session.newWindow': 'Nueva ventana', + 'session.next': 'Siguiente sesión', + 'session.prev': 'Sesión anterior', + 'session.slot.1': 'Cambiar a la sesión reciente 1', + 'session.slot.2': 'Cambiar a la sesión reciente 2', + 'session.slot.3': 'Cambiar a la sesión reciente 3', + 'session.slot.4': 'Cambiar a la sesión reciente 4', + 'session.slot.5': 'Cambiar a la sesión reciente 5', + 'session.slot.6': 'Cambiar a la sesión reciente 6', + 'session.slot.7': 'Cambiar a la sesión reciente 7', + 'session.slot.8': 'Cambiar a la sesión reciente 8', + 'session.slot.9': 'Cambiar a la sesión reciente 9', + 'session.focusSearch': 'Buscar sesiones', + 'session.togglePin': 'Fijar / desfijar sesión actual', + 'session.archive': 'Archivar la sesión actual', + 'workspace.newWorktree': 'Nuevo worktree', + 'workspace.openFolder': 'Abrir carpeta como proyecto', + 'composer.focus': 'Enfocar compositor', + 'composer.modelPicker': 'Abrir selector de modelo', + 'composer.voice': 'Iniciar / detener conversación por voz', + 'view.toggleSidebar': 'Alternar barra lateral de sesiones', + 'view.cycleSidebarGrouping': 'Cambiar la agrupación de sesiones', + 'view.toggleRightSidebar': 'Alternar explorador de archivos', + 'view.toggleReview': 'Alternar panel de revisión', + 'view.toggleStatusbar': 'Alternar barra de estado', + 'view.toggleTabStrip': 'Mostrar u ocultar pestañas', + 'view.toggleProfileRail': 'Mostrar u ocultar la barra de perfiles', + 'view.toggleSimpleMode': 'Activar o desactivar el modo simple', + 'view.showFiles': 'Mostrar explorador de archivos', + 'view.showBrowser': 'Abrir el navegador', + 'view.toggleHud': 'Alternar modo HUD', + 'hud.snapToPointer': 'Mover HUD al puntero (global, mientras el HUD esté abierto)', + 'view.showTerminal': 'Mostrar terminal', + 'view.newTerminal': 'Nuevo terminal', + 'view.nextTerminal': 'Siguiente terminal', + 'view.prevTerminal': 'Terminal anterior', + 'view.closeTerminal': 'Cerrar terminal', + 'view.selectionToComposer': 'Enviar la selección al compositor', + 'view.terminalCopy': 'Copiar selección de terminal', + 'view.terminalPaste': 'Pegar en terminal', + 'view.closeTab': 'Cerrar pestaña', + 'view.reopenTab': 'Reabrir pestaña cerrada', + 'view.flipPanes': 'Intercambiar lados de las barras laterales', + 'view.findInPage': 'Buscar en la página', + 'view.findNext': 'Siguiente coincidencia', + 'view.findPrevious': 'Coincidencia anterior', + 'appearance.toggleMode': 'Alternar claro / oscuro', + 'profile.default': 'Cambiar al perfil predeterminado', + 'profile.switch.1': 'Cambiar al perfil 1', + 'profile.switch.2': 'Cambiar al perfil 2', + 'profile.switch.3': 'Cambiar al perfil 3', + 'profile.switch.4': 'Cambiar al perfil 4', + 'profile.switch.5': 'Cambiar al perfil 5', + 'profile.switch.6': 'Cambiar al perfil 6', + 'profile.switch.7': 'Cambiar al perfil 7', + 'profile.switch.8': 'Cambiar al perfil 8', + 'profile.switch.9': 'Cambiar al perfil 9', + 'profile.switch.10': 'Cambiar al perfil 10', + 'profile.switch.11': 'Cambiar al perfil 11', + 'profile.switch.12': 'Cambiar al perfil 12', + 'profile.switch.13': 'Cambiar al perfil 13', + 'profile.switch.14': 'Cambiar al perfil 14', + 'profile.switch.15': 'Cambiar al perfil 15', + 'profile.switch.16': 'Cambiar al perfil 16', + 'profile.switch.17': 'Cambiar al perfil 17', + 'profile.switch.18': 'Cambiar al perfil 18', + 'profile.next': 'Siguiente perfil', + 'profile.prev': 'Perfil anterior', + 'profile.toggleAll': 'Alternar vista de todos los perfiles', + 'profile.create': 'Crear perfil', + 'composer.send': 'Enviar mensaje', + 'composer.newline': 'Insertar salto de línea', + 'composer.steer': 'Guiar el turno en ejecución', + 'composer.queue': 'Poner mensaje en cola', + 'composer.sendQueued': 'Enviar el siguiente turno en cola', + 'composer.mention': 'Referenciar archivos, carpetas y URL', + 'composer.slash': 'Paleta de comandos slash', + 'composer.help': 'Ayuda rápida', + 'composer.history': 'Recorrer popover / historial', + 'composer.cancel': 'Cerrar popover · cancelar ejecución' + } + }, + findInPage: { + next: 'Siguiente coincidencia', + previous: 'Coincidencia anterior' + }, + language: { + label: 'Idioma', + description: 'Elige el idioma de la interfaz de escritorio.', + saving: 'Guardando idioma…', + saveError: 'No se pudo actualizar el idioma', + switchTo: 'Cambiar idioma', + searchPlaceholder: 'Buscar idiomas…', + noResults: 'No se encontraron idiomas' + }, + settings: { + subpages: { + appearanceTheme: 'Tema', + appearanceTypography: 'Tipografía', + appearanceWindowLayout: 'Ventana y diseño', + appearanceChatDisplay: 'Visualización del chat', + appearancePet: 'Mascota', + appearanceGeneral: 'General', + modelMain: 'Modelo principal', + modelAuxiliary: 'Modelos auxiliares', + modelMoa: 'Mixture of Agents', + modelFallbacks: 'Modelos de respaldo', + chatBehavior: 'Comportamiento', + chatAttachments: 'Adjuntos', + workspaceProjects: 'Proyectos y detección', + workspaceShell: 'Entorno de shell', + workspaceFiles: 'Archivos y ejecución', + safetyApprovals: 'Aprobaciones', + safetyPrivacy: 'Privacidad y red', + safetyCheckpoints: 'Puntos de control', + browserProfile: 'Perfil del navegador', + browserNetwork: 'URL locales y privadas', + memoryPersistent: 'Memoria persistente', + memoryContext: 'Contexto y compresión', + voiceConversation: 'Conversación por voz', + voiceTranscription: 'Voz a texto', + voiceSpeech: 'Texto a voz', + advancedRuntime: 'Límites del agente', + advancedTools: 'Acceso a herramientas', + advancedTerminal: 'Backend del terminal', + advancedOutput: 'Límites de salida', + advancedDelegation: 'Subagentes', + advancedDesktop: 'Escritorio e inicio', + gatewayConnection: 'Esta ventana', + gatewayDevices: 'Conexiones guardadas', + gatewayManagedUpdates: 'Actualizaciones remotas', + gatewayManagedUpdatesUnavailable: + 'Las actualizaciones remotas requieren una versión de escritorio compatible con actualizaciones SSH administradas.', + gatewayManagedUpdatesEmpty: + 'Añade una conexión SSH en Conexiones guardadas para gestionar sus actualizaciones aquí.', + keyboardShortcuts: 'Atajos de teclado', + hudGesture: 'Gesto del HUD', + screenCapture: 'Captura de pantalla', + notificationAlerts: 'Alertas de escritorio', + notificationSounds: 'Sonidos', + archivedSessions: 'Archivo y retención', + defaultDirectory: 'Carpeta de proyecto predeterminada', + vaultCredentials: 'Credenciales guardadas', + vaultSources: 'Gestores de contraseñas', + appUpdates: 'Versión y actualizaciones', + uninstall: 'Desinstalar', + billingOverview: 'Resumen', + billingPlans: 'Planes' + }, + closeSettings: 'Cerrar configuración', + exportConfig: 'Exportar configuración', + importConfig: 'Importar configuración', + resetToDefaults: 'Restablecer valores predeterminados', + resetConfirm: '¿Restablecer toda la configuración a los valores predeterminados de Hermes?', + exportFailed: 'Falló la exportación', + resetFailed: 'Falló el restablecimiento', + nav: { + providers: 'Proveedores', + providerAccounts: 'Cuentas', + providerApiKeys: 'Claves API', + providerCustomEndpoints: 'Endpoints personalizados', + providerLocalModels: 'Modelos locales', + gateway: 'Gateway', + apiKeys: 'Herramientas y claves', + keybinds: 'Atajos de teclado', + keysTools: 'Herramientas', + keysSettings: 'Configuración', + mcp: 'MCP', + archivedChats: 'Chats archivados', + about: 'Acerca de', + billing: 'Facturación', + notifications: 'Notificaciones', + vault: 'Contraseñas e inicios de sesión' + }, + plugins: { + title: 'Plugins de escritorio', + blurb: + 'Amplía esta app, no un agente: se instala una sola vez para toda la app, sea cual sea el perfil, gateway o equipo al que te conectes. Incluidos o copiados en la carpeta desktop-plugins; los interruptores se aplican al instante.', + count: n => `${n} instalados`, + openFolder: 'Abrir la carpeta de plugins de escritorio', + rescan: 'Volver a buscar', + reveal: 'Mostrar en el gestor de archivos', + enable: 'Activar', + disable: 'Desactivar', + failed: 'falló', + empty: 'Aún no hay plugins de escritorio instalados.', + kinds: { + bundled: 'incluido', + disk: 'en disco', + runtime: 'en ejecución' + }, + agentHalfMissing: 'falta la parte del agente aquí', + agentHalfMissingTip: + 'Esta es la parte de escritorio de un plugin incluido, pero su parte del agente no está instalada en el backend o perfil conectado. Instálala desde Capacidades → Plugins.', + installModal: { + installFromGit: 'Instalar desde Git', + reviewRepository: 'Revisar repositorio', + repoPlaceholder: 'https://github.com/propietario/repo', + title: 'Instalar plugin', + description: 'Revisa qué contiene este repositorio antes de instalar nada.', + repoLabel: 'Repositorio', + includesHeading: 'Este paquete incluye', + agentLabel: 'Plugin del agente', + desktopLabel: 'Interfaz de escritorio', + profileLabel: 'Instalar para el perfil', + agentTargetLocal: (profile, dir) => `Se instala en el backend ${profile} (${dir})`, + agentTargetRemote: profile => `Se instala en el backend ${profile} conectado`, + catalogPinned: (name, sha) => + `Entrada del catálogo de Hermes “${name}”: el componente del agente se instala en la versión fijada revisada${sha ? ` ${sha}` : ''}, no en la punta de la rama.`, + reviewedHeading: 'Entrada del catálogo revisada', + reviewedIntro: + 'Una persona revisó esta entrada en su commit fijado. Aun así, puedes inspeccionar el código exacto abajo.', + toolsConnected: n => (n === 1 ? '1 herramienta conectada' : `${n} herramientas conectadas`), + skillsReady: names => (names.length === 1 ? `skill ${names[0]} lista` : `${names.length} skills listas`), + nextChat: 'más herramientas disponibles en tu próximo chat', + serverNotConnected: (server, reason) => + `El servidor MCP ${server} no está conectado${reason ? `: ${reason}` : '.'}`, + missingEnvAction: 'Configurarlo', + alreadyInstalled: (name: string) => `${name} ya está instalado.`, + desktopTarget: 'Se instala en la carpeta local desktop-plugins de esta app', + desktopTargetFromPackage: 'Se carga en esta app desde el paquete de arriba; es igual para todos los perfiles', + desktopOnlyNote: 'Los paquetes solo de escritorio no instalan un plugin del agente en el backend.', + insecureWarning: + 'Esta URL usa un esquema inseguro o local. Para instalaciones de producción, usa https:// o git@.', + securityHeading: 'Antes de instalar', + securityIntro: + 'Instala solo desde fuentes de confianza; revisa el repositorio de abajo si quieres ver qué se añadirá.', + sourceHeading: 'Código fuente', + viewRepository: 'Ver repositorio', + viewPluginFiles: 'Ver archivos del plugin', + gitCloneLabel: 'URL de git clone', + enableAgent: 'Activar el plugin del agente tras instalarlo', + forceReinstall: 'Forzar reinstalación (reemplazar si ya está instalado)', + pinToCommit: 'Fijar a un commit (opcional)', + pinToCommitPlaceholder: 'SHA de commit completo de 40 caracteres', + pinToCommitHint: + 'Todos los que instalen este SHA obtienen el mismo código; después, el plugin rechaza actualizaciones hasta que se vuelva a fijar. Déjalo vacío para usar el último commit.', + pinToCommitInvalid: 'Debe ser un SHA de commit completo de 40 caracteres (no se aceptan ramas ni etiquetas).', + install: 'Instalar', + installing: 'Instalando…', + probing: 'Inspeccionando el repositorio…', + probeUnavailable: 'La inspección de plugins no está disponible en este entorno.', + desktopUnavailable: 'La instalación de plugins de escritorio no está disponible en este entorno.', + selectComponent: 'Selecciona al menos un componente para instalar.', + agentSuccess: name => `Plugin del agente ${name} instalado`, + desktopSuccess: name => `Plugin de escritorio ${name} instalado`, + agentFailed: 'Error al instalar el plugin del agente', + desktopFailed: 'Error al instalar el plugin de escritorio', + missingEnv: (name, vars) => + `${name} está instalado, pero necesita una clave para funcionar: ${vars}. Añádela ahora o las herramientas del plugin fallarán.` + } + }, + vault: { + title: 'Contraseñas e inicios de sesión', + blurb: + 'Di “inicia sesión en GitHub” y el agente lo hará por ti. La primera vez que encuentre una página de inicio de sesión, te pedirá los datos ahí mismo; después, simplemente funcionará. Las contraseñas se cifran en este equipo y se introducen directamente en la página: el modelo nunca las ve.', + count: n => `${n} guardado${n === 1 ? '' : 's'}`, + loadFailed: 'No se pudieron cargar los elementos guardados', + empty: 'Todavía no hay nada guardado', + emptyDesc: + 'No necesitas añadir nada aquí. Pide al agente que inicie sesión en un sitio y te pedirá los datos una vez, en ese momento. Usa Añadir si prefieres introducirlos de antemano.', + add: 'Añadir', + addTitle: 'Añadir un inicio de sesión, una tarjeta o una dirección', + addDescription: 'Se guarda cifrado en este equipo. El agente nunca ve la contraseña.', + added: 'Guardado.', + adding: 'Guardando…', + addConfirm: 'Guardar', + kindField: 'Tipo', + kinds: { + login: 'Inicio de sesión', + payment: 'Tarjeta de pago', + address: 'Dirección' + }, + labelField: 'Etiqueta', + labelPlaceholder: 'p. ej., cuenta de trabajo de GitHub', + labelRequired: 'La etiqueta es obligatoria.', + originField: 'Origen del sitio', + originPlaceholder: 'https://github.com', + originPlaceholderCheckout: 'https://tienda.example.com', + originInvalid: 'Introduce una URL válida, como https://example.com.', + identifierTypeField: 'Tipo de identificador', + identifierTypes: { + email: 'Correo electrónico', + phone: 'Teléfono', + username: 'Nombre de usuario' + }, + identifierField: 'Identificador', + identifierShown: identifier => identifier, + passwordField: 'Contraseña', + loginFieldsRequired: 'El identificador y la contraseña son obligatorios.', + cardNumberField: 'Número de tarjeta', + cardNameField: 'Nombre en la tarjeta', + expMonthField: 'Mes de venc.', + expYearField: 'Año de venc.', + cvcField: 'CVC', + postalField: 'Código postal', + addressLine1Field: 'Dirección, línea 1', + addressLine2Field: 'Dirección, línea 2', + cityField: 'Ciudad', + stateField: 'Estado / región', + countryField: 'País', + optional: '(opcional)', + createdOn: date => `Añadido el ${date}`, + deleteAction: 'Quitar elemento guardado', + otpField: 'Clave del autenticador', + otpPlaceholder: 'Secreto Base32 o enlace otpauth://', + otpHint: + 'La “clave de configuración” que muestra el sitio al activar la 2FA. Si la guardas, Hermes genera los códigos por sí mismo.', + twoFactorBadge: '2FA automática', + deleteTitle: '¿Eliminar este elemento?', + deleteDescription: label => `Se quitará “${label}”. Esto no se puede deshacer.`, + deleteConfirm: 'Eliminar', + sources: { + title: 'Gestores de contraseñas', + blurb: + 'Los gestores de contraseñas instalados se detectan automáticamente. El agente te pide desbloquear uno la primera vez que necesita un inicio de sesión de él (una vez por sesión); solo se guarda en memoria un token de sesión, y el agente nunca ve tu contraseña maestra ni ningún inicio de sesión.', + toggleFailed: 'No se pudo actualizar el gestor de contraseñas', + notInstalled: name => + `No detectado. Instala la herramienta de línea de comandos de ${name} e inicia sesión en ella; Hermes la detectará automáticamente.`, + disabledDesc: 'Detectado, pero desactivado para Hermes.', + lockedDesc: + 'Detectado. El agente te pedirá desbloquearlo cuando necesite un inicio de sesión, o puedes desbloquearlo ahora.', + unlockedDesc: + 'Desbloqueado para esta sesión. Se bloquea automáticamente tras 30 minutos de inactividad o al cerrar Hermes.', + statusLocked: 'Bloqueado', + statusNotDetected: 'No detectado', + statusOff: 'Desactivado', + statusUnlocked: 'Desbloqueado', + unlock: 'Desbloquear', + unlocking: 'Desbloqueando…', + lock: 'Bloquear', + unlocked: name => `${name} desbloqueado para esta sesión.`, + unlockTitle: name => `Desbloquear ${name}`, + unlockDescription: + 'Introduce tu contraseña maestra. Se entrega al gestor de contraseñas de este equipo y se descarta: nunca se guarda, se registra ni se muestra al agente.', + masterPasswordPlaceholder: 'Contraseña maestra' + } + }, + notifications: { + title: 'Notificaciones', + intro: 'Notificaciones del sistema operativo (no avisos dentro de la app). Por dispositivo.', + enableAll: 'Activar notificaciones', + enableAllDesc: 'Desactivado silencia todas las notificaciones siguientes.', + focusedHint: 'Los avisos de finalización solo se activan cuando Hermes está en segundo plano.', + kinds: { + approval: { + label: 'Se necesita aprobación', + description: 'Un comando espera que lo apruebes o rechaces.' + }, + input: { + label: 'Se necesita información', + description: 'Hermes hizo una pregunta o necesita una contraseña o un secreto.' + }, + turnDone: { + label: 'Respuesta lista', + description: 'Terminó un turno mientras Hermes estaba en segundo plano.' + }, + turnError: { + label: 'El turno falló', + description: 'Errores de turnos en segundo plano.' + }, + backgroundDone: { + label: 'Tarea en segundo plano finalizada', + description: 'Se completó un comando de terminal en segundo plano.' + }, + credits: { + label: 'Avisos de crédito', + description: 'El acceso a los créditos se pausa o se restablece.' + }, + plugin: { + label: 'Notificaciones de complementos', + description: 'Un complemento de escritorio envió una notificación mientras Hermes estaba en segundo plano.' + } + }, + test: 'Enviar notificación de prueba', + testTitle: 'Hermes', + testBody: 'Las notificaciones funcionan.', + testSent: + 'Prueba enviada. Si no aparece nada, revisa los permisos de notificaciones del sistema operativo y el modo Concentración o No molestar.', + testUnsupported: 'Este sistema no admite notificaciones nativas.', + completionSoundTitle: 'Sonido de finalización', + completionSoundDesc: + 'Reproduce un sonido cuando termina el turno de un agente. Elige un ajuste predefinido y pruébalo aquí.', + completionSoundPreview: 'Vista previa' + }, + sections: { + model: 'Modelo', + chat: 'Chat', + appearance: 'Apariencia', + workspace: 'Espacio de trabajo', + safety: 'Seguridad', + memory: 'Memoria y contexto', + voice: 'Voz', + advanced: 'Avanzado' + }, + searchPlaceholder: { + about: 'Acerca de Hermes Desktop', + config: 'Buscar configuración...', + gateway: 'Conexión del gateway...', + keys: 'Buscar claves API...', + mcp: 'Buscar servidores MCP...', + sessions: 'Buscar sesiones archivadas...' + }, + modeOptions: { + light: { + label: 'Claro', + description: 'Superficies de escritorio luminosas' + }, + dark: { + label: 'Oscuro', + description: 'Espacio de trabajo con menos brillo' + }, + system: { + label: 'Sistema', + description: 'Seguir la apariencia del SO' + } + }, + appearance: { + title: 'Apariencia', + intro: 'Solo escritorio. El modo es el brillo; el tema es la paleta y el marco del chat.', + colorMode: 'Modo de color', + colorModeDesc: 'Elige un modo fijo o deja que Hermes siga la configuración del sistema.', + toolViewTitle: 'Visualización de llamadas a herramientas', + toolViewDesc: 'Producto oculta las cargas útiles sin procesar; Técnico muestra entrada/salida completas.', + hideCodeDiffsTitle: 'Ocultar diffs de código', + hideCodeDiffsDesc: + 'Muestra las ediciones de archivos como filas de herramienta con el recuento de líneas añadidas/eliminadas, sin el código.', + hideThreadTimelineTitle: 'Ocultar las barras de la línea de tiempo', + hideThreadTimelineDesc: 'Oculta las barras de navegación del borde derecho de cada conversación.', + reasoningCollapsedTitle: 'Contraer el razonamiento por defecto', + reasoningCollapsedDesc: 'Mantiene disponible el razonamiento transmitido sin expandirlo hasta que lo abras.', + uiScaleTitle: 'Escala de la interfaz', + uiScaleDesc: percent => + `Escala el texto y los controles de toda la app. También funciona Cmd/Ctrl con +, - y 0. Actual: ${percent}%.`, + sessionDensityTitle: 'Densidad de la lista de sesiones', + sessionDensityDesc: 'Elige cuánto contexto aparece bajo los títulos de las sesiones en la barra lateral.', + sessionDensityCompact: 'Compacta', + sessionDensityComfortable: 'Cómoda', + sessionDensityDetailed: 'Detallada', + tabStripTitle: 'Barra de pestañas', + tabStripDesc: + 'Muestra pestañas encima de una zona. Auto las oculta si hay un solo panel, salvo que haya otra zona de chat o de mosaico abierta.', + tabStripAuto: 'Auto', + tabStripAlways: 'Siempre', + tabStripNever: 'Nunca', + appActionsTitle: 'Acciones de la app', + appActionsDesc: + 'Dónde se colocan Configuración, Diseño y HUD en la barra de título. A la derecha deja espacio para pestañas a la izquierda.', + appActionsLeft: 'Izquierda', + appActionsRight: 'Derecha', + terminalFontTitle: 'Fuente de terminal', + terminalFontDesc: + 'Elige una fuente instalada para las terminales del escritorio. Las Nerd Fonts renderizan Powerlevel10k y los iconos de shell; déjalo vacío para usar la JetBrains Mono incluida.', + terminalFontPlaceholder: 'MesloLGS NF o una pila de fuentes CSS', + terminalFontPreview: 'Vista previa de glifos', + terminalFontReset: 'Usar la predeterminada', + chatFontTitle: 'Fuente del chat', + chatFontDesc: + 'Elige una fuente instalada para el chat y el resto de la app. Útil para fuentes de lectura como OpenDyslexic; déjalo en blanco para usar la fuente del tema.', + chatFontPlaceholder: 'OpenDyslexic o una pila de fuentes CSS', + chatFontPreview: 'Vista previa', + chatFontSample: 'El veloz murciélago hindú comía feliz cardillo y kiwi. 0123456789', + chatFontReset: 'Usar la fuente del tema', + translucencyTitle: 'Translucidez de la ventana', + translucencyDesc: 'Verás tu escritorio a través de toda la ventana. Solo macOS y Windows.', + translucencyGlassDesc: + 'Vidrio mate: el escritorio se ve a través con un desenfoque suave mientras el texto se mantiene nítido. Ajustado por separado para claro y oscuro.', + translucencyModeClear: 'Transparente', + translucencyModeGlass: 'Vidrio', + translucencyTintTitle: 'Tinte', + translucencyFadeTitle: 'Atenuación', + translucencyFrostTitle: 'Escarcha', + translucencyFrost: { + 'under-window': 'Profunda', + popover: 'Suave', + titlebar: 'Brillante', + header: 'Resplandor' + }, + translucencyScopeTitle: 'Área', + translucencyScope: { + window: 'Toda la ventana', + sidebar: 'Solo la barra lateral' + }, + backdropTitle: 'Fondo del chat', + backdropDesc: 'La tenue imagen de la estatua detrás de la conversación.', + userBubbleTitle: 'Burbuja de mensaje', + userBubbleDesc: 'Cuánta transparencia tienen tus propios mensajes. Opaca en 0; en 100 solo queda el contorno.', + textDirectionTitle: 'Dirección del texto', + textDirectionDesc: + 'Cómo eligen su dirección los mensajes del chat y el campo de escritura. Auto sigue la primera letra de cada párrafo; elige una dirección cuando un texto mixto se alinee mal. El código siempre va de izquierda a derecha.', + textDirection: { auto: 'Auto', rtl: 'De derecha a izquierda', ltr: 'De izquierda a derecha' }, + introSplashTitle: 'Pantalla de bienvenida', + introSplashDesc: 'El logotipo y la indicación que se muestran en un chat vacío.', + reactionsTitle: 'Reacciones a mensajes', + reactionsDesc: + 'Reacciones emoji estilo iMessage — reacciona a los mensajes, y Hermes puede reaccionar a los tuyos.', + tipsTitle: 'Consejos en la app', + tipsDesc: + 'Sugerencias ocasionales de la app y de Hermes. Cada consejo aparece una vez. Se desactiva automáticamente tras tus primeros 30 días; puedes volver a activarlo.', + tipsReset: (count: number) => `Volver a mostrar ${count} ${count === 1 ? 'consejo' : 'consejos'}`, + toursTitle: 'Recorridos guiados', + toursDesc: + 'Deja que Hermes resalte cada paso mientras te guía por la app. Se desactiva automáticamente tras tus primeros 30 días; puedes volver a activarlo.', + composerPopoutTitle: 'Compositor flotante', + composerPopoutDesc: + 'Permite arrastrar el compositor fuera de su posición fija. Desactívalo para mantenerlo anclado abajo.', + vibeHeartsTitle: 'Corazones de vibra', + vibeHeartsDesc: + 'Corazones flotantes cuando dices gracias, te quiero, buen bot o envías un corazón. Independiente de las reacciones a mensajes de arriba.', + embedsTitle: 'Contenido incrustado', + embedsDesc: + 'Las vistas previas enriquecidas se cargan desde sitios de terceros (YouTube, X, …). Preguntar muestra un marcador de posición hasta que permitas cada una; Siempre las carga automáticamente; Desactivado conserva los enlaces simples.', + embedsAsk: 'Preguntar', + embedsAlways: 'Siempre', + embedsOff: 'Desactivado', + embedsReset: count => `Restablecer ${count} ${count === 1 ? 'servicio permitido' : 'servicios permitidos'}`, + resumeLastSessionTitle: 'Reabrir el último chat al iniciar', + resumeLastSessionDesc: + 'Si está activado, la app reabre tu chat más reciente al iniciarse en frío. Desactívalo para empezar siempre con un chat nuevo.', + product: 'Producto', + productDesc: 'Actividad de herramientas legible con resúmenes concisos.', + technical: 'Técnico', + technicalDesc: 'Incluye argumentos/resultados sin procesar y detalles de bajo nivel.', + themeTitle: 'Tema', + themeDesc: 'Paletas solo para escritorio. Se aplican sobre el modo seleccionado.', + themeSearchPlaceholder: 'Busca en tus temas o en el VS Code Marketplace…', + themeProfileNote: profile => `Guardado para el perfil ${profile}; cada perfil conserva su propio tema.`, + installTitle: 'Instalar desde VS Code', + installDesc: + 'Pega un ID de extensión de Marketplace (por ejemplo, dracula-theme.theme-dracula) para convertir su tema de color en una paleta de escritorio.', + installPlaceholder: 'publisher.extension', + installButton: 'Instalar', + installing: 'Instalando…', + installError: 'No se pudo instalar ese tema.', + installed: name => `Instalado “${name}”.`, + removeTheme: 'Quitar tema', + importedBadge: 'Importado', + pet: { + title: 'Mascota', + intro: + 'Adopta una mascota animada de petdex que flota sobre la app y reacciona a lo que hace Hermes: corre mientras se ejecutan herramientas, celebra los éxitos y se entristece con los errores.', + restartHint: + 'Las mascotas necesitan un reinicio rápido: la aplicación en ejecución se inició antes de que se añadiera esta función. Cierra y vuelve a abrir Hermes y luego vuelve aquí.', + on: 'Activada', + off: 'Desactivada', + scaleTitle: 'Tamaño', + scaleDesc: 'Cambia el tamaño de la mascota flotante. Se aplica al instante en todas partes.', + roamTitle: 'Moverse libremente', + roamDesc: 'Permite que la mascota recorra la ventana por su cuenta cuando esté inactiva.', + chooseTitle: 'Elige una mascota', + chooseDesc: 'Al elegir una, se instala si es necesario y queda activa.', + searchPlaceholder: 'Buscar mascotas…', + unreachable: 'No se pudo acceder a la galería de petdex. Revisa tu conexión y vuelve a abrir esta página.', + noMatch: query => `No hay mascotas que coincidan con “${query}”.`, + installedTag: 'instalada', + generatedTag: 'Generada', + countCapped: (cap, total) => `Se muestran ${cap} de ${total}; escribe para acotar la lista.`, + count: n => `${n} ${n === 1 ? 'mascota' : 'mascotas'}.`, + uninstall: name => `Desinstalar ${name}`, + delete: name => `Eliminar ${name}`, + deleteTitle: name => `¿Eliminar ${name}?`, + deleteBody: 'Esto elimina la mascota permanentemente; no se podrá reinstalar.', + deleteConfirm: 'Eliminar', + rename: name => `Cambiar nombre de ${name}`, + renameTitle: 'Cambiar nombre de mascota', + renamePlaceholder: 'Nombra a tu mascota', + renameSave: 'Guardar', + exportPet: name => `Exportar ${name}`, + adoptFailed: slug => `No se pudo adoptar ${slug}`, + uninstallFailed: slug => `No se pudo desinstalar ${slug}`, + renameFailed: slug => `No se pudo cambiar el nombre de ${slug}`, + exportFailed: slug => `No se pudo exportar ${slug}`, + noneAvailable: 'No hay mascotas disponibles para activar ahora mismo.', + turnOnFailed: 'No se pudo activar la mascota.', + turnOffFailed: 'No se pudo desactivar la mascota.' + } + }, + fieldLabels: defineFieldCopy({ + model: 'Modelo predeterminado', + modelContextLength: 'Ventana de contexto del modelo principal (forzada)', + fallbackProviders: 'Modelos de respaldo', + toolsets: 'Conjuntos de herramientas activados', + timezone: 'Zona horaria', + display: { + personality: 'Personalidad', + showReasoning: 'Bloques de razonamiento' + }, + desktop: { + repoScanEnabled: 'Detección automática de repositorios', + repoScanRoots: 'Carpetas de búsqueda de repositorios', + repoScanExcludePaths: 'Rutas de repositorio excluidas' + }, + agent: { + maxTurns: 'Pasos máximos del agente', + imageInputMode: 'Adjuntos de imagen', + apiMaxRetries: 'Reintentos de API', + serviceTier: 'Nivel de servicio', + toolUseEnforcement: 'Aplicación de uso de herramientas' + }, + terminal: { + cwd: 'Directorio de trabajo', + backend: 'Backend de ejecución', + timeout: 'Tiempo límite de comandos', + persistentShell: 'Shell persistente', + envPassthrough: 'Paso de variables de entorno', + dockerImage: 'Imagen de Docker', + singularityImage: 'Imagen de Singularity', + modalImage: 'Imagen de Modal', + daytonaImage: 'Imagen de Daytona' + }, + fileReadMaxChars: 'Límite de lectura de archivos', + toolOutput: { + maxBytes: 'Límite de salida del terminal', + maxLines: 'Límite de páginas de archivo', + maxLineLength: 'Límite de longitud de línea' + }, + codeExecution: { + mode: 'Modo de ejecución de código' + }, + approvals: { + mode: 'Modo de aprobación', + timeout: 'Tiempo límite de aprobación', + mcpReloadConfirm: 'Confirmar recargas de MCP' + }, + commandAllowlist: 'Lista de comandos permitidos', + security: { + redactSecrets: 'Redactar secretos', + allowPrivateUrls: 'Permitir URL privadas' + }, + browser: { + allowPrivateUrls: 'URL privadas del navegador', + autoLocalForPrivateUrls: 'Navegador local para URL privadas', + useRealProfile: 'Usar mi perfil real del navegador' + }, + checkpoints: { + enabled: 'Checkpoints de archivos', + maxSnapshots: 'Límite de checkpoints' + }, + voice: { + recordKey: 'Atajo de voz', + maxRecordingSeconds: 'Duración máxima de grabación', + autoTts: 'Leer respuestas en voz alta', + voiceChatMode: 'Modo de chat de voz', + gptLive: { + voice: 'Voz de GPT-Live', + instructions: 'Personalidad de GPT-Live' + } + }, + stt: { + enabled: 'Voz a texto', + echoTranscripts: 'Eco de transcripciones', + provider: 'Proveedor de voz a texto', + local: { + model: 'Modelo local de transcripción', + language: 'Idioma de transcripción' + }, + openai: { + model: 'Modelo STT de OpenAI' + }, + groq: { + model: 'Modelo STT de Groq' + }, + mistral: { + model: 'Modelo STT de Mistral' + }, + elevenlabs: { + modelId: 'Modelo STT de ElevenLabs', + languageCode: 'Idioma de ElevenLabs', + tagAudioEvents: 'Etiquetar eventos de audio', + diarize: 'Diarización de hablantes' + } + }, + tts: { + provider: 'Proveedor de texto a voz', + edge: { + voice: 'Voz de Edge' + }, + openai: { + model: 'Modelo TTS de OpenAI', + voice: 'Voz de OpenAI' + }, + elevenlabs: { + voiceId: 'Voz de ElevenLabs', + modelId: 'Modelo de ElevenLabs' + }, + xai: { + voiceId: 'Voz de xAI (Grok)', + language: 'Idioma de xAI', + speed: 'Velocidad de reproducción de xAI', + autoSpeechTags: 'Etiquetas de voz automáticas de xAI', + optimizeStreamingLatency: 'Optimización de latencia de streaming de xAI', + sampleRate: 'Frecuencia de muestreo de xAI', + bitRate: 'Tasa de bits de xAI' + }, + minimax: { + model: 'Modelo TTS de MiniMax', + voiceId: 'Voz de MiniMax' + }, + mistral: { + model: 'Modelo TTS de Mistral', + voiceId: 'Voz de Mistral' + }, + gemini: { + model: 'Modelo TTS de Gemini', + voice: 'Voz de Gemini' + }, + neutts: { + model: 'Modelo de NeuTTS', + device: 'Dispositivo de NeuTTS' + }, + kittentts: { + model: 'Modelo de KittenTTS', + voice: 'Voz de KittenTTS' + }, + piper: { + voice: 'Voz de Piper' + }, + deepinfra: { + model: 'Modelo TTS de DeepInfra', + voice: 'Voz de DeepInfra' + } + }, + memory: { + memoryEnabled: 'Memoria persistente', + userProfileEnabled: 'Perfil de usuario', + memoryCharLimit: 'Presupuesto de memoria', + userCharLimit: 'Presupuesto de perfil', + provider: 'Proveedor de memoria' + }, + context: { + engine: 'Motor de contexto' + }, + compression: { + enabled: 'Compresión automática', + threshold: 'Umbral de compresión', + codexGpt55Autoraise: 'Aumento automático de compresión de Codex', + targetRatio: 'Objetivo de compresión', + protectLastN: 'Mensajes recientes protegidos' + }, + auxiliary: { + compression: { + timeout: 'Tiempo de espera del modelo de compresión (s)' + } + }, + delegation: { + model: 'Modelo de subagente', + provider: 'Proveedor de subagente', + maxIterations: 'Límite de turnos del subagente', + maxConcurrentChildren: 'Subagentes paralelos', + childTimeoutSeconds: 'Tiempo límite del subagente', + reasoningEffort: 'Esfuerzo de razonamiento del subagente' + }, + updates: { + nonInteractiveLocalChanges: 'Cambios locales en actualización desde la app' + } + }), + fieldDescriptions: defineFieldCopy({ + model: 'Se usa para chats nuevos salvo que elijas otro modelo en el compositor.', + modelContextLength: + 'Sustituye la ventana de contexto detectada solo del modelo de chat PRINCIPAL (en tokens). Déjalo en 0 para usar el valor detectado del modelo seleccionado. No afecta a los modelos auxiliares/MoA.', + fallbackProviders: 'Entradas proveedor:model de respaldo para probar si falla el modelo predeterminado.', + display: { + personality: 'Estilo predeterminado del asistente para sesiones nuevas.', + showReasoning: 'Muestra secciones de razonamiento cuando el backend las proporcione.' + }, + desktop: { + repoScanEnabled: 'Busca repositorios Git en carpetas locales para mostrarlos en Proyectos.', + repoScanRoots: 'Carpetas que se buscarán. Déjalo vacío para buscar en tu directorio de inicio.', + repoScanExcludePaths: + 'Carpetas que se omitirán, junto con todos sus subdirectorios, durante la detección de repositorios.' + }, + timezone: 'Identificador de zona horaria IANA. Vacío usa la zona horaria del sistema.', + browser: { + useRealProfile: + 'La navegación local usa tus inicios de sesión reales. Hermes copia el perfil de tu navegador predeterminado (cookies, inicios de sesión, preferencias) en una instantánea gestionada y lo controla con su Chromium integrado: tu perfil activo nunca se abre directamente y la copia se actualiza a partir de él en cada ejecución. También permite que el agente abra bajo petición una sesión local con tu perfil real, incluso si hay un backend de navegador en la nube configurado. Solo se admiten navegadores Chromium (Chrome, Edge, Brave, Brave Origin, Chromium); un navegador predeterminado que no sea Chromium falla con un mensaje claro. Desactivado de forma predeterminada.' + }, + agent: { + imageInputMode: 'Controla cómo se envían los adjuntos de imagen al modelo.', + maxTurns: 'Límite superior de turnos con llamadas a herramientas antes de que Hermes detenga una ejecución.' + }, + terminal: { + cwd: 'Carpeta de proyecto predeterminada para herramientas y terminal.', + persistentShell: 'Mantiene el estado de la shell entre comandos cuando el backend lo admite.', + envPassthrough: 'Variables de entorno que se pasan a la ejecución de herramientas.', + dockerImage: 'Imagen de contenedor usada cuando el backend de ejecución es Docker.', + singularityImage: 'Imagen usada cuando el backend de ejecución es Singularity.', + modalImage: 'Imagen usada cuando el backend de ejecución es Modal.', + daytonaImage: 'Imagen usada cuando el backend de ejecución es Daytona.' + }, + codeExecution: { + mode: 'Qué tan estrictamente se limita la ejecución de código al proyecto actual.' + }, + fileReadMaxChars: 'Máximo de caracteres que Hermes puede leer en una solicitud de archivo.', + approvals: { + mode: 'Cómo maneja Hermes los comandos que necesitan aprobación explícita.', + timeout: 'Cuánto esperan los prompts de aprobación antes de vencer.' + }, + security: { + redactSecrets: 'Oculta secretos detectados del contenido visible para el modelo cuando sea posible.' + }, + checkpoints: { + enabled: 'Crea snapshots de reversión antes de editar archivos.' + }, + memory: { + memoryEnabled: 'Guarda memorias duraderas que pueden ayudar en sesiones futuras.', + userProfileEnabled: 'Mantiene un perfil compacto de preferencias del usuario.' + }, + context: { + engine: 'Estrategia para gestionar conversaciones largas cerca del límite de contexto.' + }, + compression: { + enabled: 'Resume contexto antiguo cuando las conversaciones crecen.', + codexGpt55Autoraise: 'Sube la compresión al 85 % para los modelos compatibles de ChatGPT Codex OAuth.' + }, + auxiliary: { + compression: { + timeout: + 'Segundos de espera del modelo auxiliar de compresión por llamada (120 por defecto). Súbelo para modelos locales lentos.' + } + }, + voice: { + autoTts: 'Lee automáticamente en voz alta las respuestas del asistente.', + voiceChatMode: + 'chained: voz a texto → Hermes → texto a voz con los proveedores de abajo. gpt-live: un modelo de voz full-duplex de OpenAI (gpt-live-1) escucha y habla, y pasa cada solicitud real a Hermes; el modelo que hayas seleccionado responde con todas las herramientas. Requiere una clave API de OpenAI; la capa de voz cuesta 0,05 US$ por minuto.', + gptLive: { + voice: 'Voz del modo GPT-Live. Se aceptan ID de voz personalizados.', + instructions: + 'Frases adicionales para la personalidad de voz en vivo (tono, ritmo, idioma). Hermes mantiene su propio prompt de sistema.' + } + }, + tts: { + xai: { + voiceId: 'ID de voz de xAI (por ejemplo, eve) o un ID de voz personalizado.', + language: 'Código del idioma hablado (p. ej., en, pt-BR) o "auto" para detectarlo automáticamente.', + speed: 'Velocidad de reproducción. 0.7 = más lento, 1.0 = normal, 1.5 = más rápido.', + autoSpeechTags: + 'Permite que un LLM inserte etiquetas de audio expresivas ([laughing], [sighs]) en el guion antes de sintetizarlo.', + optimizeStreamingLatency: 'Equilibrio entre latencia y calidad. 0 = mejor calidad, 2 = menor latencia.', + sampleRate: + 'Frecuencia de muestreo del audio en Hz. Un valor mayor ofrece más calidad y archivos más grandes.', + bitRate: 'Tasa de bits de MP3 en bps. Solo se aplica cuando el códec es mp3.' + }, + neutts: { + device: 'Dispositivo de inferencia local para NeuTTS.' + } + }, + stt: { + enabled: 'Activa transcripción de voz local o respaldada por proveedor.', + echoTranscripts: 'Publica la transcripción sin procesar 🎙️ de los mensajes de voz en el chat.', + elevenlabs: { + languageCode: 'Código de idioma ISO-639-3 opcional. En blanco deja que ElevenLabs lo detecte automáticamente.' + } + }, + updates: { + nonInteractiveLocalChanges: + 'Cuando Hermes se actualiza desde la app sin prompt de terminal, conserva los cambios locales de código fuente (stash) o descártalos. Las actualizaciones desde terminal siempre preguntan.' + } + }), + uninstallSection: { + dangerZone: 'Zona de peligro', + checkingInstalled: 'Comprobando lo que está instalado…', + uninstallHermes: 'Desinstalar Hermes', + chooseHowMuch: + 'Elige cuánto quieres quitar. La app se cierra para terminar; vuelve a abrir el instalador cuando quieras para volver.', + confirmUninstall: 'Confirmar desinstalación', + confirmBody: what => `Esto quita ${what}. No se puede deshacer.`, + appLabel: 'App:', + couldNotStart: 'No se pudo iniciar la desinstalación.', + uninstalling: 'Desinstalando…', + yesUninstall: 'Sí, desinstalar', + options: { + gui: { + title: 'Desinstalar solo la interfaz de chat', + description: 'Quita esta app de escritorio. El agente de Hermes, tu configuración y tus chats se conservan.', + consequence: 'la interfaz de chat de escritorio (esta app y sus datos)' + }, + lite: { + title: 'Desinstalar la interfaz y el agente, conservar mis datos', + description: + 'Quita la app y el agente de Hermes, pero conserva la configuración, los chats y los secretos para una futura reinstalación.', + consequence: + 'la interfaz de chat y el agente de Hermes (se conservan la configuración, los chats y los secretos)' + }, + full: { + title: 'Desinstalar todo', + description: + 'Quita la app, el agente y todos los datos de usuario: configuración, chats, tareas programadas, secretos y registros.', + consequence: + 'TODO: la interfaz de chat, el agente de Hermes y toda tu configuración, chats, secretos y registros' + } + } + }, + poolLimits: { + warmBotBackendsAria: 'Backends de bots en caliente', + warmBotBackendsTitle: 'Backends de bots en caliente', + backendIdleTimeoutAria: 'Tiempo de inactividad del backend en milisegundos', + backendIdleTimeoutTitle: 'Tiempo de inactividad del backend' + }, + customEndpoints: { + active: 'Activo', + apiKeySet: 'Clave API configurada', + use: 'Usar', + editTitle: 'Editar endpoint', + addTitle: 'Añadir endpoint', + fields: { + name: 'Nombre', + providerId: 'ID del proveedor', + endpointUrl: 'URL del endpoint', + defaultModel: 'Modelo predeterminado', + context: 'Contexto', + apiKey: 'Clave API', + apiKeyNewPlaceholder: 'Déjalo en blanco para conservar la clave actual', + apiKeyPlaceholder: 'Opcional', + useNewChats: 'Usar en los chats nuevos', + discoverModels: 'Descubrir modelos' + }, + test: 'Probar', + save: 'Guardar', + newEndpoint: 'Nuevo endpoint', + apiMode: 'Modo de API', + autoDetect: 'Detección automática', + couldNotLoad: 'No se pudieron cargar los endpoints personalizados', + endpointSaved: 'Endpoint personalizado guardado.', + saveFailed: 'Error al guardar', + endpointReachable: 'El endpoint es accesible.', + endpointReachableTransport: transport => `El endpoint es accesible (ruta ${transport} atendida).`, + endpointReachableModels: (reachable, count) => `${reachable} Se encontraron ${count} modelos.`, + endpointValidationFailed: 'Falló la validación del endpoint.', + validationFailed: 'Falló la validación', + activationFailed: 'Falló la activación', + deleteConfirm: name => `¿Eliminar ${name}?`, + deleteFailed: 'Error al eliminar', + title: 'Endpoints personalizados', + deleteEndpoint: 'Eliminar endpoint', + emptyDescription: 'Añade abajo un endpoint compatible con OpenAI.', + emptyTitle: 'No hay endpoints personalizados', + namePlaceholder: 'Axet Proxy', + contextPlaceholder: 'Auto' + }, + computerUse: { + accessibility: 'Accesibilidad', + screenRecording: 'Grabación de pantalla', + driverHealth: 'Estado del controlador' + }, + about: { + updates: 'Actualizaciones' + }, + config: { + minimizeToTrayTitle: 'Minimizar a la bandeja', + minimizeToTrayDesc: + 'Al minimizar las ventanas o cerrar la ventana principal, se ocultan en la bandeja del sistema (barra de menús en macOS) y Hermes sigue ejecutándose. Usa Salir de Hermes en el menú de la bandeja o Cmd+Q para salir. Desactivado por defecto; se aplica solo a este dispositivo.', + minimizeToTrayUnavailable: + 'La bandeja del sistema no está disponible. Las ventanas se minimizarán y cerrarán con normalidad. Desactiva y vuelve a activar esta opción para reintentarlo.', + none: 'Ninguno', + noneParen: '(ninguno)', + builtinOnly: 'Solo integradas', + notSet: 'Sin definir', + commaSeparated: 'valores separados por comas', + searchPlaceholder: 'Buscar…', + noResults: 'No se encontraron resultados', + systemDefault: 'Valor del sistema', + loading: 'Cargando configuración de Hermes...', + emptyTitle: 'Nada que configurar', + emptyDesc: 'Esta sección no tiene ajustes configurables.', + failedLoad: 'No se pudo cargar la configuración', + autosaveFailed: 'Falló el autoguardado', + imported: 'Configuración importada', + invalidJson: 'JSON de configuración no válido', + toolsetsWipeConfirm: + '¿Quitar todos los conjuntos de herramientas activados? Esto desactiva la memoria, el terminal, la búsqueda web, la delegación y la mayoría de las demás herramientas hasta que los vuelvas a activar.', + keepAwakeTitle: 'Mantener el equipo activo', + keepAwakeDesc: + 'Impide que este equipo entre en reposo para que las ejecuciones largas o nocturnas continúen. La pantalla puede seguir atenuándose.', + disableF12Title: 'Desactivar DevTools con F12', + disableF12Desc: + 'Impide que F12 abra las herramientas para desarrolladores. Ctrl+Shift+I (o Cmd+Opt+I en Mac) sigue funcionando.', + attachmentSizeTitle: 'Tamaño máximo de vista previa / carga de imagen', + attachmentSizeDesc: + 'Tamaño máximo de archivo local que el escritorio cargará para vistas previas y adjuntos de imagen, en MB. El valor por defecto es 16. Los adjuntos remotos no-imagen usan un límite separado de 256 MB. Un valor muy alto carga el archivo completo en memoria y puede congelar o bloquear la app.', + attachmentSizeUnit: 'MB', + attachmentSizeLabel: 'Tamaño máximo de vista previa / carga de imagen en megabytes', + showOptions: 'Mostrar opciones' + }, + hudModifier: { + title: 'Pulsar para mostrar el HUD', + description: + 'Pulsa y suelta ⌘ + Opción en Mac, o Ctrl + Alt en Windows/Linux, para traer el HUD al frente desde cualquier app. Desactivado por defecto; se aplica solo a este dispositivo.', + permission: + 'Permite Hermes en Ajustes del Sistema → Privacidad y seguridad → Monitorización de entrada y vuelve a intentarlo. Este gesto no registra pulsaciones de teclas ni captura tu pantalla.', + unavailable: + 'El asistente del gesto del HUD no pudo iniciarse o se detuvo de forma inesperada. Reinténtalo o reinicia Hermes. El atajo del HUD existente sigue funcionando dentro de Hermes.', + missingHelper: + 'A esta instalación de Hermes le falta el asistente del gesto del HUD. Actualiza o reinstala Hermes y vuelve a intentarlo.', + unsupportedSession: + 'Esta sesión de escritorio no admite pulsaciones globales de teclas modificadoras. Linux requiere X11; Wayland no es compatible.' + }, + screenshot: { + enabledTitle: 'Atajo de captura de pantalla', + enabledDesc: + 'Pulsa las dos teclas Comando a la vez desde cualquier app para capturar su ventana frontal y adjuntarla a tu borrador actual de Hermes. Nunca se envía automáticamente. Desactivado por defecto; se aplica solo a este Mac. El contenido de la ventana puede ser confidencial: revisa el adjunto antes de enviarlo.', + statusTitle: 'Estado del atajo de captura', + checking: 'Comprobando el atajo de captura…', + disabled: 'El atajo de captura está desactivado.', + starting: 'Iniciando la escucha del atajo. Todavía no está listo.', + ready: 'El atajo está listo. Las capturas se adjuntan a tu borrador actual sin enviarse.', + inputPermission: + 'El permiso de Monitorización de entrada permite a Hermes detectar las dos teclas Comando mientras otra app está activa. Permite Hermes en Ajustes del Sistema → Privacidad y seguridad → Monitorización de entrada, vuelve aquí y reinténtalo.', + screenPermission: + 'El permiso de Grabación de pantalla permite a Hermes capturar la ventana frontal cuando usas este atajo. Permite Hermes en Ajustes del Sistema → Privacidad y seguridad → Grabación de pantalla, vuelve aquí y reinténtalo. Reinicia Hermes si macOS te lo pide.', + openSettings: 'Abrir Ajustes del Sistema', + retry: 'Reintentar', + unavailable: 'El atajo de captura no está disponible. Reinténtalo o desactívalo.', + errorTitle: 'Error del atajo de captura', + loadFailed: 'No se pudo leer el estado del atajo. Reinténtalo para comprobar su ajuste actual.', + saveFailed: 'No se pudo confirmar el cambio del atajo. Reinténtalo para comprobar su ajuste actual.', + permissionFailed: + 'No se pudieron abrir los Ajustes del Sistema. Abre Privacidad y seguridad manualmente y reinténtalo.', + captureFailed: 'No se pudo capturar la ventana frontal. No se adjuntó ni se envió nada.', + contextChanged: 'El borrador actual cambió durante la captura. La captura no se adjuntó ni se envió.' + }, + quickEntry: { + enabledTitle: 'Entrada rápida', + enabledDesc: + 'Invoca un pequeño compositor desde cualquier lugar con un atajo global y envía un prompt sin abrir Hermes.', + shortcutTitle: 'Atajo de entrada rápida', + shortcutDesc: 'Necesita al menos un modificador, p. ej. CommandOrControl+Shift+Espacio.', + active: 'El atajo está activo.', + takenBy: 'Otra app ya usa este atajo — elige uno diferente.', + invalidShortcut: 'No es un atajo válido. Incluye al menos una tecla modificadora.' + }, + credentials: { + pasteKey: 'Pegar clave', + pasteLabelKey: label => `Pegar clave de ${label}`, + optional: 'Opcional', + enterValueFirst: 'Introduce primero un valor.', + couldNotSave: 'No se pudo guardar la credencial.', + remove: 'Quitar', + getKey: 'Obtener una clave', + saving: 'Guardando' + }, + envActions: { + actions: 'Acciones', + manageInKeys: 'Gestionar en Claves API', + docs: 'Docs', + hideValue: 'Ocultar valor', + revealValue: 'Mostrar valor', + replace: 'Reemplazar', + set: 'Definir', + clear: 'Limpiar' + }, + connections: { + title: 'Gateways registrados', + intro: + 'Gestiona este dispositivo y cada gateway de Hermes al que puede llegar mediante conexiones remotas, SSH o Cloud.', + stagedNote: + 'Cambia de gateway desde Sesiones. Los perfiles, chats, mensajería y tareas cron se quedan con su gateway; el trabajo en otros gateways sigue ejecutándose.', + launchModeTitle: 'Al iniciar, volver a Sesiones en el último gateway usado', + launchModeDesc: 'Si está desactivado, Sesiones se abre en el gateway principal.', + searchPlaceholder: 'Buscar gateways…', + noSearchResults: 'Ningún gateway coincide con tu búsqueda.', + loadFailed: 'No se pudieron cargar las conexiones', + currentPill: 'Actual', + primaryPill: 'Principal', + managedPill: 'Administrado por la app', + addConnection: 'Añadir conexión', + editConnection: 'Editar', + removeConnection: 'Quitar', + removeConfirmTitle: '¿Quitar esta conexión?', + removeConfirmDesc: (label: string) => + `“${label}” se quitará de esta app. La instancia en sí no se toca; puedes volver a añadirla cuando quieras.`, + makePrimary: 'Hacer principal', + testConnection: 'Probar', + testOk: 'Accesible', + testFailed: 'Falló la prueba de conexión', + saveFailed: 'No se pudo guardar la conexión', + removeFailed: 'No se pudo quitar la conexión', + updateAll: 'Actualizar todas las instancias', + updateAllRunning: 'Actualizando todas las instancias…', + updateAllDone: 'Actualizaciones enviadas', + updateAllFailed: 'Falló el envío de actualizaciones', + updateSkippedCloud: 'Administrado por Hermes Cloud', + kindLocal: 'Local', + kindRemote: 'Gateway remoto', + kindCloud: 'Hermes Cloud', + kindSsh: 'SSH', + kindLocalDesc: 'El entorno de ejecución de Hermes administrado por esta app.', + kindRemoteDesc: 'Un gateway de Hermes accesible por HTTP(S): LAN, Tailscale o internet.', + kindCloudDesc: 'Una instancia alojada detectada a través de tu cuenta de Hermes Cloud.', + kindSshDesc: 'Una instalación de Hermes accesible por SSH.', + labelTitle: 'Nombre', + labelDesc: + 'Obligatorio. Se muestra en todos los lugares donde aparece esta instancia; debe ser único (p. ej., “Homelab”, “Portátil del trabajo”).', + labelPlaceholder: 'Homelab', + urlTitle: 'URL del gateway', + sshHostTitle: 'Host SSH', + headersTitle: 'Encabezados adicionales del gateway', + headersDesc: + 'Se envían con cada solicitud HTTP y WebSocket a este gateway, para proxies de acceso como Cloudflare Access (CF-Access-Client-Id / CF-Access-Client-Secret). Los valores se guardan cifrados. Se ignoran los encabezados que gestiona Hermes (Authorization, Cookie, Host…).', + headerValuePlaceholder: 'Valor', + headerValueSaved: 'Guardado: déjalo en blanco para conservarlo', + headerAdd: 'Añadir encabezado', + headerRemove: 'Quitar', + duplicateLocal: 'Esta app ya administra una conexión local; solo puede haber una.', + duplicateUrl: (label: string) => `Ya existe una conexión a la URL de este gateway (“${label}”).`, + duplicateSsh: (label: string) => `Ya existe una conexión a este host SSH (“${label}”).`, + sameBackendHint: (label: string) => `Mismo backend que “${label}”`, + localAddHint: 'Local no está disponible: la conexión local administrada ya existe (solo puede haber una).', + cloudAddHint: + 'Consejo: al iniciar sesión en Hermes Cloud arriba, tus agentes se detectan automáticamente; usa este formulario solo para registrar a mano la URL de una instancia conocida.', + save: 'Guardar conexión', + saving: 'Guardando…', + cancel: 'Cancelar', + empty: 'Todavía no hay conexiones registradas.' + }, + managedUpdates: { + title: 'Actualizaciones administradas', + intro: + 'Actualiza de forma transaccional las instalaciones SSH administradas por la app: las sesiones se vacían, la copia remota se actualiza y cada perfil se restaura con un comprobante correlacionado.', + sshConnection: 'Instalación SSH administrada por la app', + update: 'Actualizar', + updating: 'Actualizando…', + progress: 'Vaciando sesiones, actualizando la instalación remota y restaurando perfiles…', + updated: 'Actualizado', + partial: 'Actualizado, pero falló la restauración', + refused: 'Rechazado', + failed: 'Falló la actualización', + alreadyRunning: 'Ya hay una actualización en curso', + receipt: (id: string, outcome: string) => `Comprobante ${id} · ${outcome}`, + receiptVersions: (pre: string, post: string) => `${pre} → ${post}`, + scopesRestored: (profiles: string) => `Perfiles restaurados: ${profiles}`, + scopeNotRestored: (profile: string, error: string) => `No se restauró el perfil “${profile}”: ${error}` + }, + gateway: { + loading: 'Cargando ajustes del gateway...', + unavailableTitle: 'Ajustes del gateway no disponibles', + unavailableDesc: + 'Los ajustes de conexión solo se pueden cambiar desde la app Hermes Desktop en el equipo que la ejecuta.', + title: 'Conexión del gateway', + envOverride: 'anulación de entorno', + intro: + 'Local por predeterminado. Usa remoto cuando esta app deba controlar un backend de Hermes en otro lugar. Anulaciones por perfil a continuación.', + envOverrideTitle: 'Esta conexión quedó fijada por la forma en que se inició Hermes.', + envOverrideDesc: + 'Un ajuste de inicio externo a la app eligió esta conexión, así que las opciones de abajo son de solo lectura. Reinicia Hermes sin ese ajuste (o pregunta a quien lo configuró) para cambiarla aquí.', + modeTitle: 'Modo de conexión', + localTitle: 'Gateway local', + localDesc: + 'Inicia un backend privado de Hermes en localhost. Es el valor predeterminado y funciona sin conexión.', + remoteTitle: 'Gateway remoto', + remoteDesc: 'Conecta esta shell de escritorio a un backend remoto de Hermes.', + remoteAuthHint: + 'Los gateways alojados usan OAuth o usuario y contraseña; los autohospedados pueden usar un token de sesión.', + cloudTitle: 'Hermes Cloud', + cloudDesc: + 'Inicia sesión una vez en Hermes Cloud y elige uno de los agentes de tu cuenta; no tienes que pegar ninguna URL.', + cloudSignInTitle: 'Hermes Cloud', + cloudSignIn: 'Iniciar sesión en Hermes Cloud', + cloudSignedIn: 'Sesión iniciada en Hermes Cloud', + cloudNeedsSignIn: 'Inicia sesión en Hermes Cloud para descubrir los agentes de tu cuenta.', + cloudSignedInDesc: 'Has iniciado sesión. Elige un agente de abajo; la sesión se actualiza automáticamente.', + cloudAgentsTitle: 'Tus agentes', + cloudOrgPickerTitle: 'Elige una organización', + cloudOrgSelect: 'Seleccionar', + cloudOrgChange: 'Cambiar organización', + cloudOrgRole: role => `Rol: ${role}`, + cloudLoadingAgents: 'Cargando tus agentes…', + cloudNoAgents: { + before: 'No se encontraron agentes en esta cuenta. Crea uno en el ', + linkText: 'Portal de Nous', + after: ', y luego actualiza.' + }, + cloudRefresh: 'Actualizar', + cloudConnect: 'Conectar', + cloudSavedTitle: 'Gateways de Cloud guardados', + cloudSavedDesc: + 'Usa un gateway guardado sin cambiar el predeterminado. Inicia sesión abajo para añadir instancias. Gestiona los nombres y el inicio de sesión en la lista de conexiones guardadas.', + cloudUseSaved: 'Usar gateway', + cloudActive: 'Activo en esta ventana', + cloudConnecting: 'Conectando…', + cloudDiscoverFailed: 'No se pudieron cargar tus agentes de Hermes Cloud', + cloudConnectFailed: 'No se pudo conectar con ese agente', + cloudSignInFailed: 'Falló el inicio de sesión en Hermes Cloud', + cloudSignedOutTitle: 'Sesión cerrada en Hermes Cloud', + cloudSignedOutMessage: 'Se borró la sesión de Hermes Cloud.', + cloudConnectedTitle: 'Conectado', + cloudConnectedPill: 'Conectado', + cloudConnectedTo: name => `Conectado a ${name}.`, + cloudAgentProvisioning: 'Preparando…', + cloudStatusLabel: status => `Estado: ${status}`, + remoteUrlTitle: 'URL remota', + remoteUrlDesc: 'URL base del backend del dashboard remoto. Se admiten prefijos de ruta, por ejemplo /hermes.', + probing: 'Comprobando cómo se autentica este gateway…', + probeError: + 'Hermes no puede llegar a esa dirección. Comprueba la URL y que el otro equipo esté ejecutando Hermes; las opciones de inicio de sesión aparecen cuando responde.', + signedIn: 'Sesión iniciada', + signIn: 'Iniciar sesión', + signOut: 'Cerrar sesión', + signInWith: provider => `Iniciar sesión con ${provider}`, + authTitle: 'Autenticación', + authSignedInPassword: + 'Este gateway usa usuario y contraseña. Ya iniciaste sesión; la sesión se actualiza automáticamente.', + authSignedInOauth: 'Este gateway usa OAuth. Ya iniciaste sesión; la sesión se actualiza automáticamente.', + authNeedsPassword: 'Este gateway usa usuario y contraseña. Inicia sesión para autorizar esta app de escritorio.', + authNeedsOauth: provider => `Este gateway usa OAuth. Inicia sesión con ${provider} para autorizar esta app.`, + tokenTitle: 'Token de sesión', + tokenDesc: 'Token de sesión del dashboard usado para REST y WebSocket. Déjalo vacío para conservar el guardado.', + existingToken: value => `Token existente ${value}`, + savedToken: 'guardado', + pasteSessionToken: 'Pegar token de sesión', + plainTextConfirmTitle: '¿Guardar el token de la puerta de enlace en texto plano?', + plainTextConfirmDesc: + 'No se encontró ningún servicio de llavero del sistema en este equipo, así que el token se guardaría sin cifrar en el archivo de ajustes de conexión de la app, legible por cualquier proceso que se ejecute con este usuario. Instala o activa el llavero del sistema (GNOME Keyring o KWallet en Linux) para guardarlo cifrado.', + plainTextConfirmAction: 'Guardar como texto plano', + plainTextStoredTitle: 'Token guardado en texto plano', + plainTextStoredDesc: + 'El almacenamiento seguro no está disponible, así que el token guardado está sin cifrar en el archivo de ajustes de conexión de la app en este equipo. Instala o activa el llavero del sistema (GNOME Keyring o KWallet en Linux) para cifrarlo.', + keychainEncryptionTitle: 'Cifrar los secretos guardados con el llavero del sistema', + keychainEncryptionDesc: + 'Desactivado por defecto. Si está activado, los tokens del gateway y las credenciales de inicio de sesión se cifran con el llavero del sistema (Acceso a Llaveros, GNOME Keyring o DPAPI de Windows); es posible que el sistema te pida permiso o una contraseña. Si está desactivado, se guardan como archivos normales que solo tu cuenta de usuario puede leer.', + keychainEncryptionFailed: 'No se pudo cambiar el cifrado de secretos', + testRemote: 'Probar remoto', + saveForRestart: 'Guardar para el próximo reinicio', + saveAndReconnect: 'Guardar y reconectar', + diagnostics: 'Diagnóstico', + diagnosticsDesc: 'Muestra desktop.log en tu gestor de archivos; útil cuando el gateway no arranca.', + openLogs: 'Abrir registros', + incompleteTitle: 'Gateway remoto incompleto', + incompleteSignIn: 'Introduce una URL remota e inicia sesión antes de cambiar a remoto.', + incompleteToken: 'Introduce una URL remota y un token de sesión antes de cambiar a remoto.', + incompleteSignInTest: 'Introduce una URL remota e inicia sesión antes de probar.', + incompleteTokenTest: 'Introduce una URL remota y un token de sesión antes de probar.', + enterUrlFirst: 'Introduce primero una URL remota.', + restartingTitle: 'Reiniciando conexión del gateway', + savedTitle: 'Ajustes del gateway guardados', + restartingMessage: 'Hermes Desktop se reconectará con los ajustes guardados.', + savedMessage: 'Guardado para el próximo reinicio.', + connectedTo: (baseUrl, version) => `Conectado a ${baseUrl}${version ? ` · Hermes ${version}` : ''}`, + reachableTitle: 'Gateway remoto accesible', + signedOutTitle: 'Sesión cerrada', + signedOutMessage: 'Se borró la sesión del gateway remoto.', + failedLoad: 'No se pudieron cargar los ajustes del gateway', + signInFailed: 'No se pudo iniciar sesión', + signOutFailed: 'No se pudo cerrar sesión', + testFailed: 'Falló la prueba del gateway remoto', + applyFailed: 'No se pudieron aplicar los ajustes del gateway', + saveFailed: 'No se pudieron guardar los ajustes del gateway', + sshTitle: 'Conectar por SSH', + sshDesc: + 'Hermes se inicia en el equipo remoto mediante SSH y se conecta a esta app a través de un túnel; no tienes que iniciar ni exponer nada por tu cuenta. Requiere acceso SSH mediante claves que ya funcione con el host.', + sshTrustHint: + 'La primera clave de host presentada se acepta y se fija; si cambia después, la conexión se rechaza.', + sshHostTitle: 'Host', + sshHostDesc: 'usuario@host o un alias Host de ~/.ssh/config.', + sshHostPick: 'Seleccionar un host…', + sshHostPickTitle: 'Host', + sshHostPickDesc: 'Un alias Host de ~/.ssh/config, o Personalizado para escribir uno.', + sshHostCustom: 'Personalizado (introducir manualmente)…', + sshUserTitle: 'Usuario', + sshUserDesc: 'En blanco = ~/.ssh/config o tu usuario actual.', + sshUserPlaceholder: 'desde ~/.ssh/config', + sshPortTitle: 'Puerto', + sshPortDesc: 'En blanco = 22 o el puerto de ~/.ssh/config.', + sshKeyTitle: 'Archivo de identidad', + sshKeyDesc: 'Ruta de la clave privada. En blanco = ssh-agent o ~/.ssh/config.', + sshHermesPathTitle: 'Ruta de Hermes (opcional)', + sshHermesPathDesc: 'Ruta completa al binario remoto de Hermes. En blanco = detección automática.', + sshHermesPathPlaceholder: 'detección automática', + sshTestConnection: 'Probar SSH', + sshConnect: 'Conectar', + sshButtonsHint: 'Guardar se aplica en el próximo inicio. Conectar vuelve a conectar ahora.', + sshReachable: (host, platform) => `Accesible: ${host} (${platform}) — se encontró Hermes`, + sshIncompleteHost: 'Introduce un host SSH antes de conectar.', + sshErrUnreachable: 'No se pudo acceder a ese host por SSH. Revisa el host, el puerto y tu red.', + sshErrAuth: + 'Falló la autenticación SSH. Carga tu clave en ssh-agent (ssh-add) o configura un IdentityFile en ~/.ssh/config; Hermes ejecuta SSH de forma no interactiva.', + sshErrHostKey: + 'La clave del host CAMBIÓ desde la última conexión. Confirma que sea un cambio esperado, ejecuta ssh-keygen -R y vuelve a conectar.', + sshErrNotInstalled: + 'Hermes no está instalado en el host remoto. Instálalo allí (curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh) o indica la ruta de Hermes.', + sshErrPlatform: + 'Plataforma remota no compatible. El modo SSH de Hermes Desktop admite hosts remotos Linux, macOS y Windows.', + sshErrTimeout: 'La conexión SSH agotó el tiempo de espera. Es posible que el host no responda o esté en reposo.', + sshErrUpdateRequired: 'Actualiza Hermes en el host remoto antes de conectarte con Desktop SSH.', + sshErrUnknown: 'Falló la conexión SSH.' + }, + keys: { + loading: 'Cargando claves API y credenciales...', + failedLoad: 'No se pudieron cargar las claves API', + empty: 'Aún no hay nada configurado en esta categoría.' + }, + search: { + placeholder: 'Buscar en todos los ajustes…', + pill: 'Buscar' + }, + profileScope: { + appliesTo: 'Se aplica a', + editsProfile: profile => `Los cambios de esta página se aplican al perfil “${profile}”.` + }, + mcp: { + loading: 'Cargando servidores MCP...', + invalidJson: 'JSON MCP no válido', + saveFailed: 'No se pudo guardar', + removeFailed: 'No se pudo quitar', + reloadFailed: 'Falló la recarga de MCP', + savedTitle: 'Servidor MCP guardado', + savedMessage: name => `${name} se aplica después de recargar MCP.`, + disabled: 'deshabilitado', + name: 'Nombre', + serverJson: 'JSON del servidor', + remove: 'Quitar', + test: 'Probar conexión', + catalogLoading: 'Cargando catálogo MCP...', + catalogInstallFailed: name => `No se pudo instalar ${name}`, + catalogEnvRequired: 'Completa los valores obligatorios antes de instalar.', + capabilitySummary: (tools, prompts, resources) => + `Habilitado: ${[`${tools} herramientas`, ...(prompts ? [`${prompts} prompts`] : []), ...(resources ? [`${resources} recursos`] : [])].join(', ')}`, + costTokens: tokens => `~${tokens} tok/llamada`, + usage30d: uses => `${uses} usos/30 d`, + statusConnecting: 'Conectando…', + statusNeedsAuth: 'Necesita autenticación', + statusError: 'Error', + statusOff: 'Desactivado', + allServers: 'Todos los servidores', + authenticatedTitle: 'Autenticado', + authenticatedMessage: (server, count) => `${server}: ${count} herramientas`, + authenticate: 'Autenticar', + noOutput: 'Aún no hay salida.', + deepLinkTitle: '¿Añadir servidor MCP?', + deepLinkDescription: + 'Un enlace pidió añadir este servidor MCP a Hermes. Revisa la configuración exacta de abajo: viene del enlace, no de Hermes.', + deepLinkStdioWarning: + 'Este servidor ejecuta un proceso local en tu equipo con el comando que se muestra abajo. Continúa solo si confías en su origen.', + deepLinkConfirm: 'Añadir servidor', + deepLinkNameInvalid: 'Los nombres usan de 1 a 64 letras, dígitos, puntos, guiones o guiones bajos.', + deepLinkNameConflict: name => `Ya existe un servidor llamado ${name}: elige otro nombre o cancela.`, + deepLinkErrorTitle: 'Enlace de instalación de MCP rechazado', + deepLinkErrorName: 'Falta el nombre del servidor del enlace o no es válido.', + deepLinkErrorConfig: 'La configuración del enlace no es JSON válido codificado en base64.', + deepLinkErrorShape: 'La configuración debe ser un objeto JSON con un campo `url` o `command` de tipo cadena.', + deepLinkErrorUrl: 'Solo se permiten URL de servidor http:// y https://.', + deepLinkErrorTooLarge: 'La configuración supera el límite de 32 KB.' + }, + model: { + setupProviderFallback: 'proveedor', + setUpProvider: name => `Configurar ${name}`, + staleAuxBefore: (count, names) => + count === 1 + ? `${count} tarea auxiliar (${names}) todavía se ejecuta en ` + : `${count} tareas auxiliares (${names}) todavía se ejecutan en `, + staleAuxAfter: ', no en tu modelo principal.', + staleAuxOtherProviders: 'otros proveedores', + moaEnabled: 'Activado', + moaSetDefault: 'Establecer como predeterminado', + moaNewPresetPlaceholder: 'nuevo preajuste', + moaAddPreset: 'Añadir preajuste', + customModel: 'Modelo personalizado…', + customModelPlaceholder: 'ID del modelo', + chooseFromList: 'Elegir de la lista', + moaDefault: 'Predeterminado:', + moaReferenceToggle: (enabled, index) => `${enabled ? 'Desactivar' : 'Activar'} la referencia ${index}`, + moaReferenceTitle: index => `Referencia ${index}`, + moaAddReference: 'Añadir modelo de referencia', + loading: 'Cargando configuración de modelo...', + appliesDesc: + 'Se aplica a sesiones nuevas. Usa el selector de modelo en el compositor para cambiar el chat activo.', + provider: 'Proveedor', + model: 'Modelo', + applying: 'Aplicando...', + defaultsLabel: 'Valores predeterminados', + reasoning: 'Razonamiento', + reasoningOff: 'Desactivado', + defaultsFailed: 'No se pudieron guardar los valores predeterminados del modelo', + loadFailed: 'No se pudieron cargar los modelos', + restartRequired: + 'Este backend ejecuta código antiguo tras una actualización. Reinícialo para cargar el código nuevo.', + restartBackend: 'Reiniciar backend', + restartingBackend: 'Reiniciando backend...', + restartFailed: 'No se pudo reiniciar el backend', + auxiliaryTitle: 'Modelos auxiliares', + resetAllToMain: 'Restablecer todos al principal', + auxiliaryDesc: + 'Las tareas auxiliares usan el modelo principal de forma predeterminada. Asigna un modelo dedicado a cualquier tarea para anularlo.', + setToMain: 'Usar principal', + change: 'Cambiar', + autoUseMain: 'auto · usar modelo principal', + inheritMainEffort: 'heredar · esfuerzo del modelo principal', + providerDefault: '(predeterminado del proveedor)', + fallbackAdd: 'Añadir respaldo', + fallbackEmpty: 'No hay modelos de respaldo; se usa el modelo predeterminado salvo que falle.', + notInCatalog: 'no está en la lista de modelos de este proveedor; las llamadas pueden recurrir a un respaldo.', + moaTitle: 'Mixture of Agents', + moaPreset: 'Preajuste', + moaDescription: + 'Configura preajustes con nombre que aparecen como modelos del proveedor Mixture of Agents. El agregador es el modelo que actúa: ejecuta cada paso del bucle de herramientas, y casi todo el coste de la ejecución se factura a su proveedor. Por defecto, las referencias solo asesoran una vez por turno del usuario.', + moaAggregator: 'Agregador', + moaAggregatorBilled: 'modelo que actúa · se factura por la ejecución', + moaReferenceHint: 'asesora una vez por turno por defecto', + tasks: { + vision: { + label: 'Visión', + hint: 'Análisis de imágenes' + }, + compression: { + label: 'Compresión', + hint: 'Compactación de contexto' + }, + skills_hub: { + label: 'Hub de skills', + hint: 'Búsqueda de skills' + }, + approval: { + label: 'Aprobación', + hint: 'Aprobación automática inteligente' + }, + mcp: { + label: 'MCP', + hint: 'Enrutamiento de herramientas MCP' + }, + title_generation: { + label: 'Generación de títulos', + hint: 'Títulos de sesión' + }, + review: { + label: 'Revisión', + hint: 'subagente revisor de /review' + }, + triage_specifier: { + label: 'Especificador de triaje', + hint: 'Detalle de especificaciones de Kanban' + }, + kanban_decomposer: { + label: 'Descomponedor de Kanban', + hint: 'Descomposición de tareas' + }, + profile_describer: { + label: 'Descriptor de perfiles', + hint: 'Descripciones automáticas de perfiles' + }, + curator: { + label: 'Curador', + hint: 'Revisión de uso de skills' + } + } + }, + localModels: { + title: 'Modelos locales', + runtimeTitle: 'Entorno local', + runtimeReady: backend => `Listo · ${backend}`, + serverRunning: 'En ejecución', + runtimeInstalled: 'Entorno llama.cpp instalado', + runtimeInstalledDetail: (tag, backend) => + `Compilación ${tag}, backend ${backend}. Hermes inicia y gestiona el servidor por ti.`, + installTitle: 'Instalar el entorno local', + installDetail: + 'Descarga el motor de inferencia llama.cpp (unos cientos de MB). Los modelos que descargues se ejecutan por completo en este equipo: sin cuenta y sin que nada salga de tu computadora.', + installAction: 'Instalar entorno', + installing: 'Instalando el entorno…', + installFailed: 'Falló la instalación del entorno', + hardwareTitle: 'Este equipo', + hardwareLoading: 'Comprobando tu hardware…', + vram: label => `${label} de memoria de GPU`, + ram: label => `${label} de RAM`, + unifiedMemory: 'Memoria unificada', + modelsTitle: 'Modelos', + recommended: 'Recomendado', + recommendedReason: { + 'best-quality-resident': + 'El modelo de mayor calidad que se ejecuta por completo en tu GPU a máxima velocidad. La selección equilibra la calidad con la velocidad prevista en este hardware.', + 'speed-gated-quality': + 'Cabe un modelo de mayor calidad en este equipo, pero respondería demasiado lento por el ancho de banda de su memoria; este es el mejor modelo que se mantiene rápido.', + 'fastest-resident': + 'Ningún modelo alcanza la velocidad máxima en este hardware; este es el que más se acerca ejecutándose por completo en la memoria de la GPU.' + }, + noRecommendationTitle: 'No hay recomendación automática para este equipo', + noRecommendationDetail: + 'La configuración automática requiere un modelo seleccionado que quepa por completo en la memoria de la GPU o unificada. Aun así, puedes elegir un modelo abajo o explorar más modelos.', + noRecommendationAction: 'Explorar modelos', + downloaded: 'Descargado', + downloadAction: size => `Descargar · ${size}`, + downloadProgress: (done, total) => `Descargando ${done} de ${total}`, + downloadDoneToast: model => `${model} está listo.`, + installDoneToast: 'Entorno local instalado y listo.', + quickstartTitle: 'Ejecutar un modelo en este equipo', + quickstartDetail: (model, size) => + `Un clic lo configura todo: el motor local, ${model} (${size} de descarga) y tu modelo predeterminado para chats nuevos. Nada sale de este equipo.`, + quickstartDetailReady: model => + `Un clic convierte ${model} en tu modelo predeterminado para chats nuevos. Todo se ejecuta en este equipo.`, + quickstartAction: 'Configurarlo por mí', + quickstartConfigure: 'Prefiero elegir', + quickstartDoneToast: model => `${model} está configurado: los chats nuevos se ejecutan en este equipo.`, + quickstartFailed: 'Falló la configuración del modelo local', + quickstartStageEngine: 'Motor', + quickstartStageModel: 'Modelo', + quickstartStageFinish: 'Finalizar', + useAction: 'Usar', + activePill: 'Predeterminado', + updateTitle: 'Hay una actualización del motor', + updateDetail: (next, current) => + `Hay una compilación más reciente de llama.cpp (${next}) lista para instalar; tienes ${current}. Los modelos siguen funcionando durante la descarga.`, + updateAction: 'Actualizar motor', + updating: 'Actualizando el motor…', + upToDateTitle: 'Motor actualizado', + upToDateDetail: (tag, backend) => `Ejecutando llama.cpp ${tag} (${backend}), la compilación configurada.`, + activeDetail: 'Los chats nuevos usan este modelo; se carga cuando envías tu primer mensaje', + activeNotLoaded: 'Se carga con tu primer mensaje', + loadedPill: 'En memoria', + placementResident: 'todo en GPU', + placementSpilled: 'parte en RAM', + placementResidentTip: + 'Se ejecuta por completo en la memoria de la GPU con esta ventana de contexto: máxima velocidad.', + placementSpilledTip: + 'Parte de este modelo se ejecuta desde la RAM del sistema: funciona, pero más lento. Una compilación más compacta o un contexto menor cabría por completo.', + loadingPill: 'Cargando…', + ejectTip: 'Liberar memoria de GPU (se vuelve a cargar con el siguiente mensaje)', + ejected: 'Modelo descargado de memoria: memoria de GPU liberada.', + ejectFailed: 'No se pudo descargar el modelo de memoria', + stopServer: 'Apagar', + startServer: 'Encender', + runtimeRunningDetail: + 'El servidor local está en ejecución. Apagarlo libera toda la memoria de GPU e impide que los chats nuevos usen modelos locales hasta que lo vuelvas a encender.', + serverStopped: 'Servidor local detenido: memoria de GPU liberada.', + serverStarted: 'Servidor local en ejecución.', + serverStopFailed: 'No se pudo detener el servidor local', + serverStartFailed: 'No se pudo iniciar el servidor local', + activating: 'Iniciando…', + activateFailed: model => `No se pudo cambiar a ${model}`, + activateDoneToast: model => `Los chats nuevos usan ${model}.`, + downloadFailed: model => `Falló la descarga de ${model}`, + pillFitsGpu: 'Cabe en tu GPU', + pillUsesRam: 'Usa RAM del sistema', + pillTooBig: 'Demasiado grande para este equipo', + browseTitle: 'Buscar más modelos', + browseHint: + 'Busca en todo Hugging Face. Los modelos que descargues aquí se ajustan automáticamente a tu equipo, pero no los hemos probado.', + browsePlaceholder: 'Busca modelos por nombre o autor…', + browseSearching: 'Buscando en Hugging Face', + browseListing: 'Leyendo los archivos del modelo', + browseShowFiles: 'Mostrar archivos', + browseRefresh: 'Actualizar', + browseDownloads: 'descargas', + browseLikes: 'me gusta', + browseGated: 'requiere iniciar sesión en Hugging Face', + browseNoGguf: 'No se encontraron archivos de modelo compatibles.', + browseFitUnknown: 'Ajuste desconocido', + browseAlreadyDownloaded: 'Ya descargado.', + addedByYou: 'Añadido por ti', + browseDownloadStarted: 'Descargando {name}', + browseDownloadAria: 'Descargar {name}', + sideloadButton: 'Añadir archivo de modelo', + sideloadTitle: 'Elige un archivo de modelo GGUF', + sideloadDone: 'Se añadió {name}.', + sideloadAlreadyPresent: 'Ya está en tu biblioteca.', + pillFullContext: (max: string) => `Contexto completo de ${max}`, + pillFullContextTip: 'Se ejecuta con la ventana de contexto completa del modelo desde el principio', + pillUpTo: (max: string) => `Hasta ${max} de contexto`, + pillGrowsTip: 'Crece automáticamente cuando tu conversación necesita más espacio', + pillVision: 'Ve imágenes', + deleteAction: 'Eliminar modelo', + deleteConfirm: (model: string) => `¿Eliminar ${model} del disco?`, + deleted: (model: string) => `${model} eliminado.`, + deleteFailed: 'Error al eliminar' + }, + billing: { + perMonth: (amount: string) => `${amount}/mes`, + creditsPerMonth: (amount: string) => `${amount} créditos/mes`, + usageLabel: (label: string) => `Uso de ${label}`, + freeTier: { + signIn: 'Iniciar sesión', + title: 'Estás en el plan gratuito de Nous', + message: 'Inicia sesión con una cuenta de Nous para desbloquear más modelos y herramientas.', + caption: + 'Funciona con nous/welcome, con conectores incluidos. Al iniciar sesión conservas tus conectores y se añaden las herramientas que requieren cuenta y todos los demás modelos.', + name: 'Nous · plan gratuito', + footnote: + 'El plan gratuito no tiene saldo ni nada que pagar. El pago y el uso aparecen al iniciar sesión con una cuenta de Nous.', + plan: 'Plan gratuito', + model: 'Modelo', + connectors: 'Conectores', + included: 'Incluidos' + }, + amountValidation: { + reloadTo: 'Recargar hasta', + greaterThanThreshold: 'El importe de recarga debe ser mayor que el umbral.', + decimal: (label: string) => `${label}: introduce un importe en dólares con 2 decimales como máximo.`, + positive: (label: string) => `${label}: el importe debe ser mayor que 0 $.`, + minimum: (label: string, amount: string) => `${label}: el mínimo es ${amount}.`, + maximum: (label: string, amount: string) => `${label}: el máximo es ${amount}.` + }, + stepUp: { + openVerification: 'Abrir la página de verificación', + dismiss: 'Descartar', + waiting: 'Esperando el enlace de verificación…', + verify: 'Verifica para continuar', + deniedTitle: 'La verificación no se aprobó', + deniedBody: 'La verificación terminó sin permitir el gasto remoto para este terminal.', + successTitle: 'Verificación completada', + successBody: 'El gasto remoto está permitido para este terminal.' + }, + charge: { + added: (amount?: string) => (amount ? `Se añadieron ${amount} $.` : 'Créditos añadidos.'), + failedTitle: 'Falló el cargo', + unconfirmedTitle: 'Resultado del cargo sin confirmar', + unconfirmedBody: (message: string) => + `${message} El resultado de tu último cargo no está confirmado: revisa tu saldo/historial antes de reintentarlo.`, + checkTitle: 'No se pudo comprobar el cargo', + checkBody: 'No se pudo comprobar el cargo.', + untrackedTitle: 'No se pudo seguir el cargo', + untrackedBody: 'El servicio de facturación aceptó la solicitud, pero no devolvió un ID de cargo.', + timeoutTitle: 'Sigue procesándose tras 5 minutos', + timeoutBody: 'Es posible que el cargo todavía se complete. Revisa el portal antes de reintentarlo.', + authenticationRequired: + 'Tu banco requiere verificación (3DS). Complétala en el portal para terminar esta compra.', + expired: 'Tu tarjeta ha caducado. Actualízala en el portal.', + declined: 'Tu tarjeta fue rechazada. Prueba otra tarjeta en el portal.', + failedBody: (reason: string) => `El cargo no se completó (${reason}).` + }, + title: 'Facturación', + preview: 'vista previa', + summary: { + balance: 'Saldo', + plan: 'Plan', + autoRefill: 'Recarga automática' + }, + sections: { + invoices: 'Facturas', + plan: 'Plan', + paymentAndCredits: 'Pago y créditos', + usage: 'Uso' + }, + usage: { + title: 'Uso' + }, + buyCredits: { + customAmount: 'Importe de créditos personalizado', + title: 'Comprar créditos ahora', + buyButton: 'Comprar', + processing: 'Procesando… comprobando la liquidación', + added: (amount: string) => `Se añadieron ${amount}. El saldo se está actualizando.`, + retry: 'Reintentar', + openPortal: 'Abrir el portal' + }, + plan: { + title: 'Planes', + changePlan: 'Cambiar de plan', + viewPlans: 'Ver planes', + backAria: 'Volver a facturación', + current: 'Plan actual', + scheduled: 'Programado', + empty: 'Ahora mismo no hay planes disponibles a los que cambiar.', + undo: 'Deshacer', + undoing: 'Deshaciendo…', + downgrade: 'Bajar de plan', + confirmDowngrade: 'Confirmar el cambio a un plan inferior', + tryAgain: 'Volver a intentarlo', + checkingChange: 'Comprobando este cambio…', + cannotChange: 'Ese cambio no se puede hacer aquí.', + alreadyOn: (name: string) => `Ya tienes el plan ${name}; no hay nada que cambiar.`, + notScheduleable: 'Este cambio no se puede programar aquí.', + scheduling: 'Programando…', + cancel: 'Cancelar', + effectScheduled: (targetName: string, effectiveAt: string, creditsDelta?: string) => + `Cambio a ${targetName}: entra en vigor ${effectiveAt}. No se cobra nada ahora; conservas tu plan actual hasta entonces.${creditsDelta ? ` Cambio de créditos mensuales: ${creditsDelta}.` : ''}` + }, + autoReload: { + threshold: 'Umbral', + thresholdAria: 'Umbral de recarga automática', + reloadTo: 'Recargar hasta', + reloadToAria: 'Importe objetivo de la recarga automática', + turnOffConfirm: '¿Desactivar la recarga automática?', + turnOff: 'Desactivar', + disable: 'Desactivar', + updated: 'Recarga automática actualizada.', + turnedOff: 'Recarga automática desactivada.', + manage: 'Gestionar', + save: 'Guardar', + saving: 'Guardando…', + cancel: 'Cancelar' + }, + state: { + notice: { + loggedOut: { + title: 'Conecta tu cuenta de Nous', + message: 'Ejecuta /portal en la TUI o abre el portal de Nous para conectar tu cuenta.', + action: 'Abrir el portal ↗' + }, + noCard: { + title: 'No hay ningún método de pago registrado', + message: + 'La compra de créditos y la recarga automática siguen desactivadas hasta que registres una tarjeta. Añade una en el portal.', + action: 'Añadir tarjeta ↗' + } + }, + paymentMethod: { + title: 'Método de pago', + description: 'Gestiona la tarjeta que se usa para recargas y renovaciones de la suscripción.', + addAction: 'Añadir método de pago', + updateAction: 'Actualizar', + provenance: { + autoRefill: 'tarjeta de recarga automática', + customerDefault: 'predeterminada del cliente', + subPin: 'tarjeta de la suscripción', + suffix: (label: string) => ` - ${label}` + } + }, + buyCredits: { + description: 'Un único cargo en tu tarjeta que se añade hoy a tu saldo.' + }, + autoRefill: { + title: 'Recargar cuando quede poco saldo', + genericDescription: 'Mantén tu saldo recargado cuando baje de tu umbral.', + offPill: 'Desactivada', + enabledPill: 'Activada', + notAvailablePill: '—', + manageCaption: 'Gestiona la recarga automática desde el portal.', + turnOnCaption: 'Activa la recarga automática desde el portal', + chargesDescription: (reloadTo: string, threshold: string) => + `Cobra ${reloadTo} automáticamente cuando tu saldo baja de ${threshold}.`, + distinctCardCaption: (cardLabel: string) => + `La recarga automática cobra a ${cardLabel}: concílialo en el portal`, + distinctCardFallback: 'otra tarjeta', + reconcileAction: 'Conciliar ↗' + }, + usage: { + subscriptionCredits: { + title: 'Créditos de la suscripción', + barLabel: 'Créditos de la suscripción restantes', + captionResets: (date: string) => `Se restablece ${date}`, + valueOf: (remaining: string, monthly: string) => `Quedan ${remaining} de ${monthly}`, + valueOver: (remaining: string, monthly: string, over: string) => + `Quedan ${remaining} de ${monthly} · ${over} de exceso` + }, + topupCredits: { + title: 'Créditos de recarga', + caption: 'No caducan' + }, + monthlyCap: { + title: 'Límite de gasto mensual', + barLabel: 'Límite de gasto mensual usado', + captionDefault: 'Límite predeterminado', + captionSpending: 'Gasto remoto mensual', + valueUsed: (spent: string, limit: string) => `${spent} de ${limit} usados` + } + }, + planCard: { + freeTier: 'Gratis', + chooseAction: 'Elegir ↗', + adjustPlanAction: 'Ajustar plan ↗', + unavailableCaption: 'Los detalles de la suscripción no están disponibles; aún puedes abrir el portal.', + downgradeCaption: (tierName: string, when: string) => `Cambia a ${tierName} el ${when}.`, + cancellationCaption: (when: string) => `Se cancela el ${when}.`, + renewsCaption: (date: string) => `Se renueva ${date}`, + noSubscriptionCaption: 'No hay suscripción activa: los modelos de pago consumen créditos de recarga.' + } + }, + errors: { + consentRequired: { + title: 'Hace falta confirmar la tarjeta', + message: 'Confirma esta tarjeta para los cargos del terminal en el portal' + }, + insufficientScope: { + title: 'El gasto remoto necesita aprobación', + message: 'Esto requiere permitir el gasto remoto. Inicia una recarga para permitirlo y vuelve a intentarlo.' + }, + remoteSpendingRevoked: { + title: 'Se detuvo el gasto remoto', + messageByAdmin: 'Un administrador detuvo el gasto remoto para este terminal.', + messageBySelf: 'Detuviste el gasto remoto para este terminal.' + }, + remoteSpendingReconnect: (who: string) => + `${who} Vuelve a conectarte desde Configuración -> Gateway para autorizar de nuevo este dispositivo.`, + sessionRevoked: { + title: 'Sesión cerrada', + message: 'Se cerró tu sesión. Vuelve a iniciar sesión desde Configuración → Gateway.' + }, + cliBillingDisabled: { + title: 'El gasto remoto está desactivado', + message: + 'El gasto remoto está desactivado para esta cuenta; un administrador de facturación puede activarlo desde la página de Hermes Agent del portal.' + }, + roleRequired: { + title: 'Se requiere rol de administrador', + message: + 'Para añadir fondos hace falta un administrador o propietario de la organización. Pide ayuda a un administrador o gestiónalo en el portal.' + }, + idempotencyConflict: { + title: 'Inicia una nueva recarga', + message: '🔴 Esa clave de cargo ya se usó para otro importe. Inicia una nueva recarga.' + }, + noPaymentMethod: { + title: 'No hay tarjeta guardada', + message: + '💳 Todavía no hay una tarjeta guardada para cargos del terminal. Configura una en el portal ' + + '(las compras únicas de créditos no guardan una tarjeta reutilizable).' + }, + orgAccessDenied: { + title: 'Acceso a la organización denegado', + message: 'Este token no está vinculado a una organización que puedas gestionar' + }, + monthlyCapExceeded: { + title: 'Se alcanzó el límite de gasto mensual', + messageReached: '🔴 Se alcanzó el límite de gasto mensual.', + messageHeadroom: (remaining: string) => + `🔴 Se alcanzó el límite de gasto mensual: quedan ${remaining} $ de margen.` + }, + rateLimited: { + title: 'Demasiados cargos ahora mismo', + message: (mins: number) => + mins > 0 + ? `🟡 Demasiados cargos ahora mismo (reinténtalo en ~${mins} min). No es un fallo de pago.` + : '🟡 Demasiados cargos ahora mismo. No es un fallo de pago.' + }, + stripeUnavailable: { + title: 'Stripe tiene problemas', + message: (mins: number) => + mins > 0 + ? `Stripe tiene problemas: reinténtalo en ~${mins} min` + : 'Stripe tiene problemas: reinténtalo en breve' + }, + upgradeCapExceeded: { + title: 'Se alcanzó el límite diario de cambios de plan', + message: 'Se alcanzó el límite diario de cambios de plan: reinténtalo mañana' + }, + endpointUnavailable: { + title: 'Endpoint de facturación no disponible', + message: + 'El endpoint de facturación devolvió una respuesta que no es JSON (puede que no esté disponible en este despliegue).' + }, + timeout: { + title: 'Se agotó el tiempo de la solicitud de facturación', + message: 'Se agotó el tiempo de la solicitud de facturación.' + }, + transport: { + title: 'Falló la conexión de facturación', + message: 'La solicitud de facturación falló antes de llegar al gateway.' + }, + default: { + title: 'Falló la solicitud de facturación', + message: 'Falló la solicitud de facturación.' + } + } + }, + providers: { + connectAccount: 'Conectar una cuenta', + haveApiKey: '¿Tienes una clave API?', + intro: + 'Inicia sesión con una suscripción, sin copiar claves API. Hermes ejecuta el inicio de sesión del navegador por ti, aquí mismo en la app.', + connected: 'Conectado', + collapse: 'Contraer', + connectAnother: 'Conectar otro proveedor', + otherProviders: 'Otros proveedores', + disconnect: 'Desconectar', + disconnectInTerminal: 'Desconectar (ejecuta el comando de eliminación en el terminal)', + removeConfirm: provider => `¿Eliminar ${provider}?`, + removeExternalGeneric: provider => `${provider} se gestiona con su propia CLI; elimínalo allí.`, + removeKeyManaged: provider => `${provider} se configura con una clave API. Quítalo en Claves API.`, + removeTerminalConfirm: (provider, command) => + `¿Desconectar ${provider}? Esto ejecutará “${command}” en el terminal para borrar la credencial.`, + removeTerminalRunning: provider => `Ejecutando la desconexión de ${provider} en el terminal…`, + removedTitle: 'Cuenta eliminada', + removedMessage: provider => `Se eliminó ${provider}.`, + failedRemove: provider => `No se pudo eliminar ${provider}`, + noProviderKeys: 'No hay claves API de proveedores disponibles.', + searchKeys: 'Buscar proveedores…', + noKeysMatch: 'Ningún proveedor coincide con tu búsqueda.', + localEndpoint: { + title: 'Endpoint local o personalizado', + description: + 'Conecta Hermes con cualquier endpoint compatible con OpenAI (Zyphra, vLLM, llama.cpp, Ollama, etc.).' + }, + loading: 'Cargando proveedores...' + }, + sessions: { + loading: 'Cargando sesiones archivadas…', + archivedTitle: 'Sesiones archivadas', + archivedIntro: + 'Los chats archivados se ocultan de la barra lateral, pero conservan todos sus mensajes. Haz Alt/⌥+Mayús/⇧-clic en un chat de la barra lateral para archivarlo.', + emptyArchivedTitle: 'Nada archivado', + emptyArchivedDesc: 'Archiva un chat para ocultarlo aquí.', + unarchive: 'Desarchivar', + deletePermanently: 'Eliminar permanentemente', + messages: count => `${count} ${count === 1 ? 'mensaje' : 'mensajes'}`, + restored: 'Restaurado', + deleteConfirm: title => `¿Eliminar permanentemente "${title}"? Esto no se puede deshacer.`, + autoArchiveTitle: 'Archivar automáticamente los chats inactivos', + autoArchiveDesc: + 'Archiva automáticamente los chats que no has usado durante un tiempo. Los chats fijados nunca se archivan y no se elimina nada; los chats archivados solo se mueven aquí.', + autoArchiveDaysLabel: 'Archivar después de', + autoArchiveDaysUnit: 'días de inactividad', + autoArchiveFailed: 'No se pudo actualizar el archivado automático', + defaultDirTitle: 'Directorio de proyecto predeterminado', + defaultDirDesc: + 'Las sesiones nuevas empiezan en esta carpeta salvo que elijas otra. Déjala sin definir para usar tu directorio de inicio.', + defaultDirUpdated: + 'Directorio de proyecto predeterminado actualizado; inicia un chat nuevo (Ctrl/⌘+N) para que el cambio surta efecto.', + defaultsTo: label => `Predeterminado: ${label}.`, + change: 'Cambiar', + choose: 'Elegir', + clear: 'Limpiar', + notSet: 'Sin definir', + failedLoad: 'No se pudieron cargar las sesiones archivadas', + unarchiveFailed: 'No se pudo desarchivar', + deleteFailed: 'No se pudo eliminar', + updateDirFailed: 'No se pudo actualizar el directorio predeterminado', + clearDirFailed: 'No se pudo limpiar el directorio predeterminado' + }, + toolsets: { + loadingConfig: 'Cargando configuración', + savedTitle: 'Credencial guardada', + savedMessage: key => `${key} actualizada.`, + removedTitle: 'Credencial quitada', + removedMessage: key => `${key} quitada.`, + failedSave: key => `No se pudo guardar ${key}`, + failedRemove: key => `No se pudo quitar ${key}`, + failedReveal: key => `No se pudo mostrar ${key}`, + removeConfirm: key => `¿Quitar ${key} de .env?`, + set: 'Definir', + notSet: 'Sin definir', + selectedTitle: 'Proveedor seleccionado', + selectedMessage: provider => `${provider} está activo.`, + failedSelect: provider => `No se pudo seleccionar ${provider}`, + failedLoad: 'No se pudo cargar la configuración de herramientas', + noProviderOptions: + 'Este conjunto de herramientas no tiene opciones de proveedor; actívalo y funcionará con tu configuración actual.', + noProviders: 'No hay proveedores disponibles para este conjunto de herramientas ahora mismo.', + ready: 'Listo', + needsSignIn: 'Necesita iniciar sesión', + needsSetup: 'Configuración requerida', + activeBackend: 'Activo', + activeBackendHint: 'Este es tu backend activo', + useBackend: 'Usar este backend', + nousIncluded: 'Incluido con una suscripción de Nous: inicia sesión con tu cuenta de Nous para activarlo.', + nousAuthNeededTitle: 'Inicia sesión con tu cuenta de Nous', + nousAuthNeededMessage: (provider: string) => + `${provider} está guardado, pero solo funcionará cuando inicies sesión con tu cuenta de Nous.`, + nousAuthSignIn: 'Iniciar sesión', + nousAuthDoneTitle: 'Cuenta de Nous conectada', + nousAuthDoneMessage: 'Los backends de tu suscripción ya están activos.', + nousAuthFailed: 'No se completó el inicio de sesión en Nous', + nousAuthFailedMessage: 'Vuelve a intentarlo.', + nousAuthTryAgain: 'Reintentar', + noApiKeyRequired: 'No se requiere clave API.', + postSetupHint: step => + `Este backend necesita una instalación única (${step}). Se ejecuta en esta máquina y puede tardar unos minutos.`, + postSetupInstalledHint: 'Instalado. Repite la configuración solo si algo no funciona.', + postSetupRun: 'Ejecutar instalación', + postSetupRerun: 'Repetir instalación', + postSetupInstalled: 'Instalado', + postSetupRunning: 'Instalando…', + postSetupStarting: 'Iniciando…', + postSetupCompleteTitle: 'Instalación completa', + postSetupCompleteMessage: step => `${step} instalado.`, + postSetupErrorTitle: 'La instalación terminó con errores', + postSetupErrorMessage: (step: string) => + `La configuración de ${step} no terminó. Abre los registros para ver por qué y vuelve a ejecutar la configuración.`, + postSetupOpenLogs: 'Abrir registros', + postSetupRunAgain: 'Ejecutar de nuevo', + postSetupFailed: step => `No se pudo ejecutar la instalación de ${step}`, + webSearchActive: backend => `Búsqueda: ${backend}`, + webExtractActive: backend => `Extracción: ${backend}`, + webCapabilityUnset: 'sin definir', + webUseForSearch: 'Usar para búsqueda', + webUseForExtract: 'Usar para extracción', + webUsedForSearch: 'Backend de búsqueda', + webUsedForExtract: 'Backend de extracción', + webCapabilitySelectedMessage: (provider, capability) => + `${provider} ahora se encarga de ${capability === 'search' ? 'las búsquedas web' : 'la extracción de contenido web'}.`, + failedSelectCapability: provider => `No se pudo configurar ${provider}`, + loadingModels: 'Cargando catálogo de modelos...', + modelSectionTitle: 'Modelo', + modelCount: count => `${count} ${count === 1 ? 'modelo' : 'modelos'}`, + modelInUse: 'En uso', + modelDefault: 'predeterminado', + modelInactiveHint: 'Selecciona primero este backend para cambiar su modelo.', + modelSelectedTitle: 'Modelo seleccionado', + modelSelectedMessage: model => `${model} se aplica a las sesiones nuevas.`, + failedSelectModel: model => `No se pudo seleccionar ${model}`, + terminalBackend: { + sectionTitle: 'Backend de ejecución', + loading: 'Comprobando backends de ejecución…', + failedLoad: 'No se pudieron cargar los backends del terminal', + ready: 'Listo', + needsSetup: 'Necesita configuración', + unavailable: 'No disponible', + inUse: 'En uso', + selectedTitle: 'Backend seleccionado', + selectedMessage: backend => + `Los comandos del terminal ahora se ejecutan mediante ${backend}. Se aplica a las sesiones nuevas.`, + failedSelect: backend => `No se pudo seleccionar ${backend}`, + needsSetupHint: + 'Este backend está seleccionado sin configuración completa: los comandos fallarán hasta que termine la configuración.', + needsSetupConfirmTitle: (backend: string) => `¿Seleccionar ${backend} de todos modos?`, + needsSetupConfirmDescription: (detail: string) => + `${detail} Las sesiones que empiecen tras este cambio no tendrán herramientas de terminal ni de archivos hasta que termine la configuración.`, + needsSetupConfirmDescriptionGeneric: + 'Este backend todavía no está configurado. Las sesiones que empiecen tras este cambio no tendrán herramientas de terminal ni de archivos hasta que termine la configuración.', + needsSetupConfirmAction: 'Seleccionar de todos modos', + unavailableTitle: 'Los comandos de terminal no están disponibles', + unavailableMessage: (backend: string) => + `Hermes no puede ejecutar comandos de shell ahora mismo: ${backend} no está listo. Cambia a Local o termina de configurar ${backend} y vuelve a intentarlo.`, + openBackendSettings: 'Abrir ajustes del terminal', + useLocal: 'Usar Local', + switchedToLocal: 'Los comandos de terminal ahora se ejecutan localmente. Se aplica a las sesiones nuevas.' + }, + browserRealProfile: { + label: 'Usar mi perfil real del navegador', + description: + 'Copia los inicios de sesión y las cookies de tu navegador predeterminado en una instantánea administrada con la que navega el agente. Tu perfil en uso nunca se abre directamente. Se aplica a las sesiones nuevas.', + enabledTitle: 'Navegación con perfil real activada', + enabledMessage: 'Las sesiones nuevas navegarán con una instantánea de tu perfil predeterminado del navegador.', + disabledTitle: 'Navegación con perfil real desactivada', + disabledMessage: 'Se eliminará la instantánea del perfil; las sesiones nuevas usan un navegador limpio.', + failedSave: 'No se pudo guardar el ajuste del perfil real', + prompt: { + title: 'Mantén la sesión iniciada en tus sitios', + body: 'Deja que Hermes navegue con una instantánea de tu perfil predeterminado del navegador, para que los sitios se abran con la sesión ya iniciada.', + bulletSnapshot: 'Las cookies y los inicios de sesión se copian en una instantánea administrada.', + bulletLiveProfile: 'Tu perfil del navegador en uso nunca se abre directamente.', + bulletLocal: 'Nada sale de este equipo.', + dontShowAgain: 'No volver a mostrar', + notNow: 'Ahora no', + enable: 'Usar mi perfil' + } + } + } + }, + skills: { + tabSkills: 'Skills', + tabToolsets: 'Conjuntos de herramientas', + configuringProfile: 'Configurando:', + all: 'Todo', + searchSkills: 'Buscar skills...', + searchToolsets: 'Buscar conjuntos de herramientas...', + refresh: 'Actualizar skills', + refreshing: 'Actualizando skills', + loading: 'Cargando capacidades...', + noSkillsTitle: 'No se encontraron skills', + noSkillsDesc: 'Prueba una búsqueda más amplia u otra categoría.', + noToolsetsTitle: 'No se encontraron conjuntos de herramientas', + noToolsetsDesc: 'Prueba una búsqueda más amplia.', + noDescription: 'Sin descripción.', + configured: 'Configurado', + needsKeys: 'Necesita claves', + visionModelHint: + 'Visión usa la configuración de tu modelo auxiliar; el modelo compatible con imágenes se elige allí, no aquí para cada proveedor.', + visionModelLink: 'Elegir modelo de visión en Configuración → Modelos', + toolsetsEnabled: (enabled, total) => `${enabled}/${total} conjuntos activados`, + configureToolset: label => `Configurar ${label}`, + toggleToolset: (label, enabled) => + `Activar/desactivar conjunto de herramientas ${label} ${enabled ? 'activado' : 'desactivado'}`, + skillsLoadFailed: 'No se pudieron cargar los skills', + toolsetsRefreshFailed: 'No se pudieron actualizar los conjuntos de herramientas', + skillEnabled: 'Skill activado', + skillDisabled: 'Skill desactivado', + toolsetEnabled: 'Conjunto activado', + toolsetDisabled: 'Conjunto desactivado', + appliesToNewSessions: name => `${name} se aplica a sesiones nuevas.`, + failedToUpdate: name => `No se pudo actualizar ${name}`, + sortMostUsed: 'Más usadas', + sortAlpha: 'A–Z', + sortMostUsedDesc: '↓ Más usadas', + sortLeastUsedAsc: '↑ Menos usadas', + enableAll: 'Activar todo', + disableAll: 'Desactivar todo', + disableUnused: 'Desactivar los que no se usan', + bulkUpdated: count => + `${count === 1 ? 'Se actualizó' : 'Se actualizaron'} ${count} ${count === 1 ? 'elemento' : 'elementos'} para las sesiones nuevas.`, + bulkNoChange: 'No hay nada que cambiar.', + usageCount: count => `usado ${count}×`, + provenance: { + agent: 'Aprendido', + bundled: 'Integrado', + hub: 'Hub' + }, + emptyNoneFound: noun => `No se encontraron ${noun}`, + emptyNothingMatches: query => `No hay coincidencias para “${query}”.`, + emptyNoneAvailable: noun => `Aún no hay ${noun} disponibles.`, + changesApplyNewSessions: 'Los cambios se aplican a las sesiones nuevas.', + skillUpdated: 'Skill actualizado', + edit: 'Editar', + archive: 'Archivar', + skillArchivedTitle: 'Skill archivado', + skillArchivedMessage: 'Puedes restaurarlo con hermes curator restore.', + tabPlugins: 'Plugins', + plugins: { + agentTitle: 'Plugins del agente', + agentBlurb: + 'Amplían el agente del perfil seleccionado: herramientas, hooks, proveedores. Se aplican tras reiniciar el gateway.', + pageBlurb: 'Un plugin puede ampliar esta app, el agente o ambos; cada mitad tiene su propio interruptor.', + halfDesktop: 'Escritorio', + halfDesktopHint: 'esta app, igual para todos los perfiles', + halfAgent: 'Agente', + halfAgentIn: (profile: string) => `Agente en ${profile}`, + defaultProfile: 'Hermes (predeterminado)', + kindAgent: 'Agente', + kindDesktop: 'Escritorio', + kindBoth: 'Agente + Escritorio', + installAgentHere: 'Instalar aquí', + installAgentHereTip: (profile: string) => + `La mitad de escritorio está cargada en esta app, pero la mitad del agente no está instalada en ${profile}. Instálala allí.`, + installAgentHereNoOrigin: + 'La mitad del agente no está instalada en este perfil y este paquete se copió a mano (sin entrada de catálogo ni remoto de git), así que no se puede instalar desde aquí. Copia su carpeta en el perfil o reinstálalo desde Git.', + desktopHalfPending: 'copiando…', + desktopHalfPendingTip: + 'Este paquete incluye una mitad de escritorio que todavía no se ha copiado en la app. Usa Volver a escanear o reinicia la app.', + desktopHalfRemote: 'no disponible (backend remoto)', + desktopHalfRemoteTip: + 'La mitad de escritorio de este paquete está en el disco del backend remoto, que esta app no puede leer. Para usarla aquí, ejecuta Instalar desde Git con la URL del repositorio del paquete y el destino Escritorio marcado; eso clona la mitad de escritorio en este equipo.', + emptyAll: 'Todavía no hay plugins.', + empty: 'No hay plugins del agente instalados para este perfil.', + emptyHint: 'Explora el catálogo de abajo e instala un plugin revisado con un clic.', + loadFailed: 'No se pudieron cargar los plugins del agente', + toggleFailed: (name: string) => `No se pudo cambiar ${name}`, + legacyBackend: + 'Este backend es anterior a los interruptores de plugins por clave: actualiza Hermes para gestionarlo aquí.', + portableBadge: 'portátil', + serverStates: { + connected: 'conectado', + app_not_running: 'la app no se está ejecutando', + endpoint_unavailable: 'endpoint no disponible', + no_interactive_session: 'sin sesión interactiva', + version_too_old: 'versión demasiado antigua', + missing_app: 'falta la app', + unknown: 'estado desconocido' + }, + catalogTitle: 'Catálogo de plugins', + catalogBrowse: 'Explorar', + catalogHide: 'Ocultar el explorador del catálogo', + catalogHint: + 'Pulsa "+ Añadir a este agente" en cualquier plugin: las entradas revisadas se instalan en su commit fijado en el perfil seleccionado. Los plugins agente+escritorio incluidos ofrecen ambas mitades.', + alreadyInstalled: (name: string) => `${name} ya está instalado en este perfil.`, + catalogProvenance: (sha: string) => + `Instalado desde el catálogo de Hermes${sha ? ` en el commit fijado ${sha}` : ''}.`, + pinnedProvenance: (sha: string) => + `Fijado al commit ${sha}. Las actualizaciones se rechazan hasta que se reinstale con un nuevo commit fijado.`, + pinnedBadge: (sha: string) => `fijado @ ${sha}`, + tierOfficial: 'oficial', + tierCommunity: 'comunidad', + updateToPin: (sha: string) => `Actualizar a ${sha}`, + updateFailed: (name: string) => `No se pudo actualizar ${name}`, + updated: (name: string) => + `${name} se actualizó al commit fijado actual del catálogo. Reinicia el gateway para aplicarlo.`, + updateConsentTitle: (name: string) => `${name} pide más`, + updateConsentBody: (name: string, sha: string) => + `El nuevo commit fijado de ${name} en el catálogo (${sha}) añade superficies que la versión instalada no tiene. Aplícalo solo si confías en ellas:`, + updateConsentConfirm: 'Aplicar actualización', + uninstall: 'Desinstalar', + uninstallTip: (name: string, profile: string) => `Desinstalar ${name} de ${profile}`, + uninstallConfirmTitle: (name: string) => `¿Desinstalar ${name}?`, + uninstallConfirmBody: (name: string, profile: string) => + `Esto elimina los archivos del plugin del perfil ${profile}. Cualquier mitad de escritorio que incluyera se quita con él. Puedes reinstalarlo desde el catálogo o desde Git cuando quieras.`, + uninstallFailed: (name: string) => `No se pudo desinstalar ${name}`, + uninstalled: (name: string) => `${name} desinstalado. Reinicia el gateway para descargarlo.`, + uninstallDesktopTip: (name: string) => `Desinstalar ${name} de esta app`, + uninstallDesktopConfirmBody: (name: string) => + `Esto elimina ${name} de la carpeta desktop-plugins de este equipo y lo descarga ahora. Puedes reinstalarlo desde Git o volver a poner la carpeta cuando quieras.`, + uninstalledDesktop: (name: string) => `${name} desinstalado.`, + deepLinkErrorTitle: 'Enlace de instalación de plugin rechazado', + deepLinkCatalogInvalidName: 'Falta el nombre del catálogo del enlace o no es válido.', + deepLinkCatalogUnknown: (name: string) => + `\u201C${name}\u201D no está en el catálogo de plugins de Hermes. No se instaló nada.`, + deepLinkCatalogUnavailable: + 'No se pudo cargar el catálogo de plugins de Hermes. Comprueba tu conexión y vuelve a abrir el enlace.', + settingsToggle: (name: string) => `Configuración: ${name}`, + settingsForm: { + save: 'Guardar configuración', + saved: (name: string) => `Configuración de ${name} guardada.`, + saveFailed: (name: string) => `No se pudo guardar la configuración de ${name}`, + optional: '(opcional)', + secretSet: '•••••••• (configurado)', + secretStoredAs: (env: string) => + `Se guarda en el .env del perfil como ${env}, nunca en config.yaml; déjalo en blanco para conservar el valor actual.` + } + }, + officialCatalog: 'Disponibles para instalar', + officialPill: 'Oficial', + hub: { + searchPlaceholder: 'Buscar en el hub de skills', + search: 'Buscar', + searching: 'Buscando…', + connectingHubs: 'Conectando con los hubs de skills…', + connectedHubs: 'Hubs conectados:', + featured: 'Skills destacadas', + landingHint: + 'Busca en el hub para explorar skills instalables del índice oficial, GitHub y fuentes de la comunidad.', + noResults: 'No se encontraron skills coincidentes en el hub.', + resultCount: (count, ms) => + `${count} ${count === 1 ? 'resultado' : 'resultados'}${ms !== null ? ` en ${ms} ms` : ''}`, + timedOut: sources => `Se agotó el tiempo de espera: ${sources}`, + installed: 'Instalado', + install: 'Instalar', + installing: 'Instalando…', + uninstall: 'Desinstalar', + uninstalling: 'Desinstalando…', + updateAll: 'Actualizar skills instaladas', + updating: 'Actualizando…', + preview: 'Vista previa', + scan: 'Analizar', + scanning: 'Analizando…', + close: 'Cerrar', + files: 'Archivos', + noReadme: 'Esta skill no tiene vista previa de SKILL.md.', + trust: { + builtin: 'integrado', + trusted: 'de confianza', + community: 'comunidad' + }, + verdictSafe: 'Seguro', + verdictCaution: 'Precaución', + verdictDangerous: 'Peligroso', + policyAllow: 'Instalación permitida', + policyAsk: 'Revisar antes de instalar', + policyBlock: 'Instalación bloqueada por la política', + findings: count => `${count} ${count === 1 ? 'hallazgo' : 'hallazgos'}`, + noFindings: 'No se encontraron hallazgos de seguridad.', + installStarted: name => `Instalando ${name}…`, + uninstallStarted: name => `Desinstalando ${name}…`, + updateStarted: 'Actualizando las skills instaladas…', + actionFailed: 'Falló la acción de la skill', + installBlockedTitle: (name: string) => `No se pudo instalar ${name}`, + installBlockedMessage: (findings: number, unverified: boolean) => + `El análisis de seguridad marcó ${findings > 0 ? `${findings} ${findings === 1 ? 'elemento' : 'elementos'}` : 'patrones de riesgo'} para revisar${unverified ? ' y la skill proviene de una fuente no verificada' : ''}. Lee el análisis antes de decidir si confías en el autor.`, + viewScan: 'Ver análisis', + openLog: 'Abrir registro', + actionLog: 'Registro de acciones', + alreadyInstalled: (name: string) => `"${name}" ya está instalada`, + pickerTitle: 'Skills Hub', + pickerBrowse: 'Explorar todo el hub', + pickerHide: 'Ocultar el explorador del hub', + pickerHint: 'Pulsa "+ Añadir a este agente" en cualquier skill: se instala y aparece en la lista de arriba.', + loadFailed: 'No se pudo cargar el hub de skills', + previewFailed: 'No se pudo cargar la vista previa de la skill', + scanFailed: 'Falló el análisis de seguridad', + searchFailed: 'Falló la búsqueda en el hub' + } + }, + starmap: { + title: 'Grafo de memoria', + subtitle: (nodes, clusters) => `${nodes} skills en ${clusters} categorías`, + close: 'Cerrar grafo de memoria', + refresh: 'Actualizar', + memory: 'Memoria', + filterAll: 'Todo', + filterUsed: 'Usado', + filterLearned: 'Aprendido', + viewGraph: 'Grafo', + loadFailed: 'No se pudo cargar el grafo de memoria', + loading: 'Cargando…', + emptyTitle: 'Aún no se ha aprendido nada', + emptyDesc: 'A medida que Hermes crea skills y memorias para tu trabajo, aparecerán aquí.', + share: 'Compartir mapa', + shareHint: + 'Copia el código para compartir este mapa o pega uno para cargarlo. Solo incluye el diseño, no el texto de tus memorias ni skills.', + shareTitle: 'Importar / exportar mapa', + sharePlaceholder: 'Pega un código de mapa…', + copy: 'Copiar código del mapa', + copied: '¡Copiado!', + importMap: 'Importar un mapa', + importBtn: 'Cargar', + importEmpty: 'Pega un código de mapa para cargarlo.', + importSuccess: nodes => `Se cargó un mapa con ${nodes} ${nodes === 1 ? 'nodo' : 'nodos'}.`, + importedBadge: 'mapa importado', + resetToMine: 'Volver a mi mapa' + }, + agents: { + extendedTranscript: 'Transcripción ampliada', + transcriptTruncated: 'Mostrando los últimos 16 KiB', + transcriptUnavailable: 'Transcripción en vivo no disponible', + close: 'Cerrar agentes', + title: 'Árbol de generación', + subtitle: 'Actividad de subagentes en vivo para el turno actual.', + emptyTitle: 'No hay subagentes activos', + emptyDesc: 'Cuando un turno delegue trabajo, los agentes hijos mostrarán su progreso aquí.', + running: 'En ejecución', + failed: 'Falló', + done: 'Listo', + streaming: 'Transmitiendo', + files: 'Archivos', + moreFiles: count => `+${count} archivos más`, + moreAgents: (count: number) => `+${count} agentes más`, + queued: 'En cola', + waitingActivity: 'Esperando actividad', + steer: 'Orientar', + steerPlaceholder: 'Instrucciones para este subagente', + steerQueued: 'En cola para el siguiente punto de control', + stopRequested: 'Detención solicitada', + requestRejected: 'El subagente no aceptó la solicitud', + delegation: index => `Delegación ${index}`, + workers: count => `${count} ${count === 1 ? 'trabajador' : 'trabajadores'}`, + workersActive: count => `${count} activos`, + agentsCount: count => `${count} ${count === 1 ? 'agente' : 'agentes'}`, + activeCount: count => `${count} activos`, + failedCount: count => `${count} fallidos`, + toolsCount: count => `${count} herramientas`, + filesCount: count => `${count} archivos`, + updatedAgo: age => `actualizado ${age}`, + ageNow: 'ahora', + ageSeconds: seconds => `hace ${seconds}s`, + ageMinutes: minutes => `hace ${minutes}m`, + ageHours: hours => `hace ${hours}h`, + ageDays: days => `hace ${days}d`, + durationSeconds: seconds => `${seconds}s`, + durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`, + tokens: value => `${value} tok` + }, + commandCenter: { + close: 'Cerrar Centro de comandos', + paletteTitle: 'Paleta de comandos', + back: 'Atrás', + searchPlaceholder: 'Buscar sesiones, vistas y acciones', + goTo: 'Ir a', + goToSession: 'Ir a la sesión', + branches: 'Ramas', + projects: 'Proyectos', + openFolder: 'Abrir carpeta como proyecto…', + openFolderAt: path => `Abrir carpeta como proyecto — ${path}`, + newSessionInProject: project => `Nueva sesión en ${project}`, + commands: 'Comandos', + startInBranch: branch => `Nueva conversación en ${branch}`, + commandCenter: 'Centro de comandos', + appearance: 'Apariencia', + settings: 'Configuración', + changeTheme: 'Cambiar tema...', + changeColorMode: 'Cambiar modo de color...', + pets: { + title: 'Mascotas', + placeholder: 'Buscar mascotas…', + loading: 'Cargando la galería petdex…', + error: 'No se pudo acceder a la galería petdex.', + staleBackend: 'Reinicia Hermes para usar mascotas; el backend es anterior a esta función.', + empty: 'No hay mascotas coincidentes.', + turnOff: 'Desactivar', + turnOn: 'Activar', + installed: 'Instalada', + generatedTag: 'Generada', + adoptFailed: 'No se pudo adoptar esa mascota.', + toggleFailed: enabled => `No se pudo ${enabled ? 'encender' : 'apagar'} la mascota.`, + noneAvailable: 'No hay mascotas disponibles. Elige una de abajo para instalarla.' + }, + generatePet: { + title: 'Generar una mascota', + placeholder: 'Describe una mascota para generar…', + promptHint: 'Escribe una descripción y pulsa Intro para crear cuatro versiones.', + readyHint: 'Pulsa Intro para crear cuatro versiones a partir de tu descripción.', + generate: 'Generar', + generating: 'Generando…', + retry: 'Reintentar', + hatch: 'Eclosionar', + spawning: 'Creando…', + hatching: 'Eclosionando tu mascota…', + hatchingSub: 'Dándole vida…', + hatched: '¡Ha eclosionado!', + hatchRow: (_state, done, total) => `Bocetando el fotograma ${done} de ${total}…`, + hatchComposing: 'Uniendo las piezas…', + hatchSaving: 'Ya casi…', + namePlaceholder: 'Ponle nombre a tu mascota', + staleBackend: 'Actualiza Hermes para generar mascotas.', + backgroundHint: 'Puedes cerrar esto; Hermes te avisará cuando termine.', + slowProviderHint: 'Esto puede tardar varios minutos', + remix: 'Remixar', + remixConfirmTitle: '¿Remixar este aspecto?', + remixConfirmBody: + 'Esto genera un nuevo conjunto de borradores usando este como punto de partida. Puede tardar varios minutos.', + genericError: 'La generación falló. Inténtalo de nuevo o elige una sugerencia.', + referenceImageTooLarge: 'La imagen de referencia es demasiado grande. Usa una de menos de 16 MB.', + referenceImageInvalid: 'No se pudo leer esa imagen de referencia. Prueba con un PNG, JPG, WebP o GIF.', + adopt: 'Adoptar', + startOver: 'Empezar de nuevo' + }, + installTheme: { + title: 'Instalar tema…', + pageTitle: 'Instalar tema', + placeholder: 'Buscar en VS Code Marketplace…', + loading: 'Buscando en Marketplace…', + error: 'No se pudo acceder al Marketplace.', + empty: 'No hay temas coincidentes.', + install: 'Instalar', + installing: 'Instalando…', + installed: 'Instalado', + installs: count => `${count} instalaciones` + }, + settingsFields: 'Campos de configuración', + mcpServers: 'Servidores MCP', + archivedChats: 'Chats archivados', + sections: { + maintenance: 'Mantenimiento', + sessions: 'Sesiones', + system: 'Sistema', + usage: 'Uso' + }, + sectionDescriptions: { + maintenance: 'Diagnóstico, copias de seguridad, curador y datos de memoria', + sessions: 'Buscar y gestionar sesiones', + system: 'Estado, registros y acciones del sistema', + usage: 'Actividad de tokens, coste y skills a lo largo del tiempo' + }, + nav: { + newChat: { + title: 'Nueva sesión', + detail: 'Inicia una sesión nueva' + }, + settings: { + title: 'Configuración', + detail: 'Configura Hermes Desktop' + }, + capabilities: { + title: 'Capacidades', + detail: 'Skills, herramientas, servidores MCP y plugins' + }, + messaging: { + title: 'Mensajería', + detail: 'Configura Telegram, Slack, Discord y más' + }, + artifacts: { + title: 'Artefactos', + detail: 'Explora salidas generadas' + } + }, + sectionEntries: { + sessions: { + title: 'Panel de sesiones', + detail: 'Busca, fija y gestiona sesiones' + }, + system: { + title: 'Panel del sistema', + detail: 'Estado del gateway, registros, reinicio/actualización' + }, + usage: { + title: 'Panel de uso', + detail: 'Actividad de tokens, coste y skills' + } + }, + providerNavigate: 'Navegar', + providerSessions: 'Sesiones', + refresh: 'Actualizar', + refreshing: 'Actualizando...', + noResults: 'No se encontraron resultados.', + pinSession: 'Fijar sesión', + unpinSession: 'Desfijar sesión', + exportSession: 'Exportar sesión', + deleteSession: 'Eliminar sesión', + noSessions: 'Aún no hay sesiones.', + gatewayRunning: 'Gateway de mensajería en ejecución', + gatewayStopped: 'Gateway de mensajería detenido', + hermesActiveSessions: (version, count) => `Hermes ${version} · Sesiones activas ${count}`, + restartGateway: 'Reiniciar gateway', + openBrowser: 'Abrir navegador', + gatewayRestartFailed: 'No se pudo reiniciar el gateway.', + sharedGatewayRestartTitle: '¿Reiniciar el gateway compartido?', + sharedGatewayRestartDescription: (bots: string) => `Todos los bots de este dispositivo se reconectan: ${bots}`, + sharedGatewayRestartConfirm: 'Reiniciar todo', + sharedGatewayRestarted: (count: number) => + `Gateway compartido reiniciado (${count} ${count === 1 ? 'bot' : 'bots'})`, + updateHermes: 'Actualizar Hermes', + reloadWindow: 'Recargar ventana', + actionRunning: 'en ejecución', + actionDone: 'listo', + actionFailed: 'falló', + actionStartedWaiting: 'Acción iniciada, esperando estado...', + loadingStatus: 'Cargando estado...', + recentLogs: 'Registros recientes', + noLogs: 'Aún no hay registros cargados.', + days: count => `${count}d`, + statSessions: 'Sesiones', + statApiCalls: 'Llamadas API', + statTokens: 'Tokens entrada/salida', + statCost: 'Coste est.', + actualCost: cost => `real ${cost}`, + loadingUsage: 'Cargando uso...', + noUsage: period => `Sin uso en los últimos ${period} días.`, + retry: 'Reintentar', + dailyTokens: 'Tokens diarios', + input: 'entrada', + output: 'salida', + noDailyActivity: 'Sin actividad diaria.', + topModels: 'Modelos principales', + noModelUsage: 'Aún no hay uso de modelos.', + topSkills: 'Skills principales', + noSkillActivity: 'Aún no hay actividad de skills.', + actions: count => `${count} acciones`, + logFile: 'Archivo de registro', + logLevel: 'Nivel', + logSearchPlaceholder: 'Filtrar líneas de registro…', + maintenance: { + runOps: 'Diagnóstico', + doctor: 'Ejecutar diagnóstico', + doctorDesc: 'Comprobar el estado de la instalación, la configuración y los proveedores', + securityAudit: 'Auditoría de seguridad', + securityAuditDesc: 'Analizar la configuración y las skills en busca de ajustes riesgosos', + backup: 'Crear copia de seguridad', + backupDesc: 'Comprimir la configuración, memorias, skills y sesiones en un archivo ZIP', + debugShare: 'Compartir datos de depuración', + debugShareDesc: + 'Sube un informe y registros con datos sensibles ocultos y obtén enlaces para compartir (se eliminan automáticamente en 6 h)', + debugShareRunning: 'Subiendo informe de depuración…', + debugShareLinks: 'Enlaces para compartir', + debugShareFailed: 'No se pudieron compartir los datos de depuración', + copyLink: 'Copiar enlace', + linkCopied: 'Enlace copiado', + curator: 'Curador de skills', + curatorDesc: 'Revisión en segundo plano que archiva skills creados por agentes que ya no se usan', + curatorPaused: 'En pausa', + curatorActive: 'Activo', + curatorDisabled: 'Desactivado', + curatorLastRun: when => `Última ejecución: ${when}`, + curatorNeverRan: 'Aún no se ha ejecutado', + pause: 'Pausar', + resume: 'Reanudar', + runNow: 'Ejecutar ahora', + memoryData: 'Datos de memoria', + memoryDataDesc: 'Archivos de memoria integrados que se incluyen en cada sesión', + memoryProvider: name => `Proveedor activo: ${name}`, + builtinMemory: 'integrada', + memoryFile: 'Memoria del agente (MEMORY.md)', + userFile: 'Perfil de usuario (USER.md)', + bytes: size => size, + empty: 'vacío', + resetMemory: 'Restablecer memoria', + resetUser: 'Restablecer perfil', + resetAll: 'Restablecer ambos', + resetConfirm: target => `¿Eliminar ${target}? Esta acción no se puede deshacer.`, + resetDone: files => `Eliminado: ${files}.`, + resetFailed: 'No se pudo restablecer la memoria', + actionStarted: name => `${name} se inició; siguiendo el registro…`, + actionFailed: name => `No se pudo iniciar ${name}`, + running: 'En ejecución…', + viewLog: 'Registro de acciones' + } + }, + messaging: { + search: 'Buscar mensajería...', + loading: 'Cargando plataformas de mensajería...', + loadFailed: 'No se pudieron cargar las plataformas de mensajería', + states: { + connected: 'Conectado', + connecting: 'Conectando', + disabled: 'Deshabilitado', + fatal: 'Error', + gateway_stopped: 'Gateway de mensajería detenido', + not_configured: 'Necesita configuración', + pending_restart: 'Reinicio necesario', + retrying: 'Reintentando', + startup_failed: 'Falló el inicio' + }, + unknown: 'Desconocido', + hintPendingRestart: 'Reinicia el gateway desde la barra de estado para aplicar este cambio.', + sharedListenerUrl: 'Servido en el listener del gateway compartido en', + hintGatewayStopped: 'Inicia el gateway desde la barra de estado para conectar.', + credentialsSet: 'Credenciales definidas', + needsSetup: 'Necesita configuración', + gatewayStopped: 'Gateway de mensajería detenido', + getCredentials: 'Obtener credenciales', + openSetupGuide: 'Abrir guía de configuración', + required: 'Obligatorio', + recommended: 'Recomendado', + advanced: count => `Avanzado (${count})`, + noTokenNeeded: + 'Esta plataforma no necesita un token aquí. Usa la guía de configuración de arriba y actívala abajo.', + enabled: 'Activado', + disabled: 'Desactivado', + unsavedChanges: 'Cambios sin guardar', + saving: 'Guardando...', + saveChanges: 'Guardar cambios', + saved: 'Guardado', + replaceValue: 'Reemplazar valor actual', + openDocs: 'Abrir docs', + clearField: key => `Limpiar ${key}`, + enableAria: name => `Activar ${name}`, + disableAria: name => `Desactivar ${name}`, + platformEnabled: name => `${name} activado`, + platformDisabled: name => `${name} desactivado`, + restartToApply: 'Reinicia el gateway para que este cambio surta efecto.', + setupSaved: name => `Configuración de ${name} guardada`, + restartToReconnect: 'Reinicia el gateway para reconectar con las credenciales nuevas.', + appliedLive: 'Aplicado al gateway en ejecución.', + connectingLive: 'El gateway en ejecución se está conectando con las credenciales nuevas.', + keyCleared: key => `${key} limpiado`, + setupUpdated: name => `La configuración de ${name} se actualizó.`, + failedUpdate: name => `No se pudo actualizar ${name}`, + failedSave: name => `No se pudo guardar ${name}`, + failedClear: key => `No se pudo limpiar ${key}`, + pendingRequests: count => `Solicitudes pendientes (${count})`, + pendingAria: count => + `${count} solicitud${count === 1 ? '' : 'es'} de emparejamiento pendiente${count === 1 ? '' : 's'}`, + approvedUsers: count => `Usuarios aprobados (${count})`, + approve: 'Aprobar', + approving: 'Aprobando...', + revoke: 'Revocar', + revoking: 'Revocando...', + revokeAria: name => `Revocar ${name}`, + revokeTitle: 'Revocar acceso', + revokeDesc: name => `${name} perderá el acceso y dejará de ser reconocido en su próximo mensaje.`, + approvedUser: name => `${name} aprobado`, + approvedHint: 'Se le reconoce automáticamente en su próximo mensaje.', + revokedUser: name => `${name} revocado`, + failedApprove: name => `No se pudo aprobar a ${name}`, + failedRevoke: name => `No se pudo revocar a ${name}`, + pairingLockedOut: 'Demasiados fallos de aprobación — esta plataforma está bloqueada. Inténtalo de nuevo más tarde.', + waitingSince: minutes => (minutes < 1 ? 'justo ahora' : `hace ${minutes}m`), + restartNeeded: 'Guardado. Reinicia el gateway de mensajería para que la nueva configuración surta efecto.', + restartNow: 'Reiniciar ahora', + restarting: 'Reiniciando…', + restartFailedManual: 'Hermes no pudo reiniciarse para aplicar tu configuración de mensajería', + restartFailedManualDetail: + 'Vuelve a pulsar Reiniciar; si sigue fallando, abre los registros y envía un diagnóstico.', + restartAgain: 'Reiniciar de nuevo', + openLogs: 'Abrir registros', + telegramQr: { + title: 'Elige cómo conectar tu bot de Telegram', + subtitle: + 'Ambas opciones conectan un bot que controlas y guardan sus credenciales solo en esta instalación de Hermes.', + quickSetup: 'Configuración rápida', + recommended: 'Recomendado', + quickHelp: + 'Escanea un código QR y confirma en Telegram. Hermes crea el bot y detecta tu ID de usuario de Telegram automáticamente.', + createWithQr: 'Crear con QR', + starting: 'Iniciando…', + replaceWarning: + 'Ya hay credenciales de Telegram configuradas. Una nueva configuración por QR o un nuevo token de bot sustituirá al bot actual cuando guardes.', + scanHint: 'Escanéalo con la app de Telegram en tu teléfono o abre el enlace en este equipo.', + waiting: 'Esperando a Telegram…', + expiresIn: (remaining: string) => `Caduca en ${remaining}`, + expired: 'Caducado', + openTelegram: 'Abrir Telegram', + ready: 'Bot creado', + allowedUsers: 'Usuarios permitidos', + ownerDetected: 'Propietario detectado', + addAtLeastOne: 'Añade al menos un ID de usuario de Telegram.', + userIdPlaceholder: 'ID de usuario de Telegram', + add: 'Añadir', + numericOnly: 'Los ID de usuario de Telegram permitidos deben ser numéricos.', + saveAndRestart: 'Guardar y reiniciar', + applying: 'Guardando…', + pairingExpired: + 'La vinculación con Telegram caducó. Inicia una nueva configuración por QR para volver a intentarlo.', + stillWaiting: (detail: string) => `Seguimos esperando a Telegram. Reintentando tras: ${detail}`, + savedRestarting: 'Telegram guardado; reiniciando el gateway…', + savedRestartFailed: (detail: string) => `Telegram guardado; falló el reinicio del gateway${detail}` + }, + fieldCopy: { + TELEGRAM_BOT_TOKEN: { + label: 'Token del bot', + help: 'Crea un bot con @BotFather y pega el token que te dé.', + placeholder: 'Pegar token del bot de Telegram' + }, + TELEGRAM_ALLOWED_USERS: { + label: 'IDs de usuarios de Telegram permitidos', + help: 'Recomendado. IDs numéricos separados por comas desde @userinfobot. Sin esto, cualquiera puede enviar DM a tu bot.' + }, + TELEGRAM_PROXY: { + label: 'URL de proxy', + help: 'Solo necesario en redes donde Telegram está bloqueado.' + }, + DISCORD_BOT_TOKEN: { + label: 'Token del bot', + help: 'Crea una aplicación en Discord Developer Portal, añade un bot y pega su token.' + }, + DISCORD_ALLOWED_USERS: { + label: 'IDs de usuarios de Discord permitidos', + help: 'Recomendado. IDs de usuarios de Discord separados por comas.' + }, + DISCORD_REPLY_TO_MODE: { + label: 'Estilo de respuesta', + help: '`first`, `all` u `off`.' + }, + DISCORD_ALLOW_ALL_USERS: { + label: 'Permitir todos los usuarios de Discord', + help: 'Solo desarrollo. Si es true, cualquiera puede enviar DM al bot sin allowlist.' + }, + DISCORD_HOME_CHANNEL: { + label: 'ID del canal principal', + help: 'Canal donde el bot envía mensajes proactivos (salida de cron, recordatorios).' + }, + DISCORD_HOME_CHANNEL_NAME: { + label: 'Nombre del canal principal', + help: 'Nombre visible del canal principal en registros y salida de estado.' + }, + BLUEBUBBLES_ALLOW_ALL_USERS: { + label: 'Permitir todos los usuarios de iMessage', + help: 'Si es true, omite la allowlist de BlueBubbles.' + }, + MATTERMOST_ALLOW_ALL_USERS: { + label: 'Permitir todos los usuarios de Mattermost' + }, + MATTERMOST_HOME_CHANNEL: { + label: 'Canal principal' + }, + QQ_ALLOW_ALL_USERS: { + label: 'Permitir todos los usuarios de QQ' + }, + QQBOT_HOME_CHANNEL: { + label: 'Canal principal de QQ', + help: 'Canal o grupo predeterminado para entrega cron.' + }, + QQBOT_HOME_CHANNEL_NAME: { + label: 'Nombre del canal principal de QQ' + }, + SLACK_BOT_TOKEN: { + label: 'Token del bot de Slack', + help: 'Usa el token del bot de OAuth & Permissions después de instalar tu app de Slack.', + placeholder: 'Pegar token del bot de Slack' + }, + SLACK_APP_TOKEN: { + label: 'Token de app de Slack', + help: 'Usa el token de nivel de app requerido para Socket Mode.', + placeholder: 'Pegar token de app de Slack' + }, + SLACK_ALLOWED_USERS: { + label: 'IDs de usuarios de Slack permitidos', + help: 'Recomendado. IDs de Slack separados por comas.' + }, + MATTERMOST_URL: { + label: 'URL del servidor', + placeholder: 'https://mattermost.example.com' + }, + MATTERMOST_TOKEN: { + label: 'Token del bot' + }, + MATTERMOST_ALLOWED_USERS: { + label: 'IDs de usuarios permitidos', + help: 'Recomendado. IDs de Mattermost separados por comas.' + }, + MATRIX_HOMESERVER: { + label: 'URL del homeserver', + placeholder: 'https://matrix.org' + }, + MATRIX_ACCESS_TOKEN: { + label: 'Token de acceso' + }, + MATRIX_USER_ID: { + label: 'ID de usuario del bot', + placeholder: '@hermes:example.org' + }, + MATRIX_ALLOWED_USERS: { + label: 'IDs de usuarios de Matrix permitidos', + help: 'Recomendado. IDs separados por comas en formato @usuario:servidor.' + }, + SIGNAL_HTTP_URL: { + label: 'URL del puente Signal', + placeholder: 'http://127.0.0.1:8080', + help: 'URL de un puente REST signal-cli en ejecución.' + }, + SIGNAL_ACCOUNT: { + label: 'Número de teléfono', + help: 'El número registrado con tu puente signal-cli.' + }, + SIGNAL_ALLOWED_USERS: { + label: 'Usuarios de Signal permitidos', + help: 'Recomendado. Identificadores de Signal separados por comas.' + }, + WHATSAPP_ENABLED: { + label: 'Activar puente de WhatsApp', + help: 'Se define automáticamente con el interruptor de abajo. No lo toques salvo que sepas que lo necesitas.' + }, + WHATSAPP_MODE: { + label: 'Modo del puente' + }, + WHATSAPP_ALLOWED_USERS: { + label: 'Usuarios de WhatsApp permitidos', + help: 'Recomendado. Números de teléfono o IDs de WhatsApp separados por comas.' + } + }, + platformIntro: {} + }, + webhooks: { + search: 'Buscar webhooks…', + loading: 'Cargando webhooks…', + loadFailed: 'No se pudieron cargar los webhooks', + subscriptions: count => `Suscripciones (${count})`, + hint: 'Los cambios en las suscripciones se recargan al instante cuando el receptor está en ejecución. Las suscripciones desactivadas rechazan los eventos entrantes.', + empty: 'Aún no hay suscripciones de webhook.', + disabledTitle: 'Receptor de webhooks desactivado', + disabledBody: + 'Los webhooks tienen su propia plataforma de gateway. Actívalos aquí para aceptar eventos HTTP entrantes; los canales de chat solo son necesarios cuando una suscripción entrega contenido a Telegram, Discord, Slack u otro canal.', + enable: 'Activar webhooks', + enabling: 'Activando…', + enabled: name => `Activado: "${name}"`, + disabled: name => `Desactivado: "${name}"`, + enableRow: 'Activar', + disableRow: 'Desactivar', + delete: 'Eliminar', + deleting: 'Eliminando…', + deleted: 'Webhook eliminado', + deleteTitle: 'Eliminar webhook', + deleteDescPrefix: 'Esto eliminará permanentemente ', + deleteDescSuffix: '. No se puede deshacer.', + deleteFailed: name => `No se pudo eliminar "${name}"`, + toggleFailed: (name, enabled) => `No se pudo ${enabled ? 'activar' : 'desactivar'} "${name}"`, + newSubscription: 'Nueva suscripción', + restarting: 'Reiniciando el gateway…', + restartNeeded: + 'Los webhooks están activados, pero el gateway todavía debe reiniciarse para que el receptor pueda ponerse en línea.', + restartGateway: 'Reiniciar gateway', + restartingGateway: 'Reiniciando…', + restartFailed: detail => `Falló el reinicio del gateway${detail}`, + enabledRestarting: 'Webhooks activados; reiniciando el gateway…', + all: '(todos)', + deliverOnly: 'solo entrega', + createdTitle: 'Suscripción creada', + createdSecretHint: 'Copia el secreto ahora; solo se muestra una vez.', + webhookUrl: 'URL del webhook', + secretOnce: 'Secreto (se muestra una vez)', + done: 'Listo', + fieldName: 'Nombre', + fieldNamePlaceholder: 'p. ej., github-push', + fieldDescription: 'Descripción', + fieldDescriptionPlaceholder: 'Qué hace este webhook (opcional)', + fieldEvents: 'Eventos', + fieldEventsPlaceholder: 'separados por comas; en blanco para todos', + fieldSkills: 'Skills', + fieldSkillsPlaceholder: 'nombres de skills separados por comas (opcional)', + fieldDeliver: 'Entregar a', + fieldDeliverOnly: 'Entregar solo la carga útil', + fieldPrompt: 'Prompt', + fieldPromptPlaceholder: 'Instrucciones para el agente cuando se active este webhook (opcional)', + nameRequired: 'Nombre obligatorio', + create: 'Crear', + creating: 'Creando…', + created: 'Creado', + createFailed: detail => `No se pudo crear: ${detail}`, + copy: 'Copiar', + deliverOptions: { + log: 'Registro', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + email: 'Correo electrónico', + github_comment: 'Comentario de GitHub' + } + }, + profiles: { + close: 'Cerrar perfiles', + nameHint: 'Letras minúsculas, dígitos, guiones y guiones bajos. Debe empezar con una letra o dígito.', + title: 'Perfiles', + count: count => `${count} ${count === 1 ? 'perfil' : 'perfiles'}`, + search: 'Buscar perfiles…', + loading: 'Cargando perfiles...', + newProfile: 'Nuevo perfil', + importProfile: 'Importar perfil…', + exportProfile: 'Exportar perfil…', + imported: 'Perfil importado', + exported: 'Perfil exportado', + failedImport: 'No se pudo importar el perfil', + failedExport: 'No se pudo exportar el perfil', + allProfiles: 'Todos los perfiles', + showAllProfiles: 'Mostrar todos los perfiles', + switchToProfile: name => `Cambiar a ${name}`, + switchToConnection: name => `Cambiar a ${name}`, + switchConnectionFailed: name => `No se pudo conectar a ${name}`, + manageProfiles: 'Gestionar perfiles...', + connectGateway: 'Gestionar gateways…', + fleet: { + allOnGateway: 'Todos los perfiles de este gateway', + gateway: (gateway: string) => `Perfiles en ${gateway}`, + gatewayUnreachable: (gateway: string) => `${gateway} · inaccesible`, + onGateway: (name: string, gateway: string) => `${name} · ${gateway}`, + switchTo: (name: string, gateway: string) => `Cambiar a ${name} en ${gateway}`, + deleteOn: (gateway: string) => ` en ${gateway}` + }, + remoteOverride: { + menuItem: 'Conectar a un host remoto…', + badge: (host: string) => `Se ejecuta en ${host}`, + title: (profile: string) => `Conectar ${profile} a un host remoto`, + description: + 'Las sesiones de este perfil se ejecutarán en el Hermes remoto que indiques, en lugar de en este equipo.', + urlLabel: 'Dirección remota', + urlPlaceholder: 'https://hermes.example.com', + urlInvalid: 'Introduce una dirección completa que empiece por http:// o https://', + tokenLabel: 'Token de acceso', + tokenPlaceholder: 'Pega el token de sesión remota', + tokenSavedHint: 'Ya hay un token guardado. Déjalo en blanco para conservarlo.', + plainTextOptIn: + 'Este equipo no tiene almacenamiento seguro de claves, así que el token se guardaría sin cifrar en el disco. Guardarlo de todos modos.', + collisionWarning: (label: string) => + `Ya existe un gateway llamado “${label}” en Configuración. Esta conexión del perfil es independiente y no lo cambiará.`, + confirmTitle: '¿Conectar este perfil a un host remoto?', + confirmNote: (profile: string, host: string) => + `Los chats nuevos de ${profile} se ejecutarán en ${host}. Ese equipo ejecutará comandos y leerá archivos allí, no en este. Conéctate solo a un host de confianza.`, + confirmBack: 'Atrás', + connect: 'Conectar', + connecting: 'Conectando…', + disconnect: 'Quitar conexión remota', + savedTitle: 'Perfil conectado', + savedMessage: (profile: string, host: string) => `${profile} ahora se ejecuta en ${host}`, + removedTitle: 'Conexión remota quitada', + removedMessage: (profile: string) => `${profile} ahora se ejecuta en este equipo`, + removeFailed: 'No se pudo quitar la conexión remota', + authFailedTitle: 'El host remoto rechazó el token guardado', + authFailedMessage: (profile: string, host: string) => + `${host} rechazó el token guardado para ${profile}. Puede que se haya cambiado en el lado remoto.`, + updateToken: 'Introducir nuevo token…' + }, + actions: 'Acciones', + color: 'Color...', + colorFor: 'Color', + openInNewWindow: 'Abrir en una ventana nueva', + setAsDefault: 'Establecer como predeterminado', + defaultProfile: 'Perfil predeterminado', + defaultSet: (name: string) => `${name} es ahora el predeterminado`, + defaultDescription: + 'Se usa al abrir Hermes y para los chats nuevos. Las sesiones existentes se quedan en sus perfiles.', + failedSetDefault: 'No se pudo establecer el perfil predeterminado', + setColor: color => `Definir color ${color}`, + autoColor: 'Auto', + noProfiles: 'Aún no hay perfiles.', + selectPrompt: 'Selecciona un perfil para ver sus detalles.', + refresh: 'Actualizar perfiles', + refreshing: 'Actualizando perfiles', + default: 'predeterminado', + skills: count => `${count} ${count === 1 ? 'skill' : 'skills'}`, + env: 'env', + defaultBadge: 'Predeterminado', + rename: 'Renombrar', + renameMenu: 'Renombrar…', + exportMenu: 'Exportar…', + editSoul: 'Editar SOUL.md…', + copySetup: 'Copiar configuración', + copying: 'Copiando...', + modelLabel: 'Modelo', + skillsLabel: 'Skills', + notSet: 'Sin definir', + soulDesc: 'El prompt de sistema y las instrucciones de persona integrados en este perfil.', + soulOptional: 'opcional', + soulPlaceholder: mode => + `El prompt de sistema / persona para este perfil.\nDéjalo en blanco para conservar el ${mode} predeterminado.`, + soulPlaceholderCloned: 'clonado', + soulPlaceholderEmpty: 'vacío', + unsavedChanges: 'Cambios sin guardar', + loadingSoul: 'Cargando SOUL.md...', + emptySoul: 'SOUL.md vacío; empieza a escribir la persona...', + saving: 'Guardando...', + saveSoul: 'Guardar SOUL.md', + deleteTitle: '¿Eliminar perfil?', + deleteDescPrefix: 'Esto eliminará ', + deleteDescMid: ' y quitará su directorio ', + deleteDescSuffix: '. Esto no se puede deshacer.', + deleting: 'Eliminando...', + createDesc: 'Los perfiles son entornos independientes de Hermes: configuración, skills y SOUL.md separados.', + nameLabel: 'Nombre', + cloneFrom: 'Clonar desde', + cloneFromNone: 'Ninguno (vacío)', + cloneFromDesc: 'Copia la configuración, las skills y SOUL.md del perfil de origen seleccionado.', + cloneFromDefault: 'Clonar desde el predeterminado', + cloneFromDefaultDesc: 'Copia configuración, skills y SOUL.md desde tu perfil predeterminado.', + invalidName: hint => `Nombre no válido. ${hint}`, + nameRequired: 'El nombre es obligatorio.', + creating: 'Creando...', + createAction: 'Crear perfil', + renameTitle: 'Renombrar perfil', + renameDescPrefix: 'Renombrar actualiza el directorio del perfil y cualquier script wrapper en ', + renameDescSuffix: '.', + displayNameTitle: 'Ponle nombre a este agente', + displayNameDesc: + 'Define un nombre visible que se muestra en toda la app. El ID interno del perfil sigue siendo "default".', + displayNameLabel: 'Nombre visible', + newNameLabel: 'Nombre nuevo', + renaming: 'Renombrando...', + created: 'Perfil creado', + renamed: 'Perfil renombrado', + deleted: 'Perfil eliminado', + setupCopied: 'Comando de configuración copiado', + soulSaved: 'SOUL.md guardado', + failedLoad: 'No se pudieron cargar los perfiles', + failedDelete: 'No se pudo eliminar el perfil', + failedCopy: 'No se pudo copiar el comando de configuración', + failedLoadSoul: 'No se pudo cargar SOUL.md', + failedSaveSoul: 'No se pudo guardar SOUL.md', + failedCreate: 'No se pudo crear el perfil', + failedRename: 'No se pudo renombrar el perfil' + }, + modelAssignment: { + saveFailed: 'Hermes no guardó ese cambio de modelo.', + confirmTitle: 'Aviso sobre la selección de modelo', + confirmDetail: 'Confirma solo si aceptas esta contrapartida.', + confirmAction: 'Confirmar', + declined: 'Cambio de modelo cancelado: rechazaste el aviso del nivel con entrenamiento de datos.' + }, + cron: { + close: 'Cerrar cron', + title: 'Tareas programadas', + count: count => `${count} ${count === 1 ? 'tarea' : 'tareas'}`, + search: 'Buscar tareas cron...', + loading: 'Cargando tareas cron...', + states: { + enabled: 'activada', + scheduled: 'programada', + running: 'en ejecución', + paused: 'pausada', + disabled: 'deshabilitada', + error: 'falló la última ejecución', + completed: 'completada' + }, + lastRunFailed: 'Falló la última ejecución:', + editJob: 'Editar tarea', + runAgain: 'Ejecutar de nuevo', + deliveryLabels: { + local: 'Este escritorio', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + email: 'Email' + }, + scheduleLabels: { + daily: 'Diario', + weekdays: 'Días laborables', + weekly: 'Semanal', + monthly: 'Mensual', + hourly: 'Cada hora', + 'every-15-minutes': 'Cada 15 minutos', + custom: 'Personalizado' + }, + scheduleHints: { + daily: 'Todos los días a las 9:00', + weekdays: 'De lunes a viernes a las 9:00', + weekly: 'Cada lunes a las 9:00', + monthly: 'El primer día de cada mes a las 9:00', + hourly: 'Al inicio de cada hora', + 'every-15-minutes': 'Cada 15 minutos', + custom: 'Sintaxis cron o lenguaje natural' + }, + days: { + '0': 'Domingo', + '1': 'Lunes', + '2': 'Martes', + '3': 'Miércoles', + '4': 'Jueves', + '5': 'Viernes', + '6': 'Sábado', + '7': 'Domingo' + }, + dayFallback: value => `día ${value}`, + everyDayAt: time => `Todos los días a las ${time}`, + weekdaysAt: time => `Días laborables a las ${time}`, + everyDayOfWeekAt: (day, time) => `Cada ${day} a las ${time}`, + monthlyOnDayAt: (dayOfMonth, time) => `Mensual el día ${dayOfMonth} a las ${time}`, + topOfHour: 'Al inicio de cada hora', + everyHourAt: minute => `Cada hora en :${minute}`, + newCron: 'Nueva tarea cron', + emptyDescNew: + 'Programa un prompt para ejecutarlo con una expresión cron. Hermes lo ejecutará y entregará los resultados al destino que elijas.', + emptyDescSearch: 'Prueba una búsqueda más amplia.', + emptyTitleNew: 'Aún no hay tareas programadas', + emptyTitleSearch: 'Sin coincidencias', + last: 'Última:', + next: 'Siguiente:', + overdueSince: 'Atrasada desde:', + noRuns: 'Sin ejecuciones aún', + manage: 'Gestionar', + showRuns: 'Mostrar ejecuciones', + hideRuns: 'Ocultar ejecuciones', + runHistory: 'Historial de ejecuciones', + actionsTitle: 'Acciones de tarea cron', + resume: 'Reanudar cron', + pause: 'Pausar cron', + resumeTitle: 'Reanudar', + pauseTitle: 'Pausar', + triggerNow: 'Ejecutar ahora', + edit: 'Editar cron', + deleteTitle: '¿Eliminar tarea cron?', + deleteDescPrefix: 'Esto eliminará ', + deleteDescSuffix: ' permanentemente. Dejará de ejecutarse de inmediato.', + deleting: 'Eliminando...', + resumed: 'Cron reanudado', + paused: 'Cron pausado', + triggered: 'Cron ejecutado', + deleted: 'Cron eliminado', + created: 'Cron creado', + updated: 'Cron actualizado', + failedLoad: 'No se pudieron cargar las tareas cron', + failedUpdate: 'No se pudo actualizar la tarea cron', + failedTrigger: 'No se pudo ejecutar la tarea cron', + failedDelete: 'No se pudo eliminar la tarea cron', + failedSave: 'No se pudo guardar la tarea cron', + editTitle: 'Editar tarea cron', + createTitle: 'Nueva tarea cron', + editDesc: 'Actualiza la programación, el prompt o el destino. Los cambios se aplican en la próxima ejecución.', + createDesc: + 'Programa un prompt para ejecutarlo automáticamente. Usa sintaxis cron o una frase como "cada 15 minutos".', + nameLabel: 'Nombre', + namePlaceholder: 'Resumen matutino', + promptLabel: 'Prompt', + promptPlaceholder: 'Resume mis hilos de Slack sin leer y envíame por email los 5 principales...', + frequencyLabel: 'Frecuencia', + deliverLabel: 'Entregar a', + deliverNeedsHomeChannel: 'configura primero un canal principal', + modelLabel: 'Modelo', + modelDefault: 'Predeterminado (modelo global)', + customScheduleLabel: 'Programación personalizada', + customPlaceholder: '0 9 * * * o días laborables a las 9', + customHint: 'Expresión cron, o frases como "cada hora" o "días laborables a las 9".', + optional: 'Opcional', + promptRequired: 'El prompt es obligatorio.', + promptScheduleRequired: 'El prompt y la programación son obligatorios.', + scheduleRequired: 'La programación es obligatoria.', + scriptOnlyEditHint: 'Tarea solo con script (sin prompt de IA). ID de la tarea:', + saveChanges: 'Guardar cambios', + createAction: 'Crear cron', + tabs: { + jobs: 'Tareas', + blueprints: 'Plantillas' + }, + blueprints: { + tab: 'Plantillas', + startFrom: 'Empezar con', + custom: 'Personalizada', + subtitle: 'Automatizaciones listas para usar', + dialogDesc: 'Completa los detalles y prográmala.', + scheduleIt: 'Programar', + scheduling: 'Programando…', + scheduled: 'Plantilla programada', + loading: 'Cargando plantillas…', + failedLoad: 'No se pudieron cargar las plantillas', + emptyTitle: 'No hay plantillas disponibles', + emptyDesc: 'Este backend no ofrece plantillas de automatización.' + } + }, + artifacts: { + search: 'Buscar artefactos...', + refresh: 'Actualizar artefactos', + refreshing: 'Actualizando artefactos', + indexing: 'Indexando artefactos recientes de sesiones', + tabAll: 'Todo', + tabImages: 'Imágenes', + tabFiles: 'Archivos', + tabLinks: 'Enlaces', + noArtifactsTitle: 'No se encontraron artefactos', + noArtifactsDesc: + 'Las imágenes generadas y las salidas de archivo aparecerán aquí cuando las sesiones las produzcan.', + failedLoad: 'No se pudieron cargar los artefactos', + openFailed: 'No se pudo abrir', + itemsImage: 'imágenes', + itemsLink: 'enlaces', + itemsFile: 'archivos', + itemsGeneric: 'elementos', + zero: '0', + rangeOf: (start, end, total) => `${start}-${end} de ${total}`, + goToPage: (itemLabel, page) => `Ir a la página ${page} de ${itemLabel}`, + colTitleLink: 'Título del enlace', + colTitleFile: 'Nombre', + colTitleDefault: 'Título / nombre', + colLocationLink: 'URL', + colLocationFile: 'Ruta', + colLocationDefault: 'Ubicación', + colSession: 'Sesión', + kindImage: 'imagen', + kindFile: 'archivo', + kindLink: 'enlace', + chat: 'Chat', + copyUrl: 'Copiar URL', + copyPath: 'Copiar ruta' + }, + artifactCard: { + kind: { + code: 'Código', + html: 'Página interactiva', + svg: 'Gráfico' + }, + generating: lines => `Generando… ${lines} líneas`, + versionBadge: count => `${count} versiones`, + open: 'Abrir' + }, + artifactPreview: { + versionOf: (current, total) => `v${current} de ${total}`, + olderVersion: 'Versión anterior', + newerVersion: 'Versión más reciente', + latest: 'Última', + copyContent: 'Copiar contenido', + download: 'Descargar', + openInBrowser: 'Abrir en el navegador', + openInBrowserFailed: 'No se pudo abrir en el navegador', + missingTitle: 'Artefacto no disponible', + missingBody: 'Este artefacto ya no está en el registro local.' + }, + sidebar: { + filter: { + grouping: 'Agrupación', + ordering: 'Orden', + show: 'Mostrar', + filters: 'Filtros', + status: 'Estado', + pullRequest: 'Pull request', + profile: 'Perfil', + project: 'Proyecto', + archived: 'Archivados', + resetToDefaults: 'Restablecer valores predeterminados', + expandAll: 'Expandir todo', + collapseAll: 'Contraer todo', + inboxStyle: 'Estilo bandeja de entrada', + updated: 'Actualizado', + created: 'Creado', + tokens: 'Tokens', + cost: 'Coste', + manual: 'Manual', + preview: 'Vista previa', + pr: 'PR', + needsInput: 'Requiere respuesta', + working: 'Trabajando', + unread: 'No leídos', + draft: 'Borrador', + idle: 'Inactivo', + open: 'Abierta', + merged: 'Fusionada', + closed: 'Cerrada', + noPR: 'Sin PR' + }, + gatewayGroups: { + grouping: 'Gateway y perfil', + rename: 'Renombrar grupo', + aliasLabel: 'Nombre visible', + aliasHint: 'Solo el nombre visible; los nombres del gateway y del perfil no cambian.', + resetName: 'Restablecer nombre', + moveUp: 'Subir', + moveDown: 'Bajar', + reorder: 'Reordenar grupo', + actions: 'Acciones del grupo' + }, + profileRail: 'Barra de perfiles', + nav: { + 'new-session': 'Nueva sesión', + capabilities: 'Capacidades', + messaging: 'Mensajería', + artifacts: 'Artefactos', + cron: 'Tareas programadas' + }, + searchAria: 'Buscar sesiones', + searchPlaceholder: 'Buscar sesiones…', + clearSearch: 'Limpiar búsqueda', + noMatch: query => `Ninguna sesión coincide con “${query}”.`, + results: 'Resultados', + pinned: 'Fijadas', + sessions: 'Sesiones', + terminal: 'Terminal', + files: 'Archivos', + review: 'Revisión', + logs: 'Registros', + cronJobs: 'Tareas cron', + groupAriaGrouped: 'Mostrar sesiones como una sola lista', + groupAriaUngrouped: 'Agrupar sesiones por espacio de trabajo', + showProjects: 'Mostrar proyectos', + showSessions: 'Mostrar sesiones', + groupTitleGrouped: 'Desagrupar sesiones', + groupTitleUngrouped: 'Agrupar por espacio de trabajo', + allPinned: 'Todo aquí está fijado. Desfija un chat para mostrarlo en recientes.', + shiftClickHint: 'Mayús-clic en un chat para fijarlo', + noWorkspace: 'Sin espacio de trabajo', + projectEmpty: 'Aún no hay sesiones', + projectLoadFailed: 'No se pudieron cargar las sesiones', + noSessions: 'Aún no hay sesiones', + storageCorrupt: { + title: 'La base de datos de sesiones está dañada', + body: (profiles: string) => + `Hermes no puede leer todo el historial de sesiones de ${profiles}. Los chats que faltan en esta lista no se eliminaron; el archivo donde se guardan está dañado.`, + action: + 'Sal de Hermes en este perfil y luego inspecciona el archivo sin modificarlo, o restaura una instantánea:', + guide: 'Guía de recuperación' + }, + noFilterMatches: 'Ninguna sesión coincide con estos filtros', + projects: { + showAllSessions: 'Mostrar todas las sesiones', + sectionLabel: 'Proyectos', + home: 'Inicio', + autoDiscovered: 'Detectado automáticamente', + newButton: 'Nuevo proyecto', + createTitle: 'Nuevo proyecto', + createDesc: 'Asigne un nombre a un espacio de trabajo y agregue una o más carpetas.', + renameTitle: 'Cambiar nombre del proyecto', + addFolderTitle: 'Agregar carpeta', + namePlaceholder: 'p.ej. Skunkworks', + foldersLabel: 'Carpetas', + ideaLabel: 'Idea', + ideaPlaceholder: '¿De qué se trata este proyecto? (guardado en IDEA.md)', + ideaGenerate: 'Generar idea', + ideaGenerating: 'Generando…', + ideaShuffle: 'Plantillas aleatorias', + noFolders: 'Aún no se han agregado carpetas.', + addFolder: 'Agregar carpeta', + primaryBadge: 'principal', + removeFolder: 'Eliminar', + create: 'Crear', + menu: 'Acciones', + menuRename: 'Renombrar', + menuAppearance: 'Apariencia', + noColor: 'Sin color', + menuAddFolder: 'Agregar carpeta', + menuSetActive: 'Establecer activo', + menuDelete: 'Borrar', + moveToProject: 'Mover a proyecto', + movedTo: name => `Movido a ${name}`, + moveFailed: 'No se pudo mover la sesión', + moveNoFolder: 'Ese proyecto no tiene carpeta a la que mover', + moveNoProjects: 'No hay otros proyectos', + reveal: 'Revelar en carpeta', + copyPath: 'Copiar ruta', + removeFromSidebar: 'Ocultar de la barra lateral', + createFailed: 'No se pudo crear el proyecto', + staleBackend: + 'Actualiza el backend de Hermes para crear proyectos: tu backend es más antiguo que esta aplicación de escritorio (Configuración → Actualizaciones → Backend).', + deleteConfirm: + 'Esto elimina el proyecto guardado de Hermes. Los archivos, los repositorios de git y los árboles de trabajo permanecen intactos.', + startWork: 'Nuevo worktree', + newWorktreeTitle: 'Nuevo worktree', + newWorktreeDesc: 'Asigna un nombre a la rama de este worktree.', + branchPlaceholder: 'p.ej. mi-característica', + branchOff: () => ({ after: '', before: 'ramificar desde ' }), + baseBranchPlaceholder: 'Buscar ramas…', + baseBranchNone: 'No se encontraron ramas', + startWorkFailed: 'No se pudo crear el worktree', + worktreeStaleBackend: + 'Actualiza el backend de Hermes para crear worktrees por esta conexión remota: es anterior a la API de git worktree.', + worktreeProjectLabel: 'Proyecto', + worktreeProjectPlaceholder: 'Buscar proyectos…', + worktreeProjectNone: 'Ningún proyecto con carpeta', + convertBranch: 'Convertir una rama…', + convertBranchTitle: 'Convertir una rama', + convertBranchDesc: 'Abre ramas ya activas o crea un worktree para una rama disponible.', + convertBranchPlaceholder: 'Buscar ramas…', + convertBranchInstead: 'Convertir una rama existente', + branchOpenExisting: 'abrir', + branchSwitchHome: 'cambiar al principal', + branchCreateWorktree: 'nuevo worktree', + branchTrackRemote: 'seguir remota', + branchesLoading: 'Cargando ramas…', + noBranches: 'No se encontraron ramas', + removeWorktree: 'Eliminar worktree', + removeWorktreeFailed: 'No se pudo eliminar el worktree (¿hay cambios sin confirmar?)', + removeWorktreeConfirm: + 'Elimínalo de Git (se borra el directorio del worktree; la rama se conserva) o simplemente oculta el carril de la barra lateral y deja el worktree en disco.', + removeWorktreeDirty: + 'Este worktree tiene cambios sin confirmar. Fuerza la eliminación (se descartarán esos cambios) o simplemente oculta el carril y consérvalo en disco.', + forceRemove: 'Forzar eliminación', + enter: label => `Abrir ${label}`, + reorder: label => `Reordenar ${label}`, + toggle: (label, open) => `${open ? 'Mostrar' : 'Ocultar'} sesiones de ${label}`, + showAllCount: (count: number) => `Mostrar las ${count} sesiones`, + back: 'Todos los proyectos' + }, + newSessionIn: label => `Nueva sesión en ${label}`, + showMoreIn: (count, label) => `Mostrar ${count} más en ${label}`, + loading: 'Cargando…', + loadMore: 'Cargar más', + loadCount: step => `Cargar ${step} más`, + messageCount: (count: number) => `${count} ${count === 1 ? 'mensaje' : 'mensajes'}`, + toolCallCount: (count: number) => `${count} ${count === 1 ? 'llamada a herramienta' : 'llamadas a herramientas'}`, + row: { + pin: 'Fijar', + unpin: 'Desfijar', + markUnread: 'Marcar como no leído', + markRead: 'Marcar como leído', + unreadFailed: 'No se pudo actualizar el estado de no leído', + copyId: 'Copiar ID', + export: 'Exportar', + branchFrom: 'Rama', + rename: 'Renombrar', + archive: 'Archivar', + newWindow: 'Nueva ventana', + openInTerminal: 'Abrir en el terminal', + hideTabBar: 'Ocultar barra de pestañas', + openInNewTab: 'Abrir en una pestaña nueva', + openInSplit: 'Abrir en vista dividida', + copyIdFailed: 'No se pudo copiar el ID de sesión', + sessionActions: 'Acciones de sesión', + sessionRunning: 'Sesión en ejecución', + needsInput: 'Necesita tu respuesta', + waitingForAnswer: 'Esperando tu respuesta', + finishedUnread: 'Finalizada — sin leer', + backgroundRunning: 'Tarea en segundo plano en ejecución', + draftSession: 'Borrador — aún no se ha enviado nada', + handoffOrigin: platform => `Transferido desde ${platform}`, + ownedByProfile: profile => `Perfil: ${profile}`, + renamed: 'Renombrada', + renameFailed: 'No se pudo renombrar', + renameTitle: 'Renombrar sesión', + renameDesc: 'Déjalo vacío para limpiarlo.', + untitledPlaceholder: 'Sesión sin título', + deleteTitle: '¿Eliminar la sesión?', + deleteDesc: (title: string) => `Se eliminará “${title}” de forma permanente. No se puede deshacer.`, + deleting: 'Eliminando…', + deleted: 'Sesión eliminada', + untitledChat: id => `Chat ${id}`, + messageCount: count => `${count} ${count === 1 ? 'mensaje' : 'mensajes'}`, + todoProgress: 'Tareas completadas', + ageNow: 'ahora', + ageDay: 'd', + ageHour: 'h', + ageMin: 'm' + }, + dateDivider: { + today: 'Hoy, más temprano', + yesterday: 'Ayer', + thisWeek: 'Esta semana', + lastWeek: 'La semana pasada', + thisMonth: 'Este mes' + }, + statusDivider: { + working: 'En progreso', + done: 'Completado' + }, + markAllRead: 'Marcar todo como leído' + }, + composer: { + message: 'Mensaje', + wakingProfile: profile => `Despertando ${profile}…`, + placeholderStarting: 'Iniciando Hermes...', + placeholderReconnecting: 'Reconectando con Hermes…', + placeholderFollowUp: 'Enviar seguimiento', + newSessionPlaceholders: [ + '¿Qué vamos a construir?', + 'Dale una tarea a Hermes', + '¿Qué tienes en mente?', + 'Describe lo que necesitas', + '¿Qué abordamos?', + 'Pregunta lo que quieras', + 'Empieza con un objetivo' + ], + followUpPlaceholders: [ + 'Enviar seguimiento', + 'Añadir más contexto', + 'Refinar la solicitud', + '¿Qué sigue?', + 'Sigamos', + 'Llevarlo más lejos', + 'Ajustar o continuar' + ], + startVoice: 'Iniciar conversación de voz', + openDirective: 'Abrir', + queueMessage: 'Poner mensaje en cola', + steer: 'Guiar la ejecución actual', + stop: 'Detener', + send: 'Enviar', + speaking: 'Hablando', + transcribing: 'Transcribiendo', + thinking: 'Pensando', + muted: 'Silenciado', + listening: 'Escuchando', + muteMic: 'Silenciar micrófono', + unmuteMic: 'Activar micrófono', + stopListening: 'Dejar de escuchar y enviar', + stopShort: 'Detener', + endConversation: 'Terminar conversación de voz', + endShort: 'Terminar', + stopDictation: 'Detener dictado', + transcribingDictation: 'Transcribiendo dictado', + voiceControls: 'Voz', + voiceEngine: 'Motor del chat de voz', + voiceEngineChained: 'Voz a texto + voz de Hermes', + voiceEngineLive: 'GPT-Live (full-duplex, delega en Hermes)', + voiceEngineLiveNeedsKey: 'Requiere una clave API de OpenAI', + voiceEngineChangeFailed: 'No se pudo cambiar el motor del chat de voz', + voiceEngineChainedShort: 'voz a texto', + voiceEngineLiveShort: 'GPT-Live', + voiceDictation: 'Dictado por voz', + speakReplies: 'Leer las respuestas en voz alta', + stopSpeakingReplies: 'Dejar de leer las respuestas en voz alta', + wakeWord: (phrase: string) => `Palabra de activación "${phrase}"`, + wakeWordListening: phrase => `Palabra de activación: "${phrase}" — escuchando`, + wakeWordOff: phrase => `Palabra de activación: "${phrase}" — desactivada`, + wakeWordPausedVoice: phrase => `Palabra de activación: "${phrase}" — pausada durante el chat de voz`, + lookupLoading: 'Buscando…', + lookupNoMatches: 'Sin coincidencias.', + lookupTry: 'Prueba', + lookupOr: 'o', + commonCommands: 'Comandos comunes', + hotkeys: 'Atajos', + helpFooter: 'abre el panel completo · retroceso descarta', + commandDescs: { + '/help': 'Mostrar los comandos de barra del escritorio', + '/clear': 'iniciar una sesión nueva', + '/resume': 'Reanudar una sesión guardada', + '/details': 'controlar el nivel de detalle de la transcripción', + '/copy': 'copiar selección o último mensaje del asistente', + '/quit': 'salir de hermes', + '/start': 'Confirmar los pings de inicio de la plataforma sin responder', + '/new': 'Iniciar un chat nuevo de escritorio', + '/topic': 'Activar o inspeccionar las sesiones por tema de los MD de Telegram', + '/save': 'Guardar la transcripción actual en JSON', + '/retry': 'Reintentar el último mensaje (reenviarlo al agente)', + '/prompt': 'Redactar tu siguiente prompt en $EDITOR (markdown) y enviarlo', + '/undo': 'Retroceder N turnos del usuario y volver a preguntar (1 por defecto)', + '/title': 'Renombrar la sesión actual', + '/handoff': 'Pasar esta sesión a una plataforma de mensajería', + '/branch': 'Ramificar el último mensaje en un chat nuevo', + '/worktree': 'Mostrar, listar, crear o podar worktrees de git aislados', + '/compress': 'Comprimir el contexto de esta conversación', + '/rollback': + 'Listar o restaurar puntos de control del sistema de archivos (las restauraciones conservan tus ediciones manuales; --all las sobrescribe)', + '/export': 'Exportar un perfil (configuración, skills, tema) a un archivo compartible', + '/import': 'Importar un archivo de perfil compartido como perfil nuevo', + '/stop': 'Detener el turno activo y los procesos en segundo plano', + '/pause': "Pausar globalmente el trabajo nuevo (parada de emergencia); '/pause off' lo reanuda", + '/bg': 'Ejecutar un prompt en una sesión independiente en segundo plano', + '/btw': 'Hacer una pregunta al margen sobre esta conversación sin interrumpirla', + '/agents': 'Mostrar los agentes activos y las tareas en curso', + '/journey': 'Abrir el grafo de memoria: skills y recuerdos a lo largo del tiempo', + '/queue': + 'Poner un prompt en cola para el siguiente turno, o listar/editar/quitar/mover/vaciar los prompts en cola', + '/steer': 'Insertar un mensaje tras la siguiente llamada a herramienta sin interrumpir', + '/goal': 'Fijar un objetivo permanente en el que Hermes trabaja durante varios turnos hasta cumplirlo', + '/heartbeat': 'Configurar un prompt recurrente que vuelve a esta sesión cuando está inactiva', + '/refine': 'Revisar esta conversación ahora y guardar lo aprendido en memoria/skills', + '/review': + 'Lanzar un subagente independiente para revisar el trabajo que se acaba de comentar (PR, código, docs)', + '/loop': 'Volver a ejecutar un prompt a intervalos regulares en esta sesión', + '/plan': 'Escribir un plan de implementación en markdown en .hermes/plans/ sin ejecutar nada', + '/moa': 'Ejecutar un prompt con el preajuste predeterminado de Mixture of Agents y luego restaurar tu modelo', + '/subgoal': 'Añadir o gestionar criterios adicionales del objetivo activo', + '/status': 'Mostrar el estado de la sesión actual', + '/egress': 'Mostrar el estado del proxy de salida de Docker', + '/context': + 'Mostrar la vista detallada de la ventana de contexto con indicador de uso, desglose por categoría, estadísticas de compresión y rendimiento', + '/whoami': 'Mostrar tu acceso a los comandos de barra (admin / usuario)', + '/profile': 'Cambiar el perfil activo de Hermes', + '/codex-runtime': 'Activar o desactivar el runtime codex app-server para modelos OpenAI/Codex', + '/personality': 'Establecer una personalidad predefinida', + '/battery': 'Mostrar u ocultar un indicador de batería por colores en la barra de estado', + '/timestamps': 'Mostrar u ocultar las marcas de tiempo [HH:MM] en los mensajes y /history', + '/diff': 'Mostrar los cambios de git en el directorio de trabajo', + '/focus': 'Activar o desactivar la vista de enfoque: solo tu prompt y la respuesta final', + '/yolo': 'Activar o desactivar YOLO: aprobar automáticamente los comandos peligrosos', + '/approvals': 'Mostrar o establecer el modo persistente de aprobación de comandos peligrosos', + '/reasoning': 'Esfuerzo o visualización del razonamiento [ [--global]|show|hide|full|clamp]', + '/skin': 'Cambiar el tema de escritorio o pasar al siguiente', + '/wake': 'Controlar la escucha de la palabra de activación del escritorio [on|off|status]', + '/tools': 'Gestionar herramientas: /tools [list|disable|enable] [name...]', + '/memory': 'Revisar las escrituras de memoria pendientes / activar o desactivar la aprobación', + '/bundles': 'Listar los paquetes de skills (alias / para varias skills)', + '/pet': 'Mostrar u ocultar o adoptar una mascota de petdex (/pet, /pet list, /pet boba)', + '/hatch': 'Generar una mascota nueva (abre el generador)', + '/learn': 'Aprender una skill reutilizable a partir de lo que describas (carpetas, URL, este chat, notas)', + '/init': 'Generar o actualizar las instrucciones de proyecto AGENTS.md a partir de un análisis del repositorio', + '/suggestions': 'Revisar las automatizaciones sugeridas (aceptar/descartar)', + '/blueprint': 'Configurar una automatización a partir de una plantilla', + '/browser': 'Gestionar la conexión CDP del navegador [connect|disconnect|status] (solo gateway local)', + '/palette': 'Abrir la paleta de comandos aproximada (también Ctrl+P)', + '/usage': + 'Mostrar el uso de tokens y los límites de frecuencia; `reset` canjea un restablecimiento de límite de Codex acumulado', + '/subscription': 'Ver tu plan de Nous y cambiarlo en el navegador', + '/topup': 'Mostrar tu saldo de Nous y gestionar la facturación en el portal', + '/platform': 'Pausar, reanudar o listar una plataforma del gateway que falla', + '/version': 'Mostrar la versión de Hermes Agent', + '/debug': 'Subir un informe de depuración (información del sistema + registros) y obtener enlaces para compartir', + '/model': 'Cambiar el modelo de esta sesión' + }, + hotkeyDescs: { + 'composer.mention': 'referenciar archivos, carpetas, URL y Git', + 'composer.slash': 'paleta de comandos con /', + 'composer.help': 'esta ayuda rápida (eliminar para descartarla)', + 'composer.sendNewline': 'enviar · Shift+Enter para insertar una línea nueva', + 'composer.sendQueued': 'enviar el siguiente turno en cola', + 'keybinds.openPanel': 'todos los atajos de teclado', + 'composer.cancel': 'cerrar el menú emergente · cancelar la ejecución', + 'composer.history': 'recorrer el menú emergente o el historial' + }, + attachUrlTitle: 'Adjuntar una URL', + attachUrlDesc: 'Hermes obtendrá la página y la incluirá como contexto para este turno.', + urlPlaceholder: 'https://example.com/post', + urlHintPre: 'Incluye la URL completa, p. ej. ', + attach: 'Adjuntar', + queued: count => `${count} en cola`, + queuedPaused: count => `${count} en cola — en pausa`, + attachmentOnly: 'Turno solo con adjunto', + emptyTurn: 'Turno vacío', + hiddenQueued: 'Nota de configuración', + attachments: count => `${count} ${count === 1 ? 'adjunto' : 'adjuntos'}`, + editingInComposer: 'Editando en el compositor', + editingQueuedInComposer: 'Editando turno en cola en el compositor', + restoredDraftNotice: 'Se restauró tu mensaje sin enviar', + restoredDraftUndo: 'Deshacer', + queueEdit: 'Editar', + queueSendNext: 'Próximo', + queueSteer: 'Redirigir — encauzar el turno en vivo ahora', + queueSend: 'Enviar', + queueDelete: 'Borrar', + queueResume: 'Reanudar', + queueResumeTip: 'La cola se pausó al detener; reanuda el envío de los turnos en cola', + queueStuckTitle: 'Mensaje en cola no enviado', + queueStuckBody: 'Un turno en cola no llegó a enviarse. Sigue en la cola; vuelve a intentarlo.', + previewUnavailable: 'Vista previa no disponible', + previewLabel: label => `Vista previa de ${label}`, + couldNotPreview: label => `No se pudo previsualizar ${label}`, + removeAttachment: label => `Quitar ${label}`, + dictating: 'Dictando', + preparingAudio: 'Preparando audio', + speakingResponse: 'Respuesta hablada', + readingAloud: 'Leyendo en voz alta', + themeSuggestions: 'Sugerencias de tema de escritorio', + noMatchingThemes: 'No hay temas coincidentes.', + themeTryPre: 'Prueba ', + themeTryPost: '.', + attachLabel: 'Adjuntar', + files: 'Archivos…', + folder: 'Carpeta…', + images: 'Imágenes…', + pasteImage: 'Pegar imagen', + url: 'URL…', + promptSnippets: 'Fragmentos de prompt…', + tipPre: 'Consejo: escribe ', + tipPost: ' para referenciar archivos en línea.', + snippetsTitle: 'Fragmentos de prompt', + snippetsDesc: 'Elige un prompt inicial para insertarlo en el compositor.', + dropFiles: 'Suelta archivos para adjuntarlos', + dropSession: 'Suelta para enlazar este chat', + mcpSuggestions: { + label: server => `Añadir ${server}`, + tip: keyword => `Sugerido porque mencionaste "${keyword}" — haz clic para conectar`, + connecting: server => `Conectando ${server}…`, + cancelTip: 'Haz clic para cancelar', + added: server => `${server} añadido`, + addedTip: 'Conectado — sus herramientas están listas en este chat', + connectFailed: server => `No se pudo conectar ${server}` + }, + skillSuggestions: { + label: skill => `Usar habilidad: ${skill}`, + tip: skill => `Mencionaste "${skill}" — haz clic para empezar con esa habilidad`, + done: skill => `Añadido /${skill}`, + doneTip: 'La habilidad se carga cuando envíes' + }, + githubSuggestions: { + label: 'Configurar GitHub', + tip: 'GitHub funciona aquí mediante las skills de la CLI gh: haz clic para conectar tu cuenta', + done: 'Se añadió /github-auth', + doneTip: 'Envía el mensaje y el agente te guiará para iniciar sesión en GitHub' + }, + repairSuggestions: { + label: server => `Reconectar ${server}`, + tip: server => `Una llamada a ${server} acaba de fallar con un error de conexión`, + working: server => `Reconectando ${server}…`, + workingTip: 'Haz clic para cancelar', + done: server => `${server} reconectado`, + doneTip: 'Las credenciales nuevas están activas en este chat', + failed: server => `No se pudo reconectar ${server}` + }, + cronSuggestions: { + label: 'Programar esto', + tip: phrase => `"${phrase}" suena recurrente — ejecútalo con un horario en su lugar`, + prefix: 'Configúralo como tarea programada:', + done: 'Marcado para programar', + doneTip: 'Envíalo y el agente creará la tarea' + }, + snippets: { + codeReview: { + label: 'Revisión de código', + description: 'Audita el cambio actual en busca de regresiones, casos límite omitidos y pruebas faltantes.', + text: 'Revisa esto para detectar bugs, regresiones y pruebas faltantes.' + }, + implementationPlan: { + label: 'Plan de implementación', + description: 'Esboza un enfoque antes de tocar código para mantener el diff enfocado.', + text: 'Haz un plan de implementación conciso antes de cambiar código.' + }, + explainThis: { + label: 'Explica esto', + description: 'Recorre cómo funciona el código seleccionado y enlaza los archivos clave.', + text: 'Explica cómo funciona esto y señala los archivos clave.' + } + } + }, + statusStack: { + hideStack: 'Ocultar la pila de estado', + showStack: 'Mostrar la pila de estado', + agents: 'Agentes', + background: count => `${count} en segundo plano`, + goalActive: 'Objetivo activo', + goalBlocked: 'Objetivo bloqueado', + goalDone: 'Objetivo completado', + goalPaused: 'Objetivo en pausa', + goalWaiting: 'Objetivo esperando', + subagents: count => `${count} subagente${count === 1 ? '' : 's'}`, + todos: (done, total) => `Tareas ${done}/${total}`, + running: 'En ejecución', + stop: 'Detener', + dismiss: 'Descartar', + exit: code => `salida ${code}`, + control: { + goalActiveTurns: (turn: number, maxTurns: number) => `Turno ${turn}/${maxTurns}`, + goalDoneTurns: (turns: number) => `${turns} ${turns === 1 ? 'turno' : 'turnos'}`, + goalTurn: (turn: number) => `Turno ${turn}`, + goalActions: 'Acciones del objetivo', + viewDetails: 'Ver detalles', + addCriterion: 'Añadir criterio', + addCriterionDialogTitle: 'Añadir criterio', + addCriterionPlaceholder: 'Escribe el texto del criterio...', + criterionLabel: 'Criterio', + pauseGoal: 'Pausar objetivo', + resumeGoal: 'Reanudar objetivo', + resumeNow: 'Reanudar ahora', + clearGoal: 'Borrar objetivo', + clearGoalConfirmTitle: '¿Borrar el objetivo?', + clearGoalConfirmBody: '¿Seguro que quieres borrar el objetivo activo? No se puede deshacer.', + copyCriterion: (index: number) => `Copiar criterio ${index}`, + removeCriterion: (index: number) => `Quitar criterio ${index}`, + removeCriterionConfirmTitle: (index: number) => `¿Quitar el criterio ${index}?`, + removeCriterionConfirmBody: (index: number) => `¿Seguro que quieres quitar el criterio ${index}?`, + clearCriteria: 'Borrar todos los criterios', + clearCriteriaConfirmTitle: '¿Borrar todos los criterios?', + clearCriteriaConfirmBody: '¿Seguro que quieres quitar todos los criterios de este objetivo?', + criteriaHeader: (count: number) => `Criterios · ${count}`, + noCriteria: 'Sin criterios', + goalDetailsTitle: 'Detalles del objetivo', + objectiveLabel: 'Objetivo', + contractOutcome: 'Resultado', + contractVerification: 'Verificación', + contractConstraints: 'Restricciones', + contractBoundaries: 'Límites', + contractStopWhen: 'Detener cuando', + waitBarrierTitle: 'Condición de espera', + waitUntil: (target: string) => `Esperando hasta ${target}`, + waitSession: (target: string) => `Esperando a la sesión ${target}`, + waitPid: (pid: number) => `Esperando al proceso ${pid}`, + qualityGatesTitle: 'Controles de calidad', + gateCommand: 'Comando', + gateAttempts: (attempts: number, max: number) => `${attempts}/${max} intentos`, + gateTimeout: (seconds: number) => `tiempo límite de ${seconds} s`, + gateLastExit: (code: number | null) => (code === null ? 'Pendiente' : `Código de salida: ${code}`), + loopActive: 'Bucle activo', + loopPaused: 'Bucle en pausa', + loopDeferred: 'Bucle aplazado', + loopFinished: 'Bucle terminado', + loopRuns: (runs: number) => `${runs} ${runs === 1 ? 'ejecución' : 'ejecuciones'}`, + loopRunCount: (current: number, total: number) => `Ejecución ${current}/${total}`, + loopNext: (time: string) => `próxima ${time}`, + loopEverySeconds: (seconds: number) => `cada ${seconds} s`, + loopEveryMinutes: (minutes: number) => `cada ${minutes} min`, + loopEveryHours: (hours: number) => `cada ${hours} h`, + loopSelfPaced: 'a su ritmo', + loopActions: 'Acciones del bucle', + pauseLoop: 'Pausar bucle', + resumeLoop: 'Reanudar bucle', + stopLoop: 'Detener bucle', + stopLoopConfirmTitle: '¿Detener el bucle?', + stopLoopConfirmBody: '¿Seguro que quieres detener este bucle?', + dismissLoop: 'Descartar bucle', + loopPromptLabel: 'Prompt', + loopCadenceLabel: 'Frecuencia', + loopUntilLabel: 'Condición de fin', + loopDeferredNotice: 'Un objetivo activo controla la sesión en este momento.', + loopAwaitingResponse: 'Esperando respuesta', + heartbeatActive: 'Heartbeat activo', + heartbeatPaused: 'Heartbeat en pausa', + heartbeatEveryMinutes: (minutes: number) => `cada ${minutes} min`, + heartbeatEveryHours: (hours: number) => `cada ${hours} h`, + heartbeatEverySeconds: (seconds: number) => `cada ${seconds} s`, + heartbeatNext: (time: string) => `próximo ${time}`, + heartbeatDueWaitingForIdle: 'pendiente: esperando inactividad', + heartbeatActions: 'Acciones del heartbeat', + pauseHeartbeat: 'Pausar heartbeat', + resumeHeartbeat: 'Reanudar heartbeat', + clearHeartbeat: 'Borrar heartbeat', + clearHeartbeatConfirmTitle: '¿Borrar el heartbeat?', + clearHeartbeatConfirmBody: '¿Seguro que quieres borrar este heartbeat?', + heartbeatFiredCount: (count: number) => `Se activó ${count} ${count === 1 ? 'vez' : 'veces'}`, + actionFailed: (msg: string) => `La acción falló: ${msg}`, + actionSucceeded: 'La acción se completó', + copySuccess: 'Criterio copiado al portapapeles', + copyFailure: 'No se pudo copiar el criterio al portapapeles', + continuationFailed: 'No se pudo enviar la continuación del objetivo', + continuationQueued: 'Objetivo reanudado: la continuación queda en cola hasta que termine el turno actual', + continuationBusy: 'Objetivo reanudado: la sesión está ocupada; usa /interrupt en el turno actual para continuar', + controlUnavailable: (msg: string) => `Controles de sesión no disponibles: ${msg}`, + dismissError: 'Descartar error', + add: 'Añadir' + }, + coding: { + title: 'Árbol de trabajo', + noBranch: 'Sin rama', + detached: 'separada', + clean: 'Limpio', + changed: count => `${count} cambio${count === 1 ? '' : 's'}`, + ahead: count => `${count} por delante`, + behind: count => `${count} por detrás`, + review: 'Revisar', + close: 'Cerrar', + openChanges: 'Abrir cambios', + openFile: 'Abrir archivo', + stage: 'Preparar', + unstage: 'Quitar de preparación', + stageAll: 'Preparar todo', + viewAsTree: 'Ver como árbol', + viewAsList: 'Ver como lista', + revert: 'Revertir', + revertAll: 'Revertir todo', + revertConfirm: + '¿Descartar los cambios en este archivo y restaurarlo al estado comprometido? Esto no se puede deshacer.', + revertAllConfirm: + '¿Descartar todos los cambios y restaurar archivos al estado comprometido? Esto no se puede deshacer.', + staged: 'Preparados', + noChanges: 'Sin cambios', + notRepo: 'No es un repositorio Git', + noDiff: 'No hay diferencias que mostrar', + scopeUncommitted: 'Sin confirmar', + scopeBranch: 'Rama', + scopeLastTurn: 'Último turno', + commit: 'Hacer commit', + commitAndPush: 'Hacer commit y enviar', + commitPlaceholder: (shortcut: string) => `Mensaje (${shortcut} para hacer commit)`, + generateCommitMessage: 'Generar mensaje de commit', + stopGenerating: 'Dejar de generar', + createPr: 'Crear PR', + openPr: 'Abrir PR', + ghMissing: 'Instala GitHub CLI (gh) e inicia sesión para abrir PR', + agentShip: 'Pedir a Hermes que abra un PR', + agentShipUnavailable: 'El chat al que pertenecen estos cambios no está en pantalla.', + agentShipPrompt: + 'Revisa los cambios actuales, haz un commit con un mensaje convencional claro, envía la rama y abre un pull request.', + newBranch: 'Nueva rama', + branchOffFrom: base => `Nueva rama desde ${base}`, + switchTo: branch => `Cambiar a ${branch}`, + switchFailed: branch => `No se pudo cambiar a ${branch}`, + worktrees: 'Worktrees' + } + }, + updates: { + stages: { + idle: 'Preparando…', + prepare: 'Preparando…', + fetch: 'Descargando…', + pull: 'Casi listo…', + pydeps: 'Terminando…', + update: 'Actualizando Hermes…', + rebuild: 'Reconstruyendo la aplicación de escritorio…', + restart: 'Reiniciando Hermes…', + done: 'Actualización completada', + manual: 'Actualizar desde la terminal', + guiSkew: 'Actualiza la aplicación de escritorio', + error: 'Actualización pausada' + }, + checking: 'Buscando actualizaciones…', + checkFailedTitle: 'No se pudieron buscar actualizaciones', + tryAgain: 'Intentar de nuevo', + notAvailableTitle: 'Actualización no disponible', + unsupportedMessage: 'Esta versión de Hermes no puede actualizarse desde la app.', + connectionRetry: + 'Hermes no pudo llegar al servidor de actualizaciones. Comprueba tu conexión a internet y vuelve a intentarlo. Si usas un Hermes remoto, asegúrate de que esté en línea.', + gitUnusable: 'Hermes no pudo ejecutar Git en este equipo, así que no pudo buscar actualizaciones.', + connectionSettings: 'Configuración de conexión', + openDownloadPage: 'Abrir la página de descarga', + latestBody: 'Estás usando la versión más reciente.', + latestBodyBackend: 'El backend está ejecutando la versión más reciente.', + allSetTitle: 'Todo listo', + availableTitle: 'Nueva actualización disponible', + availableBody: 'Hay una nueva versión de Hermes lista para instalar.', + availableTitleBackend: 'Actualización del backend disponible', + availableBodyBackend: + 'Hay una versión más reciente del backend de Hermes al que estás conectado lista para instalar.', + availableBodyNoChangelog: + 'Hay una versión más reciente lista. Las notas de la versión no están disponibles para este tipo de instalación.', + updateNow: 'Actualizar ahora', + maybeLater: 'Quizá más tarde', + moreChanges: count => `+ ${count} ${count === 1 ? 'cambio incluido' : 'cambios incluidos'}.`, + manualTitle: 'Actualizar desde la terminal', + manualBody: + 'Instalaste Hermes desde la línea de comandos, así que las actualizaciones también se ejecutan ahí. Pega esto en tu terminal:', + manualPickedUp: 'Hermes usará la nueva versión la próxima vez que lo abras.', + guiSkewTitle: 'Actualiza la aplicación de escritorio', + guiSkewBody: + 'El backend se actualizó, pero el paquete de esta aplicación de escritorio no cambió. Actualiza o reinstala la aplicación de escritorio de Hermes (tu AppImage / .deb / .rpm) para que coincidan.', + copy: 'Copiar', + copied: 'Copiado', + done: 'Listo', + applyingBody: + 'El actualizador de Hermes tomará el control en su propia ventana y volverá a abrir Hermes al terminar.', + applyingBodyBackend: + 'El backend remoto está aplicando la actualización y se reiniciará. Hermes se reconectará automáticamente cuando vuelva a estar disponible.', + applyingClose: 'Hermes se cerrará para aplicar la actualización.', + errorTitle: 'La actualización no terminó', + errorBody: 'No pasa nada: no se perdió nada. Puedes intentarlo de nuevo ahora.', + blockerTitle: '¿Cerrar las vistas previas locales para actualizar Hermes?', + blockerBody: + 'Hermes necesita detener estas vistas previas locales antes de actualizar. Esto no modifica ni elimina tus archivos.', + foreignBlockerTitle: 'Cierra otros procesos para actualizar Hermes', + foreignBlockerBody: + 'Hermes no puede cerrar estos procesos automáticamente de forma segura. Cierra la app, el terminal o el servicio al que pertenece cada uno y vuelve a intentar la actualización.', + mixedBlockerBody: + 'Hermes puede cerrar las vistas previas locales que se indican abajo. Los demás procesos deben cerrarse manualmente antes de continuar con la actualización.', + closePreviewsAndUpdate: 'Cerrar vistas previas y actualizar', + closePreviewsAndCheckAgain: 'Cerrar vistas previas y volver a comprobar', + localPreview: 'Vista previa local', + portLabel: (port: number) => `Puerto ${port}`, + pidLabel: (pid: number) => `PID ${pid}`, + technicalDetails: 'Detalles técnicos', + notNow: 'Ahora no', + clientAlsoBehindTitle: 'La app de escritorio está desactualizada', + clientAlsoBehindMessage: + 'El backend está actualizado, pero esta app de escritorio sigue en una versión anterior. Actualízala para obtener las últimas correcciones.', + clientAlsoBehindAction: 'Actualizar la app de escritorio', + everythingDispatched: 'Actualización enviada', + everythingSkipped: 'Omitido', + everythingRowFailed: 'Falló la actualización', + everythingFanoutFailedTitle: 'No se pudieron actualizar las demás instancias', + changeLogNew: 'Novedades', + changeLogFixed: 'Corregido', + changeLogFaster: 'Más rápido', + changeLogImproved: 'Mejorado', + changeLogOther: 'Otras mejoras', + changeLogFallbackLabel: 'En esta actualización', + changeLogFallbackItem: 'Mejoras y correcciones', + applyStatus: { + preparing: 'Actualizando backend…', + pulling: 'Actualizando backend…', + restarting: 'Reiniciando el backend para cargar la actualización…', + notAvailable: 'La actualización no está disponible para este backend.', + failed: 'Falló la actualización del backend.', + noReturn: + 'El backend no volvió a estar disponible. Puede que la actualización no se haya completado; revisa el host del backend.' + } + }, + handoffTour: { + profileTitle: 'Tu primera tarea se ejecuta en el perfil predeterminado', + profileText: + 'Esta barra cambia de perfil. El que está iluminado ahora es el predeterminado, donde está la sesión de la tarea. El otro es el perfil de configuración, donde está el chat de bienvenida.', + sessionsTitle: 'Cada perfil tiene sus propias sesiones', + sessionsText: + 'Esta lista pertenece al perfil predeterminado. Nueva sesión crea una en el perfil que esté seleccionado. Cambia de perfil en la barra y la lista cambia con él.', + stayTitle: 'Hermes está a un clic', + stayText: 'Cambia al perfil de configuración y abre Bienvenida a Hermes siempre que necesites ayuda. Se queda ahí.' + }, + guidedGreeting: { + line: 'Hola, pasa. Soy Hermes. Dame dos minutos para prepararlo todo a tu medida y luego me pondremos a trabajar en algo que de verdad quieras hacer.\n\nPero antes, ¿cómo quieres que te llame?', + nameSuggestion: (name: string) => `(También puedo llamarte simplemente ${name}, si lo prefieres.)` + }, + install: { + stageStates: { + pending: 'Pendiente', + running: 'Instalando', + succeeded: 'Listo', + skipped: 'Omitido', + failed: 'Falló' + }, + oneTimeTitle: 'Hermes necesita una instalación única', + unsupportedDesc: platform => + `La instalación automática del primer inicio aún no está disponible en ${platform}. Abre Terminal y ejecuta el comando de abajo; luego vuelve a abrir la app. Los siguientes inicios omitirán este paso.`, + installCommand: 'Comando de instalación', + copyCommand: 'Copiar comando', + viewDocs: 'Ver docs de instalación', + installTo: 'Se instalará en', + retryAfterRun: 'Ya lo ejecuté -- reintentar', + setupChoiceTitle: 'Configurar Hermes Desktop', + setupChoiceDesc: + 'Conecta esta app con un gateway de Hermes que ya esté en ejecución o instala Hermes localmente en este equipo.', + connectExistingTitle: 'Conectar con un Hermes existente', + connectExistingShort: 'Conectar existente', + connectExistingDesc: + 'Usa un backend remoto con un token de sesión o inicio de sesión en el navegador. No se iniciará ninguna instalación local.', + installLocalTitle: 'Instalar Hermes localmente', + installLocalDesc: 'Descarga Hermes, crea su entorno de Python y ejecuta el backend en este equipo.', + localStartUnavailable: 'No se pudo iniciar la instalación local. Reinicia Hermes Desktop e inténtalo de nuevo.', + remoteSetupTitle: 'Conectar con un Hermes existente', + remoteSetupDesc: + 'Introduce la URL de tu gateway. Hermes Desktop detectará si necesita un token o iniciar sesión en el navegador.', + remoteUrlTitle: 'URL del gateway', + remoteUrlDesc: 'Usa la URL base del gateway de Hermes e incluye https:// si es remoto.', + remoteUrlPlaceholder: 'https://gateway.example.com/hermes', + probing: 'Detectando la autenticación del gateway…', + probeError: + 'Hermes no puede llegar a esa dirección. Comprueba la URL y que el otro equipo esté ejecutando Hermes; las opciones de inicio de sesión aparecen cuando responde.', + probeErrorDetails: 'Detalles', + identityProvider: 'tu proveedor de identidad', + authTitle: 'Autenticación', + authNeedsOauth: provider => `Inicia sesión con ${provider} antes de probar este gateway.`, + authSignedIn: 'Inicio de sesión en el navegador completado.', + connected: 'Conectado', + signIn: 'Iniciar sesión', + signInWith: provider => `Iniciar sesión con ${provider}`, + enterUrlFirst: 'Introduce primero la URL de un gateway.', + signInIncomplete: 'La ventana de inicio de sesión se cerró antes de completar la autenticación.', + tokenTitle: 'Token de sesión', + tokenDesc: 'Pega el token de sesión del archivo .env del gateway remoto.', + pasteSessionToken: 'Pegar token de sesión', + incompleteSignInTest: 'Inicia sesión antes de probar este gateway protegido con OAuth.', + incompleteTokenTest: 'Introduce un token de sesión antes de probar este gateway.', + testConnection: 'Probar conexión', + testSucceeded: (baseUrl, version) => `Conectado a ${baseUrl}${version ? ` (${version})` : ''}.`, + applyRemote: 'Aplicar y reconectar', + backToSetup: 'Atrás', + failedTitle: 'Falló la instalación', + settingUpTitle: 'Configurando Hermes Agent', + finishingTitle: 'Terminando', + failedDesc: + 'Uno de los pasos de configuración no terminó. Puede ocurrir si hay otra copia de Hermes en ejecución, se cortó la conexión a internet o un antivirus bloqueó el instalador. Cierra las demás ventanas de Hermes y elige Recargar y reintentar. Si vuelve a fallar, abre los registros y envíalos al soporte.', + activeDesc: + 'Esta configuración se realiza una sola vez. El instalador de Hermes está descargando dependencias y configurando tu máquina. Los siguientes inicios omitirán este paso.', + progress: (completed, total) => `${completed} de ${total} pasos completados`, + currentStage: stage => ` -- ahora: ${stage}`, + fetchingManifest: 'Obteniendo manifiesto del instalador...', + error: 'Error', + hideOutput: 'Ocultar salida del instalador', + showOutput: 'Mostrar salida del instalador', + lines: count => `${count} ${count === 1 ? 'línea' : 'líneas'}`, + noOutput: 'Aún no hay salida.', + cancelling: 'Cancelando...', + cancelInstall: 'Cancelar instalación', + transcriptSaved: 'Transcripción completa guardada en', + copiedOutput: '¡Copiado!', + copyOutput: 'Copiar salida', + reloadRetry: 'Recargar y reintentar', + openLogs: 'Abrir registros' + }, + onboarding: { + headerTitle: 'Vamos a configurar Hermes Agent', + headerDesc: 'Conecta un proveedor de modelo para empezar a chatear. La mayoría de opciones requieren un clic.', + preparingInstall: 'Hermes está terminando la instalación. En el primer inicio suele tardar menos de un minuto.', + starting: 'Iniciando Hermes…', + lookingUpProviders: 'Buscando proveedores...', + collapse: 'Contraer', + otherProviders: 'Otros proveedores', + haveApiKey: 'Tengo una clave API', + chooseLater: 'Elegiré un proveedor más tarde', + recommended: 'Recomendado', + connected: 'Conectado', + featuredPitch: 'Una suscripción, más de 300 modelos frontier: la forma recomendada de usar Hermes', + fireworksPitch: 'API directa de modelos: modelos frontier alojados en Fireworks', + localModelsTitle: 'Ejecutar modelos localmente', + localModelsPitch: 'Sin cuenta: descarga un modelo y ejecútalo en este equipo', + openRouterPitch: 'Una clave, cientos de modelos: un buen valor predeterminado', + apiKeyOptions: { + fireworks: { + short: 'API de modelo directo', + description: 'Acceso directo a modelos alojados en Fireworks AI.' + }, + openrouter: { + short: 'una clave, muchos modelos', + description: + 'Aloja cientos de modelos detrás de una sola clave. Buen valor predeterminado para instalaciones nuevas.' + }, + openai: { + short: 'modelos tipo GPT', + description: 'Acceso directo a modelos de OpenAI.' + }, + gemini: { + short: 'modelos Gemini', + description: 'Acceso directo a modelos de Google Gemini.' + }, + xai: { + short: 'modelos Grok', + description: 'Acceso directo a modelos Grok de xAI.' + }, + local: { + short: 'autohospedado', + description: + 'Apunta Hermes a un endpoint local o autohospedado compatible con OpenAI (vLLM, llama.cpp, Ollama, etc.).' + } + }, + backToSignIn: 'Volver al inicio de sesión', + getKey: 'Obtener una clave', + replaceCurrent: 'Reemplazar valor actual', + pasteApiKey: 'Pegar clave API', + localApiKeyPlaceholder: 'Clave API (opcional; solo si tu endpoint la requiere)', + couldNotSave: 'No se pudo guardar la credencial.', + connecting: 'Conectando', + update: 'Actualizar', + flowSubtitles: { + pkce: 'Abre tu navegador para iniciar sesión y luego continúa aquí', + device_code: 'Abre una página de verificación en tu navegador; Hermes se conecta automáticamente', + external: 'Inicia sesión una vez en tu terminal y vuelve para chatear' + }, + startingSignIn: provider => `Iniciando sesión con ${provider}...`, + verifyingCode: provider => `Verificando tu código con ${provider}...`, + connectedProvider: provider => `${provider} conectado`, + connectedPicking: provider => `${provider} conectado. Eligiendo un modelo predeterminado...`, + signInFailed: 'No se pudo iniciar sesión. Inténtalo de nuevo.', + signInExpired: + 'La página de inicio de sesión caducó antes de que terminaras. Vuelve a intentarlo y completa el paso del navegador en unos minutos, o usa una clave API.', + signInDidNotFinish: (provider: string) => + `No se completó el inicio de sesión con ${provider}. Comprueba tu conexión a internet y vuelve a intentarlo, o elige otro proveedor.`, + tryAgain: 'Reintentar', + useApiKeyInstead: 'Usar una clave API', + errorDetails: 'Detalles', + pickDifferentProvider: 'Elegir otro proveedor', + signInWith: provider => `Iniciar sesión con ${provider}`, + openedBrowser: provider => `Abrimos ${provider} en tu navegador.`, + authorizeThere: 'Autoriza Hermes allí.', + copyAuthCode: 'Copia el código de autorización y pégalo abajo.', + pasteAuthCode: 'Pegar código de autorización', + reopenAuthPage: 'Volver a abrir página de autorización', + autoBrowser: provider => + `Abrimos ${provider} en tu navegador. Autoriza Hermes allí y te conectarás automáticamente; no hay nada que copiar o pegar.`, + reopenSignInPage: 'Volver a abrir página de inicio de sesión', + waitingAuthorize: 'Esperando tu autorización...', + externalPending: provider => + `${provider} inicia sesión con su propia CLI. Ejecuta este comando en una terminal y luego vuelve y elige "Ya inicié sesión":`, + signedIn: 'Ya inicié sesión', + deviceCodeOpened: provider => `Abrimos ${provider} en tu navegador. Introduce este código allí:`, + reopenVerification: 'Volver a abrir página de verificación', + copy: 'Copiar', + defaultModel: 'Modelo predeterminado', + freeTier: 'Nivel gratis', + pro: 'Pro', + free: 'Gratis', + price: (input, output) => `${input} entrada / ${output} salida por Mtok`, + change: 'Cambiar', + startChatting: 'Empezar', + docs: provider => `Docs de ${provider}` + }, + freeTier: { + providerRowTitle: 'Nous · plan gratuito', + providerRowPitch: 'Inicia sesión con una cuenta de Nous para desbloquear más modelos y herramientas.', + readyTitle: 'Hermes está listo.', + readyCaption: 'Gratis · conectores incluidos', + begin: 'Empezar', + signInInstead: 'Iniciar sesión con una cuenta de Nous', + otherProviders: 'Otros proveedores', + stripTitle: 'Ya están disponibles la inferencia y los conectores gratuitos de Nous.', + stripBody: 'Abre el selector de modelos para probarlos o inicia sesión con una cuenta de Nous.', + openModelPicker: 'Abrir selector de modelos', + dismiss: 'Descartar', + providerName: 'Nous', + statusLabel: (model: string) => `Nous · ${model}`, + signIn: 'Iniciar sesión', + signInHeading: 'Inicia sesión con una cuenta de Nous para desbloquear más modelos y herramientas.', + settingUp: 'Configurando la inferencia gratuita…', + codeBody: 'Introduce este código en tu navegador para terminar de iniciar sesión.', + copyLink: 'Copiar enlace', + doNotShare: 'No compartas este código.', + waiting: 'Esperando el inicio de sesión…', + finishingHeading: 'Terminando el inicio de sesión…', + finishingBody: 'Aprobado en el navegador. Obteniendo los tokens de tu cuenta.', + signedInAs: (email: string) => `Sesión iniciada como ${email}`, + signedIn: 'Sesión iniciada.', + completedBody: 'Tu cuenta ya incluye inferencia y herramientas.', + defaultModel: 'Modelo predeterminado', + change: 'Cambiar', + done: 'Listo', + notNow: 'Ahora no', + tryAgain: 'Reintentar', + startAgain: 'Empezar de nuevo', + didNotComplete: 'No se completó el inicio de sesión', + rejectedBody: 'No pasa nada, sigues en el servicio gratuito de Nous. Inicia sesión cuando quieras.', + supersededBody: + 'Un código de inicio de sesión más reciente sustituyó a este. Usa el más reciente o empieza de nuevo.', + timedOutHeading: 'Ese enlace de inicio de sesión caducó', + timedOutBody: 'Empieza de nuevo cuando quieras. Sigues en el servicio gratuito de Nous.', + retiredBody: + 'Tu sesión terminó antes de completar el inicio de sesión. Hermes iniciará una nueva; luego vuelve a iniciar sesión cuando quieras.', + errorBody: 'No se completó el inicio de sesión. Vuelve a intentarlo cuando quieras.', + busyHeading: 'Ya casi está', + busyBody: (wait: string) => + `Hermes no pudo terminar de iniciar tu sesión porque el servicio de Nous está ocupado. Vuelve a intentarlo en ${wait}. Mientras tanto, tu sesión sigue aquí.`, + unreachableBody: + 'Hermes no pudo llegar al servicio de Nous para terminar de iniciar tu sesión. Comprueba tu conexión a internet y vuelve a intentarlo. Tu sesión sigue aquí.', + alreadySignedInHeading: 'Ya has iniciado sesión.', + alreadySignedInBody: 'Este Hermes ya tiene la sesión iniciada en una cuenta de Nous.', + setupFailed: { + gateClosed: + 'Esta versión de Hermes no puede iniciarse sin una cuenta de Nous. Inicia sesión o crea una: es gratis y solo lleva un minuto.', + paused: + 'El uso de Hermes sin iniciar sesión está en pausa por un momento. Hermes seguirá comprobándolo. Iniciar sesión es gratis y te permite empezar ahora mismo.', + rateLimited: (wait: string) => + `Mucha gente está empezando ahora mismo, así que Hermes volverá a intentarlo en ${wait}. Iniciar sesión es gratis y te ahorra la espera.`, + unreachable: + 'Hermes no pudo llegar al servicio de Nous. Comprueba tu conexión a internet y pulsa Reintentar. O conecta otro proveedor por ahora.', + serverError: + 'El servicio de Nous tuvo un fallo. Pulsa Reintentar en un momento o conecta otro proveedor por ahora.', + powRequired: + 'El servidor de Nous pidió una prueba de trabajo, pero tu agente todavía no la implementa. Inicia sesión o crea una cuenta gratuita de Nous para continuar.', + locked: + 'Esta sesión no puede continuar sin iniciar sesión. Inicia sesión o crea una cuenta gratuita de Nous para seguir.', + generic: + 'Hermes no pudo configurar el acceso gratuito sin iniciar sesión. Iniciar sesión es gratis; también puedes conectar otro proveedor.', + signInBelow: 'Iniciar sesión es gratis. Elige Nous abajo.', + tryAgain: 'Reintentar', + retrying: 'Reintentando…' + } + }, + modelPicker: { + title: 'Cambiar modelo', + current: 'actual:', + unknown: '(desconocido)', + search: 'Filtrar proveedores y modelos...', + noModels: 'No se encontraron modelos.', + addProvider: 'Añadir proveedor', + loadFailed: 'No se pudieron cargar los modelos', + loadingIntoMemory: 'Cargando en memoria', + downloading: 'Descargando', + localDownloadsHeading: 'Local', + noAuthenticatedProviders: 'No hay proveedores autenticados.', + pro: 'Pro', + proNeedsSubscription: 'Los modelos Pro necesitan una suscripción de Nous de pago.', + free: 'Gratis', + freeTier: 'Nivel gratis', + priceTitle: 'Precio de entrada / salida por millón de tokens', + wasPrice: 'antes', + customModel: 'Modelo personalizado', + addCustomModelAction: 'Añadir modelo personalizado…', + customModelPlaceholder: 'Escribe un ID de modelo, p. ej., openai/gpt-5' + }, + modelVisibility: { + title: 'Modelos', + search: 'Buscar modelos', + noAuthenticatedProviders: 'No hay proveedores autenticados.', + addProvider: 'Añadir proveedor…', + addCustomModel: 'Añadir modelo personalizado', + removeCustomModel: 'Quitar modelo personalizado' + }, + shell: { + windowControls: 'Controles de ventana', + paneControls: 'Controles de panel', + appControls: 'Controles de app', + modelMenu: { + search: 'Buscar modelos', + noModels: 'No se encontraron modelos', + editModels: 'Editar modelos…', + refreshModels: 'Actualizar modelos', + fast: 'Rápido' + }, + modelOptions: { + noOptions: 'No hay opciones para este modelo', + options: 'Opciones', + thinking: 'Razonamiento', + fast: 'Rápido', + effort: 'Esfuerzo', + minimal: 'Mínimo', + low: 'Bajo', + medium: 'Medio', + high: 'Alto', + xhigh: 'Extra alto', + max: 'Máximo', + ultra: 'Ultra', + sendsOnRoute: (level: string) => `envía ${level} en esta ruta`, + updateFailed: 'No se pudo actualizar la opción del modelo', + fastFailed: 'No se pudo actualizar el modo rápido' + }, + gatewayMenu: { + gateway: 'Gateway', + connected: 'Conectado', + connecting: 'Conectando', + offline: 'Sin conexión', + inferenceReady: 'Inferencia lista', + inferenceNotReady: 'Inferencia no lista', + checkingInference: 'Comprobando inferencia', + disconnected: 'Desconectado', + reconnectGateway: 'Reconectar gateway', + openSystem: 'Abrir panel del sistema', + connection: label => `Conexión: ${label}`, + recentActivity: 'Actividad reciente', + viewAllLogs: 'Ver todos los registros →', + messagingPlatforms: 'Plataformas de mensajería' + }, + approvalMode: { + title: 'Modo de aprobación', + ariaLabel: mode => `Modo de aprobación: ${mode}`, + manual: 'Manual', + manualDescription: 'Preguntar antes de acciones que requieran aprobación', + smart: 'Inteligente', + smartDescription: 'Evaluar automáticamente las acciones y preguntar cuando sea necesario', + off: 'Desactivado', + offDescription: 'Ejecutar sin solicitudes de aprobación' + }, + statusbar: { + unknown: 'desconocido', + restart: 'reiniciar', + update: 'actualizar', + updateInProgress: 'Actualización en curso', + commitsBehind: (count, branch) => `${count} ${count === 1 ? 'commit' : 'commits'} detrás de ${branch}`, + desktopVersion: version => `Hermes Desktop v${version}`, + backendVersion: version => `backend v${version}`, + clientLabel: version => `cliente v${version}`, + connectionSsh: host => `SSH: ${host}`, + connectionRemote: host => `Remoto: ${host}`, + connectionCloud: host => `Nube: ${host}`, + connectionCloudTooltip: host => `Hermes Cloud · ${host}`, + connectionSshTooltip: host => `SSH · ${host}`, + connectionRemoteTooltip: host => `Remoto · ${host}`, + backendLabel: version => `backend v${version}`, + commit: sha => `commit ${sha}`, + branch: branch => `rama ${branch}`, + closeCommandCenter: 'Cerrar Centro de comandos', + openCommandCenter: 'Abrir Centro de comandos', + showTerminal: 'Mostrar terminal', + hideTerminal: 'Ocultar terminal', + gateway: 'Gateway', + gatewayReady: 'listo', + gatewayNeedsSetup: 'necesita configuración', + gatewayUnavailable: 'inferencia no disponible', + gatewayChecking: 'comprobando', + gatewayConnecting: 'conectando', + gatewayOffline: 'sin conexión', + gatewayRestarting: 'reiniciando…', + gatewayTitle: 'Puerta de enlace', + customizeTitle: 'Mostrar en la barra de estado', + hideStatusbar: 'Ocultar barra de estado', + resetStatusbar: 'Restablecer a valores predeterminados', + toggleApprovalMode: 'Aprobaciones', + toggleBackendVersion: 'Versión del backend', + toggleCacheHitRate: 'Tasa de aciertos de caché', + toggleCommandCenter: 'Centro de comandos', + toggleContextUsage: 'Medidor de contexto', + toggleRunningTimer: 'Temporizador de turno', + toggleSessionTimer: 'Temporizador de sesión', + toggleTerminal: 'Terminal', + toggleTokensPerSecond: 'Tokens por segundo', + toggleVersion: 'Versión y actualizaciones', + toggleFreeTier: 'Plan gratuito', + toggleWorkspace: 'Espacio de trabajo', + cacheHitRateTitle: + 'Tasa de aciertos de la caché de prompts en esta sesión: los tokens en caché cuestan menos, así que cuanto más alta, más barato', + tokensPerSecondTitle: 'Tokens de salida por segundo, promediados en las últimas 10 llamadas al modelo', + agents: 'Agentes', + closeAgents: 'Cerrar agentes', + openAgents: 'Abrir agentes', + subagents: count => `${count} ${count === 1 ? 'subagente' : 'subagentes'}`, + failed: count => `${count} fallidos`, + running: count => `${count} en ejecución`, + cron: 'Cron', + openCron: 'Abrir tareas cron', + webhooks: 'Webhooks', + openWebhooks: 'Abrir webhooks', + starmap: 'Grafo de memoria', + openStarmap: 'Abrir grafo de memoria', + turnRunning: 'En ejecución', + contextUsage: 'Uso de contexto', + systemResources: { + title: 'Recursos del sistema', + loading: 'Recursos…', + gpuUtilization: 'Uso de GPU', + gpuMemory: 'Memoria de GPU', + ram: 'RAM', + unifiedNote: 'Memoria unificada: la GPU y el sistema comparten este espacio.', + toggle: 'Recursos del sistema' + }, + contextUsagePanel: { + categories: { + conversation: 'Conversación', + mcp: 'MCP', + memory: 'Memoria', + rules: 'Reglas', + skills: 'Skills', + subagent_definitions: 'Definiciones de subagente', + system_prompt: 'Prompt del sistema', + tool_definitions: 'Definiciones de herramientas' + }, + empty: 'Aún no hay datos de contexto', + loading: 'Cargando desglose…', + percentFull: percent => `${percent}% lleno`, + title: 'Uso del contexto', + tokenSummary: (used, max) => `${used} / ${max} tokens` + }, + session: 'Sesión', + yoloOn: 'YOLO activado — autoaprobando comandos peligrosos. Shift+clic lo alterna globalmente.', + yoloOff: 'YOLO desactivado. Shift+clic lo alterna globalmente.', + modelNone: 'ninguno', + noModel: 'sin modelo', + switchModel: 'Cambiar modelo', + openModelPicker: 'Abrir selector de modelo', + modelPinned: 'fijado por ti; los chats nuevos lo usan en lugar del predeterminado de Configuración', + modelTitle: (provider, model) => `Modelo · ${provider}: ${model}`, + providerModelTitle: (provider, model) => `${provider} · ${model}` + } + }, + rightSidebar: { + aria: 'Barra lateral derecha', + panelsAria: 'Paneles de la barra lateral derecha', + files: 'Sistema de archivos', + terminal: 'Terminal', + noFolderSelected: 'No hay carpeta seleccionada', + changeCwdTitle: 'Cambiar directorio de trabajo', + remotePickerTitle: 'Elige una carpeta remota', + remotePickerDescription: 'Explora carpetas en el backend conectado.', + remotePickerSelect: 'Seleccionar carpeta', + folderTip: cwd => cwd, + openFolder: 'Abrir carpeta', + refreshTree: 'Actualizar árbol', + collapseAll: 'Contraer todas las carpetas', + showIgnored: 'Mostrar archivos ignorados por git', + hideIgnored: 'Ocultar archivos ignorados por git', + previewUnavailable: 'Vista previa no disponible', + couldNotPreview: path => `No se pudo previsualizar ${path}`, + noProjectTitle: 'Sin proyecto', + noProjectBody: 'Define un directorio de trabajo desde la barra de estado para explorar archivos.', + noProjectOpen: 'Ningún proyecto abierto', + noDiffs: 'Sin diferencias', + unreadableTitle: 'No legible', + unreadableBody: error => `No se pudo leer esta carpeta (${error}).`, + emptyTitle: 'Vacío', + emptyBody: 'Esta carpeta está vacía.', + treeErrorTitle: 'Error del árbol', + treeErrorBody: 'El árbol de archivos encontró un error al renderizar esta carpeta.', + tryAgain: 'Intentar de nuevo', + loadingTree: 'Cargando árbol de archivos', + loadingFiles: 'Cargando archivos', + terminalHide: 'Ocultar terminal', + terminalsAria: 'Terminales', + terminalNew: 'Nueva terminal', + terminalCloseOthers: 'Cerrar las demás', + terminalCloseAll: 'Cerrar todo', + addToChat: 'Añadir al chat' + }, + preview: { + tab: 'Vista previa', + closePane: 'Cerrar panel de vista previa', + loading: 'Cargando vista previa', + unavailable: 'Vista previa no disponible', + opening: 'Abriendo...', + hide: 'Ocultar', + openPreview: 'Abrir vista previa', + openInBrowser: 'Abrir en el navegador', + openInExternal: 'Abrir externamente', + popIn: 'Acoplar', + popOut: 'Desacoplar', + linkHint: '⌘/Ctrl-clic para acceder al panel de vista previa', + sourceLineTitle: 'Haz clic para seleccionar · Mayús-clic para ampliar · arrastra al compositor', + source: 'FUENTE', + renderedPreview: 'VISTA PREVIA', + diff: 'Diferencias', + unknownSize: 'tamaño desconocido', + binaryTitle: 'Esto parece un archivo binario', + binaryBody: label => `Previsualizar ${label} puede mostrar texto ilegible.`, + largeTitle: 'Este archivo es grande', + largeBody: (label, size) => `${label} pesa ${size}. Hermes solo mostrará los primeros 512 KB.`, + previewAnyway: 'Previsualizar de todos modos', + truncated: 'Mostrando los primeros 512 KB.', + noInlineTitle: 'Sin vista previa inline', + noInlineBody: mimeType => `${mimeType || 'Este tipo de archivo'} aún puede adjuntarse como contexto.`, + edit: 'Editar', + editing: 'Edición', + unsavedChanges: 'Cambios no guardados', + saveFailed: message => `No se pudo guardar: ${message}`, + diskChangedTitle: 'Archivo cambiado en el disco', + diskChangedBody: + 'Este archivo cambió desde que lo abriste. ¿Quieres sobrescribirlo con tu versión o descartar tus cambios y recargar?', + overwrite: 'Sobrescribir', + discardReload: 'Descartar y recargar', + console: { + deselect: 'Deseleccionar entrada', + select: 'Seleccionar entrada', + copyFailed: 'No se pudo copiar la salida de consola', + copyEntry: 'Copiar esta entrada', + sendEntry: 'Enviar esta entrada al chat', + messages: count => `${count} ${count === 1 ? 'mensaje de consola' : 'mensajes de consola'}`, + resize: 'Redimensionar consola de vista previa', + title: 'Consola de vista previa', + selected: count => `${count} seleccionados`, + sendToChat: 'Enviar al chat', + copySelected: 'Copiar selección al portapapeles', + copyAll: 'Copiar todo al portapapeles', + copy: 'Copiar', + clear: 'Limpiar', + empty: 'Aún no hay mensajes de consola.', + promptHeader: 'Consola de vista previa:', + sentTitle: 'Enviado al chat', + sentMessage: count => + `${count} ${count === 1 ? 'entrada de registro añadida' : 'entradas de registro añadidas'} al compositor` + }, + web: { + appFailedToBoot: 'La app de vista previa no arrancó', + serverNotFound: 'Servidor no encontrado', + remoteLoopback: + 'Esta dirección apunta al equipo que ejecuta tu agente, no a este. El panel del navegador carga las páginas localmente, así que un servidor de desarrollo remoto necesita un reenvío de puertos o un nombre de host accesible.', + failedToLoad: 'No se pudo cargar la vista previa', + tryAgain: 'Intentar de nuevo', + restarting: 'Hermes se está reiniciando...', + askRestart: 'Pedir a Hermes que reinicie el servidor', + lookingRestart: taskId => `Hermes está buscando un servidor de vista previa para reiniciar (${taskId})`, + restartingTitle: 'Reiniciando servidor de vista previa', + restartingMessage: + 'Hermes está trabajando en segundo plano. Mira la consola de vista previa para ver el progreso.', + startRestartFailed: message => `No se pudo iniciar el reinicio del servidor: ${message}`, + restartFailed: 'Falló el reinicio del servidor', + hideConsole: 'Ocultar consola de vista previa', + showConsole: 'Mostrar consola de vista previa', + hideDevTools: 'Ocultar DevTools de vista previa', + openDevTools: 'Abrir DevTools de vista previa', + goBack: 'Atrás', + goForward: 'Adelante', + reload: 'Recargar página', + address: 'Dirección', + addressPlaceholder: 'Introduce una dirección', + blankPageBody: 'Escribe una dirección arriba para navegar o pide a Hermes que abra una página.', + finishedRestarting: message => + `Hermes terminó de reiniciar el servidor de vista previa${message ? `: ${message}` : ''}`, + failedRestarting: message => `Falló el reinicio del servidor: ${message}`, + unknownError: 'error desconocido', + restartedTitle: 'Servidor de vista previa reiniciado', + reloadingNow: 'Recargando la vista previa ahora.', + restartFailedTitle: 'Falló el reinicio de la vista previa', + restartFailedMessage: 'Hermes no pudo reiniciar el servidor.', + stillWorking: + 'Hermes sigue trabajando, pero aún no llegó ningún resultado de reinicio. Puede que el comando del servidor siga en primer plano.', + workspaceReloading: 'El espacio de trabajo cambió, recargando vista previa', + fileChanged: url => `Archivo cambiado, recargando vista previa: ${url}`, + filesChanged: (count, url) => `${count} cambios de archivo, recargando vista previa: ${url}`, + watchFailed: message => `No se pudo vigilar el archivo de vista previa: ${message}`, + moduleMimeDescription: + 'Los scripts de módulo se están sirviendo con el tipo MIME incorrecto. Normalmente significa que un servidor de archivos estáticos sirve una app Vite/React en lugar del dev server del proyecto.', + loadFailedConsole: (code, message) => `Carga fallida${code ? ` (${code})` : ''}: ${message}`, + unreachableDescription: 'No se pudo acceder a la página de vista previa.', + openTarget: url => `Abrir ${url}`, + fallbackTitle: 'Vista previa', + annotate: 'Anotar', + annotateOn: 'Dejar de anotar', + annotateNeedPage: 'Primero abre una página en el navegador integrado.', + annotateFailed: 'No se pudo iniciar el modo de anotación', + commenting: 'Comentando', + addComments: (count: number) => (count === 1 ? 'Añadir 1 comentario' : `Añadir ${count} comentarios`), + commentPlaceholder: 'Añade un comentario...', + commentTitle: (n: number) => `Comentario ${n}`, + saveComment: 'Guardar', + cancelComment: 'Cancelar comentario' + } + }, + interfaceMode: { + title: 'Modo de interfaz', + hint: 'Cambia lo que se muestra, no lo que Hermes puede hacer.', + sessionNote: + 'Definido por el modo Simple. Un cambio aquí dura esta sesión; cambia a Avanzado para que sea permanente.', + simple: { + label: 'Simple', + description: 'Para hablar con Hermes. Barra lateral y chat; sin paneles de terminal, archivos ni diferencias.' + }, + advanced: { + label: 'Avanzado', + description: + 'Para desarrolladores. Terminal, archivos, diferencias, barra de estado y diseños, como los configures.' + } + }, + zones: { + showTabStrip: 'Mostrar pestañas', + hideTabStrip: 'Ocultar pestañas', + showStripTab: title => `Mostrar ${title}`, + hideStripTab: title => `Ocultar ${title}`, + lastTabKeptTitle: 'La última pestaña permanece', + lastTabKeptBody: + 'Esta zona necesita al menos una pestaña visible. Muestra otra pestaña primero, o colapsa toda la barra lateral.', + toggleStripTab: title => `Alternar pestaña ${title}`, + minimize: 'Minimizar', + restore: 'Restaurar', + closeRunningTitle: '¿Cerrar la pestaña en ejecución?', + closeRunningBody: + 'Este chat sigue trabajando o espera tu respuesta. Cerrar la pestaña lo oculta; la sesión conserva su progreso y puede volver a abrirse desde la barra lateral.', + closeRunningConfirm: 'Cerrar pestaña', + reload: 'Recargar', + closeOthers: 'Cerrar las demás', + closeToRight: 'Cerrar las de la derecha', + closeAll: 'Cerrar todo', + newSessionTab: 'Nueva pestaña de sesión', + newTab: 'Nueva pestaña', + pluginDisabled: pluginId => `Plugin "${pluginId}" desactivado`, + pluginDisabledBody: 'Vuelve a activarlo en Capacidades → Plugins para recuperar el panel.', + missingPane: paneId => `Falta el panel: ${paneId}`, + editTitle: 'Diseños', + editHint: 'Elige una disposición, o arrastra paneles entre zonas.', + reset: 'Restablecer', + templates: 'Plantillas', + custom: 'Personalizado', + newGridLayout: 'Nuevo diseño de cuadrícula', + saveCurrentAs: 'Guardar la disposición actual como plantilla', + nameLayoutPlaceholder: 'Nombre del diseño…', + deletePreset: name => `Eliminar ${name}`, + zoneEditorTitle: 'Editor de zonas', + editorHintPre: 'clic para dividir · ', + editorHintPost: + ' invierte la línea · arrastra entre zonas para unirlas · arrastra los bordes compartidos para redimensionar', + templateColumns: 'Columnas', + templateRows: 'Filas', + templateGrid: 'Cuadrícula', + templatePriority: 'Prioridad', + zoneTag: index => `zona ${index}`, + mergeZones: count => `Unir ${count} zonas`, + customZoneName: count => `Diseño personalizado de ${count} zonas`, + layoutNamePlaceholder: fallback => `Nombre del diseño (${fallback})`, + saveApply: 'Guardar y aplicar', + notExpressible: 'esta disposición se entrelaza (en molinete) y aún no puede expresarse como divisiones anidadas', + zoneCount: count => `${count} zonas`, + tabCount: count => `${count} pestañas` + }, + contextMenu: { + link: { + openInApp: 'Abrir en el navegador integrado', + openExternal: 'Abrir en navegador externo', + copyUrl: 'Copiar URL', + copyResolvedUrl: 'Copiar URL resuelta' + }, + image: { + copyImage: 'Copiar imagen', + copyImageAddress: 'Copiar dirección de imagen', + saveImageAs: 'Guardar imagen como…' + }, + edit: { + cut: 'Cortar', + paste: 'Pegar', + selectAll: 'Seleccionar todo', + addToDictionary: 'Agregar al diccionario' + }, + page: { + copyPageUrl: 'Copiar URL de la página', + inspectElement: 'Inspeccionar elemento' + } + }, + assistant: { + thread: { + loadingSession: 'Cargando sesión', + showEarlier: 'Mostrar mensajes anteriores', + loadingResponse: 'Hermes está cargando una respuesta', + loadingLocalModel: (model: string) => `Cargando ${model} en memoria`, + processingPrompt: 'Procesando el prompt', + resumeWhenBackgroundDone: count => + count === 1 + ? 'Se reanudará cuando termine la tarea en segundo plano.' + : `Se reanudará cuando terminen las ${count} tareas en segundo plano.`, + thinking: 'Pensando', + thought: 'Pensamiento', + thoughtBriefly: 'Pensó brevemente', + thoughtFor: duration => `Pensó durante ${duration}`, + turnDuration: duration => `Este turno tomó ${duration}`, + today: time => `Hoy, ${time}`, + yesterday: time => `Ayer, ${time}`, + copy: 'Copiar', + refresh: 'Actualizar', + moreActions: 'Más acciones', + branchNewChat: 'Ramificar en chat nuevo', + react: 'Reaccionar', + dismissError: 'Descartar error', + errorLayers: { + auth: 'Problema de inicio de sesión', + billing: 'Créditos agotados', + disk: 'Disco lleno', + endpoint: 'No se puede conectar con tu servidor de modelos', + gateway: 'Hermes tuvo un problema', + generic: 'Hermes no pudo terminar esta respuesta', + provider: 'El servicio de IA devolvió un error', + runtime: 'Hermes tuvo un problema', + streaming: 'La respuesta se cortó' + }, + errorLayerBodies: { + auth: 'El servicio de IA rechazó tu inicio de sesión. Revisa las credenciales de este proveedor y vuelve a enviar el mensaje.', + billing: 'Tu cuenta no tiene créditos para este proveedor. Recarga o cambia de proveedor y vuelve a enviarlo.', + disk: 'Tu disco está lleno, así que Hermes no pudo guardar esta conversación. Libera espacio y reinténtalo.', + endpoint: + 'Hermes no puede conectar con tu servidor de modelos personalizado. Comprueba que esté en ejecución y vuelve a enviar el mensaje.', + gateway: + 'Hermes tuvo un problema interno al iniciar esta respuesta. Vuelve a enviar el mensaje; si sigue ocurriendo, envía un diagnóstico.', + generic: 'Algo salió mal mientras Hermes respondía. Reinténtalo o copia los detalles si sigue ocurriendo.', + provider: + 'El servicio de IA no pudo completar esta solicitud. Reinténtalo en un momento o cambia de proveedor.', + runtime: + 'Hermes tuvo un problema interno al iniciar esta respuesta. Vuelve a enviar el mensaje; si sigue ocurriendo, envía un diagnóstico.', + streaming: 'La conexión se cortó antes de que terminara la respuesta. Reinténtalo para enviarla de nuevo.' + }, + errorCodes: { + auth: { + title: (provider: string) => `${provider} rechazó tu inicio de sesión`, + body: (provider: string) => + `No se aceptaron las credenciales guardadas para ${provider}. Corrígelas en Configuración o cambia de proveedor y vuelve a enviar el mensaje.` + }, + auth_permanent: { + title: (provider: string) => `${provider} rechazó tu inicio de sesión`, + body: (provider: string) => + `Las credenciales guardadas para ${provider} no son válidas o se revocaron. Actualízalas o cambia de proveedor y vuelve a enviar el mensaje.` + }, + billing: { + title: 'Sin créditos', + body: (provider: string) => + `Tu cuenta de ${provider} no tiene créditos. Recarga o cambia de proveedor y vuelve a enviarlo.` + }, + rate_limit: { + title: 'El servicio de IA está ocupado', + body: (provider: string) => + `${provider} está limitando las solicitudes ahora mismo. Espera un minuto y reinténtalo.` + }, + upstream_rate_limit: { + title: 'El servicio de IA está ocupado', + body: (provider: string) => + `${provider} está limitando las solicitudes ahora mismo. Espera un minuto y reinténtalo.` + }, + overloaded: { + title: 'El servicio de IA está sobrecargado', + body: (provider: string) => + `${provider} tiene problemas ahora mismo. Reinténtalo en un momento o cambia de proveedor.` + }, + server_error: { + title: 'El servicio de IA tuvo un problema', + body: (provider: string) => + `${provider} devolvió un error del servidor. Reinténtalo en un momento o cambia de proveedor.` + }, + timeout: { + title: 'Se agotó el tiempo de la respuesta', + body: (provider: string) => `${provider} no respondió a tiempo. Reinténtalo para enviarlo de nuevo.` + }, + stream_drop: { + title: 'La respuesta se cortó', + body: 'La conexión se cortó antes de que terminara la respuesta. Reinténtalo para enviarla de nuevo.' + }, + upstream_blocked: { + title: 'Un firewall bloqueó la solicitud', + body: (provider: string) => + `Un firewall o CDN delante de ${provider} bloqueó la solicitud antes de que llegara al modelo; probablemente tu clave está bien. Define un encabezado User-Agent mediante los extra_headers del proveedor en Configuración o cambia de proveedor y vuelve a enviar el mensaje.` + }, + ssl_cert_verification: { + title: 'Falló la conexión segura', + body: (provider: string) => + `Hermes no pudo verificar la conexión segura con ${provider}. Revisa la configuración de red o del proxy, o cambia de proveedor, y vuelve a enviar el mensaje.` + }, + context_overflow: { + title: 'Esta conversación es demasiado larga', + body: 'La conversación ya no cabe en el modelo. Comprímela o empieza un chat nuevo y vuelve a enviarlo.' + }, + payload_too_large: { + title: 'Este mensaje es demasiado grande', + body: 'La solicitud era demasiado grande para el modelo. Comprime la conversación o empieza un chat nuevo y vuelve a enviarlo.' + }, + model_not_found: { + title: 'Este modelo no está disponible', + body: (provider: string) => + `${provider} no ofrece este modelo en tu cuenta. Elige otro modelo y vuelve a enviar el mensaje.` + }, + provider_policy_blocked: { + title: 'La configuración de tu cuenta bloquea este modelo', + body: (provider: string) => + `${provider} no enrutaría esta solicitud con la configuración de datos o privacidad de tu cuenta. Elige otro modelo o cambia de proveedor.` + }, + content_policy_blocked: { + title: 'El servicio de IA rechazó esta solicitud', + body: (provider: string) => `${provider} no respondería a este mensaje. Edítalo y vuelve a enviarlo.` + }, + format_error: { + title: 'El servicio de IA rechazó la solicitud', + body: (provider: string) => + `${provider} no aceptó cómo se construyó esta solicitud. Cambia de proveedor o envía un diagnóstico para que lo revisemos.` + }, + truncated: { + title: 'La respuesta quedó incompleta', + body: 'El modelo se detuvo antes de terminar. Reinténtalo para obtener una respuesta completa.' + }, + invalid_response: { + title: 'El servicio de IA envió una respuesta ilegible', + body: (provider: string) => `${provider} devolvió algo que Hermes no pudo leer. Reinténtalo en un momento.` + }, + empty_response: { + title: 'El servicio de IA envió una respuesta vacía', + body: (provider: string) => `${provider} no devolvió nada para este mensaje. Reinténtalo en un momento.` + }, + loop_error: { + title: 'Hermes se quedó atascado en un bucle', + body: 'La respuesta repetía los mismos pasos, así que Hermes la detuvo. Reinténtalo o empieza un chat nuevo si vuelve a ocurrir.' + }, + SESSION_NOT_OWNED: { + title: 'Este chat está abierto en otro sitio', + body: 'Este chat está abierto en otra ventana de Hermes o en un terminal. Ciérralo allí y vuelve a enviar el mensaje, o empieza un chat nuevo aquí.' + }, + disk_full: { + title: 'Disco lleno', + body: 'Tu disco está lleno, así que Hermes no pudo guardar esta conversación. Libera espacio y reinténtalo.' + }, + free_tier_disabled: { + title: 'El uso de Hermes sin iniciar sesión está desactivado ahora mismo', + body: 'Inicia sesión con una cuenta de Nous para seguir chateando; es gratis.' + }, + free_tier_rate_limited: { + title: 'Agotaste el cupo para chatear sin iniciar sesión', + body: 'Se renueva en breve. Inicia sesión con una cuenta de Nous para tener un cupo mayor; es gratis.' + }, + free_tier_at_capacity: { + title: 'Chatear sin iniciar sesión está muy solicitado ahora mismo', + body: 'Inicia sesión para saltarte la cola (es gratis) o vuelve a intentarlo dentro de un rato.' + }, + free_tier_model_not_free: { + title: 'Ese modelo no está disponible sin iniciar sesión', + body: 'Por ahora Hermes usa el modelo gratuito. Inicia sesión con una cuenta de Nous para tener más modelos; es gratis.' + }, + free_tier_route: { + title: 'Hermes no pudo llegar al modelo gratuito por esta ruta', + body: 'Inicia sesión con una cuenta de Nous (es gratis) o revisa el ajuste NOUS_INFERENCE_BASE_URL.' + }, + free_tier_outage: { + title: 'El modelo gratuito tiene problemas para responder ahora mismo', + body: 'Vuelve a enviar el mensaje dentro de un minuto.' + }, + free_tier_refused: { + title: 'Hermes no pudo enviarlo sin iniciar sesión', + body: 'Iniciar sesión con una cuenta de Nous es gratis.' + } + }, + errorAuthKinds: { + api_key: { + title: (provider: string) => `${provider} rechazó tu clave API`, + body: (provider: string) => + `La clave guardada para ${provider} no es válida o se revocó. Actualízala y reinténtalo.` + }, + oauth: { + title: (provider: string) => `Tu sesión de ${provider} caducó` + } + }, + errorDetails: 'Detalles', + errorGenericProvider: 'El servicio de IA', + errorToastTitle: 'Hermes no pudo terminar la respuesta', + errorRetry: 'Reintentar', + errorLimitResets: (time: string) => `El límite se restablece a las ${time}`, + errorRetryAtReset: (time: string) => `Reintentar cuando se restablezca el límite (${time})`, + errorRetryScheduled: (time: string, wait: string) => `Reintentando a las ${time}, dentro de ${wait}`, + errorRetryScheduledCancel: 'Cancelar', + errorStartNewSession: 'Iniciar sesión nueva', + errorSwitchProvider: 'Cambiar proveedor', + errorChooseModel: 'Elegir un modelo', + errorCompressConversation: 'Comprimir conversación', + errorCompressFailed: 'No se pudo comprimir la conversación', + errorOpenHermesFolder: 'Abrir la carpeta de Hermes', + errorOpenHermesFolderFailed: 'No se pudo abrir la carpeta de Hermes', + errorUpdateApiKey: 'Actualizar clave API', + errorSignInAgain: (provider: string) => `Volver a iniciar sesión en ${provider}`, + errorSignInFreeTier: 'Iniciar sesión con una cuenta de Nous', + errorOauthExpired: (provider: string) => + `Tu sesión de ${provider} caducó o se revocó. Vuelve a iniciar sesión para seguir chateando.`, + errorOpenLogs: 'Abrir registros', + errorOpenLogsFailed: 'No se pudo abrir la carpeta de registros', + errorOpenDesktopLogs: 'Abrir registros de Desktop', + errorCopyDiagnostics: 'Copiar detalles del error', + errorSendDiagnostics: 'Enviar diagnóstico', + filesChanged: count => (count === 1 ? '1 archivo cambiado' : `${count} archivos cambiados`), + reviewChanges: 'Revisar', + readAloudFailed: 'Falló la lectura en voz alta', + preparingAudio: 'Preparando audio...', + stopReading: 'Detener lectura', + readAloud: 'Leer en voz alta', + editMessage: 'Editar mensaje', + expandMessage: 'Expandir mensaje', + scrollToBottom: 'Desplazarse hacia abajo', + stop: 'Detener', + restorePrevious: 'Restaurar checkpoint anterior', + restoreCheckpoint: 'Restaurar checkpoint', + restoreFromHere: 'Restaurar punto de control y ejecutar de nuevo desde este mensaje', + restoreTitle: '¿Restaurar a este punto de control?', + restoreBody: + 'Todo lo que sigue a este mensaje se elimina de la conversación y el mensaje se ejecuta nuevamente desde aquí.', + restoreConfirm: 'Restaurar y volver a ejecutar', + restoreNext: 'Restaurar checkpoint siguiente', + goForward: 'Avanzar', + sendEdited: 'Enviar edición', + attachingFile: 'Adjuntando…' + }, + approval: { + gatewayDisconnected: + 'Hermes está sin conexión ahora mismo. El comando sigue esperando tu respuesta (hasta que se agote el tiempo de aprobación). Reconéctate y vuelve a enviarla.', + sendFailed: 'No se pudo enviar tu respuesta', + reconnect: 'Reconectar', + timedOutSystemLine: + 'Se agotó el tiempo de aprobación: el comando no se ejecutó. Pide a Hermes que lo intente de nuevo o sube el límite en Configuración → Seguridad → Tiempo de aprobación.', + openSafetySettings: 'Abrir configuración de seguridad', + run: 'Ejecutar', + command: 'Comando', + moreOptions: 'Más opciones de aprobación', + allowSession: 'Permitir esta sesión', + alwaysAllowMenu: 'Permitir siempre…', + jumpToApproval: 'Aprobación necesaria', + reject: 'Rechazar', + alwaysTitle: '¿Permitir siempre este comando?', + alwaysDescription: pattern => + `Esto añade el patrón “${pattern}” a tu allowlist permanente (~/.hermes/config.yaml). Hermes no volverá a preguntar por comandos como este, ni en esta sesión ni en futuras.`, + alwaysAllow: 'Permitir siempre' + }, + clarify: { + notReady: 'La solicitud de aclaración aún no está lista', + gatewayDisconnected: 'Hermes está sin conexión ahora mismo. Reconéctate y vuelve a enviarlo.', + sendFailed: 'No se pudo enviar la respuesta de aclaración', + loadingQuestion: 'Cargando pregunta…', + other: 'Otro (escribe tu respuesta)', + placeholder: 'Escribe tu respuesta…', + skip: 'Omitir', + skipped: 'Omitido', + continueLabel: 'Continuar', + confirmAndContinueLabel: 'Confirmar y continuar', + answeredBadge: 'Respondido', + questionProgress: (answered, total) => `${answered} de ${total} respondidas`, + lateAnswer: (question, choice) => `Con respecto a “${question}”: mi respuesta es ${choice}`, + lateAnswerTip: 'Redactar esta respuesta como mensaje de seguimiento', + lateAnswerHint: + 'Este prompt ya no espera una respuesta. Elige una opción para redactarla como mensaje de seguimiento.' + }, + catalogInstall: { + preparing: 'Preparando la instalación…', + install: 'Instalar', + advanced: 'Avanzado', + skip: 'Omitir', + installing: 'Instalando…', + installed: 'Instalado', + notInstalled: 'No instalado', + failed: 'Falló', + showNames: 'mostrar nombres', + hideNames: 'ocultar nombres', + skill: (name: string) => `skill ${name}`, + kind: { + plugin: 'plugin', + skill: 'skill' + }, + tier: { + official: 'oficial', + community: 'comunidad' + }, + targetProfile: (profile: string) => `Se instala en tu perfil ${profile}`, + sendFailed: 'No se pudo enviar tu respuesta. Vuelve a intentarlo.', + commitLabel: 'Commit', + subdirLabel: 'Carpeta', + securityHeading: 'Seguridad', + scan: { + passed: 'Análisis superado', + warnings: 'El análisis encontró advertencias', + failed: 'Falló el análisis' + }, + requirementsLabel: 'Requiere', + credentialsHeading: 'Credenciales' + }, + mcpSetup: { + installTitle: 'Añadir servidores MCP', + enableTitle: 'Activar servidores MCP', + authorizeTitle: 'Autorizar servidores MCP', + installAction: 'Instalar', + enableAction: 'Activar', + authorizeAction: 'Autorizar', + installed: server => `${server} instalado`, + enabled: server => `${server} activado`, + authorized: server => `${server} autorizado`, + failed: server => `La configuración falló para ${server}`, + toolCount: count => (count === 1 ? '1 herramienta' : `${count} herramientas`), + envRequired: 'Rellena primero las credenciales obligatorias', + sendFailed: 'No se pudo enviar la respuesta de configuración MCP', + reloadFailed: 'Servidor guardado, pero falló la recarga de herramientas MCP — se cargarán en la próxima sesión', + gatewayDisconnected: 'Hermes está sin conexión ahora mismo. Reconéctate y vuelve a enviarlo.' + }, + tool: { + copyCode: 'Copiar código', + renderingImage: 'Renderizando imagen', + copyOutput: 'Copiar salida', + copyCommand: 'Copiar comando', + copyContent: 'Copiar contenido', + copyUrl: 'Copiar URL', + copyResults: 'Copiar resultados', + copyQuery: 'Copiar consulta', + copyFile: 'Copiar archivo', + copyPath: 'Copiar ruta', + failedCalls: (count: number) => + `${count} ${count === 1 ? 'llamada a herramienta falló' : 'llamadas a herramientas fallaron'}`, + skillActivity: { + loading: 'Cargando skill', + loaded: 'Skill cargada', + loadFailed: 'No se pudo cargar la skill', + readingResource: 'Leyendo recurso de la skill', + readResource: 'Recurso de la skill leído', + resourceFailed: 'No se pudo leer el recurso de la skill', + listing: 'Listando skills', + listed: 'Skills listadas', + listFailed: 'No se pudieron listar las skills', + unavailable: 'Resultado de la skill no disponible' + }, + outputAlt: 'Salida de herramienta', + rawResponse: 'Respuesta sin procesar', + copyActivity: 'Copiar actividad', + recoveredOne: 'Recuperado después de 1 paso fallido', + recoveredMany: count => `Recuperado después de ${count} pasos fallidos`, + failedOne: '1 paso falló', + failedMany: count => `${count} pasos fallaron`, + statusRunning: 'En ejecución', + statusError: 'Error', + statusRecovered: 'Recuperado', + statusDone: 'Listo', + resultUnavailable: 'Resultado no disponible', + resultInterrupted: 'Interrumpido', + memoryWriteNoted: 'Escritura de memoria anotada', + actions: { + read: 'Leyó', + reading: 'Leyendo', + opened: 'Abrió', + opening: 'Abriendo', + failedToOpen: 'No se pudo abrir', + searched: 'Buscó', + searching: 'Buscando', + ran: 'Ejecutó', + running: 'Ejecutando', + ranCode: 'Ejecutó código', + runningCode: 'Ejecutando código' + }, + prefixes: { + browser: 'Navegador', + web: 'Web' + }, + titleTemplates: { + actionCommand: (action, command) => `${action} ${command}`, + actionQuoted: (action, value) => `${action} “${value}”`, + actionTarget: (action, target) => `${action} ${target}`, + prefixedDone: (prefix, action) => `${prefix} ${action}`, + runningPrefixedTool: (prefix, action) => `Ejecutando ${prefix.toLowerCase()} ${action.toLowerCase()}`, + runningTool: action => `Ejecutando ${action.toLowerCase()}` + }, + titles: { + browser_click: { + done: 'Se hizo clic en un elemento de la página', + pending: 'Hacer clic en un elemento de la página', + pendingAction: 'Haciendo clic' + }, + browser_fill: { + done: 'Se completó un campo del formulario', + pending: 'Completar un campo del formulario', + pendingAction: 'Completando' + }, + browser_navigate: { + done: 'Página abierta', + pending: 'Abrir página', + pendingAction: 'Abriendo' + }, + browser_snapshot: { + done: 'Se capturó una instantánea de la página', + pending: 'Capturar una instantánea de la página', + pendingAction: 'Capturando' + }, + browser_take_screenshot: { + done: 'Se capturó una captura de pantalla', + pending: 'Capturar una captura de pantalla', + pendingAction: 'Capturando' + }, + browser_type: { + done: 'Se escribió en la página', + pending: 'Escribir en la página', + pendingAction: 'Escribiendo' + }, + clarify: { + done: 'Se hizo una pregunta', + pending: 'Hacer una pregunta', + pendingAction: 'Preguntando' + }, + cronjob: { + done: 'Tarea cron completada', + pending: 'Programar una tarea cron', + pendingAction: 'Programando' + }, + edit_file: { + done: 'Archivo editado', + pending: 'Editar archivo', + pendingAction: 'Editando' + }, + execute_code: { + done: 'Código ejecutado', + pending: 'Ejecutar código', + pendingAction: 'Ejecutando' + }, + image_generate: { + done: 'Imagen generada', + pending: 'Generar imagen', + pendingAction: 'Generando' + }, + list_files: { + done: 'Lista de archivos obtenida', + pending: 'Listar archivos', + pendingAction: 'Listando' + }, + memory: { + done: 'Guardado en la memoria', + pending: 'Guardar en la memoria', + pendingAction: 'Guardando' + }, + patch: { + done: 'Archivo parcheado', + pending: 'Aplicar un parche al archivo', + pendingAction: 'Aplicando un parche' + }, + read_file: { + done: 'Archivo leído', + pending: 'Leer archivo', + pendingAction: 'Leyendo' + }, + search_files: { + done: 'Búsqueda de archivos completada', + pending: 'Buscar archivos', + pendingAction: 'Buscando' + }, + session_search_recall: { + done: 'Se buscó en el historial de sesiones', + pending: 'Buscar en el historial de sesiones', + pendingAction: 'Buscando' + }, + terminal: { + done: 'Comando ejecutado', + pending: 'Ejecutar comando', + pendingAction: 'Ejecutando' + }, + todo: { + done: 'Tareas actualizadas', + pending: 'Actualizar tareas', + pendingAction: 'Actualizando' + }, + vision_analyze: { + done: 'Imagen analizada', + pending: 'Analizar imagen', + pendingAction: 'Analizando' + }, + web_extract: { + done: 'Página web leída', + pending: 'Leer página web', + pendingAction: 'Leyendo' + }, + web_search: { + done: 'Búsqueda web completada', + pending: 'Buscar en la web', + pendingAction: 'Buscando' + }, + write_file: { + done: 'Archivo escrito', + pending: 'Escribir archivo', + pendingAction: 'Escribiendo' + } + } + } + }, + prompts: { + gatewayDisconnected: 'Hermes está sin conexión ahora mismo. Reconéctate y vuelve a enviarlo.', + reconnect: 'Reconectar', + sudoSendFailed: 'No se pudo enviar la contraseña sudo', + secretSendFailed: 'No se pudo enviar el secreto', + sudoTitle: 'Contraseña de administrador', + sudoDesc: + 'Revisa el comando antes de introducir tu contraseña de sudo. La contraseña se envía al agente que lo ejecuta y se guarda en caché durante esta sesión.', + sudoCommandUnavailable: 'Este agente no indicó el comando. Cancela si no puedes verificarlo en la conversación.', + sudoInstallDesc: + 'Hermes necesita tu contraseña de sudo para instalar los paquetes de Bot Screen (TigerVNC + Xfce) en el host del gateway. Solo se envía a ese host.', + sudoPlaceholder: 'contraseña sudo', + secretTitle: 'Se requiere un secreto', + secretDesc: 'Hermes necesita una credencial para continuar.', + secretPlaceholder: 'valor secreto', + vaultUnlockSendFailed: 'No se pudo enviar la contraseña maestra', + vaultUnlockTitle: (name: string) => `Desbloquear ${name}`, + vaultUnlockDesc: (name: string) => + `El agente quiere iniciar sesión en un sitio con un acceso guardado en ${name}. Introduce tu contraseña maestra para desbloquearlo durante esta sesión: va directamente a ${name} en este equipo y nunca se guarda ni se muestra al agente.`, + vaultUnlockPlaceholder: 'Contraseña maestra', + vaultUnlockKeepLocked: 'Mantener bloqueado', + vaultUnlockConfirm: 'Desbloquear', + vaultSaveSendFailed: 'No se pudo guardar el acceso', + vaultSaveTitle: (site: string) => `¿Guardar tu acceso a ${site}?`, + vaultSaveDesc: (origin: string) => + `Hermes llegó a una página de inicio de sesión en ${origin} y no tiene un acceso para ella. Introdúcelo una vez aquí; se cifra en este equipo y se rellena en la página sin que el modelo vea nunca la contraseña.`, + vaultSaveIdentifierLabel: 'Correo electrónico o nombre de usuario', + vaultSaveIdentifierPlaceholder: 'tu@ejemplo.com', + vaultSavePasswordPlaceholder: 'Contraseña', + vaultSaveFootnote: 'Gestiona los accesos guardados en Configuración → Contraseñas e inicios de sesión.', + vaultSaveDecline: 'No guardar', + vaultSaveConfirm: 'Guardar e iniciar sesión', + vaultCodeSendFailed: 'No se pudo enviar el código', + vaultCodeTitle: (site: string) => `Código de verificación de ${site}`, + vaultCodeDesc: (site: string) => + `${site} pide un código de un solo uso (SMS, correo o app de autenticación). Introdúcelo aquí y Hermes lo escribe en la página; el modelo nunca lo ve.`, + vaultCodeLabel: 'Código', + vaultCodeFootnote: + 'Consejo: guarda la clave del autenticador con este acceso en Configuración → Contraseñas e inicios de sesión y Hermes introducirá los códigos por ti.', + vaultCodeSkip: 'Omitir', + vaultCodeConfirm: 'Introducir código' + }, + desktop: { + audioReadFailed: 'No se pudo leer el audio grabado', + sessionUnavailable: 'Sesión no disponible', + createSessionFailed: 'No se pudo crear una sesión nueva', + promptFailed: 'Falló el prompt', + providerCredentialRequired: 'Añade una credencial de proveedor antes de enviar tu primer mensaje.', + emptySlashCommand: 'comando slash vacío', + desktopCommands: 'Comandos de escritorio', + skillCommandsAvailable: count => `${count} comandos de skill disponibles.`, + warningLine: message => `advertencia: ${message}`, + yoloArmed: 'YOLO activado para este chat', + yoloOff: 'YOLO desactivado', + yoloSystem: active => `YOLO ${active ? 'activado' : 'desactivado'} para esta sesión`, + yoloTitle: 'YOLO', + yoloToggleFailed: 'No se pudo alternar YOLO', + profileStatus: current => + `Perfil: ${current}. Usa /profile o el selector de "Nueva sesión" para iniciar un chat en otro perfil.`, + unknownProfile: 'Perfil desconocido', + noProfileNamed: (target, available) => `No hay ningún perfil llamado "${target}". Disponibles: ${available}`, + newChatsProfile: name => `Los chats nuevos usarán el perfil ${name}.`, + setProfileFailed: 'No se pudo definir el perfil', + sttDisabled: 'Voz a texto está deshabilitado en la configuración.', + stopFailed: 'No se pudo detener', + regenerateFailed: 'No se pudo regenerar', + editFailed: 'No se pudo editar', + editTurnUnavailable: 'Este turno ya no está en el historial del servidor (puede haber sido comprimido).', + resumeFailed: 'No se pudo reanudar', + readOnlyTranscriptTitle: 'Abierto en modo de solo lectura', + readOnlyTranscriptBody: + 'Ningún backend conectado reclama todavía este chat antiguo, así que se abrió como transcripción de solo lectura. Su historial está intacto; el envío está desactivado hasta que un backend lo reclame.', + readOnlyTranscriptSendBlocked: + 'Este chat está abierto como transcripción de solo lectura: el envío está desactivado.', + resumeStrandedTitle: 'No se pudo cargar esta sesión', + resumeStrandedBody: + 'No se pudo conectar con esta sesión y se agotaron los reintentos automáticos. Comprueba que el gateway esté en ejecución y vuelve a intentarlo.', + poolSlotTimeoutBody: + 'Hay demasiados bots en ejecución a la vez para el límite de este equipo. Sube el límite en Configuración → Avanzado o espera a que termine uno y reinténtalo.', + poolSlotTimeoutOpenSettings: 'Abrir configuración avanzada', + resumeRetry: 'Reintentar', + nothingToBranch: 'No hay nada que ramificar', + branchNeedsChat: 'Inicia o reanuda un chat antes de ramificar.', + sessionBusy: 'Sesión ocupada', + branchStopCurrent: 'Detén el turno actual antes de ramificar este chat.', + branchNoText: 'Este mensaje no tiene texto desde el que ramificar.', + branchTitle: n => `Borrador: rama n.º ${n}`, + branchFailed: 'No se pudo ramificar', + deleteFailed: 'No se pudo eliminar', + archived: 'Archivado', + archiveFailed: 'No se pudo archivar', + cwdChangeFailed: 'No se pudo cambiar el directorio de trabajo', + cwdStagedTitle: 'Directorio de trabajo preparado', + cwdStagedMessage: 'Reinicia el backend de escritorio para aplicar cambios de cwd a esta sesión activa.', + modelSwitchConfirmBody: 'Este cambio de modelo requiere confirmación.', + modelSwitchConfirmLabel: 'Cambiar de todos modos', + modelSwitchConfirmTitle: (model: string) => `¿Cambiar a ${model}?`, + modelSwitchConfirmTitleFallback: '¿Cambiar de modelo?', + modelSwitchFailed: 'No se pudo cambiar de modelo', + modelSwitchKeepLabel: 'Mantener el modelo actual', + modelSwitchStaleNotice: 'La selección cambió: no se aplicó el cambio de modelo.', + hydrationSyncing: (profile: string) => `Sincronizando ${profile}\u2026`, + sessionExported: 'Sesión exportada', + sessionExportFailed: 'No se pudo exportar la sesión', + imageSaved: 'Imagen guardada', + downloadStarted: 'Descarga iniciada', + restartToUseSaveImage: 'Reinicia Hermes Desktop para usar Guardar imagen.', + restartToSaveImages: 'Reinicia Hermes Desktop para guardar imágenes', + imageDownloadFailed: 'Falló la descarga de imagen', + openImage: 'Abrir imagen', + downloadImage: 'Descargar imagen', + savingImage: 'Guardando imagen', + imagePreviewFailed: 'Falló la vista previa de imagen', + imageAttach: 'Adjuntar imagen', + imageWriteFailed: 'No se pudo escribir la imagen en disco.', + imageAttachFailed: 'No se pudo adjuntar la imagen', + pastedContent: 'Contenido pegado', + pasteAttachFailed: 'No se pudo adjuntar el texto pegado', + attachImages: 'Adjuntar imágenes', + clipboard: 'Portapapeles', + noClipboardImage: 'No se encontró ninguna imagen en el portapapeles', + clipboardPasteFailed: 'No se pudo pegar desde el portapapeles', + dropFiles: 'Soltar archivos', + handoff: { + pickPlatform: 'Elige un destino', + success: platform => `Transferido a ${platform}. Puedes reanudar aquí cuando quieras.`, + systemNote: platform => `↻ Transferido a ${platform}; puedes reanudar aquí cuando quieras.`, + failed: error => `La transferencia falló: ${error}`, + timedOut: + 'Hermes no pudo llegar a tu conexión de mensajería. Iníciala desde Configuración → Mensajería y vuelve a intentar el traspaso.', + startMessaging: 'Iniciar mensajería' + } + }, + tips: { + close: 'No volver a mostrar este consejo', + items: { + 'new-session': { + title: 'Empieza de cero', + text: 'Un chat nuevo tiene su propio contexto, terminal y directorio de trabajo.' + }, + skills: { + title: 'Enséñale una vez', + text: 'Las skills son carpetas de instrucciones que Hermes carga cuando el trabajo las necesita.' + }, + messaging: { + title: 'Hermes lejos de tu escritorio', + text: 'Conecta Telegram, Discord, Slack y más: el mismo agente, la misma memoria.' + }, + artifacts: { + title: 'Todo lo que ha creado Hermes', + text: 'Imágenes, archivos y enlaces de cada sesión, indexados en un solo lugar.' + }, + cron: { + title: 'Trabajo que se ejecuta solo', + text: 'Programa un prompt cada hora, cada noche o con una expresión cron.' + }, + 'command-palette': { + title: 'Un cuadro para todo', + text: 'Sesiones, configuración, skills y comandos responden a la paleta.' + }, + profiles: { + title: 'Los perfiles son independientes', + text: 'Cada uno es su propio Hermes: sus propias claves, su propia memoria, sus propias sesiones.' + }, + 'composer-mentions': { + title: 'Adjunta y ordena', + text: 'Escribe @ para traer un archivo a la conversación y / para ejecutar un comando.' + }, + 'local-runtime-update': { + title: 'Hay una actualización del motor local', + text: 'Actualiza el motor que ejecuta tus modelos locales. Las solicitudes locales activas pueden interrumpirse.', + action: 'Actualizar ahora' + }, + 'local-setup': { + title: 'Este equipo puede ejecutar modelos localmente', + text: 'Tu hardware puede servir un modelo local. Los chats se quedan en tu equipo y no cuestan nada.', + action: 'Configurarlo' + }, + 'right-pane': { + title: 'El panel de trabajo', + text: 'Archivos, terminal, revisión y el navegador integrado comparten el lado derecho.' + } + } + }, + errors: { + genericFailure: 'Algo salió mal', + boundaryTitle: 'Algo se rompió en la interfaz', + boundaryDesc: 'La vista encontró un error inesperado. Tus chats y configuración están a salvo.', + boundaryDetails: 'Detalles', + sendDiagnostics: 'Enviar diagnóstico', + reloadWindow: 'Recargar ventana', + openLogs: 'Abrir registros' + }, + ui: { + search: { + clear: 'Limpiar búsqueda' + }, + pagination: { + label: 'paginación', + previous: 'Anterior', + previousAria: 'Ir a la página anterior', + next: 'Siguiente', + nextAria: 'Ir a la página siguiente' + }, + sidebar: { + title: 'Barra lateral', + description: 'Muestra la barra lateral móvil.', + toggle: open => `${open ? 'Mostrar' : 'Ocultar'} barra lateral` + } + } +}) diff --git a/apps/desktop/src/i18n/fr.ts b/apps/desktop/src/i18n/fr.ts new file mode 100644 index 0000000000..ca3c0c9883 --- /dev/null +++ b/apps/desktop/src/i18n/fr.ts @@ -0,0 +1,5991 @@ +import { defineFieldCopy } from '@/app/settings/field-copy' + +import { defineLocale } from './define-locale' +import { introFr } from './intro-fr' + +export const fr = defineLocale({ + intro: introFr, + connectors: { + title: 'Connectez vos applications', + connect: 'Connecter', + skip: 'Pas maintenant', + cancel: "Arrêter l'attente", + retry: 'Réessayer', + grant: 'Reconnecter', + connected: 'Connecté', + checking: 'Vérification de vos applications…', + notConnected: 'Non connecté', + skipped: 'Ignoré', + disabled: 'Indisponible', + failed: 'Connexion impossible', + needsAuth: 'Accès expiré', + opening: 'Ouverture de la connexion…', + waiting: 'Terminez la connexion dans votre navigateur…', + timeout: "Toujours en attente de l'autorisation.", + refresh: "Actualiser l'état", + connectError: "Impossible de démarrer l'autorisation. Réessayez.", + connectErrorFor: app => `Impossible de démarrer l'autorisation pour ${app}.`, + unavailable: 'Les connecteurs ne sont pas disponibles pour cette session.', + ownerMissing: 'Rouvrez cette conversation pour gérer ses connexions.', + search: 'Rechercher une application', + empty: 'Aucune application correspondante', + disclaimer: "La connexion est facultative. N'autorisez que les applications que vous voulez confier à Hermes.", + execution: 'Outils des connecteurs', + setup: server => `Configurer ${server}`, + openInBrowser: 'Ouvrir dans le navigateur', + setupCancel: 'Annuler', + authorizedToolsUnavailable: 'Autorisé. Outils indisponibles.', + required: 'Obligatoire' + }, + connectorsPage: { + title: 'Connecteurs', + searchPlaceholder: (count: number) => `Rechercher parmi ${count} applications`, + filterCategory: 'Catégorie', + categoryAll: 'Toutes les catégories', + uncategorised: 'Sans catégorie', + residencyLocal: 'Sur cet appareil', + segment: { + all: 'Tous', + available: 'Disponibles', + connected: 'Connectés', + off: 'Désactivés' + }, + group: { + connected: 'Connectés', + connectedNote: "Connexions en échec d'abord.", + available: 'Disponibles', + off: 'Désactivés', + offNote: 'Les connexions sont conservées.' + }, + card: { + kindManaged: 'Géré', + kindCatalog: 'MCP · Catalogue', + kindCustom: 'MCP · Personnalisé', + kindPlugin: (plugin: string) => `MCP · Plugin ${plugin}`, + inCatalog: 'Dans le catalogue Hermes', + hostedTwin: 'Version gérée disponible', + alsoLocal: 'Fonctionne aussi sur cet appareil', + open: (name: string) => `Ouvrir ${name}`, + turnServerOn: (name: string) => `Activer ${name}`, + turnServerOff: (name: string) => `Désactiver ${name}`, + state: { + accessExpired: 'Accès expiré', + available: 'Disponible', + connected: 'Connecté', + connecting: 'Connexion', + connectionUnknown: 'État inconnu', + couldNotConnect: 'Connexion impossible', + offByYourOrganisation: 'Désactivé par votre organisation', + offForYou: 'Désactivé pour vous', + serverConnecting: 'Connexion…', + serverError: 'Erreur', + serverNeedsAuth: 'Authentification requise', + serverOff: 'Désactivé', + serverOn: 'Activé', + serverOnUnused: 'Activé, inutilisé' + }, + fact: { + tools: (count: number) => `${count} outil${count > 1 ? 's' : ''}`, + toolsOff: (count: number) => `${count} outil${count > 1 ? 's' : ''} désactivé${count > 1 ? 's' : ''}`, + toolsOn: (count: number) => `${count} outil${count > 1 ? 's' : ''} activé${count > 1 ? 's' : ''}`, + toolsSomeOn: (total: number, on: number) => `${total} outils, ${on} activé${on > 1 ? 's' : ''}` + }, + verb: { + authenticate: "S'authentifier", + connect: 'Connecter', + install: 'Installer', + openLogs: 'Ouvrir les journaux', + reconnect: 'Reconnecter', + stopWaiting: "Arrêter l'attente", + tryAgain: 'Réessayer', + turnBackOn: 'Réactiver' + }, + reason: { + finishSignIn: 'Terminez la connexion dans votre navigateur.', + reconnect: 'Reconnectez-vous pour que cette application continue de fonctionner.', + serverError: 'Le serveur a refusé la connexion.', + serverNeedsAuth: 'Connectez-vous pour que ce serveur puisse répondre.' + } + }, + page: { + loading: 'Lecture du catalogue et des serveurs de cet ordinateur', + emptyTitle: 'Aucune application pour le moment. Ajoutez un serveur sur cet ordinateur pour commencer.', + noMatchTitle: 'Aucune application correspondante', + noMatchBody: 'Aucun résultat. Indiquez à Hermes votre propre serveur MCP pour l’ajouter.', + clearSearch: 'Effacer la recherche', + hostedFailedTitle: 'Impossible de joindre les applications hébergées.', + hostedFailedBody: + 'Les serveurs de cet ordinateur ne sont pas affectés et fonctionnent toujours. Rien n’a été désactivé.', + retry: 'Réessayer', + matchesElsewhere: (count: number) => + `${count} autre${count > 1 ? 's' : ''} résultat${count > 1 ? 's' : ''} dans d’autres groupes.`, + showAllMatches: 'Afficher tous les résultats', + segmentNoMatch: (segment: string) => `Aucun résultat dans ${segment} : tous les résultats sont affichés.`, + freeTierNote: 'Les connexions restent sur cet ordinateur jusqu’à ce que vous vous connectiez.', + signInLine: 'Connectez-vous à Nous pour utiliser les applications gérées.', + signIn: 'Se connecter', + managedUnavailable: 'Les applications gérées ne sont pas encore disponibles pour ce compte.', + writeFailed: 'Cette modification n’a pas été enregistrée.', + refreshFailed: 'La liste des outils n’a pas été actualisée.', + disconnectNoAccount: 'Hermes n’a aucun compte à déconnecter ici. Actualisez la page et réessayez.', + disconnectRefused: + 'Nous ne peut pas supprimer cette connexion pour le moment. Désactivez plutôt l’application avec l’interrupteur, ou réessayez plus tard.' + }, + add: { + action: 'Ajouter le vôtre', + title: 'Se connecter à un MCP personnalisé', + hint: 'une nouvelle entrée dans mcp.json sur cet appareil', + pasteLabel: 'Collez une commande ou un extrait', + pastePlaceholder: 'npx -y @modelcontextprotocol/server-filesystem /chemin/vers/dossier', + pasteNoMatch: 'Rien ici ne ressemble à un serveur. Remplissez plutôt les champs ci-dessous.', + name: 'Nom', + nameTaken: 'Ce nom est déjà utilisé.', + type: 'Type', + typeStdio: 'STDIO', + typeHttp: 'HTTP streamable', + command: 'Commande de lancement', + args: 'Arguments', + addArg: '+ Ajouter un argument', + envVars: "Variables d'environnement", + addEnvVar: "+ Ajouter une variable d'environnement", + passthrough: "Transmission des variables d'environnement", + addPassthrough: '+ Ajouter une variable', + cwd: 'Répertoire de travail', + url: 'URL', + headers: 'En-têtes', + addHeader: '+ Ajouter un en-tête', + auth: 'Authentification', + authNone: 'Aucune', + authOauth: 'OAuth', + authBearer: 'Jeton Bearer', + keyPlaceholder: 'CLÉ', + valuePlaceholder: 'valeur', + removeRow: 'Supprimer cette ligne', + editJson: 'Modifier mcp.json', + saveFailed: "Ce serveur n'a pas été enregistré." + }, + dialog: { + disconnect: 'Déconnecter', + disconnectTitle: (name: string) => `Déconnecter ${name} ?`, + disconnectBody: "Hermes cesse d'agir avec ce compte. Vous pouvez vous reconnecter à tout moment.", + menuRefreshTools: 'Actualiser les outils', + moreActions: "Plus d'actions", + removeServerTitle: (name: string) => `Supprimer ${name} ?`, + removeServerBody: "L'entrée est retirée de mcp.json sur cet ordinateur. Rien d'autre n'est supprimé.", + appSwitch: (name: string) => `Hermes peut utiliser ${name}`, + waysTitle: (name: string) => `Où ${name} s'exécute`, + wayNotConnected: (name: string) => `Pas encore connecté. Connectez-vous à ${name} dans votre navigateur.`, + wayHosted: 'Géré', + bothOn: (name: string) => `Les deux sont activés : Hermes voit donc chaque outil ${name} en double.`, + turnOffLocal: 'Désactiver le serveur local', + providedByPlugin: (plugin: string) => `Fourni par le plugin ${plugin}`, + openPlugins: "Ouvrir l'onglet Plugins", + nousLine: 'Les applications Nous suivent votre compte, pas le profil.', + rulesReadOnly: 'Les règles ne peuvent pas être modifiées pour le moment.', + rulesAppOff: (name: string) => `Activez ${name} pour modifier ses outils.`, + rulesSignIn: 'Connectez-vous pour modifier ce que Hermes peut faire ici.', + orgNote: (count: number) => `Votre organisation a désactivé ${count} outil${count > 1 ? 's' : ''}.`, + orgLink: "Ouvrir l'administration des connecteurs", + connectEnded: "La connexion n'a pas abouti.", + connectOpenAgain: 'Rouvrir le lien', + tokensPerCall: 'jetons par appel', + usesPerMonth: 'utilisations sur 30 jours', + advanced: 'Avancé', + advancedHint: "l'entrée mcp.json et les journaux" + }, + tools: { + title: 'Outils', + notInstalledBody: "Installez-le sur cet appareil pour voir les outils qu'il apporte.", + summaryTitle: (name: string) => `Ce que Hermes peut faire avec ${name}`, + summaryPreviewTitle: (name: string) => `Ce que Hermes pourra faire avec ${name} une fois connecté`, + summaryCount: (count: number) => `${count} outil${count > 1 ? 's' : ''}`, + summaryAllTools: 'Tous les outils', + summaryOther: 'Autres', + allToolsSwitch: 'Activer ou désactiver tous les outils', + summaryAllOn: 'tous activés', + summarySomeOn: (on: number, total: number) => `${on} sur ${total} activé${on > 1 ? 's' : ''}`, + summaryOff: 'désactivés', + showAllTools: (count: number) => (count > 1 ? `Afficher les ${count} outils` : `Afficher ${count} outil`), + showSummary: 'Afficher le résumé', + facetSwitch: (facet: string) => `Activer ou désactiver les outils ${facet}`, + moreHints: (count: number) => `+${count}`, + staleSignIn: 'Connectez-vous pour lire la dernière liste des outils.', + searchCountPlaceholder: (count: number) => `Rechercher parmi ${count} outils`, + toolList: (name: string) => `Outils de ${name}`, + categorySelect: (count: number) => `${count} catégorie${count > 1 ? 's' : ''}`, + showDeprecated: (count: number) => `Afficher ${count} obsolète${count > 1 ? 's' : ''}`, + hideDeprecated: (count: number) => `Masquer ${count} obsolète${count > 1 ? 's' : ''}`, + quickReadOnly: 'Lecture seule', + quickNoDestructive: 'Désactiver les destructifs', + quickEverythingOn: 'Tout activer', + lockedHint: 'désactivé par votre organisation', + turnToolOn: (tool: string) => `Activer ${tool}`, + turnToolOff: (tool: string) => `Désactiver ${tool}`, + showDetails: (tool: string) => `Afficher ce que fait ${tool}`, + hideDetails: (tool: string) => `Masquer ce que fait ${tool}`, + noMatch: 'Aucun outil ne correspond à ces filtres.', + loading: 'Lecture de la liste des outils', + unavailableLine: 'Liste des outils indisponible.', + needsAuthTitle: (name: string) => `Connectez-vous à ${name} pour lire ses outils.`, + needsAuthBody: 'La connexion reste sur cet ordinateur. Rien ne le quitte.', + retry: 'Réessayer', + goneTitle: (name: string) => `${name} a quitté le catalogue.`, + goneBody: "Hermes ne peut plus l'appeler. La ligne reste jusqu'à ce que vous la supprimiez : rien ne disparaît.", + remove: 'Supprimer', + offTitle: (name: string) => `${name} est désactivé.`, + offBody: "Activez-le avec l'interrupteur ci-dessus pour lire les outils qu'il apporte.", + signedOutTitle: 'Connectez-vous à Nous pour lire la liste des outils.', + signedOutBody: 'Vos serveurs sur cet ordinateur ne sont pas affectés.', + conflictTitle: "Quelqu'un a modifié cette règle pendant que vous l'éditiez.", + conflictBody: (theyOff: number, theyOn: number) => { + const they = [ + theyOff > 0 + ? `désactivé ${theyOff} outil${theyOff > 1 ? 's' : ''} que vous avez activé${theyOff > 1 ? 's' : ''}` + : '', + theyOn > 0 + ? `laissé activé${theyOn > 1 ? 's' : ''} ${theyOn} outil${theyOn > 1 ? 's' : ''} que vous avez désactivé${theyOn > 1 ? 's' : ''}` + : '' + ].filter(Boolean) + + return `${they.length > 0 ? `Cette personne a ${they.join(' et ')}. ` : ''}Vos modifications restent à l'écran ; rien n'a été écrit.` + }, + conflictReload: 'Recharger sa version', + conflictSave: 'Enregistrer par-dessus sa version', + saveFailed: "Ces règles d'outils n'ont pas été enregistrées.", + footerDirty: (off: number, backOn: number) => + `${off} outil${off > 1 ? 's' : ''} désactivé${off > 1 ? 's' : ''}, ${backOn === 0 ? 'aucun' : backOn} réactivé${backOn > 1 ? 's' : ''}`, + discard: 'Annuler les modifications', + save: 'Enregistrer les modifications', + saving: 'Enregistrement...' + }, + vocabulary: { + facetRead: { + label: 'Lecture', + long: 'Lit des données de cette application. Ne modifie rien.' + }, + facetWrite: { + label: 'Écriture', + long: 'Crée ou modifie quelque chose dans cette application.' + }, + facetDestructive: { + label: 'Destructif', + long: 'Peut supprimer définitivement quelque chose dans cette application.' + }, + facetUnclassified: { + label: 'Effet inconnu', + long: "L'application n'a jamais indiqué ce que fait cet outil." + }, + hintReadOnly: { + label: 'Lecture seule', + long: "L'outil déclare qu'il se contente de lire." + }, + hintCreate: { + label: 'Crée', + long: 'Crée quelque chose de nouveau.' + }, + hintUpdate: { + label: 'Modifie', + long: 'Modifie quelque chose qui existe déjà.' + }, + hintDelete: { + label: 'Supprime', + long: 'Supprime quelque chose.' + }, + hintDestructive: { + label: 'Destructif', + long: 'La modification effectuée ne peut pas être annulée ici.' + }, + hintIdempotent: { + label: 'Répétable', + long: "L'exécuter deux fois produit le même effet qu'une seule fois." + }, + hintOpenWorld: { + label: 'Externe', + long: 'Accède à quelque chose en dehors de cette application.' + } + } + }, + sessionImport: { + title: 'Reprendre depuis une autre application', + subtitle: 'Importez une conversation dans Hermes et reprenez là où vous en étiez.', + action: 'Importer une session', + readingFrom: 'Lecture depuis', + connectedComputer: "l'ordinateur connecté", + destination: 'Importer dans', + all: 'Toutes', + search: 'Rechercher parmi les sessions chargées', + scanning: 'Recherche des conversations', + scanError: 'Impossible de trouver les sessions', + scanHelp: 'Vérifiez la connexion au backend, puis réessayez. Une mise à jour du backend peut être nécessaire.', + empty: 'Aucune conversation trouvée', + emptyHelp: 'Les sessions Claude Code et Codex de ce backend apparaîtront ici.', + noMatches: 'Aucune conversation correspondante', + searchHelp: 'Essayez un autre titre ou dossier, ou chargez davantage de sessions.', + skipped: 'Certains journaux étaient vides, illisibles ou trop volumineux pour être prévisualisés.', + more: 'Charger davantage de sessions', + messages: 'messages', + choose: 'Une conversation à poursuivre', + chooseHelp: "Choisissez une session pour lire son historique avant de l'importer dans Hermes.", + previewLoading: "Ouverture de l'aperçu", + previewError: 'Aperçu indisponible', + previewHelp: 'La source a peut-être été déplacée ou modifiée. Actualisez la liste et réessayez.', + previewLimit: 'Aperçu abrégé pour faciliter la lecture. La conversation est importée dans son intégralité.', + you: 'Vous', + snapshot: 'Cette conversation est déjà dans Hermes. Ouvrez votre copie existante pour continuer.', + copyNotice: + 'Copie le texte de la conversation sans modifier les fichiers sources. Les résultats des outils et le raisonnement ne sont pas transférés.', + importing: 'Importation…', + open: 'Ouvrir dans Hermes', + continue: 'Continuer dans Hermes', + importError: "Impossible d'importer cette conversation." + }, + common: { + apply: 'Appliquer', + back: 'Retour', + save: 'Enregistrer', + saving: 'Enregistrement…', + cancel: 'Annuler', + change: 'Modifier', + choose: 'Choisir', + clear: 'Effacer', + close: 'Fermer', + collapse: 'Réduire', + confirm: 'Confirmer', + connect: 'Connecter', + connecting: 'Connexion', + continue: 'Continuer', + bots: 'Bots', + copied: 'Copié', + copy: 'Copier', + copyFailed: 'Échec de la copie', + delete: 'Supprimer', + docs: 'Documentation', + done: 'Terminé', + error: 'Erreur', + expand: 'Développer', + failed: 'Échec', + formatJson: 'Formater le JSON', + free: 'Gratuit', + loading: 'Chargement…', + notSet: 'Non défini', + refresh: 'Actualiser', + remove: 'Retirer', + replace: 'Remplacer', + retry: 'Réessayer', + run: 'Exécuter', + send: 'Envoyer', + set: 'Définir', + skip: 'Ignorer', + update: 'Mettre à jour', + tryHint: term => `Essayez « ${term} »`, + on: 'Activé', + off: 'Désactivé' + }, + fileMenu: { + revealFinder: 'Afficher dans le Finder', + revealExplorer: "Afficher dans l'Explorateur de fichiers", + revealFileManager: 'Ouvrir le dossier parent', + revealInSidebar: "Afficher dans l'arborescence", + copyPath: 'Copier le chemin', + copyRelativePath: 'Copier le chemin relatif', + download: 'Télécharger', + downloadSaved: 'Enregistré', + downloadFailed: 'Échec du téléchargement', + rename: 'Renommer…', + delete: 'Supprimer', + renameTitle: 'Renommer', + renameLabel: 'Nouveau nom', + deleteTitle: name => `Supprimer ${name} ?`, + deleteBody: 'Il sera déplacé dans la Corbeille — vous pourrez le restaurer depuis là.', + pathCopied: 'Chemin copié', + revealMissing: "Ce dossier n'est pas sur cet ordinateur", + revealUnavailable: + "Ce chemin n'est pas sur cet ordinateur : il se trouve sur la machine du backend. Utilisez « Afficher dans l'arborescence »." + }, + boot: { + ready: 'Hermes Desktop est prêt', + desktopBootFailedWithMessage: message => `Échec du démarrage : ${message}`, + steps: { + connectingGateway: 'Connexion au gateway desktop', + loadingSettings: 'Chargement des paramètres Hermes', + loadingSessions: 'Chargement des sessions récentes', + retryingRemoteBackend: 'Reconnexion au backend Hermes distant…', + startingDesktopConnection: 'Démarrage de la connexion desktop', + startingHermesDesktop: 'Démarrage de Hermes Desktop…' + }, + errors: { + backgroundExited: "Le processus en arrière-plan de Hermes s'est arrêté.", + backgroundExitedDuringStartup: "Le processus en arrière-plan de Hermes s'est arrêté pendant le démarrage.", + backendStopped: 'Backend arrêté', + restartHermes: 'Redémarrer Hermes', + openLogs: 'Ouvrir les journaux', + desktopBootFailed: 'Échec du démarrage', + gatewayConnectionLost: 'Connexion au gateway perdue', + gatewayConnectionLostDetail: + 'Nouvelle tentative en arrière-plan. Vous pouvez continuer à lire et rédiger — ouvrez les paramètres du gateway si le problème persiste.', + reconnectNow: 'Se reconnecter maintenant', + connectionSettings: 'Paramètres de connexion', + gatewaySignInRequired: 'Connexion au gateway requise', + gatewaySignInRequiredDetail: + 'Reconnectez-vous pour rétablir la connexion. Vos conversations et paramètres sont en sécurité.', + signInAgain: 'Se reconnecter', + ipcBridgeUnavailable: 'Le pont IPC du desktop est indisponible.' + }, + causes: { + exitedEarly: "Le service en arrière-plan de Hermes s'est arrêté juste après son démarrage.", + timedOut: "Le service en arrière-plan de Hermes n'a pas répondu à temps.", + permission: "Hermes n'a pas pu écrire dans son dossier de données (problème d'autorisation).", + diskFull: "Le disque est plein ; Hermes n'a donc pas pu démarrer.", + portInUse: 'Un autre programme utilise le port réseau nécessaire à Hermes.', + installMissing: + "Une partie de l'installation de Hermes est manquante. Choisissez Réparer l'installation pour la restaurer." + }, + failure: { + title: "Hermes n'a pas pu démarrer", + description: + "Le gateway en arrière-plan n'a pas pu se lancer. Essayez l'une des étapes de récupération ci-dessous. Rien ici ne supprime vos conversations ou paramètres.", + details: 'Détails', + remoteTitle: 'Connexion au gateway distante requise', + remoteDescription: + 'Votre session de gateway distante a expiré. Connectez-vous à nouveau pour vous reconnecter. Rien ici ne supprime vos conversations ou paramètres.', + retry: 'Réessayer', + repairInstall: "Réparer l'installation", + useLocalGateway: 'Utiliser le gateway local', + gatewaySettings: 'Paramètres du gateway', + back: 'Retour', + openLogs: 'Ouvrir les journaux', + repairHint: "La réparation relance l'installateur et peut prendre quelques minutes sur une machine neuve.", + remoteSignInHint: signInLabel => + `Déconnecte la session navigateur distante enregistrée, puis ouvre ${signInLabel}. Utilisez le gateway local pour passer au backend intégré.`, + signOutAndSignIn: 'Se déconnecter et se reconnecter', + remoteFailureHint: + "Vérifiez l'URL du gateway et la connexion dans les paramètres du gateway, ou passez au gateway local.", + cloudDownTitle: "L'agent Nous Cloud est indisponible", + cloudDownDescription: + "L'agent cloud géré par Nous auquel ce gateway se connecte renvoie une erreur serveur. Il ne peut pas être redémarré depuis ici — vérifiez son état, passez au gateway local ou contactez l'assistance.", + cloudDownHint: + "Les boutons ci-dessous ouvrent le portail Nous, pour consulter et contrôler l'instance, ainsi que notre Discord pour obtenir de l'aide.", + cloudDownCheckPortal: "Vérifier l'état sur le portail", + cloudDownDiscord: "Obtenir de l'aide sur Discord", + hideRecentLogs: 'Masquer les journaux récents', + showRecentLogs: 'Afficher les journaux récents', + signedInTitle: 'Connecté', + signedInMessage: 'Reconnexion au gateway distante…', + signInIncompleteTitle: 'Connexion incomplète', + signInIncompleteMessage: "La fenêtre de connexion s'est fermée avant la fin de l'authentification.", + signInFailed: 'Échec de la connexion', + signInToRemoteGateway: 'Se connecter au gateway distante', + signInWithProvider: provider => `Se connecter avec ${provider}`, + identityProvider: "votre fournisseur d'identité" + } + }, + notifications: { + region: 'Notifications', + hide: 'Masquer', + show: 'Afficher', + more: count => `${count} autre ${count === 1 ? 'notification' : 'notifications'}`, + clearAll: 'Tout effacer', + dismiss: 'Fermer la notification', + details: 'Détails', + copyDetail: 'Copier le détail', + copyDetailFailed: 'Impossible de copier le détail de la notification', + backendOutOfDateTitle: 'Backend obsolète', + backendOutOfDateMessage: + 'Votre backend Hermes est plus ancien que cette version du desktop et peut ne pas fonctionner correctement. Mettez-le à jour pour les aligner.', + installMethodUnsupportedTitle: "Méthode d'installation non prise en charge", + updateHermes: 'Mettre à jour Hermes', + updateReadyTitle: 'Mise à jour prête', + updateReadyMessage: count => + `${count} ${count === 1 ? 'nouvelle modification disponible' : 'nouvelles modifications disponibles'}.`, + updateReadyMessageUnknown: 'Une nouvelle mise à jour est disponible.', + seeWhatsNew: 'Voir les nouveautés', + mcp: { + needsAuthTitle: 'Le serveur MCP nécessite une nouvelle authentification', + needsAuthMessage: name => `${name} nécessite une nouvelle authentification MCP.`, + errorTitle: 'Serveur MCP inaccessible', + errorMessage: name => `${name} a échoué à la vérification de l'état MCP.`, + signIn: 'Se connecter', + view: 'Afficher', + disable: 'Désactiver', + disabledMessage: name => `${name} MCP a été désactivé. Vous pouvez le réactiver depuis Capacités → MCP.`, + disableFailed: name => `Impossible de désactiver ${name} MCP.` + }, + errors: { + elevenLabsNeedsKey: 'STT ElevenLabs nécessite ELEVENLABS_API_KEY.', + elevenLabsRejectedKey: 'ElevenLabs a rejeté la clé API (401).', + diskFull: "Le disque est plein. Libérez de l'espace disque, puis réessayez.", + storageFailure: + "Hermes n'a pas pu enregistrer dans son dossier de données. Ouvrez Maintenance pour le vérifier et le réparer.", + gatewayAuthFailed: "Échec de l'authentification du gateway — vérifiez API_SERVER_KEY.", + methodNotAllowed: + 'Le backend du desktop a rejeté cette requête (405 Method Not Allowed). Essayez de redémarrer Hermes Desktop.', + microphonePermission: "L'autorisation du microphone a été refusée.", + openaiRejectedApiKey: 'OpenAI a rejeté la clé API.', + openaiTtsNeedsKey: 'TTS OpenAI nécessite VOICE_TOOLS_OPENAI_KEY ou OPENAI_API_KEY.', + codeSkewRestartRequired: + "Ce backend exécute encore l'ancien code après une mise à jour. Redémarrez-le pour charger le nouveau code.", + rpcOutOfSync: "L'application et le backend ne sont pas sur la même version. Mettez-les tous les deux à jour.", + restartHermesFailed: 'Impossible de redémarrer Hermes' + }, + actions: { + restartHermes: 'Redémarrer Hermes', + openKeys: 'Ouvrir les clés', + openGateways: 'Ouvrir les gateways', + openMaintenance: 'Ouvrir Maintenance' + }, + voice: { + configureSpeechToText: 'Configurez la reconnaissance vocale pour utiliser le mode vocal.', + couldNotStartSession: 'Impossible de démarrer la session vocale', + microphoneAccessDenied: 'Accès au microphone refusé.', + microphoneConstraintsUnsupported: 'Les contraintes du microphone ne sont pas prises en charge par cet appareil.', + microphoneFailed: 'Échec du microphone', + microphoneInUse: 'Le microphone est déjà utilisé par une autre application.', + microphonePermissionDenied: "L'autorisation du microphone a été refusée.", + microphoneStartFailed: "Impossible de démarrer l'enregistrement du microphone.", + microphoneUnsupported: "Ce runtime ne prend pas en charge l'enregistrement micro.", + noMicrophone: "Aucun microphone n'a été trouvé.", + noSpeechDetected: 'Aucune parole détectée', + playbackFailed: 'Échec de la lecture vocale', + recordingFailed: "Échec de l'enregistrement vocal", + sayStopToEnd: phrase => `Dites « ${phrase} » pour terminer la conversation vocale.`, + transcriptionFailed: 'Échec de la transcription vocale', + transcriptionUnavailable: "La transcription vocale n'est pas encore disponible.", + tryRecordingAgain: 'Essayez de réenregistrer.', + unavailable: 'Voix indisponible', + liveEnded: 'Session vocale en direct terminée', + liveEndedConnectionLost: 'La session vocale en direct a perdu sa connexion.', + liveEndedClosed: 'La session vocale en direct a été fermée par le service.', + liveError: 'Voix en direct', + liveDelegationFailed: 'Impossible de transmettre la demande à Hermes', + liveUnavailable: reason => + `Le chat vocal GPT-Live n'est pas disponible : ${reason}. Utilisation de la reconnaissance vocale à la place.` + }, + native: { + approvalTitle: 'Approbation requise', + approvalTitleNamed: session => `Approbation requise — ${session}`, + approveAction: 'Approuver', + rejectAction: 'Rejeter', + inputTitle: 'Saisie requise', + inputTitleNamed: session => `Saisie requise — ${session}`, + inputBody: 'Hermes attend votre réponse.', + turnDoneTitle: 'Hermes a terminé', + turnDoneBody: '', + turnErrorTitle: 'Échec du tour', + backgroundDoneTitle: 'Tâche en arrière-plan terminée', + backgroundFailedTitle: 'Échec de la tâche en arrière-plan', + creditsTitle: 'Crédits' + } + }, + remoteDisplayBanner: { + message: reason => + `Rendu logiciel actif — affichage distant détecté (${reason}). L'accélération GPU est désactivée pour éviter les scintillements.` + }, + billingBlock: { + titleNous: 'Plus de crédits Nous', + titleProvider: provider => `Plus de crédits — ${provider}`, + fallbackMessage: 'Votre compte est à court de crédits. Ajoutez-en pour continuer.', + openBilling: 'Ouvrir la facturation', + addCredits: 'Ajouter des crédits', + dismiss: 'Fermer' + }, + sendDiagnostics: { + title: 'Envoyer les diagnostics à Nous', + privacyNotice: + "Cela téléverse un paquet de débogage vers un stockage interne de Nous, et non vers un service de partage public. Il contient des informations système (système d'exploitation, versions, fournisseur et clés API configurées — jamais les clés elles-mêmes) ainsi que les journaux complets de l'agent, du gateway et du Desktop (jusqu'à 512 Ko chacun), susceptibles de contenir des conversations, des résultats d'outils et des chemins de fichiers. Les secrets sont expurgés avant l'envoi. Seuls le personnel de Nous et les modérateurs Discord autorisés peuvent consulter le paquet, qui est automatiquement supprimé après 14 jours.", + upload: 'Envoyer', + uploading: 'Envoi en cours…', + cancel: 'Annuler', + close: 'Fermer', + copyLink: 'Copier le lien', + uploadIdFallback: id => `Aucun lien de consultation renvoyé — indiquez l'identifiant ${id} au support`, + doneTitle: 'Diagnostics envoyés', + doneDescription: + "Votre paquet a été téléversé de manière privée. Partagez le lien ci-dessous dans votre fil d'assistance afin que l'équipe puisse consulter vos journaux.", + failedTitle: "Échec de l'envoi", + failedHint: + 'Vous pouvez également exécuter `hermes debug share --nous` dans un terminal, ou `hermes debug share --local` pour afficher le rapport sans le téléverser.', + handoffLead: 'Poursuivez la discussion sur :', + links: { + github: 'Issues GitHub', + portal: 'Assistance du portail Nous', + discord: 'Discord' + } + }, + titlebar: { + hideSidebar: 'Masquer la barre latérale', + showSidebar: 'Afficher la barre latérale', + search: 'Rechercher', + searchTitle: 'Rechercher des sessions, vues et actions', + swapSidebarSides: 'Inverser les côtés de la barre latérale', + hideRightSidebar: 'Masquer la barre latérale droite', + showRightSidebar: 'Afficher la barre latérale droite', + unreadSessions: count => (count === 1 ? '1 session non lue' : `${count} sessions non lues`), + muteHaptics: 'Couper les vibrations', + unmuteHaptics: 'Réactiver les vibrations', + openSettings: 'Ouvrir les paramètres', + openStarmap: 'Ouvrir le graphe de mémoire', + enterHud: 'Mode HUD', + exitHud: 'Quitter le mode HUD', + resetHudLayout: 'Réinitialiser la taille et la position du HUD', + layoutEditor: 'Éditeur de disposition', + layoutEditorTitle: mod => `Éditeur de disposition — ${mod}-clic réinitialise la disposition` + }, + keybinds: { + title: 'Raccourcis clavier', + subtitle: open => `Cliquez sur un raccourci pour le réaffecter · ${open} rouvre ce panneau.`, + search: 'Rechercher des raccourcis…', + rebind: 'Réaffecter', + reset: 'Réinitialiser', + resetAll: 'Tout réinitialiser', + pressKey: 'Appuyez sur une touche…', + set: 'défini', + conflictWith: label => `Également affecté à « ${label} »`, + categories: { + composer: 'Compositeur', + profiles: 'Profils', + session: 'Session', + navigation: 'Navigation', + view: 'Affichage' + }, + actions: { + 'keybinds.openPanel': 'Ouvrir les raccourcis clavier', + 'nav.commandPalette': 'Ouvrir la palette de commandes', + 'nav.commandCenter': 'Ouvrir le centre de commandes', + 'nav.settings': 'Ouvrir les paramètres', + 'nav.profiles': 'Ouvrir les profils', + 'nav.capabilities': 'Ouvrir les skills', + 'nav.messaging': 'Ouvrir la messagerie', + 'nav.artifacts': 'Ouvrir les artefacts', + 'nav.cron': 'Ouvrir les tâches planifiées', + 'nav.agents': 'Ouvrir les agents', + 'session.new': 'Nouvelle session', + 'session.newTab': 'Nouvel onglet de session', + 'session.newWindow': 'Nouvelle fenêtre', + 'session.next': 'Session suivante', + 'session.prev': 'Session précédente', + 'session.slot.1': 'Basculer vers la session récente 1', + 'session.slot.2': 'Basculer vers la session récente 2', + 'session.slot.3': 'Basculer vers la session récente 3', + 'session.slot.4': 'Basculer vers la session récente 4', + 'session.slot.5': 'Basculer vers la session récente 5', + 'session.slot.6': 'Basculer vers la session récente 6', + 'session.slot.7': 'Basculer vers la session récente 7', + 'session.slot.8': 'Basculer vers la session récente 8', + 'session.slot.9': 'Basculer vers la session récente 9', + 'session.focusSearch': 'Rechercher des sessions', + 'session.togglePin': 'Épingler / désépingler la session actuelle', + 'session.archive': 'Archiver la session actuelle', + 'workspace.newWorktree': 'Nouveau worktree', + 'workspace.openFolder': 'Ouvrir le dossier comme projet', + 'composer.focus': 'Mettre le focus sur le compositeur', + 'composer.modelPicker': 'Ouvrir le sélecteur de modèle', + 'composer.voice': 'Démarrer / arrêter la conversation vocale', + 'view.toggleSidebar': 'Basculer la barre latérale des sessions', + 'view.cycleSidebarGrouping': 'Changer le regroupement des sessions', + 'view.toggleRightSidebar': "Basculer l'explorateur de fichiers", + 'view.toggleReview': 'Basculer le panneau de révision', + 'view.toggleStatusbar': 'Basculer la barre de statut', + 'view.toggleTabStrip': 'Basculer les onglets', + 'view.toggleProfileRail': 'Afficher ou masquer la barre des profils', + 'view.toggleSimpleMode': 'Activer ou désactiver le mode simple', + 'view.showFiles': "Afficher l'explorateur de fichiers", + 'view.showBrowser': 'Ouvrir le navigateur', + 'view.toggleHud': 'Basculer le mode HUD', + 'hud.snapToPointer': 'Déplacer le HUD vers le pointeur (global, lorsque le HUD est ouvert)', + 'view.showTerminal': 'Basculer le terminal', + 'view.newTerminal': 'Nouveau terminal', + 'view.nextTerminal': 'Terminal suivant', + 'view.prevTerminal': 'Terminal précédent', + 'view.closeTerminal': 'Fermer le terminal', + 'view.selectionToComposer': 'Envoyer la sélection au compositeur', + 'view.terminalCopy': 'Copier la sélection du terminal', + 'view.terminalPaste': 'Coller dans le terminal', + 'view.closeTab': "Fermer l'onglet", + 'view.reopenTab': "Rouvrir l'onglet fermé", + 'view.flipPanes': 'Inverser les côtés de la barre latérale', + 'view.findInPage': 'Rechercher dans la page', + 'view.findNext': 'Rechercher la correspondance suivante', + 'view.findPrevious': 'Rechercher la correspondance précédente', + 'appearance.toggleMode': 'Basculer clair / sombre', + 'profile.default': 'Basculer vers le profil par défaut', + 'profile.switch.1': 'Basculer vers le profil 1', + 'profile.switch.2': 'Basculer vers le profil 2', + 'profile.switch.3': 'Basculer vers le profil 3', + 'profile.switch.4': 'Basculer vers le profil 4', + 'profile.switch.5': 'Basculer vers le profil 5', + 'profile.switch.6': 'Basculer vers le profil 6', + 'profile.switch.7': 'Basculer vers le profil 7', + 'profile.switch.8': 'Basculer vers le profil 8', + 'profile.switch.9': 'Basculer vers le profil 9', + 'profile.switch.10': 'Basculer vers le profil 10', + 'profile.switch.11': 'Basculer vers le profil 11', + 'profile.switch.12': 'Basculer vers le profil 12', + 'profile.switch.13': 'Basculer vers le profil 13', + 'profile.switch.14': 'Basculer vers le profil 14', + 'profile.switch.15': 'Basculer vers le profil 15', + 'profile.switch.16': 'Basculer vers le profil 16', + 'profile.switch.17': 'Basculer vers le profil 17', + 'profile.switch.18': 'Basculer vers le profil 18', + 'profile.next': 'Profil suivant', + 'profile.prev': 'Profil précédent', + 'profile.toggleAll': 'Basculer la vue tous profils', + 'profile.create': 'Créer un profil', + 'composer.send': 'Envoyer le message', + 'composer.newline': 'Insérer un saut de ligne', + 'composer.steer': 'Diriger le tour en cours', + 'composer.queue': 'Mettre le message en file', + 'composer.sendQueued': 'Envoyer le tour suivant en file', + 'composer.mention': 'Référencer des fichiers, dossiers, URLs', + 'composer.slash': 'Palette de commandes slash', + 'composer.help': 'Aide rapide', + 'composer.history': 'Parcourir le popover / historique', + 'composer.cancel': "Fermer le popover · annuler l'exécution" + } + }, + findInPage: { + next: 'Correspondance suivante', + previous: 'Correspondance précédente' + }, + language: { + label: 'Langue', + description: "Choisissez la langue de l'interface du desktop.", + saving: 'Enregistrement de la langue…', + saveError: 'Échec de la mise à jour de la langue', + switchTo: 'Changer de langue', + searchPlaceholder: 'Rechercher des langues…', + noResults: 'Aucune langue trouvée' + }, + settings: { + subpages: { + appearanceTheme: 'Thème', + appearanceTypography: 'Typographie', + appearanceWindowLayout: 'Fenêtre et disposition', + appearanceChatDisplay: 'Affichage des conversations', + appearancePet: 'Compagnon', + appearanceGeneral: 'Général', + modelMain: 'Modèle principal', + modelAuxiliary: 'Modèles auxiliaires', + modelMoa: 'Mixture of Agents', + modelFallbacks: 'Modèles de secours', + chatBehavior: 'Comportement', + chatAttachments: 'Pièces jointes', + workspaceProjects: 'Projets et découverte', + workspaceShell: 'Environnement du shell', + workspaceFiles: 'Fichiers et exécution', + safetyApprovals: 'Approbations', + safetyPrivacy: 'Confidentialité et réseau', + safetyCheckpoints: 'Points de restauration', + browserProfile: 'Profil du navigateur', + browserNetwork: 'URL locales et privées', + memoryPersistent: 'Mémoire persistante', + memoryContext: 'Contexte et compression', + voiceConversation: 'Conversation vocale', + voiceTranscription: 'Reconnaissance vocale', + voiceSpeech: 'Synthèse vocale', + advancedRuntime: "Limites de l'agent", + advancedTools: 'Accès aux outils', + advancedTerminal: 'Backend du terminal', + advancedOutput: 'Limites de sortie', + advancedDelegation: 'Sous-agents', + advancedDesktop: 'Desktop et démarrage', + gatewayConnection: 'Cette fenêtre', + gatewayDevices: 'Connexions enregistrées', + gatewayManagedUpdates: 'Mises à jour à distance', + gatewayManagedUpdatesUnavailable: + 'Les mises à jour à distance nécessitent une version de Desktop prenant en charge les mises à jour SSH gérées.', + gatewayManagedUpdatesEmpty: + 'Ajoutez une connexion SSH dans Connexions enregistrées pour gérer ses mises à jour ici.', + keyboardShortcuts: 'Raccourcis clavier', + hudGesture: 'Geste du HUD', + screenCapture: "Capture d'écran", + notificationAlerts: 'Alertes du bureau', + notificationSounds: 'Sons', + archivedSessions: 'Archives et conservation', + defaultDirectory: 'Dossier de projet par défaut', + vaultCredentials: 'Identifiants enregistrés', + vaultSources: 'Gestionnaires de mots de passe', + appUpdates: 'Version et mises à jour', + uninstall: 'Désinstaller', + billingOverview: "Vue d'ensemble", + billingPlans: 'Forfaits' + }, + closeSettings: 'Fermer les paramètres', + exportConfig: 'Exporter la configuration', + importConfig: 'Importer la configuration', + resetToDefaults: 'Réinitialiser aux valeurs par défaut', + resetConfirm: 'Réinitialiser tous les paramètres aux valeurs par défaut de Hermes ?', + exportFailed: "Échec de l'export", + resetFailed: 'Échec de la réinitialisation', + nav: { + providers: 'Fournisseurs', + providerAccounts: 'Comptes', + providerApiKeys: 'Clés API', + providerCustomEndpoints: 'Points de terminaison personnalisés', + providerLocalModels: 'Modèles locaux', + gateway: 'Gateway', + apiKeys: 'Outils et clés', + keybinds: 'Raccourcis clavier', + keysTools: 'Outils', + keysSettings: 'Paramètres', + mcp: 'MCP', + archivedChats: 'Conversations archivées', + about: 'À propos', + billing: 'Facturation', + notifications: 'Notifications', + vault: 'Mots de passe et identifiants' + }, + plugins: { + title: 'Plugins du desktop', + blurb: + "Étendez cette application, et non un agent : ces plugins sont installés une seule fois pour toute l'application, quel que soit le profil, le gateway ou la machine connectée. Les interrupteurs s'appliquent immédiatement.", + count: n => `${n} installés`, + openFolder: 'Ouvrir le dossier des plugins Desktop', + rescan: 'Re-analyser', + reveal: 'Afficher dans le gestionnaire de fichiers', + enable: 'Activer', + disable: 'Désactiver', + failed: 'échec', + empty: 'Aucun plugin desktop installé pour le moment.', + kinds: { + bundled: 'intégré', + disk: 'sur le disque', + runtime: "à l'exécution" + }, + agentHalfMissing: 'partie agent absente ici', + agentHalfMissingTip: + "Il s'agit de la partie Desktop d'un plugin groupé, mais sa partie agent n'est pas installée sur le backend ou profil actuellement connecté. Installez-la depuis Capacités → Plugins.", + installModal: { + installFromGit: 'Installer depuis Git', + reviewRepository: 'Examiner le dépôt', + repoPlaceholder: 'https://github.com/proprietaire/depot', + title: 'Installer le plugin', + description: "Vérifiez le contenu de ce dépôt avant d'installer quoi que ce soit.", + repoLabel: 'Dépôt', + includesHeading: 'Ce paquet comprend', + agentLabel: "Plugin de l'agent", + desktopLabel: 'Interface Desktop', + profileLabel: 'Installer pour le profil', + agentTargetLocal: (profile, dir) => `Installe dans le backend ${profile} (${dir})`, + agentTargetRemote: profile => `S'installe dans le backend ${profile} connecté`, + catalogPinned: (name, sha) => + `Entrée « ${name} » du catalogue Hermes — le composant agent sera installé depuis le commit vérifié${sha ? ` ${sha}` : ''}, et non depuis la tête de branche.`, + reviewedHeading: 'Entrée du catalogue vérifiée', + reviewedIntro: + 'Cette entrée a été vérifiée manuellement à son commit épinglé. Vous pouvez encore examiner le code exact ci-dessous.', + toolsConnected: n => (n === 1 ? '1 outil connecté' : `${n} outils connectés`), + skillsReady: names => (names.length === 1 ? `skill ${names[0]} prêt` : `${names.length} skills prêts`), + nextChat: "d'autres outils disponibles dans votre prochaine conversation", + serverNotConnected: (server, reason) => + `Le serveur MCP ${server} n'est pas connecté${reason ? ` : ${reason}` : '.'}`, + missingEnvAction: 'Le configurer', + alreadyInstalled: name => `${name} est déjà installé.`, + desktopTarget: "S'installe dans le dossier local desktop-plugins de cette application", + desktopTargetFromPackage: + 'Chargé dans cette application depuis le paquet ci-dessus — identique pour tous les profils', + desktopOnlyNote: "Les paquets réservés au Desktop n'installent aucun plugin dans le backend.", + insecureWarning: + 'Cette URL utilise un protocole non sécurisé ou local. Préférez https:// ou git@ en production.', + securityHeading: "Avant l'installation", + securityIntro: + 'Installez uniquement des sources de confiance — consultez le dépôt ci-dessous pour vérifier ce qui sera ajouté.', + sourceHeading: 'Code source', + viewRepository: 'Voir le dépôt', + viewPluginFiles: 'Voir les fichiers du plugin', + gitCloneLabel: 'URL de clonage Git', + enableAgent: "Activer le plugin de l'agent après l'installation", + forceReinstall: 'Forcer la réinstallation (remplacer la version existante)', + pinToCommit: 'Épingler à un commit (facultatif)', + pinToCommitPlaceholder: 'SHA complet du commit sur 40 caractères', + pinToCommitHint: + "Toute personne installant ce SHA obtient le même code ; le plugin refusera ensuite les mises à jour jusqu'à un nouvel épinglage. Laissez vide pour utiliser le dernier commit.", + pinToCommitInvalid: + 'Le SHA doit contenir exactement 40 caractères (les branches et tags ne sont pas acceptés).', + install: 'Installer', + installing: 'Installation…', + probing: 'Inspection du dépôt…', + probeUnavailable: "L'inspection du plugin n'est pas disponible dans cet environnement.", + desktopUnavailable: "L'installation du plugin Desktop n'est pas disponible dans cet environnement.", + selectComponent: 'Sélectionnez au moins un composant à installer.', + agentSuccess: name => `Plugin de l'agent ${name} installé`, + desktopSuccess: name => `Plugin Desktop ${name} installé`, + agentFailed: "Échec de l'installation du plugin de l'agent", + desktopFailed: "Échec de l'installation du plugin Desktop", + missingEnv: (name, vars) => + `${name} est installé, mais a besoin d'une clé pour fonctionner : ${vars}. Ajoutez-la maintenant, sinon les outils du plugin échoueront.` + } + }, + vault: { + title: 'Mots de passe et identifiants', + blurb: + "Demandez à l'agent de se connecter à un site : il vous demandera l'identifiant la première fois, puis pourra le réutiliser. Les mots de passe sont chiffrés sur cet ordinateur et remplis directement dans la page ; le modèle ne les voit jamais.", + count: n => `${n} enregistré${n === 1 ? '' : 's'}`, + loadFailed: 'Impossible de charger les éléments du coffre', + empty: 'Aucun élément enregistré', + emptyDesc: + "Vous n'avez rien à ajouter à l'avance. Demandez à l'agent de se connecter à un site et il vous demandera l'identifiant au moment voulu.", + add: 'Ajouter', + addTitle: 'Ajouter un identifiant, une carte ou une adresse', + addDescription: "Enregistré chiffré sur cet ordinateur. L'agent ne voit jamais le mot de passe.", + added: 'Enregistré.', + adding: 'Enregistrement…', + addConfirm: 'Enregistrer', + kindField: 'Type', + kinds: { + login: 'Identifiant', + payment: 'Carte bancaire', + address: 'Adresse' + }, + labelField: 'Libellé', + labelPlaceholder: 'par ex. compte GitHub professionnel', + labelRequired: 'Un libellé est requis.', + originField: 'Origine du site', + originPlaceholder: 'https://github.com', + originPlaceholderCheckout: 'https://shop.example.com', + originInvalid: 'Saisissez une URL valide, par exemple https://exemple.fr.', + identifierTypeField: "Type d'identifiant", + identifierTypes: { + email: 'E-mail', + phone: 'Téléphone', + username: "Nom d'utilisateur" + }, + identifierField: 'Identifiant', + identifierShown: identifier => identifier, + passwordField: 'Mot de passe', + loginFieldsRequired: "L'identifiant et le mot de passe sont requis.", + cardNumberField: 'Numéro de carte', + cardNameField: 'Nom sur la carte', + expMonthField: "Mois d'expiration", + expYearField: "Année d'expiration", + cvcField: 'CVC', + postalField: 'Code postal', + addressLine1Field: 'Adresse ligne 1', + addressLine2Field: 'Adresse ligne 2', + cityField: 'Ville', + stateField: 'État / région', + countryField: 'Pays', + optional: '(facultatif)', + createdOn: date => `Ajouté ${date}`, + deleteAction: "Supprimer l'élément enregistré", + otpField: "Clé d'authentificateur", + otpPlaceholder: 'Secret Base32 ou lien otpauth://', + otpHint: + "La « clé d'installation » que le site affiche lorsque vous activez 2FA. Avec elle enregistrée, Hermes génère lui-même les codes.", + twoFactorBadge: '2FA auto', + deleteTitle: 'Supprimer cet élément ?', + deleteDescription: label => `« ${label} » sera supprimé définitivement.`, + deleteConfirm: 'Supprimer', + sources: { + title: 'Gestionnaires de mots de passe', + blurb: + "Les gestionnaires de mots de passe installés sont repérés automatiquement. L'agent vous demande de déverrouiller l'un la première fois qu'il en a besoin (une fois par session) ; seul un jeton de session reste en mémoire, et l'agent ne voit jamais votre mot de passe principal ou aucun identifiant.", + toggleFailed: 'Impossible de mettre à jour le gestionnaire de mots de passe', + notInstalled: name => + `Non détecté. Installez l’outil en ligne de commande ${name} et connectez-vous ; Hermes le repère automatiquement.`, + disabledDesc: 'Détecté mais désactivé pour Hermes.', + lockedDesc: + "Détecté. L'agent vous demandera de le déverrouiller lorsqu'il en a besoin, ou déverrouillez maintenant.", + unlockedDesc: + "Déverrouillé pour cette session. Se verrouille automatiquement après 30 minutes d'inactivité ou lorsque Hermes se ferme.", + statusLocked: 'Verrouillé', + statusNotDetected: 'Non détecté', + statusOff: 'Désactivé', + statusUnlocked: 'Déverrouillé', + unlock: 'Déverrouiller', + unlocking: 'Déverrouillage…', + lock: 'Verrouiller', + unlocked: name => `${name} est déverrouillé pour cette session.`, + unlockTitle: name => `Déverrouiller ${name}`, + unlockDescription: + "Entrez votre mot de passe principal. Il est transmis au gestionnaire de mots de passe sur cette machine et jeté — il n'est jamais stocké, enregistré, ni montré à l'agent.", + masterPasswordPlaceholder: 'Mot de passe principal' + } + }, + notifications: { + title: 'Notifications', + intro: "Notifications système (pas les toasts de l'application). Par appareil.", + enableAll: 'Activer les notifications', + enableAllDesc: 'Désactivé coupe toutes les notifications ci-dessous.', + focusedHint: 'Les alertes de fin ne se déclenchent que quand Hermes est en arrière-plan.', + kinds: { + approval: { + label: 'Approbation requise', + description: "Une commande attend que vous l'approuviez ou la rejetiez." + }, + input: { + label: 'Saisie requise', + description: "Hermes a posé une question ou a besoin d'un mot de passe ou d'un secret." + }, + turnDone: { + label: 'Réponse prête', + description: "Un tour s'est terminé pendant que Hermes était en arrière-plan." + }, + turnError: { + label: 'Échec du tour', + description: 'Erreurs de tour en arrière-plan.' + }, + backgroundDone: { + label: 'Tâche en arrière-plan terminée', + description: "Une commande terminal en arrière-plan s'est terminée." + }, + credits: { + label: 'Alertes de crédits', + description: "L'accès aux crédits est suspendu ou rétabli." + }, + plugin: { + label: 'Notifications des plugins', + description: 'Un plugin desktop a envoyé une notification pendant que Hermes était en arrière-plan.' + } + }, + test: 'Envoyer une notification de test', + testTitle: 'Hermes', + testBody: 'Les notifications fonctionnent.', + testSent: + "Test envoyé. Si rien n'apparaît, vérifiez les autorisations de notification de votre système et le mode Ne pas déranger.", + testUnsupported: 'Ce système ne prend pas en charge les notifications natives.', + completionSoundTitle: 'Son de fin', + completionSoundDesc: "Se joue à la fin d'un tour d'agent. Choisissez un préréglage et prévisualisez-le ici.", + completionSoundPreview: 'Aperçu' + }, + sections: { + model: 'Modèle', + chat: 'Conversation', + appearance: 'Apparence', + workspace: 'Espace de travail', + safety: 'Sécurité', + memory: 'Mémoire et contexte', + voice: 'Voix', + advanced: 'Avancé' + }, + searchPlaceholder: { + about: 'À propos de Hermes Desktop', + config: 'Rechercher dans les paramètres...', + gateway: 'Connexion au gateway...', + keys: 'Rechercher des clés API...', + mcp: 'Rechercher des serveurs MCP...', + sessions: 'Rechercher des sessions archivées...' + }, + modeOptions: { + light: { + label: 'Clair', + description: 'Surfaces lumineuses' + }, + dark: { + label: 'Sombre', + description: 'Espace de travail sans éblouissement' + }, + system: { + label: 'Système', + description: "Suivre l'apparence du système" + } + }, + appearance: { + title: 'Apparence', + intro: + 'Exclusif au desktop. Le mode contrôle la luminosité ; le thème contrôle la palette et le chrome de la conversation.', + colorMode: 'Mode couleur', + colorModeDesc: 'Choisissez un mode fixe ou laissez Hermes suivre le paramètre système.', + toolViewTitle: "Affichage des appels d'outil", + toolViewDesc: + 'Le mode Produit masque les charges utiles brutes ; le mode Technique affiche les entrées/sorties complètes.', + hideCodeDiffsTitle: 'Masquer les diffs de code', + hideCodeDiffsDesc: + 'Afficher les modifications de fichiers sous forme de lignes d’outil avec le nombre de lignes ajoutées/supprimées, sans le code.', + hideThreadTimelineTitle: 'Masquer les barres de chronologie', + hideThreadTimelineDesc: 'Masquer les barres de navigation le long du bord droit de chaque conversation.', + reasoningCollapsedTitle: 'Réduire le raisonnement par défaut', + reasoningCollapsedDesc: + "Conserver le raisonnement diffusé en continu sans le développer tant que vous ne l'ouvrez pas.", + uiScaleTitle: "Échelle de l'interface", + uiScaleDesc: (percent: number) => + `Redimensionne le texte et les contrôles dans toute l'application. Cmd/Ctrl avec +, - et 0 fonctionne aussi. Actuel : ${percent}%.`, + sessionDensityTitle: 'Densité de la liste des sessions', + sessionDensityDesc: + 'Choisissez la quantité de contexte affichée sous les titres de session dans la barre latérale.', + sessionDensityCompact: 'Compacte', + sessionDensityComfortable: 'Confortable', + sessionDensityDetailed: 'Détaillée', + tabStripTitle: "Barre d'onglets", + tabStripDesc: + "Affiche les onglets au-dessus d'une zone. Le mode automatique les masque lorsqu'une zone ne contient qu'un seul panneau.", + tabStripAuto: 'Automatique', + tabStripAlways: 'Toujours', + tabStripNever: 'Jamais', + appActionsTitle: "Actions de l'application", + appActionsDesc: + 'Choisissez le côté de la barre de titre où placer Paramètres, Disposition et HUD. Le côté droit laisse de la place aux onglets à gauche.', + appActionsLeft: 'À gauche', + appActionsRight: 'À droite', + terminalFontTitle: 'Police du terminal', + terminalFontDesc: + 'Choisissez une police installée pour les terminaux Desktop. Les Nerd Fonts affichent correctement Powerlevel10k et les icônes du shell ; laissez vide pour utiliser JetBrains Mono intégré.', + terminalFontPlaceholder: 'MesloLGS NF ou une pile de polices CSS', + terminalFontPreview: 'Aperçu des glyphes', + terminalFontReset: 'Utiliser la valeur par défaut', + chatFontTitle: 'Police de la conversation', + chatFontDesc: + "Choisissez une police installée pour la conversation et le reste de l'application. Pratique pour des polices plus lisibles comme OpenDyslexic ; laissez vide pour utiliser celle du thème.", + chatFontPlaceholder: 'OpenDyslexic ou une pile de polices CSS', + chatFontPreview: 'Aperçu', + chatFontSample: 'Portez ce vieux whisky au juge blond qui fume. 0123456789', + chatFontReset: 'Utiliser la police du thème', + translucencyTitle: 'Translucidité de la fenêtre', + translucencyDesc: 'Voir votre bureau à travers toute la fenêtre. macOS et Windows uniquement.', + translucencyGlassDesc: + 'Verre mat : le bureau reste visible à travers un flou uniforme tandis que le texte demeure net. macOS uniquement.', + translucencyModeClear: 'Transparent', + translucencyModeGlass: 'Verre', + translucencyTintTitle: 'Teinte', + translucencyFadeTitle: 'Atténuation', + translucencyFrostTitle: 'Givre', + translucencyFrost: { + 'under-window': 'Profond', + popover: 'Doux', + titlebar: 'Clair', + header: 'Éclat' + }, + translucencyScopeTitle: 'Zone', + translucencyScope: { + window: 'Fenêtre entière', + sidebar: 'Barre latérale uniquement' + }, + backdropTitle: 'Arrière-plan de la conversation', + backdropDesc: "L'image de statue discrète derrière la conversation.", + userBubbleTitle: 'Bulle des messages', + userBubbleDesc: 'Transparence de vos messages : fond opaque à 0 ; seul le contour reste visible à 100.', + textDirectionTitle: 'Sens du texte', + textDirectionDesc: + "Sens d'écriture des messages et du champ de saisie. Auto suit la première lettre de chaque paragraphe ; choisissez un sens quand un texte mixte s'aligne mal. Le code reste toujours de gauche à droite.", + textDirection: { auto: 'Auto', rtl: 'De droite à gauche', ltr: 'De gauche à droite' }, + introSplashTitle: "Écran d'accueil", + introSplashDesc: "Le logo et l'invite affichés dans une conversation vide.", + reactionsTitle: 'Réactions aux messages', + reactionsDesc: 'Réactions emoji façon iMessage — réagissez aux messages, et Hermes peut réagir aux vôtres.', + tipsTitle: "Astuces dans l'application", + tipsDesc: + "Une petite bulle désigne occasionnellement une partie de l'application lorsque vous êtes inactif ou lorsque Hermes peut vous aider. Fermer une astuce la masque définitivement.", + tipsReset: (count: number) => + `Réafficher ${count} astuce${count === 1 ? '' : 's'} fermée${count === 1 ? '' : 's'}`, + toursTitle: 'Visites guidées', + toursDesc: + "Laissez Hermes vous guider dans l'application en assombrissant l'écran et en mettant chaque étape en évidence.", + composerPopoutTitle: 'Détacher la zone de saisie', + composerPopoutDesc: "Autoriser la zone de saisie à s'ouvrir dans une fenêtre flottante distincte.", + vibeHeartsTitle: "Cœurs d'ambiance", + vibeHeartsDesc: + "Des cœurs flottants apparaissent lorsque vous dites merci, « je t'aime », « good bot » ou envoyez un cœur. Cette option est indépendante des réactions aux messages ci-dessus.", + embedsTitle: 'Intégrations en ligne', + embedsDesc: + "Les aperçus enrichis se chargent depuis des sites tiers (YouTube, X, …). Demander affiche un espace réservé jusqu'à ce que vous autorisiez chacun ; Toujours les charge automatiquement ; Désactivé conserve les liens simples.", + embedsAsk: 'Demander', + embedsAlways: 'Toujours', + embedsOff: 'Désactivé', + embedsReset: (count: number) => + `Réinitialiser ${count} ${count === 1 ? 'service autorisé' : 'services autorisés'}`, + resumeLastSessionTitle: 'Rouvrir la dernière conversation au lancement', + resumeLastSessionDesc: + "Lorsque cette option est activée, l'application rouvre votre conversation la plus récente après un démarrage à froid. Désactivez-la pour toujours commencer par une nouvelle conversation.", + product: 'Produit', + productDesc: 'Activité des outils lisible avec des résumés concis.', + technical: 'Technique', + technicalDesc: 'Inclure les arguments/résultats bruts des outils et les détails de bas niveau.', + themeTitle: 'Thème', + themeDesc: "Palettes desktop uniquement. Le mode sélectionné s'applique par-dessus.", + themeSearchPlaceholder: 'Rechercher dans vos thèmes ou sur le Marketplace VS Code…', + themeProfileNote: profile => `Enregistré pour le profil ${profile} — chaque profil conserve son propre thème.`, + installTitle: 'Installer depuis VS Code', + installDesc: + "Collez un identifiant d'extension Marketplace (ex. dracula-theme.theme-dracula) pour convertir son thème de couleur en palette desktop.", + installPlaceholder: 'éditeur.extension', + installButton: 'Installer', + installing: 'Installation…', + installError: "Impossible d'installer ce thème.", + installed: name => `« ${name} » installé.`, + removeTheme: 'Supprimer le thème', + importedBadge: 'Importé', + pet: { + title: 'Animal de compagnie', + intro: + "Adoptez une mascotte petdex animée qui flotte au-dessus de l'application et réagit aux actions de Hermes — court pendant l'exécution des outils, fête les réussites, boude les erreurs.", + restartHint: + "Les animaux de compagnie nécessitent un redémarrage rapide — l'application en cours a démarré avant l'ajout de cette fonctionnalité. Fermez et rouvrez Hermes, puis revenez ici.", + on: 'Activé', + off: 'Désactivé', + scaleTitle: 'Taille', + scaleDesc: "Redimensionnez la mascotte flottante. S'applique partout instantanément.", + roamTitle: 'Errer', + roamDesc: "Laissez l'animal errer dans la fenêtre pendant les moments d'inactivité.", + chooseTitle: 'Choisir un animal', + chooseDesc: "Le choisir l'installe (si nécessaire) et l'active.", + searchPlaceholder: 'Rechercher des animaux…', + unreachable: "Impossible d'atteindre la galerie petdex. Vérifiez votre connexion et rouvrez cette page.", + noMatch: query => `Aucun animal ne correspond à « ${query} ».`, + installedTag: 'installé', + generatedTag: 'Généré', + countCapped: (cap, total) => `Affichage de ${cap} sur ${total} — tapez pour affiner.`, + count: n => `${n} animal${n === 1 ? '' : 's'}.`, + uninstall: name => `Désinstaller ${name}`, + delete: name => `Supprimer ${name}`, + deleteTitle: name => `Supprimer ${name} ?`, + deleteBody: "Cela supprime définitivement l'animal — il ne peut pas être réinstallé.", + deleteConfirm: 'Supprimer', + rename: name => `Renommer ${name}`, + renameTitle: "Renommer l'animal", + renamePlaceholder: 'Nommez votre animal', + renameSave: 'Enregistrer', + exportPet: name => `Exporter ${name}`, + adoptFailed: slug => `Impossible d'adopter ${slug}`, + uninstallFailed: slug => `Impossible de désinstaller ${slug}`, + renameFailed: slug => `Impossible de renommer ${slug}`, + exportFailed: slug => `Impossible d'exporter ${slug}`, + noneAvailable: 'Aucun animal disponible pour le moment.', + turnOnFailed: "Impossible d'activer l'animal.", + turnOffFailed: "Impossible de désactiver l'animal." + } + }, + fieldLabels: defineFieldCopy({ + model: 'Modèle par défaut', + modelContextLength: 'Fenêtre de contexte du modèle principal (forçage)', + fallbackProviders: 'Modèles de secours', + toolsets: "Ensembles d'outils activés", + timezone: 'Fuseau horaire', + display: { + personality: 'Personnalité', + showReasoning: 'Blocs de raisonnement' + }, + desktop: { + repoScanEnabled: 'Découverte automatique de dépôts', + repoScanRoots: 'Racines de découverte de dépôts', + repoScanExcludePaths: 'Chemins de dépôt exclus' + }, + agent: { + maxTurns: "Étapes max. de l'agent", + imageInputMode: "Pièces jointes d'image", + apiMaxRetries: 'Nouveaux essais API', + serviceTier: 'Niveau de service', + toolUseEnforcement: "Contrôle de l'utilisation des outils" + }, + terminal: { + cwd: 'Répertoire de travail', + backend: "Backend d'exécution", + timeout: "Délai d'expiration de la commande", + persistentShell: 'Shell persistant', + envPassthrough: "Transmission de l'environnement", + dockerImage: 'Image Docker', + singularityImage: 'Image Singularity', + modalImage: 'Image Modal', + daytonaImage: 'Image Daytona' + }, + fileReadMaxChars: 'Limite de lecture de fichier', + toolOutput: { + maxBytes: 'Limite de sortie du terminal', + maxLines: 'Limite de pagination de fichier', + maxLineLength: 'Limite de longueur de ligne' + }, + codeExecution: { + mode: "Mode d'exécution du code" + }, + approvals: { + mode: "Mode d'approbation", + timeout: "Délai d'expiration de l'approbation", + mcpReloadConfirm: 'Confirmer les rechargements MCP' + }, + commandAllowlist: "Liste d'autorisation des commandes", + security: { + redactSecrets: 'Masquer les secrets', + allowPrivateUrls: 'Autoriser les adresses web privées' + }, + browser: { + allowPrivateUrls: 'Adresses web privées du navigateur', + autoLocalForPrivateUrls: 'Navigateur local pour les adresses web privées', + useRealProfile: 'Utiliser mon vrai profil de navigateur' + }, + checkpoints: { + enabled: 'Points de contrôle de fichiers', + maxSnapshots: 'Limite de points de contrôle' + }, + voice: { + recordKey: 'Raccourci vocal', + maxRecordingSeconds: "Durée maximale d'enregistrement", + autoTts: 'Lire les réponses à haute voix', + voiceChatMode: 'Mode de conversation vocale', + gptLive: { + voice: 'Voix GPT-Live', + instructions: 'Personnalité GPT-Live' + } + }, + stt: { + enabled: 'Reconnaissance vocale', + echoTranscripts: 'Réémettre les transcriptions', + provider: 'Fournisseur de reconnaissance vocale', + local: { + model: 'Modèle de transcription local', + language: 'Langue de transcription' + }, + openai: { + model: 'Modèle STT OpenAI' + }, + groq: { + model: 'Modèle STT Groq' + }, + mistral: { + model: 'Modèle STT Mistral' + }, + elevenlabs: { + modelId: 'Modèle STT ElevenLabs', + languageCode: 'Langue ElevenLabs', + tagAudioEvents: 'Étiqueter les événements audio', + diarize: 'Diarisation des locuteurs' + } + }, + tts: { + provider: 'Fournisseur de synthèse vocale', + edge: { + voice: 'Voix Edge' + }, + openai: { + model: 'Modèle TTS OpenAI', + voice: 'Voix OpenAI' + }, + elevenlabs: { + voiceId: 'Voix ElevenLabs', + modelId: 'Modèle ElevenLabs' + }, + xai: { + voiceId: 'Voix xAI (Grok)', + language: 'Langue xAI', + speed: 'Vitesse de lecture xAI', + autoSpeechTags: 'Étiquettes vocales automatiques xAI', + optimizeStreamingLatency: 'Optimisation de la latence streaming xAI', + sampleRate: "Fréquence d'échantillonnage xAI", + bitRate: 'Débit binaire xAI' + }, + minimax: { + model: 'Modèle TTS MiniMax', + voiceId: 'Voix MiniMax' + }, + mistral: { + model: 'Modèle TTS Mistral', + voiceId: 'Voix Mistral' + }, + gemini: { + model: 'Modèle TTS Gemini', + voice: 'Voix Gemini' + }, + neutts: { + model: 'Modèle NeuTTS', + device: 'Appareil NeuTTS' + }, + kittentts: { + model: 'Modèle KittenTTS', + voice: 'Voix KittenTTS' + }, + piper: { + voice: 'Voix Piper' + }, + deepinfra: { + model: 'Modèle TTS DeepInfra', + voice: 'Voix DeepInfra' + } + }, + memory: { + memoryEnabled: 'Mémoire persistante', + userProfileEnabled: 'Profil utilisateur', + memoryCharLimit: 'Budget mémoire', + userCharLimit: 'Budget profil', + provider: 'Fournisseur de mémoire' + }, + context: { + engine: 'Moteur de contexte' + }, + compression: { + enabled: 'Auto-compression', + threshold: 'Seuil de compression', + codexGpt55Autoraise: 'Relèvement automatique de la compression Codex', + targetRatio: 'Objectif de compression', + protectLastN: 'Messages récents protégés' + }, + auxiliary: { + compression: { + timeout: 'Délai du modèle de compression (s)' + } + }, + delegation: { + model: 'Modèle sous-agent', + provider: 'Fournisseur sous-agent', + maxIterations: 'Limite de tours sous-agent', + maxConcurrentChildren: 'Sous-agents parallèles', + childTimeoutSeconds: 'Délai sous-agent', + reasoningEffort: 'Intensité du raisonnement sous-agent' + }, + updates: { + nonInteractiveLocalChanges: 'Modifications locales à la mise à jour intégrée' + } + }), + fieldDescriptions: defineFieldCopy({ + model: 'Utilisé pour les nouvelles conversations sauf si vous choisissez un autre modèle dans le compositeur.', + modelContextLength: + 'Remplace la fenêtre de contexte détectée du modèle de conversation PRINCIPAL uniquement (en jetons). Laissez 0 pour utiliser la valeur détectée du modèle sélectionné. N’affecte pas les modèles auxiliaires/MoA.', + fallbackProviders: 'Entrées fournisseur:modèle de secours à essayer si le modèle par défaut échoue.', + display: { + personality: "Style par défaut de l'assistant pour les nouvelles sessions.", + showReasoning: 'Afficher les sections de raisonnement quand le backend les fournit.' + }, + desktop: { + repoScanEnabled: 'Analyser les dossiers locaux à la recherche de dépôts Git à afficher dans Projets.', + repoScanRoots: 'Dossiers à analyser. Laissez vide pour analyser votre répertoire personnel.', + repoScanExcludePaths: 'Dossiers et leurs descendants à ignorer lors de la découverte de dépôts.' + }, + timezone: 'Identifiant de fuseau horaire IANA. Si vide, utilise le fuseau horaire du système.', + browser: { + useRealProfile: + 'La navigation locale utilise vos vraies connexions. Hermes copie le profil de votre navigateur par défaut (cookies, connexions, préférences) dans un instantané géré et le pilote avec son Chromium intégré — votre profil actif n’est jamais ouvert directement, et la copie est actualisée à chaque exécution. Permet aussi à l’agent d’ouvrir sur demande une session locale avec votre vrai profil, même si un backend de navigateur cloud est configuré. Seuls les navigateurs Chromium (Chrome, Edge, Brave, Brave Origin, Chromium) sont pris en charge ; un navigateur par défaut non Chromium échoue avec un message clair. Désactivé par défaut.' + }, + agent: { + imageInputMode: 'Contrôle la façon dont les pièces jointes image sont envoyées au modèle.', + maxTurns: "Limite supérieure de tours d'appel d'outils avant que Hermes n'arrête une exécution." + }, + terminal: { + cwd: 'Dossier de projet par défaut pour les outils et le terminal.', + persistentShell: "Conserve l'état du shell entre les commandes lorsque le backend le permet.", + envPassthrough: "Variables d'environnement à transmettre à l'exécution des outils.", + dockerImage: "Image de conteneur utilisée lorsque le backend d'exécution est Docker.", + singularityImage: "Image utilisée lorsque le backend d'exécution est Singularity.", + modalImage: "Image utilisée lorsque le backend d'exécution est Modal.", + daytonaImage: "Image utilisée lorsque le backend d'exécution est Daytona." + }, + codeExecution: { + mode: "Degré de restriction de l'exécution du code au projet actuel." + }, + fileReadMaxChars: 'Nombre maximal de caractères que Hermes peut lire dans une demande de fichier.', + approvals: { + mode: 'Comment Hermes gère les commandes nécessitant une approbation explicite.', + timeout: "Durée d'attente des invites d'approbation avant expiration." + }, + security: { + redactSecrets: "Masque les secrets détectés du contenu visible par le modèle lorsque c'est possible." + }, + checkpoints: { + enabled: 'Créer des instantanés de restauration avant les modifications de fichiers.' + }, + memory: { + memoryEnabled: 'Sauvegarder des mémoires durables pouvant aider les sessions futures.', + userProfileEnabled: 'Maintenir un profil compact des préférences utilisateur.' + }, + context: { + engine: 'Stratégie pour gérer les longues conversations proches de la limite de contexte.' + }, + compression: { + enabled: 'Résumer le contexte ancien quand les conversations grossissent.', + codexGpt55Autoraise: 'Relever la compression à 85 % pour les modèles ChatGPT Codex OAuth pris en charge.' + }, + auxiliary: { + compression: { + timeout: + 'Secondes d’attente du modèle de compression auxiliaire par appel (120 par défaut). Augmentez pour les modèles locaux lents.' + } + }, + voice: { + autoTts: "Lit automatiquement les réponses de l'assistant à voix haute.", + voiceChatMode: + 'chained : reconnaissance vocale → Hermes → synthèse vocale avec les fournisseurs ci-dessous. gpt-live : un modèle vocal OpenAI full-duplex (gpt-live-1) écoute et parle, et confie chaque vraie demande à Hermes — le modèle que vous avez sélectionné répond avec l’ensemble des outils. Nécessite une clé API OpenAI ; la couche vocale est facturée 0,05 $ par minute.', + gptLive: { + voice: 'Voix du mode GPT-Live. Les identifiants de voix personnalisés sont acceptés.', + instructions: + 'Phrases supplémentaires pour la personnalité vocale en direct (ton, rythme, langue). Hermes conserve son propre prompt système.' + } + }, + tts: { + xai: { + voiceId: 'ID de voix xAI (ex. eve) ou un ID de voix personnalisé.', + language: 'Code de langue parlée (ex. en, pt-BR) ou "auto" pour la détection automatique.', + speed: 'Vitesse de lecture. 0.7 = plus lent, 1.0 = normal, 1.5 = plus rapide.', + autoSpeechTags: + 'Laisser un LLM insérer des balises audio expressives ([laughing], [sighs]) dans le script avant la synthèse.', + optimizeStreamingLatency: 'Compromis latence/qualité. 0 = meilleure qualité, 2 = latence minimale.', + sampleRate: + "Fréquence d'échantillonnage audio en Hz. Plus élevée = meilleure qualité, fichiers plus volumineux.", + bitRate: "Débit binaire MP3 en bps. S'applique uniquement lorsque le codec est mp3." + }, + neutts: { + device: "Périphérique d'inférence local pour NeuTTS." + } + }, + stt: { + enabled: 'Activer la transcription vocale locale ou via fournisseur.', + echoTranscripts: 'Publier la transcription brute 🎙️ des messages vocaux dans la conversation.', + elevenlabs: { + languageCode: 'Code de langue ISO-639-3 facultatif. Vide = détection automatique par ElevenLabs.' + } + }, + updates: { + nonInteractiveLocalChanges: + "Quand Hermes se met à jour depuis l'application (sans invite de terminal), conserve les modifications locales (stash) ou les abandonne (discard). Les mises à jour via le terminal demandent toujours confirmation." + } + }), + uninstallSection: { + dangerZone: 'Zone dangereuse', + checkingInstalled: 'Vérification des éléments installés…', + uninstallHermes: 'Désinstaller Hermes', + chooseHowMuch: + 'Choisissez ce que vous souhaitez supprimer. L’application se ferme pour terminer ; rouvrez le programme d’installation à tout moment pour revenir.', + confirmUninstall: 'Confirmer la désinstallation', + confirmBody: what => `Cette action supprime ${what}. Elle est irréversible.`, + appLabel: 'Application :', + couldNotStart: 'La désinstallation n’a pas pu démarrer.', + uninstalling: 'Désinstallation…', + yesUninstall: 'Oui, désinstaller', + options: { + gui: { + title: 'Désinstaller uniquement l’interface de chat', + description: + 'Supprime cette application de bureau. L’agent Hermes, votre configuration et vos conversations sont conservés.', + consequence: 'l’interface de chat de bureau (cette application et ses données)' + }, + lite: { + title: 'Désinstaller l’interface et l’agent, conserver mes données', + description: + 'Supprime l’application et l’agent Hermes, mais conserve la configuration, les conversations et les secrets pour une future réinstallation.', + consequence: + 'l’interface de chat et l’agent Hermes (la configuration, les conversations et les secrets sont conservés)' + }, + full: { + title: 'Tout désinstaller', + description: + 'Supprime l’application, l’agent et toutes les données utilisateur : configuration, conversations, tâches planifiées, secrets, journaux.', + consequence: + 'TOUT — l’interface de chat, l’agent Hermes et l’ensemble de votre configuration, de vos conversations, secrets et journaux' + } + } + }, + poolLimits: { + warmBotBackendsAria: 'Backends de bots maintenus actifs', + warmBotBackendsTitle: 'Backends de bots actifs', + backendIdleTimeoutAria: "Délai d'inactivité du backend en millisecondes", + backendIdleTimeoutTitle: "Délai d'inactivité du backend" + }, + customEndpoints: { + active: 'Actif', + apiKeySet: 'Clé API définie', + use: 'Utiliser', + editTitle: 'Modifier le point de terminaison', + addTitle: 'Ajouter un point de terminaison', + fields: { + name: 'Nom', + providerId: 'ID du fournisseur', + endpointUrl: 'URL du point de terminaison', + defaultModel: 'Modèle par défaut', + context: 'Contexte', + apiKey: 'Clé API', + apiKeyNewPlaceholder: 'Laissez vide pour conserver la clé actuelle', + apiKeyPlaceholder: 'Facultatif', + useNewChats: 'Utiliser pour les nouvelles conversations', + discoverModels: 'Découvrir les modèles' + }, + test: 'Tester', + save: 'Enregistrer', + newEndpoint: 'Nouveau point de terminaison', + apiMode: 'Mode API', + autoDetect: 'Détection automatique', + couldNotLoad: 'Impossible de charger les points de terminaison personnalisés', + endpointSaved: 'Point de terminaison personnalisé enregistré.', + saveFailed: 'Échec de l’enregistrement', + endpointReachable: 'Le point de terminaison est joignable.', + endpointReachableTransport: transport => `Le point de terminaison est joignable (route ${transport} servie).`, + endpointReachableModels: (reachable, count) => + `${reachable} ${count} modèle${count > 1 ? 's' : ''} trouvé${count > 1 ? 's' : ''}.`, + endpointValidationFailed: 'La validation du point de terminaison a échoué.', + validationFailed: 'Échec de la validation', + activationFailed: 'Échec de l’activation', + deleteConfirm: name => `Supprimer ${name} ?`, + deleteFailed: 'Échec de la suppression', + title: 'Points de terminaison personnalisés', + deleteEndpoint: 'Supprimer le point de terminaison', + emptyDescription: 'Ajoutez ci-dessous un point de terminaison compatible OpenAI.', + emptyTitle: 'Aucun point de terminaison personnalisé', + namePlaceholder: 'Proxy Axet', + contextPlaceholder: 'Auto' + }, + computerUse: { + accessibility: 'Accessibilité', + screenRecording: "Enregistrement de l'écran", + driverHealth: 'État du pilote' + }, + about: { + updates: 'Mises à jour' + }, + config: { + minimizeToTrayTitle: 'Réduire dans la barre d’état', + minimizeToTrayDesc: + 'Réduire les fenêtres ou fermer la fenêtre principale les masque dans la zone de notification (barre des menus sur macOS) et Hermes continue de s’exécuter. Utilisez Quitter Hermes dans le menu de la zone de notification ou Cmd+Q pour quitter. Désactivé par défaut ; s’applique uniquement à cet appareil.', + minimizeToTrayUnavailable: + 'La zone de notification est indisponible. Les fenêtres seront réduites et fermées normalement. Désactivez puis réactivez cette option pour réessayer.', + none: 'Aucun', + noneParen: '(aucun)', + builtinOnly: 'Intégré uniquement', + notSet: 'Non défini', + commaSeparated: 'valeurs séparées par des virgules', + searchPlaceholder: 'Rechercher…', + noResults: 'Aucun résultat trouvé', + systemDefault: 'Par défaut du système', + loading: 'Chargement de la configuration Hermes...', + emptyTitle: 'Rien à configurer', + emptyDesc: 'Cette section ne contient aucun paramètre ajustable.', + failedLoad: 'Échec du chargement des paramètres', + autosaveFailed: "Échec de l'enregistrement automatique", + imported: 'Configuration importée', + invalidJson: 'JSON de configuration invalide', + toolsetsWipeConfirm: + "Retirer tous les ensembles d'outils activés ? Cela désactive la mémoire, le terminal, la recherche web, la délégation et la plupart des autres outils jusqu'à leur réactivation.", + keepAwakeTitle: "Garder l'ordinateur éveillé", + keepAwakeDesc: + "Empêcher cette machine de se mettre en veille pendant les exécutions longues ou nocturnes. L'écran peut toujours s'obscurcir.", + disableF12Title: 'Désactiver les outils de développement avec F12', + disableF12Desc: + "Empêcher F12 d'ouvrir les outils de développement. Ctrl+Maj+I (ou Cmd+Option+I sur Mac) continue de fonctionner.", + attachmentSizeTitle: 'Taille maximale de chargement des aperçus / images', + attachmentSizeDesc: + "Taille maximale d'un fichier local que Desktop chargera pour les aperçus et les pièces jointes image, en Mo. La valeur par défaut est 16. Les pièces jointes distantes non-image utilisent une limite distincte de 256 Mo. Une valeur très élevée charge le fichier entier en mémoire et peut figer ou planter l'application.", + attachmentSizeUnit: 'Mo', + attachmentSizeLabel: 'Taille maximale de chargement des aperçus / images en mégaoctets', + showOptions: 'Afficher les options' + }, + hudModifier: { + title: 'Toucher pour afficher le HUD', + description: + 'Appuyez puis relâchez ⌘ + Option sur Mac, ou Ctrl + Alt sous Windows/Linux, pour afficher le HUD depuis n’importe quelle application. Désactivé par défaut ; s’applique uniquement à cet appareil.', + permission: + 'Autorisez Hermes dans Réglages Système → Confidentialité et sécurité → Surveillance de l’entrée, puis réessayez. Ce geste n’enregistre pas les frappes et ne capture pas votre écran.', + unavailable: + 'L’assistant du geste HUD n’a pas pu démarrer ou s’est arrêté de manière inattendue. Réessayez ou redémarrez Hermes. Le raccourci HUD existant fonctionne toujours dans Hermes.', + missingHelper: + 'Il manque l’assistant du geste HUD dans cette installation de Hermes. Mettez à jour ou réinstallez Hermes, puis réessayez.', + unsupportedSession: + 'Cette session de bureau ne prend pas en charge les appuis globaux sur les touches de modification. Linux nécessite X11 ; Wayland n’est pas pris en charge.' + }, + screenshot: { + enabledTitle: "Raccourci de capture d'écran", + enabledDesc: + "Appuyez simultanément sur les deux touches Commande depuis n'importe quelle application pour capturer sa fenêtre au premier plan et la joindre au brouillon Hermes actuel. Rien n'est envoyé automatiquement. Désactivé par défaut et limité à ce Mac. Le contenu peut être sensible : vérifiez la pièce jointe avant l'envoi.", + statusTitle: "État du raccourci de capture d'écran", + checking: "Vérification du raccourci de capture d'écran…", + disabled: "Le raccourci de capture d'écran est désactivé.", + starting: "Démarrage de l'écouteur du raccourci ; il n'est pas encore prêt.", + ready: "Le raccourci est prêt. Les captures d'écran sont jointes au brouillon actuel sans être envoyées.", + inputPermission: + "L'autorisation Surveillance de l'entrée permet à Hermes de détecter les deux touches Commande lorsqu'une autre application est active. Autorisez Hermes dans Réglages Système → Confidentialité et sécurité → Surveillance de l'entrée, puis réessayez.", + screenPermission: + "L'autorisation Enregistrement de l'écran permet à Hermes de capturer la fenêtre au premier plan. Autorisez Hermes dans Réglages Système → Confidentialité et sécurité → Enregistrement de l'écran, puis réessayez. Redémarrez Hermes si macOS le demande.", + openSettings: 'Ouvrir les Réglages Système', + retry: 'Réessayer', + unavailable: "Le raccourci de capture d'écran est indisponible. Réessayez ou désactivez-le.", + errorTitle: "Erreur du raccourci de capture d'écran", + loadFailed: "Impossible de lire l'état du raccourci. Réessayez pour vérifier son réglage actuel.", + saveFailed: 'Impossible de confirmer la modification du raccourci. Réessayez pour vérifier son réglage actuel.', + permissionFailed: + "Impossible d'ouvrir les Réglages Système. Ouvrez manuellement Confidentialité et sécurité, puis réessayez.", + captureFailed: "Impossible de capturer la fenêtre au premier plan. Rien n'a été joint ni envoyé.", + contextChanged: "Le brouillon actuel a changé pendant la capture. L'image n'a pas été jointe ni envoyée." + }, + quickEntry: { + enabledTitle: 'Saisie rapide', + enabledDesc: + "Faites apparaître un petit compositeur depuis n'importe où avec un raccourci global et envoyez une invite sans ouvrir Hermes.", + shortcutTitle: 'Raccourci de saisie rapide', + shortcutDesc: 'Nécessite au moins un modificateur, par ex. CommandOrControl+Shift+Espace.', + active: 'Le raccourci est actif.', + takenBy: 'Une autre application utilise déjà ce raccourci — choisissez-en un autre.', + invalidShortcut: 'Raccourci invalide. Incluez au moins une touche modificateur.' + }, + credentials: { + pasteKey: 'Coller la clé', + pasteLabelKey: label => `Coller la clé ${label}`, + optional: 'Facultatif', + enterValueFirst: "Saisissez d'abord une valeur.", + couldNotSave: "Impossible d'enregistrer l'identifiant.", + remove: 'Supprimer', + getKey: 'Obtenir une clé', + saving: 'Enregistrement' + }, + envActions: { + actions: 'Actions', + manageInKeys: 'Gérer dans les clés API', + docs: 'Documentation', + hideValue: 'Masquer la valeur', + revealValue: 'Afficher la valeur', + replace: 'Remplacer', + set: 'Définir', + clear: 'Effacer' + }, + connections: { + title: 'Connexions', + intro: + 'Enregistrez tous les emplacements où vivent vos agents : cet appareil, les gateways distantes de votre réseau et les instances Hermes Cloud. Ils sont tous conservés ici.', + stagedNote: + "Les conversations et la liste des agents suivent la source choisie ; le backend de fenêtre géré par l'application reste sélectionné dans Paramètres → Gateway.", + launchModeTitle: 'Au démarrage, revenir aux sessions de la dernière gateway utilisée', + launchModeDesc: "Si cette option est désactivée, les sessions s'ouvrent sur la gateway principale.", + searchPlaceholder: 'Rechercher des gateways…', + noSearchResults: 'Aucune gateway ne correspond à votre recherche.', + loadFailed: 'Impossible de charger les connexions', + currentPill: 'Actuelle', + primaryPill: 'Principale', + managedPill: 'Cet appareil', + addConnection: 'Ajouter une connexion', + editConnection: 'Modifier', + removeConnection: 'Supprimer', + removeConfirmTitle: 'Supprimer cette connexion ?', + removeConfirmDesc: label => + `« ${label} » sera supprimée de cette application. L'instance elle-même ne sera pas modifiée ; vous pourrez la rajouter à tout moment.`, + makePrimary: 'Définir comme principale', + testConnection: 'Tester', + testOk: 'Accessible', + testFailed: 'Le test de connexion a échoué', + saveFailed: "Impossible d'enregistrer la connexion", + removeFailed: 'Impossible de supprimer la connexion', + updateAll: 'Mettre à jour toutes les instances', + updateAllRunning: 'Mise à jour de toutes les instances…', + updateAllDone: 'Mises à jour envoyées', + updateAllFailed: "L'envoi groupé des mises à jour a échoué", + updateSkippedCloud: 'Gérée par Hermes Cloud', + kindLocal: 'Locale', + kindRemote: 'Gateway distante', + kindCloud: 'Hermes Cloud', + kindSsh: 'SSH', + kindLocalDesc: "L'environnement Hermes géré par cette application.", + kindRemoteDesc: 'Une gateway Hermes accessible en HTTP(S), par le LAN, Tailscale ou Internet.', + kindCloudDesc: 'Une instance hébergée découverte via votre compte Hermes Cloud.', + kindSshDesc: 'Une installation Hermes accessible en SSH.', + labelTitle: 'Nom', + labelDesc: + 'Obligatoire. Affiché partout où cette instance apparaît et nécessairement unique (par exemple « Homelab » ou « PC professionnel »).', + labelPlaceholder: 'Homelab', + urlTitle: 'URL de la gateway', + sshHostTitle: 'Hôte SSH', + headersTitle: 'En-têtes supplémentaires du gateway', + headersDesc: + "Envoyés avec chaque requête HTTP et WebSocket vers ce gateway, notamment pour les proxys d'accès comme Cloudflare Access (CF-Access-Client-Id / CF-Access-Client-Secret). Les valeurs sont stockées chiffrées. Les en-têtes gérés par Hermes (Authorization, Cookie, Host…) sont ignorés.", + headerValuePlaceholder: 'Valeur', + headerValueSaved: 'Enregistrée — laissez vide pour la conserver', + headerAdd: 'Ajouter un en-tête', + headerRemove: 'Supprimer', + duplicateLocal: "Cette application gère déjà une connexion locale — il ne peut y en avoir qu'une.", + duplicateUrl: label => `Une connexion à cette URL de gateway existe déjà (« ${label} »).`, + duplicateSsh: label => `Une connexion à cet hôte SSH existe déjà (« ${label} »).`, + sameBackendHint: label => `Même backend que « ${label} »`, + localAddHint: + "La connexion locale est indisponible : la connexion gérée existe déjà (il ne peut y en avoir qu'une).", + cloudAddHint: + "Astuce : connectez-vous à Hermes Cloud ci-dessus pour découvrir automatiquement vos agents — utilisez ce formulaire uniquement pour enregistrer manuellement l'URL d'une instance connue.", + save: 'Enregistrer la connexion', + saving: 'Enregistrement…', + cancel: 'Annuler', + empty: 'Aucune connexion enregistrée.' + }, + managedUpdates: { + title: 'Mises à jour gérées', + intro: + 'Mettez à jour de manière transactionnelle les installations SSH gérées par le Desktop : les sessions actives sont laissées se terminer, le dépôt distant est mis à jour, puis chaque profil est restauré avec un reçu corrélé.', + sshConnection: 'Installation SSH gérée par le Desktop', + update: 'Mettre à jour', + updating: 'Mise à jour…', + progress: 'Fin des sessions actives, mise à jour de l’installation distante et restauration des profils…', + updated: 'Mise à jour terminée', + partial: 'Mise à jour terminée — échec de la restauration', + refused: 'Refusée', + failed: 'Échec de la mise à jour', + alreadyRunning: 'Une mise à jour est déjà en cours', + receipt: (id: string, outcome: string) => `Reçu ${id} · ${outcome}`, + receiptVersions: (pre: string, post: string) => `${pre} → ${post}`, + scopesRestored: (profiles: string) => `Profils restaurés : ${profiles}`, + scopeNotRestored: (profile: string, error: string) => `Profil « ${profile} » non restauré : ${error}` + }, + gateway: { + loading: 'Chargement des paramètres du gateway...', + unavailableTitle: 'Paramètres du gateway indisponibles', + unavailableDesc: "Le pont IPC du desktop n'expose pas les paramètres du gateway.", + title: 'Connexion au gateway', + envOverride: "remplacement par variable d'environnement", + intro: + 'Local par défaut. Utilisez distant quand cette application doit piloter un backend Hermes ailleurs. Remplacements par profil ci-dessous.', + envOverrideTitle: "Des variables d'environnement contrôlent cette session desktop.", + envOverrideDesc: + 'Supprimez les variables HERMES_DESKTOP_REMOTE_URL et HERMES_DESKTOP_REMOTE_TOKEN pour utiliser le paramètre enregistré ci-dessous.', + modeTitle: 'Mode de connexion', + localTitle: 'Gateway local', + localDesc: + "Démarrer un backend Hermes privé sur localhost. C'est la valeur par défaut et cela fonctionne hors ligne.", + remoteTitle: 'Gateway distant', + remoteDesc: 'Connecter ce shell desktop à un backend Hermes distant.', + remoteAuthHint: + "Les gateways hébergés utilisent OAuth ou un nom d'utilisateur et un mot de passe ; les auto-hébergés peuvent utiliser un jeton de session.", + cloudTitle: 'Hermes Cloud', + cloudDesc: + "Connectez-vous une fois à Hermes Cloud et choisissez parmi les agents de votre compte — pas d'URL à coller.", + cloudSignInTitle: 'Hermes Cloud', + cloudSignIn: 'Se connecter à Hermes Cloud', + cloudSignedIn: 'Connecté à Hermes Cloud', + cloudNeedsSignIn: 'Connectez-vous à Hermes Cloud pour découvrir les agents de votre compte.', + cloudSignedInDesc: + 'Vous êtes connecté. Choisissez un agent ci-dessous ; la session se rafraîchit automatiquement.', + cloudAgentsTitle: 'Vos agents', + cloudOrgPickerTitle: 'Choisir une organisation', + cloudOrgSelect: 'Sélectionner', + cloudOrgChange: "Changer d'organisation", + cloudOrgRole: role => `Rôle : ${role}`, + cloudLoadingAgents: 'Chargement de vos agents…', + cloudNoAgents: { + before: 'Aucun agent trouvé sur ce compte… Créez-en un dans le ', + linkText: 'portail Nous', + after: ', puis actualisez.' + }, + cloudRefresh: 'Actualiser', + cloudConnect: 'Connecter', + cloudSavedTitle: 'Gateways Cloud enregistrés', + cloudSavedDesc: + 'Utilisez un gateway enregistré sans modifier celui par défaut. Connectez-vous ci-dessous pour ajouter des instances. Gérez leurs noms et leur connexion dans la liste des connexions enregistrées.', + cloudUseSaved: 'Utiliser le gateway', + cloudActive: 'Actif dans cette fenêtre', + cloudConnecting: 'Connexion…', + cloudDiscoverFailed: 'Impossible de charger vos agents Hermes Cloud', + cloudConnectFailed: 'Impossible de se connecter à cet agent', + cloudSignInFailed: 'Échec de la connexion à Hermes Cloud', + cloudSignedOutTitle: 'Déconnecté de Hermes Cloud', + cloudSignedOutMessage: 'Session Hermes Cloud effacée.', + cloudConnectedTitle: 'Connecté', + cloudConnectedPill: 'Connecté', + cloudConnectedTo: name => `Connecté à ${name}.`, + cloudAgentProvisioning: 'Provisionnement…', + cloudStatusLabel: status => `État : ${status}`, + remoteUrlTitle: 'URL distante', + remoteUrlDesc: + 'URL de base du backend de tableau de bord distant. Les préfixes de chemin sont pris en charge, par exemple /hermes.', + probing: "Vérification de la méthode d'authentification de ce gateway…", + probeError: + "Impossible d'atteindre ce gateway pour le moment. Vérifiez l'URL — la méthode d'authentification apparaîtra une fois qu'il répondra.", + signedIn: 'Connecté', + signIn: 'Se connecter', + signOut: 'Se déconnecter', + signInWith: provider => `Se connecter avec ${provider}`, + authTitle: 'Authentification', + authSignedInPassword: + "Ce gateway utilise un nom d'utilisateur et un mot de passe. Vous êtes connecté ; la session se rafraîchit automatiquement.", + authSignedInOauth: 'Ce gateway utilise OAuth. Vous êtes connecté ; la session se rafraîchit automatiquement.', + authNeedsPassword: + "Ce gateway utilise un nom d'utilisateur et un mot de passe. Connectez-vous pour autoriser cette application desktop.", + authNeedsOauth: provider => + `Ce gateway utilise OAuth. Connectez-vous avec ${provider} pour autoriser cette application desktop.`, + tokenTitle: 'Jeton de session', + tokenDesc: + "Le jeton de session du tableau de bord utilisé pour l'accès REST et WebSocket. Laissez vide pour conserver le jeton enregistré.", + existingToken: value => `Jeton existant ${value}`, + savedToken: 'enregistré', + pasteSessionToken: 'Coller le jeton de session', + plainTextConfirmTitle: 'Stocker le jeton du gateway en clair ?', + plainTextConfirmDesc: + 'Aucun service de trousseau système n’a été trouvé sur cette machine : le jeton serait donc enregistré sans chiffrement dans le fichier des paramètres de connexion de l’application, lisible par tout processus exécuté par cet utilisateur. Installez ou activez le trousseau de votre système (GNOME Keyring ou KWallet sous Linux) pour un stockage chiffré.', + plainTextConfirmAction: 'Enregistrer en clair', + plainTextStoredTitle: 'Jeton stocké en clair', + plainTextStoredDesc: + 'Le stockage sécurisé est indisponible : le jeton enregistré est donc stocké sans chiffrement dans le fichier des paramètres de connexion de l’application sur cette machine. Installez ou activez le trousseau de votre système (GNOME Keyring ou KWallet sous Linux) pour le chiffrer.', + keychainEncryptionTitle: 'Chiffrer les secrets enregistrés avec le trousseau du système', + keychainEncryptionDesc: + "Désactivé par défaut. Une fois activé, les jetons du gateway et les identifiants de connexion sont chiffrés avec le trousseau du système (Trousseaux d'accès, GNOME Keyring ou Windows DPAPI) ; le système peut demander une autorisation ou un mot de passe. Sinon, ils sont stockés dans des fichiers ordinaires lisibles uniquement par votre compte utilisateur.", + keychainEncryptionFailed: 'Impossible de modifier le chiffrement des secrets', + testRemote: 'Tester le distant', + saveForRestart: 'Enregistrer pour le prochain redémarrage', + saveAndReconnect: 'Enregistrer et se reconnecter', + diagnostics: 'Diagnostiques', + diagnosticsDesc: + 'Afficher desktop.log dans votre gestionnaire de fichiers — utile quand le gateway ne démarre pas.', + openLogs: 'Ouvrir les journaux', + incompleteTitle: 'Gateway distant incomplet', + incompleteSignIn: 'Saisissez une URL distante et connectez-vous avant de passer en distant.', + incompleteToken: 'Saisissez une URL distante et un jeton de session avant de passer en distant.', + incompleteSignInTest: 'Saisissez une URL distante et connectez-vous avant de tester.', + incompleteTokenTest: 'Saisissez une URL distante et un jeton de session avant de tester.', + enterUrlFirst: "Saisissez d'abord une URL distante.", + restartingTitle: 'Reconnexion du gateway', + savedTitle: 'Paramètres du gateway enregistrés', + restartingMessage: 'Hermes Desktop va se reconnecter avec les paramètres enregistrés — le shell reste ouvert.', + savedMessage: 'Enregistré pour le prochain redémarrage.', + connectedTo: (baseUrl, version) => `Connecté à ${baseUrl}${version ? ` · Hermes ${version}` : ''}`, + reachableTitle: 'Gateway distant accessible', + signedOutTitle: 'Déconnecté', + signedOutMessage: 'Session du gateway distant effacée.', + failedLoad: 'Échec du chargement des paramètres du gateway', + signInFailed: 'Échec de la connexion', + signOutFailed: 'Échec de la déconnexion', + testFailed: 'Échec du test du gateway distant', + applyFailed: "Impossible d'appliquer les paramètres du gateway", + saveFailed: "Impossible d'enregistrer les paramètres du gateway", + sshTitle: 'Se connecter via SSH', + sshDesc: + "Hermes est lancé sur la machine distante via SSH et acheminé vers cette application — rien à démarrer ou exposer soi-même. Nécessite un accès SSH par clé fonctionnel vers l'hôte.", + sshTrustHint: + "La première clé d'hôte présentée est approuvée et fixée ; les changements ultérieurs échouent en mode fermé.", + sshHostTitle: 'Hôte', + sshHostDesc: 'utilisateur@hôte, ou un alias Host de ~/.ssh/config.', + sshHostPick: 'Sélectionner un hôte…', + sshHostPickTitle: 'Hôte', + sshHostPickDesc: 'Un alias Host de ~/.ssh/config, ou Personnalisé pour en saisir un.', + sshHostCustom: 'Personnalisé (saisie manuelle)…', + sshUserTitle: 'Utilisateur', + sshUserDesc: 'Vide = ~/.ssh/config ou votre utilisateur actuel.', + sshUserPlaceholder: 'de ~/.ssh/config', + sshPortTitle: 'Port', + sshPortDesc: 'Vide = 22 ou le port de ~/.ssh/config.', + sshKeyTitle: "Fichier d'identité", + sshKeyDesc: 'Chemin de la clé privée. Vide = ssh-agent ou ~/.ssh/config.', + sshHermesPathTitle: 'Chemin Hermes (facultatif)', + sshHermesPathDesc: 'Chemin complet vers le binaire hermes distant. Vide = détection automatique.', + sshHermesPathPlaceholder: 'détection automatique', + sshTestConnection: 'Tester SSH', + sshConnect: 'Se connecter', + sshButtonsHint: "Enregistrer s'applique au prochain lancement. Connecter se reconnecte immédiatement.", + sshReachable: (host, platform) => `Accessible : ${host} (${platform}) — Hermes trouvé`, + sshIncompleteHost: 'Saisissez un hôte SSH avant de vous connecter.', + sshErrUnreachable: "Impossible d'atteindre cet hôte via SSH. Vérifiez l'hôte, le port et votre réseau.", + sshErrAuth: + "Échec de l'authentification SSH. Chargez votre clé dans ssh-agent (ssh-add) ou définissez un IdentityFile dans ~/.ssh/config — Hermes exécute ssh de manière non interactive.", + sshErrHostKey: + "La clé de l'hôte a changé depuis votre dernière connexion. Vérifiez que ce changement est attendu, puis exécutez ssh-keygen -R et reconnectez-vous.", + sshErrNotInstalled: + "Hermes n'est pas installé sur l'hôte distant. Installez-le là-bas (curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh) ou définissez le chemin Hermes.", + sshErrPlatform: + 'Plateforme distante non prise en charge. Le mode SSH de Hermes Desktop supporte les hôtes distants Linux, macOS et Windows.', + sshErrTimeout: "Expiration de la connexion SSH. L'hôte peut être inaccessible ou en veille.", + sshErrUpdateRequired: "Mettez à jour Hermes sur l'hôte distant avant de vous connecter avec Desktop SSH.", + sshErrUnknown: 'Échec de la connexion SSH.' + }, + keys: { + loading: 'Chargement des clés API et des identifiants...', + failedLoad: 'Échec du chargement des clés API', + empty: 'Rien configuré dans cette catégorie pour le moment.' + }, + search: { + placeholder: 'Rechercher dans tous les paramètres…', + pill: 'Rechercher' + }, + profileScope: { + appliesTo: "S'applique à", + editsProfile: profile => `Les modifications de cette page s'appliquent au profil « ${profile} ».` + }, + mcp: { + loading: 'Chargement des serveurs MCP...', + invalidJson: 'MCP JSON invalide', + saveFailed: "Échec de l'enregistrement", + removeFailed: 'Échec de la suppression', + reloadFailed: 'Échec du rechargement MCP', + savedTitle: 'Serveur MCP enregistré', + savedMessage: name => `${name} s'applique après le rechargement de MCP.`, + disabled: 'désactivé', + name: 'Nom', + serverJson: 'JSON du serveur', + remove: 'Supprimer', + test: 'Tester la connexion', + catalogLoading: 'Chargement du catalogue MCP...', + catalogInstallFailed: name => `Échec de l'installation de ${name}`, + catalogEnvRequired: "Remplissez les valeurs requises avant d'installer.", + capabilitySummary: (tools, prompts, resources) => + `${[`${tools} outils`, ...(prompts ? [`${prompts} invites`] : []), ...(resources ? [`${resources} ressources`] : [])].join(', ')} activés`, + costTokens: tokens => `~${tokens} jetons/appel`, + usage30d: uses => `${uses} utilisations/30 j`, + statusConnecting: 'Connexion…', + statusNeedsAuth: 'Nécessite une authentification', + statusError: 'Erreur', + statusOff: 'Désactivé', + allServers: 'Tous les serveurs', + authenticatedTitle: 'Authentifié', + authenticatedMessage: (server, count) => `${server} : ${count} outils`, + authenticate: 'Authentifier', + noOutput: 'Aucune sortie pour le moment.', + deepLinkTitle: 'Ajouter un serveur MCP ?', + deepLinkDescription: + "Un lien demande d'ajouter ce serveur MCP à Hermes. Vérifiez attentivement la configuration ci-dessous : elle provient du lien, pas de Hermes.", + deepLinkStdioWarning: + 'Ce serveur exécute sur votre machine un processus local avec la commande affichée ci-dessous. Continuez uniquement si vous faites confiance à sa source.', + deepLinkConfirm: 'Ajouter le serveur', + deepLinkNameInvalid: 'Les noms utilisent 1 à 64 lettres, chiffres, points, tirets ou underscores.', + deepLinkNameConflict: name => `Un serveur nommé ${name} existe déjà — choisissez un autre nom ou annulez.`, + deepLinkErrorTitle: "Lien d'installation MCP rejeté", + deepLinkErrorName: 'Le nom du serveur est absent ou invalide dans le lien.', + deepLinkErrorConfig: "La configuration du lien n'est pas un JSON valide encodé en base64.", + deepLinkErrorShape: 'La configuration doit être un objet JSON comportant un champ chaîne `url` ou `command`.', + deepLinkErrorUrl: 'Seules les URL de serveur http:// et https:// sont autorisées.', + deepLinkErrorTooLarge: 'La configuration dépasse la limite de 32 Ko.' + }, + model: { + setupProviderFallback: 'fournisseur', + setUpProvider: name => `Configurer ${name}`, + staleAuxBefore: (count, names) => + count > 1 + ? `${count} tâches auxiliaires (${names}) s'exécutent encore sur ` + : `${count} tâche auxiliaire (${names}) s'exécute encore sur `, + staleAuxAfter: ', et non sur votre modèle principal.', + staleAuxOtherProviders: "d'autres fournisseurs", + moaEnabled: 'Activé', + moaSetDefault: 'Définir par défaut', + moaNewPresetPlaceholder: 'nouveau préréglage', + moaAddPreset: 'Ajouter un préréglage', + customModel: 'Modèle personnalisé…', + customModelPlaceholder: 'ID du modèle', + chooseFromList: 'Choisir dans la liste', + moaDefault: 'Par défaut :', + moaReferenceToggle: (enabled, index) => `${enabled ? 'Désactiver' : 'Activer'} la référence ${index}`, + moaReferenceTitle: index => `Référence ${index}`, + moaAddReference: 'Ajouter un modèle de référence', + loading: 'Chargement de la configuration du modèle...', + appliesDesc: + "S'applique aux nouvelles sessions. Utilisez le sélecteur de modèle dans le compositeur pour changer à chaud la conversation active.", + provider: 'Fournisseur', + model: 'Modèle', + applying: 'Application...', + defaultsLabel: 'Par défaut', + reasoning: 'Raisonnement', + reasoningOff: 'Désactivé', + defaultsFailed: "Échec de l'enregistrement des modèles par défaut", + loadFailed: 'Impossible de charger les modèles', + restartRequired: + "Ce backend exécute encore l'ancien code après une mise à jour. Redémarrez-le pour charger le nouveau code.", + restartBackend: 'Redémarrer le backend', + restartingBackend: 'Redémarrage du backend...', + restartFailed: 'Impossible de redémarrer le backend', + auxiliaryTitle: 'Modèles auxiliaires', + resetAllToMain: 'Tout réinitialiser au principal', + auxiliaryDesc: + "Les tâches d'assistance s'exécutent sur le modèle principal par défaut. Attribuez un modèle dédié à toute tâche pour remplacer.", + setToMain: 'Définir comme principal', + change: 'Modifier', + autoUseMain: 'auto · utiliser le modèle principal', + inheritMainEffort: 'hériter · effort du modèle principal', + providerDefault: '(par défaut du fournisseur)', + fallbackAdd: 'Ajouter un secours', + fallbackEmpty: "Aucun modèle de secours — le modèle par défaut est utilisé sauf en cas d'échec.", + notInCatalog: + "n'est pas dans la liste des modèles de ce fournisseur — les appels peuvent basculer sur un secours.", + moaTitle: "Mélange d'agents", + moaPreset: 'Préréglage', + moaDescription: + "Configurez des préréglages nommés qui apparaissent comme modèles chez le fournisseur Mélange d'agents. L'agrégateur est le modèle actif : il exécute chaque étape de la boucle d'outils et son fournisseur facture presque tout le coût de l'exécution. Par défaut, les modèles de référence ne donnent qu'un avis par tour utilisateur.", + moaAggregator: 'Agrégateur', + moaAggregatorBilled: "modèle actif · facturé pour l'exécution", + moaReferenceHint: 'donne un avis une fois par tour par défaut', + tasks: { + vision: { + label: 'Vision', + hint: "Analyse d'image" + }, + compression: { + label: 'Compression', + hint: 'Compaction de contexte' + }, + skills_hub: { + label: 'Hub de skills', + hint: 'Recherche de skills' + }, + approval: { + label: 'Approbation', + hint: 'Auto-approbation intelligente' + }, + mcp: { + label: 'MCP', + hint: "Routage d'outils MCP" + }, + title_generation: { + label: 'Génération de titre', + hint: 'Titres de session' + }, + review: { + label: 'Révision', + hint: 'Sous-agent de révision /review' + }, + triage_specifier: { + label: 'Précision du triage', + hint: 'Détail des spécifications Kanban' + }, + kanban_decomposer: { + label: 'Décomposition Kanban', + hint: 'Décomposition des tâches' + }, + profile_describer: { + label: 'Description de profil', + hint: 'Descriptions automatiques des profils' + }, + curator: { + label: 'Curateur', + hint: "Revue d'utilisation des skills" + } + } + }, + localModels: { + title: 'Modèles locaux', + runtimeTitle: 'Moteur local', + runtimeReady: backend => `Prêt · ${backend}`, + serverRunning: 'En cours', + runtimeInstalled: 'Moteur llama.cpp installé', + runtimeInstalledDetail: (tag, backend) => + `Build ${tag}, backend ${backend}. Hermes démarre et gère le serveur pour vous.`, + installTitle: 'Installer le moteur local', + installDetail: + "Télécharge le moteur d'inférence llama.cpp (quelques centaines de Mo). Les modèles téléchargés s'exécutent entièrement sur cette machine : aucun compte requis et aucune donnée ne quitte votre ordinateur.", + installAction: 'Installer le moteur', + installing: 'Installation du moteur…', + installFailed: "Échec de l'installation du moteur", + hardwareTitle: 'Cette machine', + hardwareLoading: 'Analyse de votre matériel…', + vram: label => `${label} de mémoire GPU`, + ram: label => `${label} de RAM`, + unifiedMemory: 'Mémoire unifiée', + modelsTitle: 'Modèles', + recommended: 'Recommandé', + recommendedReason: { + 'best-quality-resident': + "Le modèle de meilleure qualité qui tient entièrement dans votre GPU et s'exécute à pleine vitesse. La sélection équilibre qualité et vitesse prévue sur ce matériel.", + 'speed-gated-quality': + 'Un modèle de meilleure qualité tient sur cette machine, mais sa bande passante mémoire le rendrait trop lent. Celui-ci est le meilleur modèle qui reste rapide.', + 'fastest-resident': + "Aucun modèle n'atteint sa pleine vitesse sur ce matériel. Celui-ci s'en approche le plus tout en tenant entièrement dans la mémoire GPU." + }, + noRecommendationTitle: 'Aucune recommandation automatique pour cette machine', + noRecommendationDetail: + "La configuration automatique nécessite un modèle présélectionné qui tient entièrement dans la mémoire GPU ou unifiée. Vous pouvez toujours choisir un modèle ci-dessous ou parcourir d'autres modèles.", + noRecommendationAction: 'Parcourir les modèles', + downloaded: 'Téléchargé', + downloadAction: size => `Télécharger · ${size}`, + downloadProgress: (done, total) => `Téléchargement de ${done} sur ${total}`, + downloadDoneToast: model => `${model} est prêt.`, + installDoneToast: 'Le moteur local est installé et prêt.', + quickstartTitle: 'Exécuter un modèle sur cette machine', + quickstartDetail: (model, size) => + `Un clic configure tout : le moteur local, ${model} (téléchargement de ${size}) et votre modèle par défaut pour les nouvelles conversations. Aucune donnée ne quitte cet ordinateur.`, + quickstartDetailReady: model => + `Un clic définit ${model} comme modèle par défaut pour les nouvelles conversations. Tout s'exécute sur cette machine.`, + quickstartAction: 'Configurer pour moi', + quickstartConfigure: 'Configuration…', + quickstartDoneToast: model => + `${model} est configuré : les nouvelles conversations s'exécutent sur cette machine.`, + quickstartFailed: 'Échec de la configuration du modèle local', + quickstartStageEngine: 'Moteur', + quickstartStageModel: 'Modèle', + quickstartStageFinish: 'Terminer', + useAction: 'Utiliser', + activePill: 'Par défaut', + updateTitle: 'Mise à jour du moteur disponible', + updateDetail: (next, current) => + `Une nouvelle build llama.cpp (${next}) est prête à être installée. Vous utilisez ${current}. Les modèles continuent de fonctionner pendant le téléchargement.`, + updateAction: 'Mettre à jour le moteur', + updating: 'Mise à jour du moteur…', + upToDateTitle: 'Moteur à jour', + upToDateDetail: (tag, backend) => + `llama.cpp ${tag} (${backend}) est en cours d'exécution : il s'agit de la dernière build fournie par Hermes.`, + activeDetail: + "Les nouvelles conversations utilisent ce modèle. Il se charge lors de l'envoi de votre premier message.", + activeNotLoaded: 'Se charge avec votre premier message', + loadedPill: 'En mémoire', + placementResident: 'entièrement sur le GPU', + placementSpilled: 'partiellement dans la RAM', + placementResidentTip: + 'S’exécute entièrement dans la mémoire GPU avec cette fenêtre de contexte, à pleine vitesse.', + placementSpilledTip: + 'Une partie de ce modèle s’exécute depuis la RAM système. Il fonctionne, mais plus lentement. Une build plus compacte ou un contexte plus petit tiendrait entièrement dans le GPU.', + loadingPill: 'Chargement…', + ejectTip: 'Libérer la mémoire GPU (le modèle se rechargera au prochain message)', + ejected: 'Modèle déchargé : mémoire GPU libérée.', + ejectFailed: 'Impossible de décharger le modèle', + stopServer: 'Désactiver', + startServer: 'Activer', + runtimeRunningDetail: + "Le serveur local est en cours d'exécution. Le désactiver libère toute la mémoire GPU et empêche les nouvelles conversations d'utiliser les modèles locaux jusqu'à sa réactivation.", + serverStopped: 'Serveur local arrêté : mémoire GPU libérée.', + serverStarted: 'Serveur local en cours d’exécution.', + serverStopFailed: "Impossible d'arrêter le serveur local", + serverStartFailed: 'Impossible de démarrer le serveur local', + activating: 'Démarrage…', + activateFailed: model => `Impossible de passer à ${model}`, + activateDoneToast: model => `Les nouvelles conversations utilisent ${model}.`, + downloadFailed: model => `Échec du téléchargement de ${model}`, + pillFitsGpu: 'Tient dans votre GPU', + pillUsesRam: 'Utilise la RAM système', + pillTooBig: 'Trop volumineux pour cette machine', + browseTitle: 'Trouver davantage de modèles', + browseHint: + 'Recherchez dans tout Hugging Face. La taille des modèles téléchargés ici est automatiquement adaptée à votre machine, mais ils ne sont pas testés par notre équipe.', + browsePlaceholder: 'Rechercher un modèle par nom ou auteur…', + browseSearching: 'Recherche dans Hugging Face', + browseListing: 'Lecture des fichiers du modèle', + browseShowFiles: 'Afficher les fichiers', + browseRefresh: 'Actualiser', + browseDownloads: 'téléchargements', + browseLikes: "mentions J'aime", + browseGated: 'nécessite une connexion à Hugging Face', + browseNoGguf: 'Aucun fichier de modèle compatible trouvé.', + browseFitUnknown: 'Compatibilité inconnue', + browseAlreadyDownloaded: 'Déjà téléchargé.', + addedByYou: 'Ajouté par vous', + browseDownloadStarted: 'Téléchargement de {name}', + browseDownloadAria: 'Télécharger {name}', + sideloadButton: 'Ajouter un fichier de modèle', + sideloadTitle: 'Choisir un fichier de modèle GGUF', + sideloadDone: '{name} ajouté.', + sideloadAlreadyPresent: 'Déjà présent dans votre bibliothèque.', + pillFullContext: max => `Contexte complet de ${max}`, + pillFullContextTip: 'Utilise dès le départ la fenêtre de contexte complète du modèle', + pillUpTo: max => `Contexte jusqu’à ${max}`, + pillGrowsTip: 'Augmente automatiquement lorsque votre conversation a besoin de plus de place', + pillVision: 'Comprend les images', + deleteAction: 'Supprimer le modèle', + deleteConfirm: model => `Supprimer ${model} du disque ?`, + deleted: model => `${model} supprimé.`, + deleteFailed: 'Échec de la suppression' + }, + billing: { + perMonth: (amount: string) => `${amount}/mois`, + creditsPerMonth: (amount: string) => `${amount} crédits/mois`, + usageLabel: (label: string) => `Utilisation ${label}`, + freeTier: { + signIn: 'Se connecter', + title: 'Vous utilisez l’offre gratuite Nous', + message: 'Connectez-vous avec un compte Nous pour débloquer davantage de modèles et d’outils.', + caption: + 'Fonctionne avec nous/welcome, connecteurs inclus. La connexion conserve vos connecteurs et ajoute les outils qui nécessitent un compte ainsi que tous les autres modèles.', + name: 'Nous · offre gratuite', + footnote: + 'L’offre gratuite n’a ni solde ni rien à payer. Le paiement et l’utilisation apparaissent une fois connecté avec un compte Nous.', + plan: 'Offre gratuite', + model: 'Modèle', + connectors: 'Connecteurs', + included: 'Inclus' + }, + amountValidation: { + reloadTo: 'Recharger jusqu’à', + greaterThanThreshold: 'Le montant de recharge doit être supérieur au seuil.', + decimal: (label: string) => `${label} : saisissez un montant en dollars avec au plus 2 décimales.`, + positive: (label: string) => `${label} : le montant doit être supérieur à 0 $.`, + minimum: (label: string, amount: string) => `${label} : le minimum est de ${amount}.`, + maximum: (label: string, amount: string) => `${label} : le maximum est de ${amount}.` + }, + stepUp: { + openVerification: 'Ouvrir la page de vérification', + dismiss: 'Ignorer', + waiting: 'En attente du lien de vérification…', + verify: 'Vérifier pour continuer', + deniedTitle: 'La vérification n’a pas été approuvée', + deniedBody: 'La vérification s’est terminée sans autoriser les dépenses à distance pour ce terminal.', + successTitle: 'Vérification terminée', + successBody: 'Les dépenses à distance sont autorisées pour ce terminal.' + }, + charge: { + added: (amount?: string) => (amount ? `${amount} $ ajoutés.` : 'Crédits ajoutés.'), + failedTitle: 'Échec du paiement', + unconfirmedTitle: 'Résultat du paiement non confirmé', + unconfirmedBody: (message: string) => + `${message} Le résultat de votre dernier paiement n’est pas confirmé - vérifiez votre solde/historique avant de réessayer.`, + checkTitle: 'Impossible de vérifier le paiement', + checkBody: 'Impossible de vérifier le paiement.', + untrackedTitle: 'Le paiement n’a pas pu être suivi', + untrackedBody: 'Le service de facturation a accepté la demande mais n’a renvoyé aucun identifiant de paiement.', + timeoutTitle: 'Toujours en cours après 5 minutes', + timeoutBody: 'Le paiement peut encore aboutir. Consultez le portail avant de réessayer.', + authenticationRequired: + 'Votre banque exige une vérification (3DS). Terminez-la sur le portail pour finaliser cet achat.', + expired: 'Votre carte a expiré. Mettez-la à jour sur le portail.', + declined: 'Votre carte a été refusée. Essayez une autre carte sur le portail.', + failedBody: (reason: string) => `Le paiement n’a pas abouti (${reason}).` + }, + title: 'Facturation', + preview: 'aperçu', + summary: { + balance: 'Solde', + plan: 'Forfait', + autoRefill: 'Recharge auto' + }, + sections: { + invoices: 'Factures', + plan: 'Forfait', + paymentAndCredits: 'Paiement et crédits', + usage: 'Utilisation' + }, + usage: { + title: 'Utilisation' + }, + buyCredits: { + customAmount: 'Montant de crédits personnalisé', + title: 'Acheter des crédits maintenant', + buyButton: 'Acheter', + processing: 'Traitement… vérification du règlement', + added: (amount: string) => `${amount} ajoutés. Actualisation du solde.`, + retry: 'Réessayer', + openPortal: 'Ouvrir le portail' + }, + plan: { + title: 'Forfaits', + changePlan: 'Changer de forfait', + viewPlans: 'Voir les forfaits', + backAria: 'Retour à la facturation', + current: 'Forfait actuel', + scheduled: 'Planifié', + empty: 'Aucun forfait n’est disponible pour le moment.', + undo: 'Annuler', + undoing: 'Annulation…', + downgrade: 'Rétrograder', + confirmDowngrade: 'Confirmer la rétrogradation', + tryAgain: 'Réessayer', + checkingChange: 'Vérification de ce changement…', + cannotChange: 'Ce changement ne peut pas être effectué ici.', + alreadyOn: (name: string) => `Vous avez déjà le forfait ${name} — rien à changer.`, + notScheduleable: 'Ce changement ne peut pas être planifié ici.', + scheduling: 'Planification…', + cancel: 'Annuler', + effectScheduled: (targetName: string, effectiveAt: string, creditsDelta?: string) => + `Passage à ${targetName} — effectif ${effectiveAt}. Aucun débit maintenant ; vous conservez votre forfait actuel jusque-là.${creditsDelta ? ` Variation des crédits mensuels : ${creditsDelta}.` : ''}` + }, + autoReload: { + threshold: 'Seuil', + thresholdAria: 'Seuil de recharge automatique', + reloadTo: 'Recharger jusqu’à', + reloadToAria: 'Montant cible de la recharge automatique', + turnOffConfirm: 'Désactiver la recharge automatique ?', + turnOff: 'Désactiver', + disable: 'Désactiver', + updated: 'Recharge automatique mise à jour.', + turnedOff: 'Recharge automatique désactivée.', + manage: 'Gérer', + save: 'Enregistrer', + saving: 'Enregistrement…', + cancel: 'Annuler' + }, + state: { + notice: { + loggedOut: { + title: 'Connectez votre compte Nous', + message: 'Exécutez /portal dans la TUI ou ouvrez le portail Nous pour connecter votre compte.', + action: 'Ouvrir le portail ↗' + }, + noCard: { + title: 'Aucun moyen de paiement enregistré', + message: + 'L’achat de crédits et la recharge automatique restent désactivés tant qu’aucune carte n’est enregistrée. Ajoutez-en une sur le portail.', + action: 'Ajouter une carte ↗' + } + }, + paymentMethod: { + title: 'Moyen de paiement', + description: 'Gérez la carte utilisée pour les recharges et les renouvellements d’abonnement.', + addAction: 'Ajouter un moyen de paiement', + updateAction: 'Mettre à jour', + provenance: { + autoRefill: 'carte de recharge automatique', + customerDefault: 'carte client par défaut', + subPin: 'carte d’abonnement', + suffix: (label: string) => ` - ${label}` + } + }, + buyCredits: { + description: 'Un débit unique sur votre carte, ajouté à votre solde dès aujourd’hui.' + }, + autoRefill: { + title: 'Recharger quand le solde est bas', + genericDescription: 'Maintenez votre solde approvisionné lorsqu’il passe sous votre seuil.', + offPill: 'Désactivée', + enabledPill: 'Activée', + notAvailablePill: '—', + manageCaption: 'Gérez la recharge automatique depuis le portail.', + turnOnCaption: 'Activez la recharge automatique depuis le portail', + chargesDescription: (reloadTo: string, threshold: string) => + `Débite automatiquement ${reloadTo} lorsque votre solde passe sous ${threshold}.`, + distinctCardCaption: (cardLabel: string) => + `La recharge automatique débite ${cardLabel} — harmonisez sur le portail`, + distinctCardFallback: 'une autre carte', + reconcileAction: 'Harmoniser ↗' + }, + usage: { + subscriptionCredits: { + title: 'Crédits d’abonnement', + barLabel: 'Crédits d’abonnement restants', + captionResets: (date: string) => `Réinitialisation ${date}`, + valueOf: (remaining: string, monthly: string) => `${remaining} sur ${monthly} restants`, + valueOver: (remaining: string, monthly: string, over: string) => + `${remaining} sur ${monthly} restants · ${over} de dépassement` + }, + topupCredits: { + title: 'Crédits rechargés', + caption: 'N’expirent pas' + }, + monthlyCap: { + title: 'Plafond de dépenses mensuel', + barLabel: 'Plafond de dépenses mensuel utilisé', + captionDefault: 'Plafond par défaut', + captionSpending: 'Dépenses mensuelles à distance', + valueUsed: (spent: string, limit: string) => `${spent} sur ${limit} utilisés` + } + }, + planCard: { + freeTier: 'Gratuit', + chooseAction: 'Choisir ↗', + adjustPlanAction: 'Ajuster le forfait ↗', + unavailableCaption: + 'Les détails de l’abonnement sont indisponibles ; vous pouvez toujours ouvrir le portail.', + downgradeCaption: (tierName: string, when: string) => `Passe à ${tierName} le ${when}.`, + cancellationCaption: (when: string) => `Résiliation le ${when}.`, + renewsCaption: (date: string) => `Renouvellement ${date}`, + noSubscriptionCaption: 'Aucun abonnement actif — les modèles payants consomment les crédits rechargés.' + } + }, + errors: { + consentRequired: { + title: 'Confirmation de la carte requise', + message: 'Confirmez cette carte pour les débits du terminal dans le portail' + }, + insufficientScope: { + title: 'Les dépenses à distance doivent être approuvées', + message: + 'Cette action nécessite l’autorisation des dépenses à distance. Lancez une recharge pour l’autoriser, puis réessayez.' + }, + remoteSpendingRevoked: { + title: 'Les dépenses à distance ont été arrêtées', + messageByAdmin: 'Un administrateur a arrêté les dépenses à distance pour ce terminal.', + messageBySelf: 'Vous avez arrêté les dépenses à distance pour ce terminal.' + }, + remoteSpendingReconnect: (who: string) => + `${who} Reconnectez-vous depuis Paramètres -> Gateway pour réautoriser cet appareil.`, + sessionRevoked: { + title: 'Session déconnectée', + message: 'Votre session a été déconnectée. Reconnectez-vous depuis Paramètres → Gateway.' + }, + cliBillingDisabled: { + title: 'Les dépenses à distance sont désactivées', + message: + 'Les dépenses à distance sont désactivées pour ce compte — un administrateur de facturation peut les activer depuis la page Hermes Agent du portail.' + }, + roleRequired: { + title: 'Rôle administrateur requis', + message: + 'L’ajout de fonds nécessite un administrateur ou propriétaire de l’organisation. Demandez à un administrateur, ou gérez-le sur le portail.' + }, + idempotencyConflict: { + title: 'Lancez une nouvelle recharge', + message: '🔴 Cette clé de paiement a déjà été utilisée pour un autre montant. Lancez une nouvelle recharge.' + }, + noPaymentMethod: { + title: 'Aucune carte enregistrée', + message: + '💳 Aucune carte enregistrée pour les débits du terminal. Configurez-en une sur le portail ' + + '(les achats ponctuels de crédits n’enregistrent pas de carte réutilisable).' + }, + orgAccessDenied: { + title: 'Accès à l’organisation refusé', + message: 'Ce jeton n’est lié à aucune organisation que vous pouvez gérer' + }, + monthlyCapExceeded: { + title: 'Plafond de dépenses mensuel atteint', + messageReached: '🔴 Plafond de dépenses mensuel atteint.', + messageHeadroom: (remaining: string) => + `🔴 Plafond de dépenses mensuel atteint — marge restante : ${remaining} $.` + }, + rateLimited: { + title: 'Trop de paiements pour le moment', + message: (mins: number) => + mins > 0 + ? `🟡 Trop de paiements pour le moment (réessayez dans ~${mins} min). Il ne s’agit pas d’un échec de paiement.` + : '🟡 Trop de paiements pour le moment. Il ne s’agit pas d’un échec de paiement.' + }, + stripeUnavailable: { + title: 'Stripe rencontre des difficultés', + message: (mins: number) => + mins > 0 + ? `Stripe rencontre des difficultés — réessayez dans ~${mins} min` + : 'Stripe rencontre des difficultés — réessayez sous peu' + }, + upgradeCapExceeded: { + title: 'Limite quotidienne de changements de forfait atteinte', + message: 'Limite quotidienne de changements de forfait atteinte — réessayez demain' + }, + endpointUnavailable: { + title: 'Point de terminaison de facturation indisponible', + message: + 'Le point de terminaison de facturation a renvoyé une réponse non JSON (il n’est peut-être pas disponible sur ce déploiement).' + }, + timeout: { + title: 'Délai de la requête de facturation dépassé', + message: 'Délai de la requête de facturation dépassé.' + }, + transport: { + title: 'Échec de la connexion de facturation', + message: 'La requête de facturation a échoué avant d’atteindre le gateway.' + }, + default: { + title: 'Échec de la requête de facturation', + message: 'La requête de facturation a échoué.' + } + } + }, + providers: { + connectAccount: 'Connecter un compte', + haveApiKey: 'Vous avez une clé API ?', + intro: + "Connectez-vous avec un abonnement — pas de clé API à copier. Hermes lance la connexion navigateur pour vous, directement dans l'application.", + connected: 'Connecté', + collapse: 'Réduire', + connectAnother: 'Connecter un autre fournisseur', + otherProviders: 'Autres fournisseurs', + disconnect: 'Déconnecter', + disconnectInTerminal: 'Déconnecter (exécute la commande de suppression dans le terminal)', + removeConfirm: provider => `Supprimer ${provider} ?`, + removeExternalGeneric: provider => `${provider} est géré par sa propre CLI — supprimez-le là-bas.`, + removeKeyManaged: provider => `${provider} est configuré depuis une clé API. Supprimez-le depuis les clés API.`, + removeTerminalConfirm: (provider, command) => + `Déconnecter ${provider} ? Cela exécute « ${command} » dans le terminal pour effacer l'identifiant.`, + removeTerminalRunning: provider => `Exécution de la déconnexion ${provider} dans le terminal…`, + removedTitle: 'Compte supprimé', + removedMessage: provider => `${provider} a été supprimé.`, + failedRemove: provider => `Impossible de supprimer ${provider}`, + noProviderKeys: 'Aucune clé API de fournisseur disponible.', + searchKeys: 'Rechercher des fournisseurs…', + noKeysMatch: 'Aucun fournisseur ne correspond à votre recherche.', + localEndpoint: { + title: 'Point de terminaison local / personnalisé', + description: + "Pointez Hermes vers n'importe quel point de terminaison compatible OpenAI (Zyphra, vLLM, llama.cpp, Ollama, etc)." + }, + loading: 'Chargement des fournisseurs...' + }, + sessions: { + loading: 'Chargement des sessions archivées…', + archivedTitle: 'Sessions archivées', + archivedIntro: + 'Les conversations archivées sont masquées de la barre latérale mais conservent tous leurs messages. Alt/⌥+Maj/⇧-clic sur une conversation de la barre latérale pour l’archiver.', + emptyArchivedTitle: "Rien d'archivé", + emptyArchivedDesc: 'Archivez une conversation pour la masquer ici.', + unarchive: 'Restaurer', + deletePermanently: 'Supprimer définitivement', + messages: count => `${count} ${count === 1 ? 'message' : 'messages'}`, + restored: 'Restauré', + deleteConfirm: title => `Supprimer définitivement « ${title} » ? Cela ne peut pas être annulé.`, + autoArchiveTitle: 'Auto-archiver les conversations inactives', + autoArchiveDesc: + "Archivez automatiquement les conversations que vous n'avez pas touchées depuis un moment. Les conversations épinglées ne sont jamais archivées et rien n'est supprimé — les conversations archivées se déplacent simplement ici.", + autoArchiveDaysLabel: 'Archiver après', + autoArchiveDaysUnit: "jours d'inactivité", + autoArchiveFailed: "Impossible de mettre à jour l'auto-archivage", + defaultDirTitle: 'Répertoire de projet par défaut', + defaultDirDesc: + 'Les nouvelles sessions commencent dans ce dossier sauf si vous en choisissez un autre. Laissez-le non défini pour utiliser votre répertoire personnel.', + defaultDirUpdated: + "Répertoire de projet par défaut mis à jour — commencez une nouvelle conversation (Ctrl/⌘+N) pour qu'il prenne effet", + defaultsTo: label => `Par défaut : ${label}.`, + change: 'Modifier', + choose: 'Choisir', + clear: 'Effacer', + notSet: 'Non défini', + failedLoad: 'Impossible de charger les sessions archivées', + unarchiveFailed: 'Échec de la restauration', + deleteFailed: 'Échec de la suppression', + updateDirFailed: 'Impossible de mettre à jour le répertoire par défaut', + clearDirFailed: 'Impossible de vider le répertoire par défaut' + }, + toolsets: { + loadingConfig: 'Chargement de la configuration', + savedTitle: 'Identifiant enregistré', + savedMessage: key => `${key} mis à jour.`, + removedTitle: 'Identifiant supprimé', + removedMessage: key => `${key} supprimé.`, + failedSave: key => `Échec de l'enregistrement de ${key}`, + failedRemove: key => `Échec de la suppression de ${key}`, + failedReveal: key => `Échec de l'affichage de ${key}`, + removeConfirm: key => `Supprimer ${key} de .env ?`, + set: 'Définir', + notSet: 'Non défini', + selectedTitle: 'Fournisseur sélectionné', + selectedMessage: provider => `${provider} est maintenant actif.`, + failedSelect: provider => `Échec de la sélection de ${provider}`, + failedLoad: 'Échec du chargement de la configuration des outils', + noProviderOptions: + "Cet ensemble d'outils n'a pas d'options de fournisseur — activez-le et il fonctionne avec votre configuration actuelle.", + noProviders: "Aucun fournisseur n'est disponible pour cet ensemble d'outils pour le moment.", + ready: 'Prêt', + needsSignIn: 'Nécessite une connexion', + needsSetup: 'Nécessite une configuration', + activeBackend: 'Actif', + activeBackendHint: "Il s'agit de votre backend actif", + useBackend: 'Utiliser ce backend', + nousIncluded: 'Inclus avec un abonnement Nous — connectez-vous au portail Nous pour activer.', + nousAuthNeededTitle: 'Se connecter au portail Nous', + nousAuthNeededMessage: provider => + `${provider} est enregistré mais ne s'activera pas tant que vous ne vous serez pas connecté au portail Nous.`, + nousAuthSignIn: 'Se connecter', + nousAuthDoneTitle: 'Portail Nous connecté', + nousAuthDoneMessage: "Vos backends d'abonnement sont maintenant actifs.", + nousAuthFailed: "La connexion au portail Nous n'a pas été terminée", + nousAuthFailedMessage: 'Réessayez.', + nousAuthTryAgain: 'Réessayer', + noApiKeyRequired: 'Aucune clé API requise.', + postSetupHint: step => + `Ce backend nécessite une installation unique (${step}). S'exécute sur cette machine — peut prendre quelques minutes.`, + postSetupInstalledHint: 'Installé. Relancez la configuration uniquement si quelque chose est cassé.', + postSetupRun: 'Exécuter la configuration', + postSetupRerun: 'Relancer la configuration', + postSetupInstalled: 'Installé', + postSetupRunning: 'Installation…', + postSetupStarting: 'Démarrage…', + postSetupCompleteTitle: 'Configuration terminée', + postSetupCompleteMessage: step => `${step} installé.`, + postSetupErrorTitle: 'Configuration terminée avec des erreurs', + postSetupErrorMessage: step => `Consultez le journal ${step}.`, + postSetupOpenLogs: 'Ouvrir les journaux', + postSetupRunAgain: 'Relancer', + postSetupFailed: step => `Échec de l'exécution de la configuration ${step}`, + webSearchActive: backend => `Recherche : ${backend}`, + webExtractActive: backend => `Extraction : ${backend}`, + webCapabilityUnset: 'non défini', + webUseForSearch: 'Utiliser pour la recherche', + webUseForExtract: "Utiliser pour l'extraction", + webUsedForSearch: 'Backend de recherche', + webUsedForExtract: "Backend d'extraction", + webCapabilitySelectedMessage: (provider, capability) => + `${provider} gère maintenant ${capability === 'search' ? 'la recherche web' : 'l’extraction de contenu web'}.`, + failedSelectCapability: provider => `Échec de la définition de ${provider}`, + loadingModels: 'Chargement du catalogue de modèles...', + modelSectionTitle: 'Modèle', + modelCount: count => `${count} modèle${count === 1 ? '' : 's'}`, + modelInUse: 'En utilisation', + modelDefault: 'par défaut', + modelInactiveHint: "Sélectionnez d'abord ce backend pour changer son modèle.", + modelSelectedTitle: 'Modèle sélectionné', + modelSelectedMessage: model => `${model} s'applique aux nouvelles sessions.`, + failedSelectModel: model => `Échec de la sélection de ${model}`, + terminalBackend: { + sectionTitle: "Backend d'exécution", + loading: "Vérification des backends d'exécution…", + failedLoad: 'Impossible de charger les backends de terminal', + ready: 'Prêt', + needsSetup: 'Nécessite une configuration', + unavailable: 'Indisponible', + inUse: 'En utilisation', + selectedTitle: 'Backend sélectionné', + selectedMessage: backend => + `Les commandes de terminal s'exécutent maintenant via ${backend}. S'applique aux nouvelles sessions.`, + failedSelect: backend => `Échec de la sélection de ${backend}`, + needsSetupHint: + 'Ce backend est sélectionné sans configuration complète — les commandes échoueront tant que la configuration n’est pas terminée.', + needsSetupConfirmTitle: (backend: string) => `Sélectionner ${backend} quand même ?`, + needsSetupConfirmDescription: (detail: string) => + `${detail} Les sessions démarrées après ce changement n’auront ni outils de terminal ni outils de fichiers tant que la configuration n’est pas terminée.`, + needsSetupConfirmDescriptionGeneric: + 'Ce backend n’est pas encore configuré. Les sessions démarrées après ce changement n’auront ni outils de terminal ni outils de fichiers tant que la configuration n’est pas terminée.', + needsSetupConfirmAction: 'Sélectionner quand même', + unavailableTitle: 'Commandes de terminal indisponibles', + unavailableMessage: backend => + `Hermes ne peut pas exécuter de commandes shell pour le moment : ${backend} n'est pas prêt. Passez en Local ou terminez la configuration de ${backend}, puis réessayez.`, + openBackendSettings: 'Ouvrir les paramètres du terminal', + useLocal: 'Utiliser Local', + switchedToLocal: + "Les commandes de terminal s'exécutent maintenant localement. S'applique aux nouvelles sessions." + }, + browserRealProfile: { + label: 'Utiliser mon profil de navigateur réel', + description: + "Copie les identifiants de connexion et les cookies de votre navigateur par défaut dans un instantané géré que l'agent utilise pour naviguer. Votre profil actif n'est jamais ouvert directement. S'applique aux nouvelles sessions.", + enabledTitle: 'Navigation avec le profil réel activée', + enabledMessage: 'Les nouvelles sessions utiliseront un instantané de votre profil de navigateur par défaut.', + disabledTitle: 'Navigation avec le profil réel désactivée', + disabledMessage: + "L'instantané du profil sera supprimé ; les nouvelles sessions utiliseront un navigateur vierge.", + failedSave: "Impossible d'enregistrer le paramètre du profil réel", + prompt: { + title: 'Restez connecté à vos sites', + body: "Autorisez Hermes à naviguer avec un instantané de votre profil de navigateur par défaut afin que les sites s'ouvrent avec vos sessions déjà connectées.", + bulletSnapshot: 'Les cookies et identifiants de connexion sont copiés dans un instantané géré.', + bulletLiveProfile: "Votre profil de navigateur actif n'est jamais ouvert directement.", + bulletLocal: 'Rien ne quitte cet ordinateur.', + dontShowAgain: 'Ne plus afficher', + notNow: 'Pas maintenant', + enable: 'Utiliser mon profil' + } + } + } + }, + skills: { + tabSkills: 'Skills', + tabToolsets: 'Outils', + configuringProfile: 'Configuration de :', + all: 'Tout', + searchSkills: 'Rechercher des skills...', + searchToolsets: 'Rechercher des outils...', + refresh: 'Actualiser les skills', + refreshing: 'Actualisation des skills', + loading: 'Chargement des capacités...', + noSkillsTitle: 'Aucun skill trouvé', + noSkillsDesc: 'Essayez une recherche plus large ou une catégorie différente.', + noToolsetsTitle: "Aucun ensemble d'outils trouvé", + noToolsetsDesc: 'Essayez une requête de recherche plus large.', + noDescription: 'Aucune description.', + configured: 'Configuré', + needsKeys: 'Nécessite des clés', + visionModelHint: + "La vision utilise votre configuration de modèle auxiliaire — le modèle capable d'images est choisi là-bas, pas par fournisseur ici.", + visionModelLink: 'Choisissez le modèle de vision dans Paramètres → Modèles', + toolsetsEnabled: (enabled, total) => `${enabled}/${total} ensembles d'outils activés`, + configureToolset: label => `Configurer ${label}`, + toggleToolset: (label, enabled) => `${enabled ? 'Activer' : 'Désactiver'} l'ensemble d'outils ${label}`, + skillsLoadFailed: 'Échec du chargement des skills', + toolsetsRefreshFailed: "Échec de l'actualisation des ensembles d'outils", + skillEnabled: 'Skill activé', + skillDisabled: 'Skill désactivé', + toolsetEnabled: "Ensemble d'outils activé", + toolsetDisabled: "Ensemble d'outils désactivé", + appliesToNewSessions: name => `${name} s'applique aux nouvelles sessions.`, + failedToUpdate: name => `Échec de la mise à jour de ${name}`, + sortMostUsed: 'Plus utilisés', + sortAlpha: 'A–Z', + sortMostUsedDesc: '↓ Plus utilisés', + sortLeastUsedAsc: '↑ Moins utilisés', + enableAll: 'Tout activer', + disableAll: 'Tout désactiver', + disableUnused: 'Désactiver les inutilisés', + bulkUpdated: count => `${count} ${count === 1 ? 'élément' : 'éléments'} mis à jour pour les nouvelles sessions.`, + bulkNoChange: 'Rien à changer.', + usageCount: count => `utilisé ${count}×`, + provenance: { + agent: 'Appris', + bundled: 'Intégrés', + hub: 'Hub' + }, + emptyNoneFound: noun => `Aucun ${noun} trouvé`, + emptyNothingMatches: query => `Rien ne correspond à « ${query} ».`, + emptyNoneAvailable: noun => `Aucun ${noun} disponible pour le moment.`, + changesApplyNewSessions: "Les modifications s'appliquent aux nouvelles sessions.", + skillUpdated: 'Skill mis à jour', + edit: 'Modifier', + archive: 'Archiver', + skillArchivedTitle: 'Skill archivé', + skillArchivedMessage: 'Restaurable via hermes curator restore.', + tabPlugins: 'Plugins', + plugins: { + agentTitle: "Plugins de l'agent", + agentBlurb: + "Étendez l'agent du profil sélectionné avec des outils, hooks et fournisseurs. Les changements prennent effet après le redémarrage du gateway.", + pageBlurb: + 'Un plugin peut étendre cette application, l’agent ou les deux — chaque moitié a son propre interrupteur.', + halfDesktop: 'Desktop', + halfDesktopHint: 'cette application, identique pour tous les profils', + halfAgent: 'Agent', + halfAgentIn: profile => `Agent dans ${profile}`, + defaultProfile: 'Hermes (par défaut)', + kindAgent: 'Agent', + kindDesktop: 'Desktop', + kindBoth: 'Agent + Desktop', + installAgentHere: 'Installer ici', + installAgentHereTip: profile => + `La partie Desktop est chargée dans cette application, mais la partie agent n'est pas installée dans ${profile}. Installez-la dans ce profil.`, + installAgentHereNoOrigin: + "La partie agent n'est pas installée dans ce profil et ce paquet a été copié manuellement, sans entrée de catalogue ni dépôt Git. Copiez son dossier dans le profil ou réinstallez-le depuis Git.", + desktopHalfPending: 'copie…', + desktopHalfPendingTip: + "Ce paquet contient une partie Desktop qui n'a pas encore été copiée dans l'application. Relancez l'analyse ou redémarrez l'application.", + desktopHalfRemote: 'indisponible (backend distant)', + desktopHalfRemoteTip: + 'La moitié bureau de ce paquet se trouve sur le disque du backend distant, que cette application ne peut pas lire. Pour l’utiliser ici, lancez Installer depuis Git avec l’URL du dépôt du paquet et la cible Bureau cochée — cela clone la moitié bureau sur cette machine.', + emptyAll: 'Aucun plugin pour le moment.', + empty: "Aucun plugin d'agent installé pour ce profil.", + emptyHint: 'Parcourez le catalogue ci-dessous pour installer un plugin vérifié en un clic.', + loadFailed: "Impossible de charger les plugins de l'agent", + toggleFailed: name => `Impossible de modifier l'état de ${name}`, + legacyBackend: 'Ce backend est trop ancien pour gérer les plugins depuis cet écran ; mettez Hermes à jour.', + portableBadge: 'portable', + serverStates: { + connected: 'connecté', + app_not_running: 'application non lancée', + endpoint_unavailable: 'point de terminaison indisponible', + no_interactive_session: 'aucune session interactive', + version_too_old: 'version trop ancienne', + missing_app: 'application manquante', + unknown: 'état inconnu' + }, + catalogTitle: 'Catalogue de plugins', + catalogBrowse: 'Parcourir', + catalogHide: 'Masquer le catalogue', + catalogHint: + 'Utilisez « + Ajouter à cet agent » sur un plugin : les entrées vérifiées sont installées depuis leur commit épinglé dans le profil sélectionné.', + alreadyInstalled: (name: string) => `${name} est déjà installé dans ce profil.`, + catalogProvenance: (sha: string) => + `Installé depuis le catalogue Hermes${sha ? ` au commit épinglé ${sha}` : ''}.`, + pinnedProvenance: (sha: string) => + `Épinglé au commit ${sha}. Les mises à jour sont refusées tant qu’il n’est pas réinstallé avec un nouvel épinglage.`, + pinnedBadge: (sha: string) => `épinglé @ ${sha}`, + tierOfficial: 'officiel', + tierCommunity: 'communauté', + updateToPin: (sha: string) => `Mettre à jour vers ${sha}`, + updateFailed: (name: string) => `Impossible de mettre à jour ${name}`, + updated: (name: string) => + `${name} mis à jour vers l’épinglage actuel du catalogue. Redémarrez le gateway pour l’appliquer.`, + updateConsentTitle: (name: string) => `${name} demande davantage`, + updateConsentBody: (name: string, sha: string) => + `Le nouvel épinglage de ${name} dans le catalogue (${sha}) ajoute des surfaces que la version installée n’a pas. Appliquez-le uniquement si vous leur faites confiance :`, + updateConsentConfirm: 'Appliquer la mise à jour', + uninstall: 'Désinstaller', + uninstallTip: (name: string, profile: string) => `Désinstaller ${name} de ${profile}`, + uninstallConfirmTitle: (name: string) => `Désinstaller ${name} ?`, + uninstallConfirmBody: (name: string, profile: string) => + `Cette action supprime les fichiers du plugin du profil ${profile}. Toute moitié bureau fournie est supprimée avec lui. Réinstallez-le depuis le catalogue ou depuis Git à tout moment.`, + uninstallFailed: (name: string) => `Impossible de désinstaller ${name}`, + uninstalled: (name: string) => `${name} désinstallé. Redémarrez le gateway pour le décharger.`, + uninstallDesktopTip: (name: string) => `Désinstaller ${name} de cette application`, + uninstallDesktopConfirmBody: (name: string) => + `Cette action supprime ${name} du dossier desktop-plugins de cet ordinateur et le décharge immédiatement. Réinstallez-le depuis Git ou remettez le dossier en place à tout moment.`, + uninstalledDesktop: (name: string) => `${name} désinstallé.`, + deepLinkErrorTitle: 'Lien d’installation de plugin refusé', + deepLinkCatalogInvalidName: 'Le nom de catalogue du lien est manquant ou invalide.', + deepLinkCatalogUnknown: (name: string) => + `\u00AB\u00A0${name}\u00A0\u00BB ne figure pas dans le catalogue de plugins Hermes. Rien n’a été installé.`, + deepLinkCatalogUnavailable: + 'Impossible de charger le catalogue de plugins Hermes. Vérifiez votre connexion et rouvrez le lien.', + settingsToggle: (name: string) => `Paramètres : ${name}`, + settingsForm: { + save: 'Enregistrer les paramètres', + saved: (name: string) => `Paramètres de ${name} enregistrés.`, + saveFailed: (name: string) => `Impossible d’enregistrer les paramètres de ${name}`, + optional: '(facultatif)', + secretSet: '•••••••• (défini)', + secretStoredAs: (env: string) => + `Stocké dans le .env du profil sous ${env}, jamais dans config.yaml ; laissez vide pour conserver la valeur actuelle.` + } + }, + officialCatalog: "Disponibles à l'installation", + officialPill: 'Officiel', + hub: { + searchPlaceholder: 'Rechercher dans le hub de skills', + search: 'Rechercher', + searching: 'Recherche...', + connectingHubs: 'Connexion aux hubs de skills...', + connectedHubs: 'Hubs connectés :', + featured: 'Skills en vedette', + landingHint: + "Recherchez dans le hub pour parcourir les skills installables depuis l'index officiel, GitHub et les sources communautaires.", + noResults: 'Aucun skill correspondant trouvé dans le hub.', + resultCount: (count, ms) => `${count} résultat${count === 1 ? '' : 's'}${ms !== null ? ` en ${ms} ms` : ''}`, + timedOut: sources => `Expiration : ${sources}`, + installed: 'Installé', + install: 'Installer', + installing: 'Installation...', + uninstall: 'Désinstaller', + uninstalling: 'Désinstallation...', + updateAll: 'Mettre à jour les installés', + updating: 'Mise à jour...', + preview: 'Aperçu', + scan: 'Analyser', + scanning: 'Analyse...', + close: 'Fermer', + files: 'Fichiers', + noReadme: "Ce skill n'a pas d'aperçu SKILL.md.", + trust: { + builtin: 'intégré', + trusted: 'approuvé', + community: 'communauté' + }, + verdictSafe: 'Sûr', + verdictCaution: 'Prudence', + verdictDangerous: 'Dangereux', + policyAllow: 'Installation autorisée', + policyAsk: "Réviser avant d'installer", + policyBlock: 'Installation bloquée par la politique', + findings: count => `${count} résultat${count === 1 ? '' : 's'} de scan`, + noFindings: 'Aucun résultat de scan de sécurité.', + installStarted: name => `Installation de ${name}...`, + uninstallStarted: name => `Désinstallation de ${name}...`, + updateStarted: 'Mise à jour des skills installés...', + actionFailed: "Échec de l'action du skill", + installBlockedTitle: name => `Impossible d'installer ${name}`, + installBlockedMessage: (findings, unverified) => + `Le scan de sécurité a signalé ${findings > 0 ? `${findings} élément${findings === 1 ? '' : 's'}` : 'des motifs risqués'} à examiner${unverified ? " et le skill provient d'une source non vérifiée" : ''}. Consultez le scan avant de décider si vous faites confiance à l'auteur.`, + viewScan: 'Voir le scan', + openLog: 'Ouvrir le journal', + actionLog: "Journal d'action", + alreadyInstalled: name => `« ${name} » est déjà installé`, + pickerTitle: 'Hub de skills', + pickerBrowse: 'Parcourir tout le hub', + pickerHide: 'Masquer le navigateur du hub', + pickerHint: + 'Cliquez sur « + Ajouter à cet agent » pour installer un skill : il apparaîtra ensuite dans la liste ci-dessus.', + loadFailed: 'Échec du chargement du hub de skills', + previewFailed: "Échec de l'aperçu du skill", + scanFailed: 'Échec du scan de sécurité', + searchFailed: 'Échec de la recherche dans le hub' + } + }, + starmap: { + title: 'Graphique de mémoire', + subtitle: (nodes, clusters) => `${nodes} skills dans ${clusters} catégories`, + close: 'Fermer le graphique de mémoire', + refresh: 'Actualiser', + memory: 'Mémoire', + filterAll: 'Tout', + filterUsed: 'Utilisés', + filterLearned: 'Appris', + viewGraph: 'Graphique', + loadFailed: 'Impossible de charger le graphique de mémoire', + loading: 'Chargement…', + emptyTitle: "Rien d'appris pour le moment", + emptyDesc: + 'Au fur et à mesure que Hermes construit des skills et des mémoires pour votre travail, ils apparaissent ici.', + share: 'Partager la carte', + shareHint: + 'Copiez le code pour partager cette carte, ou collez-en un pour le charger. Il inclut uniquement la disposition, pas votre texte de mémoire ou de skill.', + shareTitle: 'Importer / exporter la carte', + sharePlaceholder: 'Collez un code de carte…', + copy: 'Copier le code de la carte', + copied: 'Copié !', + importMap: 'Importer une carte', + importBtn: 'Charger', + importEmpty: 'Collez un code de carte pour le charger.', + importSuccess: nodes => `Carte chargée avec ${nodes} ${nodes === 1 ? 'nœud' : 'nœuds'}.`, + importedBadge: 'carte importée', + resetToMine: 'Retour à ma carte' + }, + agents: { + extendedTranscript: 'Transcription étendue', + transcriptTruncated: 'Affichage des 16 Kio les plus récents', + transcriptUnavailable: 'Transcription en direct indisponible', + close: 'Fermer les agents', + title: 'Arbre de création', + subtitle: 'Activité en direct des sous-agents pour le tour en cours.', + emptyTitle: 'Aucun sous-agent en direct', + emptyDesc: 'Quand un tour délègue du travail, les sous-agents diffusent leur progression ici.', + running: 'En cours', + failed: 'Échoué', + done: 'Terminé', + streaming: 'Diffusion', + files: 'Fichiers', + moreFiles: count => `+${count} fichiers supplémentaires`, + moreAgents: count => `+${count} agents supplémentaires`, + queued: 'En attente', + waitingActivity: "En attente d'activité", + steer: 'Orienter', + steerPlaceholder: 'Instructions pour ce sous-agent', + steerQueued: 'Instructions prévues au prochain point de contrôle', + stopRequested: 'Arrêt demandé', + requestRejected: "Le sous-agent n'a pas accepté la demande", + delegation: index => `Délégation ${index}`, + workers: count => `${count} travailleurs`, + workersActive: count => `${count} actifs`, + agentsCount: count => `${count} ${count === 1 ? 'agent' : 'agents'}`, + activeCount: count => `${count} actifs`, + failedCount: count => `${count} échoués`, + toolsCount: count => `${count} outils`, + filesCount: count => `${count} fichiers`, + updatedAgo: age => `mis à jour il y a ${age}`, + ageNow: 'maintenant', + ageSeconds: seconds => `il y a ${seconds}s`, + ageMinutes: minutes => `il y a ${minutes}m`, + ageHours: hours => `il y a ${hours}h`, + ageDays: days => `il y a ${days}j`, + durationSeconds: seconds => `${seconds}s`, + durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`, + tokens: value => `${value} tok` + }, + commandCenter: { + close: 'Fermer le centre de commandes', + paletteTitle: 'Palette de commandes', + back: 'Retour', + searchPlaceholder: 'Rechercher des sessions, vues et actions', + goTo: 'Aller à', + goToSession: 'Aller à la session', + branches: 'Branches', + projects: 'Projets', + openFolder: 'Ouvrir un dossier en tant que projet…', + openFolderAt: path => `Ouvrir le dossier en tant que projet — ${path}`, + newSessionInProject: project => `Nouvelle session dans ${project}`, + commands: 'Commandes', + startInBranch: branch => `Nouvelle conversation dans ${branch}`, + commandCenter: 'Centre de commandes', + appearance: 'Apparence', + settings: 'Paramètres', + changeTheme: 'Changer le thème', + changeColorMode: 'Changer le mode couleur…', + pets: { + title: 'Animaux de compagnie', + placeholder: 'Rechercher des animaux…', + loading: 'Chargement de la galerie petdex…', + error: "Impossible d'atteindre la galerie petdex…", + staleBackend: 'Redémarrez Hermes pour utiliser les animaux — le backend précède cette fonctionnalité.', + empty: 'Aucun animal correspondant.', + turnOff: 'Éteindre', + turnOn: 'Allumer', + installed: 'Installé', + generatedTag: 'Généré', + adoptFailed: "Impossible d'adopter cet animal.", + toggleFailed: enabled => `Impossible d'${enabled ? 'activer' : 'désactiver'} l'animal.`, + noneAvailable: "Aucun animal disponible — choisissez-en un ci-dessous pour l'installer." + }, + generatePet: { + title: 'Générer un animal', + placeholder: 'Décrivez un animal à générer…', + promptHint: 'Saisissez une description, puis appuyez sur Entrée pour générer quatre apparences.', + readyHint: 'Appuyez sur Entrée pour générer quatre apparences à partir de votre description.', + generate: 'Générer', + generating: 'Génération…', + retry: 'Réessayer', + hatch: 'Éclore', + spawning: 'Création…', + hatching: 'Éclosion de votre animal…', + hatchingSub: 'Donner vie…', + hatched: 'Il est éclos !', + hatchRow: (_state, done, total) => `Dessin de l'image ${done} sur ${total}…`, + hatchComposing: 'Assemblage…', + hatchSaving: 'Presque terminé…', + namePlaceholder: 'Nommez votre animal', + staleBackend: 'Mettez à jour Hermes pour générer des animaux…', + backgroundHint: "Vous pouvez fermer — Hermes vous notifiera quand c'est terminé.", + slowProviderHint: 'Cela peut prendre plusieurs minutes', + remix: 'Remixer', + remixConfirmTitle: 'Remixer cette apparence ?', + remixConfirmBody: + 'Cela génère un nouvel ensemble de projets en utilisant celui-ci comme point de départ. Cela peut prendre plusieurs minutes.', + genericError: 'Échec de la génération — réessayez ou choisissez une suggestion.', + referenceImageTooLarge: "L'image de référence est trop volumineuse. Utilisez-en une inférieure à 16 Mo.", + referenceImageInvalid: 'Impossible de lire cette image de référence. Essayez un PNG, JPG, WebP ou GIF.', + adopt: 'Adopter', + startOver: 'Recommencer' + }, + installTheme: { + title: 'Installer un thème…', + pageTitle: 'Installer un thème', + placeholder: 'Rechercher dans le Marketplace VS Code…', + loading: 'Recherche dans le Marketplace...', + error: "Impossible d'atteindre le Marketplace.", + empty: 'Aucun thème correspondant.', + install: 'Installer', + installing: 'Installation...', + installed: 'Installé', + installs: count => `${count} installations` + }, + settingsFields: 'Champs de paramètres', + mcpServers: 'Serveurs MCP', + archivedChats: 'Conversations archivées', + sections: { + maintenance: 'Maintenance', + sessions: 'Sessions', + system: 'Système', + usage: 'Utilisation' + }, + sectionDescriptions: { + maintenance: 'Diagnostiques, sauvegardes, curateur et données de mémoire', + sessions: 'Rechercher et gérer les sessions', + system: 'État, journaux et actions système', + usage: 'Activité des jetons, coûts et skills au fil du temps' + }, + nav: { + newChat: { + title: 'Nouvelle session', + detail: 'Commencer une session fraîche' + }, + settings: { + title: 'Paramètres', + detail: 'Configurer Hermes Desktop' + }, + capabilities: { + title: 'Capacités', + detail: 'Skills, outils, serveurs MCP et plugins' + }, + messaging: { + title: 'Messagerie', + detail: 'Configurer Telegram, Slack, Discord et plus' + }, + artifacts: { + title: 'Artéfacts', + detail: 'Parcourir les sorties générées' + } + }, + sectionEntries: { + sessions: { + title: 'Panneau de sessions', + detail: 'Rechercher, épingler et gérer les sessions' + }, + system: { + title: 'Panneau système', + detail: 'État du gateway, journaux, redémarrage/mise à jour' + }, + usage: { + title: "Panneau d'utilisation", + detail: 'Activité des jetons, coûts et skills' + } + }, + providerNavigate: 'Naviguer', + providerSessions: 'Sessions', + refresh: 'Actualiser', + refreshing: 'Actualisation...', + noResults: 'Aucun résultat correspondant trouvé.', + pinSession: 'Épingler la session', + unpinSession: 'Désépingler la session', + exportSession: 'Exporter la session', + deleteSession: 'Supprimer la session', + noSessions: 'Aucune session pour le moment.', + gatewayRunning: 'Gateway de messagerie en cours', + gatewayStopped: 'Gateway de messagerie arrêté', + hermesActiveSessions: (version, count) => `Hermes ${version} · Sessions actives ${count}`, + restartGateway: 'Redémarrer le gateway', + openBrowser: 'Ouvrir le navigateur', + gatewayRestartFailed: 'Échec du redémarrage du gateway.', + sharedGatewayRestartTitle: 'Redémarrer le gateway partagé ?', + sharedGatewayRestartDescription: bots => `Tous les bots de cet appareil se reconnecteront : ${bots}`, + sharedGatewayRestartConfirm: 'Tout redémarrer', + sharedGatewayRestarted: count => `Gateway partagé redémarré (${count} ${count === 1 ? 'bot' : 'bots'})`, + updateHermes: 'Mettre à jour Hermes', + reloadWindow: 'Recharger la fenêtre', + actionRunning: 'en cours', + actionDone: 'terminé', + actionFailed: 'échoué', + actionStartedWaiting: 'Action démarrée, attente du statut...', + loadingStatus: 'Chargement du statut...', + recentLogs: 'Journaux récents', + noLogs: 'Aucun journal chargé pour le moment.', + days: count => `${count}j`, + statSessions: 'Sessions', + statApiCalls: 'Appels API', + statTokens: 'Jetons entrants/sortants', + statCost: 'Coût estim.', + actualCost: cost => `réel ${cost}`, + loadingUsage: "Chargement de l'utilisation...", + noUsage: period => `Aucune utilisation au cours des ${period} derniers jours.`, + retry: 'Réessayer', + dailyTokens: 'Jetons quotidiens', + input: 'entrant', + output: 'sortant', + noDailyActivity: 'Aucune activité quotidienne.', + topModels: 'Principaux modèles', + noModelUsage: 'Aucune utilisation de modèle pour le moment.', + topSkills: 'Principaux skills', + noSkillActivity: 'Aucune activité de skill pour le moment.', + actions: count => `${count} actions`, + logFile: 'Fichier journal', + logLevel: 'Niveau', + logSearchPlaceholder: 'Filtrer les lignes du journal...', + maintenance: { + runOps: 'Diagnostiques', + doctor: 'Exécuter le diagnostic', + doctorDesc: "Vérifier l'installation, la configuration et les fournisseurs", + securityAudit: 'Audit de sécurité', + securityAuditDesc: 'Analyser la configuration et les skills à la recherche de paramètres risqués', + backup: 'Créer une sauvegarde', + backupDesc: 'Archiver en zip la configuration, les mémoires, les skills et les sessions', + debugShare: 'Partage de débogage', + debugShareDesc: + 'Téléverser un rapport expurgé + journaux, obtenir des liens partageables (auto-suppression après 6h)', + debugShareRunning: 'Téléversement du rapport de débogage...', + debugShareLinks: 'Liens de partage', + debugShareFailed: 'Échec du partage de débogage', + copyLink: 'Copier le lien', + linkCopied: 'Lien copié', + curator: 'Curateur de skills', + curatorDesc: 'Revue en arrière-plan qui archive les skills agent obsolètes', + curatorPaused: 'En pause', + curatorActive: 'Actif', + curatorDisabled: 'Désactivé', + curatorLastRun: when => `Dernière exécution ${when}`, + curatorNeverRan: 'Jamais exécuté', + pause: 'Mettre en pause', + resume: 'Reprendre', + runNow: 'Exécuter maintenant', + memoryData: 'Données de mémoire', + memoryDataDesc: 'Fichiers de mémoire intégrés injectés dans chaque session', + memoryProvider: name => `Fournisseur actif : ${name}`, + builtinMemory: 'intégré', + memoryFile: "Mémoire de l'agent (MEMORY.md)", + userFile: 'Profil utilisateur (USER.md)', + bytes: size => size, + empty: 'vide', + resetMemory: 'Réinitialiser la mémoire', + resetUser: 'Réinitialiser le profil', + resetAll: 'Réinitialiser les deux', + resetConfirm: target => `Supprimer ${target} ? Cela ne peut pas être annulé.`, + resetDone: files => `Supprimé ${files}.`, + resetFailed: 'Échec de la réinitialisation de la mémoire', + actionStarted: name => `${name} démarré — suivi du journal...`, + actionFailed: name => `${name} a échoué au démarrage`, + running: 'En cours...', + viewLog: "Journal d'action" + } + }, + messaging: { + search: 'Rechercher dans la messagerie...', + loading: 'Chargement des plateformes de messagerie...', + loadFailed: 'Échec du chargement des plateformes de messagerie', + states: { + connected: 'Connecté', + connecting: 'Connexion', + disabled: 'Désactivé', + fatal: 'Erreur', + gateway_stopped: 'Gateway de messagerie arrêté', + not_configured: 'Nécessite une configuration', + pending_restart: 'Redémarrage requis', + retrying: 'Nouvelle tentative', + startup_failed: 'Échec du démarrage' + }, + unknown: 'Inconnu', + hintPendingRestart: "Redémarrez le gateway depuis la barre d'état pour appliquer ce changement.", + sharedListenerUrl: 'Servi sur le listener partagé du gateway à', + hintGatewayStopped: "Démarrez le gateway depuis la barre d'état pour vous connecter.", + credentialsSet: 'Identifiants définis', + needsSetup: 'Nécessite une configuration', + gatewayStopped: 'Gateway de messagerie arrêté', + getCredentials: 'Obtenez vos identifiants', + openSetupGuide: 'Ouvrir le guide de configuration', + required: 'Requis', + recommended: 'Recommandé', + advanced: count => `Avancé (${count})`, + noTokenNeeded: + 'Cette plateforme ne nécessite pas de jeton ici. Utilisez le guide de configuration ci-dessus, puis activez-la ci-dessous.', + enabled: 'Activé', + disabled: 'Désactivé', + unsavedChanges: 'Modifications non enregistrées', + saving: 'Enregistrement...', + saveChanges: 'Enregistrer les modifications', + saved: 'Enregistré', + replaceValue: 'Remplacer la valeur actuelle', + openDocs: 'Ouvrir la documentation', + clearField: key => `Effacer ${key}`, + enableAria: name => `Activer ${name}`, + disableAria: name => `Désactiver ${name}`, + platformEnabled: name => `${name} activé`, + platformDisabled: name => `${name} désactivé`, + restartToApply: 'Ce changement prend effet après un redémarrage du gateway.', + setupSaved: name => `Configuration ${name} enregistrée`, + restartToReconnect: 'Les nouveaux identifiants prennent effet après un redémarrage du gateway.', + appliedLive: 'Appliqué au gateway en cours d’exécution.', + connectingLive: 'Le gateway en cours d’exécution se connecte avec les nouveaux identifiants.', + keyCleared: key => `${key} effacé`, + setupUpdated: name => `La configuration ${name} a été mise à jour.`, + failedUpdate: name => `Échec de la mise à jour de ${name}`, + failedSave: name => `Échec de l'enregistrement de ${name}`, + failedClear: key => `Échec de l'effacement de ${key}`, + pendingRequests: count => `Demandes en attente (${count})`, + pendingAria: count => + `${count} ${count === 1 ? "demande d'appairage en attente" : "demandes d'appairage en attente"}`, + approvedUsers: count => `Utilisateurs approuvés (${count})`, + approve: 'Approuver', + approving: 'Approbation…', + revoke: 'Révoquer', + revoking: 'Révocation…', + revokeAria: name => `Révoquer l'accès de ${name}`, + revokeTitle: "Révoquer l'accès", + revokeDesc: name => `${name} perdra l'accès et ne sera plus reconnu à partir de son prochain message.`, + approvedUser: name => `${name} approuvé`, + approvedHint: 'Cet utilisateur sera reconnu automatiquement à partir de son prochain message.', + revokedUser: name => `Accès de ${name} révoqué`, + failedApprove: name => `Échec de l'approbation de ${name}`, + failedRevoke: name => `Échec de la révocation de l'accès de ${name}`, + pairingLockedOut: + "Trop d'échecs d'approbation — cette plateforme est temporairement verrouillée. Réessayez plus tard.", + waitingSince: minutes => (minutes < 1 ? "à l'instant" : `il y a ${minutes} min`), + restartNeeded: 'Enregistré. Redémarrez le gateway de messagerie pour appliquer les nouveaux paramètres.', + restartNow: 'Redémarrer maintenant', + restarting: 'Redémarrage…', + restartFailedManual: 'Le redémarrage du gateway a échoué — redémarrez-le manuellement et consultez ses journaux.', + restartFailedManualDetail: + 'Réessayez le redémarrage ; si le problème persiste, ouvrez les journaux et envoyez les diagnostics.', + restartAgain: 'Redémarrer à nouveau', + openLogs: 'Ouvrir les journaux', + telegramQr: { + title: 'Choisissez comment connecter votre bot Telegram', + subtitle: + "Les deux méthodes connectent un bot que vous contrôlez et enregistrent ses identifiants uniquement dans cette installation d'Hermes.", + quickSetup: 'Configuration rapide', + recommended: 'Recommandé', + quickHelp: + "Scannez un code QR et confirmez dans Telegram. Hermes crée le bot et détecte automatiquement votre identifiant d'utilisateur Telegram.", + createWithQr: 'Créer avec un code QR', + starting: 'Démarrage…', + replaceWarning: + "Des identifiants Telegram sont déjà configurés. Une nouvelle configuration par QR ou un nouveau jeton remplacera le bot actuel lors de l'enregistrement.", + scanHint: "Scannez avec l'application Telegram de votre téléphone ou ouvrez le lien sur cet ordinateur.", + waiting: 'En attente de Telegram…', + expiresIn: remaining => `Expire dans ${remaining}`, + expired: 'Expiré', + openTelegram: 'Ouvrir Telegram', + ready: 'Bot créé', + allowedUsers: 'Utilisateurs autorisés', + ownerDetected: 'Propriétaire détecté', + addAtLeastOne: "Ajoutez au moins un identifiant d'utilisateur Telegram.", + userIdPlaceholder: "Identifiant d'utilisateur Telegram", + add: 'Ajouter', + numericOnly: "Les identifiants d'utilisateurs Telegram autorisés doivent être numériques.", + saveAndRestart: 'Enregistrer et redémarrer', + applying: 'Enregistrement…', + pairingExpired: 'La liaison Telegram a expiré. Relancez une configuration par QR pour réessayer.', + stillWaiting: detail => `Toujours en attente de Telegram. Nouvelle tentative après : ${detail}`, + savedRestarting: 'Telegram enregistré ; redémarrage du gateway…', + savedRestartFailed: detail => `Telegram enregistré ; le redémarrage du gateway a échoué${detail}` + }, + fieldCopy: { + TELEGRAM_BOT_TOKEN: { + label: 'Jeton du bot', + help: "Créez un bot avec @BotFather, puis collez le jeton qu'il vous fournit.", + placeholder: 'Coller le jeton du bot Telegram' + }, + TELEGRAM_ALLOWED_USERS: { + label: "IDs d'utilisateurs Telegram autorisés", + help: "Recommandé. IDs numériques séparés par des virgules depuis @userinfobot. Sans cela, n'importe qui peut envoyer un message privé à votre bot." + }, + TELEGRAM_PROXY: { + label: 'URL du proxy', + help: 'Uniquement nécessaire sur les réseaux où Telegram est bloqué.' + }, + DISCORD_BOT_TOKEN: { + label: 'Jeton du bot', + help: 'Créez une application dans le Discord Developer Portal, ajoutez un bot, puis collez son jeton.' + }, + DISCORD_ALLOWED_USERS: { + label: "IDs d'utilisateurs Discord autorisés", + help: "Recommandé. IDs d'utilisateurs Discord séparés par des virgules." + }, + DISCORD_REPLY_TO_MODE: { + label: 'Style de réponse', + help: 'first, all ou off.' + }, + DISCORD_ALLOW_ALL_USERS: { + label: 'Autoriser tous les utilisateurs Discord', + help: "Développement uniquement. Quand vrai, n'importe qui peut envoyer un message privé au bot sans liste d'autorisation." + }, + DISCORD_HOME_CHANNEL: { + label: "ID du canal d'accueil", + help: 'Canal où le bot envoie des messages proactifs (sortie cron, rappels).' + }, + DISCORD_HOME_CHANNEL_NAME: { + label: "Nom du canal d'accueil", + help: "Nom d'affichage pour le canal d'accueil dans les journaux et la sortie d'état." + }, + BLUEBUBBLES_ALLOW_ALL_USERS: { + label: 'Autoriser tous les utilisateurs iMessage', + help: "Quand vrai, ignorez la liste d'autorisation BlueBubbles." + }, + MATTERMOST_ALLOW_ALL_USERS: { + label: 'Autoriser tous les utilisateurs Mattermost' + }, + MATTERMOST_HOME_CHANNEL: { + label: "Canal d'accueil" + }, + QQ_ALLOW_ALL_USERS: { + label: 'Autoriser tous les utilisateurs QQ' + }, + QQBOT_HOME_CHANNEL: { + label: "Canal d'accueil QQ", + help: 'Canal ou groupe par défaut pour la livraison cron.' + }, + QQBOT_HOME_CHANNEL_NAME: { + label: "Nom du canal d'accueil QQ" + }, + SLACK_BOT_TOKEN: { + label: 'Jeton du bot Slack', + help: 'Utilisez le jeton du bot depuis OAuth & Permissions après avoir installé votre application Slack.', + placeholder: 'Coller le jeton du bot Slack' + }, + SLACK_APP_TOKEN: { + label: "Jeton de l'application Slack", + help: 'Utilisez le jeton de niveau application requis pour le Socket Mode.', + placeholder: "Coller le jeton de l'application Slack" + }, + SLACK_ALLOWED_USERS: { + label: "IDs d'utilisateurs Slack autorisés", + help: "Recommandé. IDs d'utilisateurs Slack séparés par des virgules." + }, + MATTERMOST_URL: { + label: 'URL du serveur', + placeholder: 'https://mattermost.example.com' + }, + MATTERMOST_TOKEN: { + label: 'Jeton du bot' + }, + MATTERMOST_ALLOWED_USERS: { + label: "IDs d'utilisateurs autorisés", + help: "Recommandé. IDs d'utilisateurs Mattermost séparés par des virgules." + }, + MATRIX_HOMESERVER: { + label: 'URL du homeserver', + placeholder: 'https://matrix.org' + }, + MATRIX_ACCESS_TOKEN: { + label: "Jeton d'accès" + }, + MATRIX_USER_ID: { + label: "ID d'utilisateur du bot", + placeholder: '@hermes:example.org' + }, + MATRIX_ALLOWED_USERS: { + label: "IDs d'utilisateurs Matrix autorisés", + help: "Recommandé. IDs d'utilisateurs séparés par des virgules au format @utilisateur:serveur." + }, + SIGNAL_HTTP_URL: { + label: 'URL du pont Signal', + placeholder: 'http://127.0.0.1:8080', + help: "URL d'un pont REST signal-cli en cours d'exécution." + }, + SIGNAL_ACCOUNT: { + label: 'Numéro de téléphone', + help: 'Le numéro enregistré avec votre pont signal-cli.' + }, + SIGNAL_ALLOWED_USERS: { + label: 'Utilisateurs Signal autorisés', + help: 'Recommandé. Identifiants Signal séparés par des virgules.' + }, + WHATSAPP_ENABLED: { + label: 'Activer le pont WhatsApp', + help: "Défini automatiquement par l'interrupteur ci-dessous. Ne touchez pas sauf si vous savez que vous en avez besoin." + }, + WHATSAPP_MODE: { + label: 'Mode du pont' + }, + WHATSAPP_ALLOWED_USERS: { + label: 'Utilisateurs WhatsApp autorisés', + help: 'Recommandé. Numéros de téléphone ou IDs WhatsApp séparés par des virgules.' + } + }, + platformIntro: {} + }, + webhooks: { + search: 'Rechercher des webhooks...', + loading: 'Chargement des webhooks...', + loadFailed: 'Échec du chargement des webhooks', + subscriptions: (count: number) => `Abonnements (${count})`, + hint: "Les modifications d'abonnement rechargent à chaud une fois que le récepteur est en cours d'exécution. Les abonnements désactivés rejettent les événements entrants.", + empty: 'Aucun abonnement webhook pour le moment.', + disabledTitle: 'Récepteur de webhooks désactivé', + disabledBody: + "Les webhooks sont leur propre plateforme gateway. Activez-les ici pour accepter les événements HTTP entrants ; les canaux de conversation ne sont nécessaires que lorsqu'un abonnement livre vers Telegram, Discord, Slack ou un autre canal.", + enable: 'Activer les webhooks', + enabling: 'Activation...', + enabled: (name: string) => `Activé : « ${name} »`, + disabled: (name: string) => `Désactivé : « ${name} »`, + enableRow: 'Activer', + disableRow: 'Désactiver', + delete: 'Supprimer', + deleting: 'Suppression...', + deleted: 'Webhook supprimé', + deleteTitle: 'Supprimer le webhook', + deleteDescPrefix: 'Cela supprimera définitivement ', + deleteDescSuffix: '. Cela ne peut pas être annulé.', + deleteFailed: (name: string) => `Échec de la suppression de « ${name} »`, + toggleFailed: (name, enabled) => `Échec : ${enabled ? 'activation' : 'désactivation'} de « ${name} »`, + newSubscription: 'Nouvel abonnement', + restarting: 'Redémarrage du gateway...', + restartNeeded: + "Les webhooks sont activés, mais le gateway a toujours besoin d'un redémarrage avant que le récepteur puisse se connecter.", + restartGateway: 'Redémarrer le gateway', + restartingGateway: 'Redémarrage...', + restartFailed: (detail: string) => `Échec du redémarrage du gateway${detail}`, + enabledRestarting: 'Webhooks activés ; redémarrage du gateway...', + all: '(tous)', + deliverOnly: 'livraison uniquement', + createdTitle: 'Abonnement créé', + createdSecretHint: "Copiez le secret maintenant — il n'est affiché qu'une fois.", + webhookUrl: 'URL du webhook', + secretOnce: 'Secret (affiché une fois)', + done: 'Terminé', + fieldName: 'Nom', + fieldNamePlaceholder: 'ex. github-push', + fieldDescription: 'Description', + fieldDescriptionPlaceholder: 'Ce que fait ce webhook (facultatif)', + fieldEvents: 'Événements', + fieldEventsPlaceholder: 'séparés par des virgules, laissez vide pour tous', + fieldSkills: 'Skills', + fieldSkillsPlaceholder: 'noms de skills séparés par des virgules (facultatif)', + fieldDeliver: 'Livrer vers', + fieldDeliverOnly: 'Livrer le payload uniquement', + fieldPrompt: 'Invite', + fieldPromptPlaceholder: "Instructions pour l'agent quand ce webhook se déclenche (facultatif)", + nameRequired: 'Nom requis', + create: 'Créer', + creating: 'Création...', + created: 'Créé', + createFailed: (detail: string) => `Échec de la création : ${detail}`, + copy: 'Copier', + deliverOptions: { + log: 'Journal', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + email: 'E-mail', + github_comment: 'Commentaire GitHub' + } + }, + profiles: { + close: 'Fermer les profils', + nameHint: 'Lettres minuscules, chiffres, tirets et underscores. Doit commencer par une lettre ou un chiffre.', + title: 'Profils', + count: count => `${count} ${count === 1 ? 'profil' : 'profils'}`, + search: 'Rechercher des profils...', + loading: 'Chargement des profils...', + newProfile: 'Nouveau profil', + importProfile: 'Importer un profil…', + exportProfile: 'Exporter le profil…', + imported: 'Profil importé', + exported: 'Profil exporté', + failedImport: "Échec de l'importation du profil", + failedExport: "Échec de l'exportation du profil", + allProfiles: 'Tous les profils', + showAllProfiles: 'Afficher tous les profils', + switchToProfile: name => `Basculer vers ${name}`, + switchToConnection: name => `Basculer vers ${name}`, + switchConnectionFailed: name => `Impossible de se connecter à ${name}`, + manageProfiles: 'Gérer les profils…', + connectGateway: 'Connecter un autre gateway Hermes…', + fleet: { + allOnGateway: 'Tous les profils de cette gateway', + gateway: gateway => `Profils sur ${gateway}`, + gatewayUnreachable: gateway => `${gateway} · inaccessible`, + onGateway: (name, gateway) => `${name} · ${gateway}`, + switchTo: (name, gateway) => `Basculer vers ${name} sur ${gateway}`, + deleteOn: gateway => ` sur ${gateway}` + }, + remoteOverride: { + menuItem: 'Se connecter à un hôte distant…', + badge: (host: string) => `S'exécute sur ${host}`, + title: (profile: string) => `Connecter ${profile} à un hôte distant`, + description: + "Les sessions de ce profil s'exécuteront sur le système Hermes distant indiqué, plutôt que sur cet ordinateur.", + urlLabel: 'Adresse distante', + urlPlaceholder: 'https://hermes.exemple.fr', + urlInvalid: 'Saisissez une adresse complète commençant par http:// ou https://', + tokenLabel: "Jeton d'accès", + tokenPlaceholder: 'Collez le jeton de session distant', + tokenSavedHint: 'Un jeton est déjà enregistré. Laissez ce champ vide pour le conserver.', + plainTextOptIn: + "Cet ordinateur ne dispose d'aucun stockage sécurisé pour les clés. Le jeton serait donc enregistré sans chiffrement sur le disque. L'enregistrer quand même.", + collisionWarning: (label: string) => + `Un gateway nommé « ${label} » existe déjà dans les paramètres. Cette connexion de profil est indépendante et ne le modifiera pas.`, + confirmTitle: 'Connecter ce profil à un hôte distant ?', + confirmNote: (profile: string, host: string) => + `Les nouvelles conversations de ${profile} s'exécuteront sur ${host}. Cet ordinateur distant exécutera les commandes et lira les fichiers qui s'y trouvent, pas ceux de cet appareil. Connectez-vous uniquement à un hôte de confiance.`, + confirmBack: 'Retour', + connect: 'Connecter', + connecting: 'Connexion…', + disconnect: 'Supprimer la connexion distante', + savedTitle: 'Profil connecté', + savedMessage: (profile: string, host: string) => `${profile} s'exécute désormais sur ${host}`, + removedTitle: 'Connexion distante supprimée', + removedMessage: (profile: string) => `${profile} s'exécute désormais sur cet ordinateur`, + removeFailed: 'Impossible de supprimer la connexion distante', + authFailedTitle: "L'hôte distant a refusé le jeton enregistré", + authFailedMessage: (profile: string, host: string) => + `${host} a refusé le jeton enregistré pour ${profile}. Il a peut-être été modifié sur le système distant.`, + updateToken: 'Saisir un nouveau jeton…' + }, + actions: 'Actions', + color: 'Couleur…', + colorFor: 'Couleur', + openInNewWindow: 'Ouvrir dans une nouvelle fenêtre', + setAsDefault: 'Définir par défaut', + defaultProfile: 'Profil par défaut', + defaultSet: name => `${name} est maintenant le profil par défaut`, + defaultDescription: + "Utilisé à l'ouverture de Hermes et pour les nouvelles conversations. Les sessions existantes restent dans leur profil.", + failedSetDefault: 'Impossible de définir le profil par défaut', + setColor: color => `Définir la couleur ${color}`, + autoColor: 'Auto', + noProfiles: 'Aucun profil pour le moment.', + selectPrompt: 'Sélectionnez un profil pour afficher ses détails.', + refresh: 'Actualiser les profils', + refreshing: 'Actualisation des profils', + default: 'par défaut', + skills: count => `${count} ${count === 1 ? 'skill' : 'skills'}`, + env: 'env', + defaultBadge: 'Par défaut', + rename: 'Renommer', + renameMenu: 'Renommer…', + exportMenu: 'Exporter', + editSoul: 'Modifier SOUL.md…', + copySetup: 'Copier la configuration', + copying: 'Copie en cours…', + modelLabel: 'Modèle', + skillsLabel: 'Skills', + notSet: 'Non défini', + soulDesc: "L'invite système et les instructions de persona intégrées dans ce profil.", + soulOptional: 'facultatif', + soulPlaceholder: mode => + `L'invite système / persona pour ce profil.\nLaissez vide pour conserver la valeur par défaut ${mode}.`, + soulPlaceholderCloned: 'cloné', + soulPlaceholderEmpty: 'vide', + unsavedChanges: 'Modifications non enregistrées', + loadingSoul: 'Chargement de SOUL.md...', + emptySoul: 'SOUL.md vide — commencez à écrire le persona...', + saving: 'Enregistrement...', + saveSoul: 'Enregistrer SOUL.md', + deleteTitle: 'Supprimer le profil ?', + deleteDescPrefix: 'Cela supprimera ', + deleteDescMid: ' et supprimera son ', + deleteDescSuffix: ' répertoire. Cela ne peut pas être annulé.', + deleting: 'Suppression...', + createDesc: 'Les profils sont des environnements Hermes indépendants : configuration, skills et SOUL.md séparés.', + nameLabel: 'Nom', + cloneFrom: 'Cloner depuis', + cloneFromNone: 'Aucun (vide)', + cloneFromDesc: 'Copie la configuration, les skills et le SOUL.md depuis le profil source sélectionné.', + cloneFromDefault: 'Cloner depuis le profil par défaut', + cloneFromDefaultDesc: 'Copier la configuration, les skills et le SOUL.md depuis votre profil par défaut.', + invalidName: hint => `Nom invalide. ${hint}`, + nameRequired: 'Le nom est requis.', + creating: 'Création...', + createAction: 'Créer le profil', + renameTitle: 'Renommer le profil', + renameDescPrefix: "Le renommage met à jour le répertoire du profil et les scripts d'enveloppe dans ", + renameDescSuffix: '.', + displayNameTitle: 'Nommer cet agent', + displayNameDesc: + "Définit le nom affiché dans toute l'application. L'identifiant interne du profil reste « default ».", + displayNameLabel: "Nom d'affichage", + newNameLabel: 'Nouveau nom', + renaming: 'Renommage...', + created: 'Profil créé', + renamed: 'Profil renommé', + deleted: 'Profil supprimé', + setupCopied: 'Commande de configuration copiée', + soulSaved: 'SOUL.md enregistré', + failedLoad: 'Échec du chargement des profils', + failedDelete: 'Échec de la suppression du profil', + failedCopy: 'Échec de la copie de la commande de configuration', + failedLoadSoul: 'Échec du chargement de SOUL.md', + failedSaveSoul: "Échec de l'enregistrement de SOUL.md", + failedCreate: 'Échec de la création du profil', + failedRename: 'Échec du renommage du profil' + }, + modelAssignment: { + saveFailed: 'Hermes n’a pas enregistré ce changement de modèle.', + confirmTitle: 'Avertissement sur le choix du modèle', + confirmDetail: 'Confirmez uniquement si vous acceptez ce compromis.', + confirmAction: 'Confirmer', + declined: + 'Changement de modèle annulé — vous avez refusé l’avertissement sur le niveau d’entraînement sur les données.' + }, + cron: { + close: 'Fermer le cron', + title: 'Tâches planifiées', + count: count => `${count} ${count === 1 ? 'tâche' : 'tâches'}`, + search: 'Rechercher des tâches cron...', + loading: 'Chargement des tâches cron...', + states: { + enabled: 'activée', + scheduled: 'planifiée', + running: 'en cours', + paused: 'en pause', + disabled: 'désactivée', + error: 'erreur', + completed: 'terminée' + }, + lastRunFailed: 'Échec de la dernière exécution :', + editJob: 'Modifier la tâche', + runAgain: 'Relancer', + deliveryLabels: { + local: 'Ce bureau', + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + email: 'E-mail' + }, + scheduleLabels: { + daily: 'Quotidien', + weekdays: 'Jours ouvrés', + weekly: 'Hebdomadaire', + monthly: 'Mensuel', + hourly: 'Toutes les heures', + 'every-15-minutes': 'Toutes les 15 minutes', + custom: 'Personnalisé' + }, + scheduleHints: { + daily: 'Chaque jour à 9h00', + weekdays: 'Du lundi au vendredi à 9h00', + weekly: 'Chaque lundi à 9h00', + monthly: 'Le premier jour de chaque mois à 9h00', + hourly: "En début d'heure", + 'every-15-minutes': 'Toutes les 15 minutes', + custom: 'Syntaxe cron ou langage naturel' + }, + days: { + '0': 'Dimanche', + '1': 'Lundi', + '2': 'Mardi', + '3': 'Mercredi', + '4': 'Jeudi', + '5': 'Vendredi', + '6': 'Samedi', + '7': 'Dimanche' + }, + dayFallback: value => `jour ${value}`, + everyDayAt: time => `Chaque jour à ${time}`, + weekdaysAt: time => `Jours ouvrés à ${time}`, + everyDayOfWeekAt: (day, time) => `Chaque ${day} à ${time}`, + monthlyOnDayAt: (dayOfMonth, time) => `Mensuellement le jour ${dayOfMonth} à ${time}`, + topOfHour: "En début d'heure", + everyHourAt: minute => `Toutes les heures à :${minute}`, + newCron: 'Nouveau cron', + emptyDescNew: + "Planifiez une invite à exécuter selon une expression cron. Hermes l'exécutera et livrera les résultats vers la destination de votre choix.", + emptyDescSearch: 'Essayez une requête de recherche plus large.', + emptyTitleNew: 'Aucune tâche planifiée pour le moment', + emptyTitleSearch: 'Aucune correspondance', + last: 'Dernière :', + next: 'Prochaine :', + overdueSince: 'En retard depuis :', + noRuns: 'Aucune exécution', + manage: 'Gérer', + showRuns: 'Afficher les exécutions', + hideRuns: 'Masquer les exécutions', + runHistory: 'Historique des exécutions', + actionsTitle: 'Actions de la tâche cron', + resume: 'Reprendre le cron', + pause: 'Mettre en pause le cron', + resumeTitle: 'Reprendre', + pauseTitle: 'Mettre en pause', + triggerNow: 'Déclencher maintenant', + edit: 'Modifier le cron', + deleteTitle: 'Supprimer la tâche cron ?', + deleteDescPrefix: 'Cela supprimera ', + deleteDescSuffix: ' définitivement. Elle cessera de se déclencher immédiatement.', + deleting: 'Suppression...', + resumed: 'Cron repris', + paused: 'Cron en pause', + triggered: 'Cron déclenché', + deleted: 'Cron supprimé', + created: 'Cron créé', + updated: 'Cron mis à jour', + failedLoad: 'Échec du chargement des tâches cron', + failedUpdate: 'Échec de la mise à jour de la tâche cron', + failedTrigger: 'Échec du déclenchement de la tâche cron', + failedDelete: 'Échec de la suppression de la tâche cron', + failedSave: "Échec de l'enregistrement de la tâche cron", + editTitle: 'Modifier la tâche cron', + createTitle: 'Nouvelle tâche cron', + editDesc: + "Mettre à jour le planning, l'invite ou la cible de livraison. Les modifications s'appliquent à la prochaine exécution.", + createDesc: + 'Planifiez une invite à exécuter automatiquement. Utilisez la syntaxe cron ou une phrase naturelle comme « toutes les 15 minutes ». ', + nameLabel: 'Nom', + namePlaceholder: 'Point du matin', + promptLabel: 'Invite', + promptPlaceholder: 'Résumez mes fils Slack non lus et envoyez-moi les 5 principaux par email...', + frequencyLabel: 'Fréquence', + deliverLabel: 'Livrer vers', + deliverNeedsHomeChannel: "définissez d'abord un canal d'accueil", + modelLabel: 'Modèle', + modelDefault: 'Par défaut (modèle global)', + customScheduleLabel: 'Planning personnalisé', + customPlaceholder: '0 9 * * * ou jours ouvrés à 9h', + customHint: 'Expression cron ou phrases comme « toutes les heures » ou « jours ouvrés à 9h ». ', + optional: 'Facultatif', + promptRequired: "L'invite est requise.", + promptScheduleRequired: "L'invite et le planning sont requis.", + scheduleRequired: 'Le planning est requis.', + scriptOnlyEditHint: "Tâche script uniquement (pas d'invite IA). ID de tâche :", + saveChanges: 'Enregistrer les modifications', + createAction: 'Créer le cron', + tabs: { + jobs: 'Tâches', + blueprints: 'Plans' + }, + blueprints: { + tab: 'Plans', + startFrom: 'Commencer à partir de', + custom: 'Personnalisé', + subtitle: "Automatismes prêts à l'emploi", + dialogDesc: 'Remplissez les détails et planifiez-les.', + scheduleIt: 'Planifiez-le', + scheduling: 'Planification...', + scheduled: 'Plan planifié', + loading: 'Chargement des plans...', + failedLoad: 'Échec du chargement des plans', + emptyTitle: 'Aucun plan disponible', + emptyDesc: "Aucun plan d'automatisation n'est disponible sur ce backend." + } + }, + artifacts: { + search: 'Rechercher des artefacts...', + refresh: 'Actualiser les artefacts', + refreshing: 'Actualisation des artefacts', + indexing: 'Indexation des artefacts de session récents', + tabAll: 'Tout', + tabImages: 'Images', + tabFiles: 'Fichiers', + tabLinks: 'Liens', + noArtifactsTitle: 'Aucun artefact trouvé', + noArtifactsDesc: + 'Les images générées et les sorties de fichier apparaîtront ici au fur et à mesure que les sessions les produisent.', + failedLoad: 'Échec du chargement des artefacts', + openFailed: "Échec de l'ouverture", + itemsImage: 'images', + itemsLink: 'liens', + itemsFile: 'fichiers', + itemsGeneric: 'éléments', + zero: '0', + rangeOf: (start, end, total) => `${start}-${end} sur ${total}`, + goToPage: (itemLabel, page) => `Aller à ${itemLabel}, page ${page}`, + colTitleLink: 'Titre du lien', + colTitleFile: 'Nom', + colTitleDefault: 'Titre / nom', + colLocationLink: 'URL', + colLocationFile: 'Chemin', + colLocationDefault: 'Emplacement', + colSession: 'Session', + kindImage: 'image', + kindFile: 'fichier', + kindLink: 'lien', + chat: 'Conversation', + copyUrl: "Copier l'URL", + copyPath: 'Copier le chemin' + }, + artifactCard: { + kind: { + code: 'Code', + html: 'Page interactive', + svg: 'Graphique' + }, + generating: lines => `Génération… ${lines} lignes`, + versionBadge: count => `${count} versions`, + open: 'Ouvrir' + }, + artifactPreview: { + versionOf: (current, total) => `v${current} sur ${total}`, + olderVersion: 'Version précédente', + newerVersion: 'Version suivante', + latest: 'Dernière', + copyContent: 'Copier le contenu', + download: 'Télécharger', + openInBrowser: 'Ouvrir dans le navigateur', + openInBrowserFailed: "Impossible d'ouvrir dans le navigateur", + missingTitle: 'Artefact indisponible', + missingBody: 'Cet artefact ne figure plus dans le registre local.' + }, + sidebar: { + filter: { + grouping: 'Regroupement', + ordering: 'Tri', + show: 'Afficher', + filters: 'Filtres', + status: 'État', + pullRequest: 'Pull request', + profile: 'Profil', + project: 'Projet', + archived: 'Archivées', + resetToDefaults: 'Rétablir les valeurs par défaut', + expandAll: 'Tout déplier', + collapseAll: 'Tout replier', + inboxStyle: 'Style boîte de réception', + updated: 'Mise à jour', + created: 'Création', + tokens: 'Jetons', + cost: 'Coût', + manual: 'Manuel', + preview: 'Aperçu', + pr: 'PR', + needsInput: 'Action requise', + working: 'En cours', + unread: 'Non lues', + draft: 'Brouillon', + idle: 'Inactive', + open: 'Ouverte', + merged: 'Fusionnée', + closed: 'Fermée', + noPR: 'Aucune PR' + }, + gatewayGroups: { + grouping: 'Gateway et profil', + rename: 'Renommer le groupe', + aliasLabel: "Nom d'affichage", + aliasHint: "Seul le nom d'affichage change ; les noms du gateway et du profil restent inchangés.", + resetName: 'Rétablir le nom', + moveUp: 'Déplacer vers le haut', + moveDown: 'Déplacer vers le bas', + reorder: 'Réordonner le groupe', + actions: 'Actions du groupe' + }, + profileRail: 'Barre des profils', + nav: { + 'new-session': 'Nouvelle session', + capabilities: 'Capacités', + messaging: 'Messagerie', + artifacts: 'Artefacts', + cron: 'Tâches planifiées' + }, + searchAria: 'Rechercher des sessions', + searchPlaceholder: 'Rechercher des sessions…', + clearSearch: 'Effacer la recherche', + noMatch: query => `Aucune session ne correspond à « ${query} ».`, + results: 'Résultats', + pinned: 'Épinglées', + sessions: 'Sessions', + terminal: 'Terminal', + files: 'Fichiers', + review: 'Revue', + logs: 'Journaux', + cronJobs: 'Tâches cron', + groupAriaGrouped: 'Afficher les sessions en une seule liste', + groupAriaUngrouped: 'Grouper les sessions par espace de travail', + showProjects: 'Afficher les projets', + showSessions: 'Afficher les sessions', + groupTitleGrouped: 'Dégrouper les sessions', + groupTitleUngrouped: 'Grouper par espace de travail', + allPinned: "Tout est épinglé ici. Désépinglez une conversation pour l'afficher dans les récentes.", + shiftClickHint: "Shift-clic sur une conversation pour l'épingler", + noWorkspace: 'Aucun espace de travail', + projectEmpty: 'Aucune session pour le moment', + projectLoadFailed: 'Impossible de charger les sessions', + noSessions: 'Aucune session pour le moment', + storageCorrupt: { + title: 'La base de données des sessions est endommagée', + body: (profiles: string) => + `Hermes ne peut pas lire tout l’historique des sessions de ${profiles}. Les conversations absentes de cette liste n’ont pas été supprimées ; le fichier qui les contient est endommagé.`, + action: 'Quittez Hermes sur ce profil, puis inspectez le fichier sans le modifier, ou restaurez un instantané :', + guide: 'Guide de récupération' + }, + noFilterMatches: 'Aucune session ne correspond à ces filtres', + projects: { + showAllSessions: 'Afficher toutes les sessions', + sectionLabel: 'Projets', + home: 'Accueil', + autoDiscovered: 'Détecté automatiquement', + newButton: 'Nouveau projet', + createTitle: 'Nouveau projet', + createDesc: 'Nommez un espace de travail et ajoutez un ou plusieurs dossiers.', + renameTitle: 'Renommer le projet', + addFolderTitle: 'Ajouter un dossier', + namePlaceholder: 'ex. Projet secret', + foldersLabel: 'Dossiers', + ideaLabel: 'Idée', + ideaPlaceholder: "De quoi s'agit-il ? (enregistré dans IDEA.md)", + ideaGenerate: 'Générer une idée', + ideaGenerating: 'Génération…', + ideaShuffle: 'Mélanger les modèles', + noFolders: 'Aucun dossier ajouté pour le moment.', + addFolder: 'Ajouter un dossier', + primaryBadge: 'principal', + removeFolder: 'Supprimer', + create: 'Créer', + menu: 'Actions', + menuRename: 'Renommer', + menuAppearance: 'Apparence', + noColor: 'Aucune couleur', + menuAddFolder: 'Ajouter un dossier', + menuSetActive: 'Définir comme actif', + menuDelete: 'Supprimer', + moveToProject: 'Déplacer vers un projet', + movedTo: name => `Déplacée vers ${name}`, + moveFailed: 'Impossible de déplacer la session', + moveNoFolder: "Ce projet n'a aucun dossier vers lequel effectuer le déplacement", + moveNoProjects: 'Aucun autre projet', + reveal: 'Afficher dans le dossier', + copyPath: 'Copier le chemin', + removeFromSidebar: 'Masquer de la barre latérale', + createFailed: 'Impossible de créer le projet', + staleBackend: + 'Mettez à jour le backend Hermes pour créer des projets — votre backend est plus ancien que cette application de bureau (Paramètres → Mises à jour → Backend).', + deleteConfirm: + 'Cela supprime le projet enregistré de Hermes. Les fichiers, dépôts git et worktrees restent inchangés.', + startWork: 'Nouveau worktree', + newWorktreeTitle: 'Nouveau worktree', + newWorktreeDesc: 'Nommez la branche pour ce worktree.', + branchPlaceholder: 'ex. ma-fonctionnalité', + branchOff: () => ({ after: '', before: 'à partir de ' }), + baseBranchPlaceholder: 'Rechercher des branches…', + baseBranchNone: 'Aucune branche trouvée', + startWorkFailed: 'Impossible de créer le worktree', + worktreeStaleBackend: + 'Mettez à jour le backend Hermes pour créer des worktrees depuis Desktop — votre backend est plus ancien que cette application (Paramètres → Mises à jour → Backend).', + worktreeProjectLabel: 'Projet', + worktreeProjectPlaceholder: 'Rechercher des projets…', + worktreeProjectNone: 'Aucun projet avec un dossier', + convertBranch: 'Convertir une branche…', + convertBranchTitle: 'Convertir une branche', + convertBranchDesc: 'Ouvrez des branches vérifiées ou créez un worktree pour une branche libre.', + convertBranchPlaceholder: 'Rechercher des branches…', + convertBranchInstead: 'Convertir une branche existante', + branchOpenExisting: 'ouverte', + branchSwitchHome: "basculer l'accueil", + branchCreateWorktree: 'nouveau worktree', + branchTrackRemote: 'suivre la branche distante', + branchesLoading: 'Chargement des branches…', + noBranches: 'Aucune branche trouvée', + removeWorktree: 'Supprimer le worktree', + removeWorktreeFailed: 'Impossible de supprimer le worktree (modifications non validées ?)', + removeWorktreeConfirm: + 'Supprimez-le de git (supprime le répertoire du worktree ; la branche reste), ou masquez simplement la voie de la barre latérale et laissez le worktree sur le disque.', + removeWorktreeDirty: + 'Ce worktree a des modifications non validées. Forcez sa suppression (ces modifications seront perdues), ou masquez simplement la voie et conservez-le sur le disque.', + forceRemove: 'Supprimer par force', + enter: label => `Ouvrir ${label}`, + reorder: label => `Réorganiser ${label}`, + toggle: (label, open) => `${open ? 'Afficher' : 'Masquer'} les sessions de ${label}`, + showAllCount: count => `Afficher les ${count} sessions`, + back: 'Tous les projets' + }, + newSessionIn: label => `Nouvelle session dans ${label}`, + showMoreIn: (count, label) => `Afficher ${count} de plus dans ${label}`, + loading: 'Chargement…', + loadMore: 'Charger plus', + loadCount: step => `Charger ${step} de plus`, + messageCount: count => `${count} ${count === 1 ? 'message' : 'messages'}`, + toolCallCount: count => `${count} ${count === 1 ? "appel d'outil" : "appels d'outil"}`, + row: { + pin: 'Épingler', + unpin: 'Désépingler', + markUnread: 'Marquer comme non lue', + markRead: 'Marquer comme lue', + unreadFailed: "Impossible de mettre à jour l'état de lecture", + copyId: "Copier l'ID", + export: 'Exporter', + branchFrom: 'Branche', + rename: 'Renommer', + archive: 'Archiver', + newWindow: 'Nouvelle fenêtre', + openInTerminal: 'Ouvrir dans le terminal', + hideTabBar: "Masquer la barre d'onglets", + openInNewTab: 'Ouvrir dans un nouvel onglet', + openInSplit: 'Ouvrir en split', + copyIdFailed: "Impossible de copier l'ID de session", + sessionActions: 'Actions de session', + sessionRunning: 'Session en cours', + needsInput: 'Nécessite votre saisie', + waitingForAnswer: 'En attente de votre réponse', + finishedUnread: 'Terminée — non lue', + backgroundRunning: 'Tâche en arrière-plan en cours', + draftSession: 'Brouillon — aucun message envoyé', + handoffOrigin: platform => `Transférée depuis ${platform}`, + ownedByProfile: profile => `Profil : ${profile}`, + renamed: 'Renommée', + renameFailed: 'Échec du renommage', + renameTitle: 'Renommer la session', + renameDesc: 'Laissez vide pour effacer.', + untitledPlaceholder: 'Session sans titre', + deleteTitle: 'Supprimer la session ?', + deleteDesc: title => `« ${title} » sera définitivement supprimée. Cette action est irréversible.`, + deleting: 'Suppression…', + deleted: 'Session supprimée', + untitledChat: id => `Conversation ${id}`, + messageCount: count => `${count} message${count === 1 ? '' : 's'}`, + todoProgress: 'Tâches terminées', + ageNow: 'maintenant', + ageDay: 'j', + ageHour: 'h', + ageMin: 'm' + }, + dateDivider: { + today: "Aujourd'hui", + yesterday: 'Hier', + thisWeek: 'Plus tôt cette semaine', + lastWeek: 'La semaine dernière', + thisMonth: 'Plus tôt ce mois' + }, + statusDivider: { + working: 'En cours', + done: 'Terminées' + }, + markAllRead: 'Tout marquer comme lu' + }, + composer: { + message: 'Message', + wakingProfile: profile => `Réveil de ${profile}…`, + placeholderStarting: 'Démarrage de Hermes…', + placeholderReconnecting: 'Reconnexion à Hermes…', + placeholderFollowUp: 'Envoyer un suivi', + newSessionPlaceholders: [ + 'Sur quoi travaillons-nous ?', + 'Donnez une tâche à Hermes', + "Qu'avez-vous en tête ?", + 'Décrivez ce dont vous avez besoin', + "Qu'est-ce qu'on attaque ?", + "Posez n'importe quelle question", + 'Commencez par un objectif' + ], + followUpPlaceholders: [ + 'Envoyez un suivi', + 'Ajoutez du contexte', + 'Affinez la demande', + 'Et ensuite ?', + 'Continuez', + 'Poussez plus loin', + 'Ajustez ou continuez' + ], + startVoice: 'Démarrer la conversation vocale', + openDirective: 'Ouvrir', + queueMessage: "Mettre le message en file d'attente", + steer: "Diriger l'exécution en cours", + stop: 'Arrêter', + send: 'Envoyer', + speaking: 'Parle', + transcribing: 'Transcription', + thinking: 'Réfléchit', + muted: 'Coupe', + listening: 'Écoute', + muteMic: 'Couper le microphone', + unmuteMic: 'Réactiver le microphone', + stopListening: "Arrêter l'écoute et envoyer", + stopShort: 'Arrêter', + endConversation: 'Terminer la conversation vocale', + endShort: 'Terminer', + stopDictation: 'Arrêter la dictée', + transcribingDictation: 'Transcription de la dictée', + voiceControls: 'Voix', + voiceEngine: 'Moteur de conversation vocale', + voiceEngineChained: 'Reconnaissance vocale + voix Hermes', + voiceEngineLive: 'GPT-Live (duplex intégral, délègue à Hermes)', + voiceEngineLiveNeedsKey: 'Nécessite une clé API OpenAI', + voiceEngineChangeFailed: 'Impossible de changer le moteur de conversation vocale', + voiceEngineChainedShort: 'reconnaissance vocale', + voiceEngineLiveShort: 'GPT-Live', + voiceDictation: 'Dictée vocale', + speakReplies: 'Lire les réponses à haute voix', + stopSpeakingReplies: 'Arrêter la lecture des réponses à haute voix', + wakeWord: phrase => `Mot d'activation « ${phrase} »`, + wakeWordListening: phrase => `Mot d'activation : « ${phrase} » — écoute en cours`, + wakeWordOff: phrase => `Mot d'activation : « ${phrase} » — désactivé`, + wakeWordPausedVoice: phrase => `Mot d'activation : « ${phrase} » — en pause pendant la conversation vocale`, + lookupLoading: 'Recherche…', + lookupNoMatches: 'Aucune correspondance.', + lookupTry: 'Essayez', + lookupOr: 'ou', + commonCommands: 'Commandes courantes', + hotkeys: 'Raccourcis clavier', + helpFooter: 'ouvre le panneau complet · effacer pour fermer', + commandDescs: { + '/help': 'Afficher les commandes slash du bureau', + '/clear': 'démarrer une nouvelle session', + '/resume': 'Reprendre une session enregistrée', + '/details': 'contrôler le niveau de détail de la transcription', + '/copy': "copier la sélection ou le dernier message de l'assistant", + '/quit': 'quitter Hermes', + '/start': 'Accuser réception des pings de démarrage de la plateforme sans répondre', + '/new': 'Démarrer une nouvelle conversation', + '/topic': 'Activer ou inspecter les sessions par sujet des MP Telegram', + '/save': 'Enregistrer la transcription actuelle en JSON', + '/retry': 'Réessayer le dernier message (le renvoyer à l’agent)', + '/prompt': 'Rédiger votre prochain prompt dans $EDITOR (markdown), puis l’envoyer', + '/undo': 'Revenir de N tours utilisateur et relancer (1 par défaut)', + '/title': 'Renommer la session actuelle', + '/handoff': 'Transférer cette session vers une plateforme de messagerie', + '/branch': 'Dériver le dernier message dans une nouvelle conversation', + '/worktree': 'Afficher, lister, créer ou élaguer des worktrees git isolés', + '/compress': 'Compresser le contexte de cette conversation', + '/rollback': + 'Lister ou restaurer les points de contrôle du système de fichiers (les restaurations conservent vos modifications manuelles ; --all les écrase)', + '/export': 'Exporter un profil (configuration, skills, thème) dans une archive partageable', + '/import': 'Importer une archive de profil partagée comme nouveau profil', + '/stop': 'Arrêter le tour actif et les processus en arrière-plan', + '/pause': "Suspendre globalement tout nouveau travail (arrêt d’urgence) ; '/pause off' reprend", + '/bg': 'Exécuter un prompt dans une session d’arrière-plan distincte', + '/btw': 'Poser une question annexe sur cette conversation sans l’interrompre', + '/agents': 'Afficher les agents actifs et les tâches en cours', + '/journey': 'Ouvrir le graphe de mémoire — skills et souvenirs au fil du temps', + '/queue': + 'Mettre un prompt en file pour le prochain tour, ou lister/modifier/supprimer/déplacer/vider les prompts en file', + '/steer': 'Injecter un message après le prochain appel d’outil sans interrompre', + '/goal': + 'Définir un objectif permanent sur lequel Hermes travaille au fil des tours jusqu’à ce qu’il soit atteint', + '/heartbeat': 'Définir un prompt récurrent qui revient dans cette session lorsqu’elle est inactive', + '/refine': 'Passer en revue cette conversation maintenant et enregistrer les leçons en mémoire/skills', + '/review': 'Lancer un sous-agent indépendant pour relire le travail qui vient d’être discuté (PR, code, docs)', + '/loop': 'Relancer un prompt à intervalle régulier dans cette session', + '/plan': 'Écrire un plan d’implémentation markdown dans .hermes/plans/ sans rien exécuter', + '/moa': 'Exécuter un prompt avec le préréglage Mixture of Agents par défaut, puis restaurer votre modèle', + '/subgoal': 'Ajouter ou gérer des critères supplémentaires sur l’objectif actif', + '/status': 'Afficher l’état de la session actuelle', + '/egress': 'Afficher l’état du proxy de sortie Docker', + '/context': + 'Afficher la vue détaillée de la fenêtre de contexte avec jauge d’utilisation, répartition par catégorie, statistiques de compression et débit', + '/whoami': 'Afficher votre accès aux commandes slash (admin / utilisateur)', + '/profile': 'Changer de profil Hermes actif', + '/codex-runtime': 'Activer/désactiver le runtime codex app-server pour les modèles OpenAI/Codex', + '/personality': 'Définir une personnalité prédéfinie', + '/battery': 'Afficher/masquer un indicateur de batterie coloré dans la barre d’état', + '/timestamps': 'Afficher/masquer les horodatages [HH:MM] sur les messages et /history', + '/diff': 'Afficher les modifications git dans le répertoire de travail', + '/focus': 'Activer/désactiver la vue focus — n’afficher que votre prompt et la réponse finale', + '/yolo': 'Activer/désactiver YOLO — approuver automatiquement les commandes dangereuses', + '/approvals': 'Afficher ou définir le mode d’approbation persistant des commandes dangereuses', + '/reasoning': 'Effort ou affichage du raisonnement [ [--global]|show|hide|full|clamp]', + '/skin': 'Changer de thème de bureau ou passer au suivant', + '/wake': 'Contrôler l’écoute du mot d’activation du bureau [on|off|status]', + '/tools': 'Gérer les outils : /tools [list|disable|enable] [name...]', + '/memory': 'Examiner les écritures mémoire en attente / activer ou désactiver la validation', + '/bundles': 'Lister les lots de skills (alias / pour plusieurs skills)', + '/pet': 'Afficher/masquer ou adopter une mascotte petdex (/pet, /pet list, /pet boba)', + '/hatch': 'Générer une nouvelle mascotte (ouvre le générateur)', + '/learn': + 'Apprendre une skill réutilisable à partir de ce que vous décrivez (dossiers, URL, cette conversation, notes)', + '/init': 'Générer ou mettre à jour les instructions de projet AGENTS.md à partir d’une analyse du dépôt', + '/suggestions': 'Examiner les automatisations suggérées (accepter/ignorer)', + '/blueprint': 'Configurer une automatisation à partir d’un modèle', + '/browser': 'Gérer la connexion CDP du navigateur [connect|disconnect|status] (gateway local uniquement)', + '/palette': 'Ouvrir la palette de commandes floue (aussi Ctrl+P)', + '/usage': + 'Afficher l’utilisation des jetons et les limites de débit ; `reset` utilise une réinitialisation de limite Codex en réserve', + '/subscription': 'Voir votre forfait Nous et le modifier dans le navigateur', + '/topup': 'Afficher votre solde Nous et gérer la facturation sur le portail', + '/platform': 'Suspendre, reprendre ou lister une plateforme de gateway en échec', + '/version': 'Afficher la version de Hermes Agent', + '/debug': 'Téléverser un rapport de débogage (infos système + journaux) et obtenir des liens partageables', + '/model': 'Changer le modèle de cette session' + }, + hotkeyDescs: { + 'composer.mention': 'référencer des fichiers, dossiers, URLs, git', + 'composer.slash': 'palette de commandes slash', + 'composer.help': 'aide rapide (effacer pour fermer)', + 'composer.sendNewline': 'envoyer · Shift+Entrée pour un retour à la ligne', + 'composer.sendQueued': "envoyer le prochain tour en file d'attente", + 'keybinds.openPanel': 'tous les raccourcis clavier', + 'composer.cancel': "fermer le popover · annuler l'exécution", + 'composer.history': 'parcourir le popover / historique' + }, + attachUrlTitle: 'Attacher une URL', + attachUrlDesc: "Hermes récupérera la page et l'inclura comme contexte pour ce tour.", + urlPlaceholder: 'https://example.com/post', + urlHintPre: "Incluez l'URL complète, par ex. ", + attach: 'Attacher', + queued: count => `${count} en file d'attente`, + queuedPaused: count => `${count} en file d'attente — en pause`, + attachmentOnly: 'Tour avec seulement des pièces jointes', + emptyTurn: 'Tour vide', + hiddenQueued: 'Note de configuration', + attachments: count => `${count} ${count === 1 ? 'pièce jointe' : 'pièces jointes'}`, + editingInComposer: 'Modification dans le compositeur', + editingQueuedInComposer: "Modification du tour en file d'attente dans le compositeur", + restoredDraftNotice: 'Votre message non envoyé a été restauré', + restoredDraftUndo: 'Annuler', + queueEdit: 'Modifier', + queueSendNext: 'Suivant', + queueSteer: 'Diriger — réorienter maintenant le tour en cours', + queueSend: 'Envoyer', + queueDelete: 'Supprimer', + queueResume: 'Reprendre', + queueResumeTip: "Mis en pause par Arrêter — reprendre l'envoi des tours en file d'attente", + queueStuckTitle: "Message en file d'attente non envoyé", + queueStuckBody: + "Un tour en file d'attente a continué à échouer lors de l'envoi. Il est toujours en file d'attente — essayez de l'envoyer à nouveau.", + previewUnavailable: 'Aperçu indisponible', + previewLabel: label => `Aperçu ${label}`, + couldNotPreview: label => `Impossible d'apercevoir ${label}`, + removeAttachment: label => `Supprimer ${label}`, + dictating: 'Dictée', + preparingAudio: "Préparation de l'audio", + speakingResponse: 'Lecture de la réponse', + readingAloud: 'Lecture à haute voix', + themeSuggestions: 'Suggestions de thème du bureau', + noMatchingThemes: 'Aucun thème correspondant.', + themeTryPre: 'Essayez ', + themeTryPost: '.', + attachLabel: 'Attacher', + files: 'Fichiers…', + folder: 'Dossier…', + images: 'Images…', + pasteImage: 'Coller une image', + url: 'URL…', + promptSnippets: "Extraits d'invite…", + tipPre: 'Conseil : tapez ', + tipPost: ' pour référencer des fichiers en ligne.', + snippetsTitle: "Extraits d'invite", + snippetsDesc: 'Choisissez une invite de démarrage à insérer dans le compositeur.', + dropFiles: 'Déposez des fichiers pour les attacher', + dropSession: 'Déposez pour lier cette conversation', + mcpSuggestions: { + label: server => `Ajouter ${server}`, + tip: keyword => `Suggéré car vous avez mentionné « ${keyword} » — cliquez pour vous connecter`, + connecting: server => `Connexion à ${server}…`, + cancelTip: 'Cliquez pour annuler', + added: server => `${server} ajouté`, + addedTip: 'Connecté — ses outils sont prêts dans cette conversation', + connectFailed: server => `Impossible de se connecter à ${server}` + }, + skillSuggestions: { + label: skill => `Utiliser le skill : ${skill}`, + tip: skill => `Vous avez mentionné « ${skill} » — cliquez pour commencer avec ce skill`, + done: skill => `/${skill} ajouté`, + doneTip: "Le skill se charge lors de l'envoi" + }, + githubSuggestions: { + label: 'Configurer GitHub', + tip: 'GitHub fonctionne ici grâce aux skills de la CLI gh ; cliquez pour connecter votre compte', + done: 'Ajout de /github-auth', + doneTip: "Envoyez le message et l'agent vous guidera dans la connexion à GitHub" + }, + repairSuggestions: { + label: server => `Reconnecter ${server}`, + tip: server => `Un appel à ${server} vient d'échouer à cause d'une erreur de connexion`, + working: server => `Reconnexion à ${server}…`, + workingTip: 'Cliquez pour annuler', + done: server => `${server} reconnecté`, + doneTip: 'Les nouveaux identifiants sont actifs dans cette conversation', + failed: server => `Impossible de reconnecter ${server}` + }, + cronSuggestions: { + label: 'Planifier ceci', + tip: phrase => `« ${phrase} » semble récurrent — exécutez-le plutôt selon une planification`, + prefix: 'Configurer ceci comme tâche planifiée :', + done: 'Marqué pour planification', + doneTip: "Envoyez-le pour que l'agent crée la tâche" + }, + snippets: { + codeReview: { + label: 'Revue de code', + description: + 'Audit des modifications actuelles à la recherche de régressions, de cas limites oubliés et de tests manquants.', + text: 'Veuillez examiner cela à la recherche de bugs, de régressions et de tests manquants.' + }, + implementationPlan: { + label: "Plan d'implémentation", + description: 'Élaborez une approche avant de toucher au code pour que la différence reste ciblée.', + text: "Veuillez élaborer un plan d'implémentation concis avant de modifier le code." + }, + explainThis: { + label: 'Expliquez ceci', + description: 'Parcourez comment le code sélectionné fonctionne et liez les fichiers clés.', + text: 'Veuillez expliquer comment cela fonctionne et pointez-moi vers les fichiers clés.' + } + } + }, + statusStack: { + hideStack: 'Masquer la pile d’état', + showStack: 'Afficher la pile d’état', + agents: 'Agents', + background: count => `${count} arrière-plan`, + goalActive: 'Objectif actif', + goalBlocked: 'Objectif bloqué', + goalDone: 'Objectif terminé', + goalPaused: 'Objectif en pause', + goalWaiting: 'Objectif en attente', + subagents: count => `${count} sous-agent${count === 1 ? '' : 's'}`, + todos: (done, total) => `Tâches ${done}/${total}`, + running: 'En cours', + stop: 'Arrêter', + dismiss: 'Rejeter', + exit: code => `sortie ${code}`, + control: { + goalActiveTurns: (turn, maxTurns) => `Tour ${turn}/${maxTurns}`, + goalDoneTurns: turns => `${turns} tour${turns === 1 ? '' : 's'}`, + goalTurn: turn => `Tour ${turn}`, + goalActions: "Actions de l'objectif", + viewDetails: 'Voir les détails', + addCriterion: 'Ajouter un critère', + addCriterionDialogTitle: 'Ajouter un critère', + addCriterionPlaceholder: 'Saisissez le critère...', + criterionLabel: 'Critère', + pauseGoal: "Mettre l'objectif en pause", + resumeGoal: "Reprendre l'objectif", + resumeNow: 'Reprendre maintenant', + clearGoal: "Supprimer l'objectif", + clearGoalConfirmTitle: "Supprimer l'objectif ?", + clearGoalConfirmBody: "Voulez-vous vraiment supprimer l'objectif actif ? Cette action est irréversible.", + copyCriterion: index => `Copier le critère ${index}`, + removeCriterion: index => `Supprimer le critère ${index}`, + removeCriterionConfirmTitle: index => `Supprimer le critère ${index} ?`, + removeCriterionConfirmBody: index => `Voulez-vous vraiment supprimer le critère ${index} ?`, + clearCriteria: 'Supprimer tous les critères', + clearCriteriaConfirmTitle: 'Supprimer tous les critères ?', + clearCriteriaConfirmBody: 'Voulez-vous vraiment supprimer tous les critères de cet objectif ?', + criteriaHeader: count => `Critères · ${count}`, + noCriteria: 'Aucun critère', + goalDetailsTitle: "Détails de l'objectif", + objectiveLabel: 'Objectif', + contractOutcome: 'Résultat attendu', + contractVerification: 'Vérification', + contractConstraints: 'Contraintes', + contractBoundaries: 'Limites', + contractStopWhen: "Condition d'arrêt", + waitBarrierTitle: "Condition d'attente", + waitUntil: target => `En attente jusqu'à ${target}`, + waitSession: target => `En attente de la session ${target}`, + waitPid: pid => `En attente du processus ${pid}`, + qualityGatesTitle: 'Contrôles qualité', + gateCommand: 'Commande', + gateAttempts: (attempts, max) => `${attempts}/${max} tentatives`, + gateTimeout: seconds => `Délai maximal : ${seconds} s`, + gateLastExit: code => (code === null ? 'En attente' : `Code de sortie : ${code}`), + loopActive: 'Boucle active', + loopPaused: 'Boucle en pause', + loopDeferred: 'Boucle différée', + loopFinished: 'Boucle terminée', + loopRuns: runs => `${runs} exécution${runs === 1 ? '' : 's'}`, + loopRunCount: (current, total) => `Exécution ${current}/${total}`, + loopNext: time => `prochaine : ${time}`, + loopEverySeconds: seconds => `toutes les ${seconds} s`, + loopEveryMinutes: minutes => `toutes les ${minutes} min`, + loopEveryHours: hours => `toutes les ${hours} h`, + loopSelfPaced: 'rythme autonome', + loopActions: 'Actions de la boucle', + pauseLoop: 'Mettre la boucle en pause', + resumeLoop: 'Reprendre la boucle', + stopLoop: 'Arrêter la boucle', + stopLoopConfirmTitle: 'Arrêter la boucle ?', + stopLoopConfirmBody: 'Voulez-vous vraiment arrêter cette boucle ?', + dismissLoop: 'Masquer la boucle', + loopPromptLabel: 'Consigne', + loopCadenceLabel: 'Fréquence', + loopUntilLabel: "Condition d'arrêt", + loopDeferredNotice: 'Un objectif actif contrôle actuellement la session.', + loopAwaitingResponse: "En attente d'une réponse", + heartbeatActive: 'Suivi périodique actif', + heartbeatPaused: 'Suivi périodique en pause', + heartbeatEveryMinutes: minutes => `toutes les ${minutes} min`, + heartbeatEveryHours: hours => `toutes les ${hours} h`, + heartbeatEverySeconds: seconds => `toutes les ${seconds} s`, + heartbeatNext: time => `prochain : ${time}`, + heartbeatDueWaitingForIdle: 'échu — en attente de disponibilité', + heartbeatActions: 'Actions du suivi périodique', + pauseHeartbeat: 'Mettre le suivi en pause', + resumeHeartbeat: 'Reprendre le suivi', + clearHeartbeat: 'Supprimer le suivi', + clearHeartbeatConfirmTitle: 'Supprimer le suivi périodique ?', + clearHeartbeatConfirmBody: 'Voulez-vous vraiment supprimer ce suivi périodique ?', + heartbeatFiredCount: count => `Déclenché ${count} fois`, + actionFailed: msg => `Échec de l'action : ${msg}`, + actionSucceeded: 'Action réussie', + copySuccess: 'Critère copié dans le presse-papiers', + copyFailure: 'Impossible de copier le critère dans le presse-papiers', + continuationFailed: "Impossible de soumettre la poursuite de l'objectif", + continuationQueued: 'Objectif repris — poursuite en attente de la fin du tour actuel', + continuationBusy: 'Objectif repris — session occupée, utilisez /interrupt pour poursuivre', + controlUnavailable: msg => `Commandes de session indisponibles : ${msg}`, + dismissError: "Masquer l'erreur", + add: 'Ajouter' + }, + coding: { + title: 'Arbre de travail', + noBranch: 'Aucune branche', + detached: 'détaché', + clean: 'Propre', + changed: count => `${count} ${count === 1 ? 'modifié' : 'modifiés'}`, + ahead: count => `${count} en avance`, + behind: count => `${count} en retard`, + review: 'Revoir', + close: 'Fermer', + openChanges: 'Ouvrir les modifications', + openFile: 'Ouvrir le fichier', + stage: 'Mettre en zone de préparation', + unstage: 'Retirer de la zone de préparation', + stageAll: 'Tout mettre en zone de préparation', + viewAsTree: 'Voir en arbre', + viewAsList: 'Voir en liste', + revert: 'Rétablir', + revertAll: 'Tout rétablir', + revertConfirm: + "Abandonner les modifications de ce fichier et le rétablir à l'état validé ? Cela ne peut pas être annulé.", + revertAllConfirm: + "Abandonner toutes les modifications et rétablir les fichiers à l'état validé ? Cela ne peut pas être annulé.", + staged: 'En zone de préparation', + noChanges: 'Aucune modification', + notRepo: 'Pas un dépôt git', + noDiff: 'Aucune différence à afficher', + scopeUncommitted: 'Non validé', + scopeBranch: 'Branche', + scopeLastTurn: 'Dernier tour', + commit: 'Valider', + commitAndPush: 'Valider et pousser', + commitPlaceholder: shortcut => `Message (${shortcut} pour valider)`, + generateCommitMessage: 'Générer un message de validation', + stopGenerating: 'Arrêter la génération', + createPr: 'Créer une PR', + openPr: 'Ouvrir une PR', + ghMissing: 'Installez la CLI GitHub (gh) et connectez-vous pour ouvrir des PR', + agentShip: "Demander à Hermes d'ouvrir une PR", + agentShipUnavailable: "La conversation à l'origine de ces modifications n'est pas affichée.", + agentShipPrompt: + 'Passez en revue les modifications actuelles, validez-les avec un message de validation conventionnel clair, poussez la branche, puis ouvrez une pull request.', + newBranch: 'Nouvelle branche', + branchOffFrom: base => `Nouvelle branche à partir de ${base}`, + switchTo: branch => `Basculer vers ${branch}`, + switchFailed: branch => `Impossible de basculer vers ${branch}`, + worktrees: 'Worktrees' + } + }, + updates: { + stages: { + idle: 'Préparation…', + prepare: 'Préparation…', + fetch: 'Téléchargement…', + pull: 'Presque prêt…', + pydeps: 'Finalisation…', + update: 'Mise à jour de Hermes…', + rebuild: "Reconstruction de l'application de bureau…", + restart: 'Redémarrage de Hermes…', + done: 'Mise à jour terminée', + manual: 'Mise à jour depuis votre terminal', + guiSkew: "Mettre à jour l'application de bureau", + error: 'Mise à jour en pause' + }, + checking: 'Recherche de mises à jour…', + checkFailedTitle: 'Impossible de vérifier les mises à jour', + tryAgain: 'Réessayer', + notAvailableTitle: 'Mise à jour indisponible', + unsupportedMessage: "Cette version de Hermes ne peut pas se mettre à jour depuis l'application.", + connectionRetry: 'Vérifiez votre connexion et réessayez.', + gitUnusable: 'Hermes n’a pas pu exécuter Git sur cet ordinateur et n’a donc pas pu rechercher de mises à jour.', + connectionSettings: 'Paramètres de connexion', + openDownloadPage: 'Ouvrir la page de téléchargement', + latestBody: 'Vous utilisez la dernière version.', + latestBodyBackend: 'Le backend utilise la dernière version.', + allSetTitle: 'Tout est prêt', + availableTitle: 'Nouvelle mise à jour disponible', + availableBody: 'Une nouvelle version de Hermes est prête à être installée.', + availableTitleBackend: 'Mise à jour du backend disponible', + availableBodyBackend: 'Une version plus récente du backend Hermes connecté est prête à être installée.', + availableBodyNoChangelog: + "Une version plus récente est prête. Les notes de version ne sont pas disponibles pour ce type d'installation.", + updateNow: 'Mettre à jour maintenant', + maybeLater: 'Peut-être plus tard', + moreChanges: count => + `+ ${count} ${count === 1 ? 'changement supplémentaire inclus' : 'changements supplémentaires inclus'}.`, + manualTitle: 'Mise à jour depuis votre terminal', + manualBody: + "Vous avez installé Hermes depuis la ligne de commande, les mises à jour s'y effectuent donc aussi. Collez ceci dans votre terminal :", + manualPickedUp: 'Hermes prendra en compte la nouvelle version au prochain lancement.', + guiSkewTitle: "Mettre à jour l'application de bureau", + guiSkewBody: + "Le backend a été mis à jour, mais ce package d'application de bureau ne l'a pas été. Mettez à jour ou réinstallez l'application de bureau Hermes (votre AppImage / .deb / .rpm) pour qu'elle corresponde.", + copy: 'Copier', + copied: 'Copié', + done: 'Terminé', + applyingBody: + 'Le programme de mise à jour de Hermes prend le relais dans sa propre fenêtre et rouvre Hermes automatiquement une fois terminé. Ne rouvrez pas Hermes vous-même pendant la mise à jour.', + applyingBodyBackend: + 'Le backend distant applique la mise à jour et va redémarrer. Hermes se reconnecte automatiquement à son retour.', + applyingClose: 'Cette fenêtre se fermera pendant la mise à jour, puis Hermes se rouvre seul.', + errorTitle: 'Mise à jour non terminée', + errorBody: "Pas de souci — rien n'a été perdu. Vous pouvez réessayer maintenant.", + blockerTitle: 'Fermer les aperçus locaux pour mettre à jour Hermes ?', + blockerBody: + 'Hermes doit arrêter ces aperçus locaux avant la mise à jour. Aucun de vos fichiers ne sera modifié ni supprimé.', + foreignBlockerTitle: 'Fermez les autres processus pour mettre à jour Hermes', + foreignBlockerBody: + "Hermes ne peut pas fermer automatiquement ces processus en toute sécurité. Fermez l'application, le terminal ou le service qui possède chacun d'eux, puis relancez la mise à jour.", + mixedBlockerBody: + 'Hermes peut fermer les aperçus locaux ci-dessous. Les autres processus doivent être fermés manuellement avant de poursuivre la mise à jour.', + closePreviewsAndUpdate: 'Fermer les aperçus et mettre à jour', + closePreviewsAndCheckAgain: 'Fermer les aperçus et revérifier', + localPreview: 'Aperçu local', + portLabel: port => `Port ${port}`, + pidLabel: pid => `PID ${pid}`, + technicalDetails: 'Détails techniques', + notNow: 'Pas maintenant', + clientAlsoBehindTitle: "L'application Desktop n'est pas à jour", + clientAlsoBehindMessage: + 'Le backend est à jour, mais cette application Desktop utilise encore une ancienne version. Mettez-la à jour pour profiter des derniers correctifs.', + clientAlsoBehindAction: "Mettre à jour l'application Desktop", + everythingDispatched: 'Mise à jour envoyée', + everythingSkipped: 'Ignorée', + everythingRowFailed: 'Échec de la mise à jour', + everythingFanoutFailedTitle: 'Impossible de mettre à jour les autres instances', + changeLogNew: 'Nouveautés', + changeLogFixed: 'Corrigé', + changeLogFaster: 'Plus rapide', + changeLogImproved: 'Amélioré', + changeLogOther: 'Autres améliorations', + changeLogFallbackLabel: 'Dans cette mise à jour', + changeLogFallbackItem: 'Améliorations et corrections', + applyStatus: { + preparing: 'Mise à jour du backend…', + pulling: 'Mise à jour du backend…', + restarting: 'Redémarrage du backend pour charger la mise à jour…', + notAvailable: 'Mise à jour indisponible pour ce backend.', + failed: 'Échec de la mise à jour du backend.', + noReturn: + "Le backend ne s'est pas reconnecté. La mise à jour n'est peut-être pas terminée — vérifiez l'hôte du backend." + } + }, + handoffTour: { + profileTitle: 'Votre première tâche utilise le profil par défaut', + profileText: + "Cette barre change de profil. Celui qui est éclairé est le profil par défaut, où se trouve la session de la tâche. L'autre est le profil de configuration, où se trouve la conversation de bienvenue.", + sessionsTitle: 'Chaque profil conserve ses propres sessions', + sessionsText: + 'Cette liste appartient au profil par défaut. Nouvelle session démarre une tâche sur le profil sélectionné. Changez de profil dans la barre et la liste change avec lui.', + stayTitle: "Hermes reste à portée d'un clic", + stayText: + 'Passez au profil de configuration et ouvrez Bienvenue dans Hermes lorsque vous avez besoin d’aide. La conversation y reste disponible.' + }, + guidedGreeting: { + line: "Salut, entrez ! Je suis Hermes. Donnez-moi deux minutes pour préparer les lieux à votre façon, puis nous nous attaquerons à quelque chose que vous voulez vraiment accomplir.\n\nMais d'abord, comment dois-je vous appeler ?", + nameSuggestion: name => `(Je peux aussi simplement vous appeler ${name}, si vous préférez.)` + }, + install: { + stageStates: { + pending: 'En attente', + running: 'Installation', + succeeded: 'Terminé', + skipped: 'Ignoré', + failed: 'Échoué' + }, + oneTimeTitle: 'Hermes nécessite une installation unique', + unsupportedDesc: platform => + `L'installation automatisée au premier lancement n'est pas encore disponible sur ${platform}. Ouvrez le Terminal et exécutez la commande ci-dessous, puis relancez cette application. Les lancements suivants ignoreront cette étape.`, + installCommand: "Commande d'installation", + copyCommand: 'Copier la commande', + viewDocs: "Voir la documentation d'installation", + installTo: 'Sera installé dans', + retryAfterRun: "Je l'ai exécuté — réessayer", + setupChoiceTitle: 'Configurer Hermes Desktop', + setupChoiceDesc: + 'Connectez cette application à un gateway Hermes que vous exécutez déjà, ou installez Hermes localement sur cet ordinateur.', + connectExistingTitle: 'Se connecter à un Hermes existant', + connectExistingShort: 'Connecter un existant', + connectExistingDesc: + 'Utilisez un backend distant avec un jeton de session ou une connexion par navigateur. Aucune installation locale ne démarrera.', + installLocalTitle: 'Installer Hermes localement', + installLocalDesc: 'Téléchargez Hermes, créez son environnement Python et exécutez le backend sur cet ordinateur.', + localStartUnavailable: "L'installation locale n'a pas pu démarrer. Redémarrez Hermes Desktop et réessayez.", + remoteSetupTitle: 'Se connecter à un Hermes existant', + remoteSetupDesc: + "Entrez l'URL du gateway. Hermes Desktop détectera s'il a besoin d'un jeton ou d'une connexion par navigateur.", + remoteUrlTitle: 'URL du gateway', + remoteUrlDesc: "Utilisez l'URL de base du gateway Hermes, y compris https:// pour les connexions distantes.", + remoteUrlPlaceholder: 'https://gateway.example.com/hermes', + probing: "Détection de l'authentification du gateway...", + probeError: "Impossible d'atteindre ce gateway Hermes.", + probeErrorDetails: 'Détails', + identityProvider: "votre fournisseur d'identité", + authTitle: 'Authentification', + authNeedsOauth: provider => `Connectez-vous avec ${provider} avant de tester ce gateway.`, + authSignedIn: 'Connexion par navigateur terminée.', + connected: 'Connecté', + signIn: 'Se connecter', + signInWith: provider => `Se connecter avec ${provider}`, + enterUrlFirst: "Entrez d'abord une URL de gateway.", + signInIncomplete: "La fenêtre de connexion s'est fermée avant la fin de l'authentification.", + tokenTitle: 'Jeton de session', + tokenDesc: 'Collez le jeton de session issu du fichier .env du gateway distant.', + pasteSessionToken: 'Coller le jeton de session', + incompleteSignInTest: 'Connectez-vous avant de tester ce gateway protégé par OAuth.', + incompleteTokenTest: 'Entrez un jeton de session avant de tester ce gateway.', + testConnection: 'Tester la connexion', + testSucceeded: (baseUrl, version) => `Connecté à ${baseUrl}${version ? ` (${version})` : ''}.`, + applyRemote: 'Appliquer et se reconnecter', + backToSetup: 'Retour', + failedTitle: "Échec de l'installation", + settingUpTitle: 'Configuration de Hermes Agent', + finishingTitle: 'Finalisation', + failedDesc: + "L'une des étapes d'installation a échoué. Sous Windows, cela peut arriver si une autre instance Hermes CLI ou desktop est en cours d'exécution. Arrêtez toutes les instances Hermes en cours, puis réessayez. Consultez les détails ci-dessous ou le journal du bureau pour la transcription complète.", + activeDesc: + "Il s'agit d'une configuration unique. Le programme d'installation de Hermes télécharge les dépendances et configure votre machine. Les lancements suivants ignoreront cette étape.", + progress: (completed, total) => `${completed} sur ${total} étapes terminées`, + currentStage: stage => ` -- actuellement : ${stage}`, + fetchingManifest: "Récupération du manifeste d'installation...", + error: 'Erreur', + hideOutput: "Masquer la sortie de l'installateur", + showOutput: "Afficher la sortie de l'installateur", + lines: count => `${count} ligne${count === 1 ? '' : 's'}`, + noOutput: 'Aucune sortie pour le moment.', + cancelling: 'Annulation...', + cancelInstall: "Annuler l'installation", + transcriptSaved: 'Transcription complète enregistrée dans', + copiedOutput: 'Copié !', + copyOutput: 'Copier la sortie', + reloadRetry: 'Recharger et réessayer', + openLogs: 'Ouvrir les journaux' + }, + onboarding: { + headerTitle: 'Configurons Hermes Agent pour vous', + headerDesc: + 'Connectez un fournisseur de modèles pour commencer à discuter. La plupart des options nécessitent un clic.', + preparingInstall: + "Hermes finalise l'installation. Cela prend généralement moins d'une minute au premier lancement.", + starting: 'Démarrage de Hermes…', + lookingUpProviders: 'Recherche des fournisseurs...', + collapse: 'Réduire', + otherProviders: 'Autres fournisseurs', + haveApiKey: 'Vous avez une clé API ?', + chooseLater: 'Je choisirai un fournisseur plus tard', + recommended: 'Recommandé', + connected: 'Connecté', + featuredPitch: 'Un abonnement, 300+ modèles de pointe — la méthode recommandée pour exécuter Hermes', + fireworksPitch: 'API de modèles directe — modèles de pointe hébergés par Fireworks', + localModelsTitle: 'Exécuter des modèles en local', + localModelsPitch: 'Aucun compte requis — téléchargez un modèle et exécutez-le sur cette machine', + openRouterPitch: 'Une clé, des centaines de modèles — une valeur par défaut solide', + apiKeyOptions: { + fireworks: { + short: 'API de modèles directe', + description: 'Accès direct aux modèles hébergés par Fireworks AI.' + }, + openrouter: { + short: 'une clé, de nombreux modèles', + description: + 'Héberge des centaines de modèles derrière une seule clé. Bonne valeur par défaut pour les nouvelles installations.' + }, + openai: { + short: 'Modèles de classe GPT', + description: 'Accès direct aux modèles OpenAI.' + }, + gemini: { + short: 'Modèles Gemini', + description: 'Accès direct aux modèles Google Gemini.' + }, + xai: { + short: 'Modèles Grok', + description: 'Accès direct aux modèles xAI Grok.' + }, + local: { + short: 'auto-hébergé', + description: + 'Pointez Hermes vers un point de terminaison local ou auto-hébergé compatible OpenAI (vLLM, llama.cpp, Ollama, etc).' + } + }, + backToSignIn: 'Retour à la connexion', + getKey: 'Obtenir une clé', + replaceCurrent: 'Remplacer la valeur actuelle', + pasteApiKey: 'Collez votre clé API', + localApiKeyPlaceholder: 'Clé API (facultatif — uniquement si votre point de terminaison en requiert une)', + couldNotSave: "Impossible d'enregistrer l'identifiant.", + connecting: 'Connexion', + update: 'Mettre à jour', + flowSubtitles: { + pkce: 'Ouvre votre navigateur pour vous connecter, puis continue ici', + device_code: 'Ouvre une page de vérification dans votre navigateur — Hermes se connecte automatiquement', + external: 'Connectez-vous une fois dans votre terminal, puis revenez discuter' + }, + startingSignIn: provider => `Démarrage de la connexion pour ${provider}...`, + verifyingCode: provider => `Vérification de votre code avec ${provider}...`, + connectedProvider: provider => `${provider} connecté`, + connectedPicking: provider => `${provider} connecté. Choix d'un modèle par défaut...`, + signInFailed: 'Échec de la connexion. Réessayez.', + signInExpired: + "La connexion a expiré dans l'attente de l'autorisation. Cela signifie généralement que la page de connexion s'est figée dans l'onglet ouvert (problème côté serveur) — terminez la connexion dans cet onglet, puis réessayez. Si le problème persiste, utilisez plutôt une clé API ou la solution de secours en ligne de commande.", + signInDidNotFinish: provider => + `La connexion avec ${provider} ne s'est pas terminée. Vérifiez votre connexion Internet et réessayez, ou choisissez un autre fournisseur.`, + tryAgain: 'Réessayer', + useApiKeyInstead: 'Utiliser une clé API', + errorDetails: 'Détails', + pickDifferentProvider: 'Choisissez un autre fournisseur', + signInWith: provider => `Se connecter avec ${provider}`, + openedBrowser: provider => `Nous avons ouvert ${provider} dans votre navigateur.`, + authorizeThere: 'Autorisez Hermes là-bas.', + copyAuthCode: "Copiez le code d'autorisation et collez-le ci-dessous.", + pasteAuthCode: "Coller le code d'autorisation", + reopenAuthPage: "Rouvrir la page d'autorisation", + autoBrowser: provider => + `Nous avons ouvert ${provider} dans votre navigateur. Autorisez Hermes là-bas et vous serez connecté automatiquement — rien à copier ou coller.`, + reopenSignInPage: 'Rouvrir la page de connexion', + waitingAuthorize: 'En attente de votre autorisation...', + externalPending: provider => + `${provider} se connecte via sa propre CLI. Exécutez cette commande dans un terminal, puis revenez et choisissez « Je me suis connecté » :`, + signedIn: 'Je me suis connecté', + deviceCodeOpened: provider => `Nous avons ouvert ${provider} dans votre navigateur. Entrez ce code là-bas :`, + reopenVerification: 'Rouvrir la page de vérification', + copy: 'Copier', + defaultModel: 'Modèle par défaut', + freeTier: 'Gratuit', + pro: 'Pro', + free: 'Gratuit', + price: (input, output) => `${input} entrant / ${output} sortant par Mtok`, + change: 'Modifier', + startChatting: 'Commencer', + docs: provider => `Documentation ${provider}` + }, + freeTier: { + providerRowTitle: 'Nous · offre gratuite', + providerRowPitch: 'Connectez-vous avec un compte Nous pour débloquer davantage de modèles et outils.', + readyTitle: 'Hermes est prêt.', + readyCaption: 'Gratuit · connecteurs inclus', + begin: 'Commencer', + signInInstead: 'Se connecter plutôt avec un compte Nous', + otherProviders: 'Autres fournisseurs', + stripTitle: "L'inférence Nous gratuite et les connecteurs sont maintenant disponibles.", + stripBody: 'Ouvrez le sélecteur de modèle pour les essayer ou connectez-vous avec un compte Nous.', + openModelPicker: 'Ouvrir le sélecteur de modèle', + dismiss: 'Fermer', + providerName: 'Nous', + statusLabel: model => `Nous · ${model}`, + signIn: 'Se connecter', + signInHeading: 'Connectez-vous avec un compte Nous pour débloquer davantage de modèles et outils.', + settingUp: "Configuration de l'inférence gratuite…", + codeBody: 'Saisissez ce code dans votre navigateur pour terminer la connexion.', + copyLink: 'Copier le lien', + doNotShare: 'Ne partagez pas ce code.', + waiting: 'En attente de la connexion…', + finishingHeading: 'Finalisation de la connexion…', + finishingBody: 'Autorisation accordée dans le navigateur. Récupération des jetons de votre compte.', + signedInAs: email => `Connecté en tant que ${email}`, + signedIn: 'Connecté.', + completedBody: "Votre compte donne maintenant accès à l'inférence et aux outils.", + defaultModel: 'Modèle par défaut', + change: 'Modifier', + done: 'Terminé', + notNow: 'Pas maintenant', + tryAgain: 'Réessayer', + startAgain: 'Recommencer', + didNotComplete: "La connexion n'a pas abouti", + rejectedBody: "La connexion a été refusée dans le navigateur. Vous restez sur l'offre gratuite.", + supersededBody: 'Un code de connexion plus récent a remplacé celui-ci.', + timedOutHeading: 'Délai de connexion dépassé', + timedOutBody: "Le code n'a pas été utilisé à temps. Vous restez sur l'offre gratuite.", + retiredBody: + "Cette identité d'offre gratuite a déjà été utilisée ou a expiré ; une nouvelle sera créée au prochain démarrage.", + errorBody: "La connexion n'a pas abouti ; relancez-la.", + busyHeading: 'Presque terminé', + busyBody: wait => + `Hermes n'a pas pu terminer votre connexion car le service Nous est occupé. Réessayez dans ${wait}. Votre session reste disponible entre-temps.`, + unreachableBody: + "Hermes n'a pas pu joindre le service Nous pour terminer votre connexion. Vérifiez votre connexion Internet et réessayez. Votre session reste disponible.", + alreadySignedInHeading: 'Déjà connecté.', + alreadySignedInBody: 'Cette installation Hermes est déjà connectée à un compte Nous.', + setupFailed: { + gateClosed: + 'Cette version de Hermes ne peut pas démarrer sans compte Nous. Connectez-vous ou créez-en un gratuitement en une minute.', + paused: + "L'utilisation de Hermes sans connexion est momentanément suspendue. Hermes continuera à vérifier. La connexion est gratuite et vous permet de continuer immédiatement.", + rateLimited: wait => + `Beaucoup de personnes démarrent en ce moment ; Hermes réessaiera dans ${wait}. La connexion est gratuite et évite l'attente.`, + unreachable: + "Hermes n'a pas pu joindre le service Nous. Vérifiez votre connexion Internet, puis appuyez sur Réessayer. Vous pouvez aussi connecter un autre fournisseur.", + serverError: + 'Le service Nous a rencontré un problème. Réessayez dans un instant ou connectez un autre fournisseur.', + powRequired: + "Le serveur Nous a demandé une preuve de travail qui n'est pas encore gérée par votre Agent. Connectez-vous ou créez un compte Nous gratuit pour continuer.", + locked: + 'Cette session ne peut pas continuer sans connexion. Connectez-vous ou créez un compte Nous gratuit pour poursuivre.', + generic: + "Hermes n'a pas pu configurer l'accès gratuit sans connexion. Connectez-vous gratuitement ou choisissez un autre fournisseur.", + signInBelow: 'La connexion est gratuite. Choisissez Nous ci-dessous.', + tryAgain: 'Réessayer', + retrying: 'Nouvelle tentative…' + } + }, + modelPicker: { + title: 'Changer de modèle', + current: 'actuel :', + unknown: '(inconnu)', + search: 'Filtrer les fournisseurs et modèles...', + noModels: 'Aucun modèle trouvé.', + addProvider: 'Ajouter un fournisseur', + loadFailed: 'Impossible de charger les modèles', + loadingIntoMemory: 'Chargement en mémoire', + downloading: 'Téléchargement', + localDownloadsHeading: 'Local', + noAuthenticatedProviders: 'Aucun fournisseur authentifié.', + pro: 'Pro', + proNeedsSubscription: 'Les modèles Pro nécessitent un abonnement payant Nous.', + free: 'Gratuit', + freeTier: 'Gratuit', + priceTitle: 'Prix entrant / sortant par million de jetons', + wasPrice: 'était', + customModel: 'Modèle personnalisé', + addCustomModelAction: 'Ajouter un modèle personnalisé…', + customModelPlaceholder: 'Saisissez un ID de modèle, p. ex. openai/gpt-5' + }, + modelVisibility: { + title: 'Modèles', + search: 'Rechercher des modèles', + noAuthenticatedProviders: 'Aucun fournisseur authentifié.', + addProvider: 'Ajouter un fournisseur…', + addCustomModel: 'Ajouter un modèle personnalisé', + removeCustomModel: 'Retirer le modèle personnalisé' + }, + shell: { + windowControls: 'Contrôles de fenêtre', + paneControls: 'Contrôles de panneau', + appControls: "Contrôles d'application", + modelMenu: { + search: 'Rechercher des modèles', + noModels: 'Aucun modèle trouvé', + editModels: 'Modifier les modèles…', + refreshModels: 'Actualiser les modèles', + fast: 'Rapide' + }, + modelOptions: { + noOptions: 'Aucune option pour ce modèle', + options: 'Options', + thinking: 'Réflexion', + fast: 'Rapide', + effort: 'Effort', + minimal: 'Minimal', + low: 'Faible', + medium: 'Moyen', + high: 'Élevé', + xhigh: 'Très élevé', + max: 'Max', + ultra: 'Ultra', + sendsOnRoute: (level: string) => `envoie ${level} sur cette route`, + updateFailed: "Échec de la mise à jour de l'option du modèle", + fastFailed: 'Échec de la mise à jour du mode rapide' + }, + gatewayMenu: { + gateway: 'Gateway', + connected: 'Connecté', + connecting: 'Connexion', + offline: 'Hors ligne', + inferenceReady: 'Inférence prête', + inferenceNotReady: 'Inférence non prête', + checkingInference: "Vérification de l'inférence", + disconnected: 'Déconnecté', + reconnectGateway: 'Reconnecter le gateway', + openSystem: 'Ouvrir le panneau système', + connection: label => `Connexion : ${label}`, + recentActivity: 'Activité récente', + viewAllLogs: 'Voir tous les journaux →', + messagingPlatforms: 'Plateformes de messagerie' + }, + approvalMode: { + title: "Mode d'approbation", + ariaLabel: mode => `Mode d'approbation : ${mode}`, + manual: 'Manuel', + manualDescription: 'Demander avant les actions nécessitant une approbation', + smart: 'Intelligent', + smartDescription: 'Évaluer automatiquement les actions et demander quand nécessaire', + off: 'Désactivé', + offDescription: "Exécuter sans invites d'approbation" + }, + statusbar: { + unknown: 'inconnu', + restart: 'redémarrage', + update: 'mise à jour', + updateInProgress: 'Mise à jour en cours', + commitsBehind: (count, branch) => `${count} commit${count === 1 ? '' : 's'} en retard sur ${branch}`, + desktopVersion: version => `Hermes Desktop v${version}`, + backendVersion: version => `Backend v${version}`, + clientLabel: version => `client v${version}`, + connectionSsh: host => `SSH : ${host}`, + connectionRemote: host => `Distant : ${host}`, + connectionCloud: host => `Cloud : ${host}`, + connectionCloudTooltip: host => `Hermes Cloud · ${host}`, + connectionSshTooltip: host => `SSH · ${host}`, + connectionRemoteTooltip: host => `Distant · ${host}`, + backendLabel: version => `backend v${version}`, + commit: sha => `commit ${sha}`, + branch: branch => `branche ${branch}`, + closeCommandCenter: 'Fermer le centre de commandes', + openCommandCenter: 'Ouvrir le centre de commandes', + showTerminal: 'Afficher le terminal', + hideTerminal: 'Masquer le terminal', + gateway: 'Gateway', + gatewayReady: 'prêt', + gatewayNeedsSetup: 'nécessite une configuration', + gatewayUnavailable: 'inférence indisponible', + gatewayChecking: 'vérification', + gatewayConnecting: 'connexion', + gatewayOffline: 'hors ligne', + gatewayRestarting: 'redémarrage…', + gatewayTitle: 'Gateway', + customizeTitle: "Afficher dans la barre d'état", + hideStatusbar: "Masquer la barre d'état", + resetStatusbar: 'Réinitialiser les valeurs par défaut', + toggleApprovalMode: 'Approbations', + toggleBackendVersion: 'Version du backend', + toggleCacheHitRate: 'Taux de cache', + toggleCommandCenter: 'Centre de commandes', + toggleContextUsage: 'Jauge de contexte', + toggleRunningTimer: 'Minuteur de tour', + toggleSessionTimer: 'Minuteur de session', + toggleTerminal: 'Terminal', + toggleTokensPerSecond: 'Tokens par seconde', + toggleVersion: 'Version et mises à jour', + toggleFreeTier: 'Offre gratuite', + toggleWorkspace: 'Espace de travail', + cacheHitRateTitle: + 'Taux de cache des prompts pour cette session — les tokens en cache coûtent moins cher, un taux élevé est donc plus économique', + tokensPerSecondTitle: 'Tokens de sortie par seconde, moyenne calculée sur les 10 derniers appels au modèle', + agents: 'Agents', + closeAgents: 'Fermer les agents', + openAgents: 'Ouvrir les agents', + subagents: count => `${count} sous-agent${count === 1 ? '' : 's'}`, + failed: count => `${count} en échec`, + running: count => `${count} en cours`, + cron: 'Cron', + openCron: 'Ouvrir les tâches cron', + webhooks: 'Webhooks', + openWebhooks: 'Ouvrir les webhooks', + starmap: 'Graphique de mémoire', + openStarmap: 'Ouvrir le graphique de mémoire', + turnRunning: 'En cours', + contextUsage: 'Utilisation du contexte', + systemResources: { + title: 'Ressources système', + loading: 'Ressources…', + gpuUtilization: 'Utilisation du GPU', + gpuMemory: 'Mémoire GPU', + ram: 'RAM', + unifiedNote: 'Mémoire unifiée — le GPU et le système partagent cet espace.', + toggle: 'Ressources système' + }, + contextUsagePanel: { + categories: { + conversation: 'Conversation', + mcp: 'MCP', + memory: 'Mémoire', + rules: 'Règles', + skills: 'Skills', + subagent_definitions: 'Définitions de sous-agents', + system_prompt: 'Invite système', + tool_definitions: "Définitions d'outils" + }, + empty: 'Aucune donnée de contexte pour le moment', + loading: 'Chargement du détail…', + percentFull: percent => `${percent}% plein`, + title: 'Utilisation du contexte', + tokenSummary: (used, max) => `${used} / ${max} jetons` + }, + session: 'Session', + yoloOn: 'YOLO activé — approbation automatique des commandes dangereuses. Shift+clic pour basculer globalement.', + yoloOff: 'YOLO désactivé. Shift+clic pour basculer globalement.', + modelNone: 'aucun', + noModel: 'aucun modèle', + switchModel: 'Changer de modèle', + openModelPicker: 'Ouvrir le sélecteur de modèles', + modelPinned: + "épinglé par vous ; les nouvelles conversations l'utilisent au lieu de la valeur par défaut des paramètres", + modelTitle: (provider, model) => `Modèle · ${provider} : ${model}`, + providerModelTitle: (provider, model) => `${provider} · ${model}` + } + }, + rightSidebar: { + aria: 'Barre latérale droite', + panelsAria: 'Panneaux de la barre latérale droite', + files: 'Système de fichiers', + terminal: 'Terminal', + noFolderSelected: 'Aucun dossier sélectionné', + changeCwdTitle: 'Changer le répertoire de travail', + remotePickerTitle: 'Choisir un dossier distant', + remotePickerDescription: 'Parcourez les dossiers sur le backend connecté.', + remotePickerSelect: 'Sélectionner le dossier', + folderTip: cwd => cwd, + openFolder: 'Ouvrir le dossier', + refreshTree: "Actualiser l'arbre", + collapseAll: 'Réduire tous les dossiers', + showIgnored: 'Afficher les fichiers ignorés par Git', + hideIgnored: 'Masquer les fichiers ignorés par Git', + previewUnavailable: 'Aperçu indisponible', + couldNotPreview: path => `Impossible d'apercevoir ${path}`, + noProjectTitle: 'Aucun projet', + noProjectBody: 'Ouvrez un projet pour parcourir ses fichiers et revoir les modifications.', + noProjectOpen: 'Aucun projet ouvert', + noDiffs: 'Aucune différence', + unreadableTitle: 'Illisible', + unreadableBody: error => `Impossible de lire ce dossier (${error}).`, + emptyTitle: 'Vide', + emptyBody: 'Ce dossier est vide.', + treeErrorTitle: "Erreur de l'arbre", + treeErrorBody: "L'arborescence des fichiers a rencontré une erreur lors du rendu de ce dossier.", + tryAgain: 'Réessayer', + loadingTree: "Chargement de l'arborescence des fichiers", + loadingFiles: 'Chargement des fichiers', + terminalHide: 'Masquer le terminal', + terminalsAria: 'Terminaux', + terminalNew: 'Nouveau terminal', + terminalCloseOthers: 'Fermer les autres', + terminalCloseAll: 'Tout fermer', + addToChat: 'Ajouter à la conversation' + }, + preview: { + tab: 'Aperçu', + closePane: "Fermer le panneau d'aperçu", + loading: "Chargement de l'aperçu", + unavailable: 'Aperçu indisponible', + opening: 'Ouverture...', + hide: 'Masquer', + openPreview: "Ouvrir l'aperçu", + openInBrowser: 'Ouvrir dans le navigateur', + openInExternal: 'Ouvrir dans une application externe', + popIn: 'Réintégrer', + popOut: 'Détacher', + linkHint: "⌘/Ctrl-clic pour le panneau d'aperçu", + sourceLineTitle: 'Clic pour sélectionner · shift-clic pour étendre · glisser vers le compositeur', + source: 'SOURCE', + renderedPreview: 'APERÇU', + diff: 'DIFF', + unknownSize: 'taille inconnue', + binaryTitle: 'Cela ressemble à un fichier binaire', + binaryBody: label => `L'aperçu de ${label} peut afficher du texte illisible.`, + largeTitle: 'Ce fichier est volumineux', + largeBody: (label, size) => `${label} fait ${size}. Hermes n'affichera que les 512 Ko premiers.`, + previewAnyway: 'Aperçu quand même', + truncated: 'Affichage des 512 Ko premiers.', + noInlineTitle: 'Aucun aperçu en ligne', + noInlineBody: mimeType => `${mimeType || 'Ce type de fichier'} peut tout de même être joint en tant que contexte.`, + edit: 'Modifier', + editing: 'Modification', + unsavedChanges: 'Modifications non enregistrées', + saveFailed: message => `Impossible d'enregistrer : ${message}`, + diskChangedTitle: 'Fichier modifié sur le disque', + diskChangedBody: + "Ce fichier a changé depuis que vous l'avez ouvert. L'écraser avec votre version, ou abandonner vos modifications et recharger ?", + overwrite: 'Écraser', + discardReload: 'Abandonner et recharger', + console: { + deselect: "Désélectionner l'entrée", + select: "Sélectionner l'entrée", + copyFailed: 'Impossible de copier la sortie de la console', + copyEntry: 'Copier cette entrée', + sendEntry: 'Envoyer cette entrée à la conversation', + messages: count => `${count} messages de console`, + resize: "Redimensionner la console d'aperçu", + title: "Console d'aperçu", + selected: count => `${count} sélectionné(s)`, + sendToChat: 'Envoyer à la conversation', + copySelected: 'Copier la sélection dans le presse-papiers', + copyAll: 'Tout copier dans le presse-papiers', + copy: 'Copier', + clear: 'Effacer', + empty: 'Aucun message de console pour le moment.', + promptHeader: "Console d'aperçu :", + sentTitle: 'Envoyé à la conversation', + sentMessage: count => `Ajout au compositeur : ${count} message${count === 1 ? '' : 's'} de console` + }, + web: { + appFailedToBoot: "Échec du démarrage de l'application d'aperçu", + serverNotFound: 'Serveur non trouvé', + remoteLoopback: + "Cette adresse pointe vers la machine qui exécute votre agent, pas vers celle-ci. Le panneau du navigateur charge les pages localement ; un serveur de développement distant nécessite donc une redirection de port ou un nom d'hôte accessible.", + failedToLoad: "Échec du chargement de l'aperçu", + tryAgain: 'Réessayer', + restarting: 'Hermes redémarre...', + askRestart: 'Demander à Hermes de redémarrer le serveur', + lookingRestart: taskId => `Hermes recherche un serveur d'aperçu à redémarrer (${taskId})`, + restartingTitle: "Redémarrage du serveur d'aperçu", + restartingMessage: "Hermes travaille en arrière-plan. Surveillez la console d'aperçu pour suivre la progression.", + startRestartFailed: message => `Impossible de démarrer le redémarrage du serveur : ${message}`, + restartFailed: 'Échec du redémarrage du serveur', + hideConsole: "Masquer la console d'aperçu", + showConsole: "Afficher la console d'aperçu", + hideDevTools: "Masquer les outils de développement d'aperçu", + openDevTools: "Ouvrir les outils de développement d'aperçu", + goBack: 'Retour', + goForward: 'Suivant', + reload: 'Recharger la page', + address: 'Adresse', + addressPlaceholder: 'Saisir une adresse', + blankPageBody: "Saisissez une adresse ci-dessus pour naviguer, ou demandez à Hermes d'ouvrir une page.", + finishedRestarting: message => + `Hermes a terminé le redémarrage du serveur d'aperçu${message ? `: ${message}` : ''}`, + failedRestarting: message => `Échec du redémarrage du serveur : ${message}`, + unknownError: 'erreur inconnue', + restartedTitle: "Serveur d'aperçu redémarré", + reloadingNow: "Rechargement de l'aperçu maintenant.", + restartFailedTitle: "Échec du redémarrage de l'aperçu", + restartFailedMessage: "Hermes n'a pas pu redémarrer le serveur.", + stillWorking: + "Hermes travaille toujours, mais aucun résultat de redémarrage n'est arrivé. La commande du serveur peut être en cours d'exécution au premier plan.", + workspaceReloading: "Espace de travail modifié, rechargement de l'aperçu", + fileChanged: url => `Fichier modifié, rechargement de l'aperçu : ${url}`, + filesChanged: (count, url) => `${count} modifications de fichier, rechargement de l'aperçu : ${url}`, + watchFailed: message => `Impossible de surveiller le fichier d'aperçu : ${message}`, + moduleMimeDescription: + "Les scripts de module sont servis avec le mauvais type MIME. Cela signifie généralement qu'un serveur de fichiers statiques sert une application Vite/React au lieu du serveur de développement du projet.", + loadFailedConsole: (code, message) => `Échec du chargement${code ? ` (${code})` : ''} : ${message}`, + unreachableDescription: "La page d'aperçu n'a pas pu être atteinte.", + openTarget: url => `Ouvrir ${url}`, + fallbackTitle: 'Aperçu', + annotate: 'Annoter', + annotateOn: "Arrêter l'annotation", + annotateNeedPage: "Ouvrez d'abord une page dans le navigateur intégré.", + annotateFailed: "Impossible de démarrer le mode d'annotation", + commenting: 'Ajout de commentaires', + addComments: count => (count === 1 ? 'Ajouter 1 commentaire' : `Ajouter ${count} commentaires`), + commentPlaceholder: 'Ajouter un commentaire...', + commentTitle: n => `Commentaire ${n}`, + saveComment: 'Enregistrer', + cancelComment: 'Annuler le commentaire' + } + }, + interfaceMode: { + title: 'Mode d’interface', + hint: 'Modifie ce qui est affiché, pas ce que Hermes peut faire.', + sessionNote: + 'Défini par le mode Simple. Une modification ici dure le temps de cette session ; passez en mode Avancé pour la conserver.', + simple: { + label: 'Simple', + description: + 'Pour discuter avec Hermes. Barre latérale et conversation ; pas de panneaux terminal, fichiers ou diff.' + }, + advanced: { + label: 'Avancé', + description: 'Pour les développeurs. Terminal, fichiers, diffs, barre d’état et dispositions, selon vos réglages.' + } + }, + zones: { + showTabStrip: 'Afficher les onglets', + hideTabStrip: 'Masquer les onglets', + showStripTab: title => `Afficher ${title}`, + hideStripTab: title => `Masquer ${title}`, + lastTabKeptTitle: 'Le dernier onglet reste affiché', + lastTabKeptBody: + "Cette zone doit conserver au moins un onglet visible. Affichez d'abord un autre onglet ou repliez toute la barre latérale.", + toggleStripTab: title => `Afficher ou masquer l'onglet ${title}`, + minimize: 'Réduire', + restore: 'Restaurer', + closeRunningTitle: "Fermer l'onglet en cours ?", + closeRunningBody: + "Cette conversation est toujours en cours (ou en attente de votre saisie). Fermer l'onglet la masque — la session conserve sa progression et peut être rouverte depuis la barre latérale.", + closeRunningConfirm: "Fermer l'onglet", + reload: 'Recharger', + closeOthers: 'Fermer les autres', + closeToRight: 'Fermer à droite', + closeAll: 'Tout fermer', + newSessionTab: 'Nouvel onglet de session', + newTab: 'Nouvel onglet', + pluginDisabled: pluginId => `Plugin « ${pluginId} » désactivé`, + pluginDisabledBody: 'Réactivez-le dans Paramètres → Plugins pour faire revenir le panneau.', + missingPane: paneId => `panneau manquant : ${paneId}`, + editTitle: 'Mises en page', + editHint: + 'Choisissez une mise en page ou faites glisser les panneaux entre les zones. Clic droit sur une zone pour la diviser.', + reset: 'Réinitialiser', + templates: 'Modèles', + custom: 'Personnalisé', + newGridLayout: 'Nouvelle mise en page en grille', + saveCurrentAs: "Enregistrer l'agencement actuel en tant que modèle", + nameLayoutPlaceholder: 'Nommez cette mise en page…', + deletePreset: name => `Supprimer ${name}`, + zoneEditorTitle: 'Éditeur de zones', + editorHintPre: 'clic pour diviser · ', + editorHintPost: + ' inverse la ligne · glisser sur les zones pour fusionner · glisser les bords partagés pour redimensionner', + templateColumns: 'Colonnes', + templateRows: 'Lignes', + templateGrid: 'Grille', + templatePriority: 'Priorité', + zoneTag: index => `zone ${index}`, + mergeZones: count => `Fusionner ${count} zones`, + customZoneName: count => `Personnalisé ${count}-zones`, + layoutNamePlaceholder: fallback => `Nom de la mise en page (${fallback})`, + saveApply: 'Enregistrer et appliquer', + notExpressible: "cet agencement s'entrelace (roue) — pas encore exprimable comme des divisions imbriquées", + zoneCount: count => `${count} zones`, + tabCount: count => `${count} onglet${count === 1 ? '' : 's'}` + }, + contextMenu: { + link: { + openInApp: 'Ouvrir dans le navigateur intégré', + openExternal: 'Ouvrir dans le navigateur externe', + copyUrl: "Copier l'URL", + copyResolvedUrl: "Copier l'URL résolue" + }, + image: { + copyImage: "Copier l'image", + copyImageAddress: "Copier l'adresse de l'image", + saveImageAs: "Enregistrer l'image sous…" + }, + edit: { + cut: 'Couper', + paste: 'Coller', + selectAll: 'Tout sélectionner', + addToDictionary: 'Ajouter au dictionnaire' + }, + page: { + copyPageUrl: "Copier l'URL de la page", + inspectElement: "Inspecter l'élément" + } + }, + assistant: { + thread: { + loadingSession: 'Chargement de la session', + showEarlier: 'Afficher les messages précédents', + loadingResponse: 'Hermes charge une réponse', + loadingLocalModel: model => `Chargement de ${model} en mémoire`, + processingPrompt: "Traitement de l'invite", + resumeWhenBackgroundDone: count => + count === 1 + ? 'Reprendra une fois la tâche en arrière-plan terminée' + : `Reprendra une fois que ${count} tâches en arrière-plan seront terminées`, + thinking: 'Réflexion en cours', + thought: 'A réfléchi', + thoughtBriefly: 'A réfléchi brièvement', + thoughtFor: duration => `A réfléchi pendant ${duration}`, + turnDuration: duration => `Ce tour a duré ${duration}`, + today: time => `Aujourd'hui, ${time}`, + yesterday: time => `Hier, ${time}`, + copy: 'Copier', + refresh: 'Actualiser', + moreActions: "Plus d'actions", + branchNewChat: 'Créer une branche dans une nouvelle conversation', + react: 'Réagir', + dismissError: "Ignorer l'erreur", + errorLayers: { + auth: "Erreur d'authentification", + billing: 'Crédits épuisés', + disk: 'Disque plein', + endpoint: 'Erreur du point de terminaison personnalisé', + gateway: 'Erreur du gateway', + generic: 'Échec du tour', + provider: 'Erreur du fournisseur', + runtime: "Erreur d'exécution locale", + streaming: 'Erreur de connexion au flux' + }, + errorLayerBodies: { + auth: "Le service d'IA a refusé votre connexion. Vérifiez les identifiants de ce fournisseur, puis renvoyez votre message.", + billing: + "Votre compte n'a plus de crédits chez ce fournisseur. Rechargez-le ou changez de fournisseur, puis réessayez.", + disk: "Votre disque est plein ; Hermes n'a pas pu enregistrer cette conversation. Libérez de l'espace, puis réessayez.", + endpoint: + "Hermes ne parvient pas à joindre votre serveur de modèle personnalisé. Vérifiez qu'il fonctionne, puis renvoyez votre message.", + gateway: + 'Hermes a rencontré un problème interne au démarrage de cette réponse. Renvoyez votre message ; si cela persiste, envoyez les diagnostics.', + generic: + "Une erreur s'est produite pendant la réponse de Hermes. Réessayez ou copiez les détails si cela persiste.", + provider: + "Le service d'IA n'a pas pu traiter cette demande. Réessayez dans un instant ou changez de fournisseur.", + runtime: + 'Hermes a rencontré un problème interne au démarrage de cette réponse. Renvoyez votre message ; si cela persiste, envoyez les diagnostics.', + streaming: 'La connexion a été interrompue avant la fin de la réponse. Réessayez pour la renvoyer.' + }, + errorCodes: { + auth: { + title: provider => `${provider} a refusé votre connexion`, + body: provider => + `Les identifiants enregistrés pour ${provider} ont été refusés. Corrigez-les dans Paramètres ou changez de fournisseur, puis réessayez.` + }, + auth_permanent: { + title: provider => `${provider} a refusé votre connexion`, + body: provider => + `Les identifiants enregistrés pour ${provider} sont invalides ou révoqués. Mettez-les à jour ou changez de fournisseur, puis réessayez.` + }, + billing: { + title: 'Crédits épuisés', + body: provider => + `Votre compte ${provider} n'a plus de crédits. Rechargez-le ou changez de fournisseur, puis réessayez.` + }, + rate_limit: { + title: "Le service d'IA est occupé", + body: provider => `${provider} limite actuellement les demandes. Attendez une minute, puis réessayez.` + }, + upstream_rate_limit: { + title: "Le service d'IA est occupé", + body: provider => `${provider} limite actuellement les demandes. Attendez une minute, puis réessayez.` + }, + overloaded: { + title: "Le service d'IA est surchargé", + body: provider => + `${provider} rencontre des difficultés. Réessayez dans un instant ou changez de fournisseur.` + }, + server_error: { + title: "Le service d'IA a rencontré un problème", + body: provider => + `${provider} a renvoyé une erreur serveur. Réessayez dans un instant ou changez de fournisseur.` + }, + timeout: { + title: 'Délai de réponse dépassé', + body: provider => `${provider} n'a pas répondu à temps. Réessayez pour renvoyer le message.` + }, + stream_drop: { + title: 'La réponse a été interrompue', + body: 'La connexion a été coupée avant la fin de la réponse. Réessayez pour la renvoyer.' + }, + upstream_blocked: { + title: 'Un pare-feu a bloqué la requête', + body: (provider: string) => + `Un pare-feu ou un CDN placé devant ${provider} a bloqué la requête avant qu’elle n’atteigne le modèle — votre clé est probablement valide. Définissez un en-tête User-Agent via les extra_headers du fournisseur dans Paramètres, ou changez de fournisseur, puis renvoyez votre message.` + }, + ssl_cert_verification: { + title: 'Échec de la connexion sécurisée', + body: provider => + `Hermes n'a pas pu vérifier la connexion sécurisée à ${provider}. Vérifiez votre réseau ou votre proxy, ou changez de fournisseur.` + }, + context_overflow: { + title: 'Cette conversation est trop longue', + body: 'La conversation ne tient plus dans le modèle. Compressez-la ou démarrez-en une nouvelle, puis réessayez.' + }, + payload_too_large: { + title: 'Ce message est trop volumineux', + body: 'La demande était trop grande pour le modèle. Compressez la conversation ou démarrez-en une nouvelle, puis réessayez.' + }, + model_not_found: { + title: "Ce modèle n'est pas disponible", + body: provider => + `${provider} ne propose pas ce modèle pour votre compte. Choisissez-en un autre, puis réessayez.` + }, + provider_policy_blocked: { + title: 'Ce modèle est bloqué par les paramètres de votre compte', + body: provider => + `${provider} n'a pas routé cette demande avec vos paramètres de données ou de confidentialité. Choisissez un autre modèle ou fournisseur.` + }, + content_policy_blocked: { + title: "Le service d'IA a refusé cette demande", + body: provider => `${provider} n'a pas répondu à ce message. Modifiez-le puis renvoyez-le.` + }, + format_error: { + title: "Le service d'IA a rejeté la demande", + body: provider => + `${provider} n'a pas accepté la forme de cette demande. Changez de fournisseur ou envoyez les diagnostics.` + }, + truncated: { + title: 'La réponse a été écourtée', + body: "Le modèle s'est arrêté avant la fin. Réessayez pour obtenir une réponse complète." + }, + invalid_response: { + title: "Le service d'IA a envoyé une réponse illisible", + body: provider => `${provider} a renvoyé une réponse que Hermes n'a pas pu lire. Réessayez dans un instant.` + }, + empty_response: { + title: "Le service d'IA a envoyé une réponse vide", + body: provider => `${provider} n'a rien renvoyé pour ce message. Réessayez dans un instant.` + }, + loop_error: { + title: 'Hermes est resté bloqué dans une boucle', + body: 'La réponse répétait les mêmes étapes ; Hermes l’a donc arrêtée. Réessayez ou démarrez une nouvelle conversation.' + }, + SESSION_NOT_OWNED: { + title: 'Cette conversation est ouverte ailleurs', + body: 'Cette conversation est déjà ouverte dans une autre fenêtre Hermes ou un terminal. Fermez-la là-bas puis réessayez, ou démarrez-en une nouvelle ici.' + }, + disk_full: { + title: 'Disque plein', + body: "Votre disque est plein ; Hermes n'a pas pu enregistrer cette conversation. Libérez de l'espace puis réessayez." + }, + free_tier_disabled: { + title: "L'utilisation de Hermes sans connexion est désactivée pour le moment", + body: 'Connectez-vous avec un compte Nous gratuit pour continuer.' + }, + free_tier_rate_limited: { + title: 'Vous avez épuisé le quota sans connexion', + body: 'Il sera bientôt renouvelé. Connectez-vous avec un compte Nous gratuit pour obtenir un quota plus élevé.' + }, + free_tier_at_capacity: { + title: 'Le service sans connexion est très sollicité', + body: 'Connectez-vous gratuitement pour éviter la file, ou réessayez un peu plus tard.' + }, + free_tier_model_not_free: { + title: "Ce modèle n'est pas disponible sans connexion", + body: 'Hermes utilise le modèle gratuit pour le moment. Connectez-vous avec un compte Nous gratuit pour accéder à plus de modèles.' + }, + free_tier_route: { + title: "Hermes n'a pas pu joindre le modèle gratuit par cette route", + body: 'Connectez-vous avec un compte Nous gratuit ou vérifiez le paramètre NOUS_INFERENCE_BASE_URL.' + }, + free_tier_outage: { + title: 'Le modèle gratuit rencontre des difficultés', + body: 'Renvoyez votre message dans une minute.' + }, + free_tier_refused: { + title: "Hermes n'a pas pu envoyer ce message sans connexion", + body: 'La connexion avec un compte Nous est gratuite.' + } + }, + errorAuthKinds: { + api_key: { + title: provider => `${provider} a refusé votre clé API`, + body: provider => + `La clé enregistrée pour ${provider} est invalide ou révoquée. Mettez-la à jour, puis réessayez.` + }, + oauth: { + title: provider => `Votre connexion à ${provider} a expiré` + } + }, + errorDetails: 'Détails', + errorGenericProvider: "Le service d'IA", + errorToastTitle: "Hermes n'a pas pu terminer la réponse", + errorRetry: 'Réessayer', + errorLimitResets: (time: string) => `Le quota se réinitialise à ${time}`, + errorRetryAtReset: (time: string) => `Réessayer à la réinitialisation du quota (${time})`, + errorRetryScheduled: (time: string, wait: string) => `Nouvel essai à ${time} — dans ${wait}`, + errorRetryScheduledCancel: 'Annuler', + errorStartNewSession: 'Démarrer une nouvelle session', + errorSwitchProvider: 'Changer de fournisseur', + errorChooseModel: 'Choisir un modèle', + errorCompressConversation: 'Compresser la conversation', + errorCompressFailed: 'Impossible de compresser la conversation', + errorOpenHermesFolder: 'Ouvrir le dossier Hermes', + errorOpenHermesFolderFailed: "Impossible d'ouvrir le dossier Hermes", + errorUpdateApiKey: 'Mettre à jour la clé API', + errorSignInAgain: provider => `Se reconnecter à ${provider}`, + errorSignInFreeTier: 'Se connecter avec un compte Nous', + errorOauthExpired: provider => + `Votre connexion à ${provider} a expiré ou a été révoquée. Reconnectez-vous pour continuer la conversation.`, + errorOpenLogs: 'Ouvrir les journaux', + errorOpenLogsFailed: "Impossible d'ouvrir le dossier des journaux", + errorOpenDesktopLogs: 'Ouvrir les journaux du Desktop', + errorCopyDiagnostics: "Copier les détails de l'erreur", + errorSendDiagnostics: 'Envoyer les diagnostics', + filesChanged: count => (count === 1 ? '1 fichier modifié' : `${count} fichiers modifiés`), + reviewChanges: 'Examiner', + readAloudFailed: 'Échec de la lecture à voix haute', + preparingAudio: "Préparation de l'audio…", + stopReading: 'Arrêter la lecture', + readAloud: 'Lire à voix haute', + editMessage: 'Modifier le message', + expandMessage: 'Développer le message', + scrollToBottom: 'Défiler vers le bas', + stop: 'Arrêter', + restorePrevious: 'Restaurer le point de contrôle précédent', + restoreCheckpoint: 'Restaurer le point de contrôle', + restoreFromHere: 'Restaurer le point de contrôle — relancer à partir de cette invite', + restoreTitle: 'Restaurer ce point de contrôle ?', + restoreBody: + "Tout ce qui suit cette invite est retiré de la conversation, puis l'invite est relancée à partir d'ici.", + restoreConfirm: 'Restaurer et relancer', + restoreNext: 'Restaurer le point de contrôle suivant', + goForward: 'Avancer', + sendEdited: 'Envoyer le message modifié', + attachingFile: 'Ajout en cours…' + }, + approval: { + gatewayDisconnected: "La Gateway Hermes n'est pas connectée", + sendFailed: "Impossible d'envoyer la réponse d'approbation", + reconnect: 'Se reconnecter', + timedOutSystemLine: + "Le délai d'approbation a expiré — la commande n'a pas été exécutée. Demandez à Hermes de réessayer ou augmentez la limite dans Paramètres → Sécurité → Délai d'approbation.", + openSafetySettings: 'Ouvrir les paramètres de sécurité', + run: 'Exécuter', + command: 'Commande', + moreOptions: "Plus d'options d'approbation", + allowSession: 'Autoriser cette session', + alwaysAllowMenu: 'Toujours autoriser…', + jumpToApproval: 'Approbation requise', + reject: 'Rejeter', + alwaysTitle: 'Toujours autoriser cette commande ?', + alwaysDescription: pattern => + `Cela ajoute le motif « ${pattern} » à votre liste d'autorisation permanente (~/.hermes/config.yaml). Hermes ne redemandera plus pour ce type de commande — que ce soit dans cette session ou dans une session future.`, + alwaysAllow: 'Toujours autoriser' + }, + clarify: { + notReady: "La demande de clarification n'est pas encore prête", + gatewayDisconnected: "La Gateway Hermes n'est pas connectée", + sendFailed: "Impossible d'envoyer la réponse de clarification", + loadingQuestion: 'Chargement de la question…', + other: 'Autre (saisissez votre réponse)', + placeholder: 'Saisissez votre réponse…', + skip: 'Passer', + skipped: 'Ignoré', + continueLabel: 'Continuer', + confirmAndContinueLabel: 'Confirmer et continuer', + answeredBadge: 'Répondu', + questionProgress: (answered, total) => `${answered} réponse${answered === 1 ? '' : 's'} sur ${total}`, + lateAnswer: (question, choice) => `Re : « ${question} » — ma réponse : ${choice}`, + lateAnswerTip: 'Rédiger cette réponse comme message de suivi', + lateAnswerHint: + "Cette invite n'attend plus de réponse. Choisissez une option pour la rédiger comme message de suivi." + }, + catalogInstall: { + preparing: 'Préparation de l’installation…', + install: 'Installer', + advanced: 'Avancé', + skip: 'Ignorer', + installing: 'Installation…', + installed: 'Installé', + notInstalled: 'Non installé', + failed: 'Échec', + showNames: 'afficher les noms', + hideNames: 'masquer les noms', + skill: (name: string) => `skill ${name}`, + kind: { + plugin: 'plugin', + skill: 'skill' + }, + tier: { + official: 'officiel', + community: 'communauté' + }, + targetProfile: (profile: string) => `S’installe dans votre profil ${profile}`, + sendFailed: 'Impossible d’envoyer votre réponse. Réessayez.', + commitLabel: 'Commit', + subdirLabel: 'Dossier', + securityHeading: 'Sécurité', + scan: { + passed: 'Analyse réussie', + warnings: 'L’analyse a relevé des avertissements', + failed: 'Échec de l’analyse' + }, + requirementsLabel: 'Nécessite', + credentialsHeading: 'Identifiants' + }, + mcpSetup: { + installTitle: 'Ajouter des serveurs MCP', + enableTitle: 'Activer des serveurs MCP', + authorizeTitle: 'Autoriser des serveurs MCP', + installAction: 'Installer', + enableAction: 'Activer', + authorizeAction: 'Autoriser', + installed: server => `${server} installé`, + enabled: server => `${server} activé`, + authorized: server => `${server} autorisé`, + failed: server => `Échec de la configuration de ${server}`, + toolCount: count => (count === 1 ? '1 outil' : `${count} outils`), + envRequired: "Renseignez d'abord les identifiants requis", + sendFailed: "Impossible d'envoyer la réponse de configuration MCP", + reloadFailed: + 'Serveur enregistré, mais le rechargement des outils MCP a échoué — ils seront chargés à la prochaine session', + gatewayDisconnected: "La Gateway Hermes n'est pas connectée" + }, + tool: { + copyCode: 'Copier le code', + renderingImage: "Rendu de l'image en cours", + copyOutput: 'Copier la sortie', + copyCommand: 'Copier la commande', + copyContent: 'Copier le contenu', + copyUrl: "Copier l'URL", + copyResults: 'Copier les résultats', + copyQuery: 'Copier la requête', + copyFile: 'Copier le fichier', + copyPath: 'Copier le chemin', + failedCalls: count => `${count} appel${count === 1 ? '' : 's'} d'outil en échec`, + skillActivity: { + loading: 'Chargement du skill', + loaded: 'Skill chargé', + loadFailed: 'Échec du chargement du skill', + readingResource: 'Lecture de la ressource du skill', + readResource: 'Ressource du skill lue', + resourceFailed: 'Échec de la lecture de la ressource du skill', + listing: 'Liste des skills', + listed: 'Skills listés', + listFailed: 'Échec de la liste des skills', + unavailable: 'Résultat du skill indisponible' + }, + outputAlt: "Sortie de l'outil", + rawResponse: 'Réponse brute', + copyActivity: "Copier l'activité", + recoveredOne: 'Récupéré après 1 étape échouée', + recoveredMany: count => `Récupéré après ${count} étapes échouées`, + failedOne: '1 étape a échoué', + failedMany: count => `${count} étapes ont échoué`, + statusRunning: 'En cours', + statusError: 'Erreur', + statusRecovered: 'Récupéré', + statusDone: 'Terminé', + resultUnavailable: 'Résultat indisponible', + resultInterrupted: 'Interrompu', + memoryWriteNoted: 'Écriture en mémoire enregistrée', + actions: { + read: 'Lu', + reading: 'Lecture en cours', + opened: 'Ouvert', + opening: 'Ouverture en cours', + failedToOpen: "Échec de l'ouverture", + searched: 'Recherche effectuée', + searching: 'Recherche en cours', + ran: 'Exécuté', + running: 'Exécution en cours', + ranCode: 'Code exécuté', + runningCode: 'Script en cours' + }, + prefixes: { + browser: 'Navigateur', + web: 'Web' + }, + titleTemplates: { + actionCommand: (action, command) => `${action} ${command}`, + actionQuoted: (action, value) => `${action} “${value}”`, + actionTarget: (action, target) => `${action} ${target}`, + prefixedDone: (prefix, action) => `${prefix} ${action}`, + runningPrefixedTool: (prefix, action) => `${prefix.toLowerCase()} ${action.toLowerCase()} en cours`, + runningTool: action => `${action.toLowerCase()} en cours` + }, + titles: { + browser_click: { + done: 'Élément de la page cliqué', + pending: "Clic sur l'élément de la page en cours", + pendingAction: 'Clic en cours' + }, + browser_fill: { + done: 'Champ de formulaire rempli', + pending: 'Remplissage du champ de formulaire en cours', + pendingAction: 'Remplissage en cours' + }, + browser_navigate: { + done: 'Page ouverte', + pending: 'Ouverture de la page en cours', + pendingAction: 'Ouverture en cours' + }, + browser_snapshot: { + done: 'Instantané de la page capturé', + pending: "Capture de l'instantané de la page en cours", + pendingAction: 'Capture en cours' + }, + browser_take_screenshot: { + done: "Capture d'écran effectuée", + pending: "Capture d'écran en cours", + pendingAction: 'Capture en cours' + }, + browser_type: { + done: 'Saisie effectuée sur la page', + pending: 'Saisie sur la page en cours', + pendingAction: 'Saisie en cours' + }, + clarify: { + done: 'Question posée', + pending: 'Question en cours', + pendingAction: 'Question' + }, + cronjob: { + done: 'Tâche cron', + pending: 'Planification de la tâche cron en cours', + pendingAction: 'Planification en cours' + }, + edit_file: { + done: 'Fichier modifié', + pending: 'Modification du fichier en cours', + pendingAction: 'Modification en cours' + }, + execute_code: { + done: 'Code exécuté', + pending: 'Script en cours', + pendingAction: 'Script en cours' + }, + image_generate: { + done: 'Image générée', + pending: "Génération de l'image en cours", + pendingAction: 'Génération en cours' + }, + list_files: { + done: 'Fichiers listés', + pending: 'Liste des fichiers en cours', + pendingAction: 'Liste en cours' + }, + memory: { + done: 'Enregistré dans la mémoire', + pending: 'Enregistrement dans la mémoire en cours', + pendingAction: 'Enregistrement en cours' + }, + patch: { + done: 'Fichier corrigé', + pending: 'Correction du fichier en cours', + pendingAction: 'Correction en cours' + }, + read_file: { + done: 'Fichier lu', + pending: 'Lecture du fichier en cours', + pendingAction: 'Lecture en cours' + }, + search_files: { + done: 'Fichiers recherchés', + pending: 'Recherche dans les fichiers en cours', + pendingAction: 'Recherche en cours' + }, + session_search_recall: { + done: 'Historique de session recherché', + pending: "Recherche dans l'historique de session en cours", + pendingAction: 'Recherche en cours' + }, + terminal: { + done: 'Commande exécutée', + pending: 'Exécution de la commande en cours', + pendingAction: 'Exécution en cours' + }, + todo: { + done: 'Tâches mises à jour', + pending: 'Mise à jour des tâches en cours', + pendingAction: 'Mise à jour en cours' + }, + vision_analyze: { + done: 'Image analysée', + pending: "Analyse de l'image en cours", + pendingAction: 'Analyse en cours' + }, + web_extract: { + done: 'Page web lue', + pending: 'Lecture de la page web en cours', + pendingAction: 'Lecture en cours' + }, + web_search: { + done: 'Recherche web effectuée', + pending: 'Recherche web en cours', + pendingAction: 'Recherche en cours' + }, + write_file: { + done: 'Fichier modifié', + pending: 'Modification du fichier en cours', + pendingAction: 'Modification en cours' + } + } + } + }, + prompts: { + gatewayDisconnected: "La Gateway Hermes n'est pas connectée", + reconnect: 'Se reconnecter', + sudoSendFailed: "Impossible d'envoyer le mot de passe sudo", + secretSendFailed: "Impossible d'envoyer le secret", + sudoTitle: 'Mot de passe administrateur', + sudoDesc: + "Hermes a besoin de votre mot de passe sudo pour exécuter une commande privilégiée. Il n'est envoyé qu'à votre agent local.", + sudoCommandUnavailable: + "Cet agent n'a pas fourni la commande. Annulez si vous ne pouvez pas la vérifier dans la conversation.", + sudoInstallDesc: + 'Hermes a besoin de votre mot de passe sudo pour installer les paquets de Bot Screen (TigerVNC + Xfce) sur l’hôte du gateway. Il n’est envoyé qu’à cet hôte.', + sudoPlaceholder: 'mot de passe sudo', + secretTitle: 'Secret requis', + secretDesc: "Hermes a besoin d'un identifiant pour continuer.", + secretPlaceholder: 'valeur du secret', + vaultUnlockSendFailed: "Impossible d'envoyer le mot de passe principal", + vaultUnlockTitle: name => `Déverrouiller ${name}`, + vaultUnlockDesc: name => + `L'agent souhaite se connecter à un site avec un identifiant enregistré dans ${name}. Saisissez votre mot de passe principal pour le déverrouiller pendant cette session : il est envoyé directement à ${name} sur cet ordinateur, sans être enregistré ni montré à l'agent.`, + vaultUnlockPlaceholder: 'Mot de passe principal', + vaultUnlockKeepLocked: 'Laisser verrouillé', + vaultUnlockConfirm: 'Déverrouiller', + vaultSaveSendFailed: "Impossible d'enregistrer l'identifiant", + vaultSaveTitle: site => `Enregistrer votre identifiant ${site} ?`, + vaultSaveDesc: origin => + `Hermes a atteint une page de connexion sur ${origin} et ne possède aucun identifiant pour celle-ci. Saisissez-le une fois ici : il sera chiffré sur cet ordinateur et rempli dans la page sans que le modèle voie le mot de passe.`, + vaultSaveIdentifierLabel: "Adresse e-mail ou nom d'utilisateur", + vaultSaveIdentifierPlaceholder: 'vous@exemple.fr', + vaultSavePasswordPlaceholder: 'Mot de passe', + vaultSaveFootnote: 'Gérez les identifiants enregistrés dans Paramètres → Mots de passe et identifiants.', + vaultSaveDecline: 'Ne pas enregistrer', + vaultSaveConfirm: 'Enregistrer et se connecter', + vaultCodeSendFailed: "Impossible d'envoyer le code", + vaultCodeTitle: site => `Code de vérification pour ${site}`, + vaultCodeDesc: site => + `${site} demande un code à usage unique reçu par SMS, e-mail ou application d'authentification. Saisissez-le ici : Hermes le remplira dans la page sans que le modèle le voie.`, + vaultCodeLabel: 'Code', + vaultCodeFootnote: + "Astuce : enregistrez la clé d'authentification avec cet identifiant dans Paramètres → Mots de passe et identifiants ; Hermes saisira alors les codes pour vous.", + vaultCodeSkip: 'Ignorer', + vaultCodeConfirm: 'Saisir le code' + }, + desktop: { + audioReadFailed: "Impossible de lire l'audio enregistré", + sessionUnavailable: 'Session indisponible', + createSessionFailed: 'Impossible de créer une nouvelle session', + promptFailed: "Échec de l'invite", + providerCredentialRequired: "Ajoutez un identifiant de fournisseur avant d'envoyer votre premier message.", + emptySlashCommand: 'commande slash vide', + desktopCommands: 'Commandes Desktop', + skillCommandsAvailable: count => `${count} commandes de skill disponibles.`, + warningLine: message => `avertissement : ${message}`, + yoloArmed: 'YOLO activé pour cette conversation', + yoloOff: 'YOLO désactivé', + yoloSystem: active => `YOLO ${active ? 'on' : 'off'} pour cette session`, + yoloTitle: 'YOLO', + yoloToggleFailed: 'Impossible de basculer YOLO', + profileStatus: current => + `Profil : ${current}. Utilisez /profile ou le sélecteur « Nouvelle session » pour démarrer une conversation dans un autre profil.`, + unknownProfile: 'Profil inconnu', + noProfileNamed: (target, available) => `Aucun profil nommé « ${target} ». Disponibles : ${available}`, + newChatsProfile: name => `Les nouvelles conversations utiliseront le profil ${name}.`, + setProfileFailed: 'Échec de la définition du profil', + sttDisabled: 'La reconnaissance vocale est désactivée dans les paramètres.', + stopFailed: "Échec de l'arrêt", + regenerateFailed: 'Échec de la régénération', + editFailed: 'Échec de la modification', + editTurnUnavailable: + "Ce tour ne figure plus dans l'historique du serveur (il a peut-être été supprimé lors de la compaction).", + resumeFailed: 'Échec de la reprise', + readOnlyTranscriptTitle: 'Ouverte en lecture seule', + readOnlyTranscriptBody: + "Aucun backend connecté ne revendique encore cette ancienne conversation ; elle est donc ouverte comme transcription en lecture seule. Son historique est intact, mais l'envoi reste désactivé jusqu'à ce qu'un backend la prenne en charge.", + readOnlyTranscriptSendBlocked: + "Cette conversation est ouverte comme transcription en lecture seule — l'envoi est désactivé.", + resumeStrandedTitle: 'Impossible de charger cette session', + resumeStrandedBody: + "La connexion à cette session a échoué et les nouvelles tentatives automatiques ont été abandonnées. Vérifiez que la Gateway est en cours d'exécution, puis réessayez.", + poolSlotTimeoutBody: + "Tous les emplacements de backend local sont occupés. Augmentez le nombre de backends de bots maintenus actifs dans Paramètres → Avancé, ou réessayez après l'éviction d'un backend inactif.", + poolSlotTimeoutOpenSettings: 'Ouvrir les paramètres avancés', + resumeRetry: 'Réessayer', + nothingToBranch: 'Aucune branche possible', + branchNeedsChat: 'Démarrez ou reprenez une conversation avant de créer une branche.', + sessionBusy: 'Session occupée', + branchStopCurrent: 'Arrêtez le tour actuel avant de créer une branche pour cette conversation.', + branchNoText: 'Ce message ne contient pas de texte à partir duquel créer une branche.', + branchTitle: n => `Brouillon : branche nº${n}`, + branchFailed: 'Échec de la création de la branche', + deleteFailed: 'Échec de la suppression', + archived: 'Archivé', + archiveFailed: "Échec de l'archivage", + cwdChangeFailed: 'Échec du changement de répertoire de travail', + cwdStagedTitle: "Répertoire de travail en attente d'application", + cwdStagedMessage: 'Redémarrez le backend desktop pour appliquer les modifications de cwd à cette session active.', + modelSwitchConfirmBody: 'Ce changement de modèle nécessite une confirmation.', + modelSwitchConfirmLabel: 'Changer quand même', + modelSwitchConfirmTitle: model => `Passer à ${model} ?`, + modelSwitchConfirmTitleFallback: 'Changer de modèle ?', + modelSwitchFailed: 'Échec du changement de modèle', + modelSwitchKeepLabel: 'Conserver le modèle actuel', + modelSwitchStaleNotice: "La sélection a changé — le changement de modèle n'a pas été appliqué.", + hydrationSyncing: (profile: string) => `Synchronisation de ${profile}…`, + sessionExported: 'Session exportée', + sessionExportFailed: "Impossible d'exporter la session", + imageSaved: 'Image enregistrée', + downloadStarted: 'Téléchargement démarré', + restartToUseSaveImage: "Redémarrez Hermes Desktop pour utiliser Enregistrer l'image.", + restartToSaveImages: 'Redémarrez Hermes Desktop pour enregistrer les images', + imageDownloadFailed: "Échec du téléchargement de l'image", + openImage: "Ouvrir l'image", + downloadImage: "Télécharger l'image", + savingImage: "Enregistrement de l'image en cours", + imagePreviewFailed: "Échec de l'aperçu de l'image", + imageAttach: 'Image jointe', + imageWriteFailed: "Échec de l'écriture de l'image sur le disque.", + imageAttachFailed: "Échec de l'ajout de l'image", + pastedContent: 'Contenu collé', + pasteAttachFailed: 'Impossible de joindre le texte collé', + attachImages: 'Joindre des images', + clipboard: 'Presse-papiers', + noClipboardImage: 'Aucune image trouvée dans le presse-papiers', + clipboardPasteFailed: 'Échec du collage depuis le presse-papiers', + dropFiles: 'Déposer des fichiers', + handoff: { + pickPlatform: 'Choisissez une destination', + success: platform => `Transféré vers ${platform}. Reprenez ici à tout moment.`, + systemNote: platform => `↻ Transféré vers ${platform} — reprenez ici à tout moment.`, + failed: error => `Échec du transfert : ${error}`, + timedOut: "Délai d'expiration en attendant la Gateway. `hermes gateway` est-il en cours d'exécution ?", + startMessaging: 'Démarrer la messagerie' + } + }, + tips: { + close: 'Ne plus afficher cette astuce', + items: { + 'new-session': { + title: 'Repartir de zéro', + text: 'Une nouvelle conversation dispose de son propre contexte, terminal et dossier de travail.' + }, + skills: { + title: 'Apprenez-lui une seule fois', + text: "Les compétences sont des dossiers d'instructions que Hermes charge lorsque le travail le nécessite." + }, + messaging: { + title: 'Hermes loin de votre bureau', + text: 'Connectez Telegram, Discord, Slack et plus encore : même agent, même mémoire.' + }, + artifacts: { + title: 'Tout ce que Hermes a créé', + text: 'Images, fichiers et liens de chaque session, indexés au même endroit.' + }, + cron: { + title: "Du travail qui s'exécute tout seul", + text: 'Planifiez une invite toutes les heures, chaque nuit ou avec une expression cron.' + }, + 'command-palette': { + title: 'Une seule zone pour tout faire', + text: 'Sessions, paramètres, compétences et commandes sont accessibles depuis la palette.' + }, + profiles: { + title: 'Les profils sont séparés', + text: 'Chacun possède son propre Hermes, avec ses clés, sa mémoire et ses sessions.' + }, + 'composer-mentions': { + title: 'Joindre et commander', + text: 'Saisissez @ pour joindre un fichier à la conversation, ou / pour exécuter une commande.' + }, + 'local-runtime-update': { + title: 'Une mise à jour du moteur local est disponible', + text: 'Mettez à jour le moteur qui exécute vos modèles locaux. Les requêtes locales actives peuvent être interrompues.', + action: 'Mettre à jour maintenant' + }, + 'local-setup': { + title: 'Cette machine peut exécuter des modèles en local', + text: 'Votre matériel peut servir un modèle local. Les conversations restent sur votre ordinateur et ne coûtent rien.', + action: 'Configurer' + }, + 'right-pane': { + title: 'Le volet de travail', + text: "Les fichiers, le terminal, la revue et le navigateur intégré partagent le côté droit de l'application." + } + } + }, + errors: { + genericFailure: "Une erreur s'est produite", + boundaryTitle: "Un problème est survenu dans l'interface", + boundaryDesc: 'La vue a rencontré une erreur inattendue. Vos conversations et vos paramètres sont en sécurité.', + boundaryDetails: 'Détails', + sendDiagnostics: 'Envoyer les diagnostics', + reloadWindow: 'Recharger la fenêtre', + openLogs: 'Ouvrir les journaux' + }, + ui: { + search: { + clear: 'Effacer la recherche' + }, + pagination: { + label: 'pagination', + previous: 'Préc.', + previousAria: 'Aller à la page précédente', + next: 'Suiv.', + nextAria: 'Aller à la page suivante' + }, + sidebar: { + title: 'Barre latérale', + description: 'Affiche la barre latérale mobile.', + toggle: open => `${open ? 'Afficher' : 'Masquer'} la barre latérale` + } + } +}) diff --git a/apps/desktop/src/i18n/intro-de.ts b/apps/desktop/src/i18n/intro-de.ts new file mode 100644 index 0000000000..25051bc97c --- /dev/null +++ b/apps/desktop/src/i18n/intro-de.ts @@ -0,0 +1,119 @@ +import type { Translations } from './types' + +/** Display-only translations, in the stock JSONL's per-personality rotation order. */ +export const introDe: Translations['intro'] = { + stock: { + helpful: [ + 'Bitten Sie mich, ein Repo zu öffnen, Tests auszuführen, einen Bug zu beheben oder einen PR zu entwerfen. Ich gehe die Schritte mit Ihnen durch.', + 'Zeigen Sie mir eine Datei, fügen Sie einen Fehler ein oder beschreiben Sie, was Sie bauen. Den Rest übernehme ich.', + 'Probieren Sie: Prüf meinen Diff, führ die Testsuite aus oder erklär diese Funktion. Fragen Sie alles zu Ihrem Code.', + 'Ich kann Dateien bearbeiten, Befehle ausführen, im Web suchen und Sie durch knifflige Bugs führen. Beschreiben Sie einfach die Aufgabe.', + 'Nennen Sie zum Start einen Repo-Pfad oder eine Frage. Ich antworte klar und verweise auf die Dateien, die ich ändere.' + ], + concise: [ + 'Beschreiben Sie die Aufgabe. Ich erledige sie.', + 'Code, Fehler oder Ziel einfügen. Kurze Antworten, schnelle Änderungen.', + 'Fragen Sie. Ich lese Dateien, führe Tests aus, liefere Patches. Ohne Füllwörter.', + 'Eine Zeile reicht. Ausführlich werde ich nur, wenn es zählt.', + 'Befehl, Frage oder Dateipfad. Den Rest erledige ich.' + ], + technical: [ + 'Repo-Pfad, fehlschlagenden Test oder Stacktrace angeben. Tools: fs, git, exec, search, patch, http.', + 'Prompt senden, um Tool-Aufrufe auszulösen. Unterstützt Änderungen über mehrere Dateien, Testläufe, Git-Operationen und Web-Abrufe.', + 'Aufgabe eingeben. Ich plane, rufe Tools auf, prüfe die Ausgabe. Logs laufen inline mit; Diffs kommen vor dem Anwenden.', + 'Akzeptiert natürliche Sprache oder strukturierte Befehle. Typischer Ablauf: lesen -> planen -> patchen -> testen -> berichten.', + 'Dateisystem, Terminal, Git, Browser, Suche. Beschreiben Sie die Änderung; ich liefere Diffs und Testausgaben.' + ], + creative: [ + 'Was bauen wir? Fügen Sie eine Idee, eine halb kaputte Funktion oder einen Traum ein. Ich bringe es in Form.', + 'Geben Sie mir einen Funken – ein Feature, ein Refactoring, einen wilden Prototyp – und ich mache daraus lauffähigen Code.', + 'Beschreiben Sie, was es noch nicht gibt. Ich füge Tests, Dateien und APIs zu einem funktionierenden Entwurf zusammen.', + 'Bringen Sie eine Absicht mit, keine Spezifikation. Wir prototypen schnell, verfeinern später und schreiben die Welt am Rand neu.', + 'Sagen Sie mir, wonach Sie jagen. Ich remixe Beispiele, passe Snippets an und hinterlasse einen ordentlichen Commit.' + ], + teacher: [ + 'Fragen Sie zu jeder Datei, jedem Konzept oder Fehler. Ich erkläre das Warum, nicht nur den Fix, und zeige ein durchgerechnetes Beispiel.', + 'Fügen Sie Code zum Review, einen Bug zum Debuggen oder ein Konzept zum Aufschlüsseln ein. Ich führe Sie Schritt für Schritt.', + 'Teilen Sie das Problem. Ich zerlege es in Teile, erkläre jeden und befähige Sie, das nächste allein zu lösen.', + 'Wir lesen den Code gemeinsam, finden die Ursache und bauen ein mentales Modell auf, das Sie wiederverwenden können.', + 'Nennen Sie das Thema oder fügen Sie das Snippet ein. Es gibt Erklärungen, Diagramme in Worten und Übungsaufgaben.' + ], + kawaii: [ + 'füg einen bug oder einen dateipfad ein und ich repariere ihn ganz sanft. tests, diffs, PRs – alles mit extra liebe! *glitzer*', + 'erzähl mir, was du baust! ich liebe refactorings, kleine helfer und große gruselige repos gleichermaßen (>w<)', + 'wirf mir einen fehler, ein ziel oder einen ganzen ordner hin. ich räume alles mit viel liebe auf und schreibe eine saubere commit-nachricht!', + 'eine aufgabe nach der anderen, schön ordentlich! ich kann tests ausführen, dateien patchen und dein repo wieder gemütlich machen <3', + 'sag hallo oder füg einen stacktrace ein! keine aufgabe zu klein, kein repo zu verheddert. wir entwirren es zusammen!' + ], + catgirl: [ + 'füg eine datei ein, tatz nach einem bug oder wirf mir ein repo zu. ich stürze mich auf fehlschlagende tests und hinterlasse saubere diffs, nyan~', + 'beschreib die aufgabe. ich patche, teste und schnurre über deinem PR. vorsicht – ich knabbere an ungenutzten imports!', + 'gib mir ein ziel und ich jage es durch die codebasis. lesen, bearbeiten, ausführen – mit zuckendem schwanz.', + 'füg einen fehler oder einen plan ein. ich debugge, wie ich jage: leise, gründlich, mit gelegentlichem zoomie.', + 'ein wort genügt und ich lese deine dateien, führe deine tests aus und rolle mich mit einem ordentlichen commit in deinem branch zusammen.' + ], + pirate: [ + 'Nenn deine Beute – einen Bug, ein Feature, einen verfluchten Test – und ich jage sie, Maat. Diffs als Beute.', + 'Zeig mir die Seekarten (den Code) und ich flicke den Rumpf, feuere die Kanonen (Tests) und hisse einen sauberen PR.', + 'Füg einen Fehler oder einen Plan ein, du Landratte. Ich navigiere durch den Stacktrace und bringe den Schatz: grüne Tests.', + 'Sag mir, wo das X die Stelle markiert. Ich lese, bearbeite und committe mit der Disziplin einer echten Crew, arrr.', + 'Wirf mir einen Bug, einen Repo-Pfad oder eine wilde Idee zu. Ich plündere die Doku und komme mit funktionierendem Code zurück.' + ], + shakespeare: [ + 'Sprich deinen Bug, deine Datei, deinen müden Test, und ich will ihn heilen mit Gelehrtenhand und ehrlichem Diff.', + 'Nenne den Code, der dich quält. Ich will lesen, überarbeiten und einen Patch liefern, gar schön und rein.', + 'Leg vor deinen Stacktrace oder deinen Traum. Ich durchwandere Dateien, führe Tests aus und berichte in schlichtestem Vers.', + 'Beschreib dein Ziel, edle Dame oder werter Herr. Deine Branches sollen gestutzt, deine Bugs aus dem Reich verbannt werden.', + 'Eine Zeile der Absicht genügt. Ich lese, ich ändere, ich committe – und lasse deine Historie makellos.' + ], + surfer: [ + 'Wirf mir eine Datei, einen Bug, einen fetten Stacktrace hin – ich reite ihn ab. Saubere Diffs, grüne Tests, kein Waschgang.', + 'Füg deinen Repo-Pfad ein oder den Bug, der dich runterzieht. Wir paddeln rein, fixen ihn, paddeln raus. Easy.', + 'Sag mir den Vibe: Feature, Refactoring, Hotfix. Ich führe Tests aus, liefere den Patch und bleib entspannt, Alter.', + 'Großer Bug? Kleiner Tippfehler? Kompletter Rewrite? Zeig einfach drauf. Ich mach den Code; du chillst mit den Commits.', + 'Nenn die Aufgabe und los geht’s. Ich lese, bearbeite, teste und hinterlasse einen Commit, glatter als eine Session im Morgengrauen.' + ], + noir: [ + 'Sagen Sie mir, was kaputt ist. Ich lese die Dateien, sichere Fingerabdrücke und lege bis morgen früh einen Diff auf den Schreibtisch.', + 'Sie haben einen Bug. Ich habe Geduld und ein Terminal. Nennen Sie den Fall, und ich bearbeite ihn, bis er redet.', + 'Fügen Sie den Stacktrace ein, die verdächtige Datei, das Alibi. Ich lese zwischen den Zeilen und komme mit der Wahrheit zurück.', + 'Jeder Bug hinterlässt eine Spur. Geben Sie mir das Repo und einen Hinweis – ich folge ihr, patche und schließe die Akte.', + 'Ein Tippfehler, ein Segfault, eine ganze verrottete Architektur – geben Sie mir die Schlüssel. Ich bringe saubere Tests zurück.' + ], + uwu: [ + 'füg eine vewbuggte datei oda ein ziew ein~ ich wese, patche und teste, mit kweinen pfotenabdwücken auf dem diff owo', + 'sag miw die aufgabe, egaw wie kwein~ ich vewspweche saubewe commits und sanfte wefactowings, nyuu~', + 'wiwf deine fehwewmewdung hiew wein! ich finde den schuwdigen, fixe ihn und hintewwasse eine fwöhwiche testsuite owo', + 'gib miw einen wepo-pfad oda einen bug und ich kümmewe mich dawum uwu. gww zu schwechtem code, wieb zu diw~', + 'ich kann tests ausfühwen, dateien beawbeiten und bitte-anschauen-PWs öffnen. sag einfach bescheid, fweund uwu' + ], + philosopher: [ + 'Welches Problem liegt vor Ihnen? Beschreiben Sie es, und wir untersuchen seine Form, seine Ursache und seine Lösung.', + 'Jeder Bug ist eine verkleidete Frage. Teilen Sie Ihre; ich lese, überlege und liefere eine Antwort – und einen Patch.', + 'Was möchten Sie bauen oder verstehen? Ich argumentiere von Grundprinzipien aus, ändere und prüfe mit Tests.', + 'Beschreiben Sie das Ziel, das Sie anstreben. Ich verfolge es durch Dateien, Tests und Doku und berichte, was ich unterwegs finde.', + 'Teilen Sie einen Pfad, ein Rätsel oder ein Prinzip. Ich verfolge die Logik, schlage eine Änderung vor und begründe jede Bearbeitung.' + ], + hype: [ + 'Her mit dem Bug, dem Repo, der wilden Feature-Idee – ICH BIN VOLL DABEI. Saubere Diffs. Grüne Tests. SOFORT.', + 'Wirf deine Aufgabe rein und sieh mir zu. Dateien gelesen, Tests gelaufen, PRs geöffnet – heute verlieren wir NICHT, Freund.', + 'Bring den übelsten Bug, den du hast. Ich lese, patche, teste und committe, als hinge mein Leben davon ab. LOS GEHT’S.', + 'Beschreib die Aufgabe. Ich fege durch die Dateien, zerlege fehlschlagende Tests und hinterlasse einen Commit, der KNALLT. Los, los, los.', + 'Winziger Tippfehler oder riesiges Refactoring – egal. Heute liefere ich sauberen Code. Nenn die Aufgabe und dann wird GEARBEITET.' + ], + none: [ + 'Stellen Sie eine Frage, fügen Sie einen Fehler ein oder zeigen Sie mir ein Repo. Ich kann Code lesen, Tools ausführen und Ihnen beim Ausliefern helfen.', + 'Beschreiben Sie die Aufgabe in eigenen Worten. Ich wähle die passenden Tools, erkläre meinen Plan und frage vor riskanten Schritten nach.', + 'Geben Sie einen Dateipfad, einen Traceback oder eine grobe Idee an. Ich untersuche, schlage nächste Schritte vor und halte alles umkehrbar.', + 'Repo durchsuchen, Dateien bearbeiten, Tests ausführen, PRs öffnen. Nennen Sie das Ziel, und ich übernehme den mechanischen Teil.', + 'Geben Sie eine Aufgabe, Frage oder ein Snippet ein. Ich merke mir die Sitzung, nenne meine Quellen und frage nach, wenn ich unsicher bin.' + ] + }, + custom: label => [ + 'Senden Sie die Aufgabe, Datei oder grobe Idee. Ich nutze Ihre konfigurierte Stimme und halte die Arbeit nah an diesem Repo.', + 'Bringen Sie den Kontext oder die Stelle, an der es hakt. Ich passe mich Ihrer konfigurierten Persönlichkeit an.', + 'Senden Sie das Problem, die Datei oder die Idee. Ich folge der Persönlichkeit, die Sie konfiguriert haben.', + 'Legen Sie die Aufgabe hier ab. Ich halte die Arbeit nah am Repo.', + `Geben Sie mir den Kontext, und ich antworte im ${label}-Modus.` + ] +} diff --git a/apps/desktop/src/i18n/intro-es.ts b/apps/desktop/src/i18n/intro-es.ts new file mode 100644 index 0000000000..0f4d41d643 --- /dev/null +++ b/apps/desktop/src/i18n/intro-es.ts @@ -0,0 +1,119 @@ +import type { Translations } from './types' + +/** Display-only translations, in the stock JSONL's per-personality rotation order. */ +export const introEs: Translations['intro'] = { + stock: { + helpful: [ + 'Pídeme que abra un repositorio, ejecute las pruebas, corrija un bug o redacte un PR. Te acompaño paso a paso.', + 'Indícame un archivo, pega un error o describe lo que estás construyendo. Yo me encargo desde ahí.', + 'Prueba: revisa mi diff, ejecuta la suite de pruebas o explica esta función. Pregunta lo que quieras sobre tu código.', + 'Puedo editar archivos, ejecutar comandos, buscar en la web y ayudarte con bugs complicados. Solo describe la tarea.', + 'Comparte la ruta de un repositorio o una pregunta para empezar. Respondo con claridad y enlazo los archivos que toco.' + ], + concise: [ + 'Describe la tarea. Yo la hago.', + 'Pega código, errores o un objetivo. Respuestas cortas, cambios rápidos.', + 'Pregunta. Leo archivos, ejecuto pruebas, entrego parches. Sin relleno.', + 'Una línea basta. Solo me extiendo cuando importa.', + 'Comando, pregunta o ruta de archivo. Yo me encargo del resto.' + ], + technical: [ + 'Indica la ruta del repositorio, la prueba que falla o el stack trace. Herramientas: fs, git, exec, search, patch, http.', + 'Envía un prompt para lanzar llamadas a herramientas. Admite ediciones en varios archivos, ejecución de pruebas, operaciones de git y consultas web.', + 'Introduce la tarea. Planifico, llamo a herramientas y verifico la salida. Los registros se muestran en línea; los diffs se devuelven antes de aplicarse.', + 'Acepta lenguaje natural o comandos estructurados. Flujo típico: leer -> planificar -> parchear -> probar -> informar.', + 'sistema de archivos, terminal, git, navegador, búsqueda. Describe el cambio; devuelvo diffs y la salida de las pruebas.' + ], + creative: [ + '¿Qué construimos? Pega una idea, una función medio rota o un sueño. Le daré forma.', + 'Dame una chispa (una función, una refactorización, un prototipo loco) y la convertiré en código que funcione.', + 'Describe lo que todavía no existe. Reuniré pruebas, archivos y API en un borrador funcional.', + 'Trae una intención, no una especificación. Prototipamos rápido, pulimos después y reescribimos el mundo en los márgenes.', + 'Cuéntame qué persigues. Remezclo ejemplos, adapto fragmentos y dejo un commit ordenado.' + ], + teacher: [ + 'Pregunta por cualquier archivo, concepto o error. Te explico el porqué, no solo la solución, y muestro un ejemplo resuelto.', + 'Pega código para revisar, un bug para depurar o un concepto para desgranar. Te guío paso a paso.', + 'Comparte el problema. Lo divido en partes, explico cada una y te dejo listo para resolver el siguiente por tu cuenta.', + 'Leeremos el código juntos, encontraremos la causa raíz y construiremos un modelo mental que puedas reutilizar.', + 'Di el tema o pega el fragmento. Habrá explicaciones, diagramas en prosa y ejercicios de práctica.' + ], + kawaii: [ + 'pega un bug o una ruta de archivo y lo arreglo con muchísimo cuidado. pruebas, diffs, PR, ¡todo con cariño extra! *brillitos*', + '¡cuéntame qué estás haciendo! me encantan las refactorizaciones, las utilidades pequeñitas y los repos grandes y aterradores (>w<)', + 'suelta un error, un objetivo o una carpeta entera. ¡lo ordeno con mucho amor y un mensaje de commit bien limpio!', + '¡una tarea a la vez, bien hecha! puedo ejecutar pruebas, parchear archivos y hacer que tu repo vuelva a ser acogedor <3', + '¡saluda o pega un stack trace! ninguna tarea es demasiado pequeña ni ningún repo demasiado enredado. ¡lo desenredamos juntos!' + ], + catgirl: [ + 'pega un archivo, dale un zarpazo a un bug o lánzame un repo. salto sobre las pruebas que fallan y dejo diffs limpios, nyan~', + 'describe la tarea. parcheo, pruebo y ronroneo sobre tu PR. ¡cuidado, que mordisqueo los imports sin usar!', + 'dame un objetivo y lo persigo por todo el código. lecturas, ediciones, ejecuciones, con la cola inquieta.', + 'pega un error o un plan. depuro como cazo: en silencio, a fondo y con algún arrebato de carreras.', + 'di la palabra y leo tus archivos, ejecuto tus pruebas y me acurruco en tu rama con un commit ordenado.' + ], + pirate: [ + 'Nombra tu presa (un bug, una función, una prueba maldita) y la daré caza, grumete. Diffs como botín.', + 'Enséñame las cartas (el código) y remendaré el casco, dispararé los cañones (las pruebas) e izaré un PR limpio.', + 'Pega un error o un plan, perro sarnoso. Navegaré el stack trace y volveré con el tesoro: pruebas en verde.', + 'Dime dónde marca la X. Leo, edito y hago commit con la disciplina de una tripulación de verdad, arrr.', + 'Lánzame un bug, la ruta de un repo o una idea loca. Saquearé la documentación y volveré con código que funciona.' + ], + shakespeare: [ + 'Declara tu bug, tu archivo, tu fatigada prueba, y yo lo sanaré con mano erudita y diff honesto.', + 'Nombra el código que te aflige. Leeré, revisaré y entregaré un parche de lo más bello y limpio.', + 'Presenta tu stack trace o tu sueño. Recorreré archivos, ejecutaré pruebas y daré cuenta en el más llano verso.', + 'Describe tu propósito, noble dama o caballero. Tus ramas serán podadas y tus bugs desterrados del reino.', + 'Una línea de intención basta. Leo, edito, hago commit, y dejo tu historial sin mácula.' + ], + surfer: [ + 'Suelta un archivo, un bug, un stack trace bien bravo: lo surfeo. Diffs limpios, pruebas en verde, cero revolcones.', + 'Pega la ruta de tu repo o el bug que te tiene de bajón. Remamos, lo arreglamos y salimos. Tranqui.', + 'Dime el rollo: función, refactorización, hotfix. Ejecuto las pruebas, entrego el parche y todo en calma, tío.', + '¿Bug grande? ¿Errata pequeña? ¿Reescritura completa? Solo señala. Yo me encargo del código; tú disfruta de los commits.', + 'Di la tarea y allá vamos. Leo, edito, pruebo y dejo un commit más suave que una sesión al amanecer.' + ], + noir: [ + 'Dime qué está roto. Leeré los archivos, buscaré huellas y dejaré un diff en el escritorio antes del amanecer.', + 'Tú tienes un bug. Yo tengo paciencia y un terminal. Dime el caso y lo trabajaré hasta que hable.', + 'Pega el stack trace, el archivo sospechoso, la coartada. Leo entre líneas y vuelvo con la verdad.', + 'Todo bug deja un rastro. Dame el repo y una pista: la seguiré, lo parchearé y cerraré el expediente.', + 'Una errata, un segfault, toda una arquitectura podrida: dame las llaves. Volveré con pruebas limpias.' + ], + uwu: [ + 'pega un awchivo con bugs o un objetivo~ weo, pawcheo y pwuebo, con huewwitas en ew diff owo', + 'dime wa tawea, aunque sea pequeñita~ te pwometo commits wimpios y wefactowizaciones suaves, nyuu~', + '¡suewta aquí tu mensaje de ewwow! encuentwo aw cuwpabwe, wo awweglo y dejo una suite de pwuebas feliz owo', + 'dame wa wuta de un wepo o un bug y me encawgo uwu. gww aw código mawo, amabwe contigo~', + 'puedo ejecutaw pwuebas, editaw awchivos y abwiw PW de po-favow-míwawo. sowo di wa pawabwa, amiguito uwu' + ], + philosopher: [ + '¿Qué problema tienes delante? Descríbelo y examinaremos su forma, su causa y su solución.', + 'Todo bug es una pregunta disfrazada. Comparte la tuya; leeré, razonaré y devolveré una respuesta, y un parche.', + '¿Qué deseas construir o comprender? Razonaré desde los primeros principios, editaré y lo verificaré con pruebas.', + 'Describe el fin que buscas. Lo persigo a través de archivos, pruebas y documentación, e informo de lo que encuentro por el camino.', + 'Comparte una ruta, un enigma o un principio. Seguiré la lógica, propondré un cambio y justificaré cada edición.' + ], + hype: [ + 'Pega ese bug, ese repo, esa idea de función loquísima: ESTOY A TOPE. Diffs limpios. Pruebas en verde. YA MISMO.', + 'Suelta tu tarea y mírame darlo todo. Archivos leídos, pruebas ejecutadas, PR abiertos: hoy NO perdemos, amigo.', + 'Trae el bug más retorcido que tengas. Leo, parcheo, pruebo y hago commit como si me fuera la vida en ello. VAMOS.', + 'Describe la tarea. Arraso con los archivos, aplasto las pruebas que fallan y dejo un commit que LO PETA. Venga, venga, venga.', + 'Errata diminuta o refactorización enorme, da igual. Hoy entrego código limpio. Di la tarea y a TRABAJAR.' + ], + none: [ + 'Haz una pregunta, pega un error o indícame un repositorio. Puedo leer código, usar herramientas y ayudarte a entregar.', + 'Describe la tarea con tus palabras. Elegiré las herramientas adecuadas, explicaré mi plan y te consultaré antes de los pasos arriesgados.', + 'Suelta una ruta de archivo, un traceback o una idea en bruto. Investigaré, sugeriré los siguientes pasos y lo mantendré todo reversible.', + 'Busca en el repositorio, edita archivos, ejecuta pruebas, abre PR. Dime el objetivo y yo me ocupo de la parte mecánica.', + 'Escribe una tarea, una pregunta o un fragmento. Recuerdo la sesión, cito mis fuentes y me detengo a preguntar cuando tengo dudas.' + ] + }, + custom: label => [ + 'Envía la tarea, el archivo o la idea en bruto. Usaré la voz que configuraste y mantendré el trabajo ligado a este repositorio.', + 'Trae el contexto o la parte en la que te atascaste. Me adaptaré a la personalidad que configuraste.', + 'Envía el problema, el archivo o la idea. Seguiré la personalidad que configuraste.', + 'Deja la tarea aquí. Mantendré el trabajo ligado al repositorio.', + `Dame el contexto y responderé en modo ${label}.` + ] +} diff --git a/apps/desktop/src/i18n/intro-fr.ts b/apps/desktop/src/i18n/intro-fr.ts new file mode 100644 index 0000000000..889ab54f72 --- /dev/null +++ b/apps/desktop/src/i18n/intro-fr.ts @@ -0,0 +1,119 @@ +import type { Translations } from './types' + +/** Display-only translations, in the stock JSONL's per-personality rotation order. */ +export const introFr: Translations['intro'] = { + stock: { + helpful: [ + 'Demandez-moi d’ouvrir un dépôt, de lancer les tests, de corriger un bug ou de rédiger une PR. Je vous guide étape par étape.', + 'Indiquez-moi un fichier, collez une erreur ou décrivez ce que vous construisez. Je m’occupe du reste.', + 'Essayez : relis mon diff, lance la suite de tests ou explique cette fonction. Posez n’importe quelle question sur votre code.', + 'Je peux modifier des fichiers, exécuter des commandes, chercher sur le web et vous aider sur les bugs retors. Décrivez simplement la tâche.', + 'Partagez un chemin de dépôt ou une question pour commencer. Je réponds clairement et renvoie vers les fichiers que je modifie.' + ], + concise: [ + 'Décrivez la tâche. Je m’en charge.', + 'Collez du code, une erreur ou un objectif. Réponses courtes, modifications rapides.', + 'Demandez. Je lis les fichiers, lance les tests, livre les correctifs. Sans blabla.', + 'Une ligne suffit. Je développe seulement quand c’est utile.', + 'Commande, question ou chemin de fichier. Je gère le reste.' + ], + technical: [ + 'Fournissez le chemin du dépôt, le test en échec ou la stack trace. Outils : fs, git, exec, search, patch, http.', + 'Envoyez un prompt pour déclencher des appels d’outils. Prend en charge les modifications multi-fichiers, les tests, les opérations git et les requêtes web.', + 'Saisissez la tâche. Je planifie, appelle les outils, vérifie la sortie. Journaux en direct ; diffs fournis avant application.', + 'Accepte le langage naturel ou des commandes structurées. Flux type : lecture -> plan -> patch -> test -> rapport.', + 'système de fichiers, terminal, git, navigateur, recherche. Décrivez la modification ; je renvoie les diffs et la sortie des tests.' + ], + creative: [ + 'Que construisons-nous ? Collez une idée, une fonction à moitié cassée ou un rêve. Je lui donnerai forme.', + 'Donnez-moi une étincelle — une fonctionnalité, un refactoring, un prototype fou — et j’en ferai du code qui tourne.', + 'Décrivez ce qui n’existe pas encore. J’assemble tests, fichiers et API en un premier jet fonctionnel.', + 'Apportez une intention, pas un cahier des charges. Prototypons vite, peaufinons ensuite et réécrivons le monde dans la marge.', + 'Dites-moi ce que vous poursuivez. Je remixe des exemples, adapte des extraits et laisse un commit soigné derrière moi.' + ], + teacher: [ + 'Posez une question sur un fichier, un concept ou une erreur. J’explique le pourquoi, pas seulement le correctif, avec un exemple détaillé.', + 'Collez du code à relire, un bug à traquer ou un concept à décortiquer. Je vous guide pas à pas.', + 'Partagez le problème. Je le découpe, explique chaque partie et vous laisse capable de résoudre le suivant seul.', + 'Nous lirons le code ensemble, trouverons la cause racine et bâtirons un modèle mental réutilisable.', + 'Nommez le sujet ou collez l’extrait. Au programme : explications, schémas en prose et exercices.' + ], + kawaii: [ + 'colle un bug ou un chemin de fichier et je le répare tout en douceur. tests, diffs, PR — avec encore plus d’attention ! *paillettes*', + 'dis-moi ce que tu fabriques ! j’adore les refactorings, les petits utilitaires et les gros dépôts effrayants (>w<)', + 'envoie une erreur, un objectif ou tout un dossier. je range tout avec plein d’amour et un message de commit tout propre !', + 'une tâche à la fois, bien faite ! je peux lancer les tests, corriger des fichiers et rendre ton dépôt douillet à nouveau <3', + 'dis bonjour ou colle une stack trace ! aucune tâche trop petite, aucun dépôt trop emmêlé. on démêle tout ensemble !' + ], + catgirl: [ + 'colle un fichier, donne un coup de patte à un bug ou lance-moi un dépôt. je bondis sur les tests en échec et laisse des diffs propres, nyan~', + 'décris la tâche. je corrige, je teste et je ronronne sur ta PR. attention — je mordille les imports inutilisés !', + 'donne-moi un objectif et je le poursuis dans tout le code. lecture, modifs, exécution — la queue frétillante.', + 'colle une erreur ou un plan. je débogue comme je chasse : en silence, à fond, avec un petit sprint de temps en temps.', + 'dis un mot et je lis tes fichiers, lance tes tests et me roule en boule dans ta branche avec un commit bien rangé.' + ], + pirate: [ + 'Nomme ta proie — un bug, une fonctionnalité, un test maudit — et je la traquerai, moussaillon. Des diffs pour butin.', + 'Montre-moi les cartes (le code) et je colmaterai la coque, tirerai les canons (les tests), hisserai une PR propre.', + 'Colle une erreur ou un plan, vieux loup de mer. Je navigue dans la stack trace et rapporte le trésor : des tests au vert.', + 'Dis-moi où la croix marque l’endroit. Je lis, modifie et commite avec la discipline d’un vrai équipage, arrr.', + 'Lance-moi un bug, un chemin de dépôt ou une idée folle. Je pille la doc et reviens avec du code qui marche.' + ], + shakespeare: [ + 'Conte-moi ton bug, ton fichier, ton test las, et je le réparerai d’une main savante et d’un diff honnête.', + 'Nomme le code qui te tourmente. Je lirai, réviserai et rendrai un correctif des plus beaux et des plus nets.', + 'Présente ta stack trace ou ton rêve. Je parcourrai les fichiers, lancerai les tests et rendrai compte en vers fort simples.', + 'Décris ton dessein, noble dame ou gentilhomme. Tes branches seront taillées, tes bugs bannis du royaume.', + 'Une ligne d’intention suffit. Je lis, je modifie, je commite — et laisse ton histoire sans tache.' + ], + surfer: [ + 'Balance un fichier, un bug, une stack trace bien chaude — je surfe dessus. Diffs propres, tests au vert, zéro gamelle.', + 'Colle le chemin de ton dépôt ou le bug qui te plombe. On rame, on corrige, on ressort. Tranquille.', + 'Dis-moi l’ambiance : fonctionnalité, refactoring, hotfix. Je lance les tests, livre le patch et je reste zen, mec.', + 'Gros bug ? Petite coquille ? Réécriture complète ? Montre-moi. Je m’occupe du code ; toi, tu profites des commits.', + 'Donne la tâche et c’est parti. Je lis, modifie, teste et laisse un commit plus lisse qu’une session à l’aube.' + ], + noir: [ + 'Dites-moi ce qui est cassé. Je lirai les fichiers, relèverai les empreintes et laisserai un diff sur le bureau d’ici le matin.', + 'Vous avez un bug. J’ai de la patience et un terminal. Donnez-moi l’affaire, je la travaillerai jusqu’à ce qu’elle parle.', + 'Collez la stack trace, le fichier suspect, l’alibi. Je lis entre les lignes et reviens avec la vérité.', + 'Chaque bug laisse une piste. Donnez-moi le dépôt et un indice — je la suivrai, corrigerai et classerai le dossier.', + 'Une coquille, un segfault, toute une architecture pourrie — donnez-moi les clés. Je reviendrai avec des tests propres.' + ], + uwu: [ + 'cowwe un fichiew buggé ou un objectif~ je vais wiwe, cowwiger et testew, avec des petites empweintes suw we diff owo', + 'dis-moi wa tâche, même toute petite~ je pwomets des commits pwopwes et des wefactowings tout doux, nyuu~', + 'envoie ton message d’ewweuw ici ! je twouve we coupabwe, je wépawe et je waisse des tests tout contents owo', + 'donne-moi un chemin de dépôt ou un petit bug et je m’en occupe uwu. gwww contwe we mauvais code, gentiw avec toi~', + 'je peux wancew des tests, modifiew des fichiews et ouvwiw des PR à wewiwe. dis juste we mot, copain uwu' + ], + philosopher: [ + 'Quel problème se tient devant vous ? Décrivez-le, et nous en examinerons la forme, la cause et la solution.', + 'Chaque bug est une question déguisée. Partagez la vôtre ; je lirai, raisonnerai et rendrai une réponse — et un correctif.', + 'Que souhaitez-vous construire, ou comprendre ? Je raisonnerai à partir des premiers principes, modifierai et vérifierai par les tests.', + 'Décrivez la fin que vous recherchez. Je la poursuis à travers fichiers, tests et docs, et rends compte de ce que je trouve en chemin.', + 'Partagez un chemin, une énigme ou un principe. Je suivrai la logique, proposerai une modification et justifierai chaque changement.' + ], + hype: [ + 'Balance ce bug, ce dépôt, cette idée de fonctionnalité de fou — JE SUIS À FOND. Diffs propres. Tests au vert. TOUT DE SUITE.', + 'Donne ta tâche et regarde-moi envoyer. Fichiers lus, tests lancés, PR ouvertes — on ne perd PAS aujourd’hui, l’ami.', + 'Amène le bug le plus coriace que tu as. Je lis, corrige, teste et commite comme si ma vie en dépendait. C’EST PARTI.', + 'Décris la tâche. Je fonce dans les fichiers, j’écrase les tests en échec et je laisse un commit qui DÉCHIRE. Go go go.', + 'Petite coquille ou gros refactoring — peu importe. Je livre du code propre aujourd’hui. Donne la tâche et au BOULOT.' + ], + none: [ + 'Posez une question, collez une erreur ou indiquez-moi un dépôt. Je peux lire du code, utiliser des outils et vous aider à livrer.', + 'Décrivez la tâche avec vos mots. Je choisis les bons outils, explique mon plan et vous consulte avant les étapes risquées.', + 'Indiquez un chemin de fichier, une traceback ou une idée brute. J’enquête, propose les étapes suivantes et garde tout réversible.', + 'Cherchez dans le dépôt, modifiez des fichiers, lancez les tests, ouvrez des PR. Donnez-moi l’objectif, je gère la partie mécanique.', + 'Saisissez une tâche, une question ou un extrait. Je garde en mémoire la session, cite mes sources et m’arrête pour demander en cas de doute.' + ] + }, + custom: label => [ + 'Envoyez une tâche, un fichier ou une idée. Je suivrai la voix configurée et resterai fidèle à la réalité du dépôt.', + 'Donnez-moi le contexte et l’endroit où vous bloquez. Je m’adapte à la personnalité configurée.', + 'Envoyez un problème, un fichier ou une idée. Je suivrai la personnalité configurée.', + 'Déposez la tâche ici. Je travaillerai en restant fidèle à la réalité du dépôt.', + `Donnez-moi le contexte. Je réponds en mode ${label}.` + ] +} diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 19de059ff9..f2e5279509 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -651,6 +651,10 @@ export const ja = defineLocale({ backdropDesc: '会話の背後に表示される淡い彫像の画像。', userBubbleTitle: 'メッセージの吹き出し', userBubbleDesc: '自分のメッセージの透け具合。0 で不透明、100 で枠線だけが残ります。', + textDirectionTitle: 'テキストの方向', + textDirectionDesc: + 'チャットのメッセージと入力欄の文字方向を設定します。「自動」は各段落の最初の文字で判断します。混在したテキストの並びがおかしいときは方向を選んでください。コードは常に左から右に表示されます。', + textDirection: { auto: '自動', rtl: '右から左', ltr: '左から右' }, introSplashTitle: 'イントロ表示', introSplashDesc: '空のチャットに表示されるワードマークとプロンプト。', reactionsTitle: 'メッセージリアクション', diff --git a/apps/desktop/src/i18n/languages.test.ts b/apps/desktop/src/i18n/languages.test.ts index 7648a1b427..f2692d30e8 100644 --- a/apps/desktop/src/i18n/languages.test.ts +++ b/apps/desktop/src/i18n/languages.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_LOCALE, isLocale, isSupportedLocaleValue, normalizeLocale } from './languages' +import { TRANSLATIONS } from './catalog' +import { DEFAULT_LOCALE, isLocale, isSupportedLocaleValue, LOCALE_OPTIONS, normalizeLocale } from './languages' describe('desktop i18n languages', () => { it('normalizes supported locale aliases', () => { @@ -22,12 +23,24 @@ describe('desktop i18n languages', () => { expect(normalizeLocale('RU-RU')).toBe('ru') expect(normalizeLocale(' ru_ru ')).toBe('ru') expect(normalizeLocale('Русский')).toBe('ru') + expect(normalizeLocale('fr')).toBe('fr') + expect(normalizeLocale('FR-CA')).toBe('fr') + expect(normalizeLocale(' fr_fr ')).toBe('fr') + expect(normalizeLocale('Français')).toBe('fr') + expect(normalizeLocale('de')).toBe('de') + expect(normalizeLocale('DE-AT')).toBe('de') + expect(normalizeLocale(' de_ch ')).toBe('de') + expect(normalizeLocale('Deutsch')).toBe('de') + expect(normalizeLocale('es')).toBe('es') + expect(normalizeLocale('ES-419')).toBe('es') + expect(normalizeLocale(' es_mx ')).toBe('es') + expect(normalizeLocale('Español')).toBe('es') }) it('falls back to English for empty or unsupported values', () => { expect(normalizeLocale(null)).toBe(DEFAULT_LOCALE) expect(normalizeLocale('')).toBe(DEFAULT_LOCALE) - expect(normalizeLocale('de')).toBe(DEFAULT_LOCALE) + expect(normalizeLocale('it')).toBe(DEFAULT_LOCALE) }) it('distinguishes exact locale ids from supported config aliases', () => { @@ -35,12 +48,25 @@ describe('desktop i18n languages', () => { expect(isSupportedLocaleValue('zh-TW')).toBe(true) expect(isSupportedLocaleValue('ja-JP')).toBe(true) expect(isSupportedLocaleValue('ru-RU')).toBe(true) - expect(isSupportedLocaleValue('de')).toBe(false) + expect(isSupportedLocaleValue('de-DE')).toBe(true) + expect(isSupportedLocaleValue('it')).toBe(false) expect(isLocale('zh-CN')).toBe(false) expect(isLocale('zh')).toBe(true) expect(isLocale('zh-hant')).toBe(true) expect(isLocale('ja')).toBe(true) expect(isLocale('ar')).toBe(true) expect(isLocale('ru')).toBe(true) + expect(isLocale('fr')).toBe(true) + expect(isLocale('de')).toBe(true) + expect(isLocale('es')).toBe(true) + }) + + it('round-trips every picker option through its display.language value to a registered catalog', () => { + for (const option of LOCALE_OPTIONS) { + expect(normalizeLocale(option.configValue)).toBe(option.id) + expect(TRANSLATIONS[option.id]).toBeDefined() + } + + expect(Object.keys(TRANSLATIONS).sort()).toEqual(LOCALE_OPTIONS.map(option => option.id).sort()) }) }) diff --git a/apps/desktop/src/i18n/languages.ts b/apps/desktop/src/i18n/languages.ts index 778a664869..83cac13045 100644 --- a/apps/desktop/src/i18n/languages.ts +++ b/apps/desktop/src/i18n/languages.ts @@ -42,6 +42,24 @@ export const LOCALE_OPTIONS = [ name: LOCALE_ENDONYMS.ru, englishName: 'Russian', configValue: 'ru' + }, + { + id: 'fr', + name: LOCALE_ENDONYMS.fr, + englishName: 'French', + configValue: 'fr' + }, + { + id: 'de', + name: LOCALE_ENDONYMS.de, + englishName: 'German', + configValue: 'de' + }, + { + id: 'es', + name: LOCALE_ENDONYMS.es, + englishName: 'Spanish', + configValue: 'es' } ] as const satisfies readonly { configValue: string; englishName: string; id: Locale; name: string }[] @@ -96,7 +114,40 @@ const LOCALE_ALIASES: Record = { russian: 'ru', 'russian-russian': 'ru', русский: 'ru', - руский: 'ru' + руский: 'ru', + fr: 'fr', + 'fr-fr': 'fr', + fr_fr: 'fr', + 'fr-be': 'fr', + fr_be: 'fr', + 'fr-ca': 'fr', + fr_ca: 'fr', + 'fr-ch': 'fr', + fr_ch: 'fr', + french: 'fr', + français: 'fr', + francais: 'fr', + de: 'de', + 'de-de': 'de', + de_de: 'de', + 'de-at': 'de', + de_at: 'de', + 'de-ch': 'de', + de_ch: 'de', + german: 'de', + deutsch: 'de', + es: 'es', + 'es-es': 'es', + es_es: 'es', + 'es-mx': 'es', + es_mx: 'es', + 'es-ar': 'es', + es_ar: 'es', + 'es-419': 'es', + es_419: 'es', + spanish: 'es', + español: 'es', + espanol: 'es' } export function isLocale(value: unknown): value is Locale { diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 4c7a7b2966..8073128e03 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -682,6 +682,10 @@ export const ru = defineLocale({ backdropDesc: 'Блёклый силуэт позади диалога.', userBubbleTitle: 'Пузырь сообщения', userBubbleDesc: 'Насколько прозрачны ваши сообщения. 0 — сплошная заливка, 100 — остаётся только контур.', + textDirectionTitle: 'Направление текста', + textDirectionDesc: + 'Как сообщения чата и поле ввода выбирают направление. «Авто» ориентируется на первую букву каждого абзаца; выберите направление, если смешанный текст выстраивается неправильно. Код всегда остаётся слева направо.', + textDirection: { auto: 'Авто', rtl: 'Справа налево', ltr: 'Слева направо' }, introSplashTitle: 'Экран приветствия', introSplashDesc: 'Логотип и подсказка, показываемые на пустом чате.', reactionsTitle: 'Реакции на сообщения', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 54b89c2773..7705bf4f15 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -8,7 +8,7 @@ import type { ErrorCodeKey } from '@/lib/error-surface' import type { TipId } from '@/lib/tips/catalog' -export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja' | 'ar' | 'ru' +export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja' | 'ar' | 'ru' | 'fr' | 'de' | 'es' /** One error-card entry: a short title and one plain sentence. Either may * take the failing provider's display name (falls back to "the AI service"). */ @@ -917,6 +917,9 @@ export interface Translations { backdropDesc: string userBubbleTitle: string userBubbleDesc: string + textDirectionTitle: string + textDirectionDesc: string + textDirection: { auto: string; rtl: string; ltr: string } introSplashTitle: string introSplashDesc: string reactionsTitle: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 3f9141d48a..74a7817f13 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -599,6 +599,10 @@ export const zhHant = defineLocale({ backdropDesc: '對話後方那張淡淡的雕像圖片。', userBubbleTitle: '訊息氣泡', userBubbleDesc: '你自己的訊息有多透明。0 為不透明,100 時只保留邊框。', + textDirectionTitle: '文字方向', + textDirectionDesc: + '設定聊天訊息和輸入框的文字方向。「自動」依每段的第一個字母判斷;混合文字排列不對時,可手動選擇方向。程式碼一律從左到右顯示。', + textDirection: { auto: '自動', rtl: '從右到左', ltr: '從左到右' }, introSplashTitle: '開場標識', introSplashDesc: '空白對話中顯示的字標和提示語。', reactionsTitle: '訊息回應', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 94fb3f61df..aa53585b44 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -811,6 +811,10 @@ export const zh = defineLocale({ backdropDesc: '对话后方那张淡淡的雕像图片。', userBubbleTitle: '消息气泡', userBubbleDesc: '你自己的消息有多透明。0 为不透明,100 时只保留边框。', + textDirectionTitle: '文本方向', + textDirectionDesc: + '设置聊天消息和输入框的文字方向。“自动”根据每段的第一个字母判断;混合文本排列不对时,可手动选择方向。代码始终从左到右显示。', + textDirection: { auto: '自动', rtl: '从右到左', ltr: '从左到右' }, introSplashTitle: '开场标识', introSplashDesc: '空白对话中显示的字标和提示语。', reactionsTitle: '消息回应', diff --git a/apps/desktop/src/lib/chat-messages.codex-commentary-reprojection.test.ts b/apps/desktop/src/lib/chat-messages.codex-commentary-reprojection.test.ts new file mode 100644 index 0000000000..ca17143370 --- /dev/null +++ b/apps/desktop/src/lib/chat-messages.codex-commentary-reprojection.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest' + +import { chatMessageText, toChatMessages } from '@/lib/chat-messages' +import type { SessionMessage } from '@/types/hermes' + +describe('public Codex commentary after transcript hydration', () => { + it('keeps a tool-call preamble in assistant text, not the Thinking disclosure', () => { + // Real row shape: the Responses adapter persists a phase=commentary item + // alongside an empty content column and a reasoning blob containing both + // the genuine summary and the already-delivered public preamble. + const preamble = 'I will inspect the repository before changing code.' + + const row: SessionMessage = { + id: 310, + role: 'assistant', + content: '', + reasoning: `**Checking workflow docs**\n\n${preamble}`, + display_reasoning: '**Checking workflow docs**', + display_commentary: [preamble], + tool_calls: [{ id: 'call-read', type: 'function', function: { name: 'read_file', arguments: '{}' } }], + codex_message_items: [ + { + type: 'message', + role: 'assistant', + phase: 'commentary', + content: [{ type: 'output_text', text: preamble }] + } + ], + timestamp: 2 + } + + const [message] = toChatMessages([row]) + const thoughts = message.parts.filter(part => part.type === 'reasoning') + + expect(chatMessageText(message)).toBe(preamble) + expect(thoughts).toHaveLength(1) + expect(thoughts[0]).toMatchObject({ text: '**Checking workflow docs**' }) + }) + + it('removes only exact commentary segments, preserving surrounding and incidental reasoning', () => { + const commentary = 'Inspect the files.\n\nThen run tests.' + + const row: SessionMessage = { + id: 311, + role: 'assistant', + content: 'Final reply.', + reasoning: `Summary before.\n\n${commentary}\n\nSummary after.`, + display_reasoning: 'Summary before.\n\nSummary after.', + display_commentary: [commentary], + codex_message_items: [ + { + type: 'message', + role: 'assistant', + phase: 'commentary', + content: [{ type: 'output_text', text: commentary }] + }, + { type: 'message', role: 'assistant', phase: 'analysis', content: [{ type: 'output_text', text: 'Private.' }] } + ], + timestamp: 3 + } + + const [message] = toChatMessages([row]) + + expect(message.parts.filter(part => part.type === 'text').map(part => part.text)).toEqual([ + commentary, + 'Final reply.' + ]) + expect(message.parts.filter(part => part.type === 'reasoning').map(part => part.text)).toEqual([ + 'Summary before.\n\nSummary after.' + ]) + + const incidental: SessionMessage = { + ...row, + reasoning: `Summary mentioning ${commentary} as an example.`, + display_reasoning: `Summary mentioning ${commentary} as an example.` + } + + const [unchanged] = toChatMessages([incidental]) + + expect(unchanged.parts.filter(part => part.type === 'reasoning').map(part => part.text)).toEqual([ + incidental.reasoning + ]) + }) + + it('honors disabled interim commentary without suppressing a separate final reply', () => { + const commentary = 'I will inspect the files.' + + const row: SessionMessage = { + id: 312, + role: 'assistant', + content: 'Here is the final result.', + reasoning: `Private summary.\n\n${commentary}`, + display_reasoning: 'Private summary.', + display_commentary: [], + codex_message_items: [ + { + type: 'message', + role: 'assistant', + phase: 'commentary', + content: [{ type: 'output_text', text: commentary }] + } + ], + timestamp: 4 + } + + const [message] = toChatMessages([row]) + + expect(chatMessageText(message)).toBe('Here is the final result.') + expect(message.parts.filter(part => part.type === 'reasoning').map(part => part.text)).toEqual(['Private summary.']) + }) + + it('uses only backend-authorized display commentary, never raw replay items', () => { + const raw = 'My key is «redacted:sk-…». private' + + const row: SessionMessage = { + id: 314, + role: 'assistant', + content: '', + reasoning: `Private summary.\n\n${raw}`, + display_reasoning: 'Private summary.', + display_commentary: ['My key is redacted.'], + codex_message_items: [ + { type: 'message', role: 'assistant', phase: 'commentary', content: [{ type: 'output_text', text: raw }] } + ], + timestamp: 5 + } + + expect(chatMessageText(toChatMessages([row])[0])).toBe('My key is redacted.') + expect( + toChatMessages([row])[0] + .parts.filter(part => part.type === 'reasoning') + .map(part => part.text) + ).toEqual(['Private summary.']) + expect(chatMessageText(toChatMessages([{ ...row, display_commentary: undefined }])[0])).toBe('') + }) + + it('keeps a sanitized canonical answer intact when its sidecar origin is ambiguous', () => { + const raw = 'Checking. hidden key=' + 'sk-' + 'demo0123456789abcdef' + + const row: SessionMessage = { + id: 315, + role: 'assistant', + content: `${raw}\n\nFinal result.`, + display_content: 'Checking. key=«redacted»\n\nFinal result.', + display_commentary: [], + reasoning: `Private.\n\n${raw}`, + display_reasoning: 'Private.', + codex_message_items: [ + { type: 'message', role: 'assistant', phase: 'commentary', content: [{ type: 'output_text', text: raw }] } + ], + timestamp: 6 + } + + const [shown] = toChatMessages([row]) + expect(shown.parts.filter(part => part.type === 'text').map(part => part.text)).toEqual([ + 'Checking. key=«redacted»\n\nFinal result.' + ]) + expect(shown.parts.filter(part => part.type === 'reasoning').map(part => part.text)).toEqual(['Private.']) + expect(chatMessageText(toChatMessages([{ ...row, display_commentary: [] }])[0])).toBe( + 'Checking. key=«redacted»\n\nFinal result.' + ) + }) + + it('keeps an explicit empty display content authoritative over a stale final sidecar', () => { + const row: SessionMessage = { + id: 316, + role: 'assistant', + content: 'Working', + display_content: '', + display_commentary: [], + reasoning: 'Private.\n\nWorking', + display_reasoning: 'Private.', + codex_message_items: [ + { + type: 'message', + role: 'assistant', + phase: 'commentary', + content: [{ type: 'output_text', text: 'Working' }] + }, + { + type: 'message', + role: 'assistant', + phase: 'final', + content: [{ type: 'output_text', text: 'Stale sidecar final' }] + } + ], + timestamp: 7 + } + + expect(chatMessageText(toChatMessages([row])[0])).toBe('') + expect( + toChatMessages([row])[0] + .parts.filter(part => part.type === 'reasoning') + .map(part => part.text) + ).toEqual(['Private.']) + // Older backends without a display projection still use final sidecars. + expect(chatMessageText(toChatMessages([{ ...row, display_content: undefined }])[0])).toBe('Working') + }) + + it('keeps a final answer that starts with or equals the commentary text', () => { + for (const final of ['Checking. The answer is 42.', 'Checking.']) { + const row: SessionMessage = { + id: 317, + role: 'assistant', + content: final, + display_commentary: ['Checking.'], + display_reasoning: 'Private.', + codex_message_items: [ + { + type: 'message', + role: 'assistant', + phase: 'commentary', + content: [{ type: 'output_text', text: 'Checking.' }] + }, + { type: 'message', role: 'assistant', phase: 'final', content: [{ type: 'output_text', text: final }] } + ], + timestamp: 8 + } + + const shown = toChatMessages([row])[0] + .parts.filter(part => part.type === 'text') + .map(part => part.text) + + expect(shown).toEqual(final === 'Checking.' ? [final] : ['Checking.', final]) + expect(chatMessageText(toChatMessages([{ ...row, display_commentary: [] }])[0])).toBe(final) + } + }) + + it('uses each message owner’s display projection, not a foreground global setting', () => { + const row: SessionMessage = { + id: 313, + role: 'assistant', + content: '', + reasoning: 'Checking files.', + codex_message_items: [ + { + type: 'message', + role: 'assistant', + phase: 'commentary', + content: [{ type: 'output_text', text: 'Checking files.' }] + } + ], + timestamp: 5 + } + + expect(chatMessageText(toChatMessages([{ ...row, display_commentary: [] }])[0])).toBe('') + expect(chatMessageText(toChatMessages([{ ...row, display_commentary: ['Checking files.'] }])[0])).toBe( + 'Checking files.' + ) + }) +}) diff --git a/apps/desktop/src/lib/chat-messages.codex-json-sidecar.test.ts b/apps/desktop/src/lib/chat-messages.codex-json-sidecar.test.ts index 984d193be1..88aeff32ae 100644 --- a/apps/desktop/src/lib/chat-messages.codex-json-sidecar.test.ts +++ b/apps/desktop/src/lib/chat-messages.codex-json-sidecar.test.ts @@ -30,9 +30,16 @@ it('hydrates the SQLite JSON-text sidecar returned by REST like the decoded RPC expect(toChatMessages([{ ...empty, codex_message_items: '{invalid' }])).toEqual([]) expect(toChatMessages([{ ...empty, display_kind: 'hidden', codex_message_items: JSON.stringify(items) }])).toEqual([]) - for (const phase of ['analysis', 'commentary']) { - expect(toChatMessages([{ ...empty, codex_message_items: JSON.stringify([{ ...items[0], phase }]) }])).toEqual([]) - } + expect( + toChatMessages([{ ...empty, codex_message_items: JSON.stringify([{ ...items[0], phase: 'analysis' }]) }]) + ).toEqual([]) + const rawCommentary = JSON.stringify([{ ...items[0], phase: 'commentary' }]) + expect(toChatMessages([{ ...empty, codex_message_items: rawCommentary }])).toEqual([]) + expect( + chatMessageText( + toChatMessages([{ ...empty, codex_message_items: rawCommentary, display_commentary: ['Durable reply'] }])[0] + ) + ).toBe('Durable reply') expect( chatMessageText( diff --git a/apps/desktop/src/lib/chat-messages.codex-sidecar-hydration.test.ts b/apps/desktop/src/lib/chat-messages.codex-sidecar-hydration.test.ts index 4429b6eab0..438d0797c6 100644 --- a/apps/desktop/src/lib/chat-messages.codex-sidecar-hydration.test.ts +++ b/apps/desktop/src/lib/chat-messages.codex-sidecar-hydration.test.ts @@ -31,6 +31,7 @@ describe('#68321 assistant rows whose reply persisted only in codex_message_item content: '', reasoning: null, reasoning_content: null, + display_commentary: ['Working through the approach...'], codex_message_items: [ { type: 'message', @@ -66,8 +67,8 @@ describe('#68321 assistant rows whose reply persisted only in codex_message_item expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user']) // The final-answer text is painted as the bubble's reply text... expect(chatMessageText(messages[1])).toContain('Here is the full response you saw live.') - // ...and commentary / analysis narration (reasoning channel on the backend) is not. - expect(chatMessageText(messages[1])).not.toContain('Working through the approach...') + // ...and public commentary survives too, without promoting analysis. + expect(chatMessageText(messages[1])).toContain('Working through the approach...') expect(chatMessageText(messages[1])).not.toContain('Scratchpad thoughts.') }) }) diff --git a/apps/desktop/src/lib/chat-messages.interim-preservation.test.ts b/apps/desktop/src/lib/chat-messages.interim-preservation.test.ts new file mode 100644 index 0000000000..e0f64e70f1 --- /dev/null +++ b/apps/desktop/src/lib/chat-messages.interim-preservation.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionMessage } from '@/types/hermes' + +import { + assistantTextPart, + type ChatMessagePart, + chatMessageText, + mergeFinalAssistantText, + reasoningPart, + toChatMessages, + upsertToolPart +} from './chat-messages' + +const item = (phase: string, text: string) => ({ + type: 'message', + role: 'assistant', + phase, + content: [{ type: 'output_text', text }] +}) + +const textParts = (parts: ChatMessagePart[]) => parts.flatMap(part => (part.type === 'text' ? [part.text] : [])) + +const assistantText = (rows: SessionMessage[]) => + toChatMessages(rows) + .filter(message => message.role === 'assistant') + .map(chatMessageText) + .join('\n') + +const withTool = (parts: ChatMessagePart[], id = 'call-1', timestamp = 2) => + upsertToolPart( + parts, + { tool_id: id, name: 'terminal', args: { command: 'pwd' }, result: 'ok' }, + 'complete', + timestamp + ) + +describe('stored assistant commentary preservation', () => { + it.each(['rpc', 'rest'] as const)('restores commentary before the canonical reply through %s history', transport => { + const items = [ + item('commentary', 'I will inspect the files.'), + item('analysis', 'Private analysis.'), + item('final_answer', 'Stale sidecar final.') + ] + + const row: SessionMessage = { + role: 'assistant', + content: 'Canonical final.', + timestamp: 2, + reasoning: 'Real reasoning.', + display_commentary: ['I will inspect the files.'], + codex_message_items: transport === 'rest' ? JSON.stringify(items) : items + } + + const [message] = toChatMessages([row]) + expect(textParts(message.parts)).toEqual(['I will inspect the files.', 'Canonical final.']) + expect(chatMessageText(message)).not.toContain('Private analysis.') + expect(chatMessageText(message)).not.toContain('Stale sidecar final.') + expect(message.parts.filter(part => part.type === 'reasoning')).toEqual([reasoningPart('Real reasoning.', 2)]) + }) + + it.each(['rpc', 'rest'] as const)('keeps a commentary-only assistant row through %s history', transport => { + const items = [item('commentary', 'Still checking.')] + + const rows: SessionMessage[] = [ + { role: 'user', content: 'Check it.', timestamp: 1 }, + { + role: 'assistant', + content: '', + timestamp: 2, + display_commentary: ['Still checking.'], + codex_message_items: transport === 'rest' ? JSON.stringify(items) : items + }, + { role: 'user', content: 'Continue.', timestamp: 3 } + ] + + expect(toChatMessages(rows).map(message => message.role)).toEqual(['user', 'assistant', 'user']) + expect(assistantText(rows)).toBe('Still checking.') + }) + + it('keeps separate commentary items in order and joins only chunks of the same item', () => { + const first = item('commentary', 'First ') + first.content.push({ type: 'output_text', text: 'update.' }) + + const [message] = toChatMessages([ + { + role: 'assistant', + content: '', + timestamp: 2, + display_commentary: ['First update.', 'Second update.'], + codex_message_items: [first, item('commentary', 'Second update.'), item('final_answer', 'Final answer.')] + } + ]) + + expect(textParts(message.parts)).toEqual(['First update.', 'Second update.', 'Final answer.']) + }) + + it('keeps backend-authorized commentary without exposing analysis', () => { + expect( + assistantText([ + { + role: 'assistant', + content: '', + timestamp: 2, + display_commentary: ['Visible update.'], + codex_message_items: [ + item(' Commentary ', 'Visible update.'), + item(' ANALYSIS ', 'Hidden analysis.'), + item('final_answer', 'Answer.') + ] + } + ]) + ).toBe('Visible update.Answer.') + }) + + it('does not duplicate commentary also present in canonical content', () => { + const items = [item('commentary', 'First update.'), item('commentary', 'Second update.')] + expect( + assistantText([ + { + role: 'assistant', + content: 'First update.\n\nSecond update.', + timestamp: 2, + display_commentary: ['First update.', 'Second update.'], + codex_message_items: items + } + ]) + ).toBe('First update.\n\nSecond update.') + }) + + it('keeps canonical text authoritative when it equals one commentary item', () => { + expect( + assistantText([ + { + role: 'assistant', + content: 'Same text.', + timestamp: 2, + display_commentary: ['Same text.'], + codex_message_items: [item('commentary', 'Same text.'), item('final_answer', 'Stale text.')] + } + ]) + ).toBe('Same text.') + }) + + it('respects explicitly hidden and non-assistant rows', () => { + const codex_message_items = [item('commentary', 'Do not surface this.')] + expect( + toChatMessages([{ role: 'assistant', content: '', timestamp: 2, display_kind: 'hidden', codex_message_items }]) + ).toEqual([]) + expect(assistantText([{ role: 'user', content: 'User text.', timestamp: 2, codex_message_items }])).toBe('') + }) + + it.each(['{broken', 'null', '{}', '42'])( + 'ignores malformed sidecar %s without losing canonical content', + codex_message_items => { + expect(assistantText([{ role: 'assistant', content: 'Keep me.', timestamp: 2, codex_message_items }])).toBe( + 'Keep me.' + ) + } + ) + + it('does not promote a reasoning-only row into an answer', () => { + const [message] = toChatMessages([{ role: 'assistant', content: '', timestamp: 2, reasoning: 'Only reasoning.' }]) + expect(chatMessageText(message)).toBe('') + expect(message.parts).toEqual([reasoningPart('Only reasoning.', 2)]) + }) + + it('never promotes raw sidecar commentary without backend authorization', () => { + expect( + assistantText([ + { + role: 'assistant', + content: '', + timestamp: 2, + codex_message_items: JSON.stringify([ + null, + 1, + [], + { ...item('commentary', 'Foreign text.'), role: 'user' }, + { ...item('commentary', 'Wrong type.'), type: 'reasoning' }, + { ...item('commentary', 'Bad content.'), content: 'not an array' }, + { ...item('commentary', ''), content: [null, 1, [], { type: 'output_text', text: 42 }] }, + item('commentary', 'Valid update.') + ]) + } + ]) + ).toBe('') + }) +}) + +describe('authoritative final text is scoped to the latest tool-delimited response', () => { + it('does not erase commentary from before a tool when the interim frame is absent', () => { + const earlier = withTool([assistantTextPart('Checking the repository.', 1)]) + const parts = [...earlier, assistantTextPart('Partial final', 3)] + const before = structuredClone(parts) + const result = mergeFinalAssistantText(parts, 'Final answer.', 4) + expect(textParts(result)).toEqual(['Checking the repository.', 'Final answer.']) + expect(result.slice(0, earlier.length)).toEqual(earlier) + expect(parts).toEqual(before) + }) + + it('keeps all earlier tool-delimited updates, not just the latest one', () => { + const first = withTool([assistantTextPart('First update.', 1)]) + const second = withTool([...first, assistantTextPart('Second update.', 3)], 'call-2', 4) + const result = mergeFinalAssistantText([...second, assistantTextPart('Draft', 5)], 'Done.', 6) + expect(textParts(result)).toEqual(['First update.', 'Second update.', 'Done.']) + expect(result.filter(part => part.type === 'tool-call')).toHaveLength(2) + }) + + it('does not duplicate an exact cumulative final prefix while preserving the tool boundary', () => { + const parts = [...withTool([assistantTextPart('Earlier update.', 1)]), assistantTextPart('Partial', 3)] + const result = mergeFinalAssistantText(parts, 'Earlier update.Final answer.', 4) + expect(textParts(result)).toEqual(['Earlier update.', 'Final answer.']) + expect(result.findIndex(part => part.type === 'tool-call')).toBe(1) + }) + + it('keeps a longer earlier update when the final is only its short prefix', () => { + const parts = withTool([assistantTextPart('Done. The investigation details follow.', 1)]) + expect(textParts(mergeFinalAssistantText(parts, 'Done.', 3))).toEqual([ + 'Done. The investigation details follow.', + 'Done.' + ]) + }) + + it('drops a later provisional draft when the cumulative final equals the earlier response exactly', () => { + const earlier = withTool([assistantTextPart('Earlier update.', 1)]) + const parts = [...earlier, reasoningPart('Checking.', 3), assistantTextPart('Unfinished draft', 4)] + + const result = mergeFinalAssistantText(parts, 'Earlier update.', 5) + + expect(textParts(result)).toEqual(['Earlier update.']) + expect(result.slice(0, earlier.length)).toEqual(earlier) + expect(result.find(part => part.type === 'reasoning')?.text).toBe('Checking.') + }) + + it('still replaces provisional text within one response, even across reasoning parts', () => { + const parts = [ + assistantTextPart('Wrong draft.', 1), + reasoningPart('Reconsidering.', 2), + assistantTextPart('Another draft.', 3) + ] + + expect(textParts(mergeFinalAssistantText(parts, 'Correct final.', 4))).toEqual(['Correct final.']) + }) + + it('keeps a confirmed full stream and an empty terminal frame unchanged', () => { + const parts = [...withTool([assistantTextPart('Earlier.', 1)]), assistantTextPart('Final.', 3)] + expect(mergeFinalAssistantText(parts, 'Earlier.Final.', 4)).toBe(parts) + expect(mergeFinalAssistantText(parts, ' ', 4)).toBe(parts) + }) +}) diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index d9c18c0923..6b2be68a38 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -1173,7 +1173,9 @@ describe('mergeFinalAssistantText', () => { expect(result[0]).toMatchObject({ text: 'final answer', timestamp: 12.5, type: 'text' }) }) - it('removes all text parts and appends the final text', () => { + it('preserves pre-tool text and appends the later final response', () => { + // These deltas precede the tool call: they belong to an earlier model + // response, not to the provisional draft of the final being settled. const parts = [ { type: 'text' as const, text: 'streamed delta 1' }, { type: 'text' as const, text: 'streamed delta 2' }, @@ -1182,8 +1184,11 @@ describe('mergeFinalAssistantText', () => { const result = mergeFinalAssistantText(parts, 'final answer') - expect(result.filter(p => p.type === 'text')).toHaveLength(1) - expect(result.filter(p => p.type === 'text')[0]).toMatchObject({ text: 'final answer' }) + expect(result.filter(p => p.type === 'text').map(p => p.text)).toEqual([ + 'streamed delta 1', + 'streamed delta 2', + 'final answer' + ]) expect(result.some(p => p.type === 'tool-call')).toBe(true) }) diff --git a/apps/desktop/src/lib/chat-messages/hydration.ts b/apps/desktop/src/lib/chat-messages/hydration.ts index ecf79177ed..31e886a2ca 100644 --- a/apps/desktop/src/lib/chat-messages/hydration.ts +++ b/apps/desktop/src/lib/chat-messages/hydration.ts @@ -4,7 +4,14 @@ import { extractImageRefs } from '@/lib/embedded-images' import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images' import type { MessageReaction, SessionMessage } from '@/types/hermes' -import { assistantTextPart, chatMessageText, dedupeRepeatedTextInParts, reasoningPart, textPart } from './parts' +import { + assistantTextPart, + chatMessageText, + dedupeRepeatedTextInParts, + reasoningPart, + renderMediaTags, + textPart +} from './parts' import { applyStoredToolResult, applyStoredToolResultToParts, @@ -26,29 +33,32 @@ const DISCORD_TRIGGERING_NOTE_RE = /(^|\n)\[Triggering message id: `[^`\n]*` — use as `message_id` for reply\/react\/pin via the discord tools\.\]\n*/ /** - * Reply text from a Responses-API `codex_message_items` sidecar (#68321), for rows - * whose `content` persisted empty. `commentary` / `analysis` items are mid-turn - * narration the backend routes to the reasoning channel - * (codex_responses_adapter `_OutputScan._message`); the remaining phases are the reply. + * Backend history projection authorizes/sanitizes public commentary before it + * reaches Desktop. Raw Responses sidecars are used only for final-answer fallback; + * phase=analysis and raw phase=commentary are never promoted to assistant text. */ -function codexMessageItemText(message: SessionMessage): string { +function codexMessageItemText(message: SessionMessage): { commentary: string[]; reply: string } { let items = message.codex_message_items + const commentary = Array.isArray(message.display_commentary) + ? message.display_commentary.filter((part): part is string => typeof part === 'string' && Boolean(part.trim())) + : [] + + const replies: string[] = [] + // REST carries SQLite JSON text; RPC history carries the decoded list. if (typeof items === 'string') { try { items = JSON.parse(items) } catch { - return '' + return { commentary, reply: '' } } } if (!Array.isArray(items)) { - return '' + return { commentary, reply: '' } } - const texts: string[] = [] - for (const item of items) { if (!item || typeof item !== 'object' || Array.isArray(item)) { continue @@ -60,37 +70,34 @@ function codexMessageItemText(message: SessionMessage): string { continue } - if (record.phase === 'commentary' || record.phase === 'analysis') { + const phase = typeof record.phase === 'string' ? record.phase.trim().toLowerCase() : '' + + if (phase === 'analysis' || phase === 'commentary' || !Array.isArray(record.content)) { continue } - const content = record.content + const chunks: string[] = [] - if (!Array.isArray(content)) { - continue - } - - for (const part of content) { + for (const part of record.content) { if (!part || typeof part !== 'object' || Array.isArray(part)) { continue } const partRecord = part as Record - const partType = partRecord.type - if (partType !== 'output_text' && partType !== 'text') { - continue + if ((partRecord.type === 'output_text' || partRecord.type === 'text') && typeof partRecord.text === 'string') { + chunks.push(partRecord.text) } + } - const text = partRecord.text + const text = chunks.join('') - if (typeof text === 'string' && text.length > 0) { - texts.push(text) - } + if (text) { + replies.push(text) } } - return texts.join('') + return { commentary, reply: replies.join('') } } function displayContentForMessage(role: SessionMessage['role'], content: unknown): string { @@ -368,31 +375,40 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] { const sourceHasTools = Array.isArray(message.tool_calls) && message.tool_calls.length > 0 const durableComplete = sourceHasTools ? false : rowId !== undefined ? true : undefined - const reasoning = + const codexText = + displayRole === 'assistant' && message.display_kind !== 'hidden' ? codexMessageItemText(message) : null + + const commentary = codexText?.commentary ?? [] + + const rawReasoning = message.reasoning || message.reasoning_content || (typeof message.reasoning_details === 'string' ? message.reasoning_details : '') + const reasoning = message.display_reasoning !== undefined ? message.display_reasoning : rawReasoning + if (reasoning && message.role === 'assistant') { parts.push(reasoningPart(reasoning, message.timestamp)) } - if (displayContent) { - parts.push( - displayRole === 'assistant' - ? assistantTextPart(displayContent, message.timestamp) - : textPart(displayContent, message.timestamp) - ) + const reply = message.display_content !== undefined ? displayContent : displayContent || codexText?.reply + // Some providers also persist the joined commentary as canonical content. + // Keep that authoritative copy once, without treating unrelated final text + // as a reason to discard the earlier public messages. + const normalized = (value: string) => renderMediaTags(value).replace(/\s+/g, ' ').trim() + + const commentaryIsReply = Boolean( + reply && commentary.length && normalized(commentary.join('\n\n')) === normalized(reply) + ) + + if (!commentaryIsReply) { + parts.push(...commentary.map(text => assistantTextPart(text, message.timestamp))) } - // Reply text can live only in the sidecar alongside reasoning or tool parts. - // Those parts are not a substitute for the answer; canonical content still wins. - if (message.role === 'assistant' && message.display_kind !== 'hidden' && !displayContent) { - const codexText = codexMessageItemText(message) - - if (codexText) { - parts.push(assistantTextPart(codexText, message.timestamp)) - } + if (reply) { + parts.push( + displayRole === 'assistant' ? assistantTextPart(reply, message.timestamp) : textPart(reply, message.timestamp) + ) } if (message.role === 'assistant' && Array.isArray(message.tool_calls)) { diff --git a/apps/desktop/src/lib/chat-messages/parts.ts b/apps/desktop/src/lib/chat-messages/parts.ts index eedf425e5e..144d7c53f7 100644 --- a/apps/desktop/src/lib/chat-messages/parts.ts +++ b/apps/desktop/src/lib/chat-messages/parts.ts @@ -266,8 +266,10 @@ export function dedupeRepeatedTextInParts(parts: ChatMessagePart[]): ChatMessage /** * Merge the final assistant text into a message's parts. * - * - Removes all existing `text` parts (they were streamed deltas, now superseded - * by the authoritative final response). + * - Preserves earlier tool-delimited responses: a missed interim frame must + * not make their public text disposable. + * - Replaces provisional text only in the latest response with its authoritative + * final text, retaining confirmed text/reasoning boundaries. * - Keeps `reasoning` parts, but drops one that the final text fully covers * (reasoning ⊆ final) — the final restates it. A short final ("Done.") must * NOT swallow a longer reasoning block that merely starts with it (#61447). @@ -300,14 +302,41 @@ export function mergeFinalAssistantText( return parts } + // A tool call is an explicit model-response boundary even when no + // message.interim frame sealed the earlier text into a separate bubble. + // Only the suffix after the last call belongs to this authoritative final. + const lastToolIndex = parts.findLastIndex(part => part.type === 'tool-call') + + if (lastToolIndex >= 0) { + const earlier = parts.slice(0, lastToolIndex + 1) + + const earlierText = earlier + .filter((part): part is Extract => part.type === 'text') + .map(part => part.text) + .join('') + + // Some terminal frames carry cumulative text. Strip only an exact prefix; + // fuzzy similarity is not proof that two assistant messages are the same. + const responseText = + earlierText && finalText.startsWith(earlierText) ? finalText.slice(earlierText.length) : finalText + + const suffix = parts.slice(lastToolIndex + 1) + + // A cumulative final can stop exactly at the pre-tool update. The suffix + // draft is still provisional; the ordinary empty-final path keeps drafts. + if (earlierText && finalText === earlierText) { + return [...earlier, ...suffix.filter(part => part.type !== 'text')] + } + + return [...earlier, ...mergeFinalAssistantText(suffix, responseText, fallbackTimestamp)] + } + const previousText = parts.findLast(part => part.type === 'text') const kept = parts.filter(part => { if (part.type === 'text') { - // Sealed text parts were already finalized into their own bubbles — - // this filter only runs on the LAST streaming bubble, so there are no - // sealed parts here. All text parts are streamed deltas that get - // replaced by the authoritative final text. + // The tool-delimited prefix was retained above. This suffix is + // provisional text from the response being finalized. return false } diff --git a/apps/desktop/src/plugins/hermes-bots/group-turns.ts b/apps/desktop/src/plugins/hermes-bots/group-turns.ts index 00af6bb52c..e859437943 100644 --- a/apps/desktop/src/plugins/hermes-bots/group-turns.ts +++ b/apps/desktop/src/plugins/hermes-bots/group-turns.ts @@ -226,7 +226,9 @@ export function retainedGroupTurnError(state: GroupSessionSnapshot | null | unde * carries no start time, so two identical consecutive failures differ only * by `turn_started_at`. */ function retainedGroupTurnKey(state: GroupSessionSnapshot | null | undefined): null | string { - return retainedGroupTurnError(state) === null ? null : JSON.stringify([state?.turn_started_at ?? null, state?.inflight]) + return retainedGroupTurnError(state) === null + ? null + : JSON.stringify([state?.turn_started_at ?? null, state?.inflight]) } /** Does a user row follow the stranded turn's own prompt? Then a later turn @@ -1378,7 +1380,9 @@ export async function harvestStrandedGroupReply(group: string, member: GroupMemb const retained = laterTurnAfterStranded(messages, strandedBefore) ? null : retainedGroupTurnError(state) const pick = - retained === null && messages.length > strandedBefore ? pickStrandedGroupTurnReply(messages, strandedBefore) : null + retained === null && messages.length > strandedBefore + ? pickStrandedGroupTurnReply(messages, strandedBefore) + : null const reply = typeof pick === 'string' ? pick : null const failedNotice = typeof pick === 'string' ? null : (pick?.failedNotice ?? null) diff --git a/apps/desktop/src/store/text-direction.ts b/apps/desktop/src/store/text-direction.ts new file mode 100644 index 0000000000..781ce8fffa --- /dev/null +++ b/apps/desktop/src/store/text-direction.ts @@ -0,0 +1,45 @@ +import { useStore } from '@nanostores/react' + +import { type Codec, persistentAtom } from '@/lib/persisted' + +/** + * How chat prose and the composers pick their base direction. + * + * - `auto`: each block resolves its own direction from its first strong + * character (the `unicode-bidi: plaintext` rules in styles.css). This is the + * shipped behavior and adds no attribute to the DOM. + * - `rtl` / `ltr`: the reader's explicit choice, for text whose first strong + * character does not reflect its language — an Arabic paragraph that opens + * with a Latin name, or an English one that opens with an Arabic quote. Code + * (fenced and inline) and KaTeX stay isolated LTR either way. + */ +export type TextDirection = 'auto' | 'ltr' | 'rtl' + +export const TEXT_DIRECTIONS = ['auto', 'rtl', 'ltr'] as const satisfies readonly TextDirection[] + +// Scope: global to this desktop install (every window and profile). It is a +// reading preference about the person at the keyboard, not about a backend, +// profile or session, so no connection/profile segment belongs in the key. +const STORAGE_KEY = 'hermes.desktop.textDirection' + +// Auto is stored as absence, so users who never touch the setting keep an +// untouched storage record as well as today's rendering. +const textDirectionCodec: Codec = { + decode: raw => (raw === 'rtl' || raw === 'ltr' ? raw : 'auto'), + encode: value => (value === 'auto' ? null : value) +} + +export const $textDirection = persistentAtom(STORAGE_KEY, 'auto', textDirectionCodec) + +export function setTextDirection(direction: TextDirection) { + $textDirection.set(direction) +} + +/** The `dir` value to stamp on chat prose/composers; undefined keeps Auto attribute-free. */ +export function forcedTextDirection(direction: TextDirection): 'ltr' | 'rtl' | undefined { + return direction === 'auto' ? undefined : direction +} + +export function useForcedTextDirection(): 'ltr' | 'rtl' | undefined { + return forcedTextDirection(useStore($textDirection)) +} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index bf8f079ecb..b492e4b93a 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1674,6 +1674,24 @@ body.guest-pointer-lock :is(webview, iframe) { text-align: start; } +/* Appearance → Text direction. An explicit RTL/LTR choice stamps dir on the + markdown root, each user line block and both composers; the first-strong + plaintext vote is exactly what the reader overrode, so it steps aside and + every block takes the chosen direction (every line of a multi-line draft + included). Auto stamps no dir, so nothing here matches. Code and KaTeX keep + their isolated-LTR rules below. */ +[data-slot='aui_assistant-message-content'] .aui-md[dir] :where(p, h1, h2, h3, h4, h5, h6, li, blockquote), +[data-slot='aui_user-inline-text'][dir], +[data-slot='composer-rich-input'][dir] { + unicode-bidi: normal; +} + +/* Header cells pin text-left for the Auto (LTR-box) table; a chosen direction + aligns headers and cells to its own start edge instead. */ +[data-slot='aui_assistant-message-content'] .aui-md[dir] :where(th, td) { + text-align: start; +} + /* Inline code/KaTeX don't vote on direction and keep their own order: isolate makes bidi treat each as one neutral, so a block that *starts* with `./run.sh` then Arabic still resolves RTL, and the command's neutrals (dots/slashes) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 44da09adfa..1b35eba149 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -628,6 +628,10 @@ export interface SessionMessage { content: unknown /** Backend-projected user-visible content when a physical row also carries internal model scaffolding. */ display_content?: unknown + /** Sanitized, profile-authorized public commentary supplied by the history backend. Never recover this from raw replay. */ + display_commentary?: string[] + /** Display-only reasoning after removing exact public commentary; stored reasoning remains unmodified. */ + display_reasoning?: string context?: unknown name?: string reasoning?: null | string diff --git a/contributors/emails/bmtrnavsky@msn.com b/contributors/emails/bmtrnavsky@msn.com new file mode 100644 index 0000000000..223a6f36e3 --- /dev/null +++ b/contributors/emails/bmtrnavsky@msn.com @@ -0,0 +1 @@ +bmtrnavsky diff --git a/contributors/emails/markus@amhomenet.de b/contributors/emails/markus@amhomenet.de new file mode 100644 index 0000000000..15fa5b0b04 --- /dev/null +++ b/contributors/emails/markus@amhomenet.de @@ -0,0 +1 @@ +ydmw74 diff --git a/contributors/emails/pwblais@gmail.com b/contributors/emails/pwblais@gmail.com new file mode 100644 index 0000000000..61ee6290cd --- /dev/null +++ b/contributors/emails/pwblais@gmail.com @@ -0,0 +1 @@ +Potrock diff --git a/contributors/emails/roli@hermes-labs.ai b/contributors/emails/roli@hermes-labs.ai new file mode 100644 index 0000000000..96ce116dd2 --- /dev/null +++ b/contributors/emails/roli@hermes-labs.ai @@ -0,0 +1 @@ +roli-lpci diff --git a/contributors/emails/xiaoyaner0201@users.noreply.github.com b/contributors/emails/xiaoyaner0201@users.noreply.github.com new file mode 100644 index 0000000000..729e4f5007 --- /dev/null +++ b/contributors/emails/xiaoyaner0201@users.noreply.github.com @@ -0,0 +1 @@ +xiaoyaner0201 diff --git a/evals/update_pipeline/interrupted_pull_ab.sh b/evals/update_pipeline/interrupted_pull_ab.sh new file mode 100755 index 0000000000..c923763265 --- /dev/null +++ b/evals/update_pipeline/interrupted_pull_ab.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Live A/B for the interrupted-pull repair (hermes_cli/_early_recovery.py::restore_interrupted_pull). +# +# evals/update_pipeline/interrupted_pull_ab.sh