Files
hermes-agent/hermes_cli/setup_profile.py
alt-glitch cdb957e336 fix(onboarding/next): skill docs, the metrics schema and a BOM-safe read catch up with the merged waves
The merged Wave A and skill PRs landed without CI: the generated skill docs did not list initiate-setup, the shared-metrics v3 schema lacked the start_chat toolset, and the setup-profile marker read was BOM-intolerant on Windows.
2026-09-30 12:42:06 +05:30

177 lines
7.1 KiB
Python

from __future__ import annotations
import json
import logging
import os
import random
import shutil
from pathlib import Path
from typing import Callable, NamedTuple, Optional
from hermes_cli import profiles as profiles_mod
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
SETUP_PROFILE_NAME = "hermes-setup"
SETUP_PROFILE_DESCRIPTION = "Where Hermes met you — walks your first run, then checks in as you find your feet."
SETUP_CHAT_TITLE = "Welcome to Hermes"
MAX_FAILED_STARTS = 3
_FRESH_STATE = {"intro": "unseen", "failed_starts": 0}
_SETUP_TOOLSETS = ["setup", "start_chat", "no_mcp"]
_SETUP_DISABLED_TOOLSETS = ["project"]
_SETUP_DEFERRED_TOOLS = [
"computer_use", "session_search", "image_generate", "todo_list", "process_manage", "cronjob_manage",
"drive_preview", "desktop_preview", "annotate_preview", "show_tip", "desktop_project",
"close_terminal", "read_terminal", "read_window_below", "focus_pane", "react_to_message",
]
SETUP_SOUL = "\n".join([
"# Hermes",
"",
"You are Hermes, and this profile is where you met this user for the first time and stay reachable afterwards. "
"You are the person at the front desk of somewhere good: pleased they came in, and not performing it. Quick, "
"unhurried, never flustered, never in the way. You showed them around on their first run and you keep a loose eye "
"on how they are getting on.",
"",
'- Never introduce yourself as "Setup", "the setup assistant", or "the onboarding guide". You are Hermes.',
"- Warmth is in paying attention, not in adjectives. Remember what they told you and use it. Do not thank them for "
"answering, do not praise their choices, do not ask if they are ready.",
'- Offer an opinion lightly when you have one. "Most people wire that one up first" is worth more than a neutral '
"menu.",
"- You are training wheels: useful early, ignorable later. Never guilt-trip, never nag. If the user asks you to "
"stop checking in, stop.",
"- When you check in, look at what has actually changed (their sessions, connectors, scheduled jobs) before "
"offering anything. One concrete suggestion beats a menu.",
"- Things worth offering, roughly in order: wiring a connector they said they use, scheduling something they do "
"repeatedly, a second build based on the first, keyboard/layout niceties.",
"- Write like a person talking to another person. Short sentences, plain words, no headers, no bullet walls, no "
"emoji.",
])
class SetupProfile(NamedTuple):
name: str
path: Path
created: bool
def find_setup_profile() -> Optional[tuple[str, Path]]:
found = [(p.name, Path(p.path)) for p in profiles_mod.list_profiles(lazy_skill_count=True)
if (Path(p.path) / profiles_mod.SETUP_PROFILE_MARKER).is_file()]
if len(found) > 1:
logger.warning("several profiles carry the setup marker (%s); using %s",
", ".join(name for name, _ in found), found[0][0])
return found[0] if found else None
def ensure_setup_profile() -> SetupProfile:
found = find_setup_profile()
if found is not None:
return SetupProfile(found[0], found[1], created=False)
name = _free_setup_profile_name()
path = profiles_mod.create_profile(name, clone_config=True, no_alias=True, description=SETUP_PROFILE_DESCRIPTION)
try:
_write_soul(path)
_replace_dir(path / "memories")
_write_setup_config(path)
_write_state(path, _FRESH_STATE)
except BaseException:
profiles_mod.delete_profile(name, yes=True)
raise
return SetupProfile(name, path, created=True)
def reset_setup_profile() -> SetupProfile:
found = find_setup_profile()
if found is None:
raise LookupError("no setup profile to reset")
name, path = found
source = get_hermes_home()
_write_soul(path)
_replace_dir(path / "memories")
_write_setup_config(path)
_replace_dir(path / "skills")
if (source / "skills").is_dir():
profiles_mod._copytree_keep_junctions(source / "skills", path / "skills",
profiles_mod._non_exportable_entries, dirs_exist_ok=True)
_write_state(path, _FRESH_STATE)
return SetupProfile(name, path, created=False)
def onboarding_eligible() -> bool:
from hermes_cli.anon_auth import GUEST_ONBOARDING_ENV
return os.environ.get(GUEST_ONBOARDING_ENV, "").strip() == "1"
def read_state() -> dict:
found = find_setup_profile()
return dict(_FRESH_STATE) if found is None else _read_state(found[1])
def record_failed_start() -> dict:
def change(state: dict) -> dict:
failed = min(state["failed_starts"] + 1, MAX_FAILED_STARTS)
return {**state, "failed_starts": failed,
"intro": "seen" if failed == MAX_FAILED_STARTS else state["intro"]}
return _change_state(change)
def mark_seen() -> dict:
return _change_state(lambda state: {**state, "intro": "seen"})
def _free_setup_profile_name() -> str:
from hermes_cli.dashboard_register import _NAME_NOUNS
name = SETUP_PROFILE_NAME
while profiles_mod.get_profile_dir(name).exists():
name = f"{SETUP_PROFILE_NAME}-{random.choice(_NAME_NOUNS)}"
return name
def _change_state(change: Callable[[dict], dict]) -> dict:
found = find_setup_profile()
if found is None:
return dict(_FRESH_STATE)
state = change(_read_state(found[1]))
_write_state(found[1], state)
return state
def _read_state(path: Path) -> dict:
return json.loads((path / profiles_mod.SETUP_PROFILE_MARKER).read_text(encoding="utf-8-sig"))
def _write_state(path: Path, state: dict) -> None:
from utils import atomic_json_write
atomic_json_write(path / profiles_mod.SETUP_PROFILE_MARKER, state)
def _write_setup_config(path: Path) -> None:
from agent.skill_utils import parse_config_string_list
from hermes_cli.config import atomic_config_write, read_user_config_raw
config_path = path / "config.yaml"
config = read_user_config_raw(config_path)
agent = config.get("agent") or {}
disabled = parse_config_string_list(agent.get("disabled_toolsets"))
config["agent"] = {**agent, "coding_context": "off",
"disabled_toolsets": list(dict.fromkeys([*disabled, *_SETUP_DISABLED_TOOLSETS]))}
config["platform_toolsets"] = {**(config.get("platform_toolsets") or {}), "cli": list(_SETUP_TOOLSETS)}
config["tools"] = {**(config.get("tools") or {}), "tool_search": {"defer": list(_SETUP_DEFERRED_TOOLS)}}
config["display"] = {**(config.get("display") or {}), "show_reasoning": False}
config["skills"] = {**(config.get("skills") or {}), "inline_shell": True, "inline_shell_timeout": 15}
atomic_config_write(config_path, config)
def _write_soul(path: Path) -> None:
from utils import atomic_write_bytes
atomic_write_bytes(path / "SOUL.md", SETUP_SOUL.encode("utf-8"))
def _replace_dir(directory: Path) -> None:
if directory.is_symlink() or profiles_mod._junction_target(str(directory)) is not None:
directory.unlink() if directory.is_symlink() else directory.rmdir()
elif directory.exists():
shutil.rmtree(directory)
directory.mkdir(parents=True)