Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

This commit is contained in:
Brooklyn Nicholson 2026-05-30 23:10:43 -05:00
commit 6ca65d919d
19 changed files with 1079 additions and 286 deletions

View file

@ -150,10 +150,6 @@ _JWT_RE = re.compile(
r"(?:\.[A-Za-z0-9_=-]{4,}){0,2}" # Optional payload and/or signature
)
# Discord user/role mentions: <@123456789012345678> or <@!123456789012345678>
# Snowflake IDs are 17-20 digit integers that resolve to specific Discord accounts.
_DISCORD_MENTION_RE = re.compile(r"<@!?(\d{17,20})>")
# E.164 phone numbers: +<country><number>, 7-15 digits
# Negative lookahead prevents matching hex strings or identifiers
_SIGNAL_PHONE_RE = re.compile(r"(\+[1-9]\d{6,14})(?![A-Za-z0-9])")
@ -419,10 +415,6 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
if "&" in text and "=" in text:
text = _redact_form_body(text)
# Discord user/role mentions (<@snowflake_id>)
if "<@" in text:
text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text)
# E.164 phone numbers (Signal, WhatsApp)
if "+" in text:
def _redact_phone(m):

View file

@ -484,7 +484,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
from gateway.config import Platform, PlatformConfig
from gateway.session import SessionSource, build_session_key
from hermes_constants import get_hermes_dir, get_hermes_home
from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
@ -827,6 +827,7 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots")
_HERMES_HOME = get_hermes_home()
_HERMES_ROOT = get_default_hermes_root()
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
MEDIA_DELIVERY_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES"
MEDIA_DELIVERY_TRUST_RECENT_SECONDS_ENV = "HERMES_MEDIA_TRUST_RECENT_SECONDS"
@ -954,11 +955,14 @@ def _media_delivery_denied_paths() -> List[Path]:
home = Path(os.path.expanduser("~"))
for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS:
denied.append(home / sub)
# The Hermes home itself contains credentials (auth.json, .env) — only the
# cache subdirectories under it are explicitly allowlisted above.
denied.append(_HERMES_HOME / ".env")
denied.append(_HERMES_HOME / "auth.json")
denied.append(_HERMES_HOME / "credentials")
# The active Hermes profile and shared Hermes root both contain control
# files and credentials. Only cache subdirectories under them are
# explicitly allowlisted above.
for hermes_root in (_HERMES_HOME, _HERMES_ROOT):
denied.append(hermes_root / ".env")
denied.append(hermes_root / "auth.json")
denied.append(hermes_root / "credentials")
denied.append(hermes_root / "config.yaml")
return denied
@ -3736,6 +3740,7 @@ class BasePlatformAdapter(ABC):
# Call the handler (this can take a while with tool calls)
response = await self._message_handler(event)
is_ephemeral_response = isinstance(response, EphemeralReply)
# Slash-command handlers may return an EphemeralReply sentinel to
# request that their reply message auto-delete after a TTL (used
@ -3793,12 +3798,16 @@ class BasePlatformAdapter(ABC):
if images:
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
# Auto-detect bare local file paths for native media delivery
# (helps small models that don't use MEDIA: syntax)
local_files, text_content = self.extract_local_files(text_content)
local_files = self.filter_local_delivery_paths(local_files)
if local_files:
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
local_files = []
if not is_ephemeral_response:
# Auto-detect bare local file paths for native media delivery
# (helps small models that don't use MEDIA: syntax). Skip
# system/command notices so config paths stay visible text
# instead of becoming native uploads.
local_files, text_content = self.extract_local_files(text_content)
local_files = self.filter_local_delivery_paths(local_files)
if local_files:
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has

View file

@ -9776,16 +9776,61 @@ def _cmd_update_impl(args, gateway_mode: bool):
missing_config = get_missing_config_fields()
current_ver, latest_ver = check_config_version()
needs_migration = missing_env or missing_config or current_ver < latest_ver
has_new_options = bool(missing_env or missing_config)
version_bump_only = (
not has_new_options and current_ver < latest_ver
)
needs_migration = has_new_options or current_ver < latest_ver
if needs_migration:
if version_bump_only:
# Nothing for the user to fill in — only the config format version
# changed (new defaults already merge in transparently). Asking
# "configure new options now?" here is misleading: saying yes just
# bumps the version and looks like a no-op (issue: ScottFive /
# Tt2021). Apply it silently and say what actually happened.
print()
print(
f" Updating config format (v{current_ver} → v{latest_ver})…"
)
try:
migrate_config(interactive=False, quiet=True)
print(" ✓ Config format updated (no new settings to configure)")
except Exception as _mig_err:
print(f" ⚠️ Config format update failed: {_mig_err}")
print(" Run 'hermes config migrate' to retry.")
elif needs_migration:
print()
# Show WHAT changed, not just a count, so the user can make an
# informed yes/no decision (previously the prompt named nothing).
def _print_items(items, label, key, fallback_key=None):
if not items:
return
print(f" {label}:")
shown = items[:8]
for it in shown:
if isinstance(it, dict):
name = it.get(key) or (fallback_key and it.get(fallback_key)) or "?"
desc = (it.get("description") or "").strip()
else:
# Defensive: some callers/mocks pass bare name strings.
name = str(it)
desc = ""
if desc:
print(f"{name}{desc}")
else:
print(f"{name}")
extra = len(items) - len(shown)
if extra > 0:
print(f" … and {extra} more")
if missing_env:
print(
f" ⚠️ {len(missing_env)} new required setting(s) need configuration"
)
_print_items(missing_env, "New settings", "name")
if missing_config:
print(f" {len(missing_config)} new config option(s) available")
_print_items(missing_config, "New options", "key")
print()
if assume_yes:

View file

