Files
hermes-agent/tui_gateway/methods_profiles.py
T
Siddharth Balyan 0e927c914d Guided first launch behind HERMES_GUEST_ONBOARDING: intro, guided chat, first task in default (NS-848, PR B1) (#107958)
* feat(desktop): port guided onboarding substrate

Add seeded session creation, transcript directives, profile routing, and the shared window and pane primitives needed by the guided flow. Keep later-step mounts deferred and exclude provider selection and retry machinery.

* refactor(desktop): anti-slop cleanup for substrate

Assemble seed parameters in the existing create helper and use the owning transcript attribute type. Read the guaranteed gateway and connection contracts directly to remove runtime type probes and unchecked assertions.

* test(desktop): create-overrides invariants

Verify that reasoning and title overrides do not select a provider or model. Empty overrides and seeds add no parameters.

* feat(desktop): port first-run cinematic window

Play the cinematic behind the guest onboarding launch flag using bundled Collapse and JetBrains Mono. Give the native window its own controller and restore the app on skip, renderer deadman or native watchdog.

Drop the perf scenario because it depends on the removed replay hook. Guided chat kickoff and app-shell gate wiring remain with their later steps.

* refactor(desktop): anti-slop cleanup for cinematic

Preserve audio and canvas behavior through named types and inferred results. Split the viewport node and frame drawing to keep control flow bounded. Cut comments that only repeat the code.

* feat(desktop): add onboarding gate and answers stores

Track cinematic, guided chat, handoff and completion in one phase record. Queue the guide after the intro and share pending kickoff work between callers.

Keep existing saved answers while dropping retired preferences. Leave intro seen-state ownership with the cinematic store.

* feat(desktop): port guided onboarding chat

Add guided setup cards, runbooks, machine context, and onboarding presence. Connect transcript rendering and first-build progress to the desktop behind the onboarding flag. Leave session kickoff and handoff execution for the next step.

* refactor(desktop): anti-slop cleanup for guided chat

Keep directive and layout lookups typed. Remove unsafe test casts and isolate onboarding transcript calculations without changing the flow.

* feat(desktop): connect guided onboarding to durable first-build handoff

Start the guide only after its profile backend confirms bootstrap readiness. Seed or adopt the welcome chat, then transfer the first build to default with a durable receipt and explicit retry.

Wire cinematic completion, screen stand-down, layout growth and progress check-ins. Save agreed preferences before creating the build and release prompt slots after storage refusal.

* refactor(desktop): anti-slop cleanup for onboarding handoff

Reuse the gateway request and error contracts. Isolate guide adoption and snapshot validation while preserving receipt recovery and reasoning overrides.

Validate persisted receipt fields at the JSON boundary without coercion. Keep corrupt identities rejected and retain only the permitted test mocks.

* fix(desktop): guided chat review fixes

Wire the native machine probe so guided setup can suggest a name and offer the right first task. Restore the comments that explain the flow boundaries.

The directive registration uses the launch flag to preserve ordinary chat. Ruling 6 folds active.ts into assembly to keep activity ownership together and removes the second greeting source so the seeded and visible greetings agree.

* fix(desktop): handoff review fixes

Probe the guide backend before switching profiles so a readiness refusal keeps classic onboarding on the current backend.

Restore list-valued personalization coverage and routing rationale. Remove the obsolete setup status fixture.

* chore(desktop): onboarding script cull and rehearsal recipe

Document a temporary-state rehearsal using the existing onboarding flag and optional portal stand-in. Keep the main scripts unchanged and retain window growth for the guided chat.

* fix(connectors): reject incomplete catalog responses

* feat(gateway): scope connector controls to the owning session

* feat(desktop): connect apps through native session-owned controls

* feat(desktop): gate connector cards and enable free-tier access

Use the launch flag before mounting connector controls so classic transcripts add no status requests. Allow existing free-tier identities through the read-only tool gateway gate and test the owning-profile RPC path with A’s launch gate. Keep authorization links out of previews.

* style(desktop): format connector translations

Apply Prettier to the connector copy blocks while preserving upstream translations and free-tier wording.

* refactor(desktop): anti-slop cleanup for connector card

Use the transcript JSON contract and concrete RPC parameters. Preserve malformed-value filtering at one string boundary and make the fixture and row types explicit. Keep connector execution and cancellation behavior unchanged.

* feat(desktop): detect initial language from the OS

Use the native machine locale when no supported language is saved. Preserve explicit choices and leave inferred languages out of config.

* refactor(desktop): anti-slop cleanup for initial locale detection

Keep unvalidated config values at the existing validation boundary. Pass no saved choice after that boundary has ruled it out, preserving locale precedence.

* test(desktop): onboarding port test set

Make native window tests reject duplicate IPC handlers and isolate disabled onboarding. Assert the active gate mock when onboarding re-enables.

Keep the test set limited to behavior carried by the port.

* fix(desktop): recover failed guide kickoff and reveal once

The review found that a failed guide create stranded the solo shell and draft profile, and solo boot faded an already visible window a second time. Restore the prior route and layout, release onboarding through its existing phase record, and surface create failures. Let the film own the reveal while solo boot animates the visible resize.

* fix(desktop): preserve transcript ownership across cards and handoff

The review reproduced answers submitted to the focused chat, repeated questions disabled across sessions, handoff recovery using foreground identity, and mount-dependent progress history. Target each card’s own composer, scope settlement to its message and session, carry the issuing guide through handoff, and derive progress from its transcript with streaming activity. Reuse the existing owner ladder for exact and profile-only routes.

* fix(gateway): preserve connector ownership with profile routing

The review found that shared-primary profile metadata was rejected before connector dispatch, while desktop controls treated a missing registry id as missing ownership. Accept profile only as routing metadata and keep the live transport as authorization. Resolve card ownership through the existing exact/profile ladder, retaining ambient routing only for the single-backend case.

* fix(desktop): resolve plugin roots and gate the Basic layout

The review found that the first plugin build was seeded with a different installation’s fixed path, and the director ruled that flag-off layouts must match main. Resolve the running desktop’s plugin root before seeding a plugin build and register Basic only when onboarding is enabled. Keep the runbook wording and the ordinary four layout presets intact.

* fix(desktop): clear review-fix slop findings

The slop gate flagged an undocumented layout-data assertion and unknown-return types in the new test selectors. Record the layout registry invariant and preserve each selector’s return type. The only remaining production finding is the accepted connector-tools baseline.

* fix(desktop): detect the OS language on a fresh install

The review found that the merged English config default prevented the
desktop from probing the OS language on a fresh install. Add an opt-in
saved-values read so an absent choice remains distinct from saved English.

Preserve default-valued English only for explicit language saves; unrelated
settings saves must not turn a merged default into a language choice.
Older backends ignore the new query options and keep returning merged
English, preserving their existing desktop behavior.

* test(desktop): make the flag-off layout registry test deterministic

The flag-off test awaited the full controller import, pulling in the UI
graph and installing application watchers just to read layout presets.
That import took 9.5 seconds locally and timed out in the director's run.

Move the existing trees and registration into a small layout-presets
module. Production and the synchronous test use the same flag-gated
registration, without starting the controller in the test. Keep the real
registry invariant and dispose the test's contributions after completion.

* fix(desktop): keep the transcript parser and ::ask behind the onboarding flag

Register the guided chat's question card only with onboarding enabled.
Restore main's whole-paragraph parser and contribution rendering when the
flag is off, including its streaming prose behavior. Keep segmentation for
the guided flow until B4 decides the parser's wider use.

Restore main's two parser test files so its existing product and plugin
contracts remain the flag-off check.

* test: drop the onboarding and connector tests pending a later ticket

Apply the director's ruling to remove B1's added test files and restore
main's existing suites. Keep only the gateway route-reader mock contract
that main's profile tests need against the shipped activation behavior;
their cases and assertions stay intact.

The flow's shape is not settled and B3/B4 rewrite it. The connector layer
will also be reworked. The live CDP run is the flow check until a follow-up
ticket brings tests back.

---------

Co-authored-by: brooklyn! <brooklyn.bb.nicholson@gmail.com>
2026-09-11 15:45:43 +05:30

659 lines
33 KiB
Python

"""Profile JSON-RPC handlers — the ws twin of the dashboard's /api/profiles (desktop plugins
only have the ws door), on the same `hermes_cli.profiles` primitives. Bodies are rebound onto
server.py's globals (method_ctx.bind_module) and use them bare; module-level names are published
onto server.py, so they must not collide with its globals.
"""
import contextlib
from .method_ctx import HandlerRegistry, bind_module
_registry = HandlerRegistry()
method = _registry.method
# ext -> mime; iteration order is the on-disk lookup order for assets.
_ASSET_EXTS = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
# ext -> [(start, end, magic bytes)]; format is sniffed, the declared mime is never trusted.
_ASSET_MAGIC = {"png": [(0, 8, b"\x89PNG\r\n\x1a\n")], "jpg": [(0, 3, b"\xff\xd8\xff")],
"webp": [(0, 4, b"RIFF"), (8, 12, b"WEBP")]}
def _profile_handler(name: str, code: int):
"""``@method(name)`` whose body's uncaught exception becomes ``_err(rid, code, str(e))``."""
def deco(fn):
def handler(rid, params: dict) -> dict:
try:
return fn(rid, params)
except Exception as e:
return _err(rid, code, str(e))
return method(name)(handler)
return deco
def _lazy(module, name):
"""Late-bound attribute lookup; ``__import__`` builtin on purpose (rebound bodies see only
server.py globals, not this module's imports)."""
return getattr(__import__(module, fromlist=[name]), name)
def _pin_profile_model(profile_dir, provider, model) -> None:
_lazy("hermes_cli.web_routers.profiles", "_write_profile_model")(profile_dir, provider, model)
def _model_provider_params(params) -> tuple:
return str(params.get("model") or "").strip(), str(params.get("provider") or "").strip()
def _try(fn, default):
"""``fn()`` or ``default`` on any exception (best-effort sections must never fail each other)."""
try:
return fn()
except Exception:
return default
def _best_effort(fn) -> bool:
return _try(lambda: (fn(), True)[1], False)
@contextlib.contextmanager
def _hermes_home_scope(path):
"""Scope config/auth resolution to ``path`` for the block."""
token = set_hermes_home_override(str(path))
try:
yield
finally:
reset_hermes_home_override(token)
def _resolve_profile(rid, params):
"""``(name, profile_dir, err)``; err = 4063 (name required) / 4064 (not found) response."""
name = str(params.get("name") or "").strip()
if not name:
return name, None, _err(rid, 4063, "name required")
from hermes_cli.profiles import get_profile_dir
profile_dir = Path(get_profile_dir(name))
if not profile_dir.is_dir():
return name, None, _err(rid, 4064, f"profile '{name}' not found")
return name, profile_dir, None
def _read_profile_yaml(profile_dir) -> dict:
"""profile.yaml as a mapping; ``{}`` when missing, unreadable, unparseable, or not a mapping."""
def load():
import yaml
meta_path = profile_dir / "profile.yaml"
return (yaml.safe_load(meta_path.read_text(encoding="utf-8")) or {}) if meta_path.is_file() else {}
loaded = _try(load, {})
return loaded if isinstance(loaded, dict) else {}
def _clean_revisions(raw: dict) -> dict:
"""Normalise a ``_ui_meta_revisions`` map: str keys, non-bool ints clamped at 0."""
return {str(k): max(0, int(v)) for k, v in raw.items() if isinstance(v, int) and not isinstance(v, bool)}
def _latest_message_preview(db, session_id):
"""≤80-char excerpt of the NEWEST active user/assistant message, or "" (roster semantics).
Same query shape as ``SessionDB.latest_message_row_id``; keep them in step."""
try:
with db._lock:
row = db._conn.execute(
"SELECT content FROM messages"
" WHERE session_id = ? AND role IN ('user', 'assistant')"
" AND active = 1 AND content IS NOT NULL AND TRIM(content) != ''"
" ORDER BY id DESC LIMIT 1",
(session_id,)).fetchone()
except Exception:
return ""
text = " ".join(str(row[0] or "").split()).strip() if row else ""
return text[:80] + "..." if len(text) > 80 else text
def _resurrect_recoverable_canonical(db, profile_path, session_id):
"""Un-archive an accidentally archived canonical row (judged read-only, written via a
short-lived writable handle); False otherwise.
See #92687.
"""
try:
row = db.get_session(session_id)
if not row or not row.get("archived"):
return False
tip_id = _try(lambda: db.get_compression_tip(session_id), None) or session_id
tip = (_try(lambda: db.get_session(tip_id), None) or row) if tip_id != session_id else row
from hermes_state import SessionDB
from hermes_state_registry import acquire
if (tip.get("end_reason") or "") not in SessionDB.RECOVERABLE_END_REASONS:
return False
wdb = acquire(Path(profile_path) / "state.db")
try:
return bool(wdb.unarchive_recoverable_session(session_id))
finally:
_best_effort(lambda: _lazy("hermes_state_registry", "release_or_close")(wdb))
except Exception:
return False
def _canonical_session_row(db, profile_path):
"""Summary of the profile's canonical "Bot Chat" row (identity is the NAME), or None.
Lineages via ``get_compression_tip`` (NOT the resume walker's unmarked-child fallback);
worker sources count as absent. ``id`` is the registry row, ``resolved_id`` the live tip.
The canonical chat's identity is the NAME: the session titled exactly "Bot Chat" on this profile (core
UNIQUE(title) makes it a registry of at most one row). Complements ``last_session``: that field answers
"what is the newest conversation", this answers "where is the forever-chat" — so a roster row's preview
and its click target describe the same session (hermes-agent#88200) with no client-side pointer
involved.
"""
try:
row = db.get_session_by_title("Bot Chat")
session_id = str((row or {}).get("id") or "").strip()
if not session_id or _denied_source(row):
return None
# Archived = retired (absent), except accidental reaper archives: resurrect those.
# An archived canonical row usually means the user deliberately retired it — report absent. But the
# ws-orphan reaper / older agent cleanup can archive it by accident (#92687): resurrect those. Judge
# recoverability READ-ONLY first so the writable open (20s write-lock patience, the very stall this
# refactor removes from the 5s poll) is paid only in the rare accidental-archive case, then run the
# real predicate through unarchive_recoverable_session on a short-lived writable handle.
if row.get("archived") and not _resurrect_recoverable_canonical(db, profile_path, session_id):
return None
tip = _try(lambda: db.get_compression_tip(session_id), None) or session_id
tip_row = db.get_session(tip) or row
started = row.get("started_at") or 0
return {
"id": session_id, "resolved_id": tip, "root_title": row.get("title") or "",
"title": tip_row.get("title") or "", "preview": _latest_message_preview(db, tip),
"started_at": tip_row.get("started_at") or started,
"last_active": tip_row.get("last_activity_at") or tip_row.get("started_at") or started,
"message_count": tip_row.get("message_count") or 0}
except Exception:
return None
def _latest_profile_session_rows(db):
"""(newest human-facing session, newest worker session); the worker row lets rosters show a
profile as working (workers heartbeat ``last_activity_at`` every ≤60s).
First element mirrors session.list's deny-list (drops ``tool`` sub-agent rows and ``kanban`` dispatcher
workers). Second element is the newest DENIED row — the freshest kanban/tool worker — so roster UIs can
show that a profile is actively working even though worker sessions never surface in conversation lists
(hermes-agent#90268). Workers heartbeat ``last_activity_at`` every ≤60s while running (#72016), so a
live worker's ``last_active`` stays fresh and the client can apply its own liveness window. Best-effort:
any failure (missing state.db, locked db, older schema) degrades to (None, None) rather than failing the
whole profiles.list call.
"""
try:
human = worker = None
for s in db.list_sessions_rich(source=None, limit=20, order_by_last_active=True, compact_rows=True):
title, last_active = s.get("title") or "", s.get("last_active") or s.get("started_at") or 0
if _denied_source(s):
if worker is None:
worker = {"id": s["id"], "source": (s.get("source") or "").strip().lower(),
"title": title, "last_active": last_active}
elif human is None: # rosters want "where the conversation IS": prefer the newest text
human = {"id": s["id"], "title": title,
"preview": _latest_message_preview(db, s["id"]) or s.get("preview") or "",
"started_at": s.get("started_at") or 0, "last_active": last_active,
"message_count": s.get("message_count") or 0}
if human is not None and worker is not None:
break
return human, worker
except Exception:
return None, None
def _profile_session_fields(row, profile_path):
"""Attach last_session / worker_session / canonical_session to a roster row. The DB is a
read-only attach (a writable ``SessionDB()`` waits up to 20s for the write lock + runs DDL
and stalled the 5s roster poll); no/unreadable DB -> every field None (the readers swallow)."""
db_path = Path(profile_path) / "state.db"
db = None
if _try(db_path.exists, False):
db = _try(lambda: _lazy("hermes_state", "SessionDB")(db_path=db_path, read_only=True), None)
try:
row["last_session"], row["worker_session"] = _latest_profile_session_rows(db)
# Resolved server-side on every listing so no client carries a session pointer.
row["canonical_session"] = _canonical_session_row(db, profile_path)
finally:
if db is not None:
_best_effort(db.close)
def _profile_ui_meta_fields(row: dict, profile_dir) -> None:
"""Attach ``ui_meta`` / ``ui_meta_revisions`` / ``has_avatar`` from profile.yaml + assets.
``ui_meta_revisions`` is always present: it feature-detects gateway-owned CAS for a new profile."""
raw_meta = _read_profile_yaml(profile_dir)
ui_meta, revisions = raw_meta.get("ui_meta"), raw_meta.get("_ui_meta_revisions")
# Key order is wire-visible: ui_meta_revisions precedes ui_meta.
row["ui_meta_revisions"] = _try(lambda: _clean_revisions(revisions), {}) if isinstance(revisions, dict) else {}
if isinstance(ui_meta, dict) and ui_meta:
row["ui_meta"] = ui_meta
# Cheap existence flag so rosters skip a get_asset probe per paint.
row["has_avatar"] = _try(lambda: any((profile_dir / "assets" / f"avatar.{e}").is_file() for e in _ASSET_EXTS), False)
@_profile_handler("profiles.list", 5061)
def _(rid, params: dict) -> dict:
"""List Hermes profiles. ``include_sessions`` (default true) adds ``last_session`` /
``worker_session`` / ``canonical_session`` so a roster paints previews without N calls."""
from hermes_cli.profiles import list_profiles
include_sessions = is_truthy_value(params.get("include_sessions", True))
out = []
for p in list_profiles():
row = {"name": p.name, "path": str(p.path), "is_default": bool(p.is_default), "model": p.model,
"provider": p.provider, "description": p.description or "",
"display_name": p.display_name or "", "skill_count": p.skill_count or 0}
if include_sessions:
_profile_session_fields(row, p.path)
_profile_ui_meta_fields(row, Path(str(p.path)))
out.append(row)
# bot_mode_protocol: this backend injects the Bot Mode teammate-messaging protocol into every
# session, so clients must not append it to SOUL.md.
return _ok(rid, {"profiles": out, "bot_mode_protocol": True})
def _mirror_secret(path, launch_home, name: str, wanted) -> bool:
"""Copy the launch ``name`` file into the profile (0600) when it exists and ``wanted(src, dst)``."""
src, dst = launch_home / name, path / name
if not (src.is_file() and wanted(src, dst)):
return False
import shutil
shutil.copy2(src, dst)
with contextlib.suppress(OSError):
os.chmod(str(dst), 0o600)
return True
def _env_has_content(env_path) -> bool:
lines = env_path.read_text(encoding="utf-8", errors="replace").splitlines()
return any(s and not s.startswith("#") for s in map(str.strip, lines))
def _mirror_voice_sections(path) -> bool:
"""Copy stt/tts/voice sections from the launch profile (a fresh profile has only ``model``,
so voice fell back to defaults); True if written."""
try:
from hermes_cli.config import load_config_readonly, read_user_config_raw, save_config
src_cfg = load_config_readonly() or {}
sections = {k: src_cfg[k] for k in ("stt", "tts", "voice") if src_cfg.get(k)}
if not sections:
return False
with _hermes_home_scope(path):
# RAW file: load_config() merges DEFAULT_CONFIG (every section would look present).
dst_cfg = read_user_config_raw() or {}
missing = {k: v for k, v in sections.items() if k not in dst_cfg}
if missing:
save_config({**dst_cfg, **missing})
return bool(missing)
except Exception:
return False
def _inherit_launch_model(path) -> bool:
"""Inherit launch model.provider/default when the new profile has none. Gate on the MODEL
SECTION, not config.yaml existing: voice mirroring creates the file first."""
# Gate on the MODEL SECTION being absent, not on config.yaml existing — earlier mirroring steps (voice
# sections, #85755) legitimately create the file first, and a file-existence gate silently skipped
# inheritance for every non-clone bot ("No inference provider configured" on first message, tester
# report). Clones bring their own model section and stay untouched.
from hermes_cli.config import load_config_readonly, read_user_config_raw
with _hermes_home_scope(path):
dst_model = (read_user_config_raw() or {}).get("model") or {}
if dst_model.get("provider") and dst_model.get("default"):
return False
model_cfg = (load_config_readonly() or {}).get("model") or {}
if not (model_cfg.get("provider") and model_cfg.get("default")):
return False
_pin_profile_model(path, str(model_cfg["provider"]), str(model_cfg["default"]))
return True
def _mirror_launch_credentials(path, params: dict) -> dict:
"""Copy launch .env / auth.json / voice sections into a new profile (best-effort per item).
``share_auth`` reports ``auth: "shared"`` and skips the auth copy; ``mirror_credentials``
false skips everything. ``model_inherited`` is filled in by the caller."""
share_auth = is_truthy_value(params.get("share_auth", False))
mirrored = {"env": False, "auth": "shared" if share_auth else False, "model_inherited": False,
"voice": False}
if not is_truthy_value(params.get("mirror_credentials", True)):
return mirrored
launch_home = get_hermes_home()
# .env: only over the seeded comment-only stub (never a clone's secrets).
mirrored["env"] = _try(lambda: _mirror_secret(path, launch_home, ".env", lambda src, dst: (
_env_has_content(src) and not _try(lambda: _env_has_content(dst), False))), False)
if not share_auth: # a copy forks token state: the first refresh in either store strands the other
mirrored["auth"] = _try(lambda: _mirror_secret(path, launch_home, "auth.json",
lambda src, dst: not dst.exists()), False)
if mirrored["auth"]:
# Drop single-use OAuth grants (first refresh strands every sibling); they read from the
# root grant via the pool fallback. API keys stay.
_best_effort(lambda: _lazy("hermes_cli.auth", "strip_cloned_single_use_oauth_grants")(path))
mirrored["voice"] = _mirror_voice_sections(path)
return mirrored
@method("profiles.create")
def _(rid, params: dict) -> dict:
"""Create a profile (ws twin of POST /api/profiles). Params: ``name``, ``description``,
``clone_from`` (omitted = fresh + bundled skills), ``clone_all``, ``no_skills``, ``soul``,
``model`` + ``provider``, ``share_auth``, ``no_alias``, ``mirror_credentials`` (default true: a bare
``create_profile()`` seeds a comment-only .env and no auth.json = NO provider headless)."""
name = str(params.get("name") or "").strip()
if not name:
return _err(rid, 4061, "name required")
try:
from hermes_cli import profiles as profiles_mod
clone_from = str(params.get("clone_from") or "").strip() or None
clone_all = is_truthy_value(params.get("clone_all", False))
path = profiles_mod.create_profile(
name=name, clone_from=clone_from, clone_all=clone_all,
clone_config=bool(clone_from) and not clone_all,
no_skills=is_truthy_value(params.get("no_skills", False)),
description=str(params.get("description") or "").strip() or None)
except (ValueError, FileExistsError, FileNotFoundError) as e:
return _err(rid, 4062, str(e))
except Exception as e:
return _err(rid, 5062, str(e))
# CLI/REST create flow: bundled skills for fresh profiles, then the alias wrapper.
if not clone_from:
_best_effort(lambda: profiles_mod.seed_profile_skills(path, quiet=True))
if not is_truthy_value(params.get("no_alias", False)):
_best_effort(lambda: profiles_mod.check_alias_collision(name) or profiles_mod.create_wrapper_script(name))
soul = params.get("soul")
soul_written = isinstance(soul, str) and bool(soul.strip()) and _best_effort(
lambda: (path / "SOUL.md").write_text(soul, encoding="utf-8"))
mirrored = _mirror_launch_credentials(path, params)
model, provider = _model_provider_params(params)
model_set = False
if model and provider:
model_set = _best_effort(lambda: _pin_profile_model(path, provider, model))
elif is_truthy_value(params.get("mirror_credentials", True)):
mirrored["model_inherited"] = _try(lambda: _inherit_launch_model(path), False)
return _ok(rid, {"ok": True, "name": name, "path": str(path), "soul_written": soul_written,
"model_set": model_set, "mirrored": mirrored})
def _describe_toolsets(cfg):
"""``(toolsets, pinned_set)`` as the `hermes tools` checklist presents them (the raw registry
leaks platform composites and reports everything enabled without a pin)."""
from hermes_cli.tools_config import (
_get_effective_configurable_toolsets, _get_platform_tools, _toolset_allowed_for_platform)
from toolsets import resolve_toolset
pinned = (cfg.get("tools") if isinstance(cfg.get("tools"), dict) else {}).get("enabled_toolsets")
pinned_set = _clean_names(pinned) if isinstance(pinned, list) else None
platform_enabled = _try(lambda: set(_get_platform_tools(cfg, "cli", include_default_mcp_servers=False)), set())
default_off = _try(lambda: _lazy("hermes_cli.tools_config", "_DEFAULT_OFF_TOOLSETS"), set())
toolsets_out = []
for ts_name, ts_label, ts_desc in _get_effective_configurable_toolsets():
enabled = ts_name in (pinned_set if pinned_set is not None else platform_enabled)
# Default-off integrations (+ opt-in yuanbao) are noise unless already enabled.
if not _toolset_allowed_for_platform(ts_name, "cli") or (
(ts_name in default_off or ts_name == "yuanbao") and not enabled):
continue
toolsets_out.append({"name": ts_name, "label": ts_label, "description": ts_desc or "",
"tool_count": _try(lambda: len(set(resolve_toolset(ts_name))), 0),
"enabled": enabled})
return toolsets_out, pinned_set
@_profile_handler("profiles.describe", 5063)
def _(rid, params: dict) -> dict:
"""Editor snapshot; installed skills are enabled unless in ``skills.disabled``; ``mcp_servers``
is ``[{name, enabled, transport}]`` (best-effort)."""
name, profile_dir, err = _resolve_profile(rid, params)
if err is not None:
return err
with _hermes_home_scope(profile_dir):
from hermes_cli.config import load_config
from hermes_cli.skills_config import get_disabled_skills
cfg = load_config() or {}
disabled = {s.lower() for s in get_disabled_skills(cfg)}
skills_root = profile_dir / "skills"
installed = [
{"name": md.parent.name, "enabled": md.parent.name.lower() not in disabled}
for md in (sorted(skills_root.rglob("SKILL.md")) if skills_root.is_dir() else ())]
toolsets_out, pinned_set = _describe_toolsets(cfg)
soul_path = profile_dir / "SOUL.md"
soul = _try(lambda: soul_path.read_text(encoding="utf-8", errors="replace") if soul_path.is_file() else "", "")
mcp_cfg = cfg.get("mcp_servers")
mcp_out = _try(lambda: [
{"name": str(srv_name), "enabled": not is_truthy_value(entry.get("disabled", False)),
"transport": str(entry.get("transport") or "http") if entry.get("url") else "stdio"}
for srv_name in sorted(mcp_cfg.keys()) for entry in (mcp_cfg[srv_name],)
if isinstance(entry, dict)
], []) if isinstance(mcp_cfg, dict) else []
model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {}
meta = _try(lambda: _lazy("hermes_cli.profiles", "read_profile_meta")(profile_dir), {})
return _ok(rid, {
"name": name, "description": str(meta.get("description") or ""), "soul": soul,
"model": {"provider": str(model_cfg.get("provider") or ""),
"default": str(model_cfg.get("default") or "")},
"skills": installed, "toolsets": toolsets_out,
"toolsets_pinned": pinned_set is not None, "mcp_servers": mcp_out})
def _configure_ui_meta(profile_dir, params, applied) -> None:
"""Merge ``params["ui_meta"]`` key-wise into profile.yaml (None deletes). 64KB cap (rides
every roster paint). ``ui_meta_expected_revisions``: per-key CAS, any mismatch rejects the
whole write; revisions survive deletion so a stale client cannot recreate a removed key."""
applied["ui_meta"] = False
try:
incoming = params["ui_meta"]
if len(json.dumps(incoming)) > 65536:
return
expected = params.get("ui_meta_expected_revisions")
if expected is not None and not isinstance(expected, dict):
raise ValueError("ui_meta_expected_revisions must be an object")
with _profile_ui_meta_lock:
existing = _read_profile_yaml(profile_dir)
raw_revisions = existing.get("_ui_meta_revisions")
revisions = _clean_revisions(raw_revisions if isinstance(raw_revisions, dict) else {})
conflicts = {}
for key in incoming if isinstance(expected, dict) else ():
wanted, actual = expected.get(key), revisions.get(key, 0)
if not isinstance(wanted, int) or isinstance(wanted, bool) or wanted < 0 or wanted != actual:
conflicts[key] = {"expected": wanted, "actual": actual}
if conflicts:
applied["ui_meta_conflicts"] = conflicts
applied["ui_meta_revisions"] = {key: revisions.get(key, 0) for key in incoming}
return
current = existing.get("ui_meta")
current = current if isinstance(current, dict) else {}
for key, value in incoming.items():
if value is None:
current.pop(key, None)
else:
current[key] = value
revisions[key] = revisions.get(key, 0) + 1
if current:
existing["ui_meta"] = current
else:
existing.pop("ui_meta", None)
existing["_ui_meta_revisions"] = revisions
from utils import atomic_yaml_write
atomic_yaml_write(profile_dir / "profile.yaml", existing, sort_keys=False)
applied["ui_meta"] = True
applied["ui_meta_revisions"] = {key: revisions[key] for key in incoming}
except Exception:
applied["ui_meta"] = False
def _configure_model(profile_dir, params, applied):
"""Apply a ``model`` + ``provider`` pin, or return a confirm message and write NOTHING (client
resends with ``confirm_expensive_model``). A failing guard = no warning (as _apply_model_switch)."""
model, provider = _model_provider_params(params)
if not (model and provider):
return None
confirm_message = None
# #95293 remainder: this is the Bots editor's model-switch path, and it used to write guarded
# (data-policy / expensive) models silently — the ONE surface that bypassed the selection guard every
# other switch path enforces. Same handshake contract as ``config.set model``: without
# ``confirm_expensive_model`` a guarded pick answers ``confirm_required`` + ``confirm_message`` and
# writes NOTHING; the client resends with ``confirm_expensive_model: true`` once the user confirms. A
# misbehaving guard must never break the save (treated as "no warning"), matching
# ``_apply_model_switch``.
if not is_truthy_value(params.get("confirm_expensive_model", False)):
warn = _lazy("hermes_cli.model_selection_guards", "combined_selection_warning")
confirm_message = _try(lambda: getattr(warn(model, provider=provider or None), "message", None), None)
if confirm_message is None:
applied["model"] = _best_effort(lambda: _pin_profile_model(profile_dir, provider, model))
return confirm_message
def _clean_names(values) -> set:
return {str(v).strip() for v in values if str(v).strip()}
def _save_toolset_pin(cfg, enabled, save_config) -> None:
wanted = sorted(_clean_names(enabled))
tools_cfg = cfg.get("tools") if isinstance(cfg.get("tools"), dict) else {}
if wanted:
tools_cfg["enabled_toolsets"] = wanted
else:
tools_cfg.pop("enabled_toolsets", None)
cfg["tools"] = tools_cfg
save_config(cfg)
def _save_mcp_toggles(cfg, enabled, launch_mcp, save_config) -> None:
wanted = _clean_names(enabled)
mcp_cfg = cfg.get("mcp_servers") if isinstance(cfg.get("mcp_servers"), dict) else {}
for srv in wanted:
if not isinstance(mcp_cfg.get(srv), dict) and isinstance(launch_mcp.get(srv), dict):
mcp_cfg[srv] = dict(launch_mcp[srv])
if isinstance(mcp_cfg.get(srv), dict):
mcp_cfg[srv].pop("disabled", None)
for srv, entry in mcp_cfg.items():
if srv not in wanted and isinstance(entry, dict):
entry["disabled"] = True
if mcp_cfg:
cfg["mcp_servers"] = mcp_cfg
save_config(cfg)
def _configure_cfg_sections(profile_dir, params, applied) -> None:
"""Apply ``disabled_skills`` / ``enabled_toolsets`` / ``enabled_mcp_servers`` (replace
semantics; empty toolsets clears the pin). An undefined MCP server is copied from the LAUNCH
catalog (unknown names skipped); credentials stay in .env/auth."""
want_mcp = isinstance(params.get("enabled_mcp_servers"), list)
launch_mcp = {}
if want_mcp: # launch catalog read BEFORE the home override flips config resolution
load_launch = _lazy("hermes_cli.config", "load_config_readonly")
launch_mcp = _try(lambda: (load_launch() or {}).get("mcp_servers"), {})
launch_mcp = launch_mcp if isinstance(launch_mcp, dict) else {}
with _hermes_home_scope(profile_dir):
from hermes_cli.config import load_config, save_config
cfg = load_config() or {}
if isinstance(params.get("disabled_skills"), list):
try:
from hermes_cli.skills_config import save_disabled_skills
save_disabled_skills(cfg, _clean_names(params["disabled_skills"]))
applied["skills"] = True
cfg = load_config() or {}
except Exception:
applied["skills"] = False
if isinstance(params.get("enabled_toolsets"), list):
applied["toolsets"] = _best_effort(lambda: _save_toolset_pin(cfg, params["enabled_toolsets"], save_config))
if want_mcp:
applied["mcp_servers"] = _best_effort(lambda: _save_mcp_toggles(
load_config() or {}, params["enabled_mcp_servers"], launch_mcp, save_config))
@_profile_handler("profiles.configure", 5064)
def _(rid, params: dict) -> dict:
"""Editor Save: ``name`` plus any of ``ui_meta`` (+ ``ui_meta_expected_revisions``), ``soul``,
``description``, ``model`` + ``provider`` (+ ``confirm_expensive_model``), ``disabled_skills``,
``enabled_toolsets``, ``enabled_mcp_servers``; sections are independent, ``applied`` reports each."""
_name, profile_dir, err = _resolve_profile(rid, params)
if err is not None:
return err
applied = {}
if isinstance(params.get("ui_meta"), dict):
_configure_ui_meta(profile_dir, params, applied)
if isinstance(params.get("soul"), str):
applied["soul"] = _best_effort(lambda: (profile_dir / "SOUL.md").write_text(params["soul"], encoding="utf-8"))
if isinstance(params.get("description"), str):
write_meta = _lazy("hermes_cli.profiles", "write_profile_meta")
applied["description"] = _best_effort(lambda: write_meta(
profile_dir, description=params["description"].strip(), description_auto=False))
confirm_message = _configure_model(profile_dir, params, applied)
if any(isinstance(params.get(k), list) for k in ("disabled_skills", "enabled_toolsets", "enabled_mcp_servers")):
_configure_cfg_sections(profile_dir, params, applied)
# confirm_* is the shape config.set returns, so clients reuse one confirm handler.
return _ok(rid, {"ok": all(applied.values()) if applied else True, "applied": applied,
**({"confirm_required": True, "confirm_message": confirm_message}
if confirm_message is not None else {})})
def _unlink_asset_files(assets_dir, asset) -> int:
"""Delete every ``<asset>.<ext>`` in ``assets_dir``; returns how many existed."""
present = [t for t in (assets_dir / f"{asset}.{ext}" for ext in _ASSET_EXTS) if t.is_file()]
return len([t.unlink() for t in present])
@_profile_handler("profiles.set_asset", 5065)
def _(rid, params: dict) -> dict:
"""Store ``assets/<asset>.<ext>`` atomically. Params: ``name``, ``asset`` (``"avatar"`` only),
``data`` (data URL or base64; PNG/JPEG/WebP ≤2MB, format sniffed) or ``clear: true``."""
asset = str(params.get("asset") or "avatar").strip().lower()
if not str(params.get("name") or "").strip():
return _err(rid, 4063, "name required")
if asset != "avatar":
return _err(rid, 4066, f"unknown asset '{asset}' (supported: avatar)")
import base64
import re
_name, profile_dir, err = _resolve_profile(rid, params)
if err is not None:
return err
assets_dir = profile_dir / "assets"
if is_truthy_value(params.get("clear", False)):
return _ok(rid, {"ok": True, "asset": asset, "size": 0, "removed": _unlink_asset_files(assets_dir, asset)})
data = str(params.get("data") or "")
if not data:
return _err(rid, 4067, "data required (data URL or base64)")
match = re.match(r"^data:(image/(?:png|jpeg|webp));base64,(.*)$", data, re.DOTALL)
try:
blob = base64.b64decode(match.group(2) if match else data, validate=True)
except Exception:
return _err(rid, 4068, "data is not valid base64")
if len(blob) > 2_000_000:
return _err(rid, 4069, f"asset too large ({len(blob)} bytes; max 2MB)")
ext = next((e for e, magic in _ASSET_MAGIC.items() if all(blob[a:b] == m for a, b, m in magic)), None)
if ext is None:
return _err(rid, 4070, "unsupported image format (PNG/JPEG/WebP only)")
assets_dir.mkdir(parents=True, exist_ok=True)
_unlink_asset_files(assets_dir, asset) # one canonical file per asset
tmp = assets_dir / f"{asset}.{ext}.tmp"
tmp.write_bytes(blob)
tmp.replace(assets_dir / f"{asset}.{ext}")
return _ok(rid, {"ok": True, "asset": asset, "size": len(blob)})
@_profile_handler("profiles.get_asset", 5066)
def _(rid, params: dict) -> dict:
"""Profile asset as a data URL; absent is ``found: false``, not an error."""
asset = str(params.get("asset") or "avatar").strip().lower()
import base64
_name, profile_dir, err = _resolve_profile(rid, params)
if err is not None:
return err
for ext, mime in _ASSET_EXTS.items():
target = profile_dir / "assets" / f"{asset}.{ext}"
if target.is_file():
blob = target.read_bytes()
return _ok(rid, {"found": True, "mime": mime, "size": len(blob),
"data": f"data:{mime};base64,{base64.b64encode(blob).decode('ascii')}"})
return _ok(rid, {"found": False})
@_profile_handler("profiles.remember_onboarding", 5067)
def _(rid, params: dict) -> dict:
from tui_gateway.onboarding_personalization import remember_onboarding
return _ok(rid, remember_onboarding(params.get("answers")))
def register(server) -> None:
bind_module(globals(), server)