mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(telemetry): ask once more where a "No thanks" may never have been seen
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.
This commit is contained in:
1 parent
97f9876925
commit
b5025017d5
24 files changed
+131
-41
No files matched your search
@@ -9,6 +9,7 @@ import { useI18n } from '@/i18n'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$sharedMetricsConsent,
|
||||
$sharedMetricsDetailsOpen,
|
||||
answerSharedMetricsOffer,
|
||||
type SharedMetricsChoice,
|
||||
@@ -28,6 +29,7 @@ export function SharedMetricsConsentStrip() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.sharedMetrics
|
||||
const profile = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const reask = useStore($sharedMetricsConsent)?.reask === true
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const scopedRequest = useMemo(
|
||||
@@ -80,7 +82,7 @@ export function SharedMetricsConsentStrip() {
|
||||
>
|
||||
<span className="min-w-0 truncate text-[0.73rem] leading-4 text-foreground/92">
|
||||
<span className="font-medium">{copy.consentTitle}</span>
|
||||
<span className="text-muted-foreground/80"> {copy.stripBody}</span>
|
||||
<span className="text-muted-foreground/80"> {reask ? copy.stripReaskBody : copy.stripBody}</span>
|
||||
</span>
|
||||
</StatusRow>
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ export const arCommon = {
|
||||
'رفع كل حزمة يومية إلى خدمة القياس عن بُعد لدى Nous. لا تُرسل إلا بيانات نافذة الموافقة. يتطلب تفعيل الجمع.',
|
||||
unavailable: 'حدّث واجهة Hermes الخلفية لتغيير هذا الإعداد.',
|
||||
stripBody: 'عدّادات محدودة فقط، دون أي مطالبات أو ملفات.',
|
||||
stripReaskBody: 'نسألك مرة أخرى: ربما حفظ إصدار سابق «لا، شكرًا» قبل أن ترى هذا السؤال.',
|
||||
stripChoices: { share: 'إرسال إلى Nous', local: 'محليًا فقط', off: 'لا، شكرًا' },
|
||||
stripDetails: 'التفاصيل'
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@ export const deOverrides = {
|
||||
'Jedes Tagespaket an den Nous-Telemetriedienst hochladen. Nur Daten aus einem Zustimmungsfenster werden gesendet. Erfordert aktive Erfassung.',
|
||||
unavailable: 'Aktualisieren Sie das Hermes-Backend, um diese Einstellung zu ändern.',
|
||||
stripBody: 'Nur begrenzte Zähler, niemals Prompts oder Dateien.',
|
||||
stripReaskBody: 'Wir fragen noch einmal: Eine frühere Version konnte „Nein danke“ speichern, bevor Sie diese Frage gesehen haben.',
|
||||
stripChoices: { share: 'An Nous senden', local: 'Nur lokal', off: 'Nein danke' },
|
||||
stripDetails: 'Details'
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ export const en: Translations = {
|
||||
'Upload each daily package to the Nous telemetry service. Only data from inside a consent window is sent. Needs collection on.',
|
||||
unavailable: 'Update the Hermes backend to change this setting.',
|
||||
stripBody: 'Bounded counters only, never prompts or files.',
|
||||
stripReaskBody: 'Asking once more: an earlier version could save “No thanks” before you saw this.',
|
||||
stripChoices: { share: 'Send to Nous', local: 'Local only', off: 'No thanks' },
|
||||
stripDetails: 'Details'
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ export const esOverrides = {
|
||||
'Sube cada paquete diario al servicio de telemetría de Nous. Solo se envían datos de una ventana de consentimiento. Requiere la recopilación activada.',
|
||||
unavailable: 'Actualiza el backend de Hermes para cambiar este ajuste.',
|
||||
stripBody: 'Solo contadores acotados, nunca prompts ni archivos.',
|
||||
stripReaskBody: 'Te lo preguntamos de nuevo: una versión anterior podía guardar «No, gracias» antes de que vieras esta pregunta.',
|
||||
stripChoices: { share: 'Enviar a Nous', local: 'Solo local', off: 'No, gracias' },
|
||||
stripDetails: 'Detalles'
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ export const frOverrides = {
|
||||
'Envoyer chaque paquet quotidien au service de télémétrie de Nous. Seules les données d’une fenêtre de consentement sont envoyées. Nécessite la collecte activée.',
|
||||
unavailable: 'Mettez à jour le backend Hermes pour modifier ce réglage.',
|
||||
stripBody: 'Uniquement des compteurs bornés, jamais de prompts ni de fichiers.',
|
||||
stripReaskBody: 'Nouvelle demande : une version précédente pouvait enregistrer « Non merci » avant que vous ne voyiez cette question.',
|
||||
stripChoices: { share: 'Envoyer à Nous', local: 'Local uniquement', off: 'Non merci' },
|
||||
stripDetails: 'Détails'
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ export const ja = defineLocale({
|
||||
'日次パッケージを Nous のテレメトリサービスにアップロードします。同意期間内のデータだけが送信されます。収集がオンである必要があります。',
|
||||
unavailable: 'この設定を変更するには Hermes バックエンドを更新してください。',
|
||||
stripBody: '上限付きのカウンターのみ。プロンプトやファイルは含みません。',
|
||||
stripReaskBody: '改めてお尋ねします:以前のバージョンでは、この質問が表示される前に「今はしない」が保存されることがありました。',
|
||||
stripChoices: { share: 'Nous に送信', local: 'ローカルのみ', off: '今はしない' },
|
||||
stripDetails: '詳細'
|
||||
},
|
||||
|
||||
@@ -60,6 +60,7 @@ export const ru = defineLocale({
|
||||
'Загружать ежедневный пакет в сервис телеметрии Nous. Отправляются только данные из окна согласия. Требует включённого сбора.',
|
||||
unavailable: 'Обновите бэкенд Hermes, чтобы изменить этот параметр.',
|
||||
stripBody: 'Только ограниченные счётчики, никаких промптов и файлов.',
|
||||
stripReaskBody: 'Спрашиваем ещё раз: прежняя версия могла сохранить «Нет, спасибо» до того, как вы увидели этот вопрос.',
|
||||
stripChoices: { share: 'Отправлять в Nous', local: 'Только локально', off: 'Нет, спасибо' },
|
||||
stripDetails: 'Подробнее'
|
||||
},
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface Translations {
|
||||
sendDesc: string
|
||||
unavailable: string
|
||||
stripBody: string
|
||||
stripReaskBody: string
|
||||
stripChoices: { share: string; local: string; off: string }
|
||||
stripDetails: string
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export const zhHant = defineLocale({
|
||||
sendDesc: '將每日資料包上傳到 Nous 遙測服務。只傳送同意時段內的資料。需要先開啟收集。',
|
||||
unavailable: '請更新 Hermes 後端以變更此設定。',
|
||||
stripBody: '僅限有界計數器,絕不包含提示詞或檔案。',
|
||||
stripReaskBody: '再次詢問:舊版本可能在你看到此問題之前就已儲存了「不用了」。',
|
||||
stripChoices: { share: '傳送給 Nous', local: '僅限本機', off: '不用了' },
|
||||
stripDetails: '詳細資訊'
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ export const zh = defineLocale({
|
||||
sendDesc: '将每日数据包上传到 Nous 遥测服务。只发送同意时段内的数据。需要先开启收集。',
|
||||
unavailable: '请更新 Hermes 后端以更改此设置。',
|
||||
stripBody: '仅限有界计数器,绝不包含提示词或文件。',
|
||||
stripReaskBody: '再次询问:旧版本可能在你看到此问题之前就已保存了“不用了”。',
|
||||
stripChoices: { share: '发送给 Nous', local: '仅本地', off: '不用了' },
|
||||
stripDetails: '详情'
|
||||
},
|
||||
|
||||
@@ -654,11 +654,12 @@ export interface FreeTierProvisionResult {
|
||||
export interface FreeTierAckNoticeResult {
|
||||
acked: boolean
|
||||
}
|
||||
/** The focused profile's ``telemetry.shared_metrics`` opt-ins. ``send`` is never true while ``enabled`` is false; ``decided`` = either key is written in config.yaml (the shipped defaults are not an answer). */
|
||||
/** The focused profile's ``telemetry.shared_metrics`` opt-ins. ``send`` is never true while ``enabled`` is false; ``decided`` = either key is written in config.yaml (the shipped defaults are not an answer) and it is not a ``reask``: an "off" from before the type-ahead fix, offered once more with the reason. */
|
||||
export interface SharedMetricsConsentResult {
|
||||
enabled: boolean
|
||||
send: boolean
|
||||
decided: boolean
|
||||
reask?: boolean
|
||||
}
|
||||
/** ``send`` is ignored unless ``enabled``; ``first_run`` marks the Desktop first-run answer. */
|
||||
export interface SharedMetricsSetParams {
|
||||
|
||||
@@ -32683,7 +32683,7 @@
|
||||
},
|
||||
"SharedMetricsConsentResult": {
|
||||
"additionalProperties": false,
|
||||
"description": "The focused profile's ``telemetry.shared_metrics`` opt-ins. ``send`` is never true while\n``enabled`` is false; ``decided`` = either key is written in config.yaml (the shipped defaults\nare not an answer).",
|
||||
"description": "The focused profile's ``telemetry.shared_metrics`` opt-ins. ``send`` is never true while\n``enabled`` is false; ``decided`` = either key is written in config.yaml (the shipped defaults\nare not an answer) and it is not a ``reask``: an \"off\" from before the type-ahead fix, offered\nonce more with the reason.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"title": "Enabled",
|
||||
@@ -32696,6 +32696,11 @@
|
||||
"decided": {
|
||||
"title": "Decided",
|
||||
"type": "boolean"
|
||||
},
|
||||
"reask": {
|
||||
"default": false,
|
||||
"title": "Reask",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
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
|
||||
@@ -36,6 +39,7 @@ _OFFER_DESCRIPTION = "\n".join((
|
||||
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:
|
||||
@@ -44,31 +48,42 @@ def _section(cfg: Any) -> dict:
|
||||
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}`` from a RAW config mapping (no defaults merged)."""
|
||||
"""``{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": "enabled" in section or "send" in section,
|
||||
"decided": answered and not reask,
|
||||
"reask": reask,
|
||||
}
|
||||
|
||||
|
||||
def consent_decided() -> bool:
|
||||
def _current_state() -> dict:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
return consent_state(read_raw_config())["decided"]
|
||||
return consent_state(read_raw_config())
|
||||
|
||||
|
||||
def _set_answer(target: dict, enabled: bool, send: bool) -> None:
|
||||
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, send
|
||||
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:
|
||||
@@ -87,17 +102,18 @@ def save_consent(enabled: bool, send: bool, config: dict | None = None) -> None:
|
||||
send = enabled and send
|
||||
config_path = get_config_path()
|
||||
user_config = require_readable_config_before_write(config_path)
|
||||
_set_answer(user_config, enabled, send)
|
||||
set_answer(user_config, enabled, send)
|
||||
_write_user_config(config_path, user_config)
|
||||
if config is not None:
|
||||
_set_answer(config, enabled, send)
|
||||
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) -> bool:
|
||||
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 the question open (asked again next time). True when answered."""
|
||||
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
|
||||
|
||||
@@ -106,8 +122,12 @@ def offer_consent(config: dict | None = None) -> bool:
|
||||
flush_stdin()
|
||||
idx = curses_radiolist(
|
||||
"Help improve Hermes?", [label for label, _, _ in OFFER_CHOICES], selected=_NO_THANKS, cancel_returns=-1,
|
||||
description=_OFFER_DESCRIPTION,
|
||||
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
|
||||
@@ -124,10 +144,13 @@ def offer_consent_if_undecided(config: dict | None = None) -> None:
|
||||
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() or consent_decided():
|
||||
if is_managed() or is_noninteractive() or not sys.stdin.isatty():
|
||||
return
|
||||
state = _current_state()
|
||||
if state["decided"]:
|
||||
return
|
||||
try:
|
||||
offer_consent(config)
|
||||
offer_consent(config, reask=state["reask"])
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
|
||||
@@ -148,9 +171,10 @@ def offer_consent_before_chat(args: Any) -> None:
|
||||
if not (sys.stdin.isatty() and sys.stdout.isatty()) or is_managed():
|
||||
return
|
||||
try:
|
||||
if consent_decided():
|
||||
state = _current_state()
|
||||
if state["decided"]:
|
||||
return
|
||||
offer_consent()
|
||||
offer_consent(reask=state["reask"])
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
except Exception:
|
||||
|
||||
@@ -3906,6 +3906,7 @@
|
||||
"sharedMetrics.stripChoices.off",
|
||||
"sharedMetrics.stripChoices.share",
|
||||
"sharedMetrics.stripDetails",
|
||||
"sharedMetrics.stripReaskBody",
|
||||
"sharedMetrics.unavailable",
|
||||
"sharedMetrics.whatIsCollected",
|
||||
"shell.appControls",
|
||||
|
||||
@@ -92,7 +92,7 @@ def test_no_answer_survives_the_callers_later_config_save(monkeypatch):
|
||||
setup_telemetry(config)
|
||||
save_config(config) # what the wizard and `hermes tools` do afterwards
|
||||
|
||||
assert read_raw_config()["telemetry"]["shared_metrics"] == {"enabled": False, "send": False}
|
||||
assert read_raw_config()["telemetry"]["shared_metrics"] == {"enabled": False, "send": False, "offer_version": 2}
|
||||
|
||||
|
||||
def test_chat_offer_asks_an_undecided_profile_once(monkeypatch):
|
||||
@@ -112,7 +112,7 @@ def test_chat_offer_asks_an_undecided_profile_once(monkeypatch):
|
||||
consent.offer_consent_before_chat(argparse.Namespace())
|
||||
|
||||
assert len(asked) == 1
|
||||
assert consent.consent_state(read_raw_config()) == {"enabled": True, "send": False, "decided": True}
|
||||
assert consent.consent_state(read_raw_config()) == {"enabled": True, "send": False, "decided": True, "reask": False}
|
||||
|
||||
|
||||
def test_offer_drops_keys_typed_before_it_appeared(monkeypatch):
|
||||
@@ -126,3 +126,28 @@ def test_offer_drops_keys_typed_before_it_appeared(monkeypatch):
|
||||
consent.offer_consent()
|
||||
|
||||
assert events == ["flush", "ask"]
|
||||
|
||||
|
||||
def test_unseen_no_thanks_is_asked_once_more_and_then_settled(monkeypatch):
|
||||
"""A pre-fix "No thanks" is re-offered with the reason; Esc on the re-ask keeps it for good,
|
||||
while a pre-fix opt-in was a deliberate pick and is never re-asked."""
|
||||
from hermes_cli.config import read_raw_config, save_config
|
||||
from hermes_cli.observability import shared_metrics_consent as consent
|
||||
|
||||
shown = []
|
||||
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", lambda *a, **k: shown.append(k["description"]) or -1)
|
||||
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
|
||||
monkeypatch.setattr("sys.stdout.isatty", lambda: True)
|
||||
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
|
||||
monkeypatch.delenv("HERMES_NONINTERACTIVE", raising=False)
|
||||
|
||||
save_config({"telemetry": {"shared_metrics": {"enabled": True, "send": False}}}, strip_defaults=False)
|
||||
consent.offer_consent_before_chat(argparse.Namespace())
|
||||
assert shown == []
|
||||
|
||||
save_config({"telemetry": {"shared_metrics": {"enabled": False, "send": False}}}, strip_defaults=False)
|
||||
consent.offer_consent_before_chat(argparse.Namespace())
|
||||
consent.offer_consent_before_chat(argparse.Namespace())
|
||||
|
||||
assert len(shown) == 1 and shown[0].startswith(consent._REASK_NOTE)
|
||||
assert consent.consent_state(read_raw_config()) == {"enabled": False, "send": False, "decided": True, "reask": False}
|
||||
@@ -43,14 +43,14 @@ def test_send_without_collection_is_normalized_off_and_closes_the_consent_window
|
||||
launch, worker = _bind_homes(monkeypatch, tmp_path)
|
||||
|
||||
assert _call("shared_metrics.set", {"profile": "code", "enabled": True, "send": True}) == {
|
||||
"enabled": True, "send": True, "decided": True}
|
||||
"enabled": True, "send": True, "decided": True, "reask": False}
|
||||
[(opened, closed)] = _windows(worker)
|
||||
assert opened and closed is None
|
||||
|
||||
# send=true alone is refused: collection off withdraws send consent, in config AND in the store.
|
||||
assert _call("shared_metrics.set", {"profile": "code", "enabled": False, "send": True}) == {
|
||||
"enabled": False, "send": False, "decided": True}
|
||||
assert _shared_metrics(worker) == {"enabled": False, "send": False}
|
||||
"enabled": False, "send": False, "decided": True, "reask": False}
|
||||
assert _shared_metrics(worker) == {"enabled": False, "send": False, "offer_version": 2}
|
||||
[(_, closed)] = _windows(worker)
|
||||
assert closed is not None
|
||||
|
||||
@@ -61,12 +61,16 @@ def test_send_without_collection_is_normalized_off_and_closes_the_consent_window
|
||||
def test_status_is_undecided_until_a_key_is_written(tmp_path, monkeypatch):
|
||||
_launch, worker = _bind_homes(monkeypatch, tmp_path)
|
||||
|
||||
assert _call("shared_metrics.status", {"profile": "code"}) == {"enabled": False, "send": False, "decided": False}
|
||||
unasked = {"enabled": False, "send": False, "decided": False, "reask": False}
|
||||
assert _call("shared_metrics.status", {"profile": "code"}) == unasked
|
||||
|
||||
# An answer given in `hermes setup` (only `enabled: false` written) counts as decided.
|
||||
# A "No thanks" from before the type-ahead fix (no offer_version) may never have been seen:
|
||||
# it is offered once more, and the answer to that settles it.
|
||||
(worker / "config.yaml").write_text(
|
||||
yaml.safe_dump({"telemetry": {"shared_metrics": {"enabled": False}}}), encoding="utf-8")
|
||||
assert _call("shared_metrics.status", {"profile": "code"}) == {"enabled": False, "send": False, "decided": True}
|
||||
assert _call("shared_metrics.status", {"profile": "code"}) == {**unasked, "reask": True}
|
||||
_call("shared_metrics.set", {"profile": "code", "enabled": False, "send": False})
|
||||
assert _call("shared_metrics.status", {"profile": "code"}) == {**unasked, "decided": True}
|
||||
|
||||
|
||||
def test_only_the_first_run_answer_records_desktop_setup_completed(tmp_path, monkeypatch):
|
||||
|
||||
@@ -228,11 +228,13 @@ method("free_tier.ack_notice", params=ProfileParams, result=FreeTierAckNoticeRes
|
||||
class SharedMetricsConsentResult(Result):
|
||||
"""The focused profile's ``telemetry.shared_metrics`` opt-ins. ``send`` is never true while
|
||||
``enabled`` is false; ``decided`` = either key is written in config.yaml (the shipped defaults
|
||||
are not an answer)."""
|
||||
are not an answer) and it is not a ``reask``: an "off" from before the type-ahead fix, offered
|
||||
once more with the reason."""
|
||||
|
||||
enabled: bool
|
||||
send: bool
|
||||
decided: bool
|
||||
reask: bool = False
|
||||
|
||||
|
||||
method("shared_metrics.status", params=ProfileParams, result=SharedMetricsConsentResult,
|
||||
|
||||
@@ -18,7 +18,7 @@ _profile_scoped = _registry.profile_scoped
|
||||
|
||||
|
||||
def _shared_metrics_consent(cfg) -> dict:
|
||||
"""The one ``{enabled, send, decided}`` reading every surface shares (CLI offer included)."""
|
||||
"""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)
|
||||
@@ -37,7 +37,7 @@ def _shared_metrics_record_setup_completed(cfg) -> None:
|
||||
@method("shared_metrics.status")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""``{enabled, send, decided}`` for the focused profile. A pure read of config.yaml (no defaults
|
||||
"""``{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()))
|
||||
@@ -51,18 +51,13 @@ 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}``."""
|
||||
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()
|
||||
telemetry = cfg.get("telemetry")
|
||||
if not isinstance(telemetry, dict):
|
||||
telemetry = cfg["telemetry"] = {}
|
||||
section = telemetry.get("shared_metrics")
|
||||
if not isinstance(section, dict):
|
||||
section = telemetry["shared_metrics"] = {}
|
||||
section["enabled"], section["send"] = enabled, send
|
||||
set_answer(cfg, enabled, send)
|
||||
_save_cfg(cfg)
|
||||
except Exception as e:
|
||||
return _err(rid, 5096, str(e))
|
||||
|
||||
@@ -12,7 +12,8 @@ const STORAGE_KEY = "sharedMetricsOfferDismissed";
|
||||
* The dashboard's first-run shared-metrics offer: the twin of Desktop's composer strip and the
|
||||
* terminal's offer, with the same three equal answers. Shown while the managed profile has no
|
||||
* answer in its config.yaml; answering writes it (every surface then stops asking), while the X
|
||||
* only hides the banner for this browser session.
|
||||
* only hides the banner for this browser session. A re-ask (``reask``) is the exception: its X
|
||||
* keeps the recorded "No thanks", so nobody is asked a third time.
|
||||
*/
|
||||
export function SharedMetricsConsentBanner() {
|
||||
const { t } = useI18n();
|
||||
@@ -57,6 +58,11 @@ export function SharedMetricsConsentBanner() {
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
const dismiss = () => {
|
||||
// A re-ask settles on any response: closing it keeps the recorded "No thanks" for good.
|
||||
if (consent.reask) {
|
||||
answer(false, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, "1");
|
||||
} catch {
|
||||
@@ -79,7 +85,10 @@ export function SharedMetricsConsentBanner() {
|
||||
<span className="min-w-0 flex-1 opacity-80">
|
||||
{failed
|
||||
? (t.app.sharedMetricsSaveFailed ?? "Couldn't save your choice")
|
||||
: (t.app.sharedMetricsBody ??
|
||||
: consent.reask
|
||||
? (t.app.sharedMetricsReaskBody ??
|
||||
'Asking once more: an earlier version could save "No thanks" before you saw this question.')
|
||||
: (t.app.sharedMetricsBody ??
|
||||
"Shared metrics are bounded counters, never prompts, files, paths or error text. Collection stays on this machine; sending to Nous is a separate choice.")}{" "}
|
||||
<a href={DOCS_URL} target="_blank" rel="noreferrer" className="underline">
|
||||
{t.app.sharedMetricsDetails ?? "Details"}
|
||||
|
||||
@@ -116,6 +116,8 @@ export const en: Translations = {
|
||||
sharedMetricsTitle: "Help improve Hermes?",
|
||||
sharedMetricsBody:
|
||||
"Shared metrics are bounded counters, never prompts, files, paths or error text. Collection stays on this machine; sending to Nous is a separate choice.",
|
||||
sharedMetricsReaskBody:
|
||||
"Asking once more: an earlier version could save \"No thanks\" before you saw this question.",
|
||||
sharedMetricsShare: "Send to Nous",
|
||||
sharedMetricsLocal: "Local only",
|
||||
sharedMetricsOff: "No thanks",
|
||||
|
||||
@@ -133,6 +133,7 @@ export interface Translations {
|
||||
/** First-run shared-metrics offer — optional, English fallback. */
|
||||
sharedMetricsTitle?: string;
|
||||
sharedMetricsBody?: string;
|
||||
sharedMetricsReaskBody?: string;
|
||||
sharedMetricsShare?: string;
|
||||
sharedMetricsLocal?: string;
|
||||
sharedMetricsOff?: string;
|
||||
|
||||
@@ -2001,6 +2001,8 @@ export interface SharedMetricsConsent {
|
||||
enabled: boolean;
|
||||
send: boolean;
|
||||
decided: boolean;
|
||||
/** An "off" from before the type-ahead fix, offered once more with the reason. */
|
||||
reask?: boolean;
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -618,8 +618,15 @@ Both keys are asked once per profile, with the same three answers everywhere
|
||||
"No thanks" is the default in the terminal, so pressing Enter never opts
|
||||
anyone in. Esc in the terminal and the dashboard banner's ✕ leave the question
|
||||
open, so it is asked again next time. Answering on any surface writes both keys
|
||||
to the profile's `config.yaml`, and a profile that already carries either key is
|
||||
never asked again. A managed install is never offered. To change the answer
|
||||
to the profile's `config.yaml`, plus `offer_version`, and a profile that already
|
||||
carries either key is never asked again. A managed install is never offered.
|
||||
|
||||
One exception: before the type-ahead fix, an Enter pressed while `hermes chat`
|
||||
was starting could save "No thanks" before the question appeared. A "No thanks"
|
||||
recorded without `offer_version` is therefore offered once more on every surface,
|
||||
with a note saying why. Any response settles it for good, including Esc or the
|
||||
banner's ✕, which keep "No thanks". Answers that opted in are never re-asked.
|
||||
To change the answer
|
||||
later, use `hermes setup telemetry`, `hermes tools`, or Desktop's Settings ›
|
||||
Safety › Privacy & network.
|
||||
|
||||
|
||||
Reference in new issue
Block a user