mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(free-tier): the free model is a desktop first-install trial, claimed once per machine
HERMES_GUEST_ONBOARDING was a process-wide launch gate: with it set, every CLI, gateway and serve boot minted an anonymous Nous account and any guest (a connector guest included) carried inference. It is gone from Python. - hermes_cli/welcome_trial.py: claim_desktop_free_tier() mints a guest, stamps welcome_claim on it and marks the machine. A table of blockers decides eligibility: the desktop's local backend only (never SSH, URL, cloud, CLI or gateway), nous.guest on, no Nous identity, no provider, and a machine that never used Hermes (install_state; the unpackaged desktop may pass assume_fresh). grandfather_existing_guest() keeps the trial for a guest that already chatted on nous/welcome; connector-only guests never qualify. - anon_auth.free_inference_active() replaces guest_enabled(): a stamped guest on the local desktop backend. The resolver rung, the active-provider check, the picker row, the sign-in offer and guest_without_free_tier (inference, status, account, pool readers) all read it. ensure_portal_identity is connectors-only plus the claim; every token exchange names its purpose. - No boot mint: free_tier_bootstrap only records who carries inference (and runs the grandfather check); the CLI and gateway boot hooks are deleted, and so is the inference re-mint after a dead credential (a lost claim is never re-claimed). - onboarding.start claims for a new run before the chat exists; a failed claim is error 5079 and writes nothing, so the next launch tries again. A rerun never claims. onboarding.state answers eligible only on the local desktop backend when the trial or a configured provider can carry the first chat. - Sign-in from any guest takes the promotion path (connected apps carry over). - Deleted CLI/gateway free-tier copy: the startup line, both /status lines, the one-time CLI notice, the banner's route model and their locale keys. `hermes model` points a guest at `hermes auth upgrade`.
This commit is contained in:
1 parent
88be7bd28f
commit
4920f87b3f
47 files changed
+582
-386
No files matched your search
@@ -2180,7 +2180,11 @@ export interface ProfilesGetAssetResult {
|
||||
size?: number | null
|
||||
data?: string | null
|
||||
}
|
||||
/** ``run``: onboarding opens on this launch (root config ``onboarding.run``, else a fresh install). ``eligible``: the free tier is on for this backend (``anon_auth.guest_enabled``). ``session_id``: the stored id of the onboarding chat still in progress, so a relaunch reopens it; ``run_id``: that run's id, which ``onboarding.ended`` names. */
|
||||
/** ``assume_fresh``: the unpackaged desktop's ``--fresh-install`` launch; the backend treats this machine as one that never used Hermes. Packaged builds never send it. */
|
||||
export interface OnboardingStateParams {
|
||||
assume_fresh?: boolean
|
||||
}
|
||||
/** ``run``: onboarding opens on this launch (root config ``onboarding.run``, else a machine that never used Hermes). ``eligible``: this is the desktop's local backend and the welcome trial or a configured provider can carry the first chat. ``session_id``: the stored id of the onboarding chat still in progress, so a relaunch reopens it; ``run_id``: that run's id, which ``onboarding.ended`` names. */
|
||||
export interface OnboardingRunStateResult {
|
||||
run: boolean
|
||||
eligible: boolean
|
||||
@@ -2192,10 +2196,11 @@ export interface OnboardingSetRunParams {
|
||||
run: boolean
|
||||
mark_profile_offered?: boolean
|
||||
}
|
||||
/** ``rerun``: start a new onboarding chat even when one is in progress (Settings, Run onboarding again). ``fresh_launch``: the app's once-per-launch start; a run the app was closed on does not count, so its chat is deleted and the run starts over. ``facts_source``: whether the backend runs on the user's own computer. ``locale``: the desktop's UI language, preferred over the backend OS locale. */
|
||||
/** ``rerun``: start a new onboarding chat even when one is in progress (Settings, Run onboarding again; it never claims the welcome trial). ``fresh_launch``: the app's once-per-launch start; a run the app was closed on does not count, so its chat is deleted and the run starts over. ``facts_source``: whether the backend runs on the user's own computer. ``locale``: the desktop's UI language, preferred over the backend OS locale. ``assume_fresh``: as on ``onboarding.state``. A new run on a first install claims the welcome trial before the chat exists; a failed claim is error 5079 and writes nothing. */
|
||||
export interface OnboardingStartParams {
|
||||
rerun?: boolean
|
||||
fresh_launch?: boolean
|
||||
assume_fresh?: boolean
|
||||
facts_source?: OnboardingFactsSource
|
||||
locale?: string | null
|
||||
}
|
||||
@@ -5345,7 +5350,7 @@ export interface RpcMethods {
|
||||
/** Open the onboarding chat in the default profile: create it, write its run record and play the opening. */
|
||||
'onboarding.start': { params: OnboardingStartParams; result: OnboardingStartResult }
|
||||
/** Whether desktop first-run onboarding is due, and the chat in progress; reads the root profile's config. */
|
||||
'onboarding.state': { params: Params; result: OnboardingRunStateResult }
|
||||
'onboarding.state': { params: OnboardingStateParams; result: OnboardingRunStateResult }
|
||||
/** Spill a large paste to a file and hand back the inline placeholder. */
|
||||
'paste.collapse': { params: PasteCollapseParams; result: PasteCollapseResult }
|
||||
/** Render a PDF's pages to PNG and queue them as images for the next turn. */
|
||||
|
||||
@@ -2083,7 +2083,7 @@
|
||||
{
|
||||
"name": "params",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Params"
|
||||
"$ref": "#/components/schemas/OnboardingStateParams"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -20333,7 +20333,7 @@
|
||||
},
|
||||
"OnboardingRunStateResult": {
|
||||
"additionalProperties": false,
|
||||
"description": "``run``: onboarding opens on this launch (root config ``onboarding.run``, else a fresh\ninstall). ``eligible``: the free tier is on for this backend (``anon_auth.guest_enabled``).\n``session_id``: the stored id of the onboarding chat still in progress, so a relaunch reopens it;\n``run_id``: that run's id, which ``onboarding.ended`` names.",
|
||||
"description": "``run``: onboarding opens on this launch (root config ``onboarding.run``, else a machine that\nnever used Hermes). ``eligible``: this is the desktop's local backend and the welcome trial or a\nconfigured provider can carry the first chat.\n``session_id``: the stored id of the onboarding chat still in progress, so a relaunch reopens it;\n``run_id``: that run's id, which ``onboarding.ended`` names.",
|
||||
"properties": {
|
||||
"run": {
|
||||
"title": "Run",
|
||||
@@ -20437,7 +20437,7 @@
|
||||
},
|
||||
"OnboardingStartParams": {
|
||||
"additionalProperties": false,
|
||||
"description": "``rerun``: start a new onboarding chat even when one is in progress (Settings, Run onboarding\nagain). ``fresh_launch``: the app's once-per-launch start; a run the app was closed on does not\ncount, so its chat is deleted and the run starts over. ``facts_source``: whether the backend runs\non the user's own computer. ``locale``: the desktop's UI language, preferred over the backend OS\nlocale.",
|
||||
"description": "``rerun``: start a new onboarding chat even when one is in progress (Settings, Run onboarding\nagain; it never claims the welcome trial). ``fresh_launch``: the app's once-per-launch start; a run\nthe app was closed on does not count, so its chat is deleted and the run starts over.\n``facts_source``: whether the backend runs on the user's own computer. ``locale``: the desktop's UI\nlanguage, preferred over the backend OS locale. ``assume_fresh``: as on ``onboarding.state``. A new\nrun on a first install claims the welcome trial before the chat exists; a failed claim is error 5079\nand writes nothing.",
|
||||
"properties": {
|
||||
"rerun": {
|
||||
"default": false,
|
||||
@@ -20449,6 +20449,11 @@
|
||||
"title": "Fresh Launch",
|
||||
"type": "boolean"
|
||||
},
|
||||
"assume_fresh": {
|
||||
"default": false,
|
||||
"title": "Assume Fresh",
|
||||
"type": "boolean"
|
||||
},
|
||||
"facts_source": {
|
||||
"$ref": "#/components/schemas/OnboardingFactsSource",
|
||||
"default": "local"
|
||||
@@ -20499,6 +20504,19 @@
|
||||
"title": "OnboardingStartResult",
|
||||
"type": "object"
|
||||
},
|
||||
"OnboardingStateParams": {
|
||||
"additionalProperties": false,
|
||||
"description": "``assume_fresh``: the unpackaged desktop's ``--fresh-install`` launch; the backend treats this\nmachine as one that never used Hermes. Packaged builds never send it.",
|
||||
"properties": {
|
||||
"assume_fresh": {
|
||||
"default": false,
|
||||
"title": "Assume Fresh",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "OnboardingStateParams",
|
||||
"type": "object"
|
||||
},
|
||||
"OpenRequestEntry": {
|
||||
"additionalProperties": false,
|
||||
"description": "One unanswered server\u2192client request (``server_requests.Request.snapshot``); the reconnecting\nclient re-delivers it to its request handlers.",
|
||||
|
||||
@@ -786,23 +786,6 @@ class GatewayNotificationsMixin(GatewayNotificationOwnershipMixin):
|
||||
logger.warning(failure_fmt, platform.value, home.chat_id, exc)
|
||||
return False
|
||||
|
||||
def _free_tier_startup_line(self) -> Optional[str]:
|
||||
"""Extra startup line when the gateway's inference is carried by the Nous free tier; None otherwise.
|
||||
|
||||
Best-effort: a resolution failure (no provider, auth error) must not block the online notice."""
|
||||
try:
|
||||
# Persisted state only. The free-tier check reads auth.json; it runs FIRST so the resolver
|
||||
# is only consulted when a free-tier identity already exists and its own free-tier rung
|
||||
# (which may mint on a fresh install, NS-829) answers from that identity without a network
|
||||
# call. No token refresh at boot either way.
|
||||
from hermes_cli.anon_auth import free_tier_route
|
||||
if not free_tier_route():
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug("Free tier startup line skipped: %s", exc)
|
||||
return None
|
||||
return t("gateway.startup.free_tier_line")
|
||||
|
||||
_planned_restart_notice_lock: Optional[asyncio.Lock] = None
|
||||
|
||||
async def _replay_pending_planned_restart_notification(self) -> None:
|
||||
@@ -858,9 +841,6 @@ class GatewayNotificationsMixin(GatewayNotificationOwnershipMixin):
|
||||
delivered: set[tuple[str, str, Optional[str]]] = set()
|
||||
skipped = skip_targets or set()
|
||||
message = t("gateway.startup.online")
|
||||
free_tier_line = self._free_tier_startup_line()
|
||||
if free_tier_line:
|
||||
message = f"{message}\n{free_tier_line}"
|
||||
targets = list(self._served_home_channel_transports())
|
||||
# A chat already notified for ANOTHER profile is not notified again.
|
||||
notified_chats = {
|
||||
|
||||
+1
-20
@@ -140,13 +140,6 @@ class GatewayStartupMixin:
|
||||
drained += 1
|
||||
return drained
|
||||
|
||||
@staticmethod
|
||||
def _start_free_tier_bootstrap() -> None:
|
||||
"""One bootstrap per process. `run_bootstrap` already records its own failure in the boot record
|
||||
and never raises, so this is a plain call; it exists as a method so tests can seam it."""
|
||||
from hermes_cli.free_tier_bootstrap import run_bootstrap
|
||||
run_bootstrap(announce=False)
|
||||
|
||||
def _start_startup_warmup(self) -> None:
|
||||
"""Kick off the boot turn-machinery warm-up so it overlaps the network-bound platform
|
||||
connects; ``_finish_startup_restore`` awaits it (bounded)."""
|
||||
@@ -158,8 +151,7 @@ class GatewayStartupMixin:
|
||||
|
||||
async def _run_boot_probe_in_launch_scope(self, fn):
|
||||
"""Run a boot-time probe on the default executor under the LAUNCH profile's scope when
|
||||
multiplexing (the ``_discover_gateway_mcp_tools`` shape). Boot probes (``check_fn``s, the
|
||||
free-tier bootstrap) read profile-scoped secrets; an unscoped read under multiplex fails
|
||||
multiplexing (the ``_discover_gateway_mcp_tools`` shape). Boot probes (``check_fn``s) read profile-scoped secrets; an unscoped read under multiplex fails
|
||||
closed, so routing overrides resolve as absent and default routing applies. ``copy_context``
|
||||
carries the contextvars across the executor hop; single-profile keeps environ semantics."""
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -176,11 +168,6 @@ class GatewayStartupMixin:
|
||||
getattr(fn, "__name__", fn), exc_info=True)
|
||||
return await loop.run_in_executor(None, copy_context().run, fn)
|
||||
|
||||
async def _run_free_tier_bootstrap(self) -> None:
|
||||
"""The free-tier bootstrap mints the Portal identity through the same profile-scoped routing
|
||||
overrides the warm-up resolves, so it takes the same launch-profile binding."""
|
||||
await self._run_boot_probe_in_launch_scope(self._start_free_tier_bootstrap)
|
||||
|
||||
async def _warm_turn_prerequisites(self) -> None:
|
||||
"""Initialize turn machinery on an executor thread before the gate opens. Never raises: a
|
||||
failed warm-up degrades to lazy init and must not block startup."""
|
||||
@@ -1624,12 +1611,6 @@ class GatewayStartupMixin:
|
||||
if self._start_check_access_policy():
|
||||
return True
|
||||
await self._start_recover_previous_run()
|
||||
# The gateway is a boot owner of the Nous free tier, beside `cmd_chat` and `hermes serve`: every
|
||||
# demand-time site (provider resolution, /login, the connector token) is a read that needs the
|
||||
# identity to already exist. Blocking here, before any adapter connects, is what keeps a fast
|
||||
# first DM from arriving with nothing to resolve. With the launch gate unset this is a local
|
||||
# inventory and no network.
|
||||
await self._run_free_tier_bootstrap()
|
||||
# Serialize startup restore against inbound: adapters receive as soon as they connect, so inbound
|
||||
# queues until every synthetic resume turn has finished.
|
||||
self._startup_restore_in_progress = True
|
||||
|
||||
@@ -287,14 +287,6 @@ class GatewayStatusCommandsMixin:
|
||||
lines.append(t("gateway.status.model_provider", model=fields["model"], provider=fields["provider"]))
|
||||
elif fields["model"]:
|
||||
lines.append(t("gateway.status.model", model=fields["model"]))
|
||||
try:
|
||||
from hermes_cli.anon_auth import free_tier_route
|
||||
|
||||
free_tier_active = await self._run_in_executor_with_context(free_tier_route)
|
||||
if free_tier_active:
|
||||
lines.append(t("gateway.status.free_tier"))
|
||||
except Exception:
|
||||
pass
|
||||
from agent.context_breakdown import context_display_source
|
||||
mark = "~" if context_display_source(getattr(status_agent, "context_compressor", None)) != "provider_usage" else ""
|
||||
if context_total:
|
||||
|
||||
+65
-56
@@ -1,14 +1,16 @@
|
||||
"""Nous free-tier identity: the ``anonymous`` auth method of the ``nous`` provider.
|
||||
|
||||
The identity is created in two places: at boot (``hermes_cli.free_tier_bootstrap``) while
|
||||
``HERMES_GUEST_ONBOARDING=1`` (see ``guest_enabled``), and on the first hosted connector action for
|
||||
anyone without a Nous identity (``tools.connectors.gateway.config.ensure_guest_identity``, gated only
|
||||
by ``guest_allowed``). Either mints an anonymous Nous
|
||||
account (``POST /api/anonymous/create``); its ``anon_`` credential is later exchanged for short-lived
|
||||
JWTs (``POST /api/anonymous/token``). The result is persisted as the singleton ``providers.nous``. In
|
||||
the resolver ladder (``resolve_provider``) an existing free-tier identity sits directly above the
|
||||
implicit AWS Bedrock chain (NS-829): any explicit provider (env key, ``model.provider``, OpenRouter
|
||||
pool, a logged-in ``active_provider``) beats it, and the ladder never creates one.
|
||||
The identity is created in two places: by the desktop's first install, which claims the welcome
|
||||
trial (``hermes_cli.welcome_trial``), and on the first hosted connector action for anyone without a
|
||||
Nous identity (``tools.connectors.gateway.config.ensure_guest_identity``, gated by ``guest_allowed``).
|
||||
Either mints an anonymous Nous account (``POST /api/anonymous/create``); its ``anon_`` credential is
|
||||
later exchanged for short-lived JWTs (``POST /api/anonymous/token``). The result is persisted as the
|
||||
singleton ``providers.nous``. Only a guest stamped with the trial claim carries inference, and only on
|
||||
the backend the desktop spawned on this machine (:func:`free_inference_active`); every other guest
|
||||
serves connectors and managed tools. In the resolver ladder (``resolve_provider``) a guest that
|
||||
carries inference sits directly above the implicit AWS Bedrock chain (NS-829): any explicit provider
|
||||
(env key, ``model.provider``, OpenRouter pool, a logged-in ``active_provider``) beats it, and the
|
||||
ladder never creates one.
|
||||
|
||||
Only two mechanics differ from an OAuth login and both are isolated behind ``is_guest_state``:
|
||||
token acquisition (re-exchange the ``anon_`` credential; there is no refresh token) and routing
|
||||
@@ -18,9 +20,9 @@ Users are never shown the words guest / anonymous / account for this state: surf
|
||||
"Nous · free tier". Two user-facing verbs reach the same flow, both keeping the identity's
|
||||
connectors: ``hermes auth upgrade`` in a terminal and ``/login`` inside a chat.
|
||||
|
||||
Lifecycle lives in ONE primitive, :func:`ensure_portal_identity`: adopt what the shared store already
|
||||
holds, else mint under the shared-store lock. It is the only minter; nothing else calls
|
||||
:func:`mint_guest`.
|
||||
Lifecycle lives in ONE primitive, :func:`_reconcile_and_provision`: adopt what the shared store already
|
||||
holds, else mint under the shared-store lock. :func:`ensure_portal_identity` (connectors) and the
|
||||
welcome-trial claim are its only callers; nothing else calls :func:`mint_guest`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -48,15 +50,12 @@ ANON_SECRET_HEADER = "x-anonymous-api-secret"
|
||||
# The shared secret gates the anonymous surface during its integration phase. It is a deployment
|
||||
# secret (Sid's), read from the environment only.
|
||||
ANON_SECRET_ENV = "HERMES_ANON_API_SECRET"
|
||||
# Launch gate for the free tier while it is pre-GA: exactly "1" turns it on for this process (CLI,
|
||||
# gateway, serve backend alike): the boot mint, the free model and the free-tier surfaces. A guest
|
||||
# for connectors only needs ``nous.guest`` (``guest_allowed``). Not a user preference: never written to
|
||||
# config.yaml or .env, never shown in setup. Deleted at GA together with this comment.
|
||||
GUEST_ONBOARDING_ENV = "HERMES_GUEST_ONBOARDING"
|
||||
# Set on a guest's ``providers.nous`` state when the desktop's first install claims the welcome trial
|
||||
# (``hermes_cli.welcome_trial``). Only a stamped guest carries inference; connector mints never set it.
|
||||
WELCOME_CLAIM_FLAG = "welcome_claim"
|
||||
# Preview cohort for the free tier's connector set, sent once on account creation so the account
|
||||
# service can record it on the account. "1" / "true" send true and "0" / "false" send false ("1" is
|
||||
# GUEST_ONBOARDING_ENV's spelling, so one bundle command reads the same for both); anything else (unset
|
||||
# included) omits the field and the service applies its default. Self-reported and
|
||||
# service can record it on the account. "1" / "true" send true and "0" / "false" send false; anything
|
||||
# else (unset included) omits the field and the service applies its default. Self-reported and
|
||||
# baked into desktop bundles in plain text: the service must treat it as a preference, never proof.
|
||||
PREVIEW_FULL_CONNECTORS_ENV = "HERMES_PREVIEW_FULL_CONNECTORS"
|
||||
GUEST_MINT_TIMEOUT_SECONDS = 5.0
|
||||
@@ -156,14 +155,8 @@ def anon_failure_copy(code: str, *, retry_after: Any = None) -> str:
|
||||
return template.format(wait=friendly_wait(retry_after if retry_after else 60))
|
||||
|
||||
|
||||
def guest_enabled() -> bool:
|
||||
"""The free tier is on for this process: the launch gate is set AND :func:`guest_allowed`."""
|
||||
return (os.environ.get(GUEST_ONBOARDING_ENV) or "").strip() == "1" and guest_allowed()
|
||||
|
||||
|
||||
def guest_allowed() -> bool:
|
||||
"""``nous.guest`` (default True) has not switched guests off. Enough for a guest used only for
|
||||
connectors; the free model and the boot mint also need the launch gate (:func:`guest_enabled`)."""
|
||||
"""``nous.guest`` (default True) has not switched guests off: no connector guest, no trial."""
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
nous_cfg = load_config_readonly().get("nous")
|
||||
@@ -208,20 +201,36 @@ def has_guest() -> bool:
|
||||
return is_guest_state(current_nous_state())
|
||||
|
||||
|
||||
def on_local_desktop_backend() -> bool:
|
||||
"""This process is the backend the desktop app spawned on this machine: never an SSH, URL or
|
||||
cloud backend, and never a gateway or shell the desktop merely launched."""
|
||||
from hermes_cli.anon_challenge import client_surface
|
||||
from hermes_cli.process_identity import is_local_desktop_backend
|
||||
return client_surface() == "desktop" and is_local_desktop_backend()
|
||||
|
||||
|
||||
def free_inference_active(state: Any = None) -> bool:
|
||||
"""The welcome trial carries inference here: a guest that claimed it (or was grandfathered),
|
||||
guests still allowed, and this is the desktop's local backend. *state* defaults to the profile's."""
|
||||
state = current_nous_state() if state is None else state
|
||||
return (is_guest_state(state) and bool(state.get(WELCOME_CLAIM_FLAG)) and guest_allowed()
|
||||
and on_local_desktop_backend())
|
||||
|
||||
|
||||
def has_free_tier_account() -> bool:
|
||||
"""True when the profile's Nous identity is the free tier and the free tier is on.
|
||||
"""True when the profile's Nous identity is a guest that carries inference here.
|
||||
|
||||
Profile-level: use for status, picker and notice surfaces. Routing decisions (which model a
|
||||
request may carry) must use :func:`route_is_welcome_host` on the SELECTED runtime instead: a
|
||||
credential-pool entry can pick a paid Nous key while the profile singleton is still a guest.
|
||||
"""
|
||||
return guest_enabled() and has_guest()
|
||||
return free_inference_active()
|
||||
|
||||
|
||||
def guest_without_free_tier(state: Any) -> bool:
|
||||
"""A guest identity while the free tier is off: it serves connectors and managed tools only, so
|
||||
"""A guest that does not carry inference here: it serves connectors and managed tools only, so
|
||||
inference, account and status readers treat the profile as not signed in."""
|
||||
return is_guest_state(state) and not guest_enabled()
|
||||
return is_guest_state(state) and not free_inference_active(state)
|
||||
|
||||
|
||||
def free_tier_route() -> bool:
|
||||
@@ -392,7 +401,7 @@ def held_connectors_token(state: Any, *, skew_seconds: Optional[int] = None) ->
|
||||
|
||||
def exchange_anon_jwt(
|
||||
client: httpx.Client, portal_base_url: str, anon_token: str, *, auth_state: dict[str, Any],
|
||||
purpose: str = "inference",
|
||||
purpose: str,
|
||||
) -> dict[str, Any]:
|
||||
"""``POST /api/anonymous/token {token}`` -> ``{access_token, expires_in, inference_base_url, ...}``.
|
||||
|
||||
@@ -647,32 +656,30 @@ def _reconcile_and_provision(*, timeout_seconds: float, force: bool = False) ->
|
||||
|
||||
def ensure_portal_identity(
|
||||
*, explicit: bool, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS, force: bool = False,
|
||||
for_connectors: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Make sure this profile has a Nous identity (guest or account); mint a guest only if the shared
|
||||
store has none. Returns the ``providers.nous`` state, or None (disabled / failed once already).
|
||||
|
||||
``explicit`` is required and must be True: the only callers are the boot bootstrap
|
||||
(``free_tier_bootstrap.run_bootstrap``), the desktop's ``free_tier.provision`` retry, the first
|
||||
hosted connector action (``tools.connectors.gateway.config.ensure_guest_identity``), and the
|
||||
dead-credential replacements
|
||||
(``auth_nous.resolve_nous_runtime_credentials``, ``managed_tool_gateway._replace_dead_guest_token``).
|
||||
``explicit`` is required and must be True: the only callers are the first hosted connector action
|
||||
(``tools.connectors.gateway.config.ensure_guest_identity``), the connectors dead-credential
|
||||
replacement (``managed_tool_gateway._replace_dead_guest_token``) and the desktop's welcome-trial
|
||||
claim (``welcome_trial.claim_desktop_free_tier``), which stamps the guest it gets back. An
|
||||
unstamped guest serves connectors only.
|
||||
Nothing creates an identity as a side effect of reading status, resolving a provider or fetching a
|
||||
connector bearer (NS-845 Q1.2).
|
||||
|
||||
Order: gate -> reconcile with the shared store -> mint. The gate is ``guest_enabled``, or only
|
||||
``guest_allowed`` with ``for_connectors=True``: a connectors guest never opens the free model. Locks are taken profile
|
||||
first, then shared, matching every other Nous path. Blocking, bounded by ``timeout_seconds``; the bootstrap puts it on its own thread.
|
||||
Order: gate (``guest_allowed``) -> reconcile with the shared store -> mint. Locks are taken
|
||||
profile first, then shared, matching every other Nous path. Blocking, bounded by ``timeout_seconds``.
|
||||
|
||||
A failed mint is memoised with a cooldown (``MintFailure``): until it passes, and for a
|
||||
terminal code forever, this returns None without touching the portal. ``force=True`` is the
|
||||
user's own retry (the desktop's ``free_tier.provision`` button): it makes exactly one attempt
|
||||
user's own retry: it makes exactly one attempt
|
||||
regardless of the cooldown. Every failure raises an ``AuthError`` whose ``code`` is one of the
|
||||
``ANON_*`` codes and whose ``retry_after`` / ``retryable`` say what a caller may do next.
|
||||
"""
|
||||
if not explicit:
|
||||
raise ValueError("ensure_portal_identity: only explicit creators may call this (explicit=True)")
|
||||
if not (guest_allowed() if for_connectors else guest_enabled()):
|
||||
if not guest_allowed():
|
||||
return None
|
||||
failure = _mint_failure_for_profile()
|
||||
if failure and not force and not current_nous_state() and time.monotonic() < failure.not_before:
|
||||
@@ -692,9 +699,10 @@ def ensure_portal_identity(
|
||||
return state
|
||||
|
||||
|
||||
def refresh_guest_state(state: dict[str, Any], client: httpx.Client, *, purpose: str = "inference") -> str:
|
||||
def refresh_guest_state(state: dict[str, Any], client: httpx.Client, *, purpose: str) -> str:
|
||||
"""Token-acquisition seam for a guest: re-exchange the ``anon_`` credential in place and return
|
||||
the minted token (``purpose="connectors"`` asks for a connectors-only one).
|
||||
the minted token. Every caller names its *purpose*: ``"connectors"`` for connector and managed-tool
|
||||
bearers, ``"inference"`` only for a guest that carries the welcome trial.
|
||||
|
||||
The portal URL is the resolver's canonical one (env override, else the validated stored URL,
|
||||
else the default), never a raw stored value on its own.
|
||||
@@ -945,31 +953,34 @@ def apply_model_switch(agent: Any) -> Optional[str]:
|
||||
# the free tier (inference + connectors) now exists. The flag lives on the guest state itself so it
|
||||
# dies with the identity; a fresh guest (re-mint, new profile) may announce itself once more.
|
||||
GUEST_NOTICE_FLAG = "guest_notice_shown"
|
||||
FREE_TIER_AVAILABLE_NOTICE = (
|
||||
"Free Nous inference and connectors are now available. "
|
||||
"/model to try them, /login to sign in.")
|
||||
|
||||
|
||||
def guest_notice_pending() -> bool:
|
||||
"""True when a guest identity exists and the one-time availability notice has not been shown."""
|
||||
state = current_nous_state()
|
||||
return guest_enabled() and is_guest_state(state) and not bool(state.get(GUEST_NOTICE_FLAG))
|
||||
return free_inference_active(state) and not bool(state.get(GUEST_NOTICE_FLAG))
|
||||
|
||||
|
||||
def mark_guest_notice_shown() -> bool:
|
||||
"""Persist ``guest_notice_shown`` on the guest's ``providers.nous`` state (whichever store holds it).
|
||||
|
||||
Returns True when a flag was written; False when there is no guest to mark."""
|
||||
return set_guest_flag(GUEST_NOTICE_FLAG) is not None
|
||||
|
||||
|
||||
def set_guest_flag(flag: str) -> Optional[dict[str, Any]]:
|
||||
"""Set *flag* on the guest's ``providers.nous`` state in whichever store holds it, and return the
|
||||
updated state; None when there is no guest."""
|
||||
from hermes_cli.auth import (
|
||||
_auth_file_path, _load_auth_store, _provider_state_transaction, _same_path, _save_auth_store,
|
||||
_store_section)
|
||||
with _provider_state_transaction("nous") as (auth_store, state, source_path):
|
||||
if not is_guest_state(state) or source_path is None:
|
||||
return False
|
||||
if state.get(GUEST_NOTICE_FLAG):
|
||||
return True
|
||||
return None
|
||||
if state.get(flag):
|
||||
return state
|
||||
state = dict(state)
|
||||
state[GUEST_NOTICE_FLAG] = True
|
||||
state[flag] = True
|
||||
if _same_path(source_path, _auth_file_path()):
|
||||
_store_section(auth_store, "providers")["nous"] = state
|
||||
_save_auth_store(auth_store)
|
||||
@@ -977,7 +988,7 @@ def mark_guest_notice_shown() -> bool:
|
||||
source_store = _load_auth_store(source_path)
|
||||
_store_section(source_store, "providers")["nous"] = state
|
||||
_save_auth_store(source_store, target_path=source_path)
|
||||
return True
|
||||
return state
|
||||
|
||||
|
||||
# --- ``hermes auth upgrade``: sign the guest into a real Nous account, keeping its connectors ---------
|
||||
@@ -1212,5 +1223,3 @@ from hermes_cli.anon_sign_in_cli import (
|
||||
render_sign_in_cli_code,
|
||||
upgrade_guest,
|
||||
)
|
||||
|
||||
FREE_TIER_STATUS_LINE = f"{FREE_TIER_LABEL} \u00b7 {GUEST_MODEL} \u00b7 {LOGIN_COMMAND} to sign in"
|
||||
@@ -337,8 +337,8 @@ def run_sign_in(
|
||||
|
||||
# Preconditions run inside the scope; the state they produce is yielded outside it, because a
|
||||
# scope must never be held across a ``yield``. A sign-in never creates the identity it signs in
|
||||
# from: with none on disk there is nothing to promote and the answer is ``Unavailable`` (the boot
|
||||
# bootstrap is the only creator, NS-845 Q1.2).
|
||||
# from: with none on disk there is nothing to promote and the answer is ``Unavailable`` (the
|
||||
# trial claim and connector use are the only creators, NS-845 Q1.2).
|
||||
precondition_state: Optional[SignInState] = None
|
||||
state: Optional[dict[str, Any]] = None
|
||||
try:
|
||||
@@ -346,7 +346,7 @@ def run_sign_in(
|
||||
state = _core.current_nous_state()
|
||||
if state and not _core.is_guest_state(state):
|
||||
precondition_state = AlreadySignedIn()
|
||||
elif not state or not _core.guest_enabled():
|
||||
elif not state or not _core.guest_allowed():
|
||||
precondition_state = Unavailable()
|
||||
except Exception as exc:
|
||||
# An unreadable auth store means the same thing here: there is no free tier to sign in from.
|
||||
|
||||
+9
-9
@@ -1544,9 +1544,9 @@ def _logged_in_oauth_active_provider(*, skip_free_tier: bool = False) -> Optiona
|
||||
try:
|
||||
_maybe = _load_auth_store().get("active_provider")
|
||||
if _maybe == "nous":
|
||||
from hermes_cli.anon_auth import guest_enabled, has_guest
|
||||
if has_guest() and (skip_free_tier or not guest_enabled()):
|
||||
return None # the free tier is off (or being discounted), so a guest is not a login
|
||||
from hermes_cli.anon_auth import free_inference_active, has_guest
|
||||
if has_guest() and (skip_free_tier or not free_inference_active()):
|
||||
return None # this guest carries no inference here (or is being discounted): not a login
|
||||
if _maybe and _maybe in PROVIDER_REGISTRY and get_auth_status(_maybe).get("logged_in"):
|
||||
return _maybe
|
||||
except Exception as e:
|
||||
@@ -1637,7 +1637,7 @@ def resolve_provider(
|
||||
"auto" priority (explicit intent beats a stale OAuth login): 1. CLI api_key/base_url ->
|
||||
"openrouter"; 2. config.yaml ``model.provider``; 3. OPENROUTER_API_KEY (or an sk-or- key in
|
||||
OPENAI_API_KEY) -> "openrouter"; 4. OpenRouter pool; 5. provider env keys; 6. auth.json ``active_provider``;
|
||||
7. Nous free tier when it is on and its identity exists (never created here);
|
||||
7. Nous free tier when a claimed guest exists on the desktop's local backend (never created here);
|
||||
8. AWS Bedrock chain; 9. AuthError(no_provider_configured).
|
||||
|
||||
``skip_free_tier`` hides rungs 6-for-a-free-tier-identity and 7: the boot bootstrap asks
|
||||
@@ -1690,16 +1690,16 @@ def resolve_provider(
|
||||
_oauth_active)
|
||||
return _oauth_active
|
||||
|
||||
# Nous free tier, when it is on and its identity already exists. This rung sits ABOVE the Bedrock
|
||||
# Nous free tier, when this guest claimed the welcome trial and this is the desktop's local
|
||||
# backend (``anon_auth.free_inference_active``). This rung sits ABOVE the Bedrock
|
||||
# chain on purpose: every rung above this line is explicit user intent (CLI creds, config, env
|
||||
# keys, a sign-in); the boto chain below is implicit host state, and a leftover ~/.aws profile
|
||||
# used to win the first turn of a fresh install (NS-829). The rung never CREATES the identity:
|
||||
# that is the boot bootstrap's job (free_tier_bootstrap), so provider resolution stays free of
|
||||
# network and a fresh install without the bootstrap resolves exactly as upstream does.
|
||||
# only the desktop's trial claim does (welcome_trial), so provider resolution stays free of network.
|
||||
if not skip_free_tier:
|
||||
try:
|
||||
from hermes_cli.anon_auth import guest_enabled, has_guest
|
||||
if guest_enabled() and has_guest():
|
||||
from hermes_cli.anon_auth import free_inference_active
|
||||
if free_inference_active():
|
||||
return "nous"
|
||||
except Exception as exc:
|
||||
logger.debug("free tier check during provider resolution skipped: %s", exc)
|
||||
|
||||
+6
-12
@@ -1037,7 +1037,7 @@ class _NousRuntimeResolve:
|
||||
# Guest seam: the anon_ credential is the refresh material; re-exchange instead of
|
||||
# redeeming a rotating refresh token. Quarantine never applies to a guest.
|
||||
if self.force_refresh or self.invoke_jwt_status() is not None:
|
||||
refresh_guest_state(self.state, client)
|
||||
refresh_guest_state(self.state, client, purpose="inference")
|
||||
self.access_token = self.state["access_token"]
|
||||
self.stored_inference_base_url = self.state.get("inference_base_url") or self.stored_inference_base_url
|
||||
self.inference_base_url = _nous_inference_env_override() or self.stored_inference_base_url
|
||||
@@ -1069,10 +1069,11 @@ def resolve_nous_runtime_credentials(
|
||||
stale_access_token: Optional[str] = None) -> dict[str, Any]:
|
||||
"""Resolve Nous inference credentials for runtime use (refreshing under the auth-store lock).
|
||||
|
||||
A guest whose ``anon_`` credential NAS no longer knows (reaped or claimed) is retired and a new
|
||||
identity is set up once, transparently -- the one client rule covering both reap and claim.
|
||||
A guest whose ``anon_`` credential NAS no longer knows (reaped or claimed) is retired and never
|
||||
replaced here: the welcome trial is claimed once per machine, so that user goes on to sign-in or
|
||||
provider setup.
|
||||
"""
|
||||
from hermes_cli.anon_auth import AnonCredentialDead, clear_dead_guest, ensure_portal_identity
|
||||
from hermes_cli.anon_auth import AnonCredentialDead, clear_dead_guest
|
||||
from hermes_cli.anon_challenge import run_with_challenge
|
||||
try:
|
||||
# A free-tier exchange may be answered with a browser challenge; it is worked here, after
|
||||
@@ -1082,16 +1083,9 @@ def resolve_nous_runtime_credentials(
|
||||
force_refresh=force_refresh, stale_access_token=stale_access_token))
|
||||
except AnonCredentialDead as dead_exc:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
from hermes_cli.anon_auth import ANON_ACCOUNT_LOCKED
|
||||
dead = get_provider_auth_state("nous") or {}
|
||||
clear_dead_guest(str(dead_exc.code or "anon_credential_dead"), dead_token=dead.get("anon_token"))
|
||||
# A locked account is retired but never silently replaced: the way forward is a sign-in.
|
||||
if dead_exc.code == ANON_ACCOUNT_LOCKED:
|
||||
raise
|
||||
if ensure_portal_identity(explicit=True, timeout_seconds=timeout_seconds) is None:
|
||||
raise
|
||||
return run_with_challenge(lambda: _resolve_nous_runtime_credentials(
|
||||
timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle))
|
||||
raise
|
||||
|
||||
|
||||
def _resolve_nous_runtime_credentials(
|
||||
|
||||
@@ -596,16 +596,6 @@ def _active_profile_name() -> Optional[str]:
|
||||
return get_active_profile_name()
|
||||
|
||||
|
||||
def _route_model_for_banner(provider: Any) -> str:
|
||||
"""The model the resolved route will actually serve when config names none: today only the Nous
|
||||
free tier (welcome host -> ``nous/welcome``). Read from the boot record and local auth state;
|
||||
no network. Empty when nothing resolves, so the caller keeps its "no model configured" line."""
|
||||
if (provider or "auto").strip().lower() not in ("auto", "nous"):
|
||||
return ""
|
||||
from hermes_cli.anon_auth import GUEST_MODEL, free_tier_route
|
||||
return GUEST_MODEL if free_tier_route() else ""
|
||||
|
||||
|
||||
def _banner_left_lines(model: str, cwd: str, session_id, context_length, provider, *, accent: str, dim: str,
|
||||
context_pinned: bool = False) -> list:
|
||||
"""Model / cwd / session lines under the hero art. ``context_pinned`` marks a
|
||||
@@ -616,10 +606,6 @@ def _banner_left_lines(model: str, cwd: str, session_id, context_length, provide
|
||||
pin = " (pinned)" if context_pinned else ""
|
||||
ctx_str = _dim_sep(f"{_format_context_length(context_length)} context{pin}") if context_length else ""
|
||||
nous_str = _dim_sep("Nous Research")
|
||||
if not (model or "").strip():
|
||||
# Credentials resolve lazily on the first message; the banner prints first. Ask the route
|
||||
# the same question so a fresh free-tier install shows its model, not a red "unconfigured".
|
||||
model = _quiet(lambda: _route_model_for_banner(provider), "") or model
|
||||
if (provider or "").strip().lower() == "moa":
|
||||
# MoA virtual provider: ``model`` is a preset name; show it with its aggregator.
|
||||
agg_label = _quiet(lambda: _moa_aggregator_label(model), "")
|
||||
|
||||
@@ -268,11 +268,6 @@ class CLIAgentSetupMixin:
|
||||
api_key = runtime.get("api_key")
|
||||
base_url = runtime.get("base_url")
|
||||
resolved_provider = runtime.get("provider", "openrouter")
|
||||
if resolved_provider != "nous":
|
||||
# An explicit provider carries inference. The free-tier identity (for connectors) was
|
||||
# created by the boot bootstrap before this point, never here; this prints the one-time
|
||||
# "free tier is here" notice the first time an identity is seen beside an own key.
|
||||
self._maybe_print_free_tier_available_notice()
|
||||
resolved_routing = (
|
||||
resolved_provider, runtime.get("api_mode", self.api_mode), runtime.get("command"),
|
||||
list(runtime.get("args") or []))
|
||||
@@ -347,20 +342,6 @@ class CLIAgentSetupMixin:
|
||||
self._active_agent_route_signature = None
|
||||
return True
|
||||
|
||||
def _maybe_print_free_tier_available_notice(self) -> None:
|
||||
"""One-time notice for installs whose inference is carried by an explicit provider: the free
|
||||
tier (inference + connectors) now exists. Printed the first time an identity is present, then
|
||||
flagged on that identity so it never repeats. Never blocks or raises."""
|
||||
from cli import logger
|
||||
try:
|
||||
from hermes_cli import anon_auth
|
||||
if not anon_auth.guest_notice_pending():
|
||||
return
|
||||
self._console_print(f"[dim]{anon_auth.FREE_TIER_AVAILABLE_NOTICE}[/]")
|
||||
anon_auth.mark_guest_notice_shown()
|
||||
except Exception as exc:
|
||||
logger.debug("free tier availability notice skipped: %s", exc)
|
||||
|
||||
def _resolve_fallback_runtime(self, primary_exc):
|
||||
"""Primary provider resolution failed: on an AuthError try each fallback entry in
|
||||
order and switch the CLI's requested_provider/model to the first that resolves.
|
||||
|
||||
@@ -284,13 +284,6 @@ class CLISessionMixin:
|
||||
ctx_label = None
|
||||
|
||||
lines = [t("cli.session.status_title"), "", *status_lines(fields, "session_id", "path", "title", "model")]
|
||||
try:
|
||||
from hermes_cli.anon_auth import free_tier_route
|
||||
|
||||
if free_tier_route():
|
||||
lines.append(t("gateway.status.free_tier"))
|
||||
except Exception:
|
||||
pass
|
||||
optional = ((t("cli.session.label_reasoning"), reasoning_label),
|
||||
(t("cli.session.label_approvals"), approval_label),
|
||||
(t("cli.session.label_context"), ctx_label))
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"""Serve-start bootstrap for the Nous free tier: the boot-time creator of its identity.
|
||||
"""Serve-start record of who carries inference, for ``setup.status`` and ``setup.ready``.
|
||||
|
||||
Every Hermes process that may need the free tier runs this once at boot (``hermes serve`` on a
|
||||
daemon thread beside the other background boots; the CLI first-run guard synchronously). It
|
||||
inventories credentials cheap-first, creates the identity only when the launch gate is open
|
||||
(:func:`hermes_cli.anon_auth.guest_enabled`), resolves which provider carries inference, records
|
||||
the answer in process memory, and tells every connected client with one ``setup.ready`` event.
|
||||
|
||||
Besides this boot path, only the first hosted connector action mints, and only for a user with no
|
||||
Nous identity (``tools.connectors.gateway.config.ensure_guest_identity``; with the launch gate
|
||||
closed that guest never opens the free model). ``free_tier.status`` and ``setup.status`` read the record; provider resolution
|
||||
never reaches the portal; a dead credential is replaced by the explicit re-mint in
|
||||
``auth_nous.resolve_nous_runtime_credentials``. Ruling: NS-845 Q1.2 (recorded on NS-847).
|
||||
``hermes serve`` runs this once at boot on a daemon thread. It never creates an identity: the
|
||||
desktop's welcome-trial claim (``hermes_cli.welcome_trial``) and the first hosted connector action
|
||||
are the only creators. On the desktop's local backend it first keeps the trial for a guest that
|
||||
already ran on the free model (``welcome_trial.grandfather_existing_guest``). Then it inventories
|
||||
credentials cheap-first, resolves which provider carries inference, records the answer in process
|
||||
memory, and tells every connected client with one ``setup.ready`` event. ``free_tier.status`` and
|
||||
``setup.status`` read the record; provider resolution never reaches the portal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,8 +19,7 @@ from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger("hermes_cli.auth")
|
||||
|
||||
# The desktop's first ``setup.status`` waits this long for the record before falling back to a live
|
||||
# probe. The mint budget is 5 s (``GUEST_MINT_TIMEOUT_SECONDS``); the rest covers the inventory.
|
||||
# The desktop's first ``setup.status`` waits this long for the record before falling back to a live probe.
|
||||
SETUP_READY_WAIT_SECONDS = 8.0
|
||||
SETUP_READY_EVENT = "setup.ready"
|
||||
|
||||
@@ -36,12 +31,12 @@ class SetupRecord:
|
||||
|
||||
provider_configured: bool # some provider can carry inference (free tier included)
|
||||
inference_provider: str # ``resolve_provider("auto")``'s answer, "" when nothing resolves
|
||||
free_tier_account: bool # the identity that exists is the free tier AND the tier is on
|
||||
free_tier_account: bool # the identity that exists is a guest that carries inference here
|
||||
has_identity: bool # a Nous identity (free tier or account) is on disk
|
||||
other_providers: bool # the inventory found something usable BESIDES the free tier
|
||||
error: str = "" # why the mint did not happen, when it did not; "" otherwise
|
||||
error: str = "" # why the last trial claim failed, when it did; "" otherwise
|
||||
# The mint memo's verdict, verbatim (``anon_auth.MintFailure.as_payload``):
|
||||
# ``{error, error_code, retryable, retry_after}`` when the mint did not happen, else ``{}``.
|
||||
# ``{error, error_code, retryable, retry_after}`` after a failed claim, else ``{}``.
|
||||
# One wire shape: every status RPC spreads it as is.
|
||||
failure: dict[str, Any] = field(default_factory=dict)
|
||||
finished_at: float = field(default_factory=time.time)
|
||||
@@ -93,8 +88,7 @@ def reconcile_record() -> Optional[SetupRecord]:
|
||||
"""Let a provider configured AFTER boot count: a record that says ``provider_configured:
|
||||
false`` is re-inventoried once ``config.yaml`` / ``.env`` / ``auth.json`` moved since the
|
||||
inventory that built it, and replaced (+ ``setup.ready``) when something now carries
|
||||
inference. The mint verdict (identity, failure block) is kept as is: only the boot bootstrap
|
||||
and its retries mint. A record that already says ``True`` is never re-probed, so the answer
|
||||
inference. The identity and failure block are kept as is. A record that already says ``True`` is never re-probed, so the answer
|
||||
only moves false -> true here. Every write path that assigns the main model (the Models page,
|
||||
a picker key save) calls this for the immediate broadcast; ``setup.status`` calls it for
|
||||
writes this process never saw (``hermes setup`` / ``hermes model`` from a shell, a hand edit).
|
||||
@@ -170,44 +164,28 @@ def _resolve_inference() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _build_record(*, other: bool, force: bool) -> SetupRecord:
|
||||
"""One inventory-then-mint pass into a record. ``force`` is the user's own retry: it makes one
|
||||
attempt even inside the mint memo's cooldown (``anon_auth.ensure_portal_identity``)."""
|
||||
def _build_record(*, other: bool) -> SetupRecord:
|
||||
"""One inventory pass into a record. Reads only: no identity is created here."""
|
||||
from hermes_cli import anon_auth
|
||||
|
||||
error = ""
|
||||
failure: dict[str, Any] = {}
|
||||
state: Optional[dict[str, Any]] = anon_auth.current_nous_state()
|
||||
if anon_auth.guest_enabled():
|
||||
try:
|
||||
state = anon_auth.ensure_portal_identity(explicit=True, force=force)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
logger.info("Nous free tier not set up at boot: %s", exc)
|
||||
if state is None:
|
||||
# Either this attempt failed (the memo now holds why) or an earlier one did and its
|
||||
# cooldown still runs: the record carries that verdict either way.
|
||||
failure = anon_auth.last_mint_failure() or {}
|
||||
error = error or str(failure.get("error") or "")
|
||||
free_tier_account = bool(state) and anon_auth.is_guest_state(state) and anon_auth.guest_enabled()
|
||||
failure: dict[str, Any] = {} if state else (anon_auth.last_mint_failure() or {})
|
||||
free_tier_account = anon_auth.free_inference_active(state) if state else False
|
||||
return SetupRecord(
|
||||
provider_configured=other or free_tier_account or (bool(state) and not anon_auth.is_guest_state(state)),
|
||||
inference_provider=_resolve_inference(),
|
||||
free_tier_account=free_tier_account,
|
||||
has_identity=bool(state),
|
||||
other_providers=other,
|
||||
error=error,
|
||||
error=str(failure.get("error") or ""),
|
||||
failure=failure,
|
||||
)
|
||||
|
||||
|
||||
def run_bootstrap(*, announce: bool = True) -> SetupRecord:
|
||||
"""Inventory -> ensure identity (gate permitting) -> resolve inference -> record -> broadcast.
|
||||
|
||||
Runs every boot; only the mint is gated. Idempotent per process: a second call returns the
|
||||
existing record without touching the portal. Never raises. ``announce=False`` skips the
|
||||
``setup.ready`` event: the plain CLI has no client to tell and its stdout is the user's terminal.
|
||||
"""
|
||||
"""Grandfather an existing free-model guest -> inventory -> resolve inference -> record ->
|
||||
broadcast. Idempotent per process: a second call returns the existing record. Never raises.
|
||||
``announce=False`` skips the ``setup.ready`` event."""
|
||||
global _record, _started
|
||||
with _lock:
|
||||
if _record is not None:
|
||||
@@ -218,7 +196,12 @@ def run_bootstrap(*, announce: bool = True) -> SetupRecord:
|
||||
return _record
|
||||
_started = True
|
||||
|
||||
record = _build_record(other=_inventory_other_providers(), force=False)
|
||||
try:
|
||||
from hermes_cli.welcome_trial import grandfather_existing_guest
|
||||
grandfather_existing_guest()
|
||||
except Exception as exc: # the record still says who carries inference without it
|
||||
logger.info("free tier: could not check for an existing free-model guest: %s", exc)
|
||||
record = _build_record(other=_inventory_other_providers())
|
||||
with _lock:
|
||||
_record = record
|
||||
_done.set()
|
||||
@@ -227,62 +210,20 @@ def run_bootstrap(*, announce: bool = True) -> SetupRecord:
|
||||
return record
|
||||
|
||||
|
||||
# Background retries after a boot-time mint failure: the memo's cooldown decides WHEN (a server
|
||||
# ``Retry-After``, the ops-breaker floor, or the unreachable ladder), this decides HOW MANY before
|
||||
# the process stops trying on its own (the user's retry button, ``free_tier.provision``, is not
|
||||
# counted). A terminal code (gate closed, proof of work, locked) is never retried.
|
||||
BOOTSTRAP_RETRY_ATTEMPTS = 3
|
||||
_sleep = time.sleep # seam for tests
|
||||
|
||||
|
||||
def retry_bootstrap_mint(*, force: bool = False, announce: bool = True) -> SetupRecord:
|
||||
"""Re-run the mint once (``force`` bypasses the cooldown), replace the record and announce it.
|
||||
|
||||
The desktop's ``free_tier.provision`` calls this with ``force=True``; the background loop calls
|
||||
it as each cooldown passes. Returns the existing record untouched when no bootstrap ran yet
|
||||
(nothing to replace) or when an identity already exists."""
|
||||
def refresh_record(*, announce: bool = True) -> Optional[SetupRecord]:
|
||||
"""Rebuild the launch profile's record after the identity changed (a trial claim), so
|
||||
``setup.status`` and the ``setup.ready`` listeners move with it. None before the first bootstrap."""
|
||||
global _record
|
||||
current = _record
|
||||
if current is None:
|
||||
return run_bootstrap(announce=announce)
|
||||
if current.has_identity:
|
||||
return current
|
||||
# Re-inventory: a provider the user connected during the cooldown must keep inference; the
|
||||
# boot-time answer is stale by now.
|
||||
record = _build_record(other=_inventory_other_providers(), force=force)
|
||||
if _record is None:
|
||||
return None
|
||||
record = _build_record(other=_inventory_other_providers())
|
||||
with _lock:
|
||||
# Two retries can race (the background loop and the user's click): a build that found no
|
||||
# identity must not overwrite one that did.
|
||||
if _record is not None and _record.has_identity and not record.has_identity:
|
||||
return _record
|
||||
_record = record
|
||||
if announce:
|
||||
_broadcast(record)
|
||||
return record
|
||||
|
||||
|
||||
def _retry_until_settled() -> None:
|
||||
"""The background loop behind ``start_background_bootstrap``: wait out each cooldown and try
|
||||
again, up to ``BOOTSTRAP_RETRY_ATTEMPTS``, while the record says a later attempt can succeed."""
|
||||
for _ in range(BOOTSTRAP_RETRY_ATTEMPTS):
|
||||
record = _record
|
||||
if record is None or record.has_identity or not record.failure.get("retryable"):
|
||||
return
|
||||
_sleep(max(1, int(record.failure.get("retry_after") or 0)))
|
||||
record = retry_bootstrap_mint(force=False)
|
||||
if record.has_identity:
|
||||
logger.info("Nous free tier set up after a boot-time retry")
|
||||
return
|
||||
|
||||
|
||||
def _bootstrap_then_retry() -> None:
|
||||
run_bootstrap()
|
||||
try:
|
||||
_retry_until_settled()
|
||||
except Exception as exc: # the loop is best effort; the record already says what happened
|
||||
logger.debug("free tier bootstrap retry loop stopped: %s", exc)
|
||||
|
||||
|
||||
def _broadcast(record: SetupRecord) -> None:
|
||||
try:
|
||||
from tui_gateway.server import _broadcast_global_event
|
||||
@@ -292,7 +233,7 @@ def _broadcast(record: SetupRecord) -> None:
|
||||
|
||||
|
||||
def start_background_bootstrap() -> threading.Thread:
|
||||
"""``hermes serve`` entry: run on a daemon thread so a slow portal never delays the socket."""
|
||||
thread = threading.Thread(target=_bootstrap_then_retry, daemon=True, name="free-tier-bootstrap")
|
||||
"""``hermes serve`` entry: run on a daemon thread so the inventory never delays the socket."""
|
||||
thread = threading.Thread(target=run_bootstrap, daemon=True, name="free-tier-bootstrap")
|
||||
thread.start()
|
||||
return thread
|
||||
@@ -47,7 +47,7 @@ def _transaction() -> Iterator[tuple[dict[str, Any], Any]]:
|
||||
|
||||
def _on_free_tier() -> bool:
|
||||
from hermes_cli import anon_auth
|
||||
return anon_auth.has_guest() and anon_auth.guest_enabled()
|
||||
return anon_auth.free_inference_active()
|
||||
|
||||
|
||||
def _due_at(state: dict[str, Any]) -> Optional[float]:
|
||||
|
||||
@@ -1177,12 +1177,6 @@ def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Nothing explicit anywhere: an existing Nous free-tier identity counts while the tier is on.
|
||||
try:
|
||||
from hermes_cli.anon_auth import guest_enabled, has_guest
|
||||
return guest_enabled() and has_guest()
|
||||
except Exception as exc:
|
||||
logger.debug("free tier check on first run skipped: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
@@ -1745,11 +1739,6 @@ def cmd_chat(args):
|
||||
|
||||
_warn_retired_xai_models()
|
||||
|
||||
# First-run guard: the free-tier bootstrap runs first (synchronously here; it is the only thing
|
||||
# that may create the identity), then the inventory decides whether setup is needed.
|
||||
from hermes_cli.free_tier_bootstrap import run_bootstrap
|
||||
|
||||
run_bootstrap(announce=False)
|
||||
# The TUI owns its first-run state: it renders "Setup Required" with in-place /setup.
|
||||
# The classic prompt only fronts the classic CLI.
|
||||
provider_configured = _has_any_provider_configured()
|
||||
|
||||
@@ -284,6 +284,11 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.nous_subscription import prompt_enable_tool_gateway
|
||||
state = get_provider_auth_state("nous")
|
||||
from hermes_cli import anon_auth
|
||||
if anon_auth.guest_without_free_tier(state):
|
||||
# A guest that carries no inference here: signing in promotes it and keeps its connectors.
|
||||
print(anon_auth.UPGRADE_HINT)
|
||||
return
|
||||
if not state or not state.get("access_token"):
|
||||
_say("Not logged into Nous Portal. Starting login...", "")
|
||||
|
||||
@@ -301,21 +306,6 @@ def _model_flow_nous(config, current_model="", args=None):
|
||||
# instead of the hundreds returned by the live /models endpoint.
|
||||
from hermes_cli.models import check_nous_free_tier, get_curated_nous_model_ids
|
||||
from hermes_cli.models_pricing import get_pricing_for_provider
|
||||
from hermes_cli.model_switch_providers import _free_tier_nous_row
|
||||
tier_row = _free_tier_nous_row({"name": "Nous Portal", "models": []})
|
||||
if tier_row is None:
|
||||
print("The Nous free tier is off for this install; sign in with `hermes auth upgrade` to use Nous models.")
|
||||
return
|
||||
if tier_row["models"]:
|
||||
# Free-tier identity: the welcome host serves the single pinned model; no Portal catalog,
|
||||
# pricing, or account lookups apply.
|
||||
creds = _nous_verified_credentials()
|
||||
if creds is None:
|
||||
return
|
||||
selected = tier_row["models"][0]
|
||||
_nous_persist_selection(selected, creds)
|
||||
print(f"Default model set to: {selected} (via {tier_row['name']})")
|
||||
return
|
||||
model_ids = get_curated_nous_model_ids()
|
||||
if not model_ids:
|
||||
print("No curated models available for Nous Portal.")
|
||||
|
||||
@@ -500,8 +500,8 @@ def _free_tier_nous_row(row: dict) -> dict | None:
|
||||
return row
|
||||
if not anon_auth.guest_allowed():
|
||||
return None
|
||||
if not anon_auth.guest_enabled():
|
||||
return row # a guest kept for connectors only: the row is whatever real Nous login exists
|
||||
if not anon_auth.free_inference_active():
|
||||
return row # a guest without the welcome trial here: the row is whatever real Nous login exists
|
||||
out = dict(row)
|
||||
out["name"] = anon_auth.FREE_TIER_LABEL
|
||||
out["models"] = [anon_auth.GUEST_MODEL]
|
||||
|
||||
@@ -17,9 +17,10 @@ def _root_config() -> Path:
|
||||
return get_default_hermes_root() / "config.yaml"
|
||||
|
||||
|
||||
def should_run() -> bool:
|
||||
def should_run(*, assume_fresh: bool = False) -> bool:
|
||||
"""The root config's explicit ``onboarding.run`` (managed overlay included) when it is a bool,
|
||||
else whether this machine has never used Hermes (``install_state``)."""
|
||||
else whether this machine has never used Hermes (``install_state``; *assume_fresh* is the
|
||||
unpackaged desktop's ``--fresh-install`` switch)."""
|
||||
from hermes_cli.config_effective import load_user_config_effective
|
||||
|
||||
# Not load_config(): it resolves the launch profile and fills the default None for an unset flag.
|
||||
@@ -29,7 +30,7 @@ def should_run() -> bool:
|
||||
return value
|
||||
from hermes_cli.install_state import probe
|
||||
|
||||
return probe().fresh
|
||||
return assume_fresh or probe().fresh
|
||||
|
||||
|
||||
def set_run(value: bool, *, mark_profile_offered: bool = False) -> None:
|
||||
|
||||
@@ -259,6 +259,12 @@ def is_desktop_owned_backend(argv: Optional[Sequence[str]] = None) -> bool:
|
||||
return is_desktop_ssh_backend_argv(list(sys.argv[1:] if argv is None else argv))
|
||||
|
||||
|
||||
def is_local_desktop_backend() -> bool:
|
||||
"""The backend Desktop spawned on this machine: the local pool spawn's credential, never the
|
||||
SSH spawn (its token arrives as a file on argv, and that host is another computer)."""
|
||||
return os.environ.get("HERMES_DESKTOP") == "1" and bool(os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN"))
|
||||
|
||||
|
||||
def _desktop_spawner_identity() -> tuple[Optional[int], Optional[float]]:
|
||||
"""Spawner ``(pid, create_time)`` from the Electron app's HERMES_PARENT_PID (+ optional
|
||||
``winms:<ms>`` start marker) parent-death watchdog vars, so ledger lineage works with every
|
||||
|
||||
@@ -440,7 +440,7 @@ async def _start_nous_device_code(profile: Optional[str]) -> dict[str, Any]:
|
||||
os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL") or pconfig.portal_base_url
|
||||
).rstrip("/")
|
||||
with _profile_scope(_oauth_profile_name(profile)):
|
||||
guest = anon_auth.current_nous_state() if anon_auth.guest_enabled() else None
|
||||
guest = anon_auth.current_nous_state() if anon_auth.guest_allowed() else None
|
||||
|
||||
if not anon_auth.is_guest_state(guest):
|
||||
device_data = await _httpx_call(lambda client: _request_device_code(
|
||||
|
||||
@@ -295,10 +295,9 @@ async def _lifespan(app: FastAPI):
|
||||
|
||||
threading.Thread(target=_boot_local_runtime, daemon=True, name="local-runtime-boot").start()
|
||||
|
||||
# Nous free tier: the one boot-time creator of its identity. Inventories credentials, mints only
|
||||
# when HERMES_GUEST_ONBOARDING=1, records the answer for setup.status / free_tier.status and
|
||||
# broadcasts `setup.ready`. Off-thread so a slow portal never delays the socket; the desktop's
|
||||
# first setup.status waits on the record (bounded) instead.
|
||||
# Records who carries inference for setup.status / free_tier.status and broadcasts `setup.ready`
|
||||
# (no identity is created at boot). Off-thread; the desktop's first setup.status waits on the
|
||||
# record (bounded) instead.
|
||||
from hermes_cli.free_tier_bootstrap import start_background_bootstrap
|
||||
|
||||
start_background_bootstrap()
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""The desktop's welcome trial: free inference on ``nous/welcome`` for a first install.
|
||||
|
||||
Only the backend the desktop spawned on this machine can claim it, only when this machine has never
|
||||
used Hermes (``install_state``), and only with no provider and no Nous identity yet. The claim mints a
|
||||
guest, stamps ``WELCOME_CLAIM_FLAG`` on it and marks the machine, so the trial is never claimed twice
|
||||
here. A claim that is lost later (the guest is reaped, ``auth.json`` is deleted, the user signs in) is
|
||||
never made again: that user lands on provider setup and sign-in like anyone else.
|
||||
|
||||
Guests that ran on the free model before the claim existed keep it (:func:`grandfather_existing_guest`).
|
||||
Enforcement is client-side; the account service's own entitlement check follows separately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_cli import anon_auth
|
||||
|
||||
logger = logging.getLogger("hermes_cli.auth")
|
||||
|
||||
|
||||
def _has_nous_identity() -> bool:
|
||||
from hermes_cli.auth_nous import _read_shared_nous_state
|
||||
|
||||
return anon_auth.current_nous_state() is not None or _read_shared_nous_state() is not None
|
||||
|
||||
|
||||
def provider_configured() -> bool:
|
||||
"""Something besides the welcome trial carries inference: a key, a config pin, a sign-in, a host
|
||||
credential. The resolver itself answers, with the free-tier rung hidden."""
|
||||
from hermes_cli.auth import resolve_provider
|
||||
|
||||
try:
|
||||
provider = resolve_provider("auto", skip_free_tier=True)
|
||||
except Exception: # no_provider_configured, or an unreadable store: nothing usable here
|
||||
return False
|
||||
return provider != "nous" or not anon_auth.has_guest()
|
||||
|
||||
|
||||
def inference_available(*, assume_fresh: bool = False) -> bool:
|
||||
"""Something can carry a first chat on this backend: the trial (claimed, or claimable now) or a
|
||||
configured provider."""
|
||||
return (anon_auth.free_inference_active() or provider_configured()
|
||||
or claim_blocker(assume_fresh=assume_fresh) is None)
|
||||
|
||||
|
||||
def _machine_is_used(assume_fresh: bool) -> bool:
|
||||
from hermes_cli.install_state import probe
|
||||
|
||||
return not assume_fresh and probe().existing
|
||||
|
||||
|
||||
# Cheapest first; the last one reads the disk. Each is a reason the trial cannot be claimed.
|
||||
_CLAIM_BLOCKERS: tuple[tuple[str, Callable[[bool], bool]], ...] = (
|
||||
("not the desktop's local backend", lambda _fresh: not anon_auth.on_local_desktop_backend()),
|
||||
("guests are switched off", lambda _fresh: not anon_auth.guest_allowed()),
|
||||
("a Nous identity exists", lambda _fresh: _has_nous_identity()),
|
||||
("a provider is configured", lambda _fresh: provider_configured()),
|
||||
("Hermes was used on this machine", _machine_is_used),
|
||||
)
|
||||
|
||||
|
||||
def claim_blocker(*, assume_fresh: bool = False) -> Optional[str]:
|
||||
"""Why the trial cannot be claimed here, or None when it can. *assume_fresh* is the unpackaged
|
||||
desktop's ``--fresh-install`` switch: it skips only the machine check."""
|
||||
return next((reason for reason, blocks in _CLAIM_BLOCKERS if blocks(assume_fresh)), None)
|
||||
|
||||
|
||||
def claim_desktop_free_tier(*, assume_fresh: bool = False, force: bool = False) -> Optional[dict[str, Any]]:
|
||||
"""Claim the trial when nothing blocks it: mint the guest, stamp the claim, mark the machine.
|
||||
Returns the claimed state, or None when the trial is not available here. A failed mint raises
|
||||
the coded ``AuthError`` from :func:`anon_auth.ensure_portal_identity` (memoised with its cooldown;
|
||||
*force* is the user's own retry)."""
|
||||
if reason := claim_blocker(assume_fresh=assume_fresh):
|
||||
logger.debug("welcome trial not claimed: %s", reason)
|
||||
return None
|
||||
state = anon_auth.ensure_portal_identity(explicit=True, force=force)
|
||||
if state is None: # still inside the cooldown of the last failure
|
||||
failure = anon_auth.last_mint_failure() or {}
|
||||
raise anon_auth._anon_err(str(failure.get("error") or anon_auth.ANON_FAILURE_COPY[anon_auth.ANON_SERVER_ERROR]),
|
||||
str(failure.get("error_code") or anon_auth.ANON_SERVER_ERROR),
|
||||
retry_after=failure.get("retry_after"))
|
||||
claimed = _record_claim()
|
||||
logger.info("Nous free tier: welcome trial claimed on this machine")
|
||||
return claimed
|
||||
|
||||
|
||||
def grandfather_existing_guest() -> bool:
|
||||
"""A guest from before the claim existed keeps the free model when this home already chatted on
|
||||
it. Connector-only guests never qualify. Records the claim once; True when it did."""
|
||||
if not anon_auth.on_local_desktop_backend():
|
||||
return False
|
||||
state = anon_auth.current_nous_state()
|
||||
if not anon_auth.is_guest_state(state) or state.get(anon_auth.WELCOME_CLAIM_FLAG):
|
||||
return False
|
||||
from hermes_constants import get_default_hermes_root, get_hermes_home
|
||||
|
||||
homes = dict.fromkeys((get_hermes_home(), get_default_hermes_root()))
|
||||
if not any(_chatted_on_welcome(home / "state.db") for home in homes):
|
||||
return False
|
||||
_record_claim()
|
||||
logger.info("Nous free tier: existing free-model guest kept its trial")
|
||||
return True
|
||||
|
||||
|
||||
def _record_claim() -> Optional[dict[str, Any]]:
|
||||
from hermes_cli.auth_nous import _write_shared_nous_state
|
||||
from hermes_cli.install_state import mark_machine_used
|
||||
|
||||
state = anon_auth.set_guest_flag(anon_auth.WELCOME_CLAIM_FLAG)
|
||||
if state is not None:
|
||||
_write_shared_nous_state(state)
|
||||
mark_machine_used()
|
||||
return state
|
||||
|
||||
|
||||
def _chatted_on_welcome(db: Path) -> bool:
|
||||
"""A session on the free model with at least one message. Read-only; a missing or unreadable
|
||||
database is no."""
|
||||
if not db.is_file():
|
||||
return False
|
||||
try:
|
||||
with contextlib.closing(sqlite3.connect(f"{db.as_uri()}?mode=ro", uri=True, timeout=1)) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sessions s WHERE s.model = ? "
|
||||
"AND EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id) LIMIT 1",
|
||||
(anon_auth.GUEST_MODEL,)).fetchone()
|
||||
except sqlite3.Error:
|
||||
return False
|
||||
return row is not None
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**Laaste aktiwiteit:** {timestamp}"
|
||||
model: "**Model:** `{model}`"
|
||||
model_provider: "**Model:** `{model}` ({provider})"
|
||||
free_tier: "Nous · gratis vlak · nous/welcome · /login om aan te meld"
|
||||
context: "**Konteks:** {used} / {total} ({pct}%)"
|
||||
context_used: "**Konteks:** ~{used} tokens"
|
||||
tokens: "**Lewenslange tokens gefaktureer:** {tokens} _(nie jou huidige konteksgrootte nie; gebruik `/context`)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Gateway suksesvol herbegin. Jou sessie gaan voort."
|
||||
free_tier_line: "Inferensie: Nous gratis vlak (nous/welcome). Meld aan vir meer: /login"
|
||||
online: "♻️ Gateway aanlyn — Hermes is terug en gereed."
|
||||
db_corrupt: "⚠️ Sessie-databasis-korrupsie opgespoor. Boodskappe word moontlik nie bewaar nie. Herstelopsies:\n1. Voer `hermes {profile_arg}doctor --fix` uit\n2. Stop die gateway en herstel dan met:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (as dit herstelbaar rapporteer) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — herstel neem eers 'n momentopname van die beskadigde lêer; voer NIE `sqlite3 ... \".recover\"` teen die lewendige state.db uit nie, 'n kwesbare sqlite3 CLI kan dit verder korrupteer\n3. Herstel vanaf 'n rugsteun in {backups_dir}/\nVoer `hermes {profile_arg}doctor` uit vir gesuiwerde diagnostiek."
|
||||
db_fts_corrupt: "⚠️ Sessie-databasis het 'n korrupsiefout gerapporteer wat beperk is tot die soekindeks (FTS5); die boodskaptabelle is nie beskadig nie. Boodskappe word moontlik nie bewaar totdat dit herstel is nie: voer `hermes {profile_arg}doctor --fix` uit en herbegin dan die gateway. Voer nie herstelgereedskap uit of herstel 'n rugsteun nie tensy `hermes {profile_arg}doctor` skade bevestig."
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**آخر نشاط:** {timestamp}"
|
||||
model: "**النموذج:** `{model}`"
|
||||
model_provider: "**النموذج:** `{model}` ({provider})"
|
||||
free_tier: "Nous · الخطة المجانية · nous/welcome · /login لتسجيل الدخول"
|
||||
context: "**السياق:** {used} / {total} ({pct}%)"
|
||||
context_used: "**السياق:** ~{used} رمزًا"
|
||||
tokens: "**رموز API التراكمية (تُعاد كل استدعاء):** {tokens}"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ أُعيد تشغيل البوّابة بنجاح. جلستك مستمرة."
|
||||
free_tier_line: "الاستدلال: الطبقة المجانية من Nous (nous/welcome). سجّل الدخول للمزيد: /login"
|
||||
online: "♻️ البوّابة متصلة — Hermes عاد وجاهز."
|
||||
db_corrupt: "⚠️ رُصد تلف في قاعدة بيانات الجلسات. قد لا تُحفظ الرسائل. خيارات الاستعادة:\n1. شغّل `hermes {profile_arg}doctor --fix`\n2. أوقف البوّابة، ثم استعد بـ:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (إذا أفاد بإمكانية الاستعادة) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — تأخذ الاستعادة لقطة من الملف المتضرر أولًا؛ لا تشغّل `sqlite3 ... \".recover\"` على state.db الحي، فقد يزيد sqlite3 CLI المعرّض للثغرات من تلفه\n3. استعد من نسخة احتياطية في {backups_dir}/\nشغّل `hermes {profile_arg}doctor` للحصول على تشخيصات منقّحة."
|
||||
db_fts_corrupt: "⚠️ أفادت قاعدة بيانات الجلسات بخطأ تلف محصور في فهرس البحث (FTS5)؛ جداول الرسائل غير متضررة. قد لا تُحفظ الرسائل حتى إصلاحه: شغّل `hermes {profile_arg}doctor --fix` ثم أعد تشغيل البوّابة. لا تشغّل أدوات الاستعادة ولا تستعد نسخة احتياطية إلا إذا أكّد `hermes {profile_arg}doctor` وجود تلف."
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**Letzte Aktivität:** {timestamp}"
|
||||
model: "**Modell:** `{model}`"
|
||||
model_provider: "**Modell:** `{model}` ({provider})"
|
||||
free_tier: "Nous · kostenloser Tarif · nous/welcome · /login zum Anmelden"
|
||||
context: "**Kontext:** {used} / {total} ({pct}%)"
|
||||
context_used: "**Kontext:** ~{used} Tokens"
|
||||
tokens: "**Insgesamt abgerechnete Tokens:** {tokens} _(nicht Ihre aktuelle Kontextgröße; verwenden Sie `/context`)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Gateway erfolgreich neu gestartet. Ihre Sitzung wird fortgesetzt."
|
||||
free_tier_line: "Inferenz: Nous Free Tier (nous/welcome). Für mehr anmelden: /login"
|
||||
online: "♻️ Gateway online — Hermes ist zurück und bereit."
|
||||
db_corrupt: "⚠️ Beschädigung der Sitzungsdatenbank erkannt. Nachrichten werden möglicherweise nicht gespeichert. Wiederherstellungsoptionen:\n1. Führen Sie `hermes {profile_arg}doctor --fix` aus\n2. Stoppen Sie das Gateway und stellen Sie dann wieder her mit:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (falls als wiederherstellbar gemeldet) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — die Wiederherstellung sichert zuerst einen Schnappschuss der beschädigten Datei; führen Sie NICHT `sqlite3 ... \\\".recover\\\"` gegen die laufende state.db aus, eine anfällige sqlite3-CLI kann sie weiter beschädigen\n3. Stellen Sie ein Backup aus {backups_dir}/ wieder her\nFühren Sie `hermes {profile_arg}doctor` für bereinigte Diagnosen aus."
|
||||
db_fts_corrupt: "⚠️ Die Sitzungsdatenbank hat einen Beschädigungsfehler gemeldet, der auf den Suchindex (FTS5) beschränkt ist; die Nachrichtentabellen sind nicht beschädigt. Nachrichten werden bis zur Reparatur möglicherweise nicht gespeichert: Führen Sie `hermes {profile_arg}doctor --fix` aus und starten Sie dann das Gateway neu. Verwenden Sie keine Wiederherstellungstools und stellen Sie kein Backup wieder her, sofern `hermes {profile_arg}doctor` keinen Schaden bestätigt."
|
||||
|
||||
@@ -439,7 +439,6 @@ gateway:
|
||||
last_activity: "**Last Activity:** {timestamp}"
|
||||
model: "**Model:** `{model}`"
|
||||
model_provider: "**Model:** `{model}` ({provider})"
|
||||
free_tier: "Nous · free tier · nous/welcome · /login to sign in"
|
||||
context: "**Context:** {used} / {total} ({pct}%)"
|
||||
context_used: "**Context:** ~{used} tokens"
|
||||
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
|
||||
@@ -867,7 +866,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Gateway restarted successfully. Your session continues."
|
||||
free_tier_line: "Inference: Nous free tier (nous/welcome). Sign in for more: /login"
|
||||
online: "♻️ Gateway online — Hermes is back and ready."
|
||||
db_corrupt: "⚠️ Session database corruption detected. Messages may not be persisted. Recovery options:\n1. Run `hermes {profile_arg}doctor --fix`\n2. Stop the gateway, then recover with:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (if it reports recoverable) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — recovery snapshots the damaged file first; do NOT run `sqlite3 ... \".recover\"` against the live state.db, a vulnerable sqlite3 CLI can corrupt it further\n3. Restore from a backup in {backups_dir}/\nRun `hermes {profile_arg}doctor` for sanitized diagnostics."
|
||||
db_fts_corrupt: "⚠️ Session database reported a corruption error confined to the search index (FTS5); the message tables are not damaged. Messages may not be persisted until it is repaired: run `hermes {profile_arg}doctor --fix`, then restart the gateway. Do not run recovery tools or restore a backup unless `hermes {profile_arg}doctor` confirms damage."
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**Última actividad:** {timestamp}"
|
||||
model: "**Modelo:** `{model}`"
|
||||
model_provider: "**Modelo:** `{model}` ({provider})"
|
||||
free_tier: "Nous · plan gratuito · nous/welcome · /login para iniciar sesión"
|
||||
context: "**Contexto:** {used} / {total} ({pct}%)"
|
||||
context_used: "**Contexto:** ~{used} tokens"
|
||||
tokens: "**Tokens facturados en total:** {tokens} _(no es el tamaño actual de tu contexto; usa `/context`)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Gateway reiniciado correctamente. Tu sesión continúa."
|
||||
free_tier_line: "Inferencia: nivel gratuito de Nous (nous/welcome). Inicia sesión para más: /login"
|
||||
online: "♻️ Gateway en línea — Hermes está de vuelta y listo."
|
||||
db_corrupt: "⚠️ Se detectó corrupción en la base de datos de sesiones. Es posible que los mensajes no se guarden. Opciones de recuperación:\n1. Ejecuta `hermes {profile_arg}doctor --fix`\n2. Detén el gateway y luego recupera con:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (si indica que es recuperable) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — la recuperación primero toma una instantánea del archivo dañado; NO ejecutes `sqlite3 ... \".recover\"` sobre el state.db en uso, una CLI sqlite3 vulnerable puede dañarlo aún más\n3. Restaura una copia de seguridad desde {backups_dir}/\nEjecuta `hermes {profile_arg}doctor` para obtener diagnósticos anonimizados."
|
||||
db_fts_corrupt: "⚠️ La base de datos de sesiones informó un error de corrupción limitado al índice de búsqueda (FTS5); las tablas de mensajes no están dañadas. Es posible que los mensajes no se guarden hasta repararlo: ejecuta `hermes {profile_arg}doctor --fix` y luego reinicia el gateway. No ejecutes herramientas de recuperación ni restaures una copia de seguridad a menos que `hermes {profile_arg}doctor` confirme daños."
|
||||
|
||||
@@ -475,7 +475,6 @@ gateway:
|
||||
last_activity: "**Dernière activité :** {timestamp}"
|
||||
model: "**Modèle :** `{model}`"
|
||||
model_provider: "**Modèle :** `{model}` ({provider})"
|
||||
free_tier: "Nous · offre gratuite · nous/welcome · /login pour se connecter"
|
||||
context: "**Contexte :** {used} / {total} ({pct}%)"
|
||||
context_used: "**Contexte :** ~{used} jetons"
|
||||
tokens: "**Jetons facturés au total :** {tokens} _(pas la taille actuelle de votre contexte ; utilisez `/context`)_"
|
||||
@@ -997,7 +996,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Gateway redémarré avec succès. Votre session continue."
|
||||
free_tier_line: "Inférence : offre gratuite Nous (nous/welcome). Connectez-vous pour en obtenir plus : /login"
|
||||
online: "♻️ Gateway en ligne — Hermes est de retour et prêt."
|
||||
db_corrupt: |-
|
||||
⚠️ Corruption détectée dans la base de données des sessions. Les messages risquent de ne pas être persistés. Options de récupération :
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**Gníomhaíocht is déanaí:** {timestamp}"
|
||||
model: "**Samhail:** `{model}`"
|
||||
model_provider: "**Samhail:** `{model}` ({provider})"
|
||||
free_tier: "Nous · plean saor in aisce · nous/welcome · /login chun síniú isteach"
|
||||
context: "**Comhthéacs:** {used} / {total} ({pct}%)"
|
||||
context_used: "**Comhthéacs:** ~{used} comhartha"
|
||||
tokens: "**Comharthaí billeáilte ar feadh an tsaoil:** {tokens} _(ní méid do chomhthéacs reatha; úsáid `/context`)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Atosaíodh an gateway go rathúil. Leanann do sheisiún ar aghaidh."
|
||||
free_tier_line: "Tátal: plean saor in aisce Nous (nous/welcome). Sínigh isteach le haghaidh tuilleadh: /login"
|
||||
online: "♻️ Gateway ar líne — tá Hermes ar ais agus réidh."
|
||||
db_corrupt: "⚠️ Braitheadh truailliú i mbunachar sonraí na seisiún. Seans nach sábhálfar teachtaireachtaí. Roghanna athshlánaithe:\n1. Rith `hermes {profile_arg}doctor --fix`\n2. Stop an gateway, ansin athshlánaigh le:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (má thuairiscíonn sé gur féidir é a athshlánú) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — glacann an t-athshlánú roghchóip den chomhad damáistithe ar dtús; NÁ rith `sqlite3 ... \".recover\"` i gcoinne an state.db bheo, is féidir le CLI sqlite3 leochaileach é a thruailliú níos mó\n3. Athchóirigh ó chúltaca i {backups_dir}/\nRith `hermes {profile_arg}doctor` le haghaidh diagnóisic sláintithe."
|
||||
db_fts_corrupt: "⚠️ Thuairiscigh bunachar sonraí na seisiún earráid truaillithe atá teoranta don innéacs cuardaigh (FTS5); níl damáiste ar na táblaí teachtaireachtaí. Seans nach sábhálfar teachtaireachtaí go dtí go ndeiseofar é: rith `hermes {profile_arg}doctor --fix`, ansin atosaigh an gateway. Ná rith uirlisí athshlánaithe agus ná hathchóirigh cúltaca mura ndearbhaíonn `hermes {profile_arg}doctor` damáiste."
|
||||
|
||||
@@ -438,7 +438,6 @@ gateway:
|
||||
last_activity: "**Utolsó tevékenység:** {timestamp}"
|
||||
model: "**Modell:** `{model}`"
|
||||
model_provider: "**Modell:** `{model}` ({provider})"
|
||||
free_tier: "Nous · ingyenes csomag · nous/welcome · /login a bejelentkezéshez"
|
||||
context: "**Kontextus:** {used} / {total} ({pct}%)"
|
||||
context_used: "**Kontextus:** ~{used} token"
|
||||
tokens: "**Összes számlázott token:** {tokens} _(nem az aktuális kontextusméret; lásd `/context`)_"
|
||||
@@ -853,7 +852,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Az átjáró sikeresen újraindult. A munkameneted folytatódik."
|
||||
free_tier_line: "Inferencia: Nous ingyenes szint (nous/welcome). Többért jelentkezz be: /login"
|
||||
online: "♻️ Az átjáró elérhető — a Hermes visszatért, és készen áll."
|
||||
db_corrupt: "⚠️ A munkamenet-adatbázis sérülését észleltük. Az üzenetek mentése sikertelen lehet. Helyreállítási lehetőségek:\n1. Futtasd: `hermes {profile_arg}doctor --fix`\n2. Állítsd le az átjárót, majd állítsd helyre így:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (ha helyreállíthatónak jelzi) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — a helyreállítás először pillanatképet készít a sérült fájlról; NE futtasd a `sqlite3 ... \".recover\"` parancsot az élő state.db fájlon, egy sebezhető sqlite3 CLI tovább rongálhatja\n3. Állítsd vissza egy biztonsági mentésből innen: {backups_dir}/\nA `hermes {profile_arg}doctor` parancs tisztított diagnosztikát ad."
|
||||
db_fts_corrupt: "⚠️ A munkamenet-adatbázis a keresőindexre (FTS5) korlátozódó sérülési hibát jelzett; az üzenettáblák nem sérültek. A javításig az üzenetek mentése sikertelen lehet: futtasd a `hermes {profile_arg}doctor --fix` parancsot, majd indítsd újra az átjárót. Ne futtass helyreállító eszközöket, és ne állíts vissza biztonsági mentést, hacsak a `hermes {profile_arg}doctor` nem igazol sérülést."
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**Ultima attività:** {timestamp}"
|
||||
model: "**Modello:** `{model}`"
|
||||
model_provider: "**Modello:** `{model}` ({provider})"
|
||||
free_tier: "Nous · piano gratuito · nous/welcome · /login per accedere"
|
||||
context: "**Contesto:** {used} / {total} ({pct}%)"
|
||||
context_used: "**Contesto:** ~{used} token"
|
||||
tokens: "**Token fatturati in totale:** {tokens} _(non è la dimensione attuale del contesto; usa `/context`)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Gateway riavviato correttamente. La tua sessione continua."
|
||||
free_tier_line: "Inferenza: piano gratuito Nous (nous/welcome). Accedi per avere di più: /login"
|
||||
online: "♻️ Gateway online — Hermes è tornato ed è pronto."
|
||||
db_corrupt: "⚠️ Rilevata corruzione del database delle sessioni. I messaggi potrebbero non essere salvati. Opzioni di ripristino:\n1. Esegui `hermes {profile_arg}doctor --fix`\n2. Ferma il gateway, poi ripristina con:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (se segnala che è recuperabile) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — il ripristino crea prima uno snapshot del file danneggiato; NON eseguire `sqlite3 ... \".recover\"` sullo state.db attivo, una CLI sqlite3 vulnerabile può corromperlo ulteriormente\n3. Ripristina da un backup in {backups_dir}/\nEsegui `hermes {profile_arg}doctor` per una diagnostica sanificata."
|
||||
db_fts_corrupt: "⚠️ Il database delle sessioni ha segnalato un errore di corruzione limitato all'indice di ricerca (FTS5); le tabelle dei messaggi non sono danneggiate. I messaggi potrebbero non essere salvati finché non viene riparato: esegui `hermes {profile_arg}doctor --fix`, poi riavvia il gateway. Non eseguire strumenti di ripristino né ripristinare un backup a meno che `hermes {profile_arg}doctor` non confermi il danno."
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**最終アクティビティ:** {timestamp}"
|
||||
model: "**モデル:** `{model}`"
|
||||
model_provider: "**モデル:** `{model}` ({provider})"
|
||||
free_tier: "Nous · 無料プラン · nous/welcome · /login でログイン"
|
||||
context: "**コンテキスト:** {used} / {total} ({pct}%)"
|
||||
context_used: "**コンテキスト:** ~{used} トークン"
|
||||
tokens: "**累計請求トークン:** {tokens} _(現在のコンテキストサイズではありません。`/context` を使用してください)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ ゲートウェイの再起動に成功しました。セッションは継続します。"
|
||||
free_tier_line: "推論: Nous 無料プラン (nous/welcome)。さらに利用するにはログイン: /login"
|
||||
online: "♻️ ゲートウェイがオンラインです — Hermes が復帰し、準備完了です。"
|
||||
db_corrupt: "⚠️ セッションデータベースの破損を検出しました。メッセージが永続化されない可能性があります。復旧オプション:\n1. `hermes {profile_arg}doctor --fix` を実行\n2. ゲートウェイを停止し、次で復旧:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (復旧可能と報告された場合) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — 復旧は先に破損ファイルのスナップショットを取ります。稼働中の state.db に対して `sqlite3 ... \".recover\"` を実行しないでください。脆弱な sqlite3 CLI はさらに破損させる可能性があります\n3. {backups_dir}/ のバックアップから復元\nサニタイズ済みの診断情報は `hermes {profile_arg}doctor` で確認できます。"
|
||||
db_fts_corrupt: "⚠️ セッションデータベースが検索インデックス (FTS5) に限定された破損エラーを報告しました。メッセージテーブルは損傷していません。修復されるまでメッセージが永続化されない可能性があります: `hermes {profile_arg}doctor --fix` を実行し、ゲートウェイを再起動してください。`hermes {profile_arg}doctor` が損傷を確認しない限り、復旧ツールの実行やバックアップからの復元は行わないでください。"
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**최종 활동:** {timestamp}"
|
||||
model: "**모델:** `{model}`"
|
||||
model_provider: "**모델:** `{model}` ({provider})"
|
||||
free_tier: "Nous · 무료 요금제 · nous/welcome · /login 명령으로 로그인"
|
||||
context: "**컨텍스트:** {used} / {total} ({pct}%)"
|
||||
context_used: "**컨텍스트:** ~{used} 토큰"
|
||||
tokens: "**누적 청구 토큰:** {tokens} _(현재 컨텍스트 크기가 아닙니다. `/context`를 사용하세요)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ 게이트웨이가 성공적으로 재시작되었어요. 세션은 계속 이어져요."
|
||||
free_tier_line: "추론: Nous 무료 등급 (nous/welcome). 더 많이 사용하려면 로그인하세요: /login"
|
||||
online: "♻️ 게이트웨이 온라인 — Hermes가 돌아왔고 준비되었어요."
|
||||
db_corrupt: "⚠️ 세션 데이터베이스 손상이 감지되었어요. 메시지가 저장되지 않을 수 있어요. 복구 방법:\n1. `hermes {profile_arg}doctor --fix` 실행\n2. 게이트웨이를 중지한 뒤 다음으로 복구:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (복구 가능하다고 나오면) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — 복구는 먼저 손상된 파일의 스냅샷을 만들어요. 실행 중인 state.db에 `sqlite3 ... \\\".recover\\\"`를 실행하지 마세요. 취약한 sqlite3 CLI가 손상을 더 키울 수 있어요\n3. {backups_dir}/의 백업에서 복원\n정리된 진단 정보는 `hermes {profile_arg}doctor`로 확인하세요."
|
||||
db_fts_corrupt: "⚠️ 세션 데이터베이스가 검색 인덱스(FTS5)에 한정된 손상 오류를 보고했어요. 메시지 테이블은 손상되지 않았어요. 수리될 때까지 메시지가 저장되지 않을 수 있어요: `hermes {profile_arg}doctor --fix`를 실행한 뒤 게이트웨이를 재시작하세요. `hermes {profile_arg}doctor`가 손상을 확인하지 않는 한 복구 도구를 실행하거나 백업을 복원하지 마세요."
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**Última atividade:** {timestamp}"
|
||||
model: "**Modelo:** `{model}`"
|
||||
model_provider: "**Modelo:** `{model}` ({provider})"
|
||||
free_tier: "Nous · plano gratuito · nous/welcome · /login para entrar"
|
||||
context: "**Contexto:** {used} / {total} ({pct}%)"
|
||||
context_used: "**Contexto:** ~{used} tokens"
|
||||
tokens: "**Total acumulado de tokens faturados:** {tokens} _(não é o tamanho atual do contexto; usa `/context`)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Gateway reiniciado com sucesso. Sua sessão continua."
|
||||
free_tier_line: "Inferência: nível gratuito da Nous (nous/welcome). Entre para obter mais: /login"
|
||||
online: "♻️ Gateway online — o Hermes voltou e está pronto."
|
||||
db_corrupt: "⚠️ Corrupção detectada no banco de dados de sessões. As mensagens podem não ser persistidas. Opções de recuperação:\n1. Execute `hermes {profile_arg}doctor --fix`\n2. Pare o gateway e depois recupere com:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (se indicar que é recuperável) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — a recuperação cria um instantâneo do arquivo danificado primeiro; NÃO execute `sqlite3 ... \".recover\"` no state.db em uso, um CLI sqlite3 vulnerável pode corrompê-lo ainda mais\n3. Restaure a partir de um backup em {backups_dir}/\nExecute `hermes {profile_arg}doctor` para diagnósticos sanitizados."
|
||||
db_fts_corrupt: "⚠️ O banco de dados de sessões relatou um erro de corrupção restrito ao índice de busca (FTS5); as tabelas de mensagens não estão danificadas. As mensagens podem não ser persistidas até o reparo: execute `hermes {profile_arg}doctor --fix` e depois reinicie o gateway. Não execute ferramentas de recuperação nem restaure um backup a menos que `hermes {profile_arg}doctor` confirme o dano."
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**Последняя активность:** {timestamp}"
|
||||
model: "**Модель:** `{model}`"
|
||||
model_provider: "**Модель:** `{model}` ({provider})"
|
||||
free_tier: "Nous · бесплатный тариф · nous/welcome · /login для входа"
|
||||
context: "**Контекст:** {used} / {total} ({pct}%)"
|
||||
context_used: "**Контекст:** ~{used} токенов"
|
||||
tokens: "**Всего токенов списано за всё время:** {tokens} _(это не размер текущего контекста; используйте `/context`)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Шлюз успешно перезапущен. Ваш сеанс продолжается."
|
||||
free_tier_line: "Инференс: бесплатный уровень Nous (nous/welcome). Войдите, чтобы получить больше: /login"
|
||||
online: "♻️ Шлюз в сети — Hermes снова готов к работе."
|
||||
db_corrupt: "⚠️ Обнаружено повреждение базы данных сеансов. Сообщения могут не сохраняться. Варианты восстановления:\n1. Выполните `hermes {profile_arg}doctor --fix`\n2. Остановите шлюз, затем восстановите:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (если сообщает, что восстановление возможно) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — восстановление сначала делает снимок повреждённого файла; НЕ запускайте `sqlite3 ... \".recover\"` на рабочем state.db — уязвимый CLI sqlite3 может повредить его ещё сильнее\n3. Восстановите из резервной копии в {backups_dir}/\nВыполните `hermes {profile_arg}doctor` для очищенной диагностики."
|
||||
db_fts_corrupt: "⚠️ База данных сеансов сообщила об ошибке повреждения, ограниченной поисковым индексом (FTS5); таблицы сообщений не повреждены. Сообщения могут не сохраняться до починки: выполните `hermes {profile_arg}doctor --fix`, затем перезапустите шлюз. Не запускайте инструменты восстановления и не восстанавливайте резервную копию, пока `hermes {profile_arg}doctor` не подтвердит повреждение."
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**Son etkinlik:** {timestamp}"
|
||||
model: "**Model:** `{model}`"
|
||||
model_provider: "**Model:** `{model}` ({provider})"
|
||||
free_tier: "Nous · ücretsiz plan · nous/welcome · /login ile giriş yapın"
|
||||
context: "**Bağlam:** {used} / {total} ({pct}%)"
|
||||
context_used: "**Bağlam:** ~{used} token"
|
||||
tokens: "**Faturalandırılan toplam token:** {tokens} _(mevcut bağlam boyutunuz değil; `/context` kullanın)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Gateway başarıyla yeniden başlatıldı. Oturumunuz devam ediyor."
|
||||
free_tier_line: "Çıkarım: Nous ücretsiz katman (nous/welcome). Daha fazlası için giriş yapın: /login"
|
||||
online: "♻️ Gateway çevrimiçi — Hermes geri döndü ve hazır."
|
||||
db_corrupt: "⚠️ Oturum veritabanında bozulma tespit edildi. Mesajlar kalıcı olarak kaydedilmeyebilir. Kurtarma seçenekleri:\n1. `hermes {profile_arg}doctor --fix` çalıştırın\n2. Gateway'i durdurun, ardından şununla kurtarın:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (kurtarılabilir olduğunu bildirirse) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — kurtarma önce hasarlı dosyanın anlık görüntüsünü alır; canlı state.db üzerinde `sqlite3 ... \".recover\"` ÇALIŞTIRMAYIN, açığı olan bir sqlite3 CLI dosyayı daha da bozabilir\n3. {backups_dir}/ içindeki bir yedekten geri yükleyin\nTemizlenmiş tanılama için `hermes {profile_arg}doctor` çalıştırın."
|
||||
db_fts_corrupt: "⚠️ Oturum veritabanı yalnızca arama dizinini (FTS5) etkileyen bir bozulma hatası bildirdi; mesaj tabloları hasarlı değil. Onarılana kadar mesajlar kalıcı olarak kaydedilmeyebilir: `hermes {profile_arg}doctor --fix` çalıştırıp gateway'i yeniden başlatın. `hermes {profile_arg}doctor` hasarı doğrulamadıkça kurtarma araçlarını çalıştırmayın veya yedek geri yüklemeyin."
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**Остання активність:** {timestamp}"
|
||||
model: "**Модель:** `{model}`"
|
||||
model_provider: "**Модель:** `{model}` ({provider})"
|
||||
free_tier: "Nous · безкоштовний тариф · nous/welcome · /login для входу"
|
||||
context: "**Контекст:** {used} / {total} ({pct}%)"
|
||||
context_used: "**Контекст:** ~{used} токенів"
|
||||
tokens: "**Усього токенів виставлено за весь час:** {tokens} _(це не поточний розмір контексту; використайте `/context`)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ Шлюз успішно перезапущено. Ваш сеанс продовжується."
|
||||
free_tier_line: "Інференс: безкоштовний тариф Nous (nous/welcome). Увійдіть, щоб отримати більше: /login"
|
||||
online: "♻️ Шлюз онлайн — Hermes повернувся й готовий до роботи."
|
||||
db_corrupt: "⚠️ Виявлено пошкодження бази даних сеансів. Повідомлення можуть не зберігатися. Варіанти відновлення:\n1. Виконайте `hermes {profile_arg}doctor --fix`\n2. Зупиніть шлюз, потім відновіть командами:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (якщо повідомляє, що відновлення можливе) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — відновлення спочатку робить знімок пошкодженого файлу; НЕ запускайте `sqlite3 ... \".recover\"` на робочому state.db — вразливий CLI sqlite3 може пошкодити його ще більше\n3. Відновіть із резервної копії в {backups_dir}/\nВиконайте `hermes {profile_arg}doctor` для очищеної діагностики."
|
||||
db_fts_corrupt: "⚠️ База даних сеансів повідомила про пошкодження, обмежене пошуковим індексом (FTS5); таблиці повідомлень не пошкоджено. Повідомлення можуть не зберігатися, доки його не полагоджено: виконайте `hermes {profile_arg}doctor --fix`, потім перезапустіть шлюз. Не запускайте інструменти відновлення й не відновлюйте резервну копію, якщо `hermes {profile_arg}doctor` не підтвердить пошкодження."
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**最近活動:** {timestamp}"
|
||||
model: "**模型:** `{model}`"
|
||||
model_provider: "**模型:** `{model}`({provider})"
|
||||
free_tier: "Nous · 免費方案 · nous/welcome · /login 登入"
|
||||
context: "**上下文:** {used} / {total}({pct}%)"
|
||||
context_used: "**上下文:** ~{used} tokens"
|
||||
tokens: "**累計計費 token:** {tokens} _(非目前上下文大小;請使用 `/context`)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes — {title}"
|
||||
restarted: "♻ 閘道已成功重新啟動。你的工作階段將繼續。"
|
||||
free_tier_line: "推論:Nous 免費方案(nous/welcome)。登入以取得更多:/login"
|
||||
online: "♻️ 閘道已上線 — Hermes 已恢復並準備就緒。"
|
||||
db_corrupt: "⚠️ 偵測到工作階段資料庫損毀。訊息可能無法保存。復原選項:\n1. 執行 `hermes {profile_arg}doctor --fix`\n2. 停止閘道,然後以下列指令復原:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (若回報可復原)hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — 復原前會先為受損檔案建立快照;切勿對線上的 state.db 執行 `sqlite3 ... \".recover\"`,有漏洞的 sqlite3 CLI 可能使其進一步損毀\n3. 從 {backups_dir}/ 中的備份還原\n執行 `hermes {profile_arg}doctor` 可取得去識別化的診斷資訊。"
|
||||
db_fts_corrupt: "⚠️ 工作階段資料庫回報的損毀錯誤僅限於搜尋索引(FTS5);訊息資料表並未受損。修復前訊息可能無法保存:請執行 `hermes {profile_arg}doctor --fix`,然後重新啟動閘道。除非 `hermes {profile_arg}doctor` 確認有損毀,否則請勿執行復原工具或還原備份。"
|
||||
|
||||
@@ -435,7 +435,6 @@ gateway:
|
||||
last_activity: "**最近活动:** {timestamp}"
|
||||
model: "**模型:** `{model}`"
|
||||
model_provider: "**模型:** `{model}`({provider})"
|
||||
free_tier: "Nous · 免费套餐 · nous/welcome · /login 登录"
|
||||
context: "**上下文:** {used} / {total}({pct}%)"
|
||||
context_used: "**上下文:** 约 {used} tokens"
|
||||
tokens: "**累计计费 tokens:** {tokens} _(并非当前上下文大小;请使用 `/context` 查看)_"
|
||||
@@ -841,7 +840,6 @@ gateway:
|
||||
startup:
|
||||
handoff_thread_title: "Hermes——{title}"
|
||||
restarted: "♻ 网关已成功重启。你的会话继续有效。"
|
||||
free_tier_line: "推理:Nous 免费套餐(nous/welcome)。登录以获得更多:/login"
|
||||
online: "♻️ 网关已上线——Hermes 已恢复就绪。"
|
||||
db_corrupt: "⚠️ 检测到会话数据库损坏。消息可能无法持久化。恢复方案:\n1. 运行 `hermes {profile_arg}doctor --fix`\n2. 停止网关,然后执行恢复:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (若报告可恢复)hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n ——恢复会先对损坏文件做快照;切勿对正在使用的 state.db 运行 `sqlite3 ... \\\".recover\\\"`,有漏洞的 sqlite3 CLI 可能使其进一步损坏\n3. 从 {backups_dir}/ 中的备份恢复\n运行 `hermes {profile_arg}doctor` 获取脱敏诊断信息。"
|
||||
db_fts_corrupt: "⚠️ 会话数据库报告了仅限于搜索索引(FTS5)的损坏错误;消息表未受损。修复前消息可能无法持久化:请运行 `hermes {profile_arg}doctor --fix`,然后重启网关。除非 `hermes {profile_arg}doctor` 确认存在损坏,否则不要运行恢复工具或还原备份。"
|
||||
|
||||
@@ -124,7 +124,6 @@ def install_portal(monkeypatch, tmp_path, fake: FakePortal | None = None) -> Fak
|
||||
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", PORTAL)
|
||||
monkeypatch.setenv("HERMES_ANON_API_SECRET", "test-secret")
|
||||
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store"))
|
||||
monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1")
|
||||
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
real_client = httpx.Client
|
||||
@@ -146,3 +145,20 @@ def install_portal(monkeypatch, tmp_path, fake: FakePortal | None = None) -> Fak
|
||||
# by an earlier test must not be served to this one.
|
||||
monkeypatch.setattr(auth_mod, "_RESOLVE_TOKEN_CACHE", {})
|
||||
return fake
|
||||
|
||||
|
||||
def as_local_desktop_backend(monkeypatch) -> None:
|
||||
"""This process is the backend the desktop spawned on this machine (the local spawn's credential)."""
|
||||
monkeypatch.setenv("HERMES_DESKTOP", "1")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "test-spawn-token")
|
||||
|
||||
|
||||
def claimed_trial(monkeypatch) -> dict:
|
||||
"""A guest that claimed the welcome trial, read on the desktop's local backend: the state every
|
||||
free-model surface needs. Mints through the fake portal installed by ``install_portal``."""
|
||||
from hermes_cli import welcome_trial
|
||||
|
||||
as_local_desktop_backend(monkeypatch)
|
||||
state = welcome_trial.claim_desktop_free_tier()
|
||||
assert state is not None, welcome_trial.claim_blocker()
|
||||
return state
|
||||
@@ -0,0 +1,91 @@
|
||||
"""The desktop's welcome trial (``hermes_cli.welcome_trial``): who may claim it, what a claim leaves
|
||||
behind, which guests keep the free model, and that a connector guest never asks for inference."""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import anon_auth, install_state, welcome_trial
|
||||
from tests.hermes_cli.anon_portal import as_local_desktop_backend, claimed_trial, install_portal
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def portal(monkeypatch, tmp_path):
|
||||
return install_portal(monkeypatch, tmp_path)
|
||||
|
||||
|
||||
def test_a_first_install_on_the_local_desktop_backend_claims_the_trial(portal, monkeypatch):
|
||||
state = claimed_trial(monkeypatch)
|
||||
|
||||
assert state[anon_auth.WELCOME_CLAIM_FLAG] is True
|
||||
assert anon_auth.free_inference_active()
|
||||
assert install_state.existing_install() # the machine is marked: never claimed twice here
|
||||
assert welcome_trial.claim_blocker() is not None
|
||||
assert portal.creates() == 1
|
||||
|
||||
|
||||
BLOCKED = {
|
||||
"the CLI": lambda mp, home: None,
|
||||
"an SSH backend": lambda mp, home: (mp.setenv("HERMES_DESKTOP", "1"), mp.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)),
|
||||
"guests switched off": lambda mp, home: (as_local_desktop_backend(mp),
|
||||
(home / "config.yaml").write_text("nous:\n guest: false\n", encoding="utf-8")),
|
||||
"a provider key": lambda mp, home: (as_local_desktop_backend(mp), mp.setenv("OPENROUTER_API_KEY", "sk-or-test")),
|
||||
"a used machine": lambda mp, home: (as_local_desktop_backend(mp), install_state.mark_machine_used()),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", list(BLOCKED))
|
||||
def test_the_trial_is_never_claimed_outside_a_first_desktop_install(portal, monkeypatch, case):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
BLOCKED[case](monkeypatch, get_hermes_home())
|
||||
|
||||
assert welcome_trial.claim_desktop_free_tier() is None
|
||||
assert portal.creates() == 0
|
||||
assert not anon_auth.free_inference_active()
|
||||
|
||||
|
||||
def test_a_connector_guest_neither_claims_nor_carries_inference(portal, monkeypatch):
|
||||
as_local_desktop_backend(monkeypatch)
|
||||
anon_auth.ensure_portal_identity(explicit=True) # the first hosted connector action
|
||||
|
||||
assert welcome_trial.claim_desktop_free_tier() is None
|
||||
assert not anon_auth.free_inference_active()
|
||||
from hermes_cli.auth import resolve_nous_access_token
|
||||
resolve_nous_access_token()
|
||||
assert [r["body"]["purpose"] for r in portal.token_requests] == ["connectors"]
|
||||
|
||||
|
||||
def test_a_claimed_guest_carries_inference_only_on_the_local_desktop_backend(portal, monkeypatch):
|
||||
state = claimed_trial(monkeypatch)
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN") # the same home, read by a CLI process
|
||||
|
||||
assert not anon_auth.free_inference_active()
|
||||
assert anon_auth.guest_without_free_tier(state)
|
||||
|
||||
|
||||
def _chat(db_path, model: str) -> None:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB(db_path)
|
||||
db.create_session("s1", "desktop", model=model)
|
||||
db.append_message("s1", "user", "hi")
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("model", "kept"), [(anon_auth.GUEST_MODEL, True), ("anthropic/claude", False)])
|
||||
def test_a_guest_that_chatted_on_the_free_model_keeps_it(portal, monkeypatch, model, kept):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
as_local_desktop_backend(monkeypatch)
|
||||
anon_auth.ensure_portal_identity(explicit=True)
|
||||
_chat(get_hermes_home() / "state.db", model)
|
||||
|
||||
assert welcome_trial.grandfather_existing_guest() is kept
|
||||
assert anon_auth.free_inference_active() is kept
|
||||
|
||||
|
||||
def test_a_lost_claim_is_not_replaced(portal, monkeypatch):
|
||||
claimed_trial(monkeypatch)
|
||||
anon_auth.clear_dead_guest("anon_credential_dead")
|
||||
|
||||
assert welcome_trial.claim_desktop_free_tier() is None
|
||||
assert portal.creates() == 1
|
||||
@@ -0,0 +1,95 @@
|
||||
"""``onboarding.state`` / ``onboarding.start`` and the desktop's welcome trial: a new run on a first
|
||||
install claims it before the chat exists, a rerun never does, a failed claim writes nothing, and only
|
||||
the desktop's local backend opens onboarding by itself."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from agent.onboarding_flow import Facts
|
||||
from hermes_cli import anon_auth, install_state
|
||||
from tests.hermes_cli.anon_portal import as_local_desktop_backend, install_portal
|
||||
from tui_gateway import server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path, monkeypatch) -> Path:
|
||||
home = tmp_path / "hermes-root"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(server, "_hermes_home", str(home))
|
||||
monkeypatch.setattr(server, "_db", None)
|
||||
monkeypatch.setattr("agent.onboarding_facts.read_facts", lambda **kw: Facts(os="macos", arch="arm64"))
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def portal(root, tmp_path, monkeypatch):
|
||||
fake = install_portal(monkeypatch, tmp_path)
|
||||
monkeypatch.setitem(server._methods, "session.resume",
|
||||
lambda rid, params: {"result": {"session_id": f"rt-{params['session_id']}"}})
|
||||
monkeypatch.setitem(server._methods, "prompt.submit", lambda rid, params: {"result": {"status": "streaming"}})
|
||||
return fake
|
||||
|
||||
|
||||
def _call(method: str, params: dict | None = None) -> dict:
|
||||
return server.handle_request({"jsonrpc": "2.0", "id": 9, "method": method, "params": params or {}})
|
||||
|
||||
|
||||
def test_a_new_run_on_a_first_install_claims_the_trial_once(portal, monkeypatch):
|
||||
as_local_desktop_backend(monkeypatch)
|
||||
|
||||
assert _call("onboarding.state")["result"]["run"] is True
|
||||
assert _call("onboarding.state")["result"]["eligible"] is True
|
||||
_call("onboarding.start")
|
||||
_call("onboarding.start") # a relaunch reopens the run: no second claim
|
||||
_call("onboarding.start", {"rerun": True}) # Settings: runs on the model it has
|
||||
|
||||
assert portal.creates() == 1
|
||||
assert anon_auth.free_inference_active()
|
||||
|
||||
|
||||
def test_a_rerun_on_a_first_install_never_claims(portal, monkeypatch):
|
||||
as_local_desktop_backend(monkeypatch)
|
||||
|
||||
_call("onboarding.start", {"rerun": True})
|
||||
|
||||
assert portal.creates() == 0
|
||||
|
||||
|
||||
def test_a_failed_claim_opens_nothing_and_the_next_launch_tries_again(portal, monkeypatch):
|
||||
as_local_desktop_backend(monkeypatch)
|
||||
portal.create_response = httpx.Response(503, json={"error": "unavailable"})
|
||||
|
||||
failed = _call("onboarding.start")
|
||||
|
||||
assert failed["error"]["code"] == 5079
|
||||
assert _call("onboarding.state")["result"]["session_id"] is None
|
||||
assert install_state.probe().fresh
|
||||
portal.create_response = None
|
||||
anon_auth.reset_mint_memo_for_tests()
|
||||
assert "result" in _call("onboarding.start")
|
||||
assert anon_auth.free_inference_active()
|
||||
|
||||
|
||||
def test_only_the_local_desktop_backend_opens_onboarding_by_itself(portal, monkeypatch):
|
||||
assert _call("onboarding.state")["result"]["eligible"] is False # CLI / SSH / URL backend
|
||||
|
||||
as_local_desktop_backend(monkeypatch)
|
||||
install_state.mark_machine_used()
|
||||
assert _call("onboarding.state")["result"] == {"run": False, "eligible": False, "session_id": None, "run_id": None}
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
|
||||
assert _call("onboarding.state")["result"]["eligible"] is True
|
||||
|
||||
|
||||
def test_the_dev_fresh_install_switch_treats_a_used_machine_as_new(portal, monkeypatch):
|
||||
as_local_desktop_backend(monkeypatch)
|
||||
install_state.mark_machine_used()
|
||||
|
||||
assert _call("onboarding.state", {"assume_fresh": True})["result"]["run"] is True
|
||||
assert "result" in _call("onboarding.start", {"assume_fresh": True})
|
||||
assert portal.creates() == 1
|
||||
@@ -145,7 +145,7 @@ def ensure_guest_identity() -> Optional[str]:
|
||||
from hermes_cli.auth_constants import AuthError
|
||||
|
||||
try:
|
||||
if ensure_portal_identity(explicit=True, for_connectors=True) is not None:
|
||||
if ensure_portal_identity(explicit=True) is not None:
|
||||
return None
|
||||
except AuthError as exc:
|
||||
return _SETUP_FAILED + str(exc)
|
||||
|
||||
@@ -125,8 +125,7 @@ def _replace_dead_guest_token(dead_state: dict, code: str = "anon_credential_dea
|
||||
if code == ANON_ACCOUNT_LOCKED:
|
||||
return None
|
||||
try:
|
||||
# The identity this replaces served connectors and managed tools: the connectors gate applies.
|
||||
if ensure_portal_identity(explicit=True, for_connectors=True) is None:
|
||||
if ensure_portal_identity(explicit=True) is None:
|
||||
return None
|
||||
return _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS))
|
||||
except Exception as exc:
|
||||
|
||||
@@ -355,9 +355,17 @@ method("profiles.get_asset", params=ProfilesGetAssetParams, result=ProfilesGetAs
|
||||
# ── onboarding (methods_onboarding) ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class OnboardingStateParams(Params):
|
||||
"""``assume_fresh``: the unpackaged desktop's ``--fresh-install`` launch; the backend treats this
|
||||
machine as one that never used Hermes. Packaged builds never send it."""
|
||||
|
||||
assume_fresh: bool = False
|
||||
|
||||
|
||||
class OnboardingRunStateResult(Result):
|
||||
"""``run``: onboarding opens on this launch (root config ``onboarding.run``, else a fresh
|
||||
install). ``eligible``: the free tier is on for this backend (``anon_auth.guest_enabled``).
|
||||
"""``run``: onboarding opens on this launch (root config ``onboarding.run``, else a machine that
|
||||
never used Hermes). ``eligible``: this is the desktop's local backend and the welcome trial or a
|
||||
configured provider can carry the first chat.
|
||||
``session_id``: the stored id of the onboarding chat still in progress, so a relaunch reopens it;
|
||||
``run_id``: that run's id, which ``onboarding.ended`` names."""
|
||||
|
||||
@@ -367,7 +375,7 @@ class OnboardingRunStateResult(Result):
|
||||
run_id: str | None = None
|
||||
|
||||
|
||||
method("onboarding.state", params=Params, result=OnboardingRunStateResult,
|
||||
method("onboarding.state", params=OnboardingStateParams, result=OnboardingRunStateResult,
|
||||
doc="Whether desktop first-run onboarding is due, and the chat in progress; reads the root profile's config.")
|
||||
|
||||
|
||||
@@ -390,13 +398,16 @@ class OnboardingFactsSource(WireEnum):
|
||||
|
||||
class OnboardingStartParams(Params):
|
||||
"""``rerun``: start a new onboarding chat even when one is in progress (Settings, Run onboarding
|
||||
again). ``fresh_launch``: the app's once-per-launch start; a run the app was closed on does not
|
||||
count, so its chat is deleted and the run starts over. ``facts_source``: whether the backend runs
|
||||
on the user's own computer. ``locale``: the desktop's UI language, preferred over the backend OS
|
||||
locale."""
|
||||
again; it never claims the welcome trial). ``fresh_launch``: the app's once-per-launch start; a run
|
||||
the app was closed on does not count, so its chat is deleted and the run starts over.
|
||||
``facts_source``: whether the backend runs on the user's own computer. ``locale``: the desktop's UI
|
||||
language, preferred over the backend OS locale. ``assume_fresh``: as on ``onboarding.state``. A new
|
||||
run on a first install claims the welcome trial before the chat exists; a failed claim is error 5079
|
||||
and writes nothing."""
|
||||
|
||||
rerun: bool = False
|
||||
fresh_launch: bool = False
|
||||
assume_fresh: bool = False
|
||||
facts_source: OnboardingFactsSource = OnboardingFactsSource.local
|
||||
locale: str | None = None
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Nous free-tier JSON-RPC handlers. ``free_tier.status`` answers from the
|
||||
auth store with zero network and zero side effects; ``free_tier.provision`` is the explicit retry when
|
||||
the boot bootstrap could not create the identity (desktop-only entry); ``free_tier.ack_notice``
|
||||
auth store with zero network and zero side effects; ``free_tier.provision`` retries a welcome-trial claim
|
||||
that failed (desktop-only entry); ``free_tier.ack_notice``
|
||||
persists the one-time notice flag on the free-tier identity itself, so it dies with that identity.
|
||||
``free_tier.challenge_result`` records a presentation hint for the pending attempt; the
|
||||
backend still exchanges with the portal to determine whether the credential is cleared.
|
||||
@@ -21,28 +21,28 @@ _profile_scoped = _registry.profile_scoped
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""``{has_guest, enabled, available, notice_pending, model, label}`` for the focused profile.
|
||||
``available`` = an identity exists AND the tier is on: the free tier (connectors, and the model
|
||||
when nothing else carries inference) is there for this install. Whether inference actually runs
|
||||
on it is a ROUTE question answered by ``setup.runtime_check.free_tier``, never by this flag.
|
||||
``available``: a guest that carries inference here (the welcome trial, on the desktop's local
|
||||
backend). ``enabled``: available, or the trial can still be claimed on this machine. Whether
|
||||
inference actually runs on it is a ROUTE question answered by ``setup.runtime_check.free_tier``.
|
||||
``notice_pending`` is true until ``free_tier.ack_notice`` ran for this identity.
|
||||
``nudge_due_in``: seconds until the sign-in offer after a finished task is due (install-wide,
|
||||
``hermes_cli.free_tier_offer``); absent when none is pending.
|
||||
|
||||
A pure read. The identity is created by the boot bootstrap (``free_tier_bootstrap``), never as
|
||||
a side effect of a client polling this method (NS-845 Q1.2)."""
|
||||
A pure read: an identity is never created as a side effect of polling this (NS-845 Q1.2)."""
|
||||
try:
|
||||
from hermes_cli import anon_auth
|
||||
has_guest = anon_auth.has_guest()
|
||||
enabled = anon_auth.guest_enabled()
|
||||
available = anon_auth.free_inference_active()
|
||||
enabled = available or _trial_claimable()
|
||||
payload = {
|
||||
"has_guest": has_guest, "enabled": enabled, "available": has_guest and enabled,
|
||||
"notice_pending": bool(has_guest and enabled and anon_auth.guest_notice_pending()),
|
||||
"has_guest": has_guest, "enabled": enabled, "available": available,
|
||||
"notice_pending": bool(available and anon_auth.guest_notice_pending()),
|
||||
"model": anon_auth.GUEST_MODEL, "label": anon_auth.FREE_TIER_LABEL}
|
||||
from hermes_cli import free_tier_offer
|
||||
if (nudge_due_in := free_tier_offer.offer_due_in()) is not None:
|
||||
payload["nudge_due_in"] = nudge_due_in
|
||||
if enabled and not has_guest:
|
||||
# Why there is no identity, when the last attempt to make one failed:
|
||||
# Why there is no identity, when the last claim failed:
|
||||
# ``{error, error_code, retryable, retry_after}`` (the mint memo's verdict).
|
||||
payload.update(anon_auth.last_mint_failure() or {})
|
||||
# A browser challenge the account service is waiting on (``anon_challenge``): the same
|
||||
@@ -56,30 +56,29 @@ def _(rid, params: dict) -> dict:
|
||||
return _err(rid, 5090, str(e))
|
||||
|
||||
|
||||
def _trial_claimable() -> bool:
|
||||
from hermes_cli.welcome_trial import claim_blocker
|
||||
return claim_blocker() is None
|
||||
|
||||
|
||||
@method("free_tier.provision")
|
||||
@_profile_scoped
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Explicit retry of the free-tier set-up for the focused profile: adopt the shared store's
|
||||
identity, else mint one (blocking, short timeout). The boot bootstrap normally did this already;
|
||||
the desktop calls this when the record says the identity is missing (portal down at boot, gate
|
||||
turned on later) and the user asks again. The user's click is the one attempt that may run
|
||||
inside the mint memo's cooldown. ``{has_guest, enabled}``, plus
|
||||
``{error, error_code, retryable, retry_after}`` when the portal refused."""
|
||||
"""The user's retry of a welcome-trial claim that failed: claims it when nothing blocks it here
|
||||
(blocking, short timeout; the one attempt allowed inside the mint memo's cooldown). ``{has_guest,
|
||||
enabled}``, plus ``{error, error_code, retryable, retry_after}`` when the portal refused."""
|
||||
try:
|
||||
from hermes_cli import anon_auth
|
||||
from hermes_cli import free_tier_bootstrap
|
||||
enabled = anon_auth.guest_enabled()
|
||||
if enabled and not anon_auth.has_guest():
|
||||
if free_tier_bootstrap.current_record() is not None and not params.get("profile"):
|
||||
# The launch profile: refresh the boot record too, so ``setup.status`` and the
|
||||
# ``setup.ready`` listeners move with the outcome.
|
||||
free_tier_bootstrap.retry_bootstrap_mint(force=True)
|
||||
else:
|
||||
try:
|
||||
anon_auth.ensure_portal_identity(explicit=True, force=True)
|
||||
except Exception as exc: # memoised by the primitive before it re-raised
|
||||
logger.info("free tier provisioning failed: %s", exc)
|
||||
from hermes_cli import anon_auth, free_tier_bootstrap, welcome_trial
|
||||
if _trial_claimable():
|
||||
try:
|
||||
welcome_trial.claim_desktop_free_tier(force=True)
|
||||
except Exception as exc: # memoised by the mint primitive before it re-raised
|
||||
logger.info("welcome trial claim failed: %s", exc)
|
||||
if not params.get("profile"):
|
||||
# The launch profile: ``setup.status`` and the ``setup.ready`` listeners move with it.
|
||||
free_tier_bootstrap.refresh_record()
|
||||
has_guest = anon_auth.has_guest()
|
||||
enabled = anon_auth.free_inference_active() or _trial_claimable()
|
||||
payload = {"has_guest": has_guest, "enabled": enabled}
|
||||
if enabled and not has_guest:
|
||||
payload.update(anon_auth.last_mint_failure() or {})
|
||||
|
||||
@@ -9,7 +9,7 @@ method = _registry.method
|
||||
# flag live in the root profile's config.yaml whichever profile this backend was launched under.
|
||||
@method("onboarding.state")
|
||||
def _(rid, params: dict) -> dict:
|
||||
return _ok(rid, _run_state())
|
||||
return _ok(rid, _run_state(assume_fresh=bool(params.get("assume_fresh"))))
|
||||
|
||||
|
||||
@method("onboarding.set_run")
|
||||
@@ -25,11 +25,18 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(rid, _run_state())
|
||||
|
||||
|
||||
def _run_state() -> dict:
|
||||
from hermes_cli.anon_auth import guest_enabled
|
||||
def _run_state(*, assume_fresh: bool = False) -> dict:
|
||||
"""``run``: the root config says so, else this machine never used Hermes (or the unpackaged
|
||||
desktop's ``--fresh-install`` says to assume that). ``eligible``: this is the desktop's local
|
||||
backend and something can carry the first chat (the welcome trial or a configured provider);
|
||||
remote backends run onboarding only from Settings."""
|
||||
from hermes_cli.anon_auth import on_local_desktop_backend
|
||||
from hermes_cli.onboarding_run import should_run
|
||||
from hermes_cli.welcome_trial import inference_available
|
||||
|
||||
return {"run": should_run(), "eligible": guest_enabled(), **_active_run()}
|
||||
return {"run": should_run(assume_fresh=assume_fresh),
|
||||
"eligible": on_local_desktop_backend() and inference_available(assume_fresh=assume_fresh),
|
||||
**_active_run()}
|
||||
|
||||
|
||||
# ── first-run onboarding: one chat in the default profile, driven by setup_beat ───────────────
|
||||
@@ -54,6 +61,8 @@ def _(rid, params: dict) -> dict:
|
||||
from agent.onboarding_flow import End, Facts
|
||||
from agent.onboarding_record import advance, load, open_session_ids, start
|
||||
from agent.onboarding_text import CHAT_TITLE, opening_prompt
|
||||
from hermes_cli.auth_constants import AuthError
|
||||
from hermes_cli.welcome_trial import claim_desktop_free_tier
|
||||
# The app's once-per-launch start: closing the app mid-run does not count, so that run's chat goes
|
||||
# and the run starts over. A second window or a reconnect sends no flag and gets the run back.
|
||||
fresh_launch = bool(params.get("fresh_launch"))
|
||||
@@ -69,8 +78,13 @@ def _(rid, params: dict) -> dict:
|
||||
if created:
|
||||
# The tours setting is the default profile's, whichever profile launched the backend.
|
||||
home = _profile_home("default")
|
||||
with _session_profile_runtime_scope({"profile_home": str(home) if home else None},
|
||||
hydrate_secrets=False):
|
||||
scope = {"profile_home": str(home) if home else None}
|
||||
if not params.get("rerun"):
|
||||
# Before the chat exists, so onboarding's own writes are not what the machine
|
||||
# check sees. A rerun never claims: it runs on the configured model.
|
||||
with _session_profile_runtime_scope(scope):
|
||||
claim_desktop_free_tier(assume_fresh=bool(params.get("assume_fresh")))
|
||||
with _session_profile_runtime_scope(scope, hydrate_secrets=False):
|
||||
facts = read_facts(facts_source=params.get("facts_source") or "local", locale=params.get("locale"))
|
||||
stored = db.create_session(new_session_id(), "desktop")
|
||||
# The record exists before the agent is built, so the build folds the onboarding toolset in.
|
||||
@@ -91,6 +105,11 @@ def _(rid, params: dict) -> dict:
|
||||
db.set_session_title(stored, db.get_next_title_in_lineage(CHAT_TITLE))
|
||||
# A chat with no messages never got its opening (a restart between start and the first turn).
|
||||
opening = None if (db.get_session(stored) or {}).get("message_count") else opening_prompt(facts)
|
||||
except AuthError as e:
|
||||
# The trial claim failed (portal down, rate limited): nothing was written, so the next
|
||||
# launch is still a first install and tries again.
|
||||
logger.info("onboarding.start: welcome trial not claimed: %s", e)
|
||||
return _err(rid, 5079, str(e))
|
||||
except Exception as e:
|
||||
logger.exception("onboarding.start failed")
|
||||
return _err(rid, 5078, str(e))
|
||||
|
||||
Reference in new issue
Block a user