@ -32,34 +32,43 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
# Anthropic
("anthropic/claude-opus-4.8", ""),
("anthropic/claude-opus-4.8-fast", "2x price, higher output speed"),
("anthropic/claude-opus-4.7", ""),
("anthropic/claude-opus-4.6", ""),
("anthropic/claude-sonnet-4.6", ""),
("moonshotai/kimi-k2.6", "recommended"),
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
("qwen/qwen3.7-max", ""),
("anthropic/claude-haiku-4.5", ""),
# OpenAI
("openai/gpt-5.5", ""),
("openai/gpt-5.5-pro", ""),
("openai/gpt-5.4-mini", ""),
("openai/gpt-5.4-nano", ""),
("openai/gpt-5.3-codex", ""),
("xiaomi/mimo-v2.5-pro", ""),
("tencent/hy3-preview", ""),
("google/gemini-3-pro-image-preview", ""),
("google/gemini-3.5-flash", ""),
# Google
("google/gemini-3-pro-preview", ""),
("google/gemini-3.1-pro-preview", ""),
("google/gemini-3.1-flash-lite-preview", ""),
("qwen/qwen3.6-35b-a3b", ""),
("stepfun/step-3.7-flash", ""),
("minimax/minimax-m2.7", ""),
("z-ai/glm-5.1", ""),
("x-ai/grok-4.20", ""),
("google/gemini-3.5-flash", ""),
# xAI
("x-ai/grok-4.3", ""),
("nvidia/nemotron-3-super-120b-a12b", ""),
# DeepSeek
("deepseek/deepseek-v4-pro", ""),
("deepseek/deepseek-v4-flash", ""),
# Qwen
("qwen/qwen3.7-max", ""),
("qwen/qwen3.6-35b-a3b", ""),
# MoonshotAI
("moonshotai/kimi-k2.6", "recommended"),
# MiniMax
("minimax/minimax-m2.7", ""),
# Z-AI
("z-ai/glm-5.1", ""),
# Xiaomi
("xiaomi/mimo-v2.5-pro", ""),
# Tencent
("tencent/hy3-preview", ""),
# StepFun
("stepfun/step-3.7-flash", ""),
# NVIDIA
("nvidia/nemotron-3-super-120b-a12b", ""),
# OpenRouter routers
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
# Free tier
("openrouter/elephant-alpha", "free"),
("openrouter/owl-alpha", "free"),
@ -141,31 +150,40 @@ def _xai_curated_models() -> list[str]:
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
# Anthropic
"anthropic/claude-opus-4.8",
"anthropic/claude-opus-4.7",
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.6",
"moonshotai/kimi-k2.6",
"qwen/qwen3.7-max",
"anthropic/claude-haiku-4.5",
# OpenAI
"openai/gpt-5.5",
"openai/gpt-5.5-pro",
"openai/gpt-5.4-mini",
"openai/gpt-5.4-nano",
"openai/gpt-5.3-codex",
"xiaomi/mimo-v2.5-pro",
"tencent/hy3-preview",
# Google
"google/gemini-3-pro-preview",
"google/gemini-3.5-flash",
"google/gemini-3.1-pro-preview",
"google/gemini-3.1-flash-lite-preview",
"qwen/qwen3.6-35b-a3b",
"stepfun/step-3.7-flash",
"minimax/minimax-m2.7",
"z-ai/glm-5.1",
"google/gemini-3.5-flash",
# xAI
"x-ai/grok-4.3",
"nvidia/nemotron-3-super-120b-a12b",
# DeepSeek
"deepseek/deepseek-v4-pro",
"deepseek/deepseek-v4-flash",
# Qwen
"qwen/qwen3.7-max",
"qwen/qwen3.6-35b-a3b",
# MoonshotAI
"moonshotai/kimi-k2.6",
# MiniMax
"minimax/minimax-m2.7",
# Z-AI
"z-ai/glm-5.1",
# Xiaomi
"xiaomi/mimo-v2.5-pro",
# Tencent
"tencent/hy3-preview",
# StepFun
"stepfun/step-3.7-flash",
# NVIDIA
"nvidia/nemotron-3-super-120b-a12b",
],
# Native OpenAI Chat Completions (api.openai.com). Used by /model counts and
# provider_model_ids fallback when /v1/models is unavailable.

View file

