From 85440ed2a35ecee9b262eddb2534476392e9735d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:11:45 -0700 Subject: [PATCH] =?UTF-8?q?Inspired=20by=20Energy:=20assistant=20presets?= =?UTF-8?q?=20=E2=80=94=20one-command=20role=20profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Energy (getenergy.com) ships one-click specialized assistants (Inbox Zero, Research Scout, ...) instead of making users hand-assemble memory, skills, and automations per role. Hermes has every underlying piece — profiles, SOUL.md personas, kanban-routable descriptions, Automation Blueprints — but composing a role took four manual steps. - hermes_cli/assistant_presets.py: curated preset catalog (persona + description + suggested blueprint automations); no new object type, storage, or scheduler — applies through existing profile files and fill_blueprint -> create_job inside the new profile's HERMES_HOME - hermes profile create --preset [--with-automations] - hermes profile presets: catalog listing - preset key validated before any directory is created - preset-seeded jobs drop 'origin' delivery (no chat origin yet) and fall back to local - tests: 11 new (catalog contract vs blueprint catalog, file application, create_profile integration, unknown-key atomicity) - docs: user-guide/profiles.md section --- hermes_cli/assistant_presets.py | 332 +++++++++++++++++++++ hermes_cli/main.py | 28 ++ hermes_cli/profiles.py | 31 ++ hermes_cli/subcommands/profile.py | 18 ++ tests/hermes_cli/test_assistant_presets.py | 110 +++++++ website/docs/user-guide/profiles.md | 14 + 6 files changed, 533 insertions(+) create mode 100644 hermes_cli/assistant_presets.py create mode 100644 tests/hermes_cli/test_assistant_presets.py diff --git a/hermes_cli/assistant_presets.py b/hermes_cli/assistant_presets.py new file mode 100644 index 0000000000000..ff24ed17e747c --- /dev/null +++ b/hermes_cli/assistant_presets.py @@ -0,0 +1,332 @@ +"""Assistant presets — one-command role profiles. + +Inspired by Energy (getenergy.com), whose desktop app ships one-click +specialized assistants ("Inbox Zero", "Research Scout", …) instead of making +users hand-assemble memory, skills, and automations per role. Hermes already +has every underlying piece — profiles (isolated HERMES_HOME), SOUL.md +personas, profile descriptions (used by the kanban decomposer for routing), +and the Automation Blueprints catalog — but until now composing them into a +role took four manual steps. A preset is a curated bundle of those existing +pieces: + + * a persona (written to the new profile's ``SOUL.md``) + * a profile description (routable by the kanban orchestrator) + * suggested automations, expressed as (blueprint_key, slot_values) pairs + against the existing ``cron.blueprint_catalog`` — no second job engine. + +Per the dev guide's "Extend, Don't Duplicate" rule there is NO new object +type, storage, or scheduler here: applying a preset just writes files the +profile system already owns and (optionally) creates ordinary cron jobs via +``fill_blueprint`` -> ``create_job`` inside the new profile's HERMES_HOME. + +Usage surface: + + hermes profile presets list the catalog + hermes profile create mail --preset inbox-zero + hermes profile create mail --preset inbox-zero --with-automations +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +__all__ = [ + "AssistantPreset", + "PRESETS", + "get_preset", + "preset_keys", + "apply_preset_files", + "seed_preset_automations", + "format_preset_catalog", + "suggested_automation_commands", +] + + +@dataclass(frozen=True) +class AssistantPreset: + """A curated role bundle applied at ``hermes profile create`` time.""" + + key: str + title: str + tagline: str # one line for the catalog listing + description: str # profile description (kanban-routable) + soul: str # SOUL.md persona text + # (blueprint_key, slot_values) pairs against cron.blueprint_catalog.CATALOG. + # Empty values dict = use the blueprint's defaults. + automations: Tuple[Tuple[str, Dict[str, Any]], ...] = field(default_factory=tuple) + + +def _soul(role: str, mission: str, principles: List[str]) -> str: + """Render a consistent SOUL.md persona for a preset role.""" + lines = [ + f"You are {role}, a specialist Hermes assistant. {mission}", + "", + "Operating principles:", + ] + lines += [f"- {p}" for p in principles] + lines += [ + "", + "You are one of several role assistants the user may run side by side; " + "stay in your lane, and when a request is clearly another role's job, " + "say so briefly and still help if asked.", + ] + return "\n".join(lines) + + +PRESETS: List[AssistantPreset] = [ + AssistantPreset( + key="research-scout", + title="Research Scout", + tagline="Finds what matters and distills it", + description=( + "Research specialist: web research, source-grounded digests, " + "competitive scans, and summarization. Route research, " + "fact-finding, and monitoring tasks here." + ), + soul=_soul( + "Research Scout", + "Your job is finding what matters and bringing it back distilled.", + [ + "Search broadly, then cut ruthlessly — deliver the three " + "things worth knowing, not everything you found.", + "Always keep links to primary sources; never launder a claim " + "without its origin.", + "Dedupe against what you already reported; only genuinely " + "new developments count.", + "State confidence honestly — a clearly-flagged rumor beats a " + "false certainty.", + ], + ), + automations=(("news-digest", {}),), + ), + AssistantPreset( + key="inbox-zero", + title="Inbox Zero", + tagline="Clears the queue", + description=( + "Email specialist: inbox triage, urgent-mail surfacing, drafting " + "replies, and unsubscribe hygiene. Route email and " + "communications tasks here." + ), + soul=_soul( + "Inbox Zero", + "Your job is keeping the user's inbox from owning their day.", + [ + "Surface only mail that actually needs the user; everything " + "else gets summarized in one line or not at all.", + "Draft replies in the user's voice, ready to send — but never " + "send without explicit approval.", + "Be aggressive about noise: recurring newsletters and " + "notification spam are candidates for unsubscribe suggestions.", + "When triaging, lead with the single most urgent item.", + ], + ), + automations=(("important-mail", {}),), + ), + AssistantPreset( + key="project-captain", + title="Project Captain", + tagline="Keeps work on track", + description=( + "Project coordination specialist: status tracking, priorities, " + "weekly reviews, and follow-ups. Route planning, coordination, " + "and progress-tracking tasks here." + ), + soul=_soul( + "Project Captain", + "Your job is keeping work moving and nothing falling through the cracks.", + [ + "Every check-in ends with owners and next actions, not vibes.", + "Distinguish blocked from stalled from done; chase the " + "blocked ones first.", + "Keep status updates short enough to read standing up.", + "When priorities conflict, present the trade-off in two " + "lines and ask for a call.", + ], + ), + automations=(("workday-start", {}), ("weekly-review", {})), + ), + AssistantPreset( + key="finance-keeper", + title="Finance Keeper", + tagline="Watches every number", + description=( + "Finance specialist: bills, renewals, budgets, spreadsheets, and " + "spending summaries. Route financial tracking and " + "number-crunching tasks here." + ), + soul=_soul( + "Finance Keeper", + "Your job is making sure no number surprises the user.", + [ + "Flag renewals and charges BEFORE they hit, framed as an " + "action (review / cancel / let it ride), not a notification.", + "Show your arithmetic; a total without its breakdown is a " + "claim, not an answer.", + "Round for readability, keep precision in the working.", + "Never move money or commit to a purchase — prepare the " + "action and hand it to the user.", + ], + ), + automations=(("bill-renewal-watch", {}),), + ), + AssistantPreset( + key="sales-pilot", + title="Sales Pilot", + tagline="Moves deals forward", + description=( + "Sales and outreach specialist: prospect research, follow-up " + "drafting, pipeline nudges, and meeting prep. Route outreach and " + "deal-related tasks here." + ), + soul=_soul( + "Sales Pilot", + "Your job is moving conversations toward closed, without being pushy.", + [ + "Every touch has a purpose the recipient can see; no " + "'just checking in' filler.", + "Research before outreach — reference something true and " + "recent about the prospect.", + "Track the next step for every open thread; a deal without " + "a next step is a dead deal.", + "Drafts are the deliverable: crisp, short, in the user's " + "voice, ready to send after approval.", + ], + ), + automations=(("weekly-review", {"day": "friday"}),), + ), +] + +_PRESETS_BY_KEY = {p.key: p for p in PRESETS} + + +def get_preset(key: str) -> Optional[AssistantPreset]: + return _PRESETS_BY_KEY.get((key or "").strip().lower()) + + +def preset_keys() -> List[str]: + return [p.key for p in PRESETS] + + +# --------------------------------------------------------------------------- +# Application +# --------------------------------------------------------------------------- + +def apply_preset_files(profile_dir: Path, preset: AssistantPreset) -> None: + """Write the preset's SOUL.md + profile description into ``profile_dir``. + + Called from ``create_profile`` after directory bootstrap. Overwrites the + default seeded SOUL.md (the preset IS the requested persona) but never + raises — profile creation must not fail over persona cosmetics. + """ + try: + (profile_dir / "SOUL.md").write_text(preset.soul + "\n", encoding="utf-8") + except OSError as e: + logger.warning("preset %s: could not write SOUL.md: %s", preset.key, e) + try: + from hermes_cli.profiles import write_profile_meta + + write_profile_meta( + profile_dir, + description=preset.description, + description_auto=False, + ) + except Exception as e: + logger.warning("preset %s: could not write description: %s", preset.key, e) + + +def seed_preset_automations( + profile_dir: Path, preset: AssistantPreset, quiet: bool = False +) -> List[str]: + """Create the preset's suggested automations inside the new profile. + + Runs in a subprocess with ``HERMES_HOME`` pointed at the profile (the same + isolation pattern as ``seed_profile_skills``) so the jobs land in the + profile's own cron store, not the invoking profile's. Returns the names of + the jobs created. + """ + if not preset.automations: + return [] + project_root = Path(__file__).parent.parent.resolve() + payload = json.dumps([[key, values] for key, values in preset.automations]) + script = ( + "import json, sys\n" + "from cron.blueprint_catalog import get_blueprint, fill_blueprint\n" + "from cron.jobs import create_job\n" + "created = []\n" + "for key, values in json.loads(sys.argv[1]):\n" + " bp = get_blueprint(key)\n" + " if bp is None:\n" + " continue\n" + " spec = fill_blueprint(bp, values)\n" + " # Preset-seeded jobs have no chat origin; let create_job fall back\n" + " # to local delivery instead of a dangling 'origin' target.\n" + " if spec.get('deliver') == 'origin' and not spec.get('origin'):\n" + " spec.pop('deliver')\n" + " job = create_job(**spec)\n" + " created.append(job.get('name') or key)\n" + "print(json.dumps(created))\n" + ) + try: + result = subprocess.run( + [sys.executable, "-c", script, payload], + env={**os.environ, "HERMES_HOME": str(profile_dir)}, + cwd=str(project_root), + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=60, + ) + if result.returncode == 0 and result.stdout.strip(): + return json.loads(result.stdout.strip().splitlines()[-1]) + if not quiet: + print(f"⚠ Preset automations returned exit code {result.returncode}") + if result.stderr.strip(): + print(f" {result.stderr.strip()[:200]}") + except subprocess.TimeoutExpired: + if not quiet: + print("⚠ Preset automation seeding timed out (60s)") + except Exception as e: + if not quiet: + print(f"⚠ Preset automation seeding failed: {e}") + return [] + + +# --------------------------------------------------------------------------- +# Display helpers +# --------------------------------------------------------------------------- + +def suggested_automation_commands(preset: AssistantPreset, profile_name: str) -> List[str]: + """Ready-to-paste commands for the preset's automations (when not auto-seeded).""" + from cron.blueprint_catalog import blueprint_slash_command, get_blueprint + + cmds: List[str] = [] + for key, values in preset.automations: + bp = get_blueprint(key) + if bp is None: + continue + cmds.append(f"hermes -p {profile_name} chat → {blueprint_slash_command(bp, values)}") + return cmds + + +def format_preset_catalog() -> str: + """Human-readable catalog for ``hermes profile presets``.""" + lines = ["Assistant presets — one-command role profiles:", ""] + for p in PRESETS: + lines.append(f" {p.key:<16} {p.title} — {p.tagline}") + if p.automations: + names = ", ".join(key for key, _ in p.automations) + lines.append(f" {'':<16} automations: {names}") + lines += [ + "", + "Create one: hermes profile create --preset ", + "Add its automations too: --with-automations", + ] + return "\n".join(lines) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e6612636da75e..2da83f1d02623 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9342,6 +9342,13 @@ def cmd_profile(args): print(f"Error: {e}") sys.exit(1) + elif action == "presets": + from hermes_cli.assistant_presets import format_preset_catalog + + print() + print(format_preset_catalog()) + print() + elif action == "create": name = args.profile_name clone = getattr(args, "clone", False) @@ -9352,6 +9359,7 @@ def cmd_profile(args): try: clone_from = getattr(args, "clone_from", None) clone_config = clone or clone_from is not None + preset_key = getattr(args, "preset", None) profile_dir = create_profile( name=name, @@ -9361,9 +9369,29 @@ def cmd_profile(args): no_alias=no_alias, no_skills=no_skills, description=getattr(args, "description", None), + preset=preset_key, ) print(f"\nProfile '{name}' created at {profile_dir}") + if preset_key: + from hermes_cli.assistant_presets import ( + get_preset, + seed_preset_automations, + suggested_automation_commands, + ) + + preset_obj = get_preset(preset_key) + if preset_obj is not None: + print(f"Preset applied: {preset_obj.title} — {preset_obj.tagline}") + if getattr(args, "with_automations", False): + created = seed_preset_automations(profile_dir, preset_obj) + for job_name in created: + print(f" Automation created: {job_name}") + elif preset_obj.automations: + print(" Suggested automations (add with --with-automations, or later):") + for cmd in suggested_automation_commands(preset_obj, name): + print(f" {cmd}") + if clone_config or clone_all: source_label = ( getattr(args, "clone_from", None) or get_active_profile_name() diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index c0cf2a9e8a24c..81a74011c2169 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -1003,6 +1003,7 @@ def create_profile( no_alias: bool = False, no_skills: bool = False, description: Optional[str] = None, + preset: Optional[str] = None, ) -> Path: """Create a new profile directory. @@ -1025,6 +1026,11 @@ def create_profile( a marker file so ``hermes update`` skips re-seeding this profile's skills. Mutually exclusive with ``clone_config``/``clone_all`` (those explicitly copy skills from the source). + preset: + Optional assistant-preset key (see ``hermes_cli.assistant_presets``). + Writes the preset's persona to the new profile's SOUL.md and sets its + profile description. Validated before any directory is created so a + typo can't strand a half-configured profile. Returns ------- @@ -1039,6 +1045,19 @@ def create_profile( canon = normalize_profile_name(name) validate_profile_name(canon) + # Resolve the preset up front so a typo'd key fails before any directory + # is created (never strand a half-configured profile). + preset_obj = None + if preset: + from hermes_cli.assistant_presets import get_preset, preset_keys + + preset_obj = get_preset(preset) + if preset_obj is None: + raise ValueError( + f"Unknown preset '{preset}'. Available: {', '.join(preset_keys())} " + "(see `hermes profile presets`)." + ) + if canon == "default": raise ValueError( "Cannot create a profile named 'default' — it is the built-in profile (~/.hermes)." @@ -1143,6 +1162,18 @@ def create_profile( except Exception: pass # best-effort — don't fail profile creation over this + # Apply the assistant preset (Energy-inspired one-command role profiles): + # overwrite SOUL.md with the preset persona and set the profile + # description so the kanban decomposer can route by role. An explicit + # --description below still wins over the preset's. + if preset_obj is not None: + try: + from hermes_cli.assistant_presets import apply_preset_files + + apply_preset_files(profile_dir, preset_obj) + except Exception: + pass # best-effort — the profile itself is intact without it + # Write the opt-out marker so seed_profile_skills() and `hermes update`'s # all-profile sync loop both skip this profile for bundled-skill seeding. if no_skills: diff --git a/hermes_cli/subcommands/profile.py b/hermes_cli/subcommands/profile.py index d812fadf97158..e1ff1feab8179 100644 --- a/hermes_cli/subcommands/profile.py +++ b/hermes_cli/subcommands/profile.py @@ -62,6 +62,24 @@ def build_profile_parser(subparsers, *, cmd_profile: Callable) -> None: "Used by the kanban decomposer to route tasks based on role instead " "of profile name alone. Skip and add later via `hermes profile describe`.", ) + profile_create.add_argument( + "--preset", + default=None, + metavar="KEY", + help="Assistant preset to apply (persona + description + suggested " + "automations). See `hermes profile presets` for the catalog.", + ) + profile_create.add_argument( + "--with-automations", + action="store_true", + help="With --preset: also create the preset's suggested automations " + "(cron jobs) inside the new profile.", + ) + + profile_subparsers.add_parser( + "presets", + help="List assistant presets (one-command role profiles)", + ) profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile") profile_delete.add_argument("profile_name", help="Profile to delete") diff --git a/tests/hermes_cli/test_assistant_presets.py b/tests/hermes_cli/test_assistant_presets.py new file mode 100644 index 0000000000000..c9b22baaabec8 --- /dev/null +++ b/tests/hermes_cli/test_assistant_presets.py @@ -0,0 +1,110 @@ +"""Tests for hermes_cli.assistant_presets (Energy-inspired role presets).""" + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hermes_cli.assistant_presets import ( + PRESETS, + apply_preset_files, + format_preset_catalog, + get_preset, + preset_keys, + suggested_automation_commands, +) +from hermes_cli.profiles import create_profile, read_profile_meta + + +@pytest.fixture() +def profile_env(tmp_path, monkeypatch): + """Isolated profile root (mirrors tests/hermes_cli/test_profiles.py).""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + default_home = tmp_path / ".hermes" + default_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(default_home)) + return tmp_path + + +class TestCatalog: + def test_catalog_is_nonempty_with_unique_keys(self): + keys = preset_keys() + assert keys + assert len(keys) == len(set(keys)) + + def test_every_preset_is_complete(self): + for p in PRESETS: + assert p.title and p.tagline and p.description and p.soul, p.key + + def test_automation_keys_resolve_against_blueprint_catalog(self): + """Preset automations must reference real blueprints AND fill cleanly + with their declared slot values + blueprint defaults (contract, not + snapshot: catalog edits that break a preset fail here).""" + from cron.blueprint_catalog import fill_blueprint, get_blueprint + + for p in PRESETS: + for key, values in p.automations: + bp = get_blueprint(key) + assert bp is not None, f"preset {p.key} references unknown blueprint {key}" + spec = fill_blueprint(bp, values) + assert spec["prompt"] and spec["schedule"] + + def test_get_preset_is_case_insensitive_and_none_on_unknown(self): + first = PRESETS[0] + assert get_preset(first.key.upper()) is first + assert get_preset("no-such-preset") is None + assert get_preset("") is None + + def test_format_catalog_mentions_every_key(self): + text = format_preset_catalog() + for p in PRESETS: + assert p.key in text + + +class TestApplyPresetFiles: + def test_writes_soul_and_description(self, tmp_path): + preset = PRESETS[0] + apply_preset_files(tmp_path, preset) + soul = (tmp_path / "SOUL.md").read_text(encoding="utf-8") + assert preset.soul in soul + meta = read_profile_meta(tmp_path) + assert meta.get("description") == preset.description + assert meta.get("description_auto") is False + + +class TestCreateProfileWithPreset: + def test_unknown_preset_fails_before_creating_directory(self, profile_env): + with pytest.raises(ValueError, match="Unknown preset"): + create_profile("scout", preset="not-a-preset") + assert not (profile_env / ".hermes" / "profiles" / "scout").exists() + + def test_preset_overrides_default_soul_and_sets_description(self, profile_env): + preset = get_preset("research-scout") + profile_dir = create_profile("scout", no_alias=True, preset="research-scout") + soul = (profile_dir / "SOUL.md").read_text(encoding="utf-8") + assert preset.soul in soul + assert read_profile_meta(profile_dir).get("description") == preset.description + + def test_explicit_description_wins_over_preset(self, profile_env): + profile_dir = create_profile( + "scout2", no_alias=True, preset="research-scout", + description="My custom router text", + ) + assert read_profile_meta(profile_dir).get("description") == "My custom router text" + + def test_no_preset_keeps_default_soul(self, profile_env): + from hermes_cli.default_soul import DEFAULT_SOUL_MD + + profile_dir = create_profile("plain", no_alias=True) + assert (profile_dir / "SOUL.md").read_text(encoding="utf-8") == DEFAULT_SOUL_MD + + +class TestSuggestedCommands: + def test_commands_render_for_presets_with_automations(self): + for p in PRESETS: + cmds = suggested_automation_commands(p, "myrole") + assert len(cmds) == len(p.automations) + for cmd in cmds: + assert "hermes -p myrole" in cmd + assert "/blueprint " in cmd diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index 9603f956749d0..ddb9000ee1d85 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -48,6 +48,20 @@ hermes profile create researcher --description "Reads source code and external d You can also set or auto-generate the description later with `hermes profile describe` — see the [Kanban guide](./features/kanban#auto-vs-manual-orchestration) for the full routing model. +### Assistant presets (`--preset`) + +Skip hand-assembling a role. A preset bundles a persona (`SOUL.md`), a routable profile description, and suggested automations from the [Automation Blueprints](../guides/automation-blueprints.md) catalog into a single flag: + +```bash +hermes profile presets # list the catalog +hermes profile create scout --preset research-scout # persona + description +hermes profile create mail --preset inbox-zero --with-automations +``` + +Shipped presets: `research-scout`, `inbox-zero`, `project-captain`, `finance-keeper`, `sales-pilot`. Without `--with-automations`, the suggested automations are printed as ready-to-paste `/blueprint` commands instead of being created. Automations seeded this way deliver locally until you set a delivery target (they're created outside any chat, so there is no origin channel yet). An explicit `--description` still wins over the preset's. + +Run several presets side by side — each is an isolated profile with its own memory, sessions, and cron jobs — and message them independently (`scout chat`, `mail gateway start`, or route kanban tasks by description). + ### Clone config only (`--clone`) ```bash