The setup chat no longer scans the computer. The collector, its host_facts.py
hook, the inline-shell setting on the setup profile, the ${HERMES_PYTHON}
skill token and the insights/ cache are gone.
The first /initiate-setup turn now sends the skill plus one JSON block that the
model reads as it is: machine basics (OS, release, architecture, CPU, RAM, GPU
class, vendor, WSL, container), machine_kind, has_nvidia_gpu, is_spark,
suggested_name and locale, beside the session keys. A key that was not
measured is left out.
The non-scan parts of host_facts.py (account name, locale, Spark detection,
the fork card, the NVIDIA and Blender tasks, the handoff text) move to
agent/initiate_setup_facts.py and are imported normally instead of loaded by
file path.
210 lines
8.6 KiB
Python
210 lines
8.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import random
|
|
import shutil
|
|
from datetime import datetime, timezone
|
|
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, profile_name_for_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}
|
|
_CARDS_DIR = "setup-cards"
|
|
_SETUP_TOOLSETS = ["setup", "start_chat", "connections", "no_mcp"]
|
|
_SETUP_DISABLED_TOOLSETS = ["project", "catalog"]
|
|
_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.",
|
|
"- Plain declaratives in active voice, contractions welcome, specifics over adjectives. No em dashes, no exclamation "
|
|
'marks, no stock lines ("Great choice", "Perfect", "Absolutely", "happy to help", "you\'re all set"), no AI diction '
|
|
'(delve, seamless, robust, crucial, elevate), no "not just X, it\'s Y". Do not announce what you are about to do. '
|
|
"Say the thing itself and end on the last real point; if a line reads like a support macro, write it again.",
|
|
])
|
|
|
|
|
|
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 primary_profile(launch_home: Path) -> str:
|
|
"""The profile setup hands off to: the launch profile from the setup profile's home, else this one."""
|
|
home = get_hermes_home()
|
|
if (home / profiles_mod.SETUP_PROFILE_MARKER).is_file():
|
|
home = launch_home
|
|
return profile_name_for_home(home) or "default"
|
|
|
|
|
|
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_intro_seen() -> dict:
|
|
return _change_state(lambda state: {**state, "intro": "seen"})
|
|
|
|
|
|
def mark_completed() -> dict:
|
|
completed_at = datetime.now(timezone.utc).isoformat()
|
|
return _change_state(lambda state: {**state, "intro": "seen", "completed_at": completed_at})
|
|
|
|
|
|
def _cards_path(session_id: str) -> Path:
|
|
return get_hermes_home() / _CARDS_DIR / f"{session_id}.json"
|
|
|
|
|
|
def record_cards(session_id: str, cards: dict) -> None:
|
|
"""Keep one setup conversation's card facts: what its ``/initiate-setup`` turn embedded, and where it is."""
|
|
from utils import atomic_json_write
|
|
atomic_json_write(_cards_path(session_id), cards)
|
|
|
|
|
|
def read_cards(session_id: str) -> dict:
|
|
from utils import read_json_or_empty
|
|
return read_json_or_empty(_cards_path(session_id))
|
|
|
|
|
|
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", "reasoning_effort": "low",
|
|
"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}
|
|
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)
|