Commit Graph

2798 Commits

Author SHA1 Message Date
Teknium
22bc7eac71 refactor(solstice): rebuild the provider as a standard model-provider plugin
The whole provider now lives in plugins/model-providers/solstice/ on the
generic seams:
- auth.py: NAS config discovery plus the brokered exchange/refresh, given
  to the generic PKCE flow as token_request. A refresh that NAS rejects
  (it folds every Google failure into 502) is treated as a dead grant
  needing re-login.
- transport.py: a GeminiNativeClient subclass on the
  generateContentPerUserQuota methods with Bearer auth (~30 lines, where
  the PR duplicated a 257-line adapter).
- __init__.py: a hidden profile (create_client, fallback models,
  default_aux_model).

Removed from core: hermes_cli/solstice.py, agent/solstice_adapter.py, and
the provider's branches in main.py, auth_commands.py, models.py,
runtime_provider.py, model_setup_flows.py and agent_runtime_helpers.py.
The unused SOLSTICE_CLIENT_ID env read is gone too.
2026-10-05 23:33:41 -07:00
Ben Barclay
8e015f483f feat(providers): add Solstice as a secret-launch OAuth inference provider
Per-user-quota OAuth inference for Hermes Agent, hidden by default until
public launch is cleared. Auth (OAuth2 + PKCE with the user's account) is
brokered by nous-account-service, which holds the confidential client secret;
inference goes direct to the per-user-quota endpoint.

Built fresh on current main; commit history carries only the codename.

Provider surface:
- plugins/model-providers/solstice/ — profile + hidden enable gate
- hermes_cli/solstice.py — PKCE loopback login, NAS-brokered config/exchange/
  refresh, token store, quarantine; callback path unchanged
- agent/solstice_adapter.py — per-user-quota transport over the native
  adapter's OpenAI-compatible facade
- providers/: _HIDDEN_GATES + register_hidden_provider_gate + a hidden filter
  in list_providers (kept out of the picker/registry/doctor until enabled)
- wired into main.py (_PROVIDER_MODEL_FLOWS), auth_commands.py (_PROVIDER_ALIASES,
  _OAUTH_ADD_SPECS, _is_known_provider allowlist), models.py (catalog fetcher),
  runtime_provider.py (resolution rung), model_setup_flows.py, agent_runtime_helpers.py

Kept verbatim (the live wire contract, until the broker is renamed): the
inference host, the OAuth scope, the deployed broker routes, and the
registered redirect callback path.

The enable gate is unchanged (open item: gate on stored credentials).
Verified: 226 focused tests pass; runtime resolution, client construction
and auth-add all exercise the real paths.
2026-10-05 23:33:41 -07:00
kshitijk4poor
7748c7f484 fix(gateway): do not hand an installed reconnect adapter's hold queue back to its predecessor
Gate finding C01.r1.2c [High]: the hand-back added for failed candidates
also ran in the post-install exception/cancel branches. After a successful
secondary install (or a primary publish) the predecessor already forwards
to the new adapter; if the task was cancelled during redelivery (gateway
teardown cancels secondary reconnect tasks) the hand-back linked the new
adapter back to the predecessor, and any hold - e.g. the disconnect()
teardown salvage - ping-ponged between the two until RecursionError.

Break the cycle at both ends: adopt_held_inbound clears the adopter's own
successor link (it owns the queue again), and the runner skips the
hand-back when the adapter is the one installed in its slot. Covered by a
cancel-after-install row on the secondary hand-back test.
2026-10-06 11:24:04 +05:30
unsupportedpastels
a036b13793 fix(discord): a programmatic voice join binds the requested text channel on every path
join_voice_channel(channel, text_channel_id=..., source=...) is the programmatic join API, and it
kept the requested binding only on a cold join: the already-connected paths (same voice channel, or a
move) returned before writing it, so a successful join to another text channel kept transcribing
into the old one. All three paths now apply the binding through one helper. Moving to another text
channel drops speech still buffered for the old one, as /voice join does, and the old channel's bound
source unless a new one is given: its chat would keep routing turns into the old conversation.

With no bound source, a voice turn built a synthetic one with chat_type "channel". A typed message in
a guild text channel is "group" (or "thread" under its parent), so the voice turn keyed another
session. It now goes through build_source as a typed message does, with the guild, thread and parent.

/voice join, the only in-repo caller, already writes its binding itself after joining, so this is
latent until something calls join_voice_channel with a text channel (the open auto-join PRs would).

Reported by @andrexibiza as F7 in #132474.
2026-10-06 00:08:57 -04:00
kshitijk4poor
c3f3589efb refactor(discord): move _component_check_auth into adapter_component_auth to keep adapter.py under its size cap
Code-health ratchet: plugins/platforms/discord/adapter.py was 7389 lines
against a cap of 7370 after this stack added the live-auth confirmation to
the component click check. Move the shared component authorization helper
(byte-identical body) into a topical sibling; the view's _check_auth
late-imports it, and the direct-helper tests import it from its new home.
2026-10-06 09:37:09 +05:30
kshitijk4poor
d29d0d07aa fix(discord): confirm a snapshot wildcard with the live check too
A '*' in the connect-time DISCORD_ALLOWED_USERS snapshot still
short-circuited component auth, so removing the wildcard from .env after
connect left every button open until a restart. The gateway's live check
already honours '*' itself, so route the wildcard through it like a
listed ID. Also drop the redundant _authorization_check guard
(_is_sender_authorized already returns None when no check is wired) and
trim the docstring.
2026-10-06 09:37:09 +05:30
pierrenode
502df01d54 fix(discord): buttons stop accepting a user revoked after connect
Component views authorized clicks against the adapter's connect-time
_allowed_user_ids, so a user removed by `hermes pairing revoke` (CLI or
dashboard, another process) or a .env edit kept approving exec commands
until a gateway restart. A user found only in that snapshot is now
confirmed with the gateway's per-call authorization check, as Matrix
approval reactions are since #130920; role, allow-all and pairing grants
are unchanged, and an unwired adapter keeps the snapshot.

(cherry picked from commit a6ced15a6000247b9736cede3cbf4c6ed0c11265)
2026-10-06 09:37:09 +05:30
unsupportedpastels
56f7986418 refactor(discord): move the slash gate verdict to a sibling mixin; adapt the thread-starter test
_evaluate_slash_authorization moves unchanged into adapter_slash_auth.py beside the new _slash_role_grant, so adapter.py stays within its code-health file cap. The thread-starter test passes a thread, as _dispatch_thread_session now takes one, and the voice role-grant test gains type hints.
2026-10-05 20:48:50 -04:00
John Paul Soliva
39f0da8b2f fix(discord): a native slash or /thread starter stamps the role grant for its own actor only
The salvaged #125620 commits stamp role_authorized on _build_slash_event
and _dispatch_thread_session from bool(_allowed_role_ids), which is only
right because today's two callers run _check_slash_authorization first.
Neither builder checks its actor itself, so any later caller that builds
an event before (or without) the gate would mint a grant for a member the
gate refuses.

The grant is now re-evaluated from the interaction in both builders
(_slash_role_grant): a role allowlist exists AND this actor passes the
slash gate, the meaning _discord_message_admission gives the flag. A
role-only member still reaches the agent, and an unrelated-role actor's
event is refused by the gateway even when built directly. The two
salvaged tests fold into one parametrized invariant over both builders
with the refusal row.

Recomputing the verdict from the actor follows #118966 by KoNit-K.
Reported by andrexibiza (F4, native half) on #128788 and #130303.

Co-authored-by: KoNit-K <konit.block@protonmail.com>
(cherry picked from commit 357e77d09263d25d7779d42f0eda3d352b3032a9)
2026-10-05 20:48:50 -04:00
Christopher
c314053823 docs(discord): state what the slash role flag means
Review feedback: the comment read as if the stamped role_authorized
value were a per-user role match. It is the same adapter-level verdict
the message path stamps: a role allowlist exists and the user passed
the slash authorization gate.

(cherry picked from commit e4165fc2ed7f65b9356ac75f90cab02aec85d70c)
2026-10-05 20:48:50 -04:00
Christopher
f5ef17a321 fix(discord): carry the role verdict into slash command events
Fixes #118958

(cherry picked from commit f5f538ef4b86a1d179e76292894372cfece8d2f3)
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
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
unsupportedpastels
d5d234b377 refactor(discord): move voice-channel info helpers to a sibling mixin; annotate the stale-binding test
get_voice_channel_info and get_voice_channel_context move unchanged into adapter_voice_info.py, and the new comments and docstring are folded, so adapter.py stays within its code-health file cap. Add type hints to the new test.
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
3a6d709921 fix(plugins): agent builds wait out a slow provider import; only per-turn callers are bounded
The 10 s concurrent-load bound also hit slow but healthy first imports. A
second agent built while a provider's import took longer than 10 s got no
memory provider for its whole session, and with checkpoint_required every
compaction then failed closed.

The bound now applies only inside plugin_loader.bounded_load_wait(), which
the per-turn gateway memory identity signature uses. Other callers (agent
builds) wait for the in-flight load. Cycle detection still breaks a
cross-thread deadlock for every caller.
2026-10-05 15:55:58 -07:00
Teknium
1f11e7fa23 fix(plugins): bound and cycle-check the concurrent plugin load wait
Review fixes for the per-module plugin load lock:

- A second caller waits at most 10 s for another thread's load of the same
  module, then refuses with a named warning and returns None. While that
  import is still stuck, later callers refuse at once. A hung provider import
  no longer blocks every gateway turn, and there is no inline fallback load.
- The wait polls a wait-for graph spanning the loader locks and importlib's
  own module locks. When thread A holds a helper's import lock and waits on
  the plugin while thread B loads the plugin and imports that helper, the
  cycle is detected and the waiter takes the partial module, the same way
  importlib handles a concurrent circular import. Before, both threads hung.
- The lock is still a same-thread RLock with no worker thread, so recursive
  loads and ContextVars are unchanged.
- conversation_compression.py: compact the fork-checkpoint exemption so the
  file stays under its line cap after the rebase.
2026-10-05 15:55:58 -07:00
Yuan Li
decabd79f1 fix(agent): serialize plugin loads and skip fork checkpoints 2026-10-05 15:55:58 -07:00
Teknium
7c46cd9ff7 fix(plugins): decide "memory provider" on install from code, not a text match
cmd_install chose the memory.provider path with _is_memory_provider_dir, a
substring search for "MemoryProvider" in __init__.py. A hook plugin whose
docstring says "Not a MemoryProvider" passed, and --enable replaced the
user's memory.provider with a name load_memory_provider returns None for
(exit 0). _defines_memory_provider parses the package's modules (no import)
for what _load_provider_from_dir can load: a register_memory_provider(...)
call or a MemoryProvider subclass.
2026-10-05 15:38:19 -07:00
Teknium
c93c632c93 fix(whatsapp): keep transient and restart-needed aiohttp installs retryable
ensure_aiohttp flattened every PM failure into the non-retryable
whatsapp_aiohttp_missing. That covered PM's post-install
"sms installed; restart Hermes to activate it" (sync succeeded, process
could not adopt the generation), a busy dependency lock and a failed
download. With WhatsApp as the only platform the gateway exited 78 and
systemd's RestartPreventExitStatus=78 kept it down.

ensure_aiohttp now returns (why, retryable). Final only when PM can never
install here (lazy installs disabled, or a foreign interpreter not on the
install's selected or older generation), when aiohttp is present but
broken, or when it is still absent after a successful install. A plain
shadowing aiohttp.py (no __path__) is reported as an incomplete install
instead of escaping as AttributeError.
2026-10-05 15:35:42 -07:00
Teknium
2dda207409 fix(whatsapp): install a missing aiohttp through PM and name every failed connect
A gateway whose dependency environment lacks aiohttp (a sealed env from
`hermes update` with a partial messaging extra, #126358) spawned the bridge,
then every /health poll raised ModuleNotFoundError inside
`_poll_bridge_health`'s blanket except. connect() returned False with no
fatal code, so the reconnect watcher retried forever while the bridge
answered /health 200, and telemetry counted each attempt as error_class
"other".

- connect() now obtains aiohttp through PM (`ensure_import("sms")`, the
  extra that is exactly aiohttp) before starting the bridge, the same
  lazy-install path Slack/Teams use. When PM cannot install it (lazy
  installs disabled, foreign interpreter, install failure) or aiohttp is a
  half-removed namespace shell (#71308), it fails with the named,
  non-retryable `whatsapp_aiohttp_missing` and never spawns the bridge.
- The bridge-died / "did not start in 15s" paths set a retryable fatal code
  (`whatsapp_bridge_exited` / `whatsapp_bridge_timeout`), so reconnect
  status and connect telemetry name the cause instead of "other".
- Folds the salvaged test file into test_whatsapp_connect.py as two
  invariant tests.
2026-10-05 15:35:42 -07:00
liuhao1024
f5e21913cc fix(whatsapp): name a missing aiohttp in the bridge preflight (salvage of #126557)
A sealed env built by `hermes update` can ship a partial messaging extra
without aiohttp. check_whatsapp_requirements only verified Node.js, so
connect() spawned the bridge and every health poll raised
ModuleNotFoundError — swallowed by `except Exception: continue` — and
the gateway looped forever on the misleading "Bridge HTTP server did not
start in 15s" (#126358).

Add _aiohttp_available() and wire it into _preflight() as a named,
non-retryable whatsapp_aiohttp_missing fatal error with install guidance,
so the failure surfaces before any bridge process is spawned.

Salvaged from #126557 (Finn763), whose lazy-install route through
tools/lazy_deps no longer exists on main (the module is now updater
shims); this keeps the fail-fast core and adapts it to the current
_preflight() structure.

Co-authored-by: Finn763 <165816600+Finn763@users.noreply.github.com>
2026-10-05 15:35:42 -07:00
Adolanium
8eeb6ec4c6 fix(zai): clamp GLM-5.3 thinking-off to enabled plus low
GLM-5.3 rejects thinking.type=disabled with HTTP 400 code 1210
(issue #85890, #96373). An explicit off preference now sends
enabled plus reasoning_effort=low. 4.5/4.6/5.2 still send disabled.
2026-10-05 16:43:54 -05:00
SmokeDev
0e73d1d3b6 fix(discord): track live voice credentials across re-keys instead of snapshotting at start()
VoiceReceiver.start() captured conn.secret_key (by value) and
conn.dave_session (by reference) exactly once. Both go stale in normal
operation:

- op 4 SESSION_DESCRIPTION rotates the transport secret_key on every
  voice (re)connect — the snapshot then fails NaCl decrypt forever.
- voice_state.reinit_dave_session() REPLACES conn.dave_session with a
  NEW DaveSession when none existed yet, so a receiver that starts
  before DAVE finishes negotiating keeps None forever and treats E2EE
  as off — every frame reaches opus as ciphertext for the whole
  session.

The failure is silent by construction: decrypt failures warn for the
first 10 packets then go dark, and shredded opus output raises nothing.
The operator-visible symptom is "the bot ignores me."

Fix — resolve, don't copy:

- Credentials live in ONE tuple (secret_key, dave_session,
  dave_protocol_version, dave_downgraded), re-resolved from the live
  connection and swapped in a single reference store; the receive
  thread reads one consistent generation per packet, so a refresh can
  never pair a new key with an old session.
- Refresh triggers: the voice-ws hook on op 4 and op 24 (the hook runs
  AFTER received_message dispatches the op, so it reads the new
  values); membership changes in the bot's channel (DAVE epoch bump)
  via the existing on_voice_state_update handler; and a
  25-consecutive-NaCl-failure streak as the catch-all, retried every
  streak window while the failure persists.
- Unmapped-SSRC hole closed without recreating it elsewhere: with E2EE
  actively on (protocol_version > 0 and outside a passthrough window),
  frames for an SSRC with no user mapping are dropped (after the
  existing sole-member inference) instead of being fed to opus as
  ciphertext.  A non-null session alone is NOT the predicate — the
  session object survives protocol downgrades to 0 and passthrough
  transitions, where plaintext is legitimate.  Passthrough windows
  mirror discord.py's own set_passthrough_mode calls: op 21
  pending-downgrade opens 120s; op 22 opens a 10s grace ONLY on the
  dave_downgraded True -> False edge (upgrade-after-downgrade), with
  replace-semantics so the short grace supersedes a residual long
  window; a receiver starting mid-pending-downgrade seeds the window
  from conn.dave_pending_transitions.
- Silence made visible: decrypt failures keep emitting one warning per
  250 after the first 10, and stop() logs decode-health counters
  (ok / decrypt_failed / dave_unmapped_dropped).

Tests: tests/gateway/test_discord_voice_rekey.py (24) — credential
resolution/rotation, the late-DAVE-session upgrade, failure-streak
refresh + reset, op 4/24/5 hook behavior, original-hook passthrough,
the DAVE-state drop predicate (downgraded-plaintext decodes, op-21
window decodes, expired window drops, upgrade edge replaces the long
window, same-version and duplicate op 22 open nothing,
mid-pending-downgrade startup seeds), unmapped-drop vs mapped flow,
sole-member rescue, tuple-generation consistency, teardown health
line.  Adjacent voice_command / voice_mixer / send / opus suites green
under the diff (111 passed).

Fixes #77968
2026-10-05 16:42:08 -05:00
unsupportedpastels
c53536929e test(discord): annotate the turn prompt parity test; keep adapter.py within its cap
Add type hints to the parity test and fold two new adapter comments into one line each, so adapter.py stays at its size on main (code-health file cap). The channel-label helper lives beside the thread-name formatter in adapter_thread_titles.py.
2026-10-05 15:15:44 -04:00
John Paul Soliva
04a6eb4835 fix(discord): a spoken turn reads its thread's parent bindings and the channel's current labels
A spoken turn rebuilds its source from the copy `/voice join` stored, and
three of its prompt inputs still differed from a typed message in the same
conversation:

- voice joined from a thread looked up the channel prompt and bound skills
  for the thread alone, while a typed message there falls back to the
  parent channel's entry, so a parent-only binding never reached speech;
- the chat name and topic stayed at their join-time values, so after a
  rename or topic edit every typed/spoken switch re-rendered the pinned
  context prompt and back;
- `guild.get_member()` is cache-only, and on a miss the speaker was named
  by numeric id even when they were the member who ran `/voice join`.

The resolvers now get the stored parent id, the name and topic come from
the bound channel through the same formatters a message uses (the stored
values stay as the fallback when the channel is not cached), and an
uncached joiner keeps the name bound at join; any other uncached speaker
still gets their id, never the joiner's name.

Reported by andrexibiza (F1, F2) and teknium1 (get_member) on #128788.

Co-authored-by: Matt McFarland <mattttmcfarland@gmail.com>
(cherry picked from commit b993d20b240b34a61cf9c54dbd52d9050680f497)
2026-10-05 15:15:44 -04:00
John Paul Soliva
7c3a3bfc06 fix(discord): slash, /thread and voice turns carry a message turn's prompt inputs
Native slash commands (/skill, /plan, /queue), the /thread starter and
voice-channel input are human turns, so each re-pins the session-context
prompt and the channel prompt. They built their event without the fields
on_message sets:

- a DM slash turn had no chat name, so the "origin" line rendered the
  channel id instead of the user's name;
- a thread slash turn and the /thread starter dropped the parent channel
  from the thread label, and the starter took the parent channel's topic;
- a spoken turn named the speaker by numeric id, not display name;
- none carried the channel's bound skill, so a session opened by one of
  them never loaded it.

Each switch between one of these turns and a typed message re-rendered
the pinned context prompt and back again, a prompt-cache miss each way.
They now use the same formatters (_format_thread_chat_name,
_get_effective_topic on the thread, the member display name) and
_resolve_channel_skills as the message path.

(cherry picked from commit c2727ba500f22249dd729aba6365af3bb3c5e970)
2026-10-05 15:15:44 -04:00
unsupportedpastels
455e229ef9 test(discord): annotate the auto-thread topic parity test and keep it to the topic
Add type hints, drop the channel-prompt and skill-binding assertions (they guard behaviour the fix does not change), and fold the new adapter comment into one line so adapter.py stays within its code-health cap.
2026-10-05 14:05:04 -04:00
John Paul Soliva
d63cdd32a4 fix(discord): an auto-threaded mention reads the new thread's topic
The first turn of an auto-threaded @mention already points its source at the new thread, but it read chat_topic from the text channel the mention was posted in. The thread's next message reads the thread's own topic (none, outside forums), so the pinned session-context prompt was re-rendered on turn 2 of every auto-threaded conversation in a channel with a topic: a prompt-cache miss, and a Channel Topic line the model saw once and then lost.

(cherry picked from commit d9fedccacd120ca1833269ed0608c3c89c4c1015)
2026-10-05 14:05:04 -04:00
unsupportedpastels
ea32af9331 fix(discord): retire the auto-thread title mask from raw rename events
The mask record was installed only after the REST edit returned and was
retired from the cached on_thread_update callback. Gateway updates can land
while the edit is in flight, and discord.py mutates the cached thread before
scheduling callbacks, so batched updates all report the final name. Either
way a moderator's restore of Hermes's title could render the opening name.

Record the guarded attempt before awaiting the edit, advance or retire it
from on_raw_thread_update, and on failure or cancellation drop only the
record that attempt still owns. The record store and _format_thread_chat_name
move into adapter_thread_titles.py so adapter.py does not grow.

Rename-only part of #132758's f67ef417e0; its message-id, slash/voice and
composed-contract changes are left to their own PRs.

Co-authored-by: Andrex Ibiza, MBA <andrexibiza@gmail.com>
2026-10-05 12:48:38 -04:00
John Paul Soliva
b85e24df3b fix(discord): a thread rename event retires Hermes's auto-thread title mask
(cherry picked from commit 01435400ed3edc81a72ded8b244aec41502bac30)
2026-10-05 12:48:38 -04:00
John Paul Soliva
019ac2a3e8 fix(discord): retire the auto-thread rename mask once someone else renames the thread
The mask from Hermes's own title rename matched on name text only and was never retired, so a moderator who renamed the thread away and later restored Hermes's title saw the prompt show the thread's opening name instead. Once a name other than Hermes's title has been observed the record is dropped; before Hermes's title is first seen, the replaced name is still tolerated because a cache that has not caught up with the edit reports it.

Fixes #131614

(cherry picked from commit 16e94c1648c7c81c5b69a3bc872e0ca08200aa22)
2026-10-05 12:48:38 -04:00
John Paul Soliva
ec991b468b fix(discord): Hermes's own auto-thread title rename keeps the pinned prompt
chat_name keys the pinned session-context prompt, and the title lane
renames a new auto-thread once the LLM title arrives, normally before the
user's second message. Turn 2 read the new name and re-rendered the
already-sent prompt, a prompt-cache miss on every auto-threaded
conversation on the default config.

rename_thread now records the name it replaced and the name it set for a
guarded semantic rename, and _format_thread_chat_name keeps the replaced
name while the thread still carries Hermes's title. A rename by anyone
else no longer matches and re-renders as before.

(cherry picked from commit b57858e031b8af5f6fa3fa8d567907c16fafda2b)
2026-10-05 12:48:38 -04:00
kshitijk4poor
27855e7586 refactor(telegram): plain MessageEvent annotations in the held-inbound mixin
MessageEvent is imported at runtime in the new module, so the quoted forward references carried over from adapter.py are unnecessary. Final-head gate (quality Low).
2026-10-05 20:04:04 +05:30
kshitijk4poor
d169a2f3a9 refactor(telegram): move the held-inbound queue into telegram_held_inbound to keep adapter.py under its size cap
Code-health ratchet: plugins/platforms/telegram/adapter.py was 7446 lines > its 7422 cap after the
held-inbound hand-over (#132829) added adopt_held_inbound/_adopt_held_event. The whole hold queue
(_schedule_held_inbound_redispatch, _hold_inbound_event, adopt_held_inbound, _adopt_held_event,
_rehold_from, _redispatch_held_inbound) moves verbatim into TelegramHeldInboundMixin; TelegramAdapter
inherits it, so call sites and instance-level patches are unchanged. The sibling logs under the
adapter's logger name so existing log filters still match.
2026-10-05 20:04:04 +05:30
kshitijk4poor
6964477d80 refactor(telegram): drop the unreachable guard in adopt_held_inbound
Gate finding B05-2: adopt_held_inbound is only reached through
hand_over_held_inbound, which skips a None predecessor, and both runner
callers pass a candidate freshly built by _create_adapter for the same
platform as the replaced instance. So predecessor is never self and is
always a TelegramAdapter; the guard was dead code. Also use the file's
own `events = list(held); held.clear()` idiom.
2026-10-05 20:04:04 +05:30
kshitijk4poor
d87a031fe1 fix(telegram): accept the update claim when a retired adapter forwards a hold
Gate finding B05-1: the successor-forward branch of _hold_inbound_event
returned before self._accept_update(). The admission ContextVar is per
adapter instance, so the successor's own _accept_update() in its hold
path reads its own (empty) var and the predecessor's in-flight claim
stays unaccepted. _release_claim then skips _record_receipt, and the
update can be replayed after a restart even though it was handed off.

Accept on the retired adapter before forwarding, and deref the successor
weakref once.
2026-10-05 20:04:04 +05:30
kshitijk4poor
7ef5aca075 fix(gateway): hand Telegram held inbound to the rebuilt adapter
Telegram holds inbound events that PTB has already acked (offset advanced),
so dropping them means losing the message for good. The hold queue lives on
the adapter instance and drains only on that instance's _mark_connected. When
a retryable fatal (network error, polling/ingress stall) makes the runner
build a NEW adapter, that drain never runs: the reconnect path carried the
inbound dedup caches across but not the queue. Held events, plus anything
teardown salvaged onto the retired instance later, were silently stranded.

The reconnect queue entry (primary) and the secondary reconnect task now
keep the replaced adapter as "predecessor". The existing carry_inbound_dedup
seam reads its dedup caches from it before connect. A new
hand_over_held_inbound seam moves its held events at publish time
(_install_reconnected_adapter / secondary profile_map install). A candidate
that fails to connect never owns the queue, so no hand-back is needed, and
delivery waits until the replacement is registered. TelegramAdapter.
adopt_held_inbound rebinds only source._transport_adapter_ref (profile and
role authorization stay as resolved) and leaves a successor weakref on the
predecessor. Late _hold_inbound_event calls there (teardown salvage after the
replacement drained) forward to the replacement.

Reported-by: j2h4u (Maxim Brashenko)
Fixes #132829
2026-10-05 20:04:04 +05:30
kshitijk4poor
2efd6961f7 refactor(matrix): move _CryptoStateStore into adapter_crypto to keep adapter.py under its size cap
Code-health ratchet: plugins/platforms/matrix/adapter.py was 3308 > 3278 and
tests/gateway/test_matrix.py 3310 > 3245 (both over the 2,000-line target, so
they may only shrink vs main).

- Move the existing _CryptoStateStore OlmMachine state-store shim (byte-identical)
  into the new topical sibling plugins/platforms/matrix/adapter_crypto.py; the
  one call site in _connect_setup_e2ee late-imports it. No facade re-export.
- Move this stack's encrypted-drop warning tests (and their _cryptoless_client
  helper) into tests/gateway/test_matrix_encrypted_drop_warning.py.
2026-10-05 19:47:30 +05:30
kshitijk4poor
07030cc2d3 refactor(matrix): read encrypted-drop timeline events like the rest of the sync path
Gate finding B08-2: `((room_data or {}).get("timeline", {}) or {}).get("events", ()) or ()`
guarded against shapes a /sync join entry never has. rooms_join comes from
`sync_data.get("rooms", {}).get("join", {})` (same unguarded style a few lines
up) and mautrix's own handle_sync reads the identical payload as
`room_data.get("timeline", {}).get("events", [])`, so any malformed entry would
already fail there. Match that form; keep the per-event isinstance guard.
2026-10-05 19:47:30 +05:30
kshitijk4poor
1680245d7e fix(matrix): don't give install advice in the encrypted-drop warning when E2EE is on
Gate finding B08-1: when MATRIX_E2EE_MODE is optional/required but the
decryptor failed to set up at connect (degraded mode), the warning still
told the user to pip install the E2EE deps and set MATRIX_E2EE_MODE=optional
-- advice that is wrong (the mode is already on) and hides the real cause.
Keep the install hint only for mode=off; otherwise name the active mode and
point at the earlier Matrix E2EE startup warning. The warn-once test is
parametrized over off/optional to pin both messages.
2026-10-05 19:47:30 +05:30
kshitijk4poor
f30d3c870e fix(matrix): name MATRIX_E2EE_MODE in the encrypted-drop warning
The default MATRIX_E2EE_MODE is off, so installing the E2EE deps alone
never attaches a decryptor; an operator following the old hint would
stay deaf. Point at the mode setting too.
2026-10-05 19:47:30 +05:30
liuhao1024
846e63d9ec fix(matrix): warn when encrypted room events arrive with no E2EE decryptor
Under hermes acp/gateway, a Matrix connection with E2EE off or degraded
(optional mode with missing deps or a failed setup) drops every
m.room.encrypted timeline event silently: mautrix dispatches them to an
empty ROOM_ENCRYPTED handler set, so the room looks connected but deaf —
syncs succeed with no errors and no warnings (#131778).

_absorb_sync now checks each joined room's timeline and logs one warning
per room when encrypted events arrive while client.crypto is unset,
pointing at the E2EE install hint. With a decryptor attached, mautrix's
own DecryptionDispatcher already reports decryption failures.

(cherry picked from commit 8cf08825296b88ab57ed0b83371840bb5fc7c7c0)
2026-10-05 19:47:30 +05:30
kshitijk4poor
8b66a51036 fix(email): warn about a missing authserv_id once per account, even via reconnect
fold2 finding 1 (2c, Medium): the connect-time missing-pin warning was gated on
`not is_reconnect`. An account whose FIRST startup connect fails (transient
IMAP/SMTP error) is brought up by the reconnect watcher with is_reconnect=True,
so it never warned while silently dropping every sender's mail.

Dedupe per address instead: a class-level _missing_pin_warned set (the watcher
builds a fresh adapter per retry, like _seen_uids_snapshot), checked after both
IMAP and SMTP probes succeed. The existing connect test now covers a failed
first connect followed by a reconnect (one warning), a second reconnect (none),
two accounts (one each) and pinned/opted-out accounts (silent).
2026-10-04 14:06:56 +05:30
kshitijk4poor
c495ab684b refactor(email): make the authserv-id pin a required, pre-normalised argument
fold2 finding 4 (quality, Low): _verify_sender_authentication defaulted
authserv_id to "" (a silent fail-closed for any caller that forgot it) and
re-stripped/lowercased a value EmailAdapter.__init__ already normalises. Make it
a required keyword, test it with `if not authserv_id:`, and compare directly.
All callers (the adapter and the test helper) already pass normalised ids by
keyword, so no call site changes.
2026-10-04 14:06:56 +05:30
kshitijk4poor
c1ae640464 refactor(email): share the sender-auth opt-out hint text
fold2 finding 2 (reuse+quality, Low): the per-message no-header drop hint and
the connect-time missing-authserv_id hint each spelled out the same
require_authenticated_sender / EMAIL_TRUST_FROM_HEADER opt-out sentence. Build
both from one _OPT_OUT_HINT so they cannot drift. Rendered text is byte-for-byte
unchanged (printed before/after).
2026-10-04 14:06:56 +05:30
kshitijk4poor
390c49ee78 docs(email): say an absent authserv_id pin fails closed for every sender
fold2 finding 3 (quality, Low): the __init__ comment still said an absent pin
fails closed only "when an allowlist grants access". With sender auth on, an
unset authserv_id rejects every sender's Authentication-Results, and connect()
names the fix once per account. Comment only.
2026-10-04 14:06:56 +05:30
kshitijk4poor
de6013bcef fix(email): warn about a missing authserv_id once per account at connect
The missing-pin warning lived in the per-message drop path behind a
process-wide class bool. That hid it twice over (r1 2ab/2c Medium):
- a second unpinned mailbox in the same process (multiplex) never
  warned once the first had tripped the flag;
- pairing/decline-only deployments never warned at all, because a
  first-contact sender is not granted and its drop logs at debug.

A missing pin is account config, not something one sender did, so
connect() now names the fix once per account after the IMAP/SMTP
probes succeed, skipping reconnect-watcher retries (is_reconnect).
Pinned or opted-out accounts stay quiet. The per-message drop path is
back to main's rule: a listed sender's drop warns (with the reason, no
hint), an unlisted one logs at debug, so it is never noisier than main.
2026-10-04 14:06:56 +05:30
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