@ -215,7 +215,7 @@ TIPS = [
# --- Context & Compression ---
"Context auto-compresses when it reaches the threshold — memories are flushed and history summarized.",
"The status bar turns yellow, then orange, then red as context fills up.",
"SOUL.md at ~/.hermes/SOUL.md is the agent's primary identity — customize it to shape behavior.",
"SOUL.md is the agent's primary identity file — customize it to shape behavior.",
"Hermes loads project context from .hermes.md, AGENTS.md, CLAUDE.md, or .cursorrules (first match).",
"Subdirectory AGENTS.md files are discovered progressively as the agent navigates into folders.",
"Context files are capped at 20,000 characters with smart head/tail truncation.",
@ -273,7 +273,7 @@ TIPS = [
"Cron scripts live in ~/.hermes/scripts/ and run before the agent — perfect for data collection pipelines.",
"prefill_messages_file in config.yaml injects few-shot examples into every API call, never saved to history.",
"SOUL.md completely replaces the agent's default identity — rewrite it to make Hermes your own.",
"SOUL.md is auto-seeded with a default personality on first run. Edit ~/.hermes/SOUL.md to customize.",
"SOUL.md is auto-seeded with a default personality on first run. Edit it to customize.",
"/compress <focus topic> allocates 60-70% of the summary budget to your topic and aggressively trims the rest.",
"On second+ compression, the compressor updates the previous summary instead of starting from scratch.",
"Before a gateway session reset, Hermes auto-flushes important facts to memory in the background.",
@ -430,7 +430,7 @@ TIPS = [
'hermes -z "<prompt>" is the purest one-shot: final answer on stdout, nothing else — ideal for piping in scripts.',
'hermes chat --pass-session-id injects the session ID into the system prompt so the agent can self-reference it.',
'hermes chat --image path/to/pic.png attaches a local image to a single -q query without a separate upload step.',
'hermes chat --ignore-user-config skips ~/.hermes/config.yaml — reproducible bug reports and CI runs.',
'hermes chat --ignore-user-config skips the active user config — reproducible bug reports and CI runs.',
"hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.",
'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.',
'hermes sessions rename <ID> "new title" renames any past session; hermes sessions delete <ID> removes one.',
@ -485,4 +485,3 @@ def get_random_tip(exclude_recent: int = 0) -> str:
"""
return random.choice(TIPS)

View file

@ -72,6 +72,15 @@ _last_init_error_lock = threading.Lock()
_wal_fallback_warned_paths: set[str] = set()
_wal_fallback_warned_lock = threading.Lock()
_FTS_TRIGGERS = (
"messages_fts_insert",
"messages_fts_delete",
"messages_fts_update",
"messages_fts_trigram_insert",
"messages_fts_trigram_delete",
"messages_fts_trigram_update",
)
def _set_last_init_error(msg: Optional[str]) -> None:
"""Record (or clear) the most recent state.db init failure.
@ -382,6 +391,7 @@ class SessionDB:
self._lock = threading.Lock()
self._write_count = 0
self._fts_enabled = False
self._fts_unavailable_warned = False
try:
self._conn = sqlite3.connect(
str(self.db_path),
@ -418,6 +428,111 @@ class SessionDB:
# ── Core write helper ──
@staticmethod
def _is_fts5_unavailable_error(exc: sqlite3.OperationalError) -> bool:
err = str(exc).lower()
return "no such module" in err and "fts5" in err
def _warn_fts5_unavailable(self, exc: sqlite3.OperationalError) -> None:
self._fts_enabled = False
if self._fts_unavailable_warned:
return
self._fts_unavailable_warned = True
logger.warning(
"SQLite FTS5 unavailable for %s; full-text session search "
"disabled. This usually means Hermes is running on an "
"unsupported install (e.g. a pip-installed or pip-managed "
"Python whose bundled SQLite lacks FTS5) rather than a "
"mainline install. Some features may be missing or behave "
"differently. Install the supported way: "
"https://hermes-agent.nousresearch.com (underlying error: %s)",
self.db_path,
exc,
)
def _sqlite_supports_fts5(self, cursor: sqlite3.Cursor) -> bool:
try:
cursor.execute("CREATE VIRTUAL TABLE temp._hermes_fts5_probe USING fts5(x)")
cursor.execute("DROP TABLE temp._hermes_fts5_probe")
return True
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
return False
@staticmethod
def _drop_fts_triggers(cursor: sqlite3.Cursor) -> None:
for trigger in _FTS_TRIGGERS:
try:
cursor.execute(f"DROP TRIGGER IF EXISTS {trigger}")
except sqlite3.OperationalError:
pass
@staticmethod
def _fts_trigger_count(cursor: sqlite3.Cursor) -> int:
placeholders = ",".join("?" for _ in _FTS_TRIGGERS)
row = cursor.execute(
f"SELECT COUNT(*) FROM sqlite_master "
f"WHERE type = 'trigger' AND name IN ({placeholders})",
_FTS_TRIGGERS,
).fetchone()
return int(row[0] if not isinstance(row, sqlite3.Row) else row[0])
@staticmethod
def _rebuild_fts_indexes(cursor: sqlite3.Cursor) -> None:
for table_name in ("messages_fts", "messages_fts_trigram"):
cursor.execute(f"DELETE FROM {table_name}")
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
def _fts_table_probe(self, cursor: sqlite3.Cursor, table_name: str) -> Optional[bool]:
try:
cursor.execute(f"SELECT * FROM {table_name} LIMIT 0")
return True
except sqlite3.OperationalError as exc:
if self._is_fts5_unavailable_error(exc):
self._warn_fts5_unavailable(exc)
return None
if "no such table" in str(exc).lower():
return False
raise
def _ensure_fts_schema(
self,
cursor: sqlite3.Cursor,
table_name: str,
ddl: str,
) -> bool:
status = self._fts_table_probe(cursor, table_name)
if status is None:
return False
try:
# Run even when the virtual table exists so any dropped or missing
# triggers are recreated after a previous no-FTS5 runtime disabled
# them to keep message writes working.
cursor.executescript(ddl)
return True
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
return False
def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T:
"""Execute a write transaction with BEGIN IMMEDIATE and jitter retry.
@ -630,6 +745,16 @@ class SessionDB:
except sqlite3.OperationalError as exc:
logger.debug("idx_messages_platform_msg_id create skipped: %s", exc)
fts5_available = self._sqlite_supports_fts5(cursor)
fts_migrations_complete = True
if not fts5_available:
# Existing FTS triggers can still fire on messages INSERT/UPDATE
# even though the current sqlite runtime cannot read the virtual
# tables they target. Drop only the triggers so core persistence
# continues; if a future runtime has FTS5, _ensure_fts_schema()
# recreates them.
self._drop_fts_triggers(cursor)
# ── Schema version bookkeeping ─────────────────────────────────
# Bump to current so future data migrations (if any) can gate on
# version. No version-gated column additions remain.
@ -651,17 +776,24 @@ class SessionDB:
# 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"
if fts5_available:
_fts_trigram_exists = self._fts_table_probe(
cursor, "messages_fts_trigram"
)
if _fts_trigram_exists is False:
if self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
):
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, content FROM messages WHERE content IS NOT NULL"
)
else:
fts_migrations_complete = False
elif _fts_trigram_exists is None:
fts_migrations_complete = False
else:
fts_migrations_complete = False
if current_version < 11:
# v11: re-index FTS5 tables to cover tool_name + tool_calls and
# switch from external-content to inline mode. Existing DBs have
@ -669,45 +801,50 @@ class SessionDB:
# overwrite, so we drop them explicitly and let the post-migration
# existence checks (below) recreate them from FTS_SQL /
# FTS_TRIGRAM_SQL, then backfill every message row. Fixes #16751.
for _trig in (
"messages_fts_insert",
"messages_fts_delete",
"messages_fts_update",
"messages_fts_trigram_insert",
"messages_fts_trigram_delete",
"messages_fts_trigram_update",
):
try:
cursor.execute(f"DROP TRIGGER IF EXISTS {_trig}")
except sqlite3.OperationalError:
pass
for _tbl in ("messages_fts", "messages_fts_trigram"):
try:
cursor.execute(f"DROP TABLE IF EXISTS {_tbl}")
except sqlite3.OperationalError:
pass
# Recreate virtual tables + triggers with the new inline-mode
# schema that indexes content || tool_name || tool_calls.
cursor.executescript(FTS_SQL)
cursor.executescript(FTS_TRIGRAM_SQL)
# Backfill both indexes from every existing messages row.
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
if current_version < SCHEMA_VERSION:
if fts5_available:
self._drop_fts_triggers(cursor)
for _tbl in ("messages_fts", "messages_fts_trigram"):
try:
cursor.execute(f"DROP TABLE IF EXISTS {_tbl}")
except sqlite3.OperationalError as exc:
if not self._is_fts5_unavailable_error(exc):
raise
self._warn_fts5_unavailable(exc)
fts5_available = False
fts_migrations_complete = False
break
if fts5_available:
# Recreate virtual tables + triggers with the new inline-mode
# schema that indexes content || tool_name || tool_calls.
if (
self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL)
and self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
):
# Backfill both indexes from every existing messages row.
cursor.execute(
"INSERT INTO messages_fts(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
cursor.execute(
"INSERT INTO messages_fts_trigram(rowid, content) "
"SELECT id, "
"COALESCE(content, '') || ' ' || "
"COALESCE(tool_name, '') || ' ' || "
"COALESCE(tool_calls, '') "
"FROM messages"
)
else:
fts_migrations_complete = False
else:
fts_migrations_complete = False
if current_version < SCHEMA_VERSION and fts_migrations_complete:
cursor.execute(
"UPDATE schema_version SET version = ?",
(SCHEMA_VERSION,),
@ -722,47 +859,22 @@ class SessionDB:
except sqlite3.OperationalError:
pass # Index already exists
# FTS5 setup (separate because CREATE VIRTUAL TABLE can't be in executescript with IF NOT EXISTS reliably)
try:
cursor.execute("SELECT * FROM messages_fts LIMIT 0")
self._fts_enabled = True
except sqlite3.OperationalError as exc:
if "no such table" not in str(exc).lower():
raise
try:
cursor.executescript(FTS_SQL)
self._fts_enabled = True
except sqlite3.OperationalError as fts_exc:
err = str(fts_exc).lower()
if "fts5" not in err and "no such module" not in err:
raise
logger.warning(
"SQLite FTS5 unavailable for %s; full-text session search "
"disabled. This usually means Hermes is running on an "
"unsupported install (e.g. a pip-installed or pip-managed "
"Python whose bundled SQLite lacks FTS5) rather than a "
"mainline install. Some features may be missing or behave "
"differently. Install the supported way: "
"https://hermes-agent.nousresearch.com (underlying error: %s)",
self.db_path,
fts_exc,
)
if fts5_available:
# FTS5 setup. Run the DDL even when the virtual table exists so
# CREATE TRIGGER IF NOT EXISTS repairs trigger-only degradation from
# an earlier no-FTS5 runtime.
triggers_need_repair = self._fts_trigger_count(cursor) < len(_FTS_TRIGGERS)
self._fts_enabled = self._ensure_fts_schema(cursor, "messages_fts", FTS_SQL)
# Trigram FTS5 for CJK/substring search
try:
cursor.execute("SELECT * FROM messages_fts_trigram LIMIT 0")
except sqlite3.OperationalError as exc:
if "no such table" not in str(exc).lower():
raise
try:
cursor.executescript(FTS_TRIGRAM_SQL)
except sqlite3.OperationalError as fts_exc:
err = str(fts_exc).lower()
if "fts5" not in err and "no such module" not in err:
raise
# Same FTS5-unavailable cause already warned about above for
# messages_fts; the trigram table is an additional CJK index,
# so just degrade silently here. CJK search falls back to LIKE.
# Trigram FTS5 for CJK/substring search. This is optional relative
# to the main FTS table; if it cannot be created, CJK search falls
# back to LIKE.
if self._fts_enabled:
trigram_enabled = self._ensure_fts_schema(
cursor, "messages_fts_trigram", FTS_TRIGRAM_SQL
)
if trigram_enabled and triggers_need_repair:
self._rebuild_fts_indexes(cursor)
self._conn.commit()
@ -3583,4 +3695,3 @@ class SessionDB:
(error[:500], session_id),
)
self._execute_write(_do)

View file

@ -603,35 +603,88 @@ except Exception:
PY
}
# Reinstall $PYTHON_VERSION with the current uv and re-resolve PYTHON_PATH.
# Returns 0 if the resulting interpreter ships FTS5.
_reinstall_python_with_fts5() {
local uv_bin="$1"
"$uv_bin" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1 || return 1
PYTHON_PATH="$("$uv_bin" python find "$PYTHON_VERSION" 2>/dev/null)"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
[ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH"
}
_warn_no_fts5() {
# Could not obtain an FTS5-capable interpreter (offline, pinned env, etc.).
# Install proceeds — Hermes degrades gracefully and disables only full-text
# session search — but warn so it isn't a silent gap.
log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but"
log_warn "full-text session search will be disabled until FTS5 is present."
}
# Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python
# distributions only gained FTS5 in mid-2025 (python-build-standalone #694),
# so a stale interpreter already in uv's store — which `uv python find`
# happily reuses — can lack it. When that happens, force a reinstall of the
# latest patch for $PYTHON_VERSION (which has FTS5) and re-resolve. This keeps
# the supported install path's session search working without bundling a
# second SQLite or asking the user to do anything.
# but WHICH builds a given uv can install is baked into the uv binary's
# download manifest — so a stale uv (e.g. `pip install uv==0.7.20`) only knows
# about pre-FTS5 builds, and even `uv python install --reinstall` just pulls the
# same FTS5-less interpreter. A plain reinstall with an old uv is therefore a
# no-op for FTS5. To actually fix everyone's install, we escalate uv itself:
#
# 1. reinstall with the current $UV_CMD (handles a stale *interpreter* under
# an already-current uv)
# 2. if still no FTS5, bring uv up to date (`uv self update`) and reinstall —
# this is what fixes a stale standalone uv
# 3. if uv can't self-update (pip/apt/brew-managed uv refuses), install a
# fresh standalone uv via the official installer into a temp dir and use
# THAT to reinstall — this fixes package-manager-managed stale uv
#
# Pythons live in uv's shared store, so a fresh uv's --reinstall overwrites the
# stale interpreter in place and the installer's later `uv python find` resolves
# to it. Keeps session search working without bundling a second SQLite or asking
# the user to do anything.
ensure_fts5() {
[ -n "${PYTHON_PATH:-}" ] || return 0
if _python_has_fts5 "$PYTHON_PATH"; then
return 0
fi
# Termux / non-uv installs have nothing to escalate.
[ -n "${UV_CMD:-}" ] || { _warn_no_fts5; return 0; }
log_warn "Resolved Python's SQLite lacks the FTS5 module (session search needs it)."
log_info "Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv..."
if "$UV_CMD" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1; then
PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
if _reinstall_python_with_fts5 "$UV_CMD"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
return 0
fi
if [ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
else
# Could not obtain an FTS5-capable interpreter (offline, pinned env,
# etc.). Install proceeds — Hermes degrades gracefully and disables
# only full-text session search — but warn so it isn't a silent gap.
log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but"
log_warn "full-text session search will be disabled until FTS5 is present."
# Still no FTS5 — the uv binary itself is too old to know about FTS5-capable
# Python builds. Try to update uv in place.
log_info "uv is too old to provide an FTS5-capable Python — updating uv..."
if "$UV_CMD" self update >/dev/null 2>&1; then
if _reinstall_python_with_fts5 "$UV_CMD"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
return 0
fi
fi
# `uv self update` is unavailable on externally-managed uv (pip/apt/brew),
# which is exactly the case the user hit (`pip install uv==0.7.20`). Install
# a fresh standalone uv into a temp dir and use it just for the reinstall.
log_info "Installing an up-to-date standalone uv to obtain an FTS5 Python..."
local _tmp_uv_dir _fresh_uv
_tmp_uv_dir="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-fresh-uv.$$")"
mkdir -p "$_tmp_uv_dir"
if curl -LsSf https://astral.sh/uv/install.sh 2>/dev/null \
| env UV_INSTALL_DIR="$_tmp_uv_dir" UV_UNMANAGED_INSTALL="$_tmp_uv_dir" sh >/dev/null 2>&1; then
_fresh_uv="$_tmp_uv_dir/uv"
if [ -x "$_fresh_uv" ] && _reinstall_python_with_fts5 "$_fresh_uv"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
rm -rf "$_tmp_uv_dir"
return 0
fi
fi
rm -rf "$_tmp_uv_dir"
_warn_no_fts5
}
# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git

View file

@ -342,39 +342,33 @@ class TestJWTTokens:
class TestDiscordMentions:
"""Discord snowflake IDs in <@ID> or <@!ID> format."""
"""Discord mention snowflakes (<@ID> / <@!ID>) are public syntax, not
secrets they must pass through the redactor unchanged so multi-bot
@-pings (DISCORD_ALLOW_BOTS=mentions) keep resolving. See issue #35611."""
def test_normal_mention(self):
result = redact_sensitive_text("Hello <@222589316709220353>")
assert "222589316709220353" not in result
assert "<@***>" in result
def test_normal_mention_passes_through(self):
text = "Hello <@222589316709220353>"
assert redact_sensitive_text(text) == text
def test_nickname_mention(self):
result = redact_sensitive_text("Ping <@!1331549159177846844>")
assert "1331549159177846844" not in result
assert "<@!***>" in result
def test_nickname_mention_passes_through(self):
text = "Ping <@!1331549159177846844>"
assert redact_sensitive_text(text) == text
def test_multiple_mentions(self):
def test_multiple_mentions_pass_through(self):
text = "<@111111111111111111> and <@222222222222222222>"
result = redact_sensitive_text(text)
assert "111111111111111111" not in result
assert "222222222222222222" not in result
assert redact_sensitive_text(text) == text
def test_short_id_not_matched(self):
"""IDs shorter than 17 digits are not Discord snowflakes."""
def test_short_id_passes_through(self):
text = "<@12345>"
assert redact_sensitive_text(text) == text
def test_slack_mention_not_matched(self):
"""Slack mentions use letters, not pure digits."""
def test_slack_mention_passes_through(self):
text = "<@U024BE7LH>"
assert redact_sensitive_text(text) == text
def test_preserves_surrounding_text(self):
text = "User <@222589316709220353> said hello"
result = redact_sensitive_text(text)
assert result.startswith("User ")
assert result.endswith(" said hello")
assert redact_sensitive_text(text) == text
class TestWebUrlsNotRedacted:

View file

@ -268,6 +268,37 @@ async def test_process_message_unwraps_ephemeral_before_send():
assert ("42", "sent-1") in adapter.deleted
@pytest.mark.asyncio
async def test_process_message_ephemeral_reply_does_not_auto_upload_bare_paths(tmp_path):
"""Tips/system notices may mention local paths; they must remain text."""
adapter = _delete_adapter()
adapter._send_with_retry = AsyncMock(
return_value=SendResult(success=True, message_id="sent-1")
)
adapter.send_document = AsyncMock(
return_value=SendResult(success=True, message_id="doc-1")
)
config_path = tmp_path / "config.yaml"
config_path.write_text("model:\n provider: test\n", encoding="utf-8")
reply_text = f"Tip: hermes chat --ignore-user-config skips {config_path}"
async def _handler(evt):
return EphemeralReply(reply_text, ttl_seconds=0)
adapter.set_message_handler(_handler)
event = _make_event(text="/new")
session_key = "agent:main:telegram:private:42"
with patch("gateway.platforms.base.asyncio.sleep", AsyncMock()), patch.object(
adapter, "_keep_typing", new=AsyncMock()
):
await adapter._process_message_background(event, session_key)
adapter._send_with_retry.assert_called_once()
assert adapter._send_with_retry.call_args.kwargs["content"] == reply_text
adapter.send_document.assert_not_awaited()
@pytest.mark.asyncio
async def test_process_message_incapable_platform_does_not_schedule_delete():
adapter = _no_delete_adapter()

View file

@ -728,6 +728,45 @@ class TestMediaDeliveryDefaultMode:
assert BasePlatformAdapter.validate_media_delivery_path(str(env_file)) is None
def test_denylist_blocks_hermes_config_in_active_profile(self, tmp_path, monkeypatch):
"""The active profile config stays blocked in default mode."""
self._patch_roots(monkeypatch)
fake_home = tmp_path / "home"
hermes_dir = fake_home / ".hermes"
hermes_dir.mkdir(parents=True)
config_file = hermes_dir / "config.yaml"
config_file.write_text("model:\n provider: openai\n")
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setattr(
"gateway.platforms.base._HERMES_HOME",
hermes_dir,
)
assert BasePlatformAdapter.validate_media_delivery_path(str(config_file)) is None
def test_denylist_blocks_shared_hermes_root_config_for_profiles(self, tmp_path, monkeypatch):
"""Profile-mode gateways must still block the shared Hermes root config."""
self._patch_roots(monkeypatch)
fake_home = tmp_path / "home"
profile_home = fake_home / ".hermes" / "profiles" / "work"
profile_home.mkdir(parents=True)
hermes_root = fake_home / ".hermes"
config_file = hermes_root / "config.yaml"
config_file.write_text("profiles:\n active: work\n")
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setattr(
"gateway.platforms.base._HERMES_HOME",
profile_home,
)
monkeypatch.setattr(
"gateway.platforms.base._HERMES_ROOT",
hermes_root,
)
assert BasePlatformAdapter.validate_media_delivery_path(str(config_file)) is None
def test_strict_mode_envvar_restores_legacy_behavior(self, tmp_path, monkeypatch):
"""Setting HERMES_MEDIA_DELIVERY_STRICT=1 reactivates the older
allowlist+recency logic. A stale file outside the allowlist is

View file

@ -284,6 +284,83 @@ class TestCmdUpdateBranchFallback:
assert "API keys require manual entry" in captured.out
class TestCmdUpdateMigrationPrompt:
"""The config-migration prompt names what changed and skips the prompt
entirely when only the config format version moved.
Regression guard for the contentless-prompt report (ScottFive / Tt2021):
previously the prompt printed only counts ("1 new config option") and
asked "configure them now?" even for pure version bumps, where saying
yes looked like a no-op.
"""
def test_version_bump_only_applies_silently_without_prompt(
self, mock_args, capsys
):
"""Only the version moved → apply non-interactively, never prompt."""
with patch("shutil.which", return_value=None), patch(
"subprocess.run"
) as mock_run, patch("builtins.input") as mock_input, patch(
"hermes_cli.config.get_missing_env_vars", return_value=[]
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=[]
), patch(
"hermes_cli.config.check_config_version", return_value=(5, 24)
), patch(
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": [], "warnings": []},
) as mock_migrate:
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
cmd_update(mock_args)
mock_input.assert_not_called()
mock_migrate.assert_called_once_with(interactive=False, quiet=True)
out = capsys.readouterr().out
assert "Updating config format (v5 → v24)" in out
assert "no new settings to configure" in out
# The misleading question must NOT appear for a pure version bump.
assert "configure them now" not in out.lower()
def test_new_options_are_listed_by_name_before_prompt(
self, mock_args, capsys
):
"""New env/config keys are printed by name so the user can decide."""
env_items = [
{"name": "FOO_API_KEY", "description": "Foo service API key"},
]
cfg_items = [
{"key": "display.new_widget", "description": "New config option: display.new_widget"},
]
with patch("shutil.which", return_value=None), patch(
"subprocess.run"
) as mock_run, patch("builtins.input", return_value="n"), patch(
"hermes_cli.config.get_missing_env_vars", return_value=env_items
), patch(
"hermes_cli.config.get_missing_config_fields", return_value=cfg_items
), patch(
"hermes_cli.config.check_config_version", return_value=(1, 24)
), patch(
"hermes_cli.config.migrate_config",
return_value={"env_added": [], "config_added": [], "warnings": []},
), patch("hermes_cli.main.sys") as mock_sys:
mock_sys.stdin.isatty.return_value = True
mock_sys.stdout.isatty.return_value = True
mock_run.side_effect = _make_run_side_effect(
branch="main", verify_ok=True, commit_count="1"
)
cmd_update(mock_args)
out = capsys.readouterr().out
# Names, not just counts.
assert "FOO_API_KEY" in out
assert "Foo service API key" in out
assert "display.new_widget" in out
class TestCmdUpdateProfileSkillSync:
"""cmd_update syncs bundled skills to all profiles, including the active one.

View file

@ -67,14 +67,14 @@ class TestFetchOpenRouterModels:
return False
def read(self):
return b'{"data":[{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","pricing":{"prompt":"0","completion":"0"}}]}'
return b'{"data":[{"id":"anthropic/claude-opus-4.8","pricing":{"prompt":"0.000015","completion":"0.000075"}},{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}},{"id":"nvidia/nemotron-3-super-120b-a12b:free","pricing":{"prompt":"0","completion":"0"}}]}'
monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None)
with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()):
models = fetch_openrouter_models(force_refresh=True)
assert models == [
("anthropic/claude-opus-4.6", "recommended"),
("anthropic/claude-opus-4.8", "recommended"),
("qwen/qwen3.7-max", ""),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
]
@ -154,7 +154,7 @@ class TestFetchOpenRouterModels:
# No supported_parameters field at all on either entry.
return (
b'{"data":['
b'{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"}},'
b'{"id":"anthropic/claude-opus-4.8","pricing":{"prompt":"0.000015","completion":"0.000075"}},'
b'{"id":"qwen/qwen3.7-max","pricing":{"prompt":"0.000000325","completion":"0.00000195"}}'
b']}'
)
@ -164,7 +164,7 @@ class TestFetchOpenRouterModels:
models = fetch_openrouter_models(force_refresh=True)
ids = [mid for mid, _ in models]
assert "anthropic/claude-opus-4.6" in ids
assert "anthropic/claude-opus-4.8" in ids
assert "qwen/qwen3.7-max" in ids

View file

@ -4,7 +4,7 @@ import sqlite3
import time
import pytest
from hermes_state import SessionDB
from hermes_state import SCHEMA_SQL, SessionDB
class _NoFtsCursor(sqlite3.Cursor):
@ -12,6 +12,8 @@ class _NoFtsCursor(sqlite3.Cursor):
def execute(self, sql, parameters=()):
probe = sql.strip()
if "USING fts5" in probe:
raise sqlite3.OperationalError("no such module: fts5")
if probe in (
"SELECT * FROM messages_fts LIMIT 0",
"SELECT * FROM messages_fts_trigram LIMIT 0",
@ -30,6 +32,24 @@ class _NoFtsConnection(sqlite3.Connection):
return super().cursor(factory or _NoFtsCursor)
class _NoFtsExistingTableCursor(_NoFtsCursor):
"""Simulate existing FTS virtual tables under a runtime without FTS5."""
def execute(self, sql, parameters=()):
probe = sql.strip()
if probe in (
"SELECT * FROM messages_fts LIMIT 0",
"SELECT * FROM messages_fts_trigram LIMIT 0",
):
raise sqlite3.OperationalError("no such module: fts5")
return super().execute(sql, parameters)
class _NoFtsExistingTableConnection(sqlite3.Connection):
def cursor(self, factory=None):
return super().cursor(factory or _NoFtsExistingTableCursor)
@pytest.fixture()
def db(tmp_path):
"""Create a SessionDB with a temp database file."""
@ -210,6 +230,106 @@ class TestSessionLifecycle:
finally:
db.close()
def test_existing_fts_tables_do_not_break_without_fts5(
self, tmp_path, monkeypatch
):
db_path = tmp_path / "state.db"
seeded = SessionDB(db_path=db_path)
try:
seeded.create_session(session_id="s1", source="cli")
seeded.append_message("s1", role="user", content="before runtime change")
finally:
seeded.close()
real_connect = sqlite3.connect
def connect_without_fts(*args, **kwargs):
kwargs["factory"] = _NoFtsExistingTableConnection
return real_connect(*args, **kwargs)
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
db = SessionDB(db_path=db_path)
try:
assert db._fts_enabled is False
assert db.get_session("s1") is not None
assert len(db.get_messages("s1")) == 1
# Existing FTS triggers must be disabled too; otherwise this write
# would try to insert into an unusable FTS virtual table.
db.append_message("s1", role="assistant", content="after runtime change")
messages = db.get_messages("s1")
assert len(messages) == 2
assert messages[1]["content"] == "after runtime change"
finally:
db.close()
def test_old_schema_without_fts5_does_not_crash(self, tmp_path, monkeypatch):
db_path = tmp_path / "legacy.db"
conn = sqlite3.connect(str(db_path))
conn.executescript(SCHEMA_SQL)
conn.execute("DELETE FROM schema_version")
conn.execute("INSERT INTO schema_version (version) VALUES (?)", (9,))
conn.commit()
conn.close()
real_connect = sqlite3.connect
def connect_without_fts(*args, **kwargs):
kwargs["factory"] = _NoFtsConnection
return real_connect(*args, **kwargs)
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
db = SessionDB(db_path=db_path)
try:
assert db._fts_enabled is False
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="legacy no fts")
assert db.get_messages("s1")[0]["content"] == "legacy no fts"
assert db.search_messages("legacy") == []
# Leave the FTS migration version in place so a future FTS-capable
# runtime can still rebuild and backfill the indexes.
row = db._conn.execute("SELECT version FROM schema_version").fetchone()
assert row["version"] == 9
finally:
db.close()
def test_fts_runtime_restores_triggers_after_no_fts_open(
self, tmp_path, monkeypatch
):
db_path = tmp_path / "state.db"
seeded = SessionDB(db_path=db_path)
try:
seeded.create_session(session_id="s1", source="cli")
seeded.append_message("s1", role="user", content="first searchable")
finally:
seeded.close()
real_connect = sqlite3.connect
def connect_without_fts(*args, **kwargs):
kwargs["factory"] = _NoFtsExistingTableConnection
return real_connect(*args, **kwargs)
monkeypatch.setattr("hermes_state.sqlite3.connect", connect_without_fts)
no_fts = SessionDB(db_path=db_path)
try:
no_fts.append_message("s1", role="assistant", content="not indexed yet")
finally:
no_fts.close()
monkeypatch.setattr("hermes_state.sqlite3.connect", real_connect)
restored = SessionDB(db_path=db_path)
try:
assert restored._fts_enabled is True
restored.append_message("s1", role="assistant", content="indexed again")
assert len(restored.search_messages("not indexed yet")) == 1
assert len(restored.search_messages("indexed")) == 2
finally:
restored.close()
# =========================================================================
# Message storage

View file

@ -133,4 +133,35 @@ describe('fragmented SGR mouse recovery', () => {
expect(key).toMatchObject({ kind: 'key', sequence: '1234;56;78M9;10;11M' })
})
it('swallows a fully degraded mouse-burst noise blob without leaking prompt text', () => {
// Captured from Windows Terminal during a heavy tool-call render: the event
// loop blocked past App's 50ms flush timer, so a long burst of SGR mouse
// reports (mode 1003 any-motion) arrived as text with prefixes AND
// too degraded for SGR_MOUSE_FRAGMENT_RE (1- and 2-param remnants, a
// stray focus-in `[I`), so without the whole-text noise fast path the entire
// blob types into the composer and locks the user out.
const blob =
'M6M35;220;56M6M35;218;56M169;48M;157;47M;44M20;43M79;40M78;40M0M7M35;49;41M48;41M;47;40M9;15;32M[I;31M5;211;26M35;211;25M7M;220;1MM0M09;25M24M23M3;22MM18M99;26M32MM38M63;44M47MM1;51M M4M54M'
const [events] = parseMultipleKeypresses(INITIAL_STATE, blob)
expect(events).toEqual([])
})
it('keeps plain prose that only contains scattered M and m letters', () => {
const [[key]] = parseMultipleKeypresses(INITIAL_STATE, 'Mmm MMM mmm yummy')
expect(key).toMatchObject({ kind: 'key', sequence: 'Mmm MMM mmm yummy' })
})
it('swallows noise wholesale even when it contains intact recoverable fragments', () => {
// A noise blob can carry a few intact `<b;c;r M` fragments amid the chewed
// shards. The whole-text noise check must run BEFORE fragment recovery —
// otherwise parseTextWithSgrMouseFragments returns non-null and emits a
// pile of recovered mouse events instead of dropping the blob wholesale.
const blob = '<35;159;11M;44M20;43M0M7M<35;124;26M;47;40M9;15;32M5M2M'
const [events] = parseMultipleKeypresses(INITIAL_STATE, blob)
expect(events).toEqual([])
})
})

View file

@ -65,6 +65,34 @@ const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/
const SGR_MOUSE_FRAGMENT_RE = /(?<!\d)(?:\[<|<)?(?:[0-9]|[1-9][0-9]|1\d{2}|2[0-4]\d|25[0-5]);\d+;\d+[Mm]/g
// Whole-text mouse-burst noise fast path. When a heavy render blocks the event
// loop past App's 50ms flush watchdog, a long burst of SGR mouse reports (mode
// 1003 any-motion / 1006 SGR) can arrive as a single text token with prefixes
// AND coordinate digits chewed off across many partial reads. The surviving
// shards (1- and 2-param remnants, stray focus-in `[I`, lone `M`/`m`
// terminators) are too degraded for SGR_MOUSE_FRAGMENT_RE, so the leftover
// tail leaks into the composer and locks the user out (they can't type or exit).
//
// If the ENTIRE text token is drawn only from the mouse-leak alphabet
// (`[ ] < ; I M m`, digits, and the stray spaces a burst can carry) AND it
// carries the structural signature of mouse coordinates — ≥3 `M`/`m`
// terminators, at least one digit, and at least one `;` separator — swallow it
// wholesale. All three constraints together preserve real prose: `Mmm MMM mmm`
// has no digit and no `;`, `see 1;2;3M for details` contains disqualifying
// letters, and `1234;56;78M9;10;11M` has only two terminators.
// eslint-disable-next-line no-control-regex
const MOUSE_BURST_NOISE_RE = /^(?=[\s\S]*\d)(?=[\s\S]*;)(?=(?:[^Mm]*[Mm]){3})[\d;<\[\]IMm \x1b]+$/
// Residual-shard variant for the gaps BETWEEN / AFTER recovered fragments
// inside parseTextWithSgrMouseFragments. A real recovery run leaves degraded
// remnants (e.g. `M6M`, `7M;220;1MM0M`, lone `;157;47M`) that are pure
// mouse-leak alphabet but too short to satisfy the ≥3-terminator whole-text
// rule. Swallow such a residue only when it is pure alphabet AND carries a
// digit AND at least one `M`/`m` — a prose gap like ` for details ` contains
// disqualifying letters and never matches.
// eslint-disable-next-line no-control-regex
const MOUSE_BURST_RESIDUE_RE = /^(?=[^\d]*\d)(?=[^Mm]*[Mm])[\d;<\[\]IMm \x1b]+$/
function createPasteKey(content: string): ParsedKey {
return {
kind: 'key',
@ -268,6 +296,16 @@ export function parseMultipleKeypresses(
} else if (token.type === 'text') {
if (inPaste) {
pasteBuffer += token.value
} else if (MOUSE_BURST_NOISE_RE.test(token.value)) {
// Fully degraded mouse-burst noise — a heavy render (e.g. a sudo /
// secret prompt repaint) blocked the event loop past App's 50ms flush
// watchdog, so a long burst of SGR mouse reports arrived as text with
// prefixes AND coordinate digits chewed off. Checked BEFORE fragment
// recovery: a noise blob can still contain a few intact `<b;c;r M`
// fragments, and parseTextWithSgrMouseFragments would then return
// non-null and emit a pile of recovered mouse events instead of
// dropping the blob wholesale. Swallow it here so it never leaks into
// the composer (and we skip the extra fragment-recovery work mid-stall).
} else {
const mouseFragments = parseTextWithSgrMouseFragments(token.value)
@ -674,7 +712,12 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null {
}
if (first.index! > cursor) {
parsed.push(parseKeypress(text.slice(cursor, first.index!)))
const gap = text.slice(cursor, first.index!)
// Skip pure mouse-leak residue between recovered fragments; only emit
// real text gaps as keypresses.
if (!MOUSE_BURST_RESIDUE_RE.test(gap)) {
parsed.push(parseKeypress(gap))
}
}
for (const match of run) {
@ -690,7 +733,12 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null {
}
if (cursor < text.length) {
parsed.push(parseKeypress(text.slice(cursor)))
const tail = text.slice(cursor)
// Swallow a pure mouse-leak residue tail (the head fragments recovered, but
// the burst trailed off into chewed-up shards). Emit only real trailing text.
if (!MOUSE_BURST_RESIDUE_RE.test(tail)) {
parsed.push(parseKeypress(tail))
}
}
return parsed

View file

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import {
clampStdoutDimensions,
DEFAULT_COLUMNS,
DEFAULT_ROWS,
MAX_COLUMNS,
MAX_ROWS,
sanitizeDimension,
sanitizeTerminalSize
} from '../lib/terminalDimensions.js'
describe('sanitizeDimension', () => {
it('passes through an in-range value', () => {
expect(sanitizeDimension(120, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(120)
})
it('floors fractional values', () => {
expect(sanitizeDimension(80.9, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(80)
})
it('clamps an absurd width to the max, not the fallback', () => {
expect(sanitizeDimension(131072, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(MAX_COLUMNS)
})
it('falls back when value is zero', () => {
expect(sanitizeDimension(0, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
})
it('falls back when value is negative', () => {
expect(sanitizeDimension(-5, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
})
it('falls back on NaN / undefined / non-number', () => {
expect(sanitizeDimension(NaN, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
expect(sanitizeDimension(undefined, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
expect(sanitizeDimension('80', 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
expect(sanitizeDimension(Infinity, 1, MAX_COLUMNS, DEFAULT_COLUMNS)).toBe(DEFAULT_COLUMNS)
})
})
describe('sanitizeTerminalSize', () => {
it('sanitizes the WSL 131072x1 report', () => {
// 131072 cols is absurd → clamp to max; 1 row is a valid (degenerate) TTY → keep.
expect(sanitizeTerminalSize(131072, 1)).toEqual({ columns: MAX_COLUMNS, rows: 1 })
})
it('passes a normal terminal through unchanged', () => {
expect(sanitizeTerminalSize(120, 40)).toEqual({ columns: 120, rows: 40 })
})
it('falls back when both dimensions are missing', () => {
expect(sanitizeTerminalSize(undefined, undefined)).toEqual({
columns: DEFAULT_COLUMNS,
rows: DEFAULT_ROWS
})
})
it('clamps an oversized height', () => {
expect(sanitizeTerminalSize(80, 99999)).toEqual({ columns: 80, rows: MAX_ROWS })
})
})
describe('clampStdoutDimensions', () => {
it('clamps a bogus columns getter on a live stream', () => {
let raw = 131072
const stream: { columns?: number; rows?: number } = {}
Object.defineProperty(stream, 'columns', { configurable: true, get: () => raw })
Object.defineProperty(stream, 'rows', { configurable: true, get: () => 1 })
clampStdoutDimensions(stream)
expect(stream.columns).toBe(MAX_COLUMNS)
expect(stream.rows).toBe(1)
// Live resize still propagates through the original getter, clamped.
raw = 100
expect(stream.columns).toBe(100)
raw = 0
expect(stream.columns).toBe(DEFAULT_COLUMNS)
})
it('clamps a bogus plain-value columns property', () => {
const stream: { columns?: number; rows?: number } = { columns: 131072, rows: 24 }
clampStdoutDimensions(stream)
expect(stream.columns).toBe(MAX_COLUMNS)
expect(stream.rows).toBe(24)
})
it('is idempotent', () => {
const stream: { columns?: number; rows?: number } = { columns: 131072, rows: 24 }
clampStdoutDimensions(stream)
clampStdoutDimensions(stream)
expect(stream.columns).toBe(MAX_COLUMNS)
})
it('does not crash on a non-configurable property', () => {
const stream: { columns?: number; rows?: number } = {}
Object.defineProperty(stream, 'columns', { configurable: false, value: 131072 })
expect(() => clampStdoutDimensions(stream)).not.toThrow()
})
})

View file

@ -11,6 +11,7 @@ import { setupGracefulExit } from './lib/gracefulExit.js'
import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js'
import { type MemorySnapshot, startMemoryMonitor } from './lib/memoryMonitor.js'
import { openExternalUrl } from './lib/openExternalUrl.js'
import { clampStdoutDimensions } from './lib/terminalDimensions.js'
import { resetTerminalModes } from './lib/terminalModes.js'
if (!process.stdin.isTTY) {
@ -18,6 +19,12 @@ if (!process.stdin.isTTY) {
process.exit(0)
}
// Some hosts (notably WSL) report bogus window sizes such as 131072x1. Clamp
// `process.stdout.columns`/`rows` at the source so the Ink renderer, its
// resize handler, and every component read see sane values. Must run before
// `ink.render` constructs the renderer.
clampStdoutDimensions()
// Start from a clean slate. If a previous TUI crashed or was kill -9'd, the
// terminal tab can still have mouse/focus/paste modes enabled.
resetTerminalModes()

View file

@ -0,0 +1,143 @@
/**
* Sanitize terminal dimensions reported by the host.
*
* Some environments report bogus window sizes. The motivating case (WSL,
* reported by @northframe_17) is `columns=131072, rows=1` a width that
* overflows any sane layout and a height of one row that makes the TUI
* unusable. Node's own `stdout.columns || 80` fallback only catches
* `0`/`NaN`/`undefined`, so a positive-but-absurd value sails straight into
* the Ink renderer, which then allocates a 131072-cell-wide screen buffer.
*
* We clamp each dimension independently to a sane range. Out-of-range or
* non-finite values fall back to the conventional 80x24 default rather than
* the raw garbage.
*/
export const DEFAULT_COLUMNS = 80
export const DEFAULT_ROWS = 24
// Upper bounds are generous (ultrawide multi-monitor terminals, tmux panes
// spanning huge displays) but well below the WSL garbage value. Anything
// beyond these is treated as a broken probe.
export const MAX_COLUMNS = 2000
export const MAX_ROWS = 1000
export const MIN_COLUMNS = 1
export const MIN_ROWS = 1
/**
* Clamp a single reported dimension into `[min, max]`.
*
* Returns `fallback` when the value is non-finite or `<= 0` (the classic
* "no size yet" signal). A positive value above `max` is clamped to `max`,
* not replaced by the fallback an oversized-but-finite report is more
* likely a real-but-large terminal than a missing one, and clamping keeps
* the layout sane either way.
*/
export function sanitizeDimension(value: unknown, min: number, max: number, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return fallback
}
const rounded = Math.floor(value)
if (rounded < min) {
return fallback
}
if (rounded > max) {
return max
}
return rounded
}
export interface SanitizedTerminalSize {
columns: number
rows: number
}
/** Sanitize a (columns, rows) pair using the TUI's bounds. */
export function sanitizeTerminalSize(columns: unknown, rows: unknown): SanitizedTerminalSize {
return {
columns: sanitizeDimension(columns, MIN_COLUMNS, MAX_COLUMNS, DEFAULT_COLUMNS),
rows: sanitizeDimension(rows, MIN_ROWS, MAX_ROWS, DEFAULT_ROWS)
}
}
interface ClampableStream {
columns?: number
rows?: number
}
const PATCHED = Symbol.for('hermes.tui.clampedDimensions')
/**
* Install clamping getters on `process.stdout` (or a provided stream) so every
* downstream reader the Ink renderer's root layout, its `resize` handler,
* and our React components' `stdout.columns ?? 80` reads sees sanitized
* values. Must run before `ink.render`.
*
* Idempotent: re-installing on an already-patched stream is a no-op. The raw
* values are read through the original property descriptor on each access, so
* live resizes still propagate (just clamped).
*/
export function clampStdoutDimensions(stream: ClampableStream = process.stdout): void {
const target = stream as ClampableStream & { [PATCHED]?: boolean }
if (target[PATCHED]) {
return
}
// Capture the original descriptors so we read the live host value on every
// access rather than freezing a single snapshot.
const colsDesc = findDescriptor(target, 'columns')
const rowsDesc = findDescriptor(target, 'rows')
const readCols = () => (colsDesc ? readValue(target, colsDesc) : target.columns)
const readRows = () => (rowsDesc ? readValue(target, rowsDesc) : target.rows)
try {
Object.defineProperty(target, 'columns', {
configurable: true,
enumerable: true,
get() {
return sanitizeDimension(readCols(), MIN_COLUMNS, MAX_COLUMNS, DEFAULT_COLUMNS)
}
})
Object.defineProperty(target, 'rows', {
configurable: true,
enumerable: true,
get() {
return sanitizeDimension(readRows(), MIN_ROWS, MAX_ROWS, DEFAULT_ROWS)
}
})
target[PATCHED] = true
} catch {
// Non-configurable property on an exotic stream — leave it alone rather
// than crashing startup. Components still have their own `?? 80` guard.
}
}
function findDescriptor(obj: object, key: string): PropertyDescriptor | undefined {
let cur: object | null = obj
while (cur) {
const desc = Object.getOwnPropertyDescriptor(cur, key)
if (desc) {
return desc
}
cur = Object.getPrototypeOf(cur) as object | null
}
return undefined
}
function readValue(target: object, desc: PropertyDescriptor): unknown {
if (desc.get) {
return desc.get.call(target)
}
return desc.value
}

View file

@ -1,6 +1,6 @@
{
"version": 1,
"updated_at": "2026-05-29T11:20:16Z",
"updated_at": "2026-05-31T03:27:32Z",
"metadata": {
"source": "hermes-agent repo",
"docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog"
@ -20,30 +20,10 @@
"id": "anthropic/claude-opus-4.8-fast",
"description": "2x price, higher output speed"
},
{
"id": "anthropic/claude-opus-4.7",
"description": ""
},
{
"id": "anthropic/claude-opus-4.6",
"description": ""
},
{
"id": "anthropic/claude-sonnet-4.6",
"description": ""
},
{
"id": "moonshotai/kimi-k2.6",
"description": "recommended"
},
{
"id": "openrouter/pareto-code",
"description": "auto-routes to cheapest coder meeting openrouter.min_coding_score"
},
{
"id": "qwen/qwen3.7-max",
"description": ""
},
{
"id": "anthropic/claude-haiku-4.5",
"description": ""
@ -61,11 +41,47 @@
"description": ""
},
{
"id": "openai/gpt-5.4-nano",
"id": "google/gemini-3-pro-preview",
"description": ""
},
{
"id": "openai/gpt-5.3-codex",
"id": "google/gemini-3.1-pro-preview",
"description": ""
},
{
"id": "google/gemini-3.5-flash",
"description": ""
},
{
"id": "x-ai/grok-4.3",
"description": ""
},
{
"id": "deepseek/deepseek-v4-pro",
"description": ""
},
{
"id": "deepseek/deepseek-v4-flash",
"description": ""
},
{
"id": "qwen/qwen3.7-max",
"description": ""
},
{
"id": "qwen/qwen3.6-35b-a3b",
"description": ""
},
{
"id": "moonshotai/kimi-k2.6",
"description": "recommended"
},
{
"id": "minimax/minimax-m2.7",
"description": ""
},
{
"id": "z-ai/glm-5.1",
"description": ""
},
{
@ -76,53 +92,17 @@
"id": "tencent/hy3-preview",
"description": ""
},
{
"id": "google/gemini-3-pro-image-preview",
"description": ""
},
{
"id": "google/gemini-3.5-flash",
"description": ""
},
{
"id": "google/gemini-3.1-pro-preview",
"description": ""
},
{
"id": "google/gemini-3.1-flash-lite-preview",
"description": ""
},
{
"id": "qwen/qwen3.6-35b-a3b",
"description": ""
},
{
"id": "stepfun/step-3.7-flash",
"description": ""
},
{
"id": "minimax/minimax-m2.7",
"description": ""
},
{
"id": "z-ai/glm-5.1",
"description": ""
},
{
"id": "x-ai/grok-4.20",
"description": ""
},
{
"id": "x-ai/grok-4.3",
"description": ""
},
{
"id": "nvidia/nemotron-3-super-120b-a12b",
"description": ""
},
{
"id": "deepseek/deepseek-v4-pro",
"description": ""
"id": "openrouter/pareto-code",
"description": "auto-routes to cheapest coder meeting openrouter.min_coding_score"
},
{
"id": "openrouter/elephant-alpha",
@ -155,21 +135,9 @@
{
"id": "anthropic/claude-opus-4.8"
},
{
"id": "anthropic/claude-opus-4.7"
},
{
"id": "anthropic/claude-opus-4.6"
},
{
"id": "anthropic/claude-sonnet-4.6"
},
{
"id": "moonshotai/kimi-k2.6"
},
{
"id": "qwen/qwen3.7-max"
},
{
"id": "anthropic/claude-haiku-4.5"
},
@ -182,35 +150,32 @@
{
"id": "openai/gpt-5.4-mini"
},
{
"id": "openai/gpt-5.4-nano"
},
{
"id": "openai/gpt-5.3-codex"
},
{
"id": "xiaomi/mimo-v2.5-pro"
},
{
"id": "tencent/hy3-preview"
},
{
"id": "google/gemini-3-pro-preview"
},
{
"id": "google/gemini-3.5-flash"
},
{
"id": "google/gemini-3.1-pro-preview"
},
{
"id": "google/gemini-3.1-flash-lite-preview"
"id": "google/gemini-3.5-flash"
},
{
"id": "x-ai/grok-4.3"
},
{
"id": "deepseek/deepseek-v4-pro"
},
{
"id": "deepseek/deepseek-v4-flash"
},
{
"id": "qwen/qwen3.7-max"
},
{
"id": "qwen/qwen3.6-35b-a3b"
},
{
"id": "stepfun/step-3.7-flash"
"id": "moonshotai/kimi-k2.6"
},
{
"id": "minimax/minimax-m2.7"
@ -219,13 +184,16 @@
"id": "z-ai/glm-5.1"
},
{
"id": "x-ai/grok-4.3"
"id": "xiaomi/mimo-v2.5-pro"
},
{
"id": "tencent/hy3-preview"
},
{
"id": "stepfun/step-3.7-flash"
},
{
"id": "nvidia/nemotron-3-super-120b-a12b"
},
{
"id": "deepseek/deepseek-v4-pro"
}
]
}