The Desktop accent picker moved from NousResearch/hermes-desktop-accent-picker
into the official plugins monorepo and is now in the plugin catalog, so the
user guide says to install it from the catalog rather than via Install from Git.
Holographic has a standalone third-party maintainer (itpartypattaya) and a
reviewed catalog entry, so it leaves core now rather than on Oct 15. Restores
9e827685f2 exactly: reverts the revert (435b7bd3a7) and its health waivers
(b9ecc727e9). byterover and retaindb have no maintainer and stay bundled until
the announced date.
This reverts commit 9e827685f2 (#136424). It landed before the announced
October 15, 2026 date for holographic leaving core; it re-lands on or after
that date. Restores plugins/memory/holographic and its tests/docs.
Holographic now ships from itpartypattaya/hermes-plugin-holographic through the
plugin catalog (plugin-catalog/holographic.yaml, v0.6.1 @ 46b6f908, a fork of
Nous's archived handoff copy). It keeps the provider name, memory_store.db and
plugins.hermes-memory-store, and memory_provider_migration already lists
holographic in _LEFT_CORE. Deleting the in-tree copy and its tests lets
`hermes update` and agent start install the catalog plugin for every home that
has memory.provider: holographic.
Host code that still releases memory-store handles on profile unserve/delete
resolves the store through import_provider_module, the same way profiles.py
already does, and no longer imports plugins.memory.holographic. Tests that used
holographic as "a bundled provider" now use byterover. The journal-mode
contract drops the holographic row because the plugin owns that store now.
People who installed the archived Nous repo by URL have no catalog record, so
`hermes plugins update` kept pulling the archive. read_catalog_sidecar now
adopts an install from that repo as the holographic catalog entry, and update
re-pins it to the maintained repo at the reviewed sha. That catalog re-pin
also needed the reviewed-pin scan trust that catalog installs already get. Without
it, update_plugin scanned the reviewed tree as an unreviewed community source
and blocked on caution findings, and that blocked every catalog re-pin of
holographic (the reviewed 46b6f908 tree has 14 caution findings).
Plugins that need the user's ChatGPT/Codex subscription (hermes-live-voice mints
an OpenAI Realtime client secret) vendored their own reader + refresher for
~/.codex/auth.json. Codex refresh tokens are single-use, so that second refresher
races Hermes's locked refresh and can revoke the user's whole token family.
hermes_cli.plugin_provider_requests.credentialed_provider_request() lets core make
the request instead: it resolves the credential through
resolve_codex_runtime_credentials (Hermes's locked refresh, profile-scoped),
attaches Authorization itself, sends only to chatgpt.com / api.openai.com, never
follows redirects, and returns status/headers/body. Callers are attributed by the
plugin directory on the call stack and must declare `requires_auth: [openai-codex]`
in plugin.yaml; the declaration shows at install, in `hermes plugins show`, and a
catalog re-pin that adds it asks for consent. A plain importable function so
dashboard plugin_api.py modules can use it.
Relay 0.10 starts gen_ai trace/log/metric export on its own whenever an
active configuration meets an OTEL_EXPORTER_OTLP_* endpoint in the process
environment, and those spans carry prompt and response messages. The
legacy HERMES_NEMO_RELAY_ATIF/ATOF variables never exported OTLP, so the
generated relay-plugins.toml now pins opentelemetry.enabled = false.
Also restore the discovery-only test's DISABLED-state assertions: they
pass on 0.10 and only trip on a host with a machine plugins.toml.
#118661's note only fired on the stored-prompt restore path, which a long-lived agent (classic
CLI, TUI, Desktop) runs once per conversation: those surfaces never saw a mid-conversation skill
change. The check now runs from build_turn_context every turn against the prompt the turn
actually sends.
- What was told is kept on the agent for its prompt; a fresh agent (gateway, -q --resume) reads
the newest note back from the WHOLE transcript instead of the last 200 rows, so a long session
no longer re-announces an unchanged delta (review by @kvnloo).
- A compaction rebuild that now lists the announced skills stays quiet.
- Index rows split on ": ", so qualified plugin skills (plugin:skill) parse.
- The plugin go-live note drops its own skills list: the per-turn note covers plugin skills like
any other, so a plugin install no longer announces its skills twice.
`hermes-index rate_limited` (26 events / 9 installs in 14 days): GitHubSource.fetch
paid one Contents API call per file in the skill folder on top of repo + tree,
so an anonymous user (60/h) could never install a skill with ~58+ files and
four average skills drained the hour. Live on origin/main, anonymous:
skills-sh/remotion-dev/skills/remotion-best-practices cost 143 API calls and
failed rate_limited.
A file fetch pinned to the tree's commit sha now reads
raw.githubusercontent.com/<repo>/<sha>/<path> first: the same immutable bytes,
outside the REST limit. A raw miss (private repo, raw outage) falls back to the
Contents API as before. The same install now costs 2 API calls and succeeds
anonymously; anthropics/skills/pdf drops from 16 to 2.
The per-session notification poller is the only driver of a Desktop/TUI
(route-less) /loop or /heartbeat. When the client disconnects, the WS-orphan
reaper collects the idle session ~20s later and the TTL/LRU reapers can
collect it after that; either stops the poller, so the schedule freezes
(status still 'active') until a client reattaches and the overdue tick fires.
Treat an active, non-gateway-routed loop or heartbeat like live delegated
work: the orphan reap re-arms instead of popping the session, and the TTL and
session-cap reapers skip it. Gateway-routed schedules are fired by the
gateway's own scanner and do not pin the session. The exemption is bounded by
the loop's tick budget and ends on stop/pause. Memory-trim quiescence is
unchanged: an idle looping session does not block a trim.
Every source update installs Node dependencies as one npm ci over ui-tui,
web and, when a built desktop app is present, apps/desktop. Only
apps/desktop brings native addons (node-pty, electron), so a host whose
compiler cannot build them (Ubuntu 20.04's g++ 9 rejects -std=gnu++20)
failed the whole install and lost the TUI and web UI builds as well, with
"a source update could not be finished automatically" on every launch.
When the union fails and the desktop app is part of it, retry once without
apps/desktop: the TUI and web UI build, the desktop build is reported owed
("desktop dependencies failed", the line the hand-off scripts match), and
the update names `hermes uninstall --gui` for hosts that never run the
app. The same hint prints whenever a headless Linux update builds Desktop,
since such a host keeps rebuilding an app nobody launches.
Without the opt-in, external recall is kept whole up to
max(10 x hooks.output_spill.max_chars, 100k chars) and still spills above
that, so one runaway provider can't overflow the context window and wedge
the session. Normal provider-budgeted recall (~16 KB for Honcho) is
unaffected.
recover_at_startup tried the configured provider's catalog install once per
PROCESS, so a home whose install fails (PM resolve/clone error, offline) retried
and failed again on every `hermes chat -q`, cron run, gateway agent and Desktop
restart. Each attempt is a recorded hermes.extension.install.count failure:
hindsight logged 1048 failures over 204 failing install-days (~5 per day per
install, the retry-loop shape) after it left core.
The left-core migration already had the fix: a failure stamp under
<home>/cache gates startup attempts for an hour while `hermes update` always
retries. Move that stamp into memory_provider_migration and use it in
recover_at_startup too; the backed-off start still tells the user memory is
off and names the install command.
When no resident model cleared 20 tok/s, the resolver still recommended the
fastest one (fastest-resident). A "32 GB" machine without a discrete GPU reads
31.5 GB, where only the 27B fits, at a predicted 13 tok/s, so CPU-only and
integrated-GPU machines were steered to a slow dense model. Those machines now
get no recommendation, and every fitting model stays one click away in Browse.
The no-recommendation copy now says the model must run at full speed, the user
guide states the rule, and the unused fastest-resident tooltip string is gone
from every locale.
The spotify toolset (plugins/spotify) and its loopback-PKCE login (hermes_cli/auth_spotify.py)
now live in NousResearch/hermes-spotify, installed from the plugin catalog. Same pattern as the
Home Assistant move (fb9fc8a3dd): a LEFT_CORE row installs the plugin for every home that was
using it (a Spotify login in auth.json, or the spotify toolset in platform_toolsets) on
`hermes update` / first start; the toolset name, tool names, schemas, env vars and stored login
(providers.spotify) are unchanged, so nobody re-authenticates.
The one user-visible change is the login command: plugins cannot extend `hermes auth`, so the
plugin registers `hermes spotify login|status|logout`. LeftCoreFeature gains a `cli` field and
moved_command_hint() turns `hermes auth [status|logout] spotify`, and `hermes spotify` while the
plugin is not loaded, into a line naming the new command and the install/enable step for this
home. The dashboard's "needs login" hint names the new command too.
Core drops the Spotify auth constants/dispatch, the CONFIGURABLE_TOOLSETS row and post-setup
step, the core `spotify` entries in the shared-metrics toolset enums, its tests, and the docs
(en + zh-Hans) now point at `hermes plugins install spotify` / `hermes spotify login`.
The Desktop/TUI/dashboard toggle edited the saved list before the plugin
loaded, so a backend that scanned while the plugin was off found no toolset
key and left the toolset out: the plugin showed enabled with its tools off.
Enable now edits the list after activation; disable resolves the key before
the disable is written. hermes plugins enable/disable make the same edit.
Only saved lists are touched; a fresh config still gets no list.
- hermes_cli: _find_plugin_entry is the single resolver (key / manifest name / unique leaf /
catalog name via the installer-owned record). enable/disable/show/capabilities/update/remove,
the dashboard and plugins.manage settings all go through it, so the catalog name you
installed with keeps working (17 catalog entries install under another manifest name).
_catalog_install_on_disk reuses the same catalog lookup.
- Desktop hub: join agent rows to desktop halves by package folder (what Electron stamps),
not manifest name, so a self-cloned package is one row instead of two.
- Desktop: turning a unified package on (install dialog 'Enable after install' or the Agent
switch) also turns its opt-in desktop half on, unless the user switched it off. Off stays
per half. Capability consent is untouched.
- docs: plugin-catalog page.
A plugin's on_session_finalize hook can now return a str or {"message": str}
and Hermes shows it at the session boundary without starting a turn or
rewriting the model reply:
- CLI /new: printed before the new session; CLI quit: printed above the
exit summary; one-shot -q/-z: printed to stderr (stdout stays the answer)
- TUI: session.close returns the messages; /new shows them as system lines
- Gateway /new and shutdown: sent to the owning chat via the adapter, while
adapters are still connected
on_session_end stays observer-only: it fires after every turn, not at a
session boundary. Gateway finalize dispatch moves to
gateway/run_shutdown_session_end.py.
Replaces the six TestUnattendedPlatformExecAskLeak cases in test_approval.py (past its
size cap) with two invariants in a sibling file, both red on main:
- webhook / msgraph_webhook deny from approvals.unattended_mode even with the
TurnRunner-registered notifier, and never reach the human gate;
- api_server denies without a notifier and keeps the ask path once a client bridge
(/v1/runs, streaming chat) registers one.
security.md: unattended_mode now names the api_server exception.
Root cause first identified in #100554 (@fangliquanflq); webhook leg from #134821.
/yolo on a messaging platform only flipped tools.approval's in-memory
set, so any gateway restart (hermes update, crash, systemd restart,
Desktop reopen) silently put the chat back on manual approvals while
the user still believed YOLO was on.
The flag now lives on the chat's routing entry (SessionEntry.yolo,
persisted in gateway_routing like the /model override). /yolo writes it
before flipping the live flag, materializing the entry for a
first-message toggle; every turn re-arms the live flag from the entry it
already resolved; switch_session carries it across compression-tip and
binding repoints; the conversation-boundary security funnel (/new,
/resume, /branch, auto-reset) clears the persisted copy with the
in-memory one so a restart can never revive a bypass the user dropped.
Also corrects the security docs, which still claimed /yolo sets
HERMES_YOLO_MODE.
- Decide the profile claim when the row is written (the check from #101827)
and drop the None sentinel from #103712. On current main load_pool() leaves
a profile that borrows from an empty root at the default, so the sentinel
alone never reached the claim path (its test stays red).
- add_entry() re-reads the store after writing. A missing row raises
CredentialNotSavedError; `hermes auth add` exits with that message instead
of printing "Added", and the dashboard pool endpoint returns it as a 400.
- Tests: the two contributor tests become one invariant over every
SINGLE_USE_REFRESH_POOL_PROVIDERS provider (the row is readable, root
auth.json is byte-identical), plus the CLI success-line contract.
- Docs: profiles page, EN and zh-Hans.
Fixes#103694
(cherry picked from commit 0e250a005a3353564642ce70f100d89174a583f2)
The #115692 reclaim released any running attempt whose claim was older than
max(3 x HERMES_CRON_TIMEOUT, script timeout, 2 h), even when the owner was
healthy and busy. Every cron job that legitimately ran longer than that was
marked `unknown` mid-run, its fire claim yanked from under it and the run
aborted with "lost its durable fire claim ownership" (two weekly jobs on one
install in a single day).
The run monitor now stamps `progress_at` on the execution row every 60 s
while the agent reports recent activity, and the sweep measures the stale
bound from that stamp (claim age for legacy rows). A wedged worker stops
stamping and is still reclaimed once the bound passes.
Shape: the stamper and the inactivity watchdog loop (which reads the same
idle signal) live in a new topical sibling cron/scheduler_liveness.py, so
cron/scheduler.py shrinks (4457 -> 4436 lines) instead of growing.
The installation page said "Light is a remote-only build variant" next to
the download instructions, which read as if a remote-only Desktop client
can be downloaded. Light exists only as a build target with no release
leg, and there are no plans to publish it. Replace the sentence with how
remote use actually works today (install a package, connect from Settings
-> Gateways), drop "Light packages" from the uninstall note, and mark the
variant as unpublished in the desktop README.
The self-improvement review fork refused every edit/patch/write_file/remove_file on a skill
without `created_by: "agent"`, so lessons about hand-written, URL-installed, foreground-created,
bundled, hub, external or pinned skills were dropped with only a WARNING line (77% of refused
review writes on the #134289 reporter's install). The prompts then steered the fork to a
different umbrella or a new near-duplicate skill.
Ownership now gates only delete/archive: the review may improve any skill it learns from (still
read-before-write, ledgered, and staged when skills.write_approval is on), while autonomous
archival stays limited to curator-managed, unpinned, non-bundled/hub/external skills. The review
prompts drop the protected-skill lists and say never to delete.
Fixes#134289
MemoryManager forwarded turns, transcripts and tool output to external
memory providers without the egress scrub, so a key echoed into tool
output was archived verbatim in the provider's store.
Every fan-out now goes through redact_for_egress (the chat/cron delivery
scrub; holds with security.redact_secrets off, fails closed): sync_all
(turn strings + messages), prefetch_all / queue_prefetch_all,
on_turn_start, on_session_end, on_pre_compress (both transcripts),
on_memory_write (content + metadata), on_delegation and provider
handle_tool_call args. Nested dicts/lists (tool-call arguments,
multimodal parts) are walked; data: URLs pass; the live transcript is
never mutated. A value masked anywhere in one hand-off is masked
everywhere in it, so a key the model repeats bare in prose is caught.
redact.py: the JSON-field rule now also matches inside JSON-encoded
strings (tool results wrap config dumps in escaped JSON), which is how
the reported opaque AQ.-prefixed key slipped past.
Co-authored-by: Teknium <teknium1@gmail.com>
auxiliary.voice_chat.reasoning_effort ships as "none", and it applies even when the slot is on
auto (the session's model answers the spoken turn with reasoning off; typed turns keep their
effort). Routes that cannot switch reasoning off get their weakest accepted level instead:
Responses ladders without "none" (gpt-6-astra, gpt-6.1-sol -> low), mandatory-thinking Claude,
and routes the catalog or an earlier 400 marked mandatory. A disable the voice model rejects at
runtime is remembered per route for the next voice turn, and the rejection flags never leak
between the voice route and the main one.
"Inherit" in the dashboard/Desktop writes an explicit "" so the slot default does not come back;
both resets restore the shipped default.
Voice replies had to come from the session's main model, so a heavy model made every spoken
answer slow (#83894). A new auxiliary slot, voice_chat, picks the model for voice turns:
- CLI/TUI voice mode, the Desktop voice conversation (prompt.submit voice_turn) and messaging
voice notes mark the turn; agent/voice_turn_route.py binds the route after the prompt, row and
preflight compaction were settled against the main model and undoes it before memory sync and
background review, so the next typed turn and the review run on the main model.
- A turn larger than the voice model's compression threshold stays on the main model with a
one-time notice: switching to voice never compacts the conversation.
- Usage lands in session_model_usage under task voice_chat; it never rewrites the session's
recorded model/route, so resume keeps the model the user picked.
- Slot appears in Desktop Settings -> Models, the dashboard, and `hermes model`; GPT-Live
delegations keep using the session model.
The desktop guide said nothing else on the computer is read for setup, but building the setup cards checks whether Blender and the NVIDIA apps are installed, reads their versions and for some apps their runtime file. Greptile on #134209.
OpenViking now ships from Volcengine's own repo through the plugin catalog
(plugin-catalog/openviking.yaml, pinned in #131267). Delete the in-tree copy
and its tests; memory_provider_migration already lists openviking in
_LEFT_CORE, so homes configured for it get the catalog plugin installed on
hermes update / first agent start.
The catalog pin starts openviking-server with the full Hermes environment
(the bundled copy filtered it). The docs now say so and point at
volcengine/OpenViking#5553; start openviking-server yourself to avoid it.
The desktop's old recommendation inputs were the only client of
GET /api/mcp/catalog?detect_apps=true and the only sender of the guide_*
onboarding steps; both went with the old ::onboarding chain.
- The catalog route loses detect_apps; mcp_app_detection.py and its two
test files go. The manifest's suggest.applications validator moves into
mcp_catalog.py, which still validates applications / requires_app /
examples (documented in features/mcp.md).
- DESKTOP_ONBOARDING_STEPS and the v4 schema drop guide_connectors,
guide_first_build, guide_layout and guide_look. v3 stays as published.
- developer-guide/onboarding-recommendations.md described that pipeline
and is removed.
The capture route only checked "does the provider carry tool-result
media", never `agent.image_input_mode` or a catalog verdict that the
model cannot see. deepseek-v3.2 on OpenRouter therefore went native, the
agent backstop swapped the screenshot for a "switch to a vision-capable
model" error, and the configured auxiliary vision describer never ran;
`image_input_mode: text` was ignored for screenshots too.
`tools.vision_tools._native_tool_result_images(provider, model, cfg)` is
now the one gate for every tool that hands the model pixels
(vision_analyze, browser screenshots, computer_use captures, MCP images):
image routing resolves to native AND the route accepts tool-result
images. The capture route is its negation, which drops three local
helpers that duplicated image_routing. Proxy aliases the catalog does
not know still go native under `image_input_mode: native` (#115248's
case); `auto` sends them to aux, matching vision_analyze.
Tests: test_computer_use_vision_routing.py is rewritten to drive the
real gate (the old cases patched the removed helpers). Changed
assertions: "whitelisted provider + catalog-unknown model" now requires
image_input_mode: native (it carried that cfg already); new cases pin
catalog text-only -> aux and image_input_mode: text -> aux.
An MCP tool's ImageContent was cached and the model got back only a
`MEDIA:/path` line, so a vision main model could never read the image
(a raw cua-driver MCP server could not see the screen at all).
The registry handler now returns the `_multimodal` envelope when the
active route takes images inside tool results, decided by the same gate
vision_analyze and browser screenshots use (`agent.image_input_mode`, an
explicit `auxiliary.vision` backend, catalog vision, provider support).
The image is re-encoded from the sniffed cache file, normalized to a
provider-accepted format and sized to `vision.embed_target_bytes` /
1568 px (it is re-sent every later turn), at most 4 per result; the text
half keeps the MEDIA: paths. Paths come from this call's own cache
writes, never parsed out of server text. The resize runs on the tool
thread, off the shared MCP loop. Plugins' `ctx.call_mcp` keeps its
string contract (native_images is set only on the registry handler).
Builds on #131374 by @sarahwu185, which reused the computer_use capture
gate (ignores image_input_mode, native for catalog text-only models) and
inlined the server's raw base64 unvalidated and unresized.
faster-whisper accepts `turbo` and the config docs listed it, but the CLI
picker, dashboard and Desktop settings stopped at large-v3, so the only way to
use it was hand-editing config.yaml. All three now read it from the shared
STT_MODEL_CATALOG, along with the install hint and the cloud-model warning.
Salvage of #79671 by @tank321, rebased onto the unified catalog from #133825.
Gate r4 (2c + quality, both probed): the r3 hysteresis dropped an "unwritable"
transition that came within an hour of the last notice. note_unwritable reports
entry only once, so for unwritable -> recovered -> unwritable the home channel's
last notice said "writable again" while the store stayed down, possibly for days.
Rate-limit the recovery instead. "unwritable" is sent only for a store whose
outage was not announced yet; "recovered" waits NOTICE_REPEAT_SECONDS on the
gateway loop and is sent only if the store is still writable then; a re-entry
meanwhile cancels the pending recovery and sends nothing. The last notice always
matches the store's real state, and a flapping disk posts at most one pair per
window. All bookkeeping now runs on the loop (call_soon_threadsafe), so the
ticker thread no longer needs a lock, and the notice coroutine is created
there too: a transition after the loop closed raises before any coroutine
exists (logged by store_health._notify), instead of leaking an un-awaited one
(gate r4 reuse item, loop-side variant).
Gate finding C12 r3 #5 (2c): the degraded state lives in gateway memory, so a
one-shot that came due during an outage only fires on recovery if the
gateway did not restart in between. After a restart the normal grace window
applies and it can expire. Say so in the recovery sentence rather than
seeding "since" from jobs.json's mtime, which would revive one-shots that
were really missed.