Before the type-ahead fix, an Enter pressed while `hermes chat` started could save "No thanks" before the offer was on screen, and every surface then treated the profile as answered. Every answer now also writes `telemetry.shared_metrics.offer_version`. An "off" answer without it reads as `reask`: undecided, so the CLI, Desktop strip and dashboard banner offer it once more with a one-line reason. Any response settles it, including Esc in the terminal and the banner close, which keep "No thanks", so nobody is asked a third time. Opt-in answers were always deliberate picks and are never re-asked.
182 lines
8.2 KiB
Python
182 lines
8.2 KiB
Python
"""The one shared-metrics consent answer, and the terminal's first-run offer.
|
|
|
|
Every surface reads and writes the same two keys in the profile's config.yaml:
|
|
``telemetry.shared_metrics.enabled`` (collect locally) and ``.send`` (upload daily). The question
|
|
counts as answered once either key is written explicitly; the shipped defaults are not an answer.
|
|
Every answer also writes ``offer_version``. A "No thanks" without it predates the type-ahead fix
|
|
(an Enter pressed during startup could save it unseen), so that profile is asked once more, with
|
|
the reason, and any response (Esc or dismiss included) settles it for good.
|
|
Desktop paints the offer as a composer strip (``consent-strip.tsx`` over the ``shared_metrics.*``
|
|
RPCs). The terminal asks at the end of every ``hermes setup`` flow and, for a profile that never
|
|
answered, once before an interactive ``hermes`` / ``hermes --tui`` chat (which also covers the
|
|
dashboard's Chat tab, a PTY-hosted TUI). The messaging gateway never asks: a chat participant is
|
|
not the install owner.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# (label, enabled, send): the Desktop strip's three equal answers, in its order.
|
|
OFFER_CHOICES = (
|
|
("Collect and send to Nous", True, True),
|
|
("Collect locally only", True, False),
|
|
("No thanks", False, False),
|
|
)
|
|
_NO_THANKS = len(OFFER_CHOICES) - 1
|
|
DOCS_URL = "https://hermes-agent.nousresearch.com/docs/developer-guide/relay-shared-metrics"
|
|
_OFFER_DESCRIPTION = "\n".join((
|
|
"Shared metrics are bounded counters: activity, outcomes, error classes (with a",
|
|
"fixed-list reason when a memory write or compression fails), model routes,",
|
|
"token totals, feature use and coarse machine facts. Never prompts, files, paths,",
|
|
"setting values or error text. Collection stays on this machine; sending to Nous is",
|
|
"a separate choice, and data from before you opt in is never sent.",
|
|
f"Details: {DOCS_URL}",
|
|
"Change it any time: hermes setup telemetry",
|
|
))
|
|
_REASK_NOTE = "Asking once more: an earlier version could save \"No thanks\" before you saw this question."
|
|
|
|
|
|
def _section(cfg: Any) -> dict:
|
|
telemetry = cfg.get("telemetry") if isinstance(cfg, dict) else None
|
|
section = telemetry.get("shared_metrics") if isinstance(telemetry, dict) else None
|
|
return section if isinstance(section, dict) else {}
|
|
|
|
|
|
# Bumped only when every earlier answer must be asked again; 2 = after the type-ahead fix.
|
|
OFFER_VERSION = 2
|
|
|
|
|
|
def consent_state(raw_cfg: Any) -> dict:
|
|
"""``{enabled, send, decided, reask}`` from a RAW config mapping (no defaults merged). ``reask``:
|
|
an "off" answer from before ``OFFER_VERSION`` that may never have been seen; it reads undecided
|
|
so every surface offers it once more. An opt-in was always a deliberate pick and stays."""
|
|
section = _section(raw_cfg)
|
|
enabled = section.get("enabled") is True
|
|
answered = "enabled" in section or "send" in section
|
|
reask = answered and not enabled and section.get("offer_version") != OFFER_VERSION
|
|
return {
|
|
"enabled": enabled,
|
|
"send": enabled and section.get("send") is True,
|
|
"decided": answered and not reask,
|
|
"reask": reask,
|
|
}
|
|
|
|
|
|
def _current_state() -> dict:
|
|
from hermes_cli.config import read_raw_config
|
|
|
|
return consent_state(read_raw_config())
|
|
|
|
|
|
def set_answer(target: dict, enabled: bool, send: bool) -> None:
|
|
"""Write one answer into a config mapping: both opt-ins plus the offer version that settles it."""
|
|
telemetry = target.get("telemetry")
|
|
if not isinstance(telemetry, dict):
|
|
telemetry = target["telemetry"] = {}
|
|
section = telemetry.get("shared_metrics")
|
|
if not isinstance(section, dict):
|
|
section = telemetry["shared_metrics"] = {}
|
|
section["enabled"], section["send"] = enabled, enabled and send
|
|
section["offer_version"] = OFFER_VERSION
|
|
|
|
|
|
def save_consent(enabled: bool, send: bool, config: dict | None = None) -> None:
|
|
"""Write both keys into the user's raw config.yaml now. ``save_config`` strips values equal
|
|
to the shipped defaults, so a "No thanks" (both false) saved through it vanished and the
|
|
profile was asked again on every surface. ``config`` (a caller's in-memory copy, e.g. the
|
|
wizard's) gets the same answer so its later save agrees. Sending cannot outlive collection."""
|
|
from hermes_cli.config import (
|
|
_write_user_config, get_config_path, is_managed, managed_error, require_readable_config_before_write,
|
|
)
|
|
from hermes_cli.setup import _record_send_consent_change
|
|
|
|
if is_managed():
|
|
managed_error("save configuration")
|
|
return
|
|
send = enabled and send
|
|
config_path = get_config_path()
|
|
user_config = require_readable_config_before_write(config_path)
|
|
set_answer(user_config, enabled, send)
|
|
_write_user_config(config_path, user_config)
|
|
if config is not None:
|
|
set_answer(config, enabled, send)
|
|
# Unconditional: a send key already false may still have an open consent window.
|
|
_record_send_consent_change(enabled=send)
|
|
|
|
|
|
def offer_consent(config: dict | None = None, *, reask: bool = False) -> bool:
|
|
"""Ask once, with the Desktop strip's three answers; "No thanks" is the default so Enter never
|
|
opts anyone in. Esc leaves a first question open (asked again next time); on a re-ask it keeps
|
|
the recorded "No thanks", so nobody is asked a third time. True when answered."""
|
|
from hermes_cli.cli_output import print_info, print_success
|
|
from hermes_cli.curses_ui import curses_radiolist, flush_stdin
|
|
|
|
# The offer appears seconds into startup; an Enter typed while Hermes booted would otherwise
|
|
# answer it ("No thanks") before it was ever on screen, so the user was never really asked.
|
|
flush_stdin()
|
|
idx = curses_radiolist(
|
|
"Help improve Hermes?", [label for label, _, _ in OFFER_CHOICES], selected=_NO_THANKS, cancel_returns=-1,
|
|
description=f"{_REASK_NOTE}\n{_OFFER_DESCRIPTION}" if reask else _OFFER_DESCRIPTION,
|
|
)
|
|
if idx < 0 and reask:
|
|
save_consent(False, False, config)
|
|
print_info("Kept \"No thanks\". Change it any time with `hermes setup telemetry`.")
|
|
return True
|
|
if idx < 0:
|
|
print_info("Not answered; Hermes will ask again. Decide any time with `hermes setup telemetry`.")
|
|
return False
|
|
_, enabled, send = OFFER_CHOICES[idx]
|
|
save_consent(enabled, send, config)
|
|
outcome = "collected and sent to Nous" if send else "collected on this machine only" if enabled else "off"
|
|
print_success(f"Shared metrics {outcome}. Change it any time with `hermes setup telemetry`.")
|
|
return True
|
|
|
|
|
|
def offer_consent_if_undecided(config: dict | None = None) -> None:
|
|
"""The end-of-setup offer: every ``hermes setup`` flow funnels through the setup-completed
|
|
record, so this one call covers Quick, Full, Blank Slate, Portal and ``--quick``."""
|
|
from hermes_cli.config import is_managed
|
|
from hermes_cli.setup import is_noninteractive
|
|
|
|
if is_managed() or is_noninteractive() or not sys.stdin.isatty():
|
|
return
|
|
state = _current_state()
|
|
if state["decided"]:
|
|
return
|
|
try:
|
|
offer_consent(config, reask=state["reask"])
|
|
except (KeyboardInterrupt, EOFError):
|
|
print()
|
|
|
|
|
|
def offer_consent_before_chat(args: Any) -> None:
|
|
"""One-time offer before an interactive chat on a profile that never answered. Skipped for
|
|
anything without a person at a terminal (``-q``, piped or JSON output, spawned actions) and for
|
|
a Desktop-spawned pane (the app shows its own strip)."""
|
|
from hermes_cli.config import is_managed
|
|
from hermes_cli.setup import is_noninteractive
|
|
|
|
if any(getattr(args, name, None) for name in ("query", "query_file", "oneshot", "oneshot_exit")):
|
|
return
|
|
if getattr(args, "safe_mode", False) or getattr(args, "ignore_user_config", False):
|
|
return
|
|
if os.environ.get("HERMES_DESKTOP") or is_noninteractive():
|
|
return
|
|
if not (sys.stdin.isatty() and sys.stdout.isatty()) or is_managed():
|
|
return
|
|
try:
|
|
state = _current_state()
|
|
if state["decided"]:
|
|
return
|
|
offer_consent(reask=state["reask"])
|
|
except (KeyboardInterrupt, EOFError):
|
|
print()
|
|
except Exception:
|
|
logger.debug("shared-metrics offer skipped", exc_info=True)
|