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:
parent
eb8421ba98
commit
85440ed2a3
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue