Commit Graph

3222 Commits

Author SHA1 Message Date
Teknium
3861f1da62 fix(web): pick own key or Nous Tool Gateway per web capability in Desktop
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>
2026-10-06 02:53:56 -07:00
Teknium
f9798bcf9b fix(tts): validate a plugin's stream rate once; keep nous off plugin routing
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.
2026-10-06 00:52:41 -07:00
Teknium
10f5530967 fix(tts): plugin PCM streamers keep the sync dispatcher's voice and routing
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
2026-10-06 00:52:41 -07:00
kshitijk4poor
b659f9a1d1 docs(models): the answer_in_reasoning opt-in is re-read on the active route
The provider-level `capabilities:` block is no longer handed to the agent
by the gateway; the promotion gate re-reads it on the live route in CLI,
TUI and gateway alike. Say so, and that it survives /model on the same
provider but is not inherited by fallback or other providers (OpenRouter
never trusted) (fold2 finding 1).
2026-10-06 13:01:39 +05:30
kshitijk4poor
cde277aa99 docs(models): document the answer_in_reasoning custom-provider opt-in
Reasoning-only clean stops are now promoted to the reply only on trusted
routes, so a local parser that puts the answer in `reasoning` needs an
explicit opt-in. Document the per-model key and the provider-level
`capabilities:` block next to prompt_caching, plus the route scoping
(no fallback inheritance, OpenRouter never trusted). Finding from the 2c review.
2026-10-06 13:01:39 +05:30
John Paul Soliva
fbe93e7112 docs(voice): voice-channel access follows DISCORD_ALLOWED_ROLES too
(cherry picked from commit 7976b4aae1826a4e234f3f2a7cf796cb5b7ae98e)
2026-10-05 20:48:50 -04:00
Teknium
63f5e0ea45 feat(computer-use): pluggable single-select computer-use providers
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.
2026-10-05 17:20:30 -07:00
Teknium
7b2ff7a7d4 feat(plugins): inject_message(origin=...) starts a gateway session in the plugin's own profile
`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
2026-10-05 17:11:30 -07:00
unsupportedpastels
932210ec2a fix(discord): a /voice join to another text channel drops speech the receiver still holds for the old one
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.
2026-10-05 20:06:54 -04:00
John Paul Soliva
fb49ef18f5 fix(discord): capture the voice binding when a poll batch is collected
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)
2026-10-05 20:06:54 -04:00
John Paul Soliva
118a008f9b fix(discord): an utterance transcribed across a /voice join rebind is not posted to the new channel
_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)
2026-10-05 20:06:54 -04:00
Teknium
03f545ebcf fix(memory): catalog memory-provider installs stop failing on every start and every Install click
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.
2026-10-05 16:06:32 -07:00
Teknium
1e866e34bf fix(aux): the first-token window honours providers.<id>.stale_timeout_seconds
The pre-first-token window used the env stale base, so an explicit per-provider
stale timeout was ignored (900 configured, window capped at 180). It now goes
through the main loop's _cloud_stale_timeout_for with the attempt's routed
provider, so the explicit setting wins exactly as it does for the main loop.
2026-10-05 16:00:18 -07:00
Teknium
636b9c608c fix(aux): the chat-stream watchdog cuts only its own unfinished stream
Review fixes for the #100501 chat-stream no-progress watchdog:

- The no-progress window is inter-chunk. Before the first token a remote
  endpoint gets the main loop's cloud stale patience (_cloud_stale_timeout:
  context-scaled, reasoning-model floor), capped at the request timeout, so
  o3 / deepseek-r1 / a MoA reference slot thinking silently is not cut at 60s.
- The watchdog pins its attempt's socket at construction and fences the
  response body's release under its lock; the timer decides and shuts down
  under the same lock with a generation token. A timer that already woke can
  no longer reset a later request that reused the keepalive connection.
- The default relay callback (recovery rungs, credential retries) passes the
  call's task, so auxiliary.<task>.no_progress_timeout survives the retry.
- A stall after the terminal chunk (finish_reason / usage received) keeps the
  completed response and its billed usage instead of raising into fallback.
2026-10-05 16:00:18 -07:00
Teknium
1259068415 fix(compression): fail a silent chat-completions summary stream at the no-progress window
A streamed auxiliary call over chat-completions (every OpenAI-compatible
compression route: custom/vLLM/proxy, OpenRouter, ...) blocked inside the SDK
chunk iterator until the httpx read timeout (the full auxiliary budget, >= 300s
for compression). The host compression wait gave up first, so the attempt
ended as stall_interrupted / commit_fence_cancelled with no fallback. Only the
Codex Responses wire had a no-progress watchdog.

Arm the same 60s substantive-progress window (auxiliary.<task>.no_progress_timeout)
on the chat stream: a Timer shutdown()s only this attempt's socket (FD-safe
from a stranger thread, #70773), the owner closes the stream in its finally and
raises a "stalled ... timed out" TimeoutError, so the existing retry/fallback
ladder runs. Local endpoints keep silent prefill on the request timeout. The
fallback warning names no_progress_timeout instead of the request timeout.

Fixes #100501

Co-authored-by: fangliquanflq <fangliquan@qq.com>
2026-10-05 16:00:18 -07:00
Teknium
59224a20f0 fix(plugins): only a real memory-provider tree selects memory.provider on install
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.
2026-10-05 15:38:19 -07:00
Teknium
dc81e17f0a docs(memory): memory providers activate via memory.provider, not plugins enable
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.
2026-10-05 15:38:19 -07:00
Teknium
8079fd01f4 fix(memory): stop false drift refusals; refused writes name the exact fix
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.
2026-10-05 15:36:45 -07:00
Brooklyn Nicholson
4f91c5a462 fix(stt): opt-in 16 kHz mono normalization for command STT providers
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>
2026-10-05 16:40:15 -05:00
unsupportedpastels
c83fe1b2c4 docs(dashboard): name what an untrusted proxy means for login limits 2026-10-05 12:23:21 -04:00
kshitijk4poor
adc55b6a71 docs(gateway): cron_drain_timeout floor also covers api_server runs
Gate finding B01 #1 (2c + quality): since api_server (/v1) runs now ride
the cron drain floor, the docstrings and comments for
parse_cron_drain_timeout, _load_cron_drain_timeout,
_get_cron_drain_timeout, the DEFAULT_GATEWAY_CRON_DRAIN_TIMEOUT comment,
cli-config.yaml.example and the messaging guide still called it a
cron-only floor. Say it covers /v1 runs too, that 0 opts them out as
well, and why: an interrupted /v1 run fails its waiting caller.
2026-10-05 19:39:09 +05:30
Teknium
be5481690d feat(gateway): reshape post_gateway_admission into a fail-open consume hook
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.
2026-10-05 01:26:23 -07:00
JerryLiu369
b63305f379 feat(gateway): profile-scoped post-admission consuming gateway plugin extension (#129958)
Closes #129958
2026-10-05 01:26:23 -07:00
Teknium
c958e1a7b9 feat(plugins): on_human_input_request / on_human_input_resolved hooks
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
2026-10-05 01:15:16 -07:00
liuhao1024
0df1837e81 fix(approval): fire observer hooks for the two hookless classic-CLI prompts
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
2026-10-05 00:23:41 -07:00
teknium1
639eee3058 docs(plugin-catalog): README and docs pages describe the same delisting, removal and entry fields 2026-10-04 04:35:58 -07:00
kshitijk4poor
4b4362520b docs(email): authserv_id is required for authenticated-sender mode
The guide still told users to "leave authserv_id unset" when their
server's host varies, which now drops every message that needs an
authenticated From:. State that the pin is required, how to find it
(the token before the first ';' of the topmost Authentication-Results
header your server writes), what to do when it seems to vary (RFC 8601
authserv-id names the administrative domain, so configure a stable one
and pin it), and that the only alternative is the explicit
require_authenticated_sender: false / EMAIL_TRUST_FROM_HEADER=true
opt-out. Mirror the reference tables, plugin.yaml prompt and zh-Hans
translation.

Co-authored-by: JiuYue0820 <287222957+FirmaSpring@users.noreply.github.com>
2026-10-04 14:06:56 +05:30
MoonsvnLyn
4f39bd4f35 fix(email): require exact authserv-id before trusting results
Trust only the topmost Authentication-Results field from the explicitly configured receiving MTA. Fail closed for allowlisted senders when no exact pin is configured, preventing injected or lower matching headers from authorizing a sender.

(cherry picked from commit e013fda3facc005e8f4d5f81e20c2d21aee698df)
2026-10-04 14:06:56 +05:30
kshitijk4poor
404b1fec55 fix(profiles): keep .docker/.azure/.config/gh/.config/gcloud out of profile exports
#132201 stopped profile export shipping .ssh/.aws/.gnupg/.kube/.envrc, but
the other credential dirs the file tools already write-deny still shipped:
a skill dir copied from a home carries .docker/config.json (registry auth),
.azure/accessTokens.json, .config/gh/hosts.yml (GitHub token) and
.config/gcloud/ (ADC / credentials.db), and the default-profile export plus
named exports archived them verbatim (binary DBs skip the redact pass).

The home-relative credential dirs now live once in
agent.file_safety.HOME_CREDENTIAL_DIRS; build_write_denied_prefixes and the
profile export (_OS_CREDENTIAL_STORES) both derive from it, so the write
denylist and the export filter cannot drift. Nested stores (.config/gh) are
matched on trailing path components, so only .config/gh and .config/gcloud
are dropped and the rest of .config still ships. .config/gcloud is included
for parity with file_safety. Distribution installs treat the same paths as
installer-owned via PROFILE_CREDENTIAL_PATHS.

Co-authored-by: Keiran Haax <196126709+keiranhaax@users.noreply.github.com>
2026-10-04 13:53:34 +05:30
teknium1
937f23db2d fix(discord): invite link and docs drop the unused Use External Emojis permission
The wizard's invite integer and the Discord docs' Recommended row carried
Use External Emojis (1<<18), a permission the adapter never exercises (it
only reacts with unicode emoji) and that no permission list in the docs
names. The voice-mode page and the zh-Hans Discord page already used the
correct integers, so the pages disagreed with each other.

- onboarding.INVITE_PERMISSION_BITS: drop Use External Emojis
  (wizard invite 309241171008 -> 309240908864, matching voice-mode.md)
- discord.md: Recommended 309238025280 -> 309237763136; the Minimal row
  names Embed Links (117760 already includes it)
- .env.example and the zh-Hans table follow

Co-authored-by: Emil <etoegersen@gmail.com>
2026-10-04 00:09:15 -07:00
teknium1
c26b88957c fix: an offline machine pays the Home Assistant plugin fetch once an hour, not every start
While the plugin was missing and the catalog or GitHub unreachable, every `hermes chat` process
paid a live-catalog fetch (+5.9 s on a blackholed network) and printed the raw multi-line git
stderr of the failed clone.

A failed automatic install (catalog miss, failed install) touches
<home>/cache/left-core-<plugin>.failed; agent and gateway start skip the row, before any network,
while that stamp is under an hour old. `hermes update` ignores the stamp and always retries. The
failure notice is one line: git's own fatal:/error: line when the error carries raw git output,
else its first line.

The catalog module already serves a fresh cache (6 h) and falls back to its stale/in-tree copy on
failure; pre-resolving offline would not save the round trip, because the install resolves the
entry through the live catalog again, so the gate sits before both.
2026-10-04 00:08:20 -07:00
teknium1
e641b71913 fix: a removed Home Assistant plugin stays removed after update and restart
The left-core migration's only gate was "plugin dir missing + Home Assistant in use", so after
`hermes plugins remove homeassistant` every agent start, gateway start and `hermes update`
reinstalled and re-enabled it (~7.5 s each).

The automatic install now happens at most once per home and row: `_left_core_installed` in
config.yaml is written (in the same write as the toolset-scope marker when the plugin was already
there, right after a successful install otherwise) and only once the plugin dir exists in that
home. A marked row is never installed again; a catalog miss, failed install or lazy-installs-off
start writes nothing, so those homes keep retrying. Profile clones copy plugins/ with config.yaml,
so a clone's marker always matches what its source had.
2026-10-04 00:08:20 -07:00
teknium1
f8a1ca31c6 docs: left-core migration table in plugins/AGENTS.md; HA tools sit behind Tool Search like every plugin tool 2026-10-04 00:08:20 -07:00
teknium1
0f7a95f16d docs: Home Assistant moves to the plugin catalog 2026-10-04 00:08:20 -07:00
teknium1
f785deb261 fix(discord): setup survives rich-text pastes, keeps existing allowed users, and states the intent failure correctly
Review findings on the onboarding wizard:
- A token pasted with curly quotes reached the HTTP header and crashed with
  UnicodeEncodeError; it now goes through the same non-ASCII stripping
  save_env_value applies, before the Discord check.
- Reconfiguring replaced DISCORD_ALLOWED_USERS with the owner alone; the
  wizard now starts from the existing entries and only adds.
- Hermes always requests Message Content, so a disabled intent makes Discord
  refuse the connection (close 4014), not deliver blank messages. Wizard,
  .env template and docs now say so.
The setup test records every write, so a rejected token appearing in any
write fails it, and uses ASCII fixture tokens now that the wizard sanitizes.
2026-10-03 23:40:33 -07:00
teknium1
fedac42bc1 fix(discord): wizard invite link is a superset of the documented Recommended permissions
Recommended includes Use External Emojis; the Full integer the wizard prints
now keeps it and adds Connect/Speak on top.
2026-10-03 23:40:33 -07:00
Jan-Stefan Janetzky
ae01fb8e2e docs(discord): include thread creation permission 2026-10-03 23:40:33 -07:00
teknium1
4c4ad0d387 feat(discord): setup checks the bot token with Discord and prints the invite link
Discord was the hardest gateway to set up: the .env template had no Discord
section at all, the wizard saved whatever was pasted, and users had to find
the Message Content Intent toggle, build an OAuth2 invite URL by hand and turn
on Developer Mode just to learn their own user ID.

The wizard now reads GET /applications/@me with the pasted token: a rejected
token is re-prompted instead of saved, an intent that is off gets a direct
link to the toggle and a re-check, the invite link is printed with the
permissions the adapter uses, and the allowlist defaults to the application's
owner (or accepted team members). Offline, the token is still saved with the
manual intent reminder. The wizard moves from the 7k-line adapter into
plugins/platforms/discord/onboarding.py.
2026-10-03 23:40:33 -07:00
kshitijk4poor
e4bab4f731 docs(skills): regenerate skill pages for the braced role-tag placeholders
website/scripts/generate-skill-docs.py output for the four skills whose
SKILL.md changed in this stack; docs-site-checks fails on generator drift.

Refs #132504
2026-10-04 11:56:08 +05:30
teknium1
64ad33e32d refactor: remove Skill Sync (hermes sync) and the org skill mirror from core
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.
2026-10-03 23:05:48 -07:00
Jeffrey Quesnelle
ea81748579 Merge pull request #128768 from NousResearch/ethie/llamacpp-0.5.0-linux-cuda-main
feat(local-models): llama.cpp b11146 with Linux CUDA, bundled libgomp, CLI setup, server lifetime fixes
2026-10-03 23:49:58 -04:00
unsupportedpastels
20bd00439c fix(whatsapp): body-read timeouts, Windows and failed /health keep ownership
Gaps in the unanswered-/health handling, found in a second review:

- _probe_bridge_health swallowed a timeout while reading the JSON body
  and reported an unparseable 200, so a bridge that sent headers but
  stalled on the body still reached _kill_port_process. Timeouts now
  propagate from either phase.
- The guard listed listeners with the POSIX-only lsof/ss scan, which
  finds nothing on Windows, so Windows fell through to the netstat-based
  kill. Both now use _port_listener_pids(); if listing fails, nothing
  is killed.
- _standalone_send treated a non-200 /health as an older bridge and
  sent anyway. It now sends nothing.
- Docs named a nonexistent `hermes whatsapp send`; the command is
  `hermes send --to whatsapp:<chat_id>`.

Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
2026-10-03 23:43:03 -04:00
unsupportedpastels
e1cc967c29 fix(whatsapp): unanswered /health and standalone sends respect session ownership
Two gaps left by the session check, both found in review:

- connect(): a port holder that does not answer /health in time fell
  through to _kill_port_process, so another profile's busy bridge was
  still killed. No answer is no ownership evidence: after this profile's
  own bridge is reaped by its pidfile, a remaining listener is left
  running and connect() fails with the retryable
  whatsapp_bridge_unresponsive.
- _standalone_send (hermes whatsapp send, cron delivery) posted to the
  port without asking whose bridge it was, so a profile without its own
  bridge sent from another profile's WhatsApp account. It now reads
  /health before every send and refuses one whose session is another
  profile's. A bridge without the field (older bridge.js) is still used.

The session dir is resolved by one helper for the adapter and standalone
sends, so both compare the same absolute string.

Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
2026-10-03 23:43:03 -04:00
unsupportedpastels
029574fa01 fix(whatsapp): fold the session check into main's multiplex connect path
Follow-up to the cherry-picked session handshake, rebased onto the
multiplex bridge ownership that landed in #132518:

- connect(): the foreign-session fatal sits ahead of main's
  secondary-aware pidfile/port reaping, so neither path signals a bridge
  that serves another profile's session.
- Drop the separate "no session -> restart" branch. A bridge without the
  field runs an older bridge.js, so its scriptHash already marks it
  stale and it restarts through the existing path.
- Inline sessionIdentity() as path.resolve() in /health and drop its
  node test, which only exercised path.resolve and no CI lane runs.
- Collapse the four adapter cells into one parametrized invariant (own
  session adopted; another session never adopted or killed, connected or
  disconnected). The read-receipt test now reports its own session, so
  it still restarts for the read-receipt reason rather than a missing
  field.
- Docs: fold the separate-gateway guidance into the existing "Multiple
  profiles" section instead of a second multi-profile section.

Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
2026-10-03 23:43:03 -04:00
Victor Kyriazakos
ff32607c6f fix(whatsapp): adopt only the bridge that serves this profile's session
Two profiles on one host default to the same bridge_port (3000) but have
profile-scoped session paths. _reuse_running_bridge adopted any connected
bridge whose scriptHash and read-receipt config matched, so profile B
adopted profile A's bridge and served A's session; on any mismatch,
connect() fell through to the pidfile/port kill path and killed A's
running bridge.

Contract, keyed on a new /health `session` field (normalized absolute
session dir via bridge_helpers.js sessionIdentity):

1. session present and EQUAL to the adapter's own normalized session
   path -> today's logic unchanged (adopt on hash + read-receipt match,
   else stale/restart).
2. session present and DIFFERENT -> FOREIGN bridge: not adopted, not
   killed. connect() sets a fatal error naming the port, the foreign
   session path, and the fix (platforms.whatsapp.extra.bridge_port, one
   distinct port per profile).
3. session ABSENT (bridge predates the handshake) -> treated as stale
   and restarted, exactly like an absent scriptHash today.

Normalization uses Node path.resolve / Python os.path.abspath - absolute
and `..`-normalized WITHOUT symlink resolution. fs.realpathSync /
Path.resolve() would resolve symlinks asymmetrically (macOS
/tmp -> /private/tmp) and produce false foreign verdicts.

(cherry picked from commit 7e8418678e91fa4bc69d4b4fa6faedd66f2a80e5)
2026-10-03 23:43:03 -04:00
emozilla
5059f20e1b Merge remote-tracking branch 'origin/main' into ethie/llamacpp-0.5.0-linux-cuda-main
# Conflicts:
#	hermes_cli/local_runtime/bootstrap.py
#	hermes_cli/main.py
2026-10-03 22:53:47 -04:00
emozilla
400cd69261 feat(cli): local models in hermes model
`hermes model` (and `hermes setup`) gain a Local models row whenever PM
pins an engine for this machine. The flow drives the handlers behind the
desktop's Local Models pane in-process, so both surfaces run one code
path: the catalog priced for this machine (★ = recommendation), models
already on disk, quickstart (pinned engine, model download, server start,
default assignment) and activate. Progress renders from the job view the
desktop polls; Ctrl+C pauses a download and choosing the model again
resumes from the kept bytes.

Verified: a catalog download paused at 3% and resumed to completion,
activation of an on-disk model, and `hermes -z` / interactive chat against
the result.
2026-10-03 22:32:13 -04:00
emozilla
361963828d feat(pm): llama.cpp CUDA on Linux arm64; bundle libgomp for Linux engines
b11146 ships ubuntu-cuda-13.4-arm64 (+ cudart), so llamacpp-cuda gains
linux-arm64, auto resolves NVIDIA arm64 Linux to CUDA, and the desktop's
localModels flag ships on Linux arm64 too.

Every upstream Linux llama.cpp build (CPU included) links the system
libgomp, which minimal hosts (WSL, containers) lack, and a normal install
never touches the system package manager. Each Linux engine now pins
Ubuntu 24.04's libgomp1 .deb (glibc 2.38, the floor upstream already sets)
and stages libgomp.so.1 beside llama-server, where the $ORIGIN RUNPATH
finds it; the artifact mirror covers the pool URL after 24.04's EOL.
DebPackage's ar+tar unpack becomes pm.package.unpack_deb and reads zstd
data.tar (Ubuntu's default) with the stdlib codec PM's 3.14 runtime has.

Pre-PM engines on Linux carry only llama.cpp's own archives, so adoption
maps their manifests to those and records them as outdated (usable now,
update offered) instead of failing to match.

Verified on an RTX Spark N1X (WSL, Ubuntu 26.04, no system libgomp):
ensure_engine() for cuda, cpu and vulkan through PM's real download/stage/
verify path; ldd resolves libgomp from the entry; llama-server serves
completions (CUDA0 at 445 tok/s). linux-x64 cpu/vulkan staged through the
same path with no NEEDED library missing beyond glibc/libstdc++/OpenSSL 3.
2026-10-03 22:32:13 -04:00
Lester Liang
c225c4a04e fix(cli): finalize one-shot Relay sessions (#79471)
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>
2026-10-03 19:01:42 -07:00
unsupportedpastels
d385b01469 fix(whatsapp): adopt only the recorded bridge's own port; one port resolution for launch and send
A secondary treated a bound port as its own whenever its pidfile named a
live bridge, without checking that bridge listened on that port. After a
crash left the old bridge alive and bridge_port was pointed at a port
another profile's bridge held, the secondary probed and adopted the
foreign bridge (script hash and read-receipt config do not distinguish
phones). bridge.pid now records the spawn port on line 3; ownership
requires pid, start time and port to match. Pidfiles without a port fail
closed for secondaries; the default path reads lines 1-2 as before.

A profile that recorded a port as a secondary and later launched its own
gateway started its bridge on 3000 while `whatsapp send` read the record.
The launch adapter now resolves its port like standalone send (explicit,
then record, then 3000), so profiles without a record are unchanged.

gateway/AGENTS.md no longer lists the WhatsApp bridge as default-only
shared ingress.

Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
2026-10-03 21:57:13 -04:00