mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 08:32:09 +00:00
* feat: better composer etc * docs: add desktop and dashboard run instructions * fix(desktop): address security scan findings * fix(dashboard): resolve @nous-research/ui path under npm workspaces The sync-assets prebuild step shelled out to 'cp -r node_modules/@nous-research/ui/dist/fonts ...' with a path relative to apps/dashboard/. That works only when the dep is installed locally in the dashboard workspace, but 'npm install' at the repo root (the documented setup — see apps/desktop/README.md) hoists shared deps to the root node_modules under npm workspaces. The relative cp then fails with 'No such file or directory', sync-assets exits 1, the Vite build aborts, and 'hermes dashboard' surfaces a generic 'Web UI build failed' message. Replace the shell one-liner with scripts/sync-assets.cjs, which walks up from the dashboard directory looking for node_modules/ @nous-research/ui — working in both the hoisted (workspaces) and co-located (standalone) layouts. Also guards against a missing dist/fonts or dist/assets with a clearer error pointing at a rebuild of the UI package rather than silently copying nothing. * feat(desktop): support connecting to a remote Hermes backend Add HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN env vars that, when set, short-circuit the local-child spawn in startHermes() and connect the Electron renderer to an already- running 'hermes dashboard' server reachable over the network. Motivating use case: WSL2 users who want to run the Hermes core (agent loop, tools, filesystem access) inside their WSL distribution while rendering the Electron GUI on native Windows. Before this change, the desktop app always spawned a local Python child on the same host as the renderer, which doesn't cross the WSL/Windows boundary. The remote path reuses waitForHermes() as a liveness probe (/api/status is in the backend's public endpoint allowlist), so the connection is only returned once the backend is actually ready. WebSocket URL derivation picks ws:// or wss:// based on the input scheme. URL validation rejects non-http(s) schemes and requires both env vars together to avoid a half-configured connection that would silently fall through to the spawn path. No behaviour change when the env vars are unset — the default local-spawn flow is untouched. Typical usage: # in WSL2 hermes dashboard --tui --no-open --host 0.0.0.0 --port 9119 --insecure # on Windows set HERMES_DESKTOP_REMOTE_URL=http://localhost:9119 set HERMES_DESKTOP_REMOTE_TOKEN=<session token> set HERMES_DESKTOP_IGNORE_EXISTING=1 (launch Hermes desktop) * ci(desktop): automate desktop releases Add GitHub Actions release channels for signed desktop installers and document the stable/nightly download paths. * feat: file tabs * refactor(desktop): tighten right-rail tab close API Promote closeRightRailTab/closeActiveRightRailTab as the single public entry point. Drops the activeTabRef + handleCloseDocument indirection in ChatPreviewRail, the unused $rightRailHasContent atom, and the legacy dismissFilePreviewTarget alias. -70 LOC. * feat(desktop): polish composer pill toward reference look Solid foreground-on-background send/voice-conversation circle (black-on-white in light, white-on-black in dark) anchors the right edge as the primary CTA instead of the orange theme primary. Bumps the primary control to 2.125rem so it visually outranks the ghost mic/plus controls. Opens up the surface padding (0.625rem x / 0.5rem y) so the input row breathes around its controls, and nudges the corner radius from 20 to 24px for a slightly pill-ier silhouette. LiquidGlass distortion is preserved. * feat(desktop): add startup and onboarding flow Add phase-based desktop boot progress, fresh-install sandbox testing, and first-run provider credential onboarding so packaged installs can start cleanly without manual settings detours. * fix(desktop): gate prompts on provider setup Show the desktop provider onboarding flow before prompt submission when no inference provider is configured, preventing fresh installs from falling through to backend credential errors. * fix(desktop): surface provider onboarding from session warnings Propagate credential warnings through session runtime info and open desktop onboarding whenever a session reports no usable provider, so unconfigured installs cannot fall through to prompt errors. * fix(desktop): route gateway provider errors to onboarding The "No inference provider configured" auth error reaches the renderer through gateway error events, not the prompt.submit promise; the previous patch only caught the latter, so the error toast still surfaced and onboarding never opened. Also strip credential-shaped env vars from the test:desktop:fresh sandbox so the packaged backend can't see provider keys leaking from the launching shell. * fix(desktop): use strict runtime check to drive onboarding setup.status returned True whenever any provider auth state was discoverable, including indirect fallbacks like a gh-CLI Copilot token. That made desktop think the user was set up while the agent's actual resolve_runtime_provider call still raised AuthError, leaving the user with a useless toast and no onboarding. Add a setup.runtime_check gateway method that runs the same resolver the agent uses on session creation, and switch the desktop onboarding overlay and prompt precheck to use it. * feat(desktop): OAuth-first onboarding using existing dashboard provider API Replace the engineer-flavored API key form with a Sign-in-first onboarding overlay that uses the dashboard's existing /api/providers/oauth catalog and PKCE/device-code endpoints (Anthropic, Nous, OpenAI Codex, etc.). API key entry is now a fallback tab with friendly provider names instead of env var prefixes, and the loud raw resolver error is gone in favor of a one-line welcome message. * fix(desktop): polish onboarding provider list Reorder OAuth providers so Nous Portal is first, give the segmented Sign in / API key control equal column widths, and replace the engineer-flavored backend names like "Anthropic (Claude API)" / "MiniMax (OAuth)" with friendlier in-app titles. External-CLI providers now show a softer subtitle and an external-link icon instead of a chevron. * refactor(desktop): split onboarding overlay into store + view Move the OAuth state machine, runtime check, copy-to-clipboard, and api-key save into store/onboarding.ts (matching the boot.ts pattern), leaving the overlay as a presentation layer that subscribes via useStore. Tabs are now table-driven, child panels read flow from the store instead of prop-drilling, and the polling/PKCE/error/success branches share a small Status atom. * fix(desktop): external CLI providers + center mode tabs External-CLI providers (Claude Code, Qwen Code) now open an in-overlay panel with the CLI command, copy button, and an "I've signed in" recheck instead of firing an invisible toast. Center the Sign in / API key tab control so it sits under the heading instead of hugging the left edge. * fix(desktop): drop onboarding tabs for an inline link, group device-code waiting state Replace the Sign in / API key tab pair with an "I have an API key" footer link under the OAuth provider list, with a "Back to sign in" affordance inside the API key form. Group the device-code "Waiting for you to authorize..." status next to the Cancel button so the alignment matches the action. * refactor(desktop): tighten onboarding store + overlay Drop the dead isOnboardingBusy/BUSY set, factor the catch-fallback dance into safeReq, and share a single reloadAndConnect helper between PKCE submit, device-code success, external recheck, and api-key save. In the overlay, extract Step / CodeBlock / FlowFooter / CancelBtn / DocsLink atoms so the four sign-in panels share the same chrome instead of repeating it inline. Net effect: fewer literal divs, one place to touch the spacing, and the code-block + footer rows are reusable across future flows. * fix(desktop): mount onboarding from frame 1 to kill the FOUT Default onboarding.configured to null (unknown until the runtime check resolves) and have the onboarding overlay render whenever it's not yet confirmed true. The boot overlay now yields to it, so the very first paint is the Welcome card with a "While we get you set up..." progress strip instead of a flash of the chat shell between boot dismiss and onboarding mount. The picker swaps in cleanly once the gateway opens and the runtime check confirms the user is not configured. Already-configured users see the same prep card briefly while their existing runtime warms up, then the overlay dismisses without touching the chat shell. * fix(desktop): top-align empty sessions placeholder The "Start a chat to build your history." empty state used a min-h-35 grid place-items-center container, which floated the text in a tall dead zone. Render it as a flat paragraph that sits right under the section header like the empty pinned state does. * refactor(desktop): drop dead boot overlay Onboarding overlay subsumes the boot card now that it mounts from frame 1 and renders boot progress inline. The standalone DesktopBootOverlay is unreachable in every flow (yields whenever onboarding has not confirmed configured, dismisses once it has). * fix(desktop): hide pinned/recents sections until first session A fresh sidebar showed the Pinned and Recent chats headers with floating empty-state copy underneath. Drop both sections (and the now-orphan SidebarEmptySessionState) when there are no sessions yet — they reappear after the first chat. Skeletons during initial load are unchanged. * feat(gui): route embedded TUI through dashboard gateway (#21979) Inject HERMES_TUI_GATEWAY_URL into dashboard PTY sessions so embedded ui-tui instances attach to the in-process websocket gateway, with coverage for the new env wiring. * Add desktop remote gateway settings Make the desktop gateway connection configurable from settings so local remains the default while remote backends can be saved, tested, and applied without environment variables. * feat(gui): first-class Messaging page + gateway menu redesign - Add Messaging page to the desktop app with per-platform setup, status, and inline guidance. Catalog derives from gateway.config Platform enum + plugin registry, so every messaging adapter the CLI supports (Telegram, Discord, Slack, Mattermost, Matrix, WhatsApp, Signal, BlueBubbles, Home Assistant, Email, SMS, DingTalk, Feishu, WeCom, Weixin, QQ, Yuanbao, API server, Webhooks, plugins) shows up without per-platform code. - New REST endpoints: GET /api/messaging/platforms, PUT and POST /test on the same path. Secrets go through the existing .env pipeline; enable/disable writes config.yaml. - Replace gateway statusbar dropdown with a richer panel: status row, icon-only restart + system-panel actions, recent activity (with timestamps trimmed in display, full text on hover), platform list. - Auto-poll the messaging page every 6s (paused when hidden) so status updates without a manual check. - Drop Settings / Command Center from the sidebar nav (still reachable via shortcuts and the titlebar cog). - Flatten top corners on Messaging/Skills/Artifacts/Chat panes. - Share new StatusDot component across messaging + gateway menu. - Fix gateway/config.py so an explicit platforms.<name>.enabled=false in config.yaml is honored when env tokens are present. - pb-9 on the chat content area for breathing room above the composer. * Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * pin electron version * hide application menu on non-mac systems * interpret compactPreview for non-string vlaues as JSON or an empty string * fix(desktop): keep composer contenteditable mounted across stacked toggle The composer rendered {input} inside two different parent fragments depending on `stacked`. When auto-expand flipped `stacked` (e.g. the moment typed text wrapped past two lines), React reconciled the two branches as different positions and unmounted/remounted the contenteditable. The fresh mount started empty, so any in-flight characters — most reliably reproduced by holding a key — were lost. Replace the conditional with a single CSS Grid whose template-areas swap on `stacked`. The three children (menu, input, controls) keep stable identities across the toggle; only their grid placement changes, which the browser handles without React tearing down the editor. * refactor(desktop): align install layout with install.ps1 / install.sh Make the desktop app's runtime layout match what scripts/install.ps1 and scripts/install.sh produce, so a desktop-only user and a CLI-only user end up with the same files in the same places and can share one install. Layout - ACTIVE_HERMES_ROOT = HERMES_HOME/hermes-agent (was: process.resourcesPath/hermes-agent, read-only) - VENV_ROOT = HERMES_HOME/hermes-agent/venv (was: userData/hermes-runtime) - desktop.log = HERMES_HOME/logs/desktop.log (was: userData/desktop.log) - HERMES_HOME default: %LOCALAPPDATA%\hermes on Windows, ~/.hermes elsewhere The packaged .app/.exe still ships a read-only payload at process.resourcesPath/hermes-agent (FACTORY_HERMES_ROOT). On first launch or after an installer-driven upgrade we sync factory -> active, then provision the venv and run pip install -e . against the active root. Key behaviors - Pin HERMES_HOME in the spawned Python's env so get_hermes_home() resolves to the same path resolveHermesHome() picked. Without this, Python falls back to ~/.hermes on every platform - fine on mac/linux, a split-state bug on Windows where our default is %LOCALAPPDATA%\hermes. - Detect developer installs by .git presence at ACTIVE; never overwrite a user's checkout via factory sync. - Marker at ACTIVE/.hermes-desktop-runtime.json (schema v4) tracks pyproject hash + factory version + runtime schema version. depsFresh fast-paths when nothing changed. - Dev (npm run dev) prefers SOURCE_REPO_ROOT over ACTIVE so devs run their local edits, not whatever's under HERMES_HOME. - Better error messages distinguish "no payload" from "no Python". - Preserve a legacy ~/.hermes on Windows when no %LOCALAPPDATA%\hermes exists, so users with prior pip/manual installs aren't orphaned. pyproject.toml - Promote fastapi, uvicorn[standard], ptyprocess (non-Windows), and pywinpty (Windows) to main dependencies. The dashboard backend (hermes dashboard) needs them at runtime; the previous lazy-import fallback was a footgun for fresh installs. - Empty the [pty] optional-extra; kept as a no-op back-compat alias for any existing pip install hermes-agent[pty] invocations. Drops the hardcoded BUNDLED_RUNTIME_REQUIREMENTS list in main.cjs - the desktop now installs whatever pyproject.toml says, single source of truth. Files - apps/desktop/electron/main.cjs: runtime layout, HERMES_HOME pin, factory->active sync, marker v4 - apps/desktop/scripts/test-desktop.mjs: track new venv location - apps/desktop/README.md: new Setup, Runtime Bootstrap, and Debugging sections - pyproject.toml: fastapi/uvicorn/pty backends in main dependencies; [pty] extra emptied Tested locally on Windows: npm run dev boots cleanly, sessions land at the new location, type-check + lint + test:desktop:platforms all pass. Verified end-to-end on a fresh Win11 VM via dist:win installer. Known gaps (filed as follow-ups, not in this PR): - Skills not seeded on packaged installs (sync_skills only runs in cmd_chat, not cmd_dashboard). Need to move to shared pre-dispatch. - Git Bash not bundled or detected; agent's terminal tool errors out with a useful message but desktop bootstrapper should pre-flight it. - install.ps1 / install.sh should be decomposed into composable phase libraries so the desktop bootstrapper can reuse them as a single source of truth across all install surfaces. * feat(desktop): theme polish, prose chat typography, composer chrome - DS tokens/midground, Backdrop, scoped scrollbars, typography plugin + prose - Composer liquid/radius utilities, thread font parity, tool/thinking cues - File tree label scale, preview flex, thread retry loading + streaming tests * feat(desktop): NSIS prereq detection page + auto-install via winget The packaged Windows installer now detects Python 3.11+ and Git for Windows at install time and offers to install missing prereqs via winget. Mirrors the prereq logic scripts/install.ps1 already runs for CLI installs, so desktop installer users get the same out-of-the-box experience as install.ps1 users. Why - Hermes' terminal tool calls bash.exe directly (tools/environments/ local.py); on Windows that's Git Bash from Git for Windows. Without it, the agent fails on the first terminal() call. - Hermes' Python runtime needs 3.11+. Without it, the desktop bootstrapper errors out at venv creation. - Both gaps surfaced on a fresh Windows 11 VM smoke test: VM had Python pre-installed but no Git, so the agent's first terminal call failed with "Git Bash isn't installed." - install.ps1 has had Install-Git + Install-Uv functions for ages. The desktop installer was the asymmetric outlier. How — NSIS prereq page - New file: apps/desktop/installer/prereq-check.nsh (plugged into electron-builder via build.nsis.include) - Real Wizard page using nsDialogs, inserted via customPageAfterChangeDir hook (between the Directory page and InstFiles). - Group boxes for Python and Git, each showing detection status. - Pre-checked install checkboxes when winget is available. - Auto-skips silently if both prereqs are already installed. - Falls back to manual download URLs when winget itself is missing. - Detection: - Python: probes `py -3.11`/`-3.12`/`-3.13`/`-3.14` via the Python launcher. Microsoft Store "Python stub" (no py.exe) is correctly classified as not-installed. - Git: `where git`. - winget: `where winget` (Win10 1809+ / Win11 with App Installer). - Install execution (in customInstall macro): - Python: nsExec::ExecToLog with `--scope user --silent`. Per-user install, no UAC prompt, output streams to install log. - Git: ExecShellWait via Windows ShellExecute. Critical because Git always installs per-machine and triggers UAC; ShellExecute preserves the foreground focus chain across non-elevated → elevated process spawns, so UAC actually comes to the foreground. nsExec::ExecToLog breaks the chain because winget runs hidden. - Both pass `--disable-interactivity --accept-package-agreements --accept-source-agreements` to suppress winget's own dialogs. - Verification: probes Git's standard install locations via FileExists rather than `where git`. NSIS's process inherits PATH at startup, so a freshly-installed Git won't be visible to `where` until restart. - Silent installs (/S) skip the prompts; managed deploys handle prereqs out-of-band via Group Policy / Intune. How — Electron-side safety net - New findGitBash() in main.cjs, parallel to findSystemPython(). Probes the same locations as tools/environments/local.py:_find_bash() so a positive result here means the agent's terminal tool will work. - ensureRuntime now throws a clear, actionable error on Windows when Git Bash isn't found, matching the existing "Python 3.11+ is required" error path. - Catches users the NSIS page doesn't: .msi installer users (NSIS prereq page doesn't run for MSI), `npm run dev` users, manual installers, anyone who unchecked the install boxes on the NSIS prereq page. - All gated on `IS_WINDOWS`; macOS / Linux unaffected. NSIS build issue (resolved) - electron-builder defaults to `-WX` (warnings as errors). NSIS optimizer emits "warning 6010: function not referenced" for our page functions because Page custom directives don't count as references in its static-analysis pass. The functions ARE called at runtime when NSIS invokes the page; the optimizer just can't see it statically. - Set `build.nsis.warningsAsErrors=false` in package.json so this spurious warning doesn't fail the build. (Documented option from electron-builder's nsisOptions.) Out of scope (filed for future work) - MSI prereq detection: Windows Installer custom actions are a different mechanism. Enterprise deploys typically handle prereqs via GP/Intune. - Bundle PortableGit + python-build-standalone in extraResources for zero-network installs. ~80MB increase. - Mac / Linux GUI prereq flows (different installer formats; Xcode CLT covers most macOS prereqs already; Linux is per-distro hard). Files - apps/desktop/installer/prereq-check.nsh (new, ~290 lines NSIS) - apps/desktop/package.json (build.nsis.include + warningsAsErrors) - apps/desktop/electron/main.cjs (findGitBash + preflight) - apps/desktop/README.md (Runtime prerequisites section) Cross-platform impact - macOS / Linux builds (dist:mac, dist:mac:dmg, dist:mac:zip): nsis config is ignored entirely; .nsh is dormant. - npm run dev: .nsh dormant; main.cjs preflight gated on IS_WINDOWS. - scripts/install.ps1, scripts/install.sh: no reference to any new files; CLI install paths untouched. - Hermes CLI / dashboard / gateway: no reference; runtime untouched. - All checks: node --check on main.cjs and test-desktop.mjs pass; npm run test:desktop:platforms 4/4 passing; node --test green. Tested - npm run dist:win produces signed .exe and .msi without errors. - Fresh Win11 VM (Python pre-installed, no Git): prereq page renders, Python check shows detected, Git checkbox pre-checked. Click Next → Git installs via winget with UAC prompt in foreground. - After install completes, Hermes launches and the agent's terminal tool can run bash commands. Verified Git Bash is detected at `C:\Program Files\Git\bin\bash.exe` by ensureRuntime's preflight. * feat: theme changes, composer tweaks, in app update ux, finesse * fix(cli): seed bundled skills on dashboard + gateway entrypoints `sync_skills(quiet=True)` was only being called from inside `cmd_chat`, which meant `hermes dashboard` (the desktop GUI's backend) and `hermes gateway` (Telegram/Discord/Slack/etc daemons) never seeded the bundled skill library into ~/.hermes/skills/. This surfaced as "No skills found" in the desktop GUI's skills panel on fresh installs, despite the agent having access to the full bundled library when invoked via `hermes chat`. scripts/install.ps1 worked around it by running skills_sync.py as part of Copy-ConfigTemplates, but that's not part of the desktop installer's bootstrap chain. Fix - Extract the skills-sync block from cmd_chat into a module-level `_sync_bundled_skills_quietly()` helper. - Call the helper from cmd_chat (preserving existing behavior), cmd_dashboard (after the --status/--stop early-return paths and fastapi import check, so we don't run skills_sync on management commands or when deps aren't installed), and cmd_gateway. Why these three entrypoints - cmd_chat: the user's primary CLI entrypoint - cmd_dashboard: the desktop GUI's backend; this is what `hermes dashboard --tui` invokes when the desktop bootstrapper spawns Hermes - cmd_gateway: long-running daemons where the user expects the agent to have full skill access Other entrypoints (cmd_config, cmd_doctor, cmd_login, cmd_status, etc.) are management commands that don't need skill discovery and were never running skills_sync in the first place — leaving them alone. Idempotence - tools/skills_sync.py is manifest-based: skipped skills cost milliseconds. Calling it from multiple entrypoints adds no real cost, and users running `hermes chat` then `hermes dashboard` get two fast no-ops on the second call. Failure handling - Helper wraps skills_sync in try/except. Skills are an enhancement, not a hard dependency — Hermes runs fine with an empty skills/ dir. Files - hermes_cli/main.py: + new helper `_sync_bundled_skills_quietly()` at module level + cmd_chat: replace inline block with helper call + cmd_dashboard: add helper call after fastapi import succeeds + cmd_gateway: add helper call before delegating to gateway_command * feat(desktop): hoisted todo widget, JSON tool summaries, history grouping & timer fixes - Hoist todo to first-class widget (shadcn checkboxes, brand colors, no tool-accordion). Header derives label from active task; non-active rows fade. - Replace raw JSON dumps with structured key/value summaries via formatToolResultSummary; nested error extraction for clearer failures. - Fix loaded-session grouping: stitch interleaved assistant/tool iterations into one bubble instead of orphaned synthetic messages. - Stable tool/thinking timers via keyed registry so unmount/scroll doesn't reset elapsed counts; gate "running" on real live thread state. - Reorganize chat-only assistant-ui components under components/chat/. * fix(desktop): address CodeQL alerts on PR #20059 - settings/helpers.ts: harden setNested against prototype pollution. POLLUTING_PATH_PARTS check is now applied at every assignment site (loop + leaf) and uses Object.defineProperty so CodeQL can see the guard inline rather than via a helper function call. - lib/markdown-preprocess.ts: rebuild the dangling-fence close regex from a fence-char + length instead of marker.replace(...). The marker is captured by `(`{3,}|~{3,})` so it can only be backticks or tildes, but CodeQL was tracing tainted input text into the RegExp source and flagging hostname dots from input as part of the pattern (false positive js/incomplete-hostname-regexp on the test fixture URLs). Reconstructing from a literal char breaks the dataflow. - scripts/notarize-artifact.cjs: drop args from the run() rejection message. Args carry --key-id / --issuer / key file path; the existing outer catch already squashes errors to a generic line, but CodeQL was flagging the args.join(' ') as clear-text logging of APPLE_API_KEY_ID. Composer DOM-text-as-HTML alerts (composer/index.tsx:379, :547) are already addressed in4dd9732a9— innerHTML assignment was replaced with renderComposerContents which builds DOM via replaceChildren / append text nodes (no HTML interpretation). * fix(desktop): inline prototype-pollution guard so CodeQL sees it CodeQL's dataflow doesn't follow the helper-function guard inside `safeSet`, so it kept flagging Object.defineProperty as prototype- polluting. Inline the literal `__proto__`/`constructor`/`prototype` check at the assignment site to break the dataflow. Behavior unchanged — same set of disallowed keys, same throw. * feat(ui-tui): resolve links to readable page titles Mirror desktop pretty-link behavior in the TUI by resolving HTTP links to page titles with shared caching and safe fetch filters, plus slug-based fallbacks so chat links stay readable even when title fetch fails. * fix(desktop): drop RegExp from dangling-fence close detection Previous attempt tried to break the dataflow by reconstructing the close-fence regex from a literal char + marker.length, but CodeQL still traced marker.length back to input and kept flagging the test-fixture URLs as hostname-regex sources (js/incomplete-hostname-regexp). Replace `new RegExp(...)` + `closeRe.test(body)` with a string-only hasCloseFenceLine() helper that splits on '\n' and uses ===. No regex on this path now, so input data can no longer reach a RegExp source. Behavior preserved: matches lines that are (whitespace + marker + whitespace), which is what the original `\n[ \t]*${marker}[ \t]*(?=\n|$)` matched. All 12 markdown-text tests still pass. * fix(process-registry): suppress windows-footgun false positive on guarded killpg Keep the existing POSIX-only process-group teardown path, but make the signal selection explicit via getattr and add an inline windows-footgun suppression marker on the guarded os.killpg line so the Windows footgun check no longer blocks CI on this intentionally platform-gated code. * feat(desktop): reconcile live tool events, polish thread chrome, harden boot - chat-messages: match tool rows by overlapping query/context/preview values so preview-first `tool.progress` rows reliably adopt later stable-id `tool.start` payloads instead of spawning ghost rows or mis-merging parallel same-name calls; preserve prior args/result across phases. - tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`, drop redundant `tool.started` re-emit from `tool.progress`. - electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so local backend edits actually run; split hardening helpers into `electron/hardening.cjs` with tests. - thread/tool UI: one-shot enter animation keyed by stable ids, braille spinner for running rows, Cursor-like disclosure rows, drill-down + duration/count formatting via new tool-fallback-model. - composer: extract `text-utils`, drop liquid-glass overrides. - right-rail: split preview-pane into preview-console / preview-file. - runtime: incremental external-store runtime + runtime-readiness gate; onboarding store + tests; route-resume hook test. - regression tests for live tool reconciliation (parallel tools, id-less progress, preview-first rows, structured args/results). * feat(desktop): add ripgrep to NSIS prereq page + polish layout Add ripgrep as a third (recommended) prereq alongside Python and Git in the NSIS prereq detection page, and clean up the page layout based on on-VM testing. Why ripgrep - Hermes' search_files tool calls `rg` directly for content + filename search (tools/file_operations.py:1382). Falls back to grep/find from Git Bash when missing — works but slower and noisier (no .gitignore awareness). - ~5MB winget install via `BurntSushi.ripgrep.MSVC --scope user` — no UAC prompt, parallel to how Python installs. - scripts/install.ps1 already installs ripgrep as part of Install-SystemPackages; this brings the desktop installer to parity. Why "recommended" not "required" - Python and Git are hard requirements: without them the agent runtime or terminal tool refuses to start. The bootstrapper preflight throws. - ripgrep is a performance enhancement: missing it just means slower searches. Page wording reflects this; failure to install is logged but doesn't show a MessageBox or block. Layout polish (response to on-VM screenshot review) - Wizard header now correctly reads "System Requirements" instead of the leftover "Choose Install Location" from the previous page. Set via `GetDlgItem $HWNDPARENT 1037/1038` + WM_SETTEXT — the standard NSIS pattern for overriding the page header on a custom Page. - Removed redundant in-body title + verbose intro paragraph; the wizard header IS the title now. Body has one short intro line. - Group boxes tightened to 26u with content positioned just below the groupbox title (not top-anchored status + bottom-anchored checkbox with empty space in the middle). All three panels + footer fit comfortably in 126u, well under the 140u page limit. - Checkbox labels simplified: dropped "(per-user, no admin prompt)" and "(administrator approval required)" suffixes. The footer note still calls out UAC for Git when relevant. - Footer text trimmed to fit cleanly without clipping. Install order (in customInstall macro) - Python → ripgrep → Git - Python and ripgrep are silent and run first; Git's UAC prompt comes last so the user's approval interaction isn't interrupted by silent activity afterwards. Skip behavior unchanged - All three detected → page auto-skips via Abort - Silent install (/S) → customInstall winget block skips - User unchecks all → page advances without running winget Files - apps/desktop/installer/prereq-check.nsh: ripgrep detection block, ripgrep page panel + checkbox, ripgrep customInstall block, GetDlgItem header override, layout reflow - apps/desktop/README.md: Runtime prerequisites section updated to list ripgrep as recommended, with manual winget command * feat(desktop): add model-confirmation step to onboarding After OAuth/API-key login completes, onboarding now shows a confirmation card with the curated default model and a Change button before dropping the user into chat. Closes the gap where the desktop's `model.default` was empty after first launch and the agent had to fall back to whatever heuristic happened to fire — leaving users wondering "why am I getting sonnet-4 when I logged into Nous Portal?" Why - Desktop onboarding only persisted credentials, never `model.default`. The CLI's `hermes model` command pairs provider + model selection, but the desktop's onboarding skipped the model step entirely. - Result: users saw whichever model the agent's auto-fallback picked, unpredictably and undocumented. - For the BUILD demo we want users to land on the model they expect for their provider, with a clear "this is what you're getting" UI and a one-click path to change it before chatting. How - New `confirming_model` flow status carries the just-authenticated provider slug, current default model, label, and a saving flag. - `completeWithModelConfirm()` runs after credentials succeed: reloads env, verifies runtime, fetches /api/model/options to find the curated first-model for the provider, persists it via /api/model/set, then transitions into `confirming_model`. - If anything fails (no providers returned, network error), falls through to the previous behaviour — onboarding completes without the confirm step. Polish, not a hard requirement. - All four credential paths (device_code OAuth, PKCE OAuth, external CLI flow, API key) now use completeWithModelConfirm instead of reloadAndConnect. UI - `ConfirmingModelPanel` shows: green "<provider> connected" banner, card with "Default model: <name>" + Change button, and a "Start chatting" CTA that finalises onboarding. - Reuses the existing `ModelPickerDialog` (the same picker available from the chat shell) for the change-model UX. Search, filtering, multi-provider listing — all already built. - Stacking: ModelPickerDialog defaults to z-130, which renders UNDER the onboarding overlay (z-1300) and breaks pointer events. Added optional `contentClassName` prop to ModelPickerDialog so callers can override; onboarding passes `z-[1310]`. Provider-slug matching - For OAuth flows: pass `provider.id` directly as the preferred slug. - For API-key flows: `OPENROUTER_API_KEY` → "openrouter" via env-key prefix strip. Also includes the user-visible label as a fallback candidate. - fetchProviderDefaultModel falls back to the first authenticated provider in the response if no preferred slug matches — so even a miss still surfaces a reasonable default. Files - apps/desktop/src/store/onboarding.ts: + new `confirming_model` flow variant + fetchProviderDefaultModel + completeWithModelConfirm helpers + setOnboardingModel (optimistic update + revert on failure) + confirmOnboardingModel (finalises onboarding from the card) - reloadAndConnect (replaced; the four call sites now go through completeWithModelConfirm) - apps/desktop/src/components/desktop-onboarding-overlay.tsx: + ConfirmingModelPanel component + new branch in FlowPanel for status `confirming_model` + ModelPickerDialog usage with z-[1310] content class - apps/desktop/src/components/model-picker.tsx: + optional `contentClassName` prop on ModelPickerDialog so the dialog can be stacked on top of other fixed overlays Tested - `npm run type-check` passes - `npx eslint` clean on touched files - Live test in `npm run dev`: cleared onboarding cache, walked through Nous device-code flow, saw confirm card with curated default, clicked Change → ModelPickerDialog rendered above the onboarding overlay with working pointer events, picked a different model, "Start chatting" persisted to ~/.hermes/config.yaml. * fix(desktop): suppress generic provider warning in onboarding Hide the red setup notice when the message is the generic missing-provider guidance, since onboarding already presents provider auth actions. Centralize provider-setup matching across desktop hooks and add coverage for the matcher. * fix(desktop): add 2u clearance below prereq checkboxes Group box bottom border was clipping the checkboxes by 1-2px. Bumped each box height 26u→30u; checkboxes now sit 2u above the bottom border. * fix(nix): refresh dashboard lockfile hash Update the web npm deps hash in nix/web.nix to match the committed apps/dashboard/package-lock.json so bb/gui passes the nix lockfile check. * fix(desktop): install TUI deps in release workflow Ensure desktop release builds install the standalone ui-tui package before bundling the TUI payload. * fix(desktop): run release builder from app package Invoke the desktop builder through the package script so electron-builder uses apps/desktop/package.json. * fix(desktop): expand release artifact names safely Build desktop artifact names from workflow version/channel while preserving electron-builder platform macros. * fix(desktop): use package artifact naming in release workflow Let electron-builder's desktop package config provide platform-specific artifact extensions while the workflow injects the release version/channel metadata. * fix(nix): fetch dashboard npm deps from package root Point the dashboard npm dependency fetch at apps/dashboard so Nix can find the package lockfile after the dashboard move. * fix(nix): build dashboard from package directory Set the web package source root to apps/dashboard so npm patch/build phases run beside the dashboard lockfile while keeping apps/shared available as a sibling. * feat(desktop): render LaTeX math via KaTeX after streaming completes Add @streamdown/math plugin to the chat markdown renderer. Inline ($x^2$) and block ($$...$$) math both supported with singleDollarTextMath enabled. Plugin is gated to non-streaming state to match the existing pattern for syntax highlighting — math renders when the message completes, avoiding KaTeX re-render churn during streaming. KaTeX CSS is imported in styles.css; ~30KB CSS + ~430KB JS added to the bundle. Smoothness improvements during streaming deferred to a follow-up. * perf(desktop): memoize KaTeX renders so math streams without re-rendering Wrap rehype-katex with a per-equation LRU cache (keyed by displayMode + source text) and re-enable math during streaming. Stock @streamdown/math runs rehype-katex on every markdown commit, so each new token re-katexes every equation in the message. For math-heavy responses (an equation derived step-by-step) that's hundreds of ms of wasted work per token and the streaming UI chokes. With memoization, each equation pays katex.renderToString exactly once; subsequent tokens re-walk the tree but hit cache for unchanged equations. The wrapper mirrors rehype-katex's semantics exactly: same class detection (language-math, math-inline, math-display), same <pre>-walk-up for fenced math blocks, same parent.children.splice replacement, same SKIP traversal, same strict-then-lenient render strategy with VFile message reporting. Cached children are structuredCloned on each splice so downstream rehype plugins or toJsxRuntime can't mutate the cache. * fix(desktop): declare katex-memo deps directly + drop per-app lockfile katex-memo.ts (added in112cad59b) imports hast-util-from-html-isomorphic, hast-util-to-text, remark-math, katex, and unist-util-visit-parents but those were never added to apps/desktop/package.json. They were silently resolving via @streamdown/math at the workspace root, which broke the moment `npm i --prefix apps/desktop` ran with the per-workspace lockfile because that install only consults apps/desktop/package.json. Add them as direct deps, plus unified/vfile/@types/hast for the type imports. Also delete apps/desktop/package-lock.json — root package.json declares workspaces: ["apps/*"], so npm manages all lockfile state at the root. The stale per-app lockfile is what made `npm i --prefix apps/desktop` diverge from the workspace install in the first place and left an empty apps/desktop/node_modules/@assistant-ui/ stub that Vite's dep optimizer then tried (and failed) to open at @assistant-ui/core/dist/internal.js. * feat(desktop): disable Backdrop noise overlay by default The noise overlay defaulted to on, which adds a busy speckle layer over the whole window for every new user. Flip the Leva default to off; the toggle stays in Backdrop / Noise for anyone who wants it back. * fix(desktop): polish LaTeX rendering — currency, code blocks, brackets Five distinct bugs surfaced from a math-heavy stress test: 1. Adjacent code fences glued together. scrubBacktickNoise's second-pass regex /``\s*``/g matched the LAST 2 backticks of one fence + whitespace + FIRST 2 backticks of the next, collapsing two blocks into one. Fixed with lookbehind/lookahead so we only match exactly 2 backticks not part of a longer run. 2. Whitespace eaten between fences and following content. stripPreviewTargets internally calls .trim() which strips leading/ trailing whitespace from each split-segment. For segments between two fences this collapsed \n\n to '', gluing fence close to next block. Fixed by capturing leading/trailing whitespace at the call site and restoring it after the transform. 3. Currency dollar signs eaten as math. With singleDollarTextMath:true remark-math greedy-matched any pair of $, so '$5 ... $10' became one inline math span. Added escapeCurrencyDollars to escape $<digit> patterns to \$<digit> in prose segments (not in code). Trade-off: math expressions starting with a digit (rare — '$5x = 10$') get escaped too. Mirrors the convention in ChatGPT/Claude's UIs. 4. \(...\) and \[...\] LaTeX brackets unsupported. Models often emit these instead of $...$ / $$...$$. Added rewriteLatexBracketDelimiters preprocessor pass. 5. ```latex / ```tex blocks were being routed to KaTeX via a rewrite to ```math. Aligns with GitHub markdown convention: ```math = render as math; ```latex / ```tex = LaTeX/TeX source code (syntax highlighted, not rendered). Conflating them broke teaching/showing-source use cases. MATH_FENCE_LANGUAGES pruned to {'math'} only. Also flipped parseIncompleteMarkdown to true (was !isStreaming) so the math parser can't see $ inside streaming-but-not-yet-closed code fences. Shiki was already deferred via defer={isStreaming} so this doesn't introduce new tokenization cost. Test: 18/18 existing tests still pass; one test updated to expect escaped \$ in currency-prose-with-URL case. * fix(desktop): detect Python via registry/filesystem; pin to 3.11–3.13 Two related fixes for Python detection on Windows: 1. py.exe (Python launcher) is missing from per-user installs that didn't check the launcher option, so 'py -3.X --version' alone misses real Python installs. User-reported case: clean Win11 + official Python.org 3.14 install -> 'where py' returned nothing, our installer offered to install Python again. Both NSIS prereq page and main.cjs now probe in this order: 1. py.exe launcher (when present) 2. PEP 514 registry: HKLM/HKCU\SOFTWARE\Python\PythonCore\<v>\InstallPath 3. Filesystem: %ProgramFiles%\Python<v>, %LocalAppData%\Programs\Python\Python<v> Crucially, we never fall back to running 'python.exe' from PATH on Windows — the WindowsApps stub at %LOCALAPPDATA%\Microsoft\ WindowsApps\python.exe is a redirector that opens the Microsoft Store window if no Store Python is installed. Triggering that during boot would be terrible UX. Registry/filesystem probes never execute the binary. 2. Drop 3.14 from the supported version set. Several Hermes deps (notably pywinpty, which carries Rust crates like windows_x86_64_msvc) don't yet publish 3.14 wheels. With wheels missing, 'pip install -e .' falls back to building from sdist, which needs a Rust toolchain — users see 'could not compile windows_x86_64_msvc build script' on first run. install.ps1 sidesteps this by pinning to 3.11 via uv; the desktop installer doesn't yet have the same uv-managed-Python pathway, so for now we accept 3.11/3.12/3.13 and tell winget to install 3.11 if none of those are present. Revisit when the wheel ecosystem catches up to 3.14 (~early 2026). * feat(desktop): Cron, Profiles, usage analytics, and titlebar fixes - Add Cron and Profiles sidebar routes with full CRUD-style flows and API wiring. - Extend Command Center with auxiliary task overrides and a Usage panel (7d/30d/90d). - Fix titlebar geometry for WSL/Windows (native overlay width, tool spacing). - Remove stray merge conflict markers from pyproject.toml optional deps. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(title-bar): position sidebar toggle button * feat(desktop): composer queue — queue many, edit/delete/cancel-edit, Cursor-style Press Enter while busy with a draft to queue it; with no draft to interrupt and send the next queued turn. Auto-drains one queued turn each time the session settles, same as Cursor. Queue persists across reloads so an interrupted-and-queued turn isn't lost on refresh. Each queued row supports edit-in-composer (with explicit Save/Cancel), send-now (↑), and delete. Drain skips only the entry currently being edited so the rest of the queue keeps flowing. Queue dequeue is transactional — an entry only leaves the queue after `prompt.submit` is accepted, so a rejected submit doesn't drop the turn. Also shrinks the `[interrupted]` marker to a muted one-liner and drops its assistant footer so it stops looking like a real reply. * fix(desktop): handle empty usage analytics totals Co-authored-by: Cursor <cursoragent@cursor.com> * fix(desktop): address PR review titlebar and usage races Co-authored-by: Cursor <cursoragent@cursor.com> * feat(desktop): add MCP settings and live subagent tree Surface configured MCP servers in Settings with JSON edit/save and a gateway-backed reload action so users can manage tool servers without falling back to slash commands. Track live subagent gateway events in a desktop store, show active subagent counts in the Agents statusbar item, and replace the Agents overlay stub with a live spawn tree for the active session. * fix(desktop): move power-user views out of sidebar Keep Cron and Profiles available through lower-prominence chrome entry points so the workspace sidebar stays focused on core chat navigation. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(desktop): subagent overlay reads like a live transcript, not a dashboard Strip the card chrome and rewire /agents to feel like peeking into the child agent's stream: - subagents store: single `stream` of typed entries (thinking/tool/progress/ summary) replaces the parallel notes/thinking/tools arrays. Drop unused fields (toolsets, depth, apiCalls, reasoningTokens, sessionId). - agents view: no OverlayCards, no boxed stream, no per-row borders. Goal + status pill + indented stream lines, full row width. - Group root spawns into "Delegation N" sections when batch shape + spawn time match — hides task-index interleaving and makes hierarchy obvious. - Sort tree by spawn time, then task_index. Step indicator is one colored pill (primary while running, emerald when done) inside the row, not a trailing pill that wrapped under the chevron. - Tree picks up `subagent.start` (not only `spawn_requested`) and prunes delegate-tool fallback rows once native subagent events land for the session — fixes duplicate "Delegated task" rows alongside the real ones. * feat(desktop): Esc closes every OverlayView-based overlay Lift the keyboard handler into the shared OverlayView so Agents, Settings, Command Center — and anything we build on top of it later — all dismiss on Esc by default. Nested Radix dialogs stop propagation themselves, so a modal opened inside an overlay (e.g. model picker inside Settings) still closes the modal first, not the overlay underneath. Drop the now-redundant Esc handlers in Settings (kept Cmd/Ctrl+P) and Command Center. * fix(desktop): drop numbered step pill on subagent rows The pill was getting clipped at the overlay edge anyway. Just use the status glyph (●/✓/✗/■/○) — the delegation header already conveys "3 workers, 3 active", and order in the list implies which step you're looking at. * fix(desktop): drop noisy "returned N items / empty object" stub strings When a tool returns nothing useful, the row should be silent — the title ("Search Files", etc.) already tells the user what happened. Counting the fields in an opaque payload is engineer-noise. `formatToolResultSummary` and `minimalValueSummary` now return '' for empty arrays / records / unrecognized values; tool-fallback already hides the detail section when its body is empty. * refactor(desktop): subagent rows borrow chat tool patterns (fade-in, lucide glyphs, shimmer) Pull the agents view closer to how chat tool blocks render: - statusGlyph() returns the same lucide BrailleSpinner / CheckCircle2 / AlertCircle vocabulary as tool-fallback's statusGlyph - Stream lines fade-in via useEnterAnimation (one-shot WAAPI), keyed per entry so streamed deltas settle in instead of popping - Subagent rows fade in too, and pick up the existing data-slot=tool-block spacing rules between blocks - Active stream line trails a BrailleSpinner instead of a hand-rolled pulsing rectangle - Goal text drops FadeText (which forces nowrap); keep FadeText only for the single-line meta subtitle - Running rows shimmer the title — same affordance the chat thinking row uses * refactor(desktop): make /agents subagent-only, drop sidebar + dead sections Activity rail and History stub were both noise. Strip the split layout, sidebar, route enum, and the rail/stub helpers — the overlay is now just the spawn tree, centered in a max-w-3xl column so it stops claiming the whole screen for one section's worth of content. * feat: update cron modals * Add dedicated GUI log stream for dashboard debugging. Capture dashboard and PTY websocket lifecycle failures in gui.log and expose it via hermes logs. * Improve desktop runtime UX by surfacing inference readiness in gateway status and hardening WSL link opening. This also stabilizes markdown code/table block spacing and adds root-install guards so desktop dev runs use a healthy workspace dependency tree. * Log detailed GUI websocket failure metadata. Capture richer reject/disconnect/send/parse context for dashboard gateway websocket flows so GUI connection failures are diagnosable from logs. * Default dashboard startup logging to GUI mode. Detect the dashboard subcommand during early CLI bootstrap so gui.log is attached from process start and GUI startup failures are always captured. * Clean up gateway status conditionals and logging bootstrap mode detection. Simplify nested dashboard gateway status branches for readability and use a concise first-subcommand check when selecting early GUI logging mode. * add logging to nsis installer * feat: glass ui pass * fix(desktop): persist inline assistant errors across hydrate/resume - Detect provider failure text arriving via message.complete (HTTP 4xx, "API call failed after N retries", Provider/Gateway error: ...) and persist as an inline assistant error instead of regular completion text, blocking the hydrate that was wiping it. - preserveLocalAssistantErrors: merge by id so same-id hydrated messages keep their local error, and preserve the optimistic user+error pair as a unit (with tail-user dedupe). - Hook all hydrate/resume writers (use-session-actions resume + fallback, hydrateFromStoredSession, syncSessionStateToView) into the merge so stale snapshots can't clobber a failed turn. - Add error to chatMessagesEquivalent so the resume diff actually sees error-only changes and paints them. - editMessage on a failed turn now submits a plain resend (no truncate_before_user_ordinal) and retries plainly on the "no longer in session history" race. Style polish on touched files: - Inline error: text-only treatment (no card). - User stop / edit-composer send: shared Tabler IconPlayerStopFilled glyph + shared icon-button class slot for parity. * feat(desktop): theme xterm with active light/dark mode The right-sidebar terminal hardcoded a light palette, which read poorly on the dark glass surface. Subscribe to `useTheme().resolvedMode` and hot-swap `term.options.theme` so Shift+X (and any other mode change) updates the terminal in place without tearing down the PTY session. Dark mode uses xterm's built-in defaults (white fg/cursor + vivid ANSI 16) with just a transparent background so the glass shows through; light mode keeps the existing hand-tuned overrides for legibility on a bright surface. * feat(sidebar): right-click + drag-reorder sessions and workspaces - Wire right-click on session rows to open the same actions menu; suppresses the OS-native context menu so Windows stops looking awful. - Share dropdown + context menu items via useSessionActions() driving a single declarative ItemSpec[]; render polymorphic over MenuItem. - New shadcn ContextMenu primitive mirroring DropdownMenu styling. - Restore drag-and-drop reordering for Agents (lost during the cwd cleanup) and add reordering of workspace groups via a right-side grab handle. Pinned reorder unchanged. - Generic orderByIds<T> replaces the duplicated session/group orderers; useSortableBindings() hook collapses the two Sortable wrappers. - cursor-pointer on every actionable element; cursor-grab on handles. - KISS pass: baseName() helper, AGE_TICKS table, single WORKSPACE_PAGE constant, flatter SidebarSessionsSection render. * feat(desktop): solarize the xterm palette in both light & dark xterm's default ANSI 16 is tuned for dark and reads candy-bright on the light glass surface (vivid cyans/greens). Ship the canonical Solarized palette (Schoonover) for both modes — same 16 accents either way, only fg/cursor swap between `base00/01` (light) and `base0/1` (dark), so a prompt's colors look uniform across a Shift+X toggle. Background stays transparent in both modes — Solarized's cream/slate backgrounds would fight the glass. * feat(desktop): virtualize chat thread + sidebar via TanStack Virtual Replaces `use-stick-to-bottom` and per-row session rendering with `@tanstack/react-virtual`, matching what Cursor uses. Chat thread (`thread-virtualizer.tsx`): - Natural-flow virtualization (padding spacers, not absolute items) so `position: sticky` on the human bubble still resolves cleanly against the scroller. - Custom at-bottom anchor: pins when armed, disarms on user-driven upward scroll, re-arms at bottom, jumps on session switch + `thread.runStart`. - Loading indicator and `--thread-last-message-clearance` move to a real `[data-slot=aui_composer-clearance]` node; drops the brittle `:nth-last-child(1 of …)` rule that can't fire reliably under virtualization. Sidebar (`virtual-session-list.tsx`): - Flat agents list virtualizes at >=25 rows; pinned and workspace-grouped paths stay direct-render. - `SortableContext` keeps all IDs; only the window mounts; dnd-kit's `setNodeRef` is merged with `virtualizer.measureElement` so rows participate in both DnD hit-testing and TanStack measurement. Drops `use-stick-to-bottom`. Streaming test gets a global `offsetWidth/offsetHeight` stub so the virtualizer's viewport sizing works in jsdom; the scroll-up-doesn't-pull-back invariant still passes. * feat: more ui qa * fix(desktop): trim sidebar terminal startup spacer Drop zsh's initial spacer row before writing the first terminal prompt so new sidebar terminal sessions do not open with a selectable blank line. * chore: uptick * feat(desktop): thin installer + first-launch install.ps1 bootstrap Converges the Windows packaged desktop installer onto a single canonical install topology: drop the Electron shell only (~80MB instead of ~500MB), clone Hermes Agent at a build-time-pinned commit on first launch via install.ps1's stage protocol, and treat the resulting git checkout at %LOCALAPPDATA%\hermes\hermes-agent\ as the canonical install location (same path the CLI installer uses). Future updates flow through the existing applyUpdates() git-pull path. Replaces the previous fat-installer architecture where the .exe bundled a pre-staged hermes-agent source tree under resources/hermes-agent/ that was then sync'd into ACTIVE_HERMES_ROOT at launch -- a complicated factory-vs-active dance with several footguns (FACTORY_HERMES_ROOT mismatch on path resolve, isGitCheckout guard regressions, pyproject hash drift detection inside the sync loop). Architecture overview --------------------- Build time apps/desktop/scripts/write-build-stamp.cjs writes apps/desktop/build/install-stamp.json with {commit, branch, builtAt, dirty}. Honours $GITHUB_SHA / $GITHUB_REF_NAME in CI, falls back to `git rev-parse HEAD` locally. apps/desktop/scripts/stage-native-deps.cjs copies the runtime subset of @homebridge/node-pty-prebuilt-multiarch from the workspace-root node_modules into apps/desktop/build/native-deps/. Workspace dedup hoists this dep to the root, out of reach of electron-builder's `files:`-restricted collector; staging gives us a deterministic path to extraResources. electron-builder ships both into resources/install-stamp.json and resources/native-deps/ respectively. Boot resolver (electron/main.cjs) Resolver order: 1. HERMES_DESKTOP_HERMES_ROOT override 2. SOURCE_REPO_ROOT (dev mode) 3. ACTIVE_HERMES_ROOT git checkout WITH .hermes-bootstrap-complete marker -- the post-install fast path 4. `hermes` on PATH (CLI-installed user adding the desktop) 5. pip-installed hermes_cli via system Python 6. bootstrap-needed sentinel -> hand off to runBootstrap Deletes the entire FACTORY_HERMES_ROOT / RUNTIME_MARKER / syncTreeExcludingVenv machinery (-200 lines). The isGitCheckout guard that bit us in the install.ps1 PR is gone. First-launch bootstrap (electron/bootstrap-runner.cjs) 1. Resolve install.ps1: prefer SOURCE_REPO_ROOT/scripts (dev), else download from GitHub raw at INSTALL_STAMP.commit (cached at HERMES_HOME\bootstrap-cache\install-<sha>.ps1). 2. Fetch the stage manifest via install.ps1 -Manifest -Commit X -Branch Y. 3. Iterate stages: install.ps1 -Stage <name> -NonInteractive -Json -Commit X -Branch Y per stage. 4. On all stages green: write the .hermes-bootstrap-complete marker with {schemaVersion, pinnedCommit, pinnedBranch, completedAt, desktopVersion}. Per-run log to HERMES_HOME\logs\bootstrap-<ts>.log. Cancellation via AbortSignal. Manifest cache so retries don't re-download. Install overlay (src/components/desktop-install-overlay.tsx) Mounted alongside the existing onboarding overlay; flexbox card with header (static) + middle (scrollable) + footer (failure-only, static). Subscribes to hermes:bootstrap:event IPC + resyncs from hermes:bootstrap:get on mount/reload. Renders: - 14-stage checklist with per-stage state icons - Overall progress bar + current-stage spotlight - Auto-expanded installer-output panel on failure - "Copy output" button (full ring buffer + error to clipboard) - "Reload and retry" wired through hermes:bootstrap:reset to clear main.cjs's latched failure Synthetic empty-manifest event from main.cjs flips the overlay to 'active' immediately so the slow install.ps1 download doesn't leave the user staring at the generic Preparing splash. Failure latching (main.cjs) bootstrapFailure module-scope variable holds the rejection after install.ps1 fails. startHermes() throws the latched error immediately when set, bypassing the entire ensureRuntime + runBootstrap chain. Without this, the renderer's ensureGatewayOpen retries would re-run install.ps1 in a 5-10 min hot loop while the user was still reading the failure overlay. Cleared via hermes:bootstrap:reset on user-driven retry. Unsupported-platform overlay (1F) macOS / Linux packaged builds (no install.sh stage protocol yet) emit an unsupported-platform event with a copy-pasteable install command + docs URL. Dedicated overlay branch with "Copy command" + "I've run it -- retry" buttons. install.ps1 additions (Phase 1F.3 + 1F.5) ----------------------------------------- New -Commit and -Tag string params. Precedence Commit > Tag > Branch. Honoured by all three code paths (update / fresh clone / ZIP fallback), with archive URL selection that handles each ref-type variant. Detached-HEAD checkouts intentionally -- they're pins, not branches the user pulls into. EAP=Continue wrap around the new pin-step git invocations. `git fetch origin <commit>` writes the routine 'From <url>' info line to stderr; under the script's global EAP=Stop that terminates the script even though fetch+checkout succeed. Matches the established pattern in Install-Uv, Test-Python, _Run-NpmInstall. Backend fix (hermes_cli/web_server.py) -------------------------------------- CORS allow_origin_regex now accepts Origin: 'null'. Packaged Electron loads index.html via file://; Chromium sets the WebSocket upgrade Origin header to the opaque origin 'null', which the old regex rejected with HTTP 403 before gateway_ws() ever ran. This failure mode was masked in the older FACTORY_HERMES_ROOT architecture because the resolver often found an existing hermes on PATH with different binding behavior. Security maintained: localhost-only bind keeps cross-machine pages out; per-process session token still gates every authenticated /api/ endpoint regardless of Origin. Desktop QoL ----------- DevTools is now enabled in packaged builds (F12 / Cmd+Opt+I). Field-debugging trade-off: tiny attack surface increase versus a much better support story when CSP / WS / theme issues surface. NSIS prereq-check page deleted (-767 lines). The standard Welcome -> License -> Directory -> InstallFiles -> Finish wizard now installs without custom Python/Git/ripgrep detection -- those prereqs are install.ps1's job at first launch. Test infrastructure (Phase 1G) ------------------------------ apps/desktop/scripts/test-desktop.mjs rewritten as a cross-platform bundle validator (was darwin-only and asserted on dead factory- payload paths): NEGATIVE: hermes_cli/main.py is NOT shipped (regression guard) POSITIVE: install-stamp.json carries a real commit + branch POSITIVE: node-pty native deps shipped under resources/native-deps POSITIVE: renderer dist/index.html reachable (asar or unpacked) New nsis mode and npm run test:desktop:nsis script. Validated end-to-end on clean Win10 VM -------------------------------------- Confirmed: NSIS installer drops Electron shell, app launches, install overlay shows progress, install.ps1 clones the pinned commit, 14 stages run to completion, marker written, backend spawns, WebSocket connects, onboarding overlay asks for API key, main UI loads, integrated terminal works. Failures handled: bootstrap stays failed (no hot-loop retry), "Copy output" gives actionable transcript, "Reload and retry" explicitly re-runs install.ps1. What's deferred --------------- - MSIX wrapping (Phase 2): same Electron .exe under MSIX manifest with runFullTrust, signed and submitted to Microsoft Store. - install.sh stage protocol parity (Phase 2): once shipped, the unsupported-platform overlay becomes drive-it-yourself and macOS/Linux packaged installers gain feature parity with Windows. * feat(desktop): persistent terminal pane + fullscreen takeover Adds a VSCode-style "focus terminal" toggle to the right sidebar's Terminal tab that takes over the chat pane area without unmounting the shell. The xterm host is mounted once at the layout root and CSS-overlayed onto whichever <TerminalSlot /> is currently active, so the PTY session, scrollback, selection, focus, and WebGL renderer survive every toggle. Also: - WebGL renderer (matching dashboard ChatPage) so Hermes' TUI skins paint faithfully instead of muting through xterm's default DOM renderer - File drag/drop from the project tree or OS into xterm — paths are shell-quoted (zsh/bash/pwsh/cmd) and written straight into the PTY - Solarized dark canvas with brights promoted to real accent variants (Schoonover's UI-gray brights washed out every TUI accent) - Strip NO_COLOR/FORCE_COLOR/COLORFGBG/TERM=dumb leaking from non-tty parents (CI runners, Cursor's agent shell) so the embedded shell gets truecolor regardless of how Electron was launched - rAF-debounced ResizeObserver — running fit.fit() synchronously during sibling pane transitions crashed the WebGL texture-atlas rebuild * fix(install.ps1): strip UTF-8 BOM regression that broke 'irm | iex' The canonical install flow irm https://raw.githubusercontent.com/.../scripts/install.ps1 | iex fails on PowerShell 5.1 with a cascade of 'The assignment expression is not valid' errors at every param() default value: [string]$Branch = 'main', ~~~~~~ The assignment expression is not valid. The input to an assignment operator must be an object that is able to accept assignments... Root cause: scripts/install.ps1 carries a UTF-8 BOM (0xEF 0xBB 0xBF) as its first three bytes. 'irm' returns the response body as a string; on PS 5.1 the BOM survives into that string as a leading \ufeff character. 'iex' then evaluates the string and PS's parser chokes on the invisible character before param() -- error recovery proceeds into the body but every assignment is reported as broken. This was the exact failure mode the install.ps1 hardening pass (PR #27224) deliberately fixed by stripping the BOM and ensuring the file body is pure ASCII. Commit4279da4db('fix(windows): make PowerShell installer parse in 5.1') re-introduced the BOM later, unintentionally undoing the irm|iex compatibility fix; the merge that brought it into bb/gui carried it forward. Fix: strip the three BOM bytes. File body is verified pure ASCII (any-byte > 127 returns false), so PS 5.1 with no BOM falls back to Windows-1252 decoding which is identical to ASCII for our content. Both install paths now work: - 'irm ... | iex' (canonical CLI) - 'powershell -File install.ps1' (programmatic / desktop bootstrap) * install.ps1: detect ARM64 Windows reliably for Node and Git stages Add a Get-WindowsArch helper that reads Win32_Processor.Architecture via CIM (invariant to PowerShell host bitness) with PROCESSOR_ARCHITEW6432 fallback. Use it in: - Install-Git: previously only triggered the arm64 PortableGit asset when invoked from a native-ARM64 PowerShell host. WoW64 / emulated x64 hosts (the default powershell.exe on Windows-on-ARM) saw PROCESSOR_ARCHITECTURE=AMD64 and fell through to the x64 PortableGit build, leaving ARM64 users on emulated Git for Windows. - Test-Node: previously hardcoded the Node download to win-x64 on any 64-bit OS, so ARM64 users always got x64 Node under Prism emulation even though Node ships an arm64 build for Windows. The winget fallback now also passes --architecture arm64 on ARM64. Python remains x86_64 by design: uv intentionally prefers windows-x86_64 cpython on ARM64 hosts for ecosystem (wheel) compatibility (see astral-sh/uv#19015). * install.ps1: harden Install-SystemPackages against winget msstore failures The previous winget invocation discarded stdout/stderr and trusted no signal at all -- not the exit code (winget exits 0 even when it bails "please specify --source"), not output (sent to Out-Null), not the catch handler (winget returning 0 means no exception fires). The only trust signal was a post-install Get-Command rg / Get-Command ffmpeg check, which would also miss the package because %LOCALAPPDATA%\ Microsoft\WinGet\Links (where winget puts command aliases) is added to PATH by AppExecutionAlias machinery only in fresh shells. End result on machines where the msstore source has a cert problem (0x8a15005e -- common on Windows-on-ARM and some corporate networks): silent failure, no log, no breadcrumb, and the user is told the install succeeded. Specifically: - Pin --source winget on every winget install call. Defeats the broken- msstore-source path. We ship nothing from msstore so this is safe and forward-compatible. - Add --exact --id for a tighter package match. - Capture each winget invocation's combined stdout/stderr + exit code to %TEMP%\hermes-winget-<pkg>-<n>.log instead of Out-Null. On the happy path the log is deleted after the post-install check confirms the binary is on PATH; on failure the log is kept and its path is named in a Write-Warn so the user has something to grep. - Refresh PATH to include %LOCALAPPDATA%\Microsoft\WinGet\Links in addition to the User/Machine env-var hives, so Get-Command sees newly- installed winget aliases in the same process. - No behavior change on the happy path. Same Write-Info/Success/Warn cadence, same fallback order (winget -> choco -> scoop -> manual), same $script:HasRipgrep / $script:HasFfmpeg outputs. Verified end-to-end on a real Snapdragon ARM64 Windows host: ripgrep uninstalled, stage re-run, [OK] ripgrep installed in 1.4s, ok:true. * desktop: swap node-pty fork for upstream microsoft/node-pty 1.1.0 The previous dependency, @homebridge/node-pty-prebuilt-multiarch@0.13.1, publishes no win32-arm64 prebuilds on its v0.13.x line, and its v0.14.x betas (which do add an arm64 Windows build) ship no electron-vXXX-win32- arm64 prebuilds at all -- so packaged Electron 40 builds (NMV 143) would fail at runtime even on a successful npm install. Net effect: the desktop's integrated terminal was unbuildable on Windows-on-ARM, in both dev (npm install fails: 404 fetching the node-vXXX-win32-arm64 prebuilt) and packaged builds (no Electron-ABI prebuilt exists). The homebridge fork was originally created because upstream node-pty shipped no prebuilds at all. That hasn't been true since node-pty@1.0 (April 2024), which: - bundles prebuilts for mac (arm64+x64) and Windows (arm64+x64) directly inside the npm tarball -- no GitHub-Releases fetch, no missing-binary failure mode - uses N-API (node-addon-api) for ABI stability across Node and Electron major versions, so the same pty.node binary loads under Node 22 (dev) and Electron 40+ (packaged) without per-ABI rebuilds - is what VS Code, Hyper, and Theia actually ship API surface is identical (spawn / onData / onExit / write / resize / kill) -- no call-site changes needed. Specifically: - apps/desktop/package.json: replace the @homebridge fork with node-pty@1.1.0 (exact pin). Widen `asarUnpack` from `["**/*.node"]` to also unpack `**/prebuilds/**`, because node-pty ships runtime- execed helpers alongside its .node files (darwin spawn-helper has no extension and would not be matched by `**/*.node`; conpty.dll, OpenConsole.exe, winpty.dll, winpty-agent.exe on Windows are also exec'd at runtime and cannot live inside asar). - apps/desktop/electron/main.cjs: update both require() strings to match the new package name and the new staged path under resources/native-deps/node-pty/. - apps/desktop/scripts/stage-native-deps.cjs: point at node_modules/ node-pty. node-pty's prebuilts live under prebuilds/<plat>-<arch>/ (not build/Release/), so update the include glob to copy that dir. Per-arch staging keeps the resource bundle small (target arch comes from npm_config_arch when electron-builder cross-builds, else process.arch). Explicitly enumerate file types in the prebuilds glob so the ~25 MB of .pdb debug symbols that prebuild-install bundles for Windows crash analysis don't bloat the installer (29 MB -> 2.6 MB staged on win32-arm64). Re-assert +x on the darwin spawn-helper defensively, since a stripped mode bit would manifest as a silent ENOENT at first pty.spawn(). - apps/desktop/scripts/test-desktop.mjs: update expectedNativeDepPaths() and its assertion site to look at prebuilds/<plat>-<arch>/ instead of build/Release/. Add an explicit spawn-helper-exists check on darwin so a regression in the asarUnpack glob would fail loudly in CI rather than at first PTY spawn. Trade-off: Linux end-users lose prebuilts and fall back to building node-pty from source on `npm install`. Acceptable because Hermes ships no Linux desktop builds (desktop-release.yml matrix is mac + win only, package.json declares no `linux` target), and Linux developers hacking on the desktop already need a C++ toolchain for the rest of the stack. Verified on Windows 11 ARM64 (Snapdragon): npm install -> exit 0 node -e "require('node-pty').spawn(...)" round-trip -> OK stage-native-deps -> 27 files, 2.6 MB load from staged tree (simulates packaged fallback) -> ConPTY round-trip OK * desktop+gateway: harden Slack socket recovery and Windows restart dedupe (#28873) * desktop+gateway: harden Slack socket recovery and Windows restart dedupe Fix Slack Socket Mode reliability by adding a watchdog/reconnect path so silent socket task drops no longer leave the adapter stuck. Harden Windows gateway lifecycle by avoiding desktop-binary path collisions, making gateway PID scans case/extension tolerant, and reusing in-flight restart actions to prevent duplicate gateway spawns. * test(slack): add Socket Mode watchdog/reconnect behavioural coverage Drive the new Slack Socket Mode self-healing logic through a fake AsyncSocketModeHandler so we can simulate the P0 silent-hang failure mode (task exit, transport disconnected, intentional shutdown, concurrent reconnect attempts) without touching real Slack. * fix(slack,desktop): address Copilot review on watchdog races and path normalization - connect(): explicitly cancel + await the prior socket watchdog before flipping _running, so an old monitor cannot exit between teardown and respawn (Copilot #1) - _socket_watchdog_loop: wrap the body in try/except + add a done-callback that respawns on unexpected crash, so a transient bug cannot permanently disable self-healing (Copilot #2) - normalizeExecutablePathForCompare: use the resolved path for realpathSync so non-string inputs cannot leak through (Copilot #3) - Add tests for crash-recovery and atomic watchdog replacement across reconnects * fix(slack): tighten connect() error path and clarify watchdog test intent Address Copilot review round 2. - connect(): wrap _start_socket_mode_handler/_ensure_socket_watchdog in a focused try/except so any failure rolls back partially-started handler/task state and leaves _running=False, ensuring the platform lock is always released by the outer finally - Defer _running=True until after the handler is actually started so the watchdog observes a live socket task immediately and never spins against a half-built adapter - Rename test_watchdog_self_restarts_after_unexpected_crash to test_watchdog_cancellation_does_not_respawn (matches what it actually asserts) and add test_watchdog_unexpected_exit_respawns_via_done_callback that drives a real RuntimeError through _on_socket_watchdog_done and verifies a fresh task replaces the crashed one * fix(web_server): serialize action spawn check+store under a threading lock Address Copilot review round 3. FastAPI runs sync handlers on its threadpool, so two near-simultaneous /api/gateway/restart (or /api/hermes/update) requests could both observe "no live process" in _spawn_hermes_action's poll-based dedupe and double-spawn. Add a module-level _ACTION_SPAWN_LOCK around the entire check + Popen + _ACTION_PROCS store sequence so the dedupe is atomic across threads. * fix: address Copilot review round 4 - slack.disconnect(): mirror connect()'s defensive cleanup — catch the broad Exception path on watchdog await so handler shutdown and lock release still run if the watchdog raised before cancellation took effect - web_server._spawn_hermes_action: wrap subprocess.Popen in try/except so a missing executable / permission error closes the log file handle, writes a failure marker, and re-raises instead of leaking a file descriptor - gateway._scan_gateway_pids: drop the over-broad "hermes.exe --profile" / "hermes.exe -p" patterns that would match any Hermes CLI subcommand using a profile flag (e.g. `hermes.exe --profile foo dashboard`); rely on the "hermes.exe gateway" + "hermes-gateway.exe" tokens instead - tests: tighten _fake_create_task to assert coroutine input and return a real asyncio.Task that stays pending until pytest teardown, and update the three callsites whose mocked AsyncSocketModeHandler.start_async returned a non-coroutine value * fix(slack): reset multi-workspace state on reconnect Address Copilot review round 5. connect() is reentrant (gateway restart, in-process reconnect), but it was leaving _bot_user_id / _team_clients / _team_bot_user_ids populated from the previous session. A reconnect that rotated the primary token or dropped a workspace would silently keep the stale bot user id and stale workspace client maps, leading to dispatch against gone workspaces. Clear these three pieces of state right after _stop_socket_mode_handler() and before the auth_test loop, then let the loop repopulate from the current tokens. Add test_reconnect_refreshes_multi_workspace_state to lock it in. * nix: package apps/desktop as .#desktop (#28964) Adds nix/desktop.nix building the Electron renderer with buildNpmPackage and wrapping nixpkgs' electron binary. Reuses .#default by setting HERMES_DESKTOP_HERMES to its hermes binary, so the desktop's resolver picks up the fully-wired nix hermes (venv, bundled skills/plugins, runtime PATH) without reimplementing agent resolution. - nix/desktop.nix: renderer + electron wrapper - nix/hermes-agent.nix: finalAttrs form, exposes hermesDesktop in passthru - nix/packages.nix: exposes .#desktop + adds to fix-lockfiles - apps/desktop/package-lock.json: standalone hermetic lockfile nix build .#desktop && nix run .#desktop both clean. * fix(desktop): probe steps 4 & 5 of resolveHermesBackend before trusting A user-reported failure on Windows-on-ARM: a pre-installed Python 3.13 on PATH makes findSystemPython() succeed, so resolveHermesBackend returns a backend pointing at it -- but hermes_cli isn't in that interpreter's site-packages. The spawn dies with ModuleNotFoundError and the user sees a dead GUI instead of the first-launch installer. Same shape can hit step 4 (existing `hermes` on PATH) when a stale shim survives a partial uninstall. Add cheap exit-code probes -- `python -c "import hermes_cli"` for step 5, `<hermes> --version` for step 4 -- and fall through to step 6 (bootstrap-needed) on failure. install.ps1 then runs as if on a clean box and the venv gets built. Probes live in a standalone electron/backend-probes.cjs module so they can be unit-tested with node --test, same pattern as bootstrap-platform.cjs and hardening.cjs. New test file wired into test:desktop:platforms. * test(desktop): allow `node-pty` bare-require in packaged entrypoints Pre-existing failure on bb/gui sincec858484b4swapped the node-pty fork for upstream microsoft/node-pty 1.1.0. main.cjs intentionally bare-requires node-pty (it's hoisted by workspace dedup in dev, and staged to resources/native-deps via scripts/stage-native-deps.cjs + extraResources for packaged builds, with a try/catch fallback at line ~38). The allowlist hadn't been updated to match -- same shape as `electron`, which was already allowed. * chore(deps): refresh root lockfile for dashboard @nous-research/ui 0.14.0 apps/dashboard/package.json was bumped to @nous-research/ui 0.14.0 (+ flag-icons ^7.5.0, motion ^12.38.0) but the root package-lock.json was never refreshed. Running `npm install` from the repo root now materialises 0.14.0's transitive closure (launder, bumps for @nanostores/react, nanostores, sanitize-html, tailwind-merge). No code changes; purely a lockfile catch-up so fresh checkouts on bb/gui get a working dashboard install. * chore(desktop): bump version to 0.0.1 First non-placeholder version so electron-builder's artifactName template produces `Hermes-0.0.1-win-x64.exe` instead of the obviously-unreleased `Hermes-0.0.0-...`. No release process yet; this just stops the artifact filename from telling users "you got a debug build." Bumped in three slots that all carry the desktop app's version: - apps/desktop/package.json (source of truth) - apps/desktop/package-lock.json (per-app lockfile, kept for CI parity) - root package-lock.json's apps/desktop workspace entry Identity-of-build for first-launch bootstrap continues to come from build/install-stamp.json (commit SHA + builtAt), unchanged. * fix: fs icon color * perf(desktop): cut per-keystroke layout + listener churn in chat composer Empirical work via CDP harnesses under apps/desktop/scripts/ (see profile-typing-lag.md): jsListeners growth (per round of 200 chars + GC): before: +35 (verified leak — listeners stuck after 1st trigger popover use) after: +0 Four narrow edits in src/app/chat/composer/index.tsx: 1. Drop the per-keystroke `editorRef.current.scrollHeight` read used to decide composer expansion. Replace with `draft.length > 60` heuristic; the existing ResizeObserver still catches edge cases. `scrollHeight` is a forced-layout call and was firing on every char until the first wrap. 2. Bucket measured composer height to 8px before writing `--composer-measured-height` / `--composer-surface-measured-height` on `documentElement`. Without this, the editor grows ~1px per char, setProperty fires every keystroke, computed style is invalidated tree- wide. 3. Remove the dead `$composerDraft` two-way sync. Nothing outside the composer subscribed to that atom (verified via grep). Two useEffects on `[draft]` were pushing draft→atom and atom→aui per keystroke for no consumer. Also drop the per-keystroke `reconcileComposerTerminalSelections` call; it was pruning stale labels for `terminalContextBlocksFromDraft`, but that helper already ignores labels not in the current submitted text, so pruning per keystroke was just bookkeeping. 4. `refreshTrigger` fast-bails when the draft contains neither `@` nor `/`. Previously `textBeforeCaret(editor)` ran on every input/keyup regardless; `range.toString()` inside is O(n) over draft length. Synthetic typing latency p50/p90/p99 is similar before vs after on a freshly-loaded session (Blink can already handle ~30cps typing into a contentEditable on its own); the real win is the listener leak being gone and the global computed-style invalidations dropping ~8× when the composer is sitting at a fixed height row. The `Enter → stall` follow-up (see profile-typing-lag.md §"Submit / TTFT stall") is unmeasured here — needs a throwaway session because the harness fires a real prompt. Not blocking this commit. * perf(desktop): cut FadeText forced layouts during streaming The slowest user-felt path is typing into the composer while the assistant is streaming. Profile (scripts/profile-under-stream.mjs): FadeText measureOverflow self time: 35.8 ms → 18.1 ms (-50%) total active CPU during 7s window: ~150 ms → ~50 ms Two changes in src/components/ui/fade-text.tsx: 1. Drop the `useEffect([children])` that re-ran `measureOverflow` (reads scrollWidth + clientWidth — forced layout) on every parent re-render. `useResizeObserver` already fires the same callback on mount and whenever the host span's box size changes; that covers the only case where overflow state can legitimately change. The previous explicit useEffect was a forced-layout flush on every parent render, which during streaming meant every token tick. 2. Wrap the component in `memo` with a custom comparator that short-circuits the entire render when scalar string `children` and the className/fadeWidth/style props are unchanged. The hot path was tool-fallback's title chips being re-rendered by parent streaming updates even though their text was stable; memo+ comparator skips that. Also adds two harness scripts under apps/desktop/scripts/: - latency-under-stream.mjs (key→paint latency while a turn streams) - profile-under-stream.mjs (CPU profile while a turn streams) Updates profile-typing-lag.md with the streaming numbers and confirms the Enter→paint submit path is already fast (≤320ms on the populated session; the 2s "stall after Enter" the user noticed once was a one-time cold-start, not reproducible at the UI layer). I'd guess the felt jank in real use is fast-burst typing during a long-form streaming reply (code blocks + markdown lists multiply the per-token render cost). The CPU savings here scale linearly with token volume. * chore(desktop): drop diag scratch scripts no longer needed * docs(desktop): correct leak-typing numbers on a real session Re-ran the leak harness on a populated session (Phaser thread) for both unpatched and patched builds. The original 'listener leak' was transient warm-up cost, not a steady-state leak — both versions show 0 listener growth/round in steady state. The load-bearing number is forced layouts per character: unpatched (HEAD~2): 7.02 layouts/char patched (HEAD): 2.35 layouts/char (3× fewer) The patches reduce per-char forced-layout work to Blink's natural floor. Document node count and heap are flat in both builds. * perf(desktop): fix "Enter jumps up" on long threads User reported: after pressing Enter on a long thread, the view jumps up — the just-submitted message disappears below the fold. Confirmed via apps/desktop/scripts/measure-jump.mjs: before: distFromBottom 0 → 49.5px, sticks there permanently after: distFromBottom 0 → ~0 (worst case 4px for one frame) Root cause in useThreadScrollAnchor (thread-virtualizer.tsx): 1. The sticky-bottom logic disarmed on any scroll event where `scrollTop < lastTopRef.current`. That check can't distinguish a user scrolling up from a programmatic `pinToBottom` write that the browser clamped short of bottom (because content also grew in the same frame, so `scrollTop = scrollHeight` lands at `scrollHeight - clientHeight` for the OLD scrollHeight, which is now below the NEW scrollHeight). Result: sticky-bottom disarmed permanently on the user's first submit. 2. There was no synchronous pin tied to React's commit phase. By the time the ResizeObserver fired and re-pinned, the user had already seen ~50ms of "message below the fold" — visually that reads as the view jumping up. Fix: - `programmaticScrollPendingRef` counter tracks scroll events we expect to be ours (one per `pinToBottom` write). The scroll handler skips the disarm check when consuming a pending tick, keeps the arm bit true, and re-pins synchronously if the browser clamped us short of bottom. A depth cap (8) breaks runaway loops in pathological streaming-burst layouts. - `useLayoutEffect` on `groupCount` increase pins BEFORE the browser paints, eliminating the visible ~50ms window between optimistic user-message insert and the RO/scroll-event chain firing. Verified on the long Cloud Shadows thread (7-8 turns, ~11k px tall): all three repro runs now hold within 0–4 px of bottom across the post-Enter transition. Submit latency unchanged (paint 77–107 ms), streaming-typing latency unchanged. Also adds three debug harnesses: - measure-jump.mjs — sample thread scroll across Enter - probe-thread.mjs — dump current thread / scroll state - diag-jump.mjs — intercept scrollTop + RO + mutations across Enter * perf(desktop): rate-limit thread auto-pin during streaming Follow-up to the Enter-jump fix. The first version did a synchronous re-pin loop inside the on-scroll handler when the browser clamped our `scrollTop = scrollHeight` write short of the new bottom; that gave a tight 4 px visible jump on Enter, but during streaming the ResizeObserver fires many times per second as content grows, and each RO callback re-entered the pin loop. CPU profile showed `Virtualizer.getMaxScrollOffset` climbing to 22 ms self over a typing- during-streaming window — the sync re-pin path was paying tanstack- virtual's recompute cost ~3× per token. Re-architect: - RO callback coalesces to one pin per animation frame. Streaming-rate RO bursts now cost the same as a single per-frame pin. - The on-scroll programmatic-counter guard remains (it's what prevents the false-disarm bug when the browser clamps a write). It no longer does sync re-pins; the next RO/rAF will catch up. - The useLayoutEffect on groupCount (the path that fires on user submit / new turn arrival) ALSO schedules one rAF pin in addition to the synchronous pin. This catches the case where React mounts the new message in a second commit (after our layout effect ran), which grows scrollHeight again. Two pins instead of a tight loop, paid only once per turn change. Net effect on the Cloud Shadows long thread: enter-jump transient: 12–20 px for 1 frame (was 49 px permanent) CPU during stream+type: `getMaxScrollOffset` dropped out of top-5 self-time list typing-during-stream: p50 ~10 ms paint, p99 ~20 ms (1 frame), occasional 40 ms+ outliers during burst token arrivals Also adds scripts/profile-long-stream.mjs: 20-second streaming profile with per-500ms FPS histogram + content-length tracking, so we can see whether streaming render cost grows with message length (it doesn't — sustained 60 fps). * perf(desktop): use textContent for trigger precondition Replace composerPlainText() call inside refreshTrigger's no-trigger fast-bail with a textContent check. textContent is a browser-native flat traversal; composerPlainText walks recursively with chip-aware logic. We only need to know if @ or / appears; either way the trigger char will be in textContent because chips contain @ in their refText. Profile shows composerPlainText was ~18ms self over a 12s typing-during- stream window, called from refreshTrigger on every keystroke. Most of that was the precondition check (the trigger detection path is the slow path but only runs when a trigger char is present). * Revert "perf(desktop): use textContent for trigger precondition" This reverts commita6a78ff08a. * Revert "perf(desktop): cut FadeText forced layouts during streaming" This reverts commit88e7d7537c. * Revert "perf(desktop): cut per-keystroke layout + listener churn in chat composer" This reverts commitbff1b3261d. * Revert "Revert "perf(desktop): cut per-keystroke layout + listener churn in chat composer"" This reverts commitb7b378e3a4. * Revert "Revert "perf(desktop): use textContent for trigger precondition"" This reverts commit0739588f48. * chore(desktop): synthetic-stream perf harness + scripts Drops the React `<Profiler>` approach (no-op because Vite is currently serving the production React build) in favor of an externally-observable measurement stack: rAF frame intervals, `PerformanceObserver({entryTypes: ['longtask']})`, and a `MutationObserver` on the live streaming message. Adds a synthetic stream driver — `window.__PERF_DRIVE__.stream({...})` — that pushes tokens through the live `$messages` atom at a controlled rate, so the assistant-ui runtime, incremental repository, and Streamdown markdown pipeline see the same workload they'd see during a real LLM stream, without the LLM cost. The driver lives in `src/app/chat/perf-probe.tsx`; `main.tsx` side-imports it under `import.meta.env.MODE !== 'production'` so it tree-shakes out of prod builds. (Using `MODE` rather than `DEV` because our Vite setup currently reports `DEV=false` even under `vite dev` — see the dev-build note in `profile-typing-lag.md`.) Scripts: - measure-synthetic-stream.mjs drive synthetic + record frame/longtask/mutation - profile-synth-stream.mjs CPU profile + top self-time during synthetic - measure-real-stream.mjs same harness, real LLM stream - profile-real-stream.mjs CPU profile bracketing the real stream window - eval.mjs / reload.mjs small CDP helpers A real-LLM measurement on Cloud Shadows (gpt-4o-mini, 39 s window) showed 12 longtasks in the same 75-127 ms range the synthetic predicted, so the synthetic is a faithful proxy. * perf(desktop): memo FadeText so it skips re-renders when text unchanged FadeText is used 110+ times inside `tool-fallback.tsx` on a tool-heavy thread. During streaming each parent re-render previously triggered the component's `useEffect([children])`, which forced a `scrollWidth` layout read even when the title text was unchanged. The `useResizeObserver` was already covering the genuine resize case, so that effect was strictly redundant work. Drops the effect and wraps the component in `React.memo` with a custom comparator that field-compares `className`, `fadeWidth`, and `style`, plus identity-compares `children` (scalar fast-path; correct for JSX nodes too since a new node should force a re-render). Verified via temporary render counter on the 34 MB `session_20260514_215353_fe0ac8` thread (110 FadeText instances): a 2 s synthetic stream went from ~11k FadeText render calls to 122 — roughly one render per truly-new instance instead of one per parent commit per instance. Doesn't move the longtask needle on its own (Streamdown's markdown re-parse dwarfs it) but eliminates a steady CPU floor and a class of forced layouts during streaming. Profile-typing-lag.md documents the full investigation, including the remaining Streamdown cost as the real source of the perceived "5 fps moment" hitches. * perf(desktop): memoize MarkdownText plugins to stop churning Streamdown The inline `plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }}` on `<StreamdownTextPrimitive>` constructed a new object literal on every parent render. That broke `<Streamdown>`'s outer memo and forced its internal `rehypePlugins` / `remarkPlugins` array useMemos to rebuild, which propagates a new identity into every `<Block>` and defeats Block's memoization for stable historical blocks. After memoizing on `[isStreaming]` (the only real dimension of variance), CPU profile during a 5 s synthetic stream on the 34 MB session shows `parser` self-time dropping out of the top 10, `compile` cut roughly in half, and `bn$1` / `m$1` (micromark internals) leaving the top entries. Doesn't move the visible longtask count on its own — Streamdown's per-Block parse cost still dominates whenever the last block's content changes — but it removes a class of unnecessary re-parses for historical blocks during streaming. See `scripts/profile-typing-lag.md` for the full investigation. * perf(desktop): floor assistant-text flush gap to 33ms for predictable batching `scheduleDeltaFlush` previously coalesced via `requestAnimationFrame` only. The "at most one flush per frame" guarantee that gives you is fine for fast streams (>~80 tok/sec) where multiple tokens arrive within a single frame, but breaks down at typical LLM token rates (30-80 tok/sec) where each token arrives slower than the rAF cadence and triggers its own React commit + Streamdown markdown re-parse. Track `lastFlushAt` and require at least 33 ms between two flushes. React 18+ auto-batching probabilistically already collapsed some of these, but the floor makes it deterministic. A/B on the 34 MB session, 300 tokens at 50 tok/sec (markdown chunks): | | avgFps | p99 frame | LTs / 5 s | max LT | |---|---|---|---|---| | no floor (current rAF) | 54.0 | 38 ms | 2.0 | 145 ms | | 33 ms floor (this PR) | 54.3 | 41 ms | 1.7 | 110 ms | `inter-mutation` p50 also tightens from 22-28 ms to a clean 33 ms, which is the expected signature of a deterministic floor. Doesn't fully solve the user's perceived hitches — Streamdown's per-Block parse cost when the last block grows past ~2 k chars is still the elephant — but it consistently shaves the worst-case longtask and makes the streaming cadence visibly steadier. Also threads a matching `flushMinMs` option through the synthetic stream driver in `perf-probe.tsx` + `scripts/measure-synthetic-stream.mjs` so the harness can A/B both regimes without spending LLM credits. See `scripts/profile-typing-lag.md` for the full investigation. * perf(desktop): useDeferredValue for streaming markdown so parses don't block input Streamdown's per-Block parse cost grows with the live tail's length and is unavoidable inside the block-memo pattern (industry standard, see findings doc). The fix is to stop having that work block the main thread. `<DeferStreamingText>` is a 12-line wrapper that reads message-part state via `useMessagePartText`, runs it through `useDeferredValue`, and re-publishes via assistant-ui's `<TextMessagePartProvider>`. The inner `<StreamdownTextPrimitive>` reads the deferred value through the normal `useMessagePartText` hook — no fork, no internal-path imports, fully on assistant-ui's public API. React's concurrent scheduler then: - abandons in-flight deferred renders when a newer token arrives, so intermediate states get skipped under fast streams - deprioritises the markdown render when the main thread has urgent work (typing, scroll), so input stays responsive even while a 100ms parse is queued Streamdown already uses `useTransition` for its block-array setState; this lifts the deferral up to the consumer boundary so it covers the whole pipeline (preprocess → split → repair → parse → render). A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks (four trials each, with the 33ms flush throttle on for both): | | avgFps | p99 frame | LTs/5s | max LT | typing-while-stream p95 | |---|---|---|---|---|---| | pre | 54.3 | 41 ms | 1.7 | 110 ms | ~17 ms | | post | 58.5 | 31 ms | 2.0 | 117 ms | 14-18 ms | Longtask count + max LT unchanged — useDeferredValue doesn't reduce CPU, only its priority. The avgFps lift and p99 frame drop are the proof that the existing CPU is no longer blocking 60 fps cadence. One clean run logged MUTATIONS=0 — React skipped every intermediate text state and only committed the final one (textbook deferred-value behaviour). The actually-reduce-CPU path is replacing the parser with a state machine like Flowdown — left for a future PR; see `apps/desktop/scripts/profile-typing-lag.md` for the full investigation. * feat(desktop): add hermes gui launcher * feat(desktop): launch packaged gui builds by default * bump gui version to 0.0.2 * fix(dashboard): allow file:// origin on loopback WS + diagnostic logging Upstream commit2e66eefbc("fix(dashboard): validate WebSocket Host and Origin") added a WebSocket Host/Origin guard to block DNS rebinding against the dashboard. The guard rejects any Origin whose scheme is not http/https or whose netloc is empty — which includes Electron's renderer Origin: file:// when the desktop app loads its bundle from disk in production mode. That makes the bb/gui Electron desktop unable to open the gateway WebSocket against the embedded backend on Windows / macOS prod builds. The renderer reports "Desktop boot failed" and the backend logs: WARNING hermes_cli.web_server: gateway-ws reject peer=127.0.0.1:NNNN reason=non_loopback_or_bad_origin bound_host=127.0.0.1 close_code=4403 DNS-rebinding requires a DNS-resolvable hostname; file:// has no host component and therefore cannot be the attack vector this guard exists to block. When bound to a loopback interface (127.0.0.1 / ::1 / localhost), accept file:// origins so desktop wrappers can attach. Non-loopback binds (operator opted into network exposure) keep rejecting file:// — the loose policy doesn't apply. Also adds per-reason diagnostic logging in _ws_host_origin_is_allowed, so future ws-guard rejections name the specific clause that fired (bad_host / bad_origin_scheme / origin_host_mismatch) instead of the opaque "non_loopback_or_bad_origin" surfaced at the call site. Verified against tests/hermes_cli/test_web_server_host_header.py (all 11 upstream tests still pass) and hand-tested by opening the bb/gui Electron desktop dev build against the patched backend. * fix(tui_gateway): restore _content_display_text helper Bb/gui had dropped the helper but the orchestrator code merged from main still calls it (_inflight_text, _message_preview). Re-add the definition verbatim from main so session.create / _start_inflight_turn don't crash with NameError on first prompt submit. * fix(tui-gateway): restore _content_display_text helper lost in main merge The May 27 merge of origin/main into bb/gui re-introduced two callers of _content_display_text (in _inflight_text and _history_to_messages) but dropped the helper definition itself, leaving an unresolved reference. NameError fires on every user message via _start_inflight_turn -> _inflight_text, taking down both the TUI and the desktop (which share this gateway backend) the moment input is dispatched. Restores the helper verbatim from main (commit36c99af37) -- pure structured-content text extractor, no other dependencies. * fix(telegram): import Set for _dm_topic_chat_ids annotation self._dm_topic_chat_ids: Set[str] = {...} at line 460 references Set but only Dict, List, Optional, Any are imported from typing. The file has no 'from __future__ import annotations', so the annotation is evaluated at runtime and raises NameError on TelegramAdapter construction. * fix(setup): drop shadowing inner importlib.util re-imports _print_setup_summary and _setup_tts_provider each had 'import importlib.util' inside a try: block nested deeper in the function body. Python flips importlib to function-local for the whole scope, so earlier references in the same function (the neutts branches at lines 493 / 1109) hit UnboundLocalError before the late import can run. The top-of-module 'import importlib.util' at line 14 already covers both call sites, so dropping the redundant inner imports restores the intended behavior. * feat(install.ps1): add -IncludeDesktop switch + Stage-Desktop The new Hermes-Setup.exe (Tauri bootstrap installer) passes -IncludeDesktop so users who install via the GUI end up with a launchable Hermes.exe at apps/desktop/release/<os>-unpacked/. Existing flows are unchanged: * The 'irm install.ps1 | iex' CLI one-liner omits the flag — terminal users don't need a prebuilt desktop binary; 'hermes desktop' builds on demand. * The Electron desktop's bootstrap-runner.cjs also omits the flag — rebuilding apps/desktop from inside a running Hermes.exe would try to overwrite the live binary on disk and fail. Stage-Desktop runs after Stage-NodeDeps so workspace npm is already installed when electron-builder fires. It does: 1. 'npm install' at repo root so apps/* workspaces resolve their deps (Electron itself arrives via npm here, ~150MB) 2. 'npm run pack' in apps/desktop (tsc + vite + electron-builder --dir) 3. Probes apps/desktop/release/{win-unpacked,win-arm64-unpacked}/Hermes.exe The --dir mode produces an unpacked launchable binary without an NSIS/MSI installer artifact — we don't need one because Hermes-Setup.exe spawns the unpacked binary directly via launch_hermes_desktop. * feat(installer): Tauri bootstrap installer for first-time onboarding Hermes-Setup.exe is a small signed Rust+Tauri binary that drives scripts/install.ps1 stage-by-stage with a native UI matching the desktop's design language. Replaces the chicken-and-egg pattern of shipping a 200MB Electron app whose first launch existed only to run install.ps1. The architecture: Rust backend (src-tauri/): bootstrap.rs orchestrator -- Tauri commands, stage iteration install_script.rs resolve install.ps1 (dev checkout, cache, GitHub raw) powershell.rs spawn powershell, line-stream stdout/stderr, parse JSON events.rs BootstrapEvent types -- mirror bootstrap-runner.cjs paths.rs HERMES_HOME resolution + tracing log setup build.rs bakes BUILD_PIN_COMMIT / BUILD_PIN_BRANCH from 'git rev-parse HEAD' at compile time React frontend (src/): Tauri webview rendering 4 screens (welcome / progress / success / failure), driven by nanostores subscribing to the Rust event stream. Visual layer reuses the desktop's styles.css wholesale via @import so the installer and desktop never drift visually. Distribution: targets = ['app', 'dmg', 'appimage'] -- no NSIS/MSI wrapper. The raw target/release/Hermes-Setup.exe IS the artifact on Windows; .dmg + .app on macOS; AppImage on Linux. One file, double-click, no installer-installing-an-installer pattern. Compile-time pinning: build.rs reads 'git rev-parse HEAD' and emits cargo:rustc-env=BUILD_PIN_COMMIT=<sha> + BUILD_PIN_BRANCH=<branch>. bootstrap.rs's option_env!() picks these up so the binary fetches install.ps1 from the exact SHA it was tested against. CI / release builds can override via HERMES_BUILD_PIN_COMMIT env var. Windows manifest: hermes-setup.manifest declares level='asInvoker' so the productName 'Hermes Setup' doesn't trip Windows's installer- detection heuristic and refuse to launch without elevation. Also declares PerMonitorV2 DPI + UTF-8 active code page + Common Controls v6. Limitations of this initial version: * No code signing -- Windows SmartScreen will warn once on Hermes-Setup.exe ('More info -> Run anyway'). The downstream binaries it produces (Hermes.exe in win-unpacked/, the hermes CLI) are locally-built and therefore don't carry MOTW, so they launch without SmartScreen intervention. Cert procurement tracked separately. * macOS and Linux build paths defined but untested -- Windows-only V1. * fix(installer): pass -IncludeDesktop to manifest, surface launch errors, alias hermes desktop Three bugs found in the first VM end-to-end test: 1. install.ps1 -Manifest was called WITHOUT -IncludeDesktop, so the manifest came back with the 14-stage list (no desktop stage), the UI showed '14 steps' and Stage-Desktop never ran. Pass the flag to both the manifest fetch and the per-stage runs — install.ps1 gates the desktop stage's inclusion on the flag. 2. The Success screen's Launch button silently swallowed the Tauri error when no Hermes.exe existed (e.g. Stage-Desktop was skipped). Wire the error through to inline UI with an alert callout, so the user gets actionable text ('Hermes.exe missing, run hermes desktop from a terminal') instead of an unresponsive button. 3. The Success screen tells users to run 'hermes desktop' from a terminal but the CLI only accepted 'hermes gui' — invalid choice for 'desktop'. Rename the subcommand canonically to 'desktop' with 'gui' as a backwards-compatible alias. Update the _SUBCOMMANDS sets used by session-flag arg parsing + logging-mode probe so both names route to the same logic. * fix(install.ps1): pre-warm electron-builder winCodeSign cache + fix Stage-Desktop $HasNode false-skip Two bugs caught in the second VM end-to-end run: 1. electron-builder's winCodeSign extraction fails on grandma-class Windows boxes because the .7z archive contains macOS symlinks (darwin/10.12/lib/libcrypto.dylib and libssl.dylib pointing at versioned siblings). Creating symlinks on Windows requires SeCreateSymbolicLinkPrivilege, a per-user right that non-admin accounts don't have on stock Windows. Result: every fresh install on a non-admin user fails Stage-Desktop with a 7-Zip 'cannot create symbolic link' error, retried four times, then bails. Fix: Initialize-ElectronBuilderCache pre-extracts winCodeSign-2.6.0.7z ourselves with -snl (don't preserve symlinks, store as resolved file content) AND -x!darwin (skip the entire macOS subtree — irrelevant on Windows). Writes to electron-builder's expected cache dir before electron-builder gets a chance to try its own broken extraction. Idempotent — fast-paths via signtool.exe sentinel check. 2. Install-Desktop's first guard was 'if (-not $HasNode) skip'. $HasNode is set by Stage-Node into $script:HasNode, but in cross-process driver mode (each -Stage NAME is a fresh powershell.exe spawned by Hermes-Setup.exe), that script-scope variable from the PREVIOUS process is invisible — so the guard always fired and Install-Desktop returned in 900ms with a misleading 'Node.js not available' reason. The real npm probe below it never got to run. Fix: re-probe npm directly via Get-Command when $HasNode is empty/false, since by that point Stage-Node has already verified Node is installed and the only question is whether *this* process can see it on PATH (it can — installer-wide PATH update from Stage-Node). * fix(install.ps1): tell electron-builder we're NOT signing instead of pre-extracting winCodeSign The previous commit (c7e46f9f3) worked around the winCodeSign-symlinks- on-Windows extraction crash by pre-extracting the archive ourselves with -snl + -x!darwin. That fix was correct but addressed the wrong layer. The deeper question: why was electron-builder fetching winCodeSign at all when we have no signing cert configured? Answer: electron-builder unconditionally pre-warms the toolchain assuming any build MIGHT sign. The cert auto-discovery never finds anything (we never set CSC_LINK or anything else), so the signing never happens — but the 100MB fetch of winCodeSign and its broken-on-Windows symlink extraction does. Set CSC_IDENTITY_AUTO_DISCOVERY=false (with WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD also explicitly cleared as belt-and-suspenders) before invoking npm run pack, and electron-builder skips the entire winCodeSign apparatus. No download, no extraction, no privilege check. Env vars are saved/restored around the invocation so we don't leak the override into Stage-PlatformSdks etc. Net: removes the 100-line Initialize-ElectronBuilderCache helper that manually downloaded + extracted winCodeSign-2.6.0.7z. Replaced with 3 env-var assignments. The produced Hermes.exe is functionally identical — just no longer carries a code-signing-machinery dependency we never used. * fix(installer): bump bootstrap-installer.log to capture stage transitions + every install.ps1 line Diagnosing the second VM failure was impossible because bootstrap-installer.log contained only the 'starting' banner. Two causes: 1. emit_log() inside run_bootstrap() was tracing::debug! — dropped on the floor under the default INFO env-filter. 2. The per-stage sink callbacks (on_stdout_line / on_stderr_line) only emitted Tauri events to the frontend; they never tee'd to the log file at all. When the failure route mounts, the Tauri event stream is the only place the script output lived, and it gets discarded. 3. The Failed / Stage / Manifest / Complete lifecycle frames in emit_event() were also Tauri-only — so even the 'which stage failed' frame never reached the log. Fixes: * emit_log() → tracing::info! * Sink callbacks tee stdout to info!, stderr to warn!, with stage label as a structured field for grep'ability * emit_event() now matches on the variant and logs each lifecycle frame at the right level: Failed → tracing::error!, others → info! Result: a failing install leaves a complete forensic trail in bootstrap-installer.log — manifest stage list, every install.ps1 stdout/stderr line tagged by stage, the stage transitions, and the final error. Same path as before so nothing the user does changes. * fix(install.ps1): Stage-NodeDeps cross-process $HasNode + stream npm install output to bootstrap log VM run 3 diagnosis: node-deps stage skipped on the VM (logged 'Skipping Node.js dependencies (Node not installed)') and then desktop's npm install failed with exit 1 and zero diagnostic detail. Two root causes: 1. $HasNode false-skip in Stage-NodeDeps — same cross-process bug pattern we fixed for Stage-Desktop inc7e46f9f3. Stage-Node ran in process A and set $script:HasNode = $true, then exited. Stage- NodeDeps ran in fresh process B (Hermes-Setup.exe -Stage NAME spawns each stage independently), where that variable doesn't exist. Re-probe via Get-Command npm instead of trusting the stale script-scope global. The previous stage already verified Node so the re-probe succeeds. 2. npm install --silent + Tee to TEMP file hid the real error. When the workspace install failed on the VM, the actual reason was buffered in $env:TEMP\hermes-npm-desktop-install-*.log and the user saw only 'exit 1'. Drop --silent so npm streams its full output, drop the TEMP-file dance — the Tauri installer's streaming sink already tees every stdout/stderr line to the rolling bootstrap-installer.log, so a side log file is dead weight that hides the very error we need. After this, the bootstrap log on a failure will contain npm's full output (deprecation warnings, ETARGET, native-module compile errors, whatever) tagged with stage=desktop, making the actual cause diagnosable instead of an opaque exit code. * fix(install.ps1): restore Initialize-ElectronBuilderCache (CSC env vars alone aren't enough) VM run 4 diagnosis: even with CSC_IDENTITY_AUTO_DISCOVERY=false set, electron-builder still fetches winCodeSign and signs bundled binaries. The log shows the signing happens BEFORE the cache extraction: • signing with signtool.exe ...\winpty-agent.exe • signing with signtool.exe ...\OpenConsole.exe • downloading winCodeSign-2.6.0.7z • <symlink privilege error> Cause: node-pty's bundled prebuilds are listed in apps/desktop's asarUnpack ['**/*.node', '**/prebuilds/**']. electron-builder re-signs anything unpacked from asar, regardless of whether OUR binary gets signed. The signtool invocation needs winCodeSign on disk, which needs the .7z extracted, which hits the macOS-symlink crash on non-admin Windows. The CSC env vars I added ind5fe46727only kill IDENTITY DISCOVERY (so OUR Hermes.exe stays unsigned, which is fine — we have no cert). They don't prevent the toolchain fetch for the bundled-prebuild re-sign. I removed the pre-extract ind5fe46727thinking the env vars subsumed it; that was wrong. Both are needed. Restoring Initialize-ElectronBuilderCache verbatim fromc7e46f9f3and keeping the CSC env vars. Wrote a clearer doc-comment at the call site explaining the two-knob interaction so future maintainers don't drop one half again. * fix(desktop): disable signtool via signtoolOptions.sign=null, drop dead winCodeSign pre-extract VM run 5 diagnosis: the pre-extract from3b29e65c1ran (extracted 83 files, 24MB) but produced ZERO files at the expected sentinel path '/winCodeSign-2.6.0/windows-10/x64/signtool.exe'. Cause: the .7z archive's root entries are 'windows-10/', 'darwin/', 'linux/', etc. — not 'winCodeSign-2.6.0/<arch>'. Extracting with '-o$cacheRoot' put files at $cacheRoot/windows-10/..., NOT at $cacheRoot/winCodeSign-2.6.0/windows-10/.... I had the directory nesting wrong from the start. And then we observed: electron-builder downloads winCodeSign-2.6.0.7z under a random numeric filename ('384387955.7z') regardless of what's already extracted in the parent dir. The cache key isn't the dirname; it's content-addressed. So the pre-extract approach was doomed even if the path nesting had been right. Actual fix: signtoolOptions.sign=null in apps/desktop/package.json's win build config. electron-builder honors this and skips the bundled- prebuild signing entirely — no signtool invocation, no winCodeSign fetch, no symlink-privilege crash. The previous failures all stemmed from electron-builder pre-signing node-pty's bundled .exes (winpty-agent.exe, OpenConsole.exe) which are already author-signed upstream; re-signing with our nonexistent cert was overwriting good sigs with nothing useful anyway. Cost: when we DO get a real cert later, we'll add it back with the sign function pointing at the cert chain. Until then, all-null is the correct config and unblocks every non-admin Windows user. Removed Initialize-ElectronBuilderCache (the dead pre-extract). Removed the call site. Kept the CSC_IDENTITY_AUTO_DISCOVERY env vars as belt-and-suspenders against a future electron-builder change that might revive cert auto-discovery. * fix(desktop): use no-op sign function instead of sign=null VM run 6 still hit the symlink crash even with signtoolOptions.sign=null. electron-builder 26.8.1 treats null as 'use the default signtool path' rather than 'skip signing', so the winCodeSign fetch + extraction still fired for the bundled prebuild re-sign. The Electron docs (electronjs.org/docs/latest/tutorial/code-signing) make it clear signing is OPTIONAL and unsigned apps work fine — users just see SmartScreen on first launch. The electron-builder mechanism for 'don't actually sign anything' is to supply a custom sign function (via signtoolOptions.sign: '<path-to-cjs-module>') that resolves without invoking signtool. build-noop-sign.cjs is that module — a 5-line async function that returns undefined. electron-builder calls it for every binary it would have signed, gets back a resolved promise, and considers each binary 'signed.' No signtool spawn, no winCodeSign fetch, no symlink crash. When Nous's cert arrives, replace this file with a real signing hook (@electron/windows-sign-based or a direct signtool invocation). The architecture's signing-ready and the cutover is a one-file edit. * fix(desktop): signAndEditExecutable=false to skip signtool path entirely After reading app-builder-lib/winPackager.js line 216 + 231 directly: signAndEditExecutable is the ACTUAL hardcoded gate that short-circuits both signApp() (which signs Hermes.exe + every shouldSignFile match including bundled prebuilds) AND createTransformerForExtraFiles(). None of signtoolOptions.sign / sign:null / sign:<custom-fn> gate the winCodeSign download — that happens before they're consulted. What we lose: rcedit also runs through signAndEditResources, so disabling this drops PE metadata (file properties showing 'Hermes' / 'Nous Research' / file description). Cost is real but bounded: * Hermes.exe filename, icon, asar contents, app identity intact * Task Manager shows 'Hermes.exe' (the filename) not 'Hermes' (PE description) — minor downgrade * Start menu, taskbar, window title all work normally * SmartScreen will warn once (unsigned, same as before) When the cert lands, flip signAndEditExecutable back to default true, both signing AND rcedit return, PE metadata is restored. Removes the no-op sign function (build-noop-sign.cjs) since signAndEditExecutable=false prevents signtool from being invoked at all — the custom hook never gets called either. * feat(install.ps1): write .hermes-bootstrap-complete marker at end of install The desktop app's main.cjs resolver ladder has a 'bootstrap-needed' rung that fires when .hermes-bootstrap-complete is missing from ACTIVE_HERMES_ROOT. Pre-Hermes-Setup, this marker was written by the packaged-desktop's own bootstrap-runner.cjs at the end of its install flow. Now that Hermes-Setup.exe runs install.ps1 directly, install.ps1 needs to own the marker — otherwise the desktop sees no marker on first launch and triggers its legacy first-launch bootstrap (re-running install.ps1 from inside Electron, the exact recursion Hermes-Setup.exe was supposed to obviate). Implementation: * New Stage-BootstrapMarker (worker) → Write-BootstrapMarker (helper) * Slotted in the manifest right after platform-sdks, before the interactive configure/gateway stages, so it runs unconditionally when the install reaches the finalize phase * Schema mirrors apps/desktop/electron/main.cjs writeBootstrapMarker / isBootstrapComplete EXACTLY: {schemaVersion: 1, pinnedCommit, pinnedBranch, completedAt}. Schema version stays at 1 so old desktops that read marker files written by future install.ps1s can still parse them. * pinnedCommit comes from -Commit flag (Hermes-Setup.exe passes it) or falls back to 'git rev-parse HEAD' in InstallDir * pinnedBranch from -Branch flag, defaults to 'main' matching install.ps1's own param default Two PS-5.1 gotchas baked into comments: * The ?. null-conditional operator doesn't exist pre-PS7; use explicit if-checks on Get-Command results * Set-Content -Encoding UTF8 emits a BOM in 5.1 and Node's plain JSON.parse rejects BOM — write via .NET's UTF8Encoding(false) to produce BOM-less JSON the desktop's readJson() can parse * feat(installer): drive in-app updates through the Tauri installer Converge update on the same principle as bootstrap: one driver owns all repo mutation. The desktop becomes a pure consumer that hands off to Hermes-Setup.exe --update instead of re-implementing git/pip in Electron. - hermes desktop --build-only: build without launching, so the installer owns the post-update launch (CLI keeps build logic single-sourced). - Installer AppMode {Install,Update} from argv; get_mode exposed to the UI. - Installer self-copies to HERMES_HOME/hermes-setup.exe on install success (no-op guard during --update re-invocation to avoid the locked-exe copy). - Installer --update flow (update.rs): wait for the desktop to release the venv shim, run 'hermes update --yes --gateway' (branch on exit 0/2/other), then 'hermes desktop --build-only', then launch the rebuilt desktop. Reuses the bootstrap event channel + progress UI via a synthetic two-stage manifest. - Desktop applyUpdates() gutted (~105 lines of git/stash/pull/pyproject/pip removed) -> thin handoff: spawn updater, app.quit() to free the shim. Detection (checkUpdates, commit changelog, behind-count) kept intact. - install.ps1 creates Start Menu + Desktop shortcuts to the packed Hermes.exe (never bare 'hermes desktop', which would rebuild every launch). * test update * fix(installer): pass --branch to hermes update in the --update flow The install is a detached-HEAD checkout of a pinned commit. Without --branch, 'hermes update' fell back to its default (main) and switched the checkout to main — a divergent branch that lacks the desktop CLI command — so the update targeted the wrong branch and the rebuild stage failed with 'invalid choice: desktop'. Thread BUILD_PIN_BRANCH (the branch this installer was built against, and the same branch the desktop detected the update on) into 'hermes update --branch <b>' so update + rebuild stay on-branch. * test update * fix(installer): stamp Hermes icon onto Hermes.exe via rcedit (no winCodeSign) The unpacked Hermes.exe showed the stock Electron icon + name in the taskbar because build.win.signAndEditExecutable=false disables BOTH electron-builder's signing AND its rcedit metadata/icon stamping. That flag is load-bearing: enabling it re-triggers signtool -> winCodeSign, whose macOS symlinks crash 7-Zip on non-admin Windows (unfixable dead end). Decouple identity-stamping from signing entirely: after npm run pack, run rcedit ourselves on the produced exe. - Add rcedit as a direct devDependency of apps/desktop (the transitive electron-winstaller copy is fragile). - apps/desktop/scripts/set-exe-identity.cjs: Node helper that calls rcedit's named export to set icon + ProductName/FileDescription/ CompanyName. Node builds argv natively — avoids the PowerShell->exe ->JSON double-escaping that broke the app-builder rcedit path. - install.ps1 Set-DesktopExeIdentity invokes the script after the build, before shortcuts. Best-effort: failure keeps the stock icon, never fails the install. rcedit is a pure PE editor — no signtool, no winCodeSign, no symlinks. Verified locally: stamping a copy of the built Hermes.exe embeds the 32x32 icon and sets ProductName=Hermes. Also fix update-path success-screen flash: in update mode the installer hands off + exits in ~600ms, so don't route to the 'launch Hermes' success view (it flashed before the window closed). * update test * fix(desktop): show 'hermes update' guidance for CLI installs instead of dead-end error A user who installed via the CLI (irm|iex / install.sh) then ran `hermes desktop` has no staged hermes-setup.exe, so clicking Update in-app hit resolveUpdaterBinary()=null and showed a misleading error ('re-run the Hermes installer') with a Try-again button that could never succeed — a dead loop for a perfectly valid install. Treat the no-updater case as an intentional outcome, not a failure: - main.cjs applyUpdates returns { ok:true, manual:true, command:'hermes update' } (no throw, no 'error' stage) when no updater binary exists. - New 'manual' update stage + apply-state.command thread the command to the UI. - updates-overlay ManualView: a polished terminal-native card with the exact command and a copy button, framed as the correct path for a CLI user rather than an error. GUI-installer users are unaffected — hermes-setup.exe present => seamless auto-update runs as before. Zero new process orchestration; can't fail the update demo. * update test * fix(gui): pin /api/hermes/update to the current branch The desktop command-center 'update' action hits POST /api/hermes/update, which spawned bare `hermes update` with no --branch. cmd_update then falls back to its default (main) and checks the working tree OUT of the tracked branch — a bb/gui install silently jumped to main and lost the desktop CLI. Resolve the checkout's current branch and pass --branch <current> from this endpoint only. The engine default (main) is DELIBERATELY unchanged: bare `hermes update` from a terminal, the gateway /update bot command, and the CLI/TUI relaunch path all keep their long-standing 'update against main' contract for the existing user base. Only the GUI button is scoped to update-the-branch-you're-on. Detached HEAD / git failure falls back to the bare default. * update test * fix(desktop): branch-pin the CLI manual-update command card The 'Update from your terminal' card (shown to CLI installs with no staged updater) hardcoded bare `hermes update` — which defaults to main and would switch a bb/gui (or any non-main) checkout off-branch. Same bug we fixed for the GUI button, leaked into the card's copy text. Resolve the checkout's current branch and show `hermes update --branch <current>` for non-main checkouts; keep it bare for main so the card stays clean. Best-effort: bare fallback if branch detection fails. Matches the GUI button + installer --update contract; bare terminal/bot/TUI update paths still default to main, unchanged. * docs: phragg was here * feat(desktop): lead onboarding with Nous Portal + fix fresh-install detection (#34970) - Feature Nous Portal as the primary onboarding card (Recommended tag, app logo, single pitch line); collapse other OAuth providers behind an "Other providers" disclosure whose open/closed state persists. - Surface OpenRouter as a one-click API-key option inside the disclosure; move "I have an API key" to a quiet bottom-right link. - Treat "no provider configured" as a normal onboarding state, not a red error banner (provider-setup-errors copy match). - Fix setup.runtime_check: it reported ready when the resolved runtime had an empty credential or only implicit Bedrock/IAM, so fresh installs never saw onboarding. Now requires a usable credential. - Auto-wire Windows fonts for WSL2 users so the renderer renders real Segoe UI instead of the DejaVu fallback; make WSL detection env-independent via the /proc kernel marker. * feat(desktop): live elapsed timer on install bootstrap steps The first-launch install overlay showed a static "Installing" with no motion, so long steps (notably the repo clone) looked frozen. Stamp each stage's start time on the running transition and tick once a second so the active step shows live elapsed (e.g. "Installing · 1:23"), plus elapsed on the overall current-step line. Completed steps keep their final duration. * fix(desktop): resolve PortableGit for update checks + reserve titlebar tools space - runGit() hardcoded spawn('git'), which ENOENTs on fresh installer-driven Windows installs (git is PortableGit under %LOCALAPPDATA%\hermes\git, never on PATH) — so "Check for updates" failed with "Couldn't check for updates". Add resolveGitBinary() mirroring findGitBash (PortableGit → Git-for-Windows → PATH) and use it in runGit. - PageSearchShell rendered a full-width search input in the titlebar row, so on Windows its right edge slid under the fixed top-right tools + native window controls. Reserve that footprint via --titlebar-tools-* vars. * fix(desktop): stop streaming caret from shifting layout on completion The streaming caret (::after on the running message's last child) was an in-flow inline-block adding ~0.78em of inline width, which could wrap the last line mid-stream; when the caret is removed on completion the line un-wraps and reflows — the visible post-response layout shift. Net-zero its inline advance with a compensating negative margin so it paints at the text end without consuming layout width. * fix(desktop): stop completed-message layout shift while streaming The assistant message action bar used `hideWhenRunning`, which unmounts it whenever the thread is streaming. Since the bar reserves vertical space in each completed assistant message's footer (it's invisible-until-hover via opacity, not via mount), unmounting it collapsed every prior turn by the bar's height — then remounting on resolve grew them back, shifting the whole conversation (visible as "padding appears above the last user message"). Drop hideWhenRunning so the footer height is constant; the bar stays invisible during streaming via its existing opacity/pointer-events gating. * fix(merge): keep windows-footgun suppressions inline * fix(merge): keep remaining gateway footgun suppressions inline * fix(merge): restore contracts caught by main-target CI * fix(dashboard): honor injected HERMES_DASHBOARD_SESSION_TOKEN The desktop shell mints a session token and signs its /api + /api/ws calls with it via HERMES_DASHBOARD_SESSION_TOKEN, but the main-merge restored a web_server.py that ignored the env var and minted its own random _SESSION_TOKEN -- so every desktop request 401'd and the UI reported "gateway offline". Read the injected token (fall back to a fresh random one) so loopback HTTP + WS auth line up. Adds a regression test so a future merge can't silently drop the read. * fix(desktop): align fresh-install home so upgraders don't brick Two related first-launch bugs on machines with a legacy ~/.hermes: - install.ps1 hardcoded $HermesHome/$InstallDir to %LOCALAPPDATA%\hermes and ignored the HERMES_HOME the desktop passes through. The desktop freezes HERMES_HOME at module load and prefers a legacy ~/.hermes when %LOCALAPPDATA%\hermes is absent, so the installer wrote to a different home than the shell read -> "Could not connect to Hermes gateway". Honor $env:HERMES_HOME in the param defaults. - isBootstrapComplete() trusted the marker + checkout without verifying a runnable venv, so an interrupted/split install spawned a dead backend instead of re-bootstrapping. Also require the venv python to exist. * fix(dashboard): allow packaged desktop file:// origin on loopback WS The packaged Electron desktop loads its renderer over file://, so its /api/ws handshake carries Origin: file:// (or null). The DNS-rebinding WebSocket Origin guard only accepted http(s) origins matching the bound host, so it rejected the desktop's own renderer with 4403 -> "Could not connect to Hermes gateway" on macOS. A browser DNS-rebinding attacker can only ever present an http(s) origin (the site hosting the malicious page); it cannot forge file://, null, or a custom app scheme AND hold the loopback session token. So on loopback binds we now trust non-web origins -- the token in _ws_auth_ok remains the real authenticator. Public/gated binds still reject them, and cross-site http(s) origins are still rejected everywhere. * fix(desktop): resolve renderer assets relative to BASE_URL Absolute public asset paths (/apple-touch-icon.png, /ds-assets/...) work under the dev server but break in the packaged app, where the renderer is loaded from file://.../index.html and a leading slash resolves to the filesystem root -> broken onboarding provider icon and backdrop image on macOS. Prefix these with import.meta.env.BASE_URL so they resolve next to the bundled index.html in both dev and packaged builds. * feat(desktop): automate first-launch bootstrap on macOS/Linux Previously a packaged macOS/Linux app with no Hermes install hit a dead-end ("first-launch install is not yet automated -- run install.sh manually") because install.sh lacked the staged protocol install.ps1 exposes. Now both platforms bootstrap on first launch with the same structured, per-step progress UI as Windows. - install.sh: add --manifest / --stage / --json / --non-interactive plus a stage dispatcher (prerequisites, repository, venv, python-deps, node-deps, path, config, setup, gateway, complete). User-input stages (setup, gateway) are skipped under --non-interactive; the in-app onboarding overlay owns API keys/model, matching the Windows flow. Each stage runs inside the install dir (its own process) and a new --commit flag pins the checkout to the build-stamp SHA. - bootstrap-runner.cjs: drive the staged manifest/stage/JSON protocol for both install.ps1 (PowerShell) and install.sh (bash), selected by installer kind; removed the single-blob POSIX shim. - main.cjs: drop the macOS/Linux unsupported-platform dead-end so the bootstrap-needed path runs the installer on every platform. * fix(dashboard): return 404 JSON for unmatched /api paths instead of SPA HTML The SPA catch-all (serve_spa) served index.html for any unmatched GET, including unregistered /api/* endpoints. A missing API route therefore came back as <!doctype html> with status 200, and JSON clients (the desktop app's fetchJson) crashed with an opaque 'SyntaxError: Unexpected token <' instead of a clear error. - web_server.py: unmatched /api or /api/... now returns 404 JSON ('No such API endpoint'); non-api paths still serve the SPA for client-side routing. - main.cjs fetchJson: detect an HTML body / text/html content-type on a 2xx response and reject with a clear message naming the URL, rather than a raw JSON.parse SyntaxError. Empty bodies resolve to null; malformed JSON reports the URL plus a snippet. * say 'OS appearance' instead of 'macOS appearance' * feat(install): add --include-desktop stage + PowerShell-style flags to install.sh Brings install.sh to parity with install.ps1's bootstrap surface so the shared Rust/Tauri bootstrapper (apps/bootstrap-installer) can drive a macOS/Linux install the same way it drives Windows. - Accept the PowerShell-style aliases the bootstrapper emits to both installers: -Commit / -Branch (alongside existing -Manifest / -Stage / -Json / -NonInteractive). - Add --include-desktop / -IncludeDesktop. When set, the manifest gains a 'desktop' stage (immediately before 'complete'), and a new install_desktop runs a root workspace `npm install` + `npm run pack` (electron-builder --dir, signing auto-discovery disabled) to produce release/mac*/Hermes.app -- mirroring install.ps1's Install-Desktop / Stage-Desktop. - The flag is opt-in, exactly like Windows: the signed bootstrap installer passes it; the Electron app's own first-launch bootstrap and the CLI one-liner omit it (building the desktop from inside the running app would clobber it). * fix: tts endpoints * macOS desktop: install + in-app self-update (#35607) * fix(installer): align macOS HERMES_HOME with the rest of the stack paths.rs computed the macOS Hermes home as ~/Library/Application Support/ hermes, but nothing else does: hermes_constants.get_hermes_home() (Python), scripts/install.sh, and the Electron desktop's resolveHermesHome() all use ~/.hermes on macOS. The drift meant the Tauri installer wrote the install to one directory and the desktop looked for it in another, so a fresh GUI install never found its backend (the file's own comment warned this exact drift would break things). Use ~/.hermes on macOS to match. * fix(install.sh): always emit a stage result frame on failure Stage helpers (clone_repo, install_deps, check_python, …) were written for the monolithic flow and call `exit 1` on failure. Under `--stage`, that terminated the process before the JSON result frame was printed, so the installer's parse_stage_result saw "no frame" instead of a clean {ok:false,...} contract response. Run the stage body in a subshell so an `exit` only unwinds the subshell and the parent still emits the frame. * feat(install.sh): auto-provision git on macOS/Linux (parity with install.ps1) install.ps1 downloads PortableGit on Windows, but install.sh just printed a "please install git" hint and exited — so a fresh Mac with no developer tools (no Xcode CLT → no git) couldn't get past the clone step. check_git now tries to install git before bailing: - macOS: Homebrew if present (headless), else `xcode-select --install` (the CLT prompt also provides the compiler some wheels need), polling for git to appear. - Linux: apt/dnf/pacman via sudo when available. Falls back to the manual instructions only if auto-provision fails. * feat(desktop): in-app GUI+backend self-update on macOS/Linux On Windows the staged Hermes-Setup binary drives updates (quit → hermes update → hermes desktop --build-only → relaunch). The mac drag-install has no such binary, so "Update now" previously just printed `hermes update`. Since there's no venv-shim file lock on POSIX, the desktop can drive the whole update itself. applyUpdates now, when no staged updater exists on mac/linux: 1. runs `hermes update --yes [--branch <current>]` (backend git pull + deps), 2. runs `hermes desktop --build-only` (OS-aware GUI rebuild) with the Hermes-managed Node + venv on PATH, 3. spawns a detached swapper that waits for this process to exit, dittos the freshly built Hermes.app over the running bundle, clears quarantine, and relaunches. Degrades to "backend updated — restart to load the new GUI" if the rebuild fails or there's no .app bundle to swap (dev run, Linux AppImage). * chore: uptick * chore: uptick * chore: linux build * fix(install): detect xcode-select git stub on fresh macOS * chore: bump * fix(desktop): repair voice dictation on Windows Voice dictation was broken on Windows in two ways: 1. Mic access was denied. The Electron permission request handler only granted 'media' requests whose details.mediaTypes included 'audio', but Chromium on Windows frequently fires the mic request with an empty mediaTypes array, so getUserMedia threw NotAllowedError. The handler now grants audio-capture when mediaTypes includes 'audio' OR is empty/absent, handles the 'audioCapture' permission name, and adds a setPermissionCheckHandler (the synchronous path Chromium also consults for getUserMedia on Windows). Video is still denied. 2. Transcripts went nowhere. The composer's insertText handler (used by dictation and other inserts) only updated the assistant-ui composer store via setText, never the contentEditable editor DOM. The draft->editor sync effect only re-renders the editor when it is NOT focused, and dictation runs while the editor has/regains focus, so the transcript was stored but never shown and could not be sent. insertText now renders into the editor DOM and places the caret, mirroring appendExternalText. Also hardens fetchJson: a 2xx response with an HTML body (or text/html content-type) now rejects with a clear message naming the URL instead of an opaque JSON.parse 'Unexpected token <' error. * feat(desktop): route Nous subscribers onto the Tool Gateway from the GUI When the GUI sets the main provider to Nous via POST /api/model/set, call the same apply_nous_managed_defaults the CLI uses after model selection, so GUI/onboarding users land on the Nous Tool Gateway the same way CLI users do — no separate prompt, no duplicated logic. Purely additive: apply_nous_managed_defaults skips any tool where the user has a direct key (FIRECRAWL_API_KEY, FAL_KEY, etc.) or explicit config, so it never overwrites a user's own setup. Only unconfigured tools get routed. - web_server.py: in set_model_assignment (scope=main, provider=nous), resolve enabled toolsets and apply managed defaults; guarded so a Portal hiccup never blocks saving the model. Returns routed tools as gateway_tools. - onboarding.ts: surface a 'Tool Gateway enabled' toast listing routed tools. - types/hermes.ts: add gateway_tools to ModelAssignmentResponse. - tests: cover nous-applies, non-nous-skips, and failure-doesnt-block-save. * feat(desktop): mirror hermes model free/paid curation in GUI onboarding GUI onboarding picked models[0] from /api/model/options, which ignores the Nous free/paid tier — a free user could land on a paid default (e.g. anthropic/claude-opus-4). Now the recommended default mirrors what `hermes model` does. - web_server.py: new GET /api/model/recommended-default?provider=<slug>. For Nous it runs the same curation as the CLI (get_curated_nous_model_ids + pricing + check_nous_free_tier + union_with_portal_{free,paid}_recommendations + partition_nous_models_by_tier) so free users get a free model and paid users get the curated default. Other providers fall back to the first curated model. Never 500s — returns empty model on error so onboarding degrades gracefully. - hermes.ts: getRecommendedDefaultModel client + RecommendedDefaultModel type. - onboarding.ts: fetchProviderDefaultModel prefers the recommended endpoint, falls back to models[0] when unavailable. - tests: free-tier picks free model, paid-tier picks curated default, failure returns empty without 500. * feat(desktop): show model pricing + free/paid tier gating in GUI picker The CLI `hermes model` picker shows per-model $/Mtok pricing and gates paid models on free Nous accounts. The GUI picker showed bare model names. Bring it to parity across both the model-picker dialog and onboarding confirm card. Backend: - inventory.build_models_payload gains a pricing=True flag → _apply_pricing enriches each provider row with formatted per-model pricing ({input,output,cache,free}) via the same _format_price_per_mtok the CLI uses, and for Nous adds free_tier + unavailable_models (paid models a free user can't select) via check_nous_free_tier + partition_nous_models_by_tier. Best-effort: any pricing/tier failure is swallowed and fails open (no gating). - /api/model/options and TUI model.options now pass pricing=True so the global picker and in-session picker both carry pricing. Frontend: - ModelOptionProvider gains pricing/free_tier/unavailable_models; new ModelPricing type. - model-picker dialog renders In/Out $/Mtok (or a Free pill) per model, a Free tier/Pro badge on the Nous heading, and disables + grays unavailable paid models for free users with a 'Pro models need a paid subscription' note. - onboarding confirm card shows the chosen model's price + tier badge. Tests: test_inventory_pricing covers price formatting, free-tier gating, paid no-gating, providers without pricing, and swallowed failures. * fix(desktop): GUI model picker shows curated Nous list in curated order Two bugs made the GUI Nous model list diverge from the `hermes model` CLI picker: 1. Backend (model_switch.py): the Nous row in list_authenticated_providers fell through to cached_provider_model_ids("nous"), dumping the full live /v1/models catalog (~50 vendor-prefixed models, alphabetical). Now it uses the curated list AND applies the Portal free/paid recommendation union — exactly like _model_flow_nous in main.py — so newly-launched models such as stepfun/step-3.7-flash:free surface in curated order. Best-effort: falls back to the curated list alone if the Portal fetch fails. 2. Frontend (model-picker.tsx): cmdk's Command had shouldFilter on (default), which re-sorts items by fuzzy-match score (≈alphabetical) and ignores array order. Set shouldFilter={false} + own the search term and do an order-preserving substring filter, so the backend's curated order is shown verbatim. * feat(desktop): add/switch providers from the model picker via onboarding reuse The model picker could only select models from already-authenticated providers. Switching to a new provider had no in-app path. Rather than duplicate provider UI, reuse the existing onboarding provider selector (featured Nous + other providers + API-key form + device-code/PKCE flow + model-confirm with pricing/tier). - onboarding store: add a 'manual' flag with startManualOnboarding() / closeManualOnboarding(). Manual mode forces the onboarding overlay to show even when configured===true and refreshOnboarding no longer auto-dismisses on runtime-ready (the app is already working — the user is just adding or switching a provider). - onboarding overlay: render when manual even if configured; show a Close button (the first-run flow has none since the app can't run yet). - model picker: 'Add provider' footer button opens the onboarding selector; ModelResults lists only configured (model-bearing) providers. * feat(desktop): add PUT /api/tools/toolsets/{name} enable/disable endpoint * feat(desktop): add toggleToolset RPC binding * feat(desktop): toolset enable/disable switch in Tools settings * feat(desktop): tool configuration parity in GUI Tools settings Bring the desktop GUI Tools settings to parity with the CLI `hermes tools` for provider selection and API-key configuration. Backend (hermes_cli/web_server.py): - GET /api/tools/toolsets/{name}/config - provider matrix + key status - PUT /api/tools/toolsets/{name}/provider - persist provider selection Shared core (hermes_cli/tools_config.py): - Extract apply_provider_selection / _write_provider_config from the interactive _configure_provider so the CLI and GUI write identical config keys (web.backend, tts.provider, browser.cloud_provider, plugin image/video providers, use_gateway flags) through one code path. Desktop UI: - ToolsetConfigPanel: provider list with select, per-provider API-key entry (set/replace/clear/reveal via the shared env RPCs), Ready/Needs keys state, guidance for Nous-auth and post-setup providers. - Wire the Configured/Needs keys pill to expand the panel inline; refresh the toolset list after key changes so the pill updates live. - Add getToolsetConfig / selectToolsetProvider RPC bindings + types. Post-setup (OAuth/install) flows still defer to the CLI; see docs spike findings for the planned /api/tools/setup/* endpoint family. Tests: backend round-trip + 400 cases for the new endpoints and apply_provider_selection; desktop vitest coverage for the config panel (provider render, select, key save). No change-detector tests. Also removes three stale completed plan docs. * fix(desktop): show real Hermes version + sync package.json on release The desktop app version was disconnected from the Hermes version: the release script bumped pyproject.toml + hermes_cli/__init__.py but never touched apps/desktop/package.json, which sat stale at 0.0.2 (lockfile at 0.0.1). - main.cjs: hermes:version IPC now resolves __version__ from hermes_cli/__init__.py (the canonical source release.py bumps) via a new resolveHermesVersion() helper, falling back to app.getVersion() when the source tree isn't readable. The About panel now always shows the live Hermes version and can't drift. - release.py: update_version_files() also bumps apps/desktop/package.json in lockstep with pyproject (top-level version only; dep specs untouched). - One-time catch-up: package.json 0.0.2 -> 0.15.1 and the lockfile root mirrors 0.0.1 -> 0.15.1. * fix(desktop): stamp exe identity in afterPack hook so updates stay branded The packed Hermes.exe reverted to the stock Electron icon + "Electron" name after an in-app update. The icon/identity stamp (rcedit) lived only in install.ps1, but the installer's --update path rebuilds the desktop via `hermes desktop --build-only` -> `npm run pack`, which never ran install.ps1 and so never stamped the rebuilt exe. Move the stamp into an electron-builder afterPack hook so it runs for EVERY packed build regardless of caller (first install, hermes desktop, the update rebuild, or a manual npm run pack): - set-exe-identity.cjs: refactor to export stampExeIdentity(exe, desktopRoot); still runnable as a standalone CLI. - after-pack.cjs (new): afterPack hook calling stampExeIdentity. Windows-only guard; best-effort (logs + resolves on failure, never fails the build). - package.json: register build.afterPack. - install.ps1: remove the now-redundant Set-DesktopExeIdentity function + call; the hook handles it during npm run pack. electron-builder's own rcedit step stays disabled (signAndEditExecutable=false) to avoid the signtool -> winCodeSign -> 7-Zip macOS-symlink crash on non-admin Windows; the hook runs rcedit directly (pure PE resource edit, no signing). * fix(desktop): export afterPack hook as exports.default so electron-builder runs it The afterPack hook used `module.exports = fn`, which electron-builder's hook loader doesn't pick up — it expects the function as the module's default export (the same shape afterSign/notarize.cjs uses). The hook silently never ran, so even first install shipped the stock "Electron" exe. Switch to `exports.default = async function afterPack(...)`. Verified with a real `npm run pack`: electron-builder now invokes the hook and the produced release/win-unpacked/Hermes.exe carries ProductName/FileDescription=Hermes. * chore(desktop): drop auto-build release CI in favor of manual build + upload Remove desktop-release.yml (nightly-on-main + stable publish). Installers are now built locally per platform and uploaded to a GitHub Release by hand; the website points at them via NEXT_PUBLIC_HERMES_DL_* env. Update README + docs and drop the dead desktop-nightly channel links. * fix(desktop): stable shortcut icon + bust icon cache so updates repaint Symptom on a freshly-installed laptop: Hermes.exe itself shows the correct Hermes icon (Explorer reads the live exe's stamped PE resource), but the desktop shortcut still draws the stock Electron icon. Cause: New-DesktopShortcuts set IconLocation to "<exe>,0", so Windows cached the icon it extracted from the exe at shortcut-creation time. On an update the exe gets re-stamped, but the shortcut keeps rendering the stale cached bitmap. - package.json: ship assets/icon.ico beside the exe via extraResources (-> resources/icon.ico). Verified with a real npm run pack. - install.ps1 New-DesktopShortcuts: point IconLocation at resources/icon.ico (fallback to <exe>,0 if absent) — a dedicated .ico is cache-stable and skips the per-exe extraction that goes stale. Then run `ie4uinit.exe -show` to bust the shell icon cache so the shortcut repaints immediately instead of showing the old Electron icon until reboot. Both best-effort; never fail an otherwise-good install. * dummy update * feat(desktop): self-heal update branch + backend contract guard Two fixes for the bb/gui→main transition: - Self-update self-heals: if the tracked branch (e.g. bb/gui) no longer exists on origin (merged + deleted), the desktop updater falls back to main and persists it. Read-only ls-remote probe that only flips on a definitive "ref absent" (exit 2), never on a transient network error, so already-installed clients migrate themselves with no manual flip. - Backend contract guard: tui_gateway reports DESKTOP_BACKEND_CONTRACT in session runtime info; the desktop warns with a one-click "Update Hermes" when the backend predates the GUI's required contract (e.g. a bb/gui app pointed at a main checkout) instead of failing cryptically downstream. * docs(desktop): rewrite README to match current install/update/build flow The old README contradicted itself (claimed a bundled Python payload while also saying it no longer bundles source) and predated cross-platform support. Rewrite for accuracy: Linux is a first-class build target, install.sh/install.ps1 both drive the staged bootstrap, the real self-update handoff (Windows Hermes-Setup vs in-app macOS/Linux), and the bb/gui→main self-heal + backend contract guard. * docs(desktop): rewrite README as a real product readme Lead with what the app is and how to get it (download an installer, or `hermes desktop` for existing CLI users) plus a plain-language feature list, then keep contributor/build/internals as a clearly separated secondary section. * docs(desktop): fix install framing — releases no longer auto-build installers Lead with the install-with-Hermes path (`--include-desktop` / `hermes desktop`), which always works, and describe prebuilt installers as manually published when a release ships them rather than implying CI attaches them to every release. * docs(desktop): match base repo README style Adopt the root README's conventions: centered title + badge row, bold one-liner intro, a feature <table> grid, --- section dividers, and a Community / License footer. * feat(desktop): recover from gateway boot failures + validate API keys on entry (#35864) Fresh installs that hit a gateway boot failure had no recovery path: the shell rendered dead ("gateway offline"), logs were undiscoverable, and a mistyped API key was accepted because onboarding only checked credential presence, not validity. - Add BootFailureOverlay: a top-level recovery surface (Retry, Repair install, Use local gateway, Open logs + inline recent logs) that mounts on any hard boot failure, including post-install. Trims the now-redundant recovery button from the onboarding Preparing panel. - Add hermes:logs:reveal / :recent IPC (reveal desktop.log) and a hermes:bootstrap:repair IPC that drops the bootstrap marker to force a clean reinstall. Surface "Open logs" in Gateway settings too. - Add POST /api/providers/validate: a live per-provider probe (OpenRouter/OpenAI/xAI/Gemini key check, local endpoint connectivity) wired into saveOnboardingApiKey so a rejected key blocks before it's persisted, while an unreachable probe falls through (offline-safe). * test(model-catalog): fix stale nous picker test after curated-list changeac2e48907made the GUI/picker Nous row use the curated list (curated["nous"] = get_curated_nous_model_ids()) + Portal union, matching the `hermes model` CLI — but test_picker_nous_row_uses_manifest still asserted the old 2-model manifest snapshot, breaking the test shard. Rewrite it as an invariant: stub the Portal union to passthrough and assert the row equals get_curated_nous_model_ids() computed under the same conditions, so it tracks the real contract instead of a hardcoded model list that rots on every catalog update. --------- Co-authored-by: emozilla <emozilla@nousresearch.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Austin Pickett <pickett.austin@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: ethernet <arilotter@gmail.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
5984 lines
258 KiB
Python
5984 lines
258 KiB
Python
"""
|
||
Configuration management for Hermes Agent.
|
||
|
||
Config files are stored in ~/.hermes/ for easy access:
|
||
- ~/.hermes/config.yaml - All settings (model, toolsets, terminal, etc.)
|
||
- ~/.hermes/.env - API keys and secrets
|
||
|
||
This module provides:
|
||
- hermes config - Show current configuration
|
||
- hermes config edit - Open config in editor
|
||
- hermes config set - Set a specific value
|
||
- hermes config wizard - Re-run setup wizard
|
||
"""
|
||
|
||
import copy
|
||
import logging
|
||
import os
|
||
import platform
|
||
import re
|
||
import stat
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Dict, Any, Optional, List, Tuple
|
||
|
||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Track which (config_path, mtime_ns, size) tuples we've already warned about
|
||
# so concurrent CLI/gateway loads of a broken config.yaml don't spam stderr
|
||
# every time. Cleared automatically when the file changes (different mtime).
|
||
_CONFIG_PARSE_WARNED: set = set()
|
||
|
||
|
||
def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:
|
||
"""Surface a config.yaml parse failure to user, log, and stderr.
|
||
|
||
A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()``
|
||
to silently fall back to ``DEFAULT_CONFIG``, which means every user
|
||
override (auxiliary providers, fallback chain, model overrides, etc.)
|
||
is dropped. Before this helper that was a one-line ``print(...)`` that
|
||
scrolled off-screen on the first invocation and was never seen again.
|
||
|
||
Now: warn once per (path, mtime_ns, size) on stderr **and** in
|
||
``agent.log`` / ``errors.log`` at WARNING level so ``hermes logs``
|
||
surfaces it. Re-warns automatically if the file changes (different
|
||
mtime/size), so users editing the config see the next failure.
|
||
"""
|
||
try:
|
||
st = config_path.stat()
|
||
key = (str(config_path), st.st_mtime_ns, st.st_size)
|
||
except OSError:
|
||
key = (str(config_path), 0, 0)
|
||
if key in _CONFIG_PARSE_WARNED:
|
||
return
|
||
_CONFIG_PARSE_WARNED.add(key)
|
||
|
||
msg = (
|
||
f"Failed to parse {config_path}: {exc}. "
|
||
f"Falling back to default config — every user override "
|
||
f"(auxiliary providers, fallback chain, model settings) is being IGNORED. "
|
||
f"Fix the YAML and restart."
|
||
)
|
||
logger.warning(msg)
|
||
try:
|
||
sys.stderr.write(f"⚠️ hermes config: {msg}\n")
|
||
sys.stderr.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
_IS_WINDOWS = platform.system() == "Windows"
|
||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||
|
||
# Env var names that influence how the next subprocess executes —
|
||
# never writable through ``save_env_value``. Anything that controls
|
||
# the loader, interpreter, shell, or replacement editor counts:
|
||
#
|
||
# * ``LD_PRELOAD`` / ``LD_LIBRARY_PATH`` / ``LD_AUDIT`` — Linux dynamic
|
||
# loader. ``DYLD_*`` — macOS equivalent. Planting a path here means
|
||
# the next ``subprocess.run([...])`` Hermes makes loads attacker code
|
||
# before main().
|
||
# * ``PYTHONPATH`` / ``PYTHONHOME`` / ``PYTHONSTARTUP`` /
|
||
# ``PYTHONUSERBASE`` — Python interpreter init. Hermes itself starts
|
||
# from one of these on every restart.
|
||
# * ``NODE_OPTIONS`` / ``NODE_PATH`` — Node interpreter; affects npm,
|
||
# ``hermes update``, the TUI build.
|
||
# * ``PATH`` — too broad to allow. The dashboard never needs to rewrite
|
||
# the operator's PATH; if a tool can't be found, the fix is to add an
|
||
# absolute path in the integration config, not to mutate PATH globally.
|
||
# * ``GIT_SSH_COMMAND`` / ``GIT_EXEC_PATH`` — git rewrites that fire
|
||
# on every plugin install / ``hermes update``.
|
||
# * ``BROWSER`` / ``EDITOR`` / ``VISUAL`` / ``PAGER`` — commands the
|
||
# shell or CLI invokes implicitly. Wrong values here = RCE on next
|
||
# ``$EDITOR``.
|
||
# * ``SHELL`` — what subprocess uses with ``shell=True`` (we try to
|
||
# avoid that, but defense in depth).
|
||
# * ``HERMES_HOME`` / ``HERMES_PROFILE`` / ``HERMES_CONFIG`` /
|
||
# ``HERMES_ENV`` — Hermes runtime location flags. Writing these into
|
||
# ``.env`` would relocate state in ways the user did not request from
|
||
# the dashboard. ``config.yaml`` is the supported surface for these.
|
||
#
|
||
# IMPORTANT: ``HERMES_*`` overall is NOT blocked. Many legitimate
|
||
# integration credentials follow that prefix (HERMES_GEMINI_CLIENT_ID,
|
||
# HERMES_LANGFUSE_PUBLIC_KEY, HERMES_SPOTIFY_CLIENT_ID, ...). The
|
||
# denylist is name-by-name on purpose so the gate stays narrow and
|
||
# doesn't accidentally break provider setup wizards.
|
||
#
|
||
# This is enforced on *write* only — values already in ``.env`` (set
|
||
# by the operator out-of-band, or pre-existing) keep working. The
|
||
# point is that the dashboard's writable surface cannot escalate by
|
||
# planting them.
|
||
_ENV_VAR_NAME_DENYLIST: frozenset[str] = frozenset({
|
||
# Loader / linker
|
||
"LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "LD_DEBUG",
|
||
"DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH",
|
||
"DYLD_FALLBACK_LIBRARY_PATH", "DYLD_FALLBACK_FRAMEWORK_PATH",
|
||
# Python
|
||
"PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP", "PYTHONUSERBASE",
|
||
"PYTHONEXECUTABLE", "PYTHONNOUSERSITE",
|
||
# Node
|
||
"NODE_OPTIONS", "NODE_PATH",
|
||
# General
|
||
"PATH", "SHELL", "BROWSER", "EDITOR", "VISUAL", "PAGER",
|
||
# Git
|
||
"GIT_SSH_COMMAND", "GIT_EXEC_PATH", "GIT_SHELL",
|
||
# Hermes runtime location — never via dashboard env writer.
|
||
# NOT a HERMES_* blanket: integration credentials (HERMES_GEMINI_*,
|
||
# HERMES_LANGFUSE_*, HERMES_SPOTIFY_*, ...) ARE allowed.
|
||
"HERMES_HOME", "HERMES_PROFILE", "HERMES_CONFIG", "HERMES_ENV",
|
||
})
|
||
|
||
|
||
def _reject_denylisted_env_var(key: str) -> None:
|
||
"""Raise if ``key`` is in :data:`_ENV_VAR_NAME_DENYLIST`.
|
||
|
||
Centralised so both the regular and "secure" env writers share the
|
||
same gate, and so the message is consistent for callers.
|
||
"""
|
||
if key in _ENV_VAR_NAME_DENYLIST:
|
||
raise ValueError(
|
||
f"Environment variable {key!r} is on the writer denylist. "
|
||
"Names that influence subprocess execution (LD_PRELOAD, "
|
||
"PYTHONPATH, PATH, EDITOR, ...) or Hermes runtime location "
|
||
"(HERMES_HOME, HERMES_PROFILE, ...) cannot be persisted via "
|
||
"the env writer. If you really need this, edit "
|
||
"~/.hermes/.env directly."
|
||
)
|
||
|
||
_LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}
|
||
# (path, mtime_ns, size) -> cached expanded config dict.
|
||
# load_config() returns a deepcopy of the cached value when the file
|
||
# hasn't changed since the last load, skipping yaml.safe_load +
|
||
# _deep_merge + _normalize_* + _expand_env_vars (~13 ms/call).
|
||
# save_config() + migrate_config() write via atomic_yaml_write which
|
||
# produces a fresh inode, so stat() sees a new mtime_ns and the next
|
||
# load repopulates automatically — no explicit invalidation hook.
|
||
_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
|
||
# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as
|
||
# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
|
||
# the user's on-disk values without defaults merged in.
|
||
_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
|
||
# Serializes all config read/write paths. libyaml's C extension is not
|
||
# thread-safe for concurrent safe_load() on the same file, and multiple
|
||
# tool threads (approval.py, browser_tool.py, setup flows) hit
|
||
# load_config / read_raw_config / save_config from different threads
|
||
# during long agent runs. RLock (not Lock) because save_config internally
|
||
# calls read_raw_config. Also covers mutation of the module-level cache
|
||
# dicts above.
|
||
_CONFIG_LOCK = threading.RLock()
|
||
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
|
||
# (managed by setup/provider flows directly).
|
||
_EXTRA_ENV_KEYS = frozenset({
|
||
"OPENAI_API_KEY", "OPENAI_BASE_URL",
|
||
"ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
||
"DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME",
|
||
"TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME",
|
||
"SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME",
|
||
"SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
|
||
"SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
|
||
"SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME",
|
||
"SMS_HOME_CHANNEL", "SMS_HOME_CHANNEL_NAME",
|
||
"DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
|
||
"DINGTALK_HOME_CHANNEL", "DINGTALK_HOME_CHANNEL_NAME",
|
||
"FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
|
||
"FEISHU_HOME_CHANNEL", "FEISHU_HOME_CHANNEL_NAME",
|
||
"YUANBAO_HOME_CHANNEL", "YUANBAO_HOME_CHANNEL_NAME",
|
||
"WECOM_BOT_ID", "WECOM_SECRET",
|
||
"WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID",
|
||
"WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY",
|
||
"WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT",
|
||
"WECOM_HOME_CHANNEL", "WECOM_HOME_CHANNEL_NAME",
|
||
"WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL",
|
||
"WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
|
||
"WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
|
||
"BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD",
|
||
"BLUEBUBBLES_HOME_CHANNEL", "BLUEBUBBLES_HOME_CHANNEL_NAME",
|
||
"QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME",
|
||
"QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME", # legacy aliases (pre-rename, still read for back-compat)
|
||
"QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
|
||
"QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
|
||
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
|
||
"IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
|
||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
|
||
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
|
||
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD",
|
||
"MATRIX_RECOVERY_KEY",
|
||
# Langfuse observability plugin — optional tuning keys + standard SDK vars.
|
||
# Activation is via plugins.enabled (opt-in through `hermes plugins enable
|
||
# observability/langfuse` or `hermes tools → Langfuse`); credentials gate
|
||
# the plugin at runtime.
|
||
"HERMES_LANGFUSE_ENV",
|
||
"HERMES_LANGFUSE_RELEASE",
|
||
"HERMES_LANGFUSE_SAMPLE_RATE",
|
||
"HERMES_LANGFUSE_MAX_CHARS",
|
||
"HERMES_LANGFUSE_DEBUG",
|
||
"LANGFUSE_PUBLIC_KEY",
|
||
"LANGFUSE_SECRET_KEY",
|
||
"LANGFUSE_BASE_URL",
|
||
})
|
||
import yaml
|
||
|
||
from hermes_cli.colors import Colors, color
|
||
from hermes_cli.default_soul import DEFAULT_SOUL_MD
|
||
|
||
|
||
# =============================================================================
|
||
# Managed mode (NixOS declarative config)
|
||
# =============================================================================
|
||
|
||
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
|
||
_MANAGED_SYSTEM_NAMES = {
|
||
"brew": "Homebrew",
|
||
"homebrew": "Homebrew",
|
||
"nix": "NixOS",
|
||
"nixos": "NixOS",
|
||
}
|
||
|
||
|
||
def get_managed_system() -> Optional[str]:
|
||
"""Return the package manager owning this install, if any."""
|
||
raw = os.getenv("HERMES_MANAGED", "").strip()
|
||
if raw:
|
||
normalized = raw.lower()
|
||
if normalized in _MANAGED_TRUE_VALUES:
|
||
return "NixOS"
|
||
return _MANAGED_SYSTEM_NAMES.get(normalized, raw)
|
||
|
||
managed_marker = get_hermes_home() / ".managed"
|
||
if managed_marker.exists():
|
||
return "NixOS"
|
||
return None
|
||
|
||
|
||
def is_managed() -> bool:
|
||
"""Check if Hermes is running in package-manager-managed mode.
|
||
|
||
Two signals: the HERMES_MANAGED env var (set by the systemd service),
|
||
or a .managed marker file in HERMES_HOME (set by the NixOS activation
|
||
script, so interactive shells also see it).
|
||
"""
|
||
return get_managed_system() is not None
|
||
|
||
|
||
_NIX_UPDATE_MSG = "Update your Nix flake input and rebuild (e.g. nix flake update, nixos-rebuild, or home-manager switch)"
|
||
|
||
|
||
def get_managed_update_command() -> Optional[str]:
|
||
"""Return the preferred upgrade command for a managed install."""
|
||
managed_system = get_managed_system()
|
||
if managed_system == "Homebrew":
|
||
return "brew upgrade hermes-agent"
|
||
if managed_system == "NixOS":
|
||
return _NIX_UPDATE_MSG
|
||
return None
|
||
|
||
|
||
def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||
"""Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'.
|
||
|
||
Resolution order:
|
||
1. Stamped ``~/.hermes/.install_method`` file (written by installers)
|
||
2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
|
||
3. .git directory presence -> 'git'
|
||
4. Fallback -> 'pip'
|
||
|
||
Note: running inside a container is NOT treated as "docker" on its own.
|
||
The two supported install paths both self-identify via the
|
||
``.install_method`` stamp (caught by step 1), so neither relies on
|
||
container detection here:
|
||
- the curl installer (scripts/install.sh, the README/website install
|
||
command) git-clones the repo and stamps ``git``;
|
||
- the published ``nousresearch/hermes-agent`` image stamps ``docker``
|
||
at boot via ``docker/stage2-hook.sh``.
|
||
An unsupported manual install dropped into a container (no stamp) was
|
||
wrongly classified as the published image by bare container detection,
|
||
so ``hermes update`` bailed with "doesn't apply inside the Docker
|
||
container". Without that fallback such installs fall through to the
|
||
``.git``/pip checks and behave like any off-path install. See issue #34397.
|
||
"""
|
||
stamp = get_hermes_home() / ".install_method"
|
||
try:
|
||
method = stamp.read_text(encoding="utf-8").strip().lower()
|
||
if method:
|
||
return method
|
||
except OSError:
|
||
pass
|
||
managed = get_managed_system()
|
||
if managed:
|
||
return managed.lower().replace(" ", "-")
|
||
if project_root is None:
|
||
project_root = Path(__file__).parent.parent.resolve()
|
||
if (project_root / ".git").is_dir():
|
||
return "git"
|
||
return "pip"
|
||
|
||
|
||
def stamp_install_method(method: str) -> None:
|
||
"""Write the install method to ~/.hermes/.install_method."""
|
||
stamp = get_hermes_home() / ".install_method"
|
||
try:
|
||
stamp.parent.mkdir(parents=True, exist_ok=True)
|
||
stamp.write_text(method + "\n", encoding="utf-8")
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def is_uv_tool_install() -> bool:
|
||
"""Return True when the *running* Hermes lives in a ``uv tool`` layout.
|
||
|
||
``uv tool install hermes-agent`` places the install at
|
||
``.../uv/tools/hermes-agent/...`` (default ``~/.local/share/uv/tools``,
|
||
or ``$UV_TOOL_DIR/...``). Such installs live outside any virtualenv, so
|
||
``uv pip install`` fails with ``No virtual environment found`` and the
|
||
update path must use ``uv tool upgrade`` instead.
|
||
|
||
Detection is intentionally restricted to properties of the running
|
||
interpreter (``sys.prefix`` / ``sys.executable``). We deliberately do
|
||
NOT consult ``uv tool list``: it would also return True when
|
||
``hermes-agent`` happens to be uv-tool-installed on the machine while
|
||
the *active* Hermes is a regular pip/venv install, causing
|
||
``hermes update`` to upgrade the wrong copy. It would also block on a
|
||
subprocess call (~seconds) just to compute a recommendation string.
|
||
"""
|
||
def _has_uv_tool_marker(path: str) -> bool:
|
||
norm = os.path.normpath(path).replace(os.sep, "/").lower()
|
||
return "/uv/tools/hermes-agent/" in norm + "/"
|
||
|
||
if _has_uv_tool_marker(sys.prefix):
|
||
return True
|
||
if _has_uv_tool_marker(sys.executable or ""):
|
||
return True
|
||
return False
|
||
|
||
|
||
def recommended_update_command_for_method(method: str) -> str:
|
||
"""Return the update command or guidance for a given install method."""
|
||
if method == "nixos":
|
||
return _NIX_UPDATE_MSG
|
||
if method == "homebrew":
|
||
return "brew upgrade hermes-agent"
|
||
if method == "docker":
|
||
return "docker pull nousresearch/hermes-agent:latest"
|
||
if method == "pip":
|
||
if is_uv_tool_install():
|
||
return "uv tool upgrade hermes-agent"
|
||
import shutil
|
||
if shutil.which("uv"):
|
||
return "uv pip install --upgrade hermes-agent"
|
||
return "pip install --upgrade hermes-agent"
|
||
return "hermes update"
|
||
|
||
|
||
def recommended_update_command() -> str:
|
||
"""Return the best update command for the current installation."""
|
||
managed_cmd = get_managed_update_command()
|
||
if managed_cmd:
|
||
return managed_cmd
|
||
method = detect_install_method()
|
||
return recommended_update_command_for_method(method)
|
||
|
||
|
||
# Long-form text for ``hermes update`` / ``--check`` when running inside the
|
||
# Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in
|
||
# hermes_cli/main.py; lives here so the wording stays consistent and we
|
||
# don't grow two slightly-different copies.
|
||
#
|
||
# Why this matters:
|
||
# - The published image excludes ``.git`` (see .dockerignore), so the
|
||
# git-based update path can never succeed inside the container.
|
||
# - The pre-existing fallback message ("✗ Not a git repository. Please
|
||
# reinstall: curl ... install.sh") is actively misleading inside Docker
|
||
# — that script installs a *new* host-side Hermes, it doesn't update
|
||
# the running container.
|
||
# - The right action is ``docker pull`` + restart the container; this
|
||
# helper spells that out, with notes on tag pinning and config
|
||
# persistence so users don't get blindsided.
|
||
_DOCKER_UPDATE_MESSAGE = """\
|
||
✗ ``hermes update`` doesn't apply inside the Docker container.
|
||
|
||
Hermes Agent runs as a published image (nousresearch/hermes-agent), not a
|
||
git checkout — the container has no working tree to pull into. Update by
|
||
pulling a fresh image and restarting your container instead:
|
||
|
||
docker pull nousresearch/hermes-agent:latest
|
||
# then restart whatever started the container, e.g.:
|
||
docker compose up -d --force-recreate hermes-agent
|
||
# or, for ad-hoc runs, exit the current container and `docker run` again
|
||
|
||
Verify the new version after restart:
|
||
docker run --rm nousresearch/hermes-agent:latest --version
|
||
|
||
Notes:
|
||
• If you pinned a specific tag (e.g. ``:v0.14.0``) the ``:latest`` tag
|
||
won't move your container — pull the newer tag you actually want, or
|
||
switch to ``:latest`` / ``:main`` for rolling updates. See available
|
||
tags at https://hub.docker.com/r/nousresearch/hermes-agent/tags
|
||
• Your config and session history live under ``$HERMES_HOME`` (``/opt/data``
|
||
in the container, typically bind-mounted from the host) and persist
|
||
across image upgrades — re-pulling doesn't lose any state.
|
||
• Running a fork? Build your own image with this repo's ``Dockerfile``
|
||
and replace the ``docker pull`` step with your build/push pipeline."""
|
||
|
||
|
||
def format_docker_update_message() -> str:
|
||
"""Return the user-facing message for ``hermes update`` inside Docker.
|
||
|
||
Centralised so ``cmd_update`` (the apply path) and ``_cmd_update_check``
|
||
(the dry-run path) share the same wording. See ``_DOCKER_UPDATE_MESSAGE``
|
||
above for the full rationale.
|
||
"""
|
||
return _DOCKER_UPDATE_MESSAGE
|
||
|
||
|
||
def format_managed_message(action: str = "modify this Hermes installation") -> str:
|
||
"""Build a user-facing error for managed installs."""
|
||
managed_system = get_managed_system() or "a package manager"
|
||
raw = os.getenv("HERMES_MANAGED", "").strip().lower()
|
||
|
||
if managed_system == "NixOS":
|
||
env_hint = "true" if raw in _MANAGED_TRUE_VALUES else raw or "true"
|
||
return (
|
||
f"Cannot {action}: this Hermes installation is managed by NixOS "
|
||
f"(HERMES_MANAGED={env_hint}).\n"
|
||
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
|
||
" sudo nixos-rebuild switch"
|
||
)
|
||
|
||
if managed_system == "Homebrew":
|
||
env_hint = raw or "homebrew"
|
||
return (
|
||
f"Cannot {action}: this Hermes installation is managed by Homebrew "
|
||
f"(HERMES_MANAGED={env_hint}).\n"
|
||
"Use:\n"
|
||
" brew upgrade hermes-agent"
|
||
)
|
||
|
||
return (
|
||
f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n"
|
||
"Use your package manager to upgrade or reinstall Hermes."
|
||
)
|
||
|
||
def managed_error(action: str = "modify configuration"):
|
||
"""Print user-friendly error for managed mode."""
|
||
print(format_managed_message(action), file=sys.stderr)
|
||
|
||
|
||
# =============================================================================
|
||
# Container-aware CLI (NixOS container mode)
|
||
# =============================================================================
|
||
|
||
def get_container_exec_info() -> Optional[dict]:
|
||
"""Read container mode metadata from HERMES_HOME/.container-mode.
|
||
|
||
Returns a dict with keys: backend, container_name, exec_user, hermes_bin
|
||
or None if container mode is not active, we're already inside the
|
||
container, or HERMES_DEV=1 is set.
|
||
|
||
The .container-mode file is written by the NixOS activation script when
|
||
container.enable = true. It tells the host CLI to exec into the container
|
||
instead of running locally.
|
||
"""
|
||
if os.environ.get("HERMES_DEV") == "1":
|
||
return None
|
||
|
||
from hermes_constants import is_container
|
||
if is_container():
|
||
return None
|
||
|
||
container_mode_file = get_hermes_home() / ".container-mode"
|
||
|
||
try:
|
||
info = {}
|
||
with open(container_mode_file, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if "=" in line and not line.startswith("#"):
|
||
key, _, value = line.partition("=")
|
||
info[key.strip()] = value.strip()
|
||
except FileNotFoundError:
|
||
return None
|
||
# All other exceptions (PermissionError, malformed data, etc.) propagate
|
||
|
||
backend = info.get("backend", "docker")
|
||
container_name = info.get("container_name", "hermes-agent")
|
||
exec_user = info.get("exec_user", "hermes")
|
||
hermes_bin = info.get("hermes_bin", "/data/current-package/bin/hermes")
|
||
|
||
return {
|
||
"backend": backend,
|
||
"container_name": container_name,
|
||
"exec_user": exec_user,
|
||
"hermes_bin": hermes_bin,
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# Config paths
|
||
# =============================================================================
|
||
|
||
# Re-export from hermes_constants — canonical definition lives there.
|
||
from hermes_constants import get_hermes_home # noqa: F811,E402
|
||
from utils import atomic_replace
|
||
|
||
def get_config_path() -> Path:
|
||
"""Get the main config file path."""
|
||
return get_hermes_home() / "config.yaml"
|
||
|
||
def get_env_path() -> Path:
|
||
"""Get the .env file path (for API keys)."""
|
||
return get_hermes_home() / ".env"
|
||
|
||
def get_project_root() -> Path:
|
||
"""Get the project installation directory."""
|
||
return Path(__file__).parent.parent.resolve()
|
||
|
||
def _secure_dir(path):
|
||
"""Set directory to owner-only access (0700 by default). No-op on Windows.
|
||
|
||
Skipped in managed mode — the NixOS module sets group-readable
|
||
permissions (0750) so interactive users in the hermes group can
|
||
share state with the gateway service.
|
||
|
||
The mode can be overridden via the HERMES_HOME_MODE environment variable
|
||
(e.g. HERMES_HOME_MODE=0701) for deployments where a web server (nginx,
|
||
caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory.
|
||
The execute-only bit on a directory permits cd-through without exposing
|
||
directory listings.
|
||
"""
|
||
if is_managed():
|
||
return
|
||
try:
|
||
mode_str = os.environ.get("HERMES_HOME_MODE", "").strip()
|
||
mode = int(mode_str, 8) if mode_str else 0o700
|
||
except ValueError:
|
||
mode = 0o700
|
||
try:
|
||
os.chmod(path, mode)
|
||
except (OSError, NotImplementedError):
|
||
pass
|
||
|
||
|
||
def _is_container() -> bool:
|
||
"""Detect if we're running inside a Docker/Podman/LXC container.
|
||
|
||
When Hermes runs in a container with volume-mounted config files, forcing
|
||
0o600 permissions breaks multi-process setups where the gateway and
|
||
dashboard run as different UIDs or the volume mount requires broader
|
||
permissions.
|
||
"""
|
||
# Explicit opt-out
|
||
if os.environ.get("HERMES_CONTAINER") or os.environ.get("HERMES_SKIP_CHMOD"):
|
||
return True
|
||
# Docker / Podman marker file
|
||
if os.path.exists("/.dockerenv"):
|
||
return True
|
||
# LXC / cgroup-based detection
|
||
try:
|
||
with open("/proc/1/cgroup", "r", encoding="utf-8") as f:
|
||
cgroup_content = f.read()
|
||
if "docker" in cgroup_content or "lxc" in cgroup_content or "kubepods" in cgroup_content:
|
||
return True
|
||
except (OSError, IOError):
|
||
pass
|
||
return False
|
||
|
||
|
||
def _secure_file(path):
|
||
"""Set file to owner-only read/write (0600). No-op on Windows.
|
||
|
||
Skipped in managed mode — the NixOS activation script sets
|
||
group-readable permissions (0640) on config files.
|
||
|
||
Skipped in containers — Docker/Podman volume mounts often need broader
|
||
permissions. Set HERMES_SKIP_CHMOD=1 to force-skip on other systems.
|
||
"""
|
||
if is_managed() or _is_container():
|
||
return
|
||
try:
|
||
if os.path.exists(str(path)):
|
||
os.chmod(path, 0o600)
|
||
except (OSError, NotImplementedError):
|
||
pass
|
||
|
||
|
||
def _ensure_default_soul_md(home: Path) -> None:
|
||
"""Seed a default SOUL.md into HERMES_HOME if the user doesn't have one yet."""
|
||
soul_path = home / "SOUL.md"
|
||
if soul_path.exists():
|
||
return
|
||
soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8")
|
||
_secure_file(soul_path)
|
||
|
||
|
||
def ensure_hermes_home():
|
||
"""Ensure ~/.hermes directory structure exists with secure permissions.
|
||
|
||
In managed mode (NixOS), dirs are created by the activation script with
|
||
setgid + group-writable (2770). We skip mkdir and set umask(0o007) so
|
||
any files created (e.g. SOUL.md) are group-writable (0660).
|
||
"""
|
||
home = get_hermes_home()
|
||
if is_managed():
|
||
old_umask = os.umask(0o007)
|
||
try:
|
||
_ensure_hermes_home_managed(home)
|
||
finally:
|
||
os.umask(old_umask)
|
||
else:
|
||
home.mkdir(parents=True, exist_ok=True)
|
||
_secure_dir(home)
|
||
for subdir in (
|
||
"cron", "sessions", "logs", "logs/curator", "memories",
|
||
"pairing", "hooks", "image_cache", "audio_cache", "skills",
|
||
):
|
||
d = home / subdir
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
_secure_dir(d)
|
||
_ensure_default_soul_md(home)
|
||
|
||
|
||
def _ensure_hermes_home_managed(home: Path):
|
||
"""Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md."""
|
||
if not home.is_dir():
|
||
raise RuntimeError(
|
||
f"HERMES_HOME {home} does not exist. "
|
||
"Run 'sudo nixos-rebuild switch' first."
|
||
)
|
||
for subdir in ("cron", "sessions", "logs", "memories"):
|
||
d = home / subdir
|
||
if not d.is_dir():
|
||
raise RuntimeError(
|
||
f"{d} does not exist. "
|
||
"Run 'sudo nixos-rebuild switch' first."
|
||
)
|
||
# Curator reports dir is a sub-path of logs/; create it if missing.
|
||
# In managed mode the activation script may not know about this subdir,
|
||
# so we mkdir it ourselves (it's inside an already-secured logs/ dir).
|
||
(home / "logs" / "curator").mkdir(parents=True, exist_ok=True)
|
||
# Inside umask(0o007) scope — SOUL.md will be created as 0660
|
||
_ensure_default_soul_md(home)
|
||
|
||
|
||
# =============================================================================
|
||
# Config loading/saving
|
||
# =============================================================================
|
||
|
||
DEFAULT_CONFIG = {
|
||
"model": "",
|
||
"providers": {},
|
||
"fallback_providers": [],
|
||
"credential_pool_strategies": {},
|
||
"toolsets": ["hermes-cli"],
|
||
"agent": {
|
||
"max_turns": 90,
|
||
# Inactivity timeout for gateway agent execution (seconds).
|
||
# The agent can run indefinitely as long as it's actively calling
|
||
# tools or receiving API responses. Only fires when the agent has
|
||
# been completely idle for this duration. 0 = unlimited.
|
||
"gateway_timeout": 1800,
|
||
# Graceful drain timeout for gateway stop/restart (seconds).
|
||
# The gateway stops accepting new work, waits for running agents
|
||
# to finish, then interrupts any remaining runs after the timeout.
|
||
# 0 = no drain, interrupt immediately.
|
||
#
|
||
# 180s is calibrated for realistic in-flight agent turns: a typical
|
||
# coding conversation mid-reasoning runs 60–150s per call, so a 60s
|
||
# budget routinely interrupted legitimate work on /restart. Raise
|
||
# further in config.yaml if you run very-long-reasoning models.
|
||
"restart_drain_timeout": 180,
|
||
# Max app-level retry attempts for API errors (connection drops,
|
||
# provider timeouts, 5xx, etc.) before the agent surfaces the
|
||
# failure. The OpenAI SDK already does its own low-level retries
|
||
# (max_retries=2 default) for transient network errors; this is
|
||
# the Hermes-level retry loop that wraps the whole call. Lower
|
||
# this to 1 if you use fallback providers and want fast failover
|
||
# on flaky primaries; raise it if you prefer to tolerate longer
|
||
# provider hiccups on a single provider.
|
||
"api_max_retries": 3,
|
||
"service_tier": "",
|
||
# Tool-use enforcement: injects system prompt guidance that tells the
|
||
# model to actually call tools instead of describing intended actions.
|
||
# Values: "auto" (default — applies to gpt/codex models), true/false
|
||
# (force on/off for all models), or a list of model-name substrings
|
||
# to match (e.g. ["gpt", "codex", "gemini", "qwen"]).
|
||
"tool_use_enforcement": "auto",
|
||
# Universal "finish the job" guidance — short prompt block applied to
|
||
# all models that targets two cross-family failure modes: (1) stopping
|
||
# after a stub instead of finishing the artifact, (2) fabricating
|
||
# plausible-looking output when a real path is blocked. Costs ~80
|
||
# tokens in the cached system prompt. Set False to disable globally.
|
||
"task_completion_guidance": True,
|
||
# Local-environment toolchain probe — surfaces Python/pip/uv/PEP-668
|
||
# state in the system prompt when something non-default is detected
|
||
# (e.g. python3 has no pip module, pip→python version mismatch, PEP
|
||
# 668 enforcement without uv). Costs zero tokens when the env is
|
||
# clean (probe emits nothing). Skipped for remote terminal backends
|
||
# (docker/modal/ssh — they have their own probe). Set False to
|
||
# disable entirely.
|
||
"environment_probe": True,
|
||
# Embedder-supplied environment description appended to the system
|
||
# prompt's environment-hints block. Lets a host that wraps Hermes
|
||
# (sandbox runner, managed platform) explain the runtime environment
|
||
# — proxy, credential handling, mount layout — without editing the
|
||
# identity slot (SOUL.md). Empty by default. The HERMES_ENVIRONMENT_HINT
|
||
# env var overrides this (build-time/container mechanism).
|
||
"environment_hint": "",
|
||
# Staged inactivity warning: send a warning to the user at this
|
||
# threshold before escalating to a full timeout. The warning fires
|
||
# once per run and does not interrupt the agent. 0 = disable warning.
|
||
"gateway_timeout_warning": 900,
|
||
# Maximum time (seconds) the gateway will block an agent waiting for
|
||
# a clarify-tool response from the user. Hit this and the agent
|
||
# unblocks with "[user did not respond within Xm]" so it can adapt
|
||
# rather than pinning the running-agent guard forever. CLI clarify
|
||
# blocks indefinitely (input() is synchronous) and ignores this.
|
||
"clarify_timeout": 600,
|
||
# Periodic "still working" notification interval (seconds).
|
||
# Sends a status message every N seconds so the user knows the
|
||
# agent hasn't died during long tasks. 0 = disable notifications.
|
||
# Lower values mean faster feedback on slow tasks but more chat
|
||
# noise; 180s is a compromise that catches spinning weak-model runs
|
||
# (60+ tool iterations with tiny output) before users assume the
|
||
# bot is dead and /restart.
|
||
"gateway_notify_interval": 180,
|
||
# Freshness window for the gateway auto-continue note (seconds).
|
||
# After a gateway crash/restart/SIGTERM mid-run, the next user
|
||
# message gets a "[System note: your previous turn was
|
||
# interrupted — process the unfinished tool result(s) first]"
|
||
# prepended so the model picks up where it left off. That's the
|
||
# right behaviour while the interruption is fresh, but stale
|
||
# markers (transcript last touched hours or days ago) can revive
|
||
# an unrelated old task when the user's next message starts new
|
||
# work. This window is the max age of the last persisted
|
||
# transcript row for which we still inject the continue note.
|
||
# Default 3600s comfortably covers a long turn (gateway_timeout
|
||
# default is 1800s) plus runtime slack. Set to 0 to disable the
|
||
# gate and restore pre-fix behaviour (always inject).
|
||
"gateway_auto_continue_freshness": 3600,
|
||
# How user-attached images are presented to the main model on each turn.
|
||
# "auto" — attach natively when the active model reports
|
||
# supports_vision=True AND the user hasn't explicitly
|
||
# configured auxiliary.vision.provider. Otherwise fall
|
||
# back to text (vision_analyze pre-analysis).
|
||
# "native" — always attach natively; non-vision models will either
|
||
# error at the provider or get a last-chance text fallback
|
||
# (see run_agent._prepare_messages_for_api).
|
||
# "text" — always pre-analyze with vision_analyze and prepend the
|
||
# description as text; the main model never sees pixels.
|
||
# Affects gateway platforms, the TUI, and CLI /attach. vision_analyze
|
||
# remains available as a tool regardless of this setting — the routing
|
||
# only controls how inbound user images are presented.
|
||
"image_input_mode": "auto",
|
||
"disabled_toolsets": [],
|
||
},
|
||
|
||
"terminal": {
|
||
"backend": "local",
|
||
"modal_mode": "auto",
|
||
"cwd": ".", # Use current directory
|
||
"timeout": 180,
|
||
# Environment variables to pass through to sandboxed execution
|
||
# (terminal and execute_code). Skill-declared required_environment_variables
|
||
# are passed through automatically; this list is for non-skill use cases.
|
||
"env_passthrough": [],
|
||
# Extra files to source in the login shell when building the
|
||
# per-session environment snapshot. Use this when tools like nvm,
|
||
# pyenv, asdf, or custom PATH entries are registered by files that
|
||
# a bash login shell would skip — most commonly ``~/.bashrc``
|
||
# (bash doesn't source bashrc in non-interactive login mode) or
|
||
# zsh-specific files like ``~/.zshrc`` / ``~/.zprofile``.
|
||
# Paths support ``~`` / ``${VAR}``. Missing files are silently
|
||
# skipped. When empty, Hermes auto-sources ``~/.profile``,
|
||
# ``~/.bash_profile``, and ``~/.bashrc`` (in that order) if the
|
||
# snapshot shell is bash (this is the ``auto_source_bashrc``
|
||
# behaviour — disable with that key if you want strict login-only
|
||
# semantics).
|
||
"shell_init_files": [],
|
||
# When true (default), Hermes sources the user's shell rc files
|
||
# (``~/.profile``, ``~/.bash_profile``, ``~/.bashrc``) in the
|
||
# login shell used to build the environment snapshot. This
|
||
# captures PATH additions, shell functions, and aliases — which a
|
||
# plain ``bash -l -c`` would otherwise miss because bash skips
|
||
# bashrc in non-interactive login mode, and because a default
|
||
# Debian/Ubuntu ``~/.bashrc`` short-circuits on non-interactive
|
||
# sources. ``~/.profile`` and ``~/.bash_profile`` are tried first
|
||
# because ``n`` / ``nvm`` / ``asdf`` installers typically write
|
||
# their PATH exports there without an interactivity guard. Turn
|
||
# this off if your rc files misbehave when sourced
|
||
# non-interactively (e.g. one that hard-exits on TTY checks).
|
||
"auto_source_bashrc": True,
|
||
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||
"docker_forward_env": [],
|
||
# Explicit environment variables to set inside Docker containers.
|
||
# Unlike docker_forward_env (which reads values from the host process),
|
||
# docker_env lets you specify exact key-value pairs — useful when Hermes
|
||
# runs as a systemd service without access to the user's shell environment.
|
||
# Example: {"SSH_AUTH_SOCK": "/run/user/1000/ssh-agent.sock"}
|
||
"docker_env": {},
|
||
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
|
||
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
|
||
# Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)
|
||
"container_cpu": 1,
|
||
"container_memory": 5120, # MB (default 5GB)
|
||
"container_disk": 51200, # MB (default 50GB)
|
||
"container_persistent": True, # Persist filesystem across sessions
|
||
# Docker volume mounts — share host directories with the container.
|
||
# Each entry is "host_path:container_path" (standard Docker -v syntax).
|
||
# Example:
|
||
# ["/home/user/projects:/workspace/projects",
|
||
# "/home/user/.hermes/cache/documents:/output"]
|
||
# For gateway MEDIA delivery, write inside Docker to /output/... and emit
|
||
# the host-visible path in MEDIA:, not the container path.
|
||
"docker_volumes": [],
|
||
# Explicit opt-in: mount the host cwd into /workspace for Docker sessions.
|
||
# Default off because passing host directories into a sandbox weakens isolation.
|
||
"docker_mount_cwd_to_workspace": False,
|
||
"docker_extra_args": [], # Extra flags passed verbatim to docker run
|
||
# Explicit opt-in: run the Docker container as the host user's uid:gid
|
||
# (via `--user`). When enabled, files written into bind-mounted dirs
|
||
# (docker_volumes, the persistent workspace, or the auto-mounted cwd)
|
||
# are owned by your host user instead of root, which avoids needing
|
||
# `sudo chown` after container runs. Default off to preserve behavior
|
||
# for images whose entrypoints expect to start as root (e.g. the
|
||
# bundled Hermes image, which drops to the `hermes` user via
|
||
# s6-setuidgid inside each supervised service).
|
||
# When on, SETUID/SETGID caps are omitted from the container since
|
||
# no privilege drop is needed.
|
||
"docker_run_as_host_user": False,
|
||
# Persistent shell — keep a long-lived bash shell across execute() calls
|
||
# so cwd/env vars/shell variables survive between commands.
|
||
# Enabled by default for non-local backends (SSH); local is always opt-in
|
||
# via TERMINAL_LOCAL_PERSISTENT env var.
|
||
"persistent_shell": True,
|
||
},
|
||
|
||
"web": {
|
||
"backend": "", # shared fallback — applies to both search and extract
|
||
"search_backend": "", # per-capability override for web_search (e.g. "searxng")
|
||
"extract_backend": "", # per-capability override for web_extract (e.g. "native")
|
||
},
|
||
|
||
"browser": {
|
||
"inactivity_timeout": 120,
|
||
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
|
||
"record_sessions": False, # Auto-record browser sessions as WebM videos
|
||
"allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
|
||
# Browser engine for local mode. Passed as ``--engine <value>`` to
|
||
# agent-browser v0.25.3+.
|
||
# "auto" — use Chrome (default, don't pass --engine at all)
|
||
# "lightpanda" — use Lightpanda (1.3-5.8x faster navigation, no screenshots)
|
||
# "chrome" — explicitly request Chrome
|
||
# Also settable via AGENT_BROWSER_ENGINE env var.
|
||
"engine": "auto",
|
||
"auto_local_for_private_urls": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud
|
||
"cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome
|
||
# CDP supervisor — dialog + frame detection via a persistent WebSocket.
|
||
# Active only when a CDP-capable backend is attached (Browserbase or
|
||
# local Chrome via /browser connect). See
|
||
# website/docs/developer-guide/browser-supervisor.md.
|
||
"dialog_policy": "must_respond", # must_respond | auto_dismiss | auto_accept
|
||
"dialog_timeout_s": 300, # Safety auto-dismiss after N seconds under must_respond
|
||
"camofox": {
|
||
# When true, Hermes sends a stable profile-scoped userId to Camofox
|
||
# so the server maps it to a persistent Firefox profile automatically.
|
||
# When false (default), each session gets a random userId (ephemeral).
|
||
"managed_persistence": False,
|
||
# Optional externally managed Camofox identity. Useful when another
|
||
# app owns the visible browser and Hermes should operate in it.
|
||
"user_id": "",
|
||
"session_key": "",
|
||
# Rehydrate tab_id from Camofox before creating a new tab.
|
||
"adopt_existing_tab": False,
|
||
# Docker Camofox opens page URLs from inside the container. Enable
|
||
# this to rewrite loopback page URLs (localhost/127.0.0.1/::1) to a
|
||
# host alias while leaving CAMOFOX_URL itself unchanged.
|
||
"rewrite_loopback_urls": False,
|
||
"loopback_host_alias": "host.docker.internal",
|
||
},
|
||
},
|
||
|
||
# Filesystem checkpoints — automatic snapshots before destructive file ops.
|
||
# When enabled, the agent takes a snapshot of the working directory once
|
||
# per conversation turn (on first write_file/patch call). Use /rollback
|
||
# to restore.
|
||
#
|
||
# Defaults changed in v2 (single shared shadow store, real pruning):
|
||
# - enabled: True -> False (opt-in; most users never use /rollback)
|
||
# - max_snapshots: 50 -> 20 (now actually enforced via ref rewrite)
|
||
# - auto_prune: False -> True (orphans/stale pruned automatically)
|
||
# Opt in via ``hermes chat --checkpoints`` or set enabled=True here.
|
||
"checkpoints": {
|
||
"enabled": False,
|
||
# Max checkpoints to keep per working directory. Pre-v2 this only
|
||
# limited the `/rollback` listing; v2 actually rewrites the ref and
|
||
# garbage-collects older commits.
|
||
"max_snapshots": 20,
|
||
# Hard ceiling on total ``~/.hermes/checkpoints/`` size (MB). When
|
||
# exceeded, the oldest checkpoint per project is dropped in a
|
||
# round-robin pass until total size falls under the cap.
|
||
# 0 disables the size cap.
|
||
"max_total_size_mb": 500,
|
||
# Skip any single file larger than this when staging a checkpoint.
|
||
# Prevents accidental snapshotting of datasets, model weights, and
|
||
# other large generated assets. 0 disables the filter.
|
||
"max_file_size_mb": 10,
|
||
# Auto-maintenance: hermes sweeps the checkpoint base at startup
|
||
# (at most once per ``min_interval_hours``) and:
|
||
# * deletes project entries whose workdir no longer exists (orphan)
|
||
# * deletes project entries whose last_touch is older than
|
||
# ``retention_days``
|
||
# * GCs the single shared store to reclaim unreachable objects
|
||
# * enforces ``max_total_size_mb`` across remaining projects
|
||
# * deletes ``legacy-*`` archives older than ``retention_days``
|
||
"auto_prune": True,
|
||
"retention_days": 7,
|
||
"delete_orphans": True,
|
||
"min_interval_hours": 24,
|
||
},
|
||
|
||
# Maximum characters returned by a single read_file call. Reads that
|
||
# exceed this are rejected with guidance to use offset+limit.
|
||
# 100K chars ≈ 25–35K tokens across typical tokenisers.
|
||
"file_read_max_chars": 100_000,
|
||
|
||
# Tool-output truncation thresholds. When terminal output or a
|
||
# single read_file page exceeds these limits, Hermes truncates the
|
||
# payload sent to the model (keeping head + tail for terminal,
|
||
# enforcing pagination for read_file). Tuning these trades context
|
||
# footprint against how much raw output the model can see in one
|
||
# shot. Ported from anomalyco/opencode PR #23770.
|
||
#
|
||
# - max_bytes: terminal_tool output cap, in chars
|
||
# (default 50_000 ≈ 12-15K tokens).
|
||
# - max_lines: read_file pagination cap — the maximum `limit`
|
||
# a single read_file call can request before
|
||
# being clamped (default 2000).
|
||
# - max_line_length: per-line cap applied when read_file emits a
|
||
# line-numbered view (default 2000 chars).
|
||
"tool_output": {
|
||
"max_bytes": 50_000,
|
||
"max_lines": 2000,
|
||
"max_line_length": 2000,
|
||
},
|
||
|
||
# Tool loop guardrails nudge models when they repeat failed or
|
||
# non-progressing tool calls. Soft warnings are always-on by default;
|
||
# hard stops are opt-in so interactive CLI/TUI sessions keep flowing.
|
||
"tool_loop_guardrails": {
|
||
"warnings_enabled": True,
|
||
"hard_stop_enabled": False,
|
||
"warn_after": {
|
||
"exact_failure": 2,
|
||
"same_tool_failure": 3,
|
||
"idempotent_no_progress": 2,
|
||
},
|
||
"hard_stop_after": {
|
||
"exact_failure": 5,
|
||
"same_tool_failure": 8,
|
||
"idempotent_no_progress": 5,
|
||
},
|
||
},
|
||
|
||
"compression": {
|
||
"enabled": True,
|
||
"threshold": 0.50, # compress when context usage exceeds this ratio
|
||
"target_ratio": 0.20, # fraction of threshold to preserve as recent tail
|
||
"protect_last_n": 20, # minimum recent messages to keep uncompressed
|
||
"hygiene_hard_message_limit": 400, # gateway session-hygiene force-compress threshold by message count
|
||
"protect_first_n": 3, # non-system head messages always preserved
|
||
# verbatim, in ADDITION to the system prompt
|
||
# (which is always implicitly protected). Set to
|
||
# 0 for long-running rolling-compaction sessions
|
||
# where you want nothing pinned except the
|
||
# system prompt + rolling summary + recent tail.
|
||
"abort_on_summary_failure": False, # When True, auto-compression that fails
|
||
# to generate a summary (aux LLM errored / returned
|
||
# non-JSON / timed out) aborts entirely instead of
|
||
# dropping the middle window with a static
|
||
# "summary unavailable" placeholder. Messages are
|
||
# preserved unchanged and the session "freezes" at
|
||
# its current size until the user runs /compress
|
||
# (which bypasses the failure cooldown) or /new.
|
||
# Default False matches historical behavior; set to
|
||
# True if you'd rather pause than silently lose
|
||
# context turns when your aux model is flaky.
|
||
},
|
||
|
||
# Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
|
||
# cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored.
|
||
"prompt_caching": {
|
||
"cache_ttl": "5m",
|
||
},
|
||
|
||
# OpenRouter-specific settings.
|
||
# response_cache: enable OpenRouter response caching (X-OpenRouter-Cache header).
|
||
# When enabled, identical requests return cached responses for free (zero billing).
|
||
# This is separate from Anthropic prompt caching and works alongside it.
|
||
# See: https://openrouter.ai/docs/guides/features/response-caching
|
||
# response_cache_ttl: how long cached responses remain valid, in seconds (1-86400).
|
||
# Default 300 (5 minutes). Only used when response_cache is enabled.
|
||
# min_coding_score: knob for the openrouter/pareto-code router (0.0-1.0).
|
||
# Only applied when model.model is "openrouter/pareto-code". Higher
|
||
# values route to stronger (more expensive) coders; lower values open
|
||
# up cheaper, faster options. Default 0.65 lands on the mid-tier
|
||
# coder on the current Pareto frontier. Empty string = let OpenRouter
|
||
# pick the strongest available coder (router's documented default
|
||
# when the plugins block is omitted).
|
||
# See: https://openrouter.ai/docs/guides/routing/routers/pareto-router
|
||
"openrouter": {
|
||
"response_cache": True,
|
||
"response_cache_ttl": 300,
|
||
"min_coding_score": 0.65,
|
||
},
|
||
|
||
# AWS Bedrock provider configuration.
|
||
# Only used when model.provider is "bedrock".
|
||
"bedrock": {
|
||
"region": "", # AWS region for Bedrock API calls (empty = AWS_REGION env var → us-east-1)
|
||
"discovery": {
|
||
"enabled": True, # Auto-discover models via ListFoundationModels
|
||
"provider_filter": [], # Only show models from these providers (e.g. ["anthropic", "amazon"])
|
||
"refresh_interval": 3600, # Cache discovery results for this many seconds
|
||
},
|
||
"guardrail": {
|
||
# Amazon Bedrock Guardrails — content filtering and safety policies.
|
||
# Create a guardrail in the Bedrock console, then set the ID and version here.
|
||
# See: https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html
|
||
"guardrail_identifier": "", # e.g. "abc123def456"
|
||
"guardrail_version": "", # e.g. "1" or "DRAFT"
|
||
"stream_processing_mode": "async", # "sync" or "async"
|
||
"trace": "disabled", # "enabled", "disabled", or "enabled_full"
|
||
},
|
||
},
|
||
|
||
# Auxiliary model config — provider:model for each side task.
|
||
# Format: provider is the provider name, model is the model slug.
|
||
# "auto" for provider = auto-detect best available provider.
|
||
# Empty model = use provider's default auxiliary model.
|
||
# All tasks fall back to openrouter:google/gemini-3-flash-preview if
|
||
# the configured provider is unavailable.
|
||
#
|
||
# extra_body: forwarded verbatim as request body fields on every aux call
|
||
# for that task. Use this to set provider-specific knobs (independent of
|
||
# main-agent settings). On OpenRouter you can set provider routing prefs
|
||
# and the Pareto Code coding-score floor here. Example:
|
||
#
|
||
# auxiliary:
|
||
# compression:
|
||
# provider: openrouter
|
||
# model: openrouter/pareto-code
|
||
# extra_body:
|
||
# provider: # OpenRouter provider routing
|
||
# order: [anthropic, google]
|
||
# sort: throughput # or price | latency
|
||
# plugins: # OpenRouter Pareto Code router
|
||
# - id: pareto-router
|
||
# min_coding_score: 0.5
|
||
#
|
||
# Each aux task is independent — main-agent provider_routing and
|
||
# openrouter.min_coding_score do NOT propagate to aux calls by design.
|
||
"auxiliary": {
|
||
"vision": {
|
||
"provider": "auto", # auto | openrouter | nous | codex | custom
|
||
"model": "", # e.g. "google/gemini-2.5-flash", "gpt-4o"
|
||
"base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider)
|
||
"api_key": "", # API key for base_url (falls back to OPENAI_API_KEY)
|
||
"timeout": 120, # seconds — LLM API call timeout; vision payloads need generous timeout
|
||
"extra_body": {}, # OpenAI-compatible provider-specific request fields
|
||
"download_timeout": 30, # seconds — image HTTP download timeout; increase for slow connections
|
||
},
|
||
"web_extract": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models
|
||
"extra_body": {},
|
||
},
|
||
"compression": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 120, # seconds — compression summarises large contexts; increase for local models
|
||
"extra_body": {},
|
||
},
|
||
# Note: session_search no longer uses an auxiliary LLM (PR #27590 —
|
||
# single-shape tool returns DB content directly). The old
|
||
# ``auxiliary.session_search.*`` block was removed here. Existing
|
||
# values in user config.yaml files are harmless leftovers and ignored.
|
||
"skills_hub": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 30,
|
||
"extra_body": {},
|
||
},
|
||
"approval": {
|
||
"provider": "auto",
|
||
"model": "", # fast/cheap model recommended (e.g. gemini-flash, haiku)
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 30,
|
||
"extra_body": {},
|
||
},
|
||
"mcp": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 30,
|
||
"extra_body": {},
|
||
},
|
||
"title_generation": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 30,
|
||
"extra_body": {},
|
||
},
|
||
# Triage specifier — flesh out a rough one-liner in the Kanban
|
||
# Triage column into a concrete spec, then promote it to ``todo``.
|
||
# Invoked by ``hermes kanban specify`` (single id or --all). Set a
|
||
# cheap, capable model here (gemini-flash works well); the main
|
||
# model is overkill for short spec expansion.
|
||
"triage_specifier": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 120,
|
||
"extra_body": {},
|
||
},
|
||
# Kanban decomposer — decomposes a triage task into a graph of
|
||
# child tasks routed to specialist profiles by description.
|
||
# Invoked by ``hermes kanban decompose`` and the kanban
|
||
# auto-decompose dispatcher tick. Returns a JSON task graph;
|
||
# uses more tokens than the specifier so allow more headroom.
|
||
"kanban_decomposer": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 180,
|
||
"extra_body": {},
|
||
},
|
||
# Profile describer — auto-generates a 1-2 sentence description
|
||
# of what a profile is good at. Invoked by
|
||
# ``hermes profile describe <name> --auto`` and the dashboard's
|
||
# auto-generate button. Short, cheap call.
|
||
"profile_describer": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 60,
|
||
"extra_body": {},
|
||
},
|
||
# Curator — skill-usage review fork. Timeout is generous because the
|
||
# review pass can take several minutes on reasoning models (umbrella
|
||
# building over hundreds of candidate skills). "auto" = use main chat
|
||
# model; override via `hermes model` → auxiliary → Curator to route
|
||
# to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).
|
||
"curator": {
|
||
"provider": "auto",
|
||
"model": "",
|
||
"base_url": "",
|
||
"api_key": "",
|
||
"timeout": 600,
|
||
"extra_body": {},
|
||
},
|
||
},
|
||
|
||
"display": {
|
||
"compact": False,
|
||
"personality": "",
|
||
"resume_display": "full",
|
||
# Recap tuning for /resume and startup resume. The defaults match the
|
||
# historical hardcoded values; expose them as config so power users can
|
||
# widen or tighten the snapshot to taste.
|
||
"resume_exchanges": 10, # max user+assistant pairs to show
|
||
"resume_max_user_chars": 300, # truncate user message text
|
||
"resume_max_assistant_chars": 200, # truncate non-last assistant text
|
||
"resume_max_assistant_lines": 3, # truncate non-last assistant lines
|
||
# When True (default), assistant entries that are *only* tool calls
|
||
# (no visible text) are skipped in the recap. This prevents the recap
|
||
# from being dominated by `[2 tool calls: terminal, read_file]` lines
|
||
# when an exchange was tool-heavy. Set False to restore the legacy
|
||
# behavior of showing tool-call summaries inline.
|
||
"resume_skip_tool_only": True,
|
||
"busy_input_mode": "interrupt", # interrupt | queue | steer
|
||
# When true, `hermes --tui` auto-resumes the most recent human-
|
||
# facing session on launch instead of forging a fresh one.
|
||
# Mirrors `hermes -c` muscle memory. Default off so existing
|
||
# users aren't surprised. HERMES_TUI_RESUME=<id> always wins.
|
||
"tui_auto_resume_recent": False,
|
||
# When true (default), `hermes --tui` drops a one-time hint
|
||
# ("subagents working · /agents to watch live") the first time a turn
|
||
# starts delegating, nudging the user toward the live spawn-tree
|
||
# dashboard. Set false to suppress the hint.
|
||
"tui_agents_nudge": True,
|
||
"bell_on_complete": False,
|
||
"show_reasoning": False,
|
||
"streaming": False,
|
||
"timestamps": False, # Show [HH:MM] on user and assistant labels
|
||
"final_response_markdown": "strip", # render | strip | raw
|
||
# Preserve recent classic CLI output across Ctrl+L, /redraw, and
|
||
# terminal resize full-screen clears. Disable if a terminal emulator
|
||
# behaves badly with replayed scrollback.
|
||
"persistent_output": True,
|
||
"persistent_output_max_lines": 200,
|
||
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||
# File-mutation verifier footer. When true (default), the agent
|
||
# appends a one-line advisory to its final response whenever a
|
||
# write_file / patch call failed during the turn and was never
|
||
# superseded by a successful write to the same path. This catches
|
||
# the "batch of parallel patches, half fail, model claims success"
|
||
# class of over-claim that otherwise forces users to run
|
||
# `git status` to verify edits landed. Set false to suppress.
|
||
"file_mutation_verifier": True,
|
||
# Turn-completion explainer. When true (default), the agent appends a
|
||
# one-line explanation to its final response whenever a turn ends
|
||
# abnormally with no usable reply — empty content after retries, a
|
||
# partial/truncated stream, a still-pending tool result, or an
|
||
# iteration/budget limit. Replaces the bare "(empty)" sentinel so the
|
||
# failure isn't silent from the UI's perspective. Set false to suppress.
|
||
"turn_completion_explainer": True,
|
||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||
"skin": "default",
|
||
# UI language for static user-facing messages (approval prompts, a
|
||
# handful of gateway slash-command replies). Does NOT affect agent
|
||
# responses, log lines, tool outputs, or slash-command descriptions.
|
||
# Supported: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.
|
||
"language": "en",
|
||
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
|
||
# spinner), or ascii. Live-swappable via `/indicator <style>`.
|
||
"tui_status_indicator": "kaomoji",
|
||
"user_message_preview": { # CLI: how many submitted user-message lines to echo back in scrollback
|
||
"first_lines": 2,
|
||
"last_lines": 2,
|
||
},
|
||
"interim_assistant_messages": True, # Gateway: show natural mid-turn assistant status messages
|
||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
|
||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||
# Auto-delete system-notice replies (e.g. "✨ New session started!",
|
||
# "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
|
||
# that support message deletion (currently Telegram; other platforms
|
||
# ignore and leave the message in place). Only affects slash-command
|
||
# replies wrapped with gateway.platforms.base.EphemeralReply — agent
|
||
# responses and content messages are never touched. Default 0
|
||
# (disabled) preserves prior behavior.
|
||
"ephemeral_system_ttl": 0,
|
||
"platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}}
|
||
# Gateway runtime-metadata footer appended to the FINAL message of a turn
|
||
# (disabled by default to keep replies minimal). When enabled, renders
|
||
# e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under
|
||
# display.platforms.<platform>.runtime_footer.
|
||
"runtime_footer": {
|
||
"enabled": False,
|
||
"fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide
|
||
},
|
||
"copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled"
|
||
},
|
||
|
||
# Web dashboard settings
|
||
"dashboard": {
|
||
"theme": "default", # Dashboard visual theme: "default", "midnight", "ember", "mono", "cyberpunk", "rose"
|
||
# Hide the token/cost analytics surfaces (Analytics page, token bars and
|
||
# cost figures on the Models page) by default. The numbers shown there
|
||
# are a local debug estimate: they only count successful main-agent
|
||
# responses with a usable ``response.usage``, and silently exclude every
|
||
# auxiliary call (context compression, title generation, vision,
|
||
# session search, web extract, smart approval, MCP routing, plugin LLM
|
||
# access) plus provider-side retries, fallback attempts, and any call
|
||
# whose usage block didn't come back. Cache writes are also missing
|
||
# from the API response. On models with heavy auxiliary traffic
|
||
# (Kimi K2.6, MiniMax M2.7) the local total can be 10x-100x lower than
|
||
# the provider bill, which is worse than hiding the numbers entirely
|
||
# because they look precise enough to compare against the provider.
|
||
# Set this to True to re-enable the surfaces with the understanding
|
||
# that the numbers are a local lower-bound estimate, not billing.
|
||
"show_token_analytics": False,
|
||
# OAuth gate configuration (engaged when ``--host`` is set and
|
||
# ``--insecure`` is not). The bundled Nous Portal plugin reads
|
||
# both keys at startup; they are the canonical surface for these
|
||
# settings. Each can be overridden by an environment variable —
|
||
# ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` and
|
||
# ``HERMES_DASHBOARD_PORTAL_URL`` respectively — and the env var
|
||
# wins when set to a non-empty value. The override path is what
|
||
# Fly.io's platform-secret injection uses to push the per-deploy
|
||
# client_id at provisioning time without operators needing to
|
||
# touch config.yaml. Local dev / non-Fly deploys can set either
|
||
# surface; missing values fall through to the plugin's defaults
|
||
# (no provider registered when ``client_id`` is empty;
|
||
# ``portal_url`` defaults to https://portal.nousresearch.com).
|
||
"oauth": {
|
||
"client_id": "", # agent:{instance_id} — Portal provisions this
|
||
"portal_url": "", # blank → use plugin default (production Portal)
|
||
},
|
||
# Public URL override (env: ``HERMES_DASHBOARD_PUBLIC_URL``).
|
||
# When set, this is the complete authority — scheme + host +
|
||
# optional path prefix (e.g. ``https://example.com/hermes``) —
|
||
# the OAuth ``redirect_uri`` is built from. Set this for deploys
|
||
# behind reverse proxies that don't reliably forward
|
||
# ``X-Forwarded-Host`` / ``X-Forwarded-Proto`` / ``X-Forwarded-Prefix``
|
||
# (manual nginx setups, on-prem ingresses, custom-domain Fly
|
||
# deploys without proper proxy headers). When set,
|
||
# ``X-Forwarded-Prefix`` is IGNORED on the OAuth path because
|
||
# the operator has declared the public URL — we no longer need
|
||
# to guess from proxy headers, and stacking the prefix on top
|
||
# would double-prefix the common case where the prefix is
|
||
# already baked into ``public_url``. Leave empty to use the
|
||
# existing proxy-header reconstruction (the default).
|
||
#
|
||
# Validation: rejects values without ``http(s)://`` scheme or
|
||
# without a host, and any string containing quote / angle /
|
||
# whitespace / control characters. A malformed value silently
|
||
# falls through to request reconstruction rather than breaking
|
||
# the login flow.
|
||
"public_url": "",
|
||
},
|
||
|
||
# Privacy settings
|
||
"privacy": {
|
||
"redact_pii": False, # When True, hash user IDs and strip phone numbers from LLM context
|
||
},
|
||
|
||
# Text-to-speech configuration
|
||
# Each provider supports an optional `max_text_length:` override for the
|
||
# per-request input-character cap. Omit it to use the provider's documented
|
||
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
|
||
# Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
|
||
"tts": {
|
||
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
|
||
"edge": {
|
||
"voice": "en-US-AriaNeural",
|
||
# Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural
|
||
},
|
||
"elevenlabs": {
|
||
"voice_id": "pNInz6obpgDQGcFmaJgB", # Adam
|
||
"model_id": "eleven_multilingual_v2",
|
||
},
|
||
"openai": {
|
||
"model": "gpt-4o-mini-tts",
|
||
"voice": "alloy",
|
||
# Voices: alloy, echo, fable, onyx, nova, shimmer
|
||
},
|
||
"xai": {
|
||
"voice_id": "eve", # or custom voice ID — see https://docs.x.ai/developers/model-capabilities/audio/custom-voices
|
||
"language": "en",
|
||
"sample_rate": 24000,
|
||
"bit_rate": 128000,
|
||
},
|
||
"mistral": {
|
||
"model": "voxtral-mini-tts-2603",
|
||
"voice_id": "c69964a6-ab8b-4f8a-9465-ec0925096ec8", # Paul - Neutral
|
||
},
|
||
"neutts": {
|
||
"ref_audio": "", # Path to reference voice audio (empty = bundled default)
|
||
"ref_text": "", # Path to reference voice transcript (empty = bundled default)
|
||
"model": "neuphonic/neutts-air-q4-gguf", # HuggingFace model repo
|
||
"device": "cpu", # cpu, cuda, or mps
|
||
},
|
||
"piper": {
|
||
# Voice name (e.g. "en_US-lessac-medium") downloaded on first
|
||
# use, OR an absolute path to a pre-downloaded .onnx file.
|
||
# Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md
|
||
"voice": "en_US-lessac-medium",
|
||
# "voices_dir": "", # Override voice cache dir; default = ~/.hermes/cache/piper-voices/
|
||
# "use_cuda": False, # Requires onnxruntime-gpu
|
||
# "length_scale": 1.0, # 2.0 = twice as slow
|
||
# "noise_scale": 0.667,
|
||
# "noise_w_scale": 0.8,
|
||
# "volume": 1.0,
|
||
# "normalize_audio": True,
|
||
},
|
||
},
|
||
|
||
"stt": {
|
||
"enabled": True,
|
||
"provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe)
|
||
"local": {
|
||
"model": "base", # tiny, base, small, medium, large-v3
|
||
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
|
||
},
|
||
"openai": {
|
||
"model": "whisper-1", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe
|
||
},
|
||
"mistral": {
|
||
"model": "voxtral-mini-latest", # voxtral-mini-latest, voxtral-mini-2602
|
||
},
|
||
"elevenlabs": {
|
||
"model_id": "scribe_v2", # scribe_v2, scribe_v1
|
||
"language_code": "", # auto-detect by default; set to "eng", "spa", "fra", etc. to force
|
||
"tag_audio_events": False,
|
||
"diarize": False,
|
||
},
|
||
},
|
||
|
||
"voice": {
|
||
"record_key": "ctrl+b",
|
||
"max_recording_seconds": 120,
|
||
"auto_tts": False,
|
||
"beep_enabled": True, # Play record start/stop beeps in CLI voice mode
|
||
"silence_threshold": 200, # RMS below this = silence (0-32767)
|
||
"silence_duration": 3.0, # Seconds of silence before auto-stop
|
||
},
|
||
|
||
"human_delay": {
|
||
"mode": "off",
|
||
"min_ms": 800,
|
||
"max_ms": 2500,
|
||
},
|
||
|
||
# Context engine -- controls how the context window is managed when
|
||
# approaching the model's token limit.
|
||
# "compressor" = built-in lossy summarization (default).
|
||
# Set to a plugin name to activate an alternative engine (e.g. "lcm"
|
||
# for Lossless Context Management). The engine must be installed as
|
||
# a plugin in plugins/context_engine/<name>/ or ~/.hermes/plugins/.
|
||
"context": {
|
||
"engine": "compressor",
|
||
},
|
||
|
||
# Persistent memory -- bounded curated memory injected into system prompt
|
||
"memory": {
|
||
"memory_enabled": True,
|
||
"user_profile_enabled": True,
|
||
"memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token
|
||
"user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
|
||
# External memory provider plugin (empty = built-in only).
|
||
# Set to a provider name to activate: "openviking", "mem0",
|
||
# "hindsight", "holographic", "retaindb", "byterover".
|
||
# Only ONE external provider is allowed at a time.
|
||
"provider": "",
|
||
},
|
||
|
||
# Subagent delegation — override the provider:model used by delegate_task
|
||
# so child agents can run on a different (cheaper/faster) provider and model.
|
||
# Uses the same runtime provider resolution as CLI/gateway startup, so all
|
||
# configured providers (OpenRouter, Nous, Z.ai, Kimi, etc.) are supported.
|
||
"delegation": {
|
||
"model": "", # e.g. "google/gemini-3-flash-preview" (empty = inherit parent model)
|
||
"provider": "", # e.g. "openrouter" (empty = inherit parent provider + credentials)
|
||
"base_url": "", # direct OpenAI-compatible endpoint for subagents
|
||
"api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY)
|
||
"api_mode": "", # wire protocol for delegation.base_url: "chat_completions",
|
||
# "codex_responses", or "anthropic_messages". Empty = auto-detect
|
||
# from URL (e.g. /anthropic suffix → anthropic_messages). Set this
|
||
# explicitly for non-standard endpoints the heuristic can't detect.
|
||
# When delegate_task narrows child toolsets explicitly, preserve any
|
||
# MCP toolsets the parent already has enabled. On by default so
|
||
# narrowing (e.g. toolsets=["web","browser"]) expresses "I want these
|
||
# extras" without silently stripping MCP tools the parent already has.
|
||
# Set to false for strict intersection.
|
||
"inherit_mcp_toolsets": True,
|
||
"max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget,
|
||
# independent of the parent's max_iterations)
|
||
"child_timeout_seconds": 600, # wall-clock timeout for each child agent (floor 30s,
|
||
# no ceiling). High-reasoning models on large tasks
|
||
# (e.g. gpt-5.5 xhigh, opus-4.6) need generous budgets;
|
||
# raise if children time out before producing output.
|
||
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
|
||
# "low", "minimal", "none" (empty = inherit parent's level)
|
||
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
|
||
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
|
||
# and _get_orchestrator_enabled). Values are clamped to [1, 3] with a
|
||
# warning log if out of range.
|
||
"max_spawn_depth": 1, # depth cap (1 = flat [default], 2 = orchestrator→leaf, 3 = three-level)
|
||
"orchestrator_enabled": True, # kill switch for role="orchestrator"
|
||
# When a subagent hits a dangerous-command approval prompt, the parent's
|
||
# prompt_toolkit TUI owns stdin — a thread-local input() call from the
|
||
# subagent worker would deadlock the parent UI. To avoid the deadlock,
|
||
# subagent threads ALWAYS resolve approvals non-interactively:
|
||
# false (default) → auto-deny with a logger.warning audit line (safe)
|
||
# true → auto-approve "once" with a logger.warning audit line
|
||
# Flip to true only if you trust delegated work to run dangerous cmds
|
||
# without human review (cron pipelines, batch automation, etc.).
|
||
"subagent_auto_approve": False,
|
||
},
|
||
|
||
# Ephemeral prefill messages file — JSON list of {role, content} dicts
|
||
# injected at the start of every API call for few-shot priming.
|
||
# Never saved to sessions, logs, or trajectories.
|
||
"prefill_messages_file": "",
|
||
|
||
# Goals — persistent cross-turn goals (Ralph-style loop).
|
||
# After every turn, a lightweight judge call asks the auxiliary model
|
||
# whether the active /goal is satisfied by the assistant's last
|
||
# response. If not, Hermes feeds a continuation prompt back into the
|
||
# same session and keeps working until the goal is done, the turn
|
||
# budget is exhausted, or the user pauses/clears it. Judge failures
|
||
# fail OPEN (continue) so a flaky judge never wedges progress — the
|
||
# turn budget is the real backstop.
|
||
"goals": {
|
||
# Max continuation turns before Hermes auto-pauses the goal and
|
||
# asks the user to /goal resume. Protects against judge false
|
||
# negatives (goal actually done but judge says continue) and
|
||
# unbounded model spend on fuzzy / unachievable goals.
|
||
"max_turns": 20,
|
||
},
|
||
|
||
# Skills — external skill directories for sharing skills across tools/agents.
|
||
# Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation
|
||
# always goes to ~/.hermes/skills/.
|
||
"skills": {
|
||
"external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"]
|
||
# Substitute ${HERMES_SKILL_DIR} and ${HERMES_SESSION_ID} in SKILL.md
|
||
# content with the absolute skill directory and the active session id
|
||
# before the agent sees it. Lets skill authors reference bundled
|
||
# scripts without the agent having to join paths.
|
||
"template_vars": True,
|
||
# Pre-execute inline shell snippets written as !`cmd` in SKILL.md
|
||
# body. Their stdout is inlined into the skill message before the
|
||
# agent reads it, so skills can inject dynamic context (dates, git
|
||
# state, detected tool versions, …). Off by default because any
|
||
# content from the skill author runs on the host without approval;
|
||
# only enable for skill sources you trust.
|
||
"inline_shell": False,
|
||
# Timeout (seconds) for each !`cmd` snippet when inline_shell is on.
|
||
"inline_shell_timeout": 10,
|
||
# Run the keyword/pattern security scanner on skills the agent
|
||
# writes via skill_manage (create/edit/patch). Off by default
|
||
# because the agent can already execute the same code paths via
|
||
# terminal() with no gate, so the scan adds friction (blocks
|
||
# skills that mention risky keywords in prose) without meaningful
|
||
# security. Turn on if you want the belt-and-suspenders — a
|
||
# dangerous verdict will then surface as a tool error to the
|
||
# agent, which can retry with the flagged content removed.
|
||
# External hub installs (trusted/community sources) are always
|
||
# scanned regardless of this setting.
|
||
"guard_agent_created": False,
|
||
},
|
||
|
||
# Curator — background skill maintenance.
|
||
#
|
||
# Periodically reviews AGENT-CREATED skills (never bundled or
|
||
# hub-installed) and keeps the collection tidy: marks long-unused skills
|
||
# as stale, archives genuinely obsolete ones (archive only, never
|
||
# deletes), and spawns a forked aux-model agent to consolidate overlaps
|
||
# and patch drift. Runs inactivity-triggered from session start — no
|
||
# cron daemon.
|
||
#
|
||
# See `hermes curator status` for the last run summary.
|
||
"curator": {
|
||
"enabled": True,
|
||
# How long to wait between curator runs (hours). Default: 7 days.
|
||
"interval_hours": 24 * 7,
|
||
# Only run when the agent has been idle at least this long (hours).
|
||
"min_idle_hours": 2,
|
||
# Mark a skill as "stale" after this many days without use.
|
||
"stale_after_days": 30,
|
||
# Archive a skill (move to skills/.archive/) after this many days
|
||
# without use. Archived skills are recoverable — no auto-deletion.
|
||
"archive_after_days": 90,
|
||
# Pre-run backup: before every real curator pass (dry-run is
|
||
# skipped), snapshot ~/.hermes/skills/ into
|
||
# ~/.hermes/skills/.curator_backups/<utc-iso>/skills.tar.gz so the
|
||
# user can roll back with `hermes curator rollback`.
|
||
"backup": {
|
||
"enabled": True,
|
||
"keep": 5, # retain last N regular snapshots
|
||
},
|
||
},
|
||
|
||
# Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
|
||
# This section is only needed for hermes-specific overrides; everything else
|
||
# (apiKey, workspace, peerName, sessions, enabled) comes from the global config.
|
||
"honcho": {},
|
||
|
||
# IANA timezone (e.g. "Asia/Kolkata", "America/New_York").
|
||
# Empty string means use server-local time.
|
||
"timezone": "",
|
||
|
||
# Slack platform settings (gateway mode)
|
||
"slack": {
|
||
"require_mention": True, # Require @mention to respond in channels
|
||
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
|
||
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
|
||
"channel_prompts": {}, # Per-channel ephemeral system prompts
|
||
},
|
||
|
||
# Discord platform settings (gateway mode)
|
||
"discord": {
|
||
"require_mention": True, # Require @mention to respond in server channels
|
||
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
|
||
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
|
||
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
|
||
"thread_require_mention": False, # If True, require @mention in threads too (multi-bot threads)
|
||
"history_backfill": True, # If True, prepend recent channel scrollback when bot is triggered (recovers messages missed while require_mention gated them out)
|
||
"history_backfill_limit": 50, # Max number of recent messages to scan when assembling the backfill block
|
||
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
|
||
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
|
||
# Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES
|
||
# authorizes only guild messages in the role's own guild — DMs require
|
||
# DISCORD_ALLOWED_USERS. Set dm_role_auth_guild to a guild ID to also
|
||
# authorize DMs from members of that one trusted guild holding the
|
||
# allowed role. Unset / empty / 0 = secure default (DM role-auth off).
|
||
"dm_role_auth_guild": "",
|
||
# discord / discord_admin tools: restrict which actions the agent may call.
|
||
# Default (empty) = all actions allowed (subject to bot privileged intents).
|
||
# Accepts comma-separated string ("list_guilds,list_channels,fetch_messages")
|
||
# or YAML list. Unknown names are dropped with a warning at load time.
|
||
# Actions: list_guilds, server_info, list_channels, channel_info,
|
||
# list_roles, member_info, search_members, fetch_messages, list_pins,
|
||
# pin_message, unpin_message, create_thread, add_role, remove_role.
|
||
"server_actions": "",
|
||
# Accept arbitrary attachment file types (not just SUPPORTED_DOCUMENT_TYPES).
|
||
# When True, any uploaded file is cached to disk with mime
|
||
# application/octet-stream and the path is surfaced to the agent so it
|
||
# can use terminal/read_file/etc. against it. Default False preserves
|
||
# the historical allowlist behaviour.
|
||
# Env override: DISCORD_ALLOW_ANY_ATTACHMENT.
|
||
"allow_any_attachment": False,
|
||
# Maximum bytes per attachment the gateway will cache. The whole file
|
||
# is held in memory while being written, so unlimited uploads carry a
|
||
# real memory cost. Default 32 MiB matches the historical hardcoded
|
||
# cap. Set to 0 for no cap. Env override: DISCORD_MAX_ATTACHMENT_BYTES.
|
||
"max_attachment_bytes": 33554432,
|
||
},
|
||
|
||
# WhatsApp platform settings (gateway mode)
|
||
"whatsapp": {
|
||
# Reply prefix prepended to every outgoing WhatsApp message.
|
||
# Default (None) uses the built-in "⚕ *Hermes Agent*" header.
|
||
# Set to "" (empty string) to disable the header entirely.
|
||
# Supports \n for newlines, e.g. "🤖 *My Bot*\n──────\n"
|
||
},
|
||
|
||
# Telegram platform settings (gateway mode)
|
||
"telegram": {
|
||
"reactions": False, # Add 👀/✅/❌ reactions to messages during processing
|
||
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
|
||
"allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
|
||
},
|
||
|
||
# Mattermost platform settings (gateway mode)
|
||
"mattermost": {
|
||
"require_mention": True, # Require @mention to respond in channels
|
||
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
|
||
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
|
||
"channel_prompts": {}, # Per-channel ephemeral system prompts
|
||
},
|
||
|
||
# Matrix platform settings (gateway mode)
|
||
"matrix": {
|
||
"require_mention": True, # Require @mention to respond in rooms
|
||
"free_response_rooms": "", # Comma-separated room IDs where bot responds without mention
|
||
"allowed_rooms": "", # If set, bot ONLY responds in these room IDs (whitelist)
|
||
},
|
||
|
||
# Approval mode for dangerous commands:
|
||
# manual — always prompt the user (default)
|
||
# smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk
|
||
# off — skip all approval prompts (equivalent to --yolo)
|
||
#
|
||
# cron_mode — what to do when a cron job hits a dangerous command:
|
||
# deny — block the command and let the agent find another way (default, safe)
|
||
# approve — auto-approve all dangerous commands in cron jobs
|
||
"approvals": {
|
||
"mode": "manual",
|
||
"timeout": 60,
|
||
"cron_mode": "deny",
|
||
# When true, /reload-mcp asks the user to confirm before rebuilding
|
||
# the MCP tool set for the active session. Reloading invalidates
|
||
# the provider prompt cache (tool schemas are baked into the system
|
||
# prompt), so the next message re-sends full input tokens — this can
|
||
# be expensive on long-context or high-reasoning models. Users click
|
||
# "Always Approve" to silence the prompt permanently; that flips
|
||
# this key to false.
|
||
"mcp_reload_confirm": True,
|
||
# When true, destructive session slash commands (/clear, /new, /reset,
|
||
# /undo) ask the user to confirm before discarding conversation state.
|
||
# Three-option prompt (Approve Once / Always Approve / Cancel) routed
|
||
# through tools.slash_confirm — native yes/no buttons on Telegram,
|
||
# Discord, and Slack; text fallback elsewhere. Users click "Always
|
||
# Approve" to silence the prompt permanently; that flips this key to
|
||
# false. TUI has its own modal overlay (HERMES_TUI_NO_CONFIRM=1 to
|
||
# opt out there).
|
||
"destructive_slash_confirm": True,
|
||
},
|
||
|
||
# Permanently allowed dangerous command patterns (added via "always" approval)
|
||
"command_allowlist": [],
|
||
# User-defined quick commands that bypass the agent loop (type: exec only)
|
||
"quick_commands": {},
|
||
|
||
# Shell-script hooks — declarative bridge that invokes shell scripts
|
||
# on plugin-hook events (pre_tool_call, post_tool_call, pre_llm_call,
|
||
# subagent_stop, etc.). Each entry maps an event name to a list of
|
||
# {matcher, command, timeout} dicts. First registration of a new
|
||
# command prompts the user for consent; subsequent runs reuse the
|
||
# stored approval from ~/.hermes/shell-hooks-allowlist.json.
|
||
# See `website/docs/user-guide/features/hooks.md` for schema + examples.
|
||
"hooks": {},
|
||
|
||
# Auto-accept shell-hook registrations without a TTY prompt. Also
|
||
# toggleable per-invocation via --accept-hooks or HERMES_ACCEPT_HOOKS=1.
|
||
# Gateway / cron / non-interactive runs need this (or one of the other
|
||
# channels) to pick up newly-added hooks.
|
||
"hooks_auto_accept": False,
|
||
# Custom personalities — add your own entries here
|
||
# Supports string format: {"name": "system prompt"}
|
||
# Or dict format: {"name": {"description": "...", "system_prompt": "...", "tone": "...", "style": "..."}}
|
||
"personalities": {},
|
||
|
||
# Pre-exec security scanning via tirith
|
||
"security": {
|
||
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
|
||
"redact_secrets": True,
|
||
"tirith_enabled": True,
|
||
"tirith_path": "tirith",
|
||
"tirith_timeout": 5,
|
||
"tirith_fail_open": True,
|
||
"website_blocklist": {
|
||
"enabled": False,
|
||
"domains": [],
|
||
"shared_files": [],
|
||
},
|
||
# Acknowledged supply-chain security advisories. Each entry is the
|
||
# ID of an advisory the user has read and acted on (uninstalled the
|
||
# compromised package, rotated credentials). Acked advisories no
|
||
# longer trigger the startup banner. Add via `hermes doctor --ack
|
||
# <id>`; remove by editing the list directly. See
|
||
# ``hermes_cli/security_advisories.py`` for the catalog.
|
||
"acked_advisories": [],
|
||
# Allow Hermes to lazy-install opt-in backend packages from PyPI
|
||
# the first time the user enables a backend that needs them
|
||
# (e.g. installing ``elevenlabs`` when the user picks ElevenLabs as
|
||
# their TTS provider). Set to false to require explicit
|
||
# ``pip install`` for everything beyond the base set — appropriate
|
||
# for restricted networks, audited environments, or air-gapped
|
||
# systems where any runtime install is unacceptable.
|
||
"allow_lazy_installs": True,
|
||
},
|
||
|
||
"cron": {
|
||
# Wrap delivered cron responses with a header (task name) and footer
|
||
# ("The agent cannot see this message"). Set to false for clean output.
|
||
"wrap_response": True,
|
||
# Maximum number of due jobs to run in parallel per tick.
|
||
# null/0 = unbounded (limited only by thread count).
|
||
# 1 = serial (pre-v0.9 behaviour).
|
||
# Also overridable via HERMES_CRON_MAX_PARALLEL env var.
|
||
"max_parallel_jobs": None,
|
||
},
|
||
|
||
# Kanban multi-agent coordination — controls the dispatcher loop that
|
||
# spawns workers for ready tasks. The dispatcher ticks every N seconds
|
||
# (default 60), reclaims stale claims, promotes dependency-satisfied
|
||
# todos to ready, and fires `hermes -p <assignee> chat -q ...` for
|
||
# each claimable ready task. One dispatcher per profile is sufficient;
|
||
# running more than one on the same kanban.db will race for claims.
|
||
"kanban": {
|
||
# Run the dispatcher inside the gateway process. On by default —
|
||
# the cost is ~300µs every `dispatch_interval_seconds` when idle,
|
||
# and gateway is the supervisor users already have. Set to false
|
||
# only if you run the dispatcher as a separate systemd unit or
|
||
# don't want the gateway to spawn workers.
|
||
"dispatch_in_gateway": True,
|
||
# Seconds between dispatcher ticks (idle or not). Lower = snappier
|
||
# pickup of newly-ready tasks; higher = less SQL pressure.
|
||
"dispatch_interval_seconds": 60,
|
||
# Auto-block after this many consecutive non-success attempts for the
|
||
# same task/profile (spawn_failed, timed_out, or crashed). Reassignment
|
||
# resets the streak for the new profile.
|
||
"failure_limit": 2,
|
||
# Worker stdout/stderr logs rotate at spawn time. Defaults preserve
|
||
# the historical 2 MiB + one-backup behavior; long-running workers can
|
||
# raise these to keep more early failure evidence.
|
||
"worker_log_rotate_bytes": 2 * 1024 * 1024,
|
||
"worker_log_backup_count": 1,
|
||
# Profile that decomposes tasks in the Triage column. When unset,
|
||
# falls back to the default profile (the one `hermes` launches with
|
||
# no -p flag). Set this to a dedicated 'orchestrator' profile if you
|
||
# want decomposition to use a different model/skills from your main
|
||
# working profile.
|
||
"orchestrator_profile": "",
|
||
# Where a child task lands if the orchestrator can't match an
|
||
# assignee to any installed profile. When unset, falls back to the
|
||
# default profile. A task never ends up with assignee=None.
|
||
"default_assignee": "",
|
||
# Per-profile concurrency cap (#21582). When set to a positive int,
|
||
# no single profile can have more than N workers running at once,
|
||
# even if the global max_in_progress / max_spawn caps would allow
|
||
# it. Tasks blocked this way defer to the next dispatcher tick.
|
||
# Unset (None) means "no per-profile cap" — backward-compatible
|
||
# with existing installs. Useful for fan-out workflows that would
|
||
# otherwise saturate one profile's local model / API quota /
|
||
# browser pool while leaving other profiles idle.
|
||
"max_in_progress_per_profile": None,
|
||
# When true, the kanban dispatcher auto-runs the decomposer on
|
||
# tasks that land in Triage (every dispatcher tick). When false,
|
||
# decomposition is manual via `hermes kanban decompose <id>` or
|
||
# the dashboard's Decompose button.
|
||
"auto_decompose": True,
|
||
# Max triage tasks to decompose per dispatcher tick. Prevents a
|
||
# large bulk-load of triage tasks from spending a burst of aux
|
||
# LLM calls in one tick. Excess tasks defer to the next tick.
|
||
"auto_decompose_per_tick": 3,
|
||
# Stale detection: running tasks that have exceeded this many
|
||
# seconds without a heartbeat (since ``last_heartbeat_at``) are
|
||
# auto-reclaimed to ``ready`` on the next dispatcher tick. The
|
||
# worker process (if still running host-locally) is terminated
|
||
# before the reclaim. 0 disables stale detection entirely.
|
||
"dispatch_stale_timeout_seconds": 14400,
|
||
},
|
||
|
||
# execute_code settings — controls the tool used for programmatic tool calls.
|
||
"code_execution": {
|
||
# Execution mode:
|
||
# project (default) — scripts run in the session's working directory
|
||
# with the active virtualenv/conda env's python, so project deps
|
||
# (pandas, torch, project packages) and relative paths resolve.
|
||
# strict — scripts run in an isolated temp directory with
|
||
# hermes-agent's own python (sys.executable). Maximum isolation
|
||
# and reproducibility; project deps and relative paths won't work.
|
||
# Env scrubbing (strips *_API_KEY, *_TOKEN, *_SECRET, ...) and the
|
||
# tool whitelist apply identically in both modes.
|
||
"mode": "project",
|
||
},
|
||
|
||
# Tool Search (progressive disclosure for large tool surfaces).
|
||
# When the model is connected to many MCP servers or non-core plugin
|
||
# tools, their JSON schemas can consume a substantial fraction of the
|
||
# context window on every turn. When enabled, those tools are replaced
|
||
# in the model-facing tools array with three bridge tools —
|
||
# tool_search / tool_describe / tool_call — and surfaced on demand.
|
||
#
|
||
# Core Hermes tools (terminal, read_file, write_file, patch,
|
||
# search_files, todo, memory, browser_*, etc.) are NEVER deferred.
|
||
# See tools/tool_search.py for full design notes and the
|
||
# openclaw-tool-search-report PDF in this PR for the rationale.
|
||
"tools": {
|
||
"tool_search": {
|
||
# "auto" (default) — activate only when deferrable tool schemas
|
||
# exceed ``threshold_pct`` of the active model's context length,
|
||
# so small toolsets pay no overhead.
|
||
# "on" — always activate when there is at least one deferrable
|
||
# tool. Use when you have many MCP servers and want maximum
|
||
# token reduction unconditionally.
|
||
# "off" — disable entirely. Tools-array assembly is a pass-through.
|
||
"enabled": "auto",
|
||
# Percentage of context length at which "auto" mode kicks in.
|
||
# 10 matches the Claude Code default. Range 0..100.
|
||
"threshold_pct": 10,
|
||
# When the model calls tool_search without a ``limit`` argument,
|
||
# how many hits to return. Range 1..max_search_limit.
|
||
"search_default_limit": 5,
|
||
# Hard upper bound the model can request via ``limit``. Range 1..50.
|
||
"max_search_limit": 20,
|
||
},
|
||
},
|
||
|
||
# Logging — controls file logging to ~/.hermes/logs/.
|
||
# agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.
|
||
"logging": {
|
||
"level": "INFO", # Minimum level for agent.log: DEBUG, INFO, WARNING
|
||
"max_size_mb": 5, # Max size per log file before rotation
|
||
"backup_count": 3, # Number of rotated backup files to keep
|
||
},
|
||
|
||
# Remotely-hosted model catalog manifest. When enabled, the CLI fetches
|
||
# curated model lists for OpenRouter and Nous Portal from this URL,
|
||
# falling back to the in-repo snapshot on network failure. Lets us
|
||
# update model picker lists without shipping a hermes-agent release.
|
||
# The default URL is served by the docs site GitHub Pages deploy.
|
||
"model_catalog": {
|
||
"enabled": True,
|
||
"url": "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json",
|
||
# Disk cache TTL in hours. Beyond this, the CLI refetches on the
|
||
# next /model or `hermes model` invocation; network failures
|
||
# silently fall back to the stale cache.
|
||
"ttl_hours": 1,
|
||
# Optional per-provider override URLs for third parties that want
|
||
# to self-host their own curation list using the same schema.
|
||
# Example:
|
||
# providers:
|
||
# openrouter:
|
||
# url: https://example.com/my-curation.json
|
||
"providers": {},
|
||
},
|
||
|
||
# Network settings — workarounds for connectivity issues.
|
||
"network": {
|
||
# Force IPv4 connections. On servers with broken or unreachable IPv6,
|
||
# Python tries AAAA records first and hangs for the full TCP timeout
|
||
# before falling back to IPv4. Set to true to skip IPv6 entirely.
|
||
"force_ipv4": False,
|
||
},
|
||
|
||
# Gateway settings — control how messaging platforms (Telegram, Discord,
|
||
# Slack, etc.) deliver agent-produced files as native attachments.
|
||
"gateway": {
|
||
# When false (default), any file path the agent emits is delivered
|
||
# as a native attachment as long as it isn't under the credential /
|
||
# system-path denylist (/etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env,
|
||
# auth.json, etc.). This matches the symmetry of inbound delivery
|
||
# — we accept any document type the user uploads, and the agent
|
||
# can hand back any file that isn't a credential.
|
||
#
|
||
# When true, fall back to the older allowlist+recency-window
|
||
# behavior: files must live under the Hermes cache, under
|
||
# ``media_delivery_allow_dirs``, or be freshly produced inside the
|
||
# ``trust_recent_files_seconds`` window. Recommended for
|
||
# public-facing gateways where prompt injection from one user
|
||
# shouldn't be able to exfiltrate the host's secrets to that same
|
||
# user. Bridged to HERMES_MEDIA_DELIVERY_STRICT.
|
||
"strict": False,
|
||
# Extra directories from which model-emitted bare file paths may be
|
||
# uploaded as native gateway attachments. Files inside the Hermes
|
||
# cache (~/.hermes/cache/{documents,images,audio,video,screenshots})
|
||
# are always trusted; this list adds operator-controlled roots
|
||
# (project dirs, scratch dirs, mounted shares). Accepts a list of
|
||
# absolute paths or a single os.pathsep-separated string. Bridged
|
||
# to HERMES_MEDIA_ALLOW_DIRS at gateway startup. Tilde paths are
|
||
# expanded. Honored in both default and strict mode.
|
||
"media_delivery_allow_dirs": [],
|
||
# When true, files whose mtime is within ``trust_recent_files_seconds``
|
||
# of "now" are trusted for native delivery even outside the cache /
|
||
# operator allowlist — useful for ``pandoc -o /tmp/report.pdf`` or
|
||
# PDFs the agent writes into a working directory. System paths
|
||
# (/etc, /proc, ~/.ssh, ~/.aws, etc.) remain blocked regardless.
|
||
# Disable to fall back to pure-allowlist mode. Bridged to
|
||
# HERMES_MEDIA_TRUST_RECENT_FILES. Only consulted when ``strict``
|
||
# is true; in default mode the denylist alone gates delivery.
|
||
"trust_recent_files": True,
|
||
# Recency window in seconds. 600 (10 min) comfortably covers a
|
||
# multi-tool agent turn. Bridged to HERMES_MEDIA_TRUST_RECENT_SECONDS.
|
||
# Only consulted when ``strict`` is true.
|
||
"trust_recent_files_seconds": 600,
|
||
},
|
||
|
||
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
|
||
# state.db accumulates every session, message, tool call, and FTS5 index
|
||
# entry forever. Without auto-pruning, a heavy user (gateway + cron)
|
||
# reports 384MB+ databases with 68K+ messages, which slows down FTS5
|
||
# inserts, /resume listing, and insights queries.
|
||
"sessions": {
|
||
# When true, prune ended sessions older than retention_days once
|
||
# per (roughly) min_interval_hours at CLI/gateway/cron startup.
|
||
# Only touches ended sessions — active sessions are always preserved.
|
||
# Default false: session history is valuable for search recall, and
|
||
# silently deleting it could surprise users. Opt in explicitly.
|
||
"auto_prune": False,
|
||
# How many days of ended-session history to keep. Matches the
|
||
# default of ``hermes sessions prune``.
|
||
"retention_days": 90,
|
||
# VACUUM after a prune that actually deleted rows. SQLite does not
|
||
# reclaim disk space on DELETE — freed pages are just reused on
|
||
# subsequent INSERTs — so without VACUUM the file stays bloated
|
||
# even after pruning. VACUUM blocks writes for a few seconds per
|
||
# 100MB, so it only runs at startup, and only when prune deleted
|
||
# ≥1 session.
|
||
"vacuum_after_prune": True,
|
||
# Minimum hours between auto-maintenance runs (avoids repeating
|
||
# the sweep on every CLI invocation). Tracked via state_meta in
|
||
# state.db itself, so it's shared across all processes.
|
||
"min_interval_hours": 24,
|
||
# Legacy per-session JSON snapshot writer. When true, the agent
|
||
# rewrites ``~/.hermes/sessions/session_{sid}.json`` on every turn
|
||
# boundary with the full message list. state.db is canonical and
|
||
# has every field the snapshot stored (plus per-message timestamps
|
||
# and token counts), so this is off by default — the snapshots had
|
||
# no consumer outside their own overwrite guard and accumulated
|
||
# GBs of disk on heavy users. Opt in only if you have an external
|
||
# tool that consumes the JSON files directly.
|
||
"write_json_snapshots": False,
|
||
},
|
||
|
||
# Contextual first-touch onboarding hints (see agent/onboarding.py).
|
||
# Each hint is shown once per install and then latched here so it
|
||
# never fires again. Users can wipe the section to re-see all hints.
|
||
"onboarding": {
|
||
"seen": {},
|
||
},
|
||
|
||
# ``hermes update`` behaviour.
|
||
"updates": {
|
||
# Run a full ``hermes backup``-style zip of HERMES_HOME before every
|
||
# ``hermes update``. Backups land in ``<HERMES_HOME>/backups/`` and
|
||
# can be restored with ``hermes import <path>``. Off by default —
|
||
# on large HERMES_HOME directories the zip can add minutes to every
|
||
# update. Set to true to re-enable, or pass ``--backup`` to opt in
|
||
# for a single update run.
|
||
"pre_update_backup": False,
|
||
# How many pre-update backup zips to retain. Older ones are pruned
|
||
# automatically after each successful backup. Values below 1 are
|
||
# floored to 1 — the backup just created is always preserved. To
|
||
# disable backups entirely, set ``pre_update_backup: false`` above
|
||
# rather than ``backup_keep: 0``.
|
||
"backup_keep": 5,
|
||
},
|
||
|
||
# Language Server Protocol — semantic diagnostics from real
|
||
# language servers (pyright, gopls, rust-analyzer, etc.) wired
|
||
# into the post-write lint check used by ``write_file`` and
|
||
# ``patch``.
|
||
#
|
||
# LSP is gated on git-workspace detection: when the agent's
|
||
# cwd (or the file being edited) is inside a git worktree, LSP
|
||
# runs against that workspace. When neither is in a git repo,
|
||
# LSP stays dormant and the in-process syntax check is the only
|
||
# tier — handy for Telegram/Discord chats where the cwd is the
|
||
# user's home directory.
|
||
"lsp": {
|
||
# Master toggle. Setting this to false disables the entire
|
||
# subsystem — no servers spawn, no background event loop, no
|
||
# cost.
|
||
"enabled": True,
|
||
|
||
# Diagnostic-wait mode for the post-write check.
|
||
# ``"document"`` waits up to ``wait_timeout`` seconds for the
|
||
# current file's diagnostics; ``"full"`` additionally requests
|
||
# workspace-wide diagnostics (slower).
|
||
"wait_mode": "document",
|
||
"wait_timeout": 5.0,
|
||
|
||
# How to handle missing server binaries.
|
||
# ``"auto"`` — try to install via npm/go/pip into
|
||
# ``<HERMES_HOME>/lsp/bin/`` on first use.
|
||
# ``"manual"`` — only use binaries already on PATH.
|
||
# ``"off"`` — alias for ``manual``.
|
||
"install_strategy": "auto",
|
||
|
||
# Per-server overrides. Each key is a server_id from the
|
||
# registry (``pyright``, ``typescript``, ``gopls``,
|
||
# ``rust-analyzer``, etc.) and accepts:
|
||
# disabled: true
|
||
# — skip this server even when its extensions match
|
||
# command: ["full/path/to/server", "--stdio"]
|
||
# — pin a custom binary path; bypasses auto-install
|
||
# env: {"KEY": "value"}
|
||
# — extra env vars passed to the spawned process
|
||
# initialization_options: {...}
|
||
# — merged into the LSP ``initializationOptions``
|
||
# Empty by default; the registry defaults work for typical
|
||
# setups.
|
||
"servers": {},
|
||
},
|
||
|
||
|
||
# X (Twitter) Search via xAI's built-in x_search Responses tool.
|
||
# The tool registers when xAI credentials are available (SuperGrok
|
||
# OAuth or XAI_API_KEY) AND the x_search toolset is enabled in
|
||
# `hermes tools`. These settings tune the backing Responses API call.
|
||
"x_search": {
|
||
# xAI model used for the Responses call. grok-4.20-reasoning is
|
||
# the recommended default; any Grok model with x_search tool
|
||
# access works.
|
||
"model": "grok-4.20-reasoning",
|
||
# Request timeout in seconds (minimum 30). x_search can take
|
||
# 60-120s for complex queries — the default is generous.
|
||
"timeout_seconds": 180,
|
||
# Number of automatic retries on 5xx / ReadTimeout / ConnectionError.
|
||
# Each retry backs off (1.5x attempt seconds, capped at 5s).
|
||
"retries": 2,
|
||
},
|
||
|
||
# =========================================================================
|
||
# External secret sources
|
||
# =========================================================================
|
||
# Pull credentials from external secret managers at process startup
|
||
# rather than storing them in ~/.hermes/.env.
|
||
"secrets": {
|
||
"bitwarden": {
|
||
# Master switch. When false, BSM is never contacted and the
|
||
# bws binary is never auto-installed — same as not having
|
||
# this section at all.
|
||
"enabled": False,
|
||
# Name of the env var that holds the Bitwarden machine-account
|
||
# access token. This is the one bootstrap secret; it lives
|
||
# in ~/.hermes/.env (or your shell) and never in config.yaml.
|
||
"access_token_env": "BWS_ACCESS_TOKEN",
|
||
# UUID of the BSM project to sync from.
|
||
"project_id": "",
|
||
# Seconds to cache fetched secrets in-process. 0 disables.
|
||
"cache_ttl_seconds": 300,
|
||
# When True, BSM values overwrite existing env vars. Default
|
||
# True because the point of using BSM is centralized rotation —
|
||
# if .env had the final say, rotating in Bitwarden wouldn't
|
||
# take effect until you also cleared the matching .env line.
|
||
"override_existing": True,
|
||
# When True, the bws binary is auto-downloaded into
|
||
# ~/.hermes/bin/ on first use. When False you must install
|
||
# bws yourself and have it on PATH.
|
||
"auto_install": True,
|
||
# Bitwarden region / self-hosted endpoint. Empty string
|
||
# means use the bws CLI default (US Cloud,
|
||
# https://vault.bitwarden.com). Set to
|
||
# https://vault.bitwarden.eu for EU Cloud, or your own URL
|
||
# for self-hosted Bitwarden. Plumbed into the bws subprocess
|
||
# as BWS_SERVER_URL. Prompted for during
|
||
# `hermes secrets bitwarden setup`.
|
||
"server_url": "",
|
||
},
|
||
},
|
||
|
||
# Paste collapse thresholds (TUI + CLI).
|
||
#
|
||
# paste_collapse_threshold (default 5)
|
||
# Bracketed-paste handler. Pastes with this many newlines or more
|
||
# collapse to a file reference. Set 0 to disable.
|
||
#
|
||
# paste_collapse_threshold_fallback (default 5)
|
||
# Fallback heuristic for terminals without bracketed paste support.
|
||
# Same line count test but heuristically gated by chars-added /
|
||
# newlines-added to avoid false positives from normal typing.
|
||
# Set 0 to disable.
|
||
#
|
||
# paste_collapse_char_threshold (default 2000)
|
||
# Long single-line paste guard. Pastes whose total char length
|
||
# reaches this value collapse to a file reference even if line
|
||
# count is below the line threshold. Catches the "8000 chars of
|
||
# minified JSON / log output on one line" case. Set 0 to disable.
|
||
"paste_collapse_threshold": 5,
|
||
"paste_collapse_threshold_fallback": 5,
|
||
"paste_collapse_char_threshold": 2000,
|
||
|
||
|
||
# Config schema version - bump this when adding new required fields
|
||
"_config_version": 25,
|
||
}
|
||
|
||
# =============================================================================
|
||
# Config Migration System
|
||
# =============================================================================
|
||
|
||
# Track which env vars were introduced in each config version.
|
||
# Migration only mentions vars new since the user's previous version.
|
||
ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
|
||
3: ["FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "FAL_KEY"],
|
||
4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"],
|
||
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
|
||
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
|
||
10: ["TAVILY_API_KEY"],
|
||
11: ["TERMINAL_MODAL_MODE"],
|
||
}
|
||
|
||
# Required environment variables with metadata for migration prompts.
|
||
# LLM provider is required but handled in the setup wizard's provider
|
||
# selection step (Nous Portal / OpenRouter / Custom endpoint), so this
|
||
# dict is intentionally empty — no single env var is universally required.
|
||
REQUIRED_ENV_VARS = {}
|
||
|
||
# Optional environment variables that enhance functionality
|
||
OPTIONAL_ENV_VARS = {
|
||
# ── Provider (handled in provider selection, not shown in checklists) ──
|
||
"NOUS_BASE_URL": {
|
||
"description": "Nous Portal base URL override",
|
||
"prompt": "Nous Portal base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"OPENROUTER_API_KEY": {
|
||
"description": "OpenRouter API key (for vision, web scraping helpers, and MoA)",
|
||
"prompt": "OpenRouter API key",
|
||
"url": "https://openrouter.ai/keys",
|
||
"password": True,
|
||
"tools": ["vision_analyze", "mixture_of_agents"],
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"GOOGLE_API_KEY": {
|
||
"description": "Google AI Studio API key (also recognized as GEMINI_API_KEY)",
|
||
"prompt": "Google AI Studio API key",
|
||
"url": "https://aistudio.google.com/app/apikey",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"GEMINI_API_KEY": {
|
||
"description": "Google AI Studio API key (alias for GOOGLE_API_KEY)",
|
||
"prompt": "Gemini API key",
|
||
"url": "https://aistudio.google.com/app/apikey",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"GEMINI_BASE_URL": {
|
||
"description": "Google AI Studio base URL override",
|
||
"prompt": "Gemini base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"XAI_API_KEY": {
|
||
"description": "xAI API key",
|
||
"prompt": "xAI API key",
|
||
"url": "https://console.x.ai/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"XAI_BASE_URL": {
|
||
"description": "xAI base URL override",
|
||
"prompt": "xAI base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"NVIDIA_API_KEY": {
|
||
"description": "NVIDIA NIM API key (build.nvidia.com or local NIM endpoint)",
|
||
"prompt": "NVIDIA NIM API key",
|
||
"url": "https://build.nvidia.com/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"NVIDIA_BASE_URL": {
|
||
"description": "NVIDIA NIM base URL override (e.g. http://localhost:8000/v1 for local NIM)",
|
||
"prompt": "NVIDIA NIM base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"LM_API_KEY": {
|
||
"description": "LM Studio bearer token for auth-enabled local servers",
|
||
"prompt": "LM Studio API key / bearer token",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"LM_BASE_URL": {
|
||
"description": "LM Studio base URL override",
|
||
"prompt": "LM Studio base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"GLM_API_KEY": {
|
||
"description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)",
|
||
"prompt": "Z.AI / GLM API key",
|
||
"url": "https://z.ai/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"ZAI_API_KEY": {
|
||
"description": "Z.AI API key (alias for GLM_API_KEY)",
|
||
"prompt": "Z.AI API key",
|
||
"url": "https://z.ai/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"Z_AI_API_KEY": {
|
||
"description": "Z.AI API key (alias for GLM_API_KEY)",
|
||
"prompt": "Z.AI API key",
|
||
"url": "https://z.ai/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"GLM_BASE_URL": {
|
||
"description": "Z.AI / GLM base URL override",
|
||
"prompt": "Z.AI / GLM base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"KIMI_API_KEY": {
|
||
"description": "Kimi / Moonshot API key",
|
||
"prompt": "Kimi API key",
|
||
"url": "https://platform.moonshot.cn/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"KIMI_BASE_URL": {
|
||
"description": "Kimi / Moonshot base URL override",
|
||
"prompt": "Kimi base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"KIMI_CN_API_KEY": {
|
||
"description": "Kimi / Moonshot China API key",
|
||
"prompt": "Kimi (China) API key",
|
||
"url": "https://platform.moonshot.cn/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"STEPFUN_API_KEY": {
|
||
"description": "StepFun Step Plan API key",
|
||
"prompt": "StepFun Step Plan API key",
|
||
"url": "https://platform.stepfun.com/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"STEPFUN_BASE_URL": {
|
||
"description": "StepFun Step Plan base URL override",
|
||
"prompt": "StepFun Step Plan base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"ARCEEAI_API_KEY": {
|
||
"description": "Arcee AI API key",
|
||
"prompt": "Arcee AI API key",
|
||
"url": "https://chat.arcee.ai/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"ARCEE_BASE_URL": {
|
||
"description": "Arcee AI base URL override",
|
||
"prompt": "Arcee base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"GMI_API_KEY": {
|
||
"description": "GMI Cloud API key",
|
||
"prompt": "GMI Cloud API key",
|
||
"url": "https://www.gmicloud.ai/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"GMI_BASE_URL": {
|
||
"description": "GMI Cloud base URL override",
|
||
"prompt": "GMI Cloud base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"MINIMAX_API_KEY": {
|
||
"description": "MiniMax API key (international)",
|
||
"prompt": "MiniMax API key",
|
||
"url": "https://www.minimax.io/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"MINIMAX_BASE_URL": {
|
||
"description": "MiniMax base URL override",
|
||
"prompt": "MiniMax base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"MINIMAX_CN_API_KEY": {
|
||
"description": "MiniMax API key (China endpoint)",
|
||
"prompt": "MiniMax (China) API key",
|
||
"url": "https://www.minimaxi.com/",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"MINIMAX_CN_BASE_URL": {
|
||
"description": "MiniMax (China) base URL override",
|
||
"prompt": "MiniMax (China) base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"DEEPSEEK_API_KEY": {
|
||
"description": "DeepSeek API key for direct DeepSeek access",
|
||
"prompt": "DeepSeek API Key",
|
||
"url": "https://platform.deepseek.com/api_keys",
|
||
"password": True,
|
||
"category": "provider",
|
||
},
|
||
"DEEPSEEK_BASE_URL": {
|
||
"description": "Custom DeepSeek API base URL (advanced)",
|
||
"prompt": "DeepSeek Base URL",
|
||
"url": "",
|
||
"password": False,
|
||
"category": "provider",
|
||
},
|
||
"DASHSCOPE_API_KEY": {
|
||
"description": "Alibaba Cloud DashScope API key (Qwen + multi-provider models)",
|
||
"prompt": "DashScope API Key",
|
||
"url": "https://modelstudio.console.alibabacloud.com/",
|
||
"password": True,
|
||
"category": "provider",
|
||
},
|
||
"DASHSCOPE_BASE_URL": {
|
||
"description": "Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)",
|
||
"prompt": "DashScope Base URL",
|
||
"url": "",
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"HERMES_QWEN_BASE_URL": {
|
||
"description": "Qwen Portal base URL override (default: https://portal.qwen.ai/v1)",
|
||
"prompt": "Qwen Portal base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"HERMES_GEMINI_CLIENT_ID": {
|
||
"description": "Google OAuth client ID for google-gemini-cli (optional; defaults to Google's public gemini-cli client)",
|
||
"prompt": "Google OAuth client ID (optional — leave empty to use the public default)",
|
||
"url": "https://console.cloud.google.com/apis/credentials",
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"HERMES_GEMINI_CLIENT_SECRET": {
|
||
"description": "Google OAuth client secret for google-gemini-cli (optional)",
|
||
"prompt": "Google OAuth client secret (optional)",
|
||
"url": "https://console.cloud.google.com/apis/credentials",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"HERMES_GEMINI_PROJECT_ID": {
|
||
"description": "GCP project ID for paid Gemini tiers (free tier auto-provisions)",
|
||
"prompt": "GCP project ID for Gemini OAuth (leave empty for free tier)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"OPENCODE_ZEN_API_KEY": {
|
||
"description": "OpenCode Zen API key (pay-as-you-go access to curated models)",
|
||
"prompt": "OpenCode Zen API key",
|
||
"url": "https://opencode.ai/auth",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"OPENCODE_ZEN_BASE_URL": {
|
||
"description": "OpenCode Zen base URL override",
|
||
"prompt": "OpenCode Zen base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"OPENCODE_GO_API_KEY": {
|
||
"description": "OpenCode Go API key ($10/month subscription for open models)",
|
||
"prompt": "OpenCode Go API key",
|
||
"url": "https://opencode.ai/auth",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"OPENCODE_GO_BASE_URL": {
|
||
"description": "OpenCode Go base URL override",
|
||
"prompt": "OpenCode Go base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"HF_TOKEN": {
|
||
"description": "Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)",
|
||
"prompt": "Hugging Face Token",
|
||
"url": "https://huggingface.co/settings/tokens",
|
||
"password": True,
|
||
"category": "provider",
|
||
},
|
||
"HF_BASE_URL": {
|
||
"description": "Hugging Face Inference Providers base URL override",
|
||
"prompt": "HF base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"OLLAMA_API_KEY": {
|
||
"description": "Ollama Cloud API key (ollama.com — cloud-hosted open models)",
|
||
"prompt": "Ollama Cloud API key",
|
||
"url": "https://ollama.com/settings",
|
||
"password": True,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"OLLAMA_BASE_URL": {
|
||
"description": "Ollama Cloud base URL override (default: https://ollama.com/v1)",
|
||
"prompt": "Ollama base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"XIAOMI_API_KEY": {
|
||
"description": "Xiaomi MiMo API key for MiMo models (mimo-v2.5-pro, mimo-v2.5, mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)",
|
||
"prompt": "Xiaomi MiMo API Key",
|
||
"url": "https://platform.xiaomimimo.com",
|
||
"password": True,
|
||
"category": "provider",
|
||
},
|
||
"XIAOMI_BASE_URL": {
|
||
"description": "Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)",
|
||
"prompt": "Xiaomi base URL (leave empty for default)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"AWS_REGION": {
|
||
"description": "AWS region for Bedrock API calls (e.g. us-east-1, eu-central-1)",
|
||
"prompt": "AWS Region",
|
||
"url": "https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html",
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"AWS_PROFILE": {
|
||
"description": "AWS named profile for Bedrock authentication (from ~/.aws/credentials)",
|
||
"prompt": "AWS Profile",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
"AZURE_FOUNDRY_API_KEY": {
|
||
"description": "Azure Foundry API key for custom Azure endpoints",
|
||
"prompt": "Azure Foundry API Key",
|
||
"url": "https://ai.azure.com/",
|
||
"password": True,
|
||
"category": "provider",
|
||
},
|
||
"AZURE_FOUNDRY_BASE_URL": {
|
||
"description": "Azure Foundry base URL (set via 'hermes model' for endpoint-specific config)",
|
||
"prompt": "Azure Foundry base URL",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
},
|
||
|
||
# ── Tool API keys ──
|
||
"EXA_API_KEY": {
|
||
"description": "Exa API key for AI-native web search and contents",
|
||
"prompt": "Exa API key",
|
||
"url": "https://exa.ai/",
|
||
"tools": ["web_search", "web_extract"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"PARALLEL_API_KEY": {
|
||
"description": "Parallel API key for AI-native web search and extract",
|
||
"prompt": "Parallel API key",
|
||
"url": "https://parallel.ai/",
|
||
"tools": ["web_search", "web_extract"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"FIRECRAWL_API_KEY": {
|
||
"description": "Firecrawl API key for web search and scraping",
|
||
"prompt": "Firecrawl API key",
|
||
"url": "https://firecrawl.dev/",
|
||
"tools": ["web_search", "web_extract"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"FIRECRAWL_API_URL": {
|
||
"description": "Firecrawl API URL for self-hosted instances (optional)",
|
||
"prompt": "Firecrawl API URL (leave empty for cloud)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "tool",
|
||
"advanced": True,
|
||
},
|
||
"FIRECRAWL_GATEWAY_URL": {
|
||
"description": "Exact Firecrawl tool-gateway origin override for Nous Subscribers only (optional)",
|
||
"prompt": "Firecrawl gateway URL (leave empty to derive from domain)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "tool",
|
||
"advanced": True,
|
||
},
|
||
"TOOL_GATEWAY_DOMAIN": {
|
||
"description": "Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, e.g. nousresearch.com -> firecrawl-gateway.nousresearch.com",
|
||
"prompt": "Tool-gateway domain suffix",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "tool",
|
||
"advanced": True,
|
||
},
|
||
"TOOL_GATEWAY_SCHEME": {
|
||
"description": "Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)",
|
||
"prompt": "Tool-gateway URL scheme",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "tool",
|
||
"advanced": True,
|
||
},
|
||
"TOOL_GATEWAY_USER_TOKEN": {
|
||
"description": "Explicit Nous Subscriber access token for tool-gateway requests (optional; otherwise read from the Hermes auth store)",
|
||
"prompt": "Tool-gateway user token",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "tool",
|
||
"advanced": True,
|
||
},
|
||
"TAVILY_API_KEY": {
|
||
"description": "Tavily API key for AI-native web search and extract",
|
||
"prompt": "Tavily API key",
|
||
"url": "https://app.tavily.com/home",
|
||
"tools": ["web_search", "web_extract"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"SEARXNG_URL": {
|
||
"description": "URL of your SearXNG instance for free self-hosted web search",
|
||
"prompt": "SearXNG URL (e.g. http://localhost:8080)",
|
||
"url": "https://searxng.github.io/searxng/",
|
||
"tools": ["web_search"],
|
||
"password": False,
|
||
"category": "tool",
|
||
},
|
||
"BRAVE_SEARCH_API_KEY": {
|
||
"description": "Brave Search API subscription token (free tier: 2,000 queries/mo)",
|
||
"prompt": "Brave Search subscription token",
|
||
"url": "https://brave.com/search/api/",
|
||
"tools": ["web_search"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"BROWSERBASE_API_KEY": {
|
||
"description": "Browserbase API key for cloud browser (optional — local browser works without this)",
|
||
"prompt": "Browserbase API key",
|
||
"url": "https://browserbase.com/",
|
||
"tools": ["browser_navigate", "browser_click"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"BROWSERBASE_PROJECT_ID": {
|
||
"description": "Browserbase project ID (optional — only needed for cloud browser)",
|
||
"prompt": "Browserbase project ID",
|
||
"url": "https://browserbase.com/",
|
||
"tools": ["browser_navigate", "browser_click"],
|
||
"password": False,
|
||
"category": "tool",
|
||
},
|
||
"BROWSER_USE_API_KEY": {
|
||
"description": "Browser Use API key for cloud browser (optional — local browser works without this)",
|
||
"prompt": "Browser Use API key",
|
||
"url": "https://browser-use.com/",
|
||
"tools": ["browser_navigate", "browser_click"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"FIRECRAWL_BROWSER_TTL": {
|
||
"description": "Firecrawl browser session TTL in seconds (optional, default 300)",
|
||
"prompt": "Browser session TTL (seconds)",
|
||
"tools": ["browser_navigate", "browser_click"],
|
||
"password": False,
|
||
"category": "tool",
|
||
},
|
||
"AGENT_BROWSER_ENGINE": {
|
||
"description": "Browser engine for local mode: auto (default Chrome), lightpanda (faster, no screenshots), chrome",
|
||
"prompt": "Browser engine (auto/lightpanda/chrome)",
|
||
"url": "https://github.com/vercel-labs/agent-browser",
|
||
"tools": ["browser_navigate", "browser_snapshot", "browser_click", "browser_vision"],
|
||
"password": False,
|
||
"category": "tool",
|
||
"advanced": True,
|
||
},
|
||
"CAMOFOX_URL": {
|
||
"description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)",
|
||
"prompt": "Camofox server URL",
|
||
"url": "https://github.com/jo-inc/camofox-browser",
|
||
"tools": ["browser_navigate", "browser_click"],
|
||
"password": False,
|
||
"category": "tool",
|
||
},
|
||
"FAL_KEY": {
|
||
"description": "FAL API key for image and video generation",
|
||
"prompt": "FAL API key",
|
||
"url": "https://fal.ai/",
|
||
"tools": ["image_generate", "video_generate"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"KREA_API_KEY": {
|
||
"description": "Krea API key for Krea 2 image generation (Medium + Large)",
|
||
"prompt": "Krea API key",
|
||
"url": "https://www.krea.ai/settings/api-tokens",
|
||
"tools": ["image_generate"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"VOICE_TOOLS_OPENAI_KEY": {
|
||
"description": "OpenAI API key for voice transcription (Whisper) and OpenAI TTS",
|
||
"prompt": "OpenAI API Key (for Whisper STT + TTS)",
|
||
"url": "https://platform.openai.com/api-keys",
|
||
"tools": ["voice_transcription", "openai_tts"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"ELEVENLABS_API_KEY": {
|
||
"description": "ElevenLabs API key for premium text-to-speech voices and Scribe transcription",
|
||
"prompt": "ElevenLabs API key",
|
||
"url": "https://elevenlabs.io/",
|
||
"tools": ["elevenlabs_tts", "voice_transcription"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"MISTRAL_API_KEY": {
|
||
"description": "Mistral API key for Voxtral TTS and transcription (STT)",
|
||
"prompt": "Mistral API key",
|
||
"url": "https://console.mistral.ai/",
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"GITHUB_TOKEN": {
|
||
"description": "GitHub token for Skills Hub (higher API rate limits, skill publish)",
|
||
"prompt": "GitHub Token",
|
||
"url": "https://github.com/settings/tokens",
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
|
||
# ── Bundled skills (opt-in: only needed if the user uses that skill) ──
|
||
# These use category="skill" (distinct from "tool") so the sandbox
|
||
# env blocklist in tools/environments/local.py does NOT rewrite them —
|
||
# skills legitimately need these passed through to curl via
|
||
# tools/env_passthrough.py when the user's skill calls out.
|
||
"NOTION_API_KEY": {
|
||
"description": "Notion integration token (used by the `notion` skill)",
|
||
"prompt": "Notion API key",
|
||
"url": "https://www.notion.so/my-integrations",
|
||
"password": True,
|
||
"category": "skill",
|
||
"advanced": True,
|
||
},
|
||
"LINEAR_API_KEY": {
|
||
"description": "Linear personal API key (used by the `linear` skill)",
|
||
"prompt": "Linear API key",
|
||
"url": "https://linear.app/settings/account/security",
|
||
"password": True,
|
||
"category": "skill",
|
||
"advanced": True,
|
||
},
|
||
"AIRTABLE_API_KEY": {
|
||
"description": "Airtable personal access token (used by the `airtable` skill)",
|
||
"prompt": "Airtable API key",
|
||
"url": "https://airtable.com/create/tokens",
|
||
"password": True,
|
||
"category": "skill",
|
||
"advanced": True,
|
||
},
|
||
"TENOR_API_KEY": {
|
||
"description": "Tenor API key for GIF search (used by the `gif-search` skill)",
|
||
"prompt": "Tenor API key",
|
||
"url": "https://developers.google.com/tenor/guides/quickstart",
|
||
"password": True,
|
||
"category": "skill",
|
||
"advanced": True,
|
||
},
|
||
|
||
# ── Honcho ──
|
||
"HONCHO_API_KEY": {
|
||
"description": "Honcho API key for AI-native persistent memory",
|
||
"prompt": "Honcho API key",
|
||
"url": "https://app.honcho.dev",
|
||
"tools": ["honcho_context"],
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"HONCHO_BASE_URL": {
|
||
"description": "Base URL for self-hosted Honcho instances (no API key needed)",
|
||
"prompt": "Honcho base URL (e.g. http://localhost:8000)",
|
||
"category": "tool",
|
||
},
|
||
|
||
# ── Langfuse observability ──
|
||
"HERMES_LANGFUSE_PUBLIC_KEY": {
|
||
"description": "Langfuse project public key (pk-lf-...)",
|
||
"prompt": "Langfuse public key",
|
||
"url": "https://cloud.langfuse.com",
|
||
"password": False,
|
||
"category": "tool",
|
||
},
|
||
"HERMES_LANGFUSE_SECRET_KEY": {
|
||
"description": "Langfuse project secret key (sk-lf-...)",
|
||
"prompt": "Langfuse secret key",
|
||
"url": "https://cloud.langfuse.com",
|
||
"password": True,
|
||
"category": "tool",
|
||
},
|
||
"HERMES_LANGFUSE_BASE_URL": {
|
||
"description": "Langfuse server URL (default: https://cloud.langfuse.com)",
|
||
"prompt": "Langfuse server URL (leave empty for cloud.langfuse.com)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "tool",
|
||
"advanced": True,
|
||
},
|
||
|
||
# ── Messaging platforms ──
|
||
"TELEGRAM_BOT_TOKEN": {
|
||
"description": "Telegram bot token from @BotFather",
|
||
"prompt": "Telegram bot token",
|
||
"url": "https://t.me/BotFather",
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
"TELEGRAM_ALLOWED_USERS": {
|
||
"description": "Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)",
|
||
"prompt": "Allowed Telegram user IDs (comma-separated)",
|
||
"url": "https://t.me/userinfobot",
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"TELEGRAM_PROXY": {
|
||
"description": "Proxy URL for Telegram connections (overrides HTTPS_PROXY). Supports http://, https://, socks5://",
|
||
"prompt": "Telegram proxy URL (optional)",
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"DISCORD_BOT_TOKEN": {
|
||
"description": "Discord bot token from Developer Portal",
|
||
"prompt": "Discord bot token",
|
||
"url": "https://discord.com/developers/applications",
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
"DISCORD_ALLOWED_USERS": {
|
||
"description": "Comma-separated Discord user IDs allowed to use the bot",
|
||
"prompt": "Allowed Discord user IDs (comma-separated)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"DISCORD_REPLY_TO_MODE": {
|
||
"description": "Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)",
|
||
"prompt": "Discord reply mode (off/first/all)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"SLACK_BOT_TOKEN": {
|
||
"description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. "
|
||
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
|
||
"im:history, im:read, im:write, users:read, files:read, files:write",
|
||
"prompt": "Slack Bot Token (xoxb-...)",
|
||
"url": "https://api.slack.com/apps",
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
"SLACK_APP_TOKEN": {
|
||
"description": "Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → "
|
||
"App-Level Tokens. Also ensure Event Subscriptions include: message.im, "
|
||
"message.channels, message.groups, app_mention",
|
||
"prompt": "Slack App Token (xapp-...)",
|
||
"url": "https://api.slack.com/apps",
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
"MATTERMOST_URL": {
|
||
"description": "Mattermost server URL (e.g. https://mm.example.com)",
|
||
"prompt": "Mattermost server URL",
|
||
"url": "https://mattermost.com/deploy/",
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"MATTERMOST_TOKEN": {
|
||
"description": "Mattermost bot token or personal access token",
|
||
"prompt": "Mattermost bot token",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
"MATTERMOST_ALLOWED_USERS": {
|
||
"description": "Comma-separated Mattermost user IDs allowed to use the bot",
|
||
"prompt": "Allowed Mattermost user IDs (comma-separated)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"MATTERMOST_REQUIRE_MENTION": {
|
||
"description": "Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.",
|
||
"prompt": "Require @mention in channels",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"MATTERMOST_FREE_RESPONSE_CHANNELS": {
|
||
"description": "Comma-separated Mattermost channel IDs where bot responds without @mention",
|
||
"prompt": "Free-response channel IDs (comma-separated)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"MATRIX_HOMESERVER": {
|
||
"description": "Matrix homeserver URL (e.g. https://matrix.example.org)",
|
||
"prompt": "Matrix homeserver URL",
|
||
"url": "https://matrix.org/ecosystem/servers/",
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"MATRIX_ACCESS_TOKEN": {
|
||
"description": "Matrix access token (preferred over password login)",
|
||
"prompt": "Matrix access token",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
"MATRIX_USER_ID": {
|
||
"description": "Matrix user ID (e.g. @hermes:example.org)",
|
||
"prompt": "Matrix user ID (@user:server)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"MATRIX_ALLOWED_USERS": {
|
||
"description": "Comma-separated Matrix user IDs allowed to use the bot (@user:server format)",
|
||
"prompt": "Allowed Matrix user IDs (comma-separated)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"MATRIX_REQUIRE_MENTION": {
|
||
"description": "Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.",
|
||
"prompt": "Require @mention in rooms (true/false)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"MATRIX_FREE_RESPONSE_ROOMS": {
|
||
"description": "Comma-separated Matrix room IDs where bot responds without @mention",
|
||
"prompt": "Free-response room IDs (comma-separated)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"MATRIX_AUTO_THREAD": {
|
||
"description": "Auto-create threads for messages in Matrix rooms (default: true)",
|
||
"prompt": "Auto-create threads in rooms (true/false)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"MATRIX_DM_AUTO_THREAD": {
|
||
"description": "Auto-create threads for DM messages in Matrix (default: false)",
|
||
"prompt": "Auto-create threads in DMs (true/false)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"MATRIX_DEVICE_ID": {
|
||
"description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)",
|
||
"prompt": "Matrix device ID (stable across restarts)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"MATRIX_RECOVERY_KEY": {
|
||
"description": "Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)",
|
||
"prompt": "Matrix recovery key",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"BLUEBUBBLES_SERVER_URL": {
|
||
"description": "BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)",
|
||
"prompt": "BlueBubbles server URL",
|
||
"url": "https://bluebubbles.app/",
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"BLUEBUBBLES_PASSWORD": {
|
||
"description": "BlueBubbles server password (from BlueBubbles Server → Settings → API)",
|
||
"prompt": "BlueBubbles server password",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
"BLUEBUBBLES_ALLOWED_USERS": {
|
||
"description": "Comma-separated iMessage addresses (email or phone) allowed to use the bot",
|
||
"prompt": "Allowed iMessage addresses (comma-separated)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"BLUEBUBBLES_ALLOW_ALL_USERS": {
|
||
"description": "Allow all BlueBubbles users without allowlist",
|
||
"prompt": "Allow All BlueBubbles Users",
|
||
"category": "messaging",
|
||
},
|
||
"QQ_APP_ID": {
|
||
"description": "QQ Bot App ID from QQ Open Platform (q.qq.com)",
|
||
"prompt": "QQ App ID",
|
||
"url": "https://q.qq.com",
|
||
"category": "messaging",
|
||
},
|
||
"QQ_CLIENT_SECRET": {
|
||
"description": "QQ Bot Client Secret from QQ Open Platform",
|
||
"prompt": "QQ Client Secret",
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
"QQ_ALLOWED_USERS": {
|
||
"description": "Comma-separated QQ user IDs allowed to use the bot",
|
||
"prompt": "QQ Allowed Users",
|
||
"category": "messaging",
|
||
},
|
||
"QQ_GROUP_ALLOWED_USERS": {
|
||
"description": "Comma-separated QQ group IDs allowed to interact with the bot",
|
||
"prompt": "QQ Group Allowed Users",
|
||
"category": "messaging",
|
||
},
|
||
"QQ_ALLOW_ALL_USERS": {
|
||
"description": "Allow all QQ users without an allowlist (true/false)",
|
||
"prompt": "Allow All QQ Users",
|
||
"category": "messaging",
|
||
},
|
||
"QQBOT_HOME_CHANNEL": {
|
||
"description": "Default QQ channel/group for cron delivery and notifications",
|
||
"prompt": "QQ Home Channel",
|
||
"category": "messaging",
|
||
},
|
||
"QQBOT_HOME_CHANNEL_NAME": {
|
||
"description": "Display name for the QQ home channel",
|
||
"prompt": "QQ Home Channel Name",
|
||
"category": "messaging",
|
||
},
|
||
"QQ_SANDBOX": {
|
||
"description": "Enable QQ sandbox mode for development testing (true/false)",
|
||
"prompt": "QQ Sandbox Mode",
|
||
"category": "messaging",
|
||
},
|
||
"IRC_SERVER": {
|
||
"description": "IRC server hostname (e.g. irc.libera.chat)",
|
||
"prompt": "IRC server",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"IRC_CHANNEL": {
|
||
"description": "IRC channel to join (e.g. #hermes)",
|
||
"prompt": "IRC channel",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"IRC_NICKNAME": {
|
||
"description": "Bot nickname on IRC (default: hermes-bot)",
|
||
"prompt": "IRC nickname",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"IRC_SERVER_PASSWORD": {
|
||
"description": "IRC server password (if required)",
|
||
"prompt": "IRC server password",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"IRC_NICKSERV_PASSWORD": {
|
||
"description": "NickServ password for nick identification",
|
||
"prompt": "NickServ password",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"GATEWAY_ALLOW_ALL_USERS": {
|
||
"description": "Allow all users to interact with messaging bots (true/false). Default: false.",
|
||
"prompt": "Allow all users (true/false)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"API_SERVER_ENABLED": {
|
||
"description": "Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.",
|
||
"prompt": "Enable API server (true/false)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"API_SERVER_KEY": {
|
||
"description": "Bearer token for API server authentication. Required whenever the API server is enabled; server refuses to start without it.",
|
||
"prompt": "API server auth key",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"API_SERVER_PORT": {
|
||
"description": "Port for the API server (default: 8642).",
|
||
"prompt": "API server port",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"API_SERVER_HOST": {
|
||
"description": "Host/bind address for the API server (default: 127.0.0.1). API_SERVER_KEY is still required even on loopback binds.",
|
||
"prompt": "API server host",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"API_SERVER_MODEL_NAME": {
|
||
"description": "Model name advertised on /v1/models. Defaults to the profile name (or 'hermes-agent' for the default profile). Useful for multi-user setups with OpenWebUI.",
|
||
"prompt": "API server model name",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"GATEWAY_PROXY_URL": {
|
||
"description": "URL of a remote Hermes API server to forward messages to (proxy mode). When set, the gateway handles platform I/O only — all agent work is delegated to the remote server. Use for Docker E2EE containers that relay to a host agent. Also configurable via gateway.proxy_url in config.yaml.",
|
||
"prompt": "Remote Hermes API server URL (e.g. http://192.168.1.100:8642)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"GATEWAY_PROXY_KEY": {
|
||
"description": "Bearer token for authenticating with the remote Hermes API server (proxy mode). Must match the API_SERVER_KEY on the remote host.",
|
||
"prompt": "Remote API server auth key",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "messaging",
|
||
"advanced": True,
|
||
},
|
||
"WEBHOOK_ENABLED": {
|
||
"description": "Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.",
|
||
"prompt": "Enable webhooks (true/false)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"WEBHOOK_PORT": {
|
||
"description": "Port for the webhook HTTP server (default: 8644).",
|
||
"prompt": "Webhook port",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "messaging",
|
||
},
|
||
"WEBHOOK_SECRET": {
|
||
"description": "Global HMAC secret for webhook signature validation (overridable per route in config.yaml).",
|
||
"prompt": "Webhook secret",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "messaging",
|
||
},
|
||
|
||
# ── Agent settings ──
|
||
# NOTE: MESSAGING_CWD was removed here — use terminal.cwd in config.yaml
|
||
# instead. The gateway reads TERMINAL_CWD (bridged from terminal.cwd).
|
||
"SUDO_PASSWORD": {
|
||
"description": "Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting",
|
||
"prompt": "Sudo password",
|
||
"url": None,
|
||
"password": True,
|
||
"category": "setting",
|
||
},
|
||
"HERMES_MAX_ITERATIONS": {
|
||
"description": "Maximum tool-calling iterations per conversation (default: 90)",
|
||
"prompt": "Max iterations",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "setting",
|
||
},
|
||
# HERMES_TOOL_PROGRESS and HERMES_TOOL_PROGRESS_MODE are deprecated —
|
||
# now configured via display.tool_progress in config.yaml (off|new|all|verbose).
|
||
# Gateway falls back to these env vars for backward compatibility.
|
||
"HERMES_TOOL_PROGRESS": {
|
||
"description": "(deprecated) Use display.tool_progress in config.yaml instead",
|
||
"prompt": "Tool progress (deprecated — use config.yaml)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "setting",
|
||
},
|
||
"HERMES_TOOL_PROGRESS_MODE": {
|
||
"description": "(deprecated) Use display.tool_progress in config.yaml instead",
|
||
"prompt": "Progress mode (deprecated — use config.yaml)",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "setting",
|
||
},
|
||
"HERMES_PREFILL_MESSAGES_FILE": {
|
||
"description": "Path to JSON file with ephemeral prefill messages for few-shot priming",
|
||
"prompt": "Prefill messages file path",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "setting",
|
||
},
|
||
"HERMES_EPHEMERAL_SYSTEM_PROMPT": {
|
||
"description": "Ephemeral system prompt injected at API-call time (never persisted to sessions)",
|
||
"prompt": "Ephemeral system prompt",
|
||
"url": None,
|
||
"password": False,
|
||
"category": "setting",
|
||
},
|
||
}
|
||
|
||
# Tool Gateway env vars are always visible — they're useful for
|
||
# self-hosted / custom gateway setups regardless of subscription state.
|
||
|
||
|
||
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
|
||
"""
|
||
Check which environment variables are missing.
|
||
|
||
Returns list of dicts with var info for missing variables.
|
||
"""
|
||
missing = []
|
||
|
||
# Check required vars
|
||
for var_name, info in REQUIRED_ENV_VARS.items():
|
||
if not get_env_value(var_name):
|
||
missing.append({"name": var_name, **info, "is_required": True})
|
||
|
||
# Check optional vars (if not required_only)
|
||
if not required_only:
|
||
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||
if not get_env_value(var_name):
|
||
missing.append({"name": var_name, **info, "is_required": False})
|
||
|
||
return missing
|
||
|
||
|
||
def _set_nested(config, dotted_key: str, value):
|
||
"""Set a value at an arbitrarily nested dotted key path.
|
||
|
||
Supports both dict and list navigation:
|
||
_set_nested(c, "a.b.c", 1) → c["a"]["b"]["c"] = 1
|
||
_set_nested(c, "a.0.b", 1) → c["a"][0]["b"] = 1
|
||
_set_nested(c, "providers.1", "x") → c["providers"][1] = "x"
|
||
|
||
Intermediate dicts are created on demand. List indices are parsed
|
||
from numeric path segments; the referenced index must already exist
|
||
(we do not grow lists — the user is navigating into structure they
|
||
wrote themselves). If a segment targets a non-container leaf
|
||
(scalar), the leaf is replaced with a fresh dict so the write can
|
||
proceed — this preserves the pre-existing behavior for bare scalar
|
||
overrides (e.g. setting ``a.b.c`` where ``a.b`` was previously a
|
||
string).
|
||
|
||
Guards against #17876: before this fix the code unconditionally
|
||
replaced any non-dict value (including lists) with ``{}``, silently
|
||
destroying list-typed config like ``custom_providers`` whenever a
|
||
caller used an indexed path.
|
||
"""
|
||
parts = dotted_key.split(".")
|
||
current = config
|
||
for part in parts[:-1]:
|
||
if isinstance(current, list):
|
||
try:
|
||
idx = int(part)
|
||
except (TypeError, ValueError):
|
||
raise TypeError(
|
||
f"Cannot navigate into list at key {dotted_key!r}: "
|
||
f"segment {part!r} is not a numeric index"
|
||
)
|
||
current = current[idx]
|
||
elif isinstance(current, dict):
|
||
existing = current.get(part)
|
||
# Preserve dicts and lists; replace missing/scalar with a fresh dict.
|
||
if part not in current or not isinstance(existing, (dict, list)):
|
||
current[part] = {}
|
||
current = current[part]
|
||
else:
|
||
raise TypeError(
|
||
f"Cannot navigate into {type(current).__name__} at key {dotted_key!r}"
|
||
)
|
||
last = parts[-1]
|
||
if isinstance(current, list):
|
||
current[int(last)] = value
|
||
else:
|
||
current[last] = value
|
||
|
||
|
||
def get_missing_config_fields() -> List[Dict[str, Any]]:
|
||
"""
|
||
Check which config fields are missing or outdated (recursive).
|
||
|
||
Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys
|
||
present in defaults but absent from the user's loaded config.
|
||
"""
|
||
config = load_config()
|
||
missing = []
|
||
|
||
def _check(defaults: dict, current: dict, prefix: str = ""):
|
||
for key, default_value in defaults.items():
|
||
if key.startswith('_'):
|
||
continue
|
||
full_key = key if not prefix else f"{prefix}.{key}"
|
||
if key not in current:
|
||
missing.append({
|
||
"key": full_key,
|
||
"default": default_value,
|
||
"description": f"New config option: {full_key}",
|
||
})
|
||
elif isinstance(default_value, dict) and isinstance(current.get(key), dict):
|
||
_check(default_value, current[key], full_key)
|
||
|
||
_check(DEFAULT_CONFIG, config)
|
||
return missing
|
||
|
||
|
||
def get_missing_skill_config_vars() -> List[Dict[str, Any]]:
|
||
"""Return skill-declared config vars that are missing or empty in config.yaml.
|
||
|
||
Scans all enabled skills for ``metadata.hermes.config`` entries, then checks
|
||
which ones are absent or empty under ``skills.config.<key>`` in the user's
|
||
config.yaml. Returns a list of dicts suitable for prompting.
|
||
"""
|
||
try:
|
||
from agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX
|
||
except Exception:
|
||
return []
|
||
|
||
try:
|
||
all_vars = discover_all_skill_config_vars()
|
||
except Exception as e:
|
||
# A malformed SKILL.md, unreadable external skill dir, or similar
|
||
# should never break `hermes update`. Skill-config prompting is a
|
||
# post-migration nicety, not a blocker.
|
||
import logging
|
||
logging.getLogger(__name__).debug(
|
||
"discover_all_skill_config_vars failed: %s", e
|
||
)
|
||
return []
|
||
if not all_vars:
|
||
return []
|
||
|
||
config = load_config()
|
||
missing: List[Dict[str, Any]] = []
|
||
for var in all_vars:
|
||
# Skill config is stored under skills.config.<logical_key>
|
||
storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}"
|
||
parts = storage_key.split(".")
|
||
current = config
|
||
value = None
|
||
for part in parts:
|
||
if isinstance(current, dict) and part in current:
|
||
current = current[part]
|
||
value = current
|
||
else:
|
||
value = None
|
||
break
|
||
# Missing = key doesn't exist or is empty string
|
||
if value is None or (isinstance(value, str) and not value.strip()):
|
||
missing.append(var)
|
||
return missing
|
||
|
||
|
||
def _normalize_custom_provider_entry(
|
||
entry: Any,
|
||
*,
|
||
provider_key: str = "",
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Return a runtime-compatible custom provider entry or ``None``."""
|
||
if not isinstance(entry, dict):
|
||
return None
|
||
|
||
# Accept camelCase aliases commonly used in hand-written configs.
|
||
_CAMEL_ALIASES: Dict[str, str] = {
|
||
"apiKey": "api_key",
|
||
"baseUrl": "base_url",
|
||
"apiMode": "api_mode",
|
||
"keyEnv": "key_env",
|
||
"apiKeyEnv": "key_env", # alias — OpenClaw-compatible + docs variant
|
||
"defaultModel": "default_model",
|
||
"contextLength": "context_length",
|
||
"rateLimitDelay": "rate_limit_delay",
|
||
}
|
||
# api_key_env is a documented snake_case alias for key_env (see
|
||
# website/docs/guides/azure-foundry.md). Normalize it up front so the
|
||
# rest of the normalizer treats it as the canonical field.
|
||
if "api_key_env" in entry and "key_env" not in entry:
|
||
entry["key_env"] = entry["api_key_env"]
|
||
_KNOWN_KEYS = {
|
||
"name", "api", "url", "base_url", "api_key", "key_env", "api_key_env",
|
||
"api_mode", "transport", "model", "default_model", "models",
|
||
"context_length", "rate_limit_delay",
|
||
"request_timeout_seconds", "stale_timeout_seconds",
|
||
"discover_models", "extra_body",
|
||
}
|
||
for camel, snake in _CAMEL_ALIASES.items():
|
||
if camel in entry and snake not in entry:
|
||
logger.warning(
|
||
"providers.%s: camelCase key '%s' auto-mapped to '%s' "
|
||
"(use snake_case to avoid this warning)",
|
||
provider_key or "?", camel, snake,
|
||
)
|
||
entry[snake] = entry[camel]
|
||
unknown = set(entry.keys()) - _KNOWN_KEYS - set(_CAMEL_ALIASES.keys())
|
||
if unknown:
|
||
logger.warning(
|
||
"providers.%s: unknown config keys ignored: %s",
|
||
provider_key or "?", ", ".join(sorted(unknown)),
|
||
)
|
||
|
||
from urllib.parse import urlparse
|
||
|
||
base_url = ""
|
||
for url_key in ("base_url", "url", "api"):
|
||
raw_url = entry.get(url_key)
|
||
if isinstance(raw_url, str) and raw_url.strip():
|
||
candidate = raw_url.strip()
|
||
parsed = urlparse(candidate)
|
||
if parsed.scheme and parsed.netloc:
|
||
base_url = candidate
|
||
break
|
||
else:
|
||
logger.warning(
|
||
"providers.%s: '%s' value '%s' is not a valid URL "
|
||
"(no scheme or host) — skipped",
|
||
provider_key or "?", url_key, candidate,
|
||
)
|
||
if not base_url:
|
||
return None
|
||
|
||
name = ""
|
||
raw_name = entry.get("name")
|
||
if isinstance(raw_name, str) and raw_name.strip():
|
||
name = raw_name.strip()
|
||
elif provider_key.strip():
|
||
name = provider_key.strip()
|
||
if not name:
|
||
return None
|
||
|
||
normalized: Dict[str, Any] = {
|
||
"name": name,
|
||
"base_url": base_url,
|
||
}
|
||
|
||
provider_key = provider_key.strip()
|
||
if provider_key:
|
||
normalized["provider_key"] = provider_key
|
||
|
||
api_key = entry.get("api_key")
|
||
if isinstance(api_key, str) and api_key.strip():
|
||
normalized["api_key"] = api_key.strip()
|
||
|
||
key_env = entry.get("key_env")
|
||
if isinstance(key_env, str) and key_env.strip():
|
||
normalized["key_env"] = key_env.strip()
|
||
|
||
api_mode = entry.get("api_mode") or entry.get("transport")
|
||
if isinstance(api_mode, str) and api_mode.strip():
|
||
normalized["api_mode"] = api_mode.strip()
|
||
|
||
model_name = entry.get("model") or entry.get("default_model")
|
||
if isinstance(model_name, str) and model_name.strip():
|
||
normalized["model"] = model_name.strip()
|
||
|
||
models = entry.get("models")
|
||
if isinstance(models, dict) and models:
|
||
normalized["models"] = models
|
||
elif isinstance(models, list) and models:
|
||
# Hand-edited configs (and older Hermes versions) write ``models`` as
|
||
# a plain list of model ids. Preserve them by converting to the dict
|
||
# shape downstream code expects; otherwise normalize silently drops
|
||
# the list and /model shows the provider with (0) models.
|
||
normalized["models"] = {
|
||
str(m): {} for m in models if isinstance(m, str) and m.strip()
|
||
}
|
||
|
||
context_length = entry.get("context_length")
|
||
if isinstance(context_length, int) and context_length > 0:
|
||
normalized["context_length"] = context_length
|
||
|
||
rate_limit_delay = entry.get("rate_limit_delay")
|
||
if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:
|
||
normalized["rate_limit_delay"] = rate_limit_delay
|
||
|
||
discover_models = entry.get("discover_models")
|
||
if isinstance(discover_models, bool):
|
||
normalized["discover_models"] = discover_models
|
||
|
||
extra_body = entry.get("extra_body")
|
||
if isinstance(extra_body, dict):
|
||
normalized["extra_body"] = dict(extra_body)
|
||
|
||
return normalized
|
||
|
||
|
||
def providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:
|
||
"""Normalize ``providers`` config entries into the legacy custom-provider shape."""
|
||
if not isinstance(providers_dict, dict):
|
||
return []
|
||
|
||
custom_providers: List[Dict[str, Any]] = []
|
||
for key, entry in providers_dict.items():
|
||
normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))
|
||
if normalized is not None:
|
||
custom_providers.append(normalized)
|
||
|
||
return custom_providers
|
||
|
||
|
||
def get_compatible_custom_providers(
|
||
config: Optional[Dict[str, Any]] = None,
|
||
) -> List[Dict[str, Any]]:
|
||
"""Return a deduplicated custom-provider view across legacy and v12+ config.
|
||
|
||
``custom_providers`` remains the on-disk legacy format, while ``providers``
|
||
is the newer keyed schema. Runtime and picker flows still need a single
|
||
list-shaped view, but we should not materialise that compatibility layer
|
||
back into config.yaml because it duplicates entries in UIs.
|
||
"""
|
||
if config is None:
|
||
config = load_config()
|
||
|
||
compatible: List[Dict[str, Any]] = []
|
||
seen_provider_keys: set = set()
|
||
seen_name_url_pairs: set = set()
|
||
|
||
def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:
|
||
if entry is None:
|
||
return
|
||
provider_key = str(entry.get("provider_key", "") or "").strip().lower()
|
||
name = str(entry.get("name", "") or "").strip().lower()
|
||
base_url = str(entry.get("base_url", "") or "").strip().rstrip("/").lower()
|
||
model = str(entry.get("model", "") or "").strip().lower()
|
||
pair = (name, base_url, model)
|
||
|
||
if provider_key and provider_key in seen_provider_keys:
|
||
return
|
||
if name and base_url and pair in seen_name_url_pairs:
|
||
return
|
||
|
||
compatible.append(entry)
|
||
if provider_key:
|
||
seen_provider_keys.add(provider_key)
|
||
if name and base_url:
|
||
seen_name_url_pairs.add(pair)
|
||
|
||
custom_providers = config.get("custom_providers")
|
||
if custom_providers is not None:
|
||
if not isinstance(custom_providers, list):
|
||
return []
|
||
for entry in custom_providers:
|
||
_append_if_new(_normalize_custom_provider_entry(entry))
|
||
|
||
for entry in providers_dict_to_custom_providers(config.get("providers")):
|
||
_append_if_new(entry)
|
||
|
||
return compatible
|
||
|
||
|
||
def get_custom_provider_context_length(
|
||
model: str,
|
||
base_url: str,
|
||
custom_providers: Optional[List[Dict[str, Any]]] = None,
|
||
config: Optional[Dict[str, Any]] = None,
|
||
) -> Optional[int]:
|
||
"""Look up a per-model ``context_length`` override from ``custom_providers``.
|
||
|
||
Matches any entry whose ``base_url`` equals ``base_url`` (trailing-slash
|
||
insensitive) and returns ``custom_providers[i].models.<model>.context_length``
|
||
if present and valid. Returns ``None`` when no override applies.
|
||
|
||
This is the single source of truth for custom-provider context overrides,
|
||
used by:
|
||
* ``AIAgent.__init__`` (startup resolution)
|
||
* ``AIAgent.switch_model`` (mid-session ``/model`` switch)
|
||
* ``hermes_cli.model_switch.resolve_display_context_length`` (``/model`` confirmation display)
|
||
* ``gateway.run._format_session_info`` (``/info`` display)
|
||
* ``agent.model_metadata.get_model_context_length`` (when custom_providers is threaded through)
|
||
|
||
Before this helper existed, the lookup was duplicated in ``run_agent.py``'s
|
||
startup path only; every other path (notably ``/model`` switch) fell back
|
||
to the 128K default. See #15779.
|
||
"""
|
||
if not model or not base_url:
|
||
return None
|
||
if custom_providers is None:
|
||
try:
|
||
custom_providers = get_compatible_custom_providers(config)
|
||
except Exception:
|
||
if config is None:
|
||
return None
|
||
raw = config.get("custom_providers")
|
||
custom_providers = raw if isinstance(raw, list) else []
|
||
if not isinstance(custom_providers, list):
|
||
return None
|
||
|
||
target_url = (base_url or "").rstrip("/")
|
||
if not target_url:
|
||
return None
|
||
|
||
for entry in custom_providers:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
entry_url = (entry.get("base_url") or "").rstrip("/")
|
||
if not entry_url or entry_url != target_url:
|
||
continue
|
||
models = entry.get("models")
|
||
if not isinstance(models, dict):
|
||
continue
|
||
model_cfg = models.get(model)
|
||
if not isinstance(model_cfg, dict):
|
||
continue
|
||
raw_ctx = model_cfg.get("context_length")
|
||
if raw_ctx is None:
|
||
continue
|
||
try:
|
||
ctx = int(raw_ctx)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if ctx > 0:
|
||
return ctx
|
||
return None
|
||
|
||
|
||
def check_config_version() -> Tuple[int, int]:
|
||
"""
|
||
Check config version.
|
||
|
||
Returns (current_version, latest_version).
|
||
"""
|
||
config = load_config()
|
||
current = config.get("_config_version", 0)
|
||
latest = DEFAULT_CONFIG.get("_config_version", 1)
|
||
return current, latest
|
||
|
||
|
||
# =============================================================================
|
||
# Config structure validation
|
||
# =============================================================================
|
||
|
||
# Fields that are valid at root level of config.yaml
|
||
_KNOWN_ROOT_KEYS = {
|
||
"_config_version", "model", "providers", "fallback_model",
|
||
"fallback_providers", "credential_pool_strategies", "toolsets",
|
||
"agent", "terminal", "display", "compression", "delegation",
|
||
"auxiliary", "custom_providers", "context", "memory", "gateway",
|
||
"sessions",
|
||
}
|
||
|
||
# Valid fields inside a custom_providers list entry
|
||
_VALID_CUSTOM_PROVIDER_FIELDS = {
|
||
"name", "base_url", "api_key", "api_mode", "model", "models",
|
||
"context_length", "rate_limit_delay", "extra_body",
|
||
# key_env is read at runtime by runtime_provider.py and auxiliary_client.py
|
||
# — include it here so the set accurately describes the supported schema.
|
||
"key_env",
|
||
}
|
||
|
||
# Fields that look like they should be inside custom_providers, not at root
|
||
_CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"}
|
||
|
||
|
||
@dataclass
|
||
class ConfigIssue:
|
||
"""A detected config structure problem."""
|
||
|
||
severity: str # "error", "warning"
|
||
message: str
|
||
hint: str
|
||
|
||
|
||
def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["ConfigIssue"]:
|
||
"""Validate config.yaml structure and return a list of detected issues.
|
||
|
||
Catches common YAML formatting mistakes that produce confusing runtime
|
||
errors (like "Unknown provider") instead of clear diagnostics.
|
||
|
||
Can be called with a pre-loaded config dict, or will load from disk.
|
||
"""
|
||
if config is None:
|
||
try:
|
||
config = load_config()
|
||
except Exception:
|
||
return [ConfigIssue("error", "Could not load config.yaml", "Run 'hermes setup' to create a valid config")]
|
||
|
||
issues: List[ConfigIssue] = []
|
||
|
||
# ── custom_providers must be a list, not a dict ──────────────────────
|
||
cp = config.get("custom_providers")
|
||
if cp is not None:
|
||
if isinstance(cp, dict):
|
||
issues.append(ConfigIssue(
|
||
"error",
|
||
"custom_providers is a dict — it must be a YAML list (items prefixed with '-')",
|
||
"Change to:\n"
|
||
" custom_providers:\n"
|
||
" - name: my-provider\n"
|
||
" base_url: https://...\n"
|
||
" api_key: ...",
|
||
))
|
||
# Check if dict keys look like they should be list-entry fields
|
||
cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()
|
||
suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS
|
||
if suspicious:
|
||
issues.append(ConfigIssue(
|
||
"warning",
|
||
f"Root-level keys {sorted(suspicious)} look like custom_providers entry fields",
|
||
"These should be indented under a '- name: ...' list entry, not at root level",
|
||
))
|
||
elif isinstance(cp, list):
|
||
# Validate each entry in the list
|
||
for i, entry in enumerate(cp):
|
||
if not isinstance(entry, dict):
|
||
issues.append(ConfigIssue(
|
||
"warning",
|
||
f"custom_providers[{i}] is not a dict (got {type(entry).__name__})",
|
||
"Each entry should have at minimum: name, base_url",
|
||
))
|
||
continue
|
||
if not entry.get("name"):
|
||
issues.append(ConfigIssue(
|
||
"warning",
|
||
f"custom_providers[{i}] is missing 'name' field",
|
||
"Add a name, e.g.: name: my-provider",
|
||
))
|
||
if not entry.get("base_url"):
|
||
issues.append(ConfigIssue(
|
||
"warning",
|
||
f"custom_providers[{i}] is missing 'base_url' field",
|
||
"Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1",
|
||
))
|
||
|
||
# ── fallback_model: single dict OR list of dicts (chain) ─────────────
|
||
fb = config.get("fallback_model")
|
||
if fb is not None:
|
||
if isinstance(fb, list):
|
||
# Chain fallback — validate each entry
|
||
for i, entry in enumerate(fb):
|
||
if not isinstance(entry, dict):
|
||
issues.append(ConfigIssue(
|
||
"error",
|
||
f"fallback_model[{i}] should be a dict, got {type(entry).__name__}",
|
||
"Each entry needs provider + model",
|
||
))
|
||
else:
|
||
if not entry.get("provider"):
|
||
issues.append(ConfigIssue(
|
||
"warning",
|
||
f"fallback_model[{i}] is missing 'provider' field",
|
||
"Add: provider: openrouter (or another provider)",
|
||
))
|
||
if not entry.get("model"):
|
||
issues.append(ConfigIssue(
|
||
"warning",
|
||
f"fallback_model[{i}] is missing 'model' field",
|
||
"Add: model: <model-name>",
|
||
))
|
||
elif not isinstance(fb, dict):
|
||
issues.append(ConfigIssue(
|
||
"error",
|
||
f"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}",
|
||
"Change to:\n"
|
||
" fallback_model:\n"
|
||
" provider: openrouter\n"
|
||
" model: anthropic/claude-sonnet-4",
|
||
))
|
||
elif fb:
|
||
if not fb.get("provider"):
|
||
issues.append(ConfigIssue(
|
||
"warning",
|
||
"fallback_model is missing 'provider' field — fallback will be disabled",
|
||
"Add: provider: openrouter (or another provider)",
|
||
))
|
||
if not fb.get("model"):
|
||
issues.append(ConfigIssue(
|
||
"warning",
|
||
"fallback_model is missing 'model' field — fallback will be disabled",
|
||
"Add: model: anthropic/claude-sonnet-4 (or another model)",
|
||
))
|
||
|
||
# ── Check for fallback_model accidentally nested inside custom_providers ──
|
||
if isinstance(cp, dict) and "fallback_model" not in config and "fallback_model" in (cp or {}):
|
||
issues.append(ConfigIssue(
|
||
"error",
|
||
"fallback_model appears inside custom_providers instead of at root level",
|
||
"Move fallback_model to the top level of config.yaml (no indentation)",
|
||
))
|
||
|
||
# ── model section: should exist when custom_providers is configured ──
|
||
model_cfg = config.get("model")
|
||
if cp and not model_cfg:
|
||
issues.append(ConfigIssue(
|
||
"warning",
|
||
"custom_providers defined but no 'model' section — Hermes won't know which provider to use",
|
||
"Add a model section:\n"
|
||
" model:\n"
|
||
" provider: custom\n"
|
||
" default: your-model-name\n"
|
||
" base_url: https://...",
|
||
))
|
||
|
||
# ── Root-level keys that look misplaced ──────────────────────────────
|
||
for key in config:
|
||
if key.startswith("_"):
|
||
continue
|
||
if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:
|
||
issues.append(ConfigIssue(
|
||
"warning",
|
||
f"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?",
|
||
f"Move '{key}' under the appropriate section",
|
||
))
|
||
|
||
return issues
|
||
|
||
|
||
def print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:
|
||
"""Print config structure warnings to stderr at startup.
|
||
|
||
Called early in CLI and gateway init so users see problems before
|
||
they hit cryptic "Unknown provider" errors. Prints nothing if
|
||
config is healthy.
|
||
"""
|
||
try:
|
||
issues = validate_config_structure(config)
|
||
except Exception:
|
||
return
|
||
if not issues:
|
||
return
|
||
|
||
lines = ["\033[33m⚠ Config issues detected in config.yaml:\033[0m"]
|
||
for ci in issues:
|
||
marker = "\033[31m✗\033[0m" if ci.severity == "error" else "\033[33m⚠\033[0m"
|
||
lines.append(f" {marker} {ci.message}")
|
||
lines.append(" \033[2mRun 'hermes doctor' for fix suggestions.\033[0m")
|
||
sys.stderr.write("\n".join(lines) + "\n\n")
|
||
|
||
|
||
def warn_deprecated_cwd_env_vars(config: Optional[Dict[str, Any]] = None) -> None:
|
||
"""Warn if MESSAGING_CWD or TERMINAL_CWD is set in .env instead of config.yaml.
|
||
|
||
These env vars are deprecated — the canonical setting is terminal.cwd
|
||
in config.yaml. Prints a migration hint to stderr.
|
||
"""
|
||
messaging_cwd = os.environ.get("MESSAGING_CWD")
|
||
terminal_cwd_env = os.environ.get("TERMINAL_CWD")
|
||
|
||
if config is None:
|
||
try:
|
||
config = load_config()
|
||
except Exception:
|
||
return
|
||
|
||
terminal_cfg = config.get("terminal", {})
|
||
config_cwd = terminal_cfg.get("cwd", ".") if isinstance(terminal_cfg, dict) else "."
|
||
# Only warn if config.yaml doesn't have an explicit path
|
||
config_has_explicit_cwd = config_cwd not in {".", "auto", "cwd", ""}
|
||
|
||
lines: list[str] = []
|
||
if messaging_cwd:
|
||
lines.append(
|
||
f" \033[33m⚠\033[0m MESSAGING_CWD={messaging_cwd} found in .env — "
|
||
f"this is deprecated."
|
||
)
|
||
if terminal_cwd_env and not config_has_explicit_cwd:
|
||
# TERMINAL_CWD in env but not from config bridge — likely from .env
|
||
lines.append(
|
||
f" \033[33m⚠\033[0m TERMINAL_CWD={terminal_cwd_env} found in .env — "
|
||
f"this is deprecated."
|
||
)
|
||
if lines:
|
||
hint_path = os.environ.get("HERMES_HOME", "~/.hermes")
|
||
lines.insert(0, "\033[33m⚠ Deprecated .env settings detected:\033[0m")
|
||
lines.append(
|
||
f" \033[2mMove to config.yaml instead: "
|
||
f"terminal:\\n cwd: /your/project/path\033[0m"
|
||
)
|
||
lines.append(
|
||
f" \033[2mThen remove the old entries from {hint_path}/.env\033[0m"
|
||
)
|
||
sys.stderr.write("\n".join(lines) + "\n\n")
|
||
|
||
|
||
def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:
|
||
"""
|
||
Migrate config to latest version, prompting for new required fields.
|
||
|
||
Args:
|
||
interactive: If True, prompt user for missing values
|
||
quiet: If True, suppress output
|
||
|
||
Returns:
|
||
Dict with migration results: {"env_added": [...], "config_added": [...], "warnings": [...]}
|
||
"""
|
||
results = {"env_added": [], "config_added": [], "warnings": []}
|
||
|
||
# ── Always: sanitize .env (split concatenated keys) ──
|
||
try:
|
||
fixes = sanitize_env_file()
|
||
if fixes and not quiet:
|
||
print(f" ✓ Repaired .env file ({fixes} corrupted entries fixed)")
|
||
except Exception:
|
||
pass # best-effort; don't block migration on sanitize failure
|
||
|
||
# Check config version
|
||
current_ver, latest_ver = check_config_version()
|
||
|
||
# ── Version 3 → 4: migrate tool progress from .env to config.yaml ──
|
||
if current_ver < 4:
|
||
config = load_config()
|
||
display = config.get("display", {})
|
||
if not isinstance(display, dict):
|
||
display = {}
|
||
if "tool_progress" not in display:
|
||
old_enabled = get_env_value("HERMES_TOOL_PROGRESS")
|
||
old_mode = get_env_value("HERMES_TOOL_PROGRESS_MODE")
|
||
if old_enabled and old_enabled.lower() in {"false", "0", "no"}:
|
||
display["tool_progress"] = "off"
|
||
results["config_added"].append("display.tool_progress=off (from HERMES_TOOL_PROGRESS=false)")
|
||
elif old_mode and old_mode.lower() in {"new", "all"}:
|
||
display["tool_progress"] = old_mode.lower()
|
||
results["config_added"].append(f"display.tool_progress={old_mode.lower()} (from HERMES_TOOL_PROGRESS_MODE)")
|
||
else:
|
||
display["tool_progress"] = "all"
|
||
results["config_added"].append("display.tool_progress=all (default)")
|
||
config["display"] = display
|
||
save_config(config)
|
||
if not quiet:
|
||
print(f" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}")
|
||
|
||
# ── Version 4 → 5: add timezone field ──
|
||
if current_ver < 5:
|
||
config = load_config()
|
||
if "timezone" not in config:
|
||
old_tz = os.getenv("HERMES_TIMEZONE", "")
|
||
if old_tz and old_tz.strip():
|
||
config["timezone"] = old_tz.strip()
|
||
results["config_added"].append(f"timezone={old_tz.strip()} (from HERMES_TIMEZONE)")
|
||
else:
|
||
config["timezone"] = ""
|
||
results["config_added"].append("timezone= (empty, uses server-local)")
|
||
save_config(config)
|
||
if not quiet:
|
||
tz_display = config["timezone"] or "(server-local)"
|
||
print(f" ✓ Added timezone to config.yaml: {tz_display}")
|
||
|
||
# ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──
|
||
# The new Anthropic auth flow no longer uses this env var.
|
||
if current_ver < 9:
|
||
try:
|
||
old_token = get_env_value("ANTHROPIC_TOKEN")
|
||
if old_token:
|
||
save_env_value("ANTHROPIC_TOKEN", "")
|
||
if not quiet:
|
||
print(" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)")
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Version 11 → 12: migrate custom_providers list → providers dict ──
|
||
if current_ver < 12:
|
||
config = load_config()
|
||
custom_list = config.get("custom_providers")
|
||
if isinstance(custom_list, list) and custom_list:
|
||
providers_dict = config.get("providers", {})
|
||
if not isinstance(providers_dict, dict):
|
||
providers_dict = {}
|
||
migrated_count = 0
|
||
for entry in custom_list:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
old_name = entry.get("name", "")
|
||
old_url = entry.get("base_url", "") or entry.get("url", "") or ""
|
||
old_key = entry.get("api_key", "")
|
||
if not old_url:
|
||
continue # skip entries with no URL
|
||
|
||
# Generate a kebab-case key from the display name
|
||
key = old_name.strip().lower().replace(" ", "-").replace("(", "").replace(")", "")
|
||
# Remove consecutive hyphens and trailing hyphens
|
||
while "--" in key:
|
||
key = key.replace("--", "-")
|
||
key = key.strip("-")
|
||
if not key:
|
||
# Fallback: derive from URL hostname
|
||
try:
|
||
from urllib.parse import urlparse
|
||
parsed = urlparse(old_url)
|
||
key = (parsed.hostname or "endpoint").replace(".", "-")
|
||
except Exception:
|
||
key = f"endpoint-{migrated_count}"
|
||
|
||
# Don't overwrite existing entries
|
||
if key in providers_dict:
|
||
key = f"{key}-{migrated_count}"
|
||
|
||
new_entry = {"api": old_url}
|
||
if old_name:
|
||
new_entry["name"] = old_name
|
||
if old_key and old_key not in {"no-key", "no-key-required", ""}:
|
||
new_entry["api_key"] = old_key
|
||
|
||
# Carry over model and api_mode if present
|
||
if entry.get("model"):
|
||
new_entry["default_model"] = entry["model"]
|
||
if entry.get("api_mode"):
|
||
new_entry["transport"] = entry["api_mode"]
|
||
|
||
providers_dict[key] = new_entry
|
||
migrated_count += 1
|
||
|
||
if migrated_count > 0:
|
||
config["providers"] = providers_dict
|
||
# Remove the old list — runtime reads via get_compatible_custom_providers()
|
||
config.pop("custom_providers", None)
|
||
save_config(config)
|
||
if not quiet:
|
||
print(f" ✓ Migrated {migrated_count} custom provider(s) to providers: section")
|
||
for key in list(providers_dict.keys())[-migrated_count:]:
|
||
ep = providers_dict[key]
|
||
print(f" → {key}: {ep.get('api', '')}")
|
||
|
||
# ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──
|
||
# These env vars were written by the old setup wizard but nothing reads
|
||
# them anymore (config.yaml is the sole source of truth since March 2026).
|
||
# Stale entries cause user confusion — see issue report.
|
||
if current_ver < 13:
|
||
for dead_var in ("LLM_MODEL", "OPENAI_MODEL"):
|
||
try:
|
||
old_val = get_env_value(dead_var)
|
||
if old_val:
|
||
save_env_value(dead_var, "")
|
||
if not quiet:
|
||
print(f" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)")
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Version 13 → 14: migrate legacy flat stt.model to provider section ──
|
||
# Old configs (and cli-config.yaml.example) had a flat `stt.model` key
|
||
# that was provider-agnostic. When the provider was "local" this caused
|
||
# OpenAI model names (e.g. "whisper-1") to be fed to faster-whisper,
|
||
# crashing with "Invalid model size". Move the value into the correct
|
||
# provider-specific section and remove the flat key.
|
||
if current_ver < 14:
|
||
# Read raw config (no defaults merged) to check what the user actually
|
||
# wrote, then apply changes to the merged config for saving.
|
||
raw = read_raw_config()
|
||
raw_stt = raw.get("stt", {})
|
||
if isinstance(raw_stt, dict) and "model" in raw_stt:
|
||
legacy_model = raw_stt["model"]
|
||
provider = raw_stt.get("provider", "local")
|
||
config = load_config()
|
||
stt = config.get("stt", {})
|
||
# Remove the legacy flat key
|
||
stt.pop("model", None)
|
||
# Place it in the appropriate provider section only if the
|
||
# user didn't already set a model there
|
||
if provider in {"local", "local_command"}:
|
||
# Don't migrate an OpenAI model name into the local section
|
||
_local_models = {
|
||
"tiny.en", "tiny", "base.en", "base", "small.en", "small",
|
||
"medium.en", "medium", "large-v1", "large-v2", "large-v3",
|
||
"large", "distil-large-v2", "distil-medium.en",
|
||
"distil-small.en", "distil-large-v3", "distil-large-v3.5",
|
||
"large-v3-turbo", "turbo",
|
||
}
|
||
if legacy_model in _local_models:
|
||
# Check raw config — only set if user didn't already
|
||
# have a nested local.model
|
||
raw_local = raw_stt.get("local", {})
|
||
if not isinstance(raw_local, dict) or "model" not in raw_local:
|
||
local_cfg = stt.setdefault("local", {})
|
||
local_cfg["model"] = legacy_model
|
||
# else: drop it — it was an OpenAI model name, local section
|
||
# already defaults to "base" via DEFAULT_CONFIG
|
||
else:
|
||
# Cloud provider — put it in that provider's section only
|
||
# if user didn't already set a nested model
|
||
raw_provider = raw_stt.get(provider, {})
|
||
if not isinstance(raw_provider, dict) or "model" not in raw_provider:
|
||
provider_cfg = stt.setdefault(provider, {})
|
||
provider_cfg["model"] = legacy_model
|
||
config["stt"] = stt
|
||
save_config(config)
|
||
if not quiet:
|
||
print(f" ✓ Migrated legacy stt.model to provider-specific config")
|
||
|
||
# ── Version 14 → 15: add explicit gateway interim-message gate ──
|
||
if current_ver < 15:
|
||
config = read_raw_config()
|
||
display = config.get("display", {})
|
||
if not isinstance(display, dict):
|
||
display = {}
|
||
if "interim_assistant_messages" not in display:
|
||
display["interim_assistant_messages"] = True
|
||
config["display"] = display
|
||
results["config_added"].append("display.interim_assistant_messages=true (default)")
|
||
save_config(config)
|
||
if not quiet:
|
||
print(" ✓ Added display.interim_assistant_messages=true")
|
||
|
||
# ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──
|
||
if current_ver < 16:
|
||
config = read_raw_config()
|
||
display = config.get("display", {})
|
||
if not isinstance(display, dict):
|
||
display = {}
|
||
old_overrides = display.get("tool_progress_overrides")
|
||
if isinstance(old_overrides, dict) and old_overrides:
|
||
platforms = display.get("platforms", {})
|
||
if not isinstance(platforms, dict):
|
||
platforms = {}
|
||
for plat, mode in old_overrides.items():
|
||
if plat not in platforms:
|
||
platforms[plat] = {}
|
||
if "tool_progress" not in platforms[plat]:
|
||
platforms[plat]["tool_progress"] = mode
|
||
display["platforms"] = platforms
|
||
config["display"] = display
|
||
save_config(config)
|
||
if not quiet:
|
||
migrated = ", ".join(f"{p}={m}" for p, m in old_overrides.items())
|
||
print(f" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}")
|
||
results["config_added"].append("display.platforms (migrated from tool_progress_overrides)")
|
||
|
||
# ── Version 16 → 17: remove legacy compression.summary_* keys ──
|
||
if current_ver < 17:
|
||
config = read_raw_config()
|
||
comp = config.get("compression", {})
|
||
if isinstance(comp, dict):
|
||
s_model = comp.pop("summary_model", None)
|
||
s_provider = comp.pop("summary_provider", None)
|
||
s_base_url = comp.pop("summary_base_url", None)
|
||
migrated_keys = []
|
||
# Migrate non-empty, non-default values to auxiliary.compression
|
||
if s_model and str(s_model).strip():
|
||
aux = config.setdefault("auxiliary", {})
|
||
aux_comp = aux.setdefault("compression", {})
|
||
if not aux_comp.get("model"):
|
||
aux_comp["model"] = str(s_model).strip()
|
||
migrated_keys.append(f"model={s_model}")
|
||
if s_provider and str(s_provider).strip() not in {"", "auto"}:
|
||
aux = config.setdefault("auxiliary", {})
|
||
aux_comp = aux.setdefault("compression", {})
|
||
if not aux_comp.get("provider") or aux_comp.get("provider") == "auto":
|
||
aux_comp["provider"] = str(s_provider).strip()
|
||
migrated_keys.append(f"provider={s_provider}")
|
||
if s_base_url and str(s_base_url).strip():
|
||
aux = config.setdefault("auxiliary", {})
|
||
aux_comp = aux.setdefault("compression", {})
|
||
if not aux_comp.get("base_url"):
|
||
aux_comp["base_url"] = str(s_base_url).strip()
|
||
migrated_keys.append(f"base_url={s_base_url}")
|
||
if migrated_keys or s_model is not None or s_provider is not None or s_base_url is not None:
|
||
config["compression"] = comp
|
||
save_config(config)
|
||
if not quiet:
|
||
if migrated_keys:
|
||
print(f" ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}")
|
||
else:
|
||
print(" ✓ Removed unused compression.summary_* keys")
|
||
|
||
# ── Version 20 → 21: plugins are now opt-in; grandfather existing user plugins ──
|
||
# The loader now requires plugins to appear in ``plugins.enabled`` before
|
||
# loading. Existing installs had all discovered plugins loading by default
|
||
# (minus anything in ``plugins.disabled``). To avoid silently breaking
|
||
# those setups on upgrade, populate ``plugins.enabled`` with the set of
|
||
# currently-installed user plugins that aren't already disabled.
|
||
#
|
||
# Bundled plugins (shipped in the repo itself) are NOT grandfathered —
|
||
# they ship off for everyone, including existing users, so any user who
|
||
# wants one has to opt in explicitly.
|
||
if current_ver < 21:
|
||
config = read_raw_config()
|
||
plugins_cfg = config.get("plugins")
|
||
if not isinstance(plugins_cfg, dict):
|
||
plugins_cfg = {}
|
||
# Only migrate if the enabled allow-list hasn't been set yet.
|
||
if "enabled" not in plugins_cfg:
|
||
disabled = plugins_cfg.get("disabled", []) or []
|
||
if not isinstance(disabled, list):
|
||
disabled = []
|
||
disabled_set = set(disabled)
|
||
|
||
# Scan ``$HERMES_HOME/plugins/`` for currently installed user plugins.
|
||
grandfathered: List[str] = []
|
||
try:
|
||
user_plugins_dir = get_hermes_home() / "plugins"
|
||
if user_plugins_dir.is_dir():
|
||
for child in sorted(user_plugins_dir.iterdir()):
|
||
if not child.is_dir():
|
||
continue
|
||
manifest_file = child / "plugin.yaml"
|
||
if not manifest_file.exists():
|
||
manifest_file = child / "plugin.yml"
|
||
if not manifest_file.exists():
|
||
continue
|
||
try:
|
||
with open(manifest_file, encoding="utf-8") as _mf:
|
||
manifest = yaml.safe_load(_mf) or {}
|
||
except Exception:
|
||
manifest = {}
|
||
name = manifest.get("name") or child.name
|
||
if name in disabled_set:
|
||
continue
|
||
grandfathered.append(name)
|
||
except Exception:
|
||
grandfathered = []
|
||
|
||
plugins_cfg["enabled"] = grandfathered
|
||
config["plugins"] = plugins_cfg
|
||
save_config(config)
|
||
results["config_added"].append(
|
||
f"plugins.enabled (opt-in allow-list, {len(grandfathered)} grandfathered)"
|
||
)
|
||
if not quiet:
|
||
if grandfathered:
|
||
print(
|
||
f" ✓ Plugins now opt-in: grandfathered "
|
||
f"{len(grandfathered)} existing plugin(s) into plugins.enabled"
|
||
)
|
||
else:
|
||
print(
|
||
" ✓ Plugins now opt-in: no existing plugins to grandfather. "
|
||
"Use `hermes plugins enable <name>` to activate."
|
||
)
|
||
|
||
# ── Version 22 → 23: seed curator defaults + create logs/curator/ ──
|
||
# The curator (background skill maintenance) was added in PR #16049, but
|
||
# existing configs from before that PR (or before the April 2026
|
||
# unification under `auxiliary.curator`) never wrote the curator section
|
||
# to disk. The runtime deep-merge in `load_config()` fills defaults at
|
||
# read time, so the curator *functions*; but users can't see/edit the
|
||
# settings in their `config.yaml`, and `hermes curator status` has no
|
||
# stable logs dir to point at until the first run mkdir's it.
|
||
#
|
||
# This migration:
|
||
# 1. Writes the `curator` top-level section to config.yaml (enabled,
|
||
# interval_hours, min_idle_hours, stale_after_days, archive_after_days)
|
||
# — only keys the user hasn't already overridden.
|
||
# 2. Writes the `auxiliary.curator` aux-task slot (provider, model,
|
||
# base_url, api_key, timeout, extra_body) — canonical slot for
|
||
# routing the curator fork to a cheaper aux model.
|
||
# 3. Creates `~/.hermes/logs/curator/` if missing (belt-and-suspenders
|
||
# on top of ensure_hermes_home() — old profiles that predate this
|
||
# migration still benefit).
|
||
if current_ver < 23:
|
||
try:
|
||
curator_dir = get_hermes_home() / "logs" / "curator"
|
||
curator_dir.mkdir(parents=True, exist_ok=True)
|
||
except Exception as e:
|
||
results["warnings"].append(f"Could not create {curator_dir}: {e}")
|
||
|
||
config = read_raw_config()
|
||
touched = False
|
||
|
||
# (1) Top-level curator section — only add missing keys
|
||
_curator_defaults = DEFAULT_CONFIG.get("curator", {})
|
||
raw_curator = config.get("curator")
|
||
if not isinstance(raw_curator, dict):
|
||
raw_curator = {}
|
||
added_curator: List[str] = []
|
||
for k, v in _curator_defaults.items():
|
||
if k not in raw_curator:
|
||
raw_curator[k] = copy.deepcopy(v)
|
||
added_curator.append(k)
|
||
if added_curator:
|
||
config["curator"] = raw_curator
|
||
touched = True
|
||
|
||
# (2) auxiliary.curator task slot
|
||
_aux_curator_defaults = (
|
||
DEFAULT_CONFIG.get("auxiliary", {}).get("curator", {})
|
||
)
|
||
raw_aux = config.get("auxiliary")
|
||
if not isinstance(raw_aux, dict):
|
||
raw_aux = {}
|
||
raw_aux_curator = raw_aux.get("curator")
|
||
if not isinstance(raw_aux_curator, dict):
|
||
raw_aux_curator = {}
|
||
added_aux: List[str] = []
|
||
for k, v in _aux_curator_defaults.items():
|
||
if k not in raw_aux_curator:
|
||
raw_aux_curator[k] = copy.deepcopy(v)
|
||
added_aux.append(k)
|
||
if added_aux:
|
||
raw_aux["curator"] = raw_aux_curator
|
||
config["auxiliary"] = raw_aux
|
||
touched = True
|
||
|
||
if touched:
|
||
save_config(config)
|
||
if added_curator:
|
||
results["config_added"].append(
|
||
f"curator ({len(added_curator)} default key(s))"
|
||
)
|
||
if not quiet:
|
||
print(
|
||
" ✓ Seeded curator defaults in config.yaml: "
|
||
f"{', '.join(added_curator)}"
|
||
)
|
||
if added_aux:
|
||
results["config_added"].append(
|
||
f"auxiliary.curator ({len(added_aux)} default key(s))"
|
||
)
|
||
if not quiet:
|
||
print(
|
||
" ✓ Seeded auxiliary.curator defaults in config.yaml: "
|
||
f"{', '.join(added_aux)}"
|
||
)
|
||
|
||
# ── Version 24 → 25: lower model_catalog TTL 24h → 1h ──
|
||
# The model picker now refreshes its curated list hourly so freshly
|
||
# published model-catalog.json deploys reach users without a day-long
|
||
# stale window. Only rewrite the OLD default (24) — never clobber a
|
||
# value the user deliberately customized.
|
||
if current_ver < 25:
|
||
config = read_raw_config()
|
||
raw_mc = config.get("model_catalog")
|
||
if isinstance(raw_mc, dict) and raw_mc.get("ttl_hours") == 24:
|
||
raw_mc["ttl_hours"] = 1
|
||
config["model_catalog"] = raw_mc
|
||
save_config(config)
|
||
results["config_added"].append("model_catalog.ttl_hours 24→1")
|
||
if not quiet:
|
||
print(" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")
|
||
|
||
if current_ver < latest_ver and not quiet:
|
||
print(f"Config version: {current_ver} → {latest_ver}")
|
||
|
||
# Check for missing required env vars
|
||
missing_env = get_missing_env_vars(required_only=True)
|
||
|
||
if missing_env and not quiet:
|
||
print("\n⚠️ Missing required environment variables:")
|
||
for var in missing_env:
|
||
print(f" • {var['name']}: {var['description']}")
|
||
|
||
if interactive and missing_env:
|
||
print("\nLet's configure them now:\n")
|
||
for var in missing_env:
|
||
if var.get("url"):
|
||
print(f" Get your key at: {var['url']}")
|
||
|
||
if var.get("password"):
|
||
value = masked_secret_prompt(f" {var['prompt']}: ")
|
||
else:
|
||
value = input(f" {var['prompt']}: ").strip()
|
||
|
||
if value:
|
||
save_env_value(var["name"], value)
|
||
results["env_added"].append(var["name"])
|
||
print(f" ✓ Saved {var['name']}")
|
||
else:
|
||
results["warnings"].append(f"Skipped {var['name']} - some features may not work")
|
||
print()
|
||
|
||
# Check for missing optional env vars and offer to configure interactively
|
||
# Skip "advanced" vars (like OPENAI_BASE_URL) -- those are for power users
|
||
missing_optional = get_missing_env_vars(required_only=False)
|
||
required_names = {v["name"] for v in missing_env} if missing_env else set()
|
||
missing_optional = [
|
||
v for v in missing_optional
|
||
if v["name"] not in required_names and not v.get("advanced")
|
||
]
|
||
|
||
# Only offer to configure env vars that are NEW since the user's previous version
|
||
new_var_names = set()
|
||
for ver in range(current_ver + 1, latest_ver + 1):
|
||
new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))
|
||
|
||
if new_var_names and interactive and not quiet:
|
||
new_and_unset = [
|
||
(name, OPTIONAL_ENV_VARS[name])
|
||
for name in sorted(new_var_names)
|
||
if not get_env_value(name) and name in OPTIONAL_ENV_VARS
|
||
]
|
||
if new_and_unset:
|
||
print(f"\n {len(new_and_unset)} new optional key(s) in this update:")
|
||
for name, info in new_and_unset:
|
||
print(f" • {name} — {info.get('description', '')}")
|
||
print()
|
||
try:
|
||
answer = input(" Configure new keys? [y/N]: ").strip().lower()
|
||
except (EOFError, KeyboardInterrupt):
|
||
answer = "n"
|
||
|
||
if answer in {"y", "yes"}:
|
||
print()
|
||
for name, info in new_and_unset:
|
||
if info.get("url"):
|
||
print(f" {info.get('description', name)}")
|
||
print(f" Get your key at: {info['url']}")
|
||
else:
|
||
print(f" {info.get('description', name)}")
|
||
if info.get("password"):
|
||
value = masked_secret_prompt(
|
||
f" {info.get('prompt', name)} (Enter to skip): "
|
||
)
|
||
else:
|
||
value = input(f" {info.get('prompt', name)} (Enter to skip): ").strip()
|
||
if value:
|
||
save_env_value(name, value)
|
||
results["env_added"].append(name)
|
||
print(f" ✓ Saved {name}")
|
||
print()
|
||
else:
|
||
print(" Set later with: hermes config set <key> <value>")
|
||
|
||
# Check for missing config fields
|
||
missing_config = get_missing_config_fields()
|
||
|
||
if missing_config:
|
||
config = load_config()
|
||
|
||
for field in missing_config:
|
||
key = field["key"]
|
||
default = field["default"]
|
||
|
||
_set_nested(config, key, default)
|
||
results["config_added"].append(key)
|
||
if not quiet:
|
||
print(f" ✓ Added {key} = {default}")
|
||
|
||
# Update version and save
|
||
config["_config_version"] = latest_ver
|
||
save_config(config)
|
||
elif current_ver < latest_ver:
|
||
# Just update version
|
||
config = load_config()
|
||
config["_config_version"] = latest_ver
|
||
save_config(config)
|
||
|
||
# ── Skill-declared config vars ──────────────────────────────────────
|
||
# Skills can declare config.yaml settings they need via
|
||
# metadata.hermes.config in their SKILL.md frontmatter.
|
||
# Prompt for any that are missing/empty.
|
||
missing_skill_config = get_missing_skill_config_vars()
|
||
if missing_skill_config and interactive and not quiet:
|
||
print(f"\n {len(missing_skill_config)} skill setting(s) not configured:")
|
||
for var in missing_skill_config:
|
||
skill_name = var.get("skill", "unknown")
|
||
print(f" • {var['key']} — {var['description']} (from skill: {skill_name})")
|
||
print()
|
||
try:
|
||
answer = input(" Configure skill settings? [y/N]: ").strip().lower()
|
||
except (EOFError, KeyboardInterrupt):
|
||
answer = "n"
|
||
|
||
if answer in {"y", "yes"}:
|
||
print()
|
||
config = load_config()
|
||
try:
|
||
from agent.skill_utils import SKILL_CONFIG_PREFIX
|
||
except Exception:
|
||
SKILL_CONFIG_PREFIX = "skills.config"
|
||
for var in missing_skill_config:
|
||
default = var.get("default", "")
|
||
default_hint = f" (default: {default})" if default else ""
|
||
value = input(f" {var['prompt']}{default_hint}: ").strip()
|
||
if not value and default:
|
||
value = str(default)
|
||
if value:
|
||
storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}"
|
||
_set_nested(config, storage_key, value)
|
||
results["config_added"].append(var["key"])
|
||
print(f" ✓ Saved {var['key']} = {value}")
|
||
else:
|
||
results["warnings"].append(
|
||
f"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later"
|
||
)
|
||
print()
|
||
save_config(config)
|
||
else:
|
||
print(" Set later with: hermes config set <key> <value>")
|
||
|
||
return results
|
||
|
||
|
||
def _deep_merge(base: dict, override: dict) -> dict:
|
||
"""Recursively merge *override* into *base*, preserving nested defaults.
|
||
|
||
Keys in *override* take precedence. If both values are dicts the merge
|
||
recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will
|
||
keep the default ``tts.elevenlabs.model_id`` intact.
|
||
"""
|
||
result = base.copy()
|
||
for key, value in override.items():
|
||
if (
|
||
key in result
|
||
and isinstance(result[key], dict)
|
||
and isinstance(value, dict)
|
||
):
|
||
result[key] = _deep_merge(result[key], value)
|
||
else:
|
||
result[key] = value
|
||
return result
|
||
|
||
|
||
def _expand_env_vars(obj):
|
||
"""Recursively expand ``${VAR}`` references in config values.
|
||
|
||
Only string values are processed; dict keys, numbers, booleans, and
|
||
None are left untouched. Unresolved references (variable not in
|
||
``os.environ``) are kept verbatim so callers can detect them.
|
||
"""
|
||
if isinstance(obj, str):
|
||
return re.sub(
|
||
r"\${([^}]+)}",
|
||
lambda m: os.environ.get(m.group(1), m.group(0)),
|
||
obj,
|
||
)
|
||
if isinstance(obj, dict):
|
||
return {k: _expand_env_vars(v) for k, v in obj.items()}
|
||
if isinstance(obj, list):
|
||
return [_expand_env_vars(item) for item in obj]
|
||
return obj
|
||
|
||
|
||
def _items_by_unique_name(items):
|
||
"""Return a name-indexed dict only when all items have unique string names."""
|
||
if not isinstance(items, list):
|
||
return None
|
||
indexed = {}
|
||
for item in items:
|
||
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
|
||
return None
|
||
name = item["name"]
|
||
if name in indexed:
|
||
return None
|
||
indexed[name] = item
|
||
return indexed
|
||
|
||
|
||
def _preserve_env_ref_templates(current, raw, loaded_expanded=None):
|
||
"""Restore raw ``${VAR}`` templates when a value is otherwise unchanged.
|
||
|
||
``load_config()`` expands env refs for runtime use. When a caller later
|
||
persists that config after modifying some unrelated setting, keep the
|
||
original on-disk template instead of writing the expanded plaintext
|
||
secret back to ``config.yaml``.
|
||
|
||
Prefer preserving the raw template when ``current`` still matches either
|
||
the value previously returned by ``load_config()`` for this config path or
|
||
the current environment expansion of ``raw``. This handles env-var
|
||
rotation between load and save while still treating mixed literal/template
|
||
string edits as caller-owned once their rendered value diverges.
|
||
"""
|
||
if isinstance(current, str) and isinstance(raw, str) and re.search(r"\${[^}]+}", raw):
|
||
if current == raw:
|
||
return raw
|
||
if isinstance(loaded_expanded, str) and current == loaded_expanded:
|
||
return raw
|
||
if _expand_env_vars(raw) == current:
|
||
return raw
|
||
return current
|
||
|
||
if isinstance(current, dict) and isinstance(raw, dict):
|
||
return {
|
||
key: _preserve_env_ref_templates(
|
||
value,
|
||
raw.get(key),
|
||
loaded_expanded.get(key) if isinstance(loaded_expanded, dict) else None,
|
||
)
|
||
for key, value in current.items()
|
||
}
|
||
|
||
if isinstance(current, list) and isinstance(raw, list):
|
||
# Prefer matching named config objects (e.g. custom_providers) by name
|
||
# so harmless reordering doesn't drop the original template. If names
|
||
# are duplicated, fall back to positional matching instead of silently
|
||
# shadowing one entry.
|
||
current_by_name = _items_by_unique_name(current)
|
||
raw_by_name = _items_by_unique_name(raw)
|
||
loaded_by_name = _items_by_unique_name(loaded_expanded)
|
||
if current_by_name is not None and raw_by_name is not None:
|
||
return [
|
||
_preserve_env_ref_templates(
|
||
item,
|
||
raw_by_name.get(item.get("name")),
|
||
loaded_by_name.get(item.get("name")) if loaded_by_name is not None else None,
|
||
)
|
||
for item in current
|
||
]
|
||
return [
|
||
_preserve_env_ref_templates(
|
||
item,
|
||
raw[index] if index < len(raw) else None,
|
||
loaded_expanded[index]
|
||
if isinstance(loaded_expanded, list) and index < len(loaded_expanded)
|
||
else None,
|
||
)
|
||
for index, item in enumerate(current)
|
||
]
|
||
|
||
return current
|
||
|
||
|
||
def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Move stale root-level provider/base_url/context_length into model section.
|
||
|
||
Some users (or older code) placed ``provider:``, ``base_url:``, or
|
||
``context_length:`` at the config root instead of inside ``model:``.
|
||
These root-level keys are only used as a fallback when the corresponding
|
||
``model.*`` key is empty — they never override an existing value.
|
||
After migration the root-level keys are removed so they can't cause
|
||
confusion on subsequent loads.
|
||
"""
|
||
# Only act if there are root-level keys to migrate
|
||
has_root = any(config.get(k) for k in ("provider", "base_url", "context_length"))
|
||
if not has_root:
|
||
return config
|
||
|
||
config = dict(config)
|
||
model = config.get("model")
|
||
if not isinstance(model, dict):
|
||
model = {"default": model} if model else {}
|
||
config["model"] = model
|
||
|
||
for key in ("provider", "base_url", "context_length"):
|
||
root_val = config.get(key)
|
||
if root_val and not model.get(key):
|
||
model[key] = root_val
|
||
config.pop(key, None)
|
||
|
||
return config
|
||
|
||
|
||
def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Normalize legacy root-level max_turns into agent.max_turns."""
|
||
config = dict(config)
|
||
agent_config = dict(config.get("agent") or {})
|
||
|
||
if "max_turns" in config and "max_turns" not in agent_config:
|
||
agent_config["max_turns"] = config["max_turns"]
|
||
|
||
if "max_turns" not in agent_config:
|
||
agent_config["max_turns"] = DEFAULT_CONFIG["agent"]["max_turns"]
|
||
|
||
config["agent"] = agent_config
|
||
config.pop("max_turns", None)
|
||
return config
|
||
|
||
|
||
def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any:
|
||
"""Traverse nested dict keys safely, returning ``default`` on any miss.
|
||
|
||
Canonical helper for the ``cfg.get("X", {}).get("Y", default)`` pattern
|
||
that appears 50+ times across the codebase. Handles three common gotchas
|
||
in one place:
|
||
|
||
1. Missing intermediate keys (returns ``default``, no KeyError).
|
||
2. An intermediate value that's not a dict (e.g. a user wrote a string
|
||
where a section was expected). Returns ``default`` instead of
|
||
AttributeError on ``.get()``.
|
||
3. ``cfg is None`` (callers sometimes pass ``load_config() or None``).
|
||
|
||
Named ``cfg_get`` rather than ``cfg_path`` to avoid shadowing the
|
||
ubiquitous ``cfg_path = _hermes_home / "config.yaml"`` local variable
|
||
that appears in gateway/run.py, cron/scheduler.py, main.py, etc.
|
||
|
||
Explicit ``None`` values are returned as-is (matches ``dict.get(key,
|
||
default)`` semantics — ``default`` is only returned when the key is
|
||
*absent*, not when it's present but set to ``None``).
|
||
|
||
Examples:
|
||
>>> cfg_get({"agent": {"reasoning_effort": "high"}}, "agent", "reasoning_effort")
|
||
'high'
|
||
>>> cfg_get({}, "agent", "reasoning_effort", default="medium")
|
||
'medium'
|
||
>>> cfg_get({"agent": "oops_a_string"}, "agent", "reasoning_effort", default="low")
|
||
'low'
|
||
>>> cfg_get(None, "anything", default=42)
|
||
42
|
||
>>> cfg_get({"a": {"b": None}}, "a", "b", default="def") # explicit None preserved
|
||
>>> cfg_get({"a": {"b": False}}, "a", "b", default=True) # falsy values preserved
|
||
False
|
||
"""
|
||
if not isinstance(cfg, dict):
|
||
return default
|
||
node: Any = cfg
|
||
for key in keys:
|
||
if not isinstance(node, dict):
|
||
return default
|
||
if key not in node:
|
||
return default
|
||
node = node[key]
|
||
return node
|
||
|
||
|
||
|
||
def read_raw_config() -> Dict[str, Any]:
|
||
"""Read ~/.hermes/config.yaml as-is, without merging defaults or migrating.
|
||
|
||
Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't
|
||
be parsed. Use this for lightweight config reads where you just need a
|
||
single value and don't want the overhead of ``load_config()``'s deep-merge
|
||
+ migration pipeline.
|
||
|
||
Cached on the config file's (mtime_ns, size) — same strategy as
|
||
``load_config()``. Returns a deepcopy on every call since some callers
|
||
mutate the result before passing to ``save_config()``.
|
||
"""
|
||
with _CONFIG_LOCK:
|
||
try:
|
||
config_path = get_config_path()
|
||
st = config_path.stat()
|
||
cache_key = (st.st_mtime_ns, st.st_size)
|
||
except (FileNotFoundError, OSError):
|
||
return {}
|
||
|
||
path_key = str(config_path)
|
||
cached = _RAW_CONFIG_CACHE.get(path_key)
|
||
if cached is not None and cached[:2] == cache_key:
|
||
return copy.deepcopy(cached[2])
|
||
|
||
try:
|
||
with open(config_path, encoding="utf-8") as f:
|
||
data = yaml.safe_load(f) or {}
|
||
except Exception as e:
|
||
_warn_config_parse_failure(config_path, e)
|
||
return {}
|
||
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
_RAW_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(data))
|
||
return data
|
||
|
||
|
||
def load_config() -> Dict[str, Any]:
|
||
"""Load configuration from ~/.hermes/config.yaml.
|
||
|
||
Cached on the config file's (mtime_ns, size). Returns a deepcopy of
|
||
the cached value when unchanged, since most call sites mutate the
|
||
result (e.g. ``cfg["model"]["default"] = ...`` before ``save_config``).
|
||
The cache is keyed on ``str(config_path)`` so profile switches
|
||
(which change ``HERMES_HOME`` and therefore ``get_config_path()``)
|
||
don't collide.
|
||
|
||
Read-only callers should use ``load_config_readonly()`` to skip the
|
||
defensive deepcopy — that path matters in agent-loop hot spots like
|
||
``get_provider_request_timeout`` which is called once per API turn.
|
||
"""
|
||
return _load_config_impl(want_deepcopy=True)
|
||
|
||
|
||
def load_config_readonly() -> Dict[str, Any]:
|
||
"""Fast-path variant of ``load_config()`` for callers that ONLY READ.
|
||
|
||
Returns the cached config dict directly without the defensive deepcopy
|
||
that ``load_config()`` applies. **Mutating the returned dict (or any
|
||
nested structure) corrupts the in-process cache for every subsequent
|
||
caller** — only use this when you are absolutely sure your code path
|
||
will not write to the result. If you need to mutate or pass to
|
||
``save_config``, call ``load_config()`` instead.
|
||
|
||
Why this exists: ``load_config()`` cache-hit cost is ~265us per call,
|
||
half of which (~135us) is the defensive deepcopy. The agent loop calls
|
||
into config reads (timeouts, thresholds, feature flags) ~20-50x per
|
||
conversation; skipping deepcopy here removes a measurable allocation
|
||
source and the GC pressure that comes with it.
|
||
|
||
Note: this returns a plain ``dict`` (not ``MappingProxyType``) so
|
||
existing ``isinstance(x, dict)`` guards downstream keep working. The
|
||
safety guarantee is purely documented, not enforced — be careful.
|
||
"""
|
||
return _load_config_impl(want_deepcopy=False)
|
||
|
||
|
||
def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
|
||
with _CONFIG_LOCK:
|
||
ensure_hermes_home()
|
||
config_path = get_config_path()
|
||
path_key = str(config_path)
|
||
|
||
try:
|
||
st = config_path.stat()
|
||
cache_key: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
|
||
except FileNotFoundError:
|
||
cache_key = None
|
||
|
||
cached = _LOAD_CONFIG_CACHE.get(path_key)
|
||
if cached is not None and cache_key is not None and cached[:2] == cache_key:
|
||
return copy.deepcopy(cached[2]) if want_deepcopy else cached[2]
|
||
|
||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||
|
||
if cache_key is not None:
|
||
try:
|
||
with open(config_path, encoding="utf-8") as f:
|
||
user_config = yaml.safe_load(f) or {}
|
||
|
||
if "max_turns" in user_config:
|
||
agent_user_config = dict(user_config.get("agent") or {})
|
||
if agent_user_config.get("max_turns") is None:
|
||
agent_user_config["max_turns"] = user_config["max_turns"]
|
||
user_config["agent"] = agent_user_config
|
||
user_config.pop("max_turns", None)
|
||
|
||
config = _deep_merge(config, user_config)
|
||
except Exception as e:
|
||
_warn_config_parse_failure(config_path, e)
|
||
|
||
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||
expanded = _expand_env_vars(normalized)
|
||
_LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded)
|
||
if cache_key is not None:
|
||
# Cache stores a separate deepcopy so subsequent ``load_config()``
|
||
# (deepcopy=True) callers can mutate freely without affecting the
|
||
# cached value, and ``load_config_readonly()`` (deepcopy=False)
|
||
# callers all see the same stable cached object.
|
||
cached_copy = copy.deepcopy(expanded)
|
||
_LOAD_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], cached_copy)
|
||
# On the readonly path return the same cached object subsequent
|
||
# calls will see — keeps "two readonly calls return the same
|
||
# object" invariant that callers may rely on for identity checks.
|
||
if not want_deepcopy:
|
||
return cached_copy
|
||
else:
|
||
_LOAD_CONFIG_CACHE.pop(path_key, None)
|
||
# First-load result is a fresh dict (not aliased to the cache); safe
|
||
# to return directly. For the deepcopy=True path this is the
|
||
# canonical "freshly-built mutable result" the function has always
|
||
# returned. For the deepcopy=False path with no cache (e.g. config
|
||
# file missing), it's also fine — callers get an isolated object.
|
||
return expanded
|
||
|
||
|
||
_SECURITY_COMMENT = """
|
||
# ── Security ──────────────────────────────────────────────────────────
|
||
# Secret redaction is ON by default — strings that look like API keys,
|
||
# tokens, and passwords are masked in tool output, logs, and chat
|
||
# responses before the model or user ever sees them. Set redact_secrets
|
||
# to false to disable (e.g. when developing the redactor itself).
|
||
# tirith pre-exec scanning is enabled by default when the tirith binary
|
||
# is available. Configure via security.tirith_* keys or env vars
|
||
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
|
||
#
|
||
# security:
|
||
# redact_secrets: true
|
||
# tirith_enabled: true
|
||
# tirith_path: "tirith"
|
||
# tirith_timeout: 5
|
||
# tirith_fail_open: true
|
||
"""
|
||
|
||
_FALLBACK_COMMENT = """
|
||
# ── Fallback Model ────────────────────────────────────────────────────
|
||
# Automatic provider failover when primary is unavailable.
|
||
# Uncomment and configure to enable. Triggers on rate limits (429),
|
||
# overload (529), service errors (503), or connection failures.
|
||
#
|
||
# Supported providers:
|
||
# openrouter (OPENROUTER_API_KEY) — routes to any model
|
||
# openai-codex (OAuth — hermes auth) — OpenAI Codex
|
||
# nous (OAuth — hermes auth) — Nous Portal
|
||
# zai (ZAI_API_KEY) — Z.AI / GLM
|
||
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
|
||
# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)
|
||
# minimax (MINIMAX_API_KEY) — MiniMax
|
||
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
|
||
# bedrock (AWS IAM / boto3) — AWS Bedrock (Converse API)
|
||
#
|
||
# For custom OpenAI-compatible endpoints, add base_url and key_env.
|
||
#
|
||
# fallback_model:
|
||
# provider: openrouter
|
||
# model: anthropic/claude-sonnet-4
|
||
"""
|
||
|
||
|
||
_COMMENTED_SECTIONS = """
|
||
# ── Security ──────────────────────────────────────────────────────────
|
||
# Secret redaction is ON by default. Set to false to pass tool output,
|
||
# logs, and chat responses through unmodified (e.g. for redactor dev).
|
||
#
|
||
# security:
|
||
# redact_secrets: true
|
||
|
||
# ── Fallback Model ────────────────────────────────────────────────────
|
||
# Automatic provider failover when primary is unavailable.
|
||
# Uncomment and configure to enable. Triggers on rate limits (429),
|
||
# overload (529), service errors (503), or connection failures.
|
||
#
|
||
# Supported providers:
|
||
# openrouter (OPENROUTER_API_KEY) — routes to any model
|
||
# openai-codex (OAuth — hermes auth) — OpenAI Codex
|
||
# nous (OAuth — hermes auth) — Nous Portal
|
||
# zai (ZAI_API_KEY) — Z.AI / GLM
|
||
# kimi-coding (KIMI_API_KEY) — Kimi / Moonshot
|
||
# kimi-coding-cn (KIMI_CN_API_KEY) — Kimi / Moonshot (China)
|
||
# minimax (MINIMAX_API_KEY) — MiniMax
|
||
# minimax-cn (MINIMAX_CN_API_KEY) — MiniMax (China)
|
||
# bedrock (AWS IAM / boto3) — AWS Bedrock (Converse API)
|
||
#
|
||
# For custom OpenAI-compatible endpoints, add base_url and key_env.
|
||
#
|
||
# fallback_model:
|
||
# provider: openrouter
|
||
# model: anthropic/claude-sonnet-4
|
||
"""
|
||
|
||
|
||
def save_config(config: Dict[str, Any]):
|
||
"""Save configuration to ~/.hermes/config.yaml."""
|
||
with _CONFIG_LOCK:
|
||
if is_managed():
|
||
managed_error("save configuration")
|
||
return
|
||
from utils import atomic_yaml_write
|
||
|
||
ensure_hermes_home()
|
||
config_path = get_config_path()
|
||
current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||
normalized = current_normalized
|
||
raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config()))
|
||
if raw_existing:
|
||
normalized = _preserve_env_ref_templates(
|
||
normalized,
|
||
raw_existing,
|
||
_LAST_EXPANDED_CONFIG_BY_PATH.get(str(config_path)),
|
||
)
|
||
|
||
# Build optional commented-out sections for features that are off by
|
||
# default or only relevant when explicitly configured.
|
||
parts = []
|
||
sec = normalized.get("security", {})
|
||
if not sec or sec.get("redact_secrets") is None:
|
||
parts.append(_SECURITY_COMMENT)
|
||
fb = normalized.get("fallback_model", {})
|
||
fb_is_valid = False
|
||
if isinstance(fb, list):
|
||
fb_is_valid = any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb)
|
||
elif isinstance(fb, dict):
|
||
fb_is_valid = bool(fb.get("provider") and fb.get("model"))
|
||
if not fb_is_valid:
|
||
parts.append(_FALLBACK_COMMENT)
|
||
|
||
atomic_yaml_write(
|
||
config_path,
|
||
normalized,
|
||
extra_content="".join(parts) if parts else None,
|
||
)
|
||
_secure_file(config_path)
|
||
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)
|
||
|
||
|
||
def load_env() -> Dict[str, str]:
|
||
"""Load environment variables from ~/.hermes/.env.
|
||
|
||
Sanitizes lines before parsing so that corrupted files (e.g.
|
||
concatenated KEY=VALUE pairs on a single line) are handled
|
||
gracefully instead of producing mangled values such as duplicated
|
||
bot tokens. See #8908.
|
||
|
||
The parsed dict is memoised keyed on the .env file mtime, because
|
||
``get_env_value()`` is called dozens-to-hundreds of times per
|
||
interactive menu render (`hermes tools`, `hermes setup`, status
|
||
panels). Sanitisation is O(lines × known-keys), so re-parsing the
|
||
same file on every call was burning ~300ms of CPU per `hermes tools`
|
||
menu paint on top of the OAuth-refresh slowness. The mtime check
|
||
invalidates the cache when the user edits .env mid-process.
|
||
"""
|
||
global _env_cache
|
||
env_path = get_env_path()
|
||
|
||
try:
|
||
mtime = env_path.stat().st_mtime
|
||
size = env_path.stat().st_size
|
||
cache_key = (str(env_path), mtime, size)
|
||
except FileNotFoundError:
|
||
cache_key = (str(env_path), None, None)
|
||
except Exception:
|
||
cache_key = None
|
||
|
||
if cache_key is not None and _env_cache is not None:
|
||
cached_key, cached_vars = _env_cache
|
||
if cached_key == cache_key:
|
||
return dict(cached_vars)
|
||
|
||
env_vars: Dict[str, str] = {}
|
||
|
||
if env_path.exists():
|
||
# On Windows, open() defaults to the system locale (cp1252) which can
|
||
# fail on UTF-8 .env files. Always use explicit UTF-8; tolerate BOM
|
||
# via utf-8-sig since users may edit .env in Notepad which adds one.
|
||
open_kw = {"encoding": "utf-8-sig", "errors": "replace"}
|
||
with open(env_path, **open_kw) as f:
|
||
raw_lines = f.readlines()
|
||
# Sanitize before parsing: split concatenated lines & drop stale
|
||
# placeholders so corrupted .env files don't produce invalid tokens.
|
||
lines = _sanitize_env_lines(raw_lines)
|
||
for line in lines:
|
||
line = line.strip()
|
||
if line and not line.startswith('#') and '=' in line:
|
||
key, _, value = line.partition('=')
|
||
env_vars[key.strip()] = value.strip().strip('"\'')
|
||
|
||
if cache_key is not None:
|
||
_env_cache = (cache_key, dict(env_vars))
|
||
|
||
return env_vars
|
||
|
||
|
||
# Module-level memo for load_env(), keyed on (path, mtime, size).
|
||
# Editing .env bumps mtime → next load_env() rebuilds. invalidate_env_cache()
|
||
# is the explicit knob for writers that update .env via this module
|
||
# (set_env_value, save_env, etc.) without relying on filesystem mtime
|
||
# resolution.
|
||
_env_cache: Optional[Tuple[Tuple[str, Optional[float], Optional[int]], Dict[str, str]]] = None
|
||
|
||
|
||
def invalidate_env_cache() -> None:
|
||
"""Clear the load_env() process-level memo.
|
||
|
||
Writers that mutate .env (set_env_value, save_env, etc.) call this
|
||
to guarantee the next load_env() sees their change even on
|
||
filesystems with coarse mtime resolution. Reads invalidate naturally
|
||
via the mtime/size check.
|
||
"""
|
||
global _env_cache
|
||
_env_cache = None
|
||
|
||
|
||
def _sanitize_env_lines(lines: list) -> list:
|
||
"""Fix corrupted .env lines before reading or writing.
|
||
|
||
Handles two known corruption patterns:
|
||
1. Concatenated KEY=VALUE pairs on a single line (missing newline between
|
||
entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).
|
||
2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.
|
||
|
||
Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only
|
||
split on real Hermes env var names, avoiding false positives from values
|
||
that happen to contain uppercase text with ``=``.
|
||
"""
|
||
# Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.
|
||
# Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.
|
||
known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
|
||
|
||
sanitized: list[str] = []
|
||
for line in lines:
|
||
raw = line.rstrip("\r\n")
|
||
stripped = raw.strip()
|
||
|
||
# Preserve blank lines and comments
|
||
if not stripped or stripped.startswith("#"):
|
||
sanitized.append(raw + "\n")
|
||
continue
|
||
|
||
# Detect concatenated KEY=VALUE pairs on one line.
|
||
# Search for known KEY= patterns at any position in the line.
|
||
# We collect full needle ranges so we can drop matches that are
|
||
# fully contained within a longer overlapping needle. Without this,
|
||
# suffix collisions corrupt the file: e.g. LM_API_KEY= inside
|
||
# GLM_API_KEY= would otherwise split the line into "G\nLM_API_KEY=...".
|
||
match_ranges: list[tuple[int, int]] = []
|
||
for key_name in known_keys:
|
||
needle = key_name + "="
|
||
idx = stripped.find(needle)
|
||
while idx >= 0:
|
||
match_ranges.append((idx, idx + len(needle)))
|
||
idx = stripped.find(needle, idx + len(needle))
|
||
|
||
split_positions = sorted({
|
||
s for s, e in match_ranges
|
||
if not any(
|
||
s2 <= s and e2 >= e and (s2, e2) != (s, e)
|
||
for s2, e2 in match_ranges
|
||
)
|
||
})
|
||
|
||
if len(split_positions) > 1:
|
||
for i, pos in enumerate(split_positions):
|
||
end = split_positions[i + 1] if i + 1 < len(split_positions) else len(stripped)
|
||
part = stripped[pos:end].strip()
|
||
if part:
|
||
sanitized.append(part + "\n")
|
||
else:
|
||
sanitized.append(stripped + "\n")
|
||
|
||
return sanitized
|
||
|
||
|
||
def sanitize_env_file() -> int:
|
||
"""Read, sanitize, and rewrite ~/.hermes/.env in place.
|
||
|
||
Returns the number of lines that were fixed (concatenation splits +
|
||
placeholder removals). Returns 0 when no changes are needed.
|
||
"""
|
||
env_path = get_env_path()
|
||
if not env_path.exists():
|
||
return 0
|
||
|
||
read_kw = {"encoding": "utf-8-sig", "errors": "replace"}
|
||
write_kw = {"encoding": "utf-8"}
|
||
|
||
with open(env_path, **read_kw) as f:
|
||
original_lines = f.readlines()
|
||
|
||
sanitized = _sanitize_env_lines(original_lines)
|
||
|
||
if sanitized == original_lines:
|
||
return 0
|
||
|
||
# Count fixes: difference in line count (from splits) + removed lines
|
||
fixes = abs(len(sanitized) - len(original_lines))
|
||
if fixes == 0:
|
||
# Lines changed content (e.g. *** removal) even if count is same
|
||
fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)
|
||
fixes += abs(len(sanitized) - len(original_lines))
|
||
|
||
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix=".tmp", prefix=".env_")
|
||
try:
|
||
with os.fdopen(fd, "w", **write_kw) as f:
|
||
f.writelines(sanitized)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
atomic_replace(tmp_path, env_path)
|
||
except BaseException:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
_secure_file(env_path)
|
||
invalidate_env_cache()
|
||
return fixes
|
||
|
||
|
||
def _check_non_ascii_credential(key: str, value: str) -> str:
|
||
"""Warn and strip non-ASCII characters from credential values.
|
||
|
||
API keys and tokens must be pure ASCII — they are sent as HTTP header
|
||
values which httpx/httpcore encode as ASCII. Non-ASCII characters
|
||
(commonly introduced by copy-pasting from rich-text editors or PDFs
|
||
that substitute lookalike Unicode glyphs for ASCII letters) cause
|
||
``UnicodeEncodeError: 'ascii' codec can't encode character`` at
|
||
request time.
|
||
|
||
Returns the sanitized (ASCII-only) value. Prints a warning if any
|
||
non-ASCII characters were found and removed.
|
||
"""
|
||
try:
|
||
value.encode("ascii")
|
||
return value # all ASCII — nothing to do
|
||
except UnicodeEncodeError:
|
||
pass
|
||
|
||
# Build a readable list of the offending characters
|
||
bad_chars: list[str] = []
|
||
for i, ch in enumerate(value):
|
||
if ord(ch) > 127:
|
||
bad_chars.append(f" position {i}: {ch!r} (U+{ord(ch):04X})")
|
||
sanitized = value.encode("ascii", errors="ignore").decode("ascii")
|
||
|
||
print(
|
||
f"\n Warning: {key} contains non-ASCII characters that will break API requests.\n"
|
||
f" This usually happens when copy-pasting from a PDF, rich-text editor,\n"
|
||
f" or web page that substitutes lookalike Unicode glyphs for ASCII letters.\n"
|
||
f"\n"
|
||
+ "\n".join(f" {line}" for line in bad_chars[:5])
|
||
+ ("\n ... and more" if len(bad_chars) > 5 else "")
|
||
+ f"\n\n The non-ASCII characters have been stripped automatically.\n"
|
||
f" If authentication fails, re-copy the key from the provider's dashboard.\n",
|
||
file=sys.stderr,
|
||
)
|
||
return sanitized
|
||
|
||
|
||
def save_env_value(key: str, value: str):
|
||
"""Save or update a value in ~/.hermes/.env."""
|
||
if is_managed():
|
||
managed_error(f"set {key}")
|
||
return
|
||
if not _ENV_VAR_NAME_RE.match(key):
|
||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||
_reject_denylisted_env_var(key)
|
||
value = value.replace("\n", "").replace("\r", "")
|
||
# API keys / tokens must be ASCII — strip non-ASCII with a warning.
|
||
value = _check_non_ascii_credential(key, value)
|
||
ensure_hermes_home()
|
||
env_path = get_env_path()
|
||
|
||
# On Windows, open() defaults to the system locale (cp1252) which can
|
||
# cause OSError errno 22 on UTF-8 .env files.
|
||
read_kw = {"encoding": "utf-8-sig", "errors": "replace"}
|
||
write_kw = {"encoding": "utf-8"}
|
||
|
||
lines = []
|
||
if env_path.exists():
|
||
with open(env_path, **read_kw) as f:
|
||
lines = f.readlines()
|
||
# Sanitize on every read: split concatenated keys, drop stale placeholders
|
||
lines = _sanitize_env_lines(lines)
|
||
|
||
# Find and update or append
|
||
found = False
|
||
for i, line in enumerate(lines):
|
||
if line.strip().startswith(f"{key}="):
|
||
lines[i] = f"{key}={value}\n"
|
||
found = True
|
||
break
|
||
|
||
if not found:
|
||
# Ensure there's a newline at the end of the file before appending
|
||
if lines and not lines[-1].endswith("\n"):
|
||
lines[-1] += "\n"
|
||
lines.append(f"{key}={value}\n")
|
||
|
||
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
|
||
# Preserve original permissions so Docker volume mounts aren't clobbered.
|
||
original_mode = None
|
||
if env_path.exists():
|
||
try:
|
||
original_mode = stat.S_IMODE(env_path.stat().st_mode)
|
||
except OSError:
|
||
pass
|
||
try:
|
||
with os.fdopen(fd, 'w', **write_kw) as f:
|
||
f.writelines(lines)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
atomic_replace(tmp_path, env_path)
|
||
# Restore original permissions before _secure_file may tighten them.
|
||
if original_mode is not None:
|
||
try:
|
||
os.chmod(env_path, original_mode)
|
||
except OSError:
|
||
pass
|
||
except BaseException:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
_secure_file(env_path)
|
||
|
||
os.environ[key] = value
|
||
invalidate_env_cache()
|
||
|
||
|
||
def remove_env_value(key: str) -> bool:
|
||
"""Remove a key from ~/.hermes/.env and os.environ.
|
||
|
||
Returns True if the key was found and removed, False otherwise.
|
||
"""
|
||
if is_managed():
|
||
managed_error(f"remove {key}")
|
||
return False
|
||
if not _ENV_VAR_NAME_RE.match(key):
|
||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||
env_path = get_env_path()
|
||
if not env_path.exists():
|
||
os.environ.pop(key, None)
|
||
return False
|
||
|
||
read_kw = {"encoding": "utf-8-sig", "errors": "replace"}
|
||
write_kw = {"encoding": "utf-8"}
|
||
|
||
with open(env_path, **read_kw) as f:
|
||
lines = f.readlines()
|
||
lines = _sanitize_env_lines(lines)
|
||
|
||
new_lines = [line for line in lines if not line.strip().startswith(f"{key}=")]
|
||
found = len(new_lines) < len(lines)
|
||
|
||
if found:
|
||
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
|
||
# Preserve original permissions so Docker volume mounts aren't clobbered.
|
||
original_mode = None
|
||
try:
|
||
original_mode = stat.S_IMODE(env_path.stat().st_mode)
|
||
except OSError:
|
||
pass
|
||
try:
|
||
with os.fdopen(fd, 'w', **write_kw) as f:
|
||
f.writelines(new_lines)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
atomic_replace(tmp_path, env_path)
|
||
if original_mode is not None:
|
||
try:
|
||
os.chmod(env_path, original_mode)
|
||
except OSError:
|
||
pass
|
||
except BaseException:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
_secure_file(env_path)
|
||
|
||
os.environ.pop(key, None)
|
||
invalidate_env_cache()
|
||
return found
|
||
|
||
|
||
def save_anthropic_oauth_token(value: str, save_fn=None):
|
||
"""Persist an Anthropic OAuth/setup token and clear the API-key slot."""
|
||
writer = save_fn or save_env_value
|
||
writer("ANTHROPIC_TOKEN", value)
|
||
writer("ANTHROPIC_API_KEY", "")
|
||
|
||
|
||
def use_anthropic_claude_code_credentials(save_fn=None):
|
||
"""Use Claude Code's own credential files instead of persisting env tokens."""
|
||
writer = save_fn or save_env_value
|
||
writer("ANTHROPIC_TOKEN", "")
|
||
writer("ANTHROPIC_API_KEY", "")
|
||
|
||
|
||
def save_anthropic_api_key(value: str, save_fn=None):
|
||
"""Persist an Anthropic API key and clear the OAuth/setup-token slot."""
|
||
writer = save_fn or save_env_value
|
||
writer("ANTHROPIC_API_KEY", value)
|
||
writer("ANTHROPIC_TOKEN", "")
|
||
|
||
|
||
def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
|
||
save_env_value(key, value)
|
||
return {
|
||
"success": True,
|
||
"stored_as": key,
|
||
"validated": False,
|
||
}
|
||
|
||
|
||
|
||
def reload_env() -> int:
|
||
"""Re-read ~/.hermes/.env into os.environ. Returns count of vars updated.
|
||
|
||
Adds/updates vars that changed and removes vars that were deleted from
|
||
the .env file (but only vars known to Hermes — OPTIONAL_ENV_VARS and
|
||
_EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).
|
||
"""
|
||
env_vars = load_env()
|
||
known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
|
||
count = 0
|
||
for key, value in env_vars.items():
|
||
if os.environ.get(key) != value:
|
||
os.environ[key] = value
|
||
count += 1
|
||
# Remove known Hermes vars that are no longer in .env
|
||
for key in known_keys:
|
||
if key not in env_vars and key in os.environ:
|
||
del os.environ[key]
|
||
count += 1
|
||
return count
|
||
|
||
|
||
def get_env_value(key: str) -> Optional[str]:
|
||
"""Get a value from ~/.hermes/.env or environment."""
|
||
# Check environment first
|
||
if key in os.environ:
|
||
return os.environ[key]
|
||
|
||
# Then check .env file
|
||
env_vars = load_env()
|
||
return env_vars.get(key)
|
||
|
||
|
||
# =============================================================================
|
||
# Config display
|
||
# =============================================================================
|
||
|
||
def redact_key(key: str) -> str:
|
||
"""Redact an API key for display.
|
||
|
||
Thin wrapper over :func:`agent.redact.mask_secret` — preserves the
|
||
"(not set)" placeholder in dim color for the empty case.
|
||
"""
|
||
from agent.redact import mask_secret
|
||
return mask_secret(key, empty=color("(not set)", Colors.DIM))
|
||
|
||
|
||
def show_config():
|
||
"""Display current configuration."""
|
||
config = load_config()
|
||
|
||
print()
|
||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||
print(color("│ ⚕ Hermes Configuration │", Colors.CYAN))
|
||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||
|
||
# Paths
|
||
print()
|
||
print(color("◆ Paths", Colors.CYAN, Colors.BOLD))
|
||
print(f" Config: {get_config_path()}")
|
||
print(f" Secrets: {get_env_path()}")
|
||
print(f" Install: {get_project_root()}")
|
||
|
||
# API Keys
|
||
print()
|
||
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
|
||
|
||
keys = [
|
||
("OPENROUTER_API_KEY", "OpenRouter"),
|
||
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
|
||
("EXA_API_KEY", "Exa"),
|
||
("PARALLEL_API_KEY", "Parallel"),
|
||
("FIRECRAWL_API_KEY", "Firecrawl"),
|
||
("TAVILY_API_KEY", "Tavily"),
|
||
("BROWSERBASE_API_KEY", "Browserbase"),
|
||
("BROWSER_USE_API_KEY", "Browser Use"),
|
||
("FAL_KEY", "FAL"),
|
||
]
|
||
|
||
for env_key, name in keys:
|
||
value = get_env_value(env_key)
|
||
print(f" {name:<14} {redact_key(value)}")
|
||
from hermes_cli.auth import get_anthropic_key
|
||
anthropic_value = get_anthropic_key()
|
||
print(f" {'Anthropic':<14} {redact_key(anthropic_value)}")
|
||
|
||
# Model settings
|
||
print()
|
||
print(color("◆ Model", Colors.CYAN, Colors.BOLD))
|
||
print(f" Model: {config.get('model', 'not set')}")
|
||
print(f" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}")
|
||
|
||
# Display
|
||
print()
|
||
print(color("◆ Display", Colors.CYAN, Colors.BOLD))
|
||
display = config.get('display', {})
|
||
print(f" Personality: {display.get('personality') or 'none'}")
|
||
print(f" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}")
|
||
print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}")
|
||
ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {}
|
||
ump_first = ump.get('first_lines', 2)
|
||
ump_last = ump.get('last_lines', 2)
|
||
print(f" User preview: first {ump_first} line(s), last {ump_last} line(s)")
|
||
|
||
# Terminal
|
||
print()
|
||
print(color("◆ Terminal", Colors.CYAN, Colors.BOLD))
|
||
terminal = config.get('terminal', {})
|
||
print(f" Backend: {terminal.get('backend', 'local')}")
|
||
print(f" Working dir: {terminal.get('cwd', '.')}")
|
||
print(f" Timeout: {terminal.get('timeout', 60)}s")
|
||
|
||
if terminal.get('backend') == 'docker':
|
||
print(f" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
|
||
elif terminal.get('backend') == 'singularity':
|
||
print(f" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}")
|
||
elif terminal.get('backend') == 'modal':
|
||
print(f" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
|
||
modal_token = get_env_value('MODAL_TOKEN_ID')
|
||
print(f" Modal token: {'configured' if modal_token else '(not set)'}")
|
||
elif terminal.get('backend') == 'daytona':
|
||
print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
|
||
daytona_key = get_env_value('DAYTONA_API_KEY')
|
||
print(f" API key: {'configured' if daytona_key else '(not set)'}")
|
||
elif terminal.get('backend') == 'ssh':
|
||
ssh_host = get_env_value('TERMINAL_SSH_HOST')
|
||
ssh_user = get_env_value('TERMINAL_SSH_USER')
|
||
print(f" SSH host: {ssh_host or '(not set)'}")
|
||
print(f" SSH user: {ssh_user or '(not set)'}")
|
||
|
||
# Timezone
|
||
print()
|
||
print(color("◆ Timezone", Colors.CYAN, Colors.BOLD))
|
||
tz = config.get('timezone', '')
|
||
if tz:
|
||
print(f" Timezone: {tz}")
|
||
else:
|
||
print(f" Timezone: {color('(server-local)', Colors.DIM)}")
|
||
|
||
# Compression
|
||
print()
|
||
print(color("◆ Context Compression", Colors.CYAN, Colors.BOLD))
|
||
compression = config.get('compression', {})
|
||
enabled = compression.get('enabled', True)
|
||
print(f" Enabled: {'yes' if enabled else 'no'}")
|
||
if enabled:
|
||
print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%")
|
||
print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved")
|
||
print(f" Protect last: {compression.get('protect_last_n', 20)} messages")
|
||
print(f" Protect first: {compression.get('protect_first_n', 3)} non-system head messages")
|
||
_aux_comp = config.get('auxiliary', {}).get('compression', {})
|
||
_sm = _aux_comp.get('model', '') or '(auto)'
|
||
print(f" Model: {_sm}")
|
||
comp_provider = _aux_comp.get('provider', 'auto')
|
||
if comp_provider and comp_provider != 'auto':
|
||
print(f" Provider: {comp_provider}")
|
||
|
||
# Auxiliary models
|
||
auxiliary = config.get('auxiliary', {})
|
||
aux_tasks = {
|
||
"Vision": auxiliary.get('vision', {}),
|
||
"Web extract": auxiliary.get('web_extract', {}),
|
||
}
|
||
has_overrides = any(
|
||
t.get('provider', 'auto') != 'auto' or t.get('model', '')
|
||
for t in aux_tasks.values()
|
||
)
|
||
if has_overrides:
|
||
print()
|
||
print(color("◆ Auxiliary Models (overrides)", Colors.CYAN, Colors.BOLD))
|
||
for label, task_cfg in aux_tasks.items():
|
||
prov = task_cfg.get('provider', 'auto')
|
||
mdl = task_cfg.get('model', '')
|
||
if prov != 'auto' or mdl:
|
||
parts = [f"provider={prov}"]
|
||
if mdl:
|
||
parts.append(f"model={mdl}")
|
||
print(f" {label:12s} {', '.join(parts)}")
|
||
|
||
# Messaging
|
||
print()
|
||
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
|
||
|
||
telegram_token = get_env_value('TELEGRAM_BOT_TOKEN')
|
||
discord_token = get_env_value('DISCORD_BOT_TOKEN')
|
||
|
||
print(f" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}")
|
||
print(f" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}")
|
||
|
||
# Skill config
|
||
try:
|
||
from agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values
|
||
skill_vars = discover_all_skill_config_vars()
|
||
if skill_vars:
|
||
resolved = resolve_skill_config_values(skill_vars)
|
||
print()
|
||
print(color("◆ Skill Settings", Colors.CYAN, Colors.BOLD))
|
||
for var in skill_vars:
|
||
key = var["key"]
|
||
value = resolved.get(key, "")
|
||
skill_name = var.get("skill", "")
|
||
display_val = str(value) if value else color("(not set)", Colors.DIM)
|
||
print(f" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}")
|
||
except Exception:
|
||
pass
|
||
|
||
print()
|
||
print(color("─" * 60, Colors.DIM))
|
||
print(color(" hermes config edit # Edit config file", Colors.DIM))
|
||
print(color(" hermes config set <key> <value>", Colors.DIM))
|
||
print(color(" hermes setup # Run setup wizard", Colors.DIM))
|
||
print()
|
||
|
||
|
||
def edit_config():
|
||
"""Open config file in user's editor."""
|
||
if is_managed():
|
||
managed_error("edit configuration")
|
||
return
|
||
config_path = get_config_path()
|
||
|
||
# Ensure config exists
|
||
if not config_path.exists():
|
||
save_config(DEFAULT_CONFIG)
|
||
print(f"Created {config_path}")
|
||
|
||
# Find editor
|
||
editor = os.getenv('EDITOR') or os.getenv('VISUAL')
|
||
|
||
if not editor:
|
||
# Try common editors — order is platform-aware so Windows users
|
||
# land on a working editor (notepad) even without Git Bash or nano
|
||
# installed. On POSIX, prefer nano/vim over code/notepad because
|
||
# it's more likely to be present on headless / server systems.
|
||
import shutil
|
||
import sys as _sys
|
||
if _sys.platform == "win32":
|
||
candidates = ['notepad', 'code', 'vim', 'vi', 'nano']
|
||
else:
|
||
candidates = ['nano', 'vim', 'vi', 'code', 'notepad']
|
||
for cmd in candidates:
|
||
if shutil.which(cmd):
|
||
editor = cmd
|
||
break
|
||
|
||
if not editor:
|
||
print("No editor found. Config file is at:")
|
||
print(f" {config_path}")
|
||
return
|
||
|
||
print(f"Opening {config_path} in {editor}...")
|
||
subprocess.run([editor, str(config_path)])
|
||
|
||
|
||
def set_config_value(key: str, value: str):
|
||
"""Set a configuration value."""
|
||
if is_managed():
|
||
managed_error("set configuration values")
|
||
return
|
||
# Check if it's an API key (goes to .env)
|
||
api_keys = [
|
||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
||
'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL',
|
||
'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME',
|
||
'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY',
|
||
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
|
||
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
|
||
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
|
||
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
|
||
'GITHUB_TOKEN', 'HONCHO_API_KEY',
|
||
]
|
||
|
||
if key.upper() in api_keys or key.upper().endswith(('_API_KEY', '_TOKEN')) or key.upper().startswith('TERMINAL_SSH'):
|
||
save_env_value(key.upper(), value)
|
||
print(f"✓ Set {key} in {get_env_path()}")
|
||
return
|
||
|
||
# Otherwise it goes to config.yaml
|
||
# Read the raw user config (not merged with defaults) to avoid
|
||
# dumping all default values back to the file
|
||
config_path = get_config_path()
|
||
user_config = {}
|
||
if config_path.exists():
|
||
try:
|
||
with open(config_path, encoding="utf-8") as f:
|
||
user_config = yaml.safe_load(f) or {}
|
||
except Exception:
|
||
user_config = {}
|
||
|
||
# Handle nested keys (e.g., "tts.provider") including numeric list
|
||
# indices (e.g., "custom_providers.0.api_key"). Delegates to
|
||
# _set_nested which preserves list-typed nodes; before #17876 the
|
||
# inline navigation here silently overwrote lists with dicts.
|
||
|
||
# Convert value to appropriate type
|
||
if value.lower() in {'true', 'yes', 'on'}:
|
||
value = True
|
||
elif value.lower() in {'false', 'no', 'off'}:
|
||
value = False
|
||
elif value.isdigit():
|
||
value = int(value)
|
||
elif value.replace('.', '', 1).isdigit():
|
||
value = float(value)
|
||
|
||
_set_nested(user_config, key, value)
|
||
|
||
# Write only user config back (not the full merged defaults)
|
||
ensure_hermes_home()
|
||
from utils import atomic_yaml_write
|
||
atomic_yaml_write(config_path, user_config, sort_keys=False)
|
||
|
||
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
|
||
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
|
||
_config_to_env_sync = {
|
||
"terminal.backend": "TERMINAL_ENV",
|
||
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
|
||
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
|
||
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
|
||
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
|
||
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
|
||
# terminal.cwd intentionally excluded — CLI resolves at runtime,
|
||
# gateway bridges it in gateway/run.py. Persisting to .env causes
|
||
# stale values to poison child processes.
|
||
"terminal.timeout": "TERMINAL_TIMEOUT",
|
||
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
||
"terminal.container_cpu": "TERMINAL_CONTAINER_CPU",
|
||
"terminal.container_memory": "TERMINAL_CONTAINER_MEMORY",
|
||
"terminal.container_disk": "TERMINAL_CONTAINER_DISK",
|
||
"terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
|
||
}
|
||
if key in _config_to_env_sync:
|
||
save_env_value(_config_to_env_sync[key], str(value))
|
||
|
||
print(f"✓ Set {key} = {value} in {config_path}")
|
||
|
||
|
||
# =============================================================================
|
||
# Command handler
|
||
# =============================================================================
|
||
|
||
def config_command(args):
|
||
"""Handle config subcommands."""
|
||
subcmd = getattr(args, 'config_command', None)
|
||
|
||
if subcmd is None or subcmd == "show":
|
||
show_config()
|
||
|
||
elif subcmd == "edit":
|
||
edit_config()
|
||
|
||
elif subcmd == "set":
|
||
key = getattr(args, 'key', None)
|
||
value = getattr(args, 'value', None)
|
||
if not key or value is None:
|
||
print("Usage: hermes config set <key> <value>")
|
||
print()
|
||
print("Examples:")
|
||
print(" hermes config set model anthropic/claude-sonnet-4")
|
||
print(" hermes config set terminal.backend docker")
|
||
print(" hermes config set OPENROUTER_API_KEY sk-or-...")
|
||
sys.exit(1)
|
||
set_config_value(key, value)
|
||
|
||
elif subcmd == "path":
|
||
print(get_config_path())
|
||
|
||
elif subcmd == "env-path":
|
||
print(get_env_path())
|
||
|
||
elif subcmd == "migrate":
|
||
print()
|
||
print(color("🔄 Checking configuration for updates...", Colors.CYAN, Colors.BOLD))
|
||
print()
|
||
|
||
# Check what's missing
|
||
missing_env = get_missing_env_vars(required_only=False)
|
||
missing_config = get_missing_config_fields()
|
||
current_ver, latest_ver = check_config_version()
|
||
|
||
if not missing_env and not missing_config and current_ver >= latest_ver:
|
||
print(color("✓ Configuration is up to date!", Colors.GREEN))
|
||
print()
|
||
return
|
||
|
||
# Show what needs to be updated
|
||
if current_ver < latest_ver:
|
||
print(f" Config version: {current_ver} → {latest_ver}")
|
||
|
||
if missing_config:
|
||
print(f"\n {len(missing_config)} new config option(s) will be added with defaults")
|
||
|
||
required_missing = [v for v in missing_env if v.get("is_required")]
|
||
optional_missing = [
|
||
v for v in missing_env
|
||
if not v.get("is_required") and not v.get("advanced")
|
||
]
|
||
|
||
if required_missing:
|
||
print(f"\n ⚠️ {len(required_missing)} required API key(s) missing:")
|
||
for var in required_missing:
|
||
print(f" • {var['name']}")
|
||
|
||
if optional_missing:
|
||
print(f"\n ℹ️ {len(optional_missing)} optional API key(s) not configured:")
|
||
for var in optional_missing:
|
||
tools = var.get("tools", [])
|
||
tools_str = f" (enables: {', '.join(tools[:2])})" if tools else ""
|
||
print(f" • {var['name']}{tools_str}")
|
||
|
||
print()
|
||
|
||
# Run migration
|
||
results = migrate_config(interactive=True, quiet=False)
|
||
|
||
print()
|
||
if results["env_added"] or results["config_added"]:
|
||
print(color("✓ Configuration updated!", Colors.GREEN))
|
||
|
||
if results["warnings"]:
|
||
print()
|
||
for warning in results["warnings"]:
|
||
print(color(f" ⚠️ {warning}", Colors.YELLOW))
|
||
|
||
print()
|
||
|
||
elif subcmd == "check":
|
||
# Non-interactive check for what's missing
|
||
print()
|
||
print(color("📋 Configuration Status", Colors.CYAN, Colors.BOLD))
|
||
print()
|
||
|
||
current_ver, latest_ver = check_config_version()
|
||
if current_ver >= latest_ver:
|
||
print(f" Config version: {current_ver} ✓")
|
||
else:
|
||
print(color(f" Config version: {current_ver} → {latest_ver} (update available)", Colors.YELLOW))
|
||
|
||
print()
|
||
print(color(" Required:", Colors.BOLD))
|
||
for var_name in REQUIRED_ENV_VARS:
|
||
if get_env_value(var_name):
|
||
print(f" ✓ {var_name}")
|
||
else:
|
||
print(color(f" ✗ {var_name} (missing)", Colors.RED))
|
||
|
||
print()
|
||
print(color(" Optional:", Colors.BOLD))
|
||
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||
if get_env_value(var_name):
|
||
print(f" ✓ {var_name}")
|
||
else:
|
||
tools = info.get("tools", [])
|
||
tools_str = f" → {', '.join(tools[:2])}" if tools else ""
|
||
print(color(f" ○ {var_name}{tools_str}", Colors.DIM))
|
||
|
||
missing_config = get_missing_config_fields()
|
||
if missing_config:
|
||
print()
|
||
print(color(f" {len(missing_config)} new config option(s) available", Colors.YELLOW))
|
||
print(" Run 'hermes config migrate' to add them")
|
||
|
||
print()
|
||
|
||
else:
|
||
print(f"Unknown config command: {subcmd}")
|
||
print()
|
||
print("Available commands:")
|
||
print(" hermes config Show current configuration")
|
||
print(" hermes config edit Open config in editor")
|
||
print(" hermes config set <key> <value> Set a config value")
|
||
print(" hermes config check Check for missing/outdated config")
|
||
print(" hermes config migrate Update config with new options")
|
||
print(" hermes config path Show config file path")
|
||
print(" hermes config env-path Show .env file path")
|
||
sys.exit(1)
|
||
|
||
|
||
# ── Profile-driven env var injection ─────────────────────────────────────────
|
||
# Any provider registered in providers/ with auth_type="api_key" automatically
|
||
# gets its env_vars exposed in OPTIONAL_ENV_VARS without editing this file.
|
||
# Runs once at import time.
|
||
|
||
_profile_env_vars_injected = False
|
||
|
||
|
||
def _inject_profile_env_vars() -> None:
|
||
"""Populate OPTIONAL_ENV_VARS from provider profiles not already listed.
|
||
|
||
Called once at module load time. Idempotent — repeated calls are no-ops.
|
||
"""
|
||
global _profile_env_vars_injected
|
||
if _profile_env_vars_injected:
|
||
return
|
||
_profile_env_vars_injected = True
|
||
try:
|
||
from providers import list_providers
|
||
for _pp in list_providers():
|
||
if _pp.auth_type not in {"api_key",}:
|
||
continue
|
||
for _var in _pp.env_vars:
|
||
if _var in OPTIONAL_ENV_VARS:
|
||
continue
|
||
_is_key = not _var.endswith("_BASE_URL") and not _var.endswith("_URL")
|
||
OPTIONAL_ENV_VARS[_var] = {
|
||
"description": f"{_pp.display_name or _pp.name} {'API key' if _is_key else 'base URL override'}",
|
||
"prompt": f"{_pp.display_name or _pp.name} {'API key' if _is_key else 'base URL (leave empty for default)'}",
|
||
"url": _pp.signup_url or None,
|
||
"password": _is_key,
|
||
"category": "provider",
|
||
"advanced": True,
|
||
}
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# Eagerly inject so that OPTIONAL_ENV_VARS is fully populated at import time.
|
||
_inject_profile_env_vars()
|
||
|
||
|
||
# ── Platform-plugin env var injection ────────────────────────────────────────
|
||
# Bundled platform plugins under ``plugins/platforms/*/plugin.yaml`` declare
|
||
# their required env vars via ``requires_env``. This mirror of
|
||
# ``_inject_profile_env_vars`` surfaces them in ``hermes config`` UI so users
|
||
# can configure Teams / IRC / Google Chat without the core repo ever needing
|
||
# to know they exist.
|
||
#
|
||
# Each ``requires_env`` entry may be a bare string (name only) or a dict:
|
||
#
|
||
# requires_env:
|
||
# - TEAMS_CLIENT_ID # minimal
|
||
# - name: TEAMS_CLIENT_SECRET # rich
|
||
# description: "Teams bot client secret"
|
||
# url: "https://portal.azure.com/"
|
||
# password: true
|
||
# prompt: "Teams client secret"
|
||
#
|
||
# An optional ``optional_env`` block surfaces non-required vars the same way
|
||
# (e.g. allowlist, home channel).
|
||
|
||
_platform_plugin_env_vars_injected = False
|
||
|
||
|
||
def _inject_platform_plugin_env_vars() -> None:
|
||
"""Populate OPTIONAL_ENV_VARS from bundled platform plugin manifests.
|
||
|
||
Called once at module load time. Idempotent — repeated calls are no-ops.
|
||
Failures are swallowed so a malformed plugin.yaml can't break CLI import.
|
||
"""
|
||
global _platform_plugin_env_vars_injected
|
||
if _platform_plugin_env_vars_injected:
|
||
return
|
||
_platform_plugin_env_vars_injected = True
|
||
try:
|
||
import yaml # type: ignore
|
||
|
||
# Resolve the bundled plugins dir from this file's location so the
|
||
# injector works regardless of CWD.
|
||
repo_root = Path(__file__).resolve().parents[1]
|
||
platforms_dir = repo_root / "plugins" / "platforms"
|
||
if not platforms_dir.is_dir():
|
||
return
|
||
for child in platforms_dir.iterdir():
|
||
if not child.is_dir():
|
||
continue
|
||
manifest_path = child / "plugin.yaml"
|
||
if not manifest_path.exists():
|
||
manifest_path = child / "plugin.yml"
|
||
if not manifest_path.exists():
|
||
continue
|
||
try:
|
||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||
manifest = yaml.safe_load(f) or {}
|
||
except Exception:
|
||
continue
|
||
label = manifest.get("label") or manifest.get("name") or child.name
|
||
# Merge required + optional env var declarations.
|
||
entries = list(manifest.get("requires_env") or [])
|
||
entries.extend(manifest.get("optional_env") or [])
|
||
for entry in entries:
|
||
if isinstance(entry, str):
|
||
name = entry
|
||
meta: dict = {}
|
||
elif isinstance(entry, dict) and entry.get("name"):
|
||
name = entry["name"]
|
||
meta = entry
|
||
else:
|
||
continue
|
||
if name in OPTIONAL_ENV_VARS:
|
||
continue # hardcoded entry wins (back-compat)
|
||
# Heuristic: anything named *TOKEN, *SECRET, *KEY, *PASSWORD
|
||
# is a password field unless explicitly overridden.
|
||
name_upper = name.upper()
|
||
is_secret = bool(meta.get("password") or meta.get("secret"))
|
||
if not is_secret and not meta.get("password") is False:
|
||
is_secret = any(
|
||
name_upper.endswith(suf)
|
||
for suf in ("_TOKEN", "_SECRET", "_KEY", "_PASSWORD", "_JSON")
|
||
)
|
||
OPTIONAL_ENV_VARS[name] = {
|
||
"description": (
|
||
meta.get("description")
|
||
or f"{label} configuration"
|
||
),
|
||
"prompt": meta.get("prompt") or name,
|
||
"url": meta.get("url") or None,
|
||
"password": is_secret,
|
||
"category": meta.get("category") or "messaging",
|
||
}
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# Eagerly inject so that platform plugin env vars show up in the setup wizard.
|
||
_inject_platform_plugin_env_vars()
|