3c7069bdcb
authz_mixin, browser_control_broker, delivery, delivery_ledger, display_config, drain_control, hosted_room_links/peer/policy_checkpoint, hosted_rooms, platform_registry, relay/__init__, relay/ws_transport, run.py and slash_commands.py (comments), session_context, session_state, streaming_tts_consumer, turn_lease and small modules. - HostedRoomPolicyCheckpoint._apply_event -> per-kind handler table - WebSocketRelayTransport._handle_frame -> frame-handler table - GatewayAuthorizationMixin: unified adapter setting/flag/extra readers - dead symbols removed (verified zero references): RoomLinkProbe/select_room_link, relay_bot_username, is_restart_loop_tripped, debug_rows, DeadTargetRegistry.all_dead, BrowserControlBroker.detach_owner/_prune_tickets, StreamingTTSConsumer.started/_enqueue_done/ _iter_stream_chunks/_next_stream_chunk, RecoverableHandleCache.status_for, _auth_env, _copy_default_catalog, _parse_timestamp_prefix, _present_* helpers, _send_result_error_kind, _truthy_env, SessionFieldView/TurnLeaseTokenView dunder shims, and their orphaned tests. - lost WHY/invariant text from the earlier compaction restored compactly (541 hunks audited)
197 lines
7.5 KiB
Python
197 lines
7.5 KiB
Python
"""Human-friendly generic gateway status phrases.
|
|
|
|
Turns Hermes' long-running gateway status surface into short chat-safe lines
|
|
without relaying raw model scratch text: only configured phrase strings are
|
|
used — tool args, commands, previews, and reasoning are never interpolated.
|
|
|
|
Built-in defaults live in ``gateway/assets/status_phrases.yaml``. Users can add
|
|
profile-relative catalogs under ``HERMES_HOME`` via the conventional paths
|
|
``status_phrases.yaml`` / ``status_phrases/*.yaml`` or via config::
|
|
|
|
display:
|
|
status_phrases:
|
|
path: status_phrases/whatsapp.yaml # relative to HERMES_HOME
|
|
mode: append # append (default) or replace
|
|
|
|
Absolute paths and ``..`` escapes are ignored on purpose so config stays
|
|
profile-portable and cannot read arbitrary files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random as _random
|
|
from collections.abc import Mapping, MutableSequence
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
# Hermes UI surfaces, not app/vendor buckets. Long-running-only: regular
|
|
# tool/thinking/interim chatter is deliberately not rewritten (too noisy in chat).
|
|
_STATUS_SURFACES = ("status", "generic")
|
|
_MAX_CUSTOM_PHRASES_PER_SURFACE = 80
|
|
_MAX_PHRASE_CHARS = 160
|
|
_CONVENTIONAL_RELATIVE_PATHS = ("status_phrases.yaml", "status_phrases")
|
|
_YAML_SUFFIXES = {".yaml", ".yml"}
|
|
_CONFIG_KEYS = ("generic_status_phrases", "status_phrases") # legacy alias first
|
|
|
|
_FALLBACK_PHRASES: dict[str, list[str]] = {
|
|
"status": ["still on it", "still working through it", "waiting for the result"],
|
|
"generic": ["on it", "one sec", "checking that now"],
|
|
}
|
|
|
|
|
|
def _clean_phrase_list(value: Any) -> list[str]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
cleaned: list[str] = []
|
|
for item in value[:_MAX_CUSTOM_PHRASES_PER_SURFACE]:
|
|
phrase = str(item or "").strip()
|
|
if phrase and len(phrase) <= _MAX_PHRASE_CHARS and phrase not in cleaned:
|
|
cleaned.append(phrase)
|
|
return cleaned
|
|
|
|
|
|
def _merge_phrase_mapping(catalog: dict[str, list[str]], section: Mapping[str, Any], *, inherited_mode: str | None = None) -> None:
|
|
replace = str(section.get("mode") or inherited_mode or "append").strip().lower() == "replace"
|
|
phrase_map = section.get("phrases") if isinstance(section.get("phrases"), Mapping) else section
|
|
for surface in _STATUS_SURFACES:
|
|
phrases = _clean_phrase_list(phrase_map.get(surface) if isinstance(phrase_map, Mapping) else None)
|
|
if phrases:
|
|
catalog[surface] = phrases if replace else [*catalog.get(surface, []), *phrases]
|
|
|
|
|
|
def _merge_phrase_file(catalog: dict[str, list[str]], path: Path, *, inherited_mode: str | None = None) -> None:
|
|
try:
|
|
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return
|
|
if isinstance(loaded, Mapping):
|
|
_merge_phrase_mapping(catalog, loaded, inherited_mode=inherited_mode)
|
|
|
|
|
|
def _relative_path_under(base_dir: Path, raw_path: Any) -> Path | None:
|
|
raw = str(raw_path or "").strip()
|
|
if not raw:
|
|
return None
|
|
candidate = Path(raw).expanduser()
|
|
if candidate.is_absolute() or ".." in candidate.parts:
|
|
return None
|
|
base = base_dir.resolve()
|
|
resolved = (base / candidate).resolve()
|
|
try:
|
|
resolved.relative_to(base)
|
|
except ValueError:
|
|
return None
|
|
return resolved
|
|
|
|
|
|
def _iter_phrase_files(path: Path) -> list[Path]:
|
|
if path.is_file() and path.suffix.lower() in _YAML_SUFFIXES:
|
|
return [path]
|
|
if path.is_dir():
|
|
return sorted(c for c in path.iterdir() if c.is_file() and c.suffix.lower() in _YAML_SUFFIXES)
|
|
return []
|
|
|
|
|
|
def _merge_phrase_paths(catalog: dict[str, list[str]], paths: Any, *, base_dir: Path, inherited_mode: str | None = None) -> None:
|
|
if paths is None:
|
|
return
|
|
for raw_path in paths if isinstance(paths, list) else [paths]:
|
|
resolved = _relative_path_under(base_dir, raw_path)
|
|
if resolved is not None:
|
|
for phrase_file in _iter_phrase_files(resolved):
|
|
_merge_phrase_file(catalog, phrase_file, inherited_mode=inherited_mode)
|
|
|
|
|
|
def _copy_catalog(catalog: Mapping[str, list[str]]) -> dict[str, list[str]]:
|
|
return {surface: list(phrases) for surface, phrases in catalog.items()}
|
|
|
|
|
|
def _load_builtin_catalog() -> dict[str, list[str]]:
|
|
catalog = _copy_catalog(_FALLBACK_PHRASES)
|
|
_merge_phrase_file(catalog, Path(__file__).resolve().parent / "assets" / "status_phrases.yaml", inherited_mode="replace")
|
|
return catalog
|
|
|
|
|
|
_DEFAULT_PHRASES: dict[str, list[str]] = _load_builtin_catalog()
|
|
|
|
|
|
def _merge_phrase_config(catalog: dict[str, list[str]], section: Any, *, base_dir: Path) -> None:
|
|
"""Merge one display.status_phrases-style section (files first, then inline phrases)."""
|
|
if not isinstance(section, Mapping):
|
|
return
|
|
mode = str(section.get("mode") or "append").strip().lower()
|
|
for key in ("path", "paths"):
|
|
_merge_phrase_paths(catalog, section.get(key), base_dir=base_dir, inherited_mode=mode)
|
|
_merge_phrase_mapping(catalog, section)
|
|
|
|
|
|
def resolve_status_phrase_catalog(user_config: Mapping[str, Any] | None, platform_key: str | None = None) -> dict[str, list[str]]:
|
|
"""Resolve built-in + user-configured generic status phrases.
|
|
|
|
Resolution order mirrors gateway display settings: built-ins, conventional
|
|
profile-relative user files, global ``display.status_phrases`` (or legacy
|
|
alias ``generic_status_phrases``), then
|
|
``display.platforms.<platform>.status_phrases``.
|
|
"""
|
|
catalog = _copy_catalog(_DEFAULT_PHRASES)
|
|
hermes_home = get_hermes_home()
|
|
_merge_phrase_paths(catalog, list(_CONVENTIONAL_RELATIVE_PATHS), base_dir=hermes_home)
|
|
|
|
display = (user_config or {}).get("display") if isinstance(user_config, Mapping) else None
|
|
if not isinstance(display, Mapping):
|
|
return catalog
|
|
|
|
sections = [display]
|
|
platforms = display.get("platforms")
|
|
if platform_key and isinstance(platforms, Mapping) and isinstance(platforms.get(platform_key), Mapping):
|
|
sections.append(platforms[platform_key])
|
|
for section in sections:
|
|
for key in _CONFIG_KEYS:
|
|
_merge_phrase_config(catalog, section.get(key), base_dir=hermes_home)
|
|
return catalog
|
|
|
|
|
|
def classify_status_context(
|
|
kind: str,
|
|
*,
|
|
tool_name: str | None = None,
|
|
preview: str | None = None,
|
|
args: Any = None,
|
|
) -> str:
|
|
"""Classify an internal gateway event into a Hermes UI-surface bucket."""
|
|
if str(kind or "").strip().lower() in {"heartbeat", "waiting", "long_running", "status"}:
|
|
return "status"
|
|
return "generic"
|
|
|
|
|
|
def choose_status_phrase(
|
|
kind: str,
|
|
*,
|
|
tool_name: str | None = None,
|
|
preview: str | None = None,
|
|
args: Any = None,
|
|
recent: MutableSequence[str] | None = None,
|
|
rng: Any = None,
|
|
catalog: Mapping[str, list[str]] | None = None,
|
|
) -> str:
|
|
"""Pick a short generic status phrase, avoiding recent repeats.
|
|
|
|
``preview`` and ``args`` are accepted for callback compatibility, but their
|
|
raw contents are never embedded in the returned phrase.
|
|
"""
|
|
phrase_catalog = catalog or _DEFAULT_PHRASES
|
|
category = classify_status_context(kind, tool_name=tool_name, preview=preview, args=args)
|
|
candidates = list(phrase_catalog.get(category) or phrase_catalog.get("generic") or _DEFAULT_PHRASES["generic"])
|
|
if recent:
|
|
recent_set = set(recent)
|
|
candidates = [p for p in candidates if p not in recent_set] or candidates
|
|
phrase = (rng or _random).choice(candidates)
|
|
if recent is not None:
|
|
recent.append(phrase)
|
|
del recent[:-6]
|
|
return phrase
|