mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
commit
6ca65d919d
19 changed files with 1079 additions and 286 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
291
hermes_state.py
291
hermes_state.py
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
108
ui-tui/src/__tests__/terminalDimensions.test.ts
Normal file
108
ui-tui/src/__tests__/terminalDimensions.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
143
ui-tui/src/lib/terminalDimensions.ts
Normal file
143
ui-tui/src/lib/terminalDimensions.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue