Desktop's web picker had no way to send one capability through the Nous
Tool Gateway while the other used your own key. "Use for Search/Extract"
on the Nous Subscription row wrote the row's vendor string (firecrawl)
into web.<cap>_backend, which the runtime reads as BYOK Firecrawl: the
gateway was never used, and the managed and BYOK rows lit up together.
- The managed per-capability pick now stores `nous`; search/extract
dispatch, the registry and the Firecrawl client resolve the route per
capability (search: managed Perplexity, extract: managed Firecrawl).
- A whole-toolset Nous pick clears stale per-capability pins (they
outranked the new selection).
- /api/tools/toolsets/web/config reports search_via_nous /
extract_via_nous and each row's managed_nous_feature; the panel lights
the right row, labels the route, and refetches after a pick instead of
mirroring the vendor string.
Fixes#100513. Builds on the approach in #100539 by @liuhao1024 and the
self-healing pin clear from #122676 by @praggybuilds.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Murphy (praggybuilds) <praggybuilds@users.noreply.github.com>
Independent-review findings on the plugin PCM streaming path:
- stream_sample_rate was checked, then re-read and int()-ed in the
adapter: NaN/inf raised out of resolve_streaming_provider (the speaker
then played nothing) and 0.5 became sample_rate=0. Validate once
(finite, >= 1 Hz) and hand that int to _PluginPCMStreamer.
- `nous` is an alias of the openai path in _get_provider but not a
reserved name, so a pinned `tts.streaming.provider: nous` could stream
a plugin registered as `nous` while the sync path speaks OpenAI.
- Document that stream() must be thread-safe: the speaker prefetches up
to 3 sentences, unlike the serialized synthesize() path.
Follow-up to the salvaged plugin-streaming commit. The adapter read
voice/model from the plugin's own tts.<name> section, so a streamed reply
spoke the plugin default while synthesize() got tts.voice: the voice
changed with the surface. It also skipped the sync dispatcher's routing:
a same-named `type: command` provider (which wins for synthesize) lost
to the plugin on the streaming path, and an is_available() == False
plugin was streamed anyway.
- tools/tts_tool_plugins: one routing guard (_plugin_route_key) and one
voice/model/speed builder (_plugin_voice_kwargs), shared by
synthesize() dispatch and the new _plugin_pcm_streaming_provider
(streams_pcm + positive stream_sample_rate + is_available(), read at
resolve time)
- tools/tts_streaming: _PluginPCMStreamer stays a thin adapter;
stream_channels/stream_sample_width dropped (the contract is int16
mono, which every sink assumes)
- agent/tts_provider: streams_pcm / stream_sample_rate declared on the
ABC with the contract
- tests: two invariant tests next to the plugin dispatch tests (the
streaming test module skips without numpy); docs for plugin authors
Third-party drivers (e.g. a Codex-style computer-use reimplementation) could
only replace cua-driver by patching core. computer_use now follows the
single-select provider model Hermes uses for memory providers, context
engines and cron schedulers:
- plugins/computer_use/__init__.py discovers bundled
plugins/computer_use/<name>/ then user $HERMES_HOME/plugins/<name>/
(marker: register_computer_use_provider / ComputerUseProvider) and loads
ONLY the provider computer_use.backend names (default "cua"), read per
call so each profile gets its own. Unselected providers are never
imported; listing reads plugin.yaml only. An unknown/unloadable name
returns an error naming it and the installed providers; no fallback.
- The built-in cua-driver is an ordinary bundled provider at
plugins/computer_use/cua/ (driver implementation stays in
tools/computer_use). tools/computer_use/backend.py gains
ComputerUseProvider (create_backend(permission_mode=...), is_available(),
optional doctor()).
- The tool's check_fn and backend construction go through the selected
provider; the model-facing schema is unchanged for every provider
(prompt-cache invariant). The test-only HERMES_COMPUTER_USE_BACKEND env
switch is gone (tests patch _new_backend).
- General plugin discovery treats computer-use providers as exclusive
(not eagerly imported, not listed as toggleable) and skips the bundled
computer_use/ category, same as memory/, context_engine/, cron_providers/.
- hermes tools and the Desktop toolset panel list installed providers next
to cua; computer-use doctor/status/permissions and the Desktop status
payload skip cua-driver/TCC probes when another provider is selected.
`ctx.inject_message` could only reach an existing gateway session (`session_key`), so an
orchestrator plugin that opens a chat/thread itself (a bot-authored Discord forum post never
comes back as an inbound message) had no way to get an agent turn there.
`origin` takes the SessionSource.to_dict() shape. The gateway creates (or continues) that chat's
session through the adapter's normal message path, in the profile whose plugin manager loaded the
plugin, and the reply goes back to the origin chat:
- Profile: resolved from the manager's immutable home, never an ambient one. The origin may not
name another profile, the gateway's own routing for that chat through that profile's adapter
must land in the plugin's profile, and the profile needs its own connected adapter for the
platform (another profile's bot is never borrowed). Refusals are synchronous False + one log line.
- Authorization: same current-allowlist check as the session_key path (no bypass for host code).
- Multiplex: handle_message runs inside the profile's runtime scope so the spawned turn AND the
reply send after it inherit that profile's home and secrets (applies to the session_key path too,
which used to send from the default loop context).
- allow_gateway_injection is read from the plugin's own profile config.
- Plugin/system turns (event.internal) skip the human first-contact notes (onboarding intro and
the "no home channel" nudge), which were posted into the plugin's fresh thread.
Requested by canonicle-desk in #132609.
Closes#132609
The receiver buffers PCM per speaker with no conversation identity, and a same-voice-channel /voice join keeps that receiver. The poll and the leave flush then read the binding after draining, so audio captured for channel A was stamped with B and passed the post-STT check. The gateway join handler now discards the receiver's unemitted audio, under its lock, before moving the binding to a different text channel; a first join or same-channel rejoin keeps it. Nothing awaits between the discard and the binding write.
Reported by andrexibiza (F1) on #133471 and #130312.
The listen loop transcribes a check_silence() batch serially, so a later
utterance read its binding after a /voice join landed during an earlier
utterance's STT and was posted to the new channel. Capture the binding
once per batch (and per leave flush) and pass it in.
(cherry picked from commit e4b020a0fd21833a89fc1fa7f904db489d797d9c)
_process_voice_input awaits WAV conversion and STT, then calls the
gateway callback with only guild, user and transcript, and the callback
routes by the guild's binding as it is at that moment. A `/voice join`
from another text channel while the bot is already connected rewrites
_voice_text_channels and _voice_sources, so an utterance captured for
the old conversation became a turn in the new one, with its channel
prompt, bound skills, transcript echo and replies.
The bound text channel is captured when the utterance is accepted and
compared after STT; if the binding moved, the stale work is dropped and
logged. Rejoining from the same channel keeps the conversation and is
dispatched as before. Nothing awaits between that check and the
callback's own read of the binding.
Reported by andrexibiza (F6) on #128788.
(cherry picked from commit 47d515eb4e1633945212e42d6cf77aba41906ea8)
Two install routes refused a catalog memory provider that config already
names (memory.provider), so the install could never succeed and was
retried on every agent start / every click — the hindsight (91%) and
mnemosyne (87%) failed rows in hermes.extension.install.count.
1. Agent init (`recover_at_startup`) installs the configured provider
through the consent gate. Under the classic CLI that gate's input()
runs on the agent-build thread behind prompt_toolkit, so the turn
hangs on "Initializing agent..." forever; with no terminal (Desktop,
gateway, `chat -q`) it is refused — "Install declined: dependency
install skipped (non-interactive)" — and recorded failed on every
process start. Agent init now never asks: a provider that shipped in
core installs with its built-in's consent (security.allow_lazy_installs
still gates it), any other catalog provider is not attempted and the
user is told the one command that installs it.
2. The dashboard/Desktop Install click (`dashboard_install_plugin`) on a
plugin memory.provider already selects hits the publication veto that
is meant for replacing an active plugin, so it is refused every time
while the same plugin unselected installs and enables. The click now
carries the consent the fresh-install enable already acts on; a forced
reinstall keeps the veto.
Catalog category "memory" also holds hook plugins, context engines and
skill packages. Deciding from the category made `--enable` overwrite the
user's memory.provider with an unloadable name and never enable the plugin.
Decide from the installed tree (_is_memory_provider_dir), and save
memory.provider only when find_provider_dir resolves the name.
The install prompt for a memory provider now sets memory.provider; say so on
the Hindsight setup and plugin install pages and drop the claim that the
plugin is enabled under plugins.enabled.
Fleet telemetry (14d, v0.21.5) shows the builtin store returning
success=False on 34-47% of add/replace/remove calls. Live `hermes chat -q`
runs against origin/main reproduced the dominant causes:
- Drift guard refused every replace/remove/batch on a file whose only
difference from the tool's own layout was whitespace (blank lines
around the delimiter, an empty entry). Parsing only strips whitespace,
so flushing such a file loses nothing; the round-trip signal could not
protect any content and turned the store read-only until hand-fixed
(#107270). The guard now fires only on the signal that can actually
lose foreign text: one entry larger than the whole store's limit.
- An over-budget add/replace/batch said "would exceed the limit" but not
by how much, so models retried with consolidations that were still too
big. Every over-budget message now states the exact chars to free and
points at the one-batch fix.
- An old_text that matched nothing (paraphrased, stale after a concurrent
write, or several entries glued with the delimiter) returned no hint on
the batch path. Zero-match errors now carry `closest_entries` (top word
overlap) on both paths, and flag a delimiter-spanning old_text.
- A batch replace missing its own `content` now says to set it on the
operation object.
Fixes#107270. Same diagnosis as #107278 (@KoNit-K) and #61612 (@webtecnica),
which normalized the round-trip comparison instead of dropping it.
Command STT providers received the raw audio file with no format
normalization: _transcribe_command_stt only checked .exists() before
substituting {input_path}. Desktop voice recordings arrive as WebM/Opus
48 kHz and providers with container/sample-rate contracts (e.g. Tencent
16k_zh) reject the raw webm, so desktop voice chat failed on every
message. _transcode_audio_for_stt (ffmpeg -> 16 kHz mono m4a) existed
but was wired only into the OpenAI path's rejection retry.
Add stt.providers.<name>.normalize (default off, so whisper-style CLIs
that accept any container keep the original file): when truthy, the
input is transcoded into the per-call temp dir and {input_path} points
at the normalized file. Transcode failure returns an error envelope
instead of silently feeding the raw container to a provider the user
declared needs normalization.
Fixes#81811
Co-authored-by: embwl0x <embwl0x@users.noreply.github.com>
Teknium ruled the post-admission consumer hook (#129958) must fail OPEN, so
reshape the salvaged implementation into one small seam:
- `{"action": "handled", "reply": str|None}` is the only decisive result;
None, any other value, a raising callback or a timeout runs the ordinary
agent turn. One buggy plugin can no longer blackhole every inbound
message on a profile (the cherry-picked version failed closed on any
exception or malformed return from ANY registered callback).
- Fire under the message's ROUTED profile scope. The receiving bot's
handler binds the transport home so auth can read its .env; when that
bot serves a chat for another profile, the ambient scope at the hook was
the wrong profile and that profile's consumers never ran.
- Snapshot-only payload (session_key, platform, source dict, message_id,
text): no live gateway runner / session store handles, so the plugin
isolation host needs no HOST_DEGRADED_HOOKS entry.
- Bounded by plugins.hook_callback_timeout (fail-open) instead of the
fail-closed policy-hook set; the required-but-discarded receipt, alias
table, destination snapshot/restore machinery and hard-coded English
failure notice are gone.
- Tests trimmed to three invariants through the real _handle_message and
real plugin dispatch: consume path, raise path proceeds, routed profile
scope bound.
One observer pair fires wherever the agent blocks waiting on a person, so a
notifier plugin no longer needs a hook per prompt type or a `sudo` regex over
terminal commands (#132333, consumer: pushover-hermes-plugin).
Fire sites are the existing choke points every surface already routes through:
- sudo: tools/terminal_tool_sudo.py::_prompt_for_sudo_password (CLI callback,
Ink TUI/Desktop sudo.request card, /dev/tty fallback)
- clarify: tools/clarify_tool.py::clarify_tool (CLI, TUI/Desktop, gateway)
- approval: tools/approval_prompt.py::prompt_dangerous_approval (CLI/TUI/ACP
callback and input() paths), ::_present_with_selected_transport (plugin
transports), tools/approval_gateway_wait.py::_await_gateway_decision
(gateway platforms)
Payload: kind, request_id (shared by the pair), session_id, session_key,
platform, prompt; resolved adds outcome. prompt is always force-redacted and
the typed password / clarify answers are never passed. Smart (aux-LLM)
approvals and coalesced gateway followers do not fire it: no new human prompt.
Closes#132333
The protected agent-instruction write prompt and the MCP/vault consent
prompt called prompt_dangerous_approval directly on the classic CLI
without firing pre_approval_request / post_approval_response, so
observer plugins (notifiers, audit loggers) could not see those human
waits in the terminal even though their gateway twins were visible.
Fire both hooks around the CLI prompt with the same payload shape the
gateway branch builds (pattern_key protected_instruction_file /
mcp_elicitation, surface="cli" or the caller's consent surface); the
post hook fires in a finally so the wait always settles for observers
even when the panel itself raises.
Also document the consent surface values (mcp-elicitation/<server>,
mcp-trust/<server>, vault-payment) and that the Ink TUI/Desktop report
surface="gateway" today.
Fixes#131876
Skill Sync only worked against a Nous Portal sync plane that was never made
publicly accessible, so no install has ever used it. We don't want it in core
right now, so this deletes it outright instead of moving it behind a plugin
hook surface nobody needs yet:
- `hermes sync` command, `tools/skills_sync_client*.py` and their tests
- the personal pull ticks (gateway housekeeping, classic CLI startup,
`hermes serve` maintenance) and the debounced post-write push
- the per-skill `sync` usage flag (`set_sync` / `is_sync_enabled`)
- the `skills/_org/<org_id>/` mirror: index gating on `.active_org`, the
`[org-shared]` / name-collision labels, the org provenance header and
`org_provenance` field on skill_view, the org delete guard and the
auto-propose `org_sharing` note on skill_manage
The code is preserved in the private NousResearch/hermes-skill-sync repo if
the feature comes back.
hermes -z ends its turn but leaves the Relay root conversation open:
run_conversation keeps the root resumable and the one-shot exit is
os._exit, which skips the atexit finalizer every other CLI path relies
on. Exported Relay telemetry showed session start + turn start/end with
no session end, and on_session_finalize never fired for one-shot runs.
_run_agent now keeps the session id at turn entry (compression can
rotate agent.session_id without opening a second root) and _close_agent
calls finalize_session for it after the background-completion linger and
before memory/agent teardown, on failure as well as success.
Co-authored-by: Soheil Fakour <soheil.fakour@gmail.com>
install --force --ref is the documented way to move a pinned plugin, but it replaced the whole plugin directory: untracked and git-ignored files (plugin state, local config) were deleted and tracked edits were lost with no copy. A reinstall from the source the plugin was installed from now carries user files like update does and copies tracked edits to plugins-backup/. A different source still starts clean.
The skills page said local skills win over external_dirs in one place and
listed project > local > external_dirs (no create_dir) in another, while
skill_view refused both. Document the single order every surface now uses
(project > local > skills.create_dir > external_dirs), how same-directory
duplicates are listed and loaded, and that plugin:skill names are unaffected.
Review and simplify pass on the guest fast-search restore.
- tools/tool_backend_helpers.py: _account_info was split out so two gates
could share one account read. Only managed_nous_tools_enabled is left, so
fold it back into its pre-salvage shape. Keep the branched force_fresh
call, because tests stub the reader with zero-arg lambdas.
- plugins/web/perplexity/provider.py: the managed= parameter on
_managed_gateway only fed the removed refusal helper. search() now calls
resolve_free_search_gateway() directly once it has the route answer. The
module docstring now names guests.
- hermes_cli/nous_account.py: the _error_info docstring described the
removed gate. The tier carried on a failed lookup still picks the "needs
a Nous account" copy over billing or re-login guidance.
- Tests: the case-insensitive tier match and the tier on error snapshots
lost their only coverage when the gate tests went, so cover them at
their consumer, format_nous_portal_entitlement_message. The fast-search
premise now asserts that the guest case really reads as the anonymous
tier. Remove the dead exp_offset parameter.
- Docs: the Perplexity section said there is no anonymous tier, which is
true for direct keys only. It now names the managed guest route.
The free managed Perplexity fast-search route exists for anonymous guest
Portal users. The salvage of #130121 (cee3000c24, via #130660) added a
registered-account gate, fast_search_entitled(), that excluded the
anonymous tier. That defeated the original change: guests fell back to
the keyless ring and never sent a gateway request.
This removes the gate, so resolve_free_search_gateway() decides on
identity alone again, as rewbs wrote it. The guest-disabled, expiry
refresh and dead-guest rules still apply through the token reader. Every
other vendor, and the billed managed-Firecrawl fallback, keep the paid
entitlement gate.
Kept from the salvage: the case-insensitive is_anonymous_tier, the
account tier carried on error snapshots, and the single route walk per
search dispatch. Tests: the anonymous identity is back in the autodetect
and rescue cases. The tests that pinned the guest exclusion are removed.
plugins.isolation is profile config, so a profile (or its agent) can set it
back to in_process. The managed scope already beats profile config and makes
`hermes config set` refuse, and the isolation reader goes through that
overlay; document it as the operator lock and pin the contract with a test
(red when isolation_mode() reads the raw profile file).
Control verbs with trailing words now run the verb instead of replacing
the goal, so goal text that starts with one of those words needs the
`--` escape. Without a docs line the change is only discoverable from the
in-chat hint.
A recurring job with a finite repeat count (for example repeat=3, every
24h) lost one repeat slot for each automatic re-run after a fire that
could not reach the model. The failed occurrence counted, and then each
5/15/30-minute re-run of that occurrence counted again, so a half-hour
outage finished a repeat=3 job with zero model calls. With
cron.retry_unreachable off, the same outage costs only the one failed
occurrence.
Cause: cron/jobs.py:_advance_after_run bumps repeat.completed on every
mark_job_run, and the re-runs parked by
cron/unreachable_retry.py:plan_retry go through it like scheduled
occurrences. cron/unreachable_retry.py:will_retry also treated a re-run
on the last slot as the job's final run.
Fix: cron/scheduler.py:_finish_completed_run passes ladder_rung=True
when the dispatched instant is the parked re-run instant (new
is_retry_run, which compares the dispatch snapshot's _scheduled_instant
with the ladder state). mark_job_run then calls _advance_after_run with
count_run=False, and will_retry leaves that run out of its final-run
check the same way. The first failed occurrence still counts, and manual
runs still count.
(cherry picked from commit 27038f7064bb611811efee8c527dddecd4fcb57b)
The bare 'custom' arm of _validate_cron_base_url returned early as pure BYOK, but the resolver attaches the host-gated env keys (OPENAI, OPENROUTER, OLLAMA, <VENDOR>_API_KEY) to such a job by hostname. A job could pair one with another scheme, port or subdomain. When a stored key would go with the base_url, require the origin of a configured endpoint or a registry provider; a base_url no stored key matches stays unrestricted.
(cherry picked from commit bb7c37c8d7b88077acd45de74f1efb1501adca1a)
_validate_cron_base_url accepted a job base_url whose hostname matched
the provider's endpoint or was a subdomain of it, so a cronjob tool call
could pair the stored key with plain HTTP, another port, or a subdomain.
Compare the full origin (scheme, host, effective port) via
utils.base_url_origin, the rule the other secret-to-new-URL checks use.
The scheduler backstop calls the same validator, so every surface that
stores a job is checked before the key is sent.
(cherry picked from commit a281efeedac02f87e168743b389b78f6feaf5472)
Browsers send no Origin on a GET, so a cross-site page (or a DNS-rebound
page on a wildcard bind) could still reach /v1/models with the operator's
credential. Sec-Fetch-Site, which a page cannot forge, now refuses those.
The Origin check compares the parsed host and port instead of the raw
string, so an Origin that omits the default :80 or spells an IPv6
address differently is matched the way a browser means it.
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
(cherry picked from commit 72bf4e9c34f6c6200658e42345f775291462697c)
A wildcard bind accepts any Host name, so a DNS-rebound page reaches it
with Origin equal to its own Host and the same-origin rule let it through
to the operator's credential. A wildcard listener has no origin of its
own, so an Origin-bearing request is refused there; API clients without
Origin keep working. Also treat the absolute form localhost. as localhost
and detect expanded IPv6 unspecified binds as wildcard.
(cherry picked from commit 5ce7aac4d7e4d160b2a6447f0442e747f489d72f)
hermes proxy attaches the operator's subscription credential to every
forwarded request but accepted any Host header and any Origin. A
loopback or specific-IP bind now serves only its own Host names, and a
request whose Origin is not the proxy's own is refused with 403.
Non-browser clients send no Origin and are unaffected; a wildcard bind
keeps accepting any Host.
(cherry picked from commit 3cf3870cfc2a795e439b1a8c0c53424f79c24505)
DELETE/PUT /api/learning/node, POST /api/skills and PUT /api/skills/content
read the target profile from the JSON body only. A shared-backend Desktop
(global remote, registry connections) scopes these calls with ?profile= and
sends a body without one, so the star map's delete archived, and its edit
rewrote, the same-named skill of the backend's launch profile while the
selected profile's skill stayed as it was.
Take the body profile first and fall back to the query, as
PUT /api/skills/toggle and the tools routes already do.
Completes the managed-endpoint rung for every caller that resolves a bare
llamacpp / llama.cpp / llama-cpp alias outside the main ladder:
- The rung moves from _resolve_custom_branch to the resolve_provider_client
entry and to _get_cached_client, via agent/auxiliary_local_runtime.py.
The `main_runtime is None` gate is gone: the cached auxiliary path always
passes a normalized dict, so the rung never ran there, and a task pinned to
llamacpp inherited a foreign main runtime's URL instead.
- No running server now means (None, None) — the same answer the main ladder's
_resolve_llamacpp_runtime gives — instead of continuing into
_try_custom_endpoint / _resolve_api_key_provider, which posted the local GGUF
slug (and, for compression, the conversation) to whichever cloud provider held
a key (#119227) or to the primary's own endpoint. OPENAI_BASE_URL is not
consulted either: the alias table entry (b7e0d715be) exists for explicit
base_url lanes, and the main ladder never reads it for a bare alias.
- The cached client keys on the live endpoint (base_url + key), read at call
time, so a server restarted on a new port/key gets a fresh client and a
stopped one resolves to nothing instead of a stale client.
- The endpoint is resolved with the calling profile's config (load_config() at
call time), matching the main ladder; a configured provider entry named like
the alias still wins.
- Vision keeps the raw provider name for its final resolve so the alias is not
flattened to bare `custom` first.
- The aux "unavailable" message names the stopped local server instead of an
invented LLAMACPP_API_KEY.
Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
The plugin deep link (hermes://plugin/install?catalog=) and the skill
deep link are separate features: one per kind. Reverting #119381's card
catalog should not leave skills without a link, so this restores the
skill route on its own:
- Desktop: skill-deeplink-install store, the skill route in
deeplink-routes, its use-desktop-integrations wiring, and the
confirm-store onConfirm/details/busy/done phases its dialog uses
(restored from main with their tests). Its nine strings move from the
deleted catalog.* namespace to skillDeepLink.* (en + the six locales
that translated them).
- Website: Skills Hub "Install in Hermes" button, plus the
source-qualified installIdentifier from extract-skills.py so the link,
the embedded picker and the CLI command all name the exact skill
(apps/shared/src/catalog-install.ts keeps only the skill helpers).
- Docs: skills.md "Install from the website" section, desktop.md pointer.
The card catalog UI stays reverted.
Reverts all seven commits of #119381 (59c54c78..476ff4dd), which
re-landed the native card-based Skills/Plugins catalog browser after it
was reverted twice (#113037 reverting #112669, #115787 reverting
#113128). Capabilities -> Skills and Plugins return to the list/detail
tabs with the embedded catalog picker they had before #119381.
Removed with it:
- Desktop catalog/ module (browser, cards, detail dialog, filters,
reels/masonry, install switch) and skill deep-link install
(hermes://skill/install); skills/embedded-hub-picker.tsx comes back.
- apps/shared catalog-browse.ts and catalog-install.ts.
- The `catalog` i18n namespace in every locale (+ regenerated
locales/_keys.desktop.json); the two i18n pack tests and the keys
test now exercise connectorsPage.searchPlaceholder instead.
- Website /skills "Open in Hermes Desktop" skill links and the
extract-skills install identifiers; docs return to the pre-#119381 text.
- Later fixes that only patched the reverted catalog code go with it:
5832a6eb93 + 7aba2cbbe9 (catalog download deadline),
9e63cdc4b8 (catalog lookalike rows), c756986db2 (catalog counts),
the catalog half of c13232e90c / f265273347.
Kept on purpose (later work that depends on code #119381 introduced):
- DialogContent `chrome` / `overlayClassName` / nested-dialog layering
(the image lightbox uses them).
- vitest.setup.ts / src/test/jsdom.ts stubs (other suites rely on them).
- Kanban toolset wiring on the Plugins Desktop switch (cd30fdadf6,
7ca2f5665f) and its tests, the `hermes_not_connected` server tone,
the SearchField flex-1 fix, skill provenance helpers. SkillDetail shows
a provenance pill only for agent/hub skills, since the pre-#119381
labels have no `external` entry.
memory-providers.md gets a "Plugin catalog" note on the Supermemory section,
`hermes plugins install supermemory` in its setup, and a "Migrating from
bundled Supermemory" section that mirrors the Hindsight one. memory.md,
overview.md, integrations/index.md, cli-commands.md and the developer guide
no longer list Supermemory as bundled.
Agent-start recovery (recover_at_startup) reported installed / refused / failed /
not-in-catalog only to agent.log, and so did a selected provider that reports unavailable,
so a user with security.allow_lazy_installs: false, offline, or who declined the dependency
prompt silently ran without external memory (#119769).
- Startup outcomes go through _emit_startup_warning: printed in the CLI; other drivers get
a sticky warn AgentNotice (Desktop toasts notices but drops warn-kind status.update), and
the messaging gateway, which wires notice_callback per turn, receives it on turn 1.
- hermes update labels each line with the profile it is about and gives the exact
`hermes -p <profile> plugins install <name>` command for non-active homes.
- Profiles sharing one environment get one set of dependency answers per provider
(shared_dependency_answers): the first install asks the normal Python/Node questions,
later homes reuse them; a decline or failure names the remaining profiles once instead of
failing (or re-prompting) one by one; Ctrl-C ends the migration with a message (#125794).
- The success line no longer names memory.<name> (#124038); docs follow.
Co-authored-by: chelsealong <chelsealong@126.com>
Desktop, the gateway and a scripted `hermes update` have no TTY, so the
dependency consent gate refused every catalog provider that declares Python
deps: both migration hooks failed with 'Reinstall declined: dependency install
skipped (non-interactive)' and the provider stayed missing. The agent-start
hook exists precisely for those surfaces.
The configured memory.provider plus security.allow_lazy_installs (read for the
home being migrated) is the consent that let the bundled provider install its
deps on demand; carry it through dashboard_install_plugin ->
install_catalog_entry(assume_deps_consent=...). A terminal still prompts, and
allow_lazy_installs: false still refuses with the install one-liner.
The page still said multi-local and mixed tool_call batches are rejected.
The agent now splits a batch containing local tools into one call per
entry; document that and the connector-only dispatch.
On Desktop `default` and /skin default mean Nous (a CLI/TUI or config switch
to `default` moves a Desktop pick back to Nous), the gold look is the
separate `classic` theme reached by /skin classic, gold or hermes, and
`classic` joins the Desktop built-in names a user skin file cannot recolor.