Inspired by Energy: assistant presets — one-command role profiles

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 <name> --preset <key> [--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
This commit is contained in:
Teknium 2026-08-06 20:11:45 -07:00
parent eb8421ba98
commit 85440ed2a3
No known key found for this signature in database
6 changed files with 533 additions and 0 deletions

View File

@ -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 <name> --preset <key>",
"Add its automations too: --with-automations",
]
return "\n".join(lines)

View File

@ -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()

View File

@ -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:

View File

@ -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")

View File

@ -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

View File

@ -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