Files
hermes-agent/tui_gateway/methods_shared_metrics.py
kshitijk4poor b010015cea refactor(tui_gateway): move _tui_model_friction into methods_shared_metrics
methods_tools.py is over 2,000 lines, so the code-health ratchet only lets it
shrink against main. e84e82225a kept the file at its cap by squeezing
_cmd_compress onto 118- and 136-column lines. Root AGENTS.md says to offset
growth by moving a function into a sibling module instead. This frees seven lines so
the next commit can restore the readable forms.

_tui_model_friction records the /retry and /undo model-friction metric, so it
belongs with the shared-metrics handlers. Pure move: the body is unchanged.
Its callers (/retry and /undo in methods_tools, session.undo in methods_session)
resolve it bare through server.py's namespace, which bind_module publishes from
the new module. No test or doc references it, and no re-export is left behind.

_cmd_compress itself could not move to methods_slash: the module-level
_SLASH_BUILTINS dict in methods_tools references it by value, so moving it
would need an import from methods_tools into methods_slash.

Gate finding: C04 r2 quality [Low CLEANUP].
Refs #133504
2026-10-06 13:59:55 +05:30

190 lines
8.3 KiB
Python

"""Shared-metrics consent JSON-RPC handlers (the Desktop twin of ``hermes setup``'s Shared Metrics
section). Both opt-ins live in the focused profile's config.yaml exactly where the wizard writes
them — ``telemetry.shared_metrics.enabled`` (local collection) and ``.send`` (daily upload) — so the
CLI and the app can never disagree about the answer. ``shared_metrics.set`` enforces the wizard's
invariant (sending needs collection) and reconciles the consent windows in the local store on every
change, through the same single writer the wizard uses.
Bodies are rebound onto server.py's globals (method_ctx.bind_module) and reference them bare.
"""
import logging
from .method_ctx import HandlerRegistry, bind_module
logger = logging.getLogger(__name__)
_registry = HandlerRegistry()
method = _registry.method
_profile_scoped = _registry.profile_scoped
def _shared_metrics_consent(cfg) -> dict:
"""The one ``{enabled, send, decided, reask}`` reading every surface shares (CLI offer included)."""
from hermes_cli.observability.shared_metrics_consent import consent_state
return consent_state(cfg)
def _shared_metrics_record_setup_completed(cfg) -> None:
"""Desktop has no setup-finish RPC; this first-run answer is the one backend call made once,
right after onboarding settles. A no-op (inside the events API) unless collection is on."""
from hermes_cli.observability.shared_metrics_events import record_setup_completed
model = cfg.get("model") if isinstance(cfg, dict) else None
provider = model.get("provider") if isinstance(model, dict) else None
record_setup_completed(surface="desktop", provider=provider if isinstance(provider, str) and provider else None)
def _tui_model_friction(signal, session, turns=1):
from hermes_cli.observability.shared_metrics_model import record_model_friction
record_model_friction(
signal, session_id=session.get("session_key"), agent=session.get("agent"),
hermes_home=session.get("profile_home"), turns=turns)
@method("shared_metrics.status")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""``{enabled, send, decided, reask}`` for the focused profile. A pure read of config.yaml (no defaults
merged, so ``decided`` sees only what the user wrote)."""
try:
return _ok(rid, _shared_metrics_consent(_load_cfg()))
except Exception as e:
return _err(rid, 5095, str(e))
@method("shared_metrics.set")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Write both opt-ins at once and reconcile the consent windows. ``send`` is forced off when
``enabled`` is off (the wizard's rule: sending cannot outlive collection, and turning collection
off withdraws send consent). ``first_run`` marks the Desktop first-run answer, which also records
the setup-completed metric. Answers the stored ``{enabled, send, decided, reask}``."""
enabled = params.get("enabled") is True
send = enabled and params.get("send") is True
from hermes_cli.observability.shared_metrics_consent import set_answer
try:
cfg = _load_cfg_raw()
set_answer(cfg, enabled, send)
_save_cfg(cfg)
except Exception as e:
return _err(rid, 5096, str(e))
from hermes_cli.setup import _record_send_consent_change
# Unconditional, like the wizard: a send key already false may still have an open window.
_record_send_consent_change(enabled=send)
if not enabled:
from hermes_cli.observability.shared_metrics_desktop import purge_onboarding_latches
purge_onboarding_latches()
if params.get("first_run") is True:
_shared_metrics_record_setup_completed(cfg)
return _ok(rid, _shared_metrics_consent(cfg))
@method("shared_metrics.slash_command")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""The Desktop and Ink TUI dispatchers call this once per user-typed slash command, locally
handled ones included; the gateway never counts slash.exec / command.dispatch itself, so
each command lands exactly once. Always ``{ok: true}`` (the events API never raises)."""
from hermes_cli.observability.shared_metrics_events import record_slash_command
record_slash_command(command=str(params.get("command") or ""), surface=_resolve_session_platform())
return _ok(rid, {"ok": True})
@method("shared_metrics.startup_latency")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""The Ink TUI (gateway ready) and Desktop (backend attached) each report their own launch ->
ready time once per launch; ``launch_id`` latches it here too, so a reconnect to this backend
never re-counts. A Desktop on a URL/cloud backend has no ``HERMES_DESKTOP`` here, so the
client's declared surface wins over env detection. Always ``{ok: true}``."""
from hermes_cli.observability.shared_metrics_startup import record_rpc_startup_latency
record_rpc_startup_latency(
client_surface=params.get("surface") or _resolve_session_platform(), elapsed_ms=params.get("elapsed_ms"),
launch_id=params.get("launch_id"),
)
return _ok(rid, {"ok": True})
# ---- v4 reliability ----
@method("shared_metrics.update_run")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Desktop reports each packaged self-update once (a pending record survives the restart that
applies it and is sent on the next backend attach). Always ``{ok: true}``."""
from hermes_cli.observability.shared_metrics_update import record_desktop_update
record_desktop_update(
outcome=params.get("outcome"), failed_stage=params.get("failed_stage"),
duration_ms=params.get("duration_ms"), mechanism=params.get("mechanism"),
from_commit_date=params.get("from_commit_date"),
)
return _ok(rid, {"ok": True})
# ---- end v4 reliability ----
# ---- v5 desktop ----
@method("shared_metrics.desktop_feature_use")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Desktop reports each area the user opened, at most once per UTC day (latched on both sides).
Always ``{ok: true}``; a no-op unless shared metrics are on."""
from hermes_cli.observability.shared_metrics_desktop import record_desktop_feature_use
record_desktop_feature_use(area=params.get("area"))
return _ok(rid, {"ok": True})
@method("shared_metrics.desktop_friction")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""One Desktop friction event (dismissed notice, error toast by category, renderer crash, backend
disconnect, slow frame), capped per day. Always ``{ok: true}``."""
from hermes_cli.observability.shared_metrics_desktop import record_desktop_friction
record_desktop_friction(kind=params.get("kind"), detail=params.get("detail"))
return _ok(rid, {"ok": True})
@method("shared_metrics.desktop_onboarding")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""One Desktop first-run step transition, once per (step, event) per profile. Always ``{ok: true}``."""
from hermes_cli.observability.shared_metrics_desktop import record_desktop_onboarding
record_desktop_onboarding(step=params.get("step"), event=params.get("event"))
return _ok(rid, {"ok": True})
@method("shared_metrics.desktop_dislike")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""One Desktop dislike signal (quick close, cancelled flow, setting changed vs default, rage click,
undo, feature disabled), capped per signal per day. For a setting the backend reads the saved value
and compares it to the default itself; only the key and the direction are recorded."""
from hermes_cli.observability.shared_metrics_desktop import record_desktop_dislike
record_desktop_dislike(signal=params.get("signal"), target=params.get("target"), setting=params.get("setting"))
return _ok(rid, {"ok": True})
@method("shared_metrics.desktop_daily")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""One finished Desktop day: mode use (Bot Mode vs Sessions, with the bot count) and button presses
per action. ``{recorded: true}`` once settled, so the client drops the day; false keeps it."""
from hermes_cli.observability.shared_metrics_desktop import record_desktop_daily
recorded = record_desktop_daily(
day=params.get("day"), modes=params.get("modes"), actions=params.get("actions"),
bot_count=params.get("bot_count"),
)
return _ok(rid, {"recorded": recorded})
# ---- end v5 desktop ----
def register(server) -> None:
bind_module(globals(), server, skip=("_",))