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:
Teknium committed 2026-10-06 00:40:30 -07:00
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>
)
+1
View File
@@ -33,6 +33,7 @@ export const arCommon = {
'رفع كل حزمة يومية إلى خدمة القياس عن بُعد لدى Nous. لا تُرسل إلا بيانات نافذة الموافقة. يتطلب تفعيل الجمع.',
unavailable: 'حدّث واجهة Hermes الخلفية لتغيير هذا الإعداد.',
stripBody: 'عدّادات محدودة فقط، دون أي مطالبات أو ملفات.',
stripReaskBody: 'نسألك مرة أخرى: ربما حفظ إصدار سابق «لا، شكرًا» قبل أن ترى هذا السؤال.',
stripChoices: { share: 'إرسال إلى Nous', local: 'محليًا فقط', off: 'لا، شكرًا' },
stripDetails: 'التفاصيل'
},
+1
View File
@@ -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'
},
+1
View File
@@ -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'
},
+1
View File
@@ -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'
},
+1
View File
@@ -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'
},
+1
View File
@@ -47,6 +47,7 @@ export const ja = defineLocale({
'日次パッケージを Nous のテレメトリサービスにアップロードします。同意期間内のデータだけが送信されます。収集がオンである必要があります。',
unavailable: 'この設定を変更するには Hermes バックエンドを更新してください。',
stripBody: '上限付きのカウンターのみ。プロンプトやファイルは含みません。',
stripReaskBody: '改めてお尋ねします:以前のバージョンでは、この質問が表示される前に「今はしない」が保存されることがありました。',
stripChoices: { share: 'Nous に送信', local: 'ローカルのみ', off: '今はしない' },
stripDetails: '詳細'
},
+1
View File
@@ -60,6 +60,7 @@ export const ru = defineLocale({
'Загружать ежедневный пакет в сервис телеметрии Nous. Отправляются только данные из окна согласия. Требует включённого сбора.',
unavailable: 'Обновите бэкенд Hermes, чтобы изменить этот параметр.',
stripBody: 'Только ограниченные счётчики, никаких промптов и файлов.',
stripReaskBody: 'Спрашиваем ещё раз: прежняя версия могла сохранить «Нет, спасибо» до того, как вы увидели этот вопрос.',
stripChoices: { share: 'Отправлять в Nous', local: 'Только локально', off: 'Нет, спасибо' },
stripDetails: 'Подробнее'
},
+1
View File
@@ -91,6 +91,7 @@ export interface Translations {
sendDesc: string
unavailable: string
stripBody: string
stripReaskBody: string
stripChoices: { share: string; local: string; off: string }
stripDetails: string
}
+1
View File
@@ -61,6 +61,7 @@ export const zhHant = defineLocale({
sendDesc: '將每日資料包上傳到 Nous 遙測服務。只傳送同意時段內的資料。需要先開啟收集。',
unavailable: '請更新 Hermes 後端以變更此設定。',
stripBody: '僅限有界計數器,絕不包含提示詞或檔案。',
stripReaskBody: '再次詢問:舊版本可能在你看到此問題之前就已儲存了「不用了」。',
stripChoices: { share: '傳送給 Nous', local: '僅限本機', off: '不用了' },
stripDetails: '詳細資訊'
},
+1
View File
@@ -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:
+1
View File
@@ -3906,6 +3906,7 @@
"sharedMetrics.stripChoices.off",
"sharedMetrics.stripChoices.share",
"sharedMetrics.stripDetails",
"sharedMetrics.stripReaskBody",
"sharedMetrics.unavailable",
"sharedMetrics.whatIsCollected",
"shell.appControls",
+27 -2
View File
@@ -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,
+5 -10
View File
@@ -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"}
+2
View File
@@ -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",
+1
View File
@@ -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;
+2
View File
@@ -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.