hermes-agent/hermes_cli/assistant_presets.py

333 lines
13 KiB
Python

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