refactor(cron): rebrand Cron Recipes -> Automation Blueprints

Product rename across every surface: module/file names (blueprint_catalog,
tools/blueprints, blueprint_cmd), slash command /cron-recipe -> /blueprint
(alias /bp), dashboard API /api/cron/blueprints, desktop deep-link
hermes://blueprint/<key>, docs catalog page + extract script, and the
skill frontmatter block metadata.hermes.blueprint. No behavior change.
This commit is contained in:
Teknium 2026-06-11 10:23:27 -07:00
parent 3c489fda81
commit cb29e8a82e
29 changed files with 627 additions and 627 deletions

View file

@ -1,25 +1,25 @@
"""Shared ``/cron-recipe`` command logic for CLI, TUI, and gateway.
"""Shared ``/blueprint`` command logic for CLI, TUI, and gateway.
The conversational counterpart to the dashboard's Cron Recipes form. Where a
The conversational counterpart to the dashboard's Automation Blueprints form. Where a
surface has a screen, the user fills a form (dashboard / GUI app) and the API
calls ``fill_recipe`` -> ``create_job`` directly. Where a surface is just a
chat line, the user picks a recipe by name and the agent asks for what it
needs pick a recipe by name and the agent asks you for what it needs, one
question at a time (the messaging-assistant model: pick a recipe it asks you
calls ``fill_blueprint`` -> ``create_job`` directly. Where a surface is just a
chat line, the user picks a blueprint by name and the agent asks for what it
needs pick a blueprint by name and the agent asks you for what it needs, one
question at a time (the messaging-assistant model: pick a blueprint it asks you
a couple things done).
Subcommand shapes:
/cron-recipe list the catalog
/cron-recipe <name> name-match a recipe, then SEED THE AGENT to
/blueprint list the catalog
/blueprint <name> name-match a blueprint, then SEED THE AGENT to
ask the user for each value conversationally
/cron-recipe <name> slot=val fill + create the cron job directly
/blueprint <name> slot=val fill + create the cron job directly
(the deterministic dashboard / docs / power-
user shortcut no agent turn)
The ``<name>`` form is forgiving: exact key, unique prefix, or fuzzy match all
resolve; an ambiguous query lists the candidates; an unknown one suggests the
closest. When it resolves, the handler returns an ``agent_seed`` a natural-
language instruction built from the recipe's typed slots + schedule/prompt
language instruction built from the blueprint's typed slots + schedule/prompt
templates that the calling surface feeds to the agent as a normal user turn
(gateway: rewrite ``event.text`` and fall through, the ``/steer`` pattern; CLI:
a one-shot pending seed the main loop runs). The agent then asks for each slot
@ -41,12 +41,12 @@ logger = logging.getLogger(__name__)
@dataclass
class RecipeCommandResult:
"""Outcome of a ``/cron-recipe`` invocation.
class BlueprintCommandResult:
"""Outcome of a ``/blueprint`` invocation.
``text`` is always shown to the user. When ``agent_seed`` is set, the
calling surface should ALSO hand that seed to the agent as the user's next
turn (the recipe was matched and now the agent gathers the slot values
turn (the blueprint was matched and now the agent gathers the slot values
conversationally). When ``agent_seed`` is None the command is fully handled
(catalog listing, direct create, or an error) and nothing is sent to the
agent.
@ -91,11 +91,11 @@ def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
return values, leftovers
def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]:
"""Resolve a free-typed recipe name to a recipe.
def match_blueprint(query: str) -> Tuple[Optional[Any], List[Any]]:
"""Resolve a free-typed blueprint name to a blueprint.
Returns ``(recipe, candidates)``:
* exact key or unique prefix / fuzzy match -> ``(recipe, [])``
Returns ``(blueprint, candidates)``:
* exact key or unique prefix / fuzzy match -> ``(blueprint, [])``
* ambiguous (2+ plausible) -> ``(None, [candidates])``
* no plausible match -> ``(None, [])``
@ -103,13 +103,13 @@ def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]:
dashboard/Discord where it's picked): exact key first, then case-insensitive
prefix on key or title, then a difflib fuzzy pass.
"""
from cron.recipe_catalog import CATALOG, get_recipe
from cron.blueprint_catalog import CATALOG, get_blueprint
q = (query or "").strip().lower()
if not q:
return None, []
exact = get_recipe(q)
exact = get_blueprint(q)
if exact is not None:
return exact, []
@ -138,43 +138,43 @@ def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]:
keys = [r.key for r in CATALOG]
close = difflib.get_close_matches(q, keys, n=3, cutoff=0.6)
if len(close) == 1:
return get_recipe(close[0]), []
return get_blueprint(close[0]), []
if len(close) > 1:
return None, [get_recipe(k) for k in close]
return None, [get_blueprint(k) for k in close]
return None, []
def _humanize_schedule(recipe) -> str:
from cron.recipe_catalog import _humanize_schedule as _h
def _humanize_schedule(blueprint) -> str:
from cron.blueprint_catalog import _humanize_schedule as _h
try:
return _h(recipe)
return _h(blueprint)
except Exception:
return "on a schedule"
def build_recipe_seed(recipe) -> str:
def build_blueprint_seed(blueprint) -> str:
"""Build the natural-language fill-request the agent will act on.
The agent reads this as a normal user turn, asks the user for each unfilled
slot one at a time, then calls the ``cronjob`` tool with the
cron expression it builds from the recipe's ``schedule_template`` and the
cron expression it builds from the blueprint's ``schedule_template`` and the
rendered prompt. Defaults are stated so the agent can offer them.
"""
from cron.recipe_catalog import WEEKDAY_PRESETS
from cron.blueprint_catalog import WEEKDAY_PRESETS
lines: List[str] = []
lines.append(
f"Set up the '{recipe.title}' automation for me (cron recipe "
f"'{recipe.key}'). {recipe.description}"
f"Set up the '{blueprint.title}' automation for me (automation blueprint "
f"'{blueprint.key}'). {blueprint.description}"
)
lines.append("")
lines.append(
"Ask me for each of these, one at a time, offering the default in "
"brackets if I don't have a preference:"
)
for s in recipe.slots:
for s in blueprint.slots:
bits = [f"- {s.label} ({s.name})"]
if s.options:
bits.append(f" — one of: {', '.join(map(str, s.options))}")
@ -190,47 +190,47 @@ def build_recipe_seed(recipe) -> str:
lines.append(
"Once you have my answers, create the job by calling the cronjob tool "
"with action='create'. Build the schedule as a cron expression from "
f"this template: `{recipe.schedule_template}` "
f"this template: `{blueprint.schedule_template}` "
"(fill {minute}/{hour} from the chosen time, {dow} from the weekday "
f"choice using {dict(WEEKDAY_PRESETS)}, {{interval_min}} from any "
"interval). Use this exact prompt for the job (substituting my "
f"answers into any {{slot}} placeholders): \"{recipe.prompt_template}\". "
f"answers into any {{slot}} placeholders): \"{blueprint.prompt_template}\". "
"Confirm the schedule and what it will do before you create it."
)
return "\n".join(lines)
def _fmt_catalog() -> str:
from cron.recipe_catalog import CATALOG
from cron.blueprint_catalog import CATALOG
lines = ["Cron Recipes — `/cron-recipe <name>` and I'll ask you what I need:\n"]
lines = ["Automation Blueprints — `/blueprint <name>` and I'll ask you what I need:\n"]
for r in CATALOG:
lines.append(f"{r.key}{r.title}")
lines.append(f" {r.description}")
lines.append(
"\nTip: `/cron-recipe <name>` walks you through it. Power users can "
"pass values inline, e.g. `/cron-recipe morning-brief time=08:00`."
"\nTip: `/blueprint <name>` walks you through it. Power users can "
"pass values inline, e.g. `/blueprint morning-brief time=08:00`."
)
return "\n".join(lines)
def _fmt_candidates(query: str, candidates: List[Any]) -> str:
lines = [f"'{query}' matches several recipes — which one?\n"]
lines = [f"'{query}' matches several blueprints — which one?\n"]
for r in candidates:
lines.append(f"{r.key}{r.title}")
lines.append("\nRun `/cron-recipe <name>` with one of the names above.")
lines.append("\nRun `/blueprint <name>` with one of the names above.")
return "\n".join(lines)
def _fmt_no_match(query: str) -> str:
from cron.recipe_catalog import CATALOG
from cron.blueprint_catalog import CATALOG
keys = [r.key for r in CATALOG]
close = difflib.get_close_matches((query or "").lower(), keys, n=3, cutoff=0.4)
msg = f"No cron recipe matches '{query}'."
msg = f"No automation blueprint matches '{query}'."
if close:
msg += " Did you mean: " + ", ".join(close) + "?"
msg += " Run /cron-recipe to see the catalog."
msg += " Run /blueprint to see the catalog."
return msg
@ -243,28 +243,28 @@ def _manage_hint(surface: str) -> str:
return "Ask me to list, pause, or remove it any time."
def handle_cron_recipe_command(
def handle_blueprint_command(
args: str,
*,
origin: Optional[Dict[str, Any]] = None,
surface: str = "cli",
) -> RecipeCommandResult:
"""Dispatch a ``/cron-recipe`` invocation.
) -> BlueprintCommandResult:
"""Dispatch a ``/blueprint`` invocation.
Returns a :class:`RecipeCommandResult`. When ``agent_seed`` is set the
Returns a :class:`BlueprintCommandResult`. When ``agent_seed`` is set the
caller must feed it to the agent as the next user turn; otherwise the
command is fully handled and only ``text`` is shown.
``args`` is everything after ``/cron-recipe``. ``origin`` lets a directly
``args`` is everything after ``/blueprint``. ``origin`` lets a directly
created job deliver back to the chat it was set up from. ``surface``
(``"cli"`` | ``"gateway"``) picks the right wording for follow-up hints
``/cron`` only exists on the CLI.
"""
try:
from cron.recipe_catalog import fill_recipe, RecipeFillError
from cron.blueprint_catalog import fill_blueprint, BlueprintFillError
except Exception as e: # pragma: no cover - import guard
logger.debug("recipe catalog import failed: %s", e)
return RecipeCommandResult("Cron Recipes are unavailable in this build.")
logger.debug("blueprint catalog import failed: %s", e)
return BlueprintCommandResult("Automation Blueprints are unavailable in this build.")
try:
tokens = shlex.split(args or "")
@ -273,33 +273,33 @@ def handle_cron_recipe_command(
# Bare -> list catalog.
if not tokens:
return RecipeCommandResult(_fmt_catalog())
return BlueprintCommandResult(_fmt_catalog())
query = tokens[0]
values, _leftover = _parse_kv(tokens[1:])
recipe, candidates = match_recipe(query)
if recipe is None:
blueprint, candidates = match_blueprint(query)
if blueprint is None:
if candidates:
return RecipeCommandResult(_fmt_candidates(query, candidates))
return RecipeCommandResult(_fmt_no_match(query))
return BlueprintCommandResult(_fmt_candidates(query, candidates))
return BlueprintCommandResult(_fmt_no_match(query))
# `<name>` with no inline slot values -> seed the agent to ask for them.
if not values:
seed = build_recipe_seed(recipe)
seed = build_blueprint_seed(blueprint)
text = (
f"Setting up '{recipe.title}' ({_humanize_schedule(recipe)}). "
f"Setting up '{blueprint.title}' ({_humanize_schedule(blueprint)}). "
"I'll ask you a couple of things…"
)
return RecipeCommandResult(text, agent_seed=seed)
return BlueprintCommandResult(text, agent_seed=seed)
# `<name> slot=val …` -> fill + create directly (deterministic shortcut).
try:
spec = fill_recipe(recipe, values, origin=_resolve_origin(origin))
except RecipeFillError as e:
return RecipeCommandResult(
f"Can't set up '{recipe.title}': {e}\n"
f"Or just run /cron-recipe {recipe.key} and I'll ask you for the values."
spec = fill_blueprint(blueprint, values, origin=_resolve_origin(origin))
except BlueprintFillError as e:
return BlueprintCommandResult(
f"Can't set up '{blueprint.title}': {e}\n"
f"Or just run /blueprint {blueprint.key} and I'll ask you for the values."
)
try:
@ -307,12 +307,12 @@ def handle_cron_recipe_command(
job = create_job(**spec)
except Exception as e:
logger.debug("cron-recipe create_job failed: %s", e)
return RecipeCommandResult(f"Failed to create the job: {e}")
logger.debug("blueprint create_job failed: %s", e)
return BlueprintCommandResult(f"Failed to create the job: {e}")
sched = job.get("schedule_display") or spec.get("schedule", "")
return RecipeCommandResult(
f"Scheduled '{recipe.title}'"
return BlueprintCommandResult(
f"Scheduled '{blueprint.title}'"
+ (f" ({sched})" if sched else "")
+ f", delivering to {spec.get('deliver', 'origin')}. {_manage_hint(surface)}"
)

View file

@ -1276,13 +1276,13 @@ class CLICommandsMixin:
output = f"Suggestions command failed: {e}"
self._console_print(output)
def _handle_cron_recipe_command(self, cmd: str):
"""Handle /cron-recipe — set up an automation from a recipe template.
def _handle_blueprint_command(self, cmd: str):
"""Handle /blueprint — set up an automation from a blueprint template.
Delegates to the shared handler. A bare ``/cron-recipe`` lists the
catalog; ``/cron-recipe <name>`` name-matches a recipe and seeds the
Delegates to the shared handler. A bare ``/blueprint`` lists the
catalog; ``/blueprint <name>`` name-matches a blueprint and seeds the
agent to ask the user for each value conversationally (the result's
``agent_seed``); ``/cron-recipe <name> slot=val `` creates the job
``agent_seed``); ``/blueprint <name> slot=val `` creates the job
directly. When a seed is returned it is stashed as a one-shot pending
message the interactive loop runs as the next agent turn.
"""
@ -1294,10 +1294,10 @@ class CLICommandsMixin:
tokens = (cmd or "").split()[1:]
args = " ".join(shlex.quote(t) for t in tokens)
try:
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
result = handle_cron_recipe_command(args)
from hermes_cli.blueprint_cmd import handle_blueprint_command
result = handle_blueprint_command(args)
except Exception as e:
self._console_print(f"Cron recipe command failed: {e}")
self._console_print(f"Cron blueprint command failed: {e}")
return
self._console_print(result.text)
seed = getattr(result, "agent_seed", None)

View file

@ -182,8 +182,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("suggestions", "Review suggested automations (accept/dismiss)",
"Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]",
subcommands=("accept", "dismiss", "catalog", "clear")),
CommandDef("cron-recipe", "Set up an automation from a recipe template",
"Tools & Skills", aliases=("recipe",), args_hint="[name] [slot=value ...]"),
CommandDef("blueprint", "Set up an automation from a blueprint template",
"Tools & Skills", aliases=("bp",), args_hint="[name] [slot=value ...]"),
CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)",
"Tools & Skills", args_hint="[subcommand]",
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")),

View file

@ -691,24 +691,24 @@ def do_install(identifier: str, category: str = "", force: bool = False,
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
# Recipe detection: if the installed skill declares a
# metadata.hermes.recipe block, it is a runnable automation. Register it as
# Blueprint detection: if the installed skill declares a
# metadata.hermes.blueprint block, it is a runnable automation. Register it as
# a Suggested Cron Job rather than auto-scheduling — installing never
# silently creates a recurring job; the user accepts it via /suggestions.
# This is the single surface every automation proposal flows through.
try:
from tools.recipes import RecipeError, recipe_spec_for_installed, register_recipe_suggestion
from tools.blueprints import BlueprintError, blueprint_spec_for_installed, register_blueprint_suggestion
try:
spec = recipe_spec_for_installed(bundle.name)
except RecipeError as _rec_err:
c.print(f"[yellow]Recipe block present but invalid:[/] {_rec_err}\n")
spec = blueprint_spec_for_installed(bundle.name)
except BlueprintError as _rec_err:
c.print(f"[yellow]Blueprint block present but invalid:[/] {_rec_err}\n")
spec = None
if spec is not None:
registered = register_recipe_suggestion(spec)
registered = register_blueprint_suggestion(spec)
if registered is not None:
c.print(
f"[bold cyan]Recipe:[/] '{bundle.name}' is an automation "
f"[bold cyan]Blueprint:[/] '{bundle.name}' is an automation "
f"(schedule [bold]{spec.schedule}[/])."
)
c.print(
@ -720,7 +720,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
# list is at its cap. Say so instead of silently doing nothing —
# the user can still schedule it by hand.
c.print(
f"[bold cyan]Recipe:[/] '{bundle.name}' is an automation "
f"[bold cyan]Blueprint:[/] '{bundle.name}' is an automation "
f"(schedule [bold]{spec.schedule}[/]), but it wasn't added to "
"your suggestions (already offered/dismissed, or the pending "
"list is full — run [bold]/suggestions[/] to review)."
@ -729,7 +729,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
"[dim]You can still schedule it any time by asking the agent "
"or via[/] [bold]hermes cron add[/][dim].[/]\n"
)
except Exception: # pragma: no cover - recipe detection is best-effort
except Exception: # pragma: no cover - blueprint detection is best-effort
pass
if invalidate_cache:

View file

@ -25,7 +25,7 @@ def _fmt_pending(pending: list) -> str:
return (
"No suggested automations right now.\n"
"Try `/suggestions catalog` to see the curated starter set, or "
"install a recipe skill to get one."
"install a blueprint skill to get one."
)
lines = ["Suggested automations — `/suggestions accept N` or `dismiss N`:\n"]
for i, s in enumerate(pending, 1):

View file

@ -6779,25 +6779,25 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
# ---------------------------------------------------------------------------
# Cron Recipes — parameterized automation templates. The dashboard renders the
# Automation Blueprints — parameterized automation templates. The dashboard renders the
# slot schema as a form; submitting instantiates a real cron job via the same
# create_job path. See cron/recipe_catalog.py for the single source of truth.
# create_job path. See cron/blueprint_catalog.py for the single source of truth.
# ---------------------------------------------------------------------------
class CronRecipeInstantiate(BaseModel):
recipe: str # recipe key, e.g. "morning-brief"
class AutomationBlueprintInstantiate(BaseModel):
blueprint: str # blueprint key, e.g. "morning-brief"
values: Dict[str, Any] = {} # filled slot values from the form
@app.get("/api/cron/recipes")
async def list_cron_recipes():
"""Return the recipe catalog as form schemas for the dashboard gallery.
@app.get("/api/cron/blueprints")
async def list_cron_blueprints():
"""Return the blueprint catalog as form schemas for the dashboard gallery.
The ``deliver`` slot's options are rewritten from the user's actually
configured gateway platforms (plus the universal origin/local/all), so the
form never offers a platform that isn't connected.
"""
try:
from cron.recipe_catalog import CATALOG, recipe_catalog_entry
from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry
deliver_options = None
try:
@ -6810,40 +6810,40 @@ async def list_cron_recipes():
entries = []
for r in CATALOG:
entry = recipe_catalog_entry(r)
entry = blueprint_catalog_entry(r)
if deliver_options:
for f in entry.get("fields", []):
if f.get("name") == "deliver":
f["options"] = deliver_options
entries.append(entry)
return {"recipes": entries}
return {"blueprints": entries}
except Exception as e:
_log.exception("GET /api/cron/recipes failed")
_log.exception("GET /api/cron/blueprints failed")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/cron/recipes/instantiate")
async def instantiate_cron_recipe(body: CronRecipeInstantiate, profile: str = "default"):
"""Fill a recipe's slots and create the cron job (form-submit path)."""
@app.post("/api/cron/blueprints/instantiate")
async def instantiate_blueprint(body: AutomationBlueprintInstantiate, profile: str = "default"):
"""Fill a blueprint's slots and create the cron job (form-submit path)."""
try:
from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError
from cron.blueprint_catalog import fill_blueprint, get_blueprint, BlueprintFillError
recipe = get_recipe(body.recipe)
if recipe is None:
raise HTTPException(status_code=404, detail=f"Unknown recipe: {body.recipe}")
blueprint = get_blueprint(body.blueprint)
if blueprint is None:
raise HTTPException(status_code=404, detail=f"Unknown blueprint: {body.blueprint}")
try:
spec = fill_recipe(recipe, body.values)
except RecipeFillError as exc:
spec = fill_blueprint(blueprint, body.values)
except BlueprintFillError as exc:
# Field-level validation error — 422 so the form can show it inline.
raise HTTPException(status_code=422, detail=str(exc)) from exc
# Recipe-created jobs deliver to the dashboard's configured target by
# Blueprint-created jobs deliver to the dashboard's configured target by
# default; the form's deliver slot overrides via spec["deliver"].
spec.pop("origin", None)
return _call_cron_for_profile(profile, "create_job", **spec)
except HTTPException:
raise
except Exception as e:
_log.exception("POST /api/cron/recipes/instantiate failed")
_log.exception("POST /api/cron/blueprints/instantiate failed")
raise HTTPException(status_code=400, detail=str(e))