mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(i18n): pluggable, layered language packs across core, Desktop and TUI (#126296)
* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales
* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter
ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.
* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs
* chore(tui): split en catalog siblings by lane (slash sibling)
* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports
* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts
* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list
* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes
* feat(plugins): report language-pack layers in the mid-run activation summary
* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n
StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.
* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter
- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
layers partial packs (nested or flat dotted) over bundled/en via
mergeTranslations; a string over a function-valued en entry becomes a
positional {0}/{1} formatter; $appLocaleVersion bumps so translators
re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
registry as source 'backend' (method-not-found is silent); re-synced on
socket open, display.language change and profile switch. A saved pack-only
language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).
* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)
Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.
* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()
- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces
* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)
* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()
Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.
locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).
* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()
- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions
* i18n(platforms): route Google Chat and Teams user-facing text through t()
Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.
Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.
* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()
LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.
* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)
Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.
* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)
_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.
* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)
RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.
* i18n(cli): live-work dock, subagent monitor and render copy through t()
cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.
* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()
* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)
get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.
* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output
* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time
Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).
* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)
cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.
* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()
- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
at call time; category labels via slash.category.*; help/alias/usage suffixes
via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
keeps its English constant; callers use history_unreadable() ->
gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.
* i18n(telegram): route adapter chat copy through t()
Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.
Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.
* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()
- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor
* i18n(discord): route adapter chat copy through t()
Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().
* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)
* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()
- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
_DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
Updating/Generating) are one full template per variant; plurals use
<key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).
* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English
* test(i18n): pin Telegram/Discord adapter catalog wiring
Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.
* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge
* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint
* i18n(tr): translate bundled catalog + tui pack
* i18n(ja): translate bundled catalog + tui pack
* i18n(ko): translate bundled catalog + tui pack
* i18n(zh): translate bundled catalog + tui pack
* i18n(fr): translate bundled catalog + tui pack
* i18n(af): translate bundled catalog + tui pack
* i18n(uk): translate bundled catalog + tui pack
* i18n(ar): translate bundled catalog + tui pack
* i18n(pt): translate bundled catalog + tui pack
* i18n(it): translate bundled catalog + tui pack
* i18n(es): translate bundled catalog + tui pack
* i18n(zh-hant): translate bundled catalog + tui pack
* i18n(ru): translate bundled catalog + tui pack
* i18n(hu): translate bundled catalog + tui pack
* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)
* i18n(de): translate bundled catalog + tui pack
* i18n(ga): translate bundled catalog + tui pack
* test(i18n): fixture matches _normalize_lang(lang, home) signature
* i18n(tui): scaffold userMessages/slashCmd en siblings
* i18n(tui): wire secure prompts + content tables
* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays
Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.
* i18n(tui): wire slash ops/wake replies
* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)
* i18n(tui): wire slash core/debug/setup replies
* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)
* i18n(tui): wire slash session/topup/subscription replies
* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)
* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys
* i18n(tui): wire userMessages copy through the userMessages namespace
* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json
* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)
Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).
* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)
* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)
* tui: i18n-export-en script (English templates for pack translators)
* docs(i18n): bundled TUI packs are the bottom layer of the tui surface
* i18n(ru): translate TUI pack
* i18n(ar): translate TUI pack
* i18n(es): translate TUI pack
* i18n(pt): translate TUI pack
* i18n(ko): translate TUI pack
* i18n(de): translate TUI pack
* i18n(ja): translate TUI pack
* i18n(fr): translate TUI pack
* i18n(tr): translate TUI pack
* i18n(it): translate TUI pack
* i18n(zh): translate TUI pack
* i18n(zh-hant): translate TUI pack
* i18n(hu): translate TUI pack
* i18n(uk): translate TUI pack
1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.
Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).
* i18n(ga): translate TUI pack
* i18n(af): translate TUI pack
* plugin_guard: locale catalogs in language packs step down the agent-config family
A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.
* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)
* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header
Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.
* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel
- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)
* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})
* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property
* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal
* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export
The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.
* test: unbreak two main-red timing tests the PR merge-ref inherits
- test_local_runtime racing fake publishes the modern state record (legacy pid-only
records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
a fixed 0.35s, which a loaded CI runner does not always meet
* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)
* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak
SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.
* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings
* chore(i18n): regenerate desktop key catalog after main sync
---------
Co-authored-by: Teknium <teknium@nousresearch.com>
This commit is contained in:
1 parent
2b98a469d3
commit
9bcbe7b5df
329 files changed
+104448
-12388
No files matched your search
@@ -105,6 +105,19 @@ cache break — keep it the only one. Full detail:
|
||||
- Fallback models and credential pools are resolution-chain code: E2E them with real imports
|
||||
against a temp `HERMES_HOME`, not mocks (root rubric).
|
||||
|
||||
## i18n (`agent/i18n.py` + `i18n_layers.py` + `i18n_languages.py`)
|
||||
|
||||
`t(key)` resolves plugin packs (last registered wins) → user overlay `<home>/locales/<lang>.yaml`
|
||||
(profile-scoped) → bundled `locales/<lang>.yaml` → the same for `en` → bare key; every layer is flat
|
||||
dotted keys and may be partial. One merged dict per `(home, lang)` is cached; `reset_language_cache()`
|
||||
(called by every pack registration and by `config set display.language`) drops everything.
|
||||
`supported_languages()` is the live set (bundled ∪ overlay ∪ packs) and is what `display.language`
|
||||
validation and `_normalize_lang` accept; `SUPPORTED_LANGUAGES` stays the bundled tuple. `.tui.yaml` /
|
||||
`.desktop.yaml` pack files are opaque here beyond parse/flatten/serve (`surface_catalog`, RPC
|
||||
`i18n.catalog`); for the `tui` surface the bundled `locales/<lang>.tui.yaml` is the bottom layer of what
|
||||
`surface_catalog` serves (the TUI ships English only in TS). Endonyms for bundled ids live in `i18n_languages.py` and must agree with
|
||||
`apps/shared/src/i18n.ts`.
|
||||
|
||||
## Memory, context engines, curator
|
||||
|
||||
`agent/memory_provider.py` (ABC) + `agent/memory_manager.py` (orchestrator) drive memory-provider
|
||||
|
||||
+33
-30
@@ -16,6 +16,7 @@ from contextlib import contextmanager, suppress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
from agent.i18n import t
|
||||
from agent.prompt_cache_scope import resolve_prompt_cache_scope_safe
|
||||
from agent.thread_scoped_output import thread_scoped_silence
|
||||
|
||||
@@ -264,12 +265,12 @@ def _warn_review_routing_fallback(agent: Any, task_provider: str, task_model: st
|
||||
"""The configured review route could not be resolved, so the fork runs on the main model. That
|
||||
was a debug-level line nobody saw (#116055): the misrouted model never ran and nothing said so.
|
||||
User-visible notice once per agent (same rail as the reasoning_effort notice); log every time."""
|
||||
message = (
|
||||
f"⚠ auxiliary.background_review.provider='{task_provider}' (model '{task_model}') could not be "
|
||||
f"resolved: {str(error).splitlines()[0]} — background reviews run on the main model "
|
||||
f"{agent.provider}/{agent.model} instead. Run 'hermes doctor' to check auxiliary routing."
|
||||
)
|
||||
logger.warning("%s", message)
|
||||
error_line = str(error).splitlines()[0] if str(error) else ""
|
||||
logger.warning(
|
||||
"auxiliary.background_review.provider=%r (model %r) could not be resolved: %s — background reviews "
|
||||
"run on the main model %s/%s instead.", task_provider, task_model, error_line, agent.provider, agent.model)
|
||||
message = t("display.review.routing_fallback_warning", task_provider=task_provider, task_model=task_model,
|
||||
error=error_line, provider=agent.provider, model=agent.model)
|
||||
if getattr(agent, "_warned_bg_review_routing", False):
|
||||
return
|
||||
agent._warned_bg_review_routing = True
|
||||
@@ -581,7 +582,7 @@ def _memory_op_line(label: str, action: str, fields: Dict[str, str]) -> Optional
|
||||
"""Verbose line for one memory add/replace/remove, or None when no preview text."""
|
||||
glyph, field_name, limit = _MEMORY_OP_FORMATS.get(action) or (None, "", 0)
|
||||
text = fields.get(field_name) or "" if glyph else ""
|
||||
return f"{label} {glyph} {_preview(text, limit)}" if text else None
|
||||
return t("display.review.memory_op_line", label=label, glyph=glyph, preview=_preview(text, limit)) if text else None
|
||||
|
||||
|
||||
def _verbose_skill_line(data: Dict, detail: Dict, message: str) -> str:
|
||||
@@ -593,12 +594,13 @@ def _verbose_skill_line(data: Dict, detail: Dict, message: str) -> str:
|
||||
old_string = change.get("old", "") or detail.get("old_string", "")
|
||||
new_string = change.get("new", "") or detail.get("new_string", "")
|
||||
if action == "patch" and (old_string or new_string):
|
||||
old_preview, new_preview = (_preview(t, 80).replace("\n", " ") for t in (old_string, new_string))
|
||||
return f"📝 Skill '{skill_name}' patched: \"{old_preview}\" → \"{new_preview}\""
|
||||
verb = {"create": "created", "edit": "rewritten"}.get(action)
|
||||
if verb and change.get("description"):
|
||||
return f"📝 Skill '{skill_name}' {verb}: {change['description']}"
|
||||
return f"📝 {message}" if message else f"Skill {action}"
|
||||
old_preview, new_preview = (_preview(text, 80).replace("\n", " ") for text in (old_string, new_string))
|
||||
return t("display.review.skill_patched_verbose", name=skill_name, old=old_preview, new=new_preview)
|
||||
verb_key = {"create": "created", "edit": "rewritten"}.get(action)
|
||||
if verb_key and change.get("description"):
|
||||
return t("display.review.skill_changed_verbose", name=skill_name, verb=t(f"display.review.skill_verb.{verb_key}"),
|
||||
description=change["description"])
|
||||
return t("display.review.skill_message_verbose", message=message) if message else t("display.review.skill_action", action=action)
|
||||
|
||||
|
||||
def _verbose_memory_lines(label: str, detail: Dict) -> List[str]:
|
||||
@@ -607,7 +609,7 @@ def _verbose_memory_lines(label: str, detail: Dict) -> List[str]:
|
||||
if isinstance(ops_raw, list) and ops_raw:
|
||||
lines = [_memory_op_line(label, op.get("action", ""), op) for op in ops_raw if isinstance(op, dict)]
|
||||
return [line for line in lines if line]
|
||||
return [_memory_op_line(label, detail.get("action", ""), detail) or f"{label} updated"]
|
||||
return [_memory_op_line(label, detail.get("action", ""), detail) or t("display.review.label_updated", label=label)]
|
||||
|
||||
|
||||
# Tool-call argument fields surfaced in action summaries, with their defaults.
|
||||
@@ -681,10 +683,11 @@ def _action_lines(data: Dict, detail: Dict, verbose: bool) -> List[str]:
|
||||
for result in results:
|
||||
if not isinstance(result, dict) or result.get("success") is not True:
|
||||
continue
|
||||
verb = verbs.get(result.get("action"))
|
||||
if verb and result.get("name"):
|
||||
verb_key = verbs.get(result.get("action"))
|
||||
if verb_key and result.get("name"):
|
||||
path = f" ({result['file_path']})" if result.get("file_path") else ""
|
||||
lines.append(f"Skill '{result['name']}' {verb}{path}")
|
||||
lines.append(t("display.review.skill_result", name=result["name"],
|
||||
verb=t(f"display.review.skill_verb.{verb_key}"), path=path))
|
||||
return lines
|
||||
lower = message.lower()
|
||||
if not verbose and ("created" in lower or "updated" in lower or
|
||||
@@ -692,11 +695,12 @@ def _action_lines(data: Dict, detail: Dict, verbose: bool) -> List[str]:
|
||||
return [message]
|
||||
if not is_skill and not target:
|
||||
return []
|
||||
label = "Skill" if is_skill else {"memory": "Memory", "user": "User profile"}.get(target, target)
|
||||
label_key = "skill" if is_skill else {"memory": "memory", "user": "user"}.get(target)
|
||||
label = t(f"display.review.label.{label_key}") if label_key else target
|
||||
if verbose:
|
||||
return [_verbose_skill_line(data, detail, message)] if is_skill else _verbose_memory_lines(label, detail)
|
||||
hit = any(k in lower for k in ("added", "replaced", "removed", "applied")) or (target and "add" in lower)
|
||||
return [f"{label} updated"] if hit else []
|
||||
return [t("display.review.label_updated", label=label)] if hit else []
|
||||
|
||||
|
||||
def summarize_background_review_actions(
|
||||
@@ -795,8 +799,12 @@ def _classify_review_result(actions: List[str]) -> str:
|
||||
``📝 Skill …``, ``Memory …``, ``User profile …``), so a free-text line like ``Skipped: no
|
||||
skill worth saving`` stays ``none``."""
|
||||
lowers = [str(action).lstrip().removeprefix("📝").lstrip().lower() for action in actions or []]
|
||||
has_skill = any(t.startswith("skill") for t in lowers)
|
||||
has_memory = any(t.startswith(("memory", "user profile")) for t in lowers)
|
||||
# Labels are localized (display.review.label.*); match the active language's labels and English.
|
||||
skill_prefixes = ("skill", t("display.review.label.skill").lower())
|
||||
memory_prefixes = ("memory", "user profile", t("display.review.label.memory").lower(),
|
||||
t("display.review.label.user").lower())
|
||||
has_skill = any(line.startswith(skill_prefixes) for line in lowers)
|
||||
has_memory = any(line.startswith(memory_prefixes) for line in lowers)
|
||||
return "+".join(kind for kind, hit in (("skill", has_skill), ("memory", has_memory)) if hit) or "none"
|
||||
|
||||
|
||||
@@ -850,12 +858,7 @@ def _warn_ignored_reasoning_effort(agent: Any, task_cfg: Optional[Dict[str, Any]
|
||||
if not effort or getattr(agent, "_warned_bg_review_reasoning_effort", False):
|
||||
return
|
||||
agent._warned_bg_review_reasoning_effort = True
|
||||
message = (
|
||||
f"⚠ auxiliary.background_review.reasoning_effort='{effort}' has no effect while the review "
|
||||
"runs on the main model: the fork inherits the conversation's reasoning effort to keep the "
|
||||
"parent's prompt-cache prefix (see memory docs, same-model review reasoning). Route the "
|
||||
"review elsewhere via auxiliary.background_review.provider/model to use a different effort."
|
||||
)
|
||||
message = t("display.review.reasoning_effort_warning", effort=effort)
|
||||
emit = getattr(agent, "_emit_warning", None)
|
||||
if callable(emit):
|
||||
with suppress(Exception):
|
||||
@@ -1205,10 +1208,10 @@ def _run_review_fork(
|
||||
|
||||
def _publish_review_summary(agent: Any, actions: List[str]) -> None:
|
||||
summary = " · ".join(dict.fromkeys(actions))
|
||||
agent._safe_print(f" 💾 Self-improvement review: {summary}")
|
||||
agent._safe_print(t("display.review.summary_cli", summary=summary))
|
||||
if agent.background_review_callback:
|
||||
with suppress(Exception):
|
||||
agent.background_review_callback(f"💾 Self-improvement review: {summary}")
|
||||
agent.background_review_callback(t("display.review.summary_callback", summary=summary))
|
||||
|
||||
|
||||
def _run_review_in_thread(
|
||||
@@ -1281,7 +1284,7 @@ def _run_review_in_thread(
|
||||
logger.warning("Background memory/skill review failed: %s", e)
|
||||
if st.review_usage:
|
||||
_log_review_completion(st.review_usage, "error")
|
||||
agent._emit_auxiliary_failure("background review", e)
|
||||
agent._emit_auxiliary_failure(t("display.review.aux_failure_label"), e)
|
||||
finally:
|
||||
# Safety net for the exception path (setup failures before the request-phase finally).
|
||||
# Both cleanups are identity-scoped and idempotent; re-enter thread-scoped silence so
|
||||
|
||||
+39
-28
@@ -11,6 +11,8 @@ import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from agent.i18n import t
|
||||
|
||||
_SKILLS_BLOCK_RE = re.compile(r"<available_skills>.*?</available_skills>", re.DOTALL)
|
||||
_SUBAGENT_TOOL_NAMES = frozenset({"delegate_task"})
|
||||
|
||||
@@ -31,18 +33,24 @@ _SUBAGENT_TOOL_NAMES = frozenset({"delegate_task"})
|
||||
# zero in practice, so adding them would buy nothing.
|
||||
_ALWAYS_REPORTED = frozenset({"conversation"})
|
||||
|
||||
# id -> (label, dashboard color, /context glyph); declaration order is display order.
|
||||
# id -> (dashboard color, /context glyph); declaration order is display order. The label is
|
||||
# ``gateway.context.category.<id>`` resolved when the payload is built (never at import).
|
||||
_CATEGORIES = {
|
||||
"system_prompt": ("System prompt", "var(--context-usage-system)", "■"),
|
||||
"tool_definitions": ("Tool definitions", "var(--context-usage-tools)", "▣"),
|
||||
"rules": ("Rules", "var(--context-usage-rules)", "▩"),
|
||||
"skills": ("Skills", "var(--context-usage-skills)", "▤"),
|
||||
"mcp": ("MCP", "var(--context-usage-mcp)", "▥"),
|
||||
"subagent_definitions": ("Subagent definitions", "var(--context-usage-subagents)", "▦"),
|
||||
"memory": ("Memory", "var(--context-usage-memory)", "▧"),
|
||||
"conversation": ("Conversation", "var(--context-usage-conversation)", "▨"),
|
||||
"system_prompt": ("var(--context-usage-system)", "■"),
|
||||
"tool_definitions": ("var(--context-usage-tools)", "▣"),
|
||||
"rules": ("var(--context-usage-rules)", "▩"),
|
||||
"skills": ("var(--context-usage-skills)", "▤"),
|
||||
"mcp": ("var(--context-usage-mcp)", "▥"),
|
||||
"subagent_definitions": ("var(--context-usage-subagents)", "▦"),
|
||||
"memory": ("var(--context-usage-memory)", "▧"),
|
||||
"conversation": ("var(--context-usage-conversation)", "▨"),
|
||||
}
|
||||
|
||||
|
||||
def _category_label(category_id: str) -> str:
|
||||
return t(f"gateway.context.category.{category_id}")
|
||||
_FREE_GLYPH = "·"
|
||||
_CONTEXT_SOURCES = frozenset({"local_estimate", "provider_usage", "provider_usage_plus_estimate"})
|
||||
_GRID_COLUMNS = 20
|
||||
_GRID_ROWS = 5 # 100 cells → 1 cell per percent of the context window
|
||||
_DETAILS_TABLE_LIMIT = 15 # display cap only; the underlying data keeps everything
|
||||
@@ -105,7 +113,7 @@ def _join(*parts: str) -> str:
|
||||
|
||||
|
||||
def _glyph(cat: Dict[str, Any]) -> str:
|
||||
return _CATEGORIES.get(str(cat.get("id") or ""), (None, None, "▪"))[2]
|
||||
return _CATEGORIES.get(str(cat.get("id") or ""), (None, "▪"))[1]
|
||||
|
||||
|
||||
def context_display_source(compressor: Any) -> str:
|
||||
@@ -187,8 +195,8 @@ def compute_session_context_breakdown(agent: Any, messages: Optional[List[dict]]
|
||||
|
||||
return {
|
||||
"categories": [
|
||||
{"color": color, "id": category_id, "label": label, "tokens": tokens_by_id[category_id]}
|
||||
for category_id, (label, color, _glyph_) in _CATEGORIES.items()
|
||||
{"color": color, "id": category_id, "label": _category_label(category_id), "tokens": tokens_by_id[category_id]}
|
||||
for category_id, (color, _glyph_) in _CATEGORIES.items()
|
||||
if tokens_by_id[category_id] > 0 or category_id in _ALWAYS_REPORTED
|
||||
],
|
||||
"context_max": context_max,
|
||||
@@ -261,21 +269,25 @@ def render_context_category_lines(payload: Dict[str, Any]) -> List[str]:
|
||||
estimated_total = int(payload.get("estimated_total") or 0)
|
||||
denom = context_max or estimated_total
|
||||
|
||||
lines = ["Estimated usage by category"]
|
||||
lines = [t("gateway.context.category_header")]
|
||||
if not categories:
|
||||
return [*lines, " (no data yet — send a message first)"]
|
||||
width = max(len("Free space"), *(len(str(cat.get("label") or "")) for cat in categories))
|
||||
return [*lines, t("gateway.context.no_data_yet")]
|
||||
free_label = t("gateway.context.free_space")
|
||||
width = max(len(free_label), *(len(str(cat.get("label") or "")) for cat in categories))
|
||||
for cat in categories:
|
||||
tokens, label = int(cat.get("tokens") or 0), str(cat.get("label") or cat.get("id") or "")
|
||||
lines.append(f"{_glyph(cat)} {label:<{width}} ~{tokens:>9,} tokens ~{tokens / denom * 100 if denom else 0.0:>5.1f}%")
|
||||
lines.append(t("gateway.context.category_row", glyph=_glyph(cat), label=f"{label:<{width}}",
|
||||
tokens=f"{tokens:>9,}", pct=f"{tokens / denom * 100 if denom else 0.0:>5.1f}"))
|
||||
if context_max > 0:
|
||||
free = max(0, context_max - estimated_total)
|
||||
lines.append(f"{_FREE_GLYPH} {'Free space':<{width}} ~{free:>9,} tokens ~{free / context_max * 100:>5.1f}%")
|
||||
lines.append(t("gateway.context.category_row", glyph=_FREE_GLYPH, label=f"{free_label:<{width}}",
|
||||
tokens=f"{free:>9,}", pct=f"{free / context_max * 100:>5.1f}"))
|
||||
return lines
|
||||
|
||||
|
||||
def _toolset_row(group: Dict[str, Any]) -> str:
|
||||
return f" {group['toolset']:<24} {group['tool_count']:>3} tools ~{group['schema_tokens']:>8,} tokens"
|
||||
return t("gateway.context.toolset_row", toolset=f"{group['toolset']:<24}", count=f"{group['tool_count']:>3}",
|
||||
tokens=f"{group['schema_tokens']:>8,}")
|
||||
|
||||
|
||||
def _skill_row(entry: Dict[str, Any]) -> str:
|
||||
@@ -283,8 +295,8 @@ def _skill_row(entry: Dict[str, Any]) -> str:
|
||||
if len(name) > 28:
|
||||
name = name[:27] + "…"
|
||||
md = entry.get("skill_md_tokens")
|
||||
md_str = f"~{md:>8,}" if md is not None else f"{'n/a':>8}"
|
||||
return f" {name:<28} index ~{entry['index_tokens']:>6,} SKILL.md {md_str} tokens"
|
||||
md_str = f"~{md:>8,}" if md is not None else f"{t('gateway.context.not_available'):>8}"
|
||||
return t("gateway.context.skill_row", name=f"{name:<28}", index_tokens=f"{entry['index_tokens']:>6,}", md_tokens=md_str)
|
||||
|
||||
|
||||
def _table(lines: List[str], title: str, rows: List[Dict[str, Any]], fmt) -> None:
|
||||
@@ -296,14 +308,14 @@ def _table(lines: List[str], title: str, rows: List[Dict[str, Any]], fmt) -> Non
|
||||
lines.append(title)
|
||||
lines.extend(fmt(row) for row in rows[:_DETAILS_TABLE_LIMIT])
|
||||
if len(rows) > _DETAILS_TABLE_LIMIT:
|
||||
lines.append(f" … and {len(rows) - _DETAILS_TABLE_LIMIT} more")
|
||||
lines.append(t("gateway.context.and_more", count=len(rows) - _DETAILS_TABLE_LIMIT))
|
||||
|
||||
|
||||
def render_context_details_lines(details: Dict[str, Any]) -> List[str]:
|
||||
"""Render the expanded ``/context all`` per-skill / per-toolset tables."""
|
||||
lines: List[str] = []
|
||||
_table(lines, "Toolsets by schema cost (largest first)", details.get("toolsets") or [], _toolset_row)
|
||||
_table(lines, "Skills by cost (index = always-on; SKILL.md = cost when loaded)", details.get("skills") or [], _skill_row)
|
||||
_table(lines, t("gateway.context.toolsets_title"), details.get("toolsets") or [], _toolset_row)
|
||||
_table(lines, t("gateway.context.skills_title"), details.get("skills") or [], _skill_row)
|
||||
return lines
|
||||
|
||||
|
||||
@@ -322,15 +334,14 @@ def render_context_breakdown_lines(
|
||||
if context_max > 0:
|
||||
used, pct = int(payload.get("context_used") or 0), int(payload.get("context_percent") or 0)
|
||||
mark = "~" if payload.get("context_estimated") else ""
|
||||
lines.extend(["", f"Context window: {mark}{used:,} / {context_max:,} tokens ({mark}{pct}%)"])
|
||||
lines.extend(["", t("gateway.context.window_line", mark=mark, used=f"{used:,}", max=f"{context_max:,}", pct=pct)])
|
||||
source = payload.get("context_source")
|
||||
if source:
|
||||
labels = {"local_estimate": "local estimate", "provider_usage": "provider usage",
|
||||
"provider_usage_plus_estimate": "provider usage + estimated new messages"}
|
||||
lines.append(f"Source: {labels.get(source, source)}; category counts are local estimates.")
|
||||
source_label = t(f"gateway.context.source.{source}") if source in _CONTEXT_SOURCES else source
|
||||
lines.append(t("gateway.context.source_line", source=source_label))
|
||||
|
||||
if details is None:
|
||||
lines.extend(["", "Use /context all for per-skill and per-toolset costs."])
|
||||
lines.extend(["", t("gateway.context.hint_all")])
|
||||
elif detail_lines := render_context_details_lines(details):
|
||||
lines.extend(["", *detail_lines])
|
||||
return lines
|
||||
+117
-83
@@ -16,6 +16,7 @@ from typing import Any, Iterator
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from utils import safe_json_loads
|
||||
from agent.i18n import t
|
||||
from agent.redact import redact_sensitive_text
|
||||
from agent.tool_result_classification import file_mutation_result_landed, is_guardrail_refusal
|
||||
|
||||
@@ -292,7 +293,9 @@ def summarize_shell_command(command: str) -> str:
|
||||
if not core:
|
||||
return original
|
||||
count = len(core) - 1
|
||||
return core[0] if not count else f"{core[0]} + {count} {'command' if count == 1 else 'commands'}"
|
||||
if not count:
|
||||
return core[0]
|
||||
return t("display.preview.shell_plus_one" if count == 1 else "display.preview.shell_plus_many", first=core[0], count=count)
|
||||
|
||||
|
||||
def _read_file_line_label(args: dict) -> str:
|
||||
@@ -383,7 +386,8 @@ def _preview_delegate_task(args: dict, max_len: int) -> str | None:
|
||||
return _tail_trunc(action_preview, max_len)
|
||||
if tasks and isinstance(tasks, list):
|
||||
goals = _delegate_task_goals(tasks, per_goal_len=40)
|
||||
preview = f"{len(goals)} tasks: " + " | ".join(goals) if goals else f"{len(tasks)} parallel tasks"
|
||||
preview = (t("display.preview.delegate_tasks", count=len(goals), goals=" | ".join(goals)) if goals
|
||||
else t("display.preview.delegate_parallel_tasks", count=len(tasks)))
|
||||
return _tail_trunc(preview, max_len)
|
||||
goal = args.get("goal", "")
|
||||
return None if goal is None else _tail_trunc(_oneline(str(goal)), max_len) or None
|
||||
@@ -398,8 +402,8 @@ def _preview_process_manage(args: dict, _max_len: int) -> str | None:
|
||||
|
||||
def _preview_todo_list(args: dict, _max_len: int) -> str:
|
||||
todos_arg = args.get("todos")
|
||||
verb = "updating" if args.get("merge", False) else "planning"
|
||||
return "reading task list" if todos_arg is None else f"{verb} {len(todos_arg)} task(s)"
|
||||
verb = t("display.preview.todo_verb_updating" if args.get("merge", False) else "display.preview.todo_verb_planning")
|
||||
return t("display.preview.todo_reading") if todos_arg is None else t("display.preview.todo_tasks", verb=verb, count=len(todos_arg))
|
||||
|
||||
|
||||
def _preview_shell(key: str):
|
||||
@@ -420,13 +424,14 @@ def _preview_memory(args: dict, _max_len: int) -> str:
|
||||
if action == "add":
|
||||
return f"+{target}: \"{_clip(_oneline(args.get('content', '')), 25)}\""
|
||||
if action in ("replace", "remove"):
|
||||
old = _oneline(args.get("old_text") or "") or "<missing old_text>"
|
||||
old = _oneline(args.get("old_text") or "") or t("display.preview.missing_old_text")
|
||||
return f"{'~' if action == 'replace' else '-'}{target}: \"{old[:20]}\""
|
||||
return action
|
||||
|
||||
|
||||
def _preview_send_message(args: dict, _max_len: int) -> str:
|
||||
return f"to {args.get('target', '?')}: \"{_tail_trunc(_oneline(args.get('message', '')), 20)}\""
|
||||
return t("display.preview.send_message_to", target=args.get("target", "?"),
|
||||
message=_tail_trunc(_oneline(args.get("message", "")), 20))
|
||||
|
||||
|
||||
def _preview_skill_view(args: dict, max_len: int) -> str | None:
|
||||
@@ -454,7 +459,7 @@ _PREVIEW_BUILDERS = {
|
||||
"terminal": _preview_shell("command"), "execute_code": _preview_shell("code"),
|
||||
"read_file": _preview_read_file, "memory": _preview_memory, "send_message": _preview_send_message,
|
||||
"skill_view": _preview_skill_view,
|
||||
"session_search": lambda args, _m: f"recall: \"{_clip(_oneline(args.get('query', '')), 25)}\"",
|
||||
"session_search": lambda args, _m: t("display.preview.session_search_recall", query=_clip(_oneline(args.get("query", "")), 25)),
|
||||
"tool_call": _preview_bridge_call("tool_call"),
|
||||
"tool_search": _preview_bridge_call("tool_search"),
|
||||
"tool_describe": _preview_bridge_call("tool_describe"),
|
||||
@@ -500,30 +505,33 @@ def prepare_tool_preview(tool_name: str, args: dict | None, *, fallback: str, ma
|
||||
# Curated built-ins only — we know each core tool's semantics so the verb is fixed,
|
||||
# not computed; custom/plugin/MCP tools have no entry and fall back to the raw preview.
|
||||
|
||||
_TOOL_VERBS: dict[str, str] = {
|
||||
"web_search": "Searching the web", "web_extract": "Reading",
|
||||
"browser_navigate": "Browsing", "browser_click": "Clicking", "browser_type": "Typing",
|
||||
"read_file": "Reading", "write_file": "Writing", "patch": "Editing", "search_files": "Searching files",
|
||||
"terminal": "Running", "execute_code": "Running code",
|
||||
"image_generate": "Generating image", "video_generate": "Generating video",
|
||||
"text_to_speech": "Generating speech", "vision_analyze": "Looking at the image",
|
||||
"session_search": "Searching past sessions",
|
||||
"skill_view": "Reading skill", "skills_list": "Listing skills", "skill_manage": "Updating skill",
|
||||
"delegate_task": "Delegating", "cronjob_manage": "Scheduling", "clarify": "Asking",
|
||||
"memory": "Updating memory", "todo_list": "Updating tasks",
|
||||
}
|
||||
# Tools with a curated verb; the text itself lives in the catalog (``display.verb.<tool>``) and is
|
||||
# resolved at call time by ``get_tool_verb`` so a ``display.language`` change applies immediately.
|
||||
_TOOL_VERB_TOOLS: frozenset[str] = frozenset({
|
||||
"web_search", "web_extract", "browser_navigate", "browser_click", "browser_type",
|
||||
"read_file", "write_file", "patch", "search_files", "terminal", "execute_code",
|
||||
"image_generate", "video_generate", "text_to_speech", "vision_analyze", "session_search",
|
||||
"skill_view", "skills_list", "skill_manage", "delegate_task", "cronjob_manage", "clarify",
|
||||
"memory", "todo_list",
|
||||
})
|
||||
|
||||
|
||||
def _tool_verb(tool_name: str) -> str | None:
|
||||
return t(f"display.verb.{tool_name}") if tool_name in _TOOL_VERB_TOOLS else None
|
||||
|
||||
|
||||
# Verbs that read better without the argument preview appended.
|
||||
_TOOL_VERBS_NO_PREVIEW: frozenset[str] = frozenset({"skills_list", "session_search"})
|
||||
# Verbs joined to the preview with " for " (search-style phrasing).
|
||||
_TOOL_VERBS_FOR_CONNECTOR: frozenset[str] = frozenset({"web_search", "search_files"})
|
||||
|
||||
_BRIDGE_GENERATING = {
|
||||
"tool_call": "a tool call", "tool_search": "a tool search", "tool_describe": "tool details",
|
||||
}
|
||||
_BRIDGE_GENERATING_TOOLS: frozenset[str] = frozenset({"tool_call", "tool_search", "tool_describe"})
|
||||
|
||||
|
||||
def bridge_generating_phrase(tool_name: str) -> str | None:
|
||||
return _BRIDGE_GENERATING.get(tool_name) if _friendly_tool_labels else None
|
||||
if not _friendly_tool_labels or tool_name not in _BRIDGE_GENERATING_TOOLS:
|
||||
return None
|
||||
return t(f"display.bridge_generating.{tool_name}")
|
||||
|
||||
|
||||
def tool_labels_for_call(tool_name: str, args: dict | None) -> list:
|
||||
@@ -551,12 +559,12 @@ def tool_row_emoji(tool_name: str, args: dict | None = None, default: str = "⚡
|
||||
def get_tool_verb(tool_name: str) -> str | None:
|
||||
"""Friendly verb for a built-in tool, or None (labels disabled / no curated verb);
|
||||
callers compose ``f"{verb}{tool_verb_connector(tool)}{preview}"`` themselves."""
|
||||
return _TOOL_VERBS.get(tool_name) if _friendly_tool_labels else None
|
||||
return _tool_verb(tool_name) if _friendly_tool_labels else None
|
||||
|
||||
|
||||
def tool_verb_connector(tool_name: str) -> str:
|
||||
"""Return the connector between a verb and its preview (" for " or " ")."""
|
||||
return " for " if tool_name in _TOOL_VERBS_FOR_CONNECTOR else " "
|
||||
return t("display.verb_connector.search" if tool_name in _TOOL_VERBS_FOR_CONNECTOR else "display.verb_connector.default")
|
||||
|
||||
|
||||
def verb_drops_preview(tool_name: str) -> bool:
|
||||
@@ -573,8 +581,9 @@ def build_status_phrase(tool_name: str, args: dict | None, max_len: int = 49) ->
|
||||
"""
|
||||
if not tool_name or tool_name == "_thinking" or not _friendly_tool_labels:
|
||||
return None
|
||||
verb = _TOOL_VERBS.get(tool_name)
|
||||
phrase = f"is {verb[0].lower()}{verb[1:]}" if verb else f"is using {tool_name}"
|
||||
verb = _tool_verb(tool_name)
|
||||
phrase = (t("display.status_phrase.verb", verb=f"{verb[0].lower()}{verb[1:]}") if verb
|
||||
else t("display.status_phrase.using_tool", tool=tool_name))
|
||||
with_preview = args and verb and tool_name not in _TOOL_VERBS_NO_PREVIEW
|
||||
preview = build_tool_preview(tool_name, args, max_len=None) if with_preview else None
|
||||
if preview: # previews can contain newlines (terminal commands); keep the first line
|
||||
@@ -718,7 +727,7 @@ def _emit_inline_diff(diff_text: str, print_fn) -> bool:
|
||||
if print_fn is None or not diff_text:
|
||||
return False
|
||||
try:
|
||||
for line in [" ┊ review diff", *diff_text.rstrip("\n").splitlines()]:
|
||||
for line in [t("display.diff.review_header"), *diff_text.rstrip("\n").splitlines()]:
|
||||
print_fn(line)
|
||||
return True
|
||||
except Exception:
|
||||
@@ -784,9 +793,9 @@ def _summarize_rendered_diff_sections(
|
||||
omitted_lines += len(_render_inline_unified_diff(leftover))
|
||||
break
|
||||
if omitted_files or omitted_lines:
|
||||
summary = f"… omitted {omitted_lines} diff line(s)"
|
||||
summary = t("display.diff.omitted_lines", count=omitted_lines)
|
||||
if omitted_files:
|
||||
summary += f" across {omitted_files} additional file(s)/section(s)"
|
||||
summary += t("display.diff.omitted_files", count=omitted_files)
|
||||
rendered.append(f"{_diff_ansi()['hunk']}{summary}{_ANSI_RESET}")
|
||||
return rendered
|
||||
|
||||
@@ -824,6 +833,8 @@ class KawaiiSpinner:
|
||||
"(。•́︿•̀。)", "(◔_◔)", "(¬‿¬)", "( •_•)>⌐■-■", "(⌐■_■)", "(´・_・`)", "◉_◉", "(°ロ°)", "( ˘⌣˘)♡", "ヽ(>∀<☆)☆",
|
||||
"٩(๑❛ᴗ❛๑)۶", "(⊙_⊙)", "(¬_¬)", "( ͡° ͜ʖ ͡°)", "ಠ_ಠ",
|
||||
]
|
||||
# English fallback; ``get_thinking_verbs`` prefers the skin list, then the catalog
|
||||
# (``display.thinking_verbs``, comma-separated) for the active language.
|
||||
THINKING_VERBS = [
|
||||
"pondering", "contemplating", "musing", "cogitating", "ruminating", "deliberating", "mulling",
|
||||
"reflecting", "processing", "reasoning", "analyzing", "computing", "synthesizing", "formulating",
|
||||
@@ -841,7 +852,12 @@ class KawaiiSpinner:
|
||||
|
||||
get_waiting_faces = classmethod(lambda cls: cls._skin_spinner_list("waiting_faces", cls.KAWAII_WAITING))
|
||||
get_thinking_faces = classmethod(lambda cls: cls._skin_spinner_list("thinking_faces", cls.KAWAII_THINKING))
|
||||
get_thinking_verbs = classmethod(lambda cls: cls._skin_spinner_list("thinking_verbs", cls.THINKING_VERBS))
|
||||
@classmethod
|
||||
def _catalog_thinking_verbs(cls) -> list[str]:
|
||||
verbs = [v.strip() for v in t("display.thinking_verbs").split(",") if v.strip()]
|
||||
return verbs if verbs and verbs != ["display.thinking_verbs"] else cls.THINKING_VERBS
|
||||
|
||||
get_thinking_verbs = classmethod(lambda cls: cls._skin_spinner_list("thinking_verbs", cls._catalog_thinking_verbs()))
|
||||
|
||||
def __init__(self, message: str = "", spinner_type: str = 'dots', print_fn=None):
|
||||
self.message = message
|
||||
@@ -888,7 +904,7 @@ class KawaiiSpinner:
|
||||
tty = self._is_tty
|
||||
# Non-TTY (Docker, systemd, pipe): log once instead of spamming frames.
|
||||
if not tty:
|
||||
self._write(f" [tool] {self.message}", flush=True)
|
||||
self._write(t("display.spinner.tool_line", message=self.message), flush=True)
|
||||
# Under patch_stdout the \r animation would overdraw the TUI status bar.
|
||||
if not tty or self._is_patch_stdout_proxy():
|
||||
while self.running:
|
||||
@@ -936,7 +952,7 @@ class KawaiiSpinner:
|
||||
self._write(f"\r{self._clear_line_blanks()}\r", end='', flush=True)
|
||||
if final_message:
|
||||
elapsed = f" ({time.time() - self.start_time:.1f}s)" if self.start_time else ""
|
||||
self._write(f" {final_message}" if is_tty else f" [done] {final_message}{elapsed}", flush=True)
|
||||
self._write(f" {final_message}" if is_tty else t("display.spinner.done_line", message=final_message, elapsed=elapsed), flush=True)
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
@@ -960,13 +976,13 @@ def _trim_error(msg: str) -> str:
|
||||
if "File not found:" in msg:
|
||||
tail = msg.partition("File not found:")[2].strip()
|
||||
if "/" in tail:
|
||||
msg = f"File not found: {tail.rsplit('/', 1)[-1]}"
|
||||
msg = t("display.failure.file_not_found", name=tail.rsplit("/", 1)[-1])
|
||||
return _tail_trunc(msg, _ERROR_SUFFIX_MAX_LEN)
|
||||
|
||||
|
||||
def _degraded_suffix(data: dict) -> str:
|
||||
"""`` [<reason> — <retry_hint>]`` for a ``status: degraded`` terminal result (hint omitted when empty)."""
|
||||
reason = str(data.get("reason") or data.get("error") or "terminal backend unavailable").strip()
|
||||
reason = str(data.get("reason") or data.get("error") or t("display.failure.terminal_backend_unavailable")).strip()
|
||||
hint = str(data.get("retry_hint") or "").strip()
|
||||
text = f"{reason} — {hint}" if hint else reason
|
||||
return f" [{_tail_trunc(text, _DEGRADED_SUFFIX_MAX_LEN)}]"
|
||||
@@ -996,13 +1012,13 @@ def _detect_tool_failure(tool_name: str, result: Any) -> tuple[bool, str]:
|
||||
if data.get("status") == "degraded":
|
||||
return True, _degraded_suffix(data)
|
||||
err_msg = data.get("error")
|
||||
return True, f" [{_trim_error(str(err_msg))}]" if err_msg else f" [exit {exit_code}]"
|
||||
return True, f" [{_trim_error(str(err_msg))}]" if err_msg else t("display.failure.exit_code", code=exit_code)
|
||||
|
||||
if isinstance(data, dict):
|
||||
failed = data.get("success") is False
|
||||
# Memory: distinguish "store full" from real errors.
|
||||
if tool_name == "memory" and failed and "exceed the limit" in data.get("error", ""):
|
||||
return True, " [full]"
|
||||
return True, t("display.failure.memory_full")
|
||||
err = data.get("error") or data.get("message")
|
||||
if err and (failed or "error" in data):
|
||||
return True, f" [{_trim_error(str(err))}]"
|
||||
@@ -1010,7 +1026,7 @@ def _detect_tool_failure(tool_name: str, result: Any) -> tuple[bool, str]:
|
||||
if isinstance(result, str) and (
|
||||
'"error"' in result[:500].lower() or '"failed"' in result[:500].lower() or result.startswith("Error")
|
||||
):
|
||||
return True, " [error]"
|
||||
return True, t("display.failure.generic")
|
||||
return False, ""
|
||||
|
||||
|
||||
@@ -1032,13 +1048,18 @@ def _cute_path(p) -> str:
|
||||
return "." * limit if limit <= 3 else "..." + p[-(limit - 3):]
|
||||
|
||||
|
||||
def _cute_row(emoji: str, verb: str, detail: str = "") -> str:
|
||||
"""``┊ {emoji} {verb:9} {detail}`` -- verb column from ``display.cute.verb.<verb>`` (9 cells wide)."""
|
||||
return f"┊ {emoji} {t(f'display.cute.verb.{verb}'):9} {detail}"
|
||||
|
||||
|
||||
def _cute_web_extract(a: dict, _r) -> str:
|
||||
urls = a.get("urls", [])
|
||||
url = _display_url(urls[0] if isinstance(urls, list) else urls) if urls else ""
|
||||
if not url:
|
||||
return "┊ 📄 fetch pages"
|
||||
return _cute_row("📄", "fetch", t("display.cute.fetch_pages"))
|
||||
extra = f" +{len(urls)-1}" if isinstance(urls, list) and len(urls) > 1 else ""
|
||||
return f"┊ 📄 fetch {_cute_trunc(_domain(url))}{extra}"
|
||||
return _cute_row("📄", "fetch", f"{_cute_trunc(_domain(url))}{extra}")
|
||||
|
||||
|
||||
def _cute_todo_list(a: dict, result) -> str:
|
||||
@@ -1049,97 +1070,109 @@ def _cute_todo_list(a: dict, result) -> str:
|
||||
total, done = summary.get("total", 0), summary.get("completed", 0)
|
||||
except Exception:
|
||||
pass
|
||||
progress = t("display.cute.todo_progress", done=done, total=total)
|
||||
if todos_arg is None:
|
||||
detail = f"{done}/{total} task(s)" if total > 0 else "reading tasks"
|
||||
detail = progress if total > 0 else t("display.cute.todo_reading")
|
||||
elif a.get("merge", False):
|
||||
detail = f"update {done}/{total} ✓" if total > 0 and done > 0 else f"update {len(todos_arg)} task(s)"
|
||||
detail = (t("display.cute.todo_update_progress", done=done, total=total) if total > 0 and done > 0
|
||||
else t("display.cute.todo_update_count", count=len(todos_arg)))
|
||||
else:
|
||||
detail = f"{done}/{total} task(s)" if total > 0 and done > 0 else f"{len(todos_arg)} task(s)"
|
||||
return f"┊ 📋 plan {detail}"
|
||||
detail = progress if total > 0 and done > 0 else t("display.cute.todo_count", count=len(todos_arg))
|
||||
return _cute_row("📋", "plan", detail)
|
||||
|
||||
|
||||
def _cute_memory(a: dict, _r) -> str:
|
||||
action, target = a.get("action", "?"), a.get("target", "")
|
||||
if action == "add":
|
||||
return f"┊ 🧠 memory +{target}: \"{_cute_trunc(a.get('content', ''))}\""
|
||||
return _cute_row("🧠", "memory", f"+{target}: \"{_cute_trunc(a.get('content', ''))}\"")
|
||||
if action in ("replace", "remove"):
|
||||
old = a.get("old_text") or "<missing old_text>"
|
||||
return f"┊ 🧠 memory {'~' if action == 'replace' else '-'}{target}: \"{_cute_trunc(old)}\""
|
||||
return f"┊ 🧠 memory {action}"
|
||||
old = a.get("old_text") or t("display.preview.missing_old_text")
|
||||
return _cute_row("🧠", "memory", f"{'~' if action == 'replace' else '-'}{target}: \"{_cute_trunc(old)}\"")
|
||||
return _cute_row("🧠", "memory", str(action))
|
||||
|
||||
|
||||
def _cute_skill_view(a: dict, _r) -> str:
|
||||
label, file_path = a.get("name", ""), a.get("file_path")
|
||||
label = (f"{label} → {file_path}" if label else str(file_path)) if file_path else label
|
||||
return f"┊ 📚 skill {_cute_trunc(label)}"
|
||||
return _cute_row("📚", "skill", _cute_trunc(label))
|
||||
|
||||
|
||||
def _cute_cronjob(a: dict, _r) -> str:
|
||||
action = a.get("action", "?")
|
||||
if action == "create":
|
||||
skills = a.get("skills") or ([a.get("skill")] if a.get("skill") else [])
|
||||
label = a.get("name") or (skills[0] if skills else None) or a.get("prompt", "task")
|
||||
return f"┊ ⏰ cron create {_cute_trunc(label)}"
|
||||
return "┊ ⏰ cron listing" if action == "list" else f"┊ ⏰ cron {action} {a.get('job_id', '')}"
|
||||
label = a.get("name") or (skills[0] if skills else None) or a.get("prompt", t("display.cute.cron_task_fallback"))
|
||||
return _cute_row("⏰", "cron", t("display.cute.cron_create", label=_cute_trunc(label)))
|
||||
if action == "list":
|
||||
return _cute_row("⏰", "cron", t("display.cute.cron_listing"))
|
||||
return _cute_row("⏰", "cron", f"{action} {a.get('job_id', '')}")
|
||||
|
||||
|
||||
def _cute_execute_code(a: dict, _r) -> str:
|
||||
code = a.get("code", "").strip()
|
||||
return f"┊ 🐍 exec {_cute_trunc(code.split(chr(10))[0] if code else '')}"
|
||||
return _cute_row("🐍", "exec", _cute_trunc(code.split(chr(10))[0] if code else ""))
|
||||
|
||||
|
||||
def _cute_browser_exec(a: dict, _r) -> str:
|
||||
# Leading `# …` comment becomes the step label; code stays collapsed behind the preview cap.
|
||||
label = _browser_exec_step_label(a)
|
||||
return f"┊ 🌐 browser {_cute_trunc(_oneline(str(a.get('code', '') or ''))) if label is None else label}"
|
||||
return _cute_row("🌐", "browser", _cute_trunc(_oneline(str(a.get("code", "") or ""))) if label is None else label)
|
||||
|
||||
|
||||
def _cute_delegate(a: dict, _r) -> str:
|
||||
action_preview = _delegate_action_preview(a)
|
||||
tasks = a.get("tasks")
|
||||
if action_preview is not None:
|
||||
return f"┊ 🔀 delegate {_cute_trunc(action_preview)}"
|
||||
return _cute_row("🔀", "delegate", _cute_trunc(action_preview))
|
||||
if tasks and isinstance(tasks, list):
|
||||
goals = _delegate_task_goals(tasks, per_goal_len=30)
|
||||
return f"┊ 🔀 delegate {len(goals) or len(tasks)}x: {_cute_trunc(' | '.join(goals) if goals else 'parallel')}"
|
||||
return f"┊ 🔀 delegate {_cute_trunc(a.get('goal', ''))}"
|
||||
joined = _cute_trunc(" | ".join(goals) if goals else t("display.cute.delegate_parallel"))
|
||||
return _cute_row("🔀", "delegate", f"{len(goals) or len(tasks)}x: {joined}")
|
||||
return _cute_row("🔀", "delegate", _cute_trunc(a.get("goal", "")))
|
||||
|
||||
|
||||
def _cute_process_manage(a: dict, _r) -> str:
|
||||
action, sid = a.get("action", "?"), a.get("session_id", "")[:12]
|
||||
return f"┊ ⚙️ proc {'ls processes' if action == 'list' else f'{action} {sid}'}"
|
||||
return _cute_row("⚙️ ", "proc", t("display.cute.process_list") if action == "list" else f"{action} {sid}")
|
||||
|
||||
|
||||
_SCROLL_ARROWS = {"down": "↓", "up": "↑", "right": "→", "left": "←"}
|
||||
|
||||
|
||||
def _cute_scroll(a: dict, _r) -> str:
|
||||
direction = a.get("direction", "down")
|
||||
label = t(f"display.cute.scroll.{direction}") if direction in _SCROLL_ARROWS else str(direction)
|
||||
return _cute_row(f"{_SCROLL_ARROWS.get(direction, '↓')} ", "scroll", label)
|
||||
|
||||
|
||||
# Completion-line renderers: tool -> f(args, result) -> "┊ {emoji} {verb:9} {detail}" (duration appended by caller).
|
||||
_CUTE_LINES = {
|
||||
"web_search": lambda a, r: f"┊ 🔍 search {_cute_trunc(a.get('query', ''))}",
|
||||
"web_search": lambda a, r: _cute_row("🔍", "search", _cute_trunc(a.get("query", ""))),
|
||||
"web_extract": _cute_web_extract,
|
||||
"terminal": lambda a, r: f"┊ 💻 $ {_cute_trunc(build_tool_preview('terminal', a) or a.get('command', ''))}",
|
||||
"terminal": lambda a, r: _cute_row("💻", "terminal", _cute_trunc(build_tool_preview("terminal", a) or a.get("command", ""))),
|
||||
"process_manage": _cute_process_manage,
|
||||
"read_file": lambda a, r: f"┊ 📖 read {_cute_trunc(build_tool_preview('read_file', a) or a.get('path', ''))}",
|
||||
"write_file": lambda a, r: f"┊ ✍️ write {_cute_path(a.get('path', ''))}",
|
||||
"patch": lambda a, r: f"┊ 🔧 patch {_cute_path(a.get('path', ''))}",
|
||||
"search_files": lambda a, r: f"┊ 🔎 {'find' if a.get('target', 'content') == 'files' else 'grep':9} {_cute_trunc(a.get('pattern', ''))}",
|
||||
"browser_navigate": lambda a, r: f"┊ 🌐 navigate {_cute_trunc(_domain(a.get('url', '')))}",
|
||||
"browser_snapshot": lambda a, r: f"┊ 📸 snapshot {'full' if a.get('full') else 'compact'}",
|
||||
"browser_click": lambda a, r: f"┊ 👆 click {a.get('ref', '?')}",
|
||||
"browser_type": lambda a, r: f"┊ ⌨️ type \"{_cute_trunc(a.get('text', ''))}\"",
|
||||
"browser_scroll": lambda a, r: f"┊ {_SCROLL_ARROWS.get(a.get('direction', 'down'), '↓')} scroll {a.get('direction', 'down')}",
|
||||
"browser_back": lambda a, r: "┊ ◀️ back ",
|
||||
"browser_press": lambda a, r: f"┊ ⌨️ press {a.get('key', '?')}",
|
||||
"browser_get_images": lambda a, r: "┊ 🖼️ images extracting",
|
||||
"browser_vision": lambda a, r: "┊ 👁️ vision analyzing page",
|
||||
"read_file": lambda a, r: _cute_row("📖", "read", _cute_trunc(build_tool_preview("read_file", a) or a.get("path", ""))),
|
||||
"write_file": lambda a, r: _cute_row("✍️ ", "write", _cute_path(a.get("path", ""))),
|
||||
"patch": lambda a, r: _cute_row("🔧", "patch", _cute_path(a.get("path", ""))),
|
||||
"search_files": lambda a, r: _cute_row("🔎", "find" if a.get("target", "content") == "files" else "grep", _cute_trunc(a.get("pattern", ""))),
|
||||
"browser_navigate": lambda a, r: _cute_row("🌐", "navigate", _cute_trunc(_domain(a.get("url", "")))),
|
||||
"browser_snapshot": lambda a, r: _cute_row("📸", "snapshot", t("display.cute.snapshot_full" if a.get("full") else "display.cute.snapshot_compact")),
|
||||
"browser_click": lambda a, r: _cute_row("👆", "click", str(a.get("ref", "?"))),
|
||||
"browser_type": lambda a, r: _cute_row("⌨️ ", "type", f"\"{_cute_trunc(a.get('text', ''))}\""),
|
||||
"browser_scroll": _cute_scroll,
|
||||
"browser_back": lambda a, r: _cute_row("◀️ ", "back"),
|
||||
"browser_press": lambda a, r: _cute_row("⌨️ ", "press", str(a.get("key", "?"))),
|
||||
"browser_get_images": lambda a, r: _cute_row("🖼️ ", "images", t("display.cute.images_extracting")),
|
||||
"browser_vision": lambda a, r: _cute_row("👁️ ", "vision", t("display.cute.vision_analyzing_page")),
|
||||
"todo_list": _cute_todo_list,
|
||||
"session_search": lambda a, r: f"┊ 🔍 recall \"{_cute_trunc(a.get('query', ''))}\"",
|
||||
"session_search": lambda a, r: _cute_row("🔍", "recall", f"\"{_cute_trunc(a.get('query', ''))}\""),
|
||||
"memory": _cute_memory,
|
||||
"skills_list": lambda a, r: f"┊ 📚 skills list {a.get('category', 'all')}",
|
||||
"skills_list": lambda a, r: _cute_row("📚", "skills", t("display.cute.skills_list", category=a.get("category") or t("display.cute.skills_all"))),
|
||||
"skill_view": _cute_skill_view,
|
||||
"image_generate": lambda a, r: f"┊ 🎨 create {_cute_trunc(a.get('prompt', ''))}",
|
||||
"text_to_speech": lambda a, r: f"┊ 🔊 speak {_cute_trunc(a.get('text', ''))}",
|
||||
"vision_analyze": lambda a, r: f"┊ 👁️ vision {_cute_trunc(a.get('question', ''))}",
|
||||
"send_message": lambda a, r: f"┊ 📨 send {a.get('target', '?')}: \"{_cute_trunc(a.get('message', ''))}\"",
|
||||
"image_generate": lambda a, r: _cute_row("🎨", "create", _cute_trunc(a.get("prompt", ""))),
|
||||
"text_to_speech": lambda a, r: _cute_row("🔊", "speak", _cute_trunc(a.get("text", ""))),
|
||||
"vision_analyze": lambda a, r: _cute_row("👁️ ", "vision", _cute_trunc(a.get("question", ""))),
|
||||
"send_message": lambda a, r: _cute_row("📨", "send", f"{a.get('target', '?')}: \"{_cute_trunc(a.get('message', ''))}\""),
|
||||
"cronjob_manage": _cute_cronjob,
|
||||
"execute_code": _cute_execute_code,
|
||||
"browser_exec": _cute_browser_exec,
|
||||
@@ -1163,7 +1196,7 @@ def _entry_failure_suffix(entry: Any) -> str:
|
||||
return ""
|
||||
error = entry.get("error")
|
||||
if isinstance(error, dict):
|
||||
return f" [{_trim_error(str(error.get('message') or error.get('code') or 'error'))}]"
|
||||
return f" [{_trim_error(str(error.get('message') or error.get('code') or t('display.failure.generic_word')))}]"
|
||||
if not (error or entry.get("success") is False):
|
||||
return ""
|
||||
return _detect_tool_failure(str(entry.get("name") or ""), entry)[1]
|
||||
@@ -1208,6 +1241,7 @@ def get_cute_tool_message(tool_name: str, args: dict, duration: float, result: s
|
||||
return _get_cute_tool_message(tool_name, args, duration, result=result)
|
||||
except Exception as exc: # noqa: BLE001 — display must never abort a turn
|
||||
logger.debug("Tool completion label failed for %s: %s", tool_name, exc)
|
||||
safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else "tool"
|
||||
safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else "done"
|
||||
return f"┊ ⚡ {safe_name:9} completed {safe_duration}"
|
||||
safe_name = tool_name[:9] if isinstance(tool_name, str) and tool_name else t("display.cute.fallback_tool_name")
|
||||
safe_duration = f"{duration:.1f}s" if isinstance(duration, (int, float)) else t("display.cute.fallback_done")
|
||||
return t("display.cute.completed", tool=f"{safe_name:9}", duration=safe_duration)
|
||||
|
||||
+120
-52
@@ -1,8 +1,17 @@
|
||||
"""Lightweight i18n for Hermes' static user-facing strings (approval prompts, a few gateway replies).
|
||||
"""Lightweight i18n for Hermes' static user-facing strings (approval prompts, gateway replies, CLI, tips).
|
||||
|
||||
Catalogs are ``locales/<lang>.yaml`` flattened to dotted keys. Missing keys
|
||||
fall back to English, then to the key itself, so a broken catalog never crashes.
|
||||
Language resolution: explicit ``lang=`` > ``HERMES_LANGUAGE`` > ``display.language`` > ``en``.
|
||||
Catalogs are flat dotted-key mappings resolved through layers, top first:
|
||||
|
||||
1. plugin language packs (``PluginContext.register_locale``; last registered wins) — ``agent.i18n_layers``
|
||||
2. the user overlay ``$HERMES_HOME/locales/<lang>.yaml`` of the current profile home
|
||||
3. the bundled ``locales/<lang>.yaml``
|
||||
4. the same chain for ``en``
|
||||
5. the bare key
|
||||
|
||||
Every layer may be partial. Language resolution: explicit ``lang=`` > ``HERMES_LANGUAGE`` >
|
||||
``display.language`` > ``en``; any id that some layer supplies is accepted, so a pack-only language
|
||||
(``pl``) works the moment its plugin loads. ``t()`` is a hot path: one cached merged dict per
|
||||
``(home, lang)``, invalidated by :func:`reset_language_cache` (which every pack registration calls).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,8 +23,13 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from agent import i18n_layers
|
||||
from agent.i18n_languages import language_options
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Bundled catalogs (compat: tests and the parity check iterate this). ``supported_languages()`` is the
|
||||
# live set including overlay and pack languages.
|
||||
SUPPORTED_LANGUAGES: tuple[str, ...] = (
|
||||
"en", "zh", "zh-hant", "ja", "de", "es", "fr", "tr", "uk",
|
||||
"af", "ko", "it", "ga", "pt", "ru", "hu", "ar",
|
||||
@@ -25,7 +39,7 @@ DEFAULT_LANGUAGE = "en"
|
||||
# Natural aliases so "chinese" / "zh-CN" / "jp" hit the right catalog instead of
|
||||
# silently falling back to English. Bare "chinese" defaults to Simplified;
|
||||
# Taiwan/HK/Macau tags route to the distinct Traditional catalog. pt-br shares
|
||||
# the pt catalog (no separate br one).
|
||||
# the pt catalog unless a pack supplies a real pt-br one (a supplied id always wins over an alias).
|
||||
_LANGUAGE_ALIASES: dict[str, str] = {
|
||||
"english": "en", "en-us": "en", "en-gb": "en",
|
||||
"chinese": "zh", "mandarin": "zh", "zh-cn": "zh", "zh-hans": "zh", "zh-sg": "zh",
|
||||
@@ -49,14 +63,16 @@ _LANGUAGE_ALIASES: dict[str, str] = {
|
||||
"ar-sa": "ar", "ar-eg": "ar", "ar-ae": "ar", "ar-ma": "ar", "ar-dz": "ar",
|
||||
}
|
||||
|
||||
_catalog_cache: dict[str, dict[str, str]] = {}
|
||||
# (home, lang) -> merged catalog (packs over overlay over bundled). home -> supported tuple.
|
||||
_catalog_cache: dict[tuple[str, str], dict[str, str]] = {}
|
||||
_supported_cache: dict[str, tuple[str, ...]] = {}
|
||||
_catalog_lock = threading.Lock()
|
||||
|
||||
|
||||
def _locales_dir() -> Path:
|
||||
"""Locale dir: ``HERMES_BUNDLED_LOCALES`` (sealed packaging, e.g. Nix) if it exists, else ``<repo-root>/locales``.
|
||||
|
||||
The source path is returned even when missing so ``_load_catalog`` can log
|
||||
The source path is returned even when missing so ``_load_bundled`` can log
|
||||
the path it looked at rather than raise.
|
||||
"""
|
||||
override = os.getenv("HERMES_BUNDLED_LOCALES", "").strip()
|
||||
@@ -70,52 +86,93 @@ def _locales_dir() -> Path:
|
||||
return Path(__file__).resolve().parent.parent / "locales"
|
||||
|
||||
|
||||
def _normalize_lang(value: Any) -> str:
|
||||
"""Map a user-supplied value (code, alias, or regional tag like ``zh-CN``) to a supported code, else default."""
|
||||
key = value.strip().lower() if isinstance(value, str) else ""
|
||||
if key in SUPPORTED_LANGUAGES:
|
||||
return key
|
||||
if key in _LANGUAGE_ALIASES:
|
||||
return _LANGUAGE_ALIASES[key]
|
||||
base = key.split("-", 1)[0] # strip region suffix
|
||||
return base if base in SUPPORTED_LANGUAGES else DEFAULT_LANGUAGE
|
||||
def _current_home() -> str:
|
||||
from hermes_constants import get_hermes_home
|
||||
return str(get_hermes_home())
|
||||
|
||||
|
||||
def _cache_catalog(lang: str, flat: dict[str, str]) -> dict[str, str]:
|
||||
def supported_languages(home: str | None = None) -> tuple[str, ...]:
|
||||
"""Every language some layer supplies for the current profile home: bundled ∪ user overlay ∪ plugin
|
||||
packs, ``en`` first then sorted. Cached until :func:`reset_language_cache`."""
|
||||
home = home or _current_home()
|
||||
with _catalog_lock:
|
||||
_catalog_cache[lang] = flat
|
||||
return flat
|
||||
|
||||
|
||||
def _load_catalog(lang: str) -> dict[str, str]:
|
||||
"""Load one locale YAML flattened to dotted keys; cached per language (empty dict on any failure)."""
|
||||
with _catalog_lock:
|
||||
cached = _catalog_cache.get(lang)
|
||||
cached = _supported_cache.get(home)
|
||||
if cached is not None:
|
||||
return cached
|
||||
langs = set(SUPPORTED_LANGUAGES) | i18n_layers.layered_languages(home)
|
||||
result = (DEFAULT_LANGUAGE, *sorted(langs - {DEFAULT_LANGUAGE}))
|
||||
with _catalog_lock:
|
||||
_supported_cache[home] = result
|
||||
return result
|
||||
|
||||
|
||||
def resolve_language_id(value: Any, home: str | None = None) -> str | None:
|
||||
"""Canonical supported id for a user-supplied value (code, alias, regional tag), or ``None`` when no
|
||||
layer supplies it — the validation ``hermes config set display.language`` runs."""
|
||||
key = i18n_layers.normalize_language_id(value)
|
||||
if not key:
|
||||
return None
|
||||
supported = supported_languages(home)
|
||||
if key in supported:
|
||||
return key
|
||||
alias = _LANGUAGE_ALIASES.get(key)
|
||||
if alias in supported:
|
||||
return alias
|
||||
base = key.split("-", 1)[0] # strip region suffix
|
||||
return base if base in supported else None
|
||||
|
||||
|
||||
def _normalize_lang(value: Any, home: str | None = None) -> str:
|
||||
"""Map a user-supplied value to a supported code (any layer), else the default."""
|
||||
return resolve_language_id(value, home) or DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def _load_bundled(lang: str) -> dict[str, str]:
|
||||
"""One bundled locale YAML flattened to dotted keys (empty dict on any failure — never crashes)."""
|
||||
path = _locales_dir() / f"{lang}.yaml"
|
||||
flat: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
logger.debug("i18n catalog missing for %s at %s", lang, path)
|
||||
return _cache_catalog(lang, flat)
|
||||
return {}
|
||||
try:
|
||||
import hermes_yaml as yaml
|
||||
with path.open("r", encoding="utf-8-sig") as f:
|
||||
_flatten_into(yaml.safe_load(f) or {}, "", flat)
|
||||
return i18n_layers.parse_locale_file(path)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load i18n catalog %s: %s", path, exc)
|
||||
flat = {}
|
||||
return _cache_catalog(lang, flat)
|
||||
return {}
|
||||
|
||||
|
||||
def _flatten_into(node: Any, prefix: str, out: dict[str, str]) -> None:
|
||||
# Non-string, non-dict leaves are ignored -- catalogs are text-only.
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
_flatten_into(value, f"{prefix}.{key}" if prefix else str(key), out)
|
||||
elif isinstance(node, str):
|
||||
out[prefix] = node
|
||||
"""Flatten a nested mapping into ``out`` (text leaves only)."""
|
||||
i18n_layers.flatten(node, prefix, out)
|
||||
|
||||
|
||||
def _load_catalog(lang: str, home: str | None = None) -> dict[str, str]:
|
||||
"""Merged catalog for ``lang`` in ``home`` (packs > overlay > bundled); cached per (home, lang)."""
|
||||
home = home or _current_home()
|
||||
key = (home, lang)
|
||||
with _catalog_lock:
|
||||
cached = _catalog_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
merged = _load_bundled(lang)
|
||||
merged.update(i18n_layers.overlay_layer(home, lang))
|
||||
merged.update(i18n_layers.pack_layer(lang))
|
||||
with _catalog_lock:
|
||||
_catalog_cache[key] = merged
|
||||
return merged
|
||||
|
||||
|
||||
def surface_catalog(lang: str, surface: str = i18n_layers.CORE_SURFACE) -> dict[str, str]:
|
||||
"""What ``i18n.catalog`` serves for one surface: packs > overlay > bundled ``locales/<lang>.<surface>.yaml``.
|
||||
|
||||
The core surface omits the bundled layer (the Python renderer already has it); the TUI ships only an
|
||||
English catalog in-tree, so its bundled translations live in ``locales/<lang>.tui.yaml`` and ride
|
||||
along here."""
|
||||
lang = _normalize_lang(lang)
|
||||
merged: dict[str, str] = {}
|
||||
if surface != i18n_layers.CORE_SURFACE:
|
||||
merged.update(_load_bundled(f"{lang}.{surface}"))
|
||||
merged.update(i18n_layers.surface_catalog(_current_home(), lang, surface))
|
||||
return merged
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
@@ -126,34 +183,41 @@ def _config_language_cached(hermes_home: str) -> str | None:
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
lang = (load_config_readonly().get("display") or {}).get("language")
|
||||
return _normalize_lang(lang) if lang else None
|
||||
return _normalize_lang(lang, hermes_home) if lang else None
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read display.language from config: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _config_language() -> str | None:
|
||||
from hermes_constants import get_hermes_home
|
||||
return _config_language_cached(str(get_hermes_home()))
|
||||
return _config_language_cached(_current_home())
|
||||
|
||||
|
||||
def reset_language_cache() -> None:
|
||||
"""Invalidate cached language resolution and catalogs (call after ``save_config`` changes ``display.language``)."""
|
||||
"""Invalidate cached language resolution, merged catalogs and every layer view (call after
|
||||
``save_config`` changes ``display.language``, after a pack registers/unregisters, or after editing
|
||||
an overlay file)."""
|
||||
_config_language_cached.cache_clear()
|
||||
with _catalog_lock:
|
||||
_catalog_cache.clear()
|
||||
_supported_cache.clear()
|
||||
i18n_layers.clear_cache()
|
||||
|
||||
|
||||
def _resolve_language(home: str) -> str:
|
||||
from agent.secret_scope import UnscopedSecretError, get_secret
|
||||
try:
|
||||
env_lang = get_secret("HERMES_LANGUAGE")
|
||||
except UnscopedSecretError:
|
||||
env_lang = os.environ.get("HERMES_LANGUAGE") # unscoped default-profile path: environ IS its own value
|
||||
return _normalize_lang(env_lang, home) if env_lang else _config_language_cached(home) or DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def get_language() -> str:
|
||||
"""Resolve the active language using env > config > default order. ``HERMES_LANGUAGE`` is a
|
||||
per-profile ``.env`` value, so it is read through the secret scope: under multiplexing a raw
|
||||
environ read would impose the default profile's language on every other profile."""
|
||||
from agent.secret_scope import UnscopedSecretError, get_secret
|
||||
try:
|
||||
env_lang = get_secret("HERMES_LANGUAGE")
|
||||
except UnscopedSecretError:
|
||||
env_lang = os.environ.get("HERMES_LANGUAGE") # unscoped default-profile path: environ IS its own value
|
||||
return _normalize_lang(env_lang) if env_lang else _config_language() or DEFAULT_LANGUAGE
|
||||
return _resolve_language(_current_home())
|
||||
|
||||
|
||||
def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str:
|
||||
@@ -162,10 +226,11 @@ def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str:
|
||||
``format_kwargs`` are applied with ``str.format``. Falls back to English,
|
||||
then to the bare key; a format failure returns the unformatted string.
|
||||
"""
|
||||
target = _normalize_lang(lang) if lang else get_language()
|
||||
value = _load_catalog(target).get(key)
|
||||
home = _current_home()
|
||||
target = _normalize_lang(lang, home) if lang else _resolve_language(home)
|
||||
value = _load_catalog(target, home).get(key)
|
||||
if value is None and target != DEFAULT_LANGUAGE:
|
||||
value = _load_catalog(DEFAULT_LANGUAGE).get(key)
|
||||
value = _load_catalog(DEFAULT_LANGUAGE, home).get(key)
|
||||
if value is None:
|
||||
logger.debug("i18n miss: key=%r lang=%r", key, target)
|
||||
value = key
|
||||
@@ -178,4 +243,7 @@ def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str:
|
||||
return value
|
||||
|
||||
|
||||
__all__ = ["SUPPORTED_LANGUAGES", "DEFAULT_LANGUAGE", "t", "get_language", "reset_language_cache"]
|
||||
__all__ = [
|
||||
"SUPPORTED_LANGUAGES", "DEFAULT_LANGUAGE", "t", "get_language", "reset_language_cache",
|
||||
"supported_languages", "resolve_language_id", "language_options", "surface_catalog",
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Language identity for the bundled locales — endonym and script direction — plus the picker list.
|
||||
|
||||
One Python table; ``apps/shared/src/i18n.ts`` ``LOCALE_ENDONYMS`` / ``RTL_LOCALES`` must agree for the
|
||||
ids both sides bundle (the desktop lane's test checks the relationship). Pack-only languages take
|
||||
their endonym/rtl from the pack registration (``PluginContext.register_locale(endonym=..., rtl=...)``)
|
||||
and fall back to the bare id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class LanguageOption(TypedDict):
|
||||
id: str
|
||||
endonym: str
|
||||
rtl: bool
|
||||
source: str
|
||||
|
||||
|
||||
# id -> (endonym, rtl). Keep sorted by id; ``en`` is listed first by ``language_options`` regardless.
|
||||
BUNDLED_LANGUAGE_INFO: dict[str, tuple[str, bool]] = {
|
||||
"af": ("Afrikaans", False),
|
||||
"ar": ("العربية", True),
|
||||
"de": ("Deutsch", False),
|
||||
"en": ("English", False),
|
||||
"es": ("Español", False),
|
||||
"fr": ("Français", False),
|
||||
"ga": ("Gaeilge", False),
|
||||
"hu": ("Magyar", False),
|
||||
"it": ("Italiano", False),
|
||||
"ja": ("日本語", False),
|
||||
"ko": ("한국어", False),
|
||||
"pt": ("Português", False),
|
||||
"ru": ("Русский", False),
|
||||
"tr": ("Türkçe", False),
|
||||
"uk": ("Українська", False),
|
||||
"zh": ("简体中文", False),
|
||||
"zh-hant": ("繁體中文", False),
|
||||
}
|
||||
|
||||
BUNDLED_SOURCE = "bundled"
|
||||
OVERLAY_SOURCE = "overlay"
|
||||
|
||||
|
||||
def describe_language(lang: str, *, pack: dict | None, overlay: bool) -> LanguageOption:
|
||||
"""One picker row. Precedence for endonym/rtl: bundled table → pack metadata → bare id / LTR.
|
||||
``source`` names the highest layer that supplies the language (``plugin:<name>`` > ``overlay`` >
|
||||
``bundled``) so a picker can say where a language came from."""
|
||||
bundled = BUNDLED_LANGUAGE_INFO.get(lang)
|
||||
endonym, rtl = bundled if bundled else ((pack or {}).get("endonym") or lang, bool((pack or {}).get("rtl")))
|
||||
if pack is not None:
|
||||
source = str(pack.get("source") or "plugin")
|
||||
elif overlay and bundled is None:
|
||||
source = OVERLAY_SOURCE
|
||||
else:
|
||||
source = BUNDLED_SOURCE
|
||||
return {"id": lang, "endonym": endonym, "rtl": rtl, "source": source}
|
||||
|
||||
|
||||
def language_options() -> list[LanguageOption]:
|
||||
"""``[{"id", "endonym", "rtl", "source"}, ...]`` for every supported language, ``en`` first then sorted
|
||||
by id — the list ``i18n.languages`` serves and every switcher renders (endonym only, no flags)."""
|
||||
from agent import i18n, i18n_layers
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
overlay_langs = i18n_layers.overlay_languages(get_hermes_home())
|
||||
return [
|
||||
describe_language(lang, pack=i18n_layers.pack_info(lang), overlay=lang in overlay_langs)
|
||||
for lang in i18n.supported_languages()
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["BUNDLED_LANGUAGE_INFO", "LanguageOption", "describe_language", "language_options"]
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Catalog layers above the bundled ``locales/`` tree: plugin language packs and the user overlay.
|
||||
|
||||
A language pack is per-language and multi-surface: ``<lang>.yaml`` (core, read by ``agent.i18n.t``),
|
||||
``<lang>.tui.yaml`` and ``<lang>.desktop.yaml`` (opaque to Python: parsed, flattened, served to the
|
||||
renderers over ``i18n.catalog``). Every layer is a flat ``{dotted.key: text}`` mapping and may be
|
||||
PARTIAL — it only carries the keys it overrides.
|
||||
|
||||
Two layers live here:
|
||||
|
||||
- **packs** — registered by plugins through ``PluginContext.register_locale`` (last registered wins);
|
||||
- **overlay** — ``$HERMES_HOME/locales/<lang>[.<surface>].yaml`` of the *current* profile home.
|
||||
|
||||
Merged views are cached per ``(lang, surface)`` / ``(home, lang, surface)``; :func:`clear_cache` (called
|
||||
by ``agent.i18n.reset_language_cache``) drops them, and every pack mutation resets the facade's caches
|
||||
so the next ``t()`` sees the new layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import count
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CORE_SURFACE = "core"
|
||||
SURFACES: tuple[str, ...] = (CORE_SURFACE, "tui", "desktop")
|
||||
|
||||
# ``en``, ``zh-hant``, ``pt-br``, ``sr-latn-rs``: lowercase language subtag + optional region/script parts.
|
||||
_LANGUAGE_ID_RE = re.compile(r"^[a-z]{2,3}(-[a-z0-9]{2,8})*$")
|
||||
# ``pl.yaml`` -> (pl, core); ``pl.tui.yaml`` -> (pl, tui). Anything else in a locales dir is ignored.
|
||||
_LOCALE_FILE_RE = re.compile(r"^(?P<lang>[a-z]{2,3}(?:-[a-z0-9]{2,8})*)(?:\.(?P<surface>[a-z]+))?\.ya?ml$")
|
||||
|
||||
|
||||
def normalize_language_id(value: Any) -> str:
|
||||
"""Canonical spelling of a language id: lowercase, ``_`` -> ``-``, surrounding whitespace dropped."""
|
||||
return str(value).strip().lower().replace("_", "-") if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def is_language_id(value: Any) -> bool:
|
||||
return bool(_LANGUAGE_ID_RE.match(normalize_language_id(value)))
|
||||
|
||||
|
||||
# ── parsing ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def flatten(node: Any, prefix: str = "", out: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""Nested mapping -> ``{dotted.key: text}``. Non-string, non-mapping leaves are dropped (catalogs are
|
||||
text-only); :func:`non_text_leaves` reports them for the validator."""
|
||||
flat: dict[str, str] = {} if out is None else out
|
||||
if isinstance(node, Mapping):
|
||||
for key, value in node.items():
|
||||
flatten(value, f"{prefix}.{key}" if prefix else str(key), flat)
|
||||
elif isinstance(node, str):
|
||||
flat[prefix] = node
|
||||
return flat
|
||||
|
||||
|
||||
def non_text_leaves(node: Any, prefix: str = "") -> list[str]:
|
||||
"""Dotted paths of leaves that are neither text nor a mapping (numbers, lists, booleans, nulls)."""
|
||||
if isinstance(node, Mapping):
|
||||
found: list[str] = []
|
||||
for key, value in node.items():
|
||||
found.extend(non_text_leaves(value, f"{prefix}.{key}" if prefix else str(key)))
|
||||
return found
|
||||
return [] if isinstance(node, str) else [prefix or "<root>"]
|
||||
|
||||
|
||||
def parse_locale_file(path: Path) -> dict[str, str]:
|
||||
"""Parse one locale YAML into a flat catalog. Raises on unreadable/unparseable input or a non-mapping
|
||||
document — the strict form for registration and validation."""
|
||||
import hermes_yaml as yaml
|
||||
with Path(path).open("r", encoding="utf-8-sig") as handle:
|
||||
data = yaml.safe_load(handle)
|
||||
if data is None:
|
||||
return {}
|
||||
if not isinstance(data, Mapping):
|
||||
raise ValueError(f"{path}: top level must be a mapping, got {type(data).__name__}")
|
||||
return flatten(data)
|
||||
|
||||
|
||||
def load_locale_source(source: Any) -> dict[str, str]:
|
||||
"""``register_locale`` input -> flat catalog: a mapping (nested or already flat) is flattened; a path
|
||||
is parsed with :func:`parse_locale_file`."""
|
||||
if isinstance(source, Mapping):
|
||||
return flatten(source)
|
||||
if isinstance(source, (str, Path)):
|
||||
return parse_locale_file(Path(source))
|
||||
raise TypeError(f"locale source must be a mapping or a path to a YAML file, got {type(source).__name__}")
|
||||
|
||||
|
||||
def scan_locale_dir(directory: Path) -> list[tuple[str, str, Path]]:
|
||||
"""``(lang, surface, path)`` for every ``<lang>[.<surface>].yaml`` under *directory* (sorted; empty when
|
||||
the directory is missing). Unknown surfaces are reported so a typo like ``pl.tiu.yaml`` is visible."""
|
||||
directory = Path(directory)
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
found: list[tuple[str, str, Path]] = []
|
||||
for path in sorted(directory.iterdir()):
|
||||
match = _LOCALE_FILE_RE.match(path.name)
|
||||
if match is None or not path.is_file():
|
||||
continue
|
||||
surface = match.group("surface") or CORE_SURFACE
|
||||
if surface not in SURFACES:
|
||||
logger.warning("Ignoring locale file %s: unknown surface %r (expected one of %s)",
|
||||
path, surface, ", ".join(SURFACES))
|
||||
continue
|
||||
found.append((match.group("lang"), surface, path))
|
||||
return found
|
||||
|
||||
|
||||
# ── plugin packs ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackEntry:
|
||||
"""One registered pack layer. ``token`` orders registrations; the last one registered wins."""
|
||||
|
||||
lang: str
|
||||
surface: str
|
||||
messages: Mapping[str, str]
|
||||
source: str
|
||||
endonym: str | None = None
|
||||
rtl: bool = False
|
||||
token: int = field(default=0, compare=False)
|
||||
|
||||
|
||||
_lock = threading.RLock()
|
||||
_tokens = count(1)
|
||||
_packs: list[PackEntry] = []
|
||||
_pack_cache: dict[tuple[str, str], dict[str, str]] = {}
|
||||
_overlay_cache: dict[tuple[str, str, str], dict[str, str]] = {}
|
||||
_overlay_languages_cache: dict[str, frozenset[str]] = {}
|
||||
|
||||
|
||||
def _reset_facade() -> None:
|
||||
from agent.i18n import reset_language_cache # lazy: the facade imports this module
|
||||
reset_language_cache()
|
||||
|
||||
|
||||
def register_pack(lang: str, surface: str, messages: Mapping[str, str], *, source: str,
|
||||
endonym: str | None = None, rtl: bool = False) -> PackEntry:
|
||||
"""Add a pack layer for ``(lang, surface)``; later registrations shadow earlier ones key by key."""
|
||||
lang_id = normalize_language_id(lang)
|
||||
if not is_language_id(lang_id):
|
||||
raise ValueError(f"invalid language id {lang!r} (expected e.g. 'pl', 'pt-br', 'zh-hant')")
|
||||
if surface not in SURFACES:
|
||||
raise ValueError(f"unknown locale surface {surface!r} (expected one of {', '.join(SURFACES)})")
|
||||
entry = PackEntry(lang_id, surface, dict(messages), source, endonym or None, bool(rtl), next(_tokens))
|
||||
with _lock:
|
||||
_packs.append(entry)
|
||||
_reset_facade()
|
||||
logger.debug("i18n pack registered: %s/%s from %s (%d keys)", lang_id, surface, source, len(entry.messages))
|
||||
return entry
|
||||
|
||||
|
||||
def unregister_pack(entry: PackEntry) -> bool:
|
||||
"""Drop one registration (``PluginRegistration`` release). ``False`` when it was already gone."""
|
||||
with _lock:
|
||||
try:
|
||||
_packs.remove(entry)
|
||||
except ValueError:
|
||||
return False
|
||||
_reset_facade()
|
||||
return True
|
||||
|
||||
|
||||
def pack_layer(lang: str, surface: str = CORE_SURFACE) -> dict[str, str]:
|
||||
"""Merged pack messages for ``(lang, surface)`` in registration order (last wins); cached."""
|
||||
key = (lang, surface)
|
||||
with _lock:
|
||||
cached = _pack_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
merged: dict[str, str] = {}
|
||||
for entry in _packs:
|
||||
if entry.lang == lang and entry.surface == surface:
|
||||
merged.update(entry.messages)
|
||||
_pack_cache[key] = merged
|
||||
return merged
|
||||
|
||||
|
||||
def pack_languages() -> frozenset[str]:
|
||||
with _lock:
|
||||
return frozenset(entry.lang for entry in _packs)
|
||||
|
||||
|
||||
def pack_info(lang: str) -> dict[str, Any] | None:
|
||||
"""``{endonym, rtl, source}`` of the newest pack registration for *lang* (``None`` when no pack has
|
||||
it). ``endonym``/``rtl`` come from the newest registration that declared them."""
|
||||
with _lock:
|
||||
entries = [entry for entry in _packs if entry.lang == lang]
|
||||
if not entries:
|
||||
return None
|
||||
endonym = next((entry.endonym for entry in reversed(entries) if entry.endonym), None)
|
||||
rtl = next((entry.rtl for entry in reversed(entries) if entry.endonym is not None), entries[-1].rtl)
|
||||
return {"endonym": endonym, "rtl": rtl, "source": entries[-1].source}
|
||||
|
||||
|
||||
# ── user overlay ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def overlay_dir(home: Path | str) -> Path:
|
||||
return Path(home) / "locales"
|
||||
|
||||
|
||||
def overlay_layer(home: Path | str, lang: str, surface: str = CORE_SURFACE) -> dict[str, str]:
|
||||
"""``<home>/locales/<lang>[.<surface>].yaml`` flattened; ``{}`` (logged) when missing or broken. Cached
|
||||
per home so a multiplexed process serving profile A then B then A never mixes overlays."""
|
||||
key = (str(home), lang, surface)
|
||||
with _lock:
|
||||
cached = _overlay_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
name = f"{lang}.yaml" if surface == CORE_SURFACE else f"{lang}.{surface}.yaml"
|
||||
path = overlay_dir(home) / name
|
||||
flat: dict[str, str] = {}
|
||||
if path.is_file():
|
||||
try:
|
||||
flat = parse_locale_file(path)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load i18n overlay %s: %s", path, exc)
|
||||
flat = {}
|
||||
with _lock:
|
||||
_overlay_cache[key] = flat
|
||||
return flat
|
||||
|
||||
|
||||
def overlay_languages(home: Path | str) -> frozenset[str]:
|
||||
"""Language ids that have at least one overlay file (any surface) under *home*."""
|
||||
key = str(home)
|
||||
with _lock:
|
||||
cached = _overlay_languages_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
langs = frozenset(lang for lang, _surface, _path in scan_locale_dir(overlay_dir(home)))
|
||||
with _lock:
|
||||
_overlay_languages_cache[key] = langs
|
||||
return langs
|
||||
|
||||
|
||||
def surface_catalog(home: Path | str, lang: str, surface: str = CORE_SURFACE) -> dict[str, str]:
|
||||
"""Overlay + packs (packs win) for one surface — the layer ``i18n.catalog`` ships to a renderer, which
|
||||
merges it over its own bundled catalog. Bundled Python locales are NOT included."""
|
||||
merged = dict(overlay_layer(home, lang, surface))
|
||||
merged.update(pack_layer(lang, surface))
|
||||
return merged
|
||||
|
||||
|
||||
def layered_languages(home: Path | str) -> frozenset[str]:
|
||||
"""Languages supplied by any layer above bundled (overlay of *home* ∪ packs)."""
|
||||
return overlay_languages(home) | pack_languages()
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop merged views (not the registrations); the facade's ``reset_language_cache`` calls this."""
|
||||
with _lock:
|
||||
_pack_cache.clear()
|
||||
_overlay_cache.clear()
|
||||
_overlay_languages_cache.clear()
|
||||
|
||||
|
||||
def _reset_registry_for_tests() -> None:
|
||||
with _lock:
|
||||
_packs.clear()
|
||||
clear_cache()
|
||||
|
||||
|
||||
def registered_packs() -> tuple[PackEntry, ...]:
|
||||
with _lock:
|
||||
return tuple(_packs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CORE_SURFACE", "SURFACES", "PackEntry", "normalize_language_id", "is_language_id", "flatten",
|
||||
"non_text_leaves", "parse_locale_file", "load_locale_source", "scan_locale_dir", "register_pack",
|
||||
"unregister_pack", "pack_layer", "pack_languages", "pack_info", "overlay_dir", "overlay_layer",
|
||||
"overlay_languages", "surface_catalog", "layered_languages", "clear_cache", "registered_packs",
|
||||
]
|
||||
@@ -10,6 +10,8 @@ from contextlib import suppress
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from agent.i18n import t
|
||||
|
||||
ACTIVITY_DESCRIPTION_MAX = 120
|
||||
|
||||
# Durable SessionDB heartbeat cadence. Contract: MUST stay >= 30s — the SessionDB write path is contended and
|
||||
@@ -82,8 +84,8 @@ def format_iteration_progress(api_call_count: Any, max_iterations: Any) -> str:
|
||||
except (TypeError, ValueError):
|
||||
cap = sys.maxsize
|
||||
if cap >= sys.maxsize:
|
||||
return f"iteration {api_call_count}"
|
||||
return f"iteration {api_call_count}/{cap}"
|
||||
return t("display.iteration_progress.unbounded", n=api_call_count)
|
||||
return t("display.iteration_progress.bounded", n=api_call_count, max=cap)
|
||||
|
||||
|
||||
def reset_session_activity_persist_window(agent: Any) -> None:
|
||||
|
||||
@@ -7,6 +7,7 @@ Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from agent.i18n import t
|
||||
from agent.session_activity import ActivityProvenance
|
||||
|
||||
# Same logger name as the origin module so log records / caplog filters are unchanged.
|
||||
@@ -131,12 +132,8 @@ class StatusOutputMixin:
|
||||
_warn_key = ("uncompressed_ctx_overflow", context_length)
|
||||
if getattr(self, "_last_ctx_overflow_warn", None) != _warn_key:
|
||||
self._last_ctx_overflow_warn = _warn_key
|
||||
self._emit_warning(
|
||||
f"⚠️ Session context (~{preflight_tokens:,} tokens) exceeds the model "
|
||||
f"context window (~{context_length:,} tokens) with compression disabled "
|
||||
f"(compression.enabled: false). Use /compact to compress history or "
|
||||
f"enable compression in config.yaml."
|
||||
)
|
||||
self._emit_warning(t("display.notice.uncompressed_context_overflow",
|
||||
tokens=f"{preflight_tokens:,}", context_length=f"{context_length:,}"))
|
||||
|
||||
def _clear_context_overflow_warn(self) -> None:
|
||||
"""Reset the blocked-overflow warning dedup so it can re-fire on the next blocked turn."""
|
||||
|
||||
+48
-177
@@ -8,6 +8,7 @@ import re
|
||||
from contextlib import suppress
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from agent.i18n import t
|
||||
from agent.tool_dispatch_helpers import (
|
||||
_extract_error_preview, _extract_file_mutation_targets, _extract_landed_file_mutation_paths
|
||||
)
|
||||
@@ -15,168 +16,45 @@ from agent.tool_result_classification import (
|
||||
FILE_MUTATING_TOOL_NAMES as _FILE_MUTATING_TOOLS, file_mutation_result_landed
|
||||
)
|
||||
|
||||
_NO_REPLY = "⚠️ No reply: "
|
||||
|
||||
# One text for "the model produced nothing after retries" on every surface (CLI explainer,
|
||||
# gateway ``(empty)`` rewrite, desktop); the model name is filled in by the explainer.
|
||||
# gateway ``(empty)`` rewrite, desktop). English source kept as a constant for importers; surfaces
|
||||
# rendering to a human use ``empty_response_explanation`` (``explainer.empty_response``).
|
||||
EMPTY_RESPONSE_EXPLANATION = (
|
||||
"{model} didn't produce a reply this time, even after retries. "
|
||||
"Send `continue` to try again, or switch models with /model."
|
||||
)
|
||||
|
||||
# Exact ``turn_exit_reason`` → explanation body (prefixed with ``_NO_REPLY``).
|
||||
|
||||
def empty_response_explanation(model: str = "") -> str:
|
||||
"""Localized ``EMPTY_RESPONSE_EXPLANATION`` with the model name (or "The model") filled in."""
|
||||
return t("explainer.empty_response", model=model or t("explainer.shared.the_model"))
|
||||
|
||||
|
||||
# Exact ``turn_exit_reason`` → catalog key of the explanation body (prefixed with the no-reply marker).
|
||||
_EXIT_REASON_EXPLANATIONS: Dict[str, str] = {
|
||||
"empty_response_exhausted": EMPTY_RESPONSE_EXPLANATION,
|
||||
"all_retries_exhausted_no_response": (
|
||||
"the model provider didn't answer after all retries. "
|
||||
"Send /retry, or switch models with /model."
|
||||
),
|
||||
"partial_stream_recovery": (
|
||||
"streaming stopped early and only a partial response was "
|
||||
"recovered. Send `continue` to resume from where it stopped."
|
||||
),
|
||||
"fallback_prior_turn_content": (
|
||||
"no new content was produced this turn; showing recovered "
|
||||
"prior context. Send `continue` to retry."
|
||||
),
|
||||
"redirect_restart_limit_exceeded": (
|
||||
"the request was cancelled by a new correction on every attempt, "
|
||||
"so the turn stopped instead of retrying forever. Your last "
|
||||
"correction is queued as the next message."
|
||||
),
|
||||
"rebuilt_restart_limit_exceeded": (
|
||||
"every provider in the fallback chain kept failing over, so the "
|
||||
"turn stopped instead of retrying forever. Send `continue` or "
|
||||
"switch provider."
|
||||
),
|
||||
"budget_exhausted": (
|
||||
"the per-turn iteration/cost budget was exhausted before a "
|
||||
"final answer. Send `continue` to keep going."
|
||||
),
|
||||
"ollama_runtime_context_too_small": (
|
||||
"the local model's context window was too small to finish. "
|
||||
"Increase the context size or use a larger model."
|
||||
),
|
||||
"pending_tool_result": (
|
||||
"the turn stopped while a tool result was still pending and "
|
||||
"the model produced no follow-up text. Send `continue` to "
|
||||
"let it summarize."
|
||||
),
|
||||
"empty_response_exhausted": "explainer.empty_response",
|
||||
"all_retries_exhausted_no_response": "explainer.exit.all_retries_exhausted_no_response",
|
||||
"partial_stream_recovery": "explainer.exit.partial_stream_recovery",
|
||||
"fallback_prior_turn_content": "explainer.exit.fallback_prior_turn_content",
|
||||
"redirect_restart_limit_exceeded": "explainer.exit.redirect_restart_limit_exceeded",
|
||||
"rebuilt_restart_limit_exceeded": "explainer.exit.rebuilt_restart_limit_exceeded",
|
||||
"budget_exhausted": "explainer.exit.budget_exhausted",
|
||||
"ollama_runtime_context_too_small": "explainer.exit.ollama_runtime_context_too_small",
|
||||
"pending_tool_result": "explainer.exit.pending_tool_result",
|
||||
}
|
||||
|
||||
# Parameterised reasons (``max_iterations_reached(3/3)`` …) matched by prefix.
|
||||
_EXIT_REASON_PREFIX_EXPLANATIONS = (
|
||||
# ``interrupted_during_api_call(<issuer>)`` names a system watchdog (#112647).
|
||||
("interrupted_during_api_call", (
|
||||
"the request was interrupted mid-call before a reply was "
|
||||
"received. Send `continue` to retry."
|
||||
)),
|
||||
("max_iterations_reached", (
|
||||
"the maximum tool-iteration limit was reached before a "
|
||||
"final answer. Send `continue` to keep going, or raise "
|
||||
"`max_iterations`."
|
||||
)),
|
||||
("error_near_max_iterations", (
|
||||
"an error occurred near the iteration limit before a final "
|
||||
"answer. Check the tool output above, then send `continue`."
|
||||
)),
|
||||
("repeated_outer_errors", (
|
||||
"the turn kept failing with repeated errors and was stopped "
|
||||
"early instead of retrying forever. Check the errors above, "
|
||||
"then send `continue` to retry."
|
||||
)),
|
||||
# ``interrupted_during_api_call(<issuer>)`` names a system watchdog (#112647).
|
||||
_EXIT_REASON_PREFIX_EXPLANATIONS = tuple(
|
||||
(prefix, f"explainer.exit.{prefix}") for prefix in ("interrupted_during_api_call", "max_iterations_reached", "error_near_max_iterations", "repeated_outer_errors")
|
||||
)
|
||||
|
||||
# ``session_persistence_failed`` refined by the classified cause (lock contention ≠ disk full).
|
||||
_PERSISTENCE_CAUSE_EXPLANATIONS: Dict[str, str] = {
|
||||
"compression": (
|
||||
"the turn was stopped because another process was "
|
||||
"compressing this session. Your message should already be "
|
||||
"saved — please send it again after compression completes."
|
||||
),
|
||||
"compression_closed": (
|
||||
"the turn was stopped because this session was rotated "
|
||||
"by context compression and its live continuation could "
|
||||
"not be adopted. The storage itself is healthy — refresh "
|
||||
"the client (or start a new turn) so it picks up the new "
|
||||
"session id, then send your message again."
|
||||
),
|
||||
"turn_lease": (
|
||||
"the turn was stopped because another Hermes process "
|
||||
"took over this session. Your reply was not saved — wait "
|
||||
"for the other process to finish, then send your message "
|
||||
"again."
|
||||
),
|
||||
"session_row_missing": (
|
||||
"this session was deleted from storage while the chat was still open, "
|
||||
"and Hermes could not recreate it, so this message was not saved. "
|
||||
"Send your message again; if it keeps failing, run "
|
||||
"`hermes {profile_arg}doctor`."
|
||||
),
|
||||
"locked": (
|
||||
"the turn was stopped because session storage was busy "
|
||||
"(another Hermes process was writing to the state "
|
||||
"database). Your message should already be saved — "
|
||||
"please send it again in a moment."
|
||||
),
|
||||
# The forensic runbook for both (WAL generations, manifest.json, sidecars) lives in the
|
||||
# logger.error at hermes_state.py::_raise_if_db_replaced — never in the chat reply.
|
||||
"replaced": (
|
||||
"the session database file was replaced while Hermes was running, so this "
|
||||
"message was not saved (a copy is kept in {home}/sessions/). Stop Hermes "
|
||||
"(`hermes {profile_arg}gateway stop`), run `hermes {profile_arg}doctor` — not "
|
||||
"`hermes {profile_arg}doctor --fix`, which would repair the wrong file in place — "
|
||||
"then start it again and send your message once more. Advanced recovery steps are "
|
||||
"in the log."
|
||||
),
|
||||
"deleted_wal": (
|
||||
"another Hermes process still holds an old copy of the session database's write-ahead "
|
||||
"log, so Hermes stopped writing to keep the file safe and this message was not saved (a "
|
||||
"copy is kept in {home}/sessions/). Nothing is lost. Quit every Hermes process on this "
|
||||
"profile (Desktop app, `hermes {profile_arg}gateway stop`, dashboard, cron), run "
|
||||
"`hermes {profile_arg}doctor` — it names any process still holding the log — then start "
|
||||
"Hermes again and send your message once more. Do not run `doctor --fix` or delete "
|
||||
"any state.db files while they run. Guide: {recovery_docs}"
|
||||
),
|
||||
"corrupt": (
|
||||
"the turn was stopped because the state database "
|
||||
"reported structural corruption (the transcript would "
|
||||
"have been lost on restart). Freeing disk space will "
|
||||
"not help. Recovery options:\n"
|
||||
"1. Run `hermes {profile_arg}doctor --fix`\n"
|
||||
"2. Stop the gateway, then recover with:\n"
|
||||
" hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n"
|
||||
" (if it reports recoverable) hermes {profile_arg}sessions recover "
|
||||
"--source {db_path} --output recovered-state.db\n"
|
||||
" — recovery snapshots the damaged file first; do NOT "
|
||||
"run `sqlite3 ... \".recover\"` against the live "
|
||||
"state.db, a vulnerable sqlite3 CLI can corrupt it "
|
||||
"further\n"
|
||||
"3. Restore from a backup in {backups_dir}/\n"
|
||||
"Then send your message again."
|
||||
),
|
||||
# SQLite scoped the corruption to the FTS index and the derived indexes could not be
|
||||
# detached, so this write did not land; the message store itself is intact (#97794).
|
||||
"fts_index": (
|
||||
"the turn was stopped because the session search index (FTS5) "
|
||||
"is corrupt and could not be detached, so this message was not "
|
||||
"saved. The message store itself is not damaged: do not run "
|
||||
"recovery tools or restore a backup. Run `hermes {profile_arg}doctor --fix` "
|
||||
"(or restart Hermes, which repairs the index on open), then "
|
||||
"send your message again."
|
||||
),
|
||||
"disk": (
|
||||
"Hermes couldn't save this conversation to disk, so it stopped rather than lose "
|
||||
"your messages. The disk is probably full: free some space (or fix the permissions "
|
||||
"on {home}/state.db), then send your message again."
|
||||
),
|
||||
}
|
||||
_PERSISTENCE_DEFAULT_EXPLANATION = (
|
||||
"Hermes couldn't save this conversation, so it stopped rather than lose your messages. "
|
||||
"Possible causes: the drive is out of room, or another Hermes process is holding the "
|
||||
"database. Close other Hermes windows, run `hermes {profile_arg}doctor` to check "
|
||||
"storage, then send your message again."
|
||||
)
|
||||
_PERSISTENCE_CAUSES = frozenset({"compression", "compression_closed", "turn_lease", "session_row_missing", "locked", "replaced", "deleted_wal", "corrupt", "fts_index", "disk"})
|
||||
|
||||
|
||||
def _persistence_explanation_key(cause: Optional[str]) -> str:
|
||||
return f"explainer.persistence.{cause}" if cause in _PERSISTENCE_CAUSES else "explainer.persistence.default"
|
||||
|
||||
|
||||
def _file_mutation_identity(path: str, task_id: Optional[str]) -> str:
|
||||
@@ -339,20 +217,16 @@ class TurnExplainersMixin:
|
||||
"""
|
||||
if not failed:
|
||||
return ""
|
||||
lines = [
|
||||
"⚠️ File-mutation verifier: "
|
||||
f"{len(failed)} file edit(s) FAILED this turn despite any "
|
||||
"wording above that may suggest otherwise. Run `git status` or "
|
||||
"`read_file` to confirm what actually landed."
|
||||
]
|
||||
lines = [t("explainer.file_mutation.header", count=len(failed))]
|
||||
shown = list(failed.items())[:10]
|
||||
for path, info in shown:
|
||||
preview = (info.get("error_preview") or "").strip()
|
||||
tool = info.get("tool") or "patch"
|
||||
lines.append(f" • `{path}` — [{tool}] {preview or 'failed'}")
|
||||
lines.append(t("explainer.file_mutation.entry", path=path, tool=tool,
|
||||
preview=preview or t("explainer.file_mutation.failed")))
|
||||
remaining = len(failed) - len(shown)
|
||||
if remaining > 0:
|
||||
lines.append(f" • … and {remaining} more")
|
||||
lines.append(t("explainer.shared.and_more", count=remaining))
|
||||
# Neutralize paths the preview echoed; the lookbehind prevents double-wrapping the bullet path.
|
||||
return cls._neutralize_footer_paths("\n".join(lines))
|
||||
|
||||
@@ -370,35 +244,32 @@ class TurnExplainersMixin:
|
||||
reason = str(turn_exit_reason)
|
||||
if reason.startswith("text_response"):
|
||||
return ""
|
||||
body = _EXIT_REASON_EXPLANATIONS.get(reason)
|
||||
if body is None:
|
||||
for prefix, text in _EXIT_REASON_PREFIX_EXPLANATIONS:
|
||||
key = _EXIT_REASON_EXPLANATIONS.get(reason)
|
||||
if key is None:
|
||||
for prefix, prefix_key in _EXIT_REASON_PREFIX_EXPLANATIONS:
|
||||
if reason.startswith(prefix):
|
||||
body = text
|
||||
key = prefix_key
|
||||
break
|
||||
if body is not None and "{model}" in body:
|
||||
body = body.format(model=model or "The model")
|
||||
if body is None and reason == "session_persistence_failed":
|
||||
if key is not None:
|
||||
body = t(key, model=model or t("explainer.shared.the_model"))
|
||||
elif reason == "session_persistence_failed":
|
||||
from hermes_constants import display_hermes_home, profile_cli_selector
|
||||
from hermes_state_errors import STORAGE_RECOVERY_DOCS_URL
|
||||
|
||||
# Copy-pasteable, so pin every `hermes` command to the profile whose store failed:
|
||||
# a multi-profile backend (Desktop serve) hosts sessions whose state.db is NOT the
|
||||
# process default, and a bare `hermes` follows active_profile (#105887).
|
||||
body = (
|
||||
_PERSISTENCE_CAUSE_EXPLANATIONS.get(
|
||||
persistence_cause or "unknown", _PERSISTENCE_DEFAULT_EXPLANATION
|
||||
)
|
||||
.replace("{home}", display_hermes_home())
|
||||
.replace("{profile_arg}", profile_cli_selector())
|
||||
.replace("{recovery_docs}", STORAGE_RECOVERY_DOCS_URL)
|
||||
)
|
||||
fill: Dict[str, str] = {
|
||||
"home": display_hermes_home(), "profile_arg": profile_cli_selector(),
|
||||
"recovery_docs": STORAGE_RECOVERY_DOCS_URL, "db_path": "", "backups_dir": "",
|
||||
}
|
||||
if persistence_cause in ("corrupt", "fts_index"):
|
||||
from hermes_constants import get_default_hermes_root
|
||||
from hermes_state import _default_db_path
|
||||
|
||||
body = body.replace("{db_path}", str(db_path or _default_db_path()))
|
||||
body = body.replace(
|
||||
"{backups_dir}", str(get_default_hermes_root() / "backups")
|
||||
)
|
||||
return _NO_REPLY + body if body else ""
|
||||
fill["db_path"] = str(db_path or _default_db_path())
|
||||
fill["backups_dir"] = str(get_default_hermes_root() / "backups")
|
||||
body = t(_persistence_explanation_key(persistence_cause), **fill)
|
||||
else:
|
||||
body = None
|
||||
return t("explainer.no_reply_prefix") + body if body else ""
|
||||
@@ -28,6 +28,7 @@
|
||||
"profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"build": "node scripts/build.mjs",
|
||||
"i18n:keys": "node scripts/i18n-keys.mjs",
|
||||
"postbuild": "node scripts/assert-dist-built.mjs",
|
||||
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs",
|
||||
|
||||
@@ -18,6 +18,8 @@ export function buildSourceDesktop({ source = repoRoot, icons, run = execFileSyn
|
||||
cpSync(join(icons, 'apps/desktop/assets'), join(app, 'assets'), { recursive: true })
|
||||
}
|
||||
step('apps/desktop/scripts/write-build-stamp.mjs')
|
||||
// locales/_keys.desktop.json is a committed artifact (i18n-keys.test.mjs pins it to en.ts);
|
||||
// the build must not write into the checkout — a dirty tree breaks `hermes update`.
|
||||
step('apps/desktop/scripts/stage-native-deps.mjs')
|
||||
step('scripts/build/desktop.mjs', ['--source', source, '--icons', icons,
|
||||
'--stamp', join(app, 'build/install-stamp.json'), '--native-deps', join(app, 'build/native-deps'), '--out', join(app, 'dist')])
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
// Emit the Desktop catalog's key set for language-pack validation.
|
||||
//
|
||||
// `hermes plugins validate` checks a pack's `<lang>.desktop.yaml` keys against
|
||||
// `locales/_keys.desktop.json` (repo root, committed). This script derives that
|
||||
// file from `src/i18n/en.ts` — every leaf's dotted path, sorted; function-
|
||||
// valued entries are keys too (a pack overrides them with a positional
|
||||
// `{0}`/`{1}` string). Run via `npm run i18n:keys` and commit the result (`--check` verifies);
|
||||
// the build never writes it — a dirty checkout would break `hermes update`.
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { isMain, repoRoot, workspaceTool } from '../../../scripts/build/frontend-common.mjs'
|
||||
|
||||
export const SURFACE = 'desktop'
|
||||
|
||||
/** Sorted dotted leaf paths. Mirrors `flattenMessageKeys` in apps/shared. */
|
||||
export function flattenKeys(tree, prefix = '') {
|
||||
const isRecord = value => typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
if (!isRecord(tree)) return prefix ? [prefix] : []
|
||||
const keys = []
|
||||
for (const [key, value] of Object.entries(tree)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (isRecord(value)) keys.push(...flattenKeys(value, path))
|
||||
else keys.push(path)
|
||||
}
|
||||
return keys.sort()
|
||||
}
|
||||
|
||||
export function renderKeysFile(keys) {
|
||||
return `${JSON.stringify({ surface: SURFACE, keys }, null, 2)}\n`
|
||||
}
|
||||
|
||||
/** Bundle en.ts (it imports app modules through the `@/` alias) into one ESM
|
||||
* file the emitter can import in plain Node. */
|
||||
async function loadEnglishCatalog(source) {
|
||||
const app = join(source, 'apps/desktop')
|
||||
const { build } = await import(pathToFileURL(workspaceTool(source, 'apps/desktop', 'esbuild')).href)
|
||||
const outdir = join(app, 'build/i18n-keys')
|
||||
mkdirSync(outdir, { recursive: true })
|
||||
const outfile = join(outdir, `en-${process.pid}.mjs`)
|
||||
const alias = {
|
||||
'@hermes/shared/ansi': join(source, 'apps/shared/src/ansi.ts'),
|
||||
'@hermes/shared/billing': join(source, 'apps/shared/src/billing-types.ts'),
|
||||
'@hermes/shared/color': join(source, 'apps/shared/src/color.ts'),
|
||||
'@hermes/shared/i18n': join(source, 'apps/shared/src/i18n.ts'),
|
||||
'@hermes/shared/translucency': join(source, 'apps/shared/src/translucency.ts'),
|
||||
'@hermes/shared': join(source, 'apps/shared/src/index.ts'),
|
||||
'@': join(app, 'src')
|
||||
}
|
||||
await build({
|
||||
entryPoints: [join(app, 'src/i18n/en.ts')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
jsx: 'automatic',
|
||||
alias,
|
||||
// Stylesheets and assets some transitive app modules import are irrelevant
|
||||
// to the catalog's shape.
|
||||
loader: { '.css': 'empty', '.svg': 'empty', '.png': 'empty', '.woff2': 'empty' },
|
||||
logLevel: 'silent',
|
||||
outfile
|
||||
})
|
||||
try {
|
||||
const mod = await import(pathToFileURL(outfile).href)
|
||||
return mod.en
|
||||
} finally {
|
||||
rmSync(outfile, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export async function emitDesktopKeys({ source = repoRoot, out, check = false } = {}) {
|
||||
source = resolve(source)
|
||||
out = resolve(out ?? join(source, 'locales', `_keys.${SURFACE}.json`))
|
||||
const en = await loadEnglishCatalog(source)
|
||||
const rendered = renderKeysFile(flattenKeys(en))
|
||||
if (check) {
|
||||
let current = ''
|
||||
try {
|
||||
current = readFileSync(out, 'utf8')
|
||||
} catch {
|
||||
// A missing file is drift too.
|
||||
}
|
||||
if (current !== rendered) {
|
||||
throw new Error(`${out} is out of date with src/i18n/en.ts — run \`npm run i18n:keys\` in apps/desktop and commit it`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
mkdirSync(dirname(out), { recursive: true })
|
||||
writeFileSync(out, rendered)
|
||||
return out
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const { values } = parseArgs({
|
||||
options: { source: { type: 'string' }, out: { type: 'string' }, check: { type: 'boolean', default: false } }
|
||||
})
|
||||
const out = await emitDesktopKeys(values)
|
||||
console.log(`${values.check ? 'verified' : 'wrote'} ${out}`)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { emitDesktopKeys, flattenKeys, renderKeysFile, SURFACE } from './i18n-keys.mjs'
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../../..')
|
||||
|
||||
test('flattenKeys lists every leaf as a sorted dotted path, functions included, arrays as one leaf', () => {
|
||||
assert.deepEqual(
|
||||
flattenKeys({ z: { count: n => `${n}`, a: 'x', 'dotted.key': 'y' }, list: ['a'], b: 'c' }),
|
||||
['b', 'list', 'z.a', 'z.count', 'z.dotted.key']
|
||||
)
|
||||
assert.equal(renderKeysFile(['a']), `{\n "surface": "${SURFACE}",\n "keys": [\n "a"\n ]\n}\n`)
|
||||
})
|
||||
|
||||
test('the committed locales/_keys.desktop.json matches the English catalog (run `npm run i18n:keys`)', async () => {
|
||||
const out = join(repoRoot, 'locales', `_keys.${SURFACE}.json`)
|
||||
const committed = JSON.parse(readFileSync(out, 'utf8'))
|
||||
assert.equal(committed.surface, SURFACE)
|
||||
assert.ok(committed.keys.includes('common.save'))
|
||||
assert.ok(committed.keys.includes('catalog.results'), 'function-valued entries are keys too')
|
||||
await emitDesktopKeys({ source: repoRoot, out, check: true })
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { isSessionNotOwnedError } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
import { translateNow, TRANSLATIONS } from '@/i18n'
|
||||
import { getRuntimeI18nLocale } from '@/i18n/runtime'
|
||||
import { runtimeTranslations, translateNow } from '@/i18n'
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { coerceGatewayText } from '@/lib/chat-runtime'
|
||||
import type { ErrorSurface } from '@/lib/error-surface'
|
||||
@@ -301,7 +300,7 @@ export function handleStatusEvent(ctx: GatewayEventContext): boolean {
|
||||
// pre-turn failures — agent init, resume, cancelled-before-ready), and
|
||||
// burying it under a generic "couldn't finish" gloss would hide the one
|
||||
// instruction the user needs.
|
||||
const card = surface ? errorCardText(TRANSLATIONS[getRuntimeI18nLocale()].assistant.thread, surface) : null
|
||||
const card = surface ? errorCardText(runtimeTranslations().assistant.thread, surface) : null
|
||||
const toastMessage = card ? `${card.title}. ${card.body}` : errorMessage
|
||||
|
||||
// A turn that errors out has also ended — drop any open blocking prompt
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesConfigRecord } from '@/hermes'
|
||||
import { type I18nConfigClient, I18nProvider } from '@/i18n'
|
||||
import { type I18nConfigClient, I18nProvider, registerAppLocale } from '@/i18n'
|
||||
import { stubMenuDomApis, stubResizeObserver } from '@/test/jsdom'
|
||||
|
||||
import { LanguageSwitcher } from './language-switcher'
|
||||
@@ -40,4 +40,67 @@ describe('LanguageSwitcher', () => {
|
||||
await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1))
|
||||
expect(saveConfig).toHaveBeenCalledWith({ display: { language: 'ja', skin: 'slate' } })
|
||||
})
|
||||
|
||||
it('lists a registered language by endonym, renders its pack, and writes its id to display.language', async () => {
|
||||
const dispose = registerAppLocale(
|
||||
'pl',
|
||||
{ endonym: 'Polski', translations: { language: { switchTo: 'Zmień język' } } },
|
||||
'plugin:hermes-lang-pl'
|
||||
)
|
||||
const saveConfig = vi.fn().mockResolvedValue({ ok: true })
|
||||
const latestConfig: HermesConfigRecord = { display: { language: 'en' } }
|
||||
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi.fn().mockResolvedValue(latestConfig),
|
||||
saveConfig
|
||||
}
|
||||
|
||||
try {
|
||||
render(
|
||||
<I18nProvider configClient={configClient}>
|
||||
<LanguageSwitcher />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Switch language' }).hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch language' }))
|
||||
|
||||
// Endonym only — no flag, and the option list is bundled ∪ registered.
|
||||
const options = screen.getAllByRole('option').map(option => option.textContent)
|
||||
expect(options.some(text => text?.includes('Polski'))).toBe(true)
|
||||
expect(options.some(text => text?.includes('English'))).toBe(true)
|
||||
|
||||
fireEvent.click(screen.getByRole('option', { name: /Polski/ }))
|
||||
|
||||
await waitFor(() => expect(saveConfig).toHaveBeenCalledTimes(1))
|
||||
expect(saveConfig).toHaveBeenCalledWith({ display: { language: 'pl' } })
|
||||
// The pack's strings paint: the trigger now carries the Polish label.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Zmień język' })).toBeTruthy())
|
||||
expect(screen.getByRole('button', { name: 'Zmień język' }).textContent).toContain('Polski')
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-lists when a language pack lands after first paint', async () => {
|
||||
render(
|
||||
<I18nProvider configClient={null}>
|
||||
<LanguageSwitcher />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch language' }))
|
||||
expect(screen.queryByRole('option', { name: /Українська/ })).toBeNull()
|
||||
|
||||
const dispose = registerAppLocale('uk', { endonym: 'Українська' }, 'backend')
|
||||
|
||||
try {
|
||||
await waitFor(() => expect(screen.getByRole('option', { name: /Українська/ })).toBeTruthy())
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
|
||||
import { $appLocaleVersion, type LanguageOption, languageOptions, type Locale, localeMeta, useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ChevronDown, Globe } from '@/lib/icons'
|
||||
import { normalize } from '@/lib/text'
|
||||
@@ -26,7 +27,7 @@ export interface LanguageSwitcherProps {
|
||||
}
|
||||
|
||||
interface LanguageCommandProps {
|
||||
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>
|
||||
allLocales: LanguageOption[]
|
||||
autoFocus?: boolean
|
||||
disabled?: boolean
|
||||
locale: Locale
|
||||
@@ -42,8 +43,12 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
|
||||
const [open, setOpen] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
const useMobileSheet = Boolean(dropUp && isMobile)
|
||||
const current = LOCALE_META[locale]
|
||||
const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>
|
||||
// Bundled ∪ plugin-registered ∪ backend `i18n.languages`; the registry
|
||||
// version re-lists when a pack lands after first paint.
|
||||
const registryVersion = useStore($appLocaleVersion)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- registryVersion is the registry's change token
|
||||
const allLocales = useMemo(() => languageOptions(), [registryVersion])
|
||||
const current = allLocales.find(option => option.id === locale) ?? { id: locale, ...localeMeta(locale) }
|
||||
const title = t.language.switchTo
|
||||
|
||||
const selectLocale = async (code: Locale) => {
|
||||
@@ -80,7 +85,7 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center gap-2">
|
||||
<Globe className="size-3.5 shrink-0" />
|
||||
{!collapsed && <span className="truncate">{current.name}</span>}
|
||||
{!collapsed && <span className="truncate">{current.endonym}</span>}
|
||||
</span>
|
||||
{!collapsed && <ChevronDown className="size-3 shrink-0 opacity-70" />}
|
||||
</Button>
|
||||
@@ -148,11 +153,11 @@ function LanguageCommand({
|
||||
const q = normalize(search)
|
||||
|
||||
const filtered = allLocales.filter(
|
||||
([code, meta]) =>
|
||||
option =>
|
||||
!q ||
|
||||
meta.name.toLowerCase().includes(q) ||
|
||||
meta.englishName.toLowerCase().includes(q) ||
|
||||
code.toLowerCase().includes(q)
|
||||
option.endonym.toLowerCase().includes(q) ||
|
||||
(option.englishName?.toLowerCase().includes(q) ?? false) ||
|
||||
option.id.toLowerCase().includes(q)
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -160,19 +165,19 @@ function LanguageCommand({
|
||||
<CommandInput autoFocus={autoFocus} onValueChange={setSearch} placeholder={searchPlaceholder} value={search} />
|
||||
<CommandList className={variant === 'menu' ? undefined : 'max-h-80 p-1'}>
|
||||
<CommandEmpty>{noResults}</CommandEmpty>
|
||||
{filtered.map(([code, meta]) => {
|
||||
const selected = code === locale
|
||||
{filtered.map(option => {
|
||||
const selected = option.id === locale
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(selected && 'font-medium')}
|
||||
disabled={disabled}
|
||||
key={code}
|
||||
onSelect={() => onSelect(code)}
|
||||
value={code}
|
||||
key={option.id}
|
||||
onSelect={() => onSelect(option.id)}
|
||||
value={option.id}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{meta.name}</span>
|
||||
<span className="font-mono text-[0.65rem] uppercase text-(--ui-text-tertiary)">{code}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{option.endonym}</span>
|
||||
<span className="font-mono text-[0.65rem] uppercase text-(--ui-text-tertiary)">{option.id}</span>
|
||||
<CommandItemCheck checked={selected} />
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { DOCKED_SIDEBAR_MIN_PX } from '@/hooks/use-mobile'
|
||||
import { TRANSLATIONS } from '@/i18n/catalog'
|
||||
import { getRuntimeI18nLocale } from '@/i18n/runtime'
|
||||
import { runtimeTranslations } from '@/i18n/runtime'
|
||||
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
|
||||
import { $interfaceMode, type InterfaceMode, setInterfaceMode } from '@/store/interface-mode'
|
||||
import { setSidebarOpen } from '@/store/layout'
|
||||
@@ -42,7 +41,7 @@ export function pickOnboardingGreeting(): string {
|
||||
return existing
|
||||
}
|
||||
|
||||
const copy = TRANSLATIONS[getRuntimeI18nLocale()].guidedGreeting
|
||||
const copy = runtimeTranslations().guidedGreeting
|
||||
const suggested = machineUserName()
|
||||
|
||||
const greeting = suggested ? `${copy.line}\n\n${copy.nameSuggestion(suggested)}` : copy.line
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { JsonRpcGatewayError } from '@hermes/shared'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { type BackendLocaleRequest, syncBackendLocalePacks } from './backend-packs'
|
||||
import { TRANSLATIONS } from './catalog'
|
||||
import { languageOptions } from './languages'
|
||||
import { isRegisteredLocale, registerAppLocale, resetAppLocaleRegistry, resolveTranslations } from './registry'
|
||||
|
||||
afterEach(() => {
|
||||
resetAppLocaleRegistry()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
type Call = [string, Record<string, unknown>]
|
||||
|
||||
const backend =
|
||||
(languages: unknown, catalogs: Record<string, Record<string, string>>, calls: Call[] = []): BackendLocaleRequest =>
|
||||
async <T>(method: string, params: Record<string, unknown>) => {
|
||||
calls.push([method, params])
|
||||
|
||||
if (method === 'i18n.languages') {
|
||||
return languages as T
|
||||
}
|
||||
|
||||
if (method === 'i18n.catalog') {
|
||||
const lang = String(params.lang)
|
||||
|
||||
return { lang, surface: params.surface, messages: catalogs[lang] ?? {} } as T
|
||||
}
|
||||
|
||||
throw new JsonRpcGatewayError(`unknown method: ${method}`, { code: -32601 })
|
||||
}
|
||||
|
||||
describe('syncBackendLocalePacks', () => {
|
||||
it('registers the backend language list and the desktop pack for the requested language', async () => {
|
||||
const calls: Call[] = []
|
||||
|
||||
const request = backend(
|
||||
{
|
||||
languages: [
|
||||
{ id: 'en', endonym: 'English', rtl: false, source: 'bundled' },
|
||||
{ id: 'pl', endonym: 'Polski', rtl: false, source: 'plugin:hermes-lang-pl' }
|
||||
]
|
||||
},
|
||||
{ pl: { 'common.save': 'Zapisz', 'catalog.results': '{0} wyników' } },
|
||||
calls
|
||||
)
|
||||
|
||||
await syncBackendLocalePacks(request, 'pl')
|
||||
|
||||
expect(calls).toContainEqual(['i18n.catalog', { lang: 'pl', surface: 'desktop' }])
|
||||
expect(isRegisteredLocale('pl')).toBe(true)
|
||||
expect(languageOptions().find(option => option.id === 'pl')).toMatchObject({ endonym: 'Polski', source: 'backend' })
|
||||
expect(resolveTranslations('pl').common.save).toBe('Zapisz')
|
||||
expect(resolveTranslations('pl').catalog.results(5)).toBe('5 wyników')
|
||||
expect(resolveTranslations('pl').common.cancel).toBe(TRANSLATIONS.en.common.cancel)
|
||||
})
|
||||
|
||||
it('accepts a bare array from i18n.languages and skips the catalog call without a requested language', async () => {
|
||||
const calls: Call[] = []
|
||||
|
||||
await syncBackendLocalePacks(backend([{ id: 'uk', endonym: 'Українська' }], {}, calls), null)
|
||||
|
||||
expect(calls).toEqual([['i18n.languages', {}]])
|
||||
expect(isRegisteredLocale('uk')).toBe(true)
|
||||
})
|
||||
|
||||
it('is silent on a backend that predates the i18n methods and leaves the registry alone', async () => {
|
||||
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
||||
registerAppLocale('pl', { endonym: 'Polski' }, 'backend')
|
||||
|
||||
const request: BackendLocaleRequest = async () => {
|
||||
throw new JsonRpcGatewayError('unknown method: i18n.languages', { code: -32601 })
|
||||
}
|
||||
|
||||
await expect(syncBackendLocalePacks(request, 'pl')).resolves.toBeUndefined()
|
||||
|
||||
expect(debug).not.toHaveBeenCalled()
|
||||
expect(isRegisteredLocale('pl')).toBe(true)
|
||||
})
|
||||
|
||||
it('replaces the previous backend layer and never applies a stale reply', async () => {
|
||||
await syncBackendLocalePacks(backend([{ id: 'pl', endonym: 'Polski' }], {}), null)
|
||||
expect(isRegisteredLocale('pl')).toBe(true)
|
||||
|
||||
await syncBackendLocalePacks(backend([{ id: 'uk', endonym: 'Українська' }], {}), null)
|
||||
expect(isRegisteredLocale('pl')).toBe(false)
|
||||
expect(isRegisteredLocale('uk')).toBe(true)
|
||||
|
||||
await syncBackendLocalePacks(backend([{ id: 'pt-br', endonym: 'Português' }], {}), null, () => false)
|
||||
expect(isRegisteredLocale('uk')).toBe(true)
|
||||
expect(isRegisteredLocale('pt-br')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Backend-delivered language packs. A Hermes language-pack plugin (or a user
|
||||
* overlay under `$HERMES_HOME/locales/`) ships `<lang>.desktop.yaml`; the
|
||||
* gateway serves the flattened strings over `i18n.catalog {lang, surface:
|
||||
* 'desktop'}` and the selectable languages over `i18n.languages`. Both land
|
||||
* in the app-locale registry under source `backend`, merged over the bundled
|
||||
* catalog exactly like a plugin registration.
|
||||
*
|
||||
* Pure: the caller hands in the JSON-RPC door. Older gateways predate both
|
||||
* methods — method-not-found is silence, not an error, and any other failure
|
||||
* leaves whatever the registry already holds (never blank the UI over i18n).
|
||||
*/
|
||||
|
||||
import { isRecord } from '@hermes/shared/i18n'
|
||||
|
||||
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
|
||||
|
||||
import { type AppLocaleRegistration, normalizeLocaleId, replaceAppLocaleSource } from './registry'
|
||||
|
||||
export type BackendLocaleRequest = <T>(method: string, params: Record<string, unknown>) => Promise<T>
|
||||
|
||||
/** One row of `i18n.languages` (`agent.i18n.language_options()`). */
|
||||
export interface BackendLanguageOption {
|
||||
id: string
|
||||
endonym?: string
|
||||
rtl?: boolean
|
||||
source?: string
|
||||
}
|
||||
|
||||
/** `i18n.catalog` reply: the pack + overlay layer for one surface, flat. */
|
||||
export interface BackendLocaleCatalog {
|
||||
lang: string
|
||||
surface: string
|
||||
messages: Record<string, string>
|
||||
}
|
||||
|
||||
export const DESKTOP_SURFACE = 'desktop'
|
||||
|
||||
function languageRows(value: unknown): BackendLanguageOption[] {
|
||||
const rows = isRecord(value) && Array.isArray(value.languages) ? value.languages : Array.isArray(value) ? value : []
|
||||
|
||||
return rows.filter(
|
||||
(row): row is BackendLanguageOption => isRecord(row) && typeof row.id === 'string' && row.id.trim() !== ''
|
||||
)
|
||||
}
|
||||
|
||||
function catalogMessages(value: unknown): Record<string, string> {
|
||||
if (!isRecord(value) || !isRecord(value.messages)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const messages: Record<string, string> = {}
|
||||
|
||||
for (const [key, text] of Object.entries(value.messages)) {
|
||||
if (typeof text === 'string') {
|
||||
messages[key] = text
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
async function tolerant<T>(call: () => Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await call()
|
||||
} catch (error) {
|
||||
if (!isMissingRpcMethod(error)) {
|
||||
console.debug('[i18n] backend language pack unavailable', error)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the backend's language list plus the desktop pack for `lang` and swap
|
||||
* them into the registry's `backend` layer. `isCurrent` guards the apply: a
|
||||
* profile switch or a newer language pick mid-flight must not write a stale
|
||||
* pack over the winner's.
|
||||
*/
|
||||
export async function syncBackendLocalePacks(
|
||||
request: BackendLocaleRequest,
|
||||
lang: null | string,
|
||||
isCurrent: () => boolean = () => true
|
||||
): Promise<void> {
|
||||
const wanted = lang ? normalizeLocaleId(lang) : ''
|
||||
|
||||
const [languages, catalog] = await Promise.all([
|
||||
tolerant(() => request<unknown>('i18n.languages', {})),
|
||||
wanted ? tolerant(() => request<unknown>('i18n.catalog', { lang: wanted, surface: DESKTOP_SURFACE })) : null
|
||||
])
|
||||
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (languages === null && catalog === null) {
|
||||
// Nothing answered (old backend, socket dropped): keep the current layer.
|
||||
return
|
||||
}
|
||||
|
||||
const registrations = new Map<string, { id: string } & AppLocaleRegistration>()
|
||||
|
||||
for (const row of languageRows(languages)) {
|
||||
const id = normalizeLocaleId(row.id)
|
||||
|
||||
if (id) {
|
||||
registrations.set(id, {
|
||||
id,
|
||||
endonym: typeof row.endonym === 'string' ? row.endonym : undefined,
|
||||
rtl: typeof row.rtl === 'boolean' ? row.rtl : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const messages = catalogMessages(catalog)
|
||||
|
||||
if (wanted && Object.keys(messages).length) {
|
||||
const existing = registrations.get(wanted) ?? { id: wanted }
|
||||
registrations.set(wanted, { ...existing, translations: messages })
|
||||
}
|
||||
|
||||
replaceAppLocaleSource('backend', [...registrations.values()])
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import { TRANSLATIONS } from './catalog'
|
||||
import { deOverrides } from './de'
|
||||
import { esOverrides } from './es'
|
||||
import { frOverrides } from './fr'
|
||||
import type { Locale } from './types'
|
||||
import type { BundledLocale } 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[]
|
||||
const COMPLETE_LOCALES = ['fr', 'de', 'es'] as const satisfies readonly BundledLocale[]
|
||||
const completeOverrides = { fr: frOverrides, de: deOverrides, es: esOverrides }
|
||||
|
||||
type Leaf = { path: string; value: unknown }
|
||||
@@ -32,7 +32,7 @@ const kindOf = (value: unknown) => (Array.isArray(value) ? 'array' : typeof valu
|
||||
|
||||
// `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) =>
|
||||
const catalogLeaves = (locale: BundledLocale) =>
|
||||
new Map(
|
||||
leaves(TRANSLATIONS[locale])
|
||||
.filter(leaf => !leaf.path.startsWith('intro.'))
|
||||
|
||||
@@ -5,11 +5,14 @@ import { es } from './es'
|
||||
import { fr } from './fr'
|
||||
import { ja } from './ja'
|
||||
import { ru } from './ru'
|
||||
import type { Locale, Translations } from './types'
|
||||
import type { BundledLocale, Translations } from './types'
|
||||
import { zh } from './zh'
|
||||
import { zhHant } from './zh-hant'
|
||||
|
||||
export const TRANSLATIONS: Record<Locale, Translations> = {
|
||||
/** The catalogs compiled into the app. Runtime-registered languages (plugin
|
||||
* packs, backend `.desktop.yaml` packs) are NOT here — resolve through
|
||||
* `resolveTranslations()` in `./registry`, which layers them over these. */
|
||||
export const TRANSLATIONS: Record<BundledLocale, Translations> = {
|
||||
en,
|
||||
zh,
|
||||
'zh-hant': zhHant,
|
||||
@@ -20,3 +23,9 @@ export const TRANSLATIONS: Record<Locale, Translations> = {
|
||||
de,
|
||||
es
|
||||
}
|
||||
|
||||
export const BUNDLED_LOCALES = Object.keys(TRANSLATIONS) as readonly BundledLocale[]
|
||||
|
||||
export function isBundledLocale(value: unknown): value is BundledLocale {
|
||||
return typeof value === 'string' && Object.hasOwn(TRANSLATIONS, value)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type { HermesConfigRecord } from '@/hermes'
|
||||
|
||||
import { TRANSLATIONS } from './catalog'
|
||||
import { type I18nConfigClient, I18nProvider, useI18n } from './context'
|
||||
import { registerAppLocale } from './registry'
|
||||
import { $requestedLocale } from './runtime'
|
||||
import type { Locale } from './types'
|
||||
|
||||
function LanguageProbe({ target = 'zh' }: { target?: Locale }) {
|
||||
@@ -138,6 +140,63 @@ describe('I18nProvider', () => {
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
||||
expect(screen.getByTestId('label').textContent).toBe('Language')
|
||||
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
||||
// …but remembers the ask so a backend pack for it can be fetched.
|
||||
expect($requestedLocale.get()).toBe('it')
|
||||
})
|
||||
|
||||
it('promotes a saved pack-only language once its pack registers, then drops back when it is removed', async () => {
|
||||
const configClient: I18nConfigClient = {
|
||||
getConfig: vi.fn().mockResolvedValue({ display: { language: 'pl' } }),
|
||||
saveConfig: vi.fn()
|
||||
}
|
||||
|
||||
render(
|
||||
<I18nProvider configClient={configClient}>
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
||||
expect(screen.getByTestId('locale').textContent).toBe('en')
|
||||
|
||||
const dispose = registerAppLocale(
|
||||
'pl',
|
||||
{ endonym: 'Polski', translations: { language: { label: 'Język' } } },
|
||||
'backend'
|
||||
)
|
||||
|
||||
try {
|
||||
await waitFor(() => expect(screen.getByTestId('locale').textContent).toBe('pl'))
|
||||
expect(screen.getByTestId('label').textContent).toBe('Język')
|
||||
// Unregistered keys fall back to English, never to the raw key.
|
||||
expect(screen.getByTestId('save').textContent).toBe(TRANSLATIONS.en.common.save)
|
||||
expect(document.documentElement.lang).toBe('pl')
|
||||
expect(configClient.saveConfig).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
|
||||
// The pack is gone (profile switch): strings fall back to English while the
|
||||
// chosen id stays put for the next sync.
|
||||
await waitFor(() => expect(screen.getByTestId('label').textContent).toBe('Language'))
|
||||
expect(screen.getByTestId('locale').textContent).toBe('pl')
|
||||
})
|
||||
|
||||
it('mirrors a registered language’s direction onto the document', async () => {
|
||||
const dispose = registerAppLocale('he', { endonym: 'עברית', rtl: true }, 'plugin:hermes-lang-he')
|
||||
|
||||
try {
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="he">
|
||||
<LanguageProbe />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('locale').textContent).toBe('he')
|
||||
expect(document.documentElement.dir).toBe('rtl')
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads latest config before saving language and preserves unrelated values', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { applyDocumentLocale, isRecord } from '@hermes/shared/i18n'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { getHermesConfigRecord, type HermesConfigRecord, retainConfigReadOrigin, saveHermesConfig } from '@/hermes'
|
||||
@@ -6,16 +7,16 @@ import { getHermesConfigRecord, type HermesConfigRecord, retainConfigReadOrigin,
|
||||
import { TRANSLATIONS } from './catalog'
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
isRtlLocale,
|
||||
isSupportedLocaleValue,
|
||||
localeConfigValue,
|
||||
normalizeLocale,
|
||||
resolveInitialLocale
|
||||
} from './languages'
|
||||
import { setRuntimeI18nLocale } from './runtime'
|
||||
import { $appLocaleVersion, normalizeLocaleId, resolveTranslations } from './registry'
|
||||
import { $requestedLocale, setRuntimeI18nLocale } from './runtime'
|
||||
import type { Locale, Translations } from './types'
|
||||
|
||||
export { LOCALE_META } from './languages'
|
||||
|
||||
export interface I18nConfigClient {
|
||||
getConfig: () => Promise<HermesConfigRecord>
|
||||
saveConfig: (config: HermesConfigRecord) => Promise<{ ok: boolean }>
|
||||
@@ -109,18 +110,44 @@ export function I18nProvider({
|
||||
// An explicit pick beats a late read in its own scope, not in other profiles.
|
||||
const userLocaleRef = useRef(false)
|
||||
const scopeGenerationRef = useRef(0)
|
||||
// Registered languages (plugin packs, backend `.desktop.yaml`) change the
|
||||
// catalog without a locale change; the version keys re-resolution.
|
||||
const registryVersion = useStore($appLocaleVersion)
|
||||
// The language THIS scope's config asked for (what $requestedLocale was last
|
||||
// set to from here). A ref, not the atom: another provider's or an earlier
|
||||
// scope's ask must never be promoted into this one.
|
||||
const requestedRef = useRef<null | string>(null)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
localeRef.current = locale
|
||||
setRuntimeI18nLocale(locale)
|
||||
applyDocumentLocale(locale)
|
||||
}, [locale])
|
||||
applyDocumentLocale(locale, isRtlLocale(locale))
|
||||
}, [locale, registryVersion])
|
||||
|
||||
// A saved `display.language` the app could not render at read time (`pl`
|
||||
// before its pack arrived) is parked in $requestedLocale; the moment a
|
||||
// registration makes it renderable, promote it — unless the user has since
|
||||
// picked something else in this scope.
|
||||
useEffect(() => {
|
||||
const requested = requestedRef.current
|
||||
|
||||
if (!requested || userLocaleRef.current || !isSupportedLocaleValue(requested)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = normalizeLocale(requested)
|
||||
|
||||
if (next !== localeRef.current) {
|
||||
setLocaleState(next)
|
||||
}
|
||||
}, [registryVersion])
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- scope-local request generation and user intent, not an atom mirror
|
||||
useEffect(() => {
|
||||
scopeGenerationRef.current += 1
|
||||
userLocaleRef.current = false
|
||||
requestedRef.current = null
|
||||
setSaveError(null)
|
||||
setIsSavingLocale(false)
|
||||
|
||||
@@ -154,6 +181,11 @@ export function I18nProvider({
|
||||
|
||||
const saved = getConfigDisplayLanguage(config)
|
||||
|
||||
// Publish the raw ask even when it names a language only a pack
|
||||
// can render; the backend-pack sync fetches that pack by this id.
|
||||
requestedRef.current = typeof saved === 'string' && saved.trim() ? normalizeLocaleId(saved) : null
|
||||
$requestedLocale.set(requestedRef.current)
|
||||
|
||||
// A saved choice needs no machine probe and always takes precedence.
|
||||
if (isSupportedLocaleValue(saved)) {
|
||||
setLocaleState(normalizeLocale(saved))
|
||||
@@ -211,6 +243,8 @@ export function I18nProvider({
|
||||
userLocaleRef.current = true
|
||||
setSaveError(null)
|
||||
setLocaleState(next)
|
||||
requestedRef.current = next
|
||||
$requestedLocale.set(next)
|
||||
|
||||
if (!configClient) {
|
||||
return
|
||||
@@ -253,9 +287,12 @@ export function I18nProvider({
|
||||
locale,
|
||||
saveError,
|
||||
setLocale,
|
||||
t: TRANSLATIONS[locale]
|
||||
t: resolveTranslations(locale)
|
||||
}),
|
||||
[configLoadError, isLoadingConfig, isSavingLocale, locale, saveError, setLocale]
|
||||
// `registryVersion` is the registry's change token: a pack landing after
|
||||
// first paint re-resolves `t` without a locale change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[configLoadError, isLoadingConfig, isSavingLocale, locale, registryVersion, saveError, setLocale]
|
||||
)
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
export { TRANSLATIONS } from './catalog'
|
||||
export { BUNDLED_LOCALES, isBundledLocale, TRANSLATIONS } from './catalog'
|
||||
export {
|
||||
getConfigDisplayLanguage,
|
||||
type I18nConfigClient,
|
||||
type I18nContextValue,
|
||||
I18nProvider,
|
||||
LOCALE_META,
|
||||
useI18n,
|
||||
withConfigDisplayLanguage
|
||||
} from './context'
|
||||
export {
|
||||
DEFAULT_LOCALE,
|
||||
isLocale,
|
||||
isRtlLocale,
|
||||
isSupportedLocaleValue,
|
||||
type LanguageOption,
|
||||
languageOptions,
|
||||
LOCALE_OPTIONS,
|
||||
localeConfigValue,
|
||||
localeMeta,
|
||||
normalizeLocale
|
||||
} from './languages'
|
||||
export { LocalizedTabTitle } from './localized-tab-title'
|
||||
@@ -28,5 +31,14 @@ export {
|
||||
translatePlugin,
|
||||
usePluginI18n
|
||||
} from './plugin-i18n'
|
||||
export { setRuntimeI18nLocale, translateNow } from './runtime'
|
||||
export type { Locale, ToolTitleKey, Translations } from './types'
|
||||
export {
|
||||
$appLocaleVersion,
|
||||
type AppLocaleRegistration,
|
||||
type AppLocaleSource,
|
||||
isRegisteredLocale,
|
||||
registerAppLocale,
|
||||
resolveTranslations,
|
||||
unregisterAppLocaleSource
|
||||
} from './registry'
|
||||
export { runtimeTranslations, setRuntimeI18nLocale, translateNow } from './runtime'
|
||||
export type { BundledLocale, Locale, ToolTitleKey, Translations } from './types'
|
||||
@@ -1,10 +1,18 @@
|
||||
import { LOCALE_ENDONYMS } from '@hermes/shared/i18n'
|
||||
import { LOCALE_ENDONYMS, RTL_LOCALES } from '@hermes/shared/i18n'
|
||||
|
||||
import { normalize } from '@/lib/text'
|
||||
|
||||
import type { Locale } from './types'
|
||||
import { isBundledLocale } from './catalog'
|
||||
import {
|
||||
type AppLocaleSource,
|
||||
isRegisteredLocale,
|
||||
normalizeLocaleId,
|
||||
registeredLocaleIds,
|
||||
registeredLocaleMeta
|
||||
} from './registry'
|
||||
import type { BundledLocale, Locale } from './types'
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en'
|
||||
export const DEFAULT_LOCALE: BundledLocale = 'en'
|
||||
|
||||
export const LOCALE_OPTIONS = [
|
||||
{
|
||||
@@ -61,17 +69,68 @@ export const LOCALE_OPTIONS = [
|
||||
englishName: 'Spanish',
|
||||
configValue: 'es'
|
||||
}
|
||||
] as const satisfies readonly { configValue: string; englishName: string; id: Locale; name: string }[]
|
||||
] as const satisfies readonly { configValue: string; englishName: string; id: BundledLocale; name: string }[]
|
||||
|
||||
// `name` is the endonym (native name) shown in the picker so users recognize
|
||||
// their language regardless of the current UI language. No country flags:
|
||||
// languages are not countries. `englishName` is search-only (not shown) so an
|
||||
// English speaker can type "japanese"/"traditional" to filter the list.
|
||||
export const LOCALE_META: Record<Locale, { name: string; englishName: string }> = Object.fromEntries(
|
||||
LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name, englishName: locale.englishName }])
|
||||
) as Record<Locale, { name: string; englishName: string }>
|
||||
export interface LanguageOption {
|
||||
id: Locale
|
||||
/** Native name shown in the picker (`Polski`). */
|
||||
endonym: string
|
||||
/** Search-only English name; absent for a registered language that gave none. */
|
||||
englishName?: string
|
||||
rtl: boolean
|
||||
source: 'bundled' | AppLocaleSource
|
||||
}
|
||||
|
||||
const LOCALE_ALIASES: Record<string, Locale> = {
|
||||
const bundledOption = new Map<string, (typeof LOCALE_OPTIONS)[number]>(
|
||||
LOCALE_OPTIONS.map(option => [option.id, option])
|
||||
)
|
||||
|
||||
/** Endonym / search name / direction for one id: registry (last word wins)
|
||||
* → bundled picker table → shared endonyms → the id itself. */
|
||||
export function localeMeta(locale: Locale): Pick<LanguageOption, 'endonym' | 'englishName' | 'rtl'> {
|
||||
const registered = registeredLocaleMeta(locale).reverse()
|
||||
const bundled = bundledOption.get(locale)
|
||||
const rtlEntry = registered.find(entry => entry.rtl !== undefined)
|
||||
|
||||
return {
|
||||
endonym:
|
||||
registered.find(entry => entry.endonym)?.endonym ??
|
||||
bundled?.name ??
|
||||
(LOCALE_ENDONYMS as Record<string, string>)[locale] ??
|
||||
locale,
|
||||
englishName: registered.find(entry => entry.englishName)?.englishName ?? bundled?.englishName,
|
||||
rtl: rtlEntry ? Boolean(rtlEntry.rtl) : RTL_LOCALES.has(locale)
|
||||
}
|
||||
}
|
||||
|
||||
export function isRtlLocale(locale: Locale): boolean {
|
||||
return localeMeta(locale).rtl
|
||||
}
|
||||
|
||||
// The endonym (native name) is what the picker shows so users recognize their
|
||||
// language regardless of the current UI language. No country flags: languages
|
||||
// are not countries. `englishName` is search-only (not shown) so an English
|
||||
// speaker can type "japanese"/"traditional" to filter the list.
|
||||
/** Every selectable language: bundled ∪ registered (plugin packs, backend
|
||||
* `i18n.languages`). Bundled keep their curated picker order; registered-only
|
||||
* ids follow, sorted. */
|
||||
export function languageOptions(): LanguageOption[] {
|
||||
const options: LanguageOption[] = LOCALE_OPTIONS.map(option => ({
|
||||
id: option.id,
|
||||
...localeMeta(option.id),
|
||||
source: 'bundled'
|
||||
}))
|
||||
|
||||
for (const id of registeredLocaleIds()
|
||||
.filter(id => !isBundledLocale(id))
|
||||
.sort()) {
|
||||
options.push({ id, ...localeMeta(id), source: registeredLocaleMeta(id)[0]?.source ?? 'app' })
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
const LOCALE_ALIASES: Record<string, BundledLocale> = {
|
||||
en: 'en',
|
||||
'en-us': 'en',
|
||||
en_us: 'en',
|
||||
@@ -150,20 +209,35 @@ const LOCALE_ALIASES: Record<string, Locale> = {
|
||||
espanol: 'es'
|
||||
}
|
||||
|
||||
/** A language the app can render right now: bundled or registered. Aliases
|
||||
* (`zh-CN`) are not ids — see `isSupportedLocaleValue`. */
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return typeof value === 'string' && LOCALE_OPTIONS.some(locale => locale.id === value)
|
||||
return isBundledLocale(value) || isRegisteredLocale(value)
|
||||
}
|
||||
|
||||
/** Alias table first (`zh-TW` → `zh-hant`), then any registered id as-is
|
||||
* (lowercased, `_` → `-`), else English. */
|
||||
export function normalizeLocale(value: unknown): Locale {
|
||||
if (typeof value !== 'string') {
|
||||
return DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
return LOCALE_ALIASES[normalize(value)] ?? DEFAULT_LOCALE
|
||||
const alias = LOCALE_ALIASES[normalize(value)]
|
||||
|
||||
if (alias) {
|
||||
return alias
|
||||
}
|
||||
|
||||
const id = normalizeLocaleId(value)
|
||||
|
||||
return isRegisteredLocale(id) ? id : DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export function isSupportedLocaleValue(value: unknown): boolean {
|
||||
return typeof value === 'string' && LOCALE_ALIASES[normalize(value)] != null
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(LOCALE_ALIASES[normalize(value)] != null || isRegisteredLocale(normalizeLocaleId(value)))
|
||||
)
|
||||
}
|
||||
|
||||
/** OS tags can include regions absent from the picker aliases, such as ru-UA. */
|
||||
@@ -192,6 +266,8 @@ export function resolveInitialLocale(saved: string | null | undefined, osLocale:
|
||||
return osPreferredLocale(osLocale) ?? DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
/** The `display.language` value for a picker choice — the bundled option's
|
||||
* config value, or the registered id itself. */
|
||||
export function localeConfigValue(locale: Locale): string {
|
||||
return LOCALE_OPTIONS.find(item => item.id === locale)?.configValue ?? DEFAULT_LOCALE
|
||||
return bundledOption.get(locale)?.configValue ?? (isRegisteredLocale(locale) ? locale : DEFAULT_LOCALE)
|
||||
}
|
||||
@@ -16,8 +16,9 @@ import { atom } from 'nanostores'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useI18n } from './context'
|
||||
import { type AppLocaleRegistration, registerAppLocale } from './registry'
|
||||
import { getRuntimeI18nLocale, subscribeRuntimeI18nLocale, translateFrom } from './runtime'
|
||||
import type { Locale } from './types'
|
||||
import type { BundledLocale, Locale } from './types'
|
||||
|
||||
/** A leaf message: a literal or an interpolator (`n => `${n} left``). */
|
||||
export type PluginMessageValue = string | ((...args: never[]) => string)
|
||||
@@ -28,9 +29,12 @@ export interface PluginMessages {
|
||||
[key: string]: PluginMessages | PluginMessageValue
|
||||
}
|
||||
|
||||
/** Locale → messages. Keyed by the app's locales so autocomplete guides you;
|
||||
* a bundle for a locale the app can't select is simply never resolved. */
|
||||
export type PluginLocaleBundles = Partial<Record<Locale, PluginMessages>>
|
||||
/** Locale → messages. The bundled ids autocomplete; any other id (a language
|
||||
* a pack plugin registered with `registerAppLocale`) is accepted too. A
|
||||
* bundle for a locale the app can't select is simply never resolved. */
|
||||
export type PluginLocaleBundles = Partial<Record<BundledLocale, PluginMessages>> & {
|
||||
[locale: Locale]: PluginMessages | undefined
|
||||
}
|
||||
|
||||
/** Resolve `key` for this plugin against `args`; falls back to English, then
|
||||
* the raw key. */
|
||||
@@ -46,6 +50,14 @@ export interface PluginI18n {
|
||||
/** Observe locale changes (not initial registration) to rebuild static labels.
|
||||
* Returns an unsubscribe function; also disposed on plugin unload. */
|
||||
onLocaleChange: (listener: () => void) => () => void
|
||||
/** Add (or extend) a language for the WHOLE app — a language pack. The
|
||||
* strings are a partial of the app catalog (nested, or flat dotted keys)
|
||||
* merged over the bundled catalog for that id, or English for a new
|
||||
* language; a string where English has a function becomes a positional
|
||||
* `{0}`/`{1}` formatter. Attributed to this plugin and dropped on unload;
|
||||
* the returned disposer drops it early. Registering never changes the
|
||||
* active language. */
|
||||
registerAppLocale: (id: string, registration: AppLocaleRegistration) => () => void
|
||||
}
|
||||
|
||||
const registry = new Map<string, Map<Locale, PluginMessages>>()
|
||||
@@ -100,6 +112,7 @@ export function createPluginI18n(pluginId: string, track: (dispose: () => void)
|
||||
return {
|
||||
register: bundles => track(registerPluginLocales(pluginId, bundles)),
|
||||
onLocaleChange: listener => track(subscribeRuntimeI18nLocale(listener)),
|
||||
registerAppLocale: (id, registration) => track(registerAppLocale(id, registration, `plugin:${pluginId}`)),
|
||||
t: (key, ...args) => translatePlugin(pluginId, getRuntimeI18nLocale(), key, args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { type ReactNode, useEffect } from 'react'
|
||||
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $connection } from '@/store/session'
|
||||
import { $connection, $gatewayState } from '@/store/session'
|
||||
|
||||
import { syncBackendLocalePacks } from './backend-packs'
|
||||
import { I18nProvider } from './context'
|
||||
import { unregisterAppLocaleSource } from './registry'
|
||||
import { $requestedLocale } from './runtime'
|
||||
|
||||
/** Keep gateway/store imports outside the shared i18n context's import graph. */
|
||||
export function ProfileI18nProvider({ children }: { children: ReactNode }) {
|
||||
@@ -21,5 +25,45 @@ export function ProfileI18nProvider({ children }: { children: ReactNode }) {
|
||||
profile
|
||||
])
|
||||
|
||||
useBackendLocalePacks(scopeKey)
|
||||
|
||||
return <I18nProvider scopeKey={scopeKey}>{children}</I18nProvider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the active backend's language packs into the app-locale registry:
|
||||
* `i18n.languages` for the switcher, `i18n.catalog {surface:'desktop'}` for the
|
||||
* language `display.language` asks for. Re-syncs when the socket comes up,
|
||||
* when the requested language changes, and on a profile/connection switch
|
||||
* (whose first act is dropping the previous backend's layer — a soft
|
||||
* re-home must not leak one profile's strings into the next).
|
||||
*/
|
||||
function useBackendLocalePacks(scopeKey: string): void {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const requested = useStore($requestedLocale)
|
||||
const open = gatewayState === 'open'
|
||||
|
||||
useEffect(() => {
|
||||
unregisterAppLocaleSource('backend')
|
||||
}, [scopeKey])
|
||||
|
||||
useEffect(() => {
|
||||
const gateway = $gateway.get()
|
||||
|
||||
if (!open || !gateway) {
|
||||
return
|
||||
}
|
||||
|
||||
let current = true
|
||||
|
||||
void syncBackendLocalePacks(
|
||||
(method, params) => gateway.request(method, params),
|
||||
requested,
|
||||
() => current
|
||||
)
|
||||
|
||||
return () => {
|
||||
current = false
|
||||
}
|
||||
}, [open, requested, scopeKey])
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { TRANSLATIONS } from './catalog'
|
||||
import {
|
||||
isLocale,
|
||||
isSupportedLocaleValue,
|
||||
languageOptions,
|
||||
localeConfigValue,
|
||||
localeMeta,
|
||||
normalizeLocale
|
||||
} from './languages'
|
||||
import {
|
||||
$appLocaleVersion,
|
||||
isRegisteredLocale,
|
||||
registerAppLocale,
|
||||
replaceAppLocaleSource,
|
||||
resetAppLocaleRegistry,
|
||||
resolveTranslations,
|
||||
unregisterAppLocaleSource
|
||||
} from './registry'
|
||||
import { translateNow } from './runtime'
|
||||
|
||||
afterEach(() => {
|
||||
resetAppLocaleRegistry()
|
||||
})
|
||||
|
||||
describe('registerAppLocale', () => {
|
||||
it('layers a partial pack over English for a new language and falls back per key', () => {
|
||||
registerAppLocale('pl', {
|
||||
endonym: 'Polski',
|
||||
translations: { common: { save: 'Zapisz' } }
|
||||
})
|
||||
|
||||
const pl = resolveTranslations('pl')
|
||||
|
||||
expect(pl.common.save).toBe('Zapisz')
|
||||
expect(pl.common.cancel).toBe(TRANSLATIONS.en.common.cancel)
|
||||
expect(pl.language.label).toBe(TRANSLATIONS.en.language.label)
|
||||
})
|
||||
|
||||
it('wraps a pack string over a function-valued English entry into a positional formatter', () => {
|
||||
registerAppLocale('pl', {
|
||||
translations: { 'catalog.results': '{0} wyników', 'connectorsPage.card.fact.toolsSomeOn': '{1} z {0} narzędzi' }
|
||||
})
|
||||
|
||||
const pl = resolveTranslations('pl')
|
||||
|
||||
expect(pl.catalog.results(3)).toBe('3 wyników')
|
||||
expect(pl.connectorsPage.card.fact.toolsSomeOn(10, 4)).toBe('4 z 10 narzędzi')
|
||||
expect(typeof pl.catalog.installTitle).toBe('function')
|
||||
})
|
||||
|
||||
it('keeps dotted leaf keys (keybinds.actions) addressable from a flat pack', () => {
|
||||
registerAppLocale('pl', { translations: { 'keybinds.actions.session.new': 'Nowa sesja' } })
|
||||
|
||||
const actions = resolveTranslations('pl').keybinds.actions
|
||||
|
||||
expect(actions['session.new']).toBe('Nowa sesja')
|
||||
expect(actions['nav.settings']).toBe(TRANSLATIONS.en.keybinds.actions['nav.settings'])
|
||||
})
|
||||
|
||||
it('layers a pack over the bundled catalog for a bundled id, not over English', () => {
|
||||
registerAppLocale('de', { translations: { common: { save: 'Sichern' } } }, 'backend')
|
||||
|
||||
const de = resolveTranslations('de')
|
||||
|
||||
expect(de.common.save).toBe('Sichern')
|
||||
expect(de.common.cancel).toBe(TRANSLATIONS.de.common.cancel)
|
||||
expect(de.common.cancel).not.toBe(TRANSLATIONS.en.common.cancel)
|
||||
})
|
||||
|
||||
it('lets a later source win per key and drops exactly its own layer on dispose', () => {
|
||||
const disposeBackend = registerAppLocale(
|
||||
'pl',
|
||||
{ translations: { common: { save: 'Zapisz', cancel: 'Anuluj' } } },
|
||||
'backend'
|
||||
)
|
||||
|
||||
const disposePlugin = registerAppLocale(
|
||||
'pl',
|
||||
{ translations: { common: { save: 'Zachowaj' } } },
|
||||
'plugin:hermes-lang-pl'
|
||||
)
|
||||
|
||||
expect(resolveTranslations('pl').common.save).toBe('Zachowaj')
|
||||
expect(resolveTranslations('pl').common.cancel).toBe('Anuluj')
|
||||
|
||||
disposePlugin()
|
||||
expect(resolveTranslations('pl').common.save).toBe('Zapisz')
|
||||
expect(isRegisteredLocale('pl')).toBe(true)
|
||||
|
||||
disposeBackend()
|
||||
expect(isRegisteredLocale('pl')).toBe(false)
|
||||
expect(resolveTranslations('pl').common.save).toBe(TRANSLATIONS.en.common.save)
|
||||
})
|
||||
|
||||
it('bumps the version on every change so translators re-resolve, and memoizes between', () => {
|
||||
const before = $appLocaleVersion.get()
|
||||
const dispose = registerAppLocale('pl', { translations: { common: { save: 'Zapisz' } } })
|
||||
|
||||
expect($appLocaleVersion.get()).toBe(before + 1)
|
||||
expect(resolveTranslations('pl')).toBe(resolveTranslations('pl'))
|
||||
|
||||
dispose()
|
||||
expect($appLocaleVersion.get()).toBe(before + 2)
|
||||
})
|
||||
|
||||
it('normalizes ids like the backend and ignores an empty id', () => {
|
||||
registerAppLocale(' PT_br ', { endonym: 'Português (Brasil)' })
|
||||
registerAppLocale('', { endonym: 'nothing' })
|
||||
|
||||
expect(isRegisteredLocale('pt-br')).toBe(true)
|
||||
expect(languageOptions().map(option => option.id)).not.toContain('')
|
||||
})
|
||||
|
||||
it('replaces one source atomically and unregisters a whole source', () => {
|
||||
replaceAppLocaleSource('backend', [
|
||||
{ id: 'pl', endonym: 'Polski' },
|
||||
{ id: 'pt-br', endonym: 'Português (Brasil)' }
|
||||
])
|
||||
registerAppLocale('pl', { translations: { common: { save: 'Zachowaj' } } }, 'plugin:x')
|
||||
|
||||
const before = $appLocaleVersion.get()
|
||||
replaceAppLocaleSource('backend', [{ id: 'uk', endonym: 'Українська' }])
|
||||
|
||||
expect($appLocaleVersion.get()).toBe(before + 1)
|
||||
expect(isRegisteredLocale('pt-br')).toBe(false)
|
||||
expect(isRegisteredLocale('uk')).toBe(true)
|
||||
// The plugin's layer for pl survives the backend swap.
|
||||
expect(isRegisteredLocale('pl')).toBe(true)
|
||||
|
||||
unregisterAppLocaleSource('plugin:x')
|
||||
expect(isRegisteredLocale('pl')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('languages + registry', () => {
|
||||
it('accepts a registered id as a locale and its config value, still mapping aliases first', () => {
|
||||
expect(isLocale('pl')).toBe(false)
|
||||
expect(normalizeLocale('pl')).toBe('en')
|
||||
expect(localeConfigValue('pl')).toBe('en')
|
||||
|
||||
registerAppLocale('pl', { endonym: 'Polski' })
|
||||
|
||||
expect(isLocale('pl')).toBe(true)
|
||||
expect(isSupportedLocaleValue('PL')).toBe(true)
|
||||
expect(normalizeLocale('pl_PL')).toBe('en')
|
||||
expect(normalizeLocale('PL')).toBe('pl')
|
||||
expect(localeConfigValue('pl')).toBe('pl')
|
||||
expect(normalizeLocale('zh-TW')).toBe('zh-hant')
|
||||
})
|
||||
|
||||
it('lists bundled then registered languages by endonym, with registry rtl and source', () => {
|
||||
registerAppLocale('pl', { endonym: 'Polski', englishName: 'Polish' }, 'backend')
|
||||
registerAppLocale('he', { endonym: 'עברית', rtl: true }, 'plugin:hermes-lang-he')
|
||||
|
||||
const options = languageOptions()
|
||||
const ids = options.map(option => option.id)
|
||||
|
||||
expect(ids.slice(0, 9)).toEqual(Object.keys(TRANSLATIONS))
|
||||
expect(ids.slice(9)).toEqual(['he', 'pl'])
|
||||
expect(options.find(option => option.id === 'pl')).toMatchObject({
|
||||
endonym: 'Polski',
|
||||
englishName: 'Polish',
|
||||
rtl: false,
|
||||
source: 'backend'
|
||||
})
|
||||
expect(options.find(option => option.id === 'he')).toMatchObject({ rtl: true, source: 'plugin:hermes-lang-he' })
|
||||
expect(options.find(option => option.id === 'ar')?.rtl).toBe(true)
|
||||
expect(localeMeta('xx').endonym).toBe('xx')
|
||||
})
|
||||
})
|
||||
|
||||
describe('translateNow', () => {
|
||||
it('reads registered packs for the runtime locale', () => {
|
||||
registerAppLocale('en', { translations: { common: { save: 'Keep' } } }, 'backend')
|
||||
|
||||
expect(translateNow('common.save')).toBe('Keep')
|
||||
expect(translateNow('catalog.results', 2)).toBe(TRANSLATIONS.en.catalog.results(2))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* App-locale registry — the runtime half of the desktop catalog. `TRANSLATIONS`
|
||||
* is what ships in the binary; this registry layers languages that arrive
|
||||
* later: a plugin's `host.i18n.registerAppLocale` / `ctx.i18n.registerAppLocale`
|
||||
* (source `plugin:<id>`), and the backend's `.desktop.yaml` language packs
|
||||
* delivered over `i18n.catalog` (source `backend`). Every registration is
|
||||
* PARTIAL: it merges over the bundled catalog for that id (or English for a
|
||||
* new language) via `mergeTranslations`; a pack string sitting where English
|
||||
* has a function is wrapped into a positional `{0}`/`{1}` formatter.
|
||||
*
|
||||
* Resolution order for one id: bundled <id> (or en) → registrations in the
|
||||
* order they arrived (last wins per key). `$appLocaleVersion` bumps on every
|
||||
* change so React translators re-resolve. Picker metadata (endonym, English
|
||||
* name, direction) is composed with the bundled table in `./languages`.
|
||||
*/
|
||||
|
||||
import {
|
||||
adaptStringOverrides,
|
||||
type FlatMessages,
|
||||
isRecord,
|
||||
mergeTranslations,
|
||||
type TranslationOverride,
|
||||
unflattenMessages
|
||||
} from '@hermes/shared/i18n'
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { isBundledLocale, TRANSLATIONS } from './catalog'
|
||||
import type { Locale, Translations } from './types'
|
||||
|
||||
/** Who registered a language: the backend's pack layer, a plugin, or app code. */
|
||||
export type AppLocaleSource = 'app' | 'backend' | `plugin:${string}`
|
||||
|
||||
export interface AppLocaleRegistration {
|
||||
/** Native name shown in the switcher (`Polski`). Falls back to the shared
|
||||
* endonym table, then the id. */
|
||||
endonym?: string
|
||||
/** Search-only English name (`Polish`) so an English speaker can find it. */
|
||||
englishName?: string
|
||||
/** Right-to-left script — drives `<html dir>`. */
|
||||
rtl?: boolean
|
||||
/** The strings. Either a nested partial of `Translations` (TypeScript
|
||||
* plugins) or a flat `{ "common.save": "Zapisz" }` map (YAML packs).
|
||||
* Missing keys fall back to the bundled catalog, then English. */
|
||||
translations?: FlatMessages | Record<string, unknown> | TranslationOverride<Translations>
|
||||
}
|
||||
|
||||
/** What one source said about a language, minus the strings. */
|
||||
export interface RegisteredLocaleMeta {
|
||||
endonym?: string
|
||||
englishName?: string
|
||||
rtl?: boolean
|
||||
source: AppLocaleSource
|
||||
}
|
||||
|
||||
interface Entry extends RegisteredLocaleMeta {
|
||||
id: Locale
|
||||
/** Nested, already unflattened; adapted against the base at resolve time. */
|
||||
translations?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const entries = new Map<Locale, Entry[]>()
|
||||
const resolved = new Map<Locale, { version: number; value: Translations }>()
|
||||
|
||||
/** Bumps whenever a language is registered or dropped. Translators key on it. */
|
||||
export const $appLocaleVersion = atom(0)
|
||||
|
||||
const bump = () => {
|
||||
resolved.clear()
|
||||
$appLocaleVersion.set($appLocaleVersion.get() + 1)
|
||||
}
|
||||
|
||||
/** Lowercase, `_` → `-`, trimmed — the same identity the backend uses. */
|
||||
export function normalizeLocaleId(id: string): string {
|
||||
return id.trim().toLowerCase().replace(/_/g, '-')
|
||||
}
|
||||
|
||||
function baseCatalog(id: Locale): Translations {
|
||||
return isBundledLocale(id) ? TRANSLATIONS[id] : TRANSLATIONS.en
|
||||
}
|
||||
|
||||
function toTree(translations: AppLocaleRegistration['translations'], base: Translations): Record<string, unknown> {
|
||||
if (!isRecord(translations)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const flat = Object.keys(translations).some(key => key.includes('.'))
|
||||
|
||||
if (!flat) {
|
||||
return translations
|
||||
}
|
||||
|
||||
const leaves: FlatMessages = {}
|
||||
|
||||
for (const [key, value] of Object.entries(translations)) {
|
||||
if (typeof value === 'string') {
|
||||
leaves[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return unflattenMessages(leaves, base)
|
||||
}
|
||||
|
||||
function makeEntry(id: Locale, registration: AppLocaleRegistration, source: AppLocaleSource): Entry {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
endonym: registration.endonym?.trim() || undefined,
|
||||
englishName: registration.englishName?.trim() || undefined,
|
||||
rtl: registration.rtl,
|
||||
translations: registration.translations ? toTree(registration.translations, baseCatalog(id)) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register (or replace) a language for one source. Returns a disposer that
|
||||
* drops exactly this registration; other sources' entries for the same id
|
||||
* stay. An empty id is a no-op. Registering does NOT change the active
|
||||
* locale — that stays with `display.language`.
|
||||
*/
|
||||
export function registerAppLocale(
|
||||
rawId: string,
|
||||
registration: AppLocaleRegistration,
|
||||
source: AppLocaleSource = 'app'
|
||||
): () => void {
|
||||
const id = normalizeLocaleId(rawId)
|
||||
|
||||
if (!id) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const entry = makeEntry(id, registration, source)
|
||||
|
||||
const list = (entries.get(id) ?? []).filter(existing => existing.source !== source)
|
||||
list.push(entry)
|
||||
entries.set(id, list)
|
||||
bump()
|
||||
|
||||
return () => {
|
||||
const current = entries.get(id)
|
||||
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = current.filter(existing => existing !== entry)
|
||||
|
||||
if (next.length) {
|
||||
entries.set(id, next)
|
||||
} else {
|
||||
entries.delete(id)
|
||||
}
|
||||
|
||||
bump()
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every language one source registered (the backend layer on a profile
|
||||
* switch, a plugin on unload). */
|
||||
export function unregisterAppLocaleSource(source: AppLocaleSource): void {
|
||||
if (dropSource(source)) {
|
||||
bump()
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically swap one source's whole set of languages — one version bump,
|
||||
* so a refresh never paints an intermediate "no languages" state. */
|
||||
export function replaceAppLocaleSource(
|
||||
source: AppLocaleSource,
|
||||
registrations: ReadonlyArray<{ id: string } & AppLocaleRegistration>
|
||||
): void {
|
||||
dropSource(source)
|
||||
|
||||
for (const { id: rawId, ...registration } of registrations) {
|
||||
const id = normalizeLocaleId(rawId)
|
||||
|
||||
if (id) {
|
||||
const list = entries.get(id) ?? []
|
||||
list.push(makeEntry(id, registration, source))
|
||||
entries.set(id, list)
|
||||
}
|
||||
}
|
||||
|
||||
bump()
|
||||
}
|
||||
|
||||
function dropSource(source: AppLocaleSource): boolean {
|
||||
let changed = false
|
||||
|
||||
for (const [id, list] of entries) {
|
||||
const next = list.filter(entry => entry.source !== source)
|
||||
|
||||
if (next.length !== list.length) {
|
||||
changed = true
|
||||
|
||||
if (next.length) {
|
||||
entries.set(id, next)
|
||||
} else {
|
||||
entries.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
export function isRegisteredLocale(value: unknown): value is Locale {
|
||||
return typeof value === 'string' && entries.has(value)
|
||||
}
|
||||
|
||||
/** Ids at least one source registered, in registration order. */
|
||||
export function registeredLocaleIds(): Locale[] {
|
||||
return [...entries.keys()]
|
||||
}
|
||||
|
||||
/** Per-source metadata for one id (registration order). */
|
||||
export function registeredLocaleMeta(locale: Locale): RegisteredLocaleMeta[] {
|
||||
return (entries.get(locale) ?? []).map(({ endonym, englishName, rtl, source }) => ({
|
||||
endonym,
|
||||
englishName,
|
||||
rtl,
|
||||
source
|
||||
}))
|
||||
}
|
||||
|
||||
/** The merged catalog for `locale`: bundled (or English) with every
|
||||
* registration layered on. Memoized per registry version. */
|
||||
export function resolveTranslations(locale: Locale): Translations {
|
||||
const version = $appLocaleVersion.get()
|
||||
const cached = resolved.get(locale)
|
||||
|
||||
if (cached && cached.version === version) {
|
||||
return cached.value
|
||||
}
|
||||
|
||||
let value = baseCatalog(locale)
|
||||
|
||||
for (const entry of entries.get(locale) ?? []) {
|
||||
if (entry.translations) {
|
||||
value = mergeTranslations<Translations>(
|
||||
value,
|
||||
adaptStringOverrides(value, entry.translations) as TranslationOverride<Translations>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
resolved.set(locale, { version, value })
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/** Test seam: forget every registration. */
|
||||
export function resetAppLocaleRegistry(): void {
|
||||
entries.clear()
|
||||
bump()
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import { isRecord } from '@hermes/shared/i18n'
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { TRANSLATIONS } from './catalog'
|
||||
import { DEFAULT_LOCALE } from './languages'
|
||||
import type { Locale } from './types'
|
||||
import { resolveTranslations } from './registry'
|
||||
import type { Locale, Translations } from './types'
|
||||
|
||||
const $runtimeLocale = atom<Locale>(DEFAULT_LOCALE)
|
||||
|
||||
/** The language `display.language` asked for, normalized but NOT yet checked
|
||||
* against what the app can render. A pack-only language (`pl`) lands here
|
||||
* before its pack is fetched; the backend-pack sync reads it to know which
|
||||
* `i18n.catalog` to pull, and the provider promotes it to the active locale
|
||||
* once the registry knows it. `null` = nothing saved (OS inference). */
|
||||
export const $requestedLocale = atom<null | string>(null)
|
||||
|
||||
/** Walk a dot-path (`a.b.c`) into a nested message tree. */
|
||||
function resolvePath(source: unknown, key: string): unknown {
|
||||
return key.split('.').reduce<unknown>((current, part) => (isRecord(current) ? current[part] : undefined), source)
|
||||
@@ -63,6 +70,13 @@ export function getRuntimeI18nLocale(): Locale {
|
||||
return $runtimeLocale.get()
|
||||
}
|
||||
|
||||
/** The merged catalog for the active runtime locale (bundled + registered
|
||||
* packs) — for module-level code that reads whole copy blocks rather than
|
||||
* one key. React should keep using `useI18n().t`. */
|
||||
export function runtimeTranslations(): Translations {
|
||||
return resolveTranslations($runtimeLocale.get())
|
||||
}
|
||||
|
||||
export function translateNow(key: string, ...args: unknown[]): string {
|
||||
return translateFrom(locale => TRANSLATIONS[locale], $runtimeLocale.get(), key, args)
|
||||
return translateFrom(resolveTranslations, $runtimeLocale.get(), key, args)
|
||||
}
|
||||
@@ -8,7 +8,14 @@
|
||||
import type { ErrorCodeKey } from '@/lib/error-surface'
|
||||
import type { TipId } from '@/lib/tips/catalog'
|
||||
|
||||
export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja' | 'ar' | 'ru' | 'fr' | 'de' | 'es'
|
||||
/** The locales compiled into the app (`TRANSLATIONS`). */
|
||||
export type BundledLocale = 'en' | 'zh' | 'zh-hant' | 'ja' | 'ar' | 'ru' | 'fr' | 'de' | 'es'
|
||||
|
||||
/** Any language id the app can render: a bundled locale, or one a plugin /
|
||||
* the backend registered at runtime (`registerAppLocale`). Lowercase
|
||||
* BCP-47-ish (`pl`, `pt-br`). Resolve strings through the registry, never
|
||||
* by indexing `TRANSLATIONS` directly. */
|
||||
export type Locale = string
|
||||
|
||||
/** 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"). */
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type AppLocaleRegistration, type LanguageOption, languageOptions, registerAppLocale } from '@/i18n'
|
||||
|
||||
/** THE language-pack surface: add a whole UI language (or extend one) at
|
||||
* runtime. A registration is a PARTIAL catalog — nested like `en.ts` or flat
|
||||
* dotted keys (`{ "common.save": "Zapisz" }`) — merged over the bundled
|
||||
* catalog for that id, or English for a new language; a plain string where
|
||||
* English has a function becomes a positional `{0}`/`{1}` formatter. The
|
||||
* switcher lists it by its endonym at once; `display.language` stays
|
||||
* whatever the user chose (registering is not selecting).
|
||||
*
|
||||
* Prefer `ctx.i18n.registerAppLocale` inside `register(ctx)`: same call,
|
||||
* attributed to the plugin and disposed on unload. This host form is for
|
||||
* code with no ctx in reach; hand its disposer to `ctx.onDispose`. */
|
||||
export const i18nHost = {
|
||||
/** Register (or replace) a language. Returns the disposer that drops it. */
|
||||
registerAppLocale: (id: string, registration: AppLocaleRegistration): (() => void) =>
|
||||
registerAppLocale(id, registration, 'app'),
|
||||
|
||||
/** Every selectable language right now — bundled ∪ plugin-registered ∪
|
||||
* the backend's `i18n.languages` — endonym, direction and source. */
|
||||
languageOptions: (): LanguageOption[] => languageOptions()
|
||||
}
|
||||
@@ -108,6 +108,7 @@ import type { PaginatedSessions, UsageStats } from '@/types/hermes'
|
||||
|
||||
import { pluginDecisions, profiles, skills, toolsets } from './bridge'
|
||||
import { composerHost } from './composer'
|
||||
import { i18nHost } from './i18n'
|
||||
import { planPluginOpenSession } from './plugin-open-session-plan'
|
||||
import { sessionsHost } from './sessions'
|
||||
import { desktopSettings } from './settings'
|
||||
@@ -1632,7 +1633,12 @@ export const host = {
|
||||
* active instance changes on a profile swap. */
|
||||
getGateway: (): HermesGateway | null => $gateway.get(),
|
||||
|
||||
composer: composerHost
|
||||
composer: composerHost,
|
||||
|
||||
/** Language packs: `host.i18n.registerAppLocale(id, { endonym, rtl?,
|
||||
* translations })` adds a whole UI language at runtime (see `sdk/i18n.ts`);
|
||||
* `host.i18n.languageOptions()` lists what the switcher shows. */
|
||||
i18n: i18nHost
|
||||
}
|
||||
|
||||
// -- react bridge -------------------------------------------------------------
|
||||
@@ -1890,6 +1896,9 @@ export { type GrabScroll, useGrabScroll } from '@/hooks/use-grab-scroll'
|
||||
* pane whose label must track the locale pairs that `title` with
|
||||
* `data.tabTitle: () => <LocalizedTabTitle select={t => ...} />`. */
|
||||
export {
|
||||
type AppLocaleRegistration,
|
||||
type BundledLocale,
|
||||
type LanguageOption,
|
||||
type Locale,
|
||||
LocalizedTabTitle,
|
||||
type PluginI18n,
|
||||
|
||||
@@ -1787,6 +1787,28 @@ export interface BrowserControllerParams {
|
||||
export interface BrowserControllerDetachResult {
|
||||
detached?: boolean
|
||||
}
|
||||
export interface I18nLanguagesResult {
|
||||
languages: LanguageOption[]
|
||||
}
|
||||
/** ``agent.i18n_languages.language_options`` row. ``source`` is ``bundled``, ``overlay`` or ``plugin:<name>`` — the highest layer that supplies the language. */
|
||||
export interface LanguageOption {
|
||||
id: string
|
||||
endonym: string
|
||||
rtl: boolean
|
||||
source: string
|
||||
}
|
||||
export interface I18nCatalogParams {
|
||||
profile?: string | null
|
||||
lang: string
|
||||
surface?: LocaleSurface
|
||||
}
|
||||
export type LocaleSurface = 'core' | 'tui' | 'desktop'
|
||||
/** ``messages`` is ONLY the pack + user-overlay layer for that surface (flat dotted keys); the client merges it over its bundled ``en``/``<lang>``. ``lang`` is the canonical id the request resolved to (``pt-BR`` → ``pt-br``; an unknown id resolves to ``en`` with an empty layer). */
|
||||
export interface I18nCatalogResult {
|
||||
lang: string
|
||||
surface: LocaleSurface
|
||||
messages: Record<string, string>
|
||||
}
|
||||
export type PingParams = Record<string, never>
|
||||
export interface PingResult {
|
||||
pong: boolean
|
||||
@@ -4252,7 +4274,7 @@ export interface PluginSettingField {
|
||||
has_value?: boolean | null
|
||||
}
|
||||
export type PluginSettingFieldType = 'string' | 'number' | 'boolean' | 'enum' | 'secret' | 'json'
|
||||
/** What a plugin loaded mid-run does NOW vs later (``hermes_cli.plugins_activation``). ``activated_now`` kinds (``{kind: [names]}``): ``gateway_commands`` (slash names), ``gateway_transforms`` / ``hooks`` (hook names), ``callbacks`` (platforms / ``slack:<action_id>``) — live in the running gateway once it reloaded (``gateway_reloaded``). ``live_now``: the plugin's MCP servers (connected, with their tools, or the error) and skills, usable in every open chat of the profile from its next turn — the chats also get a note listing them. ``deferred`` kinds: ``tools`` (Python tool names) and ``prompt`` (section ids) apply from the next session. */
|
||||
/** What a plugin loaded mid-run does NOW vs later (``hermes_cli.plugins_activation``). ``activated_now`` kinds (``{kind: [names]}``): ``gateway_commands`` (slash names), ``locales`` (``<lang>.<surface>`` language-pack layers), ``gateway_transforms`` / ``hooks`` (hook names), ``callbacks`` (platforms / ``slack:<action_id>``) — live in the running gateway once it reloaded (``gateway_reloaded``). ``live_now``: the plugin's MCP servers (connected, with their tools, or the error) and skills, usable in every open chat of the profile from its next turn — the chats also get a note listing them. ``deferred`` kinds: ``tools`` (Python tool names) and ``prompt`` (section ids) apply from the next session. */
|
||||
export interface PluginActivation {
|
||||
name: string
|
||||
key: string
|
||||
@@ -4988,6 +5010,10 @@ export interface RpcMethods {
|
||||
'handoff.request': { params: HandoffRequestParams; result: HandoffRequestResult }
|
||||
/** Poll the handoff row for this session. */
|
||||
'handoff.state': { params: SessionParams; result: HandoffStateResult }
|
||||
/** Pack + overlay messages for one language and surface; the renderer merges them over its bundled catalog. */
|
||||
'i18n.catalog': { params: I18nCatalogParams; result: I18nCatalogResult }
|
||||
/** Every language some layer supplies (bundled ∪ user overlay ∪ plugin packs), en first. */
|
||||
'i18n.languages': { params: ProfileParams; result: I18nLanguagesResult }
|
||||
/** Queue a gateway-visible image file for the next turn. */
|
||||
'image.attach': { params: ImageAttachParams; result: AttachedImageResult }
|
||||
/** Queue an image uploaded as base64 (remote client); reply mirrors image.attach. */
|
||||
@@ -5409,6 +5435,8 @@ export const RPC_METHODS = [
|
||||
'handoff.fail',
|
||||
'handoff.request',
|
||||
'handoff.state',
|
||||
'i18n.catalog',
|
||||
'i18n.languages',
|
||||
'image.attach',
|
||||
'image.attach_bytes',
|
||||
'image.detach',
|
||||
|
||||
@@ -1446,6 +1446,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "i18n.catalog",
|
||||
"summary": "Pack + overlay messages for one language and surface; the renderer merges them over its bundled catalog.",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/I18nCatalogParams"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/I18nCatalogResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "i18n.languages",
|
||||
"summary": "Every language some layer supplies (bundled \u222a user overlay \u222a plugin packs), en first.",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProfileParams"
|
||||
}
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"name": "result",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/I18nLanguagesResult"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "image.attach",
|
||||
"summary": "Queue a gateway-visible image file for the next turn.",
|
||||
@@ -15710,6 +15746,80 @@
|
||||
"title": "HeartbeatSnapshot",
|
||||
"type": "object"
|
||||
},
|
||||
"I18nCatalogParams": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"profile": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Profile"
|
||||
},
|
||||
"lang": {
|
||||
"title": "Lang",
|
||||
"type": "string"
|
||||
},
|
||||
"surface": {
|
||||
"$ref": "#/components/schemas/LocaleSurface",
|
||||
"default": "core"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lang"
|
||||
],
|
||||
"title": "I18nCatalogParams",
|
||||
"type": "object"
|
||||
},
|
||||
"I18nCatalogResult": {
|
||||
"additionalProperties": false,
|
||||
"description": "``messages`` is ONLY the pack + user-overlay layer for that surface (flat dotted keys); the\nclient merges it over its bundled ``en``/``<lang>``. ``lang`` is the canonical id the request\nresolved to (``pt-BR`` \u2192 ``pt-br``; an unknown id resolves to ``en`` with an empty layer).",
|
||||
"properties": {
|
||||
"lang": {
|
||||
"title": "Lang",
|
||||
"type": "string"
|
||||
},
|
||||
"surface": {
|
||||
"$ref": "#/components/schemas/LocaleSurface"
|
||||
},
|
||||
"messages": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Messages",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lang",
|
||||
"surface",
|
||||
"messages"
|
||||
],
|
||||
"title": "I18nCatalogResult",
|
||||
"type": "object"
|
||||
},
|
||||
"I18nLanguagesResult": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"languages": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/LanguageOption"
|
||||
},
|
||||
"title": "Languages",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"languages"
|
||||
],
|
||||
"title": "I18nLanguagesResult",
|
||||
"type": "object"
|
||||
},
|
||||
"ImageAttachBytesParams": {
|
||||
"additionalProperties": false,
|
||||
"description": "``content_base64`` (or the ``data`` alias) carries the bytes; ``filename`` / ``ext`` only hint\nthe extension \u2014 magic bytes decide.",
|
||||
@@ -16321,6 +16431,36 @@
|
||||
"title": "InterruptStatus",
|
||||
"type": "string"
|
||||
},
|
||||
"LanguageOption": {
|
||||
"additionalProperties": false,
|
||||
"description": "``agent.i18n_languages.language_options`` row. ``source`` is ``bundled``, ``overlay`` or\n``plugin:<name>`` \u2014 the highest layer that supplies the language.",
|
||||
"properties": {
|
||||
"id": {
|
||||
"title": "Id",
|
||||
"type": "string"
|
||||
},
|
||||
"endonym": {
|
||||
"title": "Endonym",
|
||||
"type": "string"
|
||||
},
|
||||
"rtl": {
|
||||
"title": "Rtl",
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"title": "Source",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"endonym",
|
||||
"rtl",
|
||||
"source"
|
||||
],
|
||||
"title": "LanguageOption",
|
||||
"type": "object"
|
||||
},
|
||||
"LayoutApplyPayload": {
|
||||
"additionalProperties": true,
|
||||
"description": "``tools/apply_layout_tool.py``.",
|
||||
@@ -16992,6 +17132,15 @@
|
||||
"title": "LlmOneshotResult",
|
||||
"type": "object"
|
||||
},
|
||||
"LocaleSurface": {
|
||||
"enum": [
|
||||
"core",
|
||||
"tui",
|
||||
"desktop"
|
||||
],
|
||||
"title": "LocaleSurface",
|
||||
"type": "string"
|
||||
},
|
||||
"LoopSnapshot": {
|
||||
"additionalProperties": false,
|
||||
"description": "``_safe_loop_snapshot`` \u2014 persisted LoopState fields, never its route.",
|
||||
@@ -21531,7 +21680,7 @@
|
||||
},
|
||||
"PluginActivation": {
|
||||
"additionalProperties": false,
|
||||
"description": "What a plugin loaded mid-run does NOW vs later (``hermes_cli.plugins_activation``). ``activated_now``\nkinds (``{kind: [names]}``): ``gateway_commands`` (slash names), ``gateway_transforms`` / ``hooks`` (hook\nnames), ``callbacks`` (platforms / ``slack:<action_id>``) \u2014 live in the running gateway once it reloaded\n(``gateway_reloaded``). ``live_now``: the plugin's MCP servers (connected, with their tools, or the\nerror) and skills, usable in every open chat of the profile from its next turn \u2014 the chats also get a\nnote listing them. ``deferred`` kinds: ``tools`` (Python tool names) and ``prompt`` (section ids)\napply from the next session.",
|
||||
"description": "What a plugin loaded mid-run does NOW vs later (``hermes_cli.plugins_activation``). ``activated_now``\nkinds (``{kind: [names]}``): ``gateway_commands`` (slash names), ``locales`` (``<lang>.<surface>``\nlanguage-pack layers), ``gateway_transforms`` / ``hooks`` (hook\nnames), ``callbacks`` (platforms / ``slack:<action_id>``) \u2014 live in the running gateway once it reloaded\n(``gateway_reloaded``). ``live_now``: the plugin's MCP servers (connected, with their tools, or the\nerror) and skills, usable in every open chat of the profile from its next turn \u2014 the chats also get a\nnote listing them. ``deferred`` kinds: ``tools`` (Python tool names) and ``prompt`` (section ids)\napply from the next session.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
|
||||
@@ -1,6 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { applyDocumentLocale, LOCALE_ENDONYMS, mergeTranslations, RTL_LOCALES, type TranslationOverride } from './i18n'
|
||||
import {
|
||||
adaptStringOverrides,
|
||||
applyDocumentLocale,
|
||||
flattenMessageKeys,
|
||||
formatPositional,
|
||||
LOCALE_ENDONYMS,
|
||||
mergeTranslations,
|
||||
RTL_LOCALES,
|
||||
type TranslationOverride,
|
||||
unflattenMessages
|
||||
} from './i18n'
|
||||
|
||||
describe('formatPositional', () => {
|
||||
it('substitutes positional placeholders and blanks the ones the call left out', () => {
|
||||
expect(formatPositional('{0} of {1} on', [2, 5])).toBe('2 of 5 on')
|
||||
expect(formatPositional('{1} then {0}', ['a', 'b'])).toBe('b then a')
|
||||
expect(formatPositional('{0} left{1}', [3])).toBe('3 left')
|
||||
})
|
||||
})
|
||||
|
||||
describe('adaptStringOverrides', () => {
|
||||
it('wraps a pack string over a function-valued base entry into a positional formatter', () => {
|
||||
const base = { menu: { count: (n: number) => `${n} items`, open: 'Open' }, steps: ['one'] }
|
||||
|
||||
const adapted = adaptStringOverrides(base, {
|
||||
menu: { count: '{0} Einträge', open: 'Öffnen' },
|
||||
steps: ['eins']
|
||||
}) as {
|
||||
menu: { count: (n: number) => string; open: string }
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
expect(adapted.menu.count(2)).toBe('2 Einträge')
|
||||
expect(adapted.menu.open).toBe('Öffnen')
|
||||
expect(adapted.steps).toEqual(['eins'])
|
||||
expect(mergeTranslations(base, adapted as never).menu.count(4)).toBe('4 Einträge')
|
||||
})
|
||||
|
||||
it('keeps a function override as-is and a string over a string as-is', () => {
|
||||
const fn = (n: number) => `${n}!`
|
||||
|
||||
const adapted = adaptStringOverrides({ a: (n: number) => `${n}`, b: 'x' }, { a: fn, b: 'y' }) as {
|
||||
a: typeof fn
|
||||
b: string
|
||||
}
|
||||
|
||||
expect(adapted.a).toBe(fn)
|
||||
expect(adapted.b).toBe('y')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unflattenMessages / flattenMessageKeys', () => {
|
||||
it('rebuilds a nested tree from dotted keys and lists leaves (functions included) sorted', () => {
|
||||
const tree = unflattenMessages({ 'common.save': 'Zapisz', 'common.count': '{0}', title: 'T' })
|
||||
|
||||
expect(tree).toEqual({ common: { save: 'Zapisz', count: '{0}' }, title: 'T' })
|
||||
expect(flattenMessageKeys({ z: { count: (n: number) => `${n}`, a: 'x' }, list: ['a'], b: 'y' })).toEqual([
|
||||
'b',
|
||||
'list',
|
||||
'z.a',
|
||||
'z.count'
|
||||
])
|
||||
})
|
||||
|
||||
it('never lets a flat leaf clobber an existing branch', () => {
|
||||
expect(unflattenMessages({ 'a.b': 'x', a: 'y' })).toEqual({ a: { b: 'x' } })
|
||||
expect(unflattenMessages({ a: 'y', 'a.b': 'x' })).toEqual({ a: 'y' })
|
||||
})
|
||||
|
||||
it('keeps dotted leaf keys intact when the base catalog has them', () => {
|
||||
const base = { keybinds: { actions: { 'session.slot.1': 'Slot 1', 'nav.settings': 'Settings' } } }
|
||||
const flat = { 'keybinds.actions.session.slot.1': 'Gniazdo 1', 'keybinds.actions.nav.settings': 'Ustawienia' }
|
||||
|
||||
expect(unflattenMessages(flat, base)).toEqual({
|
||||
keybinds: { actions: { 'session.slot.1': 'Gniazdo 1', 'nav.settings': 'Ustawienia' } }
|
||||
})
|
||||
expect(flattenMessageKeys(base)).toEqual(['keybinds.actions.nav.settings', 'keybinds.actions.session.slot.1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeTranslations', () => {
|
||||
it('keeps untouched sibling keys on a nested partial override and replaces functions/arrays wholesale', () => {
|
||||
|
||||
+129
-3
@@ -39,6 +39,130 @@ export function mergeTranslations<T>(base: T, overrides: TranslationOverride<T>
|
||||
return result as T
|
||||
}
|
||||
|
||||
/** Render a language-pack template with POSITIONAL placeholders (`{0}`, `{1}`)
|
||||
* against the arguments the English function entry would have received. A
|
||||
* pack author writes `"{0} of {1} tools on"` for `(on, total) => …`; an
|
||||
* index the template names but the call didn't supply renders empty. */
|
||||
export function formatPositional(template: string, args: readonly unknown[]): string {
|
||||
return template.replace(/\{(\d+)\}/g, (_match, index: string) => {
|
||||
const value = args[Number(index)]
|
||||
|
||||
return value === undefined || value === null ? '' : String(value)
|
||||
})
|
||||
}
|
||||
|
||||
/** Where the base catalog holds a function (`(n) => string`) and a pack
|
||||
* supplies a plain string, wrap the string into a positional formatter so
|
||||
* the merged catalog keeps the base's call shape. Records recurse; every
|
||||
* other shape passes through untouched (a string over a string, a function
|
||||
* over a function, arrays wholesale). Packs come from YAML, which cannot
|
||||
* express functions — this is the only bridge between the two. */
|
||||
export function adaptStringOverrides(base: unknown, overrides: unknown): unknown {
|
||||
if (typeof base === 'function' && typeof overrides === 'string') {
|
||||
return (...args: unknown[]) => formatPositional(overrides, args)
|
||||
}
|
||||
|
||||
if (!isRecord(base) || !isRecord(overrides)) {
|
||||
return overrides
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
result[key] = adaptStringOverrides(base[key], value)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/** Message leaf a language pack can carry after parsing (YAML is text-only). */
|
||||
export type FlatMessages = Record<string, string>
|
||||
|
||||
/** `{ "a.b.c": "x" }` → `{ a: { b: { c: "x" } } }`. Keys are split on `.`,
|
||||
* except where `base` (the catalog the pack overlays) has a key that itself
|
||||
* contains dots (`keybinds.actions["session.slot.1"]`): at each level the
|
||||
* longest segment run naming an existing base key wins, so dotted leaf keys
|
||||
* round-trip. A flat key that collides with an earlier branch is dropped
|
||||
* rather than clobbering it, so one bad line can't erase a subtree. */
|
||||
export function unflattenMessages(flat: FlatMessages, base?: unknown): Record<string, unknown> {
|
||||
const root: Record<string, unknown> = {}
|
||||
|
||||
for (const [path, value] of Object.entries(flat)) {
|
||||
const parts = path.split('.')
|
||||
let cursor = root
|
||||
let guide: unknown = base
|
||||
let index = 0
|
||||
|
||||
while (index < parts.length) {
|
||||
// Prefer the longest run of segments that names a key in the base guide;
|
||||
// without a guide (or a match) fall back to one segment at a time.
|
||||
let take = 1
|
||||
|
||||
if (isRecord(guide)) {
|
||||
for (let end = parts.length; end > index; end -= 1) {
|
||||
if (Object.hasOwn(guide, parts.slice(index, end).join('.'))) {
|
||||
take = end - index
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = parts.slice(index, index + take).join('.')
|
||||
index += take
|
||||
const last = index >= parts.length
|
||||
|
||||
if (last) {
|
||||
if (!isRecord(cursor[key])) {
|
||||
cursor[key] = value
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
const next = cursor[key]
|
||||
|
||||
if (next === undefined) {
|
||||
const branch: Record<string, unknown> = {}
|
||||
cursor[key] = branch
|
||||
cursor = branch
|
||||
} else if (isRecord(next)) {
|
||||
cursor = next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
guide = isRecord(guide) ? guide[key] : undefined
|
||||
}
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
/** Every leaf's dotted path, sorted. Function-valued leaves are keys too
|
||||
* (a pack overrides them with a positional-placeholder string); arrays are
|
||||
* one leaf. This is the key set `locales/_keys.<surface>.json` publishes for
|
||||
* pack validation. */
|
||||
export function flattenMessageKeys(tree: unknown, prefix = ''): string[] {
|
||||
if (!isRecord(tree)) {
|
||||
return prefix ? [prefix] : []
|
||||
}
|
||||
|
||||
const keys: string[] = []
|
||||
|
||||
for (const [key, value] of Object.entries(tree)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
|
||||
if (isRecord(value)) {
|
||||
keys.push(...flattenMessageKeys(value, path))
|
||||
} else {
|
||||
keys.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
return keys.sort()
|
||||
}
|
||||
|
||||
// Endonyms (native names) for the language pickers so users recognize their
|
||||
// language regardless of the current UI language. No country flags: languages
|
||||
// are not countries (English ≠ GB, Portuguese ≠ PT, Chinese variants ≠ any
|
||||
@@ -69,12 +193,14 @@ export type EndonymLocale = keyof typeof LOCALE_ENDONYMS
|
||||
* logical utilities (ms-/me-, ps-/pe-) flip. */
|
||||
export const RTL_LOCALES: ReadonlySet<string> = new Set<EndonymLocale>(['ar'])
|
||||
|
||||
/** Mirror the active locale onto `<html lang dir>`. No-op without a document (SSR, tests). */
|
||||
export function applyDocumentLocale(locale: string): void {
|
||||
/** Mirror the active locale onto `<html lang dir>`. No-op without a document
|
||||
* (SSR, tests). `rtl` lets a registry that knows more locales than
|
||||
* `RTL_LOCALES` (a plugin-registered language) decide the direction. */
|
||||
export function applyDocumentLocale(locale: string, rtl: boolean = RTL_LOCALES.has(locale)): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.lang = locale
|
||||
document.documentElement.dir = RTL_LOCALES.has(locale) ? 'rtl' : 'ltr'
|
||||
document.documentElement.dir = rtl ? 'rtl' : 'ltr'
|
||||
}
|
||||
@@ -328,6 +328,7 @@ _COMMAND_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧
|
||||
# ~/.hermes/.env first, project .env as dev fallback; user env files override stale shell exports.
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from agent.i18n import t as _t # noqa: E402
|
||||
|
||||
_hermes_home = get_hermes_home()
|
||||
_project_env = Path(__file__).parent / '.env'
|
||||
@@ -585,11 +586,11 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
|
||||
if _worktree_has_unpushed_commits(wt_path, timeout=10):
|
||||
if _repo_is_shallow(repo_root):
|
||||
# Shallow boundary makes the unpushed verdict unreliable; the startup pruner reaps later.
|
||||
_cprint(f"\n\033[33m⚠ Shallow clone — cannot verify push state, keeping: {wt_path}\033[0m")
|
||||
print(" The next `hermes -w` session deepens the clone and prunes merged worktrees automatically.")
|
||||
_cprint(f"\n\033[33m{_t('cli.worktree.shallow_clone_keeping', path=wt_path)}\033[0m")
|
||||
print(f" {_t('cli.worktree.next_session_deepens')}")
|
||||
else:
|
||||
_cprint(f"\n\033[33m⚠ Worktree has unpushed commits, keeping: {wt_path}\033[0m")
|
||||
print(f" To clean up manually: git worktree remove --force {wt_path}")
|
||||
_cprint(f"\n\033[33m{_t('cli.worktree.unpushed_keeping', path=wt_path)}\033[0m")
|
||||
print(f" {_t('cli.worktree.clean_up_manually', path=wt_path)}")
|
||||
_active_worktree = None
|
||||
return
|
||||
|
||||
@@ -601,7 +602,7 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
|
||||
_git_quiet(["branch", "-D", branch], repo_root, log=f"Failed to delete branch {branch}")
|
||||
|
||||
_active_worktree = None
|
||||
_cprint(f"\033[32m✓ Worktree cleaned up: {wt_path}\033[0m")
|
||||
_cprint(f"\033[32m{_t('cli.worktree.cleaned_up', path=wt_path)}\033[0m")
|
||||
|
||||
|
||||
# Light/dark terminal detection (mirrors ui-tui/src/theme.ts detectLightMode()). Priority:
|
||||
@@ -983,10 +984,7 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
|
||||
if missing_is_expected():
|
||||
logger.info("tirith not ready (downloading or lazy installs off); pattern matching only")
|
||||
else:
|
||||
_cprint(
|
||||
f" {_DIM}⚠ tirith security scanner enabled but not available "
|
||||
f"— command scanning will use pattern matching only{_RST}"
|
||||
)
|
||||
_cprint(f" {_DIM}{_t('cli.startup.tirith_unavailable')}{_RST}")
|
||||
except Exception as exc:
|
||||
logger.debug("tirith availability check failed: %s", exc)
|
||||
|
||||
@@ -1092,7 +1090,7 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
|
||||
config_path = _hermes_home / 'config.yaml'
|
||||
if not config_path.exists():
|
||||
config_path = Path(__file__).parent / 'cli-config.yaml'
|
||||
config_status = "(loaded)" if config_path.exists() else "(not found)"
|
||||
config_status = _t("cli.config.loaded") if config_path.exists() else _t("cli.config.not_found")
|
||||
|
||||
# ``api_key`` may be a callable (Entra ID bearer provider): never invoke it. Prefer the
|
||||
# LIVE agent's key: the constructor seeds self.api_key from env before provider
|
||||
@@ -1103,46 +1101,53 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
|
||||
if self.agent is not None and getattr(self.agent, "api_key", None):
|
||||
display_key = self.agent.api_key
|
||||
if is_token_provider(display_key):
|
||||
api_key_display = "Microsoft Entra ID"
|
||||
api_key_display = _t("cli.config.microsoft_entra_id")
|
||||
elif isinstance(display_key, str) and len(display_key) > 12:
|
||||
api_key_display = f"{display_key[:8]}...{display_key[-4:]}"
|
||||
else:
|
||||
api_key_display = "Not set!"
|
||||
api_key_display = _t("cli.config.api_key_not_set")
|
||||
|
||||
title = "(^_^) Configuration"
|
||||
title = _t("cli.config.title")
|
||||
width = 50
|
||||
pad = width - len(title)
|
||||
_unset = _t("cli.config.value_not_set")
|
||||
ssh_target = (
|
||||
f"{os.getenv('TERMINAL_SSH_USER', 'not set')}@{os.getenv('TERMINAL_SSH_HOST', 'not set')}"
|
||||
f"{os.getenv('TERMINAL_SSH_USER', _unset)}@{os.getenv('TERMINAL_SSH_HOST', _unset)}"
|
||||
f":{os.getenv('TERMINAL_SSH_PORT', '22')}"
|
||||
) if terminal_env == "ssh" else None
|
||||
# (section key, ((label key, value), ...)); labels are re-padded at print time so
|
||||
# translated widths still line up instead of carrying alignment spaces in the catalog.
|
||||
sections = (
|
||||
("Model", (("Model: ", self.model), ("Base URL: ", self.base_url), ("API Key: ", api_key_display))),
|
||||
("Terminal", (
|
||||
("Environment: ", terminal_env),
|
||||
*((("SSH Target: ", ssh_target),) if ssh_target else ()),
|
||||
("Working Dir: ", terminal_cwd),
|
||||
("Timeout: ", f"{terminal_timeout}s"),
|
||||
("cli.config.section_model", (
|
||||
("cli.config.label_model", self.model),
|
||||
("cli.config.label_base_url", self.base_url),
|
||||
("cli.config.label_api_key", api_key_display))),
|
||||
("cli.config.section_terminal", (
|
||||
("cli.config.label_environment", terminal_env),
|
||||
*((("cli.config.label_ssh_target", ssh_target),) if ssh_target else ()),
|
||||
("cli.config.label_working_dir", terminal_cwd),
|
||||
("cli.config.label_timeout", f"{terminal_timeout}s"),
|
||||
)),
|
||||
("Agent", (
|
||||
("Max Turns: ", self.max_turns),
|
||||
("Toolsets: ", ", ".join(self.enabled_toolsets) if self.enabled_toolsets else "all"),
|
||||
("Verbose: ", self.verbose),
|
||||
("cli.config.section_agent", (
|
||||
("cli.config.label_max_turns", self.max_turns),
|
||||
("cli.config.label_toolsets", ", ".join(self.enabled_toolsets) if self.enabled_toolsets else "all"),
|
||||
("cli.config.label_verbose", self.verbose),
|
||||
)),
|
||||
("Session", (
|
||||
("Started: ", self.session_start.strftime("%Y-%m-%d %H:%M:%S")),
|
||||
("Config File:", f"{config_path} {config_status}"),
|
||||
("cli.config.section_session", (
|
||||
("cli.config.label_started", self.session_start.strftime("%Y-%m-%d %H:%M:%S")),
|
||||
("cli.config.label_config_file", f"{config_path} {config_status}"),
|
||||
)),
|
||||
)
|
||||
print()
|
||||
print("+" + "-" * width + "+")
|
||||
print("|" + " " * (pad // 2) + title + " " * (pad - pad // 2) + "|")
|
||||
print("+" + "-" * width + "+")
|
||||
for name, rows in sections:
|
||||
for section_key, rows in sections:
|
||||
print()
|
||||
print(f" -- {name} --")
|
||||
for label, value in rows:
|
||||
print(f" {label} {value}")
|
||||
print(f" -- {_t(section_key)} --")
|
||||
label_width = max(len(_t(label_key)) for label_key, _ in rows)
|
||||
for label_key, value in rows:
|
||||
print(f" {_t(label_key):<{label_width}} {value}")
|
||||
print()
|
||||
|
||||
# canonical command -> (method name, pass cmd_original?). Absent commands resolve to
|
||||
@@ -1252,15 +1257,15 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
|
||||
if target:
|
||||
target = target if target.startswith("/") else f"/{target}"
|
||||
return self.process_command(f"{target} {user_args}".strip(), redispatch=True)
|
||||
self._console_print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
|
||||
self._console_print(f"[bold red]{_t('cli.quick.no_target', command=base_cmd)}[/]")
|
||||
return True
|
||||
if qtype != "exec":
|
||||
self._console_print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
|
||||
self._console_print(f"[bold red]{_t('cli.quick.unsupported_type', command=base_cmd)}[/]")
|
||||
return True
|
||||
import subprocess
|
||||
exec_cmd = qcmd.get("command", "")
|
||||
if not exec_cmd:
|
||||
self._console_print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]")
|
||||
self._console_print(f"[bold red]{_t('cli.quick.no_command', command=base_cmd)}[/]")
|
||||
return True
|
||||
try:
|
||||
# shell=True is intentional (user-authored config snippets, never LLM controlled);
|
||||
@@ -1278,11 +1283,11 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
|
||||
from agent.redact import redact_sensitive_text
|
||||
self._console_print(_rich_text_from_ansi(redact_sensitive_text(output)))
|
||||
else:
|
||||
self._console_print("[dim]Command returned no output[/]")
|
||||
self._console_print(f"[dim]{_t('cli.quick.no_output')}[/]")
|
||||
except subprocess.TimeoutExpired:
|
||||
self._console_print("[bold red]Quick command timed out (30s)[/]")
|
||||
self._console_print(f"[bold red]{_t('cli.quick.timed_out')}[/]")
|
||||
except Exception as e:
|
||||
self._console_print(f"[bold red]Quick command error: {e}[/]")
|
||||
self._console_print(f"[bold red]{_t('cli.quick.error', error=str(e))}[/]")
|
||||
return True
|
||||
|
||||
def _run_plugin_slash_command(self, base_cmd: str, user_args: str) -> None:
|
||||
@@ -1296,7 +1301,7 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
|
||||
if result:
|
||||
_cprint(str(result))
|
||||
except Exception as e:
|
||||
_cprint(f"\033[1;31mPlugin command error: {e}{_RST}")
|
||||
_cprint(f"\033[1;31m{_t('cli.plugin.command_error', error=str(e))}{_RST}")
|
||||
|
||||
def _queue_skill_message(self, msg) -> None:
|
||||
if hasattr(self, '_pending_input'):
|
||||
@@ -1306,15 +1311,16 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
|
||||
"""``/<bundle>`` loads several skills at once (bundles win over same-named skills)."""
|
||||
bundle_result = build_bundle_invocation_message(base_cmd, user_instruction, task_id=self.session_id)
|
||||
if not bundle_result:
|
||||
ChatConsole().print(f"[bold red]Failed to load bundle for {base_cmd}[/]")
|
||||
ChatConsole().print(f"[bold red]{_t('cli.skills.bundle_load_failed', command=base_cmd)}[/]")
|
||||
return
|
||||
msg, loaded_names, missing = bundle_result
|
||||
self._queue_loaded_skills(msg, f"Loading bundle: {bundle_info['name']} ({len(loaded_names)} skills)", missing)
|
||||
self._queue_loaded_skills(
|
||||
msg, _t("cli.skills.loading_bundle", name=bundle_info['name'], count=str(len(loaded_names))), missing)
|
||||
|
||||
def _queue_loaded_skills(self, msg, label: str, missing) -> None:
|
||||
print(f"\n⚡ {label}")
|
||||
if missing:
|
||||
ChatConsole().print(f"[yellow]Skipped missing skills: {', '.join(missing)}[/]")
|
||||
ChatConsole().print(f"[yellow]{_t('cli.skills.skipped_missing', names=', '.join(missing))}[/]")
|
||||
self._queue_skill_message(msg)
|
||||
|
||||
def _run_skill_slash_command(self, base_cmd: str, skill_info: dict, rest: str) -> None:
|
||||
@@ -1327,18 +1333,19 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
|
||||
[base_cmd, *extra_keys], user_instruction, task_id=self.session_id,
|
||||
)
|
||||
if not stacked_result:
|
||||
ChatConsole().print(f"[bold red]Failed to load stacked skills for {base_cmd}[/]")
|
||||
ChatConsole().print(f"[bold red]{_t('cli.skills.stacked_load_failed', command=base_cmd)}[/]")
|
||||
return
|
||||
msg, loaded_names, missing = stacked_result
|
||||
self._queue_loaded_skills(
|
||||
msg, f"Loading {len(loaded_names)} stacked skills: {', '.join(loaded_names)}", missing
|
||||
msg, _t("cli.skills.loading_stacked", count=str(len(loaded_names)), names=', '.join(loaded_names)),
|
||||
missing,
|
||||
)
|
||||
return
|
||||
msg = build_skill_invocation_message(base_cmd, rest, task_id=self.session_id)
|
||||
if msg:
|
||||
self._queue_loaded_skills(msg, f"Loading skill: {skill_info['name']}", None)
|
||||
self._queue_loaded_skills(msg, _t("cli.skills.loading_skill", name=skill_info['name']), None)
|
||||
else:
|
||||
ChatConsole().print(f"[bold red]Failed to load skill for {base_cmd}[/]")
|
||||
ChatConsole().print(f"[bold red]{_t('cli.skills.skill_load_failed', command=base_cmd)}[/]")
|
||||
|
||||
def _expand_slash_prefix(self, cmd_original: str, cmd_lower: str, skill_commands, skill_bundles) -> bool:
|
||||
"""Unique-prefix expansion against built-in COMMANDS + skill commands/bundles (agrees with tab-completion)."""
|
||||
@@ -1359,8 +1366,8 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
|
||||
# Expand to the full name, preserving arguments.
|
||||
return self.process_command(matches[0] + cmd_original.strip()[len(typed_base):], redispatch=True)
|
||||
if len(matches) > 1:
|
||||
_cprint(f"{_ACCENT}Ambiguous command: {cmd_lower}{_RST}")
|
||||
_cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}")
|
||||
_cprint(f"{_ACCENT}{_t('cli.command.ambiguous', command=cmd_lower)}{_RST}")
|
||||
_cprint(f"{_DIM}{_t('cli.command.did_you_mean_many', candidates=', '.join(sorted(matches)))}{_RST}")
|
||||
else:
|
||||
# Exact token with no handler (never re-dispatch the same token: recursion), or no match.
|
||||
from hermes_cli.cli_unknown_command import unknown_command_lines
|
||||
@@ -1498,12 +1505,7 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
|
||||
elif _errno in {errno.EINVAL, errno.EBADF} or any(
|
||||
s in _msg for s in ("is not registered", "Bad file descriptor", "Invalid argument")
|
||||
):
|
||||
print(
|
||||
f"\nError: stdin is not usable ({_stdin_err}).\n"
|
||||
"This can happen with certain Python installations (e.g. uv-managed cPython on macOS)\n"
|
||||
"where kqueue cannot register fd 0.\n"
|
||||
"Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup"
|
||||
)
|
||||
print(_t("cli.startup.stdin_unusable", error=str(_stdin_err)))
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
@@ -1601,7 +1603,7 @@ def _run_legacy_gateway():
|
||||
from hermes_startup_watchdog import arm_startup_watchdog
|
||||
arm_startup_watchdog()
|
||||
from gateway.run import start_gateway
|
||||
print("Starting Hermes Gateway (messaging platforms)...")
|
||||
print(_t("cli.gateway.starting"))
|
||||
asyncio.run(start_gateway())
|
||||
|
||||
|
||||
@@ -1745,7 +1747,7 @@ def main(
|
||||
register_self("cli")
|
||||
warning = shared_profile_warning()
|
||||
if warning:
|
||||
print(f"Warning: {warning}", file=sys.stderr)
|
||||
print(_t("cli.startup.warning", warning=warning), file=sys.stderr)
|
||||
|
||||
_join_worktree = _start_worktree_setup(list_tools, list_toolsets, worktree, w)
|
||||
query = query or q
|
||||
|
||||
@@ -361,10 +361,11 @@ def _payload(ev: Any, key: str) -> Any:
|
||||
return ev.payload.get(key) if ev.payload and ev.payload.get(key) else None
|
||||
|
||||
|
||||
def _clip(ev: Any, key: str, fmt: str, limit: int) -> str:
|
||||
"""``fmt`` applied to the truncated payload value, or ``""`` when absent."""
|
||||
def _clip(ev: Any, key: str, msg_key: str, limit: int) -> str:
|
||||
"""Catalog message ``msg_key`` (``{value}`` placeholder) rendered with the truncated payload
|
||||
value, or ``""`` when absent."""
|
||||
value = _payload(ev, key)
|
||||
return fmt.format(str(value)[:limit]) if value else ""
|
||||
return t(msg_key, value=str(value)[:limit]) if value else ""
|
||||
|
||||
|
||||
_NL = "\n{}"
|
||||
@@ -384,7 +385,7 @@ def _fmt_completed(ev, n) -> tuple:
|
||||
elif n.task and n.task.result:
|
||||
wake_handoff = _first_line(n.task.result, 160)
|
||||
handoff = f"\n{wake_handoff}" if wake_handoff is not None else ""
|
||||
return f"✔ {n.head} done — {n.title}{handoff}", wake_handoff, None
|
||||
return t("gateway.kanban.ping.completed", head=n.head, title=n.title, handoff=handoff), wake_handoff, None
|
||||
|
||||
|
||||
def _fmt_review_requested(ev, n) -> tuple:
|
||||
@@ -397,7 +398,7 @@ def _fmt_review_requested(ev, n) -> tuple:
|
||||
summary = str(summary)
|
||||
handoff = f"\n{summary[:200]}"
|
||||
wake_handoff = _first_line(summary, 200)
|
||||
return f"👀 {n.head} ready for review — {n.title}{handoff}", wake_handoff, None
|
||||
return t("gateway.kanban.ping.review_requested", head=n.head, title=n.title, handoff=handoff), wake_handoff, None
|
||||
|
||||
|
||||
def _fmt_changes_requested(ev, n) -> tuple:
|
||||
@@ -405,11 +406,12 @@ def _fmt_changes_requested(ev, n) -> tuple:
|
||||
reason = _safe_review_reason(payload.get("reason"))
|
||||
reviewer = _safe_review_reason(payload.get("reviewer"), 48)
|
||||
implementer = _safe_review_reason(payload.get("implementer"), 48)
|
||||
reason_text = reason or "reviewer feedback requires changes"
|
||||
provenance = f" — reviewer @{reviewer}" if reviewer else ""
|
||||
reason_text = reason or t("gateway.kanban.ping.changes_default_reason")
|
||||
provenance = t("gateway.kanban.ping.reviewer_suffix", reviewer=reviewer) if reviewer else ""
|
||||
if implementer:
|
||||
provenance += f" → implementer @{implementer}"
|
||||
msg = f"🛑 {n.board_tag}Kanban {n.task_id} review requested changes/BLOCK: {reason_text}{provenance}"
|
||||
provenance += t("gateway.kanban.ping.implementer_suffix", implementer=implementer)
|
||||
msg = t("gateway.kanban.ping.changes_requested",
|
||||
board_tag=n.board_tag, task_id=n.task_id, reason=reason_text, provenance=provenance)
|
||||
return msg, None, reason_text
|
||||
|
||||
|
||||
@@ -425,10 +427,11 @@ def _fmt_block_loop_detected(ev, n) -> tuple:
|
||||
"""
|
||||
kind = _payload(ev, "kind")
|
||||
decision = kind == "needs_input"
|
||||
msg = (
|
||||
f"🛑 {n.head} routed to TRIAGE — "
|
||||
f"{'needs a human decision' if decision else 'for orchestration attention'}"
|
||||
f"{_clip(ev, 'recurrences', ' (blocked {}x for the same cause)', 200)}{_clip(ev, 'reason', ': {}', 160)}"
|
||||
msg = t(
|
||||
"gateway.kanban.ping.triage", head=n.head,
|
||||
why=t("gateway.kanban.ping.triage_decision" if decision else "gateway.kanban.ping.triage_attention"),
|
||||
recurrences=_clip(ev, "recurrences", "gateway.kanban.ping.triage_recurrences", 200),
|
||||
reason=_clip(ev, "reason", "gateway.kanban.ping.reason_suffix", 160),
|
||||
)
|
||||
return msg, None, None
|
||||
|
||||
@@ -437,20 +440,17 @@ def _fmt_gave_up(ev, n) -> tuple:
|
||||
# The dispatcher auto-blocked the task after ``failures`` consecutive non-success attempts
|
||||
# (spawn failure, crash, or timeout alike): it is now Blocked and waiting for a human.
|
||||
failures = _payload(ev, "failures")
|
||||
count = f"it failed {int(failures)} times in a row" if failures else "it kept failing"
|
||||
last = _clip(ev, "error", " (last: {})", 160)
|
||||
return (
|
||||
f"⛔ {n.head} is now blocked: {count}{last}. Fix the cause, then `hermes kanban unblock "
|
||||
f"{n.task_id}` (or `hermes kanban reassign {n.task_id}`). Logs: `hermes kanban log {n.task_id}`.",
|
||||
None, None,
|
||||
)
|
||||
count = (t("gateway.kanban.ping.failed_n_times", count=int(failures)) if failures
|
||||
else t("gateway.kanban.ping.kept_failing"))
|
||||
last = _clip(ev, "error", "gateway.kanban.ping.last_error", 160)
|
||||
return t("gateway.kanban.ping.gave_up", head=n.head, count=count, last=last, task_id=n.task_id), None, None
|
||||
|
||||
|
||||
def _fmt_timed_out(ev, n) -> tuple:
|
||||
limit = int(_payload(ev, "limit_seconds") or 0)
|
||||
minutes = max(1, round(limit / 60)) if limit else 0
|
||||
span = f"its {minutes}-minute limit" if minutes else "its time limit"
|
||||
return f"⏱ {n.head} ran past {span} and was stopped; it will be retried automatically.", None, None
|
||||
span = t("gateway.kanban.ping.limit_minutes", minutes=minutes) if minutes else t("gateway.kanban.ping.limit_generic")
|
||||
return t("gateway.kanban.ping.timed_out", head=n.head, span=span), None, None
|
||||
|
||||
|
||||
# archived / unblocked are claimed (so the cursor advances past them) but
|
||||
@@ -458,13 +458,14 @@ def _fmt_timed_out(ev, n) -> tuple:
|
||||
# never wake the creator.
|
||||
_EVENT_FORMATTERS: dict[str, Callable[[Any, "_KanbanNotification"], tuple]] = {
|
||||
"completed": _fmt_completed,
|
||||
"blocked": lambda ev, n: (f"⏸ {n.head} blocked{_clip(ev, 'reason', ': {}', 160)}", None, None),
|
||||
"gave_up": _fmt_gave_up,
|
||||
"crashed": lambda ev, n: (
|
||||
f"✖ {n.head} — its worker stopped unexpectedly; it will be retried automatically.", None, None,
|
||||
"blocked": lambda ev, n: (
|
||||
t("gateway.kanban.ping.blocked", head=n.head, reason=_clip(ev, "reason", "gateway.kanban.ping.reason_suffix", 160)),
|
||||
None, None,
|
||||
),
|
||||
"gave_up": _fmt_gave_up,
|
||||
"crashed": lambda ev, n: (t("gateway.kanban.ping.crashed", head=n.head), None, None),
|
||||
"timed_out": _fmt_timed_out,
|
||||
"status": lambda ev, n: (f"🔄 {n.head} → {_payload(ev, 'status') or ''}", None, None),
|
||||
"status": lambda ev, n: (t("gateway.kanban.ping.status", head=n.head, status=_payload(ev, "status") or ""), None, None),
|
||||
"review_requested": _fmt_review_requested,
|
||||
"changes_requested": _fmt_changes_requested,
|
||||
"block_loop_detected": _fmt_block_loop_detected,
|
||||
@@ -494,10 +495,10 @@ class _KanbanNotification:
|
||||
self.task_id = sub["task_id"]
|
||||
self.sub_profile = sub.get("notifier_profile") or ""
|
||||
self.title = (task.title if task else sub["task_id"])[:120]
|
||||
self.board_tag = f"[{self.board_slug}] " if self.board_slug else ""
|
||||
self.board_tag = t("gateway.kanban.ping.board_tag", board=self.board_slug) if self.board_slug else ""
|
||||
# Attribute the ping to the worker that did the work.
|
||||
tag = f"@{task.assignee} " if task and task.assignee else ""
|
||||
self.head = f"{self.board_tag}{tag}Kanban {self.task_id}"
|
||||
tag = t("gateway.kanban.ping.assignee_tag", assignee=task.assignee) if task and task.assignee else ""
|
||||
self.head = t("gateway.kanban.ping.head", board_tag=self.board_tag, assignee_tag=tag, task_id=self.task_id)
|
||||
# The wake self-post path needs the key even when every event was skipped.
|
||||
self.sub_key = (sub["task_id"], sub["platform"], sub["chat_id"], sub.get("thread_id") or "")
|
||||
mode = sub.get("delivery_mode") or "notify"
|
||||
|
||||
@@ -144,6 +144,7 @@ from gateway.platforms.base import (
|
||||
MEDIA_TAG_CLEANUP_RE, BasePlatformAdapter, SendResult, _terminal_sentinel_start, is_network_accessible,
|
||||
validate_media_delivery_path)
|
||||
from gateway.platforms.api_server_run_idempotency import RunIdempotencyStore
|
||||
from agent.i18n import t
|
||||
from agent.redact import redact_sensitive_text
|
||||
from agent.interrupt_compat import request_hard_interrupt
|
||||
from gateway.readiness import collect_runtime_readiness
|
||||
@@ -1117,19 +1118,31 @@ except Exception: # pragma: no cover - scanner is optional hardening
|
||||
_scan_cron_prompt = None
|
||||
|
||||
|
||||
# English labels stay as constants: ``gateway.run._GATEWAY_AUTH_ERROR_RE`` / ``_GATEWAY_RATE_LIMIT_RE``
|
||||
# sniff these words in failure envelopes, so matchers and tests key off them regardless of the
|
||||
# display language. ``user_text()`` renders the human-facing line through ``t()``.
|
||||
PROVIDER_AUTH_FAILED_LABEL = "Provider authentication failed"
|
||||
PROVIDER_RATE_LIMITED_LABEL = "Provider rate-limited"
|
||||
|
||||
|
||||
class _ProviderAuthResolutionError(RuntimeError):
|
||||
"""Provider credential resolution failed. Typed so callers never mislabel other
|
||||
RuntimeErrors from run_conversation() (e.g. a closed OpenAI client) as auth failures."""
|
||||
|
||||
def user_text(self) -> str:
|
||||
"""Raw-surface failure line. A quota/429 cap with valid credentials must not be labelled an
|
||||
authentication failure — the cause chain (RuntimeError -> AuthError) tells them apart (#89401)."""
|
||||
def is_rate_limited(self) -> bool:
|
||||
"""A quota/429 cap with valid credentials must not be labelled an authentication
|
||||
failure — the cause chain (RuntimeError -> AuthError) tells them apart (#89401)."""
|
||||
from hermes_cli.auth import is_rate_limited_auth_error
|
||||
|
||||
cause = self.__cause__
|
||||
cause = getattr(cause, "__cause__", None) if isinstance(cause, RuntimeError) else cause
|
||||
label = "Provider rate-limited" if is_rate_limited_auth_error(cause) else "Provider authentication failed"
|
||||
return f"⚠️ {label}: {self}"
|
||||
return bool(is_rate_limited_auth_error(cause))
|
||||
|
||||
def user_text(self) -> str:
|
||||
"""Raw-surface failure line shown as the assistant reply in API-backed chat UIs."""
|
||||
label = t("platform.api_server.provider_rate_limited" if self.is_rate_limited()
|
||||
else "platform.api_server.provider_auth_failed")
|
||||
return t("platform.api_server.provider_error_line", label=label, error=self)
|
||||
|
||||
|
||||
class _SessionEventQueue:
|
||||
|
||||
@@ -15,6 +15,8 @@ import uuid
|
||||
from contextlib import suppress
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.i18n import t
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
except ImportError: # pragma: no cover - mirrors api_server's optional import
|
||||
@@ -82,7 +84,8 @@ def _hermes_extras(completed, is_partial, is_failed, err_msg, finish_reason: str
|
||||
"error_code": "output_truncated" if finish_reason == "length" else "agent_error"}
|
||||
|
||||
|
||||
_TRANSFORMED_NOTICE = "\n\n[Response transformed after streaming]\n"
|
||||
def _transformed_notice() -> str:
|
||||
return t("platform.api_server.transformed_notice")
|
||||
|
||||
|
||||
def _post_stream_transform(result: Any) -> tuple:
|
||||
@@ -949,7 +952,7 @@ class OpenAICompatRoutesMixin:
|
||||
# Chat chunks can only append: a non-append rewrite follows the streamed text (as in the CLI).
|
||||
tail, appended = _post_stream_transform(result)
|
||||
if tail:
|
||||
await response.write(_sse_frame(_chunk({"content": tail if appended else _TRANSFORMED_NOTICE + tail})))
|
||||
await response.write(_sse_frame(_chunk({"content": tail if appended else _transformed_notice() + tail})))
|
||||
if finish_reason != "stop":
|
||||
if err_msg and not presentation_muted:
|
||||
finish_chunk["error"] = {
|
||||
|
||||
+56
-34
@@ -21,6 +21,7 @@ from abc import ABC, abstractmethod
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from utils import normalize_proxy_url
|
||||
from agent.i18n import t
|
||||
from agent.retry_utils import jittered_backoff
|
||||
from agent.proxy_bypass import first_proxy_env_value, should_bypass_proxy as _should_bypass_proxy
|
||||
|
||||
@@ -178,10 +179,15 @@ def _reply_anchor_for_event(event) -> str | None:
|
||||
return getattr(event, "message_id", None)
|
||||
|
||||
|
||||
_MEDIA_KIND_KEYS = frozenset({"audio", "video", "file", "image"})
|
||||
|
||||
|
||||
def _media_failure_text(kind: str, file_name: "str | None" = None) -> str:
|
||||
"""User-facing "couldn't deliver" notice; ``file_name`` is the only name ever shown."""
|
||||
suffix = f" ({file_name})" if file_name else ""
|
||||
return f"⚠️ Couldn't deliver the {kind} attachment{suffix}."
|
||||
kind_label = t(f"gateway.notify.media_kind.{kind}") if kind in _MEDIA_KIND_KEYS else kind
|
||||
if file_name:
|
||||
return t("gateway.notify.media_delivery_failed_with_name", kind=kind_label, file_name=file_name)
|
||||
return t("gateway.notify.media_delivery_failed", kind=kind_label)
|
||||
|
||||
|
||||
def should_send_media_as_audio(platform, ext: str, is_voice: bool = False) -> bool:
|
||||
@@ -420,7 +426,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.helpers import fence_state_after
|
||||
from gateway.platforms.base_exec_approval import (
|
||||
EA_HEADER_TEXT, EA_REASON_LABEL_TEXT, approval_timeout_seconds, format_approval_deadline_line)
|
||||
approval_timeout_seconds, ea_action_labels, ea_default_reason_text, ea_header_text,
|
||||
ea_reason_label_text, ea_smart_deny_line_text, format_approval_deadline_line)
|
||||
from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome
|
||||
from gateway.warning_notifications import diagnostic_wake_muted
|
||||
from hermes_cli.observability.shared_metrics_gateway import records_delivery, stop_reply_clock
|
||||
@@ -486,9 +493,10 @@ UNAUTHORIZED_ACTION_NOTICE = (
|
||||
|
||||
|
||||
def unauthorized_action_notice(platform: Any) -> str:
|
||||
"""``UNAUTHORIZED_ACTION_NOTICE`` for a ``Platform`` member or its string name."""
|
||||
"""Localized ``UNAUTHORIZED_ACTION_NOTICE`` for a ``Platform`` member or its string name.
|
||||
Call it per refusal — never bind the result at import, or the language freezes."""
|
||||
name = getattr(platform, "value", platform)
|
||||
return UNAUTHORIZED_ACTION_NOTICE.format(platform=str(name or "<platform>"))
|
||||
return t("gateway.unauthorized.action_notice", platform=str(name or "<platform>"))
|
||||
|
||||
|
||||
def safe_url_for_log(url: str, max_len: int = 80) -> str:
|
||||
@@ -2058,22 +2066,23 @@ class BasePlatformAdapter(ABC):
|
||||
if not isinstance(event, ToolCallChunk):
|
||||
return None
|
||||
from agent.display import get_tool_emoji, prepare_tool_preview
|
||||
head = f"{get_tool_emoji(event.tool_name, default='⚙️')} {event.tool_name}"
|
||||
emoji, tool = get_tool_emoji(event.tool_name, default='⚙️'), event.tool_name
|
||||
if mode == "verbose" and event.args:
|
||||
import json
|
||||
args_str = json.dumps(event.args, ensure_ascii=False, default=str)
|
||||
if preview_max_len > 0 and len(args_str) > preview_max_len:
|
||||
args_str = args_str[:preview_max_len - 3] + "..."
|
||||
return f"{head}({list(event.args.keys())})\n{args_str}"
|
||||
return t("gateway.progress.tool_verbose", emoji=emoji, tool=tool, keys=list(event.args.keys()), args=args_str)
|
||||
if not event.preview:
|
||||
return f"{head}..."
|
||||
return t("gateway.progress.tool_pending", emoji=emoji, tool=tool)
|
||||
if mode == "verbose":
|
||||
return f'{head}: "{event.preview}"'
|
||||
return t("gateway.progress.tool_preview", emoji=emoji, tool=tool, preview=event.preview)
|
||||
# "all" / "new": short capped preview (default 40; progress bubbles persist as messages).
|
||||
cap = preview_max_len if preview_max_len > 0 else 40
|
||||
prepared = prepare_tool_preview(
|
||||
event.tool_name, event.args, fallback=event.preview, max_len=cap)
|
||||
return f'{head}: "{self.format_tool_preview(prepared)}"'
|
||||
return t("gateway.progress.tool_preview", emoji=emoji, tool=tool, preview=self.format_tool_preview(prepared))
|
||||
|
||||
|
||||
def format_tool_preview(self, preview: "ToolPreview") -> str:
|
||||
"""Platform-native formatting of a compact tool preview; rich-text adapters may use
|
||||
@@ -2729,15 +2738,27 @@ class BasePlatformAdapter(ABC):
|
||||
# ── ``_format_exec_approval`` templates; adapters override only the MARKUP (bold, HTML,
|
||||
# fences) — the words come from ``gateway.platforms.base_exec_approval`` so every surface
|
||||
# says the same thing.
|
||||
_EA_HEADER: str = f"⚠️ {EA_HEADER_TEXT}\n\n"
|
||||
# ``_EA_HEADER`` / ``_EA_REASON_LABEL`` / ``_EA_SMART_DENY_LINE`` / ``_EA_ACTION_LABELS`` are
|
||||
# properties (not class attrs) so the active ``display.language`` is read per render;
|
||||
# subclasses may still shadow them with plain class attributes carrying their own markup.
|
||||
_EA_CODE_OPEN: str = "```\n"
|
||||
_EA_CODE_CLOSE: str = "\n```\n"
|
||||
_EA_REASON_LABEL: str = f"{EA_REASON_LABEL_TEXT}: "
|
||||
_EA_DEADLINE_PREFIX: str = "\n\n" # separates the deadline line from the reason line
|
||||
_EA_SMART_DENY_LINE: str = "\n\nSmart DENY: owner override applies to this one operation only."
|
||||
_EA_CMD_BUDGET: int = 3000
|
||||
_EA_REASON_BUDGET: int = 0 # 0 = the reason is never truncated
|
||||
|
||||
@property
|
||||
def _EA_HEADER(self) -> str: # noqa: N802 — adapter-override contract name
|
||||
return f"⚠️ {ea_header_text()}\n\n"
|
||||
|
||||
@property
|
||||
def _EA_REASON_LABEL(self) -> str: # noqa: N802
|
||||
return f"{ea_reason_label_text()}: "
|
||||
|
||||
@property
|
||||
def _EA_SMART_DENY_LINE(self) -> str: # noqa: N802
|
||||
return f"\n\n{ea_smart_deny_line_text()}"
|
||||
|
||||
@staticmethod
|
||||
def _truncate_preview(text: str, budget: int, suffix: str = "...") -> str:
|
||||
"""Truncate ``text`` to ``budget`` chars, appending ``suffix`` when cut."""
|
||||
@@ -2776,10 +2797,12 @@ class BasePlatformAdapter(ABC):
|
||||
return self._EA_DEADLINE_PREFIX + self._ea_escape(format_approval_deadline_line(approval_timeout_seconds()))
|
||||
|
||||
def _format_exec_approval(
|
||||
self, command: str, description: str = "dangerous command", smart_denied: bool = False) -> str:
|
||||
self, command: str, description: Optional[str] = None, smart_denied: bool = False) -> str:
|
||||
"""Shared exec-approval prompt text: header + fenced (truncated) command + why it was
|
||||
flagged + the deadline line, plus the smart-deny line. Buttons/trailing instructions stay
|
||||
platform-local."""
|
||||
platform-local. ``description=None`` renders the localized default reason."""
|
||||
if description is None:
|
||||
description = ea_default_reason_text()
|
||||
if self._EA_REASON_BUDGET:
|
||||
description = self._ea_fit(str(description or ""), self._EA_REASON_BUDGET)
|
||||
cmd_preview = self._ea_fit(
|
||||
@@ -2792,10 +2815,12 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# ── Exec-approval prompt (template method). The choice set is one rule for every button
|
||||
# surface — three separate "same fix × N adapters" commits motivated lifting it here.
|
||||
_EA_ACTION_LABELS: Dict[str, str] = {
|
||||
"once": "Allow Once", "session": "Allow Session", "always": "Always Allow", "deny": "Deny"}
|
||||
_EA_ACTION_STYLES: Dict[str, str] = {"once": "primary", "deny": "danger"}
|
||||
|
||||
@property
|
||||
def _EA_ACTION_LABELS(self) -> Dict[str, str]: # noqa: N802 — adapter-override contract name
|
||||
return ea_action_labels()
|
||||
|
||||
def _exec_approval_actions(
|
||||
self, *, allow_permanent: bool, allow_session: bool, smart_denied: bool) -> List[Tuple[str, str, str]]:
|
||||
"""``(label, choice, style)`` rows for the approval buttons. A smart deny is an owner
|
||||
@@ -2816,13 +2841,15 @@ class BasePlatformAdapter(ABC):
|
||||
return cls._send_exec_approval_prompt is not BasePlatformAdapter._send_exec_approval_prompt
|
||||
|
||||
async def send_exec_approval(
|
||||
self, chat_id: str, command: str, session_key: str, description: str = "dangerous command",
|
||||
self, chat_id: str, command: str, session_key: str, description: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None, allow_permanent: bool = True, allow_session: bool = True,
|
||||
smart_denied: bool = False,
|
||||
) -> SendResult:
|
||||
"""Interactive exec-approval prompt; a press resolves via
|
||||
``tools.approval.resolve_gateway_approval``. Text and choice set are shared; adapters
|
||||
render them natively in ``_send_exec_approval_prompt``."""
|
||||
if description is None:
|
||||
description = ea_default_reason_text()
|
||||
prompt = ExecApprovalPrompt(
|
||||
chat_id=chat_id, session_key=session_key, metadata=metadata, command=str(command or ""),
|
||||
description=description, smart_denied=smart_denied,
|
||||
@@ -2844,7 +2871,7 @@ class BasePlatformAdapter(ABC):
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = max(0, min(page, total_pages - 1))
|
||||
start, end = page * per_page, min(page * per_page + per_page, total)
|
||||
page_info = f" ({start + 1}–{end} of {total})" if total_pages > 1 else ""
|
||||
page_info = t("gateway.picker.page_info", start=start + 1, end=end, total=total) if total_pages > 1 else ""
|
||||
meta: Dict[str, Any] = {"page": page, "total_pages": total_pages, "start": start,
|
||||
"end": end, "total": total, "page_info": page_info}
|
||||
return options[start:end], meta
|
||||
@@ -2878,17 +2905,15 @@ class BasePlatformAdapter(ABC):
|
||||
_is_multi = bool(getattr(_cg._entries.get(clarify_id), "multi_select", False))
|
||||
except Exception:
|
||||
_is_multi = False
|
||||
hint = "Reply with the number, the option text, or your own answer."
|
||||
if _is_multi:
|
||||
hint = ("Multiple selections allowed — reply with the numbers separated by commas "
|
||||
"or spaces (e.g. \"1, 3\"), the option text, or your own answer.")
|
||||
numbered = [f" {i}. {choice}" for i, choice in enumerate(choices, start=1)]
|
||||
text = "\n".join([f"❓ {question}", "", *numbered, "", hint])
|
||||
hint = t("gateway.clarify.hint_multi") if _is_multi else t("gateway.clarify.hint_single")
|
||||
numbered = [t("gateway.clarify.option_line", index=i, choice=choice)
|
||||
for i, choice in enumerate(choices, start=1)]
|
||||
text = "\n".join([t("gateway.clarify.question", question=question), "", *numbered, "", hint])
|
||||
# Text fallback: let the gateway intercept capture the typed reply.
|
||||
from tools.clarify_gateway import mark_awaiting_text
|
||||
mark_awaiting_text(clarify_id)
|
||||
else:
|
||||
text = f"❓ {question}"
|
||||
text = t("gateway.clarify.question", question=question)
|
||||
return await self.send(chat_id=chat_id, content=text, metadata=metadata)
|
||||
|
||||
async def send_private_notice(
|
||||
@@ -3672,9 +3697,7 @@ class BasePlatformAdapter(ABC):
|
||||
return result
|
||||
logger.error("[%s] Failed to deliver response after %d retries: %s", self.name, max_retries, error_str)
|
||||
# Not a diagnostic: the requested result itself was lost and this is its only signal.
|
||||
notice = (
|
||||
"\u26a0\ufe0f Message delivery failed after multiple attempts. "
|
||||
"Please try again \u2014 your request was processed but the response could not be sent.")
|
||||
notice = t("gateway.notify.delivery_failed_retry")
|
||||
try:
|
||||
await _send(notice)
|
||||
except Exception as notify_err:
|
||||
@@ -3719,7 +3742,7 @@ class BasePlatformAdapter(ABC):
|
||||
likely culprit override it (Photon drops rich links instead of adding the banner)."""
|
||||
return await self.send(
|
||||
chat_id=chat_id, content=self.warning_text(
|
||||
f"(Response formatting failed, plain text:)\n\n{content[:3500]}", content[:3500],
|
||||
t("gateway.notify.plain_fallback_prefix", content=content[:3500]), content[:3500],
|
||||
chat_id=chat_id, metadata=metadata),
|
||||
reply_to=reply_to, metadata=metadata)
|
||||
|
||||
@@ -4330,14 +4353,13 @@ class BasePlatformAdapter(ABC):
|
||||
_thread_metadata = None
|
||||
try:
|
||||
_thread_metadata = _thread_metadata_for_event(event)
|
||||
error_detail = str(e)[:300] if str(e) else "no details available"
|
||||
error_detail = str(e)[:300] if str(e) else t("gateway.notify.turn_error_no_details")
|
||||
# Only the policy reads bind the routed profile; the send stays in the launch scope
|
||||
# as before, so delivery bookkeeping keeps landing where boot-time recovery reads it.
|
||||
with self._media_delivery_scope(event.source):
|
||||
content = None if diagnostic_wake_muted(event) else self.warning_text(
|
||||
f"Sorry, I encountered an error ({type(e).__name__}).\n{error_detail}\n"
|
||||
"Try again or use /reset to start a fresh session.",
|
||||
"Sorry, I encountered an error.",
|
||||
t("gateway.notify.turn_error_detailed", error_type=type(e).__name__, error_detail=error_detail),
|
||||
t("gateway.notify.turn_error"),
|
||||
logical_platform=event.source.platform, chat_id=event.source.chat_id, metadata=_thread_metadata)
|
||||
if content is None:
|
||||
return _thread_metadata
|
||||
|
||||
@@ -6,11 +6,17 @@ the same three things: what Hermes wants to run, why it was flagged, and that si
|
||||
the command does NOT run once ``approvals.timeout`` elapses. Keeping the text here means one
|
||||
edit changes every platform; adapters only wrap these strings in their own markup.
|
||||
|
||||
The ``EA_*`` / ``APPROVAL_TIMED_OUT_NOTICE`` module constants are the English source of truth
|
||||
(catalog keys ``gateway.exec_approval.*``); anything that renders the prompt calls the sibling
|
||||
accessors (``ea_header_text()`` …) so the active ``display.language`` is honoured at render time.
|
||||
|
||||
No imports from ``gateway.platforms.base`` or ``gateway.run`` — both import this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent.i18n import t
|
||||
|
||||
# Bare strings; adapters add their own bold/HTML around them.
|
||||
EA_HEADER_TEXT = "Hermes wants to run a command that needs your OK"
|
||||
EA_REASON_LABEL_TEXT = "Why it was flagged"
|
||||
@@ -21,6 +27,41 @@ APPROVAL_TIMED_OUT_NOTICE = (
|
||||
"Ask me to try again if you still want it, or raise approvals.timeout in config.yaml.")
|
||||
|
||||
|
||||
def ea_header_text() -> str:
|
||||
"""Localized ``EA_HEADER_TEXT``."""
|
||||
return t("gateway.exec_approval.header")
|
||||
|
||||
|
||||
def ea_reason_label_text() -> str:
|
||||
"""Localized ``EA_REASON_LABEL_TEXT``."""
|
||||
return t("gateway.exec_approval.reason_label")
|
||||
|
||||
|
||||
def ea_smart_deny_line_text() -> str:
|
||||
"""Localized smart-deny sentence (bare; adapters add their own line breaks/markup)."""
|
||||
return t("gateway.exec_approval.smart_deny_line")
|
||||
|
||||
|
||||
def ea_default_reason_text() -> str:
|
||||
"""Localized default "why it was flagged" reason when the approval carries none."""
|
||||
return t("gateway.exec_approval.default_reason")
|
||||
|
||||
|
||||
def ea_action_labels() -> dict[str, str]:
|
||||
"""Localized ``choice -> button label`` for the four approval tiers."""
|
||||
return {
|
||||
"once": t("gateway.exec_approval.action_once"),
|
||||
"session": t("gateway.exec_approval.action_session"),
|
||||
"always": t("gateway.exec_approval.action_always"),
|
||||
"deny": t("gateway.exec_approval.action_deny"),
|
||||
}
|
||||
|
||||
|
||||
def approval_timed_out_notice(window: str) -> str:
|
||||
"""Localized ``APPROVAL_TIMED_OUT_NOTICE`` for an already-formatted ``window``."""
|
||||
return t("gateway.exec_approval.timed_out_notice", window=window)
|
||||
|
||||
|
||||
def approval_timeout_seconds() -> int:
|
||||
"""The configured ``approvals.timeout`` (default 300s); module attribute so tests can pin it."""
|
||||
from tools.approval_context import _get_approval_timeout
|
||||
@@ -36,8 +77,8 @@ def format_approval_window(seconds: int) -> str:
|
||||
|
||||
def format_approval_deadline_line(timeout_s: int) -> str:
|
||||
"""The last line of every approval prompt: doing nothing is a safe no."""
|
||||
return f"If you don't answer within {format_approval_window(timeout_s)} it will NOT run."
|
||||
return t("gateway.exec_approval.deadline", window=format_approval_window(timeout_s))
|
||||
|
||||
|
||||
def format_approval_timed_out_notice(timeout_s: int) -> str:
|
||||
return APPROVAL_TIMED_OUT_NOTICE.format(window=format_approval_window(timeout_s))
|
||||
return approval_timed_out_notice(format_approval_window(timeout_s))
|
||||
@@ -24,6 +24,7 @@ from urllib.parse import quote, unquote
|
||||
import httpx
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from agent.i18n import t
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter, SendResult, cache_image_from_bytes_async,
|
||||
cache_audio_from_bytes_async, cache_document_from_bytes_async, cache_image_from_url, utf16_len,
|
||||
@@ -856,8 +857,9 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
async def _notify_batch_pacing(self, chat_id: str, next_batch_idx: int, total_batches: int, wait_s: float) -> None:
|
||||
"""Tell the user about an inter-batch pacing wait over the notice threshold (best-effort)."""
|
||||
try:
|
||||
await self.emit_warning(chat_id, f"(More images coming — pausing ~{_format_wait(wait_s)} for Signal rate limit, "
|
||||
f"batch {next_batch_idx}/{total_batches}.)")
|
||||
await self.emit_warning(chat_id, t(
|
||||
"platform.signal.batch_pacing_notice",
|
||||
wait=_format_wait(wait_s), batch=next_batch_idx, total=total_batches))
|
||||
except Exception as e:
|
||||
logger.warning("Signal: failed to send pacing notice: %s", e)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import re
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.i18n import t
|
||||
from agent.retry_utils import parse_retry_after_seconds
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -74,7 +75,7 @@ def _is_signal_rate_limit_error(err: Any) -> bool:
|
||||
def _format_wait(seconds: float) -> str:
|
||||
"""Human-friendly wait label for user-facing pacing notices."""
|
||||
s = max(0.0, seconds)
|
||||
return f"{int(round(s))}s" if s < 90 else f"{max(1, int(round(s / 60)))} min"
|
||||
return t("platform.signal.wait_seconds", count=int(round(s))) if s < 90 else t("platform.signal.wait_minutes", count=max(1, int(round(s / 60))))
|
||||
|
||||
|
||||
def _signal_send_timeout(num_attachments: int) -> float:
|
||||
|
||||
@@ -30,6 +30,7 @@ CRYPTO_AVAILABLE = Cipher is not None
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.helpers import MessageDeduplicator, cancel_task, greedy_pack_blocks
|
||||
from gateway.platforms.access_policy_mixin import OwnAccessPolicyMixin
|
||||
from agent.i18n import t
|
||||
from gateway.platforms.base import (
|
||||
_IMAGE_EXTS, _VIDEO_EXTS, gateway_trust_env, BasePlatformAdapter, SendResult,
|
||||
cache_audio_from_bytes_async, cache_document_from_bytes_async, cache_image_from_bytes_async,
|
||||
@@ -1118,7 +1119,7 @@ class WeixinAdapter(OwnAccessPolicyMixin, BasePlatformAdapter):
|
||||
|
||||
async def send_voice(self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to=None, metadata=None, **kwargs) -> SendResult:
|
||||
# Native outbound voice bubbles are not proven-working upstream; a file attachment at least plays (even .silk).
|
||||
return await self._send_file_result(chat_id, audio_path, caption or self.warning_text("[voice message as attachment]"), "send_voice", force_file_attachment=True)
|
||||
return await self._send_file_result(chat_id, audio_path, caption or self.warning_text(t("platform.weixin.voice_as_attachment")), "send_voice", force_file_attachment=True)
|
||||
|
||||
async def _download_remote_media(self, url: str) -> str:
|
||||
from tools.url_safety import is_safe_url
|
||||
|
||||
@@ -39,8 +39,9 @@ except ImportError:
|
||||
httpx = None # type: ignore[assignment]
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from agent.i18n import t
|
||||
from gateway.platforms.base import BasePlatformAdapter, ExecApprovalPrompt, SendResult, transcode_to_ogg_opus
|
||||
from gateway.platforms.base_exec_approval import EA_HEADER_TEXT
|
||||
from gateway.platforms.base_exec_approval import ea_header_text
|
||||
from gateway.platforms.helpers import bounded_put
|
||||
from gateway.platforms.event import MessageEvent, MessageType
|
||||
from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin, _get_wsecret
|
||||
@@ -464,11 +465,22 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
"description": self._truncate_button_label(choice_text, limit=72)}
|
||||
for idx, choice_text in enumerate(choices_list)
|
||||
]
|
||||
rows.append({"id": f"cl:{clarify_id}:other", "title": "✏️ Other", "description": "Type your own answer"})
|
||||
interactive = {"type": "list", "body": {"text": body_text}, "action": {"button": "Choose", "sections": [{"title": "Options", "rows": rows}]}}
|
||||
rows.append({
|
||||
"id": f"cl:{clarify_id}:other",
|
||||
"title": self._truncate_button_label(t("platform.whatsapp.clarify_other_title"), limit=24),
|
||||
"description": self._truncate_button_label(t("platform.whatsapp.clarify_other_description"), limit=72),
|
||||
})
|
||||
interactive = {
|
||||
"type": "list", "body": {"text": body_text},
|
||||
"action": {"button": self._truncate_button_label(t("platform.whatsapp.clarify_list_button")),
|
||||
"sections": [{"title": t("platform.whatsapp.clarify_list_section"), "rows": rows}]},
|
||||
}
|
||||
return await self._send_interactive(chat_id, interactive, metadata, self._clarify_state, clarify_id, session_key)
|
||||
|
||||
_EA_HEADER = f"⚠️ *{EA_HEADER_TEXT}*\n\n"
|
||||
@property
|
||||
def _EA_HEADER(self) -> str: # noqa: N802 — WhatsApp bold markup around the shared header
|
||||
return f"⚠️ *{ea_header_text()}*\n\n"
|
||||
|
||||
_EA_CODE_CLOSE = "\n```\n\n"
|
||||
_EA_CMD_BUDGET = 800 # body caps at 1024; leave room for the framing prose
|
||||
|
||||
@@ -478,7 +490,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
approval_id = uuid.uuid4().hex[:12]
|
||||
interactive = self._button_interactive(
|
||||
self._truncate_body(prompt.text),
|
||||
(f"appr:{approval_id}:approve", "✅ Approve"), (f"appr:{approval_id}:deny", "❌ Deny"))
|
||||
(f"appr:{approval_id}:approve", self._truncate_button_label(t("platform.whatsapp.approve_button"))),
|
||||
(f"appr:{approval_id}:deny", self._truncate_button_label(t("platform.whatsapp.deny_button"))))
|
||||
return await self._send_interactive(
|
||||
prompt.chat_id, interactive, prompt.metadata, self._exec_approval_state, approval_id, prompt.session_key)
|
||||
|
||||
@@ -487,8 +500,10 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
) -> SendResult:
|
||||
"""Approve Once / Always / Cancel buttons; ``confirm_id`` is caller-supplied."""
|
||||
interactive = self._button_interactive(
|
||||
self._truncate_body(f"*{title}*\n\n{message}"), (f"sc:once:{confirm_id}", "✅ Approve Once"),
|
||||
(f"sc:always:{confirm_id}", "🔒 Always"), (f"sc:cancel:{confirm_id}", "❌ Cancel"),
|
||||
self._truncate_body(f"*{title}*\n\n{message}"),
|
||||
(f"sc:once:{confirm_id}", self._truncate_button_label(t("platform.whatsapp.slash_confirm_once"))),
|
||||
(f"sc:always:{confirm_id}", self._truncate_button_label(t("platform.whatsapp.slash_confirm_always"))),
|
||||
(f"sc:cancel:{confirm_id}", self._truncate_button_label(t("platform.whatsapp.slash_confirm_cancel"))),
|
||||
)
|
||||
return await self._send_interactive(chat_id, interactive, metadata, self._slash_confirm_state, confirm_id, session_key)
|
||||
|
||||
@@ -852,7 +867,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
return False
|
||||
# Keep the mapping live for further taps on the same prompt.
|
||||
self._clarify_state[clarify_id] = session_key
|
||||
await self._reply_best_effort(to, "✏️ Type your answer:", "[whatsapp_cloud] clarify other-prompt failed")
|
||||
await self._reply_best_effort(to, t("platform.whatsapp.clarify_type_answer"), "[whatsapp_cloud] clarify other-prompt failed")
|
||||
return True
|
||||
try:
|
||||
idx = int(choice)
|
||||
@@ -882,10 +897,10 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
# A tap after the wait timed out (count == 0) must not claim approval:
|
||||
# the command was already denied fail-closed.
|
||||
if count:
|
||||
confirm_text = "✅ Approved." if choice == "approve" else "❌ Denied."
|
||||
confirm_text = t("platform.whatsapp.approved" if choice == "approve" else "platform.whatsapp.denied")
|
||||
else:
|
||||
logger.info("[whatsapp_cloud] approval resolver reported no waiter (session_key=%s) — likely already resolved", session_key)
|
||||
confirm_text = "⌛ Approval expired — command was not run (already timed out or resolved elsewhere)."
|
||||
confirm_text = t("platform.whatsapp.approval_expired")
|
||||
await self._reply_best_effort(to, confirm_text, "[whatsapp_cloud] approval confirm failed")
|
||||
return True
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ except ImportError:
|
||||
websockets = None # type: ignore[assignment]
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from agent.i18n import t
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter, SendResult,
|
||||
cache_document_from_bytes_async, cache_image_from_bytes_async, cache_video_from_bytes_async,
|
||||
@@ -95,8 +96,20 @@ NO_RECONNECT_CLOSE_CODES = {4012, 4013, 4014, 4018, 4019, 4021} # permanent err
|
||||
HEARTBEAT_TIMEOUT_THRESHOLD = 2 # consecutive missed pongs before reconnect
|
||||
REPLY_HEARTBEAT_INTERVAL_S = 2.0 # RUNNING cadence
|
||||
REPLY_HEARTBEAT_TIMEOUT_S = 30.0 # auto-FINISH after this much inactivity
|
||||
SLOW_RESPONSE_TIMEOUT_S = 120.0 # push SLOW_RESPONSE_MESSAGE when the agent is silent this long
|
||||
SLOW_RESPONSE_MESSAGE = "任务有点复杂,正在努力处理中,请耐心等待..."
|
||||
SLOW_RESPONSE_TIMEOUT_S = 120.0 # push slow_response_message() when the agent is silent this long
|
||||
|
||||
|
||||
def slow_response_message() -> str:
|
||||
"""Patience notice pushed after SLOW_RESPONSE_TIMEOUT_S of agent silence (localized; the
|
||||
Chinese wording Yuanbao users historically saw lives in locales/zh.yaml)."""
|
||||
return t("platform.yuanbao.slow_response_notice")
|
||||
|
||||
|
||||
# Cron wrapper markers, mirrored from cron/scheduler_delivery.py (``wrap_response``). The wrapper
|
||||
# is not keyed yet; when it is, import the same key here so ``strip_cron_wrapper`` keeps matching.
|
||||
CRON_WRAPPER_HEADER_PREFIX = "Cronjob Response: "
|
||||
CRON_WRAPPER_DIVIDER = "\n-------------\n\n"
|
||||
CRON_WRAPPER_FOOTER_PREFIX = '\n\nTo stop or manage this job, send me a new message (e.g. "stop reminder '
|
||||
|
||||
# Transcript anchors: [image|ybres:abc] [file:report.pdf|ybres:xyz] [voice|ybres:…]
|
||||
_YB_RES_REF_RE = re.compile(r"\[(image|voice|video|file(?::[^|\]]*)?)\|ybres:([A-Za-z0-9_\-]+)\]")
|
||||
@@ -1016,7 +1029,7 @@ class OwnerCommandMiddleware(InboundMiddleware):
|
||||
if matched_cmd and not is_owner:
|
||||
logger.info("[%s] Reject non-owner slash command: chat=%s from=%s cmd=%s", adapter.name, ctx.chat_id, ctx.from_account, matched_cmd)
|
||||
adapter._track_task(asyncio.create_task(
|
||||
adapter.send(ctx.chat_id, f"⚠️ {matched_cmd} is only available to the creator in private chat mode"),
|
||||
adapter.send(ctx.chat_id, t("platform.yuanbao.owner_command_denied", command=matched_cmd)),
|
||||
name=f"yuanbao-owner-cmd-denial-{matched_cmd}"))
|
||||
return # Stop pipeline
|
||||
if matched_cmd and is_owner and cmd_line:
|
||||
@@ -2347,7 +2360,7 @@ class SlowResponseNotifier:
|
||||
try:
|
||||
await asyncio.sleep(SLOW_RESPONSE_TIMEOUT_S)
|
||||
logger.info("[%s] Agent response exceeded %ds for %s, sending wait notice", self._adapter.name, int(SLOW_RESPONSE_TIMEOUT_S), chat_id)
|
||||
await self._sender.send_text_chunk(chat_id, SLOW_RESPONSE_MESSAGE)
|
||||
await self._sender.send_text_chunk(chat_id, slow_response_message())
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
@@ -2549,10 +2562,10 @@ class MessageSender:
|
||||
@staticmethod
|
||||
def strip_cron_wrapper(content: str) -> str:
|
||||
"""Strip the scheduler's cron header/footer wrapper; unchanged when the shape doesn't match."""
|
||||
if not content.startswith("Cronjob Response: "):
|
||||
if not content.startswith(CRON_WRAPPER_HEADER_PREFIX):
|
||||
return content
|
||||
divider = "\n-------------\n\n"
|
||||
footer_prefix = '\n\nTo stop or manage this job, send me a new message (e.g. "stop reminder '
|
||||
divider = CRON_WRAPPER_DIVIDER
|
||||
footer_prefix = CRON_WRAPPER_FOOTER_PREFIX
|
||||
divider_pos = content.find(divider)
|
||||
footer_pos = content.rfind(footer_prefix)
|
||||
if divider_pos < 0 or footer_pos < 0 or footer_pos <= divider_pos or "\n(job_id: " not in content[:divider_pos]:
|
||||
@@ -2780,7 +2793,7 @@ class YuanbaoAdapter(BasePlatformAdapter):
|
||||
if not self._access_policy.is_dm_allowed(user_id):
|
||||
return SendResult(success=False, error="DM access denied for this user")
|
||||
if len(text) > self.DM_MAX_CHARS:
|
||||
text = text[:self.DM_MAX_CHARS] + "\n...(truncated)"
|
||||
text = text[:self.DM_MAX_CHARS] + t("platform.shared.truncated_suffix")
|
||||
return await self.send(f"direct:{user_id}", text, group_code=group_code)
|
||||
|
||||
# Media sends delegate to MessageSender.send_media via the named handler strategy.
|
||||
|
||||
@@ -24,8 +24,8 @@ from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter, ExecApprovalPrompt, SendResult,
|
||||
)
|
||||
from gateway.platforms.base_exec_approval import EA_HEADER_TEXT
|
||||
from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome
|
||||
from agent.i18n import t
|
||||
from gateway.relay.descriptor import CapabilityDescriptor
|
||||
from gateway.relay.egress import (
|
||||
EGRESS_DECLINE_CODE,
|
||||
@@ -731,7 +731,7 @@ class RelayAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
merged_meta = self._task_card_metadata(reply_to, metadata)
|
||||
result = await self._card_frame(
|
||||
chat_id, "task_card", reply_to, merged_meta, chunks=[dict(t) for t in tasks]
|
||||
chat_id, "task_card", reply_to, merged_meta, chunks=[dict(task) for task in tasks]
|
||||
)
|
||||
if isinstance(result, SendResult):
|
||||
return result
|
||||
@@ -2030,8 +2030,6 @@ class RelayAdapter(BasePlatformAdapter):
|
||||
|
||||
_PROMPT_UNAVAILABLE = SendResult(success=False, error="relay prompt op unavailable")
|
||||
|
||||
_EA_HEADER = f"⚠️ **{EA_HEADER_TEXT}**\n\n"
|
||||
_EA_SMART_DENY_LINE = "\n\n**Smart DENY:** owner override applies to this one operation only."
|
||||
_EA_CMD_BUDGET = 1500
|
||||
|
||||
async def _send_exec_approval_prompt(self, prompt: ExecApprovalPrompt) -> SendResult:
|
||||
@@ -2058,9 +2056,9 @@ class RelayAdapter(BasePlatformAdapter):
|
||||
"""Three-button slash-command confirmation over the relay (resolves via
|
||||
tools.slash_confirm.resolve; success=False falls back to text-intercept)."""
|
||||
options = [
|
||||
{"id": "once", "label": "Approve Once", "style": "primary"},
|
||||
{"id": "always", "label": "Always Approve"},
|
||||
{"id": "cancel", "label": "Cancel", "style": "danger"},
|
||||
{"id": "once", "label": t("platform.relay.confirm_approve_once"), "style": "primary"},
|
||||
{"id": "always", "label": t("platform.relay.confirm_always_approve")},
|
||||
{"id": "cancel", "label": t("platform.relay.confirm_cancel"), "style": "danger"},
|
||||
]
|
||||
result = await self._mint_and_send_prompt(
|
||||
"slash_confirm", {"session_key": session_key, "confirm_id": confirm_id}, chat_id,
|
||||
@@ -2085,7 +2083,7 @@ class RelayAdapter(BasePlatformAdapter):
|
||||
back to base."""
|
||||
if choices and self.descriptor.supports_op("prompt"):
|
||||
options = [{"id": f"c{i}", "label": str(choice)[:75]} for i, choice in enumerate(choices)]
|
||||
options.append({"id": "other", "label": "✏️ Other (type your answer)"})
|
||||
options.append({"id": "other", "label": t("platform.relay.prompt_other")})
|
||||
result = await self._mint_and_send_prompt(
|
||||
"clarify",
|
||||
{
|
||||
@@ -2220,8 +2218,7 @@ class RelayAdapter(BasePlatformAdapter):
|
||||
return
|
||||
self._send_lifecycle_ack(
|
||||
chat_id,
|
||||
"⌛ That prompt is no longer waiting for an answer. "
|
||||
"Send your reply as a normal message.",
|
||||
t("platform.relay.prompt_expired"),
|
||||
self._prompt_reply_metadata(event),
|
||||
)
|
||||
|
||||
|
||||
+53
-81
@@ -31,6 +31,7 @@ from datetime import datetime
|
||||
from typing import Callable, Dict, Optional, Any, List, Tuple, cast
|
||||
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
from agent.i18n import t
|
||||
from agent.conversation_compression import (
|
||||
COMPACTION_DONE_STATUS, COMPACTION_HEARTBEAT_STATUS, COMPACTION_STATUS, COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE,
|
||||
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE, COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE,
|
||||
@@ -197,12 +198,10 @@ def _hygiene_compression_timeout_message(
|
||||
"""Describe the host timeout that actually ended hygiene compression. Chat users cannot edit
|
||||
model config, so the copy names /compress, /new and `hermes doctor`, never a config key or the
|
||||
raw second counts (those stay in the gateway log)."""
|
||||
lead = (
|
||||
"⚠️ Shortening the conversation history took too long, so I skipped it and kept "
|
||||
"everything as-is. Run /compress to try again or /new to start fresh.")
|
||||
lead = t("gateway.compress.hygiene_timeout")
|
||||
if total_exhausted:
|
||||
return lead
|
||||
return lead + " If this keeps happening, run `hermes doctor` on the host."
|
||||
return lead + t("gateway.compress.hygiene_timeout_doctor_hint")
|
||||
|
||||
|
||||
def _cached_agent_for_hygiene(gateway, session_key: str):
|
||||
@@ -600,20 +599,22 @@ def _format_exec_approval_fallback(
|
||||
the button card (``BasePlatformAdapter._format_exec_approval``), plus the typed ``/approve``
|
||||
steps a surface without buttons needs."""
|
||||
from gateway.platforms.base_exec_approval import (
|
||||
EA_HEADER_TEXT, EA_REASON_LABEL_TEXT, approval_timeout_seconds, format_approval_deadline_line)
|
||||
approval_timeout_seconds, ea_header_text, ea_reason_label_text, format_approval_deadline_line)
|
||||
cmd_preview = command[:200] + "..." if len(command) > 200 else command
|
||||
heading = ("⚠️ **Smart DENY — owner override for one operation:**" if smart_denied
|
||||
else f"⚠️ **{EA_HEADER_TEXT}**")
|
||||
heading = (t("gateway.exec_approval.smart_deny_heading") if smart_denied
|
||||
else f"⚠️ **{ea_header_text()}**")
|
||||
|
||||
choices = [f"Reply `{command_prefix}approve` to run it once"]
|
||||
choices = [t("gateway.exec_approval.text_choice_once", prefix=command_prefix)]
|
||||
if not smart_denied and allow_session:
|
||||
choices.append(f"`{command_prefix}approve session` to allow this pattern for the rest of this session")
|
||||
choices.append(t("gateway.exec_approval.text_choice_session", prefix=command_prefix))
|
||||
if allow_permanent:
|
||||
choices.append(f"`{command_prefix}approve always` to allow it permanently")
|
||||
choices.append(f"`{command_prefix}deny` to cancel")
|
||||
choices.append(t("gateway.exec_approval.text_choice_always", prefix=command_prefix))
|
||||
choices.append(t("gateway.exec_approval.text_choice_deny", prefix=command_prefix))
|
||||
return (
|
||||
f"{heading}\n```\n{cmd_preview}\n```\n{EA_REASON_LABEL_TEXT}: {description}\n\n"
|
||||
+ ", ".join(choices[:-1]) + f", or {choices[-1]}.\n"
|
||||
t("gateway.exec_approval.text_body", heading=heading, command=cmd_preview,
|
||||
reason_label=ea_reason_label_text(), reason=description)
|
||||
+ t("gateway.exec_approval.text_choice_joiner").join(choices[:-1])
|
||||
+ t("gateway.exec_approval.text_choice_last", choice=choices[-1])
|
||||
+ format_approval_deadline_line(approval_timeout_seconds()))
|
||||
|
||||
# Ordered: rate-limit beats auth beats policy beats connection; first match wins. Rate-limit goes
|
||||
@@ -627,27 +628,21 @@ def _format_exec_approval_fallback(
|
||||
# been answered by it), a REFUSED/unroutable connect is the endpoint-down case #86570 wrote the
|
||||
# wording for, and a cause-free SDK ``APIConnectionError: Connection error.`` supports neither
|
||||
# diagnosis, so the catch-all names the failure without asserting a cause.
|
||||
# ``(pattern, catalog key)`` — the reply text is resolved with ``t()`` at reply time so the active
|
||||
# ``display.language`` applies; ``gateway.errors.*`` in ``locales/en.yaml`` holds the wording.
|
||||
_PROVIDER_ERROR_REPLIES = (
|
||||
(_GATEWAY_RATE_LIMIT_RE, "⏱️ The AI model service is rate-limiting requests. Wait a moment, then use /retry."),
|
||||
(_GATEWAY_AUTH_ERROR_RE, "⚠️ Sign-in to the AI model service failed. Use /login to sign in again, "
|
||||
"or ask whoever runs this bot to run `hermes doctor` on the host."),
|
||||
(_GATEWAY_PROVIDER_POLICY_RE, "⚠️ The AI model service rejected this request. Try rephrasing your "
|
||||
"message, or use /model to switch models."),
|
||||
(_GATEWAY_CONNECTION_INTERRUPTED_RE, "⚠️ The connection to the AI model service was interrupted mid-request — "
|
||||
"usually transient. Use /retry to try again; if it keeps happening, run "
|
||||
"`hermes doctor` on the host."),
|
||||
(_GATEWAY_ENDPOINT_UNREACHABLE_RE, "⚠️ The AI model service isn't reachable right now — the configured model "
|
||||
"endpoint is not running or is unreachable. Wait a moment and use /retry; "
|
||||
"if it persists, run `hermes doctor` on the host."),
|
||||
(_GATEWAY_CONNECTION_ERROR_RE, "⚠️ Hermes could not reach the AI model service (no further detail from the "
|
||||
"SDK). Use /retry to try again; if it persists, run `hermes doctor` on the host."))
|
||||
(_GATEWAY_RATE_LIMIT_RE, "gateway.errors.rate_limited"),
|
||||
(_GATEWAY_AUTH_ERROR_RE, "gateway.errors.auth_failed"),
|
||||
(_GATEWAY_PROVIDER_POLICY_RE, "gateway.errors.bad_request"),
|
||||
(_GATEWAY_CONNECTION_INTERRUPTED_RE, "gateway.errors.connection_interrupted"),
|
||||
(_GATEWAY_ENDPOINT_UNREACHABLE_RE, "gateway.errors.unreachable"),
|
||||
(_GATEWAY_CONNECTION_ERROR_RE, "gateway.errors.connection_unknown"))
|
||||
|
||||
|
||||
# Shared by the failed-turn normalizer and ``run_turn._hmwa_agent_error_reply``; canonical
|
||||
# commands (/compress, /new) — the /compact and /reset aliases are absent from /help.
|
||||
_CONTEXT_OVERFLOW_REPLY = (
|
||||
"⚠️ This conversation has grown too long for me to read all at once. "
|
||||
"Use /compress to shorten the history, or /new to start a fresh conversation.")
|
||||
def _context_overflow_reply() -> str:
|
||||
"""Shared by the failed-turn normalizer and ``run_turn._hmwa_agent_error_reply``; canonical
|
||||
commands (/compress, /new) — the /compact and /reset aliases are absent from /help."""
|
||||
return t("gateway.errors.context_overflow")
|
||||
|
||||
|
||||
def _rate_limit_reply(text: str) -> str:
|
||||
@@ -657,19 +652,16 @@ def _rate_limit_reply(text: str) -> str:
|
||||
from agent.retry_utils import format_reset_window, reset_delay_from_message
|
||||
seconds = reset_delay_from_message(text) or 0
|
||||
if seconds < 120:
|
||||
return "⏱️ The AI model service is rate-limiting requests. Wait a moment, then use /retry."
|
||||
return (f"⏱️ The AI model service's usage limit is reached; it resets in {format_reset_window(seconds)}. "
|
||||
"Use /retry after that, or /model to switch models.")
|
||||
return t("gateway.errors.rate_limited")
|
||||
return t("gateway.errors.usage_limit_resets", window=format_reset_window(seconds))
|
||||
|
||||
|
||||
def _gateway_provider_error_reply(text: str) -> str:
|
||||
"""Map raw provider/API errors to a short user-safe Telegram reply."""
|
||||
for pattern, reply in _PROVIDER_ERROR_REPLIES:
|
||||
for pattern, reply_key in _PROVIDER_ERROR_REPLIES:
|
||||
if pattern.search(text):
|
||||
return _rate_limit_reply(text) if pattern is _GATEWAY_RATE_LIMIT_RE else reply
|
||||
return (
|
||||
"⚠️ The AI model service kept failing. Use /retry to try again, or /model to switch "
|
||||
"models. Details are in the gateway log (`hermes logs`).")
|
||||
return _rate_limit_reply(text) if pattern is _GATEWAY_RATE_LIMIT_RE else t(reply_key)
|
||||
return t("gateway.errors.provider_kept_failing")
|
||||
|
||||
|
||||
# Provider/API failure envelope preambles (not ordinary assistant prose), anchored at line start.
|
||||
@@ -2851,9 +2843,7 @@ def _check_unavailable_skill(command_name: str) -> str | None:
|
||||
continue
|
||||
# disabled is keyed by the declared frontmatter name (what skills.disabled stores).
|
||||
if slug == normalized and declared_name in disabled:
|
||||
return (
|
||||
f"The **{command_name}** skill is installed but disabled.\n"
|
||||
f"Enable it with: `hermes skills config`")
|
||||
return t("gateway.skills.disabled", name=command_name)
|
||||
|
||||
# Check optional skills (shipped with repo but not installed)
|
||||
from hermes_constants import get_optional_skills_dir
|
||||
@@ -2869,9 +2859,7 @@ def _check_unavailable_skill(command_name: str) -> str | None:
|
||||
# Install path: official/<category>/<name>
|
||||
rel = skill_md.parent.relative_to(optional_dir)
|
||||
install_path = f"official/{'/'.join(rel.parts)}"
|
||||
return (
|
||||
f"The **{command_name}** skill is available but not installed.\n"
|
||||
f"Install it with: `hermes skills install {install_path}`")
|
||||
return t("gateway.skills.not_installed", name=command_name, install_name=install_path)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -3021,8 +3009,7 @@ def _format_concise_process_notification(
|
||||
session_id: str, command: str, exit_code, output: str, duration_seconds=None) -> str:
|
||||
"""One-line completion message for ``concise`` display mode; failure appends a short output tail."""
|
||||
ok = exit_code in {0, None}
|
||||
icon = "✅" if ok else "❌"
|
||||
parts = [f"{icon} Background task {'finished' if ok else 'failed'}"]
|
||||
parts = [t("gateway.background.task_finished") if ok else t("gateway.background.task_failed")]
|
||||
short_cmd = _shorten_command_for_display(command)
|
||||
if short_cmd:
|
||||
parts.append(f"— `{short_cmd}`")
|
||||
@@ -3036,7 +3023,7 @@ def _format_concise_process_notification(
|
||||
else:
|
||||
details.append(f"{secs}s")
|
||||
if not ok:
|
||||
details.append(f"exit {exit_code}")
|
||||
details.append(t("gateway.background.exit_code", code=exit_code))
|
||||
if details:
|
||||
parts.append(f"({', '.join(details)})")
|
||||
text = " ".join(parts)
|
||||
@@ -3046,9 +3033,9 @@ def _format_concise_process_notification(
|
||||
if len(tail) > 500:
|
||||
tail = tail[-500:]
|
||||
if tail:
|
||||
text += f". Last output:\n```\n{tail}\n```"
|
||||
text += t("gateway.background.last_output", tail=tail)
|
||||
if not ok:
|
||||
text += "\nAsk me to rerun it or show the full log."
|
||||
text += t("gateway.background.rerun_hint")
|
||||
return text
|
||||
|
||||
|
||||
@@ -3132,28 +3119,19 @@ def _normalize_empty_agent_response(
|
||||
return response
|
||||
if agent_result.get("failed"):
|
||||
# ``error`` can be an EXPLICIT None (bypasses dict.get default) -> would render "failed: None".
|
||||
error_detail = agent_result.get("error") or "unknown error"
|
||||
error_detail = agent_result.get("error") or t("gateway.shared.unknown_error")
|
||||
error_str = str(error_detail).lower()
|
||||
# Persistence failures: suggesting /reset would destroy context without fixing storage.
|
||||
failure_reason = str(agent_result.get("failure_reason") or "")
|
||||
if failure_reason.startswith("session_persistence_failed") or "session storage" in error_str:
|
||||
if failure_reason.endswith(":disk") or "disk" in error_str:
|
||||
return (
|
||||
"⚠️ Session storage was temporarily unavailable, so this "
|
||||
"turn was stopped to protect your conversation history. "
|
||||
"Please check available disk space, then send your message again.")
|
||||
return (
|
||||
"⚠️ Session storage was temporarily unavailable, so this "
|
||||
"turn was stopped to protect your conversation history. "
|
||||
"Your message should already be saved — please send it again in a moment.")
|
||||
return t("gateway.errors.session_storage_unavailable_disk")
|
||||
return t("gateway.errors.session_storage_unavailable")
|
||||
if is_overflow:
|
||||
return _CONTEXT_OVERFLOW_REPLY
|
||||
return _context_overflow_reply()
|
||||
# Raw exception text (class names, JSON bodies, URLs) stays in the gateway log.
|
||||
logger.warning("Agent turn failed; reply sanitized for chat. Detail: %s", str(error_detail)[:500])
|
||||
return (
|
||||
"⚠️ Something went wrong and I couldn't finish this reply. Use /retry to try again, "
|
||||
"or /new to start a fresh conversation. Technical details are in the gateway log "
|
||||
"(`hermes logs`).")
|
||||
return t("gateway.errors.generic_failed")
|
||||
|
||||
api_calls = int(agent_result.get("api_calls", 0) or 0)
|
||||
if agent_result.get("interrupted"):
|
||||
@@ -3170,9 +3148,7 @@ def _normalize_empty_agent_response(
|
||||
# (response=0 chars) and the user sees no reply at all. Surface a short retry hint so the message
|
||||
# isn't lost in silence. (#31884)
|
||||
if api_calls == 0:
|
||||
return (
|
||||
"⚠️ Your message was interrupted before processing started "
|
||||
"(likely by a recent /stop). Please send it again.")
|
||||
return t("gateway.errors.interrupted_before_start")
|
||||
return response
|
||||
if api_calls > 0:
|
||||
# Hidden-reasoning-only retry exhaustion: the loop's sentinel text ("Codex response remained
|
||||
@@ -3184,7 +3160,7 @@ def _normalize_empty_agent_response(
|
||||
if agent_result.get("partial"):
|
||||
# ``error`` mirrors the loop's own final text (curated, e.g. "Response truncated due to
|
||||
# output length limit") and is kept; a raw provider envelope goes to the log instead.
|
||||
err = str(agent_result.get("error") or "processing incomplete")
|
||||
err = str(agent_result.get("error") or t("gateway.errors.processing_incomplete"))
|
||||
# A loop site code (truncated, context_overflow, ...) already wrote the full
|
||||
# what-happened / what-to-do sentence: deliver it verbatim. Wrapping it would cut it
|
||||
# mid-sentence at 200 chars and append a second, conflicting set of instructions.
|
||||
@@ -3197,18 +3173,12 @@ def _normalize_empty_agent_response(
|
||||
reason = ""
|
||||
else:
|
||||
reason = f": {err[:200]}"
|
||||
return (
|
||||
f"⚠️ I had to stop before finishing{reason}. Use /retry to try again, or /compress "
|
||||
"if this conversation has grown very long.")
|
||||
return (
|
||||
"⚠️ Processing completed but no response was generated. "
|
||||
"This may be a transient error — try sending your message again.")
|
||||
return t("gateway.errors.stopped_before_finishing", reason=reason)
|
||||
return t("gateway.errors.no_response")
|
||||
|
||||
# api_calls == 0, not failed/interrupted: agent never ran (post-/stop race); don't drop silently.
|
||||
if api_calls == 0 and not agent_result.get("partial"):
|
||||
return (
|
||||
"⚠️ Your message wasn't processed (the previous turn was still "
|
||||
"being cleaned up). Please send it again.")
|
||||
return t("gateway.errors.previous_turn_cleanup")
|
||||
|
||||
return response
|
||||
|
||||
@@ -4017,7 +3987,8 @@ class GatewayRunner(
|
||||
return "restart" if self._restart_requested else "shutdown"
|
||||
|
||||
def _status_action_gerund(self) -> str:
|
||||
return "restarting" if self._restart_requested else "shutting down"
|
||||
"""Localized "restarting" / "shutting down" for the busy/drain notices shown in chat."""
|
||||
return t("gateway.busy.action_restarting") if self._restart_requested else t("gateway.busy.action_shutting_down")
|
||||
|
||||
def _update_runtime_status(self, gateway_state: Optional[str] = None, exit_reason: Optional[str] = None) -> None:
|
||||
# ``active_work`` names each unit only while draining — that is when an observer (``hermes
|
||||
@@ -4084,10 +4055,11 @@ class GatewayRunner(
|
||||
_MAX_INTERRUPT_DEPTH = 3 # Cap recursive interrupt handling
|
||||
# Command-specific mid-run reject texts (busy_policy == "reject" with a busy_handler naming an
|
||||
# entry here); all other rejected commands get the generic text in _dispatch_busy_slash_command.
|
||||
# Values are catalog keys; ``run_busy._dispatch_busy_slash_command`` resolves them with ``t()``.
|
||||
_BUSY_REJECT_TEXT: Dict[str, str] = {
|
||||
"model": "Agent is running — wait or /stop first, then switch models.",
|
||||
"codex-runtime": "Agent is running — wait or /stop first, then change runtime.",
|
||||
"moa": "Agent is running — wait or /stop first, then run /moa."}
|
||||
"model": "gateway.busy.reject_model",
|
||||
"codex-runtime": "gateway.busy.reject_codex_runtime",
|
||||
"moa": "gateway.busy.reject_moa"}
|
||||
|
||||
def _active_profile_name(self) -> str:
|
||||
"""Return the profile name this gateway represents."""
|
||||
|
||||
+88
-89
@@ -14,7 +14,7 @@ import contextlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from agent.i18n import t
|
||||
from agent.i18n import DEFAULT_LANGUAGE, t
|
||||
from agent.session_activity import format_iteration_progress
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import EphemeralReply
|
||||
@@ -27,6 +27,23 @@ if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle)
|
||||
from gateway.run import GatewayRunner # noqa: F401
|
||||
from gateway.run_turn_runner import TurnRunner # noqa: F401
|
||||
|
||||
|
||||
def approval_input_words(input_key: str) -> Tuple[str, ...]:
|
||||
"""Typed-reply synonyms for ``approval.inputs.<input_key>`` (comma-lists): the English words
|
||||
ALWAYS match, plus the active language's list, so a Polish pack can add "tak" without losing
|
||||
"yes". Lower-cased, de-duplicated, order preserved (English first)."""
|
||||
seen: Dict[str, None] = {}
|
||||
key = f"approval.inputs.{input_key}"
|
||||
for raw in (t(key, lang=DEFAULT_LANGUAGE), t(key)):
|
||||
if raw == key: # catalog miss: t() echoes the key
|
||||
continue
|
||||
for word in raw.split(","):
|
||||
word = word.strip().lower()
|
||||
if word:
|
||||
seen.setdefault(word, None)
|
||||
return tuple(seen)
|
||||
|
||||
|
||||
# Log-record parity with the origin module.
|
||||
logger = logging.getLogger("gateway.run")
|
||||
|
||||
@@ -491,18 +508,25 @@ class GatewayBusySessionMixin:
|
||||
return
|
||||
if self._queue_during_drain_enabled(effective_mode):
|
||||
self._queue_or_replace_pending_event(session_key, event)
|
||||
message = f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back."
|
||||
message = t("gateway.busy.drain_queued", action=self._status_action_gerund())
|
||||
else:
|
||||
message = f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now."
|
||||
message = t("gateway.busy.drain_rejected", action=self._status_action_gerund())
|
||||
await self._send_busy_reply(event, adapter, message)
|
||||
|
||||
# Bare-word approval replies → (verb, args) for the synthesized slash command.
|
||||
_PLAINTEXT_APPROVAL_WORDS: Dict[str, tuple] = {
|
||||
**{w: ("approve", "") for w in ("approve", "yes", "ok", "okay", "confirm", "y", "👍")},
|
||||
**{w: ("deny", "") for w in ("deny", "no", "reject", "cancel", "n", "👎")},
|
||||
**{w: ("approve", "always") for w in ("always", "approve always", "always approve")},
|
||||
**{w: ("approve", "session") for w in ("session", "approve session", "session approve")},
|
||||
}
|
||||
# Bare-word approval replies → (verb, args) for the synthesized slash command. English words
|
||||
# (and the thumbs) always match; ``approval.inputs.*`` adds the active language's synonyms.
|
||||
_PLAINTEXT_APPROVAL_EXTRA_WORDS: Dict[str, tuple] = {"👍": ("approve", ""), "👎": ("deny", "")}
|
||||
_PLAINTEXT_APPROVAL_INPUT_KEYS: Tuple[Tuple[str, tuple], ...] = (
|
||||
("approve", ("approve", "")), ("deny", ("deny", "")),
|
||||
("always", ("approve", "always")), ("session", ("approve", "session")))
|
||||
|
||||
def _plaintext_approval_words(self) -> Dict[str, tuple]:
|
||||
"""Word → (verb, args) for the active language: the English list ∪ ``t("approval.inputs.<k>")``."""
|
||||
words: Dict[str, tuple] = dict(self._PLAINTEXT_APPROVAL_EXTRA_WORDS)
|
||||
for input_key, verb_args in self._PLAINTEXT_APPROVAL_INPUT_KEYS:
|
||||
for word in approval_input_words(input_key):
|
||||
words.setdefault(word, verb_args)
|
||||
return words
|
||||
|
||||
async def _route_plaintext_approval_while_busy(self, event: MessageEvent, session_key: str) -> bool:
|
||||
"""Route a bare "yes"/"no" to the approval handlers while a dangerous-command approval blocks.
|
||||
@@ -528,7 +552,7 @@ class GatewayBusySessionMixin:
|
||||
# the draining-case send above).
|
||||
if event.allow_gateway_control and has_blocking_approval(session_key):
|
||||
_raw_text = (event.text or "").strip().lower()
|
||||
_match = self._PLAINTEXT_APPROVAL_WORDS.get(_raw_text)
|
||||
_match = self._plaintext_approval_words().get(_raw_text)
|
||||
if _match is not None:
|
||||
_verb, _normalized_args = _match
|
||||
_approval_handler = (
|
||||
@@ -698,9 +722,9 @@ class GatewayBusySessionMixin:
|
||||
logger.debug("Busy steer ack suppressed for session %s", session_key)
|
||||
return steer_ack_enabled
|
||||
|
||||
_BUSY_DEMOTED_TAIL = (
|
||||
" — your message is queued for when it finishes (use /stop to cancel everything)."
|
||||
)
|
||||
@property
|
||||
def _BUSY_DEMOTED_TAIL(self) -> str: # noqa: N802 — long-standing mixin attr name
|
||||
return t("gateway.busy.demoted_tail")
|
||||
|
||||
def _compose_busy_ack_message(
|
||||
self, event: MessageEvent, now: float, _busy_state, running_agent: Any, *,
|
||||
@@ -727,7 +751,7 @@ class GatewayBusySessionMixin:
|
||||
if _busy_state and _busy_state.turn.started_ts:
|
||||
elapsed_min = int((now - _busy_state.turn.started_ts) / 60)
|
||||
if elapsed_min > 0:
|
||||
status_parts.append(f"{elapsed_min} min elapsed")
|
||||
status_parts.append(t("gateway.busy.elapsed_min", minutes=elapsed_min))
|
||||
if summary.get("max_iterations", 0):
|
||||
status_parts.append(
|
||||
format_iteration_progress(
|
||||
@@ -735,27 +759,28 @@ class GatewayBusySessionMixin:
|
||||
)
|
||||
)
|
||||
if summary.get("current_tool"):
|
||||
status_parts.append(f"running: {summary.get('current_tool')}")
|
||||
status_parts.append(t("gateway.busy.running_tool", tool=summary.get("current_tool")))
|
||||
except Exception:
|
||||
pass
|
||||
status_detail = f" ({', '.join(status_parts)})" if status_parts else ""
|
||||
status_detail = (t("gateway.busy.ack_detail_wrap", detail=t("gateway.busy.ack_detail_joiner").join(status_parts))
|
||||
if status_parts else "")
|
||||
if is_steer_mode and self._agent_has_active_subagents(running_agent):
|
||||
head = "⏩ Steered into current run and its active subagent(s)"
|
||||
tail = ". Your message arrives after their next tool call."
|
||||
head = t("gateway.busy.steered_subagents_head")
|
||||
tail = t("gateway.busy.steered_subagents_tail")
|
||||
elif is_steer_mode:
|
||||
head, tail = "⏩ Steered into current run", ". Your message arrives after the next tool call."
|
||||
head, tail = t("gateway.busy.steered_head"), t("gateway.busy.steered_tail")
|
||||
elif is_redirect_mode:
|
||||
head, tail = "↪ Redirected current run", ". I'll adjust using your correction."
|
||||
head, tail = t("gateway.busy.redirected_head"), t("gateway.busy.redirected_tail")
|
||||
elif is_queue_mode and demoted_for_subagents:
|
||||
# Explain the demotion: the follow-up didn't kill the subagent; /stop is the escape hatch.
|
||||
head, tail = "⏳ Subagent working", self._BUSY_DEMOTED_TAIL
|
||||
head, tail = t("gateway.busy.subagent_working_head"), self._BUSY_DEMOTED_TAIL
|
||||
elif is_queue_mode and demoted_for_compression:
|
||||
head, tail = "⏳ Compressing context", self._BUSY_DEMOTED_TAIL
|
||||
head, tail = t("gateway.busy.compressing_head"), self._BUSY_DEMOTED_TAIL
|
||||
elif is_queue_mode:
|
||||
head, tail = "⏳ Queued for the next turn", ". I'll respond once the current task finishes."
|
||||
head, tail = t("gateway.busy.queued_head"), t("gateway.busy.queued_tail")
|
||||
else:
|
||||
head, tail = "⚡ Interrupting current task", ". I'll respond to your message shortly."
|
||||
message = f"{head}{status_detail}{tail}"
|
||||
head, tail = t("gateway.busy.interrupting_head"), t("gateway.busy.interrupting_tail")
|
||||
message = t("gateway.busy.ack", head=head, detail=status_detail, tail=tail)
|
||||
|
||||
# One-time onboarding hint about the queue/interrupt knob (flag persisted to config.yaml).
|
||||
try:
|
||||
@@ -949,9 +974,9 @@ class GatewayBusySessionMixin:
|
||||
special = self._BUSY_SPECIAL_HANDLERS.get(handler_key)
|
||||
if special is not None:
|
||||
return await getattr(self, special)(event, quick_key, source)
|
||||
reject_text = self._BUSY_REJECT_TEXT.get(handler_key)
|
||||
if reject_text is not None:
|
||||
return reject_text
|
||||
reject_key = self._BUSY_REJECT_TEXT.get(handler_key)
|
||||
if reject_key is not None:
|
||||
return t(reject_key)
|
||||
if policy in ("dispatch", "interrupt_then_dispatch"):
|
||||
plain = self._gateway_plain_command_handlers().get(name)
|
||||
if plain is not None:
|
||||
@@ -962,10 +987,7 @@ class GatewayBusySessionMixin:
|
||||
"falling back to busy-reject", policy, name,
|
||||
)
|
||||
|
||||
return (
|
||||
f"⏳ Agent is running — `/{name}` can't run "
|
||||
f"mid-turn. Wait for the current response or `/stop` first."
|
||||
)
|
||||
return t("gateway.busy.slash_rejected", command=name)
|
||||
|
||||
async def _handle_pause_command(self, event: MessageEvent):
|
||||
"""`/pause [reason]` engages the global emergency stop; `/pause off` lifts it (the estop gate
|
||||
@@ -974,18 +996,15 @@ class GatewayBusySessionMixin:
|
||||
args = (event.get_command_args() or "").strip()
|
||||
if args.lower() in {"off", "resume", "stop", "disengage"}:
|
||||
if estop.disengage():
|
||||
return "▶️ Resumed — new work is accepted again."
|
||||
return "Hermes wasn't paused."
|
||||
return t("gateway.pause.resumed")
|
||||
return t("gateway.pause.not_paused")
|
||||
state = estop.get_state()
|
||||
if state is not None and not args:
|
||||
suffix = f" (reason: {state.get('reason')})" if state.get("reason") else ""
|
||||
return f"⏸️ Hermes is already paused{suffix}. Use `/pause off` to resume."
|
||||
suffix = t("gateway.pause.reason_suffix", reason=state.get("reason")) if state.get("reason") else ""
|
||||
return t("gateway.pause.already_paused", suffix=suffix)
|
||||
estop.engage(reason=args or None)
|
||||
suffix = f" (reason: {args})" if args else ""
|
||||
return (
|
||||
f"⏸️ Paused{suffix}. New cron/kanban/gateway work is on hold; "
|
||||
"in-flight work finishes normally. Use `/pause off` to resume."
|
||||
)
|
||||
suffix = t("gateway.pause.reason_suffix", reason=args) if args else ""
|
||||
return t("gateway.pause.paused", suffix=suffix)
|
||||
|
||||
async def _busy_start_command(self, event: MessageEvent, quick_key: str, source):
|
||||
# Telegram's /start is a platform ping (bot launch/deep-link), not a user command.
|
||||
@@ -1022,7 +1041,7 @@ class GatewayBusySessionMixin:
|
||||
# A /queue carrying media or reply context is valid with no prompt text (image caption).
|
||||
has_media = bool(getattr(event, "media_urls", None))
|
||||
if not queued_text and not has_media:
|
||||
return "Usage: /queue <prompt>"
|
||||
return t("gateway.queue.usage")
|
||||
adapter = self._delivery_adapter_for(source)
|
||||
if adapter:
|
||||
self._enqueue_fifo(quick_key, MessageEvent(
|
||||
@@ -1039,7 +1058,7 @@ class GatewayBusySessionMixin:
|
||||
internal=event.internal, timestamp=event.timestamp,
|
||||
), adapter)
|
||||
depth = self._queue_depth(quick_key, adapter=adapter)
|
||||
return "Queued for the next turn." + (f" ({depth} queued)" if depth > 1 else "")
|
||||
return t("gateway.queue.queued") + (t("gateway.queue.queued_depth", depth=depth) if depth > 1 else "")
|
||||
|
||||
async def _busy_steer_command(self, event: MessageEvent, quick_key: str, source):
|
||||
# /steer lands BETWEEN tool-call iterations of the same run (appended to the last tool
|
||||
@@ -1047,7 +1066,7 @@ class GatewayBusySessionMixin:
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL
|
||||
steer_text = event.get_command_args().strip()
|
||||
if not steer_text:
|
||||
return "Usage: /steer <prompt>"
|
||||
return t("gateway.steer.usage")
|
||||
_steer_state = self._peek_session_state(quick_key)
|
||||
running_agent = _steer_state.turn.agent if _steer_state else None
|
||||
|
||||
@@ -1063,20 +1082,21 @@ class GatewayBusySessionMixin:
|
||||
return reply
|
||||
|
||||
if running_agent is _AGENT_PENDING_SENTINEL:
|
||||
return _queue_fallback("Agent still starting — /steer queued for the next turn.")
|
||||
return _queue_fallback(t("gateway.steer.queued_starting"))
|
||||
if not running_agent or not hasattr(running_agent, "steer"):
|
||||
return _queue_fallback("No active agent — /steer queued for the next turn.")
|
||||
return _queue_fallback(t("gateway.steer.queued_no_agent"))
|
||||
try:
|
||||
accepted = self._steer_running_agent(running_agent, self._steer_text_with_origin(steer_text, event))
|
||||
except Exception as exc:
|
||||
logger.warning("Steer failed for session %s: %s", quick_key, exc)
|
||||
return f"⚠️ Steer failed: {exc}"
|
||||
return t("gateway.steer.failed", error=exc)
|
||||
if not accepted:
|
||||
return "Steer rejected (empty payload)."
|
||||
return t("gateway.steer.rejected_empty")
|
||||
self._fold_into_running_turn(running_agent, quick_key, event)
|
||||
preview = steer_text[:60] + ("..." if len(steer_text) > 60 else "")
|
||||
target = "run and its active subagent(s)" if self._agent_has_active_subagents(running_agent) else "run"
|
||||
return f"⏩ Steer queued into current {target} — arrives after the next tool call: '{preview}'"
|
||||
target = (t("gateway.steer.target_subagents") if self._agent_has_active_subagents(running_agent)
|
||||
else t("gateway.steer.target_run"))
|
||||
return t("gateway.steer.queued", target=target, preview=preview)
|
||||
|
||||
async def _busy_goal_command(self, event: MessageEvent, quick_key: str, source):
|
||||
# Control verbs are safe mid-run (state only); setting new goal text is rejected so we don't
|
||||
@@ -1085,14 +1105,14 @@ class GatewayBusySessionMixin:
|
||||
|
||||
if is_goal_control(event.get_command_args() or ""):
|
||||
return await self._handle_goal_command(event)
|
||||
return "Agent is running — use /goal status / pause / clear / wait mid-run, or /stop before setting a new goal."
|
||||
return t("gateway.busy.reject_goal")
|
||||
|
||||
async def _busy_loop_command(self, event: MessageEvent, quick_key: str, source):
|
||||
# Mirrors /goal: control verbs are safe mid-run; a new loop is rejected.
|
||||
_loop_arg = (event.get_command_args() or "").strip().lower()
|
||||
if not _loop_arg or _loop_arg in {"status", "pause", "resume", "stop", "clear", "cancel", "help", "--help", "-h"}:
|
||||
return await self._handle_loop_command(event)
|
||||
return "Agent is running — use /loop status / pause / stop mid-run, or /stop before setting a new loop."
|
||||
return t("gateway.busy.reject_loop")
|
||||
|
||||
def _check_slash_access(self, source: SessionSource, canonical_cmd: str) -> Optional[str]:
|
||||
"""Denial message if ``source`` cannot run ``canonical_cmd``, else None (both dispatch paths
|
||||
@@ -1110,15 +1130,12 @@ class GatewayBusySessionMixin:
|
||||
allowed_preview = sorted(policy.user_allowed_commands)
|
||||
if allowed_preview:
|
||||
suffix = (
|
||||
"You can run: " + ", ".join(f"/{c}" for c in allowed_preview[:12])
|
||||
+ ("…" if len(allowed_preview) > 12 else "") + ". Use /whoami for the full list."
|
||||
t("gateway.unauthorized.admin_only_can_run") + ", ".join(f"/{c}" for c in allowed_preview[:12])
|
||||
+ ("…" if len(allowed_preview) > 12 else "") + t("gateway.unauthorized.admin_only_whoami_hint")
|
||||
)
|
||||
else:
|
||||
suffix = (
|
||||
"No slash commands are enabled for non-admins on this platform. Ask an admin to "
|
||||
"add you to allow_admin_from or to set user_allowed_commands."
|
||||
)
|
||||
return f"⛔ /{canonical_cmd} is admin-only here. {suffix}"
|
||||
suffix = t("gateway.unauthorized.admin_only_none")
|
||||
return t("gateway.unauthorized.admin_only", command=canonical_cmd, suffix=suffix)
|
||||
|
||||
def _same_chat_runs(self, source: SessionSource, own_key: str) -> List[Tuple[str, str, str]]:
|
||||
"""``(key, chat_type, tail)`` for every OTHER running turn in the caller's chat (``tail`` is
|
||||
@@ -1252,7 +1269,7 @@ class GatewayBusySessionMixin:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("suggestions command failed: %s", e)
|
||||
return f"Suggestions command failed: {e}"
|
||||
return t("gateway.suggestions.failed", error=e)
|
||||
|
||||
async def _handle_blueprint_command(self, event: MessageEvent):
|
||||
"""/blueprint via the shared handler (origin = event source so jobs deliver back here)."""
|
||||
@@ -1266,7 +1283,7 @@ class GatewayBusySessionMixin:
|
||||
except Exception as e:
|
||||
logger.debug("blueprint command failed: %s", e)
|
||||
from hermes_cli.blueprint_cmd import BlueprintCommandResult
|
||||
return BlueprintCommandResult(f"Cron blueprint command failed: {e}")
|
||||
return BlueprintCommandResult(t("gateway.blueprint.failed", error=e))
|
||||
|
||||
async def _maybe_confirm_destructive_slash(
|
||||
self, *, event: MessageEvent, command: str, title: str, detail: str, execute
|
||||
@@ -1296,41 +1313,23 @@ class GatewayBusySessionMixin:
|
||||
)
|
||||
|
||||
_p = self._typed_command_prefix_for(event.source.platform)
|
||||
prompt_message = (
|
||||
f"⚠️ **Confirm /{command}**\n\n"
|
||||
f"{detail}\n\n"
|
||||
"Choose:\n"
|
||||
"• **Approve Once** — proceed this time only\n"
|
||||
"• **Always Approve** — proceed and silence this prompt permanently\n"
|
||||
"• **Cancel** — keep current conversation\n\n"
|
||||
f"_Text fallback: reply `{_p}approve`, `{_p}always`, or `{_p}cancel`._"
|
||||
)
|
||||
prompt_message = t("gateway.confirm.destructive_prompt", command=command, detail=detail, prefix=_p)
|
||||
return await self._request_slash_confirm(
|
||||
event=event, command=command, title=title, message=prompt_message, handler=_on_confirm
|
||||
)
|
||||
|
||||
_DESTRUCTIVE_OPTOUT_NOTE = {
|
||||
True: (
|
||||
"\n\nℹ️ Future /clear, /new, /reset, and /undo will run "
|
||||
"without confirmation. Re-enable via "
|
||||
"`approvals.destructive_slash_confirm: true` in config.yaml."
|
||||
),
|
||||
# The user did approve this run, so the action still goes ahead, but the preference did
|
||||
# not stick and the prompt will be back next time. Say so rather than promising an
|
||||
# opt-out that was never written.
|
||||
False: (
|
||||
"\n\n⚠️ Could not save that preference (config.yaml is not "
|
||||
"writable), so /clear, /new, /reset, and /undo will ask "
|
||||
"again next time. To silence it permanently, set "
|
||||
"`approvals.destructive_slash_confirm: false` in config.yaml."
|
||||
),
|
||||
}
|
||||
@staticmethod
|
||||
def _destructive_optout_note(persisted: bool) -> str:
|
||||
"""Note appended after an "always" confirm. ``persisted=False``: the user did approve this
|
||||
run, so the action still goes ahead, but the preference did not stick and the prompt will be
|
||||
back next time — say so rather than promising an opt-out that was never written."""
|
||||
return t("gateway.confirm.destructive_optout" if persisted else "gateway.confirm.destructive_optout_save_failed")
|
||||
|
||||
@staticmethod
|
||||
async def _run_confirmed_destructive_slash(choice: str, command: str, execute, session_key: str):
|
||||
"""Confirm-callback body: ``cancel`` → message; ``always`` persists the opt-out, then runs."""
|
||||
if choice == "cancel":
|
||||
return f"🟡 /{command} cancelled. Conversation unchanged."
|
||||
return t("gateway.confirm.cancelled", command=command)
|
||||
persisted = False
|
||||
if choice == "always":
|
||||
try:
|
||||
@@ -1350,7 +1349,7 @@ class GatewayBusySessionMixin:
|
||||
result = await execute()
|
||||
# Only plain-string results get the note: it would mangle an EphemeralReply.
|
||||
if choice == "always" and isinstance(result, str):
|
||||
return result + GatewayBusySessionMixin._DESTRUCTIVE_OPTOUT_NOTE[persisted]
|
||||
return result + GatewayBusySessionMixin._destructive_optout_note(persisted)
|
||||
return result
|
||||
|
||||
async def _request_slash_confirm(
|
||||
|
||||
+50
-65
@@ -20,12 +20,14 @@ import time
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
from agent.i18n import t
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import EphemeralReply
|
||||
from gateway.platforms.event import MessageEvent, MessageType
|
||||
from gateway.run_busy import approval_input_words
|
||||
from gateway.run_common import _UNSET
|
||||
from gateway.run_inbound_unauthorized import (
|
||||
PAIRING_RATE_LIMITED_REPLY, UnauthorizedOwnerNotifier, pairing_code_reply, pairing_profile_arg,
|
||||
UnauthorizedOwnerNotifier, pairing_code_reply, pairing_profile_arg, pairing_rate_limited_reply,
|
||||
unauthorized_owner_hint,
|
||||
)
|
||||
from gateway.session import (
|
||||
@@ -158,7 +160,7 @@ class GatewayInboundMixin:
|
||||
if code:
|
||||
reply = pairing_code_reply(platform_name, code, pairing_profile_arg(pairing_store))
|
||||
else:
|
||||
reply = PAIRING_RATE_LIMITED_REPLY
|
||||
reply = pairing_rate_limited_reply()
|
||||
if adapter:
|
||||
await adapter.send(source.chat_id, reply)
|
||||
if not code:
|
||||
@@ -383,10 +385,10 @@ class GatewayInboundMixin:
|
||||
err = self._hm_write_update_response(response_text)
|
||||
if err is not None:
|
||||
logger.warning("Failed to write update response: %s", err)
|
||||
return f"✗ Failed to send response to update process: {err}"
|
||||
return t("gateway.update.reply_failed", error=err)
|
||||
_up_state.persistent.update_prompt_pending = False
|
||||
label = response_text if len(response_text) <= 20 else response_text[:20] + "…"
|
||||
return f"✓ Sent `{label}` to the update process."
|
||||
return t("gateway.update.reply_sent", label=label)
|
||||
# Recognized slash command during a pending update prompt: write a blank response so the
|
||||
# detached update's ``_gateway_prompt`` returns the prompt's default (typically a safe
|
||||
# "n" / skip) and exits instead of blocking on stdin until the watcher timeout.
|
||||
@@ -452,7 +454,7 @@ class GatewayInboundMixin:
|
||||
try:
|
||||
await _clarify_adapter.retire_clarify_card(
|
||||
_pending_clarify.clarify_id,
|
||||
f"✅ answered: {_pending_clarify.response or _raw_clarify_reply}")
|
||||
t("gateway.clarify.answered", response=_pending_clarify.response or _raw_clarify_reply))
|
||||
except Exception:
|
||||
logger.debug("Failed to retire clarify card after typed answer", exc_info=True)
|
||||
return ""
|
||||
@@ -475,7 +477,7 @@ class GatewayInboundMixin:
|
||||
try:
|
||||
await _clarify_adapter.retire_clarify_card(
|
||||
_pending_clarify.clarify_id,
|
||||
"↩️ Clarification cancelled — your message will be handled as a follow-up.")
|
||||
t("gateway.clarify.cancelled"))
|
||||
except Exception:
|
||||
logger.debug("Failed to retire clarify card after prose cancellation", exc_info=True)
|
||||
return None
|
||||
@@ -487,11 +489,15 @@ class GatewayInboundMixin:
|
||||
"always": "always", "remember": "always",
|
||||
"cancel": "cancel", "no": "cancel", "deny": "cancel", "nevermind": "cancel",
|
||||
}
|
||||
_SLASH_CONFIRM_TEXT_CHOICES = {
|
||||
"approve": "once", "approve once": "once", "once": "once",
|
||||
"always": "always", "always approve": "always",
|
||||
"cancel": "cancel", "nevermind": "cancel", "no": "cancel",
|
||||
}
|
||||
# Free-text spellings: ``approval.inputs.confirm_*`` comma-lists (English ∪ active language).
|
||||
_SLASH_CONFIRM_TEXT_INPUT_KEYS = (("confirm_once", "once"), ("confirm_always", "always"), ("confirm_cancel", "cancel"))
|
||||
|
||||
def _slash_confirm_text_choices(self) -> Dict[str, str]:
|
||||
choices: Dict[str, str] = {}
|
||||
for input_key, choice in self._SLASH_CONFIRM_TEXT_INPUT_KEYS:
|
||||
for word in approval_input_words(input_key):
|
||||
choices.setdefault(word, choice)
|
||||
return choices
|
||||
|
||||
async def _hm_slash_confirm_reply(self, event: "MessageEvent", _quick_key: str) -> Optional[str]:
|
||||
"""Resolve a reply (/approve, /always, /cancel + aliases) to a pending slash-confirm prompt;
|
||||
@@ -511,7 +517,7 @@ class GatewayInboundMixin:
|
||||
_norm_reply = (event.text or "").strip().lstrip("!/").lower()
|
||||
_confirm_choice = (
|
||||
self._SLASH_CONFIRM_CMD_CHOICES.get(event.get_command())
|
||||
or self._SLASH_CONFIRM_TEXT_CHOICES.get(_norm_reply)
|
||||
or self._slash_confirm_text_choices().get(_norm_reply)
|
||||
)
|
||||
if _confirm_choice is not None:
|
||||
_resolved = await _slash_confirm_mod.resolve(
|
||||
@@ -735,7 +741,7 @@ class GatewayInboundMixin:
|
||||
if event.get_command() == "stop": # force-clean the sentinel so the session is unlocked
|
||||
self._release_running_agent_state(_quick_key)
|
||||
logger.info("HARD STOP (pending) for session %s — sentinel cleared", _quick_key)
|
||||
return EphemeralReply("⚡ Force-stopped. The agent was still starting — session unlocked.")
|
||||
return EphemeralReply(t("gateway.stop.force_stopped_pending"))
|
||||
self._hm_merge_pending_for_source(source, _quick_key, event, merge_text=True) # picked up after start
|
||||
return None
|
||||
if self._draining:
|
||||
@@ -743,9 +749,9 @@ class GatewayInboundMixin:
|
||||
if queue_during_drain:
|
||||
self._queue_or_replace_pending_event(_quick_key, event)
|
||||
return (
|
||||
f"⏳ Gateway {self._status_action_gerund()} — queued for the next turn after it comes back."
|
||||
t("gateway.busy.drain_queued", action=self._status_action_gerund())
|
||||
if queue_during_drain
|
||||
else f"⏳ Gateway is {self._status_action_gerund()} and is not accepting another turn right now."
|
||||
else t("gateway.busy.drain_rejected", action=self._status_action_gerund())
|
||||
)
|
||||
if effective_busy_input_mode == "queue":
|
||||
logger.debug("PRIORITY queue follow-up for session %s", _quick_key)
|
||||
@@ -823,7 +829,7 @@ class GatewayInboundMixin:
|
||||
message = hook_result.get("message")
|
||||
message = message if isinstance(message, str) and message else None
|
||||
if decision == "deny":
|
||||
return True, message or f"Command `/{command}` was blocked by a hook.", None
|
||||
return True, message or t("gateway.hooks.command_blocked", command=command), None
|
||||
if decision == "handled":
|
||||
return True, message, None
|
||||
if decision == "rewrite":
|
||||
@@ -890,7 +896,7 @@ class GatewayInboundMixin:
|
||||
if await asyncio.to_thread(self._is_telegram_topic_root_lobby, source):
|
||||
return True, self._telegram_topic_root_new_message()
|
||||
return await self._hm_confirm_destructive(
|
||||
event, "new", "This starts a fresh session and discards the current conversation history.",
|
||||
event, "new", t("gateway.confirm.detail_new"),
|
||||
self._handle_reset_command,
|
||||
)
|
||||
|
||||
@@ -909,7 +915,7 @@ class GatewayInboundMixin:
|
||||
try:
|
||||
event.text = build()
|
||||
except Exception:
|
||||
return True, f"Could not start /{name} — please try again."
|
||||
return True, t("gateway.prompt_command.start_failed", command=name)
|
||||
return False, None
|
||||
|
||||
# /learn and /plan: ack, rewrite the turn to a builder prompt, fall through to the agent.
|
||||
@@ -917,14 +923,15 @@ class GatewayInboundMixin:
|
||||
from agent.learn_prompt import build_learn_prompt
|
||||
|
||||
req = event.get_command_args().strip()
|
||||
_ack = f"Learning a skill from {'what you described' if req else 'this conversation'}…"
|
||||
_ack = t("gateway.learn.ack_described") if req else t("gateway.learn.ack_conversation")
|
||||
return await self._hm_rewrite_turn_to_prompt(event, source, "learn", _ack, lambda: build_learn_prompt(req))
|
||||
|
||||
async def _hm_cmd_plan(self, event, source, _quick_key):
|
||||
from agent.plan_prompt import build_plan_prompt
|
||||
|
||||
task = event.get_command_args().strip()
|
||||
_ack = f"Planning: {task[:80]}{'…' if len(task) > 80 else ''}" if task else "Planning from this conversation's context…"
|
||||
_ack = (t("gateway.plan.ack_task", task=task[:80] + ("…" if len(task) > 80 else ""))
|
||||
if task else t("gateway.plan.ack_conversation"))
|
||||
return await self._hm_rewrite_turn_to_prompt(event, source, "plan", _ack, lambda: build_plan_prompt(task))
|
||||
|
||||
async def _hm_cmd_init(self, event, source, _quick_key):
|
||||
@@ -934,11 +941,11 @@ class GatewayInboundMixin:
|
||||
try:
|
||||
_init_prompt = build_init_prompt_for_cwd(extra=event.get_command_args().strip())
|
||||
except Exception:
|
||||
return True, "Could not start /init — please try again."
|
||||
return True, t("gateway.init.start_failed")
|
||||
_ack = (
|
||||
"Updating AGENTS.md from a project scan…"
|
||||
t("gateway.init.ack_update")
|
||||
if "UPDATE the existing AGENTS.md" in _init_prompt
|
||||
else "Generating AGENTS.md from a project scan…"
|
||||
else t("gateway.init.ack_generate")
|
||||
)
|
||||
await self._send_command_ack(source, _ack, "init")
|
||||
event.text = _init_prompt
|
||||
@@ -967,21 +974,19 @@ class GatewayInboundMixin:
|
||||
with suppress(ValueError, IndexError):
|
||||
_undo_n = max(1, int(_undo_raw.split()[0]))
|
||||
_undo_detail = (
|
||||
"This removes the last user/assistant exchange from history."
|
||||
t("gateway.confirm.detail_undo_one")
|
||||
if _undo_n == 1
|
||||
else f"This removes the last {_undo_n} user turns from history."
|
||||
else t("gateway.confirm.detail_undo_many", count=_undo_n)
|
||||
)
|
||||
return await self._hm_confirm_destructive(event, "undo", _undo_detail, self._handle_undo_command)
|
||||
|
||||
# /queue and /steer on the idle path: no agent is running, so strip the prefix and send the
|
||||
# payload as a regular user turn; an empty payload surfaces the usage hint.
|
||||
async def _hm_cmd_queue(self, event, source, _quick_key):
|
||||
return self._hm_send_payload_as_turn(event, "Usage: /queue <prompt>")
|
||||
return self._hm_send_payload_as_turn(event, t("gateway.queue.usage"))
|
||||
|
||||
async def _hm_cmd_steer(self, event, source, _quick_key):
|
||||
return self._hm_send_payload_as_turn(
|
||||
event, "Usage: /steer <prompt> (no agent is running; sending as a normal message)"
|
||||
)
|
||||
return self._hm_send_payload_as_turn(event, t("gateway.steer.usage_idle"))
|
||||
|
||||
@staticmethod
|
||||
def _hm_send_payload_as_turn(event, usage: str) -> Tuple[bool, Optional[str]]:
|
||||
@@ -1018,7 +1023,7 @@ class GatewayInboundMixin:
|
||||
}
|
||||
self._evict_cached_agent(_quick_key)
|
||||
except Exception:
|
||||
return True, "Failed to prepare MoA turn."
|
||||
return True, t("gateway.moa.prepare_failed")
|
||||
return False, None
|
||||
|
||||
# Idle-path built-ins with bespoke flow (confirmations, prompt rewrites, one-shot MoA), each
|
||||
@@ -1058,11 +1063,11 @@ class GatewayInboundMixin:
|
||||
if output:
|
||||
from agent.redact import redact_sensitive_text
|
||||
output = redact_sensitive_text(output)
|
||||
return output or "Command returned no output."
|
||||
return output or t("gateway.quick_command.no_output")
|
||||
except asyncio.TimeoutError:
|
||||
return "Quick command timed out (30s)."
|
||||
return t("gateway.quick_command.timed_out")
|
||||
except Exception as e:
|
||||
return f"Quick command error: {e}"
|
||||
return t("gateway.quick_command.error", error=e)
|
||||
|
||||
async def _hm_dispatch_quick_and_plugin_commands(
|
||||
self, event: "MessageEvent", source: SessionSource, command: Optional[str]
|
||||
@@ -1070,7 +1075,7 @@ class GatewayInboundMixin:
|
||||
"""Drain gate, user-defined quick commands (exec/alias) and plugin slash commands →
|
||||
``(handled, result, command)``; an alias quick command rewrites ``command``."""
|
||||
if self._draining:
|
||||
return True, f"⏳ Gateway is {self._status_action_gerund()} and is not accepting new work right now.", command
|
||||
return True, t("gateway.busy.drain_rejected_new_work", action=self._status_action_gerund()), command
|
||||
|
||||
# User-defined quick commands (bypass agent loop, no LLM call)
|
||||
qcmd = self._hm_quick_commands().get(command) if command else None
|
||||
@@ -1087,13 +1092,13 @@ class GatewayInboundMixin:
|
||||
if qtype == "exec":
|
||||
exec_cmd = qcmd.get("command", "")
|
||||
if not exec_cmd:
|
||||
return True, f"Quick command '/{command}' has no command defined.", command
|
||||
return True, t("gateway.quick_command.no_command", command=command), command
|
||||
return True, await self._hm_run_exec_quick_command(command, exec_cmd), command
|
||||
if qtype != "alias":
|
||||
return True, f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias').", command
|
||||
return True, t("gateway.quick_command.unsupported_type", command=command), command
|
||||
new_command = self._hm_expand_alias_quick_command(event, qcmd)
|
||||
if new_command is None:
|
||||
return True, f"Quick command '/{command}' has no target defined.", command
|
||||
return True, t("gateway.quick_command.no_target", command=command), command
|
||||
command = new_command # Fall through to normal command dispatch below
|
||||
|
||||
# Plugin-registered slash commands. Underscores normalize to hyphens so Telegram's
|
||||
@@ -1173,12 +1178,7 @@ class GatewayInboundMixin:
|
||||
"Unrecognized slash command /%s from %s — replying with unknown-command notice",
|
||||
command, source.platform.value if source.platform else "?",
|
||||
)
|
||||
return (
|
||||
f"Unknown command `/{command}`. "
|
||||
f"Type /commands to see what's available, "
|
||||
f"or resend without the leading slash to send "
|
||||
f"as a regular message."
|
||||
)
|
||||
return t("gateway.unknown_command", command=command)
|
||||
|
||||
def _hm_skill_slash_rewrite(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str, command: Optional[str]
|
||||
@@ -1217,27 +1217,20 @@ class GatewayInboundMixin:
|
||||
from agent.skill_utils import get_disabled_skill_names as _get_plat_disabled
|
||||
_plat_disabled = _get_plat_disabled(platform=_plat)
|
||||
if _skill_name and _skill_name in _plat_disabled:
|
||||
return (
|
||||
f"The **{_skill_name}** skill is disabled for {_plat}.\n"
|
||||
f"Enable it with: `hermes skills config`"
|
||||
)
|
||||
return t("gateway.skills.disabled_for_platform", name=_skill_name, platform=_plat)
|
||||
_disabled_extra = [
|
||||
skill_cmds.get(k, {}).get("name", "")
|
||||
for k in extra_keys
|
||||
if skill_cmds.get(k, {}).get("name", "") in _plat_disabled
|
||||
]
|
||||
if _disabled_extra:
|
||||
return (
|
||||
f"The **{', '.join(_disabled_extra)}** skill(s) in this "
|
||||
f"stacked invocation are disabled for {_plat}.\n"
|
||||
f"Enable them with: `hermes skills config`"
|
||||
)
|
||||
return t("gateway.skills.stacked_disabled", names=", ".join(_disabled_extra), platform=_plat)
|
||||
if extra_keys and _build_stacked is not None:
|
||||
stacked_result = _build_stacked(
|
||||
[cmd_key, *extra_keys], stacked_instruction, task_id=_quick_key,
|
||||
)
|
||||
if not stacked_result:
|
||||
return f"Failed to load stacked skills for /{command}."
|
||||
return t("gateway.skills.stacked_load_failed", command=command)
|
||||
event.text, _loaded, _missing = stacked_result
|
||||
else:
|
||||
msg = build_skill_invocation_message(cmd_key, user_instruction, task_id=_quick_key)
|
||||
@@ -1367,11 +1360,7 @@ class GatewayInboundMixin:
|
||||
# only fall to zero. Reversible.
|
||||
if self._external_drain_active:
|
||||
logger.info("Refusing new turn for session %s — external drain active.", _quick_key)
|
||||
return (
|
||||
"⏳ This agent is draining for a maintenance action and isn't "
|
||||
"accepting new turns right now. It'll be back in a moment — "
|
||||
"please resend shortly."
|
||||
)
|
||||
return t("gateway.busy.draining_maintenance")
|
||||
|
||||
# Claim this session before any await: many awaits sit between here and _run_agent
|
||||
# registering the real AIAgent; without this sentinel a second message during any of them
|
||||
@@ -1406,11 +1395,7 @@ class GatewayInboundMixin:
|
||||
"the user must resend",
|
||||
_quick_key, exc.session_id,
|
||||
)
|
||||
return (
|
||||
"⏳ Another turn is still running on this session. To "
|
||||
"protect the transcript, this message was not processed. "
|
||||
"Wait for the active turn to finish, then resend it."
|
||||
)
|
||||
return t("gateway.busy.another_turn_running")
|
||||
try:
|
||||
await self._run_post_turn_hooks(
|
||||
agent_result=_agent_result, source=source, is_internal=is_internal, event=event,
|
||||
@@ -1543,7 +1528,7 @@ class GatewayInboundMixin:
|
||||
"""Send each transcript back as ``🎙️ "…"`` (best-effort; failures are logged, never raised)."""
|
||||
for tx in transcripts:
|
||||
try:
|
||||
await adapter.send(source.chat_id, f'🎙️ "{tx}"', metadata=metadata)
|
||||
await adapter.send(source.chat_id, t("gateway.voice.transcript_echo_short", text=tx), metadata=metadata)
|
||||
except Exception as echo_exc:
|
||||
logger.debug("%s echo failed (non-fatal): %s", log_context, echo_exc)
|
||||
|
||||
@@ -1731,7 +1716,7 @@ class GatewayInboundMixin:
|
||||
if _adapter:
|
||||
await _adapter.send(
|
||||
source.chat_id,
|
||||
"\n".join(_ctx_result.warnings) or "Context injection refused.",
|
||||
"\n".join(_ctx_result.warnings) or t("gateway.notify.context_injection_refused"),
|
||||
)
|
||||
return None
|
||||
if _ctx_result.expanded:
|
||||
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
|
||||
from agent.i18n import t
|
||||
from gateway.pairing import CODE_TTL_SECONDS, _allowlist_env_for_platform
|
||||
|
||||
# Display names come from the stranger. Bound them and keep the mention/markdown surface small in
|
||||
@@ -40,19 +41,13 @@ def pairing_code_reply(platform_name: str, code: str, profile_arg: str = "") ->
|
||||
"""The DM a first-time sender receives: what happened, how long the code lives, what to do
|
||||
whether they are the owner or a guest, and that they must message again after approval."""
|
||||
hours = max(1, CODE_TTL_SECONDS // 3600)
|
||||
validity = f"{hours} hour" if hours == 1 else f"{hours} hours"
|
||||
validity = t("gateway.pairing.validity_hour" if hours == 1 else "gateway.pairing.validity_hours", hours=hours)
|
||||
approve_cmd = f"hermes {profile_arg}pairing approve {platform_name} {code}"
|
||||
return (
|
||||
"Hi! I don't recognize you yet, so I can't reply until the person running this bot "
|
||||
"approves you.\n\n"
|
||||
f"Your pairing code: `{code}` (valid for {validity})\n\n"
|
||||
f"If you run this bot, open a terminal and run: `{approve_cmd}`. "
|
||||
"Otherwise send that command to the bot owner. After approval, send your message again."
|
||||
)
|
||||
return t("gateway.pairing.code_reply", code=code, validity=validity, approve_cmd=approve_cmd)
|
||||
|
||||
|
||||
PAIRING_RATE_LIMITED_REPLY = (
|
||||
"Too many pairing requests right now. Wait a few minutes, then send your message again.")
|
||||
def pairing_rate_limited_reply() -> str:
|
||||
return t("gateway.pairing.rate_limited")
|
||||
|
||||
|
||||
def unauthorized_owner_hint(
|
||||
@@ -71,15 +66,10 @@ def unauthorized_owner_hint(
|
||||
who = f"{safe_name} ({user_id})" if safe_name else str(user_id)
|
||||
env_var = _allowlist_env_for_platform(platform_name)
|
||||
allowlist = (
|
||||
f"add the ID to {env_var} in {hermes_home}/.env and restart the gateway"
|
||||
if env_var else "add the ID to this platform's allowed-users list and restart the gateway"
|
||||
)
|
||||
return (
|
||||
f"Dropped a message from unrecognized {platform_name} user {who}. If that is you or someone "
|
||||
f"you trust, {allowlist}; or set `unauthorized_dm_behavior: pair` for {platform_name} in "
|
||||
f"{hermes_home}/config.yaml so unknown senders receive a pairing code you can approve with "
|
||||
f"`hermes pairing approve {platform_name} <code>`."
|
||||
t("gateway.unauthorized.owner_hint_env", env_var=env_var, home=hermes_home)
|
||||
if env_var else t("gateway.unauthorized.owner_hint_generic")
|
||||
)
|
||||
return t("gateway.unauthorized.owner_notice", platform=platform_name, who=who, allowlist=allowlist, home=hermes_home)
|
||||
|
||||
|
||||
class UnauthorizedOwnerNotifier:
|
||||
|
||||
@@ -18,6 +18,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, cast
|
||||
|
||||
from agent.i18n import t
|
||||
from gateway.config import Platform, _BUILTIN_PLATFORM_VALUES
|
||||
from gateway.platforms.base import BasePlatformAdapter, _mark_notify_metadata
|
||||
from gateway.platforms.event import MessageEvent, MessageType
|
||||
@@ -29,9 +30,8 @@ logger = logging.getLogger("gateway.run")
|
||||
|
||||
# A failed /update leaves the previous version running; the full pip/git log stays on the host
|
||||
# (`hermes update` re-runs it in the terminal) and only a short tail is quoted in chat.
|
||||
_UPDATE_FAILED_NOTICE = (
|
||||
"❌ Hermes update failed; the previous version is still running. Run `hermes update` on the "
|
||||
"host to see the full error, or try /update again later.")
|
||||
def _update_failed_notice() -> str:
|
||||
return t("gateway.update.failed_notice")
|
||||
|
||||
# An update's completion notice waits for its target platform adapter to (re)connect before it
|
||||
# can be delivered. Nothing bounds that wait, so a marker naming a platform that is not
|
||||
@@ -643,12 +643,9 @@ class GatewayNotificationsMixin:
|
||||
)
|
||||
sent_buttons = True
|
||||
if not sent_buttons:
|
||||
default_hint = f" (default: {default})" if default else ""
|
||||
default_hint = t("gateway.update.prompt_default", default=default) if default else ""
|
||||
_p = getattr(adapter, "typed_command_prefix", "/")
|
||||
await target.send(
|
||||
f"☤ **Update needs your input:**\n\n{prompt_text}{default_hint}\n\n"
|
||||
f"Reply `{_p}approve` (yes) or `{_p}deny` (no), or type your answer directly."
|
||||
)
|
||||
await target.send(t("gateway.update.prompt", prompt=prompt_text, default_hint=default_hint, prefix=_p))
|
||||
# Keep the prompt marker on disk until answered so a restarted watcher can re-forward it.
|
||||
self._session_state(target.session_key).persistent.update_prompt_pending = True
|
||||
logger.info("Forwarded update prompt to %s: %s", target.session_key, prompt_text[:80])
|
||||
@@ -700,7 +697,7 @@ class GatewayNotificationsMixin:
|
||||
with _log_suppressed(logging.WARNING, "Update final notification failed: %s"):
|
||||
exit_code = self._update_exit_code(paths)
|
||||
await target.send(
|
||||
"✅ Hermes update finished." if exit_code == 0 else _UPDATE_FAILED_NOTICE
|
||||
t("gateway.update.finished") if exit_code == 0 else _update_failed_notice()
|
||||
)
|
||||
logger.info("Update finished (exit=%s), notified %s", exit_code, session_key)
|
||||
self._clear_update_markers(paths, session_key)
|
||||
@@ -727,7 +724,7 @@ class GatewayNotificationsMixin:
|
||||
paths.exit_code.write_text("124", encoding="utf-8")
|
||||
await _flush_buffer()
|
||||
with suppress(Exception):
|
||||
await target.send("❌ Hermes update timed out after 30 minutes.")
|
||||
await target.send(t("gateway.update.timed_out"))
|
||||
self._clear_update_markers(paths, session_key)
|
||||
|
||||
async def _send_update_notification(self) -> bool:
|
||||
@@ -789,13 +786,13 @@ class GatewayNotificationsMixin:
|
||||
from tools.ansi_strip import strip_ansi
|
||||
output = strip_ansi(output).strip()
|
||||
if exit_code == 0:
|
||||
msg = "✅ Hermes update finished successfully."
|
||||
msg = t("gateway.update.finished_success")
|
||||
if output:
|
||||
msg = f"{msg}\n\n```\n{_update_output_tail(output, 3500)}\n```"
|
||||
else:
|
||||
msg = _UPDATE_FAILED_NOTICE
|
||||
msg = _update_failed_notice()
|
||||
if output:
|
||||
msg = f"{msg}\n\nLast lines:\n```\n{_update_output_tail(output, 800)}\n```"
|
||||
msg = t("gateway.update.last_lines", msg=msg, tail=_update_output_tail(output, 800))
|
||||
await adapter.send(chat_id, msg, metadata=_non_conversational_metadata(metadata, platform=platform))
|
||||
logger.info("Sent post-update notification to %s:%s (exit=%s)", platform_str, chat_id, exit_code)
|
||||
except Exception as e:
|
||||
@@ -841,7 +838,7 @@ class GatewayNotificationsMixin:
|
||||
if data.get(field):
|
||||
metadata[field] = str(data[field])
|
||||
result = await transport.send(
|
||||
platform, str(chat_id), "♻ Gateway restarted successfully. Your session continues.",
|
||||
platform, str(chat_id), t("gateway.startup.restarted"),
|
||||
metadata=_non_conversational_metadata(metadata, platform=platform),
|
||||
)
|
||||
# adapter.send() catches provider errors (e.g. "Chat not found") and returns
|
||||
@@ -942,7 +939,7 @@ class GatewayNotificationsMixin:
|
||||
except Exception as exc:
|
||||
logger.debug("Free tier startup line skipped: %s", exc)
|
||||
return None
|
||||
return "Inference: Nous free tier (nous/welcome). Sign in for more: /login"
|
||||
return t("gateway.startup.free_tier_line")
|
||||
|
||||
_planned_restart_notice_lock: Optional[asyncio.Lock] = None
|
||||
|
||||
@@ -998,7 +995,7 @@ class GatewayNotificationsMixin:
|
||||
"""
|
||||
delivered: set[tuple[str, str, Optional[str]]] = set()
|
||||
skipped = skip_targets or set()
|
||||
message = "♻️ Gateway online — Hermes is back and ready."
|
||||
message = t("gateway.startup.online")
|
||||
free_tier_line = self._free_tier_startup_line()
|
||||
if free_tier_line:
|
||||
message = f"{message}\n{free_tier_line}"
|
||||
@@ -1062,30 +1059,11 @@ class GatewayNotificationsMixin:
|
||||
if cause == "corrupt":
|
||||
db_path = _default_db_path()
|
||||
backups_dir = get_default_hermes_root() / "backups"
|
||||
message = (
|
||||
"⚠️ Session database corruption detected. Messages may not be "
|
||||
"persisted. Recovery options:\n"
|
||||
f"1. Run `hermes {profile_arg}doctor --fix`\n"
|
||||
"2. Stop the gateway, then recover with:\n"
|
||||
f" hermes {profile_arg}sessions recover --source {db_path} "
|
||||
"--inspect-only\n"
|
||||
f" (if it reports recoverable) hermes {profile_arg}sessions recover "
|
||||
f"--source {db_path} --output recovered-state.db\n"
|
||||
" — recovery snapshots the damaged file first; do NOT run "
|
||||
"`sqlite3 ... \".recover\"` against the live state.db, a "
|
||||
"vulnerable sqlite3 CLI can corrupt it further\n"
|
||||
f"3. Restore from a backup in {backups_dir}/\n"
|
||||
f"Run `hermes {profile_arg}doctor` for sanitized diagnostics."
|
||||
)
|
||||
message = t("gateway.startup.db_corrupt", profile_arg=profile_arg, db_path=db_path, backups_dir=backups_dir)
|
||||
elif cause == "fts_index":
|
||||
# Index-scoped corruption: the message tables are not damaged, so the recover /
|
||||
# restore advice above would be destructive on a healthy file (#97794).
|
||||
message = (
|
||||
"⚠️ Session database reported a corruption error confined to the search index "
|
||||
"(FTS5); the message tables are not damaged. Messages may not be persisted until "
|
||||
f"it is repaired: run `hermes {profile_arg}doctor --fix`, then restart the gateway. Do not run "
|
||||
f"recovery tools or restore a backup unless `hermes {profile_arg}doctor` confirms damage."
|
||||
)
|
||||
message = t("gateway.startup.db_fts_corrupt", profile_arg=profile_arg)
|
||||
else:
|
||||
from hermes_state_user_copy import describe_storage_failure
|
||||
failure = describe_storage_failure(error)
|
||||
@@ -1095,11 +1073,8 @@ class GatewayNotificationsMixin:
|
||||
# opened its store at startup and stays broken until it is restarted.
|
||||
action = failure.action
|
||||
if failure.cause not in _SELF_CLEARING_STORAGE_CAUSES:
|
||||
action = f"{action} Then `hermes {profile_arg}gateway restart`."
|
||||
message = (
|
||||
"⚠️ Session database unavailable — messages may not be saved and /resume will be "
|
||||
f"empty. Cause: {failure.gloss}. {action}"
|
||||
)
|
||||
action = t("gateway.startup.db_then_restart", action=action, profile_arg=profile_arg)
|
||||
message = t("gateway.startup.db_unavailable", cause=failure.gloss, action=action)
|
||||
logger.warning("Broadcasting state.db failure warning to home channels: %s", error)
|
||||
from gateway.warning_notifications import present_notification
|
||||
for platform, _platform_cfg, home, transport in self._home_channel_transports():
|
||||
@@ -2043,14 +2018,14 @@ class GatewayNotificationsMixin:
|
||||
return _format_concise_process_notification(session_id, command, session.exit_code, new_output,
|
||||
duration_seconds=_dur)
|
||||
header = _format_concise_process_notification(session_id, command, session.exit_code, "", duration_seconds=_dur)
|
||||
return f"{header}\n\nFinal output:\n```\n{new_output.strip()}\n```" if new_output.strip() else header
|
||||
return t("gateway.background.final_output", header=header, output=new_output.strip()) if new_output.strip() else header
|
||||
|
||||
def _format_process_running_message(self, session) -> str:
|
||||
from gateway.run import _redact_gateway_user_facing_secrets, _shorten_command_for_display
|
||||
new_output = self._redacted_output_tail(session, 500)
|
||||
short_cmd = _shorten_command_for_display(_redact_gateway_user_facing_secrets(getattr(session, "command", "") or ""))
|
||||
header = "⏳ Background task still running" + (f" — `{short_cmd}`" if short_cmd else "")
|
||||
return f"{header}\n\nRecent output:\n```\n{new_output.strip()}\n```" if new_output.strip() else header
|
||||
header = t("gateway.background.still_running") + (f" — `{short_cmd}`" if short_cmd else "")
|
||||
return t("gateway.background.recent_output", header=header, output=new_output.strip()) if new_output.strip() else header
|
||||
|
||||
def arm_process_watcher(self, watcher: dict) -> bool:
|
||||
"""Start ``_run_process_watcher`` for a watcher registered mid-turn, from the agent's
|
||||
|
||||
+4
-15
@@ -21,6 +21,7 @@ from contextvars import Context
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from agent.i18n import t
|
||||
from gateway.config import Platform
|
||||
from gateway.restart import (
|
||||
DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT, GATEWAY_SERVICE_RESTART_EXIT_CODE,
|
||||
@@ -925,7 +926,7 @@ class GatewayShutdownMixin:
|
||||
except Exception as e:
|
||||
logger.debug("Cron interrupt notification unavailable: %s", e)
|
||||
return 0
|
||||
action = "restarting" if self._restart_requested else "shutting down"
|
||||
action = t("gateway.shutdown.action_restarting" if self._restart_requested else "gateway.shutdown.action_shutting_down")
|
||||
notified: set = set()
|
||||
for job_id in job_ids:
|
||||
try:
|
||||
@@ -940,11 +941,7 @@ class GatewayShutdownMixin:
|
||||
logger.debug("Cron interrupt targets unresolved for %s: %s", job_id, e)
|
||||
continue
|
||||
job_name = job.get("name") or job_id
|
||||
msg = (
|
||||
f"⚠️ Scheduled job '{job_name}' was cut short because Hermes is {action}; "
|
||||
"no result this run. It will run again on schedule, or run it now with "
|
||||
f"`hermes cron run {job_name}` once Hermes is back."
|
||||
)
|
||||
msg = t("gateway.shutdown.cron_interrupted", job=job_name, action=action)
|
||||
for target in targets or ():
|
||||
try:
|
||||
platform = Platform(str(target.get("platform", "")).lower())
|
||||
@@ -1030,15 +1027,7 @@ class GatewayShutdownMixin:
|
||||
Called at the start of stop() while adapters are connected; send failures never block shutdown.
|
||||
"""
|
||||
restart_source = self._restart_command_source if self._restart_requested else None
|
||||
msg = (
|
||||
"⚠️ Hermes is shutting down — your current task will be interrupted. "
|
||||
"When it is back online, send any message and I'll try to pick up where we left off."
|
||||
)
|
||||
if self._restart_requested:
|
||||
msg = (
|
||||
"⚠️ Hermes is restarting — your current task will be interrupted. "
|
||||
"Send any message after the restart and I'll try to resume where you left off."
|
||||
)
|
||||
msg = t("gateway.shutdown.notice_restart" if self._restart_requested else "gateway.shutdown.notice_shutdown")
|
||||
restart_key = None
|
||||
if restart_source is not None:
|
||||
with suppress(Exception):
|
||||
|
||||
@@ -17,6 +17,7 @@ import time
|
||||
from contextlib import nullcontext, suppress
|
||||
from contextvars import copy_context
|
||||
from pathlib import Path
|
||||
from agent.i18n import t
|
||||
from gateway.config import Platform
|
||||
from gateway.delivery import looks_like_telegram_private_chat_id
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
@@ -770,7 +771,7 @@ class GatewayStartupMixin:
|
||||
is_intentional_silence_response, is_machinery_display_kind, silence_allowed,
|
||||
)
|
||||
from gateway.run import _sanitize_gateway_final_response
|
||||
from gateway.run_turn import _UNEXPECTED_SILENCE_REPLY
|
||||
from gateway.run_turn import _unexpected_silence_reply
|
||||
from gateway.warning_notifications import diagnostic_turn_muted
|
||||
from hermes_cli.timefmt import coerce_epoch
|
||||
visible = [m for m in history if m.get("role") not in ("session_meta", "system")]
|
||||
@@ -791,7 +792,7 @@ class GatewayStartupMixin:
|
||||
if is_intentional_silence_response(last["content"]):
|
||||
silent_ok = silence_allowed(
|
||||
prompt.get("display_kind"), (prompt.get("display_metadata") or {}).get("reply_expected"))
|
||||
return "" if silent_ok else _UNEXPECTED_SILENCE_REPLY
|
||||
return "" if silent_ok else _unexpected_silence_reply()
|
||||
return _strip_media_directives(_sanitize_gateway_final_response(origin.platform, last["content"])).strip() or None
|
||||
|
||||
@staticmethod
|
||||
@@ -1695,7 +1696,7 @@ class GatewayStartupMixin:
|
||||
cli_title = row.get("title") or cli_session_id[:8]
|
||||
try:
|
||||
new_thread_id = await transport.adapter.create_handoff_thread(
|
||||
home_chat_id, f"Hermes — {cli_title}",
|
||||
home_chat_id, t("gateway.startup.handoff_thread_title", title=cli_title),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Handoff: create_handoff_thread raised on %s: %s", platform_name, exc, exc_info=True)
|
||||
|
||||
+26
-75
@@ -27,14 +27,12 @@ if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle)
|
||||
# Log-record parity with the origin module.
|
||||
logger = logging.getLogger("gateway.run")
|
||||
|
||||
_TOPIC_RESTORE_STEPS = (
|
||||
"1. Create or open a topic. To create a new one, open All Messages and send any message there.",
|
||||
"2. Send /topic <session-id> inside that topic.",
|
||||
)
|
||||
def _topic_restore_steps() -> tuple:
|
||||
return (t("gateway.topic.restore_step_1"), t("gateway.topic.restore_step_2"))
|
||||
|
||||
|
||||
def _collapse_title(title: str) -> str:
|
||||
return re.sub(r"\s+", " ", str(title or "")).strip() or "Hermes Chat"
|
||||
return re.sub(r"\s+", " ", str(title or "")).strip() or t("gateway.topic.default_title")
|
||||
|
||||
|
||||
class GatewayTopicThreadsMixin:
|
||||
@@ -125,52 +123,16 @@ class GatewayTopicThreadsMixin:
|
||||
# ── Telegram topic mode: user-facing text ───────────────────────────────────────────────
|
||||
|
||||
def _telegram_topic_root_lobby_message(self) -> str:
|
||||
return (
|
||||
"This main chat is reserved for system commands.\n\n"
|
||||
"To start a new Hermes chat, open the All Messages topic at the top "
|
||||
"of this bot interface and send any message there. Telegram will "
|
||||
"create a new topic for that message; each topic works as an "
|
||||
"independent Hermes session."
|
||||
)
|
||||
return t("gateway.topic.root_lobby")
|
||||
|
||||
def _telegram_topic_root_new_message(self) -> str:
|
||||
return (
|
||||
"To start a new parallel Hermes chat, open the All Messages topic "
|
||||
"at the top of this bot interface and send any message there. "
|
||||
"Telegram will create a new topic for it.\n\n"
|
||||
"Each topic is an independent Hermes session. Use /new inside an "
|
||||
"existing topic only if you want to replace that topic's current session."
|
||||
)
|
||||
return t("gateway.topic.root_new")
|
||||
|
||||
def _telegram_topic_new_header(self, source: SessionSource) -> Optional[str]:
|
||||
return (
|
||||
"Started a new Hermes session in this topic.\n\n"
|
||||
"Tip: for parallel work, open All Messages and send a message there "
|
||||
"to create a separate topic instead of using /new here. /new replaces "
|
||||
"the session attached to the current topic."
|
||||
) if self._is_telegram_topic_lane(source) else None
|
||||
return t("gateway.topic.new_header") if self._is_telegram_topic_lane(source) else None
|
||||
|
||||
def _telegram_topic_help_text(self) -> str:
|
||||
return (
|
||||
"/topic — enable multi-session DM mode (one bot, many parallel chats)\n"
|
||||
"\n"
|
||||
"Usage:\n"
|
||||
" /topic Enable topic mode, or show status if already on\n"
|
||||
" /topic help Show this message\n"
|
||||
" /topic off Disable topic mode and clear topic bindings\n"
|
||||
" /topic <id> Inside a topic: restore a previous session by ID\n"
|
||||
"\n"
|
||||
"How it works:\n"
|
||||
"1. Run /topic once in this DM — Hermes checks BotFather Threads\n"
|
||||
" Settings are enabled and flips on multi-session mode.\n"
|
||||
"2. Tap All Messages at the top of the bot and send any message.\n"
|
||||
" Telegram creates a new topic for that message; each topic is\n"
|
||||
" an independent Hermes session (fresh history, fresh context).\n"
|
||||
"3. The root DM becomes a system lobby — send /topic, /status,\n"
|
||||
" /help, /usage there. Normal prompts go in a topic.\n"
|
||||
"4. /new inside a topic resets just that topic's session.\n"
|
||||
"5. /topic <id> inside a topic restores an old session into it."
|
||||
)
|
||||
return t("gateway.topic.help")
|
||||
|
||||
# ── Telegram topic bindings ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -257,7 +219,7 @@ class GatewayTopicThreadsMixin:
|
||||
if not callable(create_topic):
|
||||
return
|
||||
try:
|
||||
thread_id = await create_topic(int(source.chat_id), "System")
|
||||
thread_id = await create_topic(int(source.chat_id), t("gateway.topic.system_topic_name"))
|
||||
except Exception:
|
||||
logger.debug("Failed to create Telegram System topic", exc_info=True)
|
||||
return
|
||||
@@ -265,7 +227,7 @@ class GatewayTopicThreadsMixin:
|
||||
return
|
||||
try:
|
||||
send_result = await adapter.send(
|
||||
source.chat_id, "System topic for Hermes commands and status.", metadata={"thread_id": str(thread_id)},
|
||||
source.chat_id, t("gateway.topic.system_topic_intro"), metadata={"thread_id": str(thread_id)},
|
||||
)
|
||||
message_id = getattr(send_result, "message_id", None)
|
||||
except Exception:
|
||||
@@ -287,7 +249,7 @@ class GatewayTopicThreadsMixin:
|
||||
return
|
||||
try:
|
||||
await adapter.send_image_file(
|
||||
chat_id=source.chat_id, image_path=str(image_path), caption="BotFather → Bot Settings → Threads Settings",
|
||||
chat_id=source.chat_id, image_path=str(image_path), caption=t("gateway.topic.setup_image_caption"),
|
||||
metadata={"thread_id": str(source.thread_id)} if source.thread_id else None,
|
||||
)
|
||||
except Exception:
|
||||
@@ -550,7 +512,7 @@ class GatewayTopicThreadsMixin:
|
||||
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
|
||||
chat_id = str(source.chat_id or "")
|
||||
if not chat_id:
|
||||
return "Could not determine chat ID."
|
||||
return t("gateway.topic.no_chat_id")
|
||||
profile_name = self._telegram_topic_profile_name(source)
|
||||
currently_enabled = False
|
||||
with suppress(Exception):
|
||||
@@ -558,12 +520,12 @@ class GatewayTopicThreadsMixin:
|
||||
chat_id=chat_id, user_id=str(source.user_id or ""), profile_name=profile_name,
|
||||
)
|
||||
if not currently_enabled:
|
||||
return "Multi-session topic mode is not currently enabled for this chat."
|
||||
return t("gateway.topic.mode_not_enabled")
|
||||
try:
|
||||
await self._session_db.disable_telegram_topic_mode(chat_id=chat_id, profile_name=profile_name)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to disable Telegram topic mode")
|
||||
return f"Failed to disable topic mode: {exc}"
|
||||
return t("gateway.topic.disable_failed", error=exc)
|
||||
# Reset per-profile+chat debounce state so the next activation doesn't see a stale cooldown.
|
||||
# See #76423.
|
||||
cooldown_key = self._telegram_topic_cooldown_key(source)
|
||||
@@ -571,22 +533,10 @@ class GatewayTopicThreadsMixin:
|
||||
store = getattr(self, attr, None)
|
||||
if isinstance(store, dict):
|
||||
store.pop(cooldown_key, None)
|
||||
return (
|
||||
"Multi-session topic mode is now OFF for this chat.\n\n"
|
||||
"Existing topics in Telegram aren't removed — they'll just stop "
|
||||
"being gated as independent sessions. The root DM works as a "
|
||||
"normal Hermes chat again. Run /topic to re-enable later."
|
||||
)
|
||||
return t("gateway.topic.mode_disabled")
|
||||
|
||||
async def _telegram_topic_root_status_message(self, source: SessionSource) -> str:
|
||||
lines = [
|
||||
"Telegram multi-session topics are enabled.",
|
||||
"",
|
||||
"To create a new Hermes chat, open All Messages at the top of this "
|
||||
"bot interface and send any message there. Telegram will create a "
|
||||
"new topic for it.",
|
||||
"",
|
||||
]
|
||||
lines = [t("gateway.topic.status_enabled"), "", t("gateway.topic.status_how_to"), ""]
|
||||
try:
|
||||
sessions = await self._session_db.list_unlinked_telegram_sessions_for_user(
|
||||
chat_id=str(source.chat_id), user_id=str(source.user_id),
|
||||
@@ -596,16 +546,17 @@ class GatewayTopicThreadsMixin:
|
||||
logger.debug("Failed to list unlinked Telegram sessions", exc_info=True)
|
||||
sessions = []
|
||||
if sessions:
|
||||
lines.append("Previous unlinked sessions:")
|
||||
lines.append(t("gateway.topic.previous_unlinked"))
|
||||
for session in sessions:
|
||||
preview = str(session.get("preview") or "").strip()
|
||||
lines.append(
|
||||
f"- {session.get('title') or 'Untitled session'} — `{session.get('id') or ''}`"
|
||||
f"- {session.get('title') or t('gateway.topic.untitled_session')} — `{session.get('id') or ''}`"
|
||||
+ (f" — {preview}" if preview else "")
|
||||
)
|
||||
lines.extend(["", "To restore one:", *_TOPIC_RESTORE_STEPS, f"Example: Send /topic {sessions[0].get('id')} inside a topic."])
|
||||
lines.extend(["", t("gateway.topic.restore_one"), *_topic_restore_steps(),
|
||||
t("gateway.topic.restore_example", session_id=sessions[0].get("id"))])
|
||||
else:
|
||||
lines.extend(["No previous unlinked Telegram sessions found.", "", "To restore a previous session later:", *_TOPIC_RESTORE_STEPS])
|
||||
lines.extend([t("gateway.topic.no_unlinked"), "", t("gateway.topic.restore_later"), *_topic_restore_steps()])
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _restore_telegram_topic_session(self, event: MessageEvent, raw_session_id: str) -> str:
|
||||
@@ -615,17 +566,17 @@ class GatewayTopicThreadsMixin:
|
||||
session_id = await db.resolve_session_id(raw_session_id.strip())
|
||||
session = await db.get_session(session_id) if session_id else None
|
||||
if not session:
|
||||
return f"Session not found: {raw_session_id.strip()}"
|
||||
return t("gateway.topic.session_not_found", session_id=raw_session_id.strip())
|
||||
if str(session.get("source") or "") != "telegram":
|
||||
return "That session is not a Telegram session and cannot be restored into this topic."
|
||||
return t("gateway.topic.not_telegram_session")
|
||||
if str(session.get("user_id") or "") != str(source.user_id):
|
||||
return "That session does not belong to this Telegram user."
|
||||
return t("gateway.topic.not_your_session")
|
||||
linked = await db.is_telegram_session_linked_to_topic(session_id=session_id)
|
||||
topic_profile = self._telegram_topic_profile_name(source)
|
||||
current_binding = await db.get_telegram_topic_binding(
|
||||
chat_id=str(source.chat_id), thread_id=str(source.thread_id), profile_name=topic_profile,
|
||||
)
|
||||
already_linked = "That session is already linked to another Telegram topic."
|
||||
already_linked = t("gateway.topic.already_linked")
|
||||
if linked and (not current_binding or current_binding.get("session_id") != session_id):
|
||||
return already_linked
|
||||
try:
|
||||
@@ -648,5 +599,5 @@ class GatewayTopicThreadsMixin:
|
||||
if projected is not None and projected.get("content"):
|
||||
last_assistant = str(projected.get("content"))
|
||||
break
|
||||
response = f"Session restored: {title}"
|
||||
return response + (f"\n\nLast Hermes message:\n{last_assistant}" if last_assistant else "")
|
||||
response = t("gateway.topic.restored", title=title)
|
||||
return response + (t("gateway.topic.restored_last_message", text=last_assistant) if last_assistant else "")
|
||||
+69
-105
@@ -84,10 +84,9 @@ _CONTEXT_OVERFLOW_ERROR_PHRASES = (
|
||||
"payload too large", "input is too long",
|
||||
)
|
||||
|
||||
_UNEXPECTED_SILENCE_REPLY = (
|
||||
"⚠️ The model returned only a silence marker for a message that needed a reply. "
|
||||
"Try again or rephrase."
|
||||
)
|
||||
def _unexpected_silence_reply() -> str:
|
||||
"""Reply when the model returned only a silence marker for a message that needed an answer."""
|
||||
return t("gateway.errors.unexpected_silence")
|
||||
|
||||
|
||||
def _bg_prompt_preview(prompt: str, limit: int = 60) -> str:
|
||||
@@ -561,11 +560,7 @@ class GatewayTurnMixin:
|
||||
should_notify = reset_reason == "suspended"
|
||||
adapter = self._delivery_adapter_for(source) if should_notify else None
|
||||
if adapter:
|
||||
notice = (
|
||||
"◐ Session reset after being stopped. "
|
||||
f"Conversation history cleared.\n"
|
||||
f"Use /resume to browse and restore a previous session.\n"
|
||||
)
|
||||
notice = t("gateway.session.auto_reset_notice")
|
||||
with suppress(Exception):
|
||||
session_info = await asyncio.to_thread(self._reset_notice_session_info, source)
|
||||
if session_info:
|
||||
@@ -1208,24 +1203,18 @@ class GatewayTurnMixin:
|
||||
if not _hyg_fence_cancelled:
|
||||
# Force-redact: provider exception text may contain credentials; this reaches users.
|
||||
from agent.redact import redact_sensitive_text
|
||||
_err = redact_sensitive_text(getattr(_comp, "_last_summary_error", None) or "unknown error", force=True)
|
||||
_err = redact_sensitive_text(
|
||||
getattr(_comp, "_last_summary_error", None) or t("gateway.shared.unknown_error"), force=True)
|
||||
logger.warning("Session hygiene compression aborted: %s", _err)
|
||||
await self._hmwa_hygiene_notify(
|
||||
source, attempt.meta,
|
||||
"⚠️ Shortening the conversation history failed, so I kept everything as-is. "
|
||||
"Run /compress to try again or /new to start fresh. If this keeps happening, "
|
||||
"run `hermes doctor` on the host.",
|
||||
"compression-failure warning",
|
||||
source, attempt.meta, t("gateway.compress.hygiene_failed"), "compression-failure warning",
|
||||
)
|
||||
# Configured aux model failed, recovered on the main model: only the user can fix that config.
|
||||
elif _comp is not None and getattr(_comp, "_last_aux_model_failure_model", None):
|
||||
_aux_model = getattr(_comp, "_last_aux_model_failure_model", "")
|
||||
_aux_err = getattr(_comp, "_last_aux_model_failure_error", None) or "unknown error"
|
||||
_aux_err = getattr(_comp, "_last_aux_model_failure_error", None) or t("gateway.shared.unknown_error")
|
||||
await self._hmwa_hygiene_notify(
|
||||
source, attempt.meta, f"ℹ️ Configured compression model `{_aux_model}` "
|
||||
f"failed ({_aux_err}). Recovered using your main "
|
||||
"model — context is intact — but you may want to "
|
||||
"check `auxiliary.compression.model` in config.yaml.",
|
||||
source, attempt.meta, t("gateway.compress.aux_failed", model=_aux_model, error=_aux_err),
|
||||
"aux-model-fallback notice",
|
||||
)
|
||||
|
||||
@@ -1461,10 +1450,7 @@ class GatewayTurnMixin:
|
||||
# Slack routes every command through the parent `/hermes`; bare `/sethome` would fail.
|
||||
sethome_cmd = "/hermes sethome" if source.platform == Platform.SLACK else "/sethome"
|
||||
await self._deliver_platform_notice(
|
||||
source, f"📬 No home channel is set for {platform_name.title()}. "
|
||||
f"A home channel is where Hermes delivers cron job results and cross-platform "
|
||||
f"messages.\n\nType {sethome_cmd} to make this chat your home channel, or ignore "
|
||||
f"to skip.",
|
||||
source, t("gateway.notify.no_home_channel", platform=platform_name.title(), sethome_cmd=sethome_cmd),
|
||||
)
|
||||
|
||||
def _hmwa_apply_message_timestamp(self, event, message_text):
|
||||
@@ -1540,7 +1526,7 @@ class GatewayTurnMixin:
|
||||
_platform_name, source.chat_id or "unknown",
|
||||
)
|
||||
_intentional_silence = False
|
||||
response = _UNEXPECTED_SILENCE_REPLY
|
||||
response = _unexpected_silence_reply()
|
||||
elif _intentional_silence and not is_machinery_display_kind(_silence_kind):
|
||||
logger.debug(
|
||||
"silence marker suppressed on an unaddressed turn: platform=%s chat=%s",
|
||||
@@ -1553,8 +1539,8 @@ class GatewayTurnMixin:
|
||||
if response == "(empty)" and not _intentional_silence:
|
||||
from agent.turn_explainers import EMPTY_RESPONSE_EXPLANATION
|
||||
|
||||
_model = str(agent_result.get("model") or "").strip() or "The model"
|
||||
response = "⚠️ " + EMPTY_RESPONSE_EXPLANATION.format(model=_model)
|
||||
_model = str(agent_result.get("model") or "").strip() or t("gateway.errors.empty_response_model_label")
|
||||
response = t("gateway.shared.warn_passthrough", error=EMPTY_RESPONSE_EXPLANATION.format(model=_model))
|
||||
agent_messages = agent_result.get("messages", [])
|
||||
logger.info(
|
||||
"response ready: platform=%s chat=%s session=%s time=%.1fs api_calls=%d response=%d chars",
|
||||
@@ -1599,9 +1585,10 @@ class GatewayTurnMixin:
|
||||
)
|
||||
return response, _intentional_silence, agent_messages
|
||||
|
||||
# reasoning_style → (header line, per-line quote prefix for blank / non-blank lines)
|
||||
# reasoning_style → (header catalog key, per-line quote prefix for blank / non-blank lines)
|
||||
_REASONING_QUOTE_STYLES = {
|
||||
"subtext": ("-# 💭 Reasoning", "-# ", "-#"), "blockquote": ("> 💭 **Reasoning:**", "> ", ">")
|
||||
"subtext": ("gateway.reasoning.quote_label_discord", "-# ", "-#"),
|
||||
"blockquote": ("gateway.reasoning.quote_label_md", "> ", ">"),
|
||||
}
|
||||
|
||||
def _hmwa_prepend_reasoning(self, agent_result, response, source, _intentional_silence):
|
||||
@@ -1625,7 +1612,7 @@ class GatewayTurnMixin:
|
||||
# Collapse long reasoning to keep messages readable
|
||||
lines = last_reasoning.strip().splitlines()
|
||||
if len(lines) > 15:
|
||||
display_reasoning = "\n".join(lines[:15]) + f"\n_... ({len(lines) - 15} more lines)_"
|
||||
display_reasoning = "\n".join(lines[:15]) + t("gateway.reasoning.more_lines", count=len(lines) - 15)
|
||||
else:
|
||||
display_reasoning = last_reasoning.strip()
|
||||
# Per-platform render style: Discord defaults to "-# " subtext, others keep the code block.
|
||||
@@ -1638,12 +1625,12 @@ class GatewayTurnMixin:
|
||||
_reasoning_style = "code"
|
||||
_quote = self._REASONING_QUOTE_STYLES.get(_reasoning_style)
|
||||
if _quote:
|
||||
header, prefix, empty = _quote
|
||||
header_key, prefix, empty = _quote
|
||||
_quoted = "\n".join(f"{prefix}{ln}" if ln else empty for ln in display_reasoning.splitlines())
|
||||
return f"{header}\n{_quoted}\n\n{response}"
|
||||
return f"{t(header_key)}\n{_quoted}\n\n{response}"
|
||||
# Escape ``` inside reasoning so inner fences don't break the outer code block.
|
||||
display_reasoning = escape_code_fences_for_display(display_reasoning)
|
||||
return f"💭 **Reasoning:**\n```\n{display_reasoning}\n```\n\n{response}"
|
||||
return t("gateway.reasoning.block", reasoning=display_reasoning, response=response)
|
||||
|
||||
def _hmwa_runtime_footer_line(self, agent_result, source, _turn_seconds):
|
||||
"""Runtime-metadata footer for the FINAL message of the turn; off by default
|
||||
@@ -1803,10 +1790,7 @@ class GatewayTurnMixin:
|
||||
await asyncio.to_thread(
|
||||
self._sync_telegram_topic_binding, source, session_entry, reason="compression-exhausted-reset",
|
||||
)
|
||||
response = (response or "") + (
|
||||
"\n\n🔄 Session auto-reset — the conversation exceeded the maximum context size and "
|
||||
"could not be compressed further. Your next message will start a fresh session."
|
||||
)
|
||||
response = (response or "") + t("gateway.session.auto_reset_context_exhausted")
|
||||
return response, session_entry
|
||||
|
||||
@staticmethod
|
||||
@@ -1971,11 +1955,11 @@ class GatewayTurnMixin:
|
||||
|
||||
# Chat-side next steps keyed by HTTP status; Hermes commands only (/login is the gateway's own
|
||||
# sign-in, `{relogin}` the profile-aware host equivalent, filled from the turn's agent provider).
|
||||
# Values are catalog keys (``gateway.errors.hint_*``); 401 carries a ``{relogin}`` placeholder.
|
||||
_STATUS_HINTS = {
|
||||
401: (" Your sign-in to the AI model service has expired or the API key is wrong. "
|
||||
"Use /login here, or run `{relogin}` on the host."),
|
||||
402: " Your AI model service balance or quota is used up. Top it up on the service's website, or use /model to switch models.",
|
||||
529: " The AI model service is temporarily overloaded. Wait a moment, then use /retry.",
|
||||
401: "gateway.errors.hint_auth",
|
||||
402: "gateway.errors.hint_quota",
|
||||
529: "gateway.errors.hint_overloaded",
|
||||
}
|
||||
|
||||
async def _hmwa_agent_error_reply(self, e, event, source, session_entry, session_key, prepared):
|
||||
@@ -1988,8 +1972,8 @@ class GatewayTurnMixin:
|
||||
if status_code in {400, 500} and len(prepared.history) > 50:
|
||||
# Context overflow / payload too large: a deterministic rejection (#107567), and the same
|
||||
# no-grow rule as the persist path (#1630) — nothing is written into an oversized session.
|
||||
from gateway.run import _CONTEXT_OVERFLOW_REPLY
|
||||
return _CONTEXT_OVERFLOW_REPLY
|
||||
from gateway.run import _context_overflow_reply
|
||||
return _context_overflow_reply()
|
||||
# Replay can coalesce inputs; only this input's durable marker establishes ownership.
|
||||
try:
|
||||
if prepared.message_text is not None and session_entry is not None:
|
||||
@@ -2005,12 +1989,13 @@ class GatewayTurnMixin:
|
||||
except Exception:
|
||||
logger.debug("Failed to persist inbound user message after agent exception", exc_info=True)
|
||||
# Never expose raw exception types/messages to end users (info-leakage risk).
|
||||
status_hint = self._STATUS_HINTS.get(status_code, "")
|
||||
_hint_key = self._STATUS_HINTS.get(status_code)
|
||||
status_hint = t(_hint_key) if _hint_key and status_code != 401 else ""
|
||||
if status_code == 401:
|
||||
from agent.turn_failure_copy import relogin_command_hint
|
||||
|
||||
_turn_agent = getattr(self._session_state(session_key).turn, "agent", None)
|
||||
status_hint = status_hint.format(relogin=relogin_command_hint(getattr(_turn_agent, "provider", None)))
|
||||
status_hint = t(_hint_key, relogin=relogin_command_hint(getattr(_turn_agent, "provider", None)))
|
||||
elif status_code == 429:
|
||||
# Plan usage limit (resets on a schedule) vs a transient rate limit
|
||||
_err_json = {}
|
||||
@@ -2020,19 +2005,16 @@ class GatewayTurnMixin:
|
||||
_err_json = {}
|
||||
_resets_in = _err_json.get("resets_in_seconds")
|
||||
if _err_json.get("type") != "usage_limit_reached":
|
||||
status_hint = " You are being rate-limited. Please wait a moment and try again."
|
||||
status_hint = t("gateway.errors.hint_rate_limited")
|
||||
elif _resets_in and _resets_in > 0:
|
||||
import math
|
||||
status_hint = f" Your plan's usage limit has been reached. It resets in ~{math.ceil(_resets_in / 3600)}h."
|
||||
status_hint = t("gateway.errors.hint_usage_limit_resets", hours=math.ceil(_resets_in / 3600))
|
||||
else:
|
||||
status_hint = " Your plan's usage limit has been reached. Please wait until it resets."
|
||||
status_hint = t("gateway.errors.hint_usage_limit")
|
||||
elif status_code == 400:
|
||||
status_hint = " The AI model service rejected the request."
|
||||
status_hint = t("gateway.errors.hint_rejected")
|
||||
return self._hmwa_add_failed_turn_notice(
|
||||
f"⚠️ Something went wrong and I couldn't finish this reply.{status_hint}\n"
|
||||
"Use /retry to try again, or /new to start a fresh conversation. "
|
||||
"Technical details are in the gateway log (`hermes logs`).",
|
||||
PARTIAL_FAILED_TURN_NOTICE,
|
||||
t("gateway.errors.generic_failed_with_hint", hint=status_hint), PARTIAL_FAILED_TURN_NOTICE,
|
||||
)
|
||||
|
||||
def _hmwa_discard_stale_result(self, source, _quick_key, run_generation):
|
||||
@@ -2107,11 +2089,7 @@ class GatewayTurnMixin:
|
||||
)
|
||||
except TranscriptReadError:
|
||||
self._clear_session_env(_session_env_tokens)
|
||||
return (
|
||||
"⚠️ This session's history is temporarily unavailable, so this message was not "
|
||||
"processed. Ask the operator to inspect state.db, then resend after it is healthy. "
|
||||
"Use /reset only if you intentionally want to start a new conversation."
|
||||
), _session_env_tokens
|
||||
return t("gateway.errors.history_unavailable"), _session_env_tokens
|
||||
|
||||
await self._hmwa_first_contact_notes(source, history, turn_sidecar_notes)
|
||||
|
||||
@@ -2367,9 +2345,9 @@ class GatewayTurnMixin:
|
||||
else f"{context_length // 1_000}K" if context_length >= 1_000 else str(context_length)
|
||||
)
|
||||
lines = [
|
||||
f"◆ Model: `{resolved.model}`",
|
||||
f"◆ Provider: {resolved.provider or 'openrouter'}",
|
||||
f"◆ Context: {ctx_display} tokens ({ctx_source})",
|
||||
t("gateway.session.info_model", model=resolved.model),
|
||||
t("gateway.session.info_provider", provider=resolved.provider or "openrouter"),
|
||||
t("gateway.session.info_context", tokens=ctx_display, source=ctx_source),
|
||||
]
|
||||
if (resolved.provider or "") == "moa":
|
||||
# The preset name hides who pays: the aggregator runs every tool-loop step (#112359).
|
||||
@@ -2377,10 +2355,10 @@ class GatewayTurnMixin:
|
||||
from hermes_cli.moa_config import normalize_moa_config
|
||||
agg = normalize_moa_config(load_config().get("moa"))["presets"].get(resolved.model, {}).get("aggregator") or {}
|
||||
if agg:
|
||||
lines.append(f"◆ Acting model (billed for the run): {agg.get('provider')}:{agg.get('model')}")
|
||||
lines.append(t("gateway.session.info_acting_model", provider=agg.get("provider"), model=agg.get("model")))
|
||||
base_url = resolved.base_url
|
||||
if base_url and base_url_hostname(base_url) in ("localhost", "127.0.0.1", "0.0.0.0"):
|
||||
lines.append(f"◆ Endpoint: {base_url}")
|
||||
lines.append(t("gateway.session.info_endpoint", url=base_url))
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _run_background_task(
|
||||
@@ -2442,12 +2420,7 @@ class GatewayTurnMixin:
|
||||
user_config = _load_gateway_config()
|
||||
model, runtime_kwargs = self._resolve_session_agent_runtime(source=source, user_config=user_config)
|
||||
if not runtime_kwargs.get("api_key"):
|
||||
await adapter.send(
|
||||
source.chat_id,
|
||||
"❌ The background task couldn't start because no AI model sign-in is "
|
||||
"configured. Use /login, or run `hermes setup` on the host.",
|
||||
metadata=_thread_metadata,
|
||||
)
|
||||
await adapter.send(source.chat_id, t("gateway.background.no_credentials"), metadata=_thread_metadata)
|
||||
return
|
||||
|
||||
platform_key = _platform_config_key(source.platform)
|
||||
@@ -2509,13 +2482,13 @@ class GatewayTurnMixin:
|
||||
|
||||
response = result.get("final_response", "") if result else ""
|
||||
if not response and result and result.get("error"):
|
||||
response = f"Error: {result['error']}"
|
||||
response = t("gateway.shared.error_prefix", error=result["error"])
|
||||
# Fresh conversation, so history_offset=0: every message in the run belongs to this turn.
|
||||
if response:
|
||||
response = repair_explicit_computer_use_media_paths(response, result.get("messages", []))
|
||||
|
||||
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
|
||||
header = f'✅ Background task complete\nPrompt: "{preview}"\n\n'
|
||||
header = t("gateway.background.complete_header", preview=preview)
|
||||
images, media_files, text_content = [], [], ""
|
||||
if response:
|
||||
media_files, response = adapter.extract_media(response)
|
||||
@@ -2525,7 +2498,7 @@ class GatewayTurnMixin:
|
||||
await adapter.send(chat_id=source.chat_id, content=header + text_content, metadata=_thread_metadata)
|
||||
elif not images and not media_files:
|
||||
await adapter.send(
|
||||
chat_id=source.chat_id, content=header + "(No response generated)", metadata=_thread_metadata,
|
||||
chat_id=source.chat_id, content=header + t("gateway.background.no_response"), metadata=_thread_metadata,
|
||||
)
|
||||
for image_url, alt_text in (images or []):
|
||||
with suppress(Exception):
|
||||
@@ -2558,8 +2531,7 @@ class GatewayTurnMixin:
|
||||
with suppress(Exception):
|
||||
await adapter.emit_warning(
|
||||
source.chat_id,
|
||||
(f"❌ Your background task \"{_bg_prompt_preview(prompt)}\" failed before finishing. "
|
||||
"Send /bg again to retry, or /agents to see what is still running."),
|
||||
t("gateway.background.failed", preview=_bg_prompt_preview(prompt)),
|
||||
metadata=_thread_metadata, logical_platform=source.platform,
|
||||
)
|
||||
|
||||
@@ -2780,12 +2752,11 @@ class GatewayTurnMixin:
|
||||
try:
|
||||
from aiohttp import ClientSession as _AioClientSession, ClientTimeout
|
||||
except ImportError:
|
||||
return self._proxy_error_result("⚠️ Proxy mode requires aiohttp. Run: "
|
||||
f"{install_hint('messaging')}")
|
||||
return self._proxy_error_result(t("gateway.proxy.requires_aiohttp", hint=install_hint("messaging")))
|
||||
|
||||
proxy_url = self._get_proxy_url()
|
||||
if not proxy_url:
|
||||
return self._proxy_error_result("⚠️ Proxy URL not configured (GATEWAY_PROXY_URL or gateway.proxy_url)")
|
||||
return self._proxy_error_result(t("gateway.proxy.url_missing"))
|
||||
|
||||
# The proxy key is a per-profile credential: honor the installed secret scope under multiplex.
|
||||
# Only UnscopedSecretError (the unscoped default-profile path) falls back to the env; any
|
||||
@@ -2874,7 +2845,7 @@ class GatewayTurnMixin:
|
||||
if resp.status != 200:
|
||||
error_text = await resp.text()
|
||||
logger.warning("Proxy error (%d) from %s: %s", resp.status, proxy_url, error_text[:500])
|
||||
return self._proxy_error_result(f"⚠️ Proxy error ({resp.status}): {error_text[:300]}")
|
||||
return self._proxy_error_result(t("gateway.proxy.http_error", status=resp.status, error=error_text[:300]))
|
||||
|
||||
buffer = ""
|
||||
async for chunk in resp.content.iter_any():
|
||||
@@ -2905,14 +2876,13 @@ class GatewayTurnMixin:
|
||||
"(%d chars received)", proxy_url, len(full_response),
|
||||
)
|
||||
if not full_response:
|
||||
return self._proxy_error_result(
|
||||
"⚠️ Proxy connection closed before the response completed")
|
||||
return self._proxy_error_result(t("gateway.proxy.closed_early"))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Proxy connection error to %s: %s", proxy_url, e)
|
||||
if not full_response:
|
||||
return self._proxy_error_result(f"⚠️ Proxy connection error: {e}")
|
||||
return self._proxy_error_result(t("gateway.proxy.connection_error", error=e))
|
||||
# Partial response — return what we got
|
||||
finally:
|
||||
if _stream_consumer:
|
||||
@@ -2931,7 +2901,7 @@ class GatewayTurnMixin:
|
||||
proxy_url, (session_id or "")[:20], _elapsed, len(full_response),
|
||||
)
|
||||
return {
|
||||
"final_response": full_response or "(No response from remote agent)",
|
||||
"final_response": full_response or t("gateway.proxy.no_response"),
|
||||
"messages": [
|
||||
{"role": "user", "content": message},
|
||||
{"role": "assistant", "content": full_response},
|
||||
@@ -3012,7 +2982,9 @@ class GatewayTurnMixin:
|
||||
)
|
||||
except Exception as _phrase_err:
|
||||
logger.debug("generic status phrase selection failed: %s", _phrase_err)
|
||||
return "still on it" if kind in {"heartbeat", "waiting", "long_running", "status"} else "one sec"
|
||||
return (t("gateway.progress.status_fallback_long")
|
||||
if kind in {"heartbeat", "waiting", "long_running", "status"}
|
||||
else t("gateway.progress.status_fallback_short"))
|
||||
|
||||
# Webhooks can't edit messages, so tool progress / log mode are off there.
|
||||
is_webhook = source.platform == Platform.WEBHOOK
|
||||
@@ -3520,10 +3492,10 @@ class GatewayTurnMixin:
|
||||
return
|
||||
try:
|
||||
await _warn_adapter.emit_warning(
|
||||
source.chat_id, f"⚠️ I seem to be stuck (no activity for {int(worker.agent_warning // 60) or 1} min). "
|
||||
"If nothing happens in the next "
|
||||
f"{int((worker.agent_timeout - worker.agent_warning) // 60) or 1} min I'll give up on this task. "
|
||||
"You can keep waiting, send /stop to cancel it, or /new to start a fresh conversation.",
|
||||
source.chat_id, t(
|
||||
"gateway.progress.stuck_warning",
|
||||
minutes=int(worker.agent_warning // 60) or 1,
|
||||
remaining=int((worker.agent_timeout - worker.agent_warning) // 60) or 1),
|
||||
metadata=_interim_metadata(_status_thread_metadata), logical_platform=source.platform,
|
||||
)
|
||||
except Exception as _warn_err:
|
||||
@@ -3552,24 +3524,16 @@ class GatewayTurnMixin:
|
||||
request_hard_interrupt(_timed_out_agent, _INTERRUPT_REASON_TIMEOUT, tool_reason=_INTERRUPT_TOOL_REASON_TIMEOUT)
|
||||
_timeout_mins = int(worker.agent_timeout // 60) or 1
|
||||
_iter_progress = format_iteration_progress(_iter_n, _iter_max)
|
||||
_diag_lines = [
|
||||
f"⏱️ Agent inactive for {_timeout_mins} min — no tool calls or API responses."
|
||||
]
|
||||
_diag_lines = [t("gateway.progress.timeout_header", minutes=_timeout_mins)]
|
||||
if _cur_tool:
|
||||
_diag_lines.append(
|
||||
f"The agent appears stuck on tool `{_cur_tool}` ({_secs_ago:.0f}s since last "
|
||||
f"activity, {_iter_progress})."
|
||||
)
|
||||
_diag_lines.append(t(
|
||||
"gateway.progress.timeout_stuck_tool",
|
||||
tool=_cur_tool, seconds=f"{_secs_ago:.0f}", progress=_iter_progress))
|
||||
else:
|
||||
_diag_lines.append(
|
||||
f"Last activity: {_last_desc} ({_secs_ago:.0f}s ago, "
|
||||
f"{_iter_progress}). "
|
||||
"The agent may have been waiting on an API response."
|
||||
)
|
||||
_diag_lines.append(
|
||||
"To increase the limit, set agent.gateway_timeout in config.yaml (value in seconds, 0 "
|
||||
"= no limit) and restart the gateway.\nTry again, or use /reset to start fresh."
|
||||
)
|
||||
_diag_lines.append(t(
|
||||
"gateway.progress.timeout_last_activity",
|
||||
activity=_last_desc, seconds=f"{_secs_ago:.0f}", progress=_iter_progress))
|
||||
_diag_lines.append(t("gateway.progress.timeout_hint"))
|
||||
return {
|
||||
"final_response": "\n".join(_diag_lines),
|
||||
"messages": result_holder[0].get("messages", []) if result_holder[0] else [],
|
||||
@@ -3752,7 +3716,7 @@ class GatewayTurnMixin:
|
||||
"Queued follow-up for session %s: replacing a human-turn silence marker.",
|
||||
session_key or "?",
|
||||
)
|
||||
first_response = _UNEXPECTED_SILENCE_REPLY
|
||||
first_response = _unexpected_silence_reply()
|
||||
_already_streamed = False
|
||||
# Failed turns deliver their text but never their attachments (completed-turn parity).
|
||||
_deliver_media = not _delivery_result.get("failed")
|
||||
@@ -4221,7 +4185,7 @@ class GatewayTurnMixin:
|
||||
_heartbeat_text = (
|
||||
disp._generic_status_phrase("status")
|
||||
if _long_running_mode == "generic"
|
||||
else f"⏳ Working — {_elapsed_mins} min{_status_detail}"
|
||||
else t("gateway.progress.working_heartbeat", minutes=_elapsed_mins, detail=_status_detail)
|
||||
)
|
||||
try:
|
||||
_notify_res = None
|
||||
|
||||
+23
-19
@@ -19,11 +19,13 @@ from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from agent.i18n import t
|
||||
from agent.interrupt_compat import _accepts_keyword
|
||||
from agent.replay_cleanup import canonicalize_replay_history
|
||||
from gateway.config import Platform
|
||||
from gateway.media_repair import repair_explicit_computer_use_media_paths
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
from gateway.platforms.base_exec_approval import ea_default_reason_text
|
||||
from gateway.turn_context import TurnContext
|
||||
from hermes_cli.config import cfg_get
|
||||
from utils import is_truthy_value
|
||||
@@ -59,7 +61,8 @@ def _renders_exec_approval_buttons(adapter_cls: type) -> bool:
|
||||
|
||||
# Rendered on a native clarify card whose wait ended without a click (mirrors the notice the
|
||||
# Slack click handler shows on a dead entry).
|
||||
_CLARIFY_EXPIRED_NOTICE = "⏳ This prompt expired — please send a new request."
|
||||
def _clarify_expired_notice() -> str:
|
||||
return t("gateway.clarify.expired")
|
||||
|
||||
|
||||
class _ExecApprovalDeclined(RuntimeError):
|
||||
@@ -146,7 +149,7 @@ class TurnRunner:
|
||||
if event_type == "_thinking" or tool_name == "_thinking":
|
||||
thinking_text = (preview if tool_name == "_thinking" else tool_name) if ctx._thinking_enabled else None
|
||||
if thinking_text:
|
||||
ctx.progress_queue.put(f"💬 {thinking_text}")
|
||||
ctx.progress_queue.put(t("gateway.progress.thinking_prefix", text=thinking_text))
|
||||
return
|
||||
# Native task cards consume the ID-bearing tool_start/tool_complete callbacks instead;
|
||||
# name-correlated text events would duplicate cards and mispair concurrent same-tool calls.
|
||||
@@ -248,7 +251,7 @@ class TurnRunner:
|
||||
):
|
||||
return None, None
|
||||
cmd_full = args["command"].rstrip()
|
||||
header = "" if self._ctx.last_was_terminal_block[0] else f"{emoji} {tool_name}\n"
|
||||
header = "" if self._ctx.last_was_terminal_block[0] else t("gateway.progress.tool_head", emoji=emoji, tool=tool_name) + "\n"
|
||||
cap = self._preview_cap()
|
||||
lines = cmd_full.splitlines()
|
||||
cmd_short = lines[0] if lines else cmd_full
|
||||
@@ -280,15 +283,16 @@ class TurnRunner:
|
||||
# for full detail and platform message-length limits handle the rest.
|
||||
if pl > 0 and len(args_str) > pl:
|
||||
args_str = args_str[:pl - 3] + "..."
|
||||
code = f"{emoji} {tool_name}({list(args.keys())})\n{args_str}"
|
||||
code = t("gateway.progress.tool_verbose", emoji=emoji, tool=tool_name, keys=list(args.keys()), args=args_str)
|
||||
elif code is None:
|
||||
code = f"{emoji} {tool_name}: \"{preview}\"" if preview else f"{emoji} {tool_name}..."
|
||||
code = (t("gateway.progress.tool_preview", emoji=emoji, tool=tool_name, preview=preview) if preview
|
||||
else t("gateway.progress.tool_pending", emoji=emoji, tool=tool_name))
|
||||
ctx.progress_queue.put(code)
|
||||
return None
|
||||
if code is not None:
|
||||
return code
|
||||
if not preview:
|
||||
return f"{emoji} {tool_name}..."
|
||||
return t("gateway.progress.tool_pending", emoji=emoji, tool=tool_name)
|
||||
from agent.display import get_tool_verb, prepare_tool_preview, tool_verb_connector, verb_drops_preview
|
||||
prepared = prepare_tool_preview(tool_name, args, fallback=preview, max_len=self._preview_cap())
|
||||
preview = adapter.format_tool_preview(prepared) if adapter is not None else prepared.text
|
||||
@@ -296,7 +300,7 @@ class TurnRunner:
|
||||
# by prefixing the verb onto the computed preview, so the command/url/query is kept.
|
||||
verb = get_tool_verb(tool_name)
|
||||
if not verb:
|
||||
return f"{emoji} {tool_name}: \"{preview}\""
|
||||
return t("gateway.progress.tool_preview", emoji=emoji, tool=tool_name, preview=preview)
|
||||
return f"{emoji} {verb}" if verb_drops_preview(tool_name) else f"{emoji} {verb}{tool_verb_connector(tool_name)}{preview}"
|
||||
|
||||
def _progress_emit(self, msg: str) -> None:
|
||||
@@ -346,9 +350,12 @@ class TurnRunner:
|
||||
return [self.tasks[task_id] for task_id in self.task_order[-8:]]
|
||||
|
||||
def fallback_text(self) -> str:
|
||||
labels = {"in_progress": "running", "complete": "complete", "error": "error"}
|
||||
lines = [f"- {t['title']} - {labels.get(t['status'], t['status'])}" for t in self.visible_tasks()]
|
||||
return "Hermes is working\n" + "\n".join(lines)
|
||||
labels = {"in_progress": t("gateway.progress.task_status_running"),
|
||||
"complete": t("gateway.progress.task_status_complete"),
|
||||
"error": t("gateway.progress.task_status_error")}
|
||||
lines = [t("gateway.progress.task_line", title=task["title"], status=labels.get(task["status"], task["status"]))
|
||||
for task in self.visible_tasks()]
|
||||
return t("gateway.progress.task_card_title") + "\n" + "\n".join(lines)
|
||||
|
||||
def _upsert(self, call_id: str, title: str) -> Dict[str, str]:
|
||||
if call_id not in self.tasks:
|
||||
@@ -425,7 +432,7 @@ class TurnRunner:
|
||||
return
|
||||
if not st.native_failed:
|
||||
result = await st.adapter.send_native_task_card_progress(
|
||||
chat_id=ctx.source.chat_id, tasks=st.visible_tasks(), title="Hermes is working",
|
||||
chat_id=ctx.source.chat_id, tasks=st.visible_tasks(), title=t("gateway.progress.task_card_title"),
|
||||
reply_to=ctx._progress_reply_to, metadata=ctx._progress_metadata, fallback_text=st.fallback_text(),
|
||||
)
|
||||
if getattr(result, "success", False):
|
||||
@@ -1405,7 +1412,7 @@ class TurnRunner:
|
||||
)
|
||||
# Unlike approval, clarify passes reopen=True so the continuation re-opens a native stream
|
||||
# below the question; if the re-seed fails the consumer degrades to send() automatically.
|
||||
self._close_native_stream_boundary("Clarify", "💬 等待你的选择...", reopen=True)
|
||||
self._close_native_stream_boundary("Clarify", t("gateway.clarify.native_stream_placeholder"), reopen=True)
|
||||
# Pause typing: a "thinking..." status must not obscure the prompt or block an "Other" reply
|
||||
# on platforms that disable input while typing (Slack Assistant).
|
||||
with suppress(Exception):
|
||||
@@ -1438,7 +1445,7 @@ class TurnRunner:
|
||||
retire = getattr(type(ctx._status_adapter), "retire_clarify_card", None)
|
||||
if callable(retire):
|
||||
self._schedule(
|
||||
retire(ctx._status_adapter, clarify_id, _CLARIFY_EXPIRED_NOTICE),
|
||||
retire(ctx._status_adapter, clarify_id, _clarify_expired_notice()),
|
||||
"Clarify card retire failed to schedule")
|
||||
elif rearm:
|
||||
# Reopen typing IMMEDIATELY, not on the LLM's first post-answer token (native streaming
|
||||
@@ -1471,7 +1478,7 @@ class TurnRunner:
|
||||
# Redact credentials before display: Tirith's findings are already redacted, but the raw
|
||||
# command string still leaks secrets. Both the button and plain-text paths use this value.
|
||||
cmd = _redact_approval_command(approval_data.get("command", ""))
|
||||
desc = approval_data.get("description", "dangerous command")
|
||||
desc = approval_data.get("description") or ea_default_reason_text()
|
||||
flags = {k: approval_data.get(k, d) for k, d in (("allow_permanent", True), ("allow_session", True), ("smart_denied", False))}
|
||||
# Check the *class*, not the instance — MagicMock auto-creates attributes in tests.
|
||||
if _renders_exec_approval_buttons(type(adapter)):
|
||||
@@ -1930,10 +1937,7 @@ class TurnRunner:
|
||||
return {"final_response": _gateway_provider_error_reply(str(exc)),
|
||||
"messages": [], "api_calls": 0, "tools": []}
|
||||
return {
|
||||
"final_response": (
|
||||
"⚠️ I couldn't connect to the AI model service, so this message wasn't processed. "
|
||||
"Use /login to sign in again, or /model to pick a different model. If it keeps "
|
||||
"failing, run `hermes doctor` on the host."),
|
||||
"final_response": t("gateway.errors.no_credentials"),
|
||||
"messages": [], "api_calls": 0, "tools": [],
|
||||
}
|
||||
pr = runner._provider_routing
|
||||
@@ -1989,7 +1993,7 @@ class TurnRunner:
|
||||
final_response = _normalize_empty_agent_response(result, final_response or "", history_len=len(agent_history))
|
||||
final_response = _sanitize_gateway_final_response(ctx.source.platform, final_response)
|
||||
if not final_response:
|
||||
final_response = f"⚠️ {result['error']}" if result.get("error") else ""
|
||||
final_response = t("gateway.shared.warn_passthrough", error=result["error"]) if result.get("error") else ""
|
||||
# NOTE: deliberately omits agent_persisted/last_reasoning/response_* — the caller
|
||||
# defaults agent_persisted differently when the key is absent.
|
||||
return {"final_response": final_response, **common}
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from agent.i18n import t
|
||||
from gateway.platforms.base_exec_approval import approval_timeout_seconds, format_approval_timed_out_notice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,7 +62,9 @@ async def _post_timeout_notice(ctx, command: str, card_message_id: Optional[str]
|
||||
metadata = _interim_metadata(ctx._status_thread_metadata)
|
||||
try:
|
||||
# Plain markdown, not the card's platform markup: ``edit_message`` re-formats it itself.
|
||||
if card_message_id and await _edit_card(adapter, ctx._status_chat_id, card_message_id, f"{notice}\n```\n{command}\n```"):
|
||||
if card_message_id and await _edit_card(
|
||||
adapter, ctx._status_chat_id, card_message_id,
|
||||
t("gateway.exec_approval.timed_out_card_body", notice=notice, command=command)):
|
||||
return
|
||||
await adapter.send(ctx._status_chat_id, notice, metadata=metadata)
|
||||
except Exception:
|
||||
|
||||
+11
-12
@@ -18,6 +18,7 @@ from difflib import SequenceMatcher
|
||||
from types import SimpleNamespace
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from agent.i18n import t
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import build_auto_tts_output_path
|
||||
from gateway.platforms.event import MessageEvent, MessageType
|
||||
@@ -147,13 +148,13 @@ class GatewayVoiceMixin:
|
||||
async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
|
||||
adapter = self._delivery_adapter_for(event.source)
|
||||
if not hasattr(adapter, "join_voice_channel"):
|
||||
return "Voice channels are not supported on this platform."
|
||||
return t("gateway.voice.channel_unsupported")
|
||||
guild_id = self._get_guild_id(event)
|
||||
if not guild_id:
|
||||
return "This command only works in a Discord server."
|
||||
return t("gateway.voice.channel_guild_only")
|
||||
voice_channel = await adapter.get_user_voice_channel(guild_id, event.source.user_id)
|
||||
if not voice_channel:
|
||||
return "You need to be in a voice channel first."
|
||||
return t("gateway.voice.channel_not_in_voice")
|
||||
# Wire callbacks BEFORE join so voice input arriving right after connection is not lost.
|
||||
self._bind_voice_input_callback(adapter)
|
||||
voice_profile = self._adapter_profile_for_source(event.source)
|
||||
@@ -171,19 +172,17 @@ class GatewayVoiceMixin:
|
||||
logger.warning("Failed to join voice channel: %s", e)
|
||||
adapter._voice_input_callback = None
|
||||
if not any(tok in str(e).lower() for tok in ("pynacl", "nacl", "davey")):
|
||||
return f"Failed to join voice channel: {e}"
|
||||
return ("Voice dependencies are missing (PyNaCl / davey). "
|
||||
f"Run: `{install_hint('discord')}`")
|
||||
return t("gateway.voice.channel_join_failed", error=e)
|
||||
return t("gateway.voice.channel_deps_missing", hint=install_hint("discord"))
|
||||
if not success:
|
||||
adapter._voice_input_callback = None
|
||||
return "Failed to join voice channel. Check bot permissions (Connect + Speak)."
|
||||
return t("gateway.voice.channel_join_permissions")
|
||||
adapter._voice_text_channels[guild_id] = int(event.source.chat_id)
|
||||
if hasattr(adapter, "_voice_sources"):
|
||||
adapter._voice_sources[guild_id] = event.source.to_dict()
|
||||
self._apply_voice_mode(adapter, self._voice_key_for_source(event.source),
|
||||
event.source.chat_id, "all")
|
||||
return (f"Joined voice channel **{voice_channel.name}**.\n"
|
||||
f"I'll speak my replies and listen to you. Use /voice leave to disconnect.")
|
||||
return t("gateway.voice.channel_joined", name=voice_channel.name)
|
||||
|
||||
async def _handle_voice_channel_leave(self, event: MessageEvent) -> str:
|
||||
adapter = self._delivery_adapter_for(event.source)
|
||||
@@ -191,7 +190,7 @@ class GatewayVoiceMixin:
|
||||
if not (guild_id and hasattr(adapter, "leave_voice_channel")
|
||||
and hasattr(adapter, "is_in_voice_channel")
|
||||
and adapter.is_in_voice_channel(guild_id)):
|
||||
return "Not in a voice channel."
|
||||
return t("gateway.voice.channel_not_joined")
|
||||
try:
|
||||
await adapter.leave_voice_channel(guild_id)
|
||||
except Exception as e:
|
||||
@@ -201,7 +200,7 @@ class GatewayVoiceMixin:
|
||||
event.source.chat_id, "off")
|
||||
if hasattr(adapter, "_voice_input_callback"):
|
||||
adapter._voice_input_callback = None
|
||||
return "Left voice channel."
|
||||
return t("gateway.voice.channel_left")
|
||||
|
||||
def _handle_voice_timeout_cleanup(self, chat_id: str, *, adapter=None) -> None:
|
||||
"""Adapter callback on voice-channel timeout: clear runner-side voice_mode state.
|
||||
@@ -281,7 +280,7 @@ class GatewayVoiceMixin:
|
||||
if channel:
|
||||
safe_text = transcript[:2000].replace("@everyone", "@\u200beveryone")
|
||||
safe_text = safe_text.replace("@here", "@\u200bhere")
|
||||
await channel.send(f"**[Voice]** <@{user_id}>: {safe_text}")
|
||||
await channel.send(t("gateway.voice.transcript_echo", user=user_id, text=safe_text))
|
||||
# Bound text channel's channel_prompt: voice input gets the same per-channel context.
|
||||
channel_prompt = None
|
||||
if callable(resolver := getattr(adapter, "_resolve_channel_prompt", None)):
|
||||
|
||||
+53
-75
@@ -29,7 +29,7 @@ from gateway.slash_commands_goals import GatewayGoalCommandsMixin
|
||||
from gateway.slash_commands_model import GatewayModelCommandsMixin
|
||||
from gateway.slash_commands_session import GatewaySessionCommandsMixin
|
||||
from gateway.slash_commands_login import GatewayLoginCommandsMixin
|
||||
from gateway.slash_commands_status import HISTORY_UNREADABLE, GatewayStatusCommandsMixin
|
||||
from gateway.slash_commands_status import GatewayStatusCommandsMixin, history_unreadable
|
||||
from hermes_cli.config import atomic_config_write, cfg_get
|
||||
from utils import atomic_json_write, is_truthy_value
|
||||
|
||||
@@ -41,12 +41,11 @@ _ROLLBACK_SKIP_LINES = (("skipped_user_edits", "gateway.rollback.kept_user_edits
|
||||
("skipped_oversize", "gateway.rollback.kept_oversize"),
|
||||
("failed_deletes", "gateway.rollback.failed_deletes"))
|
||||
|
||||
# /busy input modes -> (status-card behavior, set-confirmation behavior).
|
||||
# /busy input modes -> (status-card behavior key, set-confirmation behavior key); text via t().
|
||||
_BUSY_MODE_BEHAVIOR = {
|
||||
"queue": ("queues for next turn", "Messages will be queued for the next turn while Hermes is busy."),
|
||||
"steer": ("steers into current run (after next tool call)",
|
||||
"Messages will be steered into the current run (after the next tool call)."),
|
||||
"interrupt": ("interrupts current run", "Messages will interrupt the current run while Hermes is busy."),
|
||||
"queue": ("gateway.busy.behavior_queue_short", "gateway.busy.behavior_queue_long"),
|
||||
"steer": ("gateway.busy.behavior_steer_short", "gateway.busy.behavior_steer_long"),
|
||||
"interrupt": ("gateway.busy.behavior_interrupt_short", "gateway.busy.behavior_interrupt_long"),
|
||||
}
|
||||
|
||||
# /diff argument -> diff mode (unknown args leave the mode unchanged).
|
||||
@@ -67,10 +66,6 @@ _FOOTER_STATE_BY_ARG = {**dict.fromkeys(("on", "enable", "true", "1"), True),
|
||||
_APPROVE_CHOICE_BY_ARG = {**dict.fromkeys(("always", "permanent", "permanently"), "always"),
|
||||
**dict.fromkeys(("session", "ses"), "session")}
|
||||
|
||||
_PLATFORM_USAGE = ("Usage: /platform <list|pause|resume> [name]\n"
|
||||
" /platform list — show platform status\n"
|
||||
" /platform pause <name> — stop retrying a failing platform\n"
|
||||
" /platform resume <name> — re-queue a paused platform")
|
||||
|
||||
_WINDOWS_UPDATE_HELPER = """
|
||||
import os, subprocess, sys
|
||||
@@ -331,18 +326,18 @@ class GatewaySlashCommandsMixin(
|
||||
policy = policy_for_runner_source(self, source)
|
||||
platform = source.platform.value if source and source.platform else "?"
|
||||
chat_type = ((source.chat_type if source else "") or "dm").lower()
|
||||
scope = "DM" if chat_type in {"dm", "direct", "private", ""} else "group/channel"
|
||||
scope = t("gateway.whoami.scope_dm" if chat_type in {"dm", "direct", "private", ""} else "gateway.whoami.scope_group")
|
||||
user_id = (source.user_id if source else None) or "?"
|
||||
head = f"**You** — {platform} ({scope})\nUser ID: `{user_id}`\n"
|
||||
head = t("gateway.whoami.header", platform=platform, scope=scope, user_id=user_id)
|
||||
if not policy.enabled:
|
||||
return head + "Tier: unrestricted (no admin list configured for this scope)\nSlash commands: all available"
|
||||
return head + t("gateway.whoami.tier_unrestricted")
|
||||
if policy.is_admin(user_id):
|
||||
return head + "Tier: **admin**\nSlash commands: all available"
|
||||
return head + t("gateway.whoami.tier_admin")
|
||||
# Non-admin: floor first (mirrors slash_access._ALWAYS_ALLOWED_FOR_USERS), then operator
|
||||
# additions, deduped in order.
|
||||
runnable = list(dict.fromkeys(["help", "whoami"] + sorted(policy.user_allowed_commands)))
|
||||
runnable_str = ", ".join(f"/{c}" for c in runnable) if runnable else "(none)"
|
||||
return head + f"Tier: user\nSlash commands you can run: {runnable_str}"
|
||||
runnable_str = ", ".join(f"/{c}" for c in runnable) if runnable else t("gateway.shared.none_marker")
|
||||
return head + t("gateway.whoami.tier_user", commands=runnable_str)
|
||||
|
||||
async def _handle_kanban_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /kanban — delegate to the shared kanban CLI (DB work in a thread pool). Allowed
|
||||
@@ -497,39 +492,39 @@ class GatewaySlashCommandsMixin(
|
||||
target = parts[1].lower() if len(parts) > 1 else ""
|
||||
failed = getattr(self, "_failed_platforms", {}) or {}
|
||||
if action == "list":
|
||||
connected = ", ".join(sorted(p.value for p in self.adapters)) or "(none)"
|
||||
lines = ["**Gateway platforms**", f"Connected: {connected}"]
|
||||
connected = ", ".join(sorted(p.value for p in self.adapters)) or t("gateway.shared.none_marker")
|
||||
lines = [t("gateway.platform.header"), t("gateway.platform.connected", platforms=connected)]
|
||||
for p, info in failed.items():
|
||||
if info.get("paused"):
|
||||
reason = info.get("pause_reason") or "paused"
|
||||
lines.append(f" · {p.value} — PAUSED ({reason}). Resume with `/platform resume {p.value}`.")
|
||||
reason = info.get("pause_reason") or t("gateway.platform.reason_default")
|
||||
lines.append(t("gateway.platform.item_paused", name=p.value, reason=reason))
|
||||
else:
|
||||
lines.append(f" · {p.value} — retrying (attempt {info.get('attempts', 0)})")
|
||||
return "\n".join(lines + ([] if failed else ["Failed/paused: (none)"]))
|
||||
lines.append(t("gateway.platform.item_retrying", name=p.value, attempts=info.get("attempts", 0)))
|
||||
return "\n".join(lines + ([] if failed else [t("gateway.platform.none_failed")]))
|
||||
if action not in {"pause", "resume"}:
|
||||
return _PLATFORM_USAGE
|
||||
return t("gateway.platform.usage")
|
||||
if not target:
|
||||
return f"Usage: /platform {action} <name>"
|
||||
return t("gateway.platform.usage_action", action=action)
|
||||
# Resolve platform name (case-insensitive, value match)
|
||||
platform = next((p for p in Platform.__members__.values() if p.value.lower() == target), None)
|
||||
if platform is None:
|
||||
return f"Unknown platform: {target}"
|
||||
return t("gateway.platform.unknown", name=target)
|
||||
name = platform.value
|
||||
queued = platform in failed
|
||||
paused = queued and bool(failed[platform].get("paused"))
|
||||
if action == "pause":
|
||||
if not queued:
|
||||
return f"{name} is not in the retry queue (it's either connected or not enabled)."
|
||||
return t("gateway.platform.not_queued_pause", name=name)
|
||||
if paused:
|
||||
return f"{name} is already paused."
|
||||
self._pause_failed_platform(platform, reason="paused via /platform pause")
|
||||
return f"✓ {name} paused. Resume with `/platform resume {name}` or `hermes gateway restart` to reset."
|
||||
return t("gateway.platform.already_paused", name=name)
|
||||
self._pause_failed_platform(platform, reason=t("gateway.platform.reason_manual"))
|
||||
return t("gateway.platform.paused", name=name)
|
||||
if not queued:
|
||||
return f"{name} is not in the retry queue — nothing to resume."
|
||||
return t("gateway.platform.not_queued_resume", name=name)
|
||||
if not paused:
|
||||
return f"{name} is already retrying — no resume needed."
|
||||
return t("gateway.platform.already_retrying", name=name)
|
||||
self._resume_paused_platform(platform)
|
||||
return f"✓ {name} resumed — retrying on next watcher tick."
|
||||
return t("gateway.platform.resumed", name=name)
|
||||
|
||||
async def _handle_restart_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
||||
"""Handle /restart command - drain active work, then restart the gateway."""
|
||||
@@ -628,7 +623,7 @@ class GatewaySlashCommandsMixin(
|
||||
chat_id = source.chat_id
|
||||
chat_name = source.chat_name or chat_id
|
||||
if source.platform is None:
|
||||
return t("gateway.set_home.save_failed", error="Missing logical platform")
|
||||
return t("gateway.set_home.save_failed", error=t("gateway.set_home.err_missing_platform"))
|
||||
via_relay = getattr(source, "delivered_via_upstream_relay", False) is True
|
||||
if via_relay:
|
||||
adapter_for_source = getattr(self, "_intake_adapter_for", None)
|
||||
@@ -637,8 +632,7 @@ class GatewaySlashCommandsMixin(
|
||||
if (source.platform in {None, Platform.LOCAL, Platform.RELAY}
|
||||
or not getattr(source, "user_id", None)
|
||||
or not callable(fronts_platform) or not fronts_platform(source.platform)):
|
||||
return t("gateway.set_home.save_failed",
|
||||
error="Relay does not authenticate this logical home target")
|
||||
return t("gateway.set_home.save_failed", error=t("gateway.set_home.err_relay_unauthenticated"))
|
||||
thread_id = _home_thread_from_source(source)
|
||||
home = HomeChannel(
|
||||
platform=source.platform, chat_id=str(chat_id), name=chat_name, thread_id=thread_id,
|
||||
@@ -780,7 +774,7 @@ class GatewaySlashCommandsMixin(
|
||||
from tools.working_diff import collect_working_diff
|
||||
result = await asyncio.to_thread(collect_working_diff, cwd, mode)
|
||||
if not result.get("success"):
|
||||
return t("gateway.diff.failed", error=result.get("error", "Could not generate diff"))
|
||||
return t("gateway.diff.failed", error=result.get("error") or t("gateway.diff.err_default"))
|
||||
return self._render_diff_result(result, stat_only)
|
||||
|
||||
def _render_diff_result(self, result: dict, stat_only: bool) -> str:
|
||||
@@ -795,8 +789,8 @@ class GatewaySlashCommandsMixin(
|
||||
out.append(f"```\n{stat}\n```")
|
||||
if untracked:
|
||||
shown = "\n".join(f"+ {rel}" for rel in untracked[:15])
|
||||
more = f"\n... and {len(untracked) - 15} more" if len(untracked) > 15 else ""
|
||||
out.append(f"**Untracked:**\n```\n{shown}{more}\n```")
|
||||
more = t("gateway.diff.more_untracked", count=len(untracked) - 15) if len(untracked) > 15 else ""
|
||||
out.append(f"{t('gateway.diff.untracked_label')}\n```\n{shown}{more}\n```")
|
||||
if not stat_only and diff:
|
||||
out.append(self._fenced_truncated_diff(diff))
|
||||
return "\n\n".join(out)
|
||||
@@ -813,7 +807,7 @@ class GatewaySlashCommandsMixin(
|
||||
truncated = True
|
||||
note = ""
|
||||
if truncated:
|
||||
note = f"\n... (truncated — {len(diff_lines)} lines total; use /diff --stat for a summary)"
|
||||
note = t("gateway.diff.truncated_note", lines=len(diff_lines))
|
||||
return f"```diff\n{diff}{note}\n```"
|
||||
|
||||
def _track_background_task(self, coro) -> None:
|
||||
@@ -847,7 +841,7 @@ class GatewaySlashCommandsMixin(
|
||||
try:
|
||||
history = await self.async_session_store.load_transcript(session_entry.session_id)
|
||||
except TranscriptReadError:
|
||||
return HISTORY_UNREADABLE
|
||||
return history_unreadable()
|
||||
if not history:
|
||||
return t("gateway.btw.no_history")
|
||||
try:
|
||||
@@ -900,9 +894,7 @@ class GatewaySlashCommandsMixin(
|
||||
out = handle_pending_subcommand(
|
||||
wa.MEMORY, event.get_command_args().strip().split(), memory_store=load_on_disk_store(),
|
||||
set_mode_fn=self._write_approval_setter("memory", event))
|
||||
return out if out is not None else (
|
||||
"Unknown /memory subcommand. Use: pending, approve <id>, reject <id>, approval <on|off>."
|
||||
)
|
||||
return out if out is not None else t("gateway.memory.unknown_subcommand")
|
||||
|
||||
async def _handle_skills_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /skills on the gateway — pending skill-write review only (hub stays CLI-only). Gated
|
||||
@@ -914,23 +906,17 @@ class GatewaySlashCommandsMixin(
|
||||
sub = args[0].lower() if args else ""
|
||||
gate_off = not wa.write_approval_enabled(wa.SKILLS) and sub not in {"approval", "mode"}
|
||||
if gate_off and wa.pending_count(wa.SKILLS) == 0:
|
||||
return ("Skill write approval is off (skills.write_approval). "
|
||||
"Enable it with /skills approval on, then review staged "
|
||||
"writes here with /skills pending.")
|
||||
return t("gateway.skills.gate_off")
|
||||
out = handle_pending_subcommand(
|
||||
wa.SKILLS, args, set_mode_fn=self._write_approval_setter("skills", event))
|
||||
if out is None:
|
||||
return ("Unknown /skills subcommand on this platform. Use: pending, "
|
||||
"approve <id>, reject <id>, diff <id>, approval <on|off>. "
|
||||
"(Search/install are CLI-only.)")
|
||||
return t("gateway.skills.unknown_subcommand")
|
||||
|
||||
# Chat bubbles can't hold a full skill diff — truncate and point at the pending JSON file
|
||||
# (NOT `hermes skills diff <name>`, which diffs a bundled skill against its stock version).
|
||||
if sub == "diff" and len(out) > 3000:
|
||||
pending_id = args[1] if len(args) > 1 else "<id>"
|
||||
out = (out[:3000]
|
||||
+ "\n… (truncated — full diff in "
|
||||
f"~/.hermes/pending/skills/{pending_id}.json)")
|
||||
out = out[:3000] + t("gateway.skills.diff_truncated", pending_id=pending_id)
|
||||
return out
|
||||
|
||||
async def _handle_approvals_command(self, event: MessageEvent) -> str:
|
||||
@@ -943,7 +929,7 @@ class GatewaySlashCommandsMixin(
|
||||
# Unconfigured policies remain unrestricted.
|
||||
policy = policy_for_runner_source(self, event.source)
|
||||
if requested and not policy.is_admin(event.source.user_id):
|
||||
return "Only gateway admins can change the persistent approval mode."
|
||||
return t("gateway.approvals.admin_only")
|
||||
# Approval checks load config dynamically; do not evict the cached agent or alter its
|
||||
# system prompt/tool schema (prompt-cache prefix is sacred).
|
||||
return run_approval_mode_command(requested).message
|
||||
@@ -990,18 +976,15 @@ class GatewaySlashCommandsMixin(
|
||||
arg = event.get_command_args().strip().lower()
|
||||
if not arg or arg == "status":
|
||||
mode = self._effective_busy_input_mode(event.source)
|
||||
behavior = _BUSY_MODE_BEHAVIOR.get(mode, _BUSY_MODE_BEHAVIOR["interrupt"])[0]
|
||||
return EphemeralReply(
|
||||
f"**Busy input mode: `{mode}`\nMessages while busy: _{behavior}_\n"
|
||||
f"Change with `/busy queue`, `/busy steer`, or `/busy interrupt`.")
|
||||
behavior = t(_BUSY_MODE_BEHAVIOR.get(mode, _BUSY_MODE_BEHAVIOR["interrupt"])[0])
|
||||
return EphemeralReply(t("gateway.busy.status", mode=mode, behavior=behavior))
|
||||
if arg not in _BUSY_MODE_BEHAVIOR:
|
||||
return EphemeralReply(
|
||||
f"Unknown mode `{arg}`. Use `/busy queue`, `/busy steer`, or `/busy interrupt`.")
|
||||
return EphemeralReply(t("gateway.busy.unknown_mode", arg=arg))
|
||||
|
||||
# Persist before mutate
|
||||
from cli import save_config_value
|
||||
if not save_config_value("display.busy_input_mode", arg):
|
||||
return EphemeralReply("Busy input mode could not be saved to config. Mode unchanged.")
|
||||
return EphemeralReply(t("gateway.busy.save_failed"))
|
||||
profile_name = self._busy_profile_name_for_source(event.source)
|
||||
if profile_name:
|
||||
from gateway.run import _load_gateway_config
|
||||
@@ -1015,8 +998,7 @@ class GatewaySlashCommandsMixin(
|
||||
adapter = self._delivery_adapter_for(event.source)
|
||||
if adapter is not None:
|
||||
adapter._busy_text_mode = self._effective_busy_text_mode(event.source)
|
||||
return EphemeralReply(
|
||||
f"Busy input mode set to **`{arg}`** (saved).\n_{_BUSY_MODE_BEHAVIOR[arg][1]}_")
|
||||
return EphemeralReply(t("gateway.busy.set", mode=arg, behavior=t(_BUSY_MODE_BEHAVIOR[arg][1])))
|
||||
|
||||
async def _handle_footer_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /footer command — toggle the runtime-metadata footer."""
|
||||
@@ -1162,15 +1144,13 @@ class GatewaySlashCommandsMixin(
|
||||
return reply.text
|
||||
bundles = reply.data["bundles"]
|
||||
if not bundles:
|
||||
return ("No skill bundles installed.\nCreate one on the host with:\n"
|
||||
" `hermes bundles create <name> --skill <s1> --skill <s2>`\n"
|
||||
f"Directory: `{reply.data['dir']}`")
|
||||
lines = [f"**Skill Bundles** ({len(bundles)} installed):", ""]
|
||||
return t("gateway.bundles.none", dir=reply.data["dir"])
|
||||
lines = [t("gateway.bundles.header", count=len(bundles)), ""]
|
||||
for info in bundles:
|
||||
skills = info.get("skills", [])
|
||||
desc = info.get("description") or f"Load {len(skills)} skills"
|
||||
lines += [f"• `/{info['slug']}` — {desc} _({len(skills)} skills)_"] + [f" · {s}" for s in skills]
|
||||
return "\n".join(lines + ["", "Invoke a bundle with `/<slug>` to load all its skills."])
|
||||
desc = info.get("description") or t("gateway.bundles.default_desc", count=len(skills))
|
||||
lines += [t("gateway.bundles.item", slug=info["slug"], desc=desc, count=len(skills))] + [f" · {s}" for s in skills]
|
||||
return "\n".join(lines + ["", t("gateway.bundles.invoke_hint")])
|
||||
|
||||
def _blocking_approval_or_stale(self, event: MessageEvent, stale_key: str, none_key: str):
|
||||
"""``(session_key, None)`` when an agent thread is blocked on approval, else the reply to send.
|
||||
@@ -1241,7 +1221,7 @@ class GatewaySlashCommandsMixin(
|
||||
_best_effort_sweep_expired_pastes()
|
||||
report = collect_debug_report(log_lines=200, dump_text=_capture_dump())
|
||||
try:
|
||||
urls = {"Report": upload_to_pastebin(report)}
|
||||
urls = {t("gateway.debug.report_label"): upload_to_pastebin(report)}
|
||||
except Exception as exc:
|
||||
return t("gateway.debug.upload_failed", error=exc)
|
||||
_schedule_auto_delete(list(urls.values())) # paste.rs only; dpaste.com has no delete
|
||||
@@ -1278,7 +1258,7 @@ class GatewaySlashCommandsMixin(
|
||||
except Exception:
|
||||
return t("gateway.update.platform_not_messaging")
|
||||
if is_managed():
|
||||
return f"✗ {format_managed_message('update Hermes Agent')}"
|
||||
return f"✗ {format_managed_message(t('gateway.update.managed_action'))}"
|
||||
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
|
||||
@@ -1293,10 +1273,8 @@ class GatewaySlashCommandsMixin(
|
||||
|
||||
method = detect_install_method(project_root)
|
||||
if method not in {"git", "unknown"}:
|
||||
return (
|
||||
f"✗ `hermes update` does not apply to this install ({method}).\n"
|
||||
f"Update with: {recommended_update_command_for_method(method)}"
|
||||
)
|
||||
return t("gateway.update.not_applicable", method=method,
|
||||
command=recommended_update_command_for_method(method))
|
||||
except Exception:
|
||||
pass # config unreadable — fall through to the .git check below
|
||||
|
||||
|
||||
@@ -12,10 +12,6 @@ from gateway.platforms.event import MessageEvent, MessageType
|
||||
logger = logging.getLogger("gateway.run")
|
||||
|
||||
|
||||
def _plural(n: int, noun: str) -> str:
|
||||
return f"{n} {noun}{'s' if n != 1 else ''}"
|
||||
|
||||
|
||||
def _quiet_bool(fn) -> bool:
|
||||
try:
|
||||
return bool(fn())
|
||||
@@ -44,9 +40,7 @@ class GatewayGoalCommandsMixin:
|
||||
|
||||
def authorize_gate():
|
||||
if not self._resume_caller_is_admin(event.source):
|
||||
return ("⛔ /goal gate add requires an explicitly configured "
|
||||
"gateway admin (allow_admin_from for DMs, "
|
||||
"group_allow_admin_from for groups).")
|
||||
return t("gateway.goal.gate_add_admin_only")
|
||||
return None
|
||||
|
||||
def dispatch():
|
||||
@@ -104,7 +98,7 @@ class GatewayGoalCommandsMixin:
|
||||
lower = args.lower()
|
||||
mgr, _session_entry = await self._get_heartbeat_manager_for_event(event)
|
||||
if mgr is None:
|
||||
return "Heartbeats unavailable (no session)."
|
||||
return t("gateway.heartbeat.unavailable")
|
||||
quick_key = self._session_key_for_source(event.source) if event.source else None
|
||||
|
||||
def _watch():
|
||||
@@ -115,18 +109,18 @@ class GatewayGoalCommandsMixin:
|
||||
return mgr.status_line()
|
||||
if lower == "pause":
|
||||
state = mgr.pause()
|
||||
return f"⏸ Heartbeat paused: {state.prompt}" if state else "No heartbeat set."
|
||||
return t("gateway.heartbeat.paused", prompt=state.prompt) if state else t("gateway.heartbeat.none_set")
|
||||
if lower == "resume":
|
||||
state = mgr.resume()
|
||||
if state is None:
|
||||
return "No heartbeat to resume."
|
||||
return t("gateway.heartbeat.no_resume")
|
||||
_watch()
|
||||
return f"▶ Heartbeat resumed (every {format_interval(state.interval_seconds)}): {state.prompt}"
|
||||
return t("gateway.heartbeat.resumed", interval=format_interval(state.interval_seconds), prompt=state.prompt)
|
||||
if lower in {"clear", "stop", "off"}:
|
||||
had = mgr.clear()
|
||||
if quick_key:
|
||||
self._unregister_heartbeat_watch(quick_key)
|
||||
return "✓ Heartbeat cleared." if had else "No heartbeat set."
|
||||
return t("gateway.heartbeat.cleared") if had else t("gateway.heartbeat.none_set")
|
||||
|
||||
# Set: `/heartbeat every 10m <prompt>` (also accepts `10m <prompt>`).
|
||||
tokens = args.split(None, 2)
|
||||
@@ -138,35 +132,29 @@ class GatewayGoalCommandsMixin:
|
||||
interval = parse_interval(tokens[0])
|
||||
prompt = args[len(tokens[0]):].strip() if interval and interval > 0 else ""
|
||||
if interval is None:
|
||||
return (
|
||||
"Usage: /heartbeat every <interval> <prompt> (e.g. /heartbeat every 10m Check CI)\n"
|
||||
"Also: /heartbeat status | pause | resume | clear"
|
||||
)
|
||||
return t("gateway.heartbeat.usage")
|
||||
if interval < 0:
|
||||
return f"Interval too small — minimum is {MIN_INTERVAL_SECONDS}s."
|
||||
return t("gateway.heartbeat.interval_too_small", min_seconds=MIN_INTERVAL_SECONDS)
|
||||
if not prompt.strip():
|
||||
return "Usage: /heartbeat every <interval> <prompt> — the prompt is required."
|
||||
state, err = _mgr_call("Invalid heartbeat", mgr.set, prompt, interval, errors=(ValueError,))
|
||||
return t("gateway.heartbeat.prompt_required")
|
||||
state, err = _mgr_call(t("gateway.heartbeat.invalid_prefix"), mgr.set, prompt, interval, errors=(ValueError,))
|
||||
if err:
|
||||
return err
|
||||
_watch()
|
||||
return (
|
||||
f"♥ Heartbeat set (every {format_interval(state.interval_seconds)}): {state.prompt}\n"
|
||||
"Fires as a normal turn whenever this session is idle and the interval has "
|
||||
"elapsed. Lives while the gateway runs — use `hermes cron` for durable schedules."
|
||||
)
|
||||
return t("gateway.heartbeat.set", interval=format_interval(state.interval_seconds), prompt=state.prompt)
|
||||
|
||||
def _idle_cached_agent_or_error(self, event: MessageEvent, verb: str):
|
||||
"""``(session_key, cached_agent, None)`` for /refine and /review, or ``(_, _, error_text)``:
|
||||
both need a cached agent from a completed turn and refuse while a run is in flight."""
|
||||
both need a cached agent from a completed turn and refuse while a run is in flight.
|
||||
``verb`` is the command name (``refine`` / ``review``) and selects ``gateway.<verb>.*`` copy."""
|
||||
quick_key = self._session_key_for_source(event.source) if event.source else None
|
||||
if not quick_key:
|
||||
return None, None, f"{verb.capitalize()} unavailable (no session)."
|
||||
return None, None, t(f"gateway.{verb}.unavailable")
|
||||
if quick_key in self._running_agents:
|
||||
return quick_key, None, f"Agent is running — wait for the turn to finish, then /{verb}."
|
||||
return quick_key, None, t("gateway.shared.agent_running_retry_later", command=verb)
|
||||
agent = self._cached_agent_for(quick_key)
|
||||
if agent is None:
|
||||
return quick_key, None, f"Nothing to {verb} yet — send a message first."
|
||||
return quick_key, None, t(f"gateway.{verb}.nothing_yet")
|
||||
return quick_key, agent, None
|
||||
|
||||
async def _handle_refine_command(self, event: MessageEvent) -> str:
|
||||
@@ -178,19 +166,16 @@ class GatewayGoalCommandsMixin:
|
||||
return error
|
||||
snapshot = list(getattr(agent, "_session_messages", None) or [])
|
||||
if not snapshot:
|
||||
return "Nothing to refine yet — the conversation is empty."
|
||||
return t("gateway.refine.empty")
|
||||
try:
|
||||
agent._spawn_background_review(
|
||||
messages_snapshot=snapshot, review_memory=True,
|
||||
review_skills="skill_manage" in getattr(agent, "valid_tool_names", set()), focus=args or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
return f"/refine failed to start: {exc}"
|
||||
tail = f" (focus: {args})" if args else ""
|
||||
return (
|
||||
f"⚗ Reviewing this conversation in the background{tail} — "
|
||||
f"any memory/skill updates will be reported when done."
|
||||
)
|
||||
return t("gateway.refine.start_failed", error=exc)
|
||||
tail = t("gateway.refine.focus_suffix", focus=args) if args else ""
|
||||
return t("gateway.refine.started", focus_suffix=tail)
|
||||
|
||||
async def _handle_review_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /review — spawn an independent reviewer subagent. The approval session-key
|
||||
@@ -218,7 +203,7 @@ class GatewayGoalCommandsMixin:
|
||||
except ValueError as exc:
|
||||
return str(exc)
|
||||
except Exception as exc:
|
||||
return f"/review failed to start: {exc}"
|
||||
return t("gateway.review.start_failed", error=exc)
|
||||
from agent.review_engine import format_dispatch_note
|
||||
return format_dispatch_note(result, args)
|
||||
|
||||
@@ -231,7 +216,7 @@ class GatewayGoalCommandsMixin:
|
||||
if mgr is None:
|
||||
return t("gateway.goal.unavailable")
|
||||
if not mgr.has_goal():
|
||||
return "No active goal. Set one with /goal <text>."
|
||||
return t("gateway.subgoal.no_goal")
|
||||
if not args:
|
||||
return f"{mgr.status_line()}\n{mgr.render_subgoals()}"
|
||||
tokens = args.split(None, 1)
|
||||
@@ -239,25 +224,27 @@ class GatewayGoalCommandsMixin:
|
||||
rest = tokens[1].strip() if len(tokens) > 1 else ""
|
||||
if verb == "remove":
|
||||
if not rest:
|
||||
return "Usage: /subgoal remove <n>"
|
||||
return t("gateway.subgoal.usage_remove")
|
||||
try:
|
||||
idx = int(rest.split()[0])
|
||||
except ValueError:
|
||||
return "/subgoal remove: <n> must be an integer (1-based index)."
|
||||
return t("gateway.subgoal.remove_not_int")
|
||||
removed, err = _mgr_call(
|
||||
"/subgoal remove", mgr.remove_subgoal, idx, errors=(IndexError, RuntimeError)
|
||||
)
|
||||
return err or f"✓ Removed subgoal {idx}: {removed}"
|
||||
return err or t("gateway.subgoal.removed", index=idx, text=removed)
|
||||
if verb == "clear":
|
||||
prev, err = _mgr_call("/subgoal clear", mgr.clear_subgoals, errors=(RuntimeError,))
|
||||
if err:
|
||||
return err
|
||||
return f"✓ Cleared {_plural(prev, 'subgoal')}." if prev else "No subgoals to clear."
|
||||
if not prev:
|
||||
return t("gateway.subgoal.none_to_clear")
|
||||
return t("gateway.subgoal.cleared_one" if prev == 1 else "gateway.subgoal.cleared_other", count=prev)
|
||||
text, err = _mgr_call("/subgoal", mgr.add_subgoal, args)
|
||||
if err:
|
||||
return err
|
||||
idx = len(mgr.state.subgoals) if mgr.state else 0
|
||||
return f"✓ Added subgoal {idx}: {text}"
|
||||
return t("gateway.subgoal.added", index=idx, text=text)
|
||||
|
||||
async def _handle_loop_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /loop — recurring in-session wakeups, via ``dispatch_loop_command`` (CLI mirror)."""
|
||||
@@ -265,7 +252,7 @@ class GatewayGoalCommandsMixin:
|
||||
from hermes_cli.loops import LoopManager, dispatch_loop_command, goal_blocks_loop_tick
|
||||
except Exception as exc:
|
||||
logger.debug("loops module unavailable: %s", exc)
|
||||
return "Loops unavailable."
|
||||
return t("gateway.loop.unavailable")
|
||||
|
||||
# Warm the SessionDB cache off-loop: a cold cache drops the first /loop write while the
|
||||
# reply claims the loop was set (same class as the /goal false-ack fix).
|
||||
@@ -276,7 +263,7 @@ class GatewayGoalCommandsMixin:
|
||||
session_entry = None
|
||||
sid = getattr(session_entry, "session_id", None) or ""
|
||||
if not sid:
|
||||
return "Loops unavailable (no active session)."
|
||||
return t("gateway.loop.no_session")
|
||||
mgr = LoopManager(session_id=sid)
|
||||
|
||||
# New loops capture the event's routing so the idle loop-wakeup watcher can inject ticks
|
||||
@@ -296,8 +283,5 @@ class GatewayGoalCommandsMixin:
|
||||
result = dispatch_loop_command(mgr, (event.get_command_args() or "").strip(), route=route)
|
||||
output = result.get("output") or ""
|
||||
if result.get("created") and _quiet_bool(lambda: goal_blocks_loop_tick(mgr.session_id)):
|
||||
output += (
|
||||
"\nNote: an active /goal is driving this session — loop "
|
||||
"wakeups defer until the goal finishes, pauses, or parks."
|
||||
)
|
||||
output += t("gateway.loop.goal_defers_note")
|
||||
return output
|
||||
@@ -12,6 +12,7 @@ from contextvars import copy_context
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from agent.i18n import t
|
||||
from gateway.run_agent_cache import _first_agent
|
||||
from gateway.slash_access import policy_for_runner_source
|
||||
from hermes_cli import anon_auth
|
||||
@@ -168,7 +169,7 @@ class GatewayLoginCommandsMixin:
|
||||
if state.model_changed and state.model:
|
||||
failed = await self._sweep_sessions_off_welcome()
|
||||
if failed:
|
||||
copy += "\nSome chats are still on the free tier; use /model in those chats to switch."
|
||||
copy += t("gateway.login.free_tier_remaining")
|
||||
await self._push_login(attempt, copy)
|
||||
return
|
||||
await self._push_login(attempt, state.copy)
|
||||
|
||||
@@ -46,11 +46,7 @@ def _model_switch_skew_guard() -> Optional[str]:
|
||||
boot_rev, disk_rev = skew
|
||||
return t(
|
||||
"gateway.model.error_prefix",
|
||||
error=(
|
||||
f"This gateway is running code from {boot_rev} but the checkout on "
|
||||
f"disk is now {disk_rev}. Switching models would risk a stale-module "
|
||||
f"crash — restart the gateway to load the new code: hermes gateway restart"
|
||||
),
|
||||
error=t("gateway.model.err_skew", boot_rev=boot_rev, disk_rev=disk_rev),
|
||||
)
|
||||
|
||||
|
||||
@@ -194,7 +190,7 @@ class GatewayModelCommandsMixin:
|
||||
)
|
||||
return t(
|
||||
"gateway.model.error_prefix",
|
||||
error=f"Model switch to {result.new_model} failed ({exc}); staying on {ctx.current_model}.",
|
||||
error=t("gateway.model.err_switch_failed", model=result.new_model, error=exc, current=ctx.current_model),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -257,7 +253,7 @@ class GatewayModelCommandsMixin:
|
||||
await _persist_model_switch_to_config(result, ctx.config_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist model switch: %s", e)
|
||||
global_error = f"config.yaml not updated ({str(e) or type(e).__name__})"
|
||||
global_error = t("gateway.model.err_config_not_updated", error=str(e) or type(e).__name__)
|
||||
# Precedence is session > channel_overrides > config.yaml: in a chat with a channel_overrides
|
||||
# model/provider the session override must stay, or the next turn runs the channel model.
|
||||
if ctx.persist_global and global_error is None and self._channel_override_for(source) is None:
|
||||
@@ -266,7 +262,7 @@ class GatewayModelCommandsMixin:
|
||||
except Exception as e:
|
||||
# Store still holds the stale copy: keep memory in agreement and report it (#100314).
|
||||
logger.warning("Failed to clear persisted session model override: %s", e)
|
||||
global_error = f"saved to config.yaml, but the stale session override was not cleared ({e})"
|
||||
global_error = t("gateway.model.err_stale_override", error=e)
|
||||
else:
|
||||
self._session_model_overrides.pop(ctx.session_key, None)
|
||||
# Non-secret write-through so the override survives a restart (api_key/api_mode are
|
||||
@@ -339,7 +335,7 @@ class GatewayModelCommandsMixin:
|
||||
elif ctx.persist_global:
|
||||
lines.append(t("gateway.model.saved_global"))
|
||||
elif one_turn:
|
||||
lines.append(" (next turn only — restores after one response)")
|
||||
lines.append(t("gateway.model.next_turn_only"))
|
||||
else:
|
||||
lines.append(t("gateway.model.session_only_hint"))
|
||||
return "\n".join(lines)
|
||||
@@ -475,7 +471,8 @@ class GatewayModelCommandsMixin:
|
||||
if await self._send_model_picker(event, ctx.source, adapter, ctx.session_key, listing_kwargs, _on_model_selected):
|
||||
return None # Picker sent — adapter handles the response
|
||||
|
||||
lines = [t("gateway.model.current_label", model=ctx.current_model or "unknown", provider=get_label(ctx.current_provider)), ""]
|
||||
lines = [t("gateway.model.current_label", model=ctx.current_model or t("gateway.shared.unknown_value"),
|
||||
provider=get_label(ctx.current_provider)), ""]
|
||||
try: # off-loop: listing still reads config/disk cache synchronously (#41289)
|
||||
providers = await asyncio.to_thread(list_authenticated_providers, max_models=5, **listing_kwargs)
|
||||
lines.extend(_model_provider_listing_lines(providers))
|
||||
@@ -511,15 +508,12 @@ class GatewayModelCommandsMixin:
|
||||
|
||||
async def _on_cost_confirm(choice: str) -> str:
|
||||
if choice == "cancel":
|
||||
return f"🟡 Model switch cancelled. Current model unchanged ({ctx.current_model or 'unknown'})."
|
||||
return t("gateway.model.switch_cancelled", model=ctx.current_model or t("gateway.shared.unknown_value"))
|
||||
# "once" and "always" both proceed — selection guards have no persistent opt-out.
|
||||
return await self._commit_model_switch(result, ctx, source=ctx.source)
|
||||
|
||||
_p = self._typed_command_prefix_for(event.source.platform)
|
||||
message = (
|
||||
f"⚠️ **{warning.title}**\n\n{warning.message}\n\n"
|
||||
f"_Text fallback: reply `{_p}approve` to switch or `{_p}cancel` to keep the current model._"
|
||||
)
|
||||
message = t("gateway.model.guard_confirm", title=warning.title, message=warning.message, prefix=_p)
|
||||
return True, await self._request_slash_confirm(
|
||||
event=event, command="model", title=warning.title, message=message, handler=_on_cost_confirm,
|
||||
)
|
||||
@@ -598,7 +592,7 @@ class GatewayModelCommandsMixin:
|
||||
try:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
except Exception as exc:
|
||||
return f"❌ Could not load config: {exc}"
|
||||
return t("gateway.codex_runtime.config_load_failed", error=exc)
|
||||
result = crs.apply(
|
||||
load_config(), new_value, persist_callback=(save_config if new_value is not None else None),
|
||||
)
|
||||
@@ -644,7 +638,7 @@ class GatewayModelCommandsMixin:
|
||||
# Persists the selection only (never agent.system_prompt, a user-owned overlay) into the
|
||||
# routed profile's config.yaml; the next turn re-resolves the prompt — no process-global state.
|
||||
if not persist_personality(name):
|
||||
return t("gateway.personality.save_failed", error="config write failed")
|
||||
return t("gateway.personality.save_failed", error=t("gateway.personality.err_config_write"))
|
||||
if not name:
|
||||
return t("gateway.personality.cleared")
|
||||
return t("gateway.personality.set_to", name=name)
|
||||
|
||||
@@ -24,7 +24,7 @@ from gateway.session_transcript import TranscriptReadError
|
||||
from gateway.slash_commands_branch_thread import (
|
||||
BRANCH_THREAD_PLATFORMS, branch_dest_source, branch_thread_parent, format_thread_ref, parse_branch_args,
|
||||
)
|
||||
from gateway.slash_commands_status import HISTORY_UNREADABLE
|
||||
from gateway.slash_commands_status import history_unreadable
|
||||
|
||||
logger = logging.getLogger("gateway.run") # log-record parity with gateway/run.py
|
||||
|
||||
@@ -61,9 +61,9 @@ def _manual_compression_reply_lines(summary: dict, compressor, focus_topic) -> l
|
||||
summary_err = redact_sensitive_text(summary_err, force=True)
|
||||
aux_fail_model = getattr(compressor, "_last_aux_model_failure_model", None)
|
||||
if getattr(compressor, "_last_compress_aborted", False):
|
||||
lines.append(t("gateway.compress.aborted", error=(summary_err or "unknown error")))
|
||||
lines.append(t("gateway.compress.aborted", error=(summary_err or t("gateway.shared.unknown_error"))))
|
||||
elif aux_fail_model:
|
||||
aux_err = getattr(compressor, "_last_aux_model_failure_error", None) or "unknown error"
|
||||
aux_err = getattr(compressor, "_last_aux_model_failure_error", None) or t("gateway.shared.unknown_error")
|
||||
lines.append(t("gateway.compress.aux_failed", model=aux_fail_model, error=aux_err))
|
||||
return lines
|
||||
|
||||
@@ -391,7 +391,7 @@ class GatewaySessionCommandsMixin:
|
||||
try:
|
||||
history = await self.async_session_store.load_transcript(session_entry.session_id)
|
||||
except TranscriptReadError:
|
||||
return HISTORY_UNREADABLE
|
||||
return history_unreadable()
|
||||
last_user_idx = next((i for i in range(len(history) - 1, -1, -1)
|
||||
if user_originated_turn_view(history[i]) is not None), None)
|
||||
if last_user_idx is None:
|
||||
@@ -403,7 +403,7 @@ class GatewaySessionCommandsMixin:
|
||||
last_user_msg = retryable_user_text(live_view.get("content"))
|
||||
handoff, _ = split_user_originated_turn(history[last_user_idx])
|
||||
except ValueError as exc:
|
||||
return f"Cannot retry that message safely: {exc}"
|
||||
return t("gateway.retry.unsafe", error=exc)
|
||||
|
||||
if handoff is not None:
|
||||
# Composite carrier (one row = retained summary + live ask): the carrier-aware rewind
|
||||
@@ -415,14 +415,14 @@ class GatewaySessionCommandsMixin:
|
||||
rewind_result = await self.async_session_store.rewind_session(
|
||||
session_entry.session_id, 1, require_retryable_composite=True)
|
||||
except ValueError as exc:
|
||||
return f"Cannot retry that message safely: {exc}"
|
||||
return t("gateway.retry.unsafe", error=exc)
|
||||
if rewind_result is None:
|
||||
return "Retry failed; transcript was not changed."
|
||||
return t("gateway.retry.failed_unchanged")
|
||||
last_user_msg = rewind_result["target_text"]
|
||||
# active_only preserves the active=0/compacted=1 archive left by in-place compaction.
|
||||
elif not await self.async_session_store.rewrite_transcript(
|
||||
session_entry.session_id, truncated, active_only=True, reject_active_turn_lease=True):
|
||||
return "Retry failed; transcript was not changed."
|
||||
return t("gateway.retry.failed_unchanged")
|
||||
session_entry.last_prompt_tokens = 0 # transcript was truncated
|
||||
self._record_model_friction("retry", source, session_entry.session_id)
|
||||
return await self._handle_message(MessageEvent(
|
||||
@@ -491,10 +491,7 @@ class GatewaySessionCommandsMixin:
|
||||
|
||||
agent = self._cached_agent_for(session_key, lockless_fallback=True)
|
||||
if agent is None or agent is _AGENT_PENDING_SENTINEL or getattr(agent, "_codex_session", None) is None:
|
||||
return (
|
||||
"🗜️ Nothing to compact: this session runs on the Codex app-server runtime, whose "
|
||||
"context lives in a Codex-owned thread that only exists while the agent is active. "
|
||||
"Send a message first, then /compress — or /reset to start fresh.")
|
||||
return t("gateway.compress.codex_nothing")
|
||||
compressor = getattr(agent, "context_compressor", None)
|
||||
count_before = getattr(compressor, "compression_count", 0)
|
||||
try:
|
||||
@@ -503,12 +500,8 @@ class GatewaySessionCommandsMixin:
|
||||
except Exception as exc:
|
||||
return t("gateway.compress.failed", error=exc)
|
||||
if getattr(compressor, "compression_count", 0) > count_before:
|
||||
return (
|
||||
"🗜️ Codex app-server thread compacted (thread/compact). The transcript mirror is "
|
||||
"unchanged by design — the app-server now carries the compacted context.")
|
||||
return (
|
||||
"⚠️ Codex app-server compaction did not complete — the thread is unchanged. Check the "
|
||||
"app-server logs, retry /compress, or /reset for a clean session.")
|
||||
return t("gateway.compress.codex_compacted")
|
||||
return t("gateway.compress.codex_incomplete")
|
||||
|
||||
async def _handle_compress_command_inner(self, event: MessageEvent) -> str:
|
||||
"""Handle /compress -- manually compress conversation context; ``/compress <focus>`` tells
|
||||
@@ -520,7 +513,7 @@ class GatewaySessionCommandsMixin:
|
||||
try:
|
||||
history = await self.async_session_store.load_transcript(session_entry.session_id)
|
||||
except TranscriptReadError:
|
||||
return HISTORY_UNREADABLE
|
||||
return history_unreadable()
|
||||
if not history or len(history) < MIN_MESSAGES:
|
||||
return t("gateway.compress.not_enough")
|
||||
request = parse_compress_args(event.get_command_args() or "")
|
||||
@@ -740,13 +733,13 @@ class GatewaySessionCommandsMixin:
|
||||
session_entry = await self.async_session_store.get_or_create_session(source)
|
||||
session_id = session_entry.session_id
|
||||
if not self._session_db:
|
||||
return "Session database not available."
|
||||
return t("gateway.shared.session_db_unavailable")
|
||||
# Never trust path separators from chat input; the filename is only echoed to the platform.
|
||||
filename = parts[1] if len(parts) > 1 else default_save_filename(session_id, fmt)
|
||||
filename = os.path.basename(filename) or default_save_filename(session_id, fmt)
|
||||
export_data = await self._session_db.export_session(session_id, include_compacted=fmt in SAVE_TRANSCRIPT_FORMATS)
|
||||
if not export_data:
|
||||
return f"No stored messages found for this session ({session_id})."
|
||||
return t("gateway.save.no_messages", session_id=session_id)
|
||||
if redact:
|
||||
from hermes_cli.session_export_md import redact_session_data
|
||||
export_data = redact_session_data(export_data)
|
||||
@@ -763,13 +756,13 @@ class GatewaySessionCommandsMixin:
|
||||
# Profile-aware: under multiplex the requester's bot lives in _profile_adapters, not self.adapters.
|
||||
adapter = self._delivery_adapter_for(source)
|
||||
if not adapter:
|
||||
return "Platform adapter not found to send the document."
|
||||
return t("gateway.save.no_adapter")
|
||||
await adapter.send_document(chat_id=source.chat_id, file_path=temp_path,
|
||||
caption=f"Session export: {filename}", file_name=filename)
|
||||
return "Export complete."
|
||||
caption=t("gateway.save.caption", filename=filename), file_name=filename)
|
||||
return t("gateway.save.complete")
|
||||
except Exception as e:
|
||||
logger.warning("Session /save failed: %s", e)
|
||||
return f"Error exporting session: {e}"
|
||||
return t("gateway.save.failed", error=e)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
os.remove(temp_path)
|
||||
@@ -927,10 +920,11 @@ class GatewaySessionCommandsMixin:
|
||||
history = await self.async_session_store.load_transcript(target_id)
|
||||
except TranscriptReadError:
|
||||
# The resume itself succeeded; only the count is missing — say so rather than "empty".
|
||||
return t("gateway.resume.resumed_no_count", title=title) + "\n" + HISTORY_UNREADABLE
|
||||
return t("gateway.resume.resumed_no_count", title=title) + "\n" + history_unreadable()
|
||||
msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0
|
||||
if source.platform == Platform.MATRIX and allow_cross_room:
|
||||
msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else ""
|
||||
msg_part = t("gateway.resume.msg_count_one" if msg_count == 1 else "gateway.resume.msg_count_other",
|
||||
count=msg_count) if msg_count else ""
|
||||
return t("gateway.resume.matrix_cross_room_success", title=title,
|
||||
room=source.chat_name or source.chat_id, msg_part=msg_part)
|
||||
if not msg_count:
|
||||
@@ -977,7 +971,7 @@ class GatewaySessionCommandsMixin:
|
||||
except ValueError as exc:
|
||||
return t("gateway.resume.parse_error", error=exc)
|
||||
if search_query == "":
|
||||
return "Usage: `/sessions search <query>`"
|
||||
return t("gateway.sessions.usage_search")
|
||||
if target:
|
||||
return await self._handle_resume_command(dataclasses.replace(event, text=f"/resume {target}"))
|
||||
source = await asyncio.to_thread(self._normalize_source_for_session_key, event.source)
|
||||
@@ -987,7 +981,7 @@ class GatewaySessionCommandsMixin:
|
||||
cross_origin = include_all and self._resume_caller_is_admin(source)
|
||||
scope_notice = None
|
||||
if include_all and not cross_origin:
|
||||
scope_notice = "_Note: `all` (cross-chat listing) requires a configured admin; showing this chat's sessions only._"
|
||||
scope_notice = t("gateway.sessions.all_requires_admin")
|
||||
current_entry = await self.async_session_store.get_or_create_session(source)
|
||||
rows = await asyncio.to_thread(
|
||||
query_session_listing, getattr(self._session_db, "_db", self._session_db),
|
||||
@@ -1002,9 +996,9 @@ class GatewaySessionCommandsMixin:
|
||||
rows = [row for row in rows if await self._resume_row_visible(source, row, allow_all=False)]
|
||||
rows = rows[:10]
|
||||
if search_query:
|
||||
title = f"Sessions matching “{search_query}”"
|
||||
title = t("gateway.sessions.title_matching", query=search_query)
|
||||
else:
|
||||
title = "Sessions" if include_unnamed else "Named Sessions"
|
||||
title = t("gateway.sessions.title_all" if include_unnamed else "gateway.sessions.title_named")
|
||||
return format_gateway_session_listing(rows, include_source=cross_origin, title=title,
|
||||
notice=scope_notice)
|
||||
|
||||
@@ -1029,7 +1023,7 @@ class GatewaySessionCommandsMixin:
|
||||
try:
|
||||
history = await self.async_session_store.load_transcript(current_entry.session_id)
|
||||
except TranscriptReadError:
|
||||
return HISTORY_UNREADABLE
|
||||
return history_unreadable()
|
||||
if not history:
|
||||
return t("gateway.branch.no_conversation")
|
||||
new_session_id = f"{_dt.now().strftime('%Y%m%d_%H%M%S')}_{_uuid.uuid4().hex[:6]}"
|
||||
|
||||
@@ -68,11 +68,17 @@ async def _quiet(call, default=None):
|
||||
return default
|
||||
|
||||
|
||||
# English source for the transcript-read-failure reply; callers render ``history_unreadable()``
|
||||
# (``gateway.shared.history_unreadable``) so the active language applies.
|
||||
HISTORY_UNREADABLE = ("⚠️ I can't read this conversation's history right now (your earlier messages "
|
||||
"exist but cannot be loaded). Run `hermes doctor --fix` on the host, or use /new "
|
||||
"to start fresh.")
|
||||
|
||||
|
||||
def history_unreadable() -> str:
|
||||
return t("gateway.shared.history_unreadable")
|
||||
|
||||
|
||||
def _configured_provider() -> str:
|
||||
"""``model.provider`` from the gateway config ("" when unset)."""
|
||||
from gateway.run import _load_gateway_config
|
||||
@@ -175,9 +181,9 @@ def _agents_delegation_lines(d: dict) -> list[str]:
|
||||
row = f"- `{d.get('delegation_id', '?')}` · {status}"
|
||||
quiet = d.get("stalled_after_quiet_seconds")
|
||||
if status == "stalling" and quiet is not None:
|
||||
row += f" · no progress {quiet:.0f}s"
|
||||
row += t("gateway.agents.no_progress", seconds=f"{quiet:.0f}")
|
||||
elif status != "stalling" and d.get("seconds_since_progress", 0) >= 60:
|
||||
row += f" · quiet {d['seconds_since_progress']:.0f}s"
|
||||
row += t("gateway.agents.quiet_for", seconds=f"{d['seconds_since_progress']:.0f}")
|
||||
if goal:
|
||||
row += f" · {goal}"
|
||||
lines = [row]
|
||||
@@ -185,10 +191,10 @@ def _agents_delegation_lines(d: dict) -> list[str]:
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
tool = child.get("current_tool")
|
||||
doing = f"`{tool}`" if tool else "between turns"
|
||||
part = f" - child {i + 1}: {child.get('api_calls', '?')} api calls · {doing}"
|
||||
doing = f"`{tool}`" if tool else t("gateway.agents.between_turns")
|
||||
part = t("gateway.agents.child_row", index=i + 1, calls=child.get("api_calls", "?"), doing=doing)
|
||||
idle = child.get("seconds_since_activity")
|
||||
lines.append(part + (f" · active {idle:.0f}s ago" if idle is not None else ""))
|
||||
lines.append(part + (t("gateway.agents.active_ago", seconds=f"{idle:.0f}") if idle is not None else ""))
|
||||
return lines
|
||||
|
||||
|
||||
@@ -313,7 +319,7 @@ class GatewayStatusCommandsMixin:
|
||||
t("gateway.status.matrix_scope_header"),
|
||||
t("gateway.status.matrix_scope_room", room=source.chat_name or source.chat_id),
|
||||
t("gateway.status.matrix_scope_room_id", room_id=source.chat_id),
|
||||
t("gateway.status.matrix_scope_thread", thread_id=source.thread_id or "none"),
|
||||
t("gateway.status.matrix_scope_thread", thread_id=source.thread_id or t("gateway.shared.none_value")),
|
||||
t("gateway.status.matrix_scope_mode", scope=scope),
|
||||
t("gateway.status.matrix_scope_key",
|
||||
session_key=self._redact_matrix_session_key(session_key)),
|
||||
@@ -389,7 +395,7 @@ class GatewayStatusCommandsMixin:
|
||||
try:
|
||||
history = await self.async_session_store.load_transcript(session_entry.session_id)
|
||||
except TranscriptReadError:
|
||||
return HISTORY_UNREADABLE
|
||||
return history_unreadable()
|
||||
if not history:
|
||||
return t("gateway.context.no_data")
|
||||
approx, count = _transcript_estimate(history)
|
||||
@@ -495,12 +501,11 @@ class GatewayStatusCommandsMixin:
|
||||
if view is None or not view.logged_in:
|
||||
return t("gateway.credits.not_logged_in")
|
||||
# Drop the helper's 📈 header; we print our own.
|
||||
lines = ["💳 **Nous balance**"] + [ln for ln in view.balance_lines if not ln.lstrip().startswith("📈")]
|
||||
lines = [t("gateway.topup.header")] + [ln for ln in view.balance_lines if not ln.lstrip().startswith("📈")]
|
||||
if view.identity_line:
|
||||
lines += ["", view.identity_line]
|
||||
if view.topup_url:
|
||||
lines += ["", f"Manage billing on the portal: {view.topup_url}",
|
||||
"Top up and manage billing in the browser — your balance updates here after."]
|
||||
lines += ["", t("gateway.topup.portal_link", url=view.topup_url), t("gateway.topup.hint")]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _context_breakdown_block(self, agent, source, expanded: bool) -> list[str]:
|
||||
@@ -511,7 +516,7 @@ class GatewayStatusCommandsMixin:
|
||||
try:
|
||||
payload = self._session_context_breakdown(agent, source)
|
||||
except TranscriptReadError:
|
||||
return [HISTORY_UNREADABLE] # a read failure is not an empty transcript
|
||||
return [history_unreadable()] # a read failure is not an empty transcript
|
||||
if not (payload.get("categories") or []):
|
||||
return []
|
||||
details = _quiet_sync(lambda: compute_context_details(agent), {"skills": [], "toolsets": []}) if expanded else None
|
||||
@@ -540,7 +545,7 @@ class GatewayStatusCommandsMixin:
|
||||
try:
|
||||
payload = self._session_context_breakdown(agent, source)
|
||||
except TranscriptReadError:
|
||||
return [HISTORY_UNREADABLE]
|
||||
return [history_unreadable()]
|
||||
categories = payload.get("categories") or []
|
||||
if not categories:
|
||||
return []
|
||||
@@ -632,7 +637,7 @@ class GatewayStatusCommandsMixin:
|
||||
try:
|
||||
history = await self.async_session_store.load_transcript(session_entry.session_id)
|
||||
except TranscriptReadError:
|
||||
return HISTORY_UNREADABLE
|
||||
return history_unreadable()
|
||||
if history:
|
||||
approx, count = _transcript_estimate(history)
|
||||
return _with_account_blocks([
|
||||
|
||||
@@ -8,6 +8,7 @@ import sys
|
||||
|
||||
from rich.markup import escape as _escape
|
||||
|
||||
from agent.i18n import t
|
||||
from utils import base_url_host_matches
|
||||
|
||||
|
||||
@@ -56,10 +57,10 @@ def _cooldown_cause(entry) -> str:
|
||||
quota response, a failed token refresh, or another HTTP failure."""
|
||||
reason = (entry.last_error_reason or "").lower()
|
||||
if entry.last_error_code in (402, 429) or any(k in reason for k in ("rate", "quota", "insufficient")):
|
||||
return "after a rate-limit or quota response"
|
||||
return t("cli.startup.cooldown_cause_rate_limit")
|
||||
if entry.last_error_code is None or "refresh" in reason:
|
||||
return "after a failed token refresh"
|
||||
return f"after an HTTP {entry.last_error_code} response"
|
||||
return t("cli.startup.cooldown_cause_token_refresh")
|
||||
return t("cli.startup.cooldown_cause_http", code=entry.last_error_code)
|
||||
|
||||
|
||||
def _credential_pool_notice(provider: str) -> tuple:
|
||||
@@ -80,17 +81,21 @@ def _credential_pool_notice(provider: str) -> tuple:
|
||||
if next_at is not None:
|
||||
minutes = max(1, int((next_at - time.time() + 59) // 60))
|
||||
benched = [e for e in entries if e.last_status == STATUS_EXHAUSTED]
|
||||
cause = _cooldown_cause(benched[0]) if benched else "after a failed request"
|
||||
lines.append(f"The {provider} credential is cooling down {cause}; "
|
||||
f"it re-enters rotation in about {minutes}m.")
|
||||
cause = _cooldown_cause(benched[0]) if benched else t("cli.startup.cooldown_cause_failed_request")
|
||||
lines.append(t("cli.startup.credential_cooling_down", provider=provider, cause=cause, minutes=minutes))
|
||||
dead = [e for e in entries if e.last_status == STATUS_DEAD]
|
||||
if dead:
|
||||
reason = dead[0].last_error_message or dead[0].last_error_reason or "sign-in lost"
|
||||
lines.append(f"The {provider} sign-in was lost ({reason}); run `hermes auth add {provider}` "
|
||||
"to sign in again.")
|
||||
reason = dead[0].last_error_message or dead[0].last_error_reason or t("cli.startup.sign_in_lost")
|
||||
lines.append(t("cli.startup.sign_in_was_lost", provider=provider, reason=reason))
|
||||
return next_at is not None, lines
|
||||
|
||||
|
||||
def _resume_counts(user_count: int, total: int) -> str:
|
||||
"""``(N user message(s), M total messages)`` suffix of the resumed-session line."""
|
||||
key = "cli.resume.counts_one" if user_count == 1 else "cli.resume.counts_other"
|
||||
return t(key, count=user_count, total=total)
|
||||
|
||||
|
||||
def _keyless_custom_base(base_url) -> bool:
|
||||
"""Custom/local endpoints (llama.cpp, ollama, vLLM) often need no auth; only a
|
||||
non-OpenRouter base_url qualifies."""
|
||||
@@ -129,16 +134,17 @@ def _tool_calls_summary(tool_calls) -> str:
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
names_str = ", ".join(names[:4]) + (", ..." if len(names) > 4 else "")
|
||||
noun = "call" if len(tool_calls) == 1 else "calls"
|
||||
return f"[{len(tool_calls)} tool {noun}: {names_str}]"
|
||||
key = "cli.resume.tool_calls_one" if len(tool_calls) == 1 else "cli.resume.tool_calls_other"
|
||||
return t(key, count=len(tool_calls), names=names_str)
|
||||
|
||||
|
||||
# display_kind -> recap event line; ``hidden`` rows are skipped before this lookup.
|
||||
_RESUME_EVENT_TEXT = {
|
||||
"model_switch": "model changed",
|
||||
"async_delegation_complete": "background delegation completed",
|
||||
"process_complete": "background process finished",
|
||||
"auto_continue": "resumed interrupted turn"}
|
||||
# display_kind -> catalog key of the recap event line (resolved at call time); ``hidden`` rows
|
||||
# are skipped before this lookup.
|
||||
_RESUME_EVENT_KEYS = {
|
||||
"model_switch": "cli.resume.event_model_changed",
|
||||
"async_delegation_complete": "cli.resume.event_delegation_completed",
|
||||
"process_complete": "cli.resume.event_process_finished",
|
||||
"auto_continue": "cli.resume.event_resumed_interrupted"}
|
||||
|
||||
def _collect_resume_entries(display_history, disp: dict, clean_assistant):
|
||||
"""Displayable ``(role, text)`` recap entries from stored history, truncated per the
|
||||
@@ -164,10 +170,10 @@ def _collect_resume_entries(display_history, disp: dict, clean_assistant):
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
if display_kind == "hidden":
|
||||
continue
|
||||
if display_kind in _RESUME_EVENT_TEXT:
|
||||
if display_kind in _RESUME_EVENT_KEYS:
|
||||
metadata = msg.get("display_metadata") or {}
|
||||
label = metadata.get("display_text") if display_kind in ("async_delegation_complete", "process_complete") else None
|
||||
entries.append(("event", _sanitize_display_text(label or _RESUME_EVENT_TEXT[display_kind])))
|
||||
entries.append(("event", _sanitize_display_text(label or t(_RESUME_EVENT_KEYS[display_kind]))))
|
||||
continue
|
||||
if role == "user":
|
||||
text = _sanitize_display_text(_user_display_text(content))
|
||||
@@ -255,7 +261,7 @@ class CLIAgentSetupMixin:
|
||||
# Only an explicit re-authentication requirement is terminal. Unknown
|
||||
# resolver/network failures must not acquire the sticky Kanban block.
|
||||
self._credentials_terminal = isinstance(_primary_exc, AuthError) and _primary_exc.relogin_required
|
||||
message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed."
|
||||
message = format_runtime_provider_error(_primary_exc) if _primary_exc else t("cli.startup.provider_resolution_failed")
|
||||
if getattr(self, "tool_progress_mode", "full") == "off":
|
||||
print(message, file=sys.stderr) # quiet/stream-json: stdout is machine-readable
|
||||
else:
|
||||
@@ -285,15 +291,13 @@ class CLIAgentSetupMixin:
|
||||
else:
|
||||
_prov = (resolved_provider or self.requested_provider or "").strip()
|
||||
if _prov and _prov != "auto":
|
||||
print(f"\n⚠️ No API key found for provider '{_prov}'.")
|
||||
print(f"\n{t('cli.startup.no_api_key_for_provider', provider=_prov)}")
|
||||
else:
|
||||
print("\n⚠️ No inference provider is configured.")
|
||||
print(" Run 'hermes model' to choose a provider, or "
|
||||
"'hermes setup' for first-time setup.")
|
||||
print(f"\n{t('cli.startup.no_provider_configured')}")
|
||||
print(f" {t('cli.startup.run_model_or_setup')}")
|
||||
return False
|
||||
if not isinstance(base_url, str) or not base_url:
|
||||
print("\n⚠️ Provider resolver returned an empty base URL. "
|
||||
"Check your provider config or run: hermes setup")
|
||||
print(f"\n{t('cli.startup.empty_base_url')}")
|
||||
return False
|
||||
credentials_changed = api_key != self.api_key or base_url != self.base_url
|
||||
routing_changed = resolved_routing != (self.provider, self.api_mode, self.acp_command, self.acp_args)
|
||||
@@ -391,7 +395,7 @@ class CLIAgentSetupMixin:
|
||||
_why_log, primary_exc, _fb_provider, _fb_model)
|
||||
from gateway.warning_notifications import render_notification
|
||||
render_notification(
|
||||
lambda: _cprint(f"⚠️ {_why} — switching to fallback: {_fb_provider} / {_fb_model}"),
|
||||
lambda: _cprint(t("cli.startup.switching_to_fallback", reason=_why, provider=_fb_provider, model=_fb_model)),
|
||||
platform="cli")
|
||||
self.requested_provider = _fb_provider
|
||||
self.model = _fb_model
|
||||
@@ -470,16 +474,15 @@ class CLIAgentSetupMixin:
|
||||
source of truth. True when a provider was configured."""
|
||||
from cli import _cprint, logger
|
||||
_cprint("")
|
||||
_cprint("☤ No inference provider is configured yet — let's fix that.")
|
||||
_cprint(" You'll pick a provider (Nous Portal OAuth is the fastest; "
|
||||
"no API key needed) and a model.")
|
||||
_cprint(t("cli.startup.first_run_no_provider"))
|
||||
_cprint(f" {t('cli.startup.first_run_pick_provider')}")
|
||||
try:
|
||||
answer = input(" Set up a provider now? [Y/n]: ").strip().lower()
|
||||
answer = input(f" {t('cli.startup.first_run_prompt')} ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
answer = "n"
|
||||
if answer in {"n", "no"}:
|
||||
_cprint(" Skipped. Run 'hermes model' or 'hermes setup' any time.")
|
||||
_cprint(f" {t('cli.startup.first_run_skipped')}")
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.main import select_provider_and_model
|
||||
@@ -488,12 +491,12 @@ class CLIAgentSetupMixin:
|
||||
select_provider_and_model()
|
||||
except (KeyboardInterrupt, EOFError, SystemExit):
|
||||
print()
|
||||
_cprint(" Setup cancelled. Run 'hermes model' any time.")
|
||||
_cprint(f" {t('cli.startup.first_run_cancelled')}")
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.debug("first-run provider setup failed: %s", exc)
|
||||
_cprint(f" ⚠️ Provider setup failed: {exc}")
|
||||
_cprint(" Run 'hermes model' to try again.")
|
||||
_cprint(f" {t('cli.startup.provider_setup_failed', error=exc)}")
|
||||
_cprint(f" {t('cli.startup.run_model_to_retry')}")
|
||||
return False
|
||||
|
||||
# Re-sync CLI state from what the picker persisted so the next turn uses it without a restart.
|
||||
@@ -515,9 +518,9 @@ class CLIAgentSetupMixin:
|
||||
_retire_agent(self)
|
||||
self._active_agent_route_signature = None
|
||||
if self._runtime_credentials_ready():
|
||||
_cprint(" ✓ Provider configured — you're ready to chat.")
|
||||
_cprint(f" {t('cli.startup.provider_configured')}")
|
||||
return True
|
||||
_cprint(" Provider setup didn't complete. Run 'hermes model' to retry.")
|
||||
_cprint(f" {t('cli.startup.provider_setup_incomplete')}")
|
||||
return False
|
||||
|
||||
def _resolve_turn_agent_config(self, user_message: str) -> dict:
|
||||
@@ -577,20 +580,19 @@ class CLIAgentSetupMixin:
|
||||
else:
|
||||
ChatConsole().print(rich)
|
||||
if not session_meta:
|
||||
hint = "Use a session ID from a previous CLI run (hermes sessions list)."
|
||||
hint = t("cli.resume.session_id_hint")
|
||||
not_found = t("cli.resume.session_not_found", session_id=self.session_id)
|
||||
if _quiet_mode:
|
||||
print(f"Session not found: {self.session_id}", file=sys.stderr)
|
||||
print(not_found, file=sys.stderr)
|
||||
print(hint, file=sys.stderr)
|
||||
else:
|
||||
_cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
|
||||
_cprint(f"\033[1;31m{not_found}{_RST}")
|
||||
_cprint(f"{_DIM}{hint}{_RST}")
|
||||
return False
|
||||
session_meta = self._follow_compression_chain(
|
||||
session_meta,
|
||||
lambda rid: ChatConsole().print(
|
||||
f"[dim]Session {_escape(self.session_id)} was compressed into "
|
||||
f"{_escape(rid)}; resuming the descendant with your "
|
||||
f"transcript.[/dim]"))
|
||||
f"[dim]{_escape(t('cli.resume.compressed_into', session_id=self.session_id, descendant=rid))}[/dim]"))
|
||||
if getattr(self, "_resume_history_error", None):
|
||||
return False
|
||||
# Only the TIP session's rows are loaded here (no ancestors), so use the
|
||||
@@ -599,8 +601,8 @@ class CLIAgentSetupMixin:
|
||||
if resume_limit_error:
|
||||
self._resume_history_error = resume_limit_error
|
||||
_say(
|
||||
f"Cannot resume session: {resume_limit_error}",
|
||||
f"[bold red]Cannot resume session:[/] {_escape(resume_limit_error)}")
|
||||
t("cli.resume.cannot_resume", error=resume_limit_error),
|
||||
f"[bold red]{_escape(t('cli.resume.cannot_resume_prefix'))}[/] {_escape(resume_limit_error)}")
|
||||
return False
|
||||
restored = self._session_db.get_messages_as_conversation(self.session_id, repair_alternation=True)
|
||||
if restored:
|
||||
@@ -608,17 +610,15 @@ class CLIAgentSetupMixin:
|
||||
self.conversation_history = restored
|
||||
msg_count = len([m for m in restored if m.get("role") == "user"])
|
||||
title_part = f" \"{session_meta['title']}\"" if session_meta.get("title") else ""
|
||||
counts = f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
|
||||
counts = _resume_counts(msg_count, len(restored))
|
||||
_say(
|
||||
f"↻ Resumed session {self.session_id}{title_part} {counts}",
|
||||
f"[bold {_accent_hex()}]↻ Resumed session[/] [bold]{_escape(self.session_id)}[/]"
|
||||
f"{t('cli.resume.resumed_session')} {self.session_id}{title_part} {counts}",
|
||||
f"[bold {_accent_hex()}]{_escape(t('cli.resume.resumed_session'))}[/] [bold]{_escape(self.session_id)}[/]"
|
||||
f"[bold {_accent_hex()}]{_escape(title_part)}[/] {counts}")
|
||||
self._restore_session_state(session_meta, quiet=_quiet_mode)
|
||||
else:
|
||||
_say(
|
||||
f"Session {self.session_id} found but has no messages. Starting fresh.",
|
||||
f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]",
|
||||
)
|
||||
no_messages = t("cli.resume.session_no_messages", session_id=self.session_id)
|
||||
_say(no_messages, f"[bold {_accent_hex()}]{_escape(no_messages)}[/]")
|
||||
self._reopen_session()
|
||||
return True
|
||||
|
||||
@@ -732,11 +732,11 @@ class CLIAgentSetupMixin:
|
||||
self.agent._ensure_db_session()
|
||||
if self.agent._session_db_created:
|
||||
self._session_db.set_session_title(self.session_id, self._pending_title)
|
||||
_cprint(f" Session title applied: {self._pending_title}")
|
||||
_cprint(f" {t('cli.resume.title_applied', title=self._pending_title)}")
|
||||
self._pending_title = None
|
||||
# else: row creation failed transiently — keep _pending_title for retry
|
||||
except Exception as e:
|
||||
_cprint(f" Could not apply pending title: {e}")
|
||||
_cprint(f" {t('cli.resume.title_apply_failed', error=e)}")
|
||||
# Keep _pending_title so it can be retried after row creation succeeds
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -780,25 +780,23 @@ class CLIAgentSetupMixin:
|
||||
return False
|
||||
session_meta = self._session_db.get_session(self.session_id)
|
||||
if not session_meta:
|
||||
self._console_print(f"[bold red]Session not found: {self.session_id}[/]")
|
||||
self._console_print("[dim]Use a session ID from a previous CLI run (hermes sessions list).[/]")
|
||||
self._console_print(f"[bold red]{_escape(t('cli.resume.session_not_found', session_id=self.session_id))}[/]")
|
||||
self._console_print(f"[dim]{_escape(t('cli.resume.session_id_hint'))}[/]")
|
||||
return False
|
||||
session_meta = self._follow_compression_chain(
|
||||
session_meta,
|
||||
lambda rid: self._console_print(
|
||||
f"[dim]Session {self.session_id} was compressed into "
|
||||
f"{rid}; resuming the descendant with your transcript.[/]"))
|
||||
f"[dim]{_escape(t('cli.resume.compressed_into', session_id=self.session_id, descendant=rid))}[/]"))
|
||||
resume_limit_error = self._resume_history_limit_error()
|
||||
if resume_limit_error:
|
||||
self._resume_history_error = resume_limit_error
|
||||
self._console_print(f"[bold red]Cannot resume session:[/] {resume_limit_error}")
|
||||
self._console_print(f"[bold red]{_escape(t('cli.resume.cannot_resume_prefix'))}[/] {resume_limit_error}")
|
||||
return False
|
||||
restored, display_history = self._session_db.get_resume_conversations(self.session_id)
|
||||
accent_color = _accent_hex()
|
||||
if not restored:
|
||||
self._console_print(
|
||||
f"[{accent_color}]Session {self.session_id} found but has no "
|
||||
f"messages. Starting fresh.[/]")
|
||||
f"[{accent_color}]{_escape(t('cli.resume.session_no_messages', session_id=self.session_id))}[/]")
|
||||
return False
|
||||
restored = [m for m in restored if m.get("role") != "session_meta"]
|
||||
self.conversation_history = restored
|
||||
@@ -809,10 +807,8 @@ class CLIAgentSetupMixin:
|
||||
msg_count = len([m for m in self._resume_display_history if is_user_originated_turn(m)])
|
||||
title_part = f' "{session_meta["title"]}"' if session_meta.get("title") else ""
|
||||
self._console_print(
|
||||
f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]"
|
||||
f"{title_part} "
|
||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
||||
f"{len(restored)} total messages)[/]")
|
||||
f"[{accent_color}]{_escape(t('cli.resume.resumed_session'))} [bold]{self.session_id}[/bold]"
|
||||
f"{title_part} {_resume_counts(msg_count, len(restored))}[/]")
|
||||
self._restore_session_state(session_meta)
|
||||
self._reopen_session()
|
||||
return True
|
||||
@@ -843,14 +839,16 @@ class CLIAgentSetupMixin:
|
||||
_resume_panel_colors())
|
||||
|
||||
# role -> (label, label style, body style, continuation indent)
|
||||
you_label = f" {t('cli.resume.label_you')} "
|
||||
assistant_label = f" {t('cli.resume.label_assistant', agent_name='Hermes')} "
|
||||
role_styles = {
|
||||
"user": (" ● You: ", f"dim bold {_session_label_c}", "dim", " " * 9),
|
||||
"assistant": (" ◆ Hermes: ", f"dim bold {_assistant_label_c}", "dim", " " * 12),
|
||||
"assistant_last": (" ◆ Hermes: ", f"bold {_assistant_label_c}", "", " " * 12), # full, non-dim
|
||||
"user": (you_label, f"dim bold {_session_label_c}", "dim", " " * len(you_label)),
|
||||
"assistant": (assistant_label, f"dim bold {_assistant_label_c}", "dim", " " * len(assistant_label)),
|
||||
"assistant_last": (assistant_label, f"bold {_assistant_label_c}", "", " " * len(assistant_label)), # full, non-dim
|
||||
}
|
||||
lines = Text()
|
||||
if skipped:
|
||||
lines.append(f" ... {skipped} earlier messages ...\n\n", style="dim italic")
|
||||
lines.append(f" {t('cli.resume.earlier_messages', count=skipped)}\n\n", style="dim italic")
|
||||
for i, (role, text) in enumerate(entries):
|
||||
if role == "event":
|
||||
lines.append(f" ◈ {text}\n", style="dim italic")
|
||||
@@ -864,7 +862,7 @@ class CLIAgentSetupMixin:
|
||||
if i < len(entries) - 1:
|
||||
lines.append("") # small gap
|
||||
panel = Panel(
|
||||
lines, title=f"[dim {_session_label_c}]Previous Conversation[/]",
|
||||
lines, title=f"[dim {_session_label_c}]{_escape(t('cli.resume.panel_title'))}[/]",
|
||||
border_style=f"dim {_session_border_c}", padding=(0, 1), style=_history_text_c)
|
||||
_record_output_history_entry(lambda: self._render_resume_history_panel_lines(panel))
|
||||
with _suspend_output_history():
|
||||
|
||||
+373
-286
File diff suppressed because it is too large.
Load diff
@@ -2,27 +2,30 @@
|
||||
|
||||
The chat panel used to echo ``Error: HTTP 401: Invalid API key`` as the assistant's answer. These
|
||||
helpers map the classifier verdict (``agent/error_classifier.py``) to WHAT happened + WHAT TO DO,
|
||||
and demote the raw provider text to a ``Details:`` line.
|
||||
and demote the raw provider text to a ``Details:`` line. Copy lives in the i18n catalog under
|
||||
``cli.error.*`` and is resolved at render time for the active language.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent.i18n import t
|
||||
|
||||
_SUMMARY_LIMIT = 120
|
||||
|
||||
# FailoverReason.value -> plain copy. ``{provider}`` / ``{model}`` are filled at render time.
|
||||
_REASON_COPY: dict[str, str] = {
|
||||
"auth": "Your {provider} key was rejected. Run `hermes model` to re-enter it.",
|
||||
"auth_permanent": "Your {provider} key was rejected. Run `hermes model` to re-enter it.",
|
||||
"billing": "Your {provider} account is out of credit. Top up at the provider, or run /model to switch.",
|
||||
"model_not_found": "'{model}' isn't available on {provider}. Run /model to pick a valid model.",
|
||||
"rate_limit": "Rate limited by {provider}; wait a minute or /model to switch.",
|
||||
"upstream_rate_limit": "Rate limited by {provider}; wait a minute or /model to switch.",
|
||||
"upstream_blocked": "A firewall/CDN in front of {provider} blocked the request (not your key). Set a User-Agent via extra_headers, or /model to switch.",
|
||||
"overloaded": "{provider} is overloaded right now. Send /retry in a moment, or /model to switch.",
|
||||
"server_error": "{provider} had an internal error. Send /retry in a moment, or /model to switch.",
|
||||
"timeout": "{provider} did not answer in time. Send /retry, or /model to switch.",
|
||||
# FailoverReason.value -> catalog key suffix. Reasons sharing copy point at one key.
|
||||
_REASON_KEYS: dict[str, str] = {
|
||||
"auth": "auth",
|
||||
"auth_permanent": "auth",
|
||||
"billing": "billing",
|
||||
"model_not_found": "model_not_found",
|
||||
"rate_limit": "rate_limit",
|
||||
"upstream_rate_limit": "rate_limit",
|
||||
"upstream_blocked": "upstream_blocked",
|
||||
"overloaded": "overloaded",
|
||||
"server_error": "server_error",
|
||||
"timeout": "timeout",
|
||||
}
|
||||
_UNKNOWN_COPY = "The model request failed. Run /model to switch or `hermes doctor` to check the setup."
|
||||
_UNKNOWN_KEY = "unknown"
|
||||
|
||||
|
||||
def _short(text: str, limit: int = _SUMMARY_LIMIT) -> str:
|
||||
@@ -52,15 +55,14 @@ def chat_error_response(
|
||||
|
||||
exc = error if isinstance(error, Exception) else Exception(str(error))
|
||||
reason = classify_api_error(exc, provider=provider or "", model=model or "").reason.value
|
||||
copy = _REASON_COPY.get(reason, _UNKNOWN_COPY)
|
||||
lead = copy.format(provider=provider or "the provider", model=model or "the current model")
|
||||
return f"{lead}\nDetails: {_short(str(error), 300)}"
|
||||
lead = t(
|
||||
f"cli.error.{_REASON_KEYS.get(reason, _UNKNOWN_KEY)}",
|
||||
provider=provider or t("cli.error.the_provider"),
|
||||
model=model or t("cli.error.the_current_model"),
|
||||
)
|
||||
return t("cli.error.with_details", lead=lead, details=_short(str(error), 300))
|
||||
|
||||
|
||||
def agent_init_failure_message(error: BaseException) -> str:
|
||||
"""Copy for a failed AIAgent build on first message: the user's turn was dropped."""
|
||||
return (
|
||||
f"Hermes couldn't start the model connection: {_short(str(error)) or type(error).__name__}. "
|
||||
"Your message was not sent. Run `hermes doctor` to check the setup, "
|
||||
"or /model to pick a different provider."
|
||||
)
|
||||
return t("cli.error.agent_init_failed", error=_short(str(error)) or type(error).__name__)
|
||||
@@ -18,6 +18,8 @@ from rich import box as rich_box
|
||||
from rich.panel import Panel
|
||||
from typing import Optional
|
||||
|
||||
from agent.i18n import t
|
||||
|
||||
from hermes_cli.cli_agent_setup_mixin import _retire_agent
|
||||
|
||||
|
||||
@@ -70,7 +72,7 @@ class CLIChatTurnMixin:
|
||||
if turn_route["signature"] != self._active_agent_route_signature:
|
||||
_retire_agent(self)
|
||||
if self.agent is None:
|
||||
_cprint(f"{_DIM}Initializing agent...{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.chat.initializing_agent')}{_RST}")
|
||||
if not self._init_agent(model_override=turn_route["model"], runtime_override=turn_route["runtime"],
|
||||
request_overrides=turn_route.get("request_overrides")):
|
||||
return None
|
||||
@@ -118,7 +120,7 @@ class CLIChatTurnMixin:
|
||||
self._chat_settle_turn(turn)
|
||||
return self._chat_render_turn(turn, agent_thread, interrupt_msg)
|
||||
except Exception as e:
|
||||
_cprint(f"Error: {e}")
|
||||
_cprint(t("gateway.model.error_prefix", error=e))
|
||||
return None
|
||||
finally:
|
||||
self._chat_release_turn_audio(turn)
|
||||
@@ -166,12 +168,11 @@ class CLIChatTurnMixin:
|
||||
_ctx_result = preprocess_context_references(message, cwd=os.getcwd(), context_length=_ctx_len)
|
||||
if _ctx_result.expanded or _ctx_result.blocked:
|
||||
if _ctx_result.references:
|
||||
_cprint(f" {_DIM}[@ context: {len(_ctx_result.references)} ref(s), "
|
||||
f"{_ctx_result.injected_tokens} tokens]{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.chat.context_refs', count=len(_ctx_result.references), tokens=_ctx_result.injected_tokens)}{_RST}")
|
||||
for w in _ctx_result.warnings:
|
||||
_cprint(f" {_DIM}⚠ {w}{_RST}")
|
||||
if _ctx_result.blocked:
|
||||
return message, ("\n".join(_ctx_result.warnings) or "Context injection refused.")
|
||||
return message, ("\n".join(_ctx_result.warnings) or t("cli.chat.context_injection_refused"))
|
||||
message = _ctx_result.message
|
||||
except Exception as e:
|
||||
logging.debug("@ context reference expansion failed: %s", e)
|
||||
@@ -208,11 +209,10 @@ class CLIChatTurnMixin:
|
||||
_img_str_paths = [str(p) for p in images]
|
||||
_parts, _skipped = build_native_content_parts(text, _img_str_paths)
|
||||
if _skipped:
|
||||
_cprint(f" {_DIM}⚠ skipped {len(_skipped)} unreadable image path(s){_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.chat.skipped_unreadable_images', count=len(_skipped))}{_RST}")
|
||||
if any(p.get("type") == "image_url" for p in _parts):
|
||||
_img_names = ", ".join(Path(p).name for p in _img_str_paths)
|
||||
_cprint(f" {_DIM}📎 attaching {len(images)} image(s) natively "
|
||||
f"(model supports vision): {_img_names}{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.chat.attaching_images_natively', count=len(images), names=_img_names)}{_RST}")
|
||||
return _parts
|
||||
# All images unreadable — fall back to text enrichment.
|
||||
except Exception as _img_exc:
|
||||
@@ -431,7 +431,7 @@ class CLIChatTurnMixin:
|
||||
pass
|
||||
interrupt_msg = None
|
||||
continue
|
||||
_cprint("\n⚡ New message detected, interrupting...")
|
||||
_cprint(f"\n{t('cli.chat.new_message_interrupting')}")
|
||||
if turn.stop_event is not None:
|
||||
turn.stop_event.set()
|
||||
self.agent.interrupt(interrupt_msg)
|
||||
@@ -523,14 +523,14 @@ class CLIChatTurnMixin:
|
||||
if turn.result and (turn.result.get("failed") or turn.result.get("partial")) and not response:
|
||||
from hermes_cli.cli_chat_error_copy import chat_error_response
|
||||
response = chat_error_response(
|
||||
str(turn.result.get("error") or "Unknown error"),
|
||||
str(turn.result.get("error") or t("cli.chat.unknown_error")),
|
||||
provider=str(getattr(self.agent, "provider", "") or self.provider or ""),
|
||||
model=str(getattr(self.agent, "model", "") or self.model or ""),
|
||||
failure_reason=turn.result.get("failure_reason"))
|
||||
# Stop continuous voice on persistent errors (e.g. 429) — else error→record→error loops.
|
||||
if self._voice_continuous:
|
||||
self._voice_continuous = False
|
||||
_cprint(f"\n{_DIM}Continuous voice mode stopped due to error.{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.chat.continuous_voice_stopped')}{_RST}")
|
||||
|
||||
pending_message, _show_interrupt_marker = self._chat_resolve_interrupt(
|
||||
turn, agent_thread, interrupt_msg, response)
|
||||
@@ -542,14 +542,14 @@ class CLIChatTurnMixin:
|
||||
# (appending it to `response` duplicated it on redraw).
|
||||
if _show_interrupt_marker:
|
||||
with _suspend_output_history():
|
||||
_cprint(f"\n{_DIM}── [Interrupted — processing new message] ──{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.chat.interrupted_marker')}{_RST}")
|
||||
# Focus view: "⋯ N tool lines hidden" after the answer; resets the counter.
|
||||
try:
|
||||
self._emit_focus_recovery_line()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._ring_bell(context="turn complete") # propagates over SSH
|
||||
self._ring_bell(context=t("cli.modal.bell_turn_complete")) # propagates over SSH
|
||||
if turn.result and not turn.result.get("completed") and not turn.result.get("interrupted"):
|
||||
_api_calls = turn.result.get("api_calls", 0)
|
||||
_max_iter = getattr(self.agent, "max_iterations", 500)
|
||||
@@ -557,8 +557,7 @@ class CLIChatTurnMixin:
|
||||
from gateway.warning_notifications import render_notification
|
||||
render_notification(
|
||||
lambda: _cprint(
|
||||
f"\n{_DIM}⚠ Iteration budget reached ({_api_calls}/{_max_iter}) — "
|
||||
f"response may be incomplete{_RST}"
|
||||
f"\n{_DIM}{t('cli.chat.iteration_budget_reached', used=_api_calls, max=_max_iter)}{_RST}"
|
||||
),
|
||||
platform="cli", user_config=getattr(self.agent, "_notification_config", None))
|
||||
|
||||
@@ -595,16 +594,16 @@ class CLIChatTurnMixin:
|
||||
payload = (combined, image_parts) if image_parts else combined
|
||||
preview = combined[:50] + ("..." if len(combined) > 50 else "")
|
||||
if len(all_parts) > 1:
|
||||
_cprint(f"\n⚡ Sending {len(all_parts)} messages after interrupt: '{preview}'")
|
||||
_cprint(f"\n{t('cli.chat.sending_after_interrupt_multi', count=len(all_parts), preview=preview)}")
|
||||
else:
|
||||
_cprint(f"\n⚡ Sending after interrupt: '{preview}'")
|
||||
_cprint(f"\n{t('cli.chat.sending_after_interrupt', preview=preview)}")
|
||||
self._pending_input.put(payload)
|
||||
|
||||
# A /steer the agent finished before absorbing becomes the next user turn.
|
||||
_leftover_steer = turn.result.get("pending_steer") if turn.result else None
|
||||
if _leftover_steer:
|
||||
preview = _leftover_steer[:60] + ("..." if len(_leftover_steer) > 60 else "")
|
||||
_cprint(f"\n⏩ Delivering leftover /steer as next turn: '{preview}'")
|
||||
_cprint(f"\n{t('cli.chat.delivering_leftover_steer', preview=preview)}")
|
||||
self._pending_input.put(_leftover_steer)
|
||||
|
||||
return response
|
||||
@@ -650,14 +649,14 @@ class CLIChatTurnMixin:
|
||||
reasoning = turn.result.get("last_reasoning")
|
||||
if reasoning:
|
||||
w = self._scrollback_box_width()
|
||||
r_label = " Reasoning "
|
||||
r_label = f" {t('cli.chat.reasoning_label')} "
|
||||
r_top = f"{_DIM}┌─{r_label}{'─' * max(w - 3 - len(r_label), 0)}┐{_RST}"
|
||||
r_bot = f"{_DIM}└{'─' * (w - 2)}┘{_RST}"
|
||||
# First 10 lines unless the user opted into /reasoning full.
|
||||
lines = reasoning.strip().splitlines()
|
||||
if len(lines) > 10 and not self.reasoning_full:
|
||||
display_reasoning = "\n".join(lines[:10])
|
||||
display_reasoning += f"\n{_DIM} ... ({len(lines) - 10} more lines — /reasoning full to show){_RST}"
|
||||
display_reasoning += f"\n{_DIM} {t('cli.chat.reasoning_more_lines', count=len(lines) - 10)}{_RST}"
|
||||
else:
|
||||
display_reasoning = reasoning.strip()
|
||||
_cprint(f"\n{r_top}\n{_DIM}{display_reasoning}{_RST}\n{r_bot}")
|
||||
@@ -713,16 +712,16 @@ class CLIChatTurnMixin:
|
||||
if turn.result and turn.result.get("failure_reason") == "billing":
|
||||
_bb = turn.result.get("billing_block") or {}
|
||||
if _bb.get("is_nous"):
|
||||
_cta_lines = ["Run [bold]/topup[/] to add credits, or "
|
||||
"[bold]/subscription[/] to change plan."]
|
||||
_cta_lines = [t("cli.chat.billing_cta_nous")]
|
||||
else:
|
||||
_url = _bb.get("billing_url")
|
||||
_cta_lines = [f"Add credits with {_bb.get('provider_label') or 'your provider'}"
|
||||
_cta_lines = [t("cli.chat.billing_cta_provider",
|
||||
provider=_bb.get("provider_label") or t("cli.chat.your_provider"))
|
||||
+ (f": [bold]{_url}[/]" if _url else ".")]
|
||||
_cta_lines.append("Or switch providers with [bold]/model <model> --provider <provider>[/].")
|
||||
_cta_lines.append(t("cli.chat.billing_cta_switch"))
|
||||
try:
|
||||
ChatConsole().print(Panel(
|
||||
"\n".join(_cta_lines), title="[#CD7F32 bold]⚡ Out of credits[/]",
|
||||
"\n".join(_cta_lines), title=f"[#CD7F32 bold]{t('cli.chat.out_of_credits_title')}[/]",
|
||||
title_align="left", border_style="#CD7F32", box=rich_box.HORIZONTALS,
|
||||
padding=(1, 4), width=self._scrollback_box_width(),
|
||||
))
|
||||
|
||||
+433
-468
File diff suppressed because it is too large.
Load diff
+182
-146
@@ -14,6 +14,7 @@ import shutil
|
||||
import threading
|
||||
import time
|
||||
|
||||
from agent.i18n import t
|
||||
from hermes_constants import is_termux as _is_termux_environment
|
||||
from rich.markup import escape as _escape
|
||||
from utils import base_url_hostname, file_signature
|
||||
@@ -25,26 +26,47 @@ CONFIG_WATCH_INTERVAL = 5.0 # seconds between config.yaml stat() calls
|
||||
|
||||
_TOOL_PROGRESS_CYCLE = ["off", "new", "all", "verbose"]
|
||||
# Raw ANSI (not Rich markup): _cprint routes through prompt_toolkit's renderer, while Rich markup
|
||||
# written to stdout gets mangled by patch_stdout's StdoutProxy ('?[33mTool progress: NEW?[0m').
|
||||
_TOOL_PROGRESS_LABELS = {
|
||||
# Use raw ANSI codes via _cprint so the output is routed through prompt_toolkit's renderer.
|
||||
# self.console.print() with Rich markup writes directly to stdout which patch_stdout's StdoutProxy
|
||||
# mangles into garbled sequences like '?[33mTool progress: NEW?[0m' (#2262).
|
||||
"off": f"{_Colors.DIM}Tool progress: OFF{_Colors.RESET} — silent mode, just the final response.",
|
||||
"new": f"{_Colors.YELLOW}Tool progress: NEW{_Colors.RESET} — show each new tool (skip repeats).",
|
||||
"all": f"{_Colors.GREEN}Tool progress: ALL{_Colors.RESET} — show every tool call.",
|
||||
"verbose": f"{_Colors.BOLD}{_Colors.GREEN}Tool progress: VERBOSE{_Colors.RESET} — full args, results, and think blocks.",
|
||||
# written to stdout gets mangled by patch_stdout's StdoutProxy ('?[33mTool progress: NEW?[0m', #2262).
|
||||
_TOOL_PROGRESS_STYLES = {
|
||||
"off": _Colors.DIM,
|
||||
"new": _Colors.YELLOW,
|
||||
"all": _Colors.GREEN,
|
||||
"verbose": f"{_Colors.BOLD}{_Colors.GREEN}",
|
||||
}
|
||||
|
||||
_RELOAD_MCP_CHOICES = [
|
||||
("once", "Approve Once", "reload now"),
|
||||
("always", "Always Approve", "reload now and silence this prompt permanently"),
|
||||
("cancel", "Cancel", "leave MCP tools unchanged")]
|
||||
_RELOAD_MCP_DETAIL = (
|
||||
"Reloading MCP servers rebuilds the tool set for this session and\n"
|
||||
"invalidates the provider prompt cache. The next message will\n"
|
||||
"re-send full input tokens (can be expensive on long-context or\n"
|
||||
"high-reasoning models).")
|
||||
|
||||
def _tool_progress_label(mode: str) -> str:
|
||||
"""Localized ``Tool progress: MODE — detail`` line for /verbose ("" for an unknown mode)."""
|
||||
style = _TOOL_PROGRESS_STYLES.get(mode)
|
||||
if style is None:
|
||||
return ""
|
||||
return f"{style}{t(f'cli.verbose.label_{mode}')}{_Colors.RESET} — {t(f'cli.verbose.detail_{mode}')}"
|
||||
|
||||
|
||||
def _reload_mcp_choices() -> list[tuple[str, str, str]]:
|
||||
"""(id, label, hint) rows for the /reload-mcp confirm modal, in the active language."""
|
||||
return [
|
||||
("once", t("cli.reload_mcp.choice_once"), t("cli.reload_mcp.choice_once_hint")),
|
||||
("always", t("cli.reload_mcp.choice_always"), t("cli.reload_mcp.choice_always_hint")),
|
||||
("cancel", t("cli.reload_mcp.choice_cancel"), t("cli.reload_mcp.choice_cancel_hint"))]
|
||||
|
||||
|
||||
# /help section headers: the category ids are load-bearing for gateway help and the registry, so
|
||||
# they stay identifiers and are only localized at render time.
|
||||
_HELP_SECTION_KEYS = {
|
||||
"Session": "session",
|
||||
"Configuration": "configuration",
|
||||
"Tools & Skills": "tools_skills",
|
||||
"Info": "info",
|
||||
"Exit": "exit",
|
||||
"Context": "context",
|
||||
"Background & Automation": "background_automation",
|
||||
}
|
||||
|
||||
|
||||
def _help_section_title(category: str) -> str:
|
||||
slug = _HELP_SECTION_KEYS.get(category)
|
||||
return t(f"cli.help.section_{slug}") if slug else category
|
||||
|
||||
|
||||
def _ascii_box(title: str, width: int) -> None:
|
||||
@@ -57,7 +79,7 @@ def _ascii_box(title: str, width: int) -> None:
|
||||
|
||||
def _toolset_map(tools, availability, get_toolset_for_tool) -> dict:
|
||||
"""tool name → toolset id, including tools of unavailable toolsets (banner snapshot)."""
|
||||
tmap = {t["function"]["name"]: get_toolset_for_tool(t["function"]["name"]) for t in tools}
|
||||
tmap = {tool["function"]["name"]: get_toolset_for_tool(tool["function"]["name"]) for tool in tools}
|
||||
for item in availability.get("unavailable_toolsets", []):
|
||||
for name in item.get("tools", []):
|
||||
tmap.setdefault(name, item.get("id", item.get("name", "")))
|
||||
@@ -160,12 +182,8 @@ class CLIInfoMixin:
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, is_local_endpoint
|
||||
if ctx_len and ctx_len < MINIMUM_CONTEXT_LENGTH:
|
||||
self._console_print()
|
||||
self._console_print(
|
||||
f"[yellow]⚠️ Context length is only {ctx_len:,} tokens — "
|
||||
f"this is likely too low for agent use with tools.[/]")
|
||||
self._console_print(
|
||||
f"[dim] Hermes needs at least {MINIMUM_CONTEXT_LENGTH:,} tokens. Tool schemas + system prompt use a large fixed prefix.[/]"
|
||||
)
|
||||
self._console_print(f"[yellow]{t('cli.banner.context_too_low', tokens=f'{ctx_len:,}')}[/]")
|
||||
self._console_print(f"[dim] {t('cli.banner.context_minimum', tokens=f'{MINIMUM_CONTEXT_LENGTH:,}')}[/]")
|
||||
base_url = getattr(self, "base_url", "") or ""
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
try:
|
||||
@@ -173,27 +191,22 @@ class CLIInfoMixin:
|
||||
except ValueError:
|
||||
_port = None
|
||||
if _port == 11434 or "ollama" in base_url_hostname(base_url):
|
||||
fix = f"Ollama fix: OLLAMA_CONTEXT_LENGTH={MINIMUM_CONTEXT_LENGTH} ollama serve"
|
||||
fix = t("cli.banner.fix_ollama", tokens=str(MINIMUM_CONTEXT_LENGTH))
|
||||
elif _port == 1234:
|
||||
fix = "LM Studio fix: Set context length in model settings → reload model"
|
||||
fix = t("cli.banner.fix_lm_studio")
|
||||
elif is_local_endpoint(base_url): # llama.cpp / vLLM / any local server — not Ollama
|
||||
fix = (f"Fix: start your server with at least {MINIMUM_CONTEXT_LENGTH // 1000}K context "
|
||||
f"(llama.cpp: -c {MINIMUM_CONTEXT_LENGTH}), or set model.ollama_num_ctx in config.yaml "
|
||||
"to the window it really serves")
|
||||
fix = t("cli.banner.fix_local_server",
|
||||
tokens_k=str(MINIMUM_CONTEXT_LENGTH // 1000), tokens=str(MINIMUM_CONTEXT_LENGTH))
|
||||
else:
|
||||
fix = "Fix: Set model.context_length in config.yaml, or increase your server's context setting"
|
||||
fix = t("cli.banner.fix_config")
|
||||
self._console_print(f"[dim] {fix}[/]")
|
||||
|
||||
from hermes_cli.model_switch import is_nous_hermes_non_agentic
|
||||
if is_nous_hermes_non_agentic(getattr(self, "model", "") or ""):
|
||||
self._console_print()
|
||||
self._console_print(
|
||||
"[bold yellow]⚠ Nous Research Hermes 3 & 4 models are NOT agentic and are not "
|
||||
"designed for use with Hermes Agent.[/]")
|
||||
self._console_print(
|
||||
"[dim] They lack tool-calling capabilities required for agent workflows. "
|
||||
"Consider using an agentic model (Claude, GPT, Gemini, DeepSeek, etc.).[/]")
|
||||
self._console_print("[dim] Switch with: /model sonnet or /model gpt5[/]")
|
||||
self._console_print(f"[bold yellow]{t('cli.banner.hermes_models_not_agentic')}[/]")
|
||||
self._console_print(f"[dim] {t('cli.banner.hermes_models_lack_tools')}[/]")
|
||||
self._console_print(f"[dim] {t('cli.banner.hermes_models_switch_hint')}[/]")
|
||||
|
||||
# Project-local skills one-liner: trusted → count; untrusted-with-skills → point at
|
||||
# `hermes skills trust`. Never raises.
|
||||
@@ -204,14 +217,13 @@ class CLIInfoMixin:
|
||||
if _proj_dirs:
|
||||
_n = sum(sum(1 for _ in iter_skill_index_files(d, "SKILL.md")) for d in _proj_dirs)
|
||||
if _n:
|
||||
self._console_print(f"[dim]◆ {_n} project skill(s) loaded from this repo[/]")
|
||||
self._console_print(f"[dim]{t('cli.banner.project_skills_loaded', count=str(_n))}[/]")
|
||||
else:
|
||||
_untrusted = get_untrusted_project_skills_root()
|
||||
if _untrusted is not None:
|
||||
_root, _n = _untrusted
|
||||
self._console_print(
|
||||
f"[yellow]◆ {_n} project skill(s) found in {_root} but not "
|
||||
f"loaded — run `hermes skills trust` to enable them.[/]")
|
||||
f"[yellow]{t('cli.banner.project_skills_untrusted', count=str(_n), root=str(_root))}[/]")
|
||||
except Exception:
|
||||
logger.debug("project skills banner notice failed", exc_info=True)
|
||||
|
||||
@@ -236,7 +248,7 @@ class CLIInfoMixin:
|
||||
from cli import (
|
||||
ChatConsole, _BOLD, _DIM, _RST, _accent_hex, _cprint, _ensure_skill_commands,
|
||||
_termux_example_image_path, get_skill_bundles)
|
||||
from hermes_cli.commands import COMMANDS_BY_CATEGORY, HELP_SESSION_SUBGROUPS
|
||||
from hermes_cli.commands import COMMAND_REGISTRY, HELP_SESSION_SUBGROUPS
|
||||
|
||||
arg = (arg or "").strip()
|
||||
skill_commands = _ensure_skill_commands()
|
||||
@@ -251,11 +263,12 @@ class CLIInfoMixin:
|
||||
from agent.skill_commands import skill_command_collision_note
|
||||
from tools.skills_tool import _find_all_skills
|
||||
if skill_commands:
|
||||
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(skill_commands)} installed):")
|
||||
_cprint(f"\n ⚡ {_BOLD}{t('cli.help.skill_commands')}{_RST} "
|
||||
f"({t('cli.help.n_installed', count=str(len(skill_commands)))}):")
|
||||
for cmd, info in sorted(skill_commands.items()):
|
||||
_row(cmd, info['description'], 22)
|
||||
else:
|
||||
_cprint("\n No skill commands installed.")
|
||||
_cprint(f"\n {t('cli.help.no_skill_commands')}")
|
||||
# Skills whose name is a built-in command never get a /<name> (agent.skill_commands guard).
|
||||
for note in filter(None, (skill_command_collision_note(s["name"]) for s in _find_all_skills())):
|
||||
_cprint(f" {_DIM}⚠ {note}{_RST}")
|
||||
@@ -264,12 +277,13 @@ class CLIInfoMixin:
|
||||
|
||||
query = arg.lower() if arg else ""
|
||||
|
||||
default_header = t("cli.help.header")
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_help_header
|
||||
header = get_active_help_header("(^_^)? Available Commands")
|
||||
header = get_active_help_header(default_header)
|
||||
except Exception:
|
||||
header = "(^_^)? Available Commands"
|
||||
header = ((header or "").strip() or "(^_^)? Available Commands")[:55]
|
||||
header = default_header
|
||||
header = ((header or "").strip() or default_header)[:55]
|
||||
_cprint(f"\n{_BOLD}+{'-' * 55}+{_RST}")
|
||||
_cprint(f"{_BOLD}|{header:^55}|{_RST}")
|
||||
_cprint(f"{_BOLD}+{'-' * 55}+{_RST}")
|
||||
@@ -283,13 +297,26 @@ class CLIInfoMixin:
|
||||
if query and query not in cmd.lower() and query not in desc.lower():
|
||||
continue
|
||||
if not printed_header:
|
||||
_cprint(f"\n {_BOLD}── {title} ──{_RST}")
|
||||
_cprint(f"\n {_BOLD}── {_help_section_title(title)} ──{_RST}")
|
||||
printed_header = True
|
||||
_row(cmd, desc)
|
||||
|
||||
for category, commands in COMMANDS_BY_CATEGORY.items():
|
||||
# Rows come straight from the registry so descriptions resolve through the localized
|
||||
# ``CommandDef.describe()`` accessor (names/aliases stay identifiers).
|
||||
by_category: dict[str, list[tuple[str, str]]] = {}
|
||||
for cmd_def in COMMAND_REGISTRY:
|
||||
if cmd_def.gateway_only:
|
||||
continue
|
||||
desc = cmd_def.describe()
|
||||
rows = by_category.setdefault(cmd_def.category, [])
|
||||
rows.append((f"/{cmd_def.name}", t("cli.help.usage_suffix", description=desc, command=cmd_def.name,
|
||||
args=cmd_def.args_hint) if cmd_def.args_hint else desc))
|
||||
for alias in cmd_def.aliases:
|
||||
rows.append((f"/{alias}", t("cli.help.alias_suffix", description=desc, command=cmd_def.name)))
|
||||
|
||||
for category, commands in by_category.items():
|
||||
if category != "Session":
|
||||
_section(category, commands.items())
|
||||
_section(category, commands)
|
||||
continue
|
||||
# The oversized Session category renders as sub-groups
|
||||
# (Session / Context / Background & Automation).
|
||||
@@ -297,7 +324,7 @@ class CLIInfoMixin:
|
||||
buckets: dict[str, list[tuple[str, str]]] = {"Session": []}
|
||||
for _sub in HELP_SESSION_SUBGROUPS:
|
||||
buckets[_sub] = []
|
||||
for cmd, desc in commands.items():
|
||||
for cmd, desc in commands:
|
||||
buckets[sub_of.get(cmd, "Session")].append((cmd, desc))
|
||||
for _sub in ("Session", *HELP_SESSION_SUBGROUPS.keys()):
|
||||
_section(_sub, buckets.get(_sub) or [])
|
||||
@@ -309,41 +336,45 @@ class CLIInfoMixin:
|
||||
(cmd, info) for cmd, info in sorted(skill_commands.items())
|
||||
if query in cmd.lower() or query in (info.get("description", "").lower())]
|
||||
if matched_skills:
|
||||
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} (matching '{arg}'):")
|
||||
_cprint(f"\n ⚡ {_BOLD}{t('cli.help.skill_commands')}{_RST} "
|
||||
f"({t('cli.help.matching_query', query=arg)}):")
|
||||
for cmd, info in matched_skills:
|
||||
_row(cmd, info['description'], 22)
|
||||
elif skill_commands:
|
||||
_cprint(
|
||||
f"\n ⚡ {_BOLD}Skill Commands{_RST}: {len(skill_commands)} installed "
|
||||
f"— {_DIM}/help skills{_RST} to list them")
|
||||
f"\n ⚡ {_BOLD}{t('cli.help.skill_commands')}{_RST}: "
|
||||
f"{t('cli.help.n_installed', count=str(len(skill_commands)))} "
|
||||
f"— {_DIM}/help skills{_RST} {t('cli.help.to_list_them')}")
|
||||
|
||||
_bundles_now = get_skill_bundles()
|
||||
if _bundles_now and not query:
|
||||
_cprint(f"\n ▣ {_BOLD}Skill Bundles{_RST} ({len(_bundles_now)} installed):")
|
||||
_cprint(f"\n ▣ {_BOLD}{t('cli.help.skill_bundles')}{_RST} "
|
||||
f"({t('cli.help.n_installed', count=str(len(_bundles_now)))}):")
|
||||
for cmd, info in sorted(_bundles_now.items()):
|
||||
skill_count = len(info.get("skills", []))
|
||||
desc = info.get("description") or f"Load {skill_count} skills"
|
||||
desc = info.get("description") or t("cli.help.load_n_skills", count=str(skill_count))
|
||||
ChatConsole().print(
|
||||
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] "
|
||||
f"{_escape(desc)} [dim]({skill_count} skills)[/]")
|
||||
f"{_escape(desc)} [dim]({t('cli.help.n_skills', count=str(skill_count))})[/]")
|
||||
|
||||
quick_commands = self.config.get("quick_commands", {})
|
||||
if quick_commands and not query:
|
||||
_cprint(f"\n ⚡ {_BOLD}Quick Commands{_RST} ({len(quick_commands)} configured):")
|
||||
_cprint(f"\n ⚡ {_BOLD}{t('cli.help.quick_commands')}{_RST} "
|
||||
f"({t('cli.help.n_configured', count=str(len(quick_commands)))}):")
|
||||
for name, qcmd in sorted(quick_commands.items()):
|
||||
_row('/' + name, qcmd.get("description", qcmd.get("type", "")), 22)
|
||||
|
||||
if query:
|
||||
_cprint(f"\n {_DIM}Filtered by '{arg}' — run /help for the full list.{_RST}\n")
|
||||
_cprint(f"\n {_DIM}{t('cli.help.filtered_by', query=arg)}{_RST}\n")
|
||||
return
|
||||
|
||||
_cprint(f"\n {_DIM}Tip: /help skills lists skill commands · /help <text> filters · Ctrl+P opens the command palette{_RST}")
|
||||
_cprint(f" {_DIM}Multi-line: Ctrl+J, Alt+Enter, or \\\\+Enter for a new line{_RST}")
|
||||
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
|
||||
_cprint(f"\n {_DIM}{t('cli.help.tip_line')}{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.help.multiline_hint')}{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.help.draft_editor_hint')}{_RST}")
|
||||
if _is_termux_environment():
|
||||
_cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n")
|
||||
_cprint(f" {_DIM}{t('cli.help.attach_image_hint', path=_termux_example_image_path())}{_RST}\n")
|
||||
else:
|
||||
_cprint(f" {_DIM}Paste image: Alt+V (or /paste){_RST}\n")
|
||||
_cprint(f" {_DIM}{t('cli.help.paste_image_hint')}{_RST}\n")
|
||||
|
||||
def show_tools(self):
|
||||
"""Display available tools with kawaii ASCII art."""
|
||||
@@ -355,15 +386,15 @@ class CLIInfoMixin:
|
||||
disabled_toolsets=self.disabled_toolsets, quiet_mode=True,
|
||||
skip_tool_search_assembly=True)
|
||||
if not tools:
|
||||
print("(;_;) No tools available")
|
||||
print(t("cli.tools.none_available"))
|
||||
return
|
||||
|
||||
print()
|
||||
_ascii_box("(^_^)/ Available Tools", 78)
|
||||
_ascii_box(t("cli.tools.header"), 78)
|
||||
print()
|
||||
|
||||
toolsets: dict[str, list] = {}
|
||||
for tool in sorted(tools, key=lambda t: t["function"]["name"]):
|
||||
for tool in sorted(tools, key=lambda item: item["function"]["name"]):
|
||||
name = tool["function"]["name"]
|
||||
toolset = get_toolset_for_tool(name) or "unknown"
|
||||
desc = tool["function"].get("description", "").split("\n")[0]
|
||||
@@ -378,7 +409,7 @@ class CLIInfoMixin:
|
||||
print(f" * {name:<20} - {desc}")
|
||||
print()
|
||||
|
||||
print(f" Total: {len(tools)} tools ヽ(^o^)ノ")
|
||||
print(f" {t('cli.tools.total', count=str(len(tools)))}")
|
||||
print()
|
||||
|
||||
def show_toolsets(self):
|
||||
@@ -387,7 +418,7 @@ class CLIInfoMixin:
|
||||
all_toolsets = get_all_toolsets()
|
||||
|
||||
print()
|
||||
_ascii_box("(^_^)b Available Toolsets", 58)
|
||||
_ascii_box(t("cli.toolsets.header"), 58)
|
||||
print()
|
||||
|
||||
for name in sorted(all_toolsets.keys()):
|
||||
@@ -397,10 +428,10 @@ class CLIInfoMixin:
|
||||
print(f" {marker} {name:<18} [{info['tool_count']:>2} tools] - {info['description']}")
|
||||
|
||||
print()
|
||||
print(" (*) = currently enabled")
|
||||
print(f" {t('cli.toolsets.currently_enabled_legend')}")
|
||||
print()
|
||||
print(" Tip: Use 'all' or '*' to enable all toolsets")
|
||||
print(" Example: python cli.py --toolsets web,terminal")
|
||||
print(f" {t('cli.toolsets.tip_enable_all')}")
|
||||
print(f" {t('cli.toolsets.example')}")
|
||||
print()
|
||||
|
||||
def _handle_whoami_command(self):
|
||||
@@ -411,10 +442,10 @@ class CLIInfoMixin:
|
||||
except Exception:
|
||||
user_name = "?"
|
||||
print()
|
||||
print(" You: cli (local terminal)")
|
||||
print(f" User: {user_name}")
|
||||
print(" Tier: unrestricted")
|
||||
print(" Slash commands: all available")
|
||||
print(f" {t('cli.whoami.you_label'):<15} {t('cli.whoami.you_value')}")
|
||||
print(f" {t('cli.whoami.user_label'):<15} {user_name}")
|
||||
print(f" {t('cli.whoami.tier_label'):<15} {t('cli.whoami.tier_value')}")
|
||||
print(f" {t('cli.whoami.slash_commands_label'):<15} {t('cli.whoami.slash_commands_value')}")
|
||||
print()
|
||||
|
||||
def _busy_inline_command(self, text: str, has_images: bool, names: tuple) -> bool:
|
||||
@@ -478,8 +509,9 @@ class CLIInfoMixin:
|
||||
|
||||
approval = check_bang_approval(command)
|
||||
if not approval.get("approved"):
|
||||
message = approval.get("message") or (
|
||||
f"Command denied: {approval.get('description', 'flagged as dangerous')}")
|
||||
message = approval.get("message") or t(
|
||||
"cli.shell.command_denied",
|
||||
reason=approval.get("description") or t("cli.shell.flagged_as_dangerous"))
|
||||
self._console_print(f"[bold red]{_escape(str(message))}[/]")
|
||||
return True
|
||||
|
||||
@@ -488,7 +520,7 @@ class CLIInfoMixin:
|
||||
cwd=resolve_bang_cwd(getattr(self, "session_id", None)),
|
||||
writer=lambda line: self._console_print(_rich_text_from_ansi(line)))
|
||||
if exit_code:
|
||||
self._console_print(f"[dim]! exited {exit_code}[/]")
|
||||
self._console_print(f"[dim]{t('cli.shell.exited', code=str(exit_code))}[/]")
|
||||
return True
|
||||
|
||||
def _show_gateway_status(self):
|
||||
@@ -497,14 +529,12 @@ class CLIInfoMixin:
|
||||
from gateway.config import load_gateway_config, Platform
|
||||
|
||||
print()
|
||||
print("+" + "-" * 60 + "+")
|
||||
print("|" + " " * 15 + "(✿◠‿◠) Gateway Status" + " " * 17 + "|")
|
||||
print("+" + "-" * 60 + "+")
|
||||
_ascii_box(t("cli.gateway_status.header"), 60)
|
||||
print()
|
||||
|
||||
try:
|
||||
config = load_gateway_config()
|
||||
print(" Messaging Platform Configuration:")
|
||||
print(f" {t('cli.gateway_status.platform_config_header')}")
|
||||
print(" " + "-" * 55)
|
||||
platform_status = {
|
||||
Platform.TELEGRAM: ("Telegram", "TELEGRAM_BOT_TOKEN"),
|
||||
@@ -516,26 +546,26 @@ class CLIInfoMixin:
|
||||
if pconfig and pconfig.enabled:
|
||||
home = config.get_home_channel(platform)
|
||||
home_str = f" → {home.name}" if home else ""
|
||||
print(f" ✓ {name:<12} Enabled{home_str}")
|
||||
print(f" ✓ {name:<12} {t('cli.gateway_status.enabled')}{home_str}")
|
||||
else:
|
||||
print(f" ○ {name:<12} Not configured ({env_var})")
|
||||
print(f" ○ {name:<12} {t('cli.gateway_status.not_configured', env_var=env_var)}")
|
||||
|
||||
print()
|
||||
print(" Conversations persist until /new or /reset.")
|
||||
print(f" {t('cli.gateway_status.conversations_persist')}")
|
||||
print()
|
||||
print(" To start the gateway:")
|
||||
print(f" {t('cli.gateway_status.to_start')}")
|
||||
print(" python cli.py --gateway")
|
||||
print()
|
||||
print(f" Configuration file: {display_hermes_home()}/config.yaml")
|
||||
print(f" {t('cli.gateway_status.config_file', path=f'{display_hermes_home()}/config.yaml')}")
|
||||
print()
|
||||
except Exception as e:
|
||||
print(f" Error loading gateway config: {e}")
|
||||
print(f" {t('cli.gateway_status.load_error', error=str(e))}")
|
||||
print()
|
||||
print(" To configure the gateway:")
|
||||
print(" 1. Set environment variables:")
|
||||
print(f" {t('cli.gateway_status.to_configure')}")
|
||||
print(f" {t('cli.gateway_status.step_env_vars')}")
|
||||
print(" TELEGRAM_BOT_TOKEN=your_token")
|
||||
print(" DISCORD_BOT_TOKEN=your_token")
|
||||
print(f" 2. Or configure settings in {display_hermes_home()}/config.yaml")
|
||||
print(f" {t('cli.gateway_status.step_config', path=f'{display_hermes_home()}/config.yaml')}")
|
||||
print()
|
||||
|
||||
def _print_random_tip(self) -> None:
|
||||
@@ -548,7 +578,7 @@ class CLIInfoMixin:
|
||||
_tip_color = get_active_skin().get_color("banner_dim", "#B8860B")
|
||||
except Exception:
|
||||
_tip_color = "#B8860B"
|
||||
self._console_print(f"[dim {_tip_color}]✦ Tip: {_tip}[/]")
|
||||
self._console_print(f"[dim {_tip_color}]{t('cli.tip_line', tip=_tip)}[/]")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -585,7 +615,7 @@ class CLIInfoMixin:
|
||||
# Sync the live agent so tool_executor rendering reflects the new mode this turn.
|
||||
self.agent.tool_progress_mode = self.tool_progress_mode
|
||||
|
||||
_cprint(_TOOL_PROGRESS_LABELS.get(self.tool_progress_mode, ""))
|
||||
_cprint(_tool_progress_label(self.tool_progress_mode))
|
||||
|
||||
def _handle_usage_command(self, cmd_original: str):
|
||||
"""Dispatch `/usage [reset [--force]]`: bare `/usage` is the classic display; `reset`
|
||||
@@ -596,7 +626,7 @@ class CLIInfoMixin:
|
||||
self._usage_reset(force="--force" in args[1:])
|
||||
return
|
||||
if args:
|
||||
print(f" Unknown /usage subcommand: {' '.join(parts[1:])}. Try /usage or /usage reset [--force].")
|
||||
print(f" {t('cli.usage.unknown_subcommand', args=' '.join(parts[1:]))}")
|
||||
return
|
||||
self._show_usage()
|
||||
|
||||
@@ -607,12 +637,12 @@ class CLIInfoMixin:
|
||||
def _usage_reset(self, force: bool = False):
|
||||
"""`/usage reset [--force]` — redeem one banked Codex reset credit."""
|
||||
if str(self._agent_or_self("provider") or "").strip().lower() != "openai-codex":
|
||||
print(" Banked usage resets are only available on the openai-codex provider.")
|
||||
print(" Switch with `/model` or `hermes auth` first.")
|
||||
print(f" {t('cli.usage.reset_wrong_provider')}")
|
||||
print(f" {t('cli.usage.reset_switch_hint')}")
|
||||
return
|
||||
from agent.account_usage import redeem_codex_reset_credit
|
||||
|
||||
print(" ⏳ Checking banked reset credits...")
|
||||
print(f" {t('cli.usage.checking_reset_credits')}")
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _pool:
|
||||
try:
|
||||
result = _pool.submit(
|
||||
@@ -620,7 +650,7 @@ class CLIInfoMixin:
|
||||
api_key=self._agent_or_self("api_key"), force=force,
|
||||
).result(timeout=45.0)
|
||||
except concurrent.futures.TimeoutError:
|
||||
print(" ❌ Timed out talking to the Codex backend — try again shortly.")
|
||||
print(f" {t('cli.usage.codex_timeout')}")
|
||||
return
|
||||
print(f" {result.message}")
|
||||
|
||||
@@ -629,7 +659,7 @@ class CLIInfoMixin:
|
||||
per-category table; `all` appends per-skill / per-toolset costs. Read-only: same chars/4
|
||||
engine as the desktop popover (agent.context_breakdown) — no provider calls, no cache impact."""
|
||||
if not self.agent:
|
||||
print(" (._.) No active agent -- send a message first.")
|
||||
print(f" {t('cli.shared.no_active_agent')}")
|
||||
return
|
||||
|
||||
args = cmd_original.split(maxsplit=1)[1].strip().lower() if " " in cmd_original else ""
|
||||
@@ -641,7 +671,7 @@ class CLIInfoMixin:
|
||||
try:
|
||||
payload = compute_session_context_breakdown(self.agent, self.conversation_history)
|
||||
except Exception as e:
|
||||
print(f" (._.) Could not compute context breakdown: {e}")
|
||||
print(f" {t('cli.context.compute_failed', error=str(e))}")
|
||||
return
|
||||
|
||||
details = None
|
||||
@@ -658,7 +688,7 @@ class CLIInfoMixin:
|
||||
file_lines = []
|
||||
|
||||
print()
|
||||
print(f" 🧠 Context Usage — {payload.get('model') or self.model}")
|
||||
print(f" {t('cli.context.header', model=payload.get('model') or self.model)}")
|
||||
print()
|
||||
for line in render_context_breakdown_lines(payload, details=details, grid=True) + ([""] + file_lines if file_lines else []):
|
||||
print(f" {line}")
|
||||
@@ -682,12 +712,12 @@ class CLIInfoMixin:
|
||||
print(fallback)
|
||||
|
||||
if not self.agent:
|
||||
_credits_or("(._.) No active agent -- send a message first.")
|
||||
_credits_or(t("cli.shared.no_active_agent"))
|
||||
return
|
||||
agent = self.agent
|
||||
calls = agent.session_api_calls
|
||||
if calls == 0:
|
||||
_credits_or("(._.) No API calls made yet in this session.")
|
||||
_credits_or(t("cli.usage.no_api_calls"))
|
||||
return
|
||||
|
||||
rl_state = agent.get_rate_limit_state()
|
||||
@@ -706,26 +736,30 @@ class CLIInfoMixin:
|
||||
pct = min(100, (last_prompt / ctx_len * 100)) if ctx_len else 0
|
||||
elapsed = format_duration_compact((datetime.now() - self.session_start).total_seconds())
|
||||
|
||||
print(" 📊 Session Token Usage")
|
||||
def _label_row(key: str, value: str) -> None:
|
||||
# Labels are re-padded here (not in the catalog) so translated widths still align.
|
||||
print(f" {t(key):<26} {value}")
|
||||
|
||||
print(f" {t('cli.usage.header_session')}")
|
||||
print(f" {'─' * 40}")
|
||||
print(f" Model: {agent.model}")
|
||||
print(f" Input tokens: {input_tokens:>10,}")
|
||||
print(f" Output tokens: {output_tokens:>10,}")
|
||||
_label_row("cli.usage.label_model", str(agent.model))
|
||||
_label_row("cli.usage.label_input_tokens", f"{input_tokens:>10,}")
|
||||
_label_row("cli.usage.label_output_tokens", f"{output_tokens:>10,}")
|
||||
if reasoning_tokens:
|
||||
print(f" ↳ Reasoning (subset): {reasoning_tokens:>10,}")
|
||||
print(f" Prompt tokens (total): {agent.session_prompt_tokens:>10,}")
|
||||
print(f" Completion tokens: {agent.session_completion_tokens:>10,}")
|
||||
print(f" Total tokens: {agent.session_total_tokens:>10,}")
|
||||
print(f" API calls: {calls:>10,}")
|
||||
print(f" Session duration: {elapsed:>10}")
|
||||
_label_row("cli.usage.label_reasoning_subset", f"{reasoning_tokens:>10,}")
|
||||
_label_row("cli.usage.label_prompt_tokens_total", f"{agent.session_prompt_tokens:>10,}")
|
||||
_label_row("cli.usage.label_completion_tokens", f"{agent.session_completion_tokens:>10,}")
|
||||
_label_row("cli.usage.label_total_tokens", f"{agent.session_total_tokens:>10,}")
|
||||
_label_row("cli.usage.label_api_calls", f"{calls:>10,}")
|
||||
_label_row("cli.usage.label_session_duration", f"{elapsed:>10}")
|
||||
print(f" {'─' * 40}")
|
||||
from agent.context_breakdown import context_display_source
|
||||
mark = "~" if context_display_source(compressor) != "provider_usage" else ""
|
||||
from agent.context_pin import context_pin_suffix
|
||||
print(f" Current context: {mark}{last_prompt:,} / {ctx_len:,} ({mark}{pct:.0f}%)"
|
||||
f"{context_pin_suffix(ctx_len, getattr(agent, '_config_context_length', None))}")
|
||||
print(f" Messages: {len(self.conversation_history)}")
|
||||
print(f" Compressions: {compressor.compression_count}")
|
||||
pin_suffix = context_pin_suffix(ctx_len, getattr(agent, '_config_context_length', None))
|
||||
print(f" {t('cli.usage.label_current_context'):<17} {mark}{last_prompt:,} / {ctx_len:,} ({mark}{pct:.0f}%){pin_suffix}")
|
||||
print(f" {t('cli.usage.label_messages'):<17} {len(self.conversation_history)}")
|
||||
print(f" {t('cli.usage.label_compressions'):<17} {compressor.compression_count}")
|
||||
|
||||
self._print_account_limits()
|
||||
|
||||
@@ -779,7 +813,7 @@ class CLIInfoMixin:
|
||||
try:
|
||||
days = int(parts[i + 1])
|
||||
except ValueError:
|
||||
print(f" Invalid --days value: {parts[i + 1]}")
|
||||
print(f" {t('gateway.insights.invalid_days', value=parts[i + 1])}")
|
||||
return
|
||||
i += 2
|
||||
elif parts[i] == "--source" and i + 1 < len(parts):
|
||||
@@ -794,7 +828,7 @@ class CLIInfoMixin:
|
||||
from hermes_state import SessionDB, _default_db_path
|
||||
from agent.insights import InsightsEngine
|
||||
if not _default_db_path().exists():
|
||||
print(" No session data yet.")
|
||||
print(f" {t('cli.insights.no_session_data')}")
|
||||
return
|
||||
db = SessionDB(read_only=True)
|
||||
try:
|
||||
@@ -803,7 +837,7 @@ class CLIInfoMixin:
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
print(f" Error generating insights: {e}")
|
||||
print(f" {t('gateway.insights.error', error=str(e))}")
|
||||
|
||||
def _check_config_mcp_changes(self) -> None:
|
||||
"""Detect mcp_servers changes in config.yaml (polled from process_loop every
|
||||
@@ -860,16 +894,15 @@ class CLIInfoMixin:
|
||||
|
||||
if not _auto:
|
||||
print()
|
||||
print("🔄 MCP server config changed — reload skipped (auto-reload disabled).")
|
||||
print(" New settings are NOT applied yet. To apply them now, run:")
|
||||
print(t("cli.mcp_watch.changed_reload_skipped"))
|
||||
print(f" {t('cli.mcp_watch.not_applied_run')}")
|
||||
print(" /reload-mcp")
|
||||
print(" ⚠️ Note: /reload-mcp rebuilds the tool set and invalidates the")
|
||||
print(" provider prompt cache (next message re-sends full input tokens).")
|
||||
print(f" {t('cli.mcp_watch.cache_note')}")
|
||||
return
|
||||
|
||||
# Separate thread so a hung MCP server can't block process_loop (freezing the TUI).
|
||||
print()
|
||||
print("🔄 MCP server config changed — reloading connections...")
|
||||
print(t("cli.mcp_watch.changed_reloading"))
|
||||
threading.Thread(target=self._reload_mcp, daemon=True).start()
|
||||
|
||||
def _confirm_and_reload_mcp(self, cmd_original: str = "") -> None:
|
||||
@@ -879,12 +912,12 @@ class CLIInfoMixin:
|
||||
(tool schemas are baked into the system prompt), hence the warning."""
|
||||
choice = _gated_confirm(
|
||||
self, "reload-mcp", "mcp_reload_confirm",
|
||||
title="⚠️ /reload-mcp — Prompt cache invalidation warning",
|
||||
detail=_RELOAD_MCP_DETAIL,
|
||||
choices=_RELOAD_MCP_CHOICES,
|
||||
unchanged="MCP tools unchanged.",
|
||||
always_msg="🔒 Future /reload-mcp calls will run without confirmation.",
|
||||
once_verb="reloading")
|
||||
title=t("cli.reload_mcp.title"),
|
||||
detail=t("cli.reload_mcp.detail"),
|
||||
choices=_reload_mcp_choices(),
|
||||
unchanged=t("cli.reload_mcp.unchanged"),
|
||||
always_msg=t("cli.reload_mcp.always_msg"),
|
||||
once_verb=t("cli.reload_mcp.once_verb"))
|
||||
if choice is None:
|
||||
return
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
@@ -901,7 +934,7 @@ class CLIInfoMixin:
|
||||
with _lock:
|
||||
old_servers = set(_servers.keys())
|
||||
if not self._command_running:
|
||||
print("🔄 Reloading MCP servers...")
|
||||
print(t("cli.reload_mcp.reloading"))
|
||||
|
||||
shutdown_mcp_servers()
|
||||
reprobe_tool_availability() # explicit reload also re-probes check_fn availability
|
||||
@@ -909,17 +942,20 @@ class CLIInfoMixin:
|
||||
|
||||
with _lock:
|
||||
connected_servers = set(_servers.keys())
|
||||
# English labels are model-facing (the [IMPORTANT: …] note below); the printed lines
|
||||
# resolve their own localized key per bucket.
|
||||
diff = {
|
||||
"Added": connected_servers - old_servers,
|
||||
"Removed": old_servers - connected_servers,
|
||||
"Reconnected": connected_servers & old_servers}
|
||||
for label, icon in (("Reconnected", "♻️ "), ("Added", "➕"), ("Removed", "➖")):
|
||||
for label, key in (("Reconnected", "cli.reload_mcp.reconnected"), ("Added", "cli.reload_mcp.added"),
|
||||
("Removed", "cli.reload_mcp.removed")):
|
||||
if diff[label]:
|
||||
print(f" {icon} {label}: {', '.join(sorted(diff[label]))}")
|
||||
print(f" {t(key, names=', '.join(sorted(diff[label])))}")
|
||||
if not connected_servers:
|
||||
print(" No MCP servers connected.")
|
||||
print(f" {t('gateway.reload_mcp.none_connected')}")
|
||||
else:
|
||||
print(f" 🔧 {len(new_tools)} tool(s) available from {len(connected_servers)} server(s)")
|
||||
print(f" {t('cli.reload_mcp.tools_available', tools=str(len(new_tools)), servers=str(len(connected_servers)))}")
|
||||
|
||||
# Route through the shared helper so this path stays in lockstep with the TUI RPC /
|
||||
# gateway reload / late-binding paths (name-diff, thread-safe, additive-preserving so
|
||||
@@ -959,9 +995,9 @@ class CLIInfoMixin:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f" ✅ Agent updated — {len(self.agent.tools if self.agent else [])} tool(s) available")
|
||||
print(f" {t('cli.reload_mcp.agent_updated', count=str(len(self.agent.tools if self.agent else [])))}")
|
||||
except Exception as e:
|
||||
print(f" ❌ MCP reload failed: {e}")
|
||||
print(f" {t('gateway.reload_mcp.failed', error=str(e))}")
|
||||
|
||||
def _reload_skills(self) -> None:
|
||||
"""Reload skills: rescan ~/.hermes/skills/ and queue a note for the next user turn.
|
||||
@@ -975,7 +1011,7 @@ class CLIInfoMixin:
|
||||
try:
|
||||
from agent.skill_commands import reload_skills, get_skill_commands
|
||||
if not self._command_running:
|
||||
print("🔄 Reloading skills...")
|
||||
print(t("cli.reload_skills.reloading"))
|
||||
result = reload_skills()
|
||||
|
||||
# Sync cli.py's module-level _skill_commands so help / dispatch / Tab-completion see
|
||||
@@ -987,19 +1023,19 @@ class CLIInfoMixin:
|
||||
total = result.get("total", 0)
|
||||
|
||||
if not added and not removed:
|
||||
print(" No new skills detected.")
|
||||
print(f" 📚 {total} skill(s) available")
|
||||
print(f" {t('gateway.reload_skills.no_new')}")
|
||||
print(f" {t('cli.reload_skills.total', count=str(total))}")
|
||||
return
|
||||
|
||||
if added:
|
||||
print(" ➕ Added Skills:")
|
||||
print(f" {t('cli.reload_skills.added_header')}")
|
||||
for item in added:
|
||||
print(f" {_skill_line(item)}")
|
||||
if removed:
|
||||
print(" ➖ Removed Skills:")
|
||||
print(f" {t('cli.reload_skills.removed_header')}")
|
||||
for item in removed:
|
||||
print(f" {_skill_line(item)}")
|
||||
print(f" 📚 {total} skill(s) available")
|
||||
print(f" {t('cli.reload_skills.total', count=str(total))}")
|
||||
|
||||
# Same shape as the system prompt's skill catalog (`` - name: description``).
|
||||
sections = ["[USER INITIATED SKILLS RELOAD:"]
|
||||
@@ -1010,4 +1046,4 @@ class CLIInfoMixin:
|
||||
sections += ["", "Use skills_list to see the updated catalog.]"]
|
||||
self._pending_skills_reload_note = "\n".join(sections)
|
||||
except Exception as e:
|
||||
print(f" ❌ Skills reload failed: {e}")
|
||||
print(f" {t('gateway.reload_skills.failed', error=str(e))}")
|
||||
@@ -230,7 +230,8 @@ class CLIInitMixin:
|
||||
invalid = [t for t in toolsets
|
||||
if not validate_toolset(t) and t not in mcp_names and t not in plugin_ts_names]
|
||||
if invalid:
|
||||
self._console_print(f"[bold red]Warning: Unknown toolsets: {', '.join(invalid)}[/]")
|
||||
from agent.i18n import t as _t
|
||||
self._console_print(f"[bold red]{_t('cli.startup.unknown_toolsets', names=', '.join(invalid))}[/]")
|
||||
|
||||
def _init_checkpoints_and_rules(self, checkpoints, pass_session_id, ignore_rules):
|
||||
from cli import CLI_CONFIG
|
||||
@@ -340,22 +341,15 @@ class CLIInitMixin:
|
||||
self._session_db_unavailable = True
|
||||
logger.warning("Failed to initialize SessionDB — session will NOT be indexed for search: %s", e)
|
||||
from hermes_state_user_copy import describe_storage_failure, storage_failure_details
|
||||
from agent.i18n import t
|
||||
failure = describe_storage_failure(e)
|
||||
def _present_store_warning():
|
||||
try:
|
||||
Console(stderr=True).print(
|
||||
"[bold yellow]⚠ Session store unavailable[/bold yellow] — "
|
||||
"this conversation will [bold]NOT be saved[/bold] and cannot be resumed later. "
|
||||
"Searching past sessions is also disabled.\n"
|
||||
f" Reason: {failure.gloss}.\n"
|
||||
f" {failure.action}\n"
|
||||
f" [dim]Details: {storage_failure_details(e)}[/dim]"
|
||||
)
|
||||
Console(stderr=True).print(t(
|
||||
"cli.session_store.unavailable_rich",
|
||||
reason=failure.gloss, action=failure.action, details=storage_failure_details(e)))
|
||||
except Exception:
|
||||
print(
|
||||
"WARNING: Session store unavailable — this conversation will NOT be "
|
||||
f"saved and cannot be resumed later. Reason: {failure.gloss}. {failure.action}"
|
||||
)
|
||||
print(t("cli.session_store.unavailable_plain", reason=failure.gloss, action=failure.action))
|
||||
# Same automatic diagnostic the gateway gates for its home channel (run_notifications).
|
||||
from gateway.warning_notifications import render_notification
|
||||
render_notification(_present_store_warning, platform="cli")
|
||||
|
||||
@@ -9,17 +9,23 @@ import shutil
|
||||
import threading
|
||||
import time
|
||||
|
||||
from agent.i18n import t
|
||||
from rich.markup import escape as _escape
|
||||
|
||||
_FRESH_START = " ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n"
|
||||
|
||||
def _fresh_start() -> str:
|
||||
"""Banner line printed after /clear — resolved at call time so the active language applies."""
|
||||
return f" {t('cli.session.fresh_start')}\n"
|
||||
|
||||
|
||||
def _preview(payload: str) -> str:
|
||||
return f"{payload[:80]}{'...' if len(payload) > 80 else ''}"
|
||||
|
||||
|
||||
_QUEUE_USAGE = ("Usage: /queue <prompt> | /queue list | /queue edit N <prompt> | "
|
||||
"/queue rm N | /queue move FROM TO | /queue clear")
|
||||
def _queue_usage() -> str:
|
||||
return t("cli.queue.usage")
|
||||
|
||||
|
||||
# management verb -> (handler, takes a leading item index). Verbs without an index are
|
||||
# only management when they stand alone; index verbs only when a number follows.
|
||||
_QUEUE_VERBS: dict[str, tuple[str, bool]] = {
|
||||
@@ -52,7 +58,7 @@ class CLILoopsMixin:
|
||||
if _args in {"--delete", "-d"}:
|
||||
self._delete_session_on_exit = True
|
||||
elif _args:
|
||||
_cprint(f" {_DIM}✗ Unknown argument: {_escape(_args)}. Use /exit --delete to also remove session history.{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.exit.unknown_argument', argument=_escape(_args))}{_RST}")
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -65,7 +71,7 @@ class CLILoopsMixin:
|
||||
# SSH restores; #8688). Ctrl+L is bound to the same helper.
|
||||
from cli import _DIM, _RST, _cprint
|
||||
self._force_full_redraw()
|
||||
_cprint(f" {_DIM}✓ UI redrawn{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.display.ui_redrawn')}{_RST}")
|
||||
|
||||
def _cmd_clear(self, cmd_original: str):
|
||||
from cli import ChatConsole, _build_compact_banner, _clear_output_history, _cprint, get_tool_definitions
|
||||
@@ -82,7 +88,7 @@ class CLILoopsMixin:
|
||||
if not self._app:
|
||||
self.console.clear()
|
||||
self.show_banner()
|
||||
print(_FRESH_START)
|
||||
print(_fresh_start())
|
||||
self._print_random_tip()
|
||||
return
|
||||
# Inside the TUI, Rich's console.clear() and self.console both go through
|
||||
@@ -108,7 +114,7 @@ class CLILoopsMixin:
|
||||
tools=tools, enabled_toolsets=self.enabled_toolsets, session_id=self.session_id,
|
||||
context_length=ctx_len, provider=self.provider,
|
||||
context_pinned=is_context_pinned(ctx_len, getattr(agent, "_config_context_length", None)))
|
||||
_cprint(_FRESH_START)
|
||||
_cprint(_fresh_start())
|
||||
self._print_random_tip()
|
||||
|
||||
def _cmd_title(self, cmd_original: str):
|
||||
@@ -120,18 +126,18 @@ class CLILoopsMixin:
|
||||
if not self._session_db:
|
||||
_cprint(f" {format_session_db_unavailable(details=True)}")
|
||||
return
|
||||
_cprint(f" Session ID: {self.session_id}")
|
||||
_cprint(f" {t('cli.title.session_id', session_id=self.session_id)}")
|
||||
session = self._session_db.get_session(self.session_id)
|
||||
if session and session.get("title"):
|
||||
_cprint(f" Title: {session['title']}")
|
||||
_cprint(f" {t('cli.title.current', title=session['title'])}")
|
||||
elif self._pending_title:
|
||||
_cprint(f" Title (pending): {self._pending_title}")
|
||||
_cprint(f" {t('cli.title.pending', title=self._pending_title)}")
|
||||
else:
|
||||
_cprint(" No title set. Usage: /title <your session title>")
|
||||
_cprint(f" {t('cli.title.none_set_usage')}")
|
||||
return
|
||||
raw_title = parts[1].strip()
|
||||
if not raw_title:
|
||||
_cprint(" Usage: /title <your session title>")
|
||||
_cprint(f" {t('cli.title.usage')}")
|
||||
return
|
||||
if not self._session_db:
|
||||
_cprint(f" {format_session_db_unavailable(details=True)}")
|
||||
@@ -146,24 +152,24 @@ class CLILoopsMixin:
|
||||
_cprint(f" {e}")
|
||||
return True
|
||||
if not new_title:
|
||||
_cprint(" Title is empty after cleanup. Please use printable characters.")
|
||||
_cprint(f" {t('cli.title.empty_after_cleanup')}")
|
||||
elif self._session_db.get_session(self.session_id):
|
||||
try:
|
||||
if self._session_db.set_session_title(self.session_id, new_title):
|
||||
self._status_bar_title_checked_at = 0.0
|
||||
_cprint(f" Session title set: {new_title}")
|
||||
_cprint(f" {t('cli.title.set', title=new_title)}")
|
||||
else:
|
||||
_cprint(" Session not found in database.")
|
||||
_cprint(f" {t('gateway.shared.session_not_found')}")
|
||||
except ValueError as e:
|
||||
_cprint(f" {e}")
|
||||
else:
|
||||
# Session not created yet — check uniqueness now, defer the title.
|
||||
existing = self._session_db.get_session_by_title(new_title)
|
||||
if existing:
|
||||
_cprint(f" Title '{new_title}' is already in use by session {existing['id']}")
|
||||
_cprint(f" {t('cli.title.already_in_use', title=new_title, session_id=existing['id'])}")
|
||||
else:
|
||||
self._pending_title = new_title
|
||||
_cprint(f" Session title queued: {new_title} (will be saved on first message)")
|
||||
_cprint(f" {t('cli.title.queued', title=new_title)}")
|
||||
|
||||
def _cmd_new(self, cmd_original: str):
|
||||
# Strip inline-skip tokens (now/--yes/-y) before deriving the title so
|
||||
@@ -202,16 +208,16 @@ class CLILoopsMixin:
|
||||
try:
|
||||
_undo_n = max(1, int(_undo_parts[1]))
|
||||
except ValueError:
|
||||
print(f"(._.) Invalid count {_undo_parts[1]!r} — use /undo or /undo N.")
|
||||
print(t("cli.undo.invalid_count", value=repr(_undo_parts[1])))
|
||||
return True # bad arg — command handled, keep the REPL alive
|
||||
# Nothing to undo → say so; no destructive confirmation for a no-op (SC-06).
|
||||
if not self.conversation_history:
|
||||
print("(._.) No messages to undo.")
|
||||
print(t("cli.session.undo_no_messages"))
|
||||
return True
|
||||
_undo_desc = (
|
||||
"This removes the last user/assistant exchange from history."
|
||||
t("cli.undo.removes_last_exchange")
|
||||
if _undo_n == 1
|
||||
else f"This removes the last {_undo_n} user turns from history.")
|
||||
else t("cli.undo.removes_last_turns", count=_undo_n))
|
||||
if self._confirm_destructive_slash("undo", _undo_desc, cmd_original=cmd_original) is None:
|
||||
return True # confirmation cancelled — command handled, keep REPL alive
|
||||
turns_undone = self.undo_last(_undo_n)
|
||||
@@ -229,7 +235,8 @@ class CLILoopsMixin:
|
||||
|
||||
def _cmd_statusbar(self, cmd_original: str):
|
||||
self._status_bar_visible = not self._status_bar_visible
|
||||
self._console_print(f" Status bar {'visible' if self._status_bar_visible else 'hidden'}")
|
||||
self._console_print(
|
||||
f" {t('cli.display.status_bar_visible') if self._status_bar_visible else t('cli.display.status_bar_hidden')}")
|
||||
|
||||
def _cmd_update(self, cmd_original: str) -> bool:
|
||||
# A truthy result means the process is relaunching — leave the REPL.
|
||||
@@ -242,7 +249,7 @@ class CLILoopsMixin:
|
||||
def _cmd_reload(self, cmd_original: str):
|
||||
from hermes_cli.config import reload_env
|
||||
count = reload_env()
|
||||
print(f" Reloaded .env ({count} var(s) updated)")
|
||||
print(f" {t('cli.reload.env_reloaded', count=count)}")
|
||||
|
||||
def _cmd_reload_skills(self, cmd_original: str):
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
@@ -265,22 +272,22 @@ class CLILoopsMixin:
|
||||
user_entries = [e for e in entries if e[3] != "bundled"]
|
||||
bundled_count = len(entries) - len(user_entries)
|
||||
if not user_entries:
|
||||
print("No user plugins installed.")
|
||||
print(" Install one: hermes plugins install owner/repo")
|
||||
print(f" Or drop a plugin directory into {display_hermes_home()}/plugins/")
|
||||
print(t("cli.plugins.none_installed"))
|
||||
print(f" {t('cli.plugins.install_hint')}")
|
||||
print(f" {t('cli.plugins.drop_dir_hint', path=f'{display_hermes_home()}/plugins/')}")
|
||||
if bundled_count:
|
||||
print(f" ({bundled_count} bundled plugins available — see: hermes plugins list)")
|
||||
print(f" {t('cli.plugins.bundled_available', count=bundled_count)}")
|
||||
return
|
||||
try: # loaded-plugin details (tools/hooks/commands counts, errors) by name
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
loaded = {p["name"]: p for p in get_plugin_manager().list_plugins()}
|
||||
except Exception:
|
||||
loaded = {}
|
||||
print(f"User plugins ({len(user_entries)}):")
|
||||
print(t("cli.plugins.user_plugins_header", count=len(user_entries)))
|
||||
for name, version, _desc, source, _dir, key in sorted(user_entries):
|
||||
state = _plugin_status(name, enabled, disabled, key=key)
|
||||
info = loaded.get(name) or {}
|
||||
bits = [f"{info[k]} {k}" for k in ("tools", "hooks", "commands") if info.get(k)]
|
||||
bits = [f"{info[k]} {t(f'cli.plugins.count_noun_{k}')}" for k in ("tools", "hooks", "commands") if info.get(k)]
|
||||
glyph = {"enabled": "✓", "disabled": "✗"}.get(state, "○")
|
||||
ver = f" v{version}" if version else ""
|
||||
detail = f" ({', '.join(bits)})" if bits else ""
|
||||
@@ -288,10 +295,10 @@ class CLILoopsMixin:
|
||||
error = f" — {info['error']}" if info.get("error") else ""
|
||||
print(f" {glyph} {name}{ver}{label}{detail}{error}")
|
||||
if bundled_count:
|
||||
print(f" (+{bundled_count} bundled — see: hermes plugins list)")
|
||||
print(" Enable/disable: hermes plugins enable/disable <name>")
|
||||
print(f" {t('cli.plugins.bundled_more', count=bundled_count)}")
|
||||
print(f" {t('cli.plugins.enable_disable_hint')}")
|
||||
except Exception as e:
|
||||
print(f"Plugin system error: {e}")
|
||||
print(t("cli.plugins.system_error", error=e))
|
||||
|
||||
# ── /queue: enqueue, list, edit, rm, move, clear ─────────────────
|
||||
# A queued next-turn prompt can be inspected and changed before it is sent.
|
||||
@@ -320,16 +327,16 @@ class CLILoopsMixin:
|
||||
from cli import _cprint
|
||||
payload = self._expand_paste_references(text)
|
||||
self._pending_input.put(payload)
|
||||
when = " for the next turn" if self._agent_running else ""
|
||||
_cprint(f" Queued{when}: {_preview(payload)}")
|
||||
key = "cli.queue.queued_next_turn" if self._agent_running else "cli.queue.queued"
|
||||
_cprint(f" {t(key, preview=_preview(payload))}")
|
||||
|
||||
def _queue_list(self, rest: str) -> None:
|
||||
from cli import _VoiceInputMessage, _cprint
|
||||
items = self._pending_input_items()
|
||||
if not items:
|
||||
_cprint(" Queue is empty." + ("" if rest else " " + _QUEUE_USAGE))
|
||||
_cprint(f" {t('cli.queue.empty')}" + ("" if rest else " " + _queue_usage()))
|
||||
return
|
||||
_cprint(f" Queue ({len(items)} pending):")
|
||||
_cprint(f" {t('cli.queue.pending_header', count=len(items))}")
|
||||
for idx, item in enumerate(items, 1):
|
||||
tag = " [voice]" if isinstance(item, _VoiceInputMessage) else ""
|
||||
_cprint(f" {idx}. {_preview(str(item).replace(chr(10), ' '))}{tag}")
|
||||
@@ -337,7 +344,8 @@ class CLILoopsMixin:
|
||||
def _queue_clear(self, rest: str) -> None:
|
||||
from cli import _cprint
|
||||
before, _ = self._mutate_pending_input(lambda items: [])
|
||||
_cprint(f" Cleared {len(before)} queued prompt{'s' if len(before) != 1 else ''}.")
|
||||
key = "cli.queue.cleared_one" if len(before) == 1 else "cli.queue.cleared_other"
|
||||
_cprint(f" {t(key, count=len(before))}")
|
||||
|
||||
def _queue_remove(self, rest: str) -> None:
|
||||
from cli import _cprint
|
||||
@@ -346,15 +354,15 @@ class CLILoopsMixin:
|
||||
before, _ = self._mutate_pending_input(
|
||||
lambda items: (removed.append(items.pop(idx - 1)) or items) if 1 <= idx <= len(items) else items)
|
||||
if removed:
|
||||
_cprint(f" Removed queue item {idx}: {_preview(str(removed[0]))}")
|
||||
_cprint(f" {t('cli.queue.removed', index=idx, preview=_preview(str(removed[0])))}")
|
||||
else:
|
||||
_cprint(f" Queue item {idx} not found. Current size: {len(before)}")
|
||||
_cprint(f" {t('cli.queue.item_not_found', index=idx, size=len(before))}")
|
||||
|
||||
def _queue_edit(self, rest: str) -> None:
|
||||
from cli import _VoiceInputMessage, _cprint
|
||||
idx_text, _, new_prompt = rest.partition(" ")
|
||||
if not new_prompt.strip():
|
||||
_cprint(" Usage: /queue edit <number> <new prompt>")
|
||||
_cprint(f" {t('cli.queue.usage_edit')}")
|
||||
return
|
||||
idx = int(idx_text)
|
||||
new_text = self._expand_paste_references(new_prompt.strip())
|
||||
@@ -369,15 +377,15 @@ class CLILoopsMixin:
|
||||
|
||||
before, after = self._mutate_pending_input(_edit)
|
||||
if before == after:
|
||||
_cprint(f" Queue item {idx} not found. Current size: {len(before)}")
|
||||
_cprint(f" {t('cli.queue.item_not_found', index=idx, size=len(before))}")
|
||||
else:
|
||||
_cprint(f" Updated queue item {idx}: {_preview(new_text)}")
|
||||
_cprint(f" {t('cli.queue.updated', index=idx, preview=_preview(new_text))}")
|
||||
|
||||
def _queue_move(self, rest: str) -> None:
|
||||
from cli import _cprint
|
||||
bits = rest.split()
|
||||
if len(bits) != 2 or not bits[1].isdigit():
|
||||
_cprint(" Usage: /queue move <from> <to>")
|
||||
_cprint(f" {t('cli.queue.usage_move')}")
|
||||
return
|
||||
src, dst = int(bits[0]), int(bits[1])
|
||||
|
||||
@@ -388,9 +396,9 @@ class CLILoopsMixin:
|
||||
|
||||
before, after = self._mutate_pending_input(_move)
|
||||
if before == after and src != dst:
|
||||
_cprint(f" Queue move out of range. Current size: {len(before)}")
|
||||
_cprint(f" {t('cli.queue.move_out_of_range', size=len(before))}")
|
||||
else:
|
||||
_cprint(f" Moved queue item {src} to {dst}.")
|
||||
_cprint(f" {t('cli.queue.moved', source=src, destination=dst)}")
|
||||
|
||||
def _cmd_queue(self, cmd_original: str):
|
||||
"""``/queue <prompt>`` enqueues; a leading management verb whose arguments fit
|
||||
@@ -408,7 +416,7 @@ class CLILoopsMixin:
|
||||
if rest:
|
||||
self._queue_enqueue(rest)
|
||||
else:
|
||||
_cprint(" Usage: /queue add <prompt>")
|
||||
_cprint(f" {t('cli.queue.usage_add')}")
|
||||
return
|
||||
handler, takes_index = _QUEUE_VERBS.get(verb, (None, False))
|
||||
is_management = handler is not None and (
|
||||
@@ -426,20 +434,20 @@ class CLILoopsMixin:
|
||||
from cli import _cprint, _slash_args
|
||||
payload = _slash_args(cmd_original)
|
||||
if not payload:
|
||||
_cprint(" Usage: /steer <prompt>")
|
||||
_cprint(f" {t('cli.steer.usage')}")
|
||||
elif self._agent_running and self.agent is not None and hasattr(self.agent, "steer"):
|
||||
try:
|
||||
accepted = self.agent.steer(payload)
|
||||
except Exception as exc:
|
||||
_cprint(f" Steer failed: {exc}")
|
||||
_cprint(f" {t('cli.steer.failed', error=exc)}")
|
||||
else:
|
||||
if accepted:
|
||||
_cprint(f" ⏩ Steer queued — arrives after the next tool call: {_preview(payload)}")
|
||||
_cprint(f" {t('cli.steer.queued', preview=_preview(payload))}")
|
||||
else:
|
||||
_cprint(" Steer rejected (empty payload).")
|
||||
_cprint(f" {t('cli.steer.rejected_empty')}")
|
||||
else:
|
||||
self._pending_input.put(payload)
|
||||
_cprint(f" No agent running; queued as next turn: {_preview(payload)}")
|
||||
_cprint(f" {t('cli.steer.no_agent_queued', preview=_preview(payload))}")
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# Session-bound managers: /goal (Ralph-style loop), /heartbeat, /loop
|
||||
@@ -554,7 +562,7 @@ class CLILoopsMixin:
|
||||
prompt = mgr.next_continuation_prompt()
|
||||
if prompt:
|
||||
from cli import _DIM, _RST, _cprint
|
||||
_cprint(f" {_DIM}▶ Goal barrier lifted — resuming.{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.goal.barrier_lifted')}{_RST}")
|
||||
self._pending_input.put(prompt)
|
||||
except Exception as exc:
|
||||
logging.debug("parked-goal resume check failed: %s", exc)
|
||||
@@ -593,7 +601,7 @@ class CLILoopsMixin:
|
||||
try:
|
||||
state = mgr.state
|
||||
tick_no = state.ticks_fired if state else "?"
|
||||
_cprint(f" {_DIM}↻ /loop wakeup #{tick_no} firing…{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.loop.wakeup_firing', tick=tick_no)}{_RST}")
|
||||
self._pending_input.put(wakeup)
|
||||
except Exception as exc:
|
||||
logging.debug("loop tick injection failed: %s", exc)
|
||||
@@ -651,14 +659,12 @@ class CLILoopsMixin:
|
||||
mgr.pause(reason="user-interrupted (Ctrl+C)")
|
||||
except Exception:
|
||||
pass
|
||||
_cprint(
|
||||
f" {_DIM}⏸ Loop paused — wakeup turn was interrupted. "
|
||||
f"Use /loop resume to continue, or /loop stop to end it.{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.loop.paused_interrupted')}{_RST}")
|
||||
return
|
||||
decision = mgr.complete_tick(self._last_assistant_response_text())
|
||||
if (not _print_decision_message(decision) and decision.get("status") == "active"
|
||||
and mgr.state is not None):
|
||||
_cprint(f" {_DIM}↻ Loop: {mgr.state.remaining_label()}.{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.loop.remaining', label=mgr.state.remaining_label())}{_RST}")
|
||||
|
||||
def _maybe_continue_goal_after_turn(self) -> None:
|
||||
"""Post-turn hook: judge the goal and maybe re-queue a continuation. A real user
|
||||
@@ -694,9 +700,7 @@ class CLILoopsMixin:
|
||||
mgr.pause(reason="user-interrupted (Ctrl+C)")
|
||||
except Exception as exc:
|
||||
logging.debug("goal pause-on-interrupt failed: %s", exc)
|
||||
_cprint(
|
||||
f" {_DIM}⏸ Goal paused — turn was interrupted. "
|
||||
f"Use /goal resume to continue, or /goal clear to stop.{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.goal.paused_interrupted')}{_RST}")
|
||||
return
|
||||
|
||||
# Empty/whitespace responses are almost always transient failures (API error,
|
||||
|
||||
@@ -14,6 +14,7 @@ import threading
|
||||
import time as _time
|
||||
import webbrowser
|
||||
|
||||
from agent.i18n import t
|
||||
from hermes_cli.callbacks import prompt_for_secret
|
||||
from typing import Optional
|
||||
|
||||
@@ -25,11 +26,19 @@ _CONFIRM_ALIASES = {
|
||||
"2": "always", "always": "always", "remember": "always",
|
||||
"3": "cancel", "cancel": "cancel", "nevermind": "cancel", "no": "cancel", "n": "cancel"}
|
||||
|
||||
_APPROVAL_OUTCOME_LABELS = {
|
||||
"once": "allowed once",
|
||||
"session": "allowed for session",
|
||||
"always": "added to allowlist",
|
||||
"deny": "denied"}
|
||||
# Approval result -> catalog key of the outcome label shown in the persisted prompt summary.
|
||||
# Resolved at call time (``_approval_outcome_label``) so the active language applies.
|
||||
_APPROVAL_OUTCOME_KEYS = {
|
||||
"once": "cli.approval.outcome_once",
|
||||
"session": "cli.approval.outcome_session",
|
||||
"always": "cli.approval.outcome_always",
|
||||
"deny": "cli.approval.outcome_deny"}
|
||||
|
||||
|
||||
def _approval_outcome_label(result) -> str:
|
||||
key = _APPROVAL_OUTCOME_KEYS.get(result)
|
||||
return t(key) if key else str(result)
|
||||
|
||||
|
||||
_CLARIFY_TIMEOUT_REPLY = (
|
||||
"The user did not provide a response within the time limit. "
|
||||
@@ -49,32 +58,35 @@ def _approval_gate_on(key: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _gated_confirm(self, command, key, *, title, detail, choices, unchanged, always_msg, once_verb):
|
||||
def _gated_confirm(self, command, key, *, title, detail, choices, unchanged, always_msg, once_verb=None,
|
||||
persist_failed_msg=None):
|
||||
"""Shared once/always/cancel confirm behind ``approvals.<key>`` (destructive slash, /reload-mcp).
|
||||
|
||||
Returns ``"once"`` without prompting when the gate is off; ``None`` on cancel / no input /
|
||||
unrecognized answer (already reported to the user). Picking "always" persists the opt-out.
|
||||
``persist_failed_msg`` is the full sentence printed when the opt-out could not be saved;
|
||||
``once_verb`` is the legacy fragment spliced into the default sentence.
|
||||
"""
|
||||
from cli import save_config_value
|
||||
if not _approval_gate_on(key):
|
||||
return "once"
|
||||
raw = self._prompt_text_input_modal(title=title, detail=detail, choices=choices)
|
||||
if raw is None:
|
||||
print(f"🟡 /{command} cancelled (no input).")
|
||||
print(t("cli.modal.cancelled_no_input", command=command))
|
||||
return None
|
||||
choice = self._normalize_slash_confirm_choice(raw, choices)
|
||||
if choice is None:
|
||||
print(f"🟡 Unrecognized choice '{raw}'. /{command} cancelled.")
|
||||
print(t("cli.modal.unrecognized_choice", raw=raw, command=command))
|
||||
return None
|
||||
if choice == "cancel":
|
||||
print(f"🟡 /{command} cancelled. {unchanged}")
|
||||
print(t("cli.modal.cancelled", command=command, unchanged=unchanged))
|
||||
return None
|
||||
if choice == "always":
|
||||
if save_config_value(f"approvals.{key}", False):
|
||||
print(always_msg)
|
||||
print(f" Re-enable via `approvals.{key}: true` in config.yaml.")
|
||||
print(t("cli.modal.re_enable_hint", setting=key))
|
||||
else:
|
||||
print(f"⚠️ Couldn't persist opt-out — {once_verb} once.")
|
||||
print(persist_failed_msg or t("cli.modal.persist_opt_out_failed", once_verb=once_verb or ""))
|
||||
return choice
|
||||
|
||||
|
||||
@@ -89,19 +101,19 @@ class CLIModalMixin:
|
||||
from cli import _DIM, _RST, _cprint
|
||||
app = getattr(self, "_app", None)
|
||||
if not app:
|
||||
_cprint(f"{_DIM}External editor is only available inside the interactive CLI.{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.editor.only_interactive')}{_RST}")
|
||||
return False
|
||||
if self._command_running:
|
||||
_cprint(f"{_DIM}Wait for the current command to finish before opening the editor.{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.editor.wait_for_command')}{_RST}")
|
||||
return False
|
||||
if (self._sudo_state or self._secret_state or self._approval_state
|
||||
or getattr(self, "_slash_confirm_state", None) or self._clarify_state
|
||||
or self._connection_state):
|
||||
_cprint(f"{_DIM}Finish the active prompt before opening the editor.{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.editor.finish_active_prompt')}{_RST}")
|
||||
return False
|
||||
target_buffer = buffer or getattr(app, "current_buffer", None)
|
||||
if target_buffer is None:
|
||||
_cprint(f"{_DIM}No active input buffer is available for the external editor.{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.editor.no_input_buffer')}{_RST}")
|
||||
return False
|
||||
try:
|
||||
# Inline pastes so the editor sees real content; set the skip flag unconditionally so
|
||||
@@ -116,7 +128,7 @@ class CLIModalMixin:
|
||||
task.add_done_callback(lambda _t, b=target_buffer: self._submit_editor_buffer(b))
|
||||
return True
|
||||
except Exception as exc:
|
||||
_cprint(f"{_DIM}Failed to open external editor: {exc}{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.editor.open_failed', error=exc)}{_RST}")
|
||||
return False
|
||||
|
||||
def _submit_editor_buffer(self, buffer) -> None:
|
||||
@@ -144,7 +156,7 @@ class CLIModalMixin:
|
||||
_done()
|
||||
return
|
||||
except Exception as exc:
|
||||
_cprint(f" {_DIM}Shell command failed: {exc}{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.editor.shell_command_failed', error=exc)}{_RST}")
|
||||
_done()
|
||||
return
|
||||
|
||||
@@ -155,7 +167,7 @@ class CLIModalMixin:
|
||||
if app is not None and app.is_running:
|
||||
app.exit()
|
||||
except Exception as exc:
|
||||
_cprint(f" {_DIM}Command failed: {exc}{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.editor.command_failed', error=exc)}{_RST}")
|
||||
finally:
|
||||
_done()
|
||||
return
|
||||
@@ -168,7 +180,7 @@ class CLIModalMixin:
|
||||
else:
|
||||
self._pending_input.put(text)
|
||||
preview = text[:80] + ("..." if len(text) > 80 else "")
|
||||
_cprint(f" Queued for the next turn: {preview}")
|
||||
_cprint(f" {t('cli.editor.queued_next_turn', preview=preview)}")
|
||||
else:
|
||||
self._pending_input.put(text)
|
||||
_done()
|
||||
@@ -309,7 +321,7 @@ class CLIModalMixin:
|
||||
if not choices:
|
||||
return None
|
||||
if not getattr(self, "_app", None):
|
||||
return self._prompt_text_input("Choice [1/2/3]: ")
|
||||
return self._prompt_text_input(t("cli.modal.choice_prompt"))
|
||||
|
||||
try:
|
||||
app_loop = self._app.loop
|
||||
@@ -324,7 +336,7 @@ class CLIModalMixin:
|
||||
if sys.platform == "win32" and not in_main_thread:
|
||||
self._invalidate()
|
||||
return None
|
||||
return self._prompt_text_input("Choice [1/2/3]: ")
|
||||
return self._prompt_text_input(t("cli.modal.choice_prompt"))
|
||||
|
||||
if not in_main_thread and app_loop is None:
|
||||
return _stdin_fallback()
|
||||
@@ -417,7 +429,7 @@ class CLIModalMixin:
|
||||
entries.append((cmd, category, desc))
|
||||
try:
|
||||
for cmd, info in sorted(_ensure_skill_commands().items()):
|
||||
entries.append((cmd, "Skill", info.get("description", "")))
|
||||
entries.append((cmd, t("cli.palette.category_skill"), info.get("description", "")))
|
||||
except Exception:
|
||||
pass
|
||||
return entries
|
||||
@@ -537,15 +549,15 @@ class CLIModalMixin:
|
||||
return "once"
|
||||
return _gated_confirm(
|
||||
self, command, "destructive_slash_confirm",
|
||||
title=f"⚠️ /{command} — destroys conversation state",
|
||||
title=t("cli.modal.destructive_title", command=command),
|
||||
detail=detail,
|
||||
choices=[
|
||||
("once", "Approve Once", "proceed this time only"),
|
||||
("always", "Always Approve", "proceed and silence this prompt permanently"),
|
||||
("cancel", "Cancel", "keep current conversation")],
|
||||
unchanged="Conversation unchanged.",
|
||||
always_msg="🔒 Future /clear, /new, /reset, and /undo will run without confirmation.",
|
||||
once_verb="proceeding")
|
||||
("once", t("cli.modal.approve_once"), t("cli.modal.approve_once_detail")),
|
||||
("always", t("cli.modal.always_approve"), t("cli.modal.always_approve_detail")),
|
||||
("cancel", t("cli.modal.cancel"), t("cli.modal.cancel_detail"))],
|
||||
unchanged=t("cli.modal.conversation_unchanged"),
|
||||
always_msg=t("cli.modal.destructive_always_msg"),
|
||||
persist_failed_msg=t("cli.modal.persist_failed_proceeding"))
|
||||
|
||||
def _ring_bell(self, prompt: bool = False, context: str = "", detail: str = "") -> None:
|
||||
"""Terminal bell (\\a) gated by ``display.bell_on_prompt`` (``prompt=True``, blocking modals)
|
||||
@@ -556,7 +568,7 @@ class CLIModalMixin:
|
||||
return
|
||||
from hermes_cli.cli_terminal_mixin import _run_on_app_loop, _write_terminal_sequence
|
||||
from hermes_cli.terminal_notify import notification_sequence, write_tty
|
||||
body = context or ("input needed" if prompt else "turn complete")
|
||||
body = context or (t("cli.modal.bell_input_needed") if prompt else t("cli.modal.bell_turn_complete"))
|
||||
try:
|
||||
seq = "\a" + notification_sequence(
|
||||
body, prompt=prompt, session_id=getattr(self, "session_id", "") or "", detail=detail)
|
||||
@@ -616,16 +628,16 @@ class CLIModalMixin:
|
||||
self._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout
|
||||
self._clarify_freetext = is_open_ended # open-ended → straight to freetext
|
||||
self._clarify_multi_base = None
|
||||
self._ring_bell(prompt=True, context="clarify")
|
||||
self._ring_bell(prompt=True, context=t("cli.clarify.bell_context"))
|
||||
self._paint_now()
|
||||
|
||||
result = self._poll_modal_queue(response_queue, "_clarify_deadline")
|
||||
if result is not _TIMED_OUT:
|
||||
self._clarify_deadline = None
|
||||
self._persist_prompt_summary("?", "Clarify", question, str(result))
|
||||
self._persist_prompt_summary("?", t("cli.clarify.label"), question, str(result))
|
||||
return result
|
||||
self._clarify_teardown()
|
||||
_cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.clarify.timed_out_agent_decides', timeout=timeout)}{_RST}")
|
||||
return _CLARIFY_TIMEOUT_REPLY
|
||||
|
||||
# --- Connection setup --------------------------------------------------
|
||||
@@ -757,7 +769,7 @@ class CLIModalMixin:
|
||||
state["owns_hook"] = installed
|
||||
state["tool_thread_id"] = threading.current_thread().ident
|
||||
if state["phase"] != "waiting":
|
||||
self._ring_bell(prompt=True, context="connection setup")
|
||||
self._ring_bell(prompt=True, context=t("cli.connect.bell_context"))
|
||||
return None
|
||||
|
||||
def _connection_answer(self, *, approve: bool) -> None:
|
||||
@@ -796,7 +808,7 @@ class CLIModalMixin:
|
||||
|
||||
state["phase"] = "waiting"
|
||||
if reissue(operation, [str(target.get("name") or "")]) is not None:
|
||||
target["detail"] = "This connection cannot be started again. Cancel and ask the agent again."
|
||||
target["detail"] = t("cli.connect.cannot_restart")
|
||||
state["phase"] = "failed"
|
||||
self._paint_now()
|
||||
return
|
||||
@@ -914,7 +926,7 @@ class CLIModalMixin:
|
||||
lines = []
|
||||
for field in state.get("fields") or ():
|
||||
marker = "*" if field.get("required") else ""
|
||||
value = "Set" if field.get("type") == "secret" and draft.get(field.get("name")) else draft.get(field.get("name"), "")
|
||||
value = t("cli.connect.secret_set") if field.get("type") == "secret" and draft.get(field.get("name")) else draft.get(field.get("name"), "")
|
||||
lines.append(f"{field.get('prompt') or field.get('name')}{marker}: {value}")
|
||||
return lines
|
||||
|
||||
@@ -925,26 +937,27 @@ class CLIModalMixin:
|
||||
return []
|
||||
target = state["target"]
|
||||
phase = state.get("phase")
|
||||
lines = [f"Set up {target.get('name', '')}"]
|
||||
buttons = f"{t('cli.connect.button_connect')} {t('cli.connect.button_cancel')}"
|
||||
lines = [t("cli.connect.set_up", name=target.get("name", ""))]
|
||||
if target.get("instructions"):
|
||||
lines.append(str(target["instructions"]))
|
||||
if phase == "form":
|
||||
lines.extend(self._connection_field_lines(state, target))
|
||||
lines.append("Connect Cancel")
|
||||
lines.append(buttons)
|
||||
elif phase == "url":
|
||||
lines.extend([str(target.get("connect_url") or ""), str(target.get("detail") or ""), "Press Enter to open in browser"])
|
||||
lines.extend([str(target.get("connect_url") or ""), str(target.get("detail") or ""), t("cli.connect.press_enter_open")])
|
||||
elif phase == "failed":
|
||||
lines.append(str(target.get("detail") or "Connection failed"))
|
||||
lines.append(str(target.get("detail") or t("cli.connect.failed")))
|
||||
lines.extend(self._connection_field_lines(state, target))
|
||||
lines.append("Connect Cancel")
|
||||
lines.append(buttons)
|
||||
elif phase == "authorized":
|
||||
# A connected row cannot be re-run inside this operation; the agent retries discovery
|
||||
# with its next manage_connections call, which needs no new consent.
|
||||
lines.extend(["Authorized. Tools unavailable.", "Continue"])
|
||||
lines.extend([t("cli.connect.authorized_no_tools"), t("cli.connect.button_continue")])
|
||||
elif phase == "connected":
|
||||
lines.append("Connected")
|
||||
lines.append(t("cli.connect.connected"))
|
||||
else:
|
||||
lines.append(str(target.get("detail") or "Waiting…"))
|
||||
lines.append(str(target.get("detail") or t("cli.connect.waiting")))
|
||||
return [line for line in lines if line]
|
||||
|
||||
# --- Batch clarify (multi-question, issue #18450) -----------------------
|
||||
@@ -987,7 +1000,7 @@ class CLIModalMixin:
|
||||
entry = state["questions"][state["active"]]
|
||||
state["answers"][entry["qid"]] = answer
|
||||
state.setdefault("answer_meta", {})[entry["qid"]] = meta or {"kind": "choice"}
|
||||
self._persist_prompt_summary("?", "Clarify", entry["question"], str(answer))
|
||||
self._persist_prompt_summary("?", t("cli.clarify.label"), entry["question"], str(answer))
|
||||
total = len(state["questions"])
|
||||
for offset in range(1, total + 1):
|
||||
candidate = (state["active"] + offset) % total
|
||||
@@ -1055,7 +1068,7 @@ class CLIModalMixin:
|
||||
self._clarify_state = state
|
||||
self._clarify_batch_set_active(state, 0)
|
||||
self._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout
|
||||
self._ring_bell(prompt=True, context="clarify")
|
||||
self._ring_bell(prompt=True, context=t("cli.clarify.bell_context"))
|
||||
self._paint_now()
|
||||
|
||||
result = self._poll_modal_queue(response_queue, "_clarify_deadline")
|
||||
@@ -1064,7 +1077,7 @@ class CLIModalMixin:
|
||||
return {"answers": result} if isinstance(result, dict) else result
|
||||
partial = dict(state["answers"])
|
||||
self._clarify_teardown()
|
||||
_cprint(f"\n{_DIM}(clarify timed out after {timeout}s — locked answers returned){_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.clarify.timed_out_locked_answers', timeout=timeout)}{_RST}")
|
||||
return {"answers": partial, "timed_out": True}
|
||||
|
||||
def _sudo_password_callback(self) -> str:
|
||||
@@ -1076,7 +1089,7 @@ class CLIModalMixin:
|
||||
self._capture_modal_input_snapshot()
|
||||
self._sudo_state = {"response_queue": response_queue}
|
||||
self._sudo_deadline = _time.monotonic() + 45
|
||||
self._ring_bell(prompt=True, context="sudo password")
|
||||
self._ring_bell(prompt=True, context=t("cli.secret.bell_sudo"))
|
||||
self._paint_now()
|
||||
|
||||
result = self._poll_modal_queue(response_queue, "_sudo_deadline", refresh=0)
|
||||
@@ -1085,12 +1098,12 @@ class CLIModalMixin:
|
||||
self._restore_modal_input_snapshot()
|
||||
self._paint_now()
|
||||
if result is _TIMED_OUT:
|
||||
_cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}")
|
||||
_cprint(f"\n{_DIM} {t('cli.secret.sudo_timeout')}{_RST}")
|
||||
return ""
|
||||
if result:
|
||||
_cprint(f"\n{_DIM} ✓ Password received (cached for session){_RST}")
|
||||
_cprint(f"\n{_DIM} {t('cli.secret.sudo_received')}{_RST}")
|
||||
else:
|
||||
_cprint(f"\n{_DIM} ⏭ Skipped{_RST}")
|
||||
_cprint(f"\n{_DIM} {t('cli.secret.sudo_skipped')}{_RST}")
|
||||
return result
|
||||
|
||||
def _approval_callback(self, command: str, description: str,
|
||||
@@ -1119,7 +1132,7 @@ class CLIModalMixin:
|
||||
"selected": 0,
|
||||
"response_queue": response_queue}
|
||||
self._approval_deadline = _time.monotonic() + timeout
|
||||
self._ring_bell(prompt=True, context="approval", detail=command)
|
||||
self._ring_bell(prompt=True, context=t("cli.approval.bell_context"), detail=command)
|
||||
self._paint_now()
|
||||
|
||||
result = self._poll_modal_queue(response_queue, "_approval_deadline")
|
||||
@@ -1127,11 +1140,12 @@ class CLIModalMixin:
|
||||
self._approval_deadline = 0
|
||||
self._paint_now()
|
||||
if result is _TIMED_OUT:
|
||||
_cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
|
||||
self._persist_prompt_summary("⚠", "Approval", command, "timed out (no response)")
|
||||
_cprint(f"\n{_DIM} {t('cli.approval.timeout_denying')}{_RST}")
|
||||
self._persist_prompt_summary(
|
||||
"⚠", t("cli.approval.label"), command, t("cli.approval.timed_out_no_response"))
|
||||
return "timeout"
|
||||
self._persist_prompt_summary(
|
||||
"⚠", "Approval", command, _APPROVAL_OUTCOME_LABELS.get(result, str(result)))
|
||||
"⚠", t("cli.approval.label"), command, _approval_outcome_label(result))
|
||||
return result
|
||||
|
||||
def _approval_choices(self, command: str, *, allow_permanent: bool = True,
|
||||
@@ -1181,7 +1195,7 @@ class CLIModalMixin:
|
||||
self._capture_modal_input_snapshot()
|
||||
self._sudo_state = {"response_queue": response_queue, "vault_backend": display_name}
|
||||
self._sudo_deadline = _time.monotonic() + 120
|
||||
self._ring_bell(prompt=True, context=f"unlock {display_name}")
|
||||
self._ring_bell(prompt=True, context=t("cli.secret.bell_unlock", name=display_name))
|
||||
self._paint_now()
|
||||
|
||||
result = self._poll_modal_queue(response_queue, "_sudo_deadline", refresh=0)
|
||||
@@ -1190,9 +1204,9 @@ class CLIModalMixin:
|
||||
self._restore_modal_input_snapshot()
|
||||
self._paint_now()
|
||||
if result is _TIMED_OUT or not result:
|
||||
_cprint(f"\n{_DIM} ⏭ {display_name} stays locked{_RST}")
|
||||
_cprint(f"\n{_DIM} {t('cli.secret.stays_locked', name=display_name)}{_RST}")
|
||||
return ""
|
||||
_cprint(f"\n{_DIM} ✓ Unlocking {display_name} for this session{_RST}")
|
||||
_cprint(f"\n{_DIM} {t('cli.secret.unlocking_for_session', name=display_name)}{_RST}")
|
||||
return result
|
||||
|
||||
def _vault_save_login_callback(self, origin: str, site: str):
|
||||
@@ -1208,7 +1222,7 @@ class CLIModalMixin:
|
||||
"step": step}}
|
||||
self._sudo_deadline = _time.monotonic() + 180
|
||||
if step == "identifier":
|
||||
self._ring_bell(prompt=True, context=f"save login for {site}")
|
||||
self._ring_bell(prompt=True, context=t("cli.secret.bell_save_login", site=site))
|
||||
self._paint_now()
|
||||
result = self._poll_modal_queue(response_queue, "_sudo_deadline", refresh=0)
|
||||
self._sudo_state = None
|
||||
@@ -1216,10 +1230,10 @@ class CLIModalMixin:
|
||||
self._restore_modal_input_snapshot()
|
||||
self._paint_now()
|
||||
if result is _TIMED_OUT or not result:
|
||||
_cprint(f"\n{_DIM} ⏭ Not saving a login for {site}{_RST}")
|
||||
_cprint(f"\n{_DIM} {t('cli.secret.login_not_saved', site=site)}{_RST}")
|
||||
return None
|
||||
answer[step] = result
|
||||
_cprint(f"\n{_DIM} ✓ Login for {site} saved to your vault{_RST}")
|
||||
_cprint(f"\n{_DIM} {t('cli.secret.login_saved', site=site)}{_RST}")
|
||||
return answer
|
||||
|
||||
def _vault_code_callback(self, site: str, hint: str) -> str:
|
||||
@@ -1231,7 +1245,7 @@ class CLIModalMixin:
|
||||
self._capture_modal_input_snapshot()
|
||||
self._sudo_state = {"response_queue": response_queue, "vault_code": {"site": site, "hint": hint}}
|
||||
self._sudo_deadline = _time.monotonic() + 180
|
||||
self._ring_bell(prompt=True, context=f"verification code for {site}")
|
||||
self._ring_bell(prompt=True, context=t("cli.secret.bell_verification_code", site=site))
|
||||
self._paint_now()
|
||||
result = self._poll_modal_queue(response_queue, "_sudo_deadline", refresh=0)
|
||||
self._sudo_state = None
|
||||
@@ -1239,9 +1253,9 @@ class CLIModalMixin:
|
||||
self._restore_modal_input_snapshot()
|
||||
self._paint_now()
|
||||
if result is _TIMED_OUT or not result:
|
||||
_cprint(f"\n{_DIM} ⏭ No code entered for {site}{_RST}")
|
||||
_cprint(f"\n{_DIM} {t('cli.secret.code_not_entered', site=site)}{_RST}")
|
||||
return ""
|
||||
_cprint(f"\n{_DIM} ✓ Code entered into {site}{_RST}")
|
||||
_cprint(f"\n{_DIM} {t('cli.secret.code_entered', site=site)}{_RST}")
|
||||
return result
|
||||
|
||||
def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict:
|
||||
|
||||
@@ -15,6 +15,7 @@ import sys
|
||||
import threading
|
||||
|
||||
from rich.markup import escape as _escape
|
||||
from agent.i18n import t
|
||||
from utils import base_url_host_matches
|
||||
from hermes_cli.cli_agent_setup_mixin import _retire_agent
|
||||
|
||||
@@ -130,15 +131,16 @@ def _print_switch_summary(cli, result, old_model, *, one_turn: bool, strict_cont
|
||||
f"via {result.provider_label or result.target_provider}. "
|
||||
f"{'This override applies to the next turn only. ' if one_turn else ''}"
|
||||
f"Adjust your self-identification accordingly.]")
|
||||
_cprint(f" ✓ Model switched: {_display_new}")
|
||||
_cprint(f" Provider: {result.provider_label or result.target_provider}")
|
||||
_cprint(f" {t('cli.model.switched', model=_display_new)}")
|
||||
_cprint(f" {t('gateway.model.provider_label', provider=result.provider_label or result.target_provider)}")
|
||||
if result.target_provider == "moa":
|
||||
# The preset name hides who pays: the aggregator runs every tool-loop step (#112359).
|
||||
from hermes_cli.moa_config import normalize_moa_config
|
||||
moa_cfg = cli.config.get("moa") if isinstance(cli.config, dict) else {}
|
||||
agg = normalize_moa_config(moa_cfg)["presets"].get(result.new_model, {}).get("aggregator") or {}
|
||||
if agg:
|
||||
_cprint(f" Acting model (billed for the run): {agg.get('provider')}:{agg.get('model')}")
|
||||
_acting = f"{agg.get('provider')}:{agg.get('model')}"
|
||||
_cprint(f" {t('cli.model.acting_model', model=_acting)}")
|
||||
|
||||
# Provider-aware context chain: Codex OAuth / Copilot / Nous caps win over the raw
|
||||
# models.dev entry (gpt-5.5 is 1.05M on openai but 272K on Codex OAuth).
|
||||
@@ -157,17 +159,17 @@ def _print_switch_summary(cli, result, old_model, *, one_turn: bool, strict_cont
|
||||
ctx = None
|
||||
if ctx:
|
||||
from agent.context_pin import context_pin_suffix
|
||||
_cprint(f" Context: {ctx:,} tokens"
|
||||
_cprint(f" {t('gateway.model.context_label', tokens=f'{ctx:,}')}"
|
||||
f"{context_pin_suffix(ctx, getattr(agent, '_config_context_length', None) if agent else None)}")
|
||||
if mi:
|
||||
if mi.max_output:
|
||||
_cprint(f" Max output: {mi.max_output:,} tokens")
|
||||
_cprint(f" Capabilities: {mi.format_capabilities()}")
|
||||
_cprint(f" {t('gateway.model.max_output_label', tokens=f'{mi.max_output:,}')}")
|
||||
_cprint(f" {t('gateway.model.capabilities_label', capabilities=mi.format_capabilities())}")
|
||||
cache_enabled = (
|
||||
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
|
||||
or result.api_mode == "anthropic_messages")
|
||||
if cache_enabled:
|
||||
_cprint(" Prompt caching: enabled")
|
||||
_cprint(f" {t('gateway.model.prompt_caching_enabled')}")
|
||||
if result.warning_message:
|
||||
_cprint(f" ⚠ {result.warning_message}")
|
||||
|
||||
@@ -196,13 +198,27 @@ def _run_confirm_and_apply(cli, target, *args) -> None:
|
||||
target(*args)
|
||||
|
||||
|
||||
# `/model` usage block: (invocation form, description key). Forms are command syntax (never
|
||||
# translated); descriptions are padded to one column at render time.
|
||||
_MODEL_USAGE_ROWS = (
|
||||
("/model <name>", "cli.model.usage_switch"),
|
||||
("/model <name> --global", "cli.model.usage_switch_global"),
|
||||
("/model <name> --once", "cli.model.usage_switch_once"),
|
||||
("/model <name> --session", "cli.model.usage_switch_session"),
|
||||
("/model <name> --provider <slug>", "cli.model.usage_switch_provider_model"),
|
||||
("/model <name> --reasoning <level>", "cli.model.usage_switch_reasoning"),
|
||||
("/model --provider <slug>", "cli.model.usage_switch_provider"),
|
||||
("/model --refresh", "cli.model.usage_refresh"))
|
||||
_MODEL_USAGE_COL = 36
|
||||
|
||||
|
||||
def _picker_reasoning_rows() -> list[tuple[str, str]]:
|
||||
"""``(value, label)`` rows for the picker's effort step: the canonical ladder, the off state,
|
||||
then a keep-current row (empty value = leave the effort alone)."""
|
||||
from hermes_constants import VALID_REASONING_EFFORTS
|
||||
rows = [(lvl, lvl) for lvl in VALID_REASONING_EFFORTS]
|
||||
rows.append(("none", "none (disable reasoning)"))
|
||||
rows.append(("", "Keep current effort"))
|
||||
rows.append(("none", t("cli.model.effort_none")))
|
||||
rows.append(("", t("cli.model.effort_keep_current")))
|
||||
return rows
|
||||
|
||||
|
||||
@@ -228,7 +244,7 @@ def _apply_reasoning_after_switch(cli, effort: str, *, persist_global: bool) ->
|
||||
saved = persist_global and save_config_value("agent.reasoning_effort", effort)
|
||||
if saved:
|
||||
CLI_CONFIG.setdefault("agent", {})["reasoning_effort"] = effort
|
||||
_cprint(f" Reasoning effort: {effort}" + (" (saved to config)" if saved else ""))
|
||||
_cprint(f" {t('cli.model.reasoning_effort_saved' if saved else 'cli.model.reasoning_effort', effort=effort)}")
|
||||
|
||||
|
||||
def _commit_model_switch(
|
||||
@@ -257,11 +273,11 @@ def _commit_model_switch(
|
||||
if persist_global:
|
||||
from hermes_cli.model_switch import persist_model_selection
|
||||
persist_model_selection(result)
|
||||
_cprint(" Saved to config.yaml (--global)" if picker else " Saved to config.yaml")
|
||||
_cprint(f" {t('cli.model.saved_global' if picker else 'cli.model.saved')}")
|
||||
elif one_turn:
|
||||
_cprint(" (next turn only — restores after one response)")
|
||||
_cprint(f" {t('cli.model.next_turn_only')}")
|
||||
else:
|
||||
_cprint(" (session only — add --global to persist)")
|
||||
_cprint(f" {t('cli.model.session_only')}")
|
||||
# The row records what THIS session runs even on --global (else a later resume restores the
|
||||
# stale creation-time model); --once is restored after one turn and never touches the row.
|
||||
if not one_turn:
|
||||
@@ -284,16 +300,10 @@ def _show_model_picker(cli, ctx, force_refresh: bool) -> None:
|
||||
except Exception:
|
||||
providers = []
|
||||
if not providers:
|
||||
_cprint(" No authenticated providers found.")
|
||||
_cprint(f" {t('cli.model.no_authenticated_providers')}")
|
||||
_cprint("")
|
||||
_cprint(" /model <name> switch model (this session)")
|
||||
_cprint(" /model <name> --global switch model and persist as default")
|
||||
_cprint(" /model <name> --once switch for the next turn only")
|
||||
_cprint(" /model <name> --session switch for this session only")
|
||||
_cprint(" /model <name> --provider <slug> switch provider + model")
|
||||
_cprint(" /model <name> --reasoning <level> switch and set reasoning effort")
|
||||
_cprint(" /model --provider <slug> switch provider")
|
||||
_cprint(" /model --refresh re-fetch live model lists")
|
||||
for form, desc_key in _MODEL_USAGE_ROWS:
|
||||
_cprint(f" {form.ljust(_MODEL_USAGE_COL)} {t(desc_key)}")
|
||||
return
|
||||
cli._open_model_picker(
|
||||
providers, cli.model or "unknown", get_label(cli.provider) if cli.provider else "unknown",
|
||||
@@ -341,8 +351,7 @@ class CLIModelSwitchMixin:
|
||||
if resolved_provider not in _AGGREGATOR_PROVIDERS:
|
||||
_adopt(
|
||||
normalize_model_for_provider(current_model, resolved_provider),
|
||||
lambda new: (
|
||||
f"Normalized model '{current_model}' to '{new}' for {resolved_provider}."))
|
||||
lambda new: t("cli.model.normalized_model", old=current_model, new=new, provider=resolved_provider))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -351,7 +360,7 @@ class CLIModelSwitchMixin:
|
||||
return _adopt_with_mode(
|
||||
lambda m: normalize_copilot_model_id(m, api_key=self.api_key),
|
||||
lambda m: copilot_model_api_mode(m, api_key=self.api_key),
|
||||
lambda new: f"Normalized Copilot model '{current_model}' to '{new}'.")
|
||||
lambda new: t("cli.model.normalized_copilot_model", old=current_model, new=new))
|
||||
|
||||
from hermes_cli.models import opencode_provider_family
|
||||
if opencode_provider_family(resolved_provider) is not None:
|
||||
@@ -359,9 +368,7 @@ class CLIModelSwitchMixin:
|
||||
return _adopt_with_mode(
|
||||
lambda m: normalize_opencode_model_id(resolved_provider, m),
|
||||
lambda m: opencode_model_api_mode(resolved_provider, m),
|
||||
lambda new: (
|
||||
f"Stripped provider prefix from '{current_model}'; "
|
||||
f"using '{new}' for {resolved_provider}."))
|
||||
lambda new: t("cli.model.stripped_provider_prefix", old=current_model, new=new, provider=resolved_provider))
|
||||
|
||||
if resolved_provider != "openai-codex":
|
||||
return changed
|
||||
@@ -371,8 +378,7 @@ class CLIModelSwitchMixin:
|
||||
slug = current_model.split("/", 1)[1]
|
||||
if not self._model_is_default:
|
||||
self._console_print(
|
||||
f"[yellow]⚠️ Stripped provider prefix from '{current_model}'; "
|
||||
f"using '{slug}' for OpenAI Codex.[/]")
|
||||
"[yellow]⚠️ " + _escape(t("cli.model.stripped_provider_prefix", old=current_model, new=slug, provider="OpenAI Codex")) + "[/]")
|
||||
self.model = slug
|
||||
current_model = slug
|
||||
changed = True
|
||||
@@ -511,7 +517,7 @@ class CLIModelSwitchMixin:
|
||||
base_url=self.base_url or "", api_mode=self.api_mode or "")
|
||||
except Exception:
|
||||
logger.debug("In-place agent model swap on resume failed", exc_info=True)
|
||||
msg = f"Model restored from session: {stored_model}"
|
||||
msg = t("cli.model.restored_from_session", model=stored_model)
|
||||
if stored_provider:
|
||||
msg += f" ({stored_provider})"
|
||||
if quiet:
|
||||
@@ -550,8 +556,8 @@ class CLIModelSwitchMixin:
|
||||
if warning is None:
|
||||
return True
|
||||
choices = [
|
||||
("once", "Switch anyway", "Use this model for the current Hermes session."),
|
||||
("cancel", "Cancel", "Keep the current model.")]
|
||||
("once", t("cli.model.choice_switch_anyway"), t("cli.model.desc_switch_anyway")),
|
||||
("cancel", t("cli.model.choice_cancel"), t("cli.model.desc_keep_current_model"))]
|
||||
raw = self._prompt_text_input_modal(
|
||||
title=f"!!! {warning.title} !!!", detail=warning.message, choices=choices, timeout=120)
|
||||
return self._normalize_slash_confirm_choice(raw, choices) == "once"
|
||||
@@ -561,12 +567,12 @@ class CLIModelSwitchMixin:
|
||||
from cli import _cprint
|
||||
try:
|
||||
if result.success and not self._confirm_expensive_model_switch(result):
|
||||
_cprint(" Model switch cancelled.")
|
||||
_cprint(f" {t('cli.model.switch_cancelled')}")
|
||||
return
|
||||
self._apply_model_switch_result(
|
||||
result, persist_global, custom_providers=custom_providers, reasoning_effort=reasoning_effort)
|
||||
except Exception as exc:
|
||||
_cprint(f" ✗ Model selection failed: {exc}")
|
||||
_cprint(f" {t('cli.model.selection_failed', error=exc)}")
|
||||
|
||||
def _close_model_picker(self) -> None:
|
||||
self._model_picker_state = None
|
||||
@@ -691,9 +697,7 @@ class CLIModelSwitchMixin:
|
||||
# rather than a dead session (#50163).
|
||||
for _k, _v in _cli_snapshot.items():
|
||||
setattr(self, _k, _v)
|
||||
_cprint(
|
||||
f" ⚠ Model switch to {result.new_model} failed ({exc}); "
|
||||
f"staying on {old_model}.")
|
||||
_cprint(f" {t('cli.model.switch_failed', model=result.new_model, error=exc, old_model=old_model)}")
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -831,7 +835,7 @@ class CLIModelSwitchMixin:
|
||||
try:
|
||||
from hermes_cli.models import clear_provider_models_cache
|
||||
clear_provider_models_cache()
|
||||
_cprint(" Cleared model picker cache. Refreshing...")
|
||||
_cprint(f" {t('cli.model.cleared_picker_cache')}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -869,7 +873,7 @@ class CLIModelSwitchMixin:
|
||||
thread when the TUI is active (see _run_confirm_and_apply) so the modal can render."""
|
||||
from cli import _cprint
|
||||
if not self._confirm_expensive_model_switch(result):
|
||||
_cprint(" Model switch cancelled.")
|
||||
_cprint(f" {t('cli.model.switch_cancelled')}")
|
||||
return
|
||||
_commit_model_switch(self, result, persist_global=persist_global, one_turn=one_turn,
|
||||
reasoning_effort=reasoning_effort)
|
||||
@@ -895,7 +899,7 @@ class CLIModelSwitchMixin:
|
||||
try:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
except Exception as exc:
|
||||
_cprint(f"❌ could not load config: {exc}")
|
||||
_cprint(t("cli.model.could_not_load_config", error=exc))
|
||||
return
|
||||
result = crs.apply(
|
||||
load_config(), new_value,
|
||||
@@ -904,7 +908,7 @@ class CLIModelSwitchMixin:
|
||||
for line in result.message.splitlines():
|
||||
_cprint(f" {prefix} {line}" if line.startswith("openai_runtime") else f" {line}")
|
||||
if result.success and result.requires_new_session:
|
||||
_cprint(" Tip: `/reset` starts a new session immediately.")
|
||||
_cprint(f" {t('cli.model.tip_reset_new_session')}")
|
||||
|
||||
def _should_handle_model_command_inline(self, text: str, has_images: bool = False) -> bool:
|
||||
"""Return True when /model should be handled immediately on the UI thread."""
|
||||
@@ -943,4 +947,4 @@ class CLIModelSwitchMixin:
|
||||
_retire_agent(self)
|
||||
self._pending_moa_disable_after_turn = True
|
||||
self._pending_agent_seed = payload
|
||||
_cprint(f" MoA one-shot queued with preset {preset}; previous model will be restored after this turn.")
|
||||
_cprint(f" {t('cli.model.moa_one_shot_queued', preset=preset)}")
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import sys
|
||||
|
||||
from agent.i18n import t
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
@@ -33,7 +34,7 @@ def print_truncated(more: int | None, hint: str = "") -> None:
|
||||
one row past its cap (``LIMIT n+1``) and knows just that at least one more exists.
|
||||
``hint`` names how to see the rest (``"use --limit 40 to see more"``).
|
||||
"""
|
||||
count = f"{more} more" if more is not None else "more not shown"
|
||||
count = t("cli.shared.n_more", count=str(more)) if more is not None else t("cli.shared.more_not_shown")
|
||||
suffix = f" ({hint})" if hint else ""
|
||||
print(color(f" … {count}{suffix}", Colors.DIM))
|
||||
|
||||
@@ -76,7 +77,10 @@ def prompt(question: str, default: str | None = None, password: bool = False) ->
|
||||
|
||||
|
||||
def prompt_yes_no(question: str, default: bool = True) -> bool:
|
||||
answer = prompt(f"{question} ({'Y/n' if default else 'y/N'})")
|
||||
hint = t("cli.shared.yes_no_default_yes") if default else t("cli.shared.yes_no_default_no")
|
||||
answer = prompt(f"{question} ({hint})")
|
||||
if not answer:
|
||||
return default
|
||||
return answer.lower().startswith("y")
|
||||
# Accept the English "y" as well as the localized affirmative initial (Y/n hint's first letter).
|
||||
yes_initial = t("cli.shared.yes_initial").strip().lower()
|
||||
return answer.lower().startswith(("y", yes_initial) if yes_initial else "y")
|
||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from agent.i18n import t
|
||||
|
||||
# A finished process stays on the dock long enough to read its exit line, then
|
||||
# leaves; the completion notification in the transcript is the durable record.
|
||||
RETAIN_SECONDS = 60
|
||||
@@ -58,7 +60,7 @@ def process_rows(now: float | None = None) -> list:
|
||||
"kind": "process",
|
||||
"key": proc.id,
|
||||
"id": proc.id,
|
||||
"command": " ".join((proc.command or "").split()) or "background process",
|
||||
"command": " ".join((proc.command or "").split()) or t("cli.dock.process_goal_label"),
|
||||
"status": status,
|
||||
"exit_code": proc.exit_code,
|
||||
"elapsed": max(0, int(end - proc.started_at)),
|
||||
@@ -70,16 +72,20 @@ def process_rows(now: float | None = None) -> list:
|
||||
|
||||
|
||||
_GLYPH = {"running": "⚙", "done": "✔", "failed": "✘", "killed": "✘", "lost": "?"}
|
||||
# Finished-status id → catalog key of the exit verdict (looked up at paint time).
|
||||
_VERDICT_KEYS = {"done": "cli.dock.exit_code", "failed": "cli.dock.exit_code",
|
||||
"killed": "cli.dock.verdict_killed", "lost": "cli.dock.verdict_lost"}
|
||||
|
||||
|
||||
def process_activity(row: dict) -> str:
|
||||
"""The trailing ``· …`` part of a dock line: elapsed + latest output while running, the exit
|
||||
verdict + age once finished."""
|
||||
if row["status"] == "running":
|
||||
return f"{row['elapsed']}s · " + (f"last: {row['detail']}" if row["detail"] else "starting")
|
||||
verdict = {"done": f"exit {row['exit_code']}", "failed": f"exit {row['exit_code']}",
|
||||
"killed": "killed", "lost": "lost"}[row["status"]]
|
||||
return f"{verdict} · {row['since_exit']}s ago"
|
||||
return f"{row['elapsed']}s · " + (
|
||||
t("cli.dock.last_output", detail=row["detail"]) if row["detail"] else t("cli.dock.starting"))
|
||||
verdict = _VERDICT_KEYS[row["status"]]
|
||||
verdict = t(verdict, code=row["exit_code"])
|
||||
return t("cli.dock.verdict_ago", verdict=verdict, seconds=row["since_exit"])
|
||||
|
||||
|
||||
def process_glyph(row: dict) -> str:
|
||||
@@ -91,10 +97,10 @@ def process_tail(process_id: str) -> str:
|
||||
|
||||
proc = process_registry.get(process_id)
|
||||
if proc is None:
|
||||
return "This process is no longer tracked."
|
||||
return t("cli.dock.process_gone")
|
||||
text = (proc.output_buffer or "")[-32768:]
|
||||
text = "".join(c for c in text if c.isprintable() or c in "\n\t")
|
||||
return text or "No output yet."
|
||||
return text or t("cli.dock.no_output")
|
||||
|
||||
|
||||
def kill(process_id: str) -> dict:
|
||||
|
||||
@@ -16,6 +16,7 @@ import textwrap
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager, suppress
|
||||
from agent.i18n import t
|
||||
from agent.think_scrubber import THINK_TAG_NAMES
|
||||
from hermes_cli.banner import format_banner_version_label
|
||||
from rich.console import Console
|
||||
@@ -461,7 +462,7 @@ def _post_stream_transform_output(response: str, result: dict | None) -> str:
|
||||
if original and response.startswith(original):
|
||||
return response[len(original):]
|
||||
|
||||
return f"\n[Response transformed after streaming]\n{response}"
|
||||
return f"\n{t('cli.render.response_transformed')}\n{response}"
|
||||
|
||||
|
||||
def _coerce_output_history_limit(value) -> int:
|
||||
@@ -919,13 +920,13 @@ def _build_compact_banner() -> str:
|
||||
tiny_line = "☤ NOUS HERMES"
|
||||
else:
|
||||
tiny_line = _skin.get_branding("agent_name", "Hermes Agent") if _skin else "Hermes Agent"
|
||||
line1 = f"{tiny_line} - AI Agent Framework"
|
||||
line1 = t("cli.render.banner_tagline", name=tiny_line)
|
||||
|
||||
if os.environ.get("HERMES_FAST_STARTUP_BANNER") == "1":
|
||||
from hermes_cli import __release_date__ as _release_date
|
||||
from hermes_cli.version_info import get_version_info
|
||||
|
||||
version_line = f"Hermes Agent v{get_version_info().derived_version} ({_release_date})"
|
||||
version_line = t("cli.render.banner_version", version=get_version_info().derived_version, date=_release_date)
|
||||
else:
|
||||
version_line = format_banner_version_label()
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from agent.i18n import t
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_state_ids import new_session_id
|
||||
from pathlib import Path
|
||||
@@ -103,7 +104,7 @@ def _reset_model_to_config_default(cli, silent: bool) -> None:
|
||||
if r.api_mode:
|
||||
cli.api_mode = r.api_mode
|
||||
if not silent:
|
||||
_cprint(f" (model reset to config default: {r.new_model})")
|
||||
_cprint(f" {t('cli.session.model_reset_default', model=r.new_model)}")
|
||||
except Exception:
|
||||
logger.debug("/new model reset to config default failed", exc_info=True)
|
||||
|
||||
@@ -115,15 +116,15 @@ def _apply_new_session_title(cli, title: str) -> Optional[str]:
|
||||
try:
|
||||
sanitized = SessionDB.sanitize_title(title)
|
||||
except ValueError as e:
|
||||
_cprint(f" Title rejected: {e}")
|
||||
_cprint(f" {t('cli.session.title_rejected', error=e)}")
|
||||
return None
|
||||
if not sanitized:
|
||||
_cprint(" Title is empty after cleanup — session started untitled.")
|
||||
_cprint(f" {t('cli.session.title_empty_untitled')}")
|
||||
return None
|
||||
try:
|
||||
cli._session_db.set_session_title(cli.session_id, sanitized)
|
||||
except ValueError as e:
|
||||
_cprint(f" {e} — session started untitled.")
|
||||
_cprint(f" {t('cli.session.title_error_untitled', error=e)}")
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
@@ -155,14 +156,14 @@ class CLISessionMixin:
|
||||
if current and os.path.realpath(recorded) == os.path.realpath(current):
|
||||
return
|
||||
if not os.path.isdir(recorded):
|
||||
msg = f"⚠ Session's working directory is gone: {recorded} — staying in {current or '.'}"
|
||||
msg = t("cli.session.cwd_gone", recorded=recorded, current=current or ".")
|
||||
else:
|
||||
try:
|
||||
os.chdir(recorded)
|
||||
os.environ["TERMINAL_CWD"] = recorded
|
||||
msg = f"↻ Working directory: {recorded}"
|
||||
msg = t("cli.session.cwd_restored", path=recorded)
|
||||
except OSError as e:
|
||||
msg = f"⚠ Could not enter session's working directory {recorded}: {e}"
|
||||
msg = t("cli.session.cwd_enter_failed", path=recorded, error=e)
|
||||
_dim_notice(self, msg, quiet)
|
||||
|
||||
def _restore_session_yolo(self, session_meta: dict, *, quiet: bool = False) -> None:
|
||||
@@ -181,9 +182,7 @@ class CLISessionMixin:
|
||||
if is_session_yolo_enabled(session_key):
|
||||
return
|
||||
enable_session_yolo(session_key)
|
||||
_dim_notice(self,
|
||||
"⚡ YOLO mode restored from session — all commands auto-approved. /yolo to turn off.",
|
||||
quiet)
|
||||
_dim_notice(self, t("cli.session.yolo_restored"), quiet)
|
||||
|
||||
def _render_resume_history_panel_lines(self, panel) -> list[str]:
|
||||
"""Render the resume panel at the current terminal width for resize replay."""
|
||||
@@ -206,7 +205,7 @@ class CLISessionMixin:
|
||||
return ref
|
||||
if 0 <= idx < len(checkpoints):
|
||||
return checkpoints[idx]["hash"]
|
||||
print(f" Invalid checkpoint number. Use 1-{len(checkpoints)}.")
|
||||
print(f" {t('cli.session.invalid_checkpoint', count=len(checkpoints))}")
|
||||
return None
|
||||
|
||||
def _show_status(self):
|
||||
@@ -214,11 +213,11 @@ class CLISessionMixin:
|
||||
from cli import get_tool_definitions
|
||||
# Avoid pulling the full tool registry into the bare Termux prompt path.
|
||||
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") == "1":
|
||||
tool_status = "tools deferred"
|
||||
tool_status = t("cli.session.status_tools_deferred")
|
||||
else:
|
||||
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets,
|
||||
disabled_toolsets=self.disabled_toolsets, quiet_mode=True)
|
||||
tool_status = f"{len(tools) if tools else 0} tools"
|
||||
tool_status = t("cli.session.status_tools_count", count=len(tools) if tools else 0)
|
||||
|
||||
model_short = self.model.split("/")[-1] if "/" in self.model else self.model
|
||||
if len(model_short) > 30:
|
||||
@@ -235,10 +234,10 @@ class CLISessionMixin:
|
||||
sep = f" [dim {separator_color}]·[/] "
|
||||
toolsets_info = ""
|
||||
if self.enabled_toolsets and "all" not in self.enabled_toolsets:
|
||||
toolsets_info = f"{sep}[{label_color}]toolsets: {', '.join(self.enabled_toolsets)}[/]"
|
||||
provider_info = f"{sep}[dim]provider: {self.provider}[/]"
|
||||
toolsets_info = f"{sep}[{label_color}]{_escape(t('cli.session.status_toolsets', toolsets=', '.join(self.enabled_toolsets)))}[/]"
|
||||
provider_info = f"{sep}[dim]{_escape(t('cli.session.status_provider', provider=self.provider))}[/]"
|
||||
if self._provider_source:
|
||||
provider_info += f"{sep}[dim]auth: {self._provider_source}[/]"
|
||||
provider_info += f"{sep}[dim]{_escape(t('cli.session.status_auth', source=self._provider_source))}[/]"
|
||||
self._console_print(
|
||||
f" {api_indicator} [{accent_color}]{model_short}[/]{sep}"
|
||||
f"[bold {label_color}]{tool_status}[/]{toolsets_info}{provider_info}")
|
||||
@@ -262,12 +261,13 @@ class CLISessionMixin:
|
||||
rc = getattr(agent, "reasoning_config", None) or getattr(self, "reasoning_config", None)
|
||||
if isinstance(rc, dict):
|
||||
if rc.get("enabled") is False:
|
||||
reasoning_label = "off"
|
||||
reasoning_label = t("cli.shared.label_off")
|
||||
elif rc.get("effort"):
|
||||
reasoning_label = str(rc.get("effort"))
|
||||
show_r = getattr(self, "show_reasoning", None)
|
||||
if reasoning_label and show_r is not None:
|
||||
reasoning_label += f" (display: {'on' if show_r else 'off'})"
|
||||
state = t("cli.shared.label_on") if show_r else t("cli.shared.label_off")
|
||||
reasoning_label += t("cli.session.reasoning_display_suffix", state=state)
|
||||
|
||||
approval_label = None
|
||||
try:
|
||||
@@ -275,7 +275,7 @@ class CLISessionMixin:
|
||||
from tools.approval_context import _get_approval_mode
|
||||
approval_label = _get_approval_mode()
|
||||
if is_approval_bypass_active_for_session(getattr(self, "session_key", "") or ""):
|
||||
approval_label += " (YOLO bypass active)"
|
||||
approval_label += t("cli.session.yolo_bypass_suffix")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -288,14 +288,14 @@ class CLISessionMixin:
|
||||
if ctx_max:
|
||||
left = ""
|
||||
if isinstance(ctx_pct, (int, float)):
|
||||
left = f"{max(0, 100 - int(ctx_pct))}% left · "
|
||||
ctx_label = f"{left}{snap.get('context_tokens') or 0:,} / {ctx_max:,} tokens used"
|
||||
left = t("cli.session.ctx_left_prefix", percent=max(0, 100 - int(ctx_pct)))
|
||||
ctx_label = t("cli.session.ctx_tokens_used", left=left, used=f"{snap.get('context_tokens') or 0:,}",
|
||||
max=f"{ctx_max:,}")
|
||||
except Exception:
|
||||
ctx_label = None
|
||||
|
||||
lines = ["Hermes CLI Status", "", *status_lines(fields, "session_id", "path", "title", "model")]
|
||||
lines = [t("cli.session.status_title"), "", *status_lines(fields, "session_id", "path", "title", "model")]
|
||||
try:
|
||||
from agent.i18n import t
|
||||
from hermes_cli.auth import resolve_provider
|
||||
from hermes_cli.anon_auth import guest_carries_inference
|
||||
|
||||
@@ -303,10 +303,12 @@ class CLISessionMixin:
|
||||
lines.append(t("gateway.status.free_tier"))
|
||||
except Exception:
|
||||
pass
|
||||
optional = (("Reasoning", reasoning_label), ("Approvals", approval_label), ("Context", ctx_label))
|
||||
optional = ((t("cli.session.label_reasoning"), reasoning_label),
|
||||
(t("cli.session.label_approvals"), approval_label),
|
||||
(t("cli.session.label_context"), ctx_label))
|
||||
for label, value in optional:
|
||||
if value:
|
||||
lines.append(f"{label}: {value}")
|
||||
lines.append(t("cli.shared.label_value", label=label, value=value))
|
||||
lines.extend(status_lines(fields, "created", "last_activity", "tokens", "agent_running"))
|
||||
self._console_print("\n".join(lines), highlight=False, markup=False)
|
||||
|
||||
@@ -339,11 +341,13 @@ class CLISessionMixin:
|
||||
|
||||
_cli_visible_print()
|
||||
if reason == "history":
|
||||
_cli_visible_print("(._.) No messages in the current chat yet — here are recent sessions you can resume:")
|
||||
_cli_visible_print(t("cli.session.recent_intro"))
|
||||
else:
|
||||
_cli_visible_print(" Recent sessions:")
|
||||
_cli_visible_print(f" {t('cli.session.recent_header')}")
|
||||
_cli_visible_print()
|
||||
_cli_visible_print(f" {'#':<3} {'Title':<32} {'Preview':<40} {'Last Active':<13} {'ID'}")
|
||||
_cli_visible_print(
|
||||
f" {'#':<3} {t('cli.session.column_title'):<32} {t('cli.session.column_preview'):<40} "
|
||||
f"{t('cli.session.column_last_active'):<13} {t('cli.session.column_id')}")
|
||||
_cli_visible_print(f" {'─' * 3} {'─' * 32} {'─' * 40} {'─' * 13} {'─' * 24}")
|
||||
for idx, session in enumerate(sessions, start=1):
|
||||
title = session.get("title") or "—"
|
||||
@@ -351,8 +355,8 @@ class CLISessionMixin:
|
||||
last_active = _relative_time(session.get("last_active"), session_id=session.get("id"))
|
||||
_cli_visible_print(f" {idx:<3} {title:<32} {preview:<40} {last_active:<13} {session['id']}")
|
||||
_cli_visible_print()
|
||||
_cli_visible_print(" Use /resume <number>, /resume <session id>, or /resume <session title> to continue.")
|
||||
_cli_visible_print(" Example: /resume 2")
|
||||
_cli_visible_print(f" {t('cli.session.resume_usage')}")
|
||||
_cli_visible_print(f" {t('cli.session.resume_example')}")
|
||||
_cli_visible_print()
|
||||
return True
|
||||
|
||||
@@ -361,7 +365,7 @@ class CLISessionMixin:
|
||||
from cli import _cli_visible_print
|
||||
if not self.conversation_history:
|
||||
if not self._show_recent_sessions(reason="history"):
|
||||
_cli_visible_print("(._.) No conversation history yet.")
|
||||
_cli_visible_print(t("cli.session.history_empty"))
|
||||
return
|
||||
|
||||
preview_limit = 400
|
||||
@@ -385,13 +389,13 @@ class CLISessionMixin:
|
||||
nonlocal hidden_tool_messages
|
||||
if not hidden_tool_messages:
|
||||
return
|
||||
noun = "message" if hidden_tool_messages == 1 else "messages"
|
||||
_cli_visible_print("\n [Tools]")
|
||||
_cli_visible_print(f" ({hidden_tool_messages} tool {noun} hidden)")
|
||||
key = "cli.session.history_tools_hidden_one" if hidden_tool_messages == 1 else "cli.session.history_tools_hidden_other"
|
||||
_cli_visible_print(f"\n {t('cli.session.history_tools_header')}")
|
||||
_cli_visible_print(f" {t(key, count=hidden_tool_messages)}")
|
||||
hidden_tool_messages = 0
|
||||
|
||||
rule = "+" + "-" * 50 + "+"
|
||||
for line in ("", rule, "|" + " " * 12 + "(^_^) Conversation History" + " " * 11 + "|", rule):
|
||||
for line in ("", rule, "|" + t("cli.session.history_title").center(50) + "|", rule):
|
||||
_cli_visible_print(line)
|
||||
|
||||
for msg in self.conversation_history:
|
||||
@@ -409,17 +413,19 @@ class CLISessionMixin:
|
||||
preview = content_text[:preview_limit]
|
||||
suffix = "..." if len(content_text) > preview_limit else ""
|
||||
if role == "user":
|
||||
_cli_visible_print(f"\n [You #{visible_index}]{_ts_suffix(msg)}")
|
||||
_cli_visible_print(f"\n {t('cli.session.history_you_header', index=visible_index)}{_ts_suffix(msg)}")
|
||||
_cli_visible_print(f" {preview}{suffix}")
|
||||
continue
|
||||
|
||||
_cli_visible_print(f"\n [Hermes #{visible_index}]{_ts_suffix(msg)}")
|
||||
_cli_visible_print(
|
||||
f"\n {t('cli.session.history_assistant_header', agent_name='Hermes', index=visible_index)}{_ts_suffix(msg)}")
|
||||
n_calls = len(msg.get("tool_calls") or [])
|
||||
if not content_text:
|
||||
suffix = ""
|
||||
preview = "(no text response)"
|
||||
preview = t("cli.session.history_no_text")
|
||||
if n_calls:
|
||||
preview = f"(requested {n_calls} tool {'call' if n_calls == 1 else 'calls'})"
|
||||
key = "cli.session.history_tool_calls_one" if n_calls == 1 else "cli.session.history_tool_calls_other"
|
||||
preview = t(key, count=n_calls)
|
||||
_cli_visible_print(f" {preview}{suffix}")
|
||||
|
||||
flush_tool_summary()
|
||||
@@ -585,9 +591,9 @@ class CLISessionMixin:
|
||||
|
||||
if not silent:
|
||||
if title:
|
||||
print(f"(^_^)v New session started: {title}")
|
||||
print(t("cli.session.new_session_titled", title=title))
|
||||
else:
|
||||
print("(^_^)v New session started!")
|
||||
print(t("cli.session.new_session"))
|
||||
|
||||
def _consume_pending_resume_selection(self, text: str) -> bool:
|
||||
"""Resolve a bare numeric reply following a bare ``/resume`` prompt.
|
||||
@@ -611,8 +617,8 @@ class CLISessionMixin:
|
||||
return False
|
||||
index = int(text.strip())
|
||||
if not 1 <= index <= len(pending):
|
||||
_cprint(f" Resume index {index} is out of range.")
|
||||
_cprint(" Use /resume with no arguments to see available sessions.")
|
||||
_cprint(f" {t('cli.session.resume_index_out_of_range', index=index)}")
|
||||
_cprint(f" {t('cli.session.resume_no_args_hint')}")
|
||||
return True
|
||||
self._handle_resume_command(f"/resume {index}")
|
||||
return True
|
||||
@@ -655,7 +661,7 @@ class CLISessionMixin:
|
||||
session_data = None
|
||||
if not session_data:
|
||||
if not self.conversation_history:
|
||||
print("(;_;) No conversation to save.")
|
||||
print(t("cli.session.save_nothing"))
|
||||
return
|
||||
session_data = {
|
||||
"id": self.session_id, "model": self.model,
|
||||
@@ -669,7 +675,7 @@ class CLISessionMixin:
|
||||
try:
|
||||
saved_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
print(f"(x_x) Failed to create save directory {saved_dir}: {e}")
|
||||
print(t("cli.session.save_dir_failed", path=saved_dir, error=e))
|
||||
return
|
||||
if filename:
|
||||
path = Path(filename).expanduser()
|
||||
@@ -683,17 +689,17 @@ class CLISessionMixin:
|
||||
content = render_session_for_save(session_data, fmt)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
label = {"json": "JSON", "md": "Markdown", "html": "HTML"}[fmt]
|
||||
print(f"(^_^)v Conversation saved to: {path} ({label})")
|
||||
label = {"json": "JSON", "md": t("cli.session.format_markdown"), "html": "HTML"}[fmt]
|
||||
print(t("cli.session.save_ok", path=path, label=label))
|
||||
# #76354 review F5: the worker thread also rebound the session ContextVar inside its own
|
||||
# (copied) context, which the caller never sees — and get_session_env() prefers an already-bound
|
||||
# ContextVar over os.environ. Rebind in the CALLER's context so post-compression
|
||||
# tools/subprocesses on this thread resolve HERMES_SESSION_ID to the child id after an
|
||||
# out-of-place rotation (idempotent when no rotation happened).
|
||||
if self.session_id:
|
||||
print(f" Resume the live session with: hermes --resume {self.session_id}")
|
||||
print(f" {t('cli.session.save_resume_hint', session_id=self.session_id)}")
|
||||
except Exception as e:
|
||||
print(f"(x_x) Failed to save: {e}")
|
||||
print(t("cli.session.save_failed", error=e))
|
||||
|
||||
def _publish_truncated_history(self, truncated: list, *, invalidate_prompt: bool) -> None:
|
||||
"""Install a rewound history and mirror it onto the agent (flush index reset so the
|
||||
@@ -717,7 +723,7 @@ class CLISessionMixin:
|
||||
"""Retry the last user message: drop the last exchange and return the text to re-send
|
||||
(None when there is nothing to retry)."""
|
||||
if not self.conversation_history:
|
||||
print("(._.) No messages to retry.")
|
||||
print(t("cli.session.retry_no_messages"))
|
||||
return None
|
||||
|
||||
from agent.context_compressor import (
|
||||
@@ -727,7 +733,7 @@ class CLISessionMixin:
|
||||
warm_history = list(self.conversation_history)
|
||||
user_indices = _user_turn_indices(warm_history)
|
||||
if not user_indices:
|
||||
print("(._.) No user message found to retry.")
|
||||
print(t("cli.session.retry_no_user_message"))
|
||||
return None
|
||||
|
||||
# Resolve a lossless live payload before touching persistence or memory. A
|
||||
@@ -742,7 +748,7 @@ class CLISessionMixin:
|
||||
live_content = sanitize_context(live_content).strip()
|
||||
last_message = retryable_user_text(live_content)
|
||||
except ValueError as exc:
|
||||
print(f"(._.) Cannot retry that message safely: {exc}")
|
||||
print(t("cli.session.retry_unsafe", error=exc))
|
||||
return None
|
||||
|
||||
# Persist the rewind before publishing the shorter in-memory view: the DB owns the
|
||||
@@ -752,11 +758,11 @@ class CLISessionMixin:
|
||||
truncated = self._session_db.rewind_user_turn(
|
||||
self.session_id, -1, warm_history=warm_history, require_retryable=True).prefix
|
||||
except Exception as exc:
|
||||
print(f"(x_x) Retry rewind failed; history was not changed: {exc}")
|
||||
print(t("cli.session.retry_rewind_failed", error=exc))
|
||||
return None
|
||||
|
||||
self._publish_truncated_history(truncated, invalidate_prompt=False)
|
||||
print(f"(^_^)b Retrying: \"{last_message[:60]}{'...' if len(last_message) > 60 else ''}\"")
|
||||
print(t("cli.session.retrying", preview=f"{last_message[:60]}{'...' if len(last_message) > 60 else ''}"))
|
||||
return last_message
|
||||
|
||||
def undo_last(self, n: int = 1, prefill: bool = True):
|
||||
@@ -771,7 +777,7 @@ class CLISessionMixin:
|
||||
"""
|
||||
from cli import logger
|
||||
if not self.conversation_history:
|
||||
print("(._.) No messages to undo.")
|
||||
print(t("cli.session.undo_no_messages"))
|
||||
return
|
||||
n = max(n, 1)
|
||||
|
||||
@@ -780,7 +786,7 @@ class CLISessionMixin:
|
||||
warm_history = list(self.conversation_history)
|
||||
user_indices = _user_turn_indices(warm_history)
|
||||
if not user_indices:
|
||||
print("(._.) No user message found to undo.")
|
||||
print(t("cli.session.undo_no_user_message"))
|
||||
return
|
||||
|
||||
turns_undone = min(n, len(user_indices))
|
||||
@@ -801,7 +807,7 @@ class CLISessionMixin:
|
||||
rewound_rows = outcome.rewound_count
|
||||
except Exception as e:
|
||||
logger.debug("undo: durable rewind failed: %s", e)
|
||||
print(f"(x_x) Undo failed; history was not changed: {e}")
|
||||
print(t("cli.session.undo_failed", error=e))
|
||||
return
|
||||
|
||||
# Publish only after the durable rewind succeeds (or no store exists).
|
||||
@@ -813,11 +819,10 @@ class CLISessionMixin:
|
||||
with contextlib.suppress(Exception):
|
||||
_mm.on_session_switch(self.session_id, parent_session_id="", reset=False, rewound=True)
|
||||
|
||||
turn_word = "turn" if turns_undone == 1 else "turns"
|
||||
print(
|
||||
f"(^_^)b Undid {turns_undone} {turn_word} ({rewound_rows or removed_count} message(s)). "
|
||||
f"Backed up to: \"{removed_text[:60]}{'...' if len(removed_text) > 60 else ''}\"")
|
||||
print(f" {len(self.conversation_history)} message(s) remaining in history.")
|
||||
key = "cli.session.undo_ok_one" if turns_undone == 1 else "cli.session.undo_ok_other"
|
||||
print(t(key, count=turns_undone, messages=rewound_rows or removed_count,
|
||||
backup=f"{removed_text[:60]}{'...' if len(removed_text) > 60 else ''}"))
|
||||
print(f" {t('cli.session.undo_remaining', count=len(self.conversation_history))}")
|
||||
# Editable, not auto-sent (Claude-Code-style).
|
||||
if prefill and removed_text:
|
||||
self._prefill_input_buffer(removed_text)
|
||||
@@ -894,11 +899,8 @@ class CLISessionMixin:
|
||||
# A frozen process-level bypass short-circuits the approval gate ahead of the session
|
||||
# check — toggling "OFF" would be a false safety claim. Say so instead.
|
||||
if _YOLO_MODE_FROZEN:
|
||||
_cprint(
|
||||
f" ⚡ YOLO is {_Colors.BOLD}{_Colors.RED}locked ON{_Colors.RESET}"
|
||||
" for this process (started with --yolo / HERMES_YOLO_MODE)."
|
||||
" /yolo cannot disable it — restart without the flag to"
|
||||
" re-enable approvals.")
|
||||
state = f"{_Colors.BOLD}{_Colors.RED}{t('cli.session.yolo_state_locked_on')}{_Colors.RESET}"
|
||||
_cprint(f" {t('cli.session.yolo_locked', state=state)}")
|
||||
return
|
||||
|
||||
session_key = self.session_id or "default"
|
||||
@@ -908,16 +910,14 @@ class CLISessionMixin:
|
||||
disable_session_yolo(session_key)
|
||||
if _persist:
|
||||
_persist(session_key, False)
|
||||
_cprint(
|
||||
f" ⚠ YOLO mode {_Colors.BOLD}{_Colors.RED}OFF{_Colors.RESET}"
|
||||
" — dangerous commands will require approval.")
|
||||
state = f"{_Colors.BOLD}{_Colors.RED}{t('cli.shared.label_off_upper')}{_Colors.RESET}"
|
||||
_cprint(f" {t('cli.session.yolo_off', state=state)}")
|
||||
else:
|
||||
enable_session_yolo(session_key)
|
||||
if _persist:
|
||||
_persist(session_key, True)
|
||||
_cprint(
|
||||
f" ⚡ YOLO mode {_Colors.BOLD}{_Colors.GREEN}ON{_Colors.RESET}"
|
||||
" — all commands auto-approved. Use with caution.")
|
||||
state = f"{_Colors.BOLD}{_Colors.GREEN}{t('cli.shared.label_on_upper')}{_Colors.RESET}"
|
||||
_cprint(f" {t('cli.session.yolo_on', state=state)}")
|
||||
|
||||
def _persist_session_yolo(self, session_key: str, enabled: bool) -> None:
|
||||
"""Persist the YOLO flag to the session row so --resume restores it. Best-effort; the
|
||||
@@ -945,10 +945,10 @@ class CLISessionMixin:
|
||||
AGGRESSIVE_UNSUPPORTED, MIN_MESSAGES, compress_now, parse_compress_args, render_compress_result)
|
||||
|
||||
if len(self.conversation_history or ()) < MIN_MESSAGES:
|
||||
print(f"(._.) Not enough conversation to compress (need at least {MIN_MESSAGES} messages).")
|
||||
print(t("cli.session.compress_too_short", min=MIN_MESSAGES))
|
||||
return
|
||||
if not self.agent:
|
||||
print("(._.) No active agent -- send a message first.")
|
||||
print(t("cli.session.compress_no_agent"))
|
||||
return
|
||||
_parts = (cmd_original or "").strip().split(None, 1)
|
||||
request = parse_compress_args(_parts[1] if len(_parts) > 1 else "")
|
||||
@@ -962,15 +962,14 @@ class CLISessionMixin:
|
||||
return
|
||||
|
||||
original_count = len(self.conversation_history)
|
||||
with self._busy_command("Compressing context...", blocks_input=False):
|
||||
with self._busy_command(t("cli.session.compress_busy"), blocks_input=False):
|
||||
try:
|
||||
if request.partial:
|
||||
print(f"🗜️ Summarizing up to here: {original_count} messages, "
|
||||
f"keeping last {request.keep_last} exchange(s) verbatim...")
|
||||
print(t("cli.session.compress_summarizing", count=original_count, keep_last=request.keep_last))
|
||||
elif request.focus_topic:
|
||||
print(f"🗜️ Compressing {original_count} messages, focus: \"{request.focus_topic}\"...")
|
||||
print(t("cli.session.compress_focus", count=original_count, focus=request.focus_topic))
|
||||
else:
|
||||
print(f"🗜️ Compressing {original_count} messages...")
|
||||
print(t("cli.session.compress_plain", count=original_count))
|
||||
result = compress_now(self.agent, self.conversation_history, request,
|
||||
task_id=self.session_id or "default")
|
||||
if result.status != "compressed":
|
||||
@@ -1000,7 +999,7 @@ class CLISessionMixin:
|
||||
print(f" {summary['note']}")
|
||||
except Exception as e:
|
||||
finalize_context_engine_compression_notification(self.agent, committed=False)
|
||||
print(f" ❌ Compression failed: {e}")
|
||||
print(f" {t('cli.session.compress_failed', error=e)}")
|
||||
|
||||
def _persist_prompt_summary(self, icon: str, label: str, detail: str, outcome: str) -> None:
|
||||
"""Print a one-line scrollback summary of a resolved modal prompt (approval/clarify
|
||||
@@ -1009,7 +1008,7 @@ class CLISessionMixin:
|
||||
if not CLI_CONFIG.get("display", {}).get("persist_prompts", True):
|
||||
return
|
||||
detail, outcome = (_squash(s) for s in (detail, outcome))
|
||||
_cprint(f"\n{_DIM}{icon} {label}: {detail} → {outcome}{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.session.persist_prompt_summary', icon=icon, label=label, detail=detail, outcome=outcome)}{_RST}")
|
||||
|
||||
def _clear_terminal_on_exit(self):
|
||||
"""Clear screen + scrollback (``ESC[3J ESC[2J ESC[H``) so nothing is stranded above
|
||||
@@ -1149,9 +1148,9 @@ class CLISessionMixin:
|
||||
if not msg_count:
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_goodbye
|
||||
goodbye = get_active_goodbye("Goodbye! ☤")
|
||||
goodbye = get_active_goodbye(t("cli.session.goodbye"))
|
||||
except Exception:
|
||||
goodbye = "Goodbye! ☤"
|
||||
goodbye = t("cli.session.goodbye")
|
||||
print(goodbye)
|
||||
return
|
||||
|
||||
@@ -1161,18 +1160,18 @@ class CLISessionMixin:
|
||||
elapsed = datetime.now() - self.session_start
|
||||
hours, remainder = divmod(int(elapsed.total_seconds()), 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
duration_str = f"{seconds}s"
|
||||
duration_str = t("cli.shared.duration_s", seconds=seconds)
|
||||
if hours > 0:
|
||||
duration_str = f"{hours}h {minutes}m {duration_str}"
|
||||
duration_str = t("cli.shared.duration_hm_rest", hours=hours, minutes=minutes, rest=duration_str)
|
||||
elif minutes > 0:
|
||||
duration_str = f"{minutes}m {duration_str}"
|
||||
duration_str = t("cli.shared.duration_m_rest", minutes=minutes, rest=duration_str)
|
||||
|
||||
session_title = None
|
||||
if self._session_db:
|
||||
with contextlib.suppress(Exception):
|
||||
session_title = self._session_db.get_session_title(self.session_id)
|
||||
|
||||
print("Resume this session with:")
|
||||
print(t("cli.session.exit_resume_hint"))
|
||||
# Session IDs are profile-constrained: non-default profiles need `-p <profile>` in
|
||||
# the hint ("default"/"custom" use the standard HERMES_HOME).
|
||||
try:
|
||||
@@ -1185,8 +1184,8 @@ class CLISessionMixin:
|
||||
if session_title:
|
||||
print(f" hermes -c \"{session_title}\"{profile_flag}")
|
||||
print()
|
||||
print(f"Session: {self.session_id}")
|
||||
print(t("cli.session.exit_label_session", session_id=self.session_id))
|
||||
if session_title:
|
||||
print(f"Title: {session_title}")
|
||||
print(f"Duration: {duration_str}")
|
||||
print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)")
|
||||
print(t("cli.session.exit_label_title", title=session_title))
|
||||
print(t("cli.session.exit_label_duration", duration=duration_str))
|
||||
print(t("cli.session.exit_label_messages", count=msg_count, user=user_msgs, tool_calls=tool_calls))
|
||||
@@ -12,6 +12,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from agent.i18n import t
|
||||
from agent.interrupt_compat import request_hard_interrupt
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
@@ -274,7 +275,7 @@ def _run_quiet_single_query(cli, effective_query, emitter=None):
|
||||
not response and isinstance(result, dict) and result.get("error")
|
||||
and (result.get("failed") or result.get("partial"))
|
||||
):
|
||||
print(f"Error: {result['error']}", file=sys.stderr)
|
||||
print(t("gateway.model.error_prefix", error=result["error"]), file=sys.stderr)
|
||||
elif response:
|
||||
print(response)
|
||||
|
||||
@@ -467,7 +468,7 @@ def _run_single_query_mode(cli, query, image, quiet, oneshot, stream_json: bool
|
||||
from tools.kanban_tools import register_current_worker_from_env
|
||||
if not register_current_worker_from_env():
|
||||
# No exit trailer: the task log now belongs to the run that replaced this one.
|
||||
print("kanban: this worker's run was reclaimed before it started; exiting", file=sys.stderr)
|
||||
print(t("cli.single_query.kanban_run_reclaimed"), file=sys.stderr)
|
||||
sys.exit(0)
|
||||
if not cli._claim_active_session("cli", stderr=bool(quiet)):
|
||||
exit_single_query(1)
|
||||
@@ -512,7 +513,7 @@ def _run_single_query_mode(cli, query, image, quiet, oneshot, stream_json: bool
|
||||
# No welcome banner (~420 ms cold); session id / resume hint come from _print_exit_summary().
|
||||
_query_label = query or ("[image attached]" if single_query_images else "")
|
||||
if _query_label:
|
||||
cli.console.print(f"[bold blue]Query:[/] {_query_label}")
|
||||
cli.console.print(f"[bold blue]{t('cli.single_query.query_label')}[/] {_query_label}")
|
||||
cli._show_security_advisories()
|
||||
response = cli.chat(query, images=single_query_images or None)
|
||||
# Kanban goal_mode on the `-q` path: same judge loop as `-Q`, but each follow-up turn
|
||||
|
||||
@@ -12,6 +12,7 @@ import shutil
|
||||
import threading
|
||||
import time
|
||||
|
||||
from agent.i18n import t
|
||||
from agent.pet import render as pet_render
|
||||
from hermes_cli.banner import _format_context_length
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -90,10 +91,10 @@ class CLIStatusBarMixin:
|
||||
return ""
|
||||
mode = app.vi_state.input_mode
|
||||
if mode in (InputMode.INSERT, InputMode.INSERT_MULTIPLE):
|
||||
return "INSERT"
|
||||
return t("cli.status_bar.vim_insert")
|
||||
if mode == InputMode.REPLACE:
|
||||
return "REPLACE"
|
||||
return "NORMAL"
|
||||
return t("cli.status_bar.vim_replace")
|
||||
return t("cli.status_bar.vim_normal")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@@ -116,11 +117,11 @@ class CLIStatusBarMixin:
|
||||
return f" — {format_battery(reading)}" if reading.available else f" — {no_battery}"
|
||||
|
||||
if arg in ("status", "show"):
|
||||
state = "on" if self._battery_visible else "off"
|
||||
detail = _detail("no battery detected on this machine")
|
||||
state = t("cli.shared.label_on") if self._battery_visible else t("cli.shared.label_off")
|
||||
detail = _detail(t("cli.status_bar.battery_none"))
|
||||
if reading is not None and reading.available:
|
||||
detail = f" — currently {format_battery(reading)}"
|
||||
self._console_print(f" Battery indicator {state}{detail}")
|
||||
detail = t("cli.status_bar.battery_currently", reading=format_battery(reading))
|
||||
self._console_print(f" {t('cli.status_bar.battery_state', state=state, detail=detail)}")
|
||||
return
|
||||
|
||||
if arg in ("on", "true", "yes"):
|
||||
@@ -130,17 +131,17 @@ class CLIStatusBarMixin:
|
||||
elif arg in ("", "toggle"):
|
||||
target = not self._battery_visible
|
||||
else:
|
||||
self._console_print(" Usage: /battery [on|off|status]")
|
||||
self._console_print(f" {t('cli.status_bar.battery_usage')}")
|
||||
return
|
||||
|
||||
self._battery_visible = target
|
||||
save_config_value("display.battery", target)
|
||||
if target:
|
||||
self._console_print(
|
||||
f" Battery indicator on{_detail('no battery detected, so nothing will show here')}"
|
||||
f" {t('cli.status_bar.battery_on', detail=_detail(t('cli.status_bar.battery_none_nothing_shown')))}"
|
||||
)
|
||||
else:
|
||||
self._console_print(" Battery indicator off")
|
||||
self._console_print(f" {t('cli.status_bar.battery_off')}")
|
||||
|
||||
@staticmethod
|
||||
def _compression_count_style(count: int) -> str:
|
||||
@@ -168,13 +169,15 @@ class CLIStatusBarMixin:
|
||||
minutes, seconds = int(remaining // 60), int(remaining % 60)
|
||||
days, hours = int(days), int(hours)
|
||||
if days > 0:
|
||||
time_str = f"{days}d {hours}h {minutes}m"
|
||||
time_str = t("cli.shared.duration_dhm", days=days, hours=hours, minutes=minutes)
|
||||
elif hours > 0:
|
||||
time_str = f"{hours}h {minutes}m {seconds}s" if seconds else f"{hours}h {minutes}m"
|
||||
time_str = (t("cli.shared.duration_hms", hours=hours, minutes=minutes, seconds=seconds) if seconds
|
||||
else t("cli.shared.duration_hm", hours=hours, minutes=minutes))
|
||||
elif minutes > 0:
|
||||
time_str = f"{minutes}m {seconds}s" if seconds else f"{minutes}m"
|
||||
time_str = (t("cli.shared.duration_ms", minutes=minutes, seconds=seconds) if seconds
|
||||
else t("cli.shared.duration_m", minutes=minutes))
|
||||
else:
|
||||
time_str = f"{int(elapsed)}s"
|
||||
time_str = t("cli.shared.duration_s", seconds=int(elapsed))
|
||||
return f"{'⏱' if live else '⏲'} {time_str}"
|
||||
|
||||
@staticmethod
|
||||
@@ -190,7 +193,7 @@ class CLIStatusBarMixin:
|
||||
from cli import _reverse_alias_for_display, datetime, format_duration_compact
|
||||
agent = getattr(self, "agent", None)
|
||||
# Prefer the agent's model name — it updates on fallback; self.model never changes.
|
||||
model_name = (getattr(agent, "model", None) or self.model or "unknown")
|
||||
model_name = (getattr(agent, "model", None) or self.model or t("cli.shared.unknown"))
|
||||
# Friendly display: reverse-alias from config ``model_aliases:`` first (turns long
|
||||
# Palantir RIDs into the user's short name), else slash/length truncation.
|
||||
model_short = _reverse_alias_for_display(model_name)
|
||||
@@ -395,9 +398,9 @@ class CLIStatusBarMixin:
|
||||
except Exception:
|
||||
avg_lat = avg_vel = None
|
||||
snapshot["avg_latency"] = float(avg_lat) if avg_lat is not None else None
|
||||
snapshot["avg_latency_label"] = f"{avg_lat:.1f}s" if avg_lat is not None else ""
|
||||
snapshot["avg_latency_label"] = t("cli.status_bar.latency_label", value=f"{avg_lat:.1f}") if avg_lat is not None else ""
|
||||
snapshot["avg_velocity"] = float(avg_vel) if avg_vel is not None else None
|
||||
snapshot["avg_velocity_label"] = f"{avg_vel:.0f} t/s" if avg_vel is not None else ""
|
||||
snapshot["avg_velocity_label"] = t("cli.status_bar.velocity_label", value=f"{avg_vel:.0f}") if avg_vel is not None else ""
|
||||
return snapshot
|
||||
|
||||
def _get_status_bar_session_title(self) -> str:
|
||||
@@ -567,9 +570,10 @@ class CLIStatusBarMixin:
|
||||
elapsed = time.monotonic() - t0
|
||||
# Fixed-width timers (01m05s / " 5.2s") avoid status-line wrap jitter on repaint.
|
||||
if elapsed >= 60:
|
||||
elapsed_str = f"{int(elapsed // 60):02d}m{int(elapsed % 60):02d}s"
|
||||
elapsed_str = t("cli.shared.duration_ms_compact",
|
||||
minutes=f"{int(elapsed // 60):02d}", seconds=f"{int(elapsed % 60):02d}")
|
||||
else:
|
||||
elapsed_str = f"{elapsed:5.1f}s"
|
||||
elapsed_str = t("cli.shared.duration_s", seconds=f"{elapsed:5.1f}")
|
||||
return f" {txt} ({elapsed_str} · {flow})" if flow else f" {txt} ({elapsed_str})"
|
||||
return f" {txt} ({flow})" if flow else f" {txt}"
|
||||
|
||||
@@ -964,15 +968,16 @@ class CLIStatusBarMixin:
|
||||
label = self._voice_record_key_label()
|
||||
if self._voice_recording:
|
||||
if compact:
|
||||
return [("class:voice-status-recording", " ● REC ")]
|
||||
return [("class:voice-status-recording", f" ● REC {label} to stop ")]
|
||||
return [("class:voice-status-recording", f" {t('cli.status_bar.voice_rec')} ")]
|
||||
return [("class:voice-status-recording", f" {t('cli.status_bar.voice_rec_stop_hint', shortcut=label)} ")]
|
||||
if self._voice_processing:
|
||||
return [("class:voice-status", " ◉ STT " if compact else " ◉ Transcribing... ")]
|
||||
return [("class:voice-status",
|
||||
f" {t('cli.status_bar.voice_stt')} " if compact else f" {t('cli.status_bar.voice_transcribing')} ")]
|
||||
if compact:
|
||||
return [("class:voice-status", f" 🎤 {label} ")]
|
||||
tts = " | TTS on" if self._voice_tts else ""
|
||||
cont = " | Continuous" if self._voice_continuous else ""
|
||||
return [("class:voice-status", f" 🎤 Voice mode{tts}{cont} — {label} to record ")]
|
||||
tts = t("cli.status_bar.voice_tts_on") if self._voice_tts else ""
|
||||
cont = t("cli.status_bar.voice_continuous") if self._voice_continuous else ""
|
||||
return [("class:voice-status", f" {t('cli.status_bar.voice_mode_hint', tts=tts, cont=cont, shortcut=label)} ")]
|
||||
|
||||
# ── status bar rendering ──────────────────────────────────────────────────
|
||||
|
||||
@@ -984,7 +989,7 @@ class CLIStatusBarMixin:
|
||||
return ""
|
||||
used = snapshot.get("goal_turns_used") or 0
|
||||
max_turns = snapshot.get("goal_max_turns") or 0
|
||||
return f"⊙ goal {used}/{max_turns}" if max_turns else "⊙ goal"
|
||||
return t("cli.status_bar.goal_turns", used=used, max=max_turns) if max_turns else t("cli.status_bar.goal")
|
||||
|
||||
def _get_status_bar_field_set(self) -> Optional[frozenset]:
|
||||
"""Visible status-bar fields from ``display.status_bar.fields`` (module-level
|
||||
@@ -1052,10 +1057,10 @@ class CLIStatusBarMixin:
|
||||
if snapshot["context_length"]:
|
||||
ctx_total = _format_context_length(snapshot["context_length"])
|
||||
ctx_used = format_token_count_compact(snapshot["context_tokens"])
|
||||
pin = " pinned" if snapshot.get("context_pinned") else ""
|
||||
pin = t("cli.status_bar.ctx_pinned") if snapshot.get("context_pinned") else ""
|
||||
context_label = f"{mark}{ctx_used}/{ctx_total}{pin}"
|
||||
else:
|
||||
context_label = "ctx --"
|
||||
context_label = t("cli.status_bar.ctx_unknown")
|
||||
segs.append([(_DIM, context_label)])
|
||||
if _ok("context_pct"):
|
||||
bar_style = self._status_bar_context_style(percent)
|
||||
@@ -1093,7 +1098,7 @@ class CLIStatusBarMixin:
|
||||
if focus_label:
|
||||
add("focus", _STRONG, focus_label)
|
||||
if yolo_active:
|
||||
add("yolo", "class:status-bar-yolo", "⚠ YOLO")
|
||||
add("yolo", "class:status-bar-yolo", t("cli.status_bar.yolo_badge"))
|
||||
if wide:
|
||||
# Session token total (Σ) — opt-in only via an explicit fields list.
|
||||
total_tokens = snapshot.get("session_total_tokens", 0)
|
||||
@@ -1184,11 +1189,11 @@ class CLIStatusBarMixin:
|
||||
def _fmt_stash_age(stashed_at: float) -> str:
|
||||
secs = int(time.monotonic() - stashed_at)
|
||||
if secs < 10:
|
||||
return "just now"
|
||||
return t("cli.shared.just_now")
|
||||
if secs < 90:
|
||||
return f"{secs}s ago"
|
||||
return t("cli.shared.ago_seconds", seconds=secs)
|
||||
mins = secs // 60
|
||||
return f"{mins} min ago" if mins < 60 else f"{mins // 60}h ago"
|
||||
return t("cli.shared.ago_minutes", minutes=mins) if mins < 60 else t("cli.shared.ago_hours", hours=mins // 60)
|
||||
|
||||
def _render_stash_panel(self, stash_list: list, cursor: int, width: int) -> list:
|
||||
"""prompt_toolkit fragments for the stash panel box. Every horizontal measurement uses
|
||||
@@ -1198,10 +1203,11 @@ class CLIStatusBarMixin:
|
||||
W = max(12, min(width - 4, 80))
|
||||
|
||||
n = len(stash_list)
|
||||
hdr_prefix_str = f"╭─ 📌 Stash ({n} item{'s' if n != 1 else ''}) "
|
||||
hdr_key = "cli.status_bar.stash_header_one" if n == 1 else "cli.status_bar.stash_header_other"
|
||||
hdr_prefix_str = f"╭─ {t(hdr_key, count=n)} "
|
||||
HDR_SUFFIX = " Ctrl+S ─╮"
|
||||
FTR_PREFIX = "╰"
|
||||
FTR_SUFFIX = " ↑↓ Enter=restore D=delete Esc ─╯"
|
||||
FTR_SUFFIX = f" {t('cli.status_bar.stash_footer')} ─╯"
|
||||
|
||||
# On narrow terminals the full hint text is wider than the box: drop to compact
|
||||
# affordances rather than letting the frame bleed past the right edge.
|
||||
@@ -1209,7 +1215,7 @@ class CLIStatusBarMixin:
|
||||
hdr_prefix_str = f"╭─ 📌 {n} "
|
||||
HDR_SUFFIX = "─╮"
|
||||
if cw(FTR_PREFIX) + cw(FTR_SUFFIX) > W:
|
||||
FTR_SUFFIX = " ↑↓ ⏎ D Esc ─╯"
|
||||
FTR_SUFFIX = f" {t('cli.status_bar.stash_footer_compact')} ─╯"
|
||||
if cw(FTR_PREFIX) + cw(FTR_SUFFIX) > W:
|
||||
FTR_SUFFIX = "─╯"
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from rich.markup import escape as _escape
|
||||
|
||||
from agent.i18n import t
|
||||
from agent.think_scrubber import THINK_CLOSE_TAGS, THINK_OPEN_TAGS
|
||||
|
||||
# Model-generated reasoning tags: suppressed during streaming (they'd display as raw XML;
|
||||
@@ -25,15 +26,21 @@ _OPEN_TAGS = THINK_OPEN_TAGS
|
||||
_CLOSE_TAGS = THINK_CLOSE_TAGS
|
||||
_MAX_CLOSE_TAG_LEN = max(len(t) for t in _CLOSE_TAGS)
|
||||
|
||||
# Ordered (prefix, status) rows for _slow_command_status — first match wins.
|
||||
# Ordered (prefix, catalog key) rows for _slow_command_status — first match wins; the key is
|
||||
# resolved through t() at call time so the active language applies.
|
||||
_SLOW_COMMAND_STATUS = (
|
||||
("/skills search", "Searching skills..."), ("/skills browse", "Loading skills..."),
|
||||
("/skills inspect", "Inspecting skill..."), ("/skills install", "Installing skill..."),
|
||||
("/skills", "Processing skills command..."), ("/browser", "Configuring browser..."))
|
||||
("/skills search", "cli.stream.busy_skills_search"), ("/skills browse", "cli.stream.busy_skills_browse"),
|
||||
("/skills inspect", "cli.stream.busy_skills_inspect"), ("/skills install", "cli.stream.busy_skills_install"),
|
||||
("/skills", "cli.stream.busy_skills"), ("/browser", "cli.stream.busy_browser"))
|
||||
_SLOW_COMMAND_STATUS_EXACT = {
|
||||
"/reload-mcp": "Reloading MCP servers...",
|
||||
"/reload-skills": "Reloading skills...",
|
||||
"/reload_skills": "Reloading skills..."}
|
||||
"/reload-mcp": "cli.stream.busy_reload_mcp",
|
||||
"/reload-skills": "cli.stream.busy_reload_skills",
|
||||
"/reload_skills": "cli.stream.busy_reload_skills"}
|
||||
|
||||
|
||||
def _thinking_prefix() -> str:
|
||||
"""`` [thinking] `` — the reasoning-preview label; also measured for wrap width."""
|
||||
return f" {t('cli.stream.thinking_label')} "
|
||||
|
||||
|
||||
def _terminal_columns(default: int = 80) -> int:
|
||||
@@ -118,7 +125,7 @@ class CLIStreamMixin:
|
||||
preview_text = reasoning_text.strip()
|
||||
if not preview_text:
|
||||
return
|
||||
wrap_width = max(30, _terminal_columns() - len(" [thinking] ") - 2)
|
||||
wrap_width = max(30, _terminal_columns() - len(_thinking_prefix()) - 2)
|
||||
paragraphs = []
|
||||
for paragraph in re.split(r"\n\s*\n+", preview_text.replace("\r\n", "\n")):
|
||||
compact = " ".join(line.strip() for line in paragraph.splitlines() if line.strip())
|
||||
@@ -128,14 +135,14 @@ class CLIStreamMixin:
|
||||
if not preview_text:
|
||||
return
|
||||
if self.verbose:
|
||||
_cprint(f" {_DIM}[thinking] {preview_text}{_RST}")
|
||||
_cprint(f"{_DIM}{_thinking_prefix()}{preview_text}{_RST}")
|
||||
return
|
||||
lines = preview_text.splitlines()
|
||||
if len(lines) > 5:
|
||||
preview = "\n".join(lines[:5]) + f"\n ... ({len(lines) - 5} more lines)"
|
||||
preview = "\n".join(lines[:5]) + f"\n {t('cli.stream.thinking_more_lines', count=len(lines) - 5)}"
|
||||
else:
|
||||
preview = preview_text
|
||||
_cprint(f" {_DIM}[thinking] {preview}{_RST}")
|
||||
_cprint(f"{_DIM}{_thinking_prefix()}{preview}{_RST}")
|
||||
|
||||
def _flush_reasoning_preview(self, *, force: bool = False) -> None:
|
||||
"""Flush buffered reasoning text at natural boundaries.
|
||||
@@ -146,7 +153,7 @@ class CLIStreamMixin:
|
||||
buf = getattr(self, "_reasoning_preview_buf", "")
|
||||
if not buf:
|
||||
return
|
||||
target_width = max(40, _terminal_columns() - len(" [thinking] ") - 4)
|
||||
target_width = max(40, _terminal_columns() - len(_thinking_prefix()) - 4)
|
||||
flush_text = ""
|
||||
if force:
|
||||
flush_text, buf = buf, ""
|
||||
@@ -194,8 +201,8 @@ class CLIStreamMixin:
|
||||
preview_lines = [f"[bold {_accent_hex()}]●[/] [bold]{_escape(head[0])}[/]{ts_suffix}"]
|
||||
preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in head[1:])
|
||||
if hidden_middle_count > 0:
|
||||
noun = "line" if hidden_middle_count == 1 else "lines"
|
||||
preview_lines.append(f"[dim]... (+{hidden_middle_count} more {noun})[/]")
|
||||
key = "cli.stream.preview_more_lines_one" if hidden_middle_count == 1 else "cli.stream.preview_more_lines_other"
|
||||
preview_lines.append(f"[dim]{_escape(t(key, count=hidden_middle_count))}[/]")
|
||||
preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in tail)
|
||||
return "\n".join(preview_lines)
|
||||
|
||||
@@ -251,7 +258,7 @@ class CLIStreamMixin:
|
||||
if not getattr(self, "_reasoning_box_opened", False):
|
||||
self._reasoning_box_opened = True
|
||||
w = self._scrollback_box_width()
|
||||
r_label = " Reasoning "
|
||||
r_label = f" {t('cli.chat.reasoning_label')} "
|
||||
r_fill = w - 2 - len(r_label)
|
||||
_cprint(f"\n{_DIM}┌─{r_label}{'─' * max(r_fill - 1, 0)}┐{_RST}")
|
||||
|
||||
@@ -541,11 +548,11 @@ class CLIStreamMixin:
|
||||
cmd_lower = command.lower().strip()
|
||||
exact = _SLOW_COMMAND_STATUS_EXACT.get(cmd_lower)
|
||||
if exact:
|
||||
return exact
|
||||
for prefix, status in _SLOW_COMMAND_STATUS:
|
||||
return t(exact)
|
||||
for prefix, key in _SLOW_COMMAND_STATUS:
|
||||
if cmd_lower.startswith(prefix):
|
||||
return status
|
||||
return "Processing command..."
|
||||
return t(key)
|
||||
return t("cli.stream.processing_command")
|
||||
|
||||
def _command_spinner_frame(self) -> str:
|
||||
"""Return the current spinner frame for slow slash commands."""
|
||||
@@ -592,7 +599,7 @@ class CLIStreamMixin:
|
||||
continue
|
||||
size_kb = img_path.stat().st_size // 1024
|
||||
if announce:
|
||||
_cprint(f" {_DIM}👁️ analyzing {img_path.name} ({size_kb}KB)...{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.stream.vision_analyzing', name=img_path.name, size_kb=size_kb)}{_RST}")
|
||||
try:
|
||||
result_json = _asyncio.run(
|
||||
vision_analyze_tool(image_url=str(img_path), user_prompt=analysis_prompt))
|
||||
@@ -604,14 +611,14 @@ class CLIStreamMixin:
|
||||
f"[If you need a closer look, use vision_analyze with "
|
||||
f"image_url: {img_path}]")
|
||||
if announce:
|
||||
_cprint(f" {_DIM}✓ image analyzed{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.stream.vision_analyzed')}{_RST}")
|
||||
else:
|
||||
enriched_parts.append(
|
||||
f"[The user attached an image but it couldn't be analyzed. "
|
||||
f"You can try examining it with vision_analyze using "
|
||||
f"image_url: {img_path}]")
|
||||
if announce:
|
||||
render_notification(lambda: _cprint(f" {_DIM}⚠ vision analysis failed — path included for retry{_RST}"),
|
||||
render_notification(lambda: _cprint(f" {_DIM}{t('cli.stream.vision_failed')}{_RST}"),
|
||||
platform="cli", user_config=getattr(getattr(self, "agent", None), "_notification_config", None))
|
||||
except Exception as e:
|
||||
enriched_parts.append(
|
||||
@@ -619,7 +626,7 @@ class CLIStreamMixin:
|
||||
f"You can try examining it with vision_analyze using "
|
||||
f"image_url: {img_path}]")
|
||||
if announce:
|
||||
render_notification(lambda: _cprint(f" {_DIM}⚠ vision analysis error — path included for retry{_RST}"),
|
||||
render_notification(lambda: _cprint(f" {_DIM}{t('cli.stream.vision_error')}{_RST}"),
|
||||
platform="cli", user_config=getattr(getattr(self, "agent", None), "_notification_config", None))
|
||||
|
||||
# Vision descriptions first, then the user's original text
|
||||
@@ -654,7 +661,7 @@ class CLIStreamMixin:
|
||||
announced.add(tool_name)
|
||||
from agent.display import bridge_generating_phrase, get_tool_emoji
|
||||
what = bridge_generating_phrase(tool_name) or tool_name
|
||||
_cprint(f" ┊ {get_tool_emoji(tool_name, default='⚡')} preparing {what}…")
|
||||
_cprint(f" ┊ {t('cli.stream.tool_preparing', emoji=get_tool_emoji(tool_name, default='⚡'), what=what)}")
|
||||
|
||||
def _on_tool_progress(self, event_type: str, function_name: str = None, preview: str = None, function_args: dict = None, **kwargs):
|
||||
"""Tool lifecycle events (tool.started / tool.completed / reasoning.* / moa.*).
|
||||
@@ -666,11 +673,12 @@ class CLIStreamMixin:
|
||||
# MoA reference outputs (display-only events from the MoA facade): render each answer
|
||||
# as a labelled thinking-style block BEFORE the aggregator acts.
|
||||
if event_type == "moa.reference":
|
||||
label = function_name or "reference"
|
||||
label = function_name or t("cli.stream.reference_noun")
|
||||
text = preview or ""
|
||||
idx = kwargs.get("moa_index")
|
||||
count = kwargs.get("moa_count")
|
||||
header = f"Reference {idx}/{count} — {label}" if idx and count else f"Reference — {label}"
|
||||
header = (t("cli.stream.reference_header_indexed", index=idx, count=count, label=label) if idx and count
|
||||
else t("cli.stream.reference_header", label=label))
|
||||
try:
|
||||
self._flush_reasoning_preview(force=True)
|
||||
except Exception:
|
||||
@@ -685,7 +693,7 @@ class CLIStreamMixin:
|
||||
return
|
||||
if event_type == "moa.aggregating":
|
||||
agg = function_name or ""
|
||||
self._spinner_text = f"◆ aggregating ({agg})" if agg else "◆ aggregating"
|
||||
self._spinner_text = t("cli.stream.aggregating_detail", detail=agg) if agg else t("cli.stream.aggregating")
|
||||
self._invalidate()
|
||||
return
|
||||
|
||||
@@ -784,9 +792,9 @@ class CLIStreamMixin:
|
||||
parsed = {}
|
||||
if isinstance(parsed, dict) and parsed.get("status") == "dispatched" and parsed.get("mode") == "background":
|
||||
n = parsed.get("count") or 1
|
||||
noun, tail = ("task", "it finishes") if n == 1 else (f"{n} tasks", "they finish")
|
||||
key = "cli.stream.background_running_one" if n == 1 else "cli.stream.background_running_other"
|
||||
try:
|
||||
_cprint(f"\033[2m\u21a9 Background {noun} running — I'll resume when {tail}. Keep chatting.\033[0m")
|
||||
_cprint(f"\033[2m{t(key, count=n)}\033[0m")
|
||||
except Exception:
|
||||
pass
|
||||
snapshot = self._pending_edit_snapshots.pop(tool_call_id, None)
|
||||
|
||||
@@ -8,6 +8,7 @@ import time
|
||||
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
from agent.i18n import t
|
||||
from hermes_cli import cli_process_dock as procs
|
||||
from hermes_cli import cli_session_dock as session_rows
|
||||
|
||||
@@ -120,7 +121,7 @@ class SubagentMonitor:
|
||||
target = target or self.selected_id
|
||||
if any(r['key'] == target for r in self.processes):
|
||||
if action != 'stop':
|
||||
return {'error': 'Background processes cannot be steered; stop them or read the log.'}
|
||||
return {'error': t('cli.subagents.cannot_steer_process')}
|
||||
return procs.kill(target)
|
||||
from tools.delegate_tool_registry import _handle_control_action
|
||||
return json.loads(_handle_control_action(action, target, message, getattr(self.cli, 'agent', None)))
|
||||
@@ -130,29 +131,39 @@ class SubagentMonitor:
|
||||
(``session=True``) adds ``goal active|paused|parked`` and ``N queued``."""
|
||||
parts = []
|
||||
if self.entries:
|
||||
parts.append(f'{len(self.entries)} live')
|
||||
parts.append(t('cli.subagents.count_live', count=len(self.entries)))
|
||||
if self.processes:
|
||||
running = sum(r['status'] == 'running' for r in self.processes)
|
||||
parts.append(f"{running} proc{'s' if running != 1 else ''}" if running else f'{len(self.processes)} done')
|
||||
if running:
|
||||
parts.append(t('cli.subagents.count_procs_one' if running == 1 else 'cli.subagents.count_procs_other',
|
||||
count=running))
|
||||
else:
|
||||
parts.append(t('cli.subagents.count_done', count=len(self.processes)))
|
||||
if session and self.goal:
|
||||
parts.append('goal ' + ('parked' if self.goal.startswith('⏳') else
|
||||
'paused' if self.goal.startswith('⏸') else 'active'))
|
||||
parts.append(t('cli.subagents.goal_parked' if self.goal.startswith('⏳') else
|
||||
'cli.subagents.goal_paused' if self.goal.startswith('⏸') else 'cli.subagents.goal_active'))
|
||||
if session and self.queued:
|
||||
parts.append(f'{len(self.queued)} queued')
|
||||
parts.append(t('cli.subagents.count_queued', count=len(self.queued)))
|
||||
return ' · '.join(parts)
|
||||
|
||||
def _title(self):
|
||||
if self.entries and self.processes:
|
||||
return 'Live work'
|
||||
return 'Subagents' if self.entries else 'Processes'
|
||||
return t('cli.subagents.title_live_work')
|
||||
return t('cli.subagents.title_subagents') if self.entries else t('cli.subagents.title_processes')
|
||||
|
||||
@staticmethod
|
||||
def _agent_activity(row):
|
||||
"""``last: <tool>`` while a tool runs, else the raw status id, else ``starting``."""
|
||||
if row.get('last_tool'):
|
||||
return t('cli.subagents.last_tool', tool=row['last_tool'])
|
||||
return row.get('status') or t('cli.subagents.starting')
|
||||
|
||||
def _collapsed_activity(self):
|
||||
if self.entries:
|
||||
row = self.entries[0]
|
||||
return f"last: {row['last_tool']}" if row.get('last_tool') else row.get('status') or 'starting'
|
||||
return self._agent_activity(self.entries[0])
|
||||
if self.processes:
|
||||
return procs.process_activity(self.processes[0])
|
||||
return self.goal or f'next: {self.queued[0]}'
|
||||
return self.goal or t('cli.subagents.next_queued', text=self.queued[0])
|
||||
|
||||
def dock_text(self, *, columns, rows):
|
||||
if not self.has_rows:
|
||||
@@ -164,13 +175,14 @@ class SubagentMonitor:
|
||||
# collapse/restore shortcut.
|
||||
if self.entries or self.processes:
|
||||
headings = (
|
||||
f'{self._title()} · {count} · Ctrl+T expand · Ctrl+R restore',
|
||||
f'{count} · Ctrl+T expand · Ctrl+R restore',
|
||||
f'{count} · Ctrl+T · Ctrl+R',
|
||||
t('cli.subagents.heading_full', title=self._title(), count=count),
|
||||
t('cli.subagents.heading_medium', count=count),
|
||||
t('cli.subagents.heading_compact', count=count),
|
||||
count,
|
||||
)
|
||||
else:
|
||||
headings = (f'{count} · Ctrl+R restore', f'{count} · Ctrl+R', count)
|
||||
headings = (t('cli.subagents.heading_session_restore', count=count),
|
||||
t('cli.subagents.heading_session_compact', count=count), count)
|
||||
width = max(0, columns - 1)
|
||||
heading = next((text for text in headings if get_cwidth(text) <= width), count)
|
||||
activity = self._collapsed_activity()
|
||||
@@ -187,42 +199,46 @@ class SubagentMonitor:
|
||||
agent_count = min(len(self.entries), agent_budget)
|
||||
if self.entries:
|
||||
hidden = len(self.entries) - agent_count
|
||||
lines.append(_clip(f' Subagents · {len(self.entries)} live · Ctrl+T expand · Ctrl+R collapse', columns))
|
||||
lines.append(_clip(' ' + t('cli.subagents.subagents_heading', count=len(self.entries)), columns))
|
||||
for row in self.entries[:agent_count]:
|
||||
activity = f"{row['elapsed']}s · " + (f"last: {row['last_tool']}" if row['last_tool'] else row.get('status') or 'starting')
|
||||
activity = f"{row['elapsed']}s · " + self._agent_activity(row)
|
||||
# Reserve activity even on narrow terminals; task names use the remainder.
|
||||
goal_width = max(3, columns - get_cwidth(activity) - 5)
|
||||
lines.append(_clip(f" ● {_clip(row.get('goal'), goal_width)} · {activity}", columns))
|
||||
if hidden:
|
||||
lines.append(_clip(f' +{hidden} more · Ctrl+T all subagents', columns))
|
||||
lines.append(_clip(' ' + t('cli.subagents.more_subagents', count=hidden), columns))
|
||||
if self.processes:
|
||||
proc_count = min(len(self.processes), max(1, budget - agent_count))
|
||||
running = sum(r['status'] == 'running' for r in self.processes)
|
||||
done = len(self.processes) - running
|
||||
summary = ' · '.join(p for p in (f'{running} running' if running else '', f'{done} done' if done else '') if p)
|
||||
controls = ' · Ctrl+T expand · Ctrl+R collapse' if not self.entries else ''
|
||||
lines.append(_clip(f' Processes · {summary}{controls}', columns))
|
||||
summary = ' · '.join(p for p in (
|
||||
t('cli.subagents.count_running', count=running) if running else '',
|
||||
t('cli.subagents.count_done', count=done) if done else '') if p)
|
||||
controls = t('cli.subagents.controls_expand_collapse') if not self.entries else ''
|
||||
lines.append(_clip(' ' + t('cli.subagents.processes_heading', summary=summary, controls=controls), columns))
|
||||
for row in self.processes[:proc_count]:
|
||||
activity = procs.process_activity(row)
|
||||
command_width = max(3, columns - get_cwidth(activity) - 5)
|
||||
lines.append(_clip(f" {procs.process_glyph(row)} {_clip(row['command'], command_width)} · {activity}", columns))
|
||||
if len(self.processes) > proc_count:
|
||||
lines.append(_clip(f' +{len(self.processes) - proc_count} more · Ctrl+T all processes', columns))
|
||||
lines.append(_clip(
|
||||
' ' + t('cli.subagents.more_processes', count=len(self.processes) - proc_count), columns))
|
||||
if self.queued:
|
||||
# Last, so the next prompt to run sits right above the input it came from.
|
||||
shown = min(len(self.queued), session_rows.QUEUE_ROWS if rows >= 24 else 1)
|
||||
controls = '' if self.entries or self.processes else ' · Ctrl+R collapse'
|
||||
lines.append(_clip(f' Queue · {len(self.queued)} queued · /queue list{controls}', columns))
|
||||
controls = '' if self.entries or self.processes else t('cli.subagents.controls_collapse')
|
||||
lines.append(_clip(
|
||||
' ' + t('cli.subagents.queue_heading', count=len(self.queued), controls=controls), columns))
|
||||
for index, text in enumerate(self.queued[:shown], 1):
|
||||
lines.append(_clip(f' {index}. {text}', columns))
|
||||
if len(self.queued) > shown:
|
||||
lines.append(_clip(f' +{len(self.queued) - shown} more', columns))
|
||||
lines.append(_clip(' ' + t('cli.subagents.more_queued', count=len(self.queued) - shown), columns))
|
||||
return '\n'.join(' ' + line for line in lines)
|
||||
|
||||
|
||||
def read_tail(path):
|
||||
if not path:
|
||||
return 'Live transcript not available yet.'
|
||||
return t('cli.subagents.transcript_unavailable')
|
||||
try:
|
||||
with open(path, 'rb') as stream:
|
||||
stream.seek(0, 2)
|
||||
@@ -230,7 +246,7 @@ def read_tail(path):
|
||||
text = stream.read(32768).decode('utf-8', errors='replace')
|
||||
return ''.join(c for c in text if c.isprintable() or c in '\n\t')
|
||||
except OSError:
|
||||
return 'Live transcript not available yet.'
|
||||
return t('cli.subagents.transcript_unavailable')
|
||||
|
||||
|
||||
def modal_prompt_active(cli):
|
||||
@@ -249,7 +265,7 @@ def build_monitor_application(monitor, **kwargs):
|
||||
from prompt_toolkit.widgets import TextArea
|
||||
|
||||
state = {'detail': False, 'steering': False, 'confirm': False, 'notice': ''}
|
||||
steer = TextArea(height=1, prompt='Steer: ', multiline=False)
|
||||
steer = TextArea(height=1, prompt=t('cli.subagents.steer_prompt'), multiline=False)
|
||||
tail = TextArea(read_only=True, scrollbar=True, wrap_lines=True)
|
||||
|
||||
def roster_text():
|
||||
@@ -262,15 +278,15 @@ def build_monitor_application(monitor, **kwargs):
|
||||
activity = ''
|
||||
subject = row['command']
|
||||
else:
|
||||
prefix = f"{row['elapsed']}s · {row.get('status') or 'starting'} · "
|
||||
activity = f" · last: {row['last_tool']}" if row.get('last_tool') else ''
|
||||
prefix = f"{row['elapsed']}s · {row.get('status') or t('cli.subagents.starting')} · "
|
||||
activity = ' · ' + t('cli.subagents.last_tool', tool=row['last_tool']) if row.get('last_tool') else ''
|
||||
subject = row.get('goal') or row['subagent_id']
|
||||
goal_width = max(0, size.columns - 2 - get_cwidth(prefix + activity))
|
||||
text = f"{'❯' if selected else ' '} " + _clip(prefix + _clip(subject, goal_width) + activity, max(0, size.columns - 2))
|
||||
# Pad selection in terminal cells, not codepoints (task names may be wide).
|
||||
text += ' ' * max(0, size.columns - get_cwidth(text))
|
||||
rows.append(('class:subagent-dock.selected' if selected else '', text + '\n'))
|
||||
return rows or [('', 'No live subagents or background processes. Results arrive in the conversation.')]
|
||||
return rows or [('', t('cli.subagents.roster_empty'))]
|
||||
|
||||
def cursor():
|
||||
index = next((i for i, row in enumerate(monitor.roster) if monitor._key(row) == monitor.selected_id), 0)
|
||||
@@ -281,7 +297,7 @@ def build_monitor_application(monitor, **kwargs):
|
||||
def update_tail():
|
||||
row = monitor.selected
|
||||
if row is None:
|
||||
text = 'This entry is no longer live.'
|
||||
text = t('cli.subagents.entry_gone')
|
||||
elif row.get('kind') == 'process':
|
||||
text = procs.process_tail(row['id'])
|
||||
else:
|
||||
@@ -303,16 +319,17 @@ def build_monitor_application(monitor, **kwargs):
|
||||
narrow = app.output.get_size().columns < 60
|
||||
process = monitor.selected_process is not None
|
||||
if state['confirm']:
|
||||
noun = 'process' if process else 'subagent'
|
||||
return 'Stop? y yes · Esc cancel' if narrow else f'Stop selected {noun}? y confirm · Esc cancel'
|
||||
noun = t('cli.subagents.noun_process' if process else 'cli.subagents.noun_subagent')
|
||||
return (t('cli.subagents.footer_confirm_stop_compact') if narrow
|
||||
else t('cli.subagents.footer_confirm_stop', noun=noun))
|
||||
if state['steering']:
|
||||
return 'Enter send · Esc cancel' if narrow else 'Enter queues guidance · Esc cancels (does not interrupt)'
|
||||
steer = '' if process else ' s steer'
|
||||
return t('cli.subagents.footer_steering_compact' if narrow else 'cli.subagents.footer_steering')
|
||||
steer = '' if process else t('cli.subagents.footer_steer_key')
|
||||
if narrow:
|
||||
return f'PgUp/Dn ·{steer} x stop · Esc' if state['detail'] else '↑↓ · Enter tail · Ctrl+T close'
|
||||
steer = '' if process else ' · s steer'
|
||||
return (f'Esc roster · PgUp/PgDn tail{steer} · x stop' if state['detail'] else
|
||||
f'↑/↓ select · Enter tail{steer} · x stop · q/Ctrl+T close')
|
||||
return (t('cli.subagents.footer_detail_compact', steer=steer) if state['detail']
|
||||
else t('cli.subagents.footer_roster_compact'))
|
||||
steer = '' if process else t('cli.subagents.footer_steer_key_long')
|
||||
return t('cli.subagents.footer_detail' if state['detail'] else 'cli.subagents.footer_roster', steer=steer)
|
||||
|
||||
kb = KeyBindings()
|
||||
normal = Condition(lambda: not state['steering'] and not state['confirm'])
|
||||
|
||||
@@ -17,6 +17,8 @@ from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Mapping
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from agent.i18n import t
|
||||
|
||||
# Log-record parity with the origin module.
|
||||
logger = logging.getLogger("cli")
|
||||
|
||||
@@ -191,7 +193,7 @@ def _format_image_attachment_badges(attached_images: list[Path], image_counter:
|
||||
if width < 52:
|
||||
if len(attached_images) == 1:
|
||||
return f"[📎 {_trunc(attached_images[0].name, 20)}]"
|
||||
return f"[📎 {len(attached_images)} images attached]"
|
||||
return t("cli.terminal.image_badge_count", count=len(attached_images))
|
||||
|
||||
if width < 80:
|
||||
if len(attached_images) == 1:
|
||||
@@ -199,7 +201,7 @@ def _format_image_attachment_badges(attached_images: list[Path], image_counter:
|
||||
return f"[📎 {_trunc(attached_images[0].name, 20)}] [+{len(attached_images) - 1}]"
|
||||
|
||||
base = image_counter - len(attached_images) + 1
|
||||
return " ".join(f"[📎 Image #{base + i}]" for i in range(len(attached_images)))
|
||||
return " ".join(t("cli.terminal.image_badge_numbered", index=base + i) for i in range(len(attached_images)))
|
||||
|
||||
|
||||
def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool:
|
||||
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from agent.i18n import t
|
||||
from hermes_cli.cli_render import (
|
||||
_chrome_floor,
|
||||
_hold_paints,
|
||||
@@ -626,9 +627,7 @@ class CLITerminalMixin:
|
||||
logger.warning("Recovered terminal input modes after leak: %s", reason)
|
||||
if not self._input_mode_recovery_notice_shown:
|
||||
self._input_mode_recovery_notice_shown = True
|
||||
_cprint(
|
||||
f" {_DIM}Recovered terminal input modes after leaked mouse reports. "
|
||||
f"If this repeats, run /new or restart this tab.{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.terminal.recovered_mouse_modes')}{_RST}")
|
||||
|
||||
def _check_termios_drift(self) -> None:
|
||||
"""Idle watchdog: heal a tty that drifted back to cooked mode (a lost
|
||||
@@ -666,6 +665,4 @@ class CLITerminalMixin:
|
||||
pass
|
||||
if not self._termios_drift_notice_shown:
|
||||
self._termios_drift_notice_shown = True
|
||||
_cprint(
|
||||
f" {_DIM}Recovered terminal from cooked-mode drift "
|
||||
f"(input should respond normally again).{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.terminal.recovered_cooked_mode')}{_RST}")
|
||||
+137
-117
@@ -14,6 +14,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from agent.i18n import t
|
||||
from agent.interrupt_compat import request_hard_interrupt
|
||||
from hermes_cli.commands_completion import SlashCommandAutoSuggest, SlashCommandCompleter
|
||||
from pathlib import Path
|
||||
@@ -46,12 +47,32 @@ _PANEL_RESERVED_BELOW = 6
|
||||
_RAPID_INPUT_ENTER_WINDOW_S = 0.05
|
||||
_TYPING_CHARS = string.digits + string.ascii_letters + "-_.:/ "
|
||||
|
||||
_APPROVAL_CHOICE_LABELS = {
|
||||
"once": "Allow once",
|
||||
"session": "Allow for this session",
|
||||
"always": "Add to permanent allowlist",
|
||||
"deny": "Deny",
|
||||
"view": "Show full command"}
|
||||
# Approval-choice identifiers (protocol tokens, never translated) → catalog keys for their labels.
|
||||
_APPROVAL_CHOICE_KEYS = {
|
||||
"once": "cli.tui.approval_once",
|
||||
"session": "cli.tui.approval_session",
|
||||
"always": "cli.tui.approval_always",
|
||||
"deny": "cli.tui.approval_deny",
|
||||
"view": "cli.tui.approval_view"}
|
||||
|
||||
|
||||
def _approval_choice_label(choice: str) -> str:
|
||||
"""Localized label for an approval choice id; unknown ids render verbatim."""
|
||||
key = _APPROVAL_CHOICE_KEYS.get(choice)
|
||||
return t(key) if key else choice
|
||||
|
||||
|
||||
def _agent_name() -> str:
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
return get_active_skin().get_branding("agent_name", "Hermes")
|
||||
except Exception:
|
||||
return "Hermes"
|
||||
|
||||
|
||||
def _tn(key: str, count: int, **kwargs) -> str:
|
||||
"""Plural-aware lookup: ``<key>_one`` for exactly one, ``<key>_other`` otherwise."""
|
||||
return t(f"{key}_one" if count == 1 else f"{key}_other", count=count, **kwargs)
|
||||
|
||||
|
||||
def _num_prefix(i: int) -> str:
|
||||
@@ -128,11 +149,11 @@ class CLITuiMixin:
|
||||
if not state:
|
||||
return []
|
||||
wrap = _wrap_panel_text_keep_ws
|
||||
title = state.get("title") or "Confirm action"
|
||||
title = state.get("title") or t("cli.tui.confirm_title")
|
||||
detail = state.get("detail") or ""
|
||||
choices = state.get("choices") or []
|
||||
selected = state.get("selected", 0)
|
||||
footer = "Type 1/2/3 or use ↑/↓ then Enter. ESC/Ctrl+C cancels."
|
||||
footer = t("cli.tui.confirm_footer")
|
||||
choice_labels = [
|
||||
f"{'❯' if idx == selected else ' '} [{idx + 1}] {label} — {desc}"
|
||||
for idx, (_value, label, desc) in enumerate(choices)]
|
||||
@@ -149,7 +170,7 @@ class CLITuiMixin:
|
||||
available = max(0, _term_rows() - _PANEL_RESERVED_BELOW)
|
||||
max_detail_rows = min(8, max(1, available - chrome_full - len(choice_wrapped)))
|
||||
if len(detail_wrapped) > max_detail_rows:
|
||||
detail_wrapped = detail_wrapped[:max(1, max_detail_rows - 1)] + ["… (detail truncated)"]
|
||||
detail_wrapped = detail_wrapped[:max(1, max_detail_rows - 1)] + [t("cli.tui.detail_truncated")]
|
||||
|
||||
panel = _Panel('class:approval-border', box_width)
|
||||
panel.row('class:approval-title', title)
|
||||
@@ -181,13 +202,13 @@ class CLITuiMixin:
|
||||
choices = state["choices"]
|
||||
selected = state.get("selected", 0)
|
||||
show_full = state.get("show_full", False)
|
||||
title = "⚠️ Dangerous Command"
|
||||
title = t("cli.tui.approval_title")
|
||||
|
||||
preview_lines = wrap(description, 60)
|
||||
preview_lines.extend(wrap(command, 60))
|
||||
for i, choice in enumerate(choices):
|
||||
prefix = '❯ ' if i == selected else ' '
|
||||
label = _APPROVAL_CHOICE_LABELS.get(choice, choice)
|
||||
label = _approval_choice_label(choice)
|
||||
preview_lines.extend(wrap(f"{prefix}{label}", 60, subsequent_indent=" "))
|
||||
box_width = _panel_box_width(title, preview_lines)
|
||||
inner_text_width = max(8, box_width - 2)
|
||||
@@ -195,9 +216,10 @@ class CLITuiMixin:
|
||||
# Pre-wrap the mandatory content — command + choices must always render.
|
||||
cmd_wrapped = wrap(command, inner_text_width)
|
||||
if not show_full and "view" in choices and len(cmd_wrapped) > 4:
|
||||
cmd_wrapped = cmd_wrapped[:3] + wrap("… (choose Show full command)", inner_text_width)
|
||||
cmd_wrapped = cmd_wrapped[:3] + wrap(
|
||||
t("cli.tui.approval_choose_full", label=t("cli.tui.approval_view")), inner_text_width)
|
||||
choice_labels = [
|
||||
f"{'❯' if i == selected else ' '} {_num_prefix(i)}. {_APPROVAL_CHOICE_LABELS.get(choice, choice)}"
|
||||
f"{'❯' if i == selected else ' '} {_num_prefix(i)}. {_approval_choice_label(choice)}"
|
||||
for i, choice in enumerate(choices)]
|
||||
choice_wrapped = _wrap_rows(wrap, choice_labels, inner_text_width, " ")
|
||||
|
||||
@@ -214,7 +236,7 @@ class CLITuiMixin:
|
||||
if len(cmd_wrapped) > max_cmd_rows:
|
||||
keep = max(1, max_cmd_rows - 1) if max_cmd_rows > 1 else 1
|
||||
cmd_wrapped = cmd_wrapped[:keep] + wrap(
|
||||
"… (command truncated — use /logs or /debug for full text)", inner_text_width)
|
||||
t("cli.tui.approval_command_truncated"), inner_text_width)
|
||||
|
||||
# Remaining rows go to the description (minus the blank separator in full mode), capped
|
||||
# at 10 so the panel stays compact even on huge terminals.
|
||||
@@ -225,7 +247,7 @@ class CLITuiMixin:
|
||||
if available_for_desc < 1 or not desc_wrapped:
|
||||
desc_wrapped = []
|
||||
elif len(desc_wrapped) > available_for_desc:
|
||||
desc_wrapped = desc_wrapped[:max(1, available_for_desc - 1)] + ["… (description truncated)"]
|
||||
desc_wrapped = desc_wrapped[:max(1, available_for_desc - 1)] + [t("cli.tui.approval_description_truncated")]
|
||||
|
||||
# Render title → command → choices → description; description last so any overflow
|
||||
# clips the least-critical content, never the command or choices.
|
||||
@@ -449,8 +471,8 @@ class CLITuiMixin:
|
||||
multi_select = state.get("multi_select", False)
|
||||
selected_indices = state.get("selected_indices", set()) if multi_select else set()
|
||||
freetext = self._clarify_freetext
|
||||
title = "Hermes needs your input"
|
||||
header = f"{len(questions_list)} questions"
|
||||
title = t("cli.tui.clarify_title", agent_name=_agent_name())
|
||||
header = _tn("cli.tui.clarify_question_count", len(questions_list))
|
||||
|
||||
def _status_rows(width):
|
||||
rows = []
|
||||
@@ -482,20 +504,20 @@ class CLITuiMixin:
|
||||
# An earlier typed answer stays visible next to Other; Enter on it edits
|
||||
# (the composer is prefilled).
|
||||
other_text = (answer_meta.get(entry["qid"]) or {}).get("other_text") or ""
|
||||
other_suffix = f"Other: {other_text}" if other_text else None
|
||||
other_suffix = t("cli.tui.clarify_other_answer", text=other_text) if other_text else None
|
||||
if freetext:
|
||||
other_label = f" ❯ {mid}. " + (other_suffix or "Other (type below)")
|
||||
other_label = f" ❯ {mid}. " + (other_suffix or t("cli.tui.clarify_other_type_below"))
|
||||
other_style = 'class:clarify-active-other'
|
||||
elif selected == other_idx:
|
||||
other_label = f" ❯ {mid}. " + (other_suffix or "Other (type your answer)")
|
||||
other_label = f" ❯ {mid}. " + (other_suffix or t("cli.tui.clarify_other_type_answer"))
|
||||
other_style = 'class:clarify-selected'
|
||||
else:
|
||||
other_label = f" {mid}. " + (other_suffix or "Other (type your answer)")
|
||||
other_label = f" {mid}. " + (other_suffix or t("cli.tui.clarify_other_type_answer"))
|
||||
other_style = 'class:clarify-choice'
|
||||
for wrapped in _wrap_panel_text(other_label, width, subsequent_indent=" "):
|
||||
rows.append((other_style, wrapped))
|
||||
elif freetext:
|
||||
guidance = " Type your answer in the prompt below, then press Enter."
|
||||
guidance = " " + t("cli.tui.clarify_guidance")
|
||||
for wrapped in _wrap_panel_text(guidance, width):
|
||||
rows.append(('class:clarify-active-other', wrapped))
|
||||
return rows
|
||||
@@ -529,7 +551,7 @@ class CLITuiMixin:
|
||||
multi_select = state.get("multi_select", False)
|
||||
selected_indices = state.get("selected_indices", set()) if multi_select else set()
|
||||
freetext = self._clarify_freetext
|
||||
title = "Hermes needs your input"
|
||||
title = t("cli.tui.clarify_title", agent_name=_agent_name())
|
||||
other_idx = len(choices)
|
||||
|
||||
def _label(i, text):
|
||||
@@ -538,7 +560,8 @@ class CLITuiMixin:
|
||||
return f"{cursor} {cb}{_num_prefix(i)}. {text}"
|
||||
|
||||
choice_labels = [_label(i, c) for i, c in enumerate(choices)]
|
||||
other_label = _label(other_idx, "Other (type below)" if freetext else "Other (type your answer)")
|
||||
other_label = _label(
|
||||
other_idx, t("cli.tui.clarify_other_type_below") if freetext else t("cli.tui.clarify_other_type_answer"))
|
||||
|
||||
preview_lines = wrap(question, 60)
|
||||
preview_lines.extend(w for _i, w in _wrap_rows(wrap, choice_labels + [other_label], 60, " "))
|
||||
@@ -550,7 +573,7 @@ class CLITuiMixin:
|
||||
if choices:
|
||||
other_wrapped = wrap(other_label, inner_text_width, subsequent_indent=" ")
|
||||
elif freetext:
|
||||
other_wrapped = wrap("Type your answer in the prompt below, then press Enter.", inner_text_width)
|
||||
other_wrapped = wrap(t("cli.tui.clarify_guidance"), inner_text_width)
|
||||
else:
|
||||
other_wrapped = []
|
||||
|
||||
@@ -575,7 +598,7 @@ class CLITuiMixin:
|
||||
elif len(question_wrapped) > max_question_rows:
|
||||
# The marker is itself a row: with a 1-row budget show the marker alone so the
|
||||
# rendered question never exceeds max_question_rows.
|
||||
question_wrapped = question_wrapped[:max(0, max_question_rows - 1)] + ["… (question truncated)"]
|
||||
question_wrapped = question_wrapped[:max(0, max_question_rows - 1)] + [t("cli.tui.clarify_question_truncated")]
|
||||
|
||||
panel = _Panel('class:clarify-border', box_width, title, 'class:clarify-title')
|
||||
if not use_compact_chrome:
|
||||
@@ -652,50 +675,50 @@ class CLITuiMixin:
|
||||
if not state:
|
||||
return []
|
||||
if state.get("stage", "provider") == "provider":
|
||||
title = "⚙ Model Picker — Select Provider"
|
||||
title = t("cli.tui.picker_title_provider")
|
||||
choices = []
|
||||
_providers = state.get("providers")
|
||||
for p in _providers if isinstance(_providers, list) else []:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count} model{'s' if count != 1 else ''})"
|
||||
label = _tn("cli.tui.picker_provider_row", count, name=p['name'])
|
||||
if p.get("is_current"):
|
||||
label += " ← current"
|
||||
label += t("cli.tui.picker_current_marker")
|
||||
choices.append(label)
|
||||
choices.append("Cancel")
|
||||
hint = (
|
||||
f"Current: {state.get('current_model', 'unknown')} "
|
||||
f"on {state.get('current_provider', 'unknown')}")
|
||||
choices.append(t("cli.shared.cancel"))
|
||||
_unknown = t("cli.shared.unknown")
|
||||
hint = t("cli.tui.picker_current_hint",
|
||||
model=state.get('current_model', _unknown),
|
||||
provider=state.get('current_provider', _unknown))
|
||||
elif state.get("stage") == "reasoning":
|
||||
from hermes_cli.cli_model_switch_mixin import _picker_reasoning_rows
|
||||
result = state.get("switch_result")
|
||||
picked = getattr(result, "new_model", "") or "model"
|
||||
title = f"⚙ Model Picker — Reasoning effort for {picked}"
|
||||
picked = getattr(result, "new_model", "") or t("cli.shared.noun_model")
|
||||
title = t("cli.tui.picker_title_effort", model=picked)
|
||||
rc = self.reasoning_config
|
||||
current = ("none" if isinstance(rc, dict) and rc.get("enabled") is False
|
||||
else (rc or {}).get("effort", "medium") if isinstance(rc, dict) else "medium")
|
||||
choices = [f"{label} ← current" if value == current else label
|
||||
choices = [t("cli.tui.picker_current_labeled", label=label) if value == current else label
|
||||
for value, label in _picker_reasoning_rows()]
|
||||
choices += ["← Back", "Cancel"]
|
||||
hint = "Applies with the model switch (same scope) — Enter to choose"
|
||||
choices += [t("cli.shared.back"), t("cli.shared.cancel")]
|
||||
hint = t("cli.tui.picker_effort_hint")
|
||||
else:
|
||||
provider_data = state.get("provider_data") or {}
|
||||
model_list = state.get("model_list") or []
|
||||
title = f"⚙ Model Picker — {provider_data.get('name', provider_data.get('slug', 'Provider'))}"
|
||||
title = t("cli.tui.picker_title_models", provider=provider_data.get(
|
||||
'name', provider_data.get('slug', t("cli.tui.picker_provider_fallback"))))
|
||||
# Fuzzy filter narrows the concrete list; selection still resolves to a real entry via
|
||||
# the filtered_pairs index mapping, so this never makes model resolution ambiguous.
|
||||
_query = state.get("filter", "") or ""
|
||||
filtered_pairs = self._filter_model_picker_entries(model_list, _query)
|
||||
state["_filtered_pairs"] = filtered_pairs
|
||||
model_labels = [e for (_i, e) in filtered_pairs]
|
||||
choices = list(model_labels) + ["← Back", "Cancel"]
|
||||
choices = list(model_labels) + [t("cli.shared.back"), t("cli.shared.cancel")]
|
||||
if _query:
|
||||
hint = (
|
||||
f"Filter: {_query}▏ ({len(model_labels)}/{len(model_list)} match "
|
||||
"— type to narrow, Backspace to clear)")
|
||||
hint = t("cli.tui.picker_filter_hint", query=_query, shown=len(model_labels), total=len(model_list))
|
||||
elif model_list:
|
||||
hint = f"Select a model ({len(model_list)} available) — type to filter"
|
||||
hint = t("cli.tui.picker_select_hint", count=len(model_list))
|
||||
else:
|
||||
hint = "No models listed for this provider. Use Back or Cancel."
|
||||
hint = t("cli.tui.picker_empty")
|
||||
return self._render_scroll_list_panel(
|
||||
state, title, hint, choices, min_width=46, max_width=84, indent=' ')
|
||||
|
||||
@@ -708,12 +731,12 @@ class CLITuiMixin:
|
||||
_query = state.get("filter", "") or ""
|
||||
total = len(state.get("entries") or [])
|
||||
if _query:
|
||||
hint = f"Filter: {_query}▏ ({len(rows)}/{total} match — Enter inserts, Esc cancels)"
|
||||
hint = t("cli.tui.palette_filter_hint", query=_query, shown=len(rows), total=total)
|
||||
else:
|
||||
hint = f"Type to filter {total} commands — ↑/↓ then Enter inserts, Esc cancels"
|
||||
labels = [f"{c} — {d}" if d else c for (c, _cat, d) in rows] or ["(no matching commands)"]
|
||||
hint = t("cli.tui.palette_hint", total=total)
|
||||
labels = [f"{c} — {d}" if d else c for (c, _cat, d) in rows] or [t("cli.tui.palette_empty")]
|
||||
return self._render_scroll_list_panel(
|
||||
state, "⚙ Command Palette", hint, labels, min_width=50, max_width=90, indent=' ')
|
||||
state, t("cli.tui.palette_title"), hint, labels, min_width=50, max_width=90, indent=' ')
|
||||
|
||||
def _render_sudo_style_panel(
|
||||
self, title: str, body_lines: list[str], row_styles: list[str] | None = None
|
||||
@@ -734,43 +757,36 @@ class CLITuiMixin:
|
||||
def _get_sudo_display_fragments(self):
|
||||
if not self._sudo_state:
|
||||
return []
|
||||
# Panel bodies are one catalog value per panel (multi-line prose), split into rows here.
|
||||
if code := self._sudo_state.get("vault_code"):
|
||||
return self._render_sudo_style_panel(
|
||||
f'🔐 Verification code for {code["site"]}',
|
||||
[f'{code["site"]} is asking for a one-time code (text message, email or authenticator app).',
|
||||
'Type the code and press Enter; Hermes enters it into the page for you.',
|
||||
'Enter on an empty line skips. The model never sees the code.'])
|
||||
t("cli.tui.vault_code_title", site=code["site"]),
|
||||
t("cli.tui.vault_code_body", site=code["site"], agent_name=_agent_name()).split("\n"))
|
||||
if save := self._sudo_state.get("vault_save"):
|
||||
if save["step"] == "identifier":
|
||||
return self._render_sudo_style_panel(
|
||||
f'🔐 Save login for {save["site"]}',
|
||||
['The agent reached a sign-in page with no saved login for this site.',
|
||||
'Type the email / username you sign in with (shown), then Enter.',
|
||||
'Enter on an empty line skips. Nothing here is shown to the model.'])
|
||||
t("cli.tui.vault_save_title", site=save["site"]),
|
||||
t("cli.tui.vault_save_body").split("\n"))
|
||||
return self._render_sudo_style_panel(
|
||||
f'🔐 Save login for {save["site"]}',
|
||||
['Now the password (hidden). It is encrypted on this machine, bound to',
|
||||
f'{save["origin"]}, and filled into the page without the model ever seeing it.',
|
||||
'Enter on an empty line skips.'])
|
||||
t("cli.tui.vault_save_title", site=save["site"]),
|
||||
t("cli.tui.vault_save_pw_body", origin=save["origin"]).split("\n"))
|
||||
if backend := self._sudo_state.get("vault_backend"):
|
||||
return self._render_sudo_style_panel(
|
||||
f'🔐 Unlock {backend}',
|
||||
[f'The agent wants to sign into a site with a login saved in {backend}.',
|
||||
'Type your master password (hidden) to unlock it for this session.',
|
||||
'Enter on an empty line keeps it locked. The model never sees the password.'])
|
||||
return self._render_sudo_style_panel(
|
||||
'🔐 Sudo Password Required', ['Enter password below (hidden), or press Enter to skip'])
|
||||
t("cli.tui.vault_unlock_title", backend=backend),
|
||||
t("cli.tui.vault_unlock_body", backend=backend).split("\n"))
|
||||
return self._render_sudo_style_panel(t("cli.tui.sudo_title"), [t("cli.tui.sudo_body")])
|
||||
|
||||
def _get_secret_display_fragments(self):
|
||||
state = self._secret_state
|
||||
if not state:
|
||||
return []
|
||||
prompt = state.get("prompt") or f"Enter value for {state.get('var_name', 'secret')}"
|
||||
prompt = state.get("prompt") or t(
|
||||
"cli.tui.secret_prompt", var_name=state.get('var_name', t("cli.tui.secret_fallback_name")))
|
||||
help_text = (state.get("metadata") or {}).get("help")
|
||||
content_lines = [prompt, 'Enter secret below (hidden), ESC or Ctrl+C to skip']
|
||||
content_lines = [prompt, t("cli.tui.secret_body")]
|
||||
if help_text:
|
||||
content_lines.insert(1, str(help_text))
|
||||
return self._render_sudo_style_panel('🔑 Skill Setup Required', content_lines)
|
||||
return self._render_sudo_style_panel(t("cli.tui.secret_title"), content_lines)
|
||||
|
||||
def _get_connection_display_fragments(self):
|
||||
state = self._connection_state
|
||||
@@ -790,43 +806,46 @@ class CLITuiMixin:
|
||||
elif body_lines:
|
||||
action_index = len(body_lines) - 1
|
||||
styles[action_index] = 'class:clarify-selected'
|
||||
choices = ["Connect", "Cancel"]
|
||||
choices = [t("cli.tui.connection_connect"), t("cli.shared.cancel")]
|
||||
selected = min(1, max(0, state.get("selected", 0)))
|
||||
body_lines[action_index] = " ".join(
|
||||
f"▸ {choice}" if i == selected else choice for i, choice in enumerate(choices)
|
||||
)
|
||||
elif phase == "authorized" and body_lines:
|
||||
styles[-1] = 'class:clarify-selected'
|
||||
body_lines[-1] = "▸ Continue"
|
||||
body_lines[-1] = "▸ " + t("cli.tui.connection_continue")
|
||||
return self._render_sudo_style_panel(lines[0], body_lines, styles)
|
||||
|
||||
# (state attr, deadline attr, hint) for the modal prompts with a countdown hint row.
|
||||
# (state attr, deadline attr, hint catalog key) for the modal prompts with a countdown hint
|
||||
# row. Keys, not text: the label is looked up at render time so a language switch applies.
|
||||
_TUI_MODAL_HINTS = (
|
||||
("_sudo_state", "_sudo_deadline", ' password hidden · Enter to skip'),
|
||||
("_secret_state", "_secret_deadline", ' secret hidden · Enter to skip'),
|
||||
("_approval_state", "_approval_deadline", ' ↑/↓ to select, Enter to confirm'),
|
||||
("_slash_confirm_state", "_slash_confirm_deadline", ' type 1/2/3, or ↑/↓ to select, Enter to confirm'),
|
||||
("_sudo_state", "_sudo_deadline", "cli.tui.hint_sudo"),
|
||||
("_secret_state", "_secret_deadline", "cli.tui.hint_secret"),
|
||||
("_approval_state", "_approval_deadline", "cli.tui.hint_approval"),
|
||||
("_slash_confirm_state", "_slash_confirm_deadline", "cli.tui.hint_slash_confirm"),
|
||||
)
|
||||
# Connection-setup phase (protocol id) → hint catalog key.
|
||||
_TUI_CONNECTION_HINT_KEYS = {
|
||||
"form": "cli.tui.hint_connection_form",
|
||||
"failed": "cli.tui.hint_connection_form",
|
||||
"url": "cli.tui.hint_connection_url",
|
||||
"authorized": "cli.tui.hint_connection_authorized",
|
||||
"waiting": "cli.tui.hint_connection_waiting",
|
||||
}
|
||||
|
||||
def _tui_hint_text(self):
|
||||
if self._connection_state:
|
||||
phase = self._connection_state.get("phase")
|
||||
hints = {
|
||||
"form": " type the value, Enter for next field · ↑/↓ move · ESC cancel",
|
||||
"failed": " type the value, Enter for next field · ↑/↓ move · ESC cancel",
|
||||
"url": " Enter to open in browser · ESC cancel",
|
||||
"authorized": " ↑/↓ select, Enter to confirm",
|
||||
"waiting": " waiting for the backend… · Ctrl+C interrupt",
|
||||
}
|
||||
hint = hints.get(phase, " connection setup")
|
||||
hint = " " + t(self._TUI_CONNECTION_HINT_KEYS.get(phase, "cli.tui.hint_connection_default"))
|
||||
deadline = float(self._connection_state.get("payload", {}).get("deadline_at") or 0)
|
||||
countdown = f" ({max(0, int(deadline - time.time()))}s)" if deadline else ""
|
||||
return [('class:hint', hint), ('class:clarify-countdown', countdown)]
|
||||
for state_attr, deadline_attr, hint in self._TUI_MODAL_HINTS:
|
||||
for state_attr, deadline_attr, hint_key in self._TUI_MODAL_HINTS:
|
||||
if getattr(self, state_attr):
|
||||
if state_attr == "_sudo_state" and ((self._sudo_state.get("vault_save") or {}).get("step") == "identifier"
|
||||
or self._sudo_state.get("vault_code")):
|
||||
hint = ' shown as you type · Enter to continue'
|
||||
hint_key = "cli.tui.hint_vault_username"
|
||||
hint = " " + t(hint_key)
|
||||
remaining = max(0, int(getattr(self, deadline_attr) - time.monotonic()))
|
||||
return [('class:hint', hint), ('class:clarify-countdown', f' ({remaining}s)')]
|
||||
if self._clarify_state:
|
||||
@@ -836,48 +855,48 @@ class CLITuiMixin:
|
||||
else:
|
||||
countdown = f' ({max(0, int(self._clarify_deadline - time.monotonic()))}s)'
|
||||
if self._clarify_freetext:
|
||||
hint = ' type your answer and press Enter'
|
||||
hint = " " + t("cli.tui.hint_clarify_freetext")
|
||||
elif self._clarify_state.get("questions"):
|
||||
hint = ' ↑/↓ to select, Enter to lock, Tab next question'
|
||||
hint = " " + t("cli.tui.hint_clarify_batch")
|
||||
else:
|
||||
hint = ' ↑/↓ to select, Enter to confirm'
|
||||
hint = " " + t("cli.tui.hint_clarify_choice")
|
||||
return [('class:hint', hint), ('class:clarify-countdown', countdown)]
|
||||
if self._command_running:
|
||||
frame = self._command_spinner_frame()
|
||||
if self._command_blocks_input:
|
||||
detail = "input temporarily disabled"
|
||||
detail = t("cli.tui.hint_input_disabled")
|
||||
else:
|
||||
detail = "input stays active; Enter queues"
|
||||
return [('class:hint', f' {frame} command in progress · {detail}')]
|
||||
detail = t("cli.tui.hint_input_queues")
|
||||
return [('class:hint', " " + t("cli.tui.hint_command_running", frame=frame, detail=detail))]
|
||||
return []
|
||||
|
||||
def _tui_placeholder_text(self):
|
||||
if self._voice_recording:
|
||||
return f"recording... {self._voice_record_key_label()} to stop, Ctrl+C to cancel"
|
||||
return t("cli.tui.placeholder_recording", shortcut=self._voice_record_key_label())
|
||||
if self._voice_processing:
|
||||
return "transcribing..."
|
||||
return t("cli.tui.placeholder_transcribing")
|
||||
if self._sudo_state:
|
||||
if (self._sudo_state.get("vault_save") or {}).get("step") == "identifier":
|
||||
return "type your email / username, Enter to continue · ESC to skip"
|
||||
return t("cli.tui.placeholder_vault_username")
|
||||
if self._sudo_state.get("vault_code"):
|
||||
return "type the code, Enter to submit · ESC to skip"
|
||||
return "type password (hidden), Enter to submit · ESC to skip"
|
||||
return t("cli.tui.placeholder_vault_code")
|
||||
return t("cli.tui.placeholder_password")
|
||||
if self._secret_state:
|
||||
return "type secret (hidden), Enter to submit · ESC to skip"
|
||||
return t("cli.tui.placeholder_secret")
|
||||
if self._approval_state:
|
||||
return ""
|
||||
if self._slash_confirm_state:
|
||||
return "type 1/2/3, or use ↑/↓ then Enter"
|
||||
return t("cli.tui.placeholder_choice")
|
||||
if self._clarify_freetext:
|
||||
return "type your answer here and press Enter"
|
||||
return t("cli.tui.placeholder_answer")
|
||||
if self._clarify_state:
|
||||
return ""
|
||||
if self._command_running:
|
||||
return f"{self._command_spinner_frame()} {self._command_status or 'Processing command...'}"
|
||||
return f"{self._command_spinner_frame()} {self._command_status or t('cli.tui.placeholder_processing_command')}"
|
||||
if self._agent_running:
|
||||
return "msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel"
|
||||
return t("cli.tui.placeholder_busy")
|
||||
if self._voice_mode:
|
||||
return f"type or {self._voice_record_key_label()} to record"
|
||||
return t("cli.tui.placeholder_voice_idle", shortcut=self._voice_record_key_label())
|
||||
# Advertise a parked draft so the stash can never be silently forgotten.
|
||||
try:
|
||||
_stash_hint = self._prompt_stash.placeholder_hint()
|
||||
@@ -949,7 +968,7 @@ class CLITuiMixin:
|
||||
if hasattr(self, '_app') and self._app:
|
||||
self._app.invalidate()
|
||||
except Exception as e:
|
||||
_cprint(f"\n{_DIM}Voice recording failed: {e}{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.tui.voice_recording_failed', error=e)}{_RST}")
|
||||
|
||||
threading.Thread(target=_start_recording, daemon=True).start()
|
||||
event.app.invalidate()
|
||||
@@ -965,7 +984,7 @@ class CLITuiMixin:
|
||||
self._voice_continuous = False
|
||||
if _recorder_ref is None:
|
||||
return False
|
||||
_cprint(f"\n{_DIM}Recording cancelled.{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.tui.recording_cancelled')}{_RST}")
|
||||
# cancel() may block on AudioRecorder._lock / CoreAudio — keep it off the event loop.
|
||||
threading.Thread(target=_recorder_ref.cancel, daemon=True).start()
|
||||
event.app.invalidate()
|
||||
@@ -1023,12 +1042,12 @@ class CLITuiMixin:
|
||||
return
|
||||
if self._agent_running and self.agent:
|
||||
if now - self._last_ctrl_c_time < 2.0:
|
||||
print("\n⚡ Force exiting...")
|
||||
print("\n" + t("cli.tui.force_exiting"))
|
||||
self._should_exit = True
|
||||
event.app.exit()
|
||||
return
|
||||
self._last_ctrl_c_time = now
|
||||
print("\n⚡ Interrupting agent... (press Ctrl+C again to force exit)")
|
||||
print("\n" + t("cli.tui.interrupting_again_hint"))
|
||||
request_hard_interrupt(self.agent)
|
||||
else:
|
||||
self._tui_clear_or_exit(event)
|
||||
@@ -1045,7 +1064,7 @@ class CLITuiMixin:
|
||||
if overlay_cleared and not (self._agent_running and self.agent):
|
||||
return
|
||||
if self._agent_running and self.agent:
|
||||
print("\n⚡ Interrupting agent...")
|
||||
print("\n" + t("cli.tui.interrupting"))
|
||||
request_hard_interrupt(self.agent)
|
||||
else:
|
||||
self._tui_clear_or_exit(event)
|
||||
@@ -1186,14 +1205,14 @@ class CLITuiMixin:
|
||||
"""Ctrl+Z suspends the process (Unix only)."""
|
||||
from cli import _DIM, _RST, _cprint
|
||||
if sys.platform == 'win32':
|
||||
_cprint(f"\n{_DIM}Suspend (Ctrl+Z) is not supported on Windows.{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.tui.suspend_unsupported')}{_RST}")
|
||||
event.app.invalidate()
|
||||
return
|
||||
import signal as _sig
|
||||
from prompt_toolkit.application import run_in_terminal
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
agent_name = get_active_skin().get_branding("agent_name", "Hermes Agent")
|
||||
msg = f"\n{agent_name} has been suspended. Run `fg` to bring {agent_name} back."
|
||||
msg = "\n" + t("cli.tui.suspended", agent_name=agent_name)
|
||||
|
||||
def _suspend():
|
||||
os.write(1, msg.encode())
|
||||
@@ -1582,17 +1601,18 @@ class CLITuiMixin:
|
||||
if self.agent is not None and hasattr(self.agent, "steer"):
|
||||
accepted = bool(self.agent.steer(text))
|
||||
except Exception as exc:
|
||||
_cprint(f" {_DIM}Steer failed ({exc}) — queued for next turn.{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.tui.steer_failed_queued', error=exc)}{_RST}")
|
||||
accepted = False
|
||||
if accepted:
|
||||
preview = text[:80] + ("..." if len(text) > 80 else "")
|
||||
_cprint(f" {_ACCENT}⏩ Steered: '{preview}'{_RST}")
|
||||
_cprint(f" {_ACCENT}{t('cli.tui.steered', preview=preview)}{_RST}")
|
||||
else:
|
||||
_effective_mode = "queue"
|
||||
if _effective_mode == "queue":
|
||||
self._pending_input.put(payload)
|
||||
preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]"
|
||||
_cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}")
|
||||
preview = text if text else _tn("cli.tui.images_attached_preview", len(images))
|
||||
preview = preview[:80] + ('...' if len(preview) > 80 else '')
|
||||
_cprint(" " + t("cli.tui.queued_next_turn", preview=preview))
|
||||
elif _effective_mode == "interrupt":
|
||||
if not images and text:
|
||||
try:
|
||||
@@ -1605,7 +1625,7 @@ class CLITuiMixin:
|
||||
redirected = False
|
||||
if redirected:
|
||||
preview = text[:80] + ("..." if len(text) > 80 else "")
|
||||
_cprint(f" {_ACCENT}↪ Redirected current turn: '{preview}'{_RST}")
|
||||
_cprint(f" {_ACCENT}{t('cli.tui.redirected_turn', preview=preview)}{_RST}")
|
||||
else:
|
||||
self._interrupt_queue.put(payload)
|
||||
try:
|
||||
@@ -1678,7 +1698,7 @@ class CLITuiMixin:
|
||||
from hermes_cli.model_switch import resolve_persist_behavior
|
||||
self._handle_model_picker_selection(persist_global=resolve_persist_behavior(False, False))
|
||||
except Exception as _exc:
|
||||
_cprint(f" ✗ Model selection failed: {_exc}")
|
||||
_cprint(" " + t("cli.tui.model_selection_failed", error=_exc))
|
||||
self._close_model_picker()
|
||||
buf.reset()
|
||||
event.app.invalidate()
|
||||
|
||||
@@ -13,6 +13,7 @@ import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from agent.i18n import t
|
||||
from agent.interrupt_compat import request_hard_interrupt
|
||||
from agent.pet import render as pet_render
|
||||
from contextlib import suppress
|
||||
@@ -90,9 +91,9 @@ class CLITuiRuntimeMixin:
|
||||
if _file_drop["is_image"]:
|
||||
submit_images.append(_drop_path)
|
||||
user_input = _remainder or f"[User attached image: {_drop_path.name}]"
|
||||
_cprint(f" 📎 Auto-attached image: {_drop_path.name}")
|
||||
_cprint(" " + t("cli.tui.auto_attached_image", name=_drop_path.name))
|
||||
else:
|
||||
_cprint(f" 📄 Detected file: {_drop_path.name}")
|
||||
_cprint(" " + t("cli.tui.detected_file", name=_drop_path.name))
|
||||
user_input = f"[User attached file: {_drop_path}]" + (f"\n{_remainder}" if _remainder else "")
|
||||
elif isinstance(user_input, str):
|
||||
# A bare number right after a bare `/resume` selects that session (never sent to the agent).
|
||||
@@ -113,7 +114,8 @@ class CLITuiRuntimeMixin:
|
||||
|
||||
if submit_images:
|
||||
n = len(submit_images)
|
||||
_cprint(f" {_DIM}📎 {n} image{'s' if n > 1 else ''} attached{_RST}")
|
||||
_attached = t("cli.tui.images_attached_one" if n == 1 else "cli.tui.images_attached_other", count=n)
|
||||
_cprint(f" {_DIM}{_attached}{_RST}")
|
||||
|
||||
self._agent_running = self._interactive_turn = True
|
||||
self._pet_turn_error = self._pet_reasoning = False
|
||||
@@ -135,7 +137,7 @@ class CLITuiRuntimeMixin:
|
||||
self._app.exit()
|
||||
except KeyboardInterrupt:
|
||||
# Ctrl+C during a slow slash command returns to the prompt instead of exiting.
|
||||
_cprint("\n[dim]Command interrupted.[/dim]")
|
||||
_cprint(f"\n[dim]{t('cli.tui.command_interrupted')}[/dim]")
|
||||
return None
|
||||
_seed, self._pending_agent_seed = self._pending_agent_seed, None
|
||||
return _seed or None
|
||||
@@ -186,7 +188,7 @@ class CLITuiRuntimeMixin:
|
||||
self._voice_start_recording()
|
||||
self._app.invalidate()
|
||||
except Exception as e:
|
||||
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.autorestart_failed', error=e)}{_RST}")
|
||||
threading.Thread(target=_restart_recording, daemon=True).start()
|
||||
|
||||
with suppress(Exception):
|
||||
@@ -263,7 +265,7 @@ class CLITuiRuntimeMixin:
|
||||
self._display_resumed_history()
|
||||
|
||||
_welcome_skin = None # stays None when the skin engine failed
|
||||
_welcome_text = "Welcome to Hermes Agent! Type your message or /help for commands."
|
||||
_welcome_text = t("cli.tui.welcome")
|
||||
_welcome_color = "#FFF8DC"
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
@@ -281,7 +283,8 @@ class CLITuiRuntimeMixin:
|
||||
# Before the background preload is folded in (at agent init), show the REQUESTED names.
|
||||
_skills_for_line = self.preloaded_skills or list(self._preload_skills_requested or [])
|
||||
if _skills_for_line and not self._startup_skills_line_shown:
|
||||
self._console_print(f"[bold {_accent_hex()}]Activated skills:[/] {', '.join(_skills_for_line)}")
|
||||
self._console_print(
|
||||
f"[bold {_accent_hex()}]{t('cli.tui.activated_skills_label')}[/] {', '.join(_skills_for_line)}")
|
||||
self._startup_skills_line_shown = True
|
||||
self._console_print()
|
||||
|
||||
@@ -313,12 +316,9 @@ class CLITuiRuntimeMixin:
|
||||
_redact_raw = os.getenv("HERMES_REDACT_SECRETS", "true")
|
||||
if _redact_raw.lower() not in {"1", "true", "yes", "on"}:
|
||||
self._console_print(
|
||||
"[bold red]⚠ Secret redaction is DISABLED[/] "
|
||||
f"(HERMES_REDACT_SECRETS={_redact_raw}). "
|
||||
"API keys and tokens may appear verbatim in chat output, "
|
||||
"session JSONs, and logs. Set "
|
||||
"[cyan]security.redact_secrets: true[/] in config.yaml "
|
||||
"to re-enable."
|
||||
f"[bold red]{t('cli.tui.redaction_disabled_title')}[/] "
|
||||
+ t("cli.tui.redaction_disabled_body", value=_escape(_redact_raw),
|
||||
setting="[cyan]security.redact_secrets: true[/]")
|
||||
)
|
||||
# One-time banner when ~/.openclaw/ is left over from a migration.
|
||||
try:
|
||||
@@ -428,11 +428,7 @@ class CLITuiRuntimeMixin:
|
||||
try:
|
||||
os.fstat(0)
|
||||
except OSError:
|
||||
print(
|
||||
"Error: stdin (fd 0) is not available.\n"
|
||||
"This can happen with certain Python installations (e.g. uv-managed cPython on macOS).\n"
|
||||
"Try reinstalling Python via pyenv or Homebrew, then re-run: hermes setup"
|
||||
)
|
||||
print(t("cli.tui.stdin_unavailable"))
|
||||
return False
|
||||
if sys.platform == "darwin":
|
||||
import selectors as _selectors
|
||||
@@ -461,7 +457,7 @@ class CLITuiRuntimeMixin:
|
||||
self._pet_stop_anim()
|
||||
# Without this line the terminal sits silent through the whole cleanup window.
|
||||
with suppress(Exception):
|
||||
print(f"{_DIM}Shutting down… (finalizing session){_RST}", flush=True)
|
||||
print(f"{_DIM}{t('cli.tui.shutting_down')}{_RST}", flush=True)
|
||||
if self.agent and self._agent_running:
|
||||
with suppress(Exception):
|
||||
request_hard_interrupt(self.agent)
|
||||
@@ -497,9 +493,9 @@ class CLITuiRuntimeMixin:
|
||||
try:
|
||||
_sid = self.agent.session_id
|
||||
if self._session_db.delete_session(_sid, sessions_dir=get_hermes_home() / "sessions"):
|
||||
_cprint(f" {_DIM}✓ Session {_escape(_sid)} deleted{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.tui.session_deleted', session_id=_escape(_sid))}{_RST}")
|
||||
else:
|
||||
_cprint(f" {_DIM}✗ Session {_escape(_sid)} not found for deletion{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.tui.session_delete_missing', session_id=_escape(_sid))}{_RST}")
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Could not delete session on exit: %s", e)
|
||||
# run_conversation() fires on_session_end on normal completion; only fire here mid-turn.
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import difflib
|
||||
from collections.abc import Iterable
|
||||
|
||||
from agent.i18n import t
|
||||
|
||||
|
||||
def unknown_command_lines(typed: str, known: Iterable[str]) -> tuple[str, str]:
|
||||
"""(lead line, pointer line) for a slash token with no handler.
|
||||
@@ -15,5 +17,5 @@ def unknown_command_lines(typed: str, known: Iterable[str]) -> tuple[str, str]:
|
||||
"""
|
||||
base = typed.split()[0] if typed.split() else typed
|
||||
close = difflib.get_close_matches(base, list(known), n=1, cutoff=0.6)
|
||||
hint = f" Did you mean {close[0]}?" if close else ""
|
||||
return (f"Unknown command {base} — nothing was sent.{hint}", "Type /help for the full list.")
|
||||
hint = " " + t("cli.command.did_you_mean_one", suggestion=close[0]) if close else ""
|
||||
return (t("cli.command.unknown_nothing_sent", command=base, hint=hint), t("cli.command.type_help"))
|
||||
@@ -14,6 +14,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
from agent.i18n import t
|
||||
from hermes_constants import is_termux as _is_termux_environment
|
||||
from typing import Optional
|
||||
|
||||
@@ -68,23 +69,15 @@ class CLIVoiceMixin:
|
||||
reqs = check_voice_requirements()
|
||||
if not reqs["audio_available"]:
|
||||
if _is_termux_environment():
|
||||
# These RuntimeErrors are human copy: callers print ``{e}`` straight to the terminal.
|
||||
if "Termux:API Android app is not installed" in reqs.get("details", ""):
|
||||
raise RuntimeError(
|
||||
"Termux:API command package detected, but the Android app is missing.\n"
|
||||
"Install/update the Termux:API Android app, then retry /voice on."
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Voice mode needs microphone access.\n"
|
||||
"Run pkg install termux-api and install the Termux:API Android app, "
|
||||
"then retry /voice on."
|
||||
)
|
||||
raise RuntimeError(t("cli.voice.err_termux_app_missing"))
|
||||
raise RuntimeError(t("cli.voice.err_mic_access"))
|
||||
# check_voice_requirements already asked PM to enable audio-io; its detail line
|
||||
# says why that did not happen (lazy installs off, needs restart, platform gate).
|
||||
raise RuntimeError("Voice mode requires audio capture.\n" + reqs.get("details", ""))
|
||||
raise RuntimeError(t("cli.voice.err_audio_capture") + "\n" + reqs.get("details", ""))
|
||||
if not reqs.get("stt_available", reqs.get("stt_key_set")):
|
||||
raise RuntimeError(
|
||||
"Voice mode requires an STT provider for transcription.\n"
|
||||
"Run hermes tools and configure Speech-to-Text, then restart Hermes.")
|
||||
raise RuntimeError(t("cli.voice.err_stt_provider"))
|
||||
|
||||
# Prevent double-start from concurrent threads (atomic check-and-set)
|
||||
with self._voice_lock:
|
||||
@@ -117,7 +110,7 @@ class CLIVoiceMixin:
|
||||
with self._voice_lock:
|
||||
if not self._voice_recording:
|
||||
return
|
||||
_cprint(f"\n{_DIM}Silence detected, auto-stopping...{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.voice.silence_autostop')}{_RST}")
|
||||
self._voice_invalidate()
|
||||
self._voice_stop_and_transcribe()
|
||||
|
||||
@@ -132,12 +125,12 @@ class CLIVoiceMixin:
|
||||
raise
|
||||
_label = self._voice_record_key_label()
|
||||
if getattr(self._voice_recorder, "supports_silence_autostop", True):
|
||||
_recording_hint = f"auto-stops on silence | {_label} to stop & exit continuous"
|
||||
_recording_hint = t("cli.voice.hint_continuous", shortcut=_label)
|
||||
elif _is_termux_environment():
|
||||
_recording_hint = f"Termux:API capture | {_label} to stop"
|
||||
_recording_hint = t("cli.voice.hint_termux", shortcut=_label)
|
||||
else:
|
||||
_recording_hint = f"{_label} to stop"
|
||||
_cprint(f"\n{_ACCENT}● Recording...{_RST} {_DIM}({_recording_hint}){_RST}")
|
||||
_recording_hint = t("cli.voice.hint_stop", shortcut=_label)
|
||||
_cprint(f"\n{_ACCENT}{t('cli.voice.recording')}{_RST} {_DIM}({_recording_hint}){_RST}")
|
||||
|
||||
# Periodically refresh prompt to update audio level indicator
|
||||
def _refresh_level():
|
||||
@@ -185,7 +178,7 @@ class CLIVoiceMixin:
|
||||
self._voice_start_recording()
|
||||
self._voice_invalidate()
|
||||
except Exception as e:
|
||||
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.autorestart_failed', error=e)}{_RST}")
|
||||
threading.Thread(target=_restart_recording, daemon=True).start()
|
||||
|
||||
def _voice_stop_and_transcribe(self):
|
||||
@@ -209,16 +202,14 @@ class CLIVoiceMixin:
|
||||
# Audio cue: double beep after stream stopped (no CoreAudio conflict)
|
||||
self._voice_beep(frequency=660, count=2)
|
||||
if wav_path is None:
|
||||
_cprint(f"{_DIM}No speech detected.{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.no_speech')}{_RST}")
|
||||
return
|
||||
self._voice_invalidate()
|
||||
stt_model = self._voice_stt_model()
|
||||
if self._voice_stt_provider() == "local":
|
||||
_cprint(
|
||||
f"{_DIM}Preparing local STT model '{stt_model}' "
|
||||
f"(first use may download it from Hugging Face)...{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.preparing_stt', model=stt_model)}{_RST}")
|
||||
else:
|
||||
_cprint(f"{_DIM}Transcribing...{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.transcribing')}{_RST}")
|
||||
from tools.voice_mode_transcript import is_voice_stop_phrase
|
||||
from tools.voice_mode import transcribe_recording
|
||||
result = transcribe_recording(wav_path, model=stt_model)
|
||||
@@ -226,7 +217,7 @@ class CLIVoiceMixin:
|
||||
transcript = result["transcript"].strip()
|
||||
if is_voice_stop_phrase(transcript):
|
||||
# Bare "stop" (or configured phrase) ends the voice chat, not a turn.
|
||||
_cprint(f"{_DIM}Stop phrase detected — ending voice chat.{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.stop_phrase')}{_RST}")
|
||||
self._disable_voice_mode()
|
||||
return
|
||||
self._attached_images.clear()
|
||||
@@ -234,12 +225,12 @@ class CLIVoiceMixin:
|
||||
self._pending_input.put(_VoiceInputMessage(transcript))
|
||||
submitted = True
|
||||
elif result.get("success"):
|
||||
_cprint(f"{_DIM}No speech detected.{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.no_speech')}{_RST}")
|
||||
else:
|
||||
_cprint(f"\n{_DIM}Transcription failed: {result.get('error', 'Unknown error')}{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.voice.transcription_failed', error=result.get('error') or t('cli.shared.unknown_error'))}{_RST}")
|
||||
transcription_failed = True
|
||||
except Exception as e:
|
||||
_cprint(f"\n{_DIM}Voice processing error: {e}{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.voice.processing_error', error=e)}{_RST}")
|
||||
transcription_failed = wav_path is not None
|
||||
finally:
|
||||
with self._voice_lock:
|
||||
@@ -249,7 +240,7 @@ class CLIVoiceMixin:
|
||||
try:
|
||||
if wav_path and os.path.isfile(wav_path):
|
||||
if transcription_failed:
|
||||
_cprint(f"{_DIM}Recording preserved at: {wav_path}{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.recording_preserved', path=wav_path)}{_RST}")
|
||||
else:
|
||||
os.unlink(wav_path)
|
||||
except Exception:
|
||||
@@ -271,7 +262,7 @@ class CLIVoiceMixin:
|
||||
if self._no_speech_count >= 3:
|
||||
self._voice_continuous = False
|
||||
self._no_speech_count = 0
|
||||
_cprint(f"{_DIM}No speech detected 3 times, continuous mode stopped.{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.no_speech_stop')}{_RST}")
|
||||
stop_continuous_restart = True
|
||||
# No transcript but continuous mode active: restart so the user can keep talking
|
||||
# (when a transcript IS submitted, process_loop restarts after chat()).
|
||||
@@ -344,7 +335,7 @@ class CLIVoiceMixin:
|
||||
_unlink_quietly(path)
|
||||
except Exception as e:
|
||||
logger.warning("Voice TTS playback failed: %s", e)
|
||||
_cprint(f"{_DIM}TTS playback failed: {e}{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.tts_failed', error=e)}{_RST}")
|
||||
finally:
|
||||
self._voice_tts_done.set()
|
||||
|
||||
@@ -415,7 +406,7 @@ class CLIVoiceMixin:
|
||||
_pipe_stop.set() # never let the stale reply speak
|
||||
try:
|
||||
if self.agent is not None and getattr(self, "_agent_running", False):
|
||||
_cprint(f"\n{_DIM}🎤 Voice interjection — interrupting…{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.voice.interjection')}{_RST}")
|
||||
self.agent.interrupt()
|
||||
except Exception as e:
|
||||
logger.debug("voice interjection interrupt failed: %s", e)
|
||||
@@ -444,7 +435,7 @@ class CLIVoiceMixin:
|
||||
if transcript:
|
||||
from tools.voice_mode_transcript import is_voice_stop_phrase
|
||||
if is_voice_stop_phrase(transcript):
|
||||
_cprint(f"\n{_DIM}Stop phrase detected — ending voice chat.{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.voice.stop_phrase')}{_RST}")
|
||||
self._disable_voice_mode()
|
||||
return
|
||||
# Fail-closed echo guard: playback-phase capture has no echo cancellation, so
|
||||
@@ -454,14 +445,14 @@ class CLIVoiceMixin:
|
||||
if is_tts_echo(transcript, getattr(self, "_voice_last_tts_text", "")):
|
||||
logger.debug(
|
||||
"Dropping playback-phase barge transcript as TTS echo: %r", transcript)
|
||||
_cprint(f"\n{_DIM}Ignored likely TTS echo (not queued).{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.voice.echo_ignored')}{_RST}")
|
||||
return
|
||||
self._pending_input.put(_VoiceInputMessage(transcript))
|
||||
submitted = True
|
||||
elif not result.get("success"):
|
||||
_cprint(f"\n{_DIM}Transcription failed: {result.get('error', 'Unknown error')}{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.voice.transcription_failed', error=result.get('error') or t('cli.shared.unknown_error'))}{_RST}")
|
||||
except Exception as e:
|
||||
_cprint(f"\n{_DIM}Voice processing error: {e}{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.voice.processing_error', error=e)}{_RST}")
|
||||
finally:
|
||||
_unlink_quietly(wav_path)
|
||||
self._voice_barge_capture.clear()
|
||||
@@ -482,26 +473,26 @@ class CLIVoiceMixin:
|
||||
"""Enable voice mode after checking requirements."""
|
||||
from cli import _ACCENT, _BOLD, _DIM, _RST, _cprint
|
||||
if self._voice_mode:
|
||||
_cprint(f"{_DIM}Voice mode is already enabled.{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.already_enabled')}{_RST}")
|
||||
return
|
||||
|
||||
from tools.voice_mode import check_voice_requirements, detect_audio_environment
|
||||
env_check = detect_audio_environment()
|
||||
if not env_check["available"]:
|
||||
_cprint(f"\n{_ACCENT}Voice mode unavailable in this environment:{_RST}")
|
||||
_cprint(f"\n{_ACCENT}{t('cli.voice.unavailable_header')}{_RST}")
|
||||
for warning in env_check["warnings"]:
|
||||
_cprint(f" {_DIM}{warning}{_RST}")
|
||||
return
|
||||
|
||||
reqs = check_voice_requirements()
|
||||
if not reqs["available"]:
|
||||
_cprint(f"\n{_ACCENT}Voice mode requirements not met:{_RST}")
|
||||
_cprint(f"\n{_ACCENT}{t('cli.voice.requirements_header')}{_RST}")
|
||||
for line in reqs["details"].split("\n"):
|
||||
_cprint(f" {_DIM}{line}{_RST}")
|
||||
if reqs["missing_packages"]:
|
||||
if _is_termux_environment():
|
||||
_cprint(f"\n {_BOLD}Run: pkg install termux-api{_RST}")
|
||||
_cprint(f" {_DIM}Then install/update the Termux:API Android app for microphone capture{_RST}")
|
||||
_cprint(f"\n {_BOLD}{t('cli.voice.termux_install_cmd')}{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.voice.termux_install_hint')}{_RST}")
|
||||
return
|
||||
|
||||
with self._voice_lock:
|
||||
@@ -512,14 +503,14 @@ class CLIVoiceMixin:
|
||||
|
||||
# The voice-mode instruction is injected as a user message prefix (not a system
|
||||
# prompt change) to avoid invalidating the prompt cache — see _voice_message_prefix.
|
||||
tts_status = " (TTS enabled)" if self._voice_tts else ""
|
||||
tts_status = t("cli.voice.tts_enabled_suffix") if self._voice_tts else ""
|
||||
if self._voice_tts:
|
||||
self._tts_lease_async(True) # warm the engine so the first reply isn't dead air
|
||||
# Startup-pinned label so the advertised shortcut always matches the live
|
||||
# prompt_toolkit binding (live config would drift after a mid-session edit).
|
||||
# See #19835.
|
||||
_cprint(f"\n{_ACCENT}Voice mode enabled{tts_status}{_RST}")
|
||||
_cprint(f" {_DIM}{self._voice_record_key_label()} to start/stop recording{_RST}")
|
||||
_cprint(f"\n{_ACCENT}{t('cli.voice.enabled', tts_status=tts_status)}{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.voice.record_key_hint', shortcut=self._voice_record_key_label())}{_RST}")
|
||||
# Spoken-stop hint from voice.stop_phrases (first entry); "" when disabled.
|
||||
try:
|
||||
from tools.voice_mode_transcript import voice_stop_hint
|
||||
@@ -528,8 +519,8 @@ class CLIVoiceMixin:
|
||||
_stop_hint = ""
|
||||
if _stop_hint:
|
||||
_cprint(f" {_DIM}{_stop_hint}{_RST}")
|
||||
_cprint(f" {_DIM}/voice tts to toggle speech output{_RST}")
|
||||
_cprint(f" {_DIM}/voice off to disable voice mode{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.voice.tts_toggle_hint')}{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.voice.disable_hint')}{_RST}")
|
||||
|
||||
def _typed_voice_stop(self, user_input) -> bool:
|
||||
"""Typed bare stop phrase during an active voice chat ends the chat (mirrors the spoken
|
||||
@@ -552,7 +543,7 @@ class CLIVoiceMixin:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
_cprint(f"\n{_DIM}Stop phrase typed — ending voice chat.{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.voice.stop_phrase_typed')}{_RST}")
|
||||
self._disable_voice_mode()
|
||||
return True
|
||||
|
||||
@@ -589,7 +580,7 @@ class CLIVoiceMixin:
|
||||
except Exception:
|
||||
pass
|
||||
self._voice_tts_done.set()
|
||||
_cprint(f"\n{_DIM}Voice mode disabled.{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.voice.disabled')}{_RST}")
|
||||
|
||||
def _maybe_start_wake_word(self):
|
||||
"""Start the wake-word listener at CLI startup if this surface is eligible."""
|
||||
@@ -609,31 +600,31 @@ class CLIVoiceMixin:
|
||||
from tools.wake_word import (
|
||||
check_wake_word_requirements, load_wake_word_config, owns_listener, start_listening)
|
||||
except Exception as e:
|
||||
say(f"{_DIM}Wake word unavailable: {e}{_RST}")
|
||||
say(f"{_DIM}{t('cli.voice.wake_unavailable', error=e)}{_RST}")
|
||||
return False
|
||||
|
||||
if getattr(self, "_wake_word_active", False) and owns_listener(self):
|
||||
say(f"{_DIM}Wake word is already listening.{_RST}")
|
||||
say(f"{_DIM}{t('cli.voice.wake_already')}{_RST}")
|
||||
return True
|
||||
self._wake_word_active = False
|
||||
|
||||
cfg = load_wake_word_config()
|
||||
reqs = check_wake_word_requirements(cfg)
|
||||
if not reqs["available"]:
|
||||
say(f"\n{_ACCENT}Wake word requirements not met:{_RST}")
|
||||
say(f"\n{_ACCENT}{t('cli.voice.wake_requirements_header')}{_RST}")
|
||||
if reqs.get("hint"):
|
||||
say(f" {_DIM}{reqs['hint']}{_RST}")
|
||||
return False
|
||||
if not reqs.get("deps_available", True):
|
||||
# Fresh install: the engine constructor lazy-installs its deps (onnxruntime is
|
||||
# a large wheel) — tell the user why this is slow.
|
||||
say(f"{_DIM}Installing wake word engine (first use — this may take a minute)...{_RST}")
|
||||
say(f"{_DIM}{t('cli.voice.wake_installing')}{_RST}")
|
||||
|
||||
self._wake_start_new_session = bool(cfg.get("start_new_session", True))
|
||||
try:
|
||||
start_listening(self._on_wake_word, owner=self, config=cfg)
|
||||
except Exception as e:
|
||||
say(f"\n{_DIM}Failed to start wake word: {e}{_RST}")
|
||||
say(f"\n{_DIM}{t('cli.voice.wake_start_failed', error=e)}{_RST}")
|
||||
return False
|
||||
|
||||
self._wake_word_active = True
|
||||
@@ -641,8 +632,8 @@ class CLIVoiceMixin:
|
||||
import cli as _cli
|
||||
_cli._cli_wake_owner = self
|
||||
self._start_wake_watchdog()
|
||||
say(f"\n{_ACCENT}Wake word listening{_RST} "
|
||||
f"{_DIM}(say \"{reqs['phrase']}\" — /wake off to stop){_RST}")
|
||||
say(f"\n{_ACCENT}{t('cli.voice.wake_listening')}{_RST} "
|
||||
f"{_DIM}{t('cli.voice.wake_listening_hint', phrase=reqs['phrase'])}{_RST}")
|
||||
return True
|
||||
|
||||
def _stop_wake_word_listener(self, announce: bool = False):
|
||||
@@ -660,7 +651,7 @@ class CLIVoiceMixin:
|
||||
if _cli._cli_wake_owner is self:
|
||||
_cli._cli_wake_owner = None
|
||||
if announce:
|
||||
_cprint(f"{_DIM}Wake word {'stopped' if was_active else 'is not running'}.{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.wake_stopped' if was_active else 'cli.voice.wake_not_running')}{_RST}")
|
||||
|
||||
def _on_wake_word(self):
|
||||
"""Fired after the detector hears the wake phrase."""
|
||||
@@ -692,12 +683,11 @@ class CLIVoiceMixin:
|
||||
if _match and _match[1]:
|
||||
from tools.wake_word import _active_profile_name
|
||||
if _match[1] != _active_profile_name():
|
||||
_cprint(f"\n{_DIM}Wake phrase for profile '{_match[1]}' — "
|
||||
f"run: hermes -p {_match[1]}{_RST}")
|
||||
_cprint(f"\n{_DIM}{t('cli.voice.wake_profile_hint', profile=_match[1])}{_RST}")
|
||||
self._wake_suspended = True # watchdog resumes the listener
|
||||
return
|
||||
|
||||
_cprint(f"\n{_ACCENT}✦ Wake word detected — listening...{_RST}")
|
||||
_cprint(f"\n{_ACCENT}{t('cli.voice.wake_detected')}{_RST}")
|
||||
if getattr(self, "_app", None):
|
||||
try:
|
||||
self._app.invalidate()
|
||||
@@ -717,7 +707,7 @@ class CLIVoiceMixin:
|
||||
try:
|
||||
self._voice_start_recording()
|
||||
except Exception as e:
|
||||
_cprint(f"{_DIM}Wake capture failed: {e}{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.wake_capture_failed', error=e)}{_RST}")
|
||||
|
||||
def _start_wake_watchdog(self):
|
||||
"""Resume the paused detector when the CLI returns to a stable idle."""
|
||||
@@ -770,21 +760,22 @@ class CLIVoiceMixin:
|
||||
cfg = load_wake_word_config()
|
||||
reqs = check_wake_word_requirements(cfg)
|
||||
owned = owns_listener(self)
|
||||
state = "LISTENING" if owned and is_listening() else "PAUSED" if owned else "OFF"
|
||||
_cprint(f"\n{_BOLD}Wake Word Status{_RST}")
|
||||
_cprint(f" State: {state}")
|
||||
_cprint(f" Phrase: \"{reqs['phrase']}\"")
|
||||
_cprint(f" Provider: {reqs['provider']}")
|
||||
_cprint(f" Surface: {cfg.get('surface', 'auto')}")
|
||||
_cprint(f" New session: {'yes' if cfg.get('start_new_session', True) else 'no'}")
|
||||
if state == "LISTENING" and audio_is_silent():
|
||||
_cprint(f" {_ACCENT}⚠ Microphone delivers only silence — the listener can't hear anything.{_RST}")
|
||||
_cprint(f" {_DIM}On macOS: System Settings > Privacy & Security > Microphone — allow your"
|
||||
f" terminal/Hermes, then /wake off + /wake on.{_RST}")
|
||||
# ``state`` is the internal id; only its display label is localized.
|
||||
state = "listening" if owned and is_listening() else "paused" if owned else "off"
|
||||
_cprint(f"\n{_BOLD}{t('cli.voice.wake_status_title')}{_RST}")
|
||||
_cprint(t("cli.voice.label_state", state=t(f"cli.voice.state_{state}")))
|
||||
_cprint(t("cli.voice.label_phrase", phrase=reqs['phrase']))
|
||||
_cprint(t("cli.voice.label_provider", provider=reqs['provider']))
|
||||
_cprint(t("cli.voice.label_surface", surface=cfg.get('surface', 'auto')))
|
||||
_cprint(t("cli.voice.label_new_session",
|
||||
value=t("cli.shared.label_yes" if cfg.get('start_new_session', True) else "cli.shared.label_no")))
|
||||
if state == "listening" and audio_is_silent():
|
||||
_cprint(f" {_ACCENT}{t('cli.voice.mic_silent_warning')}{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.voice.mic_silent_macos_hint')}{_RST}")
|
||||
if not reqs["available"] and reqs.get("hint"):
|
||||
_cprint(f" {_DIM}{reqs['hint']}{_RST}")
|
||||
if not owned:
|
||||
_cprint(f" {_DIM}Enable with /wake on{_RST}")
|
||||
_cprint(f" {_DIM}{t('cli.voice.wake_enable_hint')}{_RST}")
|
||||
|
||||
def _tts_lease_async(self, active: bool) -> None:
|
||||
"""Acquire/release this CLI's TTS engine lease in the background.
|
||||
@@ -810,18 +801,18 @@ class CLIVoiceMixin:
|
||||
"""Toggle TTS output for voice mode."""
|
||||
from cli import _ACCENT, _DIM, _RST, _cprint
|
||||
if not self._voice_mode:
|
||||
_cprint(f"{_DIM}Enable voice mode first: /voice on{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.enable_first_hint')}{_RST}")
|
||||
return
|
||||
|
||||
with self._voice_lock:
|
||||
self._voice_tts = not self._voice_tts
|
||||
status = "enabled" if self._voice_tts else "disabled"
|
||||
status = t("cli.shared.label_enabled" if self._voice_tts else "cli.shared.label_disabled")
|
||||
if self._voice_tts:
|
||||
from tools.tts_tool import check_tts_requirements
|
||||
if not check_tts_requirements():
|
||||
_cprint(f"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}")
|
||||
_cprint(f"{_DIM}{t('cli.voice.tts_no_provider')}{_RST}")
|
||||
self._tts_lease_async(self._voice_tts) # warm-up / release signal for the TTS engine
|
||||
_cprint(f"{_ACCENT}Voice TTS {status}.{_RST}")
|
||||
_cprint(f"{_ACCENT}{t('cli.voice.tts_status', status=status)}{_RST}")
|
||||
|
||||
def _show_voice_status(self):
|
||||
"""Show current voice mode status."""
|
||||
@@ -829,14 +820,16 @@ class CLIVoiceMixin:
|
||||
from tools.voice_mode import check_voice_requirements
|
||||
|
||||
reqs = check_voice_requirements()
|
||||
_cprint(f"\n{_BOLD}Voice Mode Status{_RST}")
|
||||
_cprint(f" Mode: {'ON' if self._voice_mode else 'OFF'}")
|
||||
_cprint(f" TTS: {'ON' if self._voice_tts else 'OFF'}")
|
||||
_cprint(f" Recording: {'YES' if self._voice_recording else 'no'}")
|
||||
_on, _off = t("cli.voice.state_on"), t("cli.voice.state_off")
|
||||
_cprint(f"\n{_BOLD}{t('cli.voice.status_title')}{_RST}")
|
||||
_cprint(t("cli.voice.label_mode", value=_on if self._voice_mode else _off))
|
||||
_cprint(t("cli.voice.label_tts", value=_on if self._voice_tts else _off))
|
||||
_cprint(t("cli.voice.label_recording",
|
||||
value=t("cli.voice.state_yes") if self._voice_recording else t("cli.shared.label_no")))
|
||||
# Startup-pinned label so /voice status always matches the live prompt_toolkit
|
||||
# binding (live config would drift after a mid-session config edit).
|
||||
# See #19835.
|
||||
_cprint(f" Record key: {self._voice_record_key_label()}")
|
||||
_cprint(f"\n {_BOLD}Requirements:{_RST}")
|
||||
_cprint(t("cli.voice.label_record_key", shortcut=self._voice_record_key_label()))
|
||||
_cprint(f"\n {_BOLD}{t('cli.voice.label_requirements')}{_RST}")
|
||||
for line in reqs["details"].split("\n"):
|
||||
_cprint(f" {line}")
|
||||
+68
-17
@@ -14,11 +14,33 @@ from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from utils import is_truthy_value
|
||||
from agent.i18n import t
|
||||
from hermes_constants import INDICATOR_STYLES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _localized(key: str, default: str) -> str:
|
||||
"""``t(key)`` with *default* when no catalog (not even ``en``) carries the key."""
|
||||
value = t(key)
|
||||
return default if value == key else value
|
||||
|
||||
|
||||
# Category identifiers -> ``slash.category.<slug>`` (identifiers stay English; labels are localized
|
||||
# only at render time via ``category_label``).
|
||||
_CATEGORY_SLUGS: dict[str, str] = {
|
||||
"Session": "session", "Configuration": "configuration", "Info": "info",
|
||||
"Tools & Skills": "tools_skills", "Plugins": "plugins", "Exit": "exit",
|
||||
"Context": "context", "Background & Automation": "background_automation",
|
||||
}
|
||||
|
||||
|
||||
def category_label(category: str) -> str:
|
||||
"""Localized label for a registry category / help sub-group identifier."""
|
||||
slug = _CATEGORY_SLUGS.get(category)
|
||||
return _localized(f"slash.category.{slug}", category) if slug else category
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandDef:
|
||||
"""Definition of a single slash command."""
|
||||
@@ -44,6 +66,10 @@ class CommandDef:
|
||||
# Desktop availability: None = offered; "hidden" = runs but out of the popover; else a reason.
|
||||
desktop: str | None = None
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Localized description (``slash.<name>.description``); ``description`` is the English source."""
|
||||
return _localized(f"slash.{self.name}.description", self.description)
|
||||
|
||||
|
||||
VALID_BUSY_POLICIES: frozenset[str] = frozenset({"dispatch", "reject", "interrupt_then_dispatch"})
|
||||
|
||||
@@ -358,27 +384,45 @@ def resolve_command(name: str) -> CommandDef | None:
|
||||
|
||||
|
||||
def _build_description(cmd: CommandDef) -> str:
|
||||
"""CLI-facing description including the usage hint."""
|
||||
"""CLI-facing localized description including the usage hint."""
|
||||
if not cmd.args_hint:
|
||||
return cmd.description
|
||||
return f"{cmd.description} (usage: /{cmd.name} {cmd.args_hint})"
|
||||
return cmd.describe()
|
||||
return t("slash.shared.usage_suffix", description=cmd.describe(), name=cmd.name, args_hint=cmd.args_hint)
|
||||
|
||||
|
||||
# Flat "/command" -> description, and the same grouped by category; both exclude gateway_only.
|
||||
COMMANDS: dict[str, str] = {}
|
||||
COMMANDS_BY_CATEGORY: dict[str, dict[str, str]] = {}
|
||||
def build_commands_by_category() -> dict[str, dict[str, str]]:
|
||||
"""Localized ``{category: {"/command": description}}`` (excludes gateway_only).
|
||||
|
||||
Built per call so a ``display.language`` change applies without a restart; category keys are
|
||||
the registry identifiers -- render them with ``category_label``.
|
||||
"""
|
||||
grouped: dict[str, dict[str, str]] = {}
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if cmd.gateway_only:
|
||||
continue
|
||||
entries = {f"/{cmd.name}": _build_description(cmd)}
|
||||
for alias in cmd.aliases:
|
||||
entries[f"/{alias}"] = t("slash.shared.alias_for", description=cmd.describe(), name=cmd.name)
|
||||
grouped.setdefault(cmd.category, {}).update(entries)
|
||||
return grouped
|
||||
|
||||
|
||||
def build_commands() -> dict[str, str]:
|
||||
"""Localized flat ``{"/command": description}`` (excludes gateway_only)."""
|
||||
flat: dict[str, str] = {}
|
||||
for entries in build_commands_by_category().values():
|
||||
flat.update(entries)
|
||||
return flat
|
||||
|
||||
|
||||
# ``COMMANDS`` / ``COMMANDS_BY_CATEGORY`` resolve lazily through the module ``__getattr__`` below so
|
||||
# every ``from hermes_cli.commands import COMMANDS`` sees the active language.
|
||||
_LAZY_LOCALIZED_TABLES = {"COMMANDS": build_commands, "COMMANDS_BY_CATEGORY": build_commands_by_category}
|
||||
|
||||
# Subcommands lookup: "/cmd" -> ["sub1", ...]; explicit ``subcommands`` first (in
|
||||
# registry order), then pipe patterns in args_hint ("[on|off|status]") as fallback.
|
||||
SUBCOMMANDS: dict[str, list[str]] = {
|
||||
f"/{_cmd.name}": list(_cmd.subcommands) for _cmd in COMMAND_REGISTRY if _cmd.subcommands}
|
||||
for _cmd in COMMAND_REGISTRY:
|
||||
if _cmd.gateway_only:
|
||||
continue
|
||||
_entries = {f"/{_cmd.name}": _build_description(_cmd)}
|
||||
for _alias in _cmd.aliases:
|
||||
_entries[f"/{_alias}"] = f"{_cmd.description} (alias for /{_cmd.name})"
|
||||
COMMANDS.update(_entries)
|
||||
COMMANDS_BY_CATEGORY.setdefault(_cmd.category, {}).update(_entries)
|
||||
|
||||
_PIPE_SUBS_RE = re.compile(r"[a-z]+(?:\|[a-z]+)+")
|
||||
for _cmd in COMMAND_REGISTRY:
|
||||
@@ -483,8 +527,8 @@ def gateway_help_lines(allowed: Optional[Iterable[str]] = None) -> list[str]:
|
||||
# Skip internal aliases like reload_mcp (underscore variant of the name).
|
||||
alias_parts = [f"`/{a}`" for a in cmd.aliases
|
||||
if not (a.replace("-", "_") == cmd.name.replace("-", "_") and a != cmd.name)]
|
||||
alias_note = f" (alias: {', '.join(alias_parts)})" if alias_parts else ""
|
||||
lines.append(f"`/{cmd.name}{args}` -- {cmd.description}{alias_note}")
|
||||
alias_note = t("slash.shared.alias_note", aliases=", ".join(alias_parts)) if alias_parts else ""
|
||||
lines.append(f"`/{cmd.name}{args}` -- {cmd.describe()}{alias_note}")
|
||||
return lines
|
||||
|
||||
|
||||
@@ -496,6 +540,13 @@ def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
|
||||
commands = get_plugin_commands() or {}
|
||||
except Exception:
|
||||
return []
|
||||
return [(name, str(meta.get("description") or f"Run /{name}"),
|
||||
return [(name, str(meta.get("description") or t("slash.shared.plugin_default_desc", name=name)),
|
||||
str(meta.get("args_hint") or "").strip())
|
||||
for name, meta in commands.items() if isinstance(name, str) and isinstance(meta, dict)]
|
||||
|
||||
|
||||
def __getattr__(name): # PEP 562 — COMMANDS / COMMANDS_BY_CATEGORY are built per call so labels follow the active language
|
||||
localized = _LAZY_LOCALIZED_TABLES.get(name)
|
||||
if localized is None:
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
return localized()
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from agent.i18n import t
|
||||
from hermes_cli.commands import (
|
||||
COMMAND_REGISTRY, _is_gateway_available, _iter_plugin_command_entries, _resolve_config_gates)
|
||||
|
||||
@@ -90,7 +91,7 @@ def telegram_bot_commands(*, include_plugins: bool = True) -> list[tuple[str, st
|
||||
"""(command_name, description) pairs for Telegram setMyCommands: sanitized canonical names
|
||||
only (no aliases). Built-ins needing arguments are included (their handlers show usage when
|
||||
selected bare); plugin commands needing arguments are excluded (may lack a no-arg fallback)."""
|
||||
pairs = [(cmd.name, cmd.description) for cmd in _gateway_available_commands()]
|
||||
pairs = [(cmd.name, cmd.describe()) for cmd in _gateway_available_commands()]
|
||||
if include_plugins:
|
||||
pairs += [(n, d) for n, d, hint in _iter_plugin_command_entries()
|
||||
if not _requires_argument(hint)]
|
||||
@@ -263,7 +264,7 @@ def _collect_gateway_skill_entries(
|
||||
meta = plugin_cmds[cmd_name]
|
||||
if platform == "telegram" and _requires_argument(str(meta.get("args_hint") or "")):
|
||||
continue
|
||||
yield cmd_name, meta.get("description", "Plugin command"), ""
|
||||
yield cmd_name, meta.get("description") or t("slash.shared.plugin_command_desc"), ""
|
||||
|
||||
plugin_entries = _entries(_plugin_rows())
|
||||
reserved_names.update(n for n, *_rest in plugin_entries)
|
||||
@@ -396,13 +397,13 @@ def slack_native_slashes() -> list[tuple[str, str, str]]:
|
||||
standalone slash, deduped and clamped to the 50-command cap; Slack built-ins and
|
||||
_SLACK_VIA_HERMES_ONLY are skipped. ``/hermes`` is always first for anything dropped."""
|
||||
available = _gateway_available_commands()
|
||||
wanted = [(cmd.name, cmd.description, cmd.args_hint or "") for cmd in available]
|
||||
wanted += [(alias, f"Alias for /{cmd.name} — {cmd.description}", cmd.args_hint or "")
|
||||
for cmd in available for alias in cmd.aliases]
|
||||
wanted = [(cmd.name, cmd.describe(), cmd.args_hint or "") for cmd in available]
|
||||
wanted += [(alias, t("slash.shared.slack_alias_for", name=cmd.name, description=cmd.describe()),
|
||||
cmd.args_hint or "") for cmd in available for alias in cmd.aliases]
|
||||
wanted += [(name, desc, hint or "") for name, desc, hint in _iter_plugin_command_entries()]
|
||||
|
||||
entries: list[tuple[str, str, str]] = [
|
||||
("hermes", "Talk to Hermes or run a subcommand", "[subcommand] [args]")]
|
||||
("hermes", t("slash.hermes.description"), "[subcommand] [args]")]
|
||||
seen = {"hermes"}
|
||||
for name, desc, hint in wanted:
|
||||
slack_name = _sanitize_slack_name(name)
|
||||
@@ -422,7 +423,7 @@ def slack_app_manifest(
|
||||
users configure in the Slack UI); ``request_url`` is schema-required, ignored in Socket Mode."""
|
||||
slashes = []
|
||||
for name, desc, usage in slack_native_slashes():
|
||||
entry = {"command": f"/{name}", "description": desc or f"Run /{name}",
|
||||
entry = {"command": f"/{name}", "description": desc or t("slash.shared.plugin_default_desc", name=name),
|
||||
"should_escape": False, "url": request_url}
|
||||
if usage:
|
||||
entry["usage_hint"] = usage
|
||||
|
||||
@@ -3606,6 +3606,12 @@ def set_config_value(key: str, value: str, force: bool = False):
|
||||
if not is_known and not force and _is_wrong_prefix_suggestion(key, suggestion):
|
||||
_exit_invalid(_unknown_subkey_refusal(key, suggestion))
|
||||
|
||||
if key == "display.language":
|
||||
from hermes_cli.config_language import display_language_error
|
||||
language_error = display_language_error(value)
|
||||
if language_error:
|
||||
_exit_invalid(language_error)
|
||||
|
||||
# Read the RAW user config (not merged) so defaults are never dumped back; fail-closed.
|
||||
config_path = get_config_path()
|
||||
user_config = require_readable_config_before_write(config_path)
|
||||
@@ -3660,6 +3666,9 @@ def set_config_value(key: str, value: str, force: bool = False):
|
||||
save_env_value(env_var, _terminal_env_value(value))
|
||||
|
||||
_touch_skin_file(key, value)
|
||||
if key == "display.language":
|
||||
from agent.i18n import reset_language_cache
|
||||
reset_language_cache()
|
||||
|
||||
# Mask the echoed value when the (possibly nested) key is credential-shaped, e.g.
|
||||
# ``model.api_key`` (lowercase, so it misses the .env routing above).
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""``display.language`` validation for ``hermes config set`` — pluggable languages.
|
||||
|
||||
The accepted set is ``agent.i18n.supported_languages()``: bundled catalogs ∪ the profile's overlay dir
|
||||
∪ every loaded plugin language pack. Packs only exist after discovery, and ``hermes config`` never
|
||||
imports ``model_tools`` (the usual discovery trigger), so this runs ``discover_plugins()`` first —
|
||||
otherwise ``hermes config set display.language pl`` would refuse the very pack the user just installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
DISPLAY_LANGUAGE_KEY = "display.language"
|
||||
|
||||
|
||||
def resolve_display_language(value: str) -> Optional[str]:
|
||||
"""Canonical id for *value* (alias/region-tolerant) or ``None`` when no layer supplies it."""
|
||||
from agent.i18n import reset_language_cache, resolve_language_id
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins()
|
||||
except Exception:
|
||||
pass # a broken plugin tree must not block setting a bundled language
|
||||
reset_language_cache() # packs registered during discovery must be visible to this check
|
||||
return resolve_language_id(value)
|
||||
|
||||
|
||||
def display_language_error(value: str) -> Optional[str]:
|
||||
"""User-facing refusal for an unsupported ``display.language`` value, ``None`` when acceptable.
|
||||
Auto-detect (empty) is always accepted."""
|
||||
if not str(value).strip():
|
||||
return None
|
||||
if resolve_display_language(value) is not None:
|
||||
return None
|
||||
from agent.i18n import supported_languages
|
||||
return (f"✗ Unknown language {value!r} for {DISPLAY_LANGUAGE_KEY}. Available: "
|
||||
f"{', '.join(supported_languages())}.\n Install a language pack plugin "
|
||||
"(hermes plugins install <pack>) or drop <HERMES_HOME>/locales/<id>.yaml to add one.")
|
||||
|
||||
|
||||
__all__ = ["DISPLAY_LANGUAGE_KEY", "display_language_error", "resolve_display_language"]
|
||||
@@ -974,10 +974,19 @@ _PROTOCOL_VIOLATION_ERROR = (
|
||||
)
|
||||
|
||||
|
||||
_EXIT_SUMMARY_MARKER = "Resume this session with:"
|
||||
# Rich panel/rule chrome around the rendered response, and the CLI's own preamble lines.
|
||||
_LOG_CHROME = re.compile(r"[─━═╭╮╰╯│┃┌┐└┘]+|☤\s*Hermes")
|
||||
_LOG_NOISE_PREFIXES = ("session_id:", "Query:", "Initializing agent")
|
||||
|
||||
|
||||
def _exit_summary_marker() -> str:
|
||||
"""The CLI exit-summary header (``cli_session_mixin.show_exit_summary``), in the active language."""
|
||||
from agent.i18n import t
|
||||
return t("cli.session.exit_resume_hint")
|
||||
|
||||
|
||||
def _log_noise_prefixes() -> tuple[str, ...]:
|
||||
from agent.i18n import t
|
||||
return ("session_id:", "Query:", t("cli.chat.initializing_agent"))
|
||||
|
||||
|
||||
def _worker_final_output(task_id: str, board: Optional[str] = None) -> str:
|
||||
@@ -1002,13 +1011,13 @@ def _worker_final_output(task_id: str, board: Optional[str] = None) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
raw = _EXIT_TRAILER_RE.sub("", raw)
|
||||
cut = raw.rfind(_EXIT_SUMMARY_MARKER)
|
||||
cut = raw.rfind(_exit_summary_marker())
|
||||
if cut != -1:
|
||||
raw = raw[:cut]
|
||||
lines = []
|
||||
for ln in raw.splitlines():
|
||||
ln = _LOG_CHROME.sub("", ln).strip()
|
||||
if ln and not ln.startswith(_LOG_NOISE_PREFIXES):
|
||||
if ln and not ln.startswith(_log_noise_prefixes()):
|
||||
lines.append(ln)
|
||||
return " ".join(lines)[-400:]
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import sys
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from agent.i18n import t
|
||||
|
||||
# Log-record parity with the origin module.
|
||||
logger = logging.getLogger("hermes_cli.main")
|
||||
@@ -63,7 +64,7 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
active_profile = get_active_profile_name()
|
||||
profile_flag = "" if active_profile in ("default", "custom") else f" -p {active_profile}"
|
||||
print(f"\nResume this session with:\n hermes --tui --resume {target}{profile_flag}")
|
||||
print(f"\n{t('cli.session.exit_resume_hint')}\n hermes --tui --resume {target}{profile_flag}")
|
||||
if title:
|
||||
print(f' hermes --tui -c "{title}"{profile_flag}')
|
||||
print(f"\nSession: {target}")
|
||||
|
||||
@@ -24,6 +24,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_cli.plugin_validate_desktop import check_desktop_surface
|
||||
from hermes_cli.plugin_validate_locales import check_language_packs
|
||||
from hermes_cli.plugins_manifest import _CONFIG_SCHEMA_TYPES
|
||||
|
||||
_UPPER_SNAKE_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
||||
@@ -510,28 +511,31 @@ def validate_plugin_dir(plugin_dir: Path) -> ValidationReport:
|
||||
_check_requires_hermes(report, manifest)
|
||||
_check_config_spec(report, manifest)
|
||||
_check_requires_env(report, manifest)
|
||||
_check_loadable(report, plugin_dir)
|
||||
_check_loadable(report, plugin_dir, manifest)
|
||||
_check_python_dependencies(report, plugin_dir)
|
||||
recorded = _check_capabilities(report, manifest, plugin_dir)
|
||||
_check_builtin_collisions(report, manifest, recorded)
|
||||
_check_security_scan(report, plugin_dir)
|
||||
check_desktop_surface(report, plugin_dir)
|
||||
check_language_packs(report, manifest, plugin_dir)
|
||||
return report
|
||||
|
||||
|
||||
_LOADABLE_ENTRYPOINTS = ("__init__.py", "desktop/plugin.js", "plugin.json")
|
||||
|
||||
|
||||
def _check_loadable(report: ValidationReport, plugin_dir: Path) -> None:
|
||||
def _check_loadable(report: ValidationReport, plugin_dir: Path, manifest: Optional[dict] = None) -> None:
|
||||
"""A plugin.yaml with nothing beside it that Hermes can load (no ``register()`` module, no
|
||||
desktop bundle, no portable manifest) installs "successfully" and does nothing — a pip-layout
|
||||
repo whose code lives under ``src/`` behind an entry point is the usual shape."""
|
||||
desktop bundle, no portable manifest, no declared language pack) installs "successfully" and does
|
||||
nothing — a pip-layout repo whose code lives under ``src/`` behind an entry point is the usual shape."""
|
||||
present = [rel for rel in _LOADABLE_ENTRYPOINTS if (plugin_dir / rel).is_file()]
|
||||
if (manifest or {}).get("provides_locales") and (plugin_dir / "locales").is_dir():
|
||||
present.append("locales/ (language pack)")
|
||||
report.add(
|
||||
"loadable", bool(present),
|
||||
f"entry: {', '.join(present)}" if present else
|
||||
"nothing to load: no __init__.py, desktop/plugin.js or plugin.json beside plugin.yaml "
|
||||
"(pip-layout packages need a directory-plugin wrapper with a pyproject.toml declaring the deps)",
|
||||
"nothing to load: no __init__.py, desktop/plugin.js, plugin.json or provides_locales + locales/ beside "
|
||||
"plugin.yaml (pip-layout packages need a directory-plugin wrapper with a pyproject.toml declaring the deps)",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""``hermes plugins validate`` — language-pack checks (``provides_locales``).
|
||||
|
||||
For every declared id the pack must ship ``locales/<id>.yaml`` that parses and is text-only (a number,
|
||||
list or null leaf is an error: ``t()`` would silently drop it). Keys the English catalogs do not have
|
||||
are a WARNING naming them — a pack written against a newer or older Hermes still installs, it just
|
||||
carries dead keys. Core keys come from the bundled ``locales/en.yaml``; TUI/Desktop keys from
|
||||
``locales/_keys.tui.json`` / ``locales/_keys.desktop.json`` (exported by the TS packages' build
|
||||
scripts). When an export is absent the key check for that surface is skipped, never failed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from agent.i18n_layers import CORE_SURFACE, flatten, non_text_leaves, scan_locale_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_KEY_EXPORTS = {"tui": "_keys.tui.json", "desktop": "_keys.desktop.json"}
|
||||
|
||||
|
||||
def _bundled_locales_dir() -> Path:
|
||||
from agent.i18n import _locales_dir
|
||||
return _locales_dir()
|
||||
|
||||
|
||||
def reference_keys(surface: str) -> Optional[Set[str]]:
|
||||
"""English key set for *surface*: flattened ``en.yaml`` for core, the JSON export for tui/desktop.
|
||||
``None`` when the reference is unavailable (the check is then skipped)."""
|
||||
locales = _bundled_locales_dir()
|
||||
try:
|
||||
if surface == CORE_SURFACE:
|
||||
from agent.i18n_layers import parse_locale_file
|
||||
return set(parse_locale_file(locales / "en.yaml"))
|
||||
export = locales / _KEY_EXPORTS[surface]
|
||||
if not export.is_file():
|
||||
return None
|
||||
data = json.loads(export.read_text(encoding="utf-8-sig"))
|
||||
keys = data.get("keys") if isinstance(data, dict) else data
|
||||
return {str(k) for k in keys} if isinstance(keys, list) else None
|
||||
except Exception as exc: # a broken reference is a Hermes bug, not the pack's
|
||||
logger.debug("i18n reference keys for %s unavailable: %s", surface, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _load_document(path: Path) -> Any:
|
||||
import hermes_yaml as yaml
|
||||
with path.open("r", encoding="utf-8-sig") as handle:
|
||||
return yaml.safe_load(handle)
|
||||
|
||||
|
||||
def check_language_packs(report, manifest: dict, plugin_dir: Path) -> None:
|
||||
"""Locale-pack admission checks; a no-op (single passing line) when nothing is declared."""
|
||||
from hermes_cli.plugins_manifest import parse_provides_locales
|
||||
|
||||
raw = manifest.get("provides_locales")
|
||||
declared, _meta = parse_provides_locales(raw, str(manifest.get("name") or plugin_dir.name))
|
||||
if raw is None:
|
||||
return
|
||||
if not declared:
|
||||
report.add("locales", False, "provides_locales declares no valid language id (expected e.g. pl, pt-br)")
|
||||
return
|
||||
locales_dir = plugin_dir / "locales"
|
||||
found = scan_locale_dir(locales_dir)
|
||||
by_lang: Dict[str, List] = {}
|
||||
for lang_id, surface, path in found:
|
||||
by_lang.setdefault(lang_id, []).append((surface, path))
|
||||
for lang_id in declared:
|
||||
files = by_lang.get(lang_id, [])
|
||||
if not any(surface == CORE_SURFACE for surface, _ in files):
|
||||
report.add(f"locale {lang_id}", False, f"provides_locales declares {lang_id!r} but locales/{lang_id}.yaml is missing")
|
||||
continue
|
||||
for surface, path in files:
|
||||
_check_locale_file(report, lang_id, surface, path)
|
||||
undeclared = sorted(set(by_lang) - set(declared))
|
||||
if undeclared:
|
||||
report.warn(f"locales/ ships {', '.join(undeclared)} not listed in provides_locales "
|
||||
"(loaded anyway; declare them so users can see the pack's languages)")
|
||||
|
||||
|
||||
def _check_locale_file(report, lang_id: str, surface: str, path: Path) -> None:
|
||||
label = f"locale {lang_id}" if surface == CORE_SURFACE else f"locale {lang_id}.{surface}"
|
||||
try:
|
||||
document = _load_document(path)
|
||||
except Exception as exc:
|
||||
report.add(label, False, f"{path.name} failed to parse: {exc}")
|
||||
return
|
||||
if document is None:
|
||||
document = {}
|
||||
if not isinstance(document, dict):
|
||||
report.add(label, False, f"{path.name} must be a mapping, got {type(document).__name__}")
|
||||
return
|
||||
bad_leaves = non_text_leaves(document)
|
||||
if bad_leaves:
|
||||
report.add(label, False, f"{path.name} has non-text value(s) at: {', '.join(sorted(bad_leaves))}")
|
||||
return
|
||||
flat = flatten(document)
|
||||
reference = reference_keys(surface)
|
||||
if reference is None:
|
||||
report.add(label, True, f"{path.name}: {len(flat)} key(s); no {surface} key reference available, key check skipped")
|
||||
return
|
||||
unknown = sorted(set(flat) - reference)
|
||||
if unknown:
|
||||
report.warn(f"{path.name}: {len(unknown)} key(s) not in the English {surface} catalog (ignored at runtime): "
|
||||
+ ", ".join(unknown))
|
||||
report.add(label, True, f"{path.name}: {len(flat)} key(s), {len(flat) - len(unknown)} match the English {surface} catalog")
|
||||
|
||||
|
||||
__all__ = ["check_language_packs", "reference_keys"]
|
||||
Loaded 100 of 329 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user