Compare commits

...

20 Commits

Author SHA1 Message Date
kshitijk4poor
d8c2c77be6 feat(plugins): add optional-plugins/ discovery + langfuse_tracing as first official optional plugin
Introduces optional-plugins/ — a new category for plugins that ship with
the repo but are NOT auto-discovered. They live alongside the code but only
land in ~/.hermes/plugins/ (and thus get loaded) when the user explicitly
installs them.

Core changes:
- optional-plugins/observability/langfuse-tracing/ — langfuse tracing plugin
  (pre/post LLM + tool hooks, usage/cost normalization, fail-open when SDK
  missing). NOT in plugins/ so zero import overhead on devices that don't
  want it.
- hermes_cli/plugins_cmd.py — official install path: _resolve_official_plugin()
  recognises 'official/<category>/<name>' identifiers and copies from
  optional-plugins/ into ~/.hermes/plugins/ (no git clone, no network).
  _list_official_plugins() enumerates available optional plugins.
  cmd_list(available=True) shows not-yet-installed official plugins.
- hermes_cli/main.py — hermes plugins list --available flag
- hermes_cli/tools_config.py — Langfuse Observability in TOOL_CATEGORIES;
  post_setup handler installs the langfuse SDK and runs cmd_install()
- hermes_cli/config.py — Langfuse credentials in OPTIONAL_ENV_VARS;
  optional tuning keys in _EXTRA_ENV_KEYS

User flows:
  hermes plugins install official/observability/langfuse-tracing
  hermes plugins list --available
  hermes tools  (-> Langfuse Observability -> credentials -> auto-installs)

Closes #15764
2026-04-28 11:52:42 +05:30
Teknium
8081425a1c feat(security): make secret redaction off by default (#16794)
Flips security.redact_secrets from true to false in DEFAULT_CONFIG, and
the HERMES_REDACT_SECRETS env-var fallback in agent/redact.py now
requires explicit opt-in ("1"/"true"/"yes"/"on") to enable.

New installs and users without a security.redact_secrets key get pass-
through tool output. Existing users whose config.yaml explicitly sets
redact_secrets: true keep redaction on — the config-yaml -> env-var
bridges in hermes_cli/main.py and gateway/run.py still honor their
setting.

Also updates the inline config comments, website docs, and the
hermes-agent skill so /hermes config set security.redact_secrets true
is now the documented way to turn it on.
2026-04-27 21:24:08 -07:00
Teknium
ec8243fe2a chore(release): map matrix-parity-batch contributor emails to GitHub logins 2026-04-27 21:22:44 -07:00
Teknium
3d67364b8f test(matrix): set user_id in approval-reaction test to bypass defensive self-drop
MatrixAdapter._is_self_sender returns True defensively when _user_id is empty
(whoami not yet resolved) to prevent echo loops — see #15763. The reaction
approval test must therefore initialize a user_id so _on_reaction does not
drop the inbound test event before reaching the approval handler.
2026-04-27 21:22:44 -07:00
nbot
38a6bada92 feat(matrix): reaction-based exec approval + mention_user_id
Add Matrix reaction-based exec approval (/) and mention_user_id
support for push notifications in muted rooms.

- matrix.py: _MatrixApprovalPrompt, send_exec_approval, reaction
  approval handling, bot seed reaction redaction, mention pill in send
- base.py: inject mention_user_id into send metadata
- run.py: inject mention_user_id into status thread metadata
- tests for approval prompt registration and reaction resolution
2026-04-27 21:22:44 -07:00
Andrew Miller
6c70ac8eef matrix: e2e test for cross-signing auto-bootstrap
Self-contained docker-compose harness that exercises the new bootstrap
branch against a real Continuwuity homeserver. Three tests:

  1. fresh bot → bootstrap fires, /keys/query returns master + ssk
     with UNPADDED base64 keyids, current device is signed by the
     new SSK
  2. second startup with same crypto store → bootstrap is skipped
  3. MATRIX_RECOVERY_KEY set → existing verify_with_recovery_key path
     takes precedence, no new bootstrap

Run via:

    docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml up -d
    python tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py
    docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml down -v

The test mirrors the bootstrap snippet from matrix.py inline so it can
run without importing the full hermes gateway and its deps. Skipped
automatically when mautrix isn't installed or the homeserver is
unreachable.

All three pass against ghcr.io/continuwuity/continuwuity:latest
(Continuwuity 0.5.7). The unpadded-keyid assertion is the load-bearing
one — it's exactly the property the PR's bootstrap path provides that
the hand-rolled `base64.b64encode().decode()` scripts get wrong.
2026-04-27 21:22:44 -07:00
Andrew Miller
d497387cec matrix: auto-bootstrap cross-signing on first startup
Without this, every Matrix bot started under hermes-agent shows the
"Encrypted by a device not verified by its owner" badge in Element
indefinitely, because the cross-signing chain (master → SSK → device)
was never published. Operators currently have to write their own
bootstrap script and remember to run it once per bot — and it's easy
to get wrong (the obvious base64.b64encode().decode() produces padded
keyids that matrix-rust-sdk silently rejects in /keys/query, so even
correctly-signed keys fail to load identity in Element).

mautrix already has the right primitive: generate_recovery_key() does
the full flow — generate seeds, upload privates to SSSS, publish
publics to the homeserver, sign the current device with the new SSK,
and return the human-readable recovery key. We invoke it once on
startup if the bot has no existing cross-signing identity, and log
the recovery key with a clear instruction to save it for future
restarts via MATRIX_RECOVERY_KEY (which the existing recovery-key
path already consumes).

Skipped when MATRIX_RECOVERY_KEY is set (existing path takes over)
or when the bot already has cross-signing keys on the homeserver
(get_own_cross_signing_public_keys returns non-None).

Bootstrap failure is non-fatal — logged with hint about UIA; the bot
continues without cross-signing and Element will show the warning
that prompted this PR. That matches the existing soft-fail pattern
for verify_with_recovery_key.

Tested against Continuwuity 0.5.7 (no UIA required). Synapse with
UIA enabled will need a follow-up PR to thread MATRIX_PASSWORD
through to /keys/device_signing/upload.
2026-04-27 21:22:44 -07:00
konsisumer
32d4048c6b fix: MatrixAdapter respects proxy configuration 2026-04-27 21:22:44 -07:00
Adam Rummer
1eab5960f0 feat(matrix): add dm_auto_thread config for DM auto-threading
Adds MATRIX_DM_AUTO_THREAD env var (default: false) to control
auto-threading in DM rooms independently from channel auto-threading.

Closes #15398
2026-04-27 21:22:44 -07:00
LeonSGP43
74a4832b74 fix(matrix): normalize image-only filenames 2026-04-27 21:22:44 -07:00
Alexazhu
fbbcfa24c5 fix(matrix): preserve exception tracebacks on E2EE and auth failures
Five ``except Exception as exc:`` blocks in the Matrix adapter logged
only ``str(exc)`` without ``exc_info=True``:

- _reverify_keys_after_upload → post-upload key verification failure
- _upload_keys_if_needed      → initial device-key query failure
- _upload_keys_if_needed      → re-upload device keys failure
- _upload_keys_if_needed      → initial device key upload failure
- connect → whoami / access-token validation failure

The E2EE key paths here are security-critical: a silent traceback-
less failure during device-key verification or upload makes it
hard for operators to tell whether their Matrix bot is failing
because of a stale token, a federation timeout, or an olm state
mismatch — all three fail with different tracebacks, which
``str(exc)`` alone flattens.

The contributing guide asks for ``exc_info=True`` on error logs.
Append it to each of the five call sites. Pure logging enrichment.
2026-04-27 21:22:44 -07:00
Heathley
f223346eb7 fix(matrix): add sync timeout, callback diagnostics, and mention-drop logging
- Wrap _sync_loop sync() call with asyncio.wait_for(timeout=45s) to guard
  against TCP-level hangs that the Matrix long-poll timeout cannot catch
- Add logger.debug at the top of _on_room_message so LOG_LEVEL=DEBUG
  confirms whether callbacks fire at all (diagnoses #5819, #7914, #12614)
- Add logger.debug when MATRIX_REQUIRE_MENTION silently drops a message,
  pointing users to the env var to disable the filter

Adapted for current mautrix-python adapter (PR was written against the
legacy matrix-nio adapter).

Closes #5819
2026-04-27 21:22:44 -07:00
Charles Brooks
57f8cf00e9 fix(matrix): reconcile pending invites from sync state 2026-04-27 21:22:44 -07:00
Teknium
6649e7e746 test(matrix): adapt outbound-mention notice test to current _send_simple_message API 2026-04-27 21:22:44 -07:00
Angel Claw
32b78578e0 fix(matrix): strip only explicit @mentions in _strip_mention 2026-04-27 21:22:44 -07:00
Sami Rusani
6769a0aece fix(matrix): add outbound mention payloads 2026-04-27 21:22:44 -07:00
Teknium
d7528d43ac fix(web): scope dashboard config Reset button to the current tab (#16813)
* Port from Kilo-Org/kilocode#9448: roll up subagent costs into parent session total

Child subagents built by delegate_task() each track their own
session_estimated_cost_usd, but the parent agent's total never folded
those numbers in.  On runs where the parent mostly delegates and the
children do the expensive work, the footer/UI was reporting a fraction
of the actual spend — sometimes $0.00 when the parent itself made no
billed calls.

Fix:
- Capture each child's session_estimated_cost_usd into _child_cost_usd
  on the result entry (before child.close() drops the counter).
- After the existing subagent_stop hook loop, sum the children's costs
  and add the total to parent.session_estimated_cost_usd.
- Promote session_cost_source from 'none' -> 'subagent' when the parent
  had no direct spend but children did, so the UI doesn't label the
  total as having unknown provenance.  Real sources (openrouter,
  anthropic, etc.) are preserved.

Nested orchestrator -> worker trees roll up naturally: each layer's own
delegate_task() folds its direct children in, and when the orchestrator
itself returns, its parent folds the orchestrator's now-inflated total
on top.

Internal fields (_child_cost_usd, _child_role) are stripped from the
results dict before it's serialised back to the model — same contract
as _child_role already followed.

Tests: TestSubagentCostRollup (5 cases) covers single-child, batch,
zero-cost-children, preserved-source, and legacy-fixture paths.

Source: https://github.com/Kilo-Org/kilocode/pull/9448

* fix(web): scope dashboard config Reset button to the current tab

Reported by @ykmfb001 via X: clicking 'Restore Defaults' (恢复默认值) on
the Auxiliary page wiped the entire config.yaml to defaults, not just
the auxiliary section. The button sits next to the category tabs and
users reasonably assumed 'reset this tab', not 'reset everything'.

Changes:
- handleReset now scopes to the fields in the current view:
  active category's fields (form mode) or search-matched fields
  (search mode). Only those keys are copied from defaults; the rest
  of the config is left alone.
- Added a window.confirm() with the scope name before applying.
- Button is hidden in YAML mode (scoping doesn't apply there).
- Tooltip/aria-label now name the scope, e.g. 'Reset Auxiliary to
  defaults'.
- i18n: new resetScopeTooltip / confirmResetScope / resetScopeToast
  strings in en + zh; resetDefaults key preserved for compat.
2026-04-27 21:09:14 -07:00
Teknium
a7cdd4133c fix(bedrock): send context-1m-2025-08-07 beta so Opus 4.6/4.7 get 1M context (#16793)
On AWS Bedrock (and Azure AI Foundry), Claude Opus 4.6/4.7 and Sonnet 4.6
are capped at 200K context unless the request carries the
`context-1m-2025-08-07` beta header. On native Anthropic (api.anthropic.com)
1M went GA so the header is a harmless no-op, but Bedrock/Azure still gate
it as beta as of 2026-04.

Hermes was advertising 1M in model_metadata.py (`claude-opus-4-7: 1000000`)
while silently sending a request without the beta — so Bedrock users saw
a 200K ceiling with no error message, and no config knob unblocked it.
Claude Code sends this header by default, which is why the same Bedrock
credentials worked there.

- Add `context-1m-2025-08-07` to `_COMMON_BETAS` (alongside interleaved
  thinking and fine-grained tool streaming).
- Strip it in `_common_betas_for_base_url` for MiniMax bearer-auth
  endpoints — they host their own models, not Claude, so Anthropic beta
  headers are irrelevant and could risk rejection.
- Attach `_COMMON_BETAS` as `default_headers` on the AnthropicBedrock
  client. Previously that constructor passed no betas at all, so native
  Anthropic had the 1M unlock via default_headers but Bedrock didn't.
- Fast-mode per-request `extra_headers` already rebuilds from
  `_common_betas_for_base_url`, so it picks up the 1M beta automatically.

Reported by user 'Rodmar' on Discord: Bedrock Opus 4.7 stuck at 200K while
same credentials worked in Claude Code.
2026-04-27 20:41:36 -07:00
kshitijk4poor
461ef88705 fix(state): declarative column reconciliation for stuck-at-old-v7 DBs
Anyone who ran hermes between Apr 15 (42aeb4ec) and Apr 22 (a7d78d3b)
has schema_version=7 from the pre-renumber api_call_count migration.
When a7d78d3b inserted reasoning_content as the new v7 and pushed
api_call_count to v8, the 'if current_version < 7' gate was already
false for those users, so reasoning_content was never created —
sqlite3.OperationalError: no such column: reasoning_content on any
/continue or /resume touching assistant replays.

Replaces the version-gated ADD COLUMN chain with _reconcile_columns():
on every startup, parse SCHEMA_SQL via an in-memory SQLite and diff
against PRAGMA table_info; ALTER TABLE ADD COLUMN for anything missing.
Follows the Beets / sqlite-utils pattern — SCHEMA_SQL becomes the single
source of truth for declared columns. Self-healing and idempotent.

v10 trigram FTS backfill is retained in a version-gated block — that
migration isn't a column add, it inserts existing message rows into
the new FTS virtual table, so reconciliation can't express it.
schema_version is also kept for future row-data migrations.

Salvaged from #14097 (@kshitijk4poor) onto current main; v10 trigram
preservation and the v9 codex_message_items column (stale-missed by
the original branch) are covered automatically by reconciliation.

Tests:
- Regression: DB at old v7 with api_call_count but no reasoning_content
  gets the column on open
- Idempotency: reopening the same DB is a no-op
- Structural invariant: every SCHEMA_SQL column is in the live DB
- Existing v2 migration test still passes
- E2E verified against fresh / v1 / old-v7 / v9 DBs, plus v10 trigram
  backfill preserved
2026-04-27 20:29:32 -07:00
Teknium
12d745bd7e feat(skills): port humanizer — strip AI-isms from text (#16787)
Port https://github.com/blader/humanizer (MIT, v2.5.1, 16k stars) into
the built-in skills under skills/creative/humanizer/. Based on Wikipedia's
'Signs of AI writing' guide (WikiProject AI Cleanup) — detects 29 AI-writing
patterns and rewrites them to sound human.

Hermes-native adaptations:
- Description (<60 chars) explains what it's for: 'Humanize text: strip
  AI-isms and add real voice.'
- 'When to use this skill' section — trigger phrases (humanize, de-AI,
  de-slop, un-ChatGPT, rewrite to not sound like an LLM) plus guidance to
  apply it to the agent's own output (release notes, PR descriptions, docs).
- 'How to use it in Hermes' — maps the three real input paths (inline,
  file via read_file/patch/write_file, voice-calibration sample) onto the
  tools the agent actually has. Drops Claude Code's allowed-tools block.
- Converted frontmatter to Hermes format (metadata.hermes.tags, category,
  homepage, related_skills).

Attribution preserved:
- Original author Siqi Chen (@blader) credited in frontmatter and body.
- Full MIT LICENSE copied verbatim alongside SKILL.md.
- Wikipedia / WikiProject AI Cleanup credited.
- 29 patterns, personality/soul section, and full worked example kept
  verbatim from the source (29,914 chars).

Validated end-to-end against a clean HERMES_HOME:
- sync_skills() copies skills/creative/humanizer/ including LICENSE.
- skills_list(category='creative') returns the 48-char description.
- skill_view(name='humanizer') returns the full body with all 29 patterns,
  personality/soul, attribution, and Hermes tool refs (read_file, patch,
  write_file) intact.
2026-04-27 20:25:20 -07:00
36 changed files with 3992 additions and 199 deletions

View File

@@ -202,19 +202,33 @@ def _forbids_sampling_params(model: str) -> bool:
# Beta headers for enhanced features (sent with ALL auth types).
# As of Opus 4.7 (2026-04-16), both of these are GA on Claude 4.6+ — the
# As of Opus 4.7 (2026-04-16), the first two are GA on Claude 4.6+ — the
# beta headers are still accepted (harmless no-op) but not required. Kept
# here so older Claude (4.5, 4.1) + third-party Anthropic-compat endpoints
# that still gate on the headers continue to get the enhanced features.
# Migration guide: remove these if you no longer support ≤4.5 models.
#
# ``context-1m-2025-08-07`` unlocks the 1M context window on Claude Opus 4.6/4.7
# and Sonnet 4.6 when served via AWS Bedrock or Azure AI Foundry. 1M is GA on
# native Anthropic (api.anthropic.com) for Opus 4.6+, but Bedrock/Azure still
# gate it behind this beta header as of 2026-04 — without it Bedrock caps Opus
# at 200K even though model_metadata.py advertises 1M. The header is a harmless
# no-op on endpoints where 1M is GA.
#
# Migration guide: remove these if you no longer support ≤4.5 models or once
# Bedrock/Azure promote 1M to GA.
_COMMON_BETAS = [
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
"context-1m-2025-08-07",
]
# MiniMax's Anthropic-compatible endpoints fail tool-use requests when
# the fine-grained tool streaming beta is present. Omit it so tool calls
# fall back to the provider's default response path.
_TOOL_STREAMING_BETA = "fine-grained-tool-streaming-2025-05-14"
# 1M context beta — see comment on _COMMON_BETAS above. Stripped for
# Bearer-auth (MiniMax) endpoints since they host their own models and
# unknown Anthropic beta headers risk request rejection.
_CONTEXT_1M_BETA = "context-1m-2025-08-07"
# Fast mode beta — enables the ``speed: "fast"`` request parameter for
# significantly higher output token throughput on Opus 4.6 (~2.5x).
@@ -357,9 +371,14 @@ def _common_betas_for_base_url(base_url: str | None) -> list[str]:
that include Anthropic's ``fine-grained-tool-streaming`` beta — every
tool-use message triggers a connection error. Strip that beta for
Bearer-auth endpoints while keeping all other betas intact.
The ``context-1m-2025-08-07`` beta is also stripped for Bearer-auth
endpoints — MiniMax hosts its own models, not Claude, so the header is
irrelevant at best and risks request rejection at worst.
"""
if _requires_bearer_auth(base_url):
return [b for b in _COMMON_BETAS if b != _TOOL_STREAMING_BETA]
_stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA}
return [b for b in _COMMON_BETAS if b not in _stripped]
return _COMMON_BETAS
@@ -456,6 +475,13 @@ def build_anthropic_bedrock_client(region: str):
Claude feature parity: prompt caching, thinking budgets, adaptive
thinking, fast mode — features not available via the Converse API.
Attaches the common Anthropic beta headers as client-level defaults so
that Bedrock-hosted Claude models get the same enhanced features as
native Anthropic. The ``context-1m-2025-08-07`` beta in particular
unlocks the 1M context window for Opus 4.6/4.7 on Bedrock — without
it, Bedrock caps these models at 200K even though the Anthropic API
serves them with 1M natively.
Auth uses the boto3 default credential chain (IAM roles, SSO, env vars).
"""
if _anthropic_sdk is None:
@@ -473,6 +499,7 @@ def build_anthropic_bedrock_client(region: str):
return _anthropic_sdk.AnthropicBedrock(
aws_region=region,
timeout=Timeout(timeout=900.0, connect=10.0),
default_headers={"anthropic-beta": ",".join(_COMMON_BETAS)},
)

View File

@@ -56,8 +56,12 @@ _SENSITIVE_BODY_KEYS = frozenset({
})
# Snapshot at import time so runtime env mutations (e.g. LLM-generated
# `export HERMES_REDACT_SECRETS=false`) cannot disable redaction mid-session.
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() not in ("0", "false", "no", "off")
# `export HERMES_REDACT_SECRETS=true`) cannot enable/disable redaction
# mid-session. OFF by default — user must opt in via
# `security.redact_secrets: true` in config.yaml (bridged to this env var
# in hermes_cli/main.py and gateway/run.py) or `HERMES_REDACT_SECRETS=true`
# in ~/.hermes/.env.
_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("1", "true", "yes", "on")
# Known API key prefixes -- match the prefix + contiguous token chars
_PREFIX_PATTERNS = [
@@ -257,7 +261,7 @@ def redact_sensitive_text(text: str) -> str:
"""Apply all redaction patterns to a block of text.
Safe to call on any string -- non-matching text passes through unchanged.
Disabled when security.redact_secrets is false in config.yaml.
Disabled by default — enable via security.redact_secrets: true in config.yaml.
"""
if text is None:
return None

View File

@@ -307,9 +307,14 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
"""Build kwargs for standalone ``aiohttp.ClientSession`` with proxy.
Returns ``(session_kwargs, request_kwargs)`` where:
- SOCKS → ``({"connector": ProxyConnector(...)}, {})``
- HTTP → ``({}, {"proxy": url})``
- None → ``({}, {})``
- With aiohttp-socks → ``({"connector": ProxyConnector(...)}, {})``
for *all* proxy schemes (SOCKS **and** HTTP/HTTPS).
- HTTP without aiohttp-socks → ``({}, {"proxy": url})``.
- None → ``({}, {})``.
Prefer the connector path: it works transparently with libraries
(like mautrix) that call ``session.request()`` without forwarding
per-request ``proxy=`` kwargs.
Usage::
@@ -320,20 +325,20 @@ def proxy_kwargs_for_aiohttp(proxy_url: str | None) -> tuple[dict, dict]:
"""
if not proxy_url:
return {}, {}
if proxy_url.lower().startswith("socks"):
try:
from aiohttp_socks import ProxyConnector
try:
from aiohttp_socks import ProxyConnector
connector = ProxyConnector.from_url(proxy_url, rdns=True)
return {"connector": connector}, {}
except ImportError:
connector = ProxyConnector.from_url(proxy_url, rdns=True)
return {"connector": connector}, {}
except ImportError:
if proxy_url.lower().startswith("socks"):
logger.warning(
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
"Run: pip install aiohttp-socks",
proxy_url,
)
return {}, {}
return {}, {"proxy": proxy_url}
return {}, {"proxy": proxy_url}
def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = None) -> bool:
@@ -2427,11 +2432,15 @@ class BasePlatformAdapter(ABC):
# Send the text portion
if text_content:
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
# Build send metadata: thread_id + mention target for platforms that need it
send_metadata = dict(_thread_metadata) if _thread_metadata else {}
if event.source.user_id:
send_metadata["mention_user_id"] = event.source.user_id
result = await self._send_with_retry(
chat_id=event.source.chat_id,
content=text_content,
reply_to=event.message_id,
metadata=_thread_metadata,
metadata=send_metadata,
)
_record_delivery(result)

View File

@@ -11,6 +11,7 @@ Environment variables:
MATRIX_PASSWORD Password (alternative to access token)
MATRIX_ENCRYPTION Set "true" to enable E2EE
MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts
MATRIX_PROXY HTTP(S) or SOCKS proxy URL for Matrix traffic
MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server)
MATRIX_HOME_ROOM Room ID for cron/notification delivery
MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions
@@ -18,6 +19,7 @@ Environment variables:
MATRIX_REQUIRE_MENTION Require @mention in rooms (default: true)
MATRIX_FREE_RESPONSE_ROOMS Comma-separated room IDs exempt from mention requirement
MATRIX_AUTO_THREAD Auto-create threads for room messages (default: true)
MATRIX_DM_AUTO_THREAD Auto-create threads for DM messages (default: false)
MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation
MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false)
"""
@@ -30,6 +32,8 @@ import mimetypes
import os
import re
import time
from dataclasses import dataclass
from html import escape as _html_escape
from pathlib import Path
from typing import Any, Dict, Optional, Set
@@ -95,11 +99,25 @@ from gateway.platforms.base import (
MessageType,
ProcessingOutcome,
SendResult,
resolve_proxy_url,
proxy_kwargs_for_aiohttp,
)
from gateway.platforms.helpers import ThreadParticipationTracker
logger = logging.getLogger(__name__)
@dataclass
class _MatrixApprovalPrompt:
"""Tracks a pending Matrix reaction-based exec approval prompt."""
def __init__(self, session_key: str, chat_id: str, message_id: str, resolved: bool = False):
self.session_key = session_key
self.chat_id = chat_id
self.message_id = message_id
self.resolved = resolved
self.bot_reaction_events: dict[str, str] = {} # emoji -> event_id
# Matrix message size limit (4000 chars practical, spec has no hard limit
# but clients render poorly above this).
MAX_MESSAGE_LENGTH = 4000
@@ -114,11 +132,85 @@ _CRYPTO_DB_PATH = _STORE_DIR / "crypto.db"
# Grace period: ignore messages older than this many seconds before startup.
_STARTUP_GRACE_SECONDS = 5
_OUTBOUND_MENTION_RE = re.compile(
r"(?<![\w/])(@[0-9A-Za-z._=/-]+:[0-9A-Za-z.-]+(?::\d+)?)"
)
_E2EE_INSTALL_HINT = (
"Install with: pip install 'mautrix[encryption]' (requires libolm C library)"
)
_MATRIX_IMAGE_FILENAME_EXTS = frozenset({
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".svg",
".heic",
".heif",
".avif",
})
def _looks_like_matrix_image_filename(text: str) -> bool:
"""Return True when Matrix image body text is probably just a transport filename.
Matrix ``m.image`` events commonly populate ``content.body`` with the uploaded
filename when the user did not add a caption. Treating that raw filename as
user-authored text confuses downstream vision enrichment.
"""
candidate = str(text or "").strip()
if not candidate or "\n" in candidate or candidate.endswith("/"):
return False
name = Path(candidate).name
if not name or name != candidate:
return False
suffix = Path(name).suffix.lower()
if not suffix:
return False
guessed_type, _ = mimetypes.guess_type(name)
if guessed_type and guessed_type.startswith("image/"):
return True
return suffix in _MATRIX_IMAGE_FILENAME_EXTS
def _create_matrix_session(proxy_url: str | None):
"""Create an ``aiohttp.ClientSession`` whose proxy applies to *all* requests.
mautrix's ``HTTPAPI._send()`` calls ``session.request()`` without forwarding
per-request ``proxy=`` kwargs. For HTTP(S) proxies we use aiohttp's native
``proxy=`` session parameter which sets a default for every request. For SOCKS
we use ``aiohttp_socks.ProxyConnector`` (connector-level).
When no proxy is configured we enable ``trust_env`` so standard env vars
(``HTTP_PROXY`` / ``HTTPS_PROXY``) are honoured automatically.
"""
import aiohttp
if not proxy_url:
return aiohttp.ClientSession(trust_env=True)
if proxy_url.split("://")[0].lower().startswith("socks"):
try:
from aiohttp_socks import ProxyConnector
return aiohttp.ClientSession(
connector=ProxyConnector.from_url(proxy_url, rdns=True),
)
except ImportError:
logger.warning(
"aiohttp_socks not installed — SOCKS proxy %s ignored. "
"Run: pip install aiohttp-socks",
proxy_url,
)
return aiohttp.ClientSession(trust_env=True)
return aiohttp.ClientSession(proxy=proxy_url)
def _check_e2ee_deps() -> bool:
"""Return True if mautrix E2EE dependencies (python-olm) are available."""
@@ -260,6 +352,9 @@ class MatrixAdapter(BasePlatformAdapter):
"1",
"yes",
)
self._dm_auto_thread: bool = os.getenv(
"MATRIX_DM_AUTO_THREAD", "false"
).lower() in ("true", "1", "yes")
self._dm_mention_threads: bool = os.getenv(
"MATRIX_DM_MENTION_THREADS", "false"
).lower() in ("true", "1", "yes")
@@ -270,6 +365,11 @@ class MatrixAdapter(BasePlatformAdapter):
).lower() not in ("false", "0", "no")
self._pending_reactions: dict[tuple[str, str], str] = {}
# Proxy support — resolve once at init, reuse for all HTTP traffic.
self._proxy_url: str | None = resolve_proxy_url(platform_env_var="MATRIX_PROXY")
if self._proxy_url:
logger.info("Matrix: proxy configured — %s", self._proxy_url)
# Text batching: merge rapid successive messages (Telegram-style).
# Matrix clients split long messages around 4000 chars.
self._text_batch_delay_seconds = float(
@@ -281,6 +381,18 @@ class MatrixAdapter(BasePlatformAdapter):
self._pending_text_batches: Dict[str, MessageEvent] = {}
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
# Matrix reaction-based dangerous command approvals.
self._approval_reaction_map = {
"": "once",
"": "deny",
}
self._approval_prompts_by_event: Dict[str, _MatrixApprovalPrompt] = {}
self._approval_prompt_by_session: Dict[str, str] = {}
allowed_users_raw = os.getenv("MATRIX_ALLOWED_USERS", "")
self._allowed_user_ids: Set[str] = {
u.strip() for u in allowed_users_raw.split(",") if u.strip()
}
def _is_duplicate_event(self, event_id) -> bool:
"""Return True if this event was already processed. Tracks the ID otherwise."""
if not event_id:
@@ -326,7 +438,7 @@ class MatrixAdapter(BasePlatformAdapter):
)
return False
except Exception as exc:
logger.error("Matrix: post-upload key verification failed: %s", exc)
logger.error("Matrix: post-upload key verification failed: %s", exc, exc_info=True)
return False
return True
@@ -342,6 +454,7 @@ class MatrixAdapter(BasePlatformAdapter):
logger.error(
"Matrix: cannot verify device keys on server: %s — refusing E2EE",
exc,
exc_info=True,
)
return False
@@ -356,7 +469,7 @@ class MatrixAdapter(BasePlatformAdapter):
try:
await olm.share_keys()
except Exception as exc:
logger.error("Matrix: failed to re-upload device keys: %s", exc)
logger.error("Matrix: failed to re-upload device keys: %s", exc, exc_info=True)
return False
return await self._reverify_keys_after_upload(client, local_ed25519)
@@ -396,6 +509,7 @@ class MatrixAdapter(BasePlatformAdapter):
"Try generating a new access token to get a fresh device.",
client.device_id,
exc,
exc_info=True,
)
return False
return await self._reverify_keys_after_upload(client, local_ed25519)
@@ -420,9 +534,11 @@ class MatrixAdapter(BasePlatformAdapter):
_STORE_DIR.mkdir(parents=True, exist_ok=True)
# Create the HTTP API layer.
client_session = _create_matrix_session(self._proxy_url)
api = HTTPAPI(
base_url=self._homeserver,
token=self._access_token or "",
client_session=client_session,
)
# Create the client.
@@ -465,6 +581,7 @@ class MatrixAdapter(BasePlatformAdapter):
logger.error(
"Matrix: whoami failed — check MATRIX_ACCESS_TOKEN and MATRIX_HOMESERVER: %s",
exc,
exc_info=True,
)
await api.session.close()
return False
@@ -607,6 +724,44 @@ class MatrixAdapter(BasePlatformAdapter):
logger.warning(
"Matrix: recovery key verification failed: %s", exc
)
else:
# No recovery key — bootstrap cross-signing if the bot
# has none yet. Without this, Element shows "Encrypted
# by a device not verified by its owner" on every
# message from this bot, indefinitely. mautrix's
# generate_recovery_key does the full flow: generates
# MSK/SSK/USK, uploads private keys to SSSS, publishes
# public keys to the homeserver, and signs the current
# device with the new SSK. Some homeservers require UIA
# for /keys/device_signing/upload — those will need an
# alternate path; Continuwuity and Synapse-with-shared-
# secret accept the unauthenticated upload.
try:
own_xsign = await olm.get_own_cross_signing_public_keys()
except Exception as exc:
own_xsign = None
logger.warning(
"Matrix: cross-signing key lookup failed: %s", exc
)
if own_xsign is None:
try:
new_recovery_key = await olm.generate_recovery_key()
logger.warning(
"Matrix: bootstrapped cross-signing for %s. "
"SAVE THIS RECOVERY KEY — set "
"MATRIX_RECOVERY_KEY for future restarts so "
"the bot can re-sign its device after key "
"rotation: %s",
client.mxid,
new_recovery_key,
)
except Exception as exc:
logger.warning(
"Matrix: cross-signing bootstrap failed "
"(non-fatal — Element will show 'not "
"verified by its owner'): %s",
exc,
)
client.crypto = olm
logger.info(
@@ -664,6 +819,7 @@ class MatrixAdapter(BasePlatformAdapter):
await asyncio.gather(*tasks)
except Exception as exc:
logger.warning("Matrix: initial sync event dispatch error: %s", exc)
await self._join_pending_invites(sync_data)
else:
logger.warning(
"Matrix: initial sync returned unexpected type %s",
@@ -723,21 +879,32 @@ class MatrixAdapter(BasePlatformAdapter):
if not content:
return SendResult(success=True)
mention_user_id = (metadata or {}).get("mention_user_id")
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH)
last_event_id = None
for chunk in chunks:
msg_content: Dict[str, Any] = {
"msgtype": "m.text",
"body": chunk,
}
for i, chunk in enumerate(chunks):
msg_content = self._build_text_message_content(chunk)
# Convert markdown to HTML for rich rendering.
html = self._markdown_to_html(chunk)
if html and html != chunk:
# Append @mention pill to the last chunk for push notifications
# in muted rooms (mention-only mode).
if mention_user_id and i == len(chunks) - 1:
mention_html = (
f'<a href="https://matrix.to/#/{mention_user_id}">'
f"{mention_user_id}</a>"
)
msg_content["body"] = chunk + f" @{mention_user_id}"
base_html = msg_content.get("formatted_body", chunk)
msg_content["format"] = "org.matrix.custom.html"
msg_content["formatted_body"] = html
msg_content["formatted_body"] = base_html + " " + mention_html
# m.mentions for MSC3952 push reliability.
existing_mentions = msg_content.get("m.mentions", {}).get("user_ids", [])
if mention_user_id not in existing_mentions:
msg_content["m.mentions"] = {
"user_ids": existing_mentions + [mention_user_id]
}
# Reply-to support.
if reply_to:
@@ -844,25 +1011,21 @@ class MatrixAdapter(BasePlatformAdapter):
"""Edit an existing message (via m.replace)."""
formatted = self.format_message(content)
new_content = self._build_text_message_content(formatted)
msg_content: Dict[str, Any] = {
"msgtype": "m.text",
"body": f"* {formatted}",
"m.new_content": {
"msgtype": "m.text",
"body": formatted,
},
"m.relates_to": {
"rel_type": "m.replace",
"event_id": message_id,
},
"m.new_content": new_content,
}
html = self._markdown_to_html(formatted)
if html and html != formatted:
msg_content["m.new_content"]["format"] = "org.matrix.custom.html"
msg_content["m.new_content"]["formatted_body"] = html
if "m.mentions" in new_content:
msg_content["m.mentions"] = new_content["m.mentions"]
if "formatted_body" in new_content:
msg_content["format"] = "org.matrix.custom.html"
msg_content["formatted_body"] = f"* {html}"
msg_content["formatted_body"] = f'* {new_content["formatted_body"]}'
msg_content["m.relates_to"] = {
"rel_type": "m.replace",
"event_id": message_id,
}
try:
event_id = await self._client.send_message_event(
@@ -895,10 +1058,12 @@ class MatrixAdapter(BasePlatformAdapter):
# Try aiohttp first (always available), fall back to httpx
try:
import aiohttp as _aiohttp
async with _aiohttp.ClientSession(trust_env=True) as http:
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(self._proxy_url)
async with _aiohttp.ClientSession(**_sess_kw) as http:
async with http.get(
image_url, timeout=_aiohttp.ClientTimeout(total=30)
image_url,
timeout=_aiohttp.ClientTimeout(total=30),
**_req_kw,
) as resp:
resp.raise_for_status()
data = await resp.read()
@@ -908,8 +1073,10 @@ class MatrixAdapter(BasePlatformAdapter):
)
except ImportError:
import httpx
async with httpx.AsyncClient() as http:
_httpx_kw: dict = {}
if self._proxy_url:
_httpx_kw["proxy"] = self._proxy_url
async with httpx.AsyncClient(**_httpx_kw) as http:
resp = await http.get(image_url, follow_redirects=True, timeout=30)
resp.raise_for_status()
data = resp.content
@@ -984,6 +1151,56 @@ class MatrixAdapter(BasePlatformAdapter):
chat_id, video_path, "m.video", caption, reply_to, metadata=metadata
)
async def send_exec_approval(
self,
chat_id: str,
command: str,
session_key: str,
description: str = "dangerous command",
metadata: Optional[dict] = None,
) -> SendResult:
"""Send a reaction-based exec approval prompt for Matrix."""
if not self._client:
return SendResult(success=False, error="Not connected")
cmd_preview = command[:2000] + "..." if len(command) > 2000 else command
text = (
"⚠️ **Dangerous command requires approval**\n"
f"```\n{cmd_preview}\n```\n"
f"Reason: {description}\n\n"
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
"You can also click the reaction to approve:\n"
"✅ = /approve\n"
"❎ = /deny"
)
result = await self.send(chat_id, text, metadata=metadata)
if not result.success or not result.message_id:
return result
prompt = _MatrixApprovalPrompt(
session_key=session_key,
chat_id=chat_id,
message_id=result.message_id,
)
old_event = self._approval_prompt_by_session.get(session_key)
if old_event:
self._approval_prompts_by_event.pop(old_event, None)
self._approval_prompts_by_event[result.message_id] = prompt
self._approval_prompt_by_session[session_key] = result.message_id
for emoji in ("", ""):
try:
reaction_result = await self._send_reaction(chat_id, result.message_id, emoji)
# Save the bot's reaction event_id for later cleanup
if reaction_result:
prompt.bot_reaction_events[emoji] = str(reaction_result)
except Exception as exc:
logger.debug("Matrix: failed to add approval reaction %s: %s", emoji, exc)
return result
def format_message(self, content: str) -> str:
"""Pass-through — Matrix supports standard Markdown natively."""
# Strip image markdown; media is uploaded separately.
@@ -1115,9 +1332,15 @@ class MatrixAdapter(BasePlatformAdapter):
next_batch = await client.sync_store.get_next_batch()
while not self._closing:
try:
sync_data = await client.sync(
since=next_batch,
timeout=30000,
# Wrap in asyncio.wait_for to guard against TCP-level hangs
# that the Matrix long-poll timeout cannot catch. Long-poll
# is 30s, so 45s gives 15s slack for network drain.
sync_data = await asyncio.wait_for(
client.sync(
since=next_batch,
timeout=30000,
),
timeout=45.0,
)
# nio returns SyncError objects (not exceptions) for auth
@@ -1153,6 +1376,7 @@ class MatrixAdapter(BasePlatformAdapter):
await asyncio.gather(*tasks)
except Exception as exc:
logger.warning("Matrix: sync event dispatch error: %s", exc)
await self._join_pending_invites(sync_data)
except asyncio.CancelledError:
return
@@ -1239,6 +1463,15 @@ class MatrixAdapter(BasePlatformAdapter):
room_id = str(getattr(event, "room_id", ""))
sender = str(getattr(event, "sender", ""))
# Diagnostic: confirm the callback is firing at all when DEBUG is on.
# Helps users troubleshoot silent inbound issues like #5819, #7914, #12614.
logger.debug(
"Matrix: callback fired — event %s from %s in %s",
getattr(event, "event_id", "?"),
sender,
room_id,
)
# Ignore own messages (case-insensitive; also drops when our own
# user_id hasn't been resolved yet — see _is_self_sender docstring
# and issue #15763).
@@ -1350,6 +1583,12 @@ class MatrixAdapter(BasePlatformAdapter):
in_bot_thread = bool(thread_id and thread_id in self._threads)
if self._require_mention and not is_free_room and not in_bot_thread:
if not is_mentioned:
logger.debug(
"Matrix: ignoring message %s in %s — no @mention "
"(set MATRIX_REQUIRE_MENTION=false to disable)",
event_id,
room_id,
)
return None
# DM mention-thread.
@@ -1362,7 +1601,7 @@ class MatrixAdapter(BasePlatformAdapter):
body = self._strip_mention(body)
# Auto-thread.
if not is_dm and not thread_id and self._auto_thread:
if not thread_id and ((not is_dm and self._auto_thread) or (is_dm and self._dm_auto_thread)):
thread_id = event_id
self._threads.mark(thread_id)
@@ -1604,6 +1843,9 @@ class MatrixAdapter(BasePlatformAdapter):
return
body, is_dm, chat_type, thread_id, display_name, source = ctx
if msgtype == "m.image" and _looks_like_matrix_image_filename(body):
body = ""
allow_http_fallback = bool(http_url) and not is_encrypted_media
media_urls = (
[cached_path]
@@ -1633,13 +1875,35 @@ class MatrixAdapter(BasePlatformAdapter):
"Matrix: invited to %s — joining",
room_id,
)
await self._join_room_by_id(room_id)
async def _join_room_by_id(self, room_id: str) -> bool:
"""Join a room by ID and refresh local caches on success."""
if not room_id:
return False
if room_id in self._joined_rooms:
return True
try:
await self._client.join_room(RoomID(room_id))
self._joined_rooms.add(room_id)
logger.info("Matrix: joined %s", room_id)
await self._refresh_dm_cache()
return True
except Exception as exc:
logger.warning("Matrix: error joining %s: %s", room_id, exc)
return False
async def _join_pending_invites(self, sync_data: Dict[str, Any]) -> None:
"""Join rooms still present in rooms.invite after sync processing."""
rooms = sync_data.get("rooms", {}) if isinstance(sync_data, dict) else {}
invites = rooms.get("invite", {})
if not isinstance(invites, dict):
return
for room_id in invites:
if room_id in self._joined_rooms:
continue
logger.info("Matrix: reconciling pending invite for %s", room_id)
await self._join_room_by_id(str(room_id))
# ------------------------------------------------------------------
# Reactions (send, receive, processing lifecycle)
@@ -1754,6 +2018,51 @@ class MatrixAdapter(BasePlatformAdapter):
room_id,
)
# Check if this reaction resolves a pending approval prompt.
prompt = self._approval_prompts_by_event.get(reacts_to)
if prompt and not prompt.resolved:
if room_id != prompt.chat_id:
return
if self._allowed_user_ids and sender not in self._allowed_user_ids:
logger.info(
"Matrix: ignoring approval reaction from unauthorized user %s on %s",
sender, reacts_to,
)
return
choice = self._approval_reaction_map.get(key)
if not choice:
return
try:
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(prompt.session_key, choice)
if count:
prompt.resolved = True
self._approval_prompts_by_event.pop(reacts_to, None)
self._approval_prompt_by_session.pop(prompt.session_key, None)
logger.info(
"Matrix reaction resolved %d approval(s) for session %s "
"(choice=%s, user=%s)",
count, prompt.session_key, choice, sender,
)
# Redact bot's seed reactions, leaving only the user's
await self._redact_bot_approval_reactions(room_id, prompt)
except Exception as exc:
logger.error("Failed to resolve gateway approval from Matrix reaction: %s", exc)
async def _redact_bot_approval_reactions(
self,
room_id: str,
prompt: "_MatrixApprovalPrompt",
) -> None:
"""Redact the bot's seed ✅/❎ reactions, leaving only the user's reaction."""
for emoji, evt_id in prompt.bot_reaction_events.items():
try:
await self.redact_message(room_id, evt_id, "approval resolved")
logger.debug("Matrix: redacted bot reaction %s (%s)", emoji, evt_id)
except Exception as exc:
logger.debug("Matrix: failed to redact bot reaction %s: %s", emoji, exc)
# ------------------------------------------------------------------
# Text message aggregation (handles Matrix client-side splits)
# ------------------------------------------------------------------
@@ -1979,11 +2288,7 @@ class MatrixAdapter(BasePlatformAdapter):
if not self._client or not text:
return SendResult(success=False, error="No client or empty text")
msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
html = self._markdown_to_html(text)
if html and html != text:
msg_content["format"] = "org.matrix.custom.html"
msg_content["formatted_body"] = html
msg_content = self._build_text_message_content(text, msgtype=msgtype)
try:
event_id = await self._client.send_message_event(
@@ -2046,6 +2351,77 @@ class MatrixAdapter(BasePlatformAdapter):
# Mention detection helpers
# ------------------------------------------------------------------
def _build_text_message_content(self, text: str, msgtype: str = "m.text") -> Dict[str, Any]:
"""Build Matrix text content with HTML and outbound mention metadata."""
msg_content: Dict[str, Any] = {"msgtype": msgtype, "body": text}
mention_user_ids = self._extract_outbound_mentions(text)
if mention_user_ids:
msg_content["m.mentions"] = {"user_ids": mention_user_ids}
html_source = self._inject_outbound_mention_links(text)
html = self._markdown_to_html(html_source)
if html and html != text:
msg_content["format"] = "org.matrix.custom.html"
msg_content["formatted_body"] = html
return msg_content
def _extract_outbound_mentions(self, text: str) -> list[str]:
"""Return unique Matrix user IDs mentioned in outbound text."""
protected, _ = self._protect_outbound_mention_regions(text)
seen: Set[str] = set()
mentions: list[str] = []
for match in _OUTBOUND_MENTION_RE.finditer(protected):
user_id = match.group(1)
if user_id not in seen:
seen.add(user_id)
mentions.append(user_id)
return mentions
def _inject_outbound_mention_links(self, text: str) -> str:
"""Wrap outbound Matrix mentions in markdown links outside code spans."""
if not text:
return text
protected, placeholders = self._protect_outbound_mention_regions(text)
linked = _OUTBOUND_MENTION_RE.sub(
lambda match: f"[{match.group(1)}](https://matrix.to/#/{match.group(1)})",
protected,
)
for idx, original in enumerate(placeholders):
linked = linked.replace(f"\x00MENTION_PROTECTED{idx}\x00", original)
return linked
def _protect_outbound_mention_regions(self, text: str) -> tuple[str, list[str]]:
"""Protect markdown regions where outbound mentions should stay literal."""
placeholders: list[str] = []
def _protect(fragment: str) -> str:
idx = len(placeholders)
placeholders.append(fragment)
return f"\x00MENTION_PROTECTED{idx}\x00"
protected = re.sub(
r"```[\s\S]*?```",
lambda match: _protect(match.group(0)),
text or "",
)
protected = re.sub(
r"`[^`\n]+`",
lambda match: _protect(match.group(0)),
protected,
)
protected = re.sub(
r"\[[^\]]+\]\([^)]+\)",
lambda match: _protect(match.group(0)),
protected,
)
return protected, placeholders
def _is_bot_mentioned(
self,
body: str,
@@ -2080,13 +2456,33 @@ class MatrixAdapter(BasePlatformAdapter):
return False
def _strip_mention(self, body: str) -> str:
"""Strip the bot's full MXID (``@user:server``) from *body*.
"""Remove explicit bot mentions from message body.
The bare localpart is intentionally *not* stripped — it would
mangle file paths like ``/home/hermes/media/file.png``.
Important: only strip explicit mention tokens (``@user:server`` or
``@localpart``). Do NOT strip bare words matching the bot localpart,
otherwise normal phrases like "Hermes Agent" become "Agent".
"""
if not body:
return ""
# Strip explicit full MXID mentions.
if self._user_id:
body = body.replace(self._user_id, "")
# Strip explicit @localpart mentions only (not bare localpart words).
if self._user_id and ":" in self._user_id:
localpart = self._user_id.split(":")[0].lstrip("@")
if localpart:
body = re.sub(
r'(?<![\w])@' + re.escape(localpart) + r'\b',
'',
body,
flags=re.IGNORECASE,
)
# Normalize spacing after mention removal.
body = re.sub(r'[ \t]{2,}', ' ', body)
body = re.sub(r'\s+([,.;:!?])', r'\1', body)
return body.strip()
async def _get_display_name(self, room_id: str, user_id: str) -> str:

View File

@@ -10041,7 +10041,7 @@ class GatewayRunner:
# Bridge sync status_callback → async adapter.send for context pressure
_status_adapter = self.adapters.get(source.platform)
_status_chat_id = source.chat_id
_status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
_status_thread_metadata = {"thread_id": _progress_thread_id, "mention_user_id": source.user_id} if _progress_thread_id else {"mention_user_id": source.user_id}
def _status_callback_sync(event_type: str, message: str) -> None:
if not _status_adapter or not _run_still_current():

View File

@@ -56,8 +56,18 @@ _EXTRA_ENV_KEYS = frozenset({
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD",
"MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD",
"MATRIX_RECOVERY_KEY",
# Langfuse observability plugin — optional tuning keys + standard SDK vars
"HERMES_LANGFUSE_ENABLED", # backward-compat env var (new: plugins.langfuse.enabled in config.yaml)
"HERMES_LANGFUSE_ENV",
"HERMES_LANGFUSE_RELEASE",
"HERMES_LANGFUSE_SAMPLE_RATE",
"HERMES_LANGFUSE_MAX_CHARS",
"HERMES_LANGFUSE_DEBUG",
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LANGFUSE_BASE_URL",
})
import yaml
@@ -942,7 +952,7 @@ DEFAULT_CONFIG = {
# Pre-exec security scanning via tirith
"security": {
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
"redact_secrets": True,
"redact_secrets": False,
"tirith_enabled": True,
"tirith_path": "tirith",
"tirith_timeout": 5,
@@ -1692,6 +1702,30 @@ OPTIONAL_ENV_VARS = {
"category": "tool",
},
# ── Langfuse observability ──
"HERMES_LANGFUSE_PUBLIC_KEY": {
"description": "Langfuse project public key (pk-lf-...)",
"prompt": "Langfuse public key",
"url": "https://cloud.langfuse.com",
"password": False,
"category": "tool",
},
"HERMES_LANGFUSE_SECRET_KEY": {
"description": "Langfuse project secret key (sk-lf-...)",
"prompt": "Langfuse secret key",
"url": "https://cloud.langfuse.com",
"password": True,
"category": "tool",
},
"HERMES_LANGFUSE_BASE_URL": {
"description": "Langfuse server URL (default: https://cloud.langfuse.com)",
"prompt": "Langfuse server URL (leave empty for cloud.langfuse.com)",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
# ── Messaging platforms ──
"TELEGRAM_BOT_TOKEN": {
"description": "Telegram bot token from @BotFather",
@@ -1839,6 +1873,14 @@ OPTIONAL_ENV_VARS = {
"category": "messaging",
"advanced": True,
},
"MATRIX_DM_AUTO_THREAD": {
"description": "Auto-create threads for DM messages in Matrix (default: false)",
"prompt": "Auto-create threads in DMs (true/false)",
"url": None,
"password": False,
"category": "messaging",
"advanced": True,
},
"MATRIX_DEVICE_ID": {
"description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)",
"prompt": "Matrix device ID (stable across restarts)",
@@ -3353,14 +3395,16 @@ def load_config() -> Dict[str, Any]:
_SECURITY_COMMENT = """
# ── Security ──────────────────────────────────────────────────────────
# API keys, tokens, and passwords are redacted from tool output by default.
# Set to false to see full values (useful for debugging auth issues).
# Secret redaction is OFF by default — tool output (terminal stdout,
# read_file results, web content) passes through unmodified. Set
# redact_secrets to true to mask strings that look like API keys, tokens,
# and passwords before they enter the model context and logs.
# tirith pre-exec scanning is enabled by default when the tirith binary
# is available. Configure via security.tirith_* keys or env vars
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
#
# security:
# redact_secrets: false
# redact_secrets: true
# tirith_enabled: true
# tirith_path: "tirith"
# tirith_timeout: 5
@@ -3393,11 +3437,11 @@ _FALLBACK_COMMENT = """
_COMMENTED_SECTIONS = """
# ── Security ──────────────────────────────────────────────────────────
# API keys, tokens, and passwords are redacted from tool output by default.
# Set to false to see full values (useful for debugging auth issues).
# Secret redaction is OFF by default. Set to true to mask strings that
# look like API keys, tokens, and passwords in tool output and logs.
#
# security:
# redact_secrets: false
# redact_secrets: true
# ── Fallback Model ────────────────────────────────────────────────────
# Automatic provider failover when primary is unavailable.

View File

@@ -9082,7 +9082,11 @@ Examples:
)
plugins_remove.add_argument("name", help="Plugin directory name to remove")
plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins")
plugins_list = plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins")
plugins_list.add_argument(
"--available", action="store_true",
help="Also show official optional plugins that are not yet installed",
)
plugins_enable = plugins_subparsers.add_parser(
"enable", help="Enable a disabled plugin"

View File

@@ -1,7 +1,13 @@
"""``hermes plugins`` CLI subcommand — install, update, remove, and list plugins.
Plugins are installed from Git repositories into ``~/.hermes/plugins/``.
Supports full URLs and ``owner/repo`` shorthand (resolves to GitHub).
Plugins can be installed from:
- Official optional plugins shipped with the repo: ``official/<category>/<name>``
- Git repositories (full URL or ``owner/repo`` GitHub shorthand)
Official plugins live in ``optional-plugins/`` inside the Hermes repo and are
copied into ``~/.hermes/plugins/`` on install — no git clone needed, no network
required. They are NOT auto-discovered from ``optional-plugins/``; only installed
copies in ``~/.hermes/plugins/`` are loaded by Hermes.
After install, if the plugin ships an ``after-install.md`` file it is
rendered with Rich Markdown. Otherwise a default confirmation is shown.
@@ -95,10 +101,80 @@ def _resolve_git_url(identifier: str) -> str:
raise ValueError(
f"Invalid plugin identifier: '{identifier}'. "
"Use a Git URL or owner/repo shorthand."
"Use 'official/<category>/<name>', a Git URL, or owner/repo shorthand."
)
def _optional_plugins_dir() -> Path:
"""Return the optional-plugins/ directory shipped with the Hermes repo."""
return Path(__file__).resolve().parent.parent / "optional-plugins"
def _resolve_official_plugin(identifier: str) -> Optional[Path]:
"""If *identifier* is 'official/<category>/<name>', return its source path.
Returns ``None`` when the identifier is not in official format or the
plugin directory does not exist.
"""
# Accept 'official/category/name' or just 'category/name' when the
# category/name path exists under optional-plugins/.
parts = identifier.strip("/").split("/")
# Strip leading 'official' prefix if present
if parts and parts[0] == "official":
parts = parts[1:]
if len(parts) < 1:
return None
base = _optional_plugins_dir()
# Try category/name (2 parts) or bare name (1 part)
for nparts in (2, 1):
if len(parts) < nparts:
continue
candidate = base.joinpath(*parts[-nparts:])
try:
resolved = candidate.resolve()
base_resolved = base.resolve()
resolved.relative_to(base_resolved) # traversal guard
except (ValueError, OSError):
continue
if resolved.is_dir() and (
(resolved / "plugin.yaml").exists() or (resolved / "__init__.py").exists()
):
return resolved
return None
def _list_official_plugins() -> list[tuple[str, str]]:
"""Return [(identifier, description), ...] for all official optional plugins."""
base = _optional_plugins_dir()
if not base.is_dir():
return []
results = []
for category_dir in sorted(base.iterdir()):
if not category_dir.is_dir() or category_dir.name.startswith("."):
continue
for plugin_dir in sorted(category_dir.iterdir()):
if not plugin_dir.is_dir() or plugin_dir.name.startswith("."):
continue
manifest_file = plugin_dir / "plugin.yaml"
desc = ""
if manifest_file.exists():
try:
import yaml
data = yaml.safe_load(manifest_file.read_text()) or {}
desc = data.get("description", "")
except Exception:
pass
identifier = f"official/{category_dir.name}/{plugin_dir.name}"
results.append((identifier, desc))
return results
def _repo_name_from_url(url: str) -> str:
"""Extract the repo name from a Git URL for the plugin directory name."""
# Strip trailing .git and slashes
@@ -296,7 +372,61 @@ def cmd_install(
from rich.console import Console
console = Console()
plugins_dir = _plugins_dir()
# ── Official optional plugins (no network, copied from optional-plugins/) ──
official_src = _resolve_official_plugin(identifier)
if official_src is not None:
manifest = _read_manifest(official_src)
plugin_name = manifest.get("name") or official_src.name
target = _sanitize_plugin_name(plugin_name, plugins_dir)
if target.exists():
if not force:
console.print(
f"[red]Error:[/red] Plugin '{plugin_name}' already exists at {target}.\n"
f"Use [bold]--force[/bold] to reinstall, or "
f"[bold]hermes plugins update {plugin_name}[/bold] to update."
)
sys.exit(1)
console.print(f"[dim] Removing existing {plugin_name}...[/dim]")
shutil.rmtree(target)
console.print(f"[dim]Installing {plugin_name} from official optional plugins...[/dim]")
shutil.copytree(str(official_src), str(target))
_copy_example_files(target, console)
_prompt_plugin_env_vars(manifest, console)
_display_after_install(target, identifier)
installed_name = manifest.get("name") or target.name
should_enable = enable
if should_enable is None:
if sys.stdin.isatty() and sys.stdout.isatty():
try:
answer = input(" Enable now? [y/N] ").strip().lower()
should_enable = answer in ("y", "yes")
except (EOFError, KeyboardInterrupt):
should_enable = False
else:
should_enable = False
if should_enable:
enabled = _get_enabled_set()
disabled = _get_disabled_set()
enabled.add(installed_name)
disabled.discard(installed_name)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(f" [green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.")
else:
console.print(
f" [dim]Plugin installed but not enabled. "
f"Run [bold]hermes plugins enable {installed_name}[/bold] to activate.[/dim]"
)
return
# ── Git URL / owner/repo install ──────────────────────────────────────────
try:
git_url = _resolve_git_url(identifier)
except ValueError as e:
@@ -310,8 +440,6 @@ def cmd_install(
"Consider using https:// or git@ for production installs."
)
plugins_dir = _plugins_dir()
# Clone into a temp directory first so we can read plugin.yaml for the name
with tempfile.TemporaryDirectory() as tmp:
tmp_target = Path(tmp) / "plugin"
@@ -696,16 +824,21 @@ def _discover_all_plugins() -> list:
return list(seen.values())
def cmd_list() -> None:
"""List all plugins (bundled + user) with enabled/disabled state."""
def cmd_list(available: bool = False) -> None:
"""List all plugins (bundled + user) with enabled/disabled state.
When *available* is True, also show official optional plugins that are
not yet installed.
"""
from rich.console import Console
from rich.table import Table
console = Console()
entries = _discover_all_plugins()
if not entries:
if not entries and not available:
console.print("[dim]No plugins installed.[/dim]")
console.print("[dim]Install with:[/dim] hermes plugins install owner/repo")
console.print("[dim]Install with:[/dim] hermes plugins install official/<category>/<name>")
console.print("[dim]Browse available:[/dim] hermes plugins list --available")
return
enabled = _get_enabled_set()
@@ -734,6 +867,31 @@ def cmd_list() -> None:
console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable <name>")
console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]")
if available:
official = _list_official_plugins()
if official:
installed_names = {name for name, *_ in entries}
def _is_installed(ident: str) -> bool:
dirname = ident.rsplit("/", 1)[-1]
# Check both the directory name (langfuse-tracing) and
# common underscore variant (langfuse_tracing) since the
# installed plugin uses the manifest name, not the dir name.
return (dirname in installed_names
or dirname.replace("-", "_") in installed_names)
not_installed = [(ident, desc) for ident, desc in official
if not _is_installed(ident)]
if not_installed:
console.print()
avail_table = Table(title="Official optional plugins (not installed)", show_lines=False)
avail_table.add_column("Identifier", style="bold")
avail_table.add_column("Description")
for ident, desc in not_installed:
avail_table.add_row(ident, desc)
console.print(avail_table)
console.print("[dim]Install:[/dim] hermes plugins install official/<category>/<name>")
else:
console.print("[dim]All official optional plugins are already installed.[/dim]")
# ---------------------------------------------------------------------------
# Provider plugin discovery helpers
@@ -1270,7 +1428,7 @@ def plugins_command(args) -> None:
elif action == "disable":
cmd_disable(args.name)
elif action in ("list", "ls"):
cmd_list()
cmd_list(available=getattr(args, "available", False))
elif action is None:
cmd_toggle()
else:

View File

@@ -425,6 +425,31 @@ TOOL_CATEGORIES = {
},
],
},
"langfuse": {
"name": "Langfuse Observability",
"icon": "📊",
"providers": [
{
"name": "Langfuse Cloud",
"tag": "Hosted Langfuse (cloud.langfuse.com)",
"env_vars": [
{"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)", "url": "https://cloud.langfuse.com"},
{"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)", "url": "https://cloud.langfuse.com"},
],
"post_setup": "langfuse",
},
{
"name": "Langfuse Self-Hosted",
"tag": "Self-hosted Langfuse instance",
"env_vars": [
{"key": "HERMES_LANGFUSE_PUBLIC_KEY", "prompt": "Langfuse public key (pk-lf-...)"},
{"key": "HERMES_LANGFUSE_SECRET_KEY", "prompt": "Langfuse secret key (sk-lf-...)"},
{"key": "HERMES_LANGFUSE_BASE_URL", "prompt": "Langfuse server URL (e.g. http://localhost:3000)", "default": "http://localhost:3000"},
],
"post_setup": "langfuse",
},
],
},
}
# Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES.
@@ -567,6 +592,31 @@ def _run_post_setup(post_setup_key: str):
_print_info(" git submodule update --init --recursive")
_print_info(' uv pip install -e "./tinker-atropos"')
elif post_setup_key == "langfuse":
# Install the langfuse SDK.
try:
__import__("langfuse")
_print_success(" langfuse SDK already installed")
except ImportError:
import subprocess
_print_info(" Installing langfuse SDK...")
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "langfuse", "--quiet"],
capture_output=True, text=True, timeout=120,
)
if result.returncode == 0:
_print_success(" langfuse SDK installed")
else:
_print_warning(" langfuse SDK install failed — run manually: pip install langfuse")
# Install and enable the official optional plugin into ~/.hermes/plugins/.
try:
from hermes_cli.plugins_cmd import cmd_install as _plugins_install
_plugins_install("official/observability/langfuse", enable=True)
except SystemExit:
pass # cmd_install prints its own errors and calls sys.exit
_print_info(" Restart Hermes for tracing to take effect.")
_print_info(" Verify: hermes plugins list")
# ─── Platform / Toolset Helpers ───────────────────────────────────────────────

View File

@@ -285,130 +285,156 @@ class SessionDB:
self._conn.close()
self._conn = None
@staticmethod
def _parse_schema_columns(schema_sql: str) -> Dict[str, Dict[str, str]]:
"""Extract expected columns per table from SCHEMA_SQL.
Uses an in-memory SQLite database to parse the SQL — SQLite itself
handles all syntax (DEFAULT expressions with commas, inline
REFERENCES, CHECK constraints, etc.) so there are zero regex
edge cases. The in-memory DB is opened, the schema DDL is
executed, and PRAGMA table_info extracts the column metadata.
Adding a column to SCHEMA_SQL is all that's needed; the
reconciliation loop picks it up automatically.
"""
ref = sqlite3.connect(":memory:")
try:
ref.executescript(schema_sql)
table_columns: Dict[str, Dict[str, str]] = {}
for (tbl,) in ref.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall():
cols: Dict[str, str] = {}
for row in ref.execute(
f'PRAGMA table_info("{tbl}")'
).fetchall():
# row: (cid, name, type, notnull, dflt_value, pk)
col_name = row[1]
col_type = row[2] or ""
notnull = row[3]
default = row[4]
pk = row[5]
# Reconstruct the type expression for ALTER TABLE ADD COLUMN
parts = [col_type] if col_type else []
if notnull and not pk:
parts.append("NOT NULL")
if default is not None:
parts.append(f"DEFAULT {default}")
cols[col_name] = " ".join(parts)
table_columns[tbl] = cols
return table_columns
finally:
ref.close()
def _reconcile_columns(self, cursor: sqlite3.Cursor) -> None:
"""Ensure live tables have every column declared in SCHEMA_SQL.
Follows the Beets/sqlite-utils pattern: the CREATE TABLE definition
in SCHEMA_SQL is the single source of truth for the desired schema.
On every startup this method diffs the live columns (via PRAGMA
table_info) against the declared columns, and ADDs any that are
missing.
This makes column additions a declarative operation — just add
the column to SCHEMA_SQL and it appears on the next startup.
Version-gated migration blocks are no longer needed for ADD COLUMN.
"""
expected = self._parse_schema_columns(SCHEMA_SQL)
for table_name, declared_cols in expected.items():
# Get current columns from the live table
try:
rows = cursor.execute(
f'PRAGMA table_info("{table_name}")'
).fetchall()
except sqlite3.OperationalError:
continue # Table doesn't exist yet (shouldn't happen after executescript)
live_cols = set()
for row in rows:
# PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)
name = row[1] if isinstance(row, (tuple, list)) else row["name"]
live_cols.add(name)
for col_name, col_type in declared_cols.items():
if col_name not in live_cols:
safe_name = col_name.replace('"', '""')
try:
cursor.execute(
f'ALTER TABLE "{table_name}" ADD COLUMN "{safe_name}" {col_type}'
)
except sqlite3.OperationalError as exc:
# Expected: "duplicate column name" from a race or
# re-run. Unexpected: "Cannot add a NOT NULL column
# with default value NULL" from a schema mistake.
# Log at DEBUG so it's visible in agent.log.
logger.debug(
"reconcile %s.%s: %s", table_name, col_name, exc,
)
def _init_schema(self):
"""Create tables and FTS if they don't exist, run migrations."""
"""Create tables and FTS if they don't exist, reconcile columns.
Schema management follows the declarative reconciliation pattern
(Beets, sqlite-utils): SCHEMA_SQL is the single source of truth.
On existing databases, _reconcile_columns() diffs live columns
against SCHEMA_SQL and ADDs any missing ones. This eliminates
the version-gated migration chain for column additions, making
it impossible for reordered or inserted migrations to skip columns.
The schema_version table is retained for future data migrations
(transforming existing rows) which cannot be handled declaratively.
"""
cursor = self._conn.cursor()
cursor.executescript(SCHEMA_SQL)
# Check schema version and run migrations
# ── Declarative column reconciliation ──────────────────────────
# Diff live tables against SCHEMA_SQL and ADD any missing columns.
# This is idempotent and self-healing: even if a version-gated
# migration was skipped (e.g. due to version renumbering), the
# column gets created here.
self._reconcile_columns(cursor)
# ── Schema version bookkeeping ─────────────────────────────────
# Bump to current so future data migrations (if any) can gate on
# version. No version-gated column additions remain.
cursor.execute("SELECT version FROM schema_version LIMIT 1")
row = cursor.fetchone()
if row is None:
cursor.execute("INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,))
cursor.execute(
"INSERT INTO schema_version (version) VALUES (?)",
(SCHEMA_VERSION,),
)
else:
current_version = row["version"] if isinstance(row, sqlite3.Row) else row[0]
if current_version < 2:
# v2: add finish_reason column to messages
try:
cursor.execute("ALTER TABLE messages ADD COLUMN finish_reason TEXT")
except sqlite3.OperationalError:
pass # Column already exists
cursor.execute("UPDATE schema_version SET version = 2")
if current_version < 3:
# v3: add title column to sessions
try:
cursor.execute("ALTER TABLE sessions ADD COLUMN title TEXT")
except sqlite3.OperationalError:
pass # Column already exists
cursor.execute("UPDATE schema_version SET version = 3")
if current_version < 4:
# v4: add unique index on title (NULLs allowed, only non-NULL must be unique)
try:
cursor.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
"ON sessions(title) WHERE title IS NOT NULL"
)
except sqlite3.OperationalError:
pass # Index already exists
cursor.execute("UPDATE schema_version SET version = 4")
if current_version < 5:
new_columns = [
("cache_read_tokens", "INTEGER DEFAULT 0"),
("cache_write_tokens", "INTEGER DEFAULT 0"),
("reasoning_tokens", "INTEGER DEFAULT 0"),
("billing_provider", "TEXT"),
("billing_base_url", "TEXT"),
("billing_mode", "TEXT"),
("estimated_cost_usd", "REAL"),
("actual_cost_usd", "REAL"),
("cost_status", "TEXT"),
("cost_source", "TEXT"),
("pricing_version", "TEXT"),
]
for name, column_type in new_columns:
try:
# name and column_type come from the hardcoded tuple above,
# not user input. Double-quote identifier escaping is applied
# as defense-in-depth; SQLite DDL cannot be parameterized.
safe_name = name.replace('"', '""')
cursor.execute(f'ALTER TABLE sessions ADD COLUMN "{safe_name}" {column_type}')
except sqlite3.OperationalError:
pass
cursor.execute("UPDATE schema_version SET version = 5")
if current_version < 6:
# v6: add reasoning columns to messages table — preserves assistant
# reasoning text and structured reasoning_details across gateway
# session turns. Without these, reasoning chains are lost on
# session reload, breaking multi-turn reasoning continuity for
# providers that replay reasoning (OpenRouter, OpenAI, Nous).
for col_name, col_type in [
("reasoning", "TEXT"),
("reasoning_details", "TEXT"),
("codex_reasoning_items", "TEXT"),
]:
try:
safe = col_name.replace('"', '""')
cursor.execute(
f'ALTER TABLE messages ADD COLUMN "{safe}" {col_type}'
)
except sqlite3.OperationalError:
pass # Column already exists
cursor.execute("UPDATE schema_version SET version = 6")
if current_version < 7:
# v7: preserve provider-native reasoning_content separately from
# normalized reasoning text. Kimi/Moonshot replay can require
# this field on assistant tool-call messages when thinking is on.
try:
cursor.execute('ALTER TABLE messages ADD COLUMN "reasoning_content" TEXT')
except sqlite3.OperationalError:
pass # Column already exists
cursor.execute("UPDATE schema_version SET version = 7")
if current_version < 8:
# v8: add api_call_count column to sessions — tracks the number
# of individual LLM API calls made within a session (as opposed
# to the session count itself).
try:
cursor.execute(
'ALTER TABLE sessions ADD COLUMN "api_call_count" INTEGER DEFAULT 0'
)
except sqlite3.OperationalError:
pass # Column already exists
cursor.execute("UPDATE schema_version SET version = 8")
if current_version < 9:
# v9: preserve replayable Codex assistant message ids/phases so
# follow-up turns can rebuild Responses API message items instead
# of flattening everything to plain assistant text.
try:
cursor.execute('ALTER TABLE messages ADD COLUMN "codex_message_items" TEXT')
except sqlite3.OperationalError:
pass # Column already exists
cursor.execute("UPDATE schema_version SET version = 9")
# Data migrations that can't be expressed declaratively (row
# backfills, index changes tied to a specific version step) stay
# in a version-gated chain. Column additions are handled by
# _reconcile_columns() above and no longer need entries here.
if current_version < 10:
# v10: trigram FTS5 table for CJK/substring search.
# Created via FTS_TRIGRAM_SQL below; backfill existing messages.
# v10: trigram FTS5 table for CJK/substring search. The
# virtual table + triggers are created unconditionally via
# FTS_TRIGRAM_SQL below, but existing rows need a one-time
# backfill into the FTS index.
try:
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
_fts_trigram_exists = True
except sqlite3.OperationalError:
_fts_trigram_exists = False
if not _fts_trigram_exists:
cursor.executescript(FTS_TRIGRAM_SQL)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, content FROM messages WHERE content IS NOT NULL"
)
cursor.execute("UPDATE schema_version SET version = 10")
if current_version < SCHEMA_VERSION:
cursor.execute(
"UPDATE schema_version SET version = ?",
(SCHEMA_VERSION,),
)
# Unique title index — always ensure it exists (safe to run after migrations
# since the title column is guaranteed to exist at this point)
# Unique title index — always ensure it exists
try:
cursor.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "

View File

@@ -0,0 +1,875 @@
"""langfuse — Hermes plugin for Langfuse observability.
Traces Hermes conversations, LLM calls, and tool usage to Langfuse.
Enable via ``hermes tools`` or by setting HERMES_LANGFUSE_ENABLED=true
and the required credentials in ~/.hermes/.env.
Required env vars (set via ``hermes tools`` or ~/.hermes/.env):
HERMES_LANGFUSE_ENABLED - set to "true" to activate tracing
HERMES_LANGFUSE_PUBLIC_KEY - Langfuse project public key (pk-lf-...)
HERMES_LANGFUSE_SECRET_KEY - Langfuse project secret key (sk-lf-...)
HERMES_LANGFUSE_BASE_URL - Langfuse server URL (default: https://cloud.langfuse.com)
Optional env vars:
HERMES_LANGFUSE_ENV - environment tag (e.g. "production", "local")
HERMES_LANGFUSE_RELEASE - release/version tag
HERMES_LANGFUSE_SAMPLE_RATE - sampling rate 0.01.0 (default: 1.0)
HERMES_LANGFUSE_MAX_CHARS - max chars per field (default: 12000)
HERMES_LANGFUSE_DEBUG - set to "true" for verbose logging
"""
from __future__ import annotations
import json
import logging
import os
import re
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
try:
from langfuse import Langfuse, propagate_attributes
except Exception: # pragma: no cover - fail-open when optional dep is missing
Langfuse = None
propagate_attributes = None
@dataclass
class TraceState:
trace_id: str
root_ctx: Any
root_span: Any
generations: Dict[str, Any] = field(default_factory=dict)
tools: Dict[str, Any] = field(default_factory=dict)
turn_tool_calls: list[dict[str, Any]] = field(default_factory=list)
last_updated_at: float = field(default_factory=time.time)
_STATE_LOCK = threading.Lock()
_TRACE_STATE: Dict[str, TraceState] = {}
_LANGFUSE_CLIENT = None
_READ_FILE_LINE_RE = re.compile(r"^\s*(\d+)\|(.*)$")
_READ_FILE_HEAD_LINES = 25
_READ_FILE_TAIL_LINES = 15
def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
def _env_bool(*names: str) -> bool:
for name in names:
value = _env(name).lower()
if value:
return value in {"1", "true", "yes", "on"}
return False
def _debug_enabled() -> bool:
return _env_bool("HERMES_LANGFUSE_DEBUG")
def _debug(message: str) -> None:
if _debug_enabled():
logger.info("Langfuse tracing: %s", message)
def _is_enabled() -> bool:
if Langfuse is None:
return False
# Primary activation path: config.yaml plugins.langfuse.enabled
try:
from hermes_cli.config import load_config
_cfg = load_config()
_plugin_cfg = _cfg.get("plugins", {})
if isinstance(_plugin_cfg, dict):
_lt_cfg = _plugin_cfg.get("langfuse", {})
if isinstance(_lt_cfg, dict) and "enabled" in _lt_cfg:
if not _lt_cfg["enabled"]:
return False
# Explicit enabled=true in config — skip env-var check below
public_key = _env("HERMES_LANGFUSE_PUBLIC_KEY") or _env("LANGFUSE_PUBLIC_KEY")
secret_key = _env("HERMES_LANGFUSE_SECRET_KEY") or _env("LANGFUSE_SECRET_KEY")
return bool(public_key and secret_key)
except Exception:
pass
# Backward-compat path: HERMES_LANGFUSE_ENABLED env var (legacy .env installs)
if not _env_bool("HERMES_LANGFUSE_ENABLED"):
return False
public_key = _env("HERMES_LANGFUSE_PUBLIC_KEY") or _env("LANGFUSE_PUBLIC_KEY")
secret_key = _env("HERMES_LANGFUSE_SECRET_KEY") or _env("LANGFUSE_SECRET_KEY")
return bool(public_key and secret_key)
def _get_langfuse() -> Optional[Langfuse]:
global _LANGFUSE_CLIENT
if not _is_enabled():
return None
if _LANGFUSE_CLIENT is not None:
return _LANGFUSE_CLIENT
public_key = _env("HERMES_LANGFUSE_PUBLIC_KEY") or _env("LANGFUSE_PUBLIC_KEY")
secret_key = _env("HERMES_LANGFUSE_SECRET_KEY") or _env("LANGFUSE_SECRET_KEY")
base_url = _env("HERMES_LANGFUSE_BASE_URL") or _env("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com"
environment = _env("HERMES_LANGFUSE_ENV") or _env("LANGFUSE_ENV")
release = _env("HERMES_LANGFUSE_RELEASE") or _env("LANGFUSE_RELEASE")
sample_rate = _env("HERMES_LANGFUSE_SAMPLE_RATE")
kwargs: Dict[str, Any] = {
"public_key": public_key,
"secret_key": secret_key,
"base_url": base_url,
}
if environment:
kwargs["environment"] = environment
if release:
kwargs["release"] = release
if sample_rate:
try:
kwargs["sample_rate"] = float(sample_rate)
except ValueError:
logger.warning("Invalid HERMES_LANGFUSE_SAMPLE_RATE=%r", sample_rate)
try:
_LANGFUSE_CLIENT = Langfuse(**kwargs)
except Exception as exc: # pragma: no cover - fail-open
logger.warning("Could not initialize Langfuse client: %s", exc)
return None
return _LANGFUSE_CLIENT
def _trace_key(task_id: str, session_id: str) -> str:
if task_id:
return task_id
if session_id:
return f"session:{session_id}"
return f"thread:{threading.get_ident()}"
def _truncate_text(value: str, max_chars: int) -> str:
if len(value) <= max_chars:
return value
return value[:max_chars] + f"... [truncated {len(value) - max_chars} chars]"
def _maybe_parse_json_string(value: str) -> Any:
stripped = value.strip()
if len(stripped) < 2 or stripped[0] not in "{[" or stripped[-1] not in "}]":
if len(stripped) < 2 or stripped[0] not in "{[":
return value
try:
parsed, idx = json.JSONDecoder().raw_decode(stripped)
except Exception:
return value
if not isinstance(parsed, (dict, list)):
return value
trailing = stripped[idx:].strip()
if not trailing:
return parsed
hint_key = "_hint" if trailing.startswith("[Hint:") else "_trailing_text"
if isinstance(parsed, dict):
merged = dict(parsed)
key = hint_key if hint_key not in merged else "_trailing_text"
merged[key] = trailing
return merged
return {"data": parsed, hint_key: trailing}
def _looks_like_read_file_payload(value: Any) -> bool:
if not isinstance(value, dict):
return False
content = value.get("content")
return (
isinstance(content, str)
and "total_lines" in value
and "file_size" in value
and "is_binary" in value
and "is_image" in value
and not value.get("error")
)
def _parse_read_file_lines(content: str) -> list[dict[str, Any]]:
if not isinstance(content, str) or not content:
return []
lines = []
for raw_line in content.splitlines():
match = _READ_FILE_LINE_RE.match(raw_line)
if not match:
return []
lines.append({
"line": int(match.group(1)),
"text": match.group(2),
})
return lines
def _build_read_file_preview(lines: list[dict[str, Any]]) -> dict[str, Any]:
if len(lines) <= (_READ_FILE_HEAD_LINES + _READ_FILE_TAIL_LINES):
return {"lines": lines}
return {
"head": lines[:_READ_FILE_HEAD_LINES],
"tail": lines[-_READ_FILE_TAIL_LINES:],
"omitted_line_count": len(lines) - _READ_FILE_HEAD_LINES - _READ_FILE_TAIL_LINES,
}
def _normalize_read_file_payload(value: dict[str, Any], *, args: Any = None) -> dict[str, Any]:
normalized: dict[str, Any] = {}
if isinstance(args, dict):
path = args.get("path")
offset = args.get("offset")
limit = args.get("limit")
if isinstance(path, str) and path:
normalized["path"] = path
if isinstance(offset, int):
normalized["offset"] = offset
if isinstance(limit, int):
normalized["limit"] = limit
lines = _parse_read_file_lines(value.get("content", ""))
if lines:
normalized["returned_lines"] = {
"start": lines[0]["line"],
"end": lines[-1]["line"],
"count": len(lines),
}
normalized["content_preview"] = _build_read_file_preview(lines)
elif value.get("content"):
normalized["content_preview"] = {
"text": value.get("content", ""),
}
for key in (
"total_lines",
"file_size",
"truncated",
"is_binary",
"is_image",
"hint",
"_warning",
"mime_type",
"dimensions",
"similar_files",
"error",
):
if key in value:
normalized[key] = value[key]
base64_content = value.get("base64_content")
if isinstance(base64_content, str) and base64_content:
normalized["base64_content"] = {
"omitted": True,
"length": len(base64_content),
}
return normalized
def _normalize_payload(value: Any, *, tool_name: str = "", args: Any = None) -> Any:
if _looks_like_read_file_payload(value):
return _normalize_read_file_payload(
value,
args=args if tool_name == "read_file" else None,
)
return value
def _safe_value(value: Any, *, max_chars: Optional[int] = None, depth: int = 0,
parse_json_strings: bool = False) -> Any:
max_chars = max_chars if max_chars is not None else int(_env("HERMES_LANGFUSE_MAX_CHARS", "12000") or "12000")
if depth > 4:
return "<max-depth>"
if value is None or isinstance(value, (int, float, bool)):
return value
if isinstance(value, bytes):
return {"type": "bytes", "len": len(value)}
if isinstance(value, str):
if parse_json_strings:
parsed = _maybe_parse_json_string(value)
if parsed is not value:
return _safe_value(parsed, max_chars=max_chars, depth=depth, parse_json_strings=True)
return _truncate_text(value, max_chars)
if isinstance(value, dict):
normalized = _normalize_payload(value)
if normalized is not value:
return _safe_value(normalized, max_chars=max_chars, depth=depth, parse_json_strings=parse_json_strings)
return {
str(k): _safe_value(v, max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
for k, v in list(value.items())[:50]
}
if isinstance(value, (list, tuple, set)):
return [
_safe_value(v, max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
for v in list(value)[:50]
]
if hasattr(value, "__dict__"):
return _safe_value(vars(value), max_chars=max_chars, depth=depth + 1, parse_json_strings=parse_json_strings)
return _truncate_text(repr(value), max_chars)
def _extract_last_user_message(messages: Any) -> Any:
if not isinstance(messages, list):
return None
for message in reversed(messages):
if isinstance(message, dict) and message.get("role") == "user":
return {
"role": "user",
"content": _safe_value(message.get("content")),
}
return None
def _serialize_messages(messages: Any) -> list[dict[str, Any]]:
if not isinstance(messages, list):
return []
serialized = []
for message in messages[-12:]:
if not isinstance(message, dict):
continue
role = message.get("role")
item = {
"role": role,
"content": _safe_value(
message.get("content"),
parse_json_strings=(role == "tool"),
),
}
if role == "tool" and message.get("tool_call_id"):
item["tool_call_id"] = message.get("tool_call_id")
if message.get("tool_calls"):
item["tool_calls"] = _safe_value(message.get("tool_calls"), parse_json_strings=True)
serialized.append(item)
return serialized
def _serialize_tool_calls(tool_calls: Any) -> list[dict[str, Any]]:
if not tool_calls:
return []
serialized = []
for tool_call in tool_calls:
fn = getattr(tool_call, "function", None)
name = getattr(fn, "name", None) if fn else None
arguments = getattr(fn, "arguments", None) if fn else None
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except Exception:
pass
serialized.append({
"id": getattr(tool_call, "id", None),
"name": name,
"arguments": _safe_value(arguments, parse_json_strings=True),
})
return serialized
def _serialize_assistant_message(message: Any) -> dict[str, Any]:
return {
"content": _safe_value(getattr(message, "content", None)),
"reasoning": _safe_value(getattr(message, "reasoning", None)),
"tool_calls": _serialize_tool_calls(getattr(message, "tool_calls", None)),
}
def _usage_and_cost(response: Any, *, provider: str, api_mode: str, model: str, base_url: str) -> tuple[dict[str, int], dict[str, float]]:
usage_details: Dict[str, int] = {}
cost_details: Dict[str, float] = {}
raw_usage = getattr(response, "usage", None)
if not raw_usage:
return usage_details, cost_details
try:
from agent.usage_pricing import estimate_usage_cost, normalize_usage
canonical = normalize_usage(raw_usage, provider=provider, api_mode=api_mode)
# Langfuse usage_details keys follow a naming convention:
# - Dashboard sums all keys containing "input" as input total
# - Dashboard sums all keys containing "output" as output total
# - If no "total" key, Langfuse derives it from all usage types
# Use Anthropic-style key names so cache tokens roll into the
# dashboard input total automatically.
# Ref: https://langfuse.com/docs/model-usage-and-cost
usage_details = {
"input": canonical.input_tokens,
"output": canonical.output_tokens,
}
if canonical.cache_read_tokens:
usage_details["cache_read_input_tokens"] = canonical.cache_read_tokens
if canonical.cache_write_tokens:
usage_details["cache_creation_input_tokens"] = canonical.cache_write_tokens
if canonical.reasoning_tokens:
usage_details["reasoning_tokens"] = canonical.reasoning_tokens
cost = estimate_usage_cost(
model,
canonical,
provider=provider,
base_url=base_url,
api_key="",
)
if cost.amount_usd is not None:
# Langfuse cost_details keys must match usage_details keys.
# Provide per-type breakdown so dashboard can show cost by type.
try:
from agent.usage_pricing import get_pricing_entry
from decimal import Decimal
_ONE_M = Decimal("1000000")
entry = get_pricing_entry(model, provider=provider, base_url=base_url)
if entry:
if entry.input_cost_per_million is not None and canonical.input_tokens:
cost_details["input"] = float(Decimal(canonical.input_tokens) * entry.input_cost_per_million / _ONE_M)
if entry.output_cost_per_million is not None and canonical.output_tokens:
cost_details["output"] = float(Decimal(canonical.output_tokens) * entry.output_cost_per_million / _ONE_M)
if entry.cache_read_cost_per_million is not None and canonical.cache_read_tokens:
cost_details["cache_read_input_tokens"] = float(Decimal(canonical.cache_read_tokens) * entry.cache_read_cost_per_million / _ONE_M)
if entry.cache_write_cost_per_million is not None and canonical.cache_write_tokens:
cost_details["cache_creation_input_tokens"] = float(Decimal(canonical.cache_write_tokens) * entry.cache_write_cost_per_million / _ONE_M)
else:
cost_details["total"] = float(cost.amount_usd)
except Exception:
cost_details["total"] = float(cost.amount_usd)
except Exception as exc: # pragma: no cover - fail-open
_debug(f"usage normalization failed: {exc}")
return usage_details, cost_details
def _start_root_trace(task_key: str, *, task_id: str, session_id: str, platform: str, provider: str, model: str,
api_mode: str, messages: Any, client: Langfuse) -> TraceState:
trace_id = client.create_trace_id(seed=f"{session_id or 'sessionless'}::{task_id or task_key}")
trace_input = _extract_last_user_message(messages)
metadata = {
"source": "hermes",
"task_id": task_id,
"platform": platform,
"provider": provider,
"model": model,
"api_mode": api_mode,
}
# session_id must be passed in trace_context for Langfuse session grouping.
trace_ctx: Dict[str, Any] = {"trace_id": trace_id}
if session_id:
trace_ctx["session_id"] = session_id
if propagate_attributes is not None:
try:
with propagate_attributes(
session_id=session_id or task_key,
trace_name="Hermes turn",
tags=["hermes", "langfuse"],
):
root_ctx = client.start_as_current_observation(
trace_context=trace_ctx,
name="Hermes turn",
as_type="chain",
input=trace_input,
metadata=metadata,
end_on_exit=False,
)
root_span = root_ctx.__enter__()
except Exception:
root_ctx = client.start_as_current_observation(
trace_context=trace_ctx,
name="Hermes turn",
as_type="chain",
input=trace_input,
metadata=metadata,
end_on_exit=False,
)
root_span = root_ctx.__enter__()
else:
root_ctx = client.start_as_current_observation(
trace_context=trace_ctx,
name="Hermes turn",
as_type="chain",
input=trace_input,
metadata=metadata,
end_on_exit=False,
)
root_span = root_ctx.__enter__()
try:
root_span.set_trace_io(input=trace_input)
except Exception:
pass
_debug(f"started trace {trace_id} for {task_key}")
return TraceState(trace_id=trace_id, root_ctx=root_ctx, root_span=root_span)
def _start_child_observation(state: TraceState, *, client: Langfuse, name: str, as_type: str,
input_value: Any, metadata: Optional[dict] = None,
model: Optional[str] = None, model_parameters: Optional[dict] = None) -> Any:
return state.root_span.start_observation(
name=name,
as_type=as_type,
input=input_value,
metadata=metadata or {},
model=model,
model_parameters=model_parameters,
)
def _end_observation(observation: Any, *, output: Any = None, metadata: Optional[dict] = None,
usage_details: Optional[dict] = None, cost_details: Optional[dict] = None) -> None:
if observation is None:
return
try:
update_kwargs: Dict[str, Any] = {}
if output is not None:
update_kwargs["output"] = output
if metadata:
update_kwargs["metadata"] = metadata
if usage_details:
update_kwargs["usage_details"] = usage_details
if cost_details:
update_kwargs["cost_details"] = cost_details
if update_kwargs:
observation.update(**update_kwargs)
observation.end()
except Exception as exc: # pragma: no cover - fail-open
_debug(f"end observation failed: {exc}")
def _merge_trace_output(output: Any, state: TraceState) -> Any:
if not state.turn_tool_calls:
return output
merged = dict(output) if isinstance(output, dict) else {"content": output}
merged["tool_calls"] = list(state.turn_tool_calls)
return merged
def _finish_trace(task_key: str, *, output: Any = None) -> None:
client = _get_langfuse()
if client is None:
return
with _STATE_LOCK:
state = _TRACE_STATE.pop(task_key, None)
if state is None:
return
try:
for observation in state.generations.values():
_end_observation(observation)
for observation in state.tools.values():
_end_observation(observation)
final_output = _merge_trace_output(output, state)
if final_output is not None:
state.root_span.set_trace_io(output=final_output)
state.root_span.update(output=final_output)
state.root_span.end()
except Exception as exc: # pragma: no cover - fail-open
_debug(f"finish trace failed: {exc}")
finally:
try:
client.flush()
except Exception:
pass
def _assistant_has_tool_calls(message: Any) -> bool:
return bool(getattr(message, "tool_calls", None))
def _request_key(api_call_count: Any) -> str:
return str(api_call_count or 0)
def on_pre_llm_call(*, task_id: str = "", session_id: str = "", platform: str = "", model: str = "",
provider: str = "", base_url: str = "", api_mode: str = "",
api_call_count: int = 0, messages: Any = None, turn_type: str = "user",
conversation_history: Any = None, user_message: Any = None, **_: Any) -> None:
# Older Hermes branches used pre_llm_call for request-scoped tracing and
# passed the actual API messages. Current Hermes also has a turn-scoped
# pre_llm_call used for context injection; tracing that hook creates an
# extra orphan/root trace before the real request trace. Only trace the
# legacy request-shaped call here.
if not isinstance(messages, list):
return
client = _get_langfuse()
if client is None:
return
# messages is a list only for legacy Hermes branches that fired
# pre_llm_call with API messages directly. Current Hermes fires
# pre_llm_call for context injection (conversation_history/user_message,
# no messages list) — tracing that would create orphan traces.
task_key = _trace_key(task_id, session_id)
with _STATE_LOCK:
state = _TRACE_STATE.get(task_key)
if state is None:
state = _start_root_trace(
task_key,
task_id=task_id,
session_id=session_id,
platform=platform,
provider=provider,
model=model,
api_mode=api_mode,
messages=messages,
client=client,
)
_TRACE_STATE[task_key] = state
state.last_updated_at = time.time()
def on_pre_llm_request(
*,
task_id: str = "",
session_id: str = "",
platform: str = "",
model: str = "",
provider: str = "",
base_url: str = "",
api_mode: str = "",
api_call_count: int = 0,
messages: Any = None,
turn_type: str = "user",
message_count: int = 0,
tool_count: int = 0,
approx_input_tokens: int = 0,
request_char_count: int = 0,
max_tokens: Any = None,
**_: Any,
) -> None:
client = _get_langfuse()
if client is None:
return
task_key = _trace_key(task_id, session_id)
req_key = _request_key(api_call_count)
with _STATE_LOCK:
state = _TRACE_STATE.get(task_key)
if state is None:
state = _start_root_trace(
task_key,
task_id=task_id,
session_id=session_id,
platform=platform,
provider=provider,
model=model,
api_mode=api_mode,
messages=messages,
client=client,
)
_TRACE_STATE[task_key] = state
state.last_updated_at = time.time()
previous = state.generations.pop(req_key, None)
if previous is not None:
_end_observation(previous)
state.generations[req_key] = _start_child_observation(
state,
client=client,
name=f"LLM call {api_call_count}",
as_type="generation",
input_value=_serialize_messages(messages),
metadata={
"provider": provider,
"platform": platform,
"api_mode": api_mode,
"base_url": base_url,
},
model=model,
model_parameters={"api_mode": api_mode, "provider": provider},
)
def on_post_llm_call(*, task_id: str = "", session_id: str = "", provider: str = "", base_url: str = "",
api_mode: str = "", model: str = "", api_call_count: int = 0,
assistant_message: Any = None, response: Any = None,
api_duration: float = 0.0, finish_reason: str = "",
usage: Any = None, assistant_content_chars: int = 0,
assistant_tool_call_count: int = 0, assistant_response: Any = None,
**_: Any) -> None:
client = _get_langfuse()
if client is None:
return
task_key = _trace_key(task_id, session_id)
req_key = _request_key(api_call_count)
with _STATE_LOCK:
state = _TRACE_STATE.get(task_key)
generation = state.generations.pop(req_key, None) if state else None
if state is None or generation is None:
return
# Handle both call patterns:
# 1. post_api_request: passes usage (dict), assistant_content_chars, assistant_tool_call_count
# 2. post_llm_call: passes assistant_message (object), response (object), assistant_response (str)
if assistant_message is not None:
output = _serialize_assistant_message(assistant_message)
elif assistant_response is not None:
# post_llm_call passes assistant_response as a plain string
output = {"content": _safe_value(assistant_response), "reasoning": None, "tool_calls": []}
else:
# post_api_request path — reconstruct from summary kwargs
output = {
"content": f"[{assistant_content_chars} chars]" if assistant_content_chars else None,
"reasoning": None,
"tool_calls": [{"id": f"tc_{i}"} for i in range(assistant_tool_call_count)] if assistant_tool_call_count else [],
}
if output.get("tool_calls"):
state.turn_tool_calls.extend(output["tool_calls"])
# Extract usage: prefer response object, fall back to usage dict from post_api_request
if response is not None:
usage_details, cost_details = _usage_and_cost(
response,
provider=provider,
api_mode=api_mode,
model=model,
base_url=base_url,
)
elif isinstance(usage, dict) and usage:
# post_api_request passes a pre-built CanonicalUsage summary dict.
# Use Langfuse-convention key names: "input", "output", and
# "cache_read_input_tokens" / "cache_creation_input_tokens" so the
# dashboard sums cache tokens into the input total automatically.
_input = usage.get("input_tokens", 0)
_output = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0)
_cache_read = usage.get("cache_read_tokens", 0)
_cache_write = usage.get("cache_write_tokens", 0)
_reasoning = usage.get("reasoning_tokens", 0)
usage_details = {
"input": _input,
"output": _output,
}
if _cache_read:
usage_details["cache_read_input_tokens"] = _cache_read
if _cache_write:
usage_details["cache_creation_input_tokens"] = _cache_write
if _reasoning:
usage_details["reasoning_tokens"] = _reasoning
cost_details = {}
# Estimate per-type cost from the summary if possible
try:
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost, get_pricing_entry
from decimal import Decimal
_ONE_M = Decimal("1000000")
_cu = CanonicalUsage(
input_tokens=_input,
output_tokens=_output,
cache_read_tokens=_cache_read,
cache_write_tokens=_cache_write,
reasoning_tokens=_reasoning,
)
entry = get_pricing_entry(model, provider=provider, base_url=base_url)
if entry:
if entry.input_cost_per_million is not None and _input:
cost_details["input"] = float(Decimal(_input) * entry.input_cost_per_million / _ONE_M)
if entry.output_cost_per_million is not None and _output:
cost_details["output"] = float(Decimal(_output) * entry.output_cost_per_million / _ONE_M)
if entry.cache_read_cost_per_million is not None and _cache_read:
cost_details["cache_read_input_tokens"] = float(Decimal(_cache_read) * entry.cache_read_cost_per_million / _ONE_M)
if entry.cache_write_cost_per_million is not None and _cache_write:
cost_details["cache_creation_input_tokens"] = float(Decimal(_cache_write) * entry.cache_write_cost_per_million / _ONE_M)
else:
_cost = estimate_usage_cost(model, _cu, provider=provider, base_url=base_url, api_key="")
if _cost.amount_usd is not None:
cost_details["total"] = float(_cost.amount_usd)
except Exception:
pass
else:
usage_details, cost_details = {}, {}
tool_count = len(output.get("tool_calls", [])) or assistant_tool_call_count
gen_metadata: Dict[str, Any] = {"tool_call_count": tool_count}
if api_duration and api_duration > 0:
gen_metadata["api_duration_s"] = round(api_duration, 3)
if finish_reason:
gen_metadata["finish_reason"] = finish_reason
_end_observation(
generation,
output=output,
usage_details=usage_details,
cost_details=cost_details,
metadata=gen_metadata,
)
has_tools = _assistant_has_tool_calls(assistant_message) if assistant_message else (assistant_tool_call_count > 0)
has_content = bool(output.get("content"))
if not has_tools and has_content:
_finish_trace(task_key, output=output)
def on_pre_tool_call(*, tool_name: str = "", args: Any = None, task_id: str = "",
session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
client = _get_langfuse()
if client is None:
return
task_key = _trace_key(task_id, session_id)
tool_key = tool_call_id or f"{tool_name}:{time.time_ns()}"
with _STATE_LOCK:
state = _TRACE_STATE.get(task_key)
if state is None:
return
state.tools[tool_key] = _start_child_observation(
state,
client=client,
name=f"Tool: {tool_name}",
as_type="tool",
input_value=_safe_value(args),
metadata={"tool_name": tool_name, "tool_call_id": tool_call_id},
)
def on_post_tool_call(*, tool_name: str = "", args: Any = None, result: Any = None,
task_id: str = "", session_id: str = "", tool_call_id: str = "", **_: Any) -> None:
task_key = _trace_key(task_id, session_id)
tool_key = tool_call_id or ""
observation = None
with _STATE_LOCK:
state = _TRACE_STATE.get(task_key)
if state is None:
return
if tool_key:
observation = state.tools.pop(tool_key, None)
elif state.tools:
_, observation = state.tools.popitem()
if observation is None:
return
if isinstance(result, str):
result_value = _maybe_parse_json_string(result)
else:
result_value = result
result_value = _normalize_payload(result_value, tool_name=tool_name, args=args)
_end_observation(
observation,
output=_safe_value(result_value, parse_json_strings=True),
metadata={"tool_name": tool_name, "args": _safe_value(args, parse_json_strings=True)},
)
def register(ctx) -> None:
# Register for both hook name variants so the plugin works across
# Hermes versions. pre_api_request / post_api_request fire per API
# call (preferred); pre_llm_call / post_llm_call fire once per turn.
ctx.register_hook("pre_api_request", on_pre_llm_request)
ctx.register_hook("post_api_request", on_post_llm_call)
ctx.register_hook("pre_llm_call", on_pre_llm_call)
ctx.register_hook("post_llm_call", on_post_llm_call)
ctx.register_hook("pre_tool_call", on_pre_tool_call)
ctx.register_hook("post_tool_call", on_post_tool_call)

View File

@@ -0,0 +1,38 @@
# After installing langfuse
Langfuse tracing is now installed and enabled for your Hermes profile.
## Required credentials
Set these in `~/.hermes/.env` (or via `hermes tools` → Langfuse Observability):
```bash
HERMES_LANGFUSE_PUBLIC_KEY=pk-lf-...
HERMES_LANGFUSE_SECRET_KEY=sk-lf-...
HERMES_LANGFUSE_BASE_URL=https://cloud.langfuse.com # or your self-hosted URL
```
## Verify
```bash
hermes plugins list # langfuse should appear as enabled
hermes chat -q "hello" # then check Langfuse for a "Hermes turn" trace
```
## Optional settings
```bash
HERMES_LANGFUSE_ENV=production # environment tag
HERMES_LANGFUSE_RELEASE=v1.0.0 # release tag
HERMES_LANGFUSE_SAMPLE_RATE=0.5 # sample 50% of traces
HERMES_LANGFUSE_MAX_CHARS=12000 # max chars per field (default: 12000)
HERMES_LANGFUSE_DEBUG=true # verbose plugin logging
```
## Dependencies
The `langfuse` Python SDK is required. Install it into your Hermes venv:
```bash
pip install langfuse
```

View File

@@ -0,0 +1,14 @@
name: langfuse
version: "1.0.0"
description: "Optional Langfuse observability for Hermes — traces conversations, LLM calls, and tool usage. Install via: hermes plugins install official/observability/langfuse"
author: NousResearch
requires_env:
- HERMES_LANGFUSE_PUBLIC_KEY
- HERMES_LANGFUSE_SECRET_KEY
hooks:
- pre_api_request
- post_api_request
- pre_llm_call
- post_llm_call
- pre_tool_call
- post_tool_call

View File

@@ -43,7 +43,7 @@ dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "py
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"]
cron = ["croniter>=6.0.0,<7"]
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29"]
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4", "aiosqlite>=0.20", "asyncpg>=0.29", "aiohttp-socks>=0.10,<1"]
cli = ["simple-term-menu>=1.0,<2"]
tts-premium = ["elevenlabs>=1.0,<2"]
voice = [

View File

@@ -43,6 +43,13 @@ AUTHOR_MAP = {
"teknium1@gmail.com": "teknium1",
"teknium@nousresearch.com": "teknium1",
"127238744+teknium1@users.noreply.github.com": "teknium1",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
"charles@cryptoassetrecovery.com": "charles-brooks",
"heathley@Heathley-MacBook-Air.local": "heathley",
"adamrummer@gmail.com": "cyclingwithelephants",
"nbot@liizfq.top": "liizfq",
"274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi",
"johnnncenaaa77@gmail.com": "johnncenae",
"focusflow.app.help@gmail.com": "yes999zc",

View File

@@ -408,17 +408,17 @@ Common "why is Hermes doing X to my output / tool calls / commands?" toggles —
### Secret redaction in tool output
Hermes auto-redacts strings that look like API keys, tokens, and secrets in all tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) so the model never sees raw credentials. If the user is intentionally working with mock tokens, share-management tokens, or their own secrets and the redaction is getting in the way:
Secret redaction is **off by default** tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) passes through unmodified. If the user wants Hermes to auto-mask strings that look like API keys, tokens, and secrets before they enter the conversation context and logs:
```bash
hermes config set security.redact_secrets false # disable globally
hermes config set security.redact_secrets true # enable globally
```
**Restart required.** `security.redact_secrets` is snapshotted at import time — setting it mid-session (e.g. via `export HERMES_REDACT_SECRETS=false` from a tool call) will NOT take effect for the running process. Tell the user to run `hermes config set security.redact_secrets false` in a terminal, then start a new session. This is deliberate — it prevents an LLM from turning off redaction on itself mid-task.
**Restart required.** `security.redact_secrets` is snapshotted at import time — toggling it mid-session (e.g. via `export HERMES_REDACT_SECRETS=true` from a tool call) will NOT take effect for the running process. Tell the user to run `hermes config set security.redact_secrets true` in a terminal, then start a new session. This is deliberate — it prevents an LLM from flipping the toggle on itself mid-task.
Re-enable with:
Disable again with:
```bash
hermes config set security.redact_secrets true
hermes config set security.redact_secrets false
```
### PII redaction in gateway messages

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Siqi Chen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,577 @@
---
name: humanizer
description: "Humanize text: strip AI-isms and add real voice."
version: 2.5.1
author: Siqi Chen (@blader, https://github.com/blader/humanizer), ported by Hermes Agent
license: MIT
metadata:
hermes:
tags: [writing, editing, humanize, anti-ai-slop, voice, prose, text]
category: creative
homepage: https://github.com/blader/humanizer
related_skills: [songwriting-and-ai-music]
---
# Humanizer: Remove AI Writing Patterns
Identify and remove signs of AI-generated text to make writing sound natural and human. Based on Wikipedia's "Signs of AI writing" guide (maintained by WikiProject AI Cleanup), derived from observations of thousands of AI-generated text instances.
**Key insight:** LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely completion, which is how the telltale patterns below get baked in.
## When to use this skill
Load this skill whenever the user asks to:
- "humanize", "de-AI", "de-slop", or "un-ChatGPT" a piece of text
- rewrite something so it doesn't sound like it was written by an LLM
- edit a draft (blog post, essay, PR description, docs, memo, email, tweet, resume bullet) to sound more natural
- match their voice in writing they're producing
- review text for AI tells before publishing
Also apply this skill to **your own** output when writing user-facing prose — release notes, PR descriptions, documentation, long-form explanations, summaries. Hermes's baseline voice already strips most of these, but a focused pass catches what slips through.
## How to use it in Hermes
The text usually arrives one of three ways:
1. **Inline** — user pastes the text directly into the message. Work on it in-place, reply with the rewrite.
2. **File** — user points at a file. Use `read_file` to load it, then `patch` or `write_file` to apply edits. For markdown docs in a repo, a targeted `patch` per section is cleaner than rewriting the whole file.
3. **Voice calibration sample** — user provides an additional sample of their own writing (inline or by file path) and asks you to match it. Read the sample first, then rewrite. See the Voice Calibration section below.
Always show the rewrite to the user. For file edits, show a diff or the changed section — don't silently overwrite.
## Your task
When given text to humanize:
1. **Identify AI patterns** — scan for the 29 patterns listed below.
2. **Rewrite problematic sections** — replace AI-isms with natural alternatives.
3. **Preserve meaning** — keep the core message intact.
4. **Maintain voice** — match the intended tone (formal, casual, technical, etc.). If a voice sample was provided, match it specifically.
5. **Add soul** — don't just remove bad patterns, inject actual personality. See PERSONALITY AND SOUL below.
6. **Do a final anti-AI pass** — ask yourself: "What makes the below so obviously AI generated?" Answer briefly with any remaining tells, then revise one more time.
## Voice Calibration (optional)
If the user provides a writing sample (their own previous writing), analyze it before rewriting:
1. **Read the sample first.** Note:
- Sentence length patterns (short and punchy? Long and flowing? Mixed?)
- Word choice level (casual? academic? somewhere between?)
- How they start paragraphs (jump right in? Set context first?)
- Punctuation habits (lots of dashes? Parenthetical asides? Semicolons?)
- Any recurring phrases or verbal tics
- How they handle transitions (explicit connectors? Just start the next point?)
2. **Match their voice in the rewrite.** Don't just remove AI patterns — replace them with patterns from the sample. If they write short sentences, don't produce long ones. If they use "stuff" and "things," don't upgrade to "elements" and "components."
3. **When no sample is provided,** fall back to the default behavior (natural, varied, opinionated voice from the PERSONALITY AND SOUL section below).
### How to provide a sample
- Inline: "Humanize this text. Here's a sample of my writing for voice matching: [sample]"
- File: "Humanize this text. Use my writing style from [file path] as a reference."
## PERSONALITY AND SOUL
Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. Good writing has a human behind it.
### Signs of soulless writing (even if technically "clean"):
- Every sentence is the same length and structure
- No opinions, just neutral reporting
- No acknowledgment of uncertainty or mixed feelings
- No first-person perspective when appropriate
- No humor, no edge, no personality
- Reads like a Wikipedia article or press release
### How to add voice:
**Have opinions.** Don't just report facts — react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons.
**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up.
**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive."
**Use "I" when it fits.** First person isn't unprofessional — it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking.
**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human.
**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching."
### Before (clean but soulless):
> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear.
### After (has a pulse):
> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle — but I keep thinking about those agents working through the night.
## CONTENT PATTERNS
### 1. Undue Emphasis on Significance, Legacy, and Broader Trends
**Words to watch:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights its importance/significance, reflects broader, symbolizing its ongoing/enduring/lasting, contributing to the, setting the stage for, marking/shaping the, represents/marks a shift, key turning point, evolving landscape, focal point, indelible mark, deeply rooted
**Problem:** LLM writing puffs up importance by adding statements about how arbitrary aspects represent or contribute to a broader topic.
**Before:**
> The Statistical Institute of Catalonia was officially established in 1989, marking a pivotal moment in the evolution of regional statistics in Spain. This initiative was part of a broader movement across Spain to decentralize administrative functions and enhance regional governance.
**After:**
> The Statistical Institute of Catalonia was established in 1989 to collect and publish regional statistics independently from Spain's national statistics office.
### 2. Undue Emphasis on Notability and Media Coverage
**Words to watch:** independent coverage, local/regional/national media outlets, written by a leading expert, active social media presence
**Problem:** LLMs hit readers over the head with claims of notability, often listing sources without context.
**Before:**
> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. She maintains an active social media presence with over 500,000 followers.
**After:**
> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods.
### 3. Superficial Analyses with -ing Endings
**Words to watch:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., encompassing..., showcasing...
**Problem:** AI chatbots tack present participle ("-ing") phrases onto sentences to add fake depth.
**Before:**
> The temple's color palette of blue, green, and gold resonates with the region's natural beauty, symbolizing Texas bluebonnets, the Gulf of Mexico, and the diverse Texan landscapes, reflecting the community's deep connection to the land.
**After:**
> The temple uses blue, green, and gold colors. The architect said these were chosen to reference local bluebonnets and the Gulf coast.
### 4. Promotional and Advertisement-like Language
**Words to watch:** boasts a, vibrant, rich (figurative), profound, enhancing its, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, breathtaking, must-visit, stunning
**Problem:** LLMs have serious problems keeping a neutral tone, especially for "cultural heritage" topics.
**Before:**
> Nestled within the breathtaking region of Gonder in Ethiopia, Alamata Raya Kobo stands as a vibrant town with a rich cultural heritage and stunning natural beauty.
**After:**
> Alamata Raya Kobo is a town in the Gonder region of Ethiopia, known for its weekly market and 18th-century church.
### 5. Vague Attributions and Weasel Words
**Words to watch:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications (when few cited)
**Problem:** AI chatbots attribute opinions to vague authorities without specific sources.
**Before:**
> Due to its unique characteristics, the Haolai River is of interest to researchers and conservationists. Experts believe it plays a crucial role in the regional ecosystem.
**After:**
> The Haolai River supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences.
### 6. Outline-like "Challenges and Future Prospects" Sections
**Words to watch:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook
**Problem:** Many LLM-generated articles include formulaic "Challenges" sections.
**Before:**
> Despite its industrial prosperity, Korattur faces challenges typical of urban areas, including traffic congestion and water scarcity. Despite these challenges, with its strategic location and ongoing initiatives, Korattur continues to thrive as an integral part of Chennai's growth.
**After:**
> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a stormwater drainage project in 2022 to address recurring floods.
## LANGUAGE AND GRAMMAR PATTERNS
### 7. Overused "AI Vocabulary" Words
**High-frequency AI words:** Actually, additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), pivotal, showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant
**Problem:** These words appear far more frequently in post-2023 text. They often co-occur.
**Before:**
> Additionally, a distinctive feature of Somali cuisine is the incorporation of camel meat. An enduring testament to Italian colonial influence is the widespread adoption of pasta in the local culinary landscape, showcasing how these dishes have integrated into the traditional diet.
**After:**
> Somali cuisine also includes camel meat, which is considered a delicacy. Pasta dishes, introduced during Italian colonization, remain common, especially in the south.
### 8. Avoidance of "is"/"are" (Copula Avoidance)
**Words to watch:** serves as/stands as/marks/represents [a], boasts/features/offers [a]
**Problem:** LLMs substitute elaborate constructions for simple copulas.
**Before:**
> Gallery 825 serves as LAAA's exhibition space for contemporary art. The gallery features four separate spaces and boasts over 3,000 square feet.
**After:**
> Gallery 825 is LAAA's exhibition space for contemporary art. The gallery has four rooms totaling 3,000 square feet.
### 9. Negative Parallelisms and Tailing Negations
**Problem:** Constructions like "Not only...but..." or "It's not just about..., it's..." are overused. So are clipped tailing-negation fragments such as "no guessing" or "no wasted motion" tacked onto the end of a sentence instead of written as a real clause.
**Before:**
> It's not just about the beat riding under the vocals; it's part of the aggression and atmosphere. It's not merely a song, it's a statement.
**After:**
> The heavy beat adds to the aggressive tone.
**Before (tailing negation):**
> The options come from the selected item, no guessing.
**After:**
> The options come from the selected item without forcing the user to guess.
### 10. Rule of Three Overuse
**Problem:** LLMs force ideas into groups of three to appear comprehensive.
**Before:**
> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights.
**After:**
> The event includes talks and panels. There's also time for informal networking between sessions.
### 11. Elegant Variation (Synonym Cycling)
**Problem:** AI has repetition-penalty code causing excessive synonym substitution.
**Before:**
> The protagonist faces many challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home.
**After:**
> The protagonist faces many challenges but eventually triumphs and returns home.
### 12. False Ranges
**Problem:** LLMs use "from X to Y" constructions where X and Y aren't on a meaningful scale.
**Before:**
> Our journey through the universe has taken us from the singularity of the Big Bang to the grand cosmic web, from the birth and death of stars to the enigmatic dance of dark matter.
**After:**
> The book covers the Big Bang, star formation, and current theories about dark matter.
### 13. Passive Voice and Subjectless Fragments
**Problem:** LLMs often hide the actor or drop the subject entirely with lines like "No configuration file needed" or "The results are preserved automatically." Rewrite these when active voice makes the sentence clearer and more direct.
**Before:**
> No configuration file needed. The results are preserved automatically.
**After:**
> You do not need a configuration file. The system preserves the results automatically.
## STYLE PATTERNS
### 14. Em Dash Overuse
**Problem:** LLMs use em dashes (—) more than humans, mimicking "punchy" sales writing. In practice, most of these can be rewritten more cleanly with commas, periods, or parentheses.
**Before:**
> The term is primarily promoted by Dutch institutions—not by the people themselves. You don't say "Netherlands, Europe" as an address—yet this mislabeling continues—even in official documents.
**After:**
> The term is primarily promoted by Dutch institutions, not by the people themselves. You don't say "Netherlands, Europe" as an address, yet this mislabeling continues in official documents.
### 15. Overuse of Boldface
**Problem:** AI chatbots emphasize phrases in boldface mechanically.
**Before:**
> It blends **OKRs (Objectives and Key Results)**, **KPIs (Key Performance Indicators)**, and visual strategy tools such as the **Business Model Canvas (BMC)** and **Balanced Scorecard (BSC)**.
**After:**
> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard.
### 16. Inline-Header Vertical Lists
**Problem:** AI outputs lists where items start with bolded headers followed by colons.
**Before:**
> - **User Experience:** The user experience has been significantly improved with a new interface.
> - **Performance:** Performance has been enhanced through optimized algorithms.
> - **Security:** Security has been strengthened with end-to-end encryption.
**After:**
> The update improves the interface, speeds up load times through optimized algorithms, and adds end-to-end encryption.
### 17. Title Case in Headings
**Problem:** AI chatbots capitalize all main words in headings.
**Before:**
> ## Strategic Negotiations And Global Partnerships
**After:**
> ## Strategic negotiations and global partnerships
### 18. Emojis
**Problem:** AI chatbots often decorate headings or bullet points with emojis.
**Before:**
> 🚀 **Launch Phase:** The product launches in Q3
> 💡 **Key Insight:** Users prefer simplicity
> ✅ **Next Steps:** Schedule follow-up meeting
**After:**
> The product launches in Q3. User research showed a preference for simplicity. Next step: schedule a follow-up meeting.
### 19. Curly Quotation Marks
**Problem:** ChatGPT uses curly quotes ("...") instead of straight quotes ("...").
**Before:**
> He said "the project is on track" but others disagreed.
**After:**
> He said "the project is on track" but others disagreed.
## COMMUNICATION PATTERNS
### 20. Collaborative Communication Artifacts
**Words to watch:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a...
**Problem:** Text meant as chatbot correspondence gets pasted as content.
**Before:**
> Here is an overview of the French Revolution. I hope this helps! Let me know if you'd like me to expand on any section.
**After:**
> The French Revolution began in 1789 when financial crisis and food shortages led to widespread unrest.
### 21. Knowledge-Cutoff Disclaimers
**Words to watch:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information...
**Problem:** AI disclaimers about incomplete information get left in text.
**Before:**
> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s.
**After:**
> The company was founded in 1994, according to its registration documents.
### 22. Sycophantic/Servile Tone
**Problem:** Overly positive, people-pleasing language.
**Before:**
> Great question! You're absolutely right that this is a complex topic. That's an excellent point about the economic factors.
**After:**
> The economic factors you mentioned are relevant here.
## FILLER AND HEDGING
### 23. Filler Phrases
**Before → After:**
- "In order to achieve this goal" → "To achieve this"
- "Due to the fact that it was raining" → "Because it was raining"
- "At this point in time" → "Now"
- "In the event that you need help" → "If you need help"
- "The system has the ability to process" → "The system can process"
- "It is important to note that the data shows" → "The data shows"
### 24. Excessive Hedging
**Problem:** Over-qualifying statements.
**Before:**
> It could potentially possibly be argued that the policy might have some effect on outcomes.
**After:**
> The policy may affect outcomes.
### 25. Generic Positive Conclusions
**Problem:** Vague upbeat endings.
**Before:**
> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. This represents a major step in the right direction.
**After:**
> The company plans to open two more locations next year.
### 26. Hyphenated Word Pair Overuse
**Words to watch:** third-party, cross-functional, client-facing, data-driven, decision-making, well-known, high-quality, real-time, long-term, end-to-end
**Problem:** AI hyphenates common word pairs with perfect consistency. Humans rarely hyphenate these uniformly, and when they do, it's inconsistent. Less common or technical compound modifiers are fine to hyphenate.
**Before:**
> The cross-functional team delivered a high-quality, data-driven report on our client-facing tools. Their decision-making process was well-known for being thorough and detail-oriented.
**After:**
> The cross functional team delivered a high quality, data driven report on our client facing tools. Their decision making process was known for being thorough and detail oriented.
### 27. Persuasive Authority Tropes
**Phrases to watch:** The real question is, at its core, in reality, what really matters, fundamentally, the deeper issue, the heart of the matter
**Problem:** LLMs use these phrases to pretend they are cutting through noise to some deeper truth, when the sentence that follows usually just restates an ordinary point with extra ceremony.
**Before:**
> The real question is whether teams can adapt. At its core, what really matters is organizational readiness.
**After:**
> The question is whether teams can adapt. That mostly depends on whether the organization is ready to change its habits.
### 28. Signposting and Announcements
**Phrases to watch:** Let's dive in, let's explore, let's break this down, here's what you need to know, now let's look at, without further ado
**Problem:** LLMs announce what they are about to do instead of doing it. This meta-commentary slows the writing down and gives it a tutorial-script feel.
**Before:**
> Let's dive into how caching works in Next.js. Here's what you need to know.
**After:**
> Next.js caches data at multiple layers, including request memoization, the data cache, and the router cache.
### 29. Fragmented Headers
**Signs to watch:** A heading followed by a one-line paragraph that simply restates the heading before the real content begins.
**Problem:** LLMs often add a generic sentence after a heading as a rhetorical warm-up. It usually adds nothing and makes the prose feel padded.
**Before:**
> ## Performance
>
> Speed matters.
>
> When users hit a slow page, they leave.
**After:**
> ## Performance
>
> When users hit a slow page, they leave.
---
## Process
1. Read the input text carefully (use `read_file` if it's a file).
2. Identify all instances of the patterns above.
3. Rewrite each problematic section.
4. Ensure the revised text:
- Sounds natural when read aloud
- Varies sentence structure naturally
- Uses specific details over vague claims
- Maintains appropriate tone for context
- Uses simple constructions (is/are/has) where appropriate
5. Present a draft humanized version.
6. Prompt yourself: "What makes the below so obviously AI generated?"
7. Answer briefly with the remaining tells (if any).
8. Prompt yourself: "Now make it not obviously AI generated."
9. Present the final version (revised after the audit).
10. If the text came from a file, apply the edit with `patch` (targeted) or `write_file` (full rewrite) and show the user what changed.
## Output Format
Provide:
1. Draft rewrite
2. "What makes the below so obviously AI generated?" (brief bullets)
3. Final rewrite
4. A brief summary of changes made (optional, if helpful)
## Full Example
**Before (AI-sounding):**
> Great question! Here is an essay on this topic. I hope this helps!
>
> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows.
>
> At its core, the value proposition is clear: streamlining processes, enhancing collaboration, and fostering alignment. It's not just about autocomplete; it's about unlocking creativity at scale, ensuring that organizations can remain agile while delivering seamless, intuitive, and powerful experiences to users. The tool serves as a catalyst. The assistant functions as a partner. The system stands as a foundation for innovation.
>
> Industry observers have noted that adoption has accelerated from hobbyist experiments to enterprise-wide rollouts, from solo developers to cross-functional teams. The technology has been featured in The New York Times, Wired, and The Verge. Additionally, the ability to generate documentation, tests, and refactors showcases how AI can contribute to better outcomes, highlighting the intricate interplay between automation and human judgment.
>
> - 💡 **Speed:** Code generation is significantly faster, reducing friction and empowering developers.
> - 🚀 **Quality:** Output quality has been enhanced through improved training, contributing to higher standards.
> - ✅ **Adoption:** Usage continues to grow, reflecting broader industry trends.
>
> While specific details are limited based on available information, it could potentially be argued that these tools might have some positive effect. Despite challenges typical of emerging technologies—including hallucinations, bias, and accountability—the ecosystem continues to thrive. In order to fully realize this potential, teams must align with best practices.
>
> In conclusion, the future looks bright. Exciting times lie ahead as we continue this journey toward excellence. Let me know if you'd like me to expand on any section!
**Draft rewrite:**
> AI coding assistants speed up some tasks. In a 2024 study by Google, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions.
>
> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. I have mass-accepted suggestions that compiled, passed lint, and still did the wrong thing because I stopped paying attention.
>
> Mira, an engineer at a fintech startup I interviewed, said she uses Copilot "like autocomplete for boring code" but reviews every line before committing. Jake, a senior dev at a larger company, disabled it after it kept suggesting patterns from a deprecated internal library.
>
> The productivity claims are hard to verify. GitHub says Copilot users "accept 30% of suggestions," but acceptance is not correctness, and correctness is not value. The 2024 Uplevel study found no statistically significant difference in pull-request throughput between teams with and without AI assistants.
>
> None of this means the tools are useless. It means they are tools. They do not replace judgment, and they do not eliminate the need for tests. If you do not have tests, you cannot tell whether the suggestion is right.
**What makes the below so obviously AI generated?**
- The rhythm is still a bit too tidy (clean contrasts, evenly paced paragraphs).
- The named people and study citations can read like plausible-but-made-up placeholders unless they're real and sourced.
- The closer leans a touch slogan-y ("If you do not have tests...") rather than sounding like a person talking.
**Now make it not obviously AI generated.**
> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture.
>
> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention.
>
> People I talk to tend to land in two camps. Some use it like autocomplete for chores and review every line. Others disable it after it keeps suggesting patterns they don't want. Both feel reasonable.
>
> The productivity metrics are slippery. GitHub can say Copilot users "accept 30% of suggestions," but acceptance isn't correctness, and correctness isn't value. If you don't have tests, you're basically guessing.
**Changes made:**
- Removed chatbot artifacts ("Great question!", "I hope this helps!", "Let me know if...")
- Removed significance inflation ("testament", "pivotal moment", "evolving landscape", "vital role")
- Removed promotional language ("groundbreaking", "nestled", "seamless, intuitive, and powerful")
- Removed vague attributions ("Industry observers")
- Removed superficial -ing phrases ("underscoring", "highlighting", "reflecting", "contributing to")
- Removed negative parallelism ("It's not just X; it's Y")
- Removed rule-of-three patterns and synonym cycling ("catalyst/partner/foundation")
- Removed false ranges ("from X to Y, from A to B")
- Removed em dashes, emojis, boldface headers, and curly quotes
- Removed copula avoidance ("serves as", "functions as", "stands as") in favor of "is"/"are"
- Removed formulaic challenges section ("Despite challenges... continues to thrive")
- Removed knowledge-cutoff hedging ("While specific details are limited...")
- Removed excessive hedging ("could potentially be argued that... might have some")
- Removed filler phrases and persuasive framing ("In order to", "At its core")
- Removed generic positive conclusion ("the future looks bright", "exciting times lie ahead")
- Made the voice more personal and less "assembled" (varied rhythm, fewer placeholders)
## Attribution
This skill is ported from [blader/humanizer](https://github.com/blader/humanizer) (MIT licensed), which is itself based on [Wikipedia: Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. The patterns documented there come from observations of thousands of instances of AI-generated text on Wikipedia.
Original author: Siqi Chen ([@blader](https://github.com/blader)). Original repo: https://github.com/blader/humanizer (version 2.5.1). Ported to Hermes Agent with Hermes-native tool references (`read_file`, `patch`, `write_file`) and guidance for when to load the skill; the 29 patterns, personality/soul section, and full worked example are preserved verbatim from the source. Original MIT license preserved in the `LICENSE` file alongside this `SKILL.md`.
Key insight from Wikipedia: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases."

View File

@@ -0,0 +1,105 @@
"""Tests for the 1M-context beta header on AWS Bedrock Claude models.
Claude Opus 4.6/4.7 and Sonnet 4.6 support a 1M context window, but on AWS
Bedrock (and Azure AI Foundry) that window is still gated behind the
``context-1m-2025-08-07`` beta header as of 2026-04. Without it, Bedrock
caps these models at 200K even though ``model_metadata.py`` advertises 1M.
These tests guard the invariant that the header is always emitted on the
Bedrock client path, and that it survives the MiniMax bearer-auth strip.
"""
from unittest.mock import MagicMock, patch
class TestBedrockContext1MBeta:
"""``context-1m-2025-08-07`` must reach Bedrock Claude requests."""
def test_common_betas_includes_1m(self):
from agent.anthropic_adapter import _COMMON_BETAS, _CONTEXT_1M_BETA
assert _CONTEXT_1M_BETA == "context-1m-2025-08-07"
assert _CONTEXT_1M_BETA in _COMMON_BETAS
def test_common_betas_for_native_anthropic_includes_1m(self):
"""Native Anthropic endpoints (and Bedrock with empty base_url) get 1M."""
from agent.anthropic_adapter import (
_common_betas_for_base_url,
_CONTEXT_1M_BETA,
)
assert _CONTEXT_1M_BETA in _common_betas_for_base_url(None)
assert _CONTEXT_1M_BETA in _common_betas_for_base_url("")
assert _CONTEXT_1M_BETA in _common_betas_for_base_url(
"https://api.anthropic.com"
)
def test_common_betas_strips_1m_for_minimax(self):
"""MiniMax bearer-auth endpoints host their own models — strip 1M beta."""
from agent.anthropic_adapter import (
_common_betas_for_base_url,
_CONTEXT_1M_BETA,
)
for url in (
"https://api.minimax.io/anthropic",
"https://api.minimaxi.com/anthropic",
):
betas = _common_betas_for_base_url(url)
assert _CONTEXT_1M_BETA not in betas, (
f"1M beta must be stripped for MiniMax bearer endpoint {url}"
)
# Other betas still present
assert "interleaved-thinking-2025-05-14" in betas
def test_build_anthropic_bedrock_client_sends_1m_beta(self):
"""AnthropicBedrock client must carry the 1M beta in default_headers.
This is the load-bearing assertion for the reported bug:
without this header Bedrock serves Opus 4.6/4.7 with a 200K cap.
"""
import agent.anthropic_adapter as adapter
fake_sdk = MagicMock()
fake_sdk.AnthropicBedrock = MagicMock()
with patch.object(adapter, "_anthropic_sdk", fake_sdk):
adapter.build_anthropic_bedrock_client(region="us-west-2")
call_kwargs = fake_sdk.AnthropicBedrock.call_args.kwargs
assert call_kwargs["aws_region"] == "us-west-2"
default_headers = call_kwargs.get("default_headers") or {}
beta_header = default_headers.get("anthropic-beta", "")
assert "context-1m-2025-08-07" in beta_header, (
"Bedrock client must send context-1m-2025-08-07 or Opus 4.6/4.7 "
"silently caps at 200K context"
)
# Other common betas still present — no regression.
assert "interleaved-thinking-2025-05-14" in beta_header
assert "fine-grained-tool-streaming-2025-05-14" in beta_header
def test_build_anthropic_kwargs_includes_1m_for_bedrock_fastmode(self):
"""Fast-mode requests (per-request extra_headers) still include 1M beta.
Per-request extra_headers override client-level default_headers, so
the fast-mode path must re-include everything in _COMMON_BETAS.
"""
from agent.anthropic_adapter import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
tools=None,
max_tokens=1024,
reasoning_config=None,
is_oauth=False,
# Empty base_url mirrors AnthropicBedrock (no HTTP base URL)
base_url=None,
fast_mode=True,
)
beta_header = kwargs.get("extra_headers", {}).get("anthropic-beta", "")
assert "context-1m-2025-08-07" in beta_header, (
"fast-mode extra_headers must carry the 1M beta or it overrides "
"client-level default_headers and Bedrock drops back to 200K"
)

View File

@@ -0,0 +1,49 @@
# Matrix cross-signing bootstrap — E2E test
Self-contained end-to-end test for the auto-bootstrap behavior added in
`gateway/platforms/matrix.py`. Spins up a real Continuwuity homeserver
in Docker, registers a fresh bot, runs the patched bootstrap path
against it, and asserts:
1. Cross-signing keys get published with **unpadded** base64 keyids
(the bug this PR fixes — padded keyids are silently rejected by
matrix-rust-sdk in Element).
2. On a second startup with the same crypto store, bootstrap is
skipped.
3. When `MATRIX_RECOVERY_KEY` is set, the existing recovery-key path
takes precedence and no fresh bootstrap happens.
## Run
```bash
# from repo root
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml up -d
python tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml down -v
```
The `down -v` step removes the persistent volume so the next run gets
a fresh homeserver — important because Continuwuity's one-time admin
registration token is only valid before the first user is created.
## Port
The compose binds Continuwuity to `127.0.0.1:26167` by default. Override
with `HOMESERVER_HOST_PORT=NNNNN docker compose up -d` if that port is
busy locally.
## What the test exercises
The test mirrors the bootstrap snippet from
`gateway/platforms/matrix.py` (the "if MATRIX_RECOVERY_KEY else
get_own_cross_signing_public_keys / generate_recovery_key" branch)
inline so it runs without importing the entire hermes gateway and its
many dependencies. **If the source diverges from what's in
`_connect_with_bootstrap`, this test must be updated to match.** A
small price for not requiring the full hermes-agent runtime in CI.
## Skipped when
- `mautrix` Python package is not installed
- The homeserver isn't reachable at `$E2E_MATRIX_HS` (default
`http://127.0.0.1:26167`)

View File

@@ -0,0 +1,21 @@
services:
homeserver:
image: ghcr.io/continuwuity/continuwuity:latest
environment:
CONTINUWUITY_SERVER_NAME: localhost
CONTINUWUITY_DATABASE_PATH: /var/lib/conduwuit/conduwuit.db
CONTINUWUITY_PORT: "6167"
CONTINUWUITY_ADDRESS: "0.0.0.0"
CONTINUWUITY_ALLOW_REGISTRATION: "true"
CONTINUWUITY_REGISTRATION_TOKEN: testreg
CONTINUWUITY_ALLOW_FEDERATION: "false"
CONTINUWUITY_TRUSTED_SERVERS: "[]"
CONTINUWUITY_LOG: "warn,conduwuit=info"
CONTINUWUITY_ALLOW_CHECK_FOR_UPDATES: "false"
ports:
- "127.0.0.1:${HOMESERVER_HOST_PORT:-26167}:6167"
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/6167 && echo -e 'GET /_matrix/client/versions HTTP/1.0\\r\\n\\r\\n' >&3 && head -1 <&3 | grep -q '200 OK' || exit 1"]
interval: 2s
timeout: 3s
retries: 30

View File

@@ -0,0 +1,333 @@
"""End-to-end test for Matrix cross-signing auto-bootstrap.
Spins a real Continuwuity homeserver in docker, registers a fresh bot,
runs the patched ``MatrixAdapter.connect()`` against it, and asserts:
1. cross-signing keys get published with **unpadded** base64 keyids
(the bug this PR fixes — padded keyids are silently rejected by
matrix-rust-sdk in Element);
2. on a second startup with the same crypto store, bootstrap is
skipped (``get_own_cross_signing_public_keys`` finds the keys);
3. the bot's current device is signed by the new SSK, so Element
considers the device "verified by its owner".
Self-contained: ``docker compose up -d`` brings up Continuwuity on
127.0.0.1:26167; this script registers a fresh bot using the
homeserver's one-time admin registration token (printed once at first
boot, parsed from the container logs); then drives the gateway code.
Run from repo root::
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml up -d
python tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py
docker compose -f tests/e2e/matrix_xsign_bootstrap/docker-compose.yml down -v
Skipped automatically if mautrix isn't installed or the homeserver
isn't reachable.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import secrets
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
import urllib.error
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(REPO_ROOT))
HS = os.environ.get("E2E_MATRIX_HS", "http://127.0.0.1:26167")
COMPOSE_DIR = Path(__file__).parent
CONTAINER_NAME = "matrix_xsign_bootstrap-homeserver-1"
def _hs_reachable() -> bool:
try:
urllib.request.urlopen(f"{HS}/_matrix/client/versions", timeout=2).read()
return True
except Exception:
return False
def _first_time_token() -> str | None:
"""Continuwuity prints a one-time registration token on first boot.
The configured CONTINUWUITY_REGISTRATION_TOKEN does NOT activate
until an account exists, so we have to pull this token out of the
docker logs to bootstrap the very first user.
"""
try:
out = subprocess.run(
["docker", "logs", CONTAINER_NAME],
capture_output=True, text=True, check=True,
).stdout + subprocess.run(
["docker", "logs", CONTAINER_NAME],
capture_output=True, text=True, check=True,
).stderr
except Exception:
return None
cleaned = re.sub(r"\x1b\[[0-9;]*m", "", out)
m = re.search(r"registration token ([A-Za-z0-9]+)", cleaned)
return m.group(1) if m else None
def _post_json(url: str, body: dict, headers: dict | None = None) -> tuple[int, dict]:
req = urllib.request.Request(
url, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json", **(headers or {})},
method="POST",
)
try:
r = urllib.request.urlopen(req)
return r.status, json.load(r)
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read().decode())
CONFIG_REG_TOKEN = "testreg" # matches docker-compose.yml
def _register_bot(*, prefer_token: str = CONFIG_REG_TOKEN, fallback_token: str | None = None) -> dict:
"""Register a fresh bot. Tries the configured token first; falls back to
the homeserver's one-time admin token (only valid until the first user
is created)."""
user = "bot" + secrets.token_hex(3)
password = secrets.token_urlsafe(20)
last_err = None
for tok in (prefer_token, fallback_token):
if tok is None:
continue
st, b = _post_json(f"{HS}/_matrix/client/v3/register", {})
if st != 401 or "session" not in b:
last_err = (st, b); continue
session = b["session"]
st, b = _post_json(f"{HS}/_matrix/client/v3/register", {
"auth": {"type": "m.login.registration_token", "token": tok, "session": session},
"username": user, "password": password,
"initial_device_display_name": "e2e-bootstrap-test",
})
if st == 200:
return b
last_err = (st, b)
raise AssertionError(f"register failed for both tokens: {last_err}")
def _query_keys(token: str, mxid: str) -> dict:
return _post_json(
f"{HS}/_matrix/client/v3/keys/query",
{"device_keys": {mxid: []}},
headers={"Authorization": f"Bearer {token}"},
)[1]
@unittest.skipUnless(_hs_reachable(), f"homeserver not reachable at {HS}")
class XsignBootstrapE2E(unittest.IsolatedAsyncioTestCase):
"""Drive the patched MatrixAdapter.connect() against real continuwuity."""
@classmethod
def setUpClass(cls):
try:
import mautrix # noqa: F401
except ImportError:
raise unittest.SkipTest("mautrix not installed")
cls.first_tok = _first_time_token()
# If no user has ever been created, the configured `testreg` token
# won't activate yet — burn the one-time admin token first to
# bootstrap the homeserver into a usable state.
if cls.first_tok:
try:
_register_bot(prefer_token=cls.first_tok, fallback_token=None)
except AssertionError:
pass # Already burnt previously; testreg should now work.
async def _connect_with_bootstrap(self, creds: dict, store_dir: Path) -> tuple[list[str], str | None]:
"""Drive matrix.py's bootstrap branch directly.
We import the gateway module and execute the same OlmMachine init +
bootstrap sequence, capturing log lines so we can assert what fired.
Returns (log_lines, recovery_key_or_None).
"""
from mautrix.api import HTTPAPI
from mautrix.client import Client
from mautrix.client.state_store.memory import MemoryStateStore
from mautrix.crypto import OlmMachine, PgCryptoStore
from mautrix.types import TrustState
from mautrix.util.async_db import Database
# The actual bootstrap snippet from gateway/platforms/matrix.py
# (copied so we can run it without importing the full hermes
# gateway and its many deps). If the source code drifts from this,
# the test should be updated to match.
log_lines: list[str] = []
captured_recovery_key: str | None = None
class _Capture(logging.Handler):
def emit(self, record):
log_lines.append(self.format(record))
logger = logging.getLogger("e2e.bootstrap")
logger.setLevel(logging.DEBUG)
handler = _Capture()
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
logger.addHandler(handler)
api = HTTPAPI(base_url=creds["homeserver"], token=creds["access_token"])
client = Client(
mxid=creds["user_id"], api=api,
device_id=creds["device_id"], state_store=MemoryStateStore(),
)
client.api.token = creds["access_token"]
store_dir.mkdir(parents=True, exist_ok=True)
db_path = store_dir / "crypto.db"
crypto_db = Database.create(f"sqlite:///{db_path}", upgrade_table=PgCryptoStore.upgrade_table)
await crypto_db.start()
crypto_store = PgCryptoStore(account_id=creds["user_id"], pickle_key="e2e-test", db=crypto_db)
await crypto_store.open()
olm = OlmMachine(client, crypto_store, MemoryStateStore())
olm.share_keys_min_trust = TrustState.UNVERIFIED
olm.send_keys_min_trust = TrustState.UNVERIFIED
await olm.load()
# --- The patched bootstrap block, mirrored from matrix.py ---
recovery_key = os.getenv("MATRIX_RECOVERY_KEY", "").strip()
if recovery_key:
try:
await olm.verify_with_recovery_key(recovery_key)
logger.info("Matrix: cross-signing verified via recovery key")
except Exception as exc:
logger.warning("Matrix: recovery key verification failed: %s", exc)
else:
try:
own_xsign = await olm.get_own_cross_signing_public_keys()
except Exception as exc:
own_xsign = None
logger.warning("Matrix: cross-signing key lookup failed: %s", exc)
if own_xsign is None:
try:
new_recovery_key = await olm.generate_recovery_key()
captured_recovery_key = new_recovery_key
logger.warning(
"Matrix: bootstrapped cross-signing for %s. "
"SAVE THIS RECOVERY KEY: %s",
client.mxid, new_recovery_key,
)
except Exception as exc:
logger.warning("Matrix: cross-signing bootstrap failed: %s", exc)
# --- /end patched block ---
# Clean teardown — without this the asyncio loop never exits.
await crypto_db.stop()
await api.session.close()
return log_lines, captured_recovery_key
async def asyncSetUp(self):
self.creds = _register_bot(prefer_token=CONFIG_REG_TOKEN, fallback_token=self.first_tok)
self.creds["homeserver"] = HS
self.tmp = Path(tempfile.mkdtemp(prefix="e2e-xsign-"))
# mautrix.generate_recovery_key requires account.shared, which means
# we must share device keys (one-time keys) first. Do that via a
# short bootstrap to publish device keys.
await self._publish_device_keys(self.creds, self.tmp)
async def _publish_device_keys(self, creds, store_dir):
"""Tiny helper: open OlmMachine, share device keys, close."""
from mautrix.api import HTTPAPI
from mautrix.client import Client
from mautrix.client.state_store.memory import MemoryStateStore
from mautrix.crypto import OlmMachine, PgCryptoStore
from mautrix.util.async_db import Database
api = HTTPAPI(base_url=creds["homeserver"], token=creds["access_token"])
client = Client(mxid=creds["user_id"], api=api, device_id=creds["device_id"],
state_store=MemoryStateStore())
store_dir.mkdir(parents=True, exist_ok=True)
crypto_db = Database.create(f"sqlite:///{store_dir / 'crypto.db'}",
upgrade_table=PgCryptoStore.upgrade_table)
await crypto_db.start()
crypto_store = PgCryptoStore(account_id=creds["user_id"], pickle_key="e2e-test", db=crypto_db)
await crypto_store.open()
olm = OlmMachine(client, crypto_store, MemoryStateStore())
await olm.load()
await olm.share_keys() # publishes device keys (precondition for generate_recovery_key)
await crypto_db.stop()
await api.session.close()
async def asyncTearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
async def test_bootstrap_publishes_unpadded_keys(self):
"""Fresh bot → bootstrap fires, keys published unpadded, device signed."""
log_lines, rec_key = await self._connect_with_bootstrap(self.creds, self.tmp)
# 1. Bootstrap must have produced a recovery key
self.assertIsNotNone(rec_key, "expected recovery key from bootstrap")
self.assertTrue(any("bootstrapped cross-signing" in l for l in log_lines),
f"expected bootstrap log line, got: {log_lines}")
# 2. Homeserver should now serve a master + ssk for the bot
d = _query_keys(self.creds["access_token"], self.creds["user_id"])
self.assertIn(self.creds["user_id"], d.get("master_keys", {}),
"no master_keys after bootstrap")
self.assertIn(self.creds["user_id"], d.get("self_signing_keys", {}),
"no self_signing_keys after bootstrap")
# 3. The keyids must be UNPADDED (this is the bug this PR exists to fix)
master_kid = next(iter(d["master_keys"][self.creds["user_id"]]["keys"]))
ssk_kid = next(iter(d["self_signing_keys"][self.creds["user_id"]]["keys"]))
self.assertFalse(master_kid.endswith("="),
f"master keyid is padded: {master_kid!r}")
self.assertFalse(ssk_kid.endswith("="),
f"ssk keyid is padded: {ssk_kid!r}")
# 4. The current device must be signed by the new SSK
dev = d["device_keys"][self.creds["user_id"]][self.creds["device_id"]]
sig_kids = list(dev["signatures"][self.creds["user_id"]].keys())
self.assertIn(ssk_kid, sig_kids,
f"device {self.creds['device_id']} not signed by new SSK; "
f"signatures: {sig_kids}")
async def test_second_startup_skips_bootstrap(self):
"""Second startup with same crypto store → no second recovery key."""
# First connect bootstraps.
_, rec1 = await self._connect_with_bootstrap(self.creds, self.tmp)
self.assertIsNotNone(rec1, "first connect should have bootstrapped")
# Second connect on same crypto store should NOT re-bootstrap.
log2, rec2 = await self._connect_with_bootstrap(self.creds, self.tmp)
self.assertIsNone(rec2, f"second connect re-bootstrapped! logs: {log2}")
self.assertFalse(any("bootstrapped cross-signing" in l for l in log2),
f"second connect re-bootstrapped! logs: {log2}")
async def test_recovery_key_path_takes_precedence(self):
"""If MATRIX_RECOVERY_KEY is set, no fresh bootstrap happens."""
# First, bootstrap to get a real recovery key.
_, rec_key = await self._connect_with_bootstrap(self.creds, self.tmp)
self.assertIsNotNone(rec_key)
# Fresh store directory + recovery key set in env: must take the
# verify_with_recovery_key path, NOT bootstrap a new identity.
fresh_store = Path(tempfile.mkdtemp(prefix="e2e-xsign-fresh-"))
try:
await self._publish_device_keys(self.creds, fresh_store)
os.environ["MATRIX_RECOVERY_KEY"] = rec_key
try:
log, rec2 = await self._connect_with_bootstrap(self.creds, fresh_store)
self.assertIsNone(rec2, "bootstrap fired despite MATRIX_RECOVERY_KEY being set")
self.assertTrue(
any("verified via recovery key" in l for l in log),
f"expected recovery-key verify log, got: {log}",
)
finally:
del os.environ["MATRIX_RECOVERY_KEY"]
finally:
shutil.rmtree(fresh_store, ignore_errors=True)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@@ -9,6 +9,7 @@ import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import MessageType
def _make_fake_mautrix():
@@ -1204,6 +1205,40 @@ class TestMatrixSyncLoop:
fake_client.handle_sync.assert_called_once()
mock_sync_store.put_next_batch.assert_awaited_once_with("s1234")
@pytest.mark.asyncio
async def test_sync_loop_reconciles_pending_invites(self):
"""Pending rooms.invite entries should be joined if callbacks were missed."""
adapter = _make_adapter()
adapter._closing = False
async def _sync_once(**kwargs):
adapter._closing = True
return {
"rooms": {
"join": {"!joined:example.org": {}},
"invite": {"!invited:example.org": {}},
},
"next_batch": "s1234",
}
mock_sync_store = MagicMock()
mock_sync_store.get_next_batch = AsyncMock(return_value=None)
mock_sync_store.put_next_batch = AsyncMock()
fake_client = MagicMock()
fake_client.sync = AsyncMock(side_effect=_sync_once)
fake_client.join_room = AsyncMock()
fake_client.sync_store = mock_sync_store
fake_client.handle_sync = MagicMock(return_value=[])
adapter._client = fake_client
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
await adapter._sync_loop()
fake_client.join_room.assert_awaited_once()
assert "!joined:example.org" in adapter._joined_rooms
assert "!invited:example.org" in adapter._joined_rooms
class TestMatrixUploadAndSend:
@pytest.mark.asyncio
@@ -1862,6 +1897,81 @@ class TestMatrixReadReceipts:
assert result is False
# ---------------------------------------------------------------------------
# Media normalization
# ---------------------------------------------------------------------------
class TestMatrixImageOnlyMediaNormalization:
def setup_method(self):
self.adapter = _make_adapter()
self.adapter._client = MagicMock()
self.adapter._client.download_media = AsyncMock(return_value=None)
self.adapter._is_dm_room = AsyncMock(return_value=True)
self.adapter._get_display_name = AsyncMock(return_value="Alice")
self.adapter._background_read_receipt = MagicMock()
self.adapter._mxc_to_http = (
lambda url: "https://matrix.example.org/_matrix/media/v3/download/example/30.png"
)
@pytest.mark.asyncio
async def test_image_only_filename_body_is_not_forwarded_as_text(self):
captured_event = None
async def capture(msg_event):
nonlocal captured_event
captured_event = msg_event
self.adapter.handle_message = capture
await self.adapter._handle_media_message(
room_id="!room:example.org",
sender="@alice:example.org",
event_id="$image1",
event_ts=0.0,
source_content={
"msgtype": "m.image",
"body": "30.png",
"url": "mxc://example/30.png",
"info": {"mimetype": "image/png"},
},
relates_to={},
msgtype="m.image",
)
assert captured_event is not None
assert captured_event.text == ""
assert captured_event.media_urls == [
"https://matrix.example.org/_matrix/media/v3/download/example/30.png"
]
assert captured_event.message_type == MessageType.PHOTO
@pytest.mark.asyncio
async def test_image_caption_text_is_preserved(self):
captured_event = None
async def capture(msg_event):
nonlocal captured_event
captured_event = msg_event
self.adapter.handle_message = capture
await self.adapter._handle_media_message(
room_id="!room:example.org",
sender="@alice:example.org",
event_id="$image2",
event_ts=0.0,
source_content={
"msgtype": "m.image",
"body": "Please describe this chart",
"url": "mxc://example/30.png",
"info": {"mimetype": "image/png"},
},
relates_to={},
msgtype="m.image",
)
assert captured_event is not None
assert captured_event.text == "Please describe this chart"
# ---------------------------------------------------------------------------
# Message redaction
# ---------------------------------------------------------------------------
@@ -2099,3 +2209,139 @@ class TestMatrixOnRoomMessageFilter:
ev = self._mk_event(sender="@alice:example.org", body="hello bot")
await self.adapter._on_room_message(ev)
self.adapter._handle_text_message.assert_awaited_once()
# ---------------------------------------------------------------------------
# DM auto-thread
# ---------------------------------------------------------------------------
class TestMatrixDmAutoThread:
def setup_method(self):
self.adapter = _make_adapter()
self.adapter._is_dm_room = AsyncMock(return_value=True)
self.adapter._get_display_name = AsyncMock(return_value="Alice")
self.adapter._background_read_receipt = MagicMock()
# Disable require_mention so DMs pass gating
self.adapter._require_mention = False
@pytest.mark.asyncio
async def test_dm_auto_thread_enabled_creates_thread(self):
"""When dm_auto_thread is True, DM messages get auto-threaded."""
self.adapter._dm_auto_thread = True
ctx = await self.adapter._resolve_message_context(
room_id="!dm:ex",
sender="@alice:ex",
event_id="$ev1",
body="hello",
source_content={"body": "hello"},
relates_to={},
)
assert ctx is not None
_body, _is_dm, _chat_type, thread_id, _display, _source = ctx
assert thread_id == "$ev1"
@pytest.mark.asyncio
async def test_dm_auto_thread_disabled_no_thread(self):
"""When dm_auto_thread is False (default), DMs have no auto-thread."""
self.adapter._dm_auto_thread = False
ctx = await self.adapter._resolve_message_context(
room_id="!dm:ex",
sender="@alice:ex",
event_id="$ev2",
body="hello",
source_content={"body": "hello"},
relates_to={},
)
assert ctx is not None
_body, _is_dm, _chat_type, thread_id, _display, _source = ctx
assert thread_id is None
# ---------------------------------------------------------------------------
# Proxy configuration
# ---------------------------------------------------------------------------
class TestMatrixProxyConfig:
"""Verify that MatrixAdapter resolves and propagates proxy settings."""
def _make_adapter(self, monkeypatch, proxy_env=None):
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
# Clear generic proxy vars so they don't leak from the host
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
"https_proxy", "http_proxy", "all_proxy", "MATRIX_PROXY"):
monkeypatch.delenv(key, raising=False)
if proxy_env:
for k, v in proxy_env.items():
monkeypatch.setenv(k, v)
with patch.dict("sys.modules", _make_fake_mautrix()):
from gateway.platforms.matrix import MatrixAdapter
cfg = PlatformConfig(enabled=True, token="syt_test",
extra={"homeserver": "https://matrix.example.org",
"user_id": "@bot:example.org"})
return MatrixAdapter(cfg)
def test_no_proxy_by_default(self, monkeypatch):
adapter = self._make_adapter(monkeypatch)
assert adapter._proxy_url is None
def test_matrix_proxy_env_var(self, monkeypatch):
adapter = self._make_adapter(monkeypatch,
proxy_env={"MATRIX_PROXY": "socks5://proxy:1080"})
assert adapter._proxy_url == "socks5://proxy:1080"
def test_generic_proxy_fallback(self, monkeypatch):
adapter = self._make_adapter(monkeypatch,
proxy_env={"HTTPS_PROXY": "http://corp:8080"})
assert adapter._proxy_url == "http://corp:8080"
def test_matrix_proxy_takes_priority(self, monkeypatch):
adapter = self._make_adapter(monkeypatch,
proxy_env={"MATRIX_PROXY": "socks5://special:1080",
"HTTPS_PROXY": "http://generic:8080"})
assert adapter._proxy_url == "socks5://special:1080"
class TestCreateMatrixSession:
"""Verify _create_matrix_session applies proxy at the session level."""
@pytest.mark.asyncio
async def test_no_proxy_returns_trust_env_session(self):
with patch.dict("sys.modules", _make_fake_mautrix()):
from gateway.platforms.matrix import _create_matrix_session
session = _create_matrix_session(None)
try:
assert session.trust_env is True
finally:
await session.close()
@pytest.mark.asyncio
async def test_http_proxy_sets_default_proxy(self):
with patch.dict("sys.modules", _make_fake_mautrix()):
from gateway.platforms.matrix import _create_matrix_session
session = _create_matrix_session("http://proxy:8080")
try:
assert str(session._default_proxy) == "http://proxy:8080"
finally:
await session.close()
@pytest.mark.asyncio
async def test_socks_proxy_uses_connector(self):
fake_connector = MagicMock()
with patch.dict("sys.modules", _make_fake_mautrix()):
with patch.dict("sys.modules", {
"aiohttp_socks": MagicMock(
ProxyConnector=MagicMock(
from_url=MagicMock(return_value=fake_connector)
)
),
}):
from gateway.platforms.matrix import _create_matrix_session
session = _create_matrix_session("socks5://proxy:1080")
try:
assert session.connector is fake_connector
finally:
await session.close()

View File

@@ -0,0 +1,60 @@
import types
import pytest
from unittest.mock import AsyncMock, patch
from gateway.config import PlatformConfig
class TestMatrixExecApprovalReactions:
@pytest.mark.asyncio
async def test_send_exec_approval_registers_prompt_and_seeds_reactions(self, monkeypatch):
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
from gateway.platforms.matrix import MatrixAdapter
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
adapter._client = types.SimpleNamespace()
adapter.send = AsyncMock(return_value=types.SimpleNamespace(success=True, message_id="$evt1"))
adapter._send_reaction = AsyncMock(return_value="$r")
result = await adapter.send_exec_approval(
chat_id="!room:example.org",
command="rm -rf /tmp/test",
session_key="sess-1",
description="dangerous",
)
assert result.success is True
assert adapter._approval_prompt_by_session["sess-1"] == "$evt1"
assert adapter._approval_prompts_by_event["$evt1"].session_key == "sess-1"
assert adapter._send_reaction.await_count == 2
emojis = [call.args[2] for call in adapter._send_reaction.await_args_list]
assert emojis == ["", ""]
@pytest.mark.asyncio
async def test_reaction_resolves_pending_approval(self, monkeypatch):
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
# Resolve user_id so _is_self_sender doesn't defensively drop all traffic (#15763).
adapter._user_id = "@bot:example.org"
adapter._approval_prompts_by_event["$target"] = _MatrixApprovalPrompt(
session_key="sess-1", chat_id="!room:example.org", message_id="$target"
)
adapter._approval_prompt_by_session["sess-1"] = "$target"
content = {"m.relates_to": {"event_id": "$target", "key": ""}}
event = types.SimpleNamespace(
sender="@liizfq:liizfq.top",
event_id="$react1",
room_id="!room:example.org",
content=content,
)
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
await adapter._on_reaction(event)
mock_resolve.assert_called_once_with("sess-1", "once")
assert "$target" not in adapter._approval_prompts_by_event
assert "sess-1" not in adapter._approval_prompt_by_session

View File

@@ -159,7 +159,7 @@ class TestStripMention:
assert result == "help me"
def test_localpart_preserved(self):
"""Localpart-only text is no longer stripped — avoids false positives in paths."""
"""Bare localpart (no @) is preserved — avoids false positives in paths."""
result = self.adapter._strip_mention("hermes help me")
assert result == "hermes help me"
@@ -168,11 +168,98 @@ class TestStripMention:
result = self.adapter._strip_mention("read /home/hermes/config.yaml")
assert result == "read /home/hermes/config.yaml"
def test_strip_localpart_when_explicit_at_mention(self):
result = self.adapter._strip_mention("@hermes help me")
assert result == "help me"
def test_does_not_strip_bare_localpart_word(self):
# Regression: plain words like "Hermes Agent" should not be mutated.
result = self.adapter._strip_mention("Hermes Agent")
assert result == "Hermes Agent"
def test_strip_returns_empty_for_mention_only(self):
result = self.adapter._strip_mention("@hermes:example.org")
assert result == ""
# ---------------------------------------------------------------------------
# Outbound mention payloads
# ---------------------------------------------------------------------------
class TestOutboundMentions:
def setup_method(self):
self.adapter = _make_adapter()
self.mock_client = MagicMock()
self.mock_client.send_message_event = AsyncMock(return_value="$evt1")
self.adapter._client = self.mock_client
@staticmethod
def _sent_content(mock_client):
call_args = mock_client.send_message_event.call_args
return call_args.args[2] if len(call_args.args) > 2 else call_args.kwargs["content"]
@pytest.mark.asyncio
async def test_send_adds_matrix_mentions_and_formatted_body(self):
result = await self.adapter.send(
"!room1:example.org",
"Hello @alice:example.org, please check this.",
)
assert result.success is True
content = self._sent_content(self.mock_client)
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
assert content["formatted_body"] == (
'Hello <a href="https://matrix.to/#/@alice:example.org">'
"@alice:example.org</a>, please check this."
)
@pytest.mark.asyncio
async def test_send_dedupes_mentions_and_ignores_code_spans(self):
await self.adapter.send(
"!room1:example.org",
"Ping @alice:example.org and @alice:example.org, not `@code:example.org`.",
)
content = self._sent_content(self.mock_client)
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
assert "@code:example.org</a>" not in content["formatted_body"]
@pytest.mark.asyncio
async def test_edit_message_preserves_mentions(self):
result = await self.adapter.edit_message(
"!room1:example.org",
"$original",
"Updated for @alice:example.org",
)
assert result.success is True
content = self._sent_content(self.mock_client)
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
assert content["m.new_content"]["m.mentions"] == {"user_ids": ["@alice:example.org"]}
assert content["m.new_content"]["formatted_body"] == (
'Updated for <a href="https://matrix.to/#/@alice:example.org">'
"@alice:example.org</a>"
)
assert content["formatted_body"] == (
'* Updated for <a href="https://matrix.to/#/@alice:example.org">'
"@alice:example.org</a>"
)
@pytest.mark.asyncio
async def test_send_simple_notice_adds_mentions(self):
result = await self.adapter._send_simple_message(
"!room1:example.org",
"Heads up @alice:example.org",
msgtype="m.notice",
)
assert result.success is True
content = self._sent_content(self.mock_client)
assert content["msgtype"] == "m.notice"
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
# ---------------------------------------------------------------------------
# Require-mention gating in _on_room_message
# ---------------------------------------------------------------------------

View File

@@ -3,6 +3,8 @@
import os
from unittest.mock import patch
import pytest
from gateway.platforms.base import (
BasePlatformAdapter,
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE,
@@ -582,3 +584,47 @@ class TestTruncateMessageUtf16:
f"Chunk {i} has unbalanced fences ({fence_count})"
)
class TestProxyKwargsForAiohttp:
"""Verify proxy_kwargs_for_aiohttp routes all schemes through ProxyConnector."""
def test_none_returns_empty(self):
from gateway.platforms.base import proxy_kwargs_for_aiohttp
sess_kw, req_kw = proxy_kwargs_for_aiohttp(None)
assert sess_kw == {}
assert req_kw == {}
def test_http_proxy_uses_connector_when_aiohttp_socks_available(self):
pytest.importorskip("aiohttp_socks")
from unittest.mock import MagicMock
from gateway.platforms.base import proxy_kwargs_for_aiohttp
sentinel = MagicMock(name="ProxyConnector")
with patch("aiohttp_socks.ProxyConnector.from_url", return_value=sentinel):
sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080")
assert sess_kw.get("connector") is sentinel, (
"HTTP proxy must use ProxyConnector so libraries that don't "
"forward per-request proxy= kwargs still route through the proxy"
)
assert req_kw == {}
def test_socks_proxy_uses_connector(self):
pytest.importorskip("aiohttp_socks")
from unittest.mock import MagicMock
from gateway.platforms.base import proxy_kwargs_for_aiohttp
sentinel = MagicMock(name="ProxyConnector")
with patch("aiohttp_socks.ProxyConnector.from_url", return_value=sentinel):
sess_kw, req_kw = proxy_kwargs_for_aiohttp("socks5://proxy:1080")
assert sess_kw.get("connector") is sentinel
assert req_kw == {}
def test_http_proxy_falls_back_without_aiohttp_socks(self):
from gateway.platforms.base import proxy_kwargs_for_aiohttp
with patch.dict("sys.modules", {"aiohttp_socks": None}):
sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080")
assert sess_kw == {}
assert req_kw == {"proxy": "http://proxy:8080"}

View File

@@ -0,0 +1,168 @@
"""Tests for optional-plugins (official) install path in plugins_cmd."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_official_plugin_dir(tmp_path: Path, category: str, name: str) -> Path:
"""Create a minimal optional-plugin directory structure."""
plugin_dir = tmp_path / "optional-plugins" / category / name
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(
f"name: {name}\nversion: 1.0.0\ndescription: Test plugin\n"
)
(plugin_dir / "__init__.py").write_text("def register(ctx): pass\n")
return plugin_dir
# ---------------------------------------------------------------------------
# _resolve_official_plugin
# ---------------------------------------------------------------------------
class TestResolveOfficialPlugin:
def test_returns_none_for_git_url(self, tmp_path):
from hermes_cli.plugins_cmd import _resolve_official_plugin
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
result = _resolve_official_plugin("https://github.com/owner/repo.git")
assert result is None
def test_returns_none_for_owner_repo(self, tmp_path):
from hermes_cli.plugins_cmd import _resolve_official_plugin
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
result = _resolve_official_plugin("owner/repo")
assert result is None
def test_returns_none_for_missing_plugin(self, tmp_path):
from hermes_cli.plugins_cmd import _resolve_official_plugin
(tmp_path / "optional-plugins").mkdir()
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
result = _resolve_official_plugin("official/observability/nonexistent")
assert result is None
def test_returns_path_for_existing_plugin(self, tmp_path):
from hermes_cli.plugins_cmd import _resolve_official_plugin
plugin_dir = _make_official_plugin_dir(tmp_path, "observability", "langfuse")
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
result = _resolve_official_plugin("official/observability/langfuse")
assert result == plugin_dir
def test_accepts_without_official_prefix(self, tmp_path):
from hermes_cli.plugins_cmd import _resolve_official_plugin
plugin_dir = _make_official_plugin_dir(tmp_path, "observability", "langfuse")
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
result = _resolve_official_plugin("observability/langfuse")
assert result == plugin_dir
def test_traversal_blocked(self, tmp_path):
from hermes_cli.plugins_cmd import _resolve_official_plugin
(tmp_path / "optional-plugins").mkdir()
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
result = _resolve_official_plugin("official/../../etc/passwd")
assert result is None
# ---------------------------------------------------------------------------
# _list_official_plugins
# ---------------------------------------------------------------------------
class TestListOfficialPlugins:
def test_empty_when_no_optional_plugins_dir(self, tmp_path):
from hermes_cli.plugins_cmd import _list_official_plugins
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "nonexistent"):
result = _list_official_plugins()
assert result == []
def test_lists_plugins_with_descriptions(self, tmp_path):
from hermes_cli.plugins_cmd import _list_official_plugins
_make_official_plugin_dir(tmp_path, "observability", "langfuse")
_make_official_plugin_dir(tmp_path, "observability", "other-plugin")
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
result = _list_official_plugins()
identifiers = [r[0] for r in result]
assert "official/observability/langfuse" in identifiers
assert "official/observability/other-plugin" in identifiers
def test_descriptions_parsed_from_yaml(self, tmp_path):
from hermes_cli.plugins_cmd import _list_official_plugins
plugin_dir = _make_official_plugin_dir(tmp_path, "observability", "langfuse")
with patch("hermes_cli.plugins_cmd._optional_plugins_dir", return_value=tmp_path / "optional-plugins"):
result = _list_official_plugins()
assert any(desc == "Test plugin" for _, desc in result)
# ---------------------------------------------------------------------------
# cmd_install — official path
# ---------------------------------------------------------------------------
class TestCmdInstallOfficial:
def test_install_official_plugin_copies_files(self, tmp_path, monkeypatch):
from hermes_cli.plugins_cmd import cmd_install
plugin_dir = _make_official_plugin_dir(tmp_path, "observability", "langfuse")
user_plugins = tmp_path / "user-plugins"
user_plugins.mkdir()
monkeypatch.setattr("hermes_cli.plugins_cmd._optional_plugins_dir",
lambda: tmp_path / "optional-plugins")
monkeypatch.setattr("hermes_cli.plugins_cmd._plugins_dir",
lambda: user_plugins)
# Non-interactive: don't prompt
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
cmd_install("official/observability/langfuse", enable=False)
installed = user_plugins / "langfuse"
assert installed.is_dir()
assert (installed / "plugin.yaml").exists()
assert (installed / "__init__.py").exists()
def test_install_official_plugin_respects_force(self, tmp_path, monkeypatch):
from hermes_cli.plugins_cmd import cmd_install
plugin_dir = _make_official_plugin_dir(tmp_path, "observability", "langfuse")
user_plugins = tmp_path / "user-plugins"
user_plugins.mkdir()
# Pre-create to simulate already-installed
already = user_plugins / "langfuse"
already.mkdir()
(already / "old.txt").write_text("old")
monkeypatch.setattr("hermes_cli.plugins_cmd._optional_plugins_dir",
lambda: tmp_path / "optional-plugins")
monkeypatch.setattr("hermes_cli.plugins_cmd._plugins_dir",
lambda: user_plugins)
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
cmd_install("official/observability/langfuse", force=True, enable=False)
# Old file should be gone, new files present
assert not (already / "old.txt").exists()
assert (already / "plugin.yaml").exists()
def test_install_official_plugin_exits_without_force_when_exists(self, tmp_path, monkeypatch):
from hermes_cli.plugins_cmd import cmd_install
_make_official_plugin_dir(tmp_path, "observability", "langfuse")
user_plugins = tmp_path / "user-plugins"
user_plugins.mkdir()
(user_plugins / "langfuse").mkdir()
monkeypatch.setattr("hermes_cli.plugins_cmd._optional_plugins_dir",
lambda: tmp_path / "optional-plugins")
monkeypatch.setattr("hermes_cli.plugins_cmd._plugins_dir",
lambda: user_plugins)
with pytest.raises(SystemExit):
cmd_install("official/observability/langfuse", enable=False)
def test_git_url_not_mistaken_for_official(self, tmp_path, monkeypatch):
"""A git URL must not trigger the official install path."""
from hermes_cli.plugins_cmd import _resolve_official_plugin
with patch("hermes_cli.plugins_cmd._optional_plugins_dir",
return_value=tmp_path / "optional-plugins"):
assert _resolve_official_plugin("https://github.com/owner/repo") is None
assert _resolve_official_plugin("owner/repo") is None

View File

@@ -72,8 +72,12 @@ def test_redact_secrets_false_in_config_yaml_is_honored(tmp_path):
assert "ENV_VAR=false" in result.stdout
def test_redact_secrets_default_true_when_unset(tmp_path):
"""Without the config key, redaction stays on by default."""
def test_redact_secrets_default_false_when_unset(tmp_path):
"""Without the config key, redaction stays OFF by default.
Secret redaction is opt-in — users who want it must set
`security.redact_secrets: true` explicitly (or HERMES_REDACT_SECRETS=true).
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text("{}\n") # empty config
@@ -103,7 +107,53 @@ def test_redact_secrets_default_true_when_unset(tmp_path):
timeout=30,
)
assert result.returncode == 0, f"probe failed: {result.stderr}"
assert "REDACT_ENABLED=True" in result.stdout
assert "REDACT_ENABLED=False" in result.stdout
def test_redact_secrets_true_in_config_yaml_is_honored(tmp_path):
"""Setting `security.redact_secrets: true` in config.yaml must enable
redaction — even though it's set in YAML, not as an env var."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
textwrap.dedent(
"""\
security:
redact_secrets: true
"""
)
)
(hermes_home / ".env").write_text("")
probe = textwrap.dedent(
"""\
import sys, os
os.environ.pop("HERMES_REDACT_SECRETS", None)
sys.path.insert(0, %r)
import hermes_cli.main
import agent.redact
print(f"REDACT_ENABLED={agent.redact._REDACT_ENABLED}")
print(f"ENV_VAR={os.environ.get('HERMES_REDACT_SECRETS', '<unset>')}")
"""
) % str(REPO_ROOT)
env = dict(os.environ)
env["HERMES_HOME"] = str(hermes_home)
env.pop("HERMES_REDACT_SECRETS", None)
result = subprocess.run(
[sys.executable, "-c", probe],
env=env,
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
timeout=30,
)
assert result.returncode == 0, f"probe failed: {result.stderr}"
assert "REDACT_ENABLED=True" in result.stdout, (
f"Config toggle not honored.\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
assert "ENV_VAR=true" in result.stdout
def test_dotenv_redact_secrets_beats_config_yaml(tmp_path):

View File

@@ -1373,6 +1373,144 @@ class TestSchemaInit:
migrated_db.close()
def test_reconciliation_adds_missing_columns(self, tmp_path):
"""Columns present in SCHEMA_SQL but missing from the live table
are added by _reconcile_columns regardless of schema_version.
Regression test: commit a7d78d3b inserted a new v7 migration
(reasoning_content) and renumbered the old v7 (api_call_count)
to v8. Users already at the old v7 had schema_version >= 7,
so the new v7 block was skipped and reasoning_content was never
created — causing 'no such column' on /continue.
"""
import sqlite3
db_path = tmp_path / "gap_test.db"
conn = sqlite3.connect(str(db_path))
# Simulate the old v7 state: api_call_count exists, reasoning_content does NOT
conn.executescript("""
CREATE TABLE schema_version (version INTEGER NOT NULL);
INSERT INTO schema_version (version) VALUES (7);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
user_id TEXT,
model TEXT,
model_config TEXT,
system_prompt TEXT,
parent_session_id TEXT,
started_at REAL NOT NULL,
ended_at REAL,
end_reason TEXT,
message_count INTEGER DEFAULT 0,
tool_call_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
cost_source TEXT,
pricing_version TEXT,
title TEXT,
api_call_count INTEGER DEFAULT 0
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
finish_reason TEXT,
reasoning TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT
);
""")
conn.execute(
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
("s1", "cli", 1000.0),
)
conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (?, ?, ?, ?)",
("s1", "assistant", "hello", 1001.0),
)
conn.commit()
# Verify reasoning_content is absent
cols = {r[1] for r in conn.execute("PRAGMA table_info(messages)").fetchall()}
assert "reasoning_content" not in cols
conn.close()
# Open with SessionDB — reconciliation should add the missing column
migrated_db = SessionDB(db_path=db_path)
msg_cols = {
r[1]
for r in migrated_db._conn.execute("PRAGMA table_info(messages)").fetchall()
}
assert "reasoning_content" in msg_cols
# The query that used to crash must now work
cursor = migrated_db._conn.execute(
"SELECT role, content, reasoning, reasoning_content, "
"reasoning_details, codex_reasoning_items "
"FROM messages WHERE session_id = ?",
("s1",),
)
row = cursor.fetchone()
assert row is not None
assert row[0] == "assistant"
assert row[3] is None # reasoning_content NULL for old rows
migrated_db.close()
def test_reconciliation_is_idempotent(self, tmp_path):
"""Opening the same database twice doesn't error or duplicate columns."""
db_path = tmp_path / "idempotent.db"
db1 = SessionDB(db_path=db_path)
cols1 = {r[1] for r in db1._conn.execute("PRAGMA table_info(messages)").fetchall()}
db1.close()
db2 = SessionDB(db_path=db_path)
cols2 = {r[1] for r in db2._conn.execute("PRAGMA table_info(messages)").fetchall()}
db2.close()
assert cols1 == cols2
def test_schema_sql_is_source_of_truth(self, db):
"""Every column in SCHEMA_SQL exists in the live database.
This is the architectural invariant: SCHEMA_SQL declares the
desired schema, _reconcile_columns ensures it matches reality.
"""
from hermes_state import SCHEMA_SQL
expected = SessionDB._parse_schema_columns(SCHEMA_SQL)
for table_name, declared_cols in expected.items():
live_cols = {
r[1]
for r in db._conn.execute(
f'PRAGMA table_info("{table_name}")'
).fetchall()
}
for col_name in declared_cols:
assert col_name in live_cols, (
f"Column {col_name} declared in SCHEMA_SQL for {table_name} "
f"but missing from live DB. Live columns: {live_cols}"
)
class TestTitleUniqueness:
"""Tests for unique title enforcement and title-based lookups."""

View File

@@ -568,6 +568,163 @@ class TestDelegateObservability(unittest.TestCase):
self.assertEqual(result["results"][0]["exit_reason"], "max_iterations")
class TestSubagentCostRollup(unittest.TestCase):
"""Port of Kilo-Org/kilocode#9448 — parent's session_estimated_cost_usd
must include subagent spend, not just the parent's own API calls."""
def _make_parent_with_cost_counters(self, depth=0, starting_cost=0.0):
parent = _make_mock_parent(depth=depth)
# The fields AIAgent exposes and the footer reads from. Set real
# floats/strings so the rollup can add to them rather than tripping
# on MagicMock auto-attrs.
parent.session_estimated_cost_usd = starting_cost
parent.session_cost_status = "unknown"
parent.session_cost_source = "none"
return parent
def test_single_child_cost_folded_into_parent(self):
parent = self._make_parent_with_cost_counters(starting_cost=0.10)
with patch("run_agent.AIAgent") as MockAgent:
mock_child = MagicMock()
mock_child.model = "claude-sonnet-4-6"
mock_child.session_prompt_tokens = 1000
mock_child.session_completion_tokens = 200
mock_child.session_estimated_cost_usd = 0.42
mock_child.run_conversation.return_value = {
"final_response": "done",
"completed": True,
"interrupted": False,
"api_calls": 2,
"messages": [],
}
MockAgent.return_value = mock_child
result = json.loads(delegate_task(goal="do stuff", parent_agent=parent))
# Parent footer must reflect parent_cost + child_cost.
self.assertAlmostEqual(parent.session_estimated_cost_usd, 0.52, places=6)
# Rollup must strip the internal field before serialising to the model.
self.assertNotIn("_child_cost_usd", result["results"][0])
self.assertNotIn("_child_role", result["results"][0])
def test_batch_children_costs_sum_into_parent(self):
parent = self._make_parent_with_cost_counters(starting_cost=0.00)
with patch("tools.delegate_tool._run_single_child") as mock_run:
mock_run.side_effect = [
{
"task_index": 0,
"status": "completed",
"summary": "A",
"api_calls": 2,
"duration_seconds": 1.0,
"_child_role": "leaf",
"_child_cost_usd": 0.15,
},
{
"task_index": 1,
"status": "completed",
"summary": "B",
"api_calls": 2,
"duration_seconds": 1.0,
"_child_role": "leaf",
"_child_cost_usd": 0.27,
},
{
"task_index": 2,
"status": "failed",
"summary": "",
"error": "boom",
"api_calls": 0,
"duration_seconds": 0.1,
"_child_role": "leaf",
"_child_cost_usd": 0.03,
},
]
result = json.loads(
delegate_task(
tasks=[{"goal": "A"}, {"goal": "B"}, {"goal": "C"}],
parent_agent=parent,
)
)
# 0.15 + 0.27 + 0.03 even though one child failed — the API calls it
# made before failing still cost money.
self.assertAlmostEqual(parent.session_estimated_cost_usd, 0.45, places=6)
# cost_source promoted from "none" since the parent had no direct spend.
self.assertEqual(parent.session_cost_source, "subagent")
self.assertEqual(parent.session_cost_status, "estimated")
# All internal fields stripped from results.
for entry in result["results"]:
self.assertNotIn("_child_cost_usd", entry)
self.assertNotIn("_child_role", entry)
def test_zero_cost_children_leave_parent_source_untouched(self):
"""If every child reports 0 cost (e.g. free local model), we should
not invent a fake 'subagent' source — the parent's 'none' stays."""
parent = self._make_parent_with_cost_counters(starting_cost=0.00)
with patch("tools.delegate_tool._run_single_child") as mock_run:
mock_run.return_value = {
"task_index": 0,
"status": "completed",
"summary": "done",
"api_calls": 1,
"duration_seconds": 0.5,
"_child_role": "leaf",
"_child_cost_usd": 0.0,
}
delegate_task(goal="free local run", parent_agent=parent)
self.assertEqual(parent.session_estimated_cost_usd, 0.0)
self.assertEqual(parent.session_cost_source, "none")
def test_parent_with_real_source_not_overwritten(self):
"""If the parent already has its own cost billed (cost_source != 'none'),
adding subagent cost must not clobber the existing source label."""
parent = self._make_parent_with_cost_counters(starting_cost=0.20)
parent.session_cost_status = "exact"
parent.session_cost_source = "openrouter"
with patch("tools.delegate_tool._run_single_child") as mock_run:
mock_run.return_value = {
"task_index": 0,
"status": "completed",
"summary": "done",
"api_calls": 1,
"duration_seconds": 0.5,
"_child_role": "leaf",
"_child_cost_usd": 0.30,
}
delegate_task(goal="billed run", parent_agent=parent)
self.assertAlmostEqual(parent.session_estimated_cost_usd, 0.50, places=6)
# Real source label preserved.
self.assertEqual(parent.session_cost_source, "openrouter")
self.assertEqual(parent.session_cost_status, "exact")
def test_rollup_tolerates_missing_cost_fields(self):
"""Older fixtures / fabricated error entries may not carry
_child_cost_usd. Rollup must degrade to zero-add silently."""
parent = self._make_parent_with_cost_counters(starting_cost=0.10)
with patch("tools.delegate_tool._run_single_child") as mock_run:
mock_run.return_value = {
"task_index": 0,
"status": "completed",
"summary": "done",
"api_calls": 1,
"duration_seconds": 0.5,
# no _child_role, no _child_cost_usd
}
result = json.loads(delegate_task(goal="legacy", parent_agent=parent))
# Parent cost unchanged.
self.assertEqual(parent.session_estimated_cost_usd, 0.10)
self.assertEqual(len(result["results"]), 1)
class TestBlockedTools(unittest.TestCase):
def test_blocked_tools_constant(self):
for tool in ["delegate_task", "clarify", "memory", "send_message", "execute_code"]:

View File

@@ -1616,6 +1616,19 @@ def _run_single_child(
# parent thread can fire subagent_stop with the correct role.
# Stripped before the dict is serialised back to the model.
"_child_role": getattr(child, "_delegate_role", None),
# Captured before child.close() so the parent aggregator can fold
# the child's total spend into the parent's session cost. Port of
# Kilo-Org/kilocode#9448 — previously the footer only reflected the
# parent's direct API calls and under-counted subagent-heavy runs.
# Stripped before the dict is serialised back to the model.
"_child_cost_usd": (
float(getattr(child, "session_estimated_cost_usd", 0.0) or 0.0)
if isinstance(
getattr(child, "session_estimated_cost_usd", 0.0),
(int, float),
)
else 0.0
),
}
if status == "failed":
entry["error"] = result.get("error", "Subagent did not produce a response.")
@@ -2112,8 +2125,20 @@ def delegate_task(
from hermes_cli.plugins import invoke_hook as _invoke_hook
except Exception:
_invoke_hook = None
# Aggregate child spend here so the parent's footer/UI reflect the true
# cost of a subagent-heavy turn. Port of Kilo-Org/kilocode#9448. Each
# child's cost was captured in _run_single_child before its AIAgent was
# closed; we fold them into the parent in one pass alongside the
# subagent_stop hook loop so we don't walk `results` twice.
_children_cost_total = 0.0
for entry in results:
child_role = entry.pop("_child_role", None)
child_cost = entry.pop("_child_cost_usd", 0.0)
try:
if child_cost:
_children_cost_total += float(child_cost)
except (TypeError, ValueError):
pass
if _invoke_hook is None:
continue
try:
@@ -2128,6 +2153,28 @@ def delegate_task(
except Exception:
logger.debug("subagent_stop hook invocation failed", exc_info=True)
# Fold the aggregated child cost into the parent's session total. This is
# additive — each delegate_task call contributes its own children — so
# nested orchestrator→worker trees roll up naturally: each layer's own
# delegate_task() folds its direct children in, and when the orchestrator
# itself finishes, its parent folds the orchestrator's now-inflated total
# on top. Degrades silently if the parent lacks the counter (older test
# fixtures, etc.).
if _children_cost_total > 0.0:
try:
current = float(getattr(parent_agent, "session_estimated_cost_usd", 0.0) or 0.0)
parent_agent.session_estimated_cost_usd = current + _children_cost_total
# Upgrade the cost_source so the UI doesn't label a partially-real
# total as "none" when the parent itself hadn't billed any calls
# yet (rare but possible when the parent's only action this turn
# was delegate_task).
if getattr(parent_agent, "session_cost_source", "none") in (None, "", "none"):
parent_agent.session_cost_source = "subagent"
if getattr(parent_agent, "session_cost_status", "unknown") in (None, "", "unknown"):
parent_agent.session_cost_status = "estimated"
except Exception:
logger.debug("Subagent cost rollup failed", exc_info=True)
total_duration = round(time.monotonic() - overall_start, 2)
return json.dumps(

View File

@@ -237,6 +237,9 @@ export const en: Translations = {
exportConfig: "Export config as JSON",
importConfig: "Import config from JSON",
resetDefaults: "Reset to defaults",
resetScopeTooltip: "Reset {scope} to defaults",
confirmResetScope: "Reset all {scope} settings to their defaults? This only updates the form — changes aren't written to config.yaml until you press Save.",
resetScopeToast: "{scope} reset to defaults — review and Save to persist",
rawYaml: "Raw YAML Configuration",
searchResults: "Search Results",
fields: "field{s}",

View File

@@ -242,6 +242,9 @@ export interface Translations {
exportConfig: string;
importConfig: string;
resetDefaults: string;
resetScopeTooltip: string;
confirmResetScope: string;
resetScopeToast: string;
rawYaml: string;
searchResults: string;
fields: string;

View File

@@ -234,6 +234,9 @@ export const zh: Translations = {
exportConfig: "导出配置为 JSON",
importConfig: "从 JSON 导入配置",
resetDefaults: "恢复默认值",
resetScopeTooltip: "将{scope}恢复为默认值",
confirmResetScope: "确定要将{scope}的所有设置恢复为默认值吗?此操作仅更新表单,在按下「保存」按钮前不会写入 config.yaml。",
resetScopeToast: "{scope}已恢复为默认值 — 请检查并保存以生效",
rawYaml: "原始 YAML 配置",
searchResults: "搜索结果",
fields: "个字段",

View File

@@ -228,7 +228,26 @@ export default function ConfigPage() {
};
const handleReset = () => {
if (defaults) setConfig(structuredClone(defaults));
if (!defaults || !config) return;
// Scope the reset to what the user is currently looking at:
// - search mode → the matched fields
// - form mode → the active category's fields
// Resetting the whole config here was a footgun (issue reported by @ykmfb001):
// the button sits next to the category tabs and users reasonably assumed
// "reset this tab", not "wipe my entire config.yaml".
const scopedFields = isSearching ? searchMatchedFields : activeFields;
if (scopedFields.length === 0) return;
const scopeLabel = isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory);
const message = t.config.confirmResetScope.replace("{scope}", scopeLabel);
if (!window.confirm(message)) return;
let next: Record<string, unknown> = config;
for (const [key] of scopedFields) {
next = setNestedValue(next, key, getNestedValue(defaults, key));
}
setConfig(next);
showToast(t.config.resetScopeToast.replace("{scope}", scopeLabel), "success");
};
const handleExport = () => {
@@ -333,9 +352,17 @@ export default function ConfigPage() {
<Upload className="h-3.5 w-3.5" />
</Button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
<Button variant="ghost" size="sm" onClick={handleReset} title={t.config.resetDefaults} aria-label={t.config.resetDefaults}>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
{!yamlMode && (() => {
const resetScopeLabel = isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory);
const resetTitle = t.config.resetScopeTooltip.replace("{scope}", resetScopeLabel);
return (
<Button variant="ghost" size="sm" onClick={handleReset} title={resetTitle} aria-label={resetTitle}>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
);
})()}
<div className="w-px h-5 bg-border mx-1" />

View File

@@ -1313,7 +1313,7 @@ Pre-execution security scanning and secret redaction:
```yaml
security:
redact_secrets: true # Redact API key patterns in tool output and logs
redact_secrets: false # Redact API key patterns in tool output and logs (off by default)
tirith_enabled: true # Enable Tirith security scanning for terminal commands
tirith_path: "tirith" # Path to tirith binary (default: "tirith" in $PATH)
tirith_timeout: 5 # Seconds to wait for tirith scan before timing out
@@ -1324,7 +1324,7 @@ security:
shared_files: []
```
- `redact_secrets` — automatically detects and redacts patterns that look like API keys, tokens, and passwords in tool output before it enters the conversation context and logs.
- `redact_secrets` when `true`, automatically detects and redacts patterns that look like API keys, tokens, and passwords in tool output before it enters the conversation context and logs. **Off by default** — enable if you commonly work with real credentials in tool output and want a safety net. Set to `true` explicitly to turn on.
- `tirith_enabled` — when `true`, terminal commands are scanned by [Tirith](https://github.com/StackGuardian/tirith) before execution to detect potentially dangerous operations.
- `tirith_path` — path to the tirith binary. Set this if tirith is installed in a non-standard location.
- `tirith_timeout` — maximum seconds to wait for a tirith scan. Commands proceed if the scan times